Merged in chore/move-enter-details (pull request #2778)

Chore/move enter details

Approved-by: Anton Gunnarsson
This commit is contained in:
Joakim Jäderberg
2025-09-11 07:16:24 +00:00
parent 15711cb3a4
commit 7dee6d5083
238 changed files with 1656 additions and 1602 deletions

View File

@@ -0,0 +1,116 @@
"use client"
import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { membershipTermsAndConditions } from "@scandic-hotels/common/constants/routes/membershipTermsAndConditions"
import { useLazyPathname } from "@scandic-hotels/common/hooks/useLazyPathname"
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 { LoginButton } from "@scandic-hotels/design-system/LoginButton"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useRoomContext } from "../../../../../contexts/EnterDetails/RoomContext"
import useLang from "../../../../../hooks/useLang"
import { useTrackingContext } from "../../../../../trackingContext"
import styles from "./joinScandicFriendsCard.module.css"
type Props = {
name?: string
}
export default function JoinScandicFriendsCard({ name = "join" }: Props) {
const lang = useLang()
const intl = useIntl()
const loginPathname = useLazyPathname({ includeSearchParams: true })
const { trackLoginClick } = useTrackingContext()
const {
room,
actions: { updateJoin },
} = useRoomContext()
function onChange(event: { target: { value: boolean } }) {
updateJoin(event.target.value)
}
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>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${intl.formatMessage({
defaultMessage: "Get the member room price",
})}: `}
</span>
<span className={styles.price}>
{formatPrice(
intl,
room.roomRate.member.localPrice.pricePerStay ?? 0,
room.roomRate.member.localPrice.currency ?? CurrencyEnum.Unknown
)}
</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>
<Button size="small" color="Primary" asChild>
<LoginButton
lang={lang}
className={styles.login}
color="white"
trackingId="join-scandic-friends-enter-details"
onClick={() => {
trackLoginClick("enter details")
}}
redirectTo={loginPathname}
>
{intl.formatMessage({
defaultMessage: "Log in",
})}
</LoginButton>
</Button>
<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,54 @@
.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 login"
"checkbox checkbox"
"terms terms";
width: min(100%, 696px);
}
.priceContainer {
grid-area: price;
margin-bottom: var(--Space-x1);
}
.price {
color: var(--Text-Accent-Primary);
}
.login {
grid-area: login;
align-self: center;
justify-self: end;
}
.checkBox {
align-self: center;
grid-area: checkbox;
}
.terms {
grid-area: terms;
}
@media screen and (min-width: 768px) {
.cardContainer {
grid-template-columns: 1fr auto auto;
grid-template-rows: auto auto;
gap: var(--Space-x3);
grid-template-areas:
"price checkbox login"
"terms terms terms";
}
.priceContainer {
margin-bottom: 0;
display: flex;
flex-direction: column;
}
}

View File

@@ -0,0 +1,87 @@
"use client"
import { useEffect, useState } from "react"
import {
type FieldErrors,
type RegisterOptions,
useWatch,
} from "react-hook-form"
import { useIntl } from "react-intl"
import Caption from "@scandic-hotels/design-system/Caption"
import DateSelect from "@scandic-hotels/design-system/Form/Date"
import useLang from "../../../../../hooks/useLang"
import BookingFlowInput from "../../../../BookingFlowInput"
import { getErrorMessage } from "../../../../BookingFlowInput/errors"
import styles from "./signup.module.css"
export default function Signup({
errors,
name,
registerOptions,
}: {
errors: FieldErrors
name: string
registerOptions?: RegisterOptions
}) {
const intl = useIntl()
const lang = useLang()
const [isJoinChecked, setIsJoinChecked] = useState(false)
const joinValue = useWatch({ name })
useEffect(() => {
// In order to avoid hydration errors the state needs to be set as side effect,
// since the join value can come from search params
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,
errors["dateOfBirth"]?.message?.toString()
),
}}
lang={lang}
name="dateOfBirth"
registerOptions={{ required: true, ...registerOptions }}
/>
</div>
</div>
) : (
<BookingFlowInput
label={intl.formatMessage({
defaultMessage: "Membership ID",
})}
name="membershipNo"
type="tel"
registerOptions={registerOptions}
/>
)
}

View File

@@ -0,0 +1,19 @@
.container {
display: grid;
grid-column: 1/-1;
gap: var(--Spacing-x3);
}
.additionalFormData {
display: grid;
gap: var(--Spacing-x4);
}
.dateField {
display: grid;
gap: var(--Spacing-x1);
}
.required:after {
content: " *";
}

View File

@@ -0,0 +1,24 @@
.form {
display: grid;
gap: var(--Spacing-x3);
}
.container {
display: grid;
gap: var(--Spacing-x2);
width: min(100%, 696px);
}
.fullWidth {
grid-column: 1/-1;
}
.footer {
margin-top: var(--Spacing-x1);
}
@media screen and (min-width: 768px) {
.container {
grid-template-columns: 1fr 1fr;
}
}

View File

@@ -0,0 +1,246 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { usePhoneNumberParsing } from "@scandic-hotels/common/hooks/usePhoneNumberParsing"
import { getDefaultCountryFromLang } from "@scandic-hotels/common/utils/phone"
import Footnote from "@scandic-hotels/design-system/Footnote"
import CountrySelect from "@scandic-hotels/design-system/Form/Country"
import Phone from "@scandic-hotels/design-system/Form/Phone"
import { useFormTracking } from "@scandic-hotels/tracking/useFormTracking"
import { useRoomContext } from "../../../../contexts/EnterDetails/RoomContext"
import useLang from "../../../../hooks/useLang"
import { getFormattedCountryList } from "../../../../misc/getFormatedCountryList"
import { useEnterDetailsStore } from "../../../../stores/enter-details"
import BookingFlowInput from "../../../BookingFlowInput"
import { getErrorMessage } from "../../../BookingFlowInput/errors"
import MemberPriceModal from "../MemberPriceModal"
import { SpecialRequests } from "../SpecialRequests"
import JoinScandicFriendsCard from "./JoinScandicFriendsCard"
import {
type GuestDetailsSchema,
guestDetailsSchema,
signedInDetailsSchema,
} from "./schema"
import Signup from "./Signup"
import styles from "./details.module.css"
import type { User } from "@scandic-hotels/trpc/types/user"
type DetailsProps = {
user: User | null
}
const formID = "enter-details"
export default function Details({ user }: DetailsProps) {
const intl = useIntl()
const lang = useLang()
const { lastRoom, addPreSubmitCallback } = useEnterDetailsStore((state) => ({
lastRoom: state.lastRoom,
addPreSubmitCallback: state.actions.addPreSubmitCallback,
}))
const {
actions: { updateDetails, updatePartialGuestData, setIncomplete },
room,
roomNr,
idx,
} = useRoomContext()
const initialData = room.guest
const memberRate = "member" in room.roomRate ? room.roomRate.member : null
const { phoneNumber, phoneNumberCC } = usePhoneNumberParsing(
user?.phoneNumber || initialData.phoneNumber,
initialData.phoneNumberCC
)
const methods = useForm({
defaultValues: {
countryCode: user?.address?.countryCode || initialData.countryCode,
dateOfBirth:
"dateOfBirth" in initialData ? initialData.dateOfBirth : undefined,
email: user?.email || initialData.email,
firstName: user?.firstName || initialData.firstName,
join: initialData.join,
lastName: user?.lastName || initialData.lastName,
membershipNo: initialData.membershipNo,
phoneNumber,
phoneNumberCC,
zipCode: "zipCode" in initialData ? initialData.zipCode : undefined,
specialRequest: {
comment: room.specialRequest.comment,
},
},
criteriaMode: "all",
mode: "onBlur",
resolver: zodResolver(user ? signedInDetailsSchema : guestDetailsSchema),
reValidateMode: "onChange",
})
const {
formState,
handleSubmit,
trigger,
control,
subscribe,
setValue,
watch,
getValues,
} = methods
const { trackFormSubmit } = useFormTracking(
"checkout",
subscribe,
control,
lastRoom === idx ? "" : " - room 1"
)
useEffect(() => {
function callback() {
trigger()
trackFormSubmit()
}
addPreSubmitCallback(`${idx}-details`, callback)
}, [addPreSubmitCallback, idx, trigger, trackFormSubmit])
const onSubmit = useCallback(
(values: GuestDetailsSchema) => {
updateDetails(values)
},
[updateDetails]
)
const updateDetailsStore = useCallback(() => {
if (formState.isValid) {
handleSubmit(onSubmit)()
} else {
updatePartialGuestData({
firstName: getValues("firstName")?.toString(),
lastName: getValues("lastName")?.toString(),
membershipNo: getValues("membershipNo")?.toString(),
})
setIncomplete()
}
}, [
handleSubmit,
formState.isValid,
onSubmit,
setIncomplete,
updatePartialGuestData,
getValues,
])
useEffect(updateDetailsStore, [updateDetailsStore])
const countryCode = watch("countryCode")
useEffect(() => {
if (countryCode) {
setValue("phoneNumberCC", countryCode.toLowerCase())
}
}, [countryCode, setValue])
return (
<FormProvider {...methods}>
<form
className={styles.form}
id={`${formID}-room-${roomNr}`}
onSubmit={methods.handleSubmit(onSubmit)}
>
{user || !memberRate ? null : <JoinScandicFriendsCard />}
<div className={styles.container}>
<Footnote
color="uiTextHighContrast"
textTransform="uppercase"
type="label"
className={styles.fullWidth}
>
{intl.formatMessage({
defaultMessage: "Guest information",
})}
</Footnote>
<BookingFlowInput
autoComplete="given-name"
label={intl.formatMessage({
defaultMessage: "First name",
})}
maxLength={30}
name="firstName"
readOnly={!!user}
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
<BookingFlowInput
autoComplete="family-name"
label={intl.formatMessage({
defaultMessage: "Last name",
})}
maxLength={30}
name="lastName"
readOnly={!!user}
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
<CountrySelect
className={styles.fullWidth}
label={intl.formatMessage({
defaultMessage: "Country",
})}
lang={lang}
countries={getFormattedCountryList(intl)}
errorMessage={getErrorMessage(
intl,
formState.errors.countryCode?.message
)}
name="countryCode"
registerOptions={{ required: true, onBlur: updateDetailsStore }}
disabled={!!user}
/>
<BookingFlowInput
autoComplete="email"
className={styles.fullWidth}
label={intl.formatMessage({
defaultMessage: "Email address",
})}
name="email"
readOnly={!!user}
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
<Phone
className={styles.fullWidth}
countryLabel={intl.formatMessage({
defaultMessage: "Country code",
})}
countriesWithTranslatedName={getFormattedCountryList(intl)}
defaultCountryCode={getDefaultCountryFromLang(lang)}
errorMessage={getErrorMessage(
intl,
formState.errors.phoneNumber?.message
)}
label={intl.formatMessage({
defaultMessage: "Phone number",
})}
name="phoneNumber"
disabled={!!user}
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
{user ? null : (
<div className={styles.fullWidth}>
<Signup
errors={formState.errors}
name="join"
registerOptions={{ onBlur: updateDetailsStore }}
/>
</div>
)}
<SpecialRequests registerOptions={{ onBlur: updateDetailsStore }} />
</div>
<MemberPriceModal />
</form>
</FormProvider>
)
}

View File

@@ -0,0 +1,101 @@
import { z } from "zod"
import { dt } from "@scandic-hotels/common/dt"
import { phoneValidator } from "@scandic-hotels/common/utils/zod/phoneValidator"
import { roomOneErrors } from "../../enterDetailsErrors"
import { specialRequestSchema } from "../SpecialRequests/schema"
// stringMatcher regex is copied from current web as specified by requirements.
const stringMatcher =
/^[A-Za-z¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ0-9-\s]*$/
const isValidString = (key: string) => stringMatcher.test(key)
export const baseDetailsSchema = z.object({
countryCode: z.string().min(1, roomOneErrors.COUNTRY_REQUIRED),
email: z.string().email(roomOneErrors.EMAIL_REQUIRED),
firstName: z
.string()
.min(1, roomOneErrors.FIRST_NAME_REQUIRED)
.refine(isValidString, roomOneErrors.FIRST_NAME_SPECIAL_CHARACTERS),
lastName: z
.string()
.min(1, roomOneErrors.LAST_NAME_REQUIRED)
.refine(isValidString, roomOneErrors.LAST_NAME_SPECIAL_CHARACTERS),
phoneNumber: phoneValidator(
roomOneErrors.PHONE_REQUIRED,
roomOneErrors.PHONE_REQUESTED
),
phoneNumberCC: z.string(),
specialRequest: specialRequestSchema,
})
export const notJoinDetailsSchema = baseDetailsSchema.merge(
z.object({
join: z.literal<boolean>(false),
zipCode: z.string().optional(),
dateOfBirth: z.string().optional(),
membershipNo: z
.string()
.optional()
.refine((val) => {
if (val) {
return !val.match(/[^0-9]/g)
}
return true
}, roomOneErrors.MEMBERSHIP_NO_ONLY_DIGITS)
.refine((num) => {
if (num) {
return num.match(/^30812(?!(0|1|2))[0-9]{9}$/)
}
return true
}, roomOneErrors.MEMBERSHIP_NO_INVALID),
})
)
export const joinDetailsSchema = baseDetailsSchema.merge(
z.object({
join: z.literal<boolean>(true),
zipCode: z
.string()
.min(1, roomOneErrors.ZIP_CODE_REQUIRED)
.regex(/^[A-Za-z0-9-\s]{1,9}$/g, roomOneErrors.ZIP_CODE_INVALID),
dateOfBirth: z
.string()
.min(1, roomOneErrors.BIRTH_DATE_REQUIRED)
.refine((date) => {
const today = dt()
const dob = dt(date)
const age = today.diff(dob, "year")
return age >= 18
}, roomOneErrors.BIRTH_DATE_AGE_18),
membershipNo: z.string().default(""),
})
)
export type GuestDetailsSchema = z.infer<typeof guestDetailsSchema>
export const guestDetailsSchema = z.discriminatedUnion("join", [
notJoinDetailsSchema,
joinDetailsSchema,
])
export type SignedInDetailsSchema = z.output<typeof signedInDetailsSchema>
// For signed in users we accept partial or invalid data. Users cannot
// change their info in this flow, so we don't want to validate it.
export const signedInDetailsSchema = z.object({
countryCode: z.string().default(""),
email: z.string().default(""),
firstName: z.string().default(""),
lastName: z.string().default(""),
membershipNo: z.string().default(""),
phoneNumber: z.string().default(""),
phoneNumberCC: z.string().default(""),
join: z
.boolean()
.optional()
.transform((_) => false),
dateOfBirth: z.string().default(""),
zipCode: z.string().default(""),
specialRequest: specialRequestSchema,
})