Merged in feat/sw-3514-join-card-membership-id (pull request #2967)

feat(SW-3514): Add variant of join friends card with membership id input

* Move membershipId input in enter details to join card

Add booking flow feature flag to move membershipId into join card and hide login button.
Currently only applies to first room.

* Add sas join card to multiroom


Approved-by: Hrishikesh Vaipurkar
This commit is contained in:
Anton Gunnarsson
2025-10-16 08:24:20 +00:00
parent b5817c5dcd
commit 611d81222c
11 changed files with 360 additions and 106 deletions

View File

@@ -61,6 +61,7 @@ export default async function RootLayout(props: RootLayoutProps) {
const bookingFlowConfig: BookingFlowConfig = {
bookingCodeEnabled: false,
enterDetailsMembershipIdInputLocation: "join-card",
variant: "partner-sas",
routes: {
myStay: routeToScandicWeb(myStay),

View File

@@ -7,6 +7,7 @@ import type { BookingFlowConfig } from "@scandic-hotels/booking-flow/BookingFlow
export const bookingFlowConfig: BookingFlowConfig = {
bookingCodeEnabled: true,
enterDetailsMembershipIdInputLocation: "form",
variant: "scandic",
routes: {
myStay,

View File

@@ -10,6 +10,7 @@ import type { BookingFlowVariant } from "./bookingFlowVariants"
export type BookingFlowConfig = {
bookingCodeEnabled: boolean
enterDetailsMembershipIdInputLocation: "form" | "join-card"
variant: BookingFlowVariant
routes: {
myStay: LangRoute

View File

@@ -0,0 +1,127 @@
"use client"
import { useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { membershipTermsAndConditions } from "@scandic-hotels/common/constants/routes/membershipTermsAndConditions"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import Footnote from "@scandic-hotels/design-system/Footnote"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import Link from "@scandic-hotels/design-system/Link"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@scandic-hotels/trpc/client"
import { useRoomContext } from "../../../../../contexts/EnterDetails/RoomContext"
import useLang from "../../../../../hooks/useLang"
import { MembershipNumberInput } from "../../RoomOne/Signup/MembershipNumberInput"
import styles from "./partnerSASJoinScandicFriendsCard.module.css"
type Props = {
name?: string
updateDetailsStore?: () => void
}
export function PartnerSASJoinScandicFriendsCard({
name = "join",
updateDetailsStore,
}: Props) {
const lang = useLang()
const intl = useIntl()
const { data: euroBonusProfile } =
trpc.partner.sas.getEuroBonusProfile.useQuery()
const {
room,
roomNr,
actions: { updateJoin },
} = useRoomContext()
const joinValue = useWatch({ name: "join" })
function onChange(event: { target: { value: boolean } }) {
updateJoin(event.target.value)
}
if (euroBonusProfile && euroBonusProfile.linkStatus !== "UNLINKED") {
return null
}
if (!("member" in room.roomRate) || !room.roomRate.member) {
return null
}
return (
<div className={styles.cardContainer}>
<Typography variant="Title/Subtitle/md">
<h2 className={styles.priceContainer}>
<span>
{intl.formatMessage({
defaultMessage: "Get the member room price",
})}
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`: `}
</span>
<span className={styles.price}>
{intl.formatMessage(
{
defaultMessage: "{amount} for room {roomNr}",
},
{
amount: formatPrice(
intl,
room.roomRate.member.localPrice.pricePerStay ?? 0,
room.roomRate.member.localPrice.currency ??
CurrencyEnum.Unknown
),
roomNr,
}
)}
</span>
</h2>
</Typography>
<div className={styles.checkBox}>
<Checkbox name={name} registerOptions={{ onChange }}>
<Typography variant="Body/Paragraph/mdRegular">
<div>
{intl.formatMessage({
defaultMessage: "Join Scandic Friends before check-in",
})}
</div>
</Typography>
</Checkbox>
<MembershipNumberInput
className={styles.membershipInput}
registerOptions={{ onBlur: updateDetailsStore }}
disabled={Boolean(joinValue)}
label={intl.formatMessage({
defaultMessage: "Already a member? Membership ID",
})}
/>
</div>
<div className={styles.terms}>
<Footnote color="uiTextPlaceholder">
{intl.formatMessage(
{
defaultMessage:
"By joining you accept the <termsAndConditionsLink>Terms and Conditions</termsAndConditionsLink>. The Scandic Friends Membership is valid until further notice, but can at any time be terminated by contacting Scandic Customer Service.",
},
{
termsAndConditionsLink: (str) => (
<Link
textDecoration="underline"
size="tiny"
target="_blank"
href={membershipTermsAndConditions[lang]}
>
{str}
</Link>
),
}
)}
</Footnote>
</div>
</div>
)
}

View File

@@ -0,0 +1,51 @@
.cardContainer {
align-self: flex-start;
background-color: var(--Surface-Primary-Hover-Accent);
border-radius: var(--Corner-radius-lg);
display: grid;
gap: var(--Space-x2);
padding: var(--Space-x2);
grid-template-areas:
"price"
"checkbox"
"terms";
width: min(100%, 696px);
}
.priceContainer {
grid-area: price;
margin-bottom: var(--Space-x1);
}
.price {
color: var(--Text-Accent-Primary);
}
.checkBox {
align-self: center;
grid-area: checkbox;
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.terms {
grid-area: terms;
}
@media screen and (min-width: 768px) {
.cardContainer {
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
gap: var(--Space-x3);
grid-template-areas:
"price checkbox"
"terms terms";
}
.priceContainer {
margin-bottom: 0;
display: flex;
flex-direction: column;
}
}

View File

@@ -22,6 +22,7 @@ import { getErrorMessage } from "../../../BookingFlowInput/errors"
import MemberPriceModal from "../MemberPriceModal"
import { SpecialRequests } from "../SpecialRequests"
import JoinScandicFriendsCard from "./JoinScandicFriendsCard"
import { PartnerSASJoinScandicFriendsCard } from "./PartnerSASJoinScandicFriendsCard"
import { getMultiroomDetailsSchema } from "./schema"
import styles from "./details.module.css"
@@ -161,6 +162,10 @@ export default function Details() {
const guestIsGoingToJoin = methods.watch("join")
const guestIsMember = methods.watch("membershipNo")
const showMembershipIdInput =
config.enterDetailsMembershipIdInputLocation === "form" &&
!guestIsGoingToJoin
return (
<FormProvider {...methods}>
<form
@@ -168,7 +173,9 @@ export default function Details() {
id={`${formID}-room-${roomNr}`}
onSubmit={methods.handleSubmit(updateDetails)}
>
{guestIsMember ? null : <JoinScandicFriendsCard />}
{guestIsMember ? null : (
<JoinScandicCard updateDetailsStore={updateDetailsStore} />
)}
<div className={styles.container}>
<Footnote
color="uiTextHighContrast"
@@ -245,7 +252,7 @@ export default function Details() {
name="phoneNumber"
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
{guestIsGoingToJoin ? null : (
{showMembershipIdInput ? (
<BookingFlowInput
className={styles.fullWidth}
label={intl.formatMessage({
@@ -255,7 +262,7 @@ export default function Details() {
type="tel"
registerOptions={{ onBlur: updateDetailsStore }}
/>
)}
) : null}
<SpecialRequests registerOptions={{ onBlur: updateDetailsStore }} />
</div>
<MemberPriceModal />
@@ -263,3 +270,26 @@ export default function Details() {
</FormProvider>
)
}
function JoinScandicCard({
updateDetailsStore,
}: {
updateDetailsStore: () => void
}) {
const config = useBookingFlowConfig()
switch (config.enterDetailsMembershipIdInputLocation) {
case "form":
return <JoinScandicFriendsCard />
case "join-card":
return (
<PartnerSASJoinScandicFriendsCard
updateDetailsStore={updateDetailsStore}
/>
)
default:
const _exhaustiveCheck: never =
config.enterDetailsMembershipIdInputLocation
return null
}
}

View File

@@ -1,26 +1,30 @@
"use client"
import { useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { membershipTermsAndConditions } from "@scandic-hotels/common/constants/routes/membershipTermsAndConditions"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Button } from "@scandic-hotels/design-system/Button"
import Footnote from "@scandic-hotels/design-system/Footnote"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import Link from "@scandic-hotels/design-system/Link"
import { toast } from "@scandic-hotels/design-system/Toast"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@scandic-hotels/trpc/client"
import { useRoomContext } from "../../../../../contexts/EnterDetails/RoomContext"
import useLang from "../../../../../hooks/useLang"
import { MembershipNumberInput } from "../Signup/MembershipNumberInput"
import styles from "./partnerSASJoinScandicFriendsCard.module.css"
type Props = {
name?: string
updateDetailsStore?: () => void
}
export function PartnerSASJoinScandicFriendsCard({ name = "join" }: Props) {
export function PartnerSASJoinScandicFriendsCard({
name = "join",
updateDetailsStore,
}: Props) {
const lang = useLang()
const intl = useIntl()
const { data: euroBonusProfile } =
@@ -31,11 +35,13 @@ export function PartnerSASJoinScandicFriendsCard({ name = "join" }: Props) {
actions: { updateJoin },
} = useRoomContext()
const joinValue = useWatch({ name: "join" })
function onChange(event: { target: { value: boolean } }) {
updateJoin(event.target.value)
}
if (!euroBonusProfile || euroBonusProfile.linkStatus !== "UNLINKED") {
if (euroBonusProfile && euroBonusProfile.linkStatus !== "UNLINKED") {
return null
}
@@ -48,14 +54,9 @@ export function PartnerSASJoinScandicFriendsCard({ name = "join" }: Props) {
<Typography variant="Title/Subtitle/md">
<h2 className={styles.priceContainer}>
<span>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<>
NOT IMPLEMENTED
<br />
{intl.formatMessage({
defaultMessage: "Get the Scandic Friends room price",
})}
</>
{intl.formatMessage({
defaultMessage: "Get the member room price",
})}
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`: `}
</span>
@@ -68,32 +69,26 @@ export function PartnerSASJoinScandicFriendsCard({ name = "join" }: Props) {
</span>
</h2>
</Typography>
<Checkbox
name={name}
className={styles.checkBox}
registerOptions={{ onChange }}
>
<Typography variant="Body/Paragraph/mdRegular">
<div>
{intl.formatMessage({
defaultMessage: "Join Scandic Friends now",
})}
</div>
</Typography>
</Checkbox>
<div className={styles.checkBox}>
<Checkbox name={name} registerOptions={{ onChange }}>
<Typography variant="Body/Paragraph/mdRegular">
<div>
{intl.formatMessage({
defaultMessage: "Join Scandic Friends now",
})}
</div>
</Typography>
</Checkbox>
<Button
variant={"Tertiary"}
size={"Small"}
typography={"Body/Paragraph/mdBold"}
color="Inverted"
className={styles.login}
onClick={() => toast.error("TODO: Not implemented")}
>
{intl.formatMessage({
defaultMessage: "Link",
})}
</Button>
<MembershipNumberInput
className={styles.membershipInput}
registerOptions={{ onBlur: updateDetailsStore }}
disabled={Boolean(joinValue)}
label={intl.formatMessage({
defaultMessage: "Already a member? Membership ID",
})}
/>
</div>
<div className={styles.terms}>
<Footnote color="uiTextPlaceholder">

View File

@@ -1,15 +1,14 @@
.cardContainer {
align-self: flex-start;
background-color: orchid;
color: white;
background-color: var(--Surface-Primary-Hover-Accent);
border-radius: var(--Corner-radius-lg);
display: grid;
gap: var(--Space-x2);
padding: var(--Space-x2);
grid-template-areas:
"price login"
"checkbox checkbox"
"terms terms";
"price"
"checkbox"
"terms";
width: min(100%, 696px);
}
@@ -22,15 +21,12 @@
color: var(--Text-Accent-Primary);
}
.login {
grid-area: login;
align-self: center;
justify-self: end;
}
.checkBox {
align-self: center;
grid-area: checkbox;
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.terms {
@@ -39,12 +35,12 @@
@media screen and (min-width: 768px) {
.cardContainer {
grid-template-columns: 1fr auto auto;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
gap: var(--Space-x3);
grid-template-areas:
"price checkbox login"
"terms terms terms";
"price checkbox"
"terms terms";
}
.priceContainer {

View File

@@ -0,0 +1,35 @@
import { useIntl } from "react-intl"
import BookingFlowInput from "../../../../BookingFlowInput"
import type { RegisterOptions } from "react-hook-form"
export function MembershipNumberInput({
registerOptions,
label,
className,
disabled,
}: {
registerOptions?: RegisterOptions
label?: string
className?: string
disabled?: boolean
}) {
const intl = useIntl()
return (
<BookingFlowInput
label={
label ||
intl.formatMessage({
defaultMessage: "Membership ID",
})
}
name="membershipNo"
type="tel"
registerOptions={registerOptions}
className={className}
disabled={disabled}
/>
)
}

View File

@@ -15,6 +15,7 @@ import { useBookingFlowConfig } from "../../../../../bookingFlowConfig/bookingFl
import useLang from "../../../../../hooks/useLang"
import BookingFlowInput from "../../../../BookingFlowInput"
import { getErrorMessage } from "../../../../BookingFlowInput/errors"
import { MembershipNumberInput } from "./MembershipNumberInput"
import styles from "./signup.module.css"
@@ -41,50 +42,46 @@ export default function Signup({
setIsJoinChecked(joinValue)
}, [joinValue])
return isJoinChecked ? (
<div className={styles.additionalFormData}>
<BookingFlowInput
name="zipCode"
label={intl.formatMessage({
defaultMessage: "Zip code",
})}
registerOptions={{ required: true, ...registerOptions }}
/>
<div className={styles.dateField}>
<header>
<Caption type="bold">
<span className={styles.required}>
{intl.formatMessage({
defaultMessage: "Birth date",
})}
</span>
</Caption>
</header>
<DateSelect
labels={{
day: intl.formatMessage({ defaultMessage: "Day" }),
month: intl.formatMessage({ defaultMessage: "Month" }),
year: intl.formatMessage({ defaultMessage: "Year" }),
errorMessage: getErrorMessage(
intl,
config.variant,
errors["dateOfBirth"]?.message?.toString()
),
}}
lang={lang}
name="dateOfBirth"
if (isJoinChecked)
return (
<div className={styles.additionalFormData}>
<BookingFlowInput
name="zipCode"
label={intl.formatMessage({
defaultMessage: "Zip code",
})}
registerOptions={{ required: true, ...registerOptions }}
/>
<div className={styles.dateField}>
<header>
<Caption type="bold">
<span className={styles.required}>
{intl.formatMessage({
defaultMessage: "Birth date",
})}
</span>
</Caption>
</header>
<DateSelect
labels={{
day: intl.formatMessage({ defaultMessage: "Day" }),
month: intl.formatMessage({ defaultMessage: "Month" }),
year: intl.formatMessage({ defaultMessage: "Year" }),
errorMessage: getErrorMessage(
intl,
config.variant,
errors["dateOfBirth"]?.message?.toString()
),
}}
lang={lang}
name="dateOfBirth"
registerOptions={{ required: true, ...registerOptions }}
/>
</div>
</div>
</div>
) : (
<BookingFlowInput
label={intl.formatMessage({
defaultMessage: "Membership ID",
})}
name="membershipNo"
type="tel"
registerOptions={registerOptions}
/>
)
)
if (config.enterDetailsMembershipIdInputLocation === "join-card") return null
return <MembershipNumberInput registerOptions={registerOptions} />
}

View File

@@ -149,6 +149,9 @@ export default function Details({ user }: DetailsProps) {
setValue("phoneNumberCC", countryCode.toLowerCase())
}
}, [countryCode, setValue])
const showJoinCard = memberRate && !user
return (
<FormProvider {...methods}>
<form
@@ -156,15 +159,9 @@ export default function Details({ user }: DetailsProps) {
id={`${formID}-room-${roomNr}`}
onSubmit={methods.handleSubmit(onSubmit)}
>
{config.variant === "scandic" && (
<>{user || !memberRate ? null : <JoinScandicFriendsCard />}</>
)}
{config.variant === "partner-sas" && (
<>
{user || !memberRate ? null : <PartnerSASJoinScandicFriendsCard />}
</>
)}
{showJoinCard ? (
<JoinScandicCard updateDetailsStore={updateDetailsStore} />
) : null}
<div className={styles.container}>
<Footnote
color="uiTextHighContrast"
@@ -257,3 +254,26 @@ export default function Details({ user }: DetailsProps) {
</FormProvider>
)
}
function JoinScandicCard({
updateDetailsStore,
}: {
updateDetailsStore: () => void
}) {
const config = useBookingFlowConfig()
switch (config.enterDetailsMembershipIdInputLocation) {
case "form":
return <JoinScandicFriendsCard />
case "join-card":
return (
<PartnerSASJoinScandicFriendsCard
updateDetailsStore={updateDetailsStore}
/>
)
default:
const _exhaustiveCheck: never =
config.enterDetailsMembershipIdInputLocation
return null
}
}