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,106 @@
"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 { 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 { useRoomContext } from "../../../../../contexts/EnterDetails/RoomContext"
import useLang from "../../../../../hooks/useLang"
import styles from "./joinScandicFriendsCard.module.css"
export type JoinScandicFriendsCardProps = {
name?: string
}
export default function JoinScandicFriendsCard({
name = "join",
}: JoinScandicFriendsCardProps) {
const lang = useLang()
const intl = useIntl()
const {
room,
roomNr,
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}>
{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>
<Checkbox
name={name}
className={styles.checkBox}
registerOptions={{ onChange, value: room.guest.join }}
>
<Typography variant="Body/Paragraph/mdRegular">
<div>
{intl.formatMessage({
defaultMessage: "Join Scandic Friends before check-in",
})}
</div>
</Typography>
</Checkbox>
<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,48 @@
.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;
}
.terms {
grid-area: terms;
}
@media screen and (min-width: 768px) {
.cardContainer {
grid-template-columns: 1fr auto;
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

@@ -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,255 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useEffect, useMemo } 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 { getMultiroomDetailsSchema } from "./schema"
import styles from "./details.module.css"
const formID = "enter-details"
export default function Details() {
const intl = useIntl()
const lang = useLang()
const { addPreSubmitCallback, rooms } = useEnterDetailsStore((state) => ({
addPreSubmitCallback: state.actions.addPreSubmitCallback,
rooms: state.rooms,
}))
const {
actions: { updateDetails, updatePartialGuestData, setIncomplete },
idx,
room,
roomNr,
} = useRoomContext()
const initialData = room.guest
/**
* The data that each room needs from each other to do validations
* across the rooms
*/
const crossValidationData = useMemo(
() =>
rooms
.filter((_, i) => i !== idx)
.map((room) => ({
firstName: room.room.guest.firstName,
lastName: room.room.guest.lastName,
membershipNo: room.room.guest.membershipNo,
})),
[idx, rooms]
)
const { phoneNumber, phoneNumberCC } = usePhoneNumberParsing(
initialData.phoneNumber,
initialData.phoneNumberCC
)
const methods = useForm({
defaultValues: {
countryCode: initialData.countryCode,
email: initialData.email,
firstName: initialData.firstName,
join: initialData.join,
lastName: initialData.lastName,
membershipNo: initialData.membershipNo,
phoneNumber,
phoneNumberCC,
specialRequest: {
comment: room.specialRequest.comment,
},
},
criteriaMode: "all",
mode: "onBlur",
resolver: zodResolver(getMultiroomDetailsSchema(crossValidationData)),
reValidateMode: "onChange",
})
const {
handleSubmit,
trigger,
control,
subscribe,
formState: { isValid, errors },
setValue,
watch,
getValues,
} = methods
const { trackFormSubmit } = useFormTracking(
"checkout",
subscribe,
control,
` - room ${roomNr}`
)
useEffect(() => {
function callback() {
trigger()
trackFormSubmit()
}
addPreSubmitCallback(`${idx}-details`, callback)
}, [addPreSubmitCallback, idx, trigger, trackFormSubmit])
const updateDetailsStore = useCallback(() => {
if (isValid) {
handleSubmit(updateDetails)()
} else {
updatePartialGuestData({
firstName: getValues("firstName")?.toString(),
lastName: getValues("lastName")?.toString(),
membershipNo: getValues("membershipNo")?.toString(),
})
setIncomplete()
}
}, [
handleSubmit,
isValid,
setIncomplete,
updateDetails,
updatePartialGuestData,
getValues,
])
useEffect(updateDetailsStore, [updateDetailsStore])
// Trigger validation of the room manually when another room changes its data.
// Only do it if the field has a value, to avoid error states before the user
// has filled anything in.
useEffect(() => {
const { firstName, lastName, membershipNo } = methods.getValues()
if (firstName) {
methods.trigger("firstName")
}
if (lastName) {
methods.trigger("lastName")
}
if (membershipNo) {
methods.trigger("membershipNo")
}
}, [crossValidationData, methods])
const countryCode = watch("countryCode")
useEffect(() => {
if (countryCode) {
setValue("phoneNumberCC", countryCode.toLowerCase())
}
}, [countryCode, setValue])
const guestIsGoingToJoin = methods.watch("join")
const guestIsMember = methods.watch("membershipNo")
return (
<FormProvider {...methods}>
<form
className={styles.form}
id={`${formID}-room-${roomNr}`}
onSubmit={methods.handleSubmit(updateDetails)}
>
{guestIsMember ? null : <JoinScandicFriendsCard />}
<div className={styles.container}>
<Footnote
color="uiTextHighContrast"
textTransform="uppercase"
type="label"
className={styles.fullWidth}
>
{intl.formatMessage({
defaultMessage: "Guest information",
})}
</Footnote>
<BookingFlowInput
label={intl.formatMessage({
defaultMessage: "First name",
})}
maxLength={30}
name="firstName"
registerOptions={{
required: true,
deps: "lastName",
onBlur: updateDetailsStore,
}}
/>
<BookingFlowInput
label={intl.formatMessage({
defaultMessage: "Last name",
})}
maxLength={30}
name="lastName"
registerOptions={{
required: true,
deps: "firstName",
onBlur: updateDetailsStore,
}}
/>
<CountrySelect
className={styles.fullWidth}
countries={getFormattedCountryList(intl)}
errorMessage={getErrorMessage(intl, errors.countryCode?.message)}
label={intl.formatMessage({
defaultMessage: "Country",
})}
lang={lang}
name="countryCode"
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
<BookingFlowInput
className={styles.fullWidth}
label={intl.formatMessage({
defaultMessage: "Email address",
})}
name="email"
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
<Phone
countryLabel={intl.formatMessage({
defaultMessage: "Country code",
})}
countriesWithTranslatedName={getFormattedCountryList(intl)}
defaultCountryCode={getDefaultCountryFromLang(lang)}
errorMessage={getErrorMessage(intl, errors.phoneNumber?.message)}
className={styles.fullWidth}
label={intl.formatMessage({
defaultMessage: "Phone number",
})}
name="phoneNumber"
registerOptions={{ required: true, onBlur: updateDetailsStore }}
/>
{guestIsGoingToJoin ? null : (
<BookingFlowInput
className={styles.fullWidth}
label={intl.formatMessage({
defaultMessage: "Membership ID",
})}
name="membershipNo"
type="tel"
registerOptions={{ onBlur: updateDetailsStore }}
/>
)}
<SpecialRequests registerOptions={{ onBlur: updateDetailsStore }} />
</div>
<MemberPriceModal />
</form>
</FormProvider>
)
}

View File

@@ -0,0 +1,94 @@
import { z } from "zod"
import { phoneValidator } from "@scandic-hotels/common/utils/zod/phoneValidator"
import { multiroomErrors } 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 type CrossValidationData = {
firstName: string | undefined
lastName: string | undefined
membershipNo: string | undefined
}
export type MultiroomDetailsSchema = z.output<
ReturnType<typeof getMultiroomDetailsSchema>
>
export function getMultiroomDetailsSchema(
crossValidationData: CrossValidationData[] | undefined = []
) {
return z
.object({
countryCode: z.string().min(1, multiroomErrors.COUNTRY_REQUIRED),
email: z.string().email(multiroomErrors.EMAIL_REQUIRED),
firstName: z
.string()
.min(1, multiroomErrors.FIRST_NAME_REQUIRED)
.refine(isValidString, multiroomErrors.FIRST_NAME_SPECIAL_CHARACTERS),
join: z.boolean().default(false),
lastName: z
.string()
.min(1, multiroomErrors.LAST_NAME_REQUIRED)
.refine(isValidString, multiroomErrors.LAST_NAME_SPECIAL_CHARACTERS),
phoneNumber: phoneValidator(
multiroomErrors.PHONE_REQUIRED,
multiroomErrors.PHONE_REQUESTED
),
phoneNumberCC: z.string(),
membershipNo: z
.string()
.optional()
.refine((val) => {
if (val) {
return !val.match(/[^0-9]/g)
}
return true
}, multiroomErrors.MEMBERSHIP_NO_ONLY_DIGITS)
.refine((num) => {
if (num) {
return num.match(/^30812(?!(0|1|2))[0-9]{9}$/)
}
return true
}, multiroomErrors.MEMBERSHIP_NO_INVALID),
specialRequest: specialRequestSchema,
})
.refine(
(data) =>
!crossValidationData.some(
(room) =>
room.firstName?.toLowerCase() === data.firstName.toLowerCase() &&
room.lastName?.toLowerCase() === data.lastName.toLowerCase()
),
{
message: multiroomErrors.FIRST_AND_LAST_NAME_UNIQUE,
path: ["firstName"],
}
)
.refine(
(data) =>
!crossValidationData.some(
(room) =>
room.firstName?.toLowerCase() === data.firstName.toLowerCase() &&
room.lastName?.toLowerCase() === data.lastName.toLowerCase()
),
{
message: multiroomErrors.FIRST_AND_LAST_NAME_UNIQUE,
path: ["lastName"],
}
)
.refine(
(data) =>
!crossValidationData.some(
(room) => room.membershipNo && room.membershipNo === data.membershipNo
),
{
message: multiroomErrors.MEMBERSHIP_NO_UNIQUE,
path: ["membershipNo"],
}
)
}