Merged in monorepo-step-1 (pull request #1080)
Migrate to a monorepo setup - step 1 * Move web to subfolder /apps/scandic-web * Yarn + transitive deps - Move to yarn - design-system package removed for now since yarn doesn't support the parameter for token (ie project currently broken) - Add missing transitive dependencies as Yarn otherwise prevents these imports - VS Code doesn't pick up TS path aliases unless you open /apps/scandic-web instead of root (will be fixed with monorepo) * Pin framer-motion to temporarily fix typing issue https://github.com/adobe/react-spectrum/issues/7494 * Pin zod to avoid typ error There seems to have been a breaking change in the types returned by zod where error is now returned as undefined instead of missing in the type. We should just handle this but to avoid merge conflicts just pin the dependency for now. * Pin react-intl version Pin version of react-intl to avoid tiny type issue where formatMessage does not accept a generic any more. This will be fixed in a future commit, but to avoid merge conflicts just pin for now. * Pin typescript version Temporarily pin version as newer versions as stricter and results in a type error. Will be fixed in future commit after merge. * Setup workspaces * Add design-system as a monorepo package * Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN * Fix husky for monorepo setup * Update netlify.toml * Add lint script to root package.json * Add stub readme * Fix react-intl formatMessage types * Test netlify.toml in root * Remove root toml * Update netlify.toml publish path * Remove package-lock.json * Update build for branch/preview builds Approved-by: Linus Flood
This commit is contained in:
committed by
Linus Flood
parent
667cab6fb6
commit
80100e7631
@@ -0,0 +1,42 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type { BedTypeInfoProps } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
|
||||
export default function BedTypeInfo({ hasMultipleBedTypes }: BedTypeInfoProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const hasChildWithExtraBed = useEnterDetailsStore((state) =>
|
||||
state.booking.rooms[0].childrenInRoom?.some(
|
||||
(child) => Number(child.bed) === ChildBedMapEnum.IN_EXTRA_BED
|
||||
)
|
||||
)
|
||||
|
||||
const availabilityText = intl.formatMessage({
|
||||
id: "Your selected bed type will be provided based on availability",
|
||||
})
|
||||
|
||||
const extraBedText = intl.formatMessage({
|
||||
id: "Extra bed will be provided additionally",
|
||||
})
|
||||
|
||||
const combinedStr = `${availabilityText}. ${extraBedText}`
|
||||
|
||||
if (hasMultipleBedTypes && hasChildWithExtraBed) {
|
||||
return <Body>{combinedStr}</Body>
|
||||
}
|
||||
|
||||
if (hasMultipleBedTypes) {
|
||||
return <Body>{availabilityText}</Body>
|
||||
}
|
||||
|
||||
if (hasChildWithExtraBed) {
|
||||
return <Body>{extraBedText}</Body>
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
width: min(600px, 100%);
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
|
||||
import {
|
||||
BED_TYPE_ICONS,
|
||||
type BedTypeEnum,
|
||||
type ExtraBedTypeEnum,
|
||||
} from "@/constants/booking"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import { selectRoom } from "@/stores/enter-details/helpers"
|
||||
|
||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||
|
||||
import BedTypeInfo from "./BedTypeInfo"
|
||||
import { bedTypeFormSchema } from "./schema"
|
||||
|
||||
import styles from "./bedOptions.module.css"
|
||||
|
||||
import type {
|
||||
BedTypeFormSchema,
|
||||
BedTypeProps,
|
||||
} from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function BedType({
|
||||
bedTypes,
|
||||
roomIndex,
|
||||
}: BedTypeProps & { roomIndex: number }) {
|
||||
const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex))
|
||||
const initialBedType = room.bedType?.roomTypeCode
|
||||
|
||||
const updateBedType = useEnterDetailsStore(
|
||||
(state) => state.actions.updateBedType
|
||||
)
|
||||
|
||||
const methods = useForm<BedTypeFormSchema>({
|
||||
defaultValues: initialBedType ? { bedType: initialBedType } : undefined,
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(bedTypeFormSchema),
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(bedTypeRoomCode: BedTypeFormSchema) => {
|
||||
const matchingRoom = bedTypes.find(
|
||||
(roomType) => roomType.value === bedTypeRoomCode.bedType
|
||||
)
|
||||
if (matchingRoom) {
|
||||
const bedType = {
|
||||
description: matchingRoom.description,
|
||||
roomTypeCode: matchingRoom.value,
|
||||
}
|
||||
updateBedType(bedType)
|
||||
}
|
||||
},
|
||||
[bedTypes, updateBedType]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (initialBedType) {
|
||||
methods.setValue("bedType", initialBedType)
|
||||
}
|
||||
}, [initialBedType, methods])
|
||||
|
||||
useEffect(() => {
|
||||
if (methods.formState.isSubmitting) {
|
||||
return
|
||||
}
|
||||
|
||||
const subscription = methods.watch(() => methods.handleSubmit(onSubmit)())
|
||||
return () => subscription.unsubscribe()
|
||||
}, [methods, onSubmit])
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<div className={styles.container}>
|
||||
<BedTypeInfo hasMultipleBedTypes={bedTypes.length > 1} />
|
||||
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
{bedTypes.map((roomType) => {
|
||||
const width =
|
||||
roomType.size.max === roomType.size.min
|
||||
? `${roomType.size.min} cm`
|
||||
: `${roomType.size.min} cm - ${roomType.size.max} cm`
|
||||
return (
|
||||
<RadioCard
|
||||
key={roomType.value}
|
||||
Icon={(props) => (
|
||||
<BedIconRenderer
|
||||
mainBedType={roomType.type}
|
||||
extraBedType={roomType.extraBed?.type}
|
||||
props={props}
|
||||
/>
|
||||
)}
|
||||
id={roomType.value}
|
||||
name="bedType"
|
||||
subtitle={width}
|
||||
title={roomType.description}
|
||||
value={roomType.value}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</form>
|
||||
</div>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function BedIconRenderer({
|
||||
mainBedType,
|
||||
extraBedType,
|
||||
props,
|
||||
}: {
|
||||
mainBedType: BedTypeEnum
|
||||
extraBedType: ExtraBedTypeEnum | undefined
|
||||
props: IconProps
|
||||
}) {
|
||||
const MainBedIcon = BED_TYPE_ICONS[mainBedType] ?? BED_TYPE_ICONS.Other
|
||||
const ExtraBedIcon = extraBedType ? BED_TYPE_ICONS[extraBedType] : null
|
||||
|
||||
if (!MainBedIcon) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${props.className} ${styles.iconContainer}`}>
|
||||
<MainBedIcon height={32} color="uiTextMediumContrast" />
|
||||
{ExtraBedIcon && (
|
||||
<ExtraBedIcon height={32} color="uiTextMediumContrast" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const bedTypeSchema = z.object({
|
||||
bedType: z.object({ description: z.string(), roomTypeCode: z.string() }),
|
||||
})
|
||||
export const bedTypeFormSchema = z.object({
|
||||
bedType: z.string(),
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
.ancillaryChoiceCard:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
import { AncillaryCard } from "@/components/TempDesignSystem/AncillaryCard"
|
||||
|
||||
import styles from "./ancillaryChoiceCard.module.css"
|
||||
|
||||
import type { BreakfastChoiceCardProps } from "@/types/components/ancillaryCard"
|
||||
|
||||
export default function BreakfastChoiceCard({
|
||||
name,
|
||||
id,
|
||||
value,
|
||||
ancillary,
|
||||
}: BreakfastChoiceCardProps) {
|
||||
const { register, setValue } = useFormContext()
|
||||
|
||||
function onLabelClick(event: React.MouseEvent) {
|
||||
// Preventing click event on label elements firing twice: https://github.com/facebook/react/issues/14295
|
||||
event.preventDefault()
|
||||
setValue(name, value)
|
||||
}
|
||||
return (
|
||||
<label
|
||||
onClick={onLabelClick}
|
||||
tabIndex={0}
|
||||
className={styles.ancillaryChoiceCard}
|
||||
>
|
||||
<AncillaryCard ancillary={ancillary} />
|
||||
<input
|
||||
{...register(name)}
|
||||
aria-hidden
|
||||
id={id || name}
|
||||
hidden
|
||||
type="radio"
|
||||
value={value}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
width: min(600px, 100%);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"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 { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import { selectRoom } from "@/stores/enter-details/helpers"
|
||||
|
||||
import BreakfastChoiceCard from "@/components/HotelReservation/EnterDetails/Breakfast/BreakfastChoiceCard"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import { breakfastFormSchema } from "./schema"
|
||||
|
||||
import styles from "./breakfast.module.css"
|
||||
|
||||
import type {
|
||||
BreakfastFormSchema,
|
||||
BreakfastProps,
|
||||
} from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default function Breakfast({
|
||||
packages,
|
||||
roomIndex,
|
||||
}: BreakfastProps & { roomIndex: number }) {
|
||||
const intl = useIntl()
|
||||
|
||||
const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex))
|
||||
|
||||
const breakfastSelection = room?.breakfast
|
||||
? room.breakfast.code
|
||||
: room?.breakfast === false
|
||||
? "false"
|
||||
: undefined
|
||||
|
||||
const updateBreakfast = useEnterDetailsStore(
|
||||
(state) => state.actions.updateBreakfast
|
||||
)
|
||||
|
||||
const children = useEnterDetailsStore(
|
||||
(state) => state.booking.rooms[0].childrenInRoom
|
||||
)
|
||||
|
||||
const methods = useForm<BreakfastFormSchema>({
|
||||
defaultValues: breakfastSelection
|
||||
? { breakfast: breakfastSelection }
|
||||
: undefined,
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(breakfastFormSchema),
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(values: BreakfastFormSchema) => {
|
||||
const pkg = packages?.find((p) => p.code === values.breakfast)
|
||||
if (pkg) {
|
||||
updateBreakfast(pkg)
|
||||
} else {
|
||||
updateBreakfast(false)
|
||||
}
|
||||
},
|
||||
[packages, updateBreakfast]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (breakfastSelection) {
|
||||
methods.setValue("breakfast", breakfastSelection)
|
||||
}
|
||||
}, [breakfastSelection, methods])
|
||||
|
||||
useEffect(() => {
|
||||
if (methods.formState.isSubmitting) {
|
||||
return
|
||||
}
|
||||
const subscription = methods.watch(() => methods.handleSubmit(onSubmit)())
|
||||
return () => subscription.unsubscribe()
|
||||
}, [methods, onSubmit])
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<div className={styles.container}>
|
||||
{children?.length ? (
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "Children's breakfast is always free as part of the adult's breakfast.",
|
||||
})}
|
||||
</Body>
|
||||
) : null}
|
||||
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
{packages.map((pkg) => (
|
||||
<BreakfastChoiceCard
|
||||
key={pkg.code}
|
||||
name="breakfast"
|
||||
ancillary={{
|
||||
title: intl.formatMessage({ id: "Breakfast buffet" }),
|
||||
price: {
|
||||
total: parseInt(pkg.localPrice.price),
|
||||
currency: pkg.localPrice.currency,
|
||||
included:
|
||||
pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST,
|
||||
text: intl.formatMessage({ id: "/night per adult" }),
|
||||
},
|
||||
description: intl.formatMessage({
|
||||
id: "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.",
|
||||
}),
|
||||
imageUrl: "/_static/img/enter-details/breakfast.png", // TODO: Add dynamic image
|
||||
}}
|
||||
value={pkg.code}
|
||||
id={pkg.code}
|
||||
/>
|
||||
))}
|
||||
<BreakfastChoiceCard
|
||||
name="breakfast"
|
||||
ancillary={{
|
||||
title: intl.formatMessage({ id: "No breakfast" }),
|
||||
price: {
|
||||
total: 0,
|
||||
currency: packages[0].localPrice.currency,
|
||||
},
|
||||
description: intl.formatMessage({
|
||||
id: "You can always change your mind later and add breakfast at the hotel.",
|
||||
}),
|
||||
imageUrl: "/_static/img/enter-details/breakfast.png", // TODO: Add dynamic image
|
||||
imageOpacity: 0.1,
|
||||
}}
|
||||
value="false"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { breakfastPackageSchema } from "@/server/routers/hotels/schemas/packages"
|
||||
|
||||
export const breakfastStoreSchema = z.object({
|
||||
breakfast: breakfastPackageSchema.or(z.literal(false)),
|
||||
})
|
||||
|
||||
export const breakfastFormSchema = z.object({
|
||||
breakfast: z.string().or(z.literal("false")),
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { privacyPolicy } from "@/constants/currentWebHrefs"
|
||||
|
||||
import { CheckIcon } from "@/components/Icons"
|
||||
import LoginButton from "@/components/LoginButton"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./joinScandicFriendsCard.module.css"
|
||||
|
||||
import type { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
|
||||
export default function JoinScandicFriendsCard({
|
||||
name,
|
||||
memberPrice,
|
||||
}: JoinScandicFriendsCardProps) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
|
||||
const list = [
|
||||
{ title: intl.formatMessage({ id: "Friendly room rates" }) },
|
||||
{ title: intl.formatMessage({ id: "Earn & spend points" }) },
|
||||
{ title: intl.formatMessage({ id: "Join for free" }) },
|
||||
]
|
||||
|
||||
const saveOnJoiningLabel = intl.formatMessage(
|
||||
{
|
||||
id: "Get the member price: {amount}",
|
||||
},
|
||||
{
|
||||
amount: formatPrice(
|
||||
intl,
|
||||
memberPrice?.price ?? 0,
|
||||
memberPrice?.currency ?? "SEK"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={styles.cardContainer}>
|
||||
<Checkbox name={name} className={styles.checkBox}>
|
||||
<div>
|
||||
{memberPrice ? (
|
||||
<Caption type="label" textTransform="uppercase" color="red">
|
||||
{saveOnJoiningLabel}
|
||||
</Caption>
|
||||
) : null}
|
||||
<Caption
|
||||
type="label"
|
||||
textTransform="uppercase"
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{intl.formatMessage({ id: "Join Scandic Friends" })}
|
||||
</Caption>
|
||||
</div>
|
||||
</Checkbox>
|
||||
|
||||
<Footnote color="uiTextHighContrast" className={styles.login}>
|
||||
{intl.formatMessage({ id: "Already a friend?" })}{" "}
|
||||
<LoginButton
|
||||
color="burgundy"
|
||||
position="enter details"
|
||||
trackingId="join-scandic-friends-enter-details"
|
||||
variant="breadcrumb"
|
||||
>
|
||||
{intl.formatMessage({ id: "Log in" })}
|
||||
</LoginButton>
|
||||
</Footnote>
|
||||
|
||||
<div className={styles.list}>
|
||||
{list.map((item) => (
|
||||
<Caption
|
||||
key={item.title}
|
||||
color="uiTextPlaceholder"
|
||||
className={styles.listItem}
|
||||
>
|
||||
<CheckIcon color="uiTextPlaceholder" height="20" /> {item.title}
|
||||
</Caption>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.terms}>
|
||||
<Footnote color="uiTextPlaceholder">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "By signing up you accept the Scandic Friends <termsAndConditionsLink>Terms and Conditions</termsAndConditionsLink>. Your membership is valid until further notice, and you can terminate your membership at any time by sending an email to Scandic's customer service",
|
||||
},
|
||||
{
|
||||
termsAndConditionsLink: (str) => (
|
||||
<Link
|
||||
variant="default"
|
||||
textDecoration="underline"
|
||||
size="tiny"
|
||||
target="_blank"
|
||||
color="uiTextPlaceholder"
|
||||
href={privacyPolicy[lang]}
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Footnote>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.cardContainer {
|
||||
align-self: flex-start;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: grid;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
|
||||
grid-template-areas:
|
||||
"checkbox"
|
||||
"list"
|
||||
"login"
|
||||
"terms";
|
||||
width: min(100%, 600px);
|
||||
}
|
||||
|
||||
.login {
|
||||
grid-area: login;
|
||||
}
|
||||
|
||||
.checkBox {
|
||||
align-self: center;
|
||||
grid-area: checkbox;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
grid-area: list;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.listItem {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.terms {
|
||||
border-top: 1px solid var(--Base-Border-Normal);
|
||||
grid-area: terms;
|
||||
padding-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.cardContainer {
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-areas:
|
||||
"checkbox login"
|
||||
"list list"
|
||||
"terms terms";
|
||||
}
|
||||
.list {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import { selectRoom } from "@/stores/enter-details/helpers"
|
||||
|
||||
import { MagicWandIcon } from "@/components/Icons"
|
||||
import Modal from "@/components/Modal"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./modal.module.css"
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react"
|
||||
|
||||
export default function MemberPriceModal({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>
|
||||
}) {
|
||||
const room = useEnterDetailsStore(selectRoom)
|
||||
const memberRate = room.roomRate.memberRate
|
||||
const intl = useIntl()
|
||||
|
||||
const memberPrice = memberRate?.localPrice ?? memberRate?.requestedPrice
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onToggle={setIsOpen}>
|
||||
<div className={styles.modalContent}>
|
||||
<div className={styles.innerModalContent}>
|
||||
<MagicWandIcon width="265px" />
|
||||
<Title as="h3" level="h1" textTransform="regular">
|
||||
{intl.formatMessage({
|
||||
id: "Member price activated",
|
||||
})}
|
||||
</Title>
|
||||
|
||||
{memberPrice && (
|
||||
<span className={styles.newPrice}>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "The new price is",
|
||||
})}
|
||||
</Body>
|
||||
<Subtitle type="two" color="red">
|
||||
{formatPrice(
|
||||
intl,
|
||||
memberPrice.pricePerStay,
|
||||
memberPrice.currency
|
||||
)}
|
||||
</Subtitle>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button intent="primary" theme="base" onClick={() => setIsOpen(false)}>
|
||||
{intl.formatMessage({ id: "OK" })}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.modalContent {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.innerModalContent {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.newPrice {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.modalContent {
|
||||
width: 352px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import DateSelect from "@/components/TempDesignSystem/Form/Date"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import styles from "./signup.module.css"
|
||||
|
||||
export default function Signup({ name }: { name: string }) {
|
||||
const intl = useIntl()
|
||||
|
||||
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}>
|
||||
<Input
|
||||
name="zipCode"
|
||||
label={intl.formatMessage({ id: "Zip code" })}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<div className={styles.dateField}>
|
||||
<header>
|
||||
<Caption type="bold">
|
||||
<span className={styles.required}>
|
||||
{intl.formatMessage({ id: "Birth date" })}
|
||||
</span>
|
||||
</Caption>
|
||||
</header>
|
||||
<DateSelect name="dateOfBirth" registerOptions={{ required: true }} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
label={intl.formatMessage({ id: "Membership no" })}
|
||||
name="membershipNo"
|
||||
type="tel"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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: " *";
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { ChevronDownIcon } from "@/components/Icons"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import TextArea from "@/components/TempDesignSystem/Form/TextArea"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
|
||||
import styles from "./specialRequests.module.css"
|
||||
|
||||
export default function SpecialRequests() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const intl = useIntl()
|
||||
|
||||
function toggleRequests() {
|
||||
setIsOpen((prevVal) => !prevVal)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.requests} data-requests-open={isOpen}>
|
||||
<button className={styles.toggle} onClick={toggleRequests} type="button">
|
||||
<Footnote
|
||||
color="uiTextHighContrast"
|
||||
textTransform="uppercase"
|
||||
type="label"
|
||||
className={styles.header}
|
||||
textAlign="left"
|
||||
>
|
||||
{intl.formatMessage({ id: "Special requests" })}
|
||||
</Footnote>
|
||||
<ChevronDownIcon className={styles.chevron} />
|
||||
<Divider className={styles.divider} color="subtle" />
|
||||
</button>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.contentWrapper}>
|
||||
{/*
|
||||
|
||||
TODO: Hiding because API is not ready for this yet (https://scandichotels.atlassian.net/browse/SW-1497). Add back in when API is ready.
|
||||
|
||||
<Select
|
||||
label={intl.formatMessage({ id: "Floor preference" })}
|
||||
name="specialRequests.floorPreference"
|
||||
items={[
|
||||
noPreferenceItem,
|
||||
{
|
||||
value: FloorPreference.HIGH,
|
||||
label: intl.formatMessage({ id: "High floor" }),
|
||||
},
|
||||
{
|
||||
value: FloorPreference.LOW,
|
||||
label: intl.formatMessage({ id: "Low floor" }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label={intl.formatMessage({ id: "Elevator preference" })}
|
||||
name="specialRequests.elevatorPreference"
|
||||
items={[
|
||||
noPreferenceItem,
|
||||
{
|
||||
value: ElevatorPreference.AWAY_FROM_ELEVATOR,
|
||||
label: intl.formatMessage({
|
||||
id: "Away from elevator",
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: ElevatorPreference.NEAR_ELEVATOR,
|
||||
label: intl.formatMessage({
|
||||
id: "Near elevator",
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/> */}
|
||||
<TextArea
|
||||
label={intl.formatMessage({
|
||||
id: "Is there anything else you would like us to know before your arrival?",
|
||||
})}
|
||||
name="specialRequests.comments"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.requests {
|
||||
--header-height: 50px;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: var(--header-height) 0fr;
|
||||
transition: 0.3s ease-out;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"header chevron"
|
||||
"divider divider";
|
||||
grid-template-columns: 1fr auto;
|
||||
background-color: transparent;
|
||||
gap: var(--Spacing-x1);
|
||||
border: none;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
margin: var(--Spacing-x2) 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
grid-area: header;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
grid-area: chevron;
|
||||
}
|
||||
|
||||
.divider {
|
||||
grid-area: divider;
|
||||
border-top: 1px solid var(--Color-gray-300);
|
||||
}
|
||||
|
||||
.requests[data-requests-open="true"] .chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.contentWrapper {
|
||||
padding-top: var(--Spacing-x3);
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.requests[data-requests-open="true"] {
|
||||
grid-template-rows: var(--header-height) 1fr;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
width: min(100%, 600px);
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.form {
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.container {
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
width: min(100%, 600px);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useState } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import {
|
||||
selectBookingProgress,
|
||||
selectRoom,
|
||||
} from "@/stores/enter-details/helpers"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import Phone from "@/components/TempDesignSystem/Form/Phone"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
|
||||
import JoinScandicFriendsCard from "./JoinScandicFriendsCard"
|
||||
import MemberPriceModal from "./MemberPriceModal"
|
||||
import { guestDetailsSchema, signedInDetailsSchema } from "./schema"
|
||||
import Signup from "./Signup"
|
||||
import SpecialRequests from "./SpecialRequests"
|
||||
|
||||
import styles from "./details.module.css"
|
||||
|
||||
import type {
|
||||
DetailsProps,
|
||||
DetailsSchema,
|
||||
} from "@/types/components/hotelReservation/enterDetails/details"
|
||||
|
||||
const formID = "enter-details"
|
||||
export default function Details({
|
||||
user,
|
||||
memberPrice,
|
||||
roomIndex,
|
||||
}: DetailsProps & { roomIndex: number }) {
|
||||
const intl = useIntl()
|
||||
const [isMemberPriceModalOpen, setIsMemberPriceModalOpen] = useState(false)
|
||||
|
||||
const { currentRoomIndex, canProceedToPayment, roomStatuses } =
|
||||
useEnterDetailsStore(selectBookingProgress)
|
||||
const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex))
|
||||
const initialData = room.guest
|
||||
|
||||
const updateDetails = useEnterDetailsStore(
|
||||
(state) => state.actions.updateDetails
|
||||
)
|
||||
|
||||
const isPaymentNext = currentRoomIndex === roomStatuses.length - 1
|
||||
|
||||
const methods = useForm<DetailsSchema>({
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(user ? signedInDetailsSchema : guestDetailsSchema),
|
||||
reValidateMode: "onChange",
|
||||
values: {
|
||||
countryCode: user?.address?.countryCode ?? initialData.countryCode,
|
||||
dateOfBirth: initialData.dateOfBirth,
|
||||
email: user?.email ?? initialData.email,
|
||||
firstName: user?.firstName ?? initialData.firstName,
|
||||
join: initialData.join,
|
||||
lastName: user?.lastName ?? initialData.lastName,
|
||||
membershipNo: initialData.membershipNo,
|
||||
phoneNumber: user?.phoneNumber ?? initialData.phoneNumber,
|
||||
zipCode: initialData.zipCode,
|
||||
specialRequests: initialData.specialRequests,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(values: DetailsSchema) => {
|
||||
if ((values.join || values.membershipNo) && memberPrice && !user) {
|
||||
setIsMemberPriceModalOpen(true)
|
||||
}
|
||||
updateDetails(values)
|
||||
},
|
||||
[updateDetails, setIsMemberPriceModalOpen, memberPrice, user]
|
||||
)
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.form}
|
||||
id={`${formID}-room-${roomIndex + 1}`}
|
||||
onSubmit={methods.handleSubmit(onSubmit)}
|
||||
>
|
||||
{user ? null : (
|
||||
<JoinScandicFriendsCard name="join" memberPrice={memberPrice} />
|
||||
)}
|
||||
<div className={styles.container}>
|
||||
<Footnote
|
||||
color="uiTextHighContrast"
|
||||
textTransform="uppercase"
|
||||
type="label"
|
||||
className={styles.fullWidth}
|
||||
>
|
||||
{intl.formatMessage({ id: "Guest information" })}
|
||||
</Footnote>
|
||||
<Input
|
||||
label={intl.formatMessage({ id: "First name" })}
|
||||
maxLength={30}
|
||||
name="firstName"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<Input
|
||||
label={intl.formatMessage({ id: "Last name" })}
|
||||
maxLength={30}
|
||||
name="lastName"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<CountrySelect
|
||||
className={styles.fullWidth}
|
||||
label={intl.formatMessage({ id: "Country" })}
|
||||
name="countryCode"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<Input
|
||||
className={styles.fullWidth}
|
||||
label={intl.formatMessage({ id: "Email address" })}
|
||||
name="email"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<Phone
|
||||
className={styles.fullWidth}
|
||||
label={intl.formatMessage({ id: "Phone number" })}
|
||||
name="phoneNumber"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
{user ? null : (
|
||||
<div className={styles.fullWidth}>
|
||||
<Signup name="join" />
|
||||
</div>
|
||||
)}
|
||||
<SpecialRequests />
|
||||
</div>
|
||||
<footer className={styles.footer}>
|
||||
<Button
|
||||
disabled={
|
||||
!(
|
||||
methods.formState.isValid ||
|
||||
(isPaymentNext && canProceedToPayment)
|
||||
)
|
||||
}
|
||||
intent="secondary"
|
||||
size="small"
|
||||
theme="base"
|
||||
type="submit"
|
||||
>
|
||||
{isPaymentNext
|
||||
? intl.formatMessage({ id: "Proceed to payment method" })
|
||||
: intl.formatMessage(
|
||||
{ id: "Continue to room {nextRoomNumber}" },
|
||||
{ nextRoomNumber: currentRoomIndex + 2 }
|
||||
)}
|
||||
</Button>
|
||||
</footer>
|
||||
<MemberPriceModal
|
||||
isOpen={isMemberPriceModalOpen}
|
||||
setIsOpen={setIsMemberPriceModalOpen}
|
||||
/>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import { phoneValidator } from "@/utils/zod/phoneValidator"
|
||||
|
||||
// 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 enum FloorPreference {
|
||||
LOW = "Low floor",
|
||||
HIGH = "High floor",
|
||||
}
|
||||
|
||||
export enum ElevatorPreference {
|
||||
AWAY_FROM_ELEVATOR = "Away from elevator",
|
||||
NEAR_ELEVATOR = "Near elevator",
|
||||
}
|
||||
|
||||
const specialRequestsSchema = z
|
||||
.object({
|
||||
floorPreference: z
|
||||
.nativeEnum(FloorPreference)
|
||||
.or(z.literal("").transform((_) => undefined))
|
||||
.optional(),
|
||||
elevatorPreference: z
|
||||
.nativeEnum(ElevatorPreference)
|
||||
.or(z.literal("").transform((_) => undefined))
|
||||
.optional(),
|
||||
comments: z.string().default(""),
|
||||
})
|
||||
.optional()
|
||||
|
||||
export const baseDetailsSchema = z.object({
|
||||
countryCode: z.string().min(1, { message: "Country is required" }),
|
||||
email: z.string().email({ message: "Email address is required" }),
|
||||
firstName: z
|
||||
.string()
|
||||
.min(1, { message: "First name is required" })
|
||||
.refine(isValidString, {
|
||||
message: "First name can't contain any special characters",
|
||||
}),
|
||||
lastName: z
|
||||
.string()
|
||||
.min(1, { message: "Last name is required" })
|
||||
.refine(isValidString, {
|
||||
message: "Last name can't contain any special characters",
|
||||
}),
|
||||
phoneNumber: phoneValidator(),
|
||||
specialRequests: specialRequestsSchema,
|
||||
})
|
||||
|
||||
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
|
||||
}, "Only digits are allowed")
|
||||
.refine((num) => {
|
||||
if (num) {
|
||||
return num.match(/^30812(?!(0|1|2))[0-9]{9}$/)
|
||||
}
|
||||
return true
|
||||
}, "Invalid membership number format"),
|
||||
})
|
||||
)
|
||||
|
||||
export const joinDetailsSchema = baseDetailsSchema.merge(
|
||||
z.object({
|
||||
join: z.literal<boolean>(true),
|
||||
zipCode: z.string().min(1, { message: "Zip code is required" }),
|
||||
dateOfBirth: z
|
||||
.string()
|
||||
.min(1, { message: "Date of birth is required" })
|
||||
.refine(
|
||||
(date) => {
|
||||
const today = dt()
|
||||
const dob = dt(date)
|
||||
const age = today.diff(dob, "year")
|
||||
return age >= 18
|
||||
},
|
||||
{ message: "Must be at least 18 years of age to continue" }
|
||||
),
|
||||
membershipNo: z.string().default(""),
|
||||
})
|
||||
)
|
||||
|
||||
export const guestDetailsSchema = z.discriminatedUnion("join", [
|
||||
notJoinDetailsSchema,
|
||||
joinDetailsSchema,
|
||||
])
|
||||
|
||||
// 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(""),
|
||||
join: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.transform((_) => false),
|
||||
dateOfBirth: z.string().default(""),
|
||||
zipCode: z.string().default(""),
|
||||
specialRequests: specialRequestsSchema,
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import ChevronRight from "@/components/Icons/ChevronRight"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import styles from "./header.module.css"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import type { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||
|
||||
export default function ToggleSidePeek({
|
||||
hotelId,
|
||||
intent = "textInverted",
|
||||
}: ToggleSidePeekProps) {
|
||||
const intl = useIntl()
|
||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => openSidePeek({ key: SidePeekEnum.hotelDetails, hotelId })}
|
||||
theme="base"
|
||||
size="small"
|
||||
variant="icon"
|
||||
intent={intent}
|
||||
wrapping
|
||||
className={styles.toggle}
|
||||
>
|
||||
{intl.formatMessage({ id: "See hotel details" })}
|
||||
<ChevronRight height="14" color="white" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
.header {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
background-color: rgba(57, 57, 57, 0.5);
|
||||
width: 100dvw;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
max-width: var(--max-width-page);
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3) 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2; /* number of lines to show */
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.address {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--Spacing-x-one-and-half);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
padding: var(--Spacing-x3) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.container {
|
||||
padding: var(--Spacing-x6) 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Image from "@/components/Image"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getSingleDecimal } from "@/utils/numberFormatting"
|
||||
|
||||
import ToggleSidePeek from "./ToggleSidePeek"
|
||||
|
||||
import styles from "./header.module.css"
|
||||
|
||||
import type { HotelHeaderProps } from "@/types/components/hotelReservation/enterDetails/hotelHeader"
|
||||
|
||||
export default async function HotelHeader({
|
||||
hotelData: { hotel },
|
||||
}: HotelHeaderProps) {
|
||||
const intl = await getIntl()
|
||||
const image = hotel.hotelContent?.images
|
||||
|
||||
const addressStr = `${hotel.address.streetAddress}, ${hotel.address.city}`
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<Image
|
||||
className={styles.hero}
|
||||
alt={image.metaData.altText || image.metaData.altText_En || ""}
|
||||
src={image.imageSizes.large}
|
||||
height={200}
|
||||
width={1196}
|
||||
/>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Title as="h1" level="h1" color="white" className={styles.title}>
|
||||
{hotel.name}
|
||||
</Title>
|
||||
<div className={styles.address}>
|
||||
<Caption color="white">{addressStr}</Caption>
|
||||
<Caption color="white">∙</Caption>
|
||||
<Caption color="white">
|
||||
{intl.formatMessage(
|
||||
{ id: "{number} km to city center" },
|
||||
{
|
||||
number: getSingleDecimal(
|
||||
hotel.location.distanceToCentre / 1000
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSidePeek hotelId={hotel.operaId} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
padding-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.content ol {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.summary::-webkit-details-marker,
|
||||
.summary::marker {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import ChevronDown from "@/components/Icons/ChevronDown"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import styles from "./guaranteeDetails.module.css"
|
||||
|
||||
export default function GuaranteeDetails() {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<details>
|
||||
<Caption color="burgundy" type="bold" asChild>
|
||||
<summary className={styles.summary}>
|
||||
{intl.formatMessage({ id: "How it works" })}
|
||||
<ChevronDown color="burgundy" height={16} />
|
||||
</summary>
|
||||
</Caption>
|
||||
<section className={styles.content}>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "When guaranteeing your booking, we will hold the booking until 07:00 until the day after check-in. This will provide you as a guest with added flexibility for check-in times.",
|
||||
})}
|
||||
</Body>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "What you have to do to guarantee booking:",
|
||||
})}
|
||||
</Body>
|
||||
<ol>
|
||||
<Body asChild>
|
||||
<li>{intl.formatMessage({ id: "Complete the booking" })}</li>
|
||||
</Body>
|
||||
<Body asChild>
|
||||
<li>
|
||||
{intl.formatMessage({
|
||||
id: "Provide a payment card in the next step",
|
||||
})}
|
||||
</li>
|
||||
</Body>
|
||||
</ol>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "Please note that this is mandatory, and that your card will only be charged in the event of a no-show.",
|
||||
})}
|
||||
</Body>
|
||||
</section>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { detailsStorageName } from "@/stores/enter-details"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import { trackPaymentEvent } from "@/utils/tracking"
|
||||
import { convertObjToSearchParams } from "@/utils/url"
|
||||
|
||||
import type { PersistedState } from "@/types/stores/enter-details"
|
||||
|
||||
export default function PaymentCallback({
|
||||
returnUrl,
|
||||
searchObject,
|
||||
status,
|
||||
errorMessage,
|
||||
}: {
|
||||
returnUrl: string
|
||||
searchObject: URLSearchParams
|
||||
status: "error" | "success" | "cancel"
|
||||
errorMessage?: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const bookingData = window.sessionStorage.getItem(detailsStorageName)
|
||||
|
||||
if (bookingData) {
|
||||
const detailsStorage: PersistedState = JSON.parse(bookingData)
|
||||
const searchParams = convertObjToSearchParams(
|
||||
detailsStorage.booking,
|
||||
searchObject
|
||||
)
|
||||
|
||||
if (status === "cancel") {
|
||||
trackPaymentEvent({
|
||||
event: "paymentCancel",
|
||||
hotelId: detailsStorage.booking.hotelId,
|
||||
status: "cancelled",
|
||||
})
|
||||
}
|
||||
if (status === "error") {
|
||||
trackPaymentEvent({
|
||||
event: "paymentFail",
|
||||
hotelId: detailsStorage.booking.hotelId,
|
||||
errorMessage,
|
||||
status: "failed",
|
||||
})
|
||||
}
|
||||
|
||||
if (searchParams.size > 0) {
|
||||
router.replace(`${returnUrl}?${searchParams.toString()}`)
|
||||
}
|
||||
}
|
||||
}, [returnUrl, router, searchObject, status, errorMessage])
|
||||
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import {
|
||||
BookingStatusEnum,
|
||||
PAYMENT_METHOD_TITLES,
|
||||
PaymentMethodEnum,
|
||||
} from "@/constants/booking"
|
||||
import {
|
||||
bookingTermsAndConditions,
|
||||
privacyPolicy,
|
||||
} from "@/constants/currentWebHrefs"
|
||||
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||
import { env } from "@/env/client"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions"
|
||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||
import { usePaymentFailedToast } from "@/hooks/booking/usePaymentFailedToast"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { trackPaymentEvent } from "@/utils/tracking"
|
||||
|
||||
import { bedTypeMap } from "../../utils"
|
||||
import PriceChangeDialog from "../PriceChangeDialog"
|
||||
import GuaranteeDetails from "./GuaranteeDetails"
|
||||
import PaymentOption from "./PaymentOption"
|
||||
import { type PaymentFormData, paymentSchema } from "./schema"
|
||||
|
||||
import styles from "./payment.module.css"
|
||||
|
||||
import type { PaymentClientProps } from "@/types/components/hotelReservation/enterDetails/payment"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
|
||||
const maxRetries = 15
|
||||
const retryInterval = 2000
|
||||
|
||||
export const formId = "submit-booking"
|
||||
|
||||
function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
|
||||
return Object.values(PaymentMethodEnum).includes(value as PaymentMethodEnum)
|
||||
}
|
||||
|
||||
export default function PaymentClient({
|
||||
user,
|
||||
otherPaymentOptions,
|
||||
savedCreditCards,
|
||||
mustBeGuaranteed,
|
||||
}: PaymentClientProps) {
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const { totalPrice, booking, rooms, bookingProgress } = useEnterDetailsStore(
|
||||
(state) => {
|
||||
return {
|
||||
totalPrice: state.totalPrice,
|
||||
booking: state.booking,
|
||||
rooms: state.rooms,
|
||||
bookingProgress: state.bookingProgress,
|
||||
}
|
||||
}
|
||||
)
|
||||
const canProceedToPayment = bookingProgress.canProceedToPayment
|
||||
|
||||
const setIsSubmittingDisabled = useEnterDetailsStore(
|
||||
(state) => state.actions.setIsSubmittingDisabled
|
||||
)
|
||||
|
||||
const [bookingNumber, setBookingNumber] = useState<string>("")
|
||||
const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
|
||||
useState(false)
|
||||
|
||||
const availablePaymentOptions =
|
||||
useAvailablePaymentOptions(otherPaymentOptions)
|
||||
const [priceChangeData, setPriceChangeData] = useState<{
|
||||
oldPrice: number
|
||||
newPrice: number
|
||||
} | null>()
|
||||
|
||||
const { toDate, fromDate, hotelId } = booking
|
||||
|
||||
usePaymentFailedToast()
|
||||
|
||||
const methods = useForm<PaymentFormData>({
|
||||
defaultValues: {
|
||||
paymentMethod: savedCreditCards?.length
|
||||
? savedCreditCards[0].id
|
||||
: PaymentMethodEnum.card,
|
||||
smsConfirmation: false,
|
||||
termsAndConditions: false,
|
||||
},
|
||||
mode: "all",
|
||||
reValidateMode: "onChange",
|
||||
resolver: zodResolver(paymentSchema),
|
||||
})
|
||||
|
||||
const initiateBooking = trpc.booking.create.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result) {
|
||||
setBookingNumber(result.id)
|
||||
|
||||
const priceChange = result.rooms.find(
|
||||
(r) => r.priceChangedMetadata
|
||||
)?.priceChangedMetadata
|
||||
|
||||
if (priceChange) {
|
||||
setPriceChangeData({
|
||||
oldPrice: rooms[0].roomPrice.perStay.local.price,
|
||||
newPrice: priceChange.totalPrice,
|
||||
})
|
||||
} else {
|
||||
setIsPollingForBookingStatus(true)
|
||||
}
|
||||
} else {
|
||||
handlePaymentError("No confirmation number")
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error", error)
|
||||
handlePaymentError(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const priceChange = trpc.booking.priceChange.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result?.id) {
|
||||
setIsPollingForBookingStatus(true)
|
||||
} else {
|
||||
handlePaymentError("No confirmation number")
|
||||
}
|
||||
|
||||
setPriceChangeData(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error", error)
|
||||
setPriceChangeData(null)
|
||||
handlePaymentError(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const bookingStatus = useHandleBookingStatus({
|
||||
confirmationNumber: bookingNumber,
|
||||
expectedStatus: BookingStatusEnum.BookingCompleted,
|
||||
maxRetries,
|
||||
retryInterval,
|
||||
enabled: isPollingForBookingStatus,
|
||||
})
|
||||
|
||||
const handlePaymentError = useCallback(
|
||||
(errorMessage: string) => {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "We had an issue processing your booking. Please try again. No charges have been made.",
|
||||
})
|
||||
)
|
||||
const currentPaymentMethod = methods.getValues("paymentMethod")
|
||||
const smsEnable = methods.getValues("smsConfirmation")
|
||||
const isSavedCreditCard = savedCreditCards?.some(
|
||||
(card) => card.id === currentPaymentMethod
|
||||
)
|
||||
|
||||
trackPaymentEvent({
|
||||
event: "paymentFail",
|
||||
hotelId,
|
||||
method: currentPaymentMethod,
|
||||
isSavedCreditCard,
|
||||
smsEnable,
|
||||
errorMessage,
|
||||
status: "failed",
|
||||
})
|
||||
},
|
||||
[intl, methods, savedCreditCards, hotelId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingStatus?.data?.paymentUrl) {
|
||||
router.push(bookingStatus.data.paymentUrl)
|
||||
} else if (bookingStatus.isTimeout) {
|
||||
handlePaymentError("Timeout")
|
||||
}
|
||||
}, [bookingStatus, router, intl, handlePaymentError])
|
||||
|
||||
useEffect(() => {
|
||||
setIsSubmittingDisabled(
|
||||
!methods.formState.isValid || methods.formState.isSubmitting
|
||||
)
|
||||
}, [
|
||||
methods.formState.isValid,
|
||||
methods.formState.isSubmitting,
|
||||
setIsSubmittingDisabled,
|
||||
])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(data: PaymentFormData) => {
|
||||
// set payment method to card if saved card is submitted
|
||||
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
|
||||
? data.paymentMethod
|
||||
: PaymentMethodEnum.card
|
||||
|
||||
const savedCreditCard = savedCreditCards?.find(
|
||||
(card) => card.id === data.paymentMethod
|
||||
)
|
||||
|
||||
const paymentRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback`
|
||||
|
||||
trackPaymentEvent({
|
||||
event: "paymentAttemptStart",
|
||||
hotelId,
|
||||
method: paymentMethod,
|
||||
isSavedCreditCard: !!savedCreditCard,
|
||||
smsEnable: data.smsConfirmation,
|
||||
status: "attempt",
|
||||
})
|
||||
|
||||
initiateBooking.mutate({
|
||||
language: lang,
|
||||
hotelId,
|
||||
checkInDate: fromDate,
|
||||
checkOutDate: toDate,
|
||||
rooms: rooms.map((room, idx) => ({
|
||||
adults: room.adults,
|
||||
childrenAges: room.childrenInRoom?.map((child) => ({
|
||||
age: child.age,
|
||||
bedType: bedTypeMap[parseInt(child.bed.toString())],
|
||||
})),
|
||||
rateCode:
|
||||
(user || room.guest.join || room.guest.membershipNo) &&
|
||||
booking.rooms[idx].counterRateCode
|
||||
? booking.rooms[idx].counterRateCode
|
||||
: booking.rooms[idx].rateCode,
|
||||
roomTypeCode: room.bedType!.roomTypeCode, // A selection has been made in order to get to this step.
|
||||
guest: {
|
||||
firstName: room.guest.firstName,
|
||||
lastName: room.guest.lastName,
|
||||
email: room.guest.email,
|
||||
phoneNumber: room.guest.phoneNumber,
|
||||
countryCode: room.guest.countryCode,
|
||||
membershipNumber: room.guest.membershipNo,
|
||||
becomeMember: room.guest.join,
|
||||
dateOfBirth: room.guest.dateOfBirth,
|
||||
postalCode: room.guest.zipCode,
|
||||
},
|
||||
packages: {
|
||||
breakfast: !!(room.breakfast && room.breakfast.code),
|
||||
allergyFriendly:
|
||||
room.roomFeatures?.some(
|
||||
(feature) => feature.code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||
) ?? false,
|
||||
petFriendly:
|
||||
room.roomFeatures?.some(
|
||||
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||
) ?? false,
|
||||
accessibility:
|
||||
room.roomFeatures?.some(
|
||||
(feature) =>
|
||||
feature.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
|
||||
) ?? false,
|
||||
},
|
||||
smsConfirmationRequested: data.smsConfirmation,
|
||||
roomPrice: {
|
||||
memberPrice: room.roomRate.memberRate?.localPrice.pricePerStay,
|
||||
publicPrice: room.roomRate.publicRate.localPrice.pricePerStay,
|
||||
},
|
||||
})),
|
||||
payment: {
|
||||
paymentMethod,
|
||||
card: savedCreditCard
|
||||
? {
|
||||
alias: savedCreditCard.alias,
|
||||
expiryDate: savedCreditCard.expirationDate,
|
||||
cardType: savedCreditCard.cardType,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
success: `${paymentRedirectUrl}/success`,
|
||||
error: `${paymentRedirectUrl}/error`,
|
||||
cancel: `${paymentRedirectUrl}/cancel`,
|
||||
},
|
||||
})
|
||||
},
|
||||
[
|
||||
savedCreditCards,
|
||||
lang,
|
||||
initiateBooking,
|
||||
hotelId,
|
||||
fromDate,
|
||||
toDate,
|
||||
rooms,
|
||||
user,
|
||||
booking,
|
||||
]
|
||||
)
|
||||
|
||||
if (
|
||||
initiateBooking.isPending ||
|
||||
(isPollingForBookingStatus &&
|
||||
!bookingStatus.data?.paymentUrl &&
|
||||
!bookingStatus.isTimeout)
|
||||
) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
|
||||
const paymentGuarantee = intl.formatMessage({
|
||||
id: "Payment Guarantee",
|
||||
})
|
||||
const payment = intl.formatMessage({
|
||||
id: "Payment",
|
||||
})
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`${styles.paymentSection} ${canProceedToPayment ? "" : styles.disabled}`}
|
||||
>
|
||||
<header>
|
||||
<Title level="h2" as="h4">
|
||||
{mustBeGuaranteed ? paymentGuarantee : payment}
|
||||
</Title>
|
||||
</header>
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.paymentContainer}
|
||||
onSubmit={methods.handleSubmit(handleSubmit)}
|
||||
id={formId}
|
||||
>
|
||||
{mustBeGuaranteed ? (
|
||||
<section className={styles.section}>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.",
|
||||
})}
|
||||
</Body>
|
||||
<GuaranteeDetails />
|
||||
</section>
|
||||
) : null}
|
||||
{savedCreditCards?.length ? (
|
||||
<section className={styles.section}>
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{intl.formatMessage({ id: "MY SAVED CARDS" })}
|
||||
</Body>
|
||||
<div className={styles.paymentOptionContainer}>
|
||||
{savedCreditCards?.map((savedCreditCard) => (
|
||||
<PaymentOption
|
||||
key={savedCreditCard.id}
|
||||
name="paymentMethod"
|
||||
value={savedCreditCard.id}
|
||||
label={
|
||||
PAYMENT_METHOD_TITLES[
|
||||
savedCreditCard.cardType as PaymentMethodEnum
|
||||
]
|
||||
}
|
||||
cardNumber={savedCreditCard.truncatedNumber}
|
||||
hotelId={hotelId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<section className={styles.section}>
|
||||
{savedCreditCards?.length ? (
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{intl.formatMessage({ id: "OTHER PAYMENT METHODS" })}
|
||||
</Body>
|
||||
) : null}
|
||||
<div className={styles.paymentOptionContainer}>
|
||||
<PaymentOption
|
||||
name="paymentMethod"
|
||||
value={PaymentMethodEnum.card}
|
||||
label={intl.formatMessage({ id: "Credit card" })}
|
||||
hotelId={hotelId}
|
||||
/>
|
||||
{availablePaymentOptions.map((paymentMethod) => (
|
||||
<PaymentOption
|
||||
key={paymentMethod}
|
||||
name="paymentMethod"
|
||||
value={paymentMethod}
|
||||
label={
|
||||
PAYMENT_METHOD_TITLES[paymentMethod as PaymentMethodEnum]
|
||||
}
|
||||
hotelId={hotelId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.section}>
|
||||
<Caption>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "By paying with any of the payment methods available, I accept the terms for this booking and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.",
|
||||
},
|
||||
{
|
||||
termsAndConditionsLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
variant="underscored"
|
||||
href={bookingTermsAndConditions[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
privacyPolicyLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
variant="underscored"
|
||||
href={privacyPolicy[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
<Checkbox name="termsAndConditions">
|
||||
<Caption>
|
||||
{intl.formatMessage({
|
||||
id: "I accept the terms and conditions",
|
||||
})}
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
<Checkbox name="smsConfirmation">
|
||||
<Caption>
|
||||
{intl.formatMessage({
|
||||
id: "I would like to get my booking confirmation via sms",
|
||||
})}
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
</section>
|
||||
<div className={styles.submitButton}>
|
||||
<Button
|
||||
intent="primary"
|
||||
theme="base"
|
||||
size="small"
|
||||
type="submit"
|
||||
disabled={
|
||||
!methods.formState.isValid || methods.formState.isSubmitting
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({ id: "Complete booking" })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
{priceChangeData ? (
|
||||
<PriceChangeDialog
|
||||
isOpen={!!priceChangeData}
|
||||
oldPrice={priceChangeData.oldPrice}
|
||||
newPrice={priceChangeData.newPrice}
|
||||
currency={totalPrice.local.currency}
|
||||
onCancel={() => {
|
||||
const allSearchParams = searchParams.size
|
||||
? `?${searchParams.toString()}`
|
||||
: ""
|
||||
router.push(`${selectRate(lang)}${allSearchParams}`)
|
||||
}}
|
||||
onAccept={() =>
|
||||
priceChange.mutate({ confirmationNumber: bookingNumber })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Image from "next/image"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
import {
|
||||
PAYMENT_METHOD_ICONS,
|
||||
type PaymentMethodEnum,
|
||||
} from "@/constants/booking"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { trackUpdatePaymentMethod } from "@/utils/tracking"
|
||||
|
||||
import styles from "./paymentOption.module.css"
|
||||
|
||||
import type { PaymentOptionProps } from "./paymentOption"
|
||||
|
||||
export default function PaymentOption({
|
||||
name,
|
||||
value,
|
||||
label,
|
||||
cardNumber,
|
||||
registerOptions = {},
|
||||
}: PaymentOptionProps) {
|
||||
const { register } = useFormContext()
|
||||
|
||||
return (
|
||||
<label key={value} className={styles.paymentOption}>
|
||||
<div className={styles.titleContainer}>
|
||||
<input
|
||||
aria-hidden
|
||||
hidden
|
||||
type="radio"
|
||||
id={value}
|
||||
value={value}
|
||||
onClick={() => trackUpdatePaymentMethod("", value)}
|
||||
{...register(name, registerOptions)}
|
||||
/>
|
||||
<span className={styles.radio} />
|
||||
<Body>{label}</Body>
|
||||
</div>
|
||||
{cardNumber ? (
|
||||
<Caption color="uiTextMediumContrast">•••• {cardNumber}</Caption>
|
||||
) : (
|
||||
<Image
|
||||
className={styles.paymentOptionIcon}
|
||||
src={PAYMENT_METHOD_ICONS[value as PaymentMethodEnum]}
|
||||
alt={label}
|
||||
width={48}
|
||||
height={32}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
.paymentOption {
|
||||
position: relative;
|
||||
background-color: var(--UI-Input-Controls-Surface-Normal);
|
||||
padding: var(--Spacing-x3);
|
||||
border: 1px solid var(--Base-Border-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--Spacing-x2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.paymentOption .radio {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--Base-Border-Normal);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.paymentOption input:checked + .radio {
|
||||
border: 8px solid var(--UI-Input-Controls-Fill-Selected);
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.paymentOptionIcon {
|
||||
position: absolute;
|
||||
right: var(--Spacing-x3);
|
||||
top: calc(50% - 16px);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { RegisterOptions } from "react-hook-form"
|
||||
|
||||
export interface PaymentOptionProps {
|
||||
name: string
|
||||
value: string
|
||||
label: string
|
||||
cardNumber?: string
|
||||
registerOptions?: RegisterOptions
|
||||
onChange?: () => void
|
||||
hotelId: string
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { getSavedPaymentCardsSafely } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import PaymentClient from "./PaymentClient"
|
||||
|
||||
import type { PaymentProps } from "@/types/components/hotelReservation/enterDetails/payment"
|
||||
|
||||
export default async function Payment({
|
||||
user,
|
||||
otherPaymentOptions,
|
||||
mustBeGuaranteed,
|
||||
supportedCards,
|
||||
}: PaymentProps) {
|
||||
const savedCreditCards = await getSavedPaymentCardsSafely({
|
||||
supportedCards,
|
||||
})
|
||||
|
||||
return (
|
||||
<PaymentClient
|
||||
user={user}
|
||||
otherPaymentOptions={otherPaymentOptions}
|
||||
savedCreditCards={savedCreditCards}
|
||||
mustBeGuaranteed={mustBeGuaranteed}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
.paymentSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.paymentContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x4);
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.paymentOptionContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.paymentContainer .link {
|
||||
font-weight: 500;
|
||||
font-size: var(--Typography-Caption-Regular-fontSize);
|
||||
}
|
||||
|
||||
.terms {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.submitButton {
|
||||
display: flex;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const paymentSchema = z.object({
|
||||
paymentMethod: z.string(),
|
||||
smsConfirmation: z.boolean(),
|
||||
termsAndConditions: z.boolean().refine((value) => value === true, {
|
||||
message: "You must accept the terms and conditions",
|
||||
}),
|
||||
})
|
||||
|
||||
export interface PaymentFormData extends z.output<typeof paymentSchema> {}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { InfoCircleIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./priceChangeDialog.module.css"
|
||||
|
||||
import type { PriceChangeDialogProps } from "@/types/components/hotelReservation/enterDetails/priceChangeDialog"
|
||||
|
||||
export default function PriceChangeDialog({
|
||||
isOpen,
|
||||
oldPrice,
|
||||
newPrice,
|
||||
currency,
|
||||
onCancel,
|
||||
onAccept,
|
||||
}: PriceChangeDialogProps) {
|
||||
const intl = useIntl()
|
||||
const title = intl.formatMessage({ id: "The price has increased" })
|
||||
|
||||
return (
|
||||
<ModalOverlay
|
||||
className={styles.overlay}
|
||||
isOpen={isOpen}
|
||||
isKeyboardDismissDisabled
|
||||
>
|
||||
<Modal className={styles.modal}>
|
||||
<Dialog aria-label={title} className={styles.dialog}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.titleContainer}>
|
||||
<InfoCircleIcon height={48} width={48} color="burgundy" />
|
||||
<Title
|
||||
level="h1"
|
||||
as="h3"
|
||||
textAlign="center"
|
||||
textTransform="regular"
|
||||
>
|
||||
{title}
|
||||
</Title>
|
||||
</div>
|
||||
<Body textAlign="center">
|
||||
{intl.formatMessage({
|
||||
id: "The price has increased since you selected your room.",
|
||||
})}
|
||||
<br />
|
||||
{intl.formatMessage({
|
||||
id: "You can still book the room but you need to confirm that you accept the new price",
|
||||
})}
|
||||
<br />
|
||||
<span className={styles.oldPrice}>
|
||||
{formatPrice(intl, oldPrice, currency)}
|
||||
</span>
|
||||
<strong className={styles.newPrice}>
|
||||
{formatPrice(intl, newPrice, currency)}
|
||||
</strong>
|
||||
</Body>
|
||||
</header>
|
||||
<footer className={styles.footer}>
|
||||
<Button intent="secondary" onClick={onCancel}>
|
||||
{intl.formatMessage({ id: "Cancel" })}
|
||||
</Button>
|
||||
<Button onClick={onAccept}>
|
||||
{intl.formatMessage({ id: "Accept new price" })}
|
||||
</Button>
|
||||
</footer>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
@keyframes modal-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
height: var(--visual-viewport-height);
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
z-index: 100;
|
||||
|
||||
&[data-entering] {
|
||||
animation: modal-fade 200ms;
|
||||
}
|
||||
|
||||
&[data-exiting] {
|
||||
animation: modal-fade 150ms reverse ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
&[data-entering] {
|
||||
animation: slide-up 200ms;
|
||||
}
|
||||
&[data-exiting] {
|
||||
animation: slide-up 200ms reverse ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: var(--Scandic-Brand-Pale-Peach);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
box-shadow: var(--modal-box-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x5) var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.oldPrice {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.newPrice {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import {
|
||||
selectBookingProgress,
|
||||
selectRoom,
|
||||
selectRoomStatus,
|
||||
} from "@/stores/enter-details/helpers"
|
||||
|
||||
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useStickyPosition from "@/hooks/useStickyPosition"
|
||||
|
||||
import styles from "./sectionAccordion.module.css"
|
||||
|
||||
import type { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
|
||||
export default function SectionAccordion({
|
||||
children,
|
||||
header,
|
||||
label,
|
||||
step,
|
||||
roomIndex,
|
||||
}: React.PropsWithChildren<SectionAccordionProps>) {
|
||||
const intl = useIntl()
|
||||
const roomStatus = useEnterDetailsStore((state) =>
|
||||
selectRoomStatus(state, roomIndex)
|
||||
)
|
||||
|
||||
const stickyPosition = useStickyPosition({})
|
||||
const setStep = useEnterDetailsStore((state) => state.actions.setStep)
|
||||
const { bedType, breakfast } = useEnterDetailsStore((state) =>
|
||||
selectRoom(state, roomIndex)
|
||||
)
|
||||
const { roomStatuses, currentRoomIndex } = useEnterDetailsStore((state) =>
|
||||
selectBookingProgress(state)
|
||||
)
|
||||
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const isValid = roomStatus.steps[step]?.isValid ?? false
|
||||
|
||||
const [title, setTitle] = useState(label)
|
||||
|
||||
const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" })
|
||||
const breakfastTitle = intl.formatMessage({ id: "Breakfast buffet" })
|
||||
|
||||
// useScrollToActiveSection(step, steps, roomStatus.currentStep === step)
|
||||
|
||||
useEffect(() => {
|
||||
if (step === StepEnum.selectBed && bedType) {
|
||||
setTitle(bedType.description)
|
||||
}
|
||||
// If breakfast step, check if an option has been selected
|
||||
if (step === StepEnum.breakfast && breakfast !== undefined) {
|
||||
if (breakfast === false) {
|
||||
setTitle(noBreakfastTitle)
|
||||
} else {
|
||||
setTitle(breakfastTitle)
|
||||
}
|
||||
}
|
||||
}, [bedType, breakfast, setTitle, step, breakfastTitle, noBreakfastTitle])
|
||||
|
||||
useEffect(() => {
|
||||
setIsComplete(isValid)
|
||||
}, [isValid, setIsComplete])
|
||||
|
||||
const accordionRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const shouldBeOpen =
|
||||
roomStatus.currentStep === step && currentRoomIndex === roomIndex
|
||||
|
||||
setIsOpen(shouldBeOpen)
|
||||
|
||||
// Scroll to this section when it is opened, but wait for the accordion animations to
|
||||
// finish, else the height calculations will not be correct and the scroll position
|
||||
// will be off.
|
||||
if (shouldBeOpen) {
|
||||
setTimeout(() => {
|
||||
if (accordionRef.current) {
|
||||
window.scrollTo({
|
||||
top: accordionRef.current.offsetTop - stickyPosition.getTopOffset(),
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentRoomIndex, roomIndex, roomStatus.currentStep, setIsOpen, step])
|
||||
|
||||
function onModify() {
|
||||
setStep(step, roomIndex)
|
||||
}
|
||||
|
||||
function close() {
|
||||
setIsOpen(false)
|
||||
|
||||
const nextRoom = roomStatuses.find((room) => !room.isComplete)
|
||||
const nextStep = nextRoom
|
||||
? Object.values(nextRoom.steps).find((step) => !step.isValid)?.step
|
||||
: null
|
||||
|
||||
if (nextRoom !== undefined && nextStep !== undefined) {
|
||||
setStep(nextStep, roomStatuses.indexOf(nextRoom))
|
||||
} else {
|
||||
// Time for payment, collapse any open step
|
||||
setStep(null)
|
||||
}
|
||||
}
|
||||
|
||||
const textColor =
|
||||
isComplete || isOpen ? "uiTextHighContrast" : "baseTextDisabled"
|
||||
return (
|
||||
<div
|
||||
className={styles.accordion}
|
||||
data-section-open={isOpen}
|
||||
data-step={step}
|
||||
ref={accordionRef}
|
||||
>
|
||||
<div className={styles.iconWrapper}>
|
||||
<div className={styles.circle} data-checked={isComplete}>
|
||||
{isComplete ? (
|
||||
<CheckIcon color="white" height="16" width="16" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<header className={styles.header}>
|
||||
<button
|
||||
onClick={isOpen ? close : onModify}
|
||||
disabled={!isComplete}
|
||||
className={styles.modifyButton}
|
||||
>
|
||||
<Footnote
|
||||
className={styles.title}
|
||||
asChild
|
||||
textTransform="uppercase"
|
||||
type="label"
|
||||
color={textColor}
|
||||
>
|
||||
<h2>{header}</h2>
|
||||
</Footnote>
|
||||
<Subtitle className={styles.selection} type="two" color={textColor}>
|
||||
{title}
|
||||
</Subtitle>
|
||||
{isComplete && (
|
||||
<ChevronDownIcon
|
||||
className={`${styles.button} ${isOpen ? styles.buttonOpen : ""}`}
|
||||
color="burgundy"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</header>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.contentWrapper}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
.accordion {
|
||||
--header-height: 2.4em;
|
||||
--circle-height: 24px;
|
||||
|
||||
gap: var(--Spacing-x3);
|
||||
width: 100%;
|
||||
padding-top: var(--Spacing-x3);
|
||||
transition: 0.3s ease-out;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas: "circle header" "content content";
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: var(--header-height) 0fr;
|
||||
|
||||
column-gap: var(--Spacing-x-one-and-half);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.header {
|
||||
grid-area: header;
|
||||
}
|
||||
|
||||
.modifyButton {
|
||||
display: grid;
|
||||
grid-template-areas: "title button" "selection button";
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modifyButton:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.button {
|
||||
grid-area: button;
|
||||
justify-self: flex-end;
|
||||
transform-origin: 50% 50%;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.buttonOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.selection {
|
||||
grid-area: selection;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
position: relative;
|
||||
grid-area: circle;
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: var(--circle-height);
|
||||
height: var(--circle-height);
|
||||
border-radius: 100px;
|
||||
transition: background-color 0.4s;
|
||||
border: 2px solid var(--Base-Border-Inverted);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.circle[data-checked="true"] {
|
||||
background-color: var(--UI-Input-Controls-Fill-Selected);
|
||||
}
|
||||
|
||||
.accordion[data-section-open="true"] .circle[data-checked="false"] {
|
||||
background-color: var(--UI-Text-Placeholder);
|
||||
}
|
||||
|
||||
.accordion[data-section-open="false"] .circle[data-checked="false"] {
|
||||
background-color: var(--Base-Surface-Subtle-Hover);
|
||||
}
|
||||
|
||||
.accordion[data-section-open="true"] {
|
||||
grid-template-rows: var(--header-height) 1fr;
|
||||
}
|
||||
|
||||
.contentWrapper {
|
||||
opacity: 0;
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.accordion[data-section-open="true"] .contentWrapper {
|
||||
opacity: 1;
|
||||
}
|
||||
.content {
|
||||
overflow: hidden;
|
||||
grid-area: content;
|
||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
transform-origin: top;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
|
||||
.accordion[data-section-open="true"] .content {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.accordion {
|
||||
column-gap: var(--Spacing-x3);
|
||||
grid-template-areas: "circle header" "circle content";
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.accordion:not(:last-child) .iconWrapper::after {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
bottom: calc(0px - var(--Spacing-x5));
|
||||
top: var(--circle-height);
|
||||
|
||||
content: "";
|
||||
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import ChevronRight from "@/components/Icons/ChevronRight"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import type { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||
|
||||
export default function ToggleSidePeek({
|
||||
hotelId,
|
||||
roomTypeCode,
|
||||
intent = "textInverted",
|
||||
title,
|
||||
}: ToggleSidePeekProps) {
|
||||
const intl = useIntl()
|
||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
openSidePeek({ key: SidePeekEnum.roomDetails, hotelId, roomTypeCode })
|
||||
}
|
||||
theme="base"
|
||||
size="small"
|
||||
variant="icon"
|
||||
intent={intent}
|
||||
wrapping
|
||||
>
|
||||
{title ? title : intl.formatMessage({ id: "See room details" })}
|
||||
<ChevronRight height="14" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTransition } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
|
||||
|
||||
import { CheckIcon, EditIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import ToggleSidePeek from "./ToggleSidePeek"
|
||||
|
||||
import styles from "./selectedRoom.module.css"
|
||||
|
||||
import type { SelectedRoomProps } from "@/types/components/hotelReservation/enterDetails/room"
|
||||
|
||||
export default function SelectedRoom({
|
||||
hotelId,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
rateDescription,
|
||||
roomIndex,
|
||||
searchParamsStr,
|
||||
}: SelectedRoomProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const { modifyRate } = useRateSelectionStore()
|
||||
|
||||
function changeRoom() {
|
||||
modifyRate(roomIndex)
|
||||
startTransition(() => {
|
||||
router.push(`${selectRate(lang)}?${searchParamsStr}`)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.iconWrapper}>
|
||||
<div className={styles.circle}>
|
||||
<CheckIcon color="white" height="16" width="16" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.main}>
|
||||
<div className={styles.headerContainer}>
|
||||
<Footnote
|
||||
className={styles.title}
|
||||
asChild
|
||||
textTransform="uppercase"
|
||||
type="label"
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
<h2>{intl.formatMessage({ id: "Your room" })}</h2>
|
||||
</Footnote>
|
||||
<Subtitle
|
||||
type="two"
|
||||
className={styles.description}
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{intl.formatMessage(
|
||||
{ id: "{roomType} <rate>{rateDescription}</rate>" },
|
||||
{
|
||||
roomType: roomType,
|
||||
rateDescription,
|
||||
rate: (str) => {
|
||||
return <span className={styles.rate}>{str}</span>
|
||||
},
|
||||
}
|
||||
)}
|
||||
</Subtitle>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="small"
|
||||
color="burgundy"
|
||||
onClick={changeRoom}
|
||||
disabled={isPending}
|
||||
>
|
||||
<EditIcon color="burgundy" />
|
||||
{intl.formatMessage({ id: "Change room" })}
|
||||
</Button>
|
||||
</div>
|
||||
{roomTypeCode && (
|
||||
<div className={styles.details}>
|
||||
<ToggleSidePeek hotelId={hotelId} roomTypeCode={roomTypeCode} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.main {
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.headerContainer {
|
||||
display: grid;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
grid-template-areas:
|
||||
"title title"
|
||||
"description button";
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.description {
|
||||
grid-area: description;
|
||||
}
|
||||
|
||||
.button {
|
||||
grid-area: button;
|
||||
justify-self: flex-end;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 100px;
|
||||
border: 2px solid var(--Base-Border-Inverted);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.circle {
|
||||
background-color: var(--UI-Input-Controls-Fill-Selected);
|
||||
}
|
||||
|
||||
.rate {
|
||||
color: var(--UI-Text-Placeholder);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.wrapper {
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.rate {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.rate::before {
|
||||
content: "(";
|
||||
}
|
||||
.rate::after {
|
||||
content: ")";
|
||||
}
|
||||
|
||||
.wrapper:not(:last-child)::after {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
bottom: 0;
|
||||
top: var(--Spacing-x7);
|
||||
height: 100%;
|
||||
content: "";
|
||||
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { hotelreservation } from "@/constants/routes/hotelReservation"
|
||||
import { detailsStorageName } from "@/stores/enter-details"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
/**
|
||||
* Cleanup component to make sure no stale data is left
|
||||
* from previous booking when user is not in the booking
|
||||
* flow anymore
|
||||
*/
|
||||
export default function StorageCleaner() {
|
||||
const lang = useLang()
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname.startsWith(hotelreservation(lang))) {
|
||||
sessionStorage.removeItem(detailsStorageName)
|
||||
}
|
||||
}, [lang, pathname])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import SidePanel from "@/components/HotelReservation/SidePanel"
|
||||
|
||||
import SummaryUI from "./UI"
|
||||
|
||||
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
|
||||
export default function DesktopSummary(props: SummaryProps) {
|
||||
const {
|
||||
booking,
|
||||
actions: { toggleSummaryOpen },
|
||||
totalPrice,
|
||||
vat,
|
||||
} = useEnterDetailsStore((state) => state)
|
||||
|
||||
const rooms = useEnterDetailsStore((state) => state.rooms)
|
||||
|
||||
return (
|
||||
<SidePanel variant="summary">
|
||||
<SummaryUI
|
||||
booking={booking}
|
||||
rooms={rooms}
|
||||
isMember={props.isMember}
|
||||
breakfastIncluded={props.breakfastIncluded}
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
toggleSummaryOpen={toggleSummaryOpen}
|
||||
/>
|
||||
</SidePanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr 7.5em;
|
||||
|
||||
transition: 0.5s ease-in-out;
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
align-content: end;
|
||||
}
|
||||
|
||||
.bottomSheet {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
padding: var(--Spacing-x2) 0 var(--Spacing-x5);
|
||||
align-items: flex-start;
|
||||
transition: 0.5s ease-in-out;
|
||||
max-width: var(--max-width-page);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.priceDetailsButton {
|
||||
display: block;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: start;
|
||||
transition: padding 0.5s ease-in-out;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] {
|
||||
grid-template-rows: 1fr 7.5em;
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] .bottomSheet {
|
||||
grid-template-columns: 0fr auto;
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] .priceDetailsButton {
|
||||
animation: fadeOut 0.3s ease-out;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wrapper[data-open="false"] .priceDetailsButton {
|
||||
animation: fadeIn 0.8s ease-in;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.priceDetailsButton {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-height: 50dvh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.bottomSheet {
|
||||
padding: var(--Spacing-x2) 0 var(--Spacing-x7);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { type PropsWithChildren, useEffect, useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { formId } from "@/components/HotelReservation/EnterDetails/Payment/PaymentClient"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./bottomSheet.module.css"
|
||||
|
||||
export default function SummaryBottomSheet({ children }: PropsWithChildren) {
|
||||
const intl = useIntl()
|
||||
const scrollY = useRef(0)
|
||||
|
||||
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } =
|
||||
useEnterDetailsStore((state) => ({
|
||||
isSummaryOpen: state.isSummaryOpen,
|
||||
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||
totalPrice: state.totalPrice,
|
||||
isSubmittingDisabled: state.isSubmittingDisabled,
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
if (isSummaryOpen) {
|
||||
scrollY.current = window.scrollY
|
||||
document.body.style.position = "fixed"
|
||||
document.body.style.top = `-${scrollY.current}px`
|
||||
} else {
|
||||
document.body.style.position = ""
|
||||
document.body.style.top = ""
|
||||
window.scrollTo({
|
||||
top: scrollY.current,
|
||||
left: 0,
|
||||
behavior: "instant",
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.position = ""
|
||||
document.body.style.top = ""
|
||||
}
|
||||
}, [isSummaryOpen])
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} data-open={isSummaryOpen}>
|
||||
<div className={styles.content}>{children}</div>
|
||||
<div className={styles.bottomSheet}>
|
||||
<button
|
||||
data-open={isSummaryOpen}
|
||||
onClick={toggleSummaryOpen}
|
||||
className={styles.priceDetailsButton}
|
||||
>
|
||||
<Caption>{intl.formatMessage({ id: "Total price" })}</Caption>
|
||||
<Subtitle>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPrice.local.price,
|
||||
totalPrice.local.currency
|
||||
)}
|
||||
</Subtitle>
|
||||
<Caption color="baseTextHighContrast" type="underline">
|
||||
{intl.formatMessage({ id: "See details" })}
|
||||
</Caption>
|
||||
</button>
|
||||
<Button
|
||||
intent="primary"
|
||||
theme="base"
|
||||
size="large"
|
||||
type="submit"
|
||||
disabled={isSubmittingDisabled}
|
||||
form={formId}
|
||||
>
|
||||
{intl.formatMessage({ id: "Complete booking" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import SignupPromoMobile from "@/components/HotelReservation/SignupPromo/Mobile"
|
||||
|
||||
import SummaryUI from "../UI"
|
||||
import SummaryBottomSheet from "./BottomSheet"
|
||||
|
||||
import styles from "./mobile.module.css"
|
||||
|
||||
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
|
||||
export default function MobileSummary(props: SummaryProps) {
|
||||
const {
|
||||
booking,
|
||||
actions: { toggleSummaryOpen },
|
||||
totalPrice,
|
||||
vat,
|
||||
} = useEnterDetailsStore((state) => state)
|
||||
|
||||
const rooms = useEnterDetailsStore((state) => state.rooms)
|
||||
|
||||
const showPromo =
|
||||
!props.isMember &&
|
||||
rooms.length === 1 &&
|
||||
!rooms[0].guest.join &&
|
||||
!rooms[0].guest.membershipNo
|
||||
|
||||
return (
|
||||
<div className={styles.mobileSummary}>
|
||||
{showPromo ? <SignupPromoMobile /> : null}
|
||||
<SummaryBottomSheet>
|
||||
<div className={styles.wrapper}>
|
||||
<SummaryUI
|
||||
booking={booking}
|
||||
rooms={rooms}
|
||||
isMember={props.isMember}
|
||||
breakfastIncluded={props.breakfastIncluded}
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
toggleSummaryOpen={toggleSummaryOpen}
|
||||
/>
|
||||
</div>
|
||||
</SummaryBottomSheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.mobileSummary {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
.wrapper {
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-bottom: none;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.mobileSummary {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
|
||||
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckIcon,
|
||||
ChevronDownSmallIcon,
|
||||
} from "@/components/Icons"
|
||||
import Modal from "@/components/Modal"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./ui.module.css"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import type { EnterDetailsSummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
|
||||
export default function SummaryUI({
|
||||
booking,
|
||||
rooms,
|
||||
totalPrice,
|
||||
isMember,
|
||||
breakfastIncluded,
|
||||
vat,
|
||||
toggleSummaryOpen,
|
||||
}: EnterDetailsSummaryProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const diff = dt(booking.toDate).diff(booking.fromDate, "days")
|
||||
|
||||
const nights = intl.formatMessage(
|
||||
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
|
||||
{ totalNights: diff }
|
||||
)
|
||||
|
||||
function handleToggleSummary() {
|
||||
if (toggleSummaryOpen) {
|
||||
toggleSummaryOpen()
|
||||
}
|
||||
}
|
||||
|
||||
function getMemberPrice(roomRate: RoomRate) {
|
||||
return roomRate?.memberRate
|
||||
? {
|
||||
currency: roomRate.memberRate.localPrice.currency,
|
||||
pricePerNight: roomRate.memberRate.localPrice.pricePerNight,
|
||||
amount: roomRate.memberRate.localPrice.pricePerStay,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
const showSignupPromo =
|
||||
rooms.length === 1 &&
|
||||
rooms
|
||||
.slice(0, 1)
|
||||
.some((r) => !isMember || !r.guest.join || !r.guest.membershipNo)
|
||||
|
||||
const memberPrice = getMemberPrice(rooms[0].roomRate)
|
||||
|
||||
return (
|
||||
<section className={styles.summary}>
|
||||
<header className={styles.header}>
|
||||
<Subtitle className={styles.title} type="two">
|
||||
{intl.formatMessage({ id: "Booking summary" })}
|
||||
</Subtitle>
|
||||
<Body className={styles.date} color="baseTextMediumContrast">
|
||||
{dt(booking.fromDate).locale(lang).format("ddd, D MMM")}
|
||||
<ArrowRightIcon color="peach80" height={15} width={15} />
|
||||
{dt(booking.toDate).locale(lang).format("ddd, D MMM")} ({nights})
|
||||
</Body>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
className={styles.chevronButton}
|
||||
onClick={handleToggleSummary}
|
||||
>
|
||||
<ChevronDownSmallIcon height="20" width="20" />
|
||||
</Button>
|
||||
</header>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
{rooms.map((room, idx) => {
|
||||
const roomNumber = idx + 1
|
||||
const adults = room.adults
|
||||
const childrenInRoom = room.childrenInRoom
|
||||
|
||||
const childrenBeds = childrenInRoom?.reduce(
|
||||
(acc, value) => {
|
||||
const bedType = Number(value.bed)
|
||||
if (bedType === ChildBedMapEnum.IN_ADULTS_BED) {
|
||||
return acc
|
||||
}
|
||||
const count = acc.get(bedType) ?? 0
|
||||
acc.set(bedType, count + 1)
|
||||
return acc
|
||||
},
|
||||
new Map<ChildBedMapEnum, number>([
|
||||
[ChildBedMapEnum.IN_CRIB, 0],
|
||||
[ChildBedMapEnum.IN_EXTRA_BED, 0],
|
||||
])
|
||||
)
|
||||
|
||||
const childBedCrib = childrenBeds?.get(ChildBedMapEnum.IN_CRIB)
|
||||
const childBedExtraBed = childrenBeds?.get(ChildBedMapEnum.IN_EXTRA_BED)
|
||||
|
||||
const memberPrice = getMemberPrice(room.roomRate)
|
||||
|
||||
const isFirstRoomMember = roomNumber === 1 && isMember
|
||||
const showMemberPrice =
|
||||
!!(isFirstRoomMember || room.guest.join || room.guest.membershipNo) &&
|
||||
memberPrice
|
||||
|
||||
const adultsMsg = intl.formatMessage(
|
||||
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
|
||||
{ totalAdults: adults }
|
||||
)
|
||||
|
||||
const guestsParts = [adultsMsg]
|
||||
if (childrenInRoom?.length) {
|
||||
const childrenMsg = intl.formatMessage(
|
||||
{
|
||||
id: "{totalChildren, plural, one {# child} other {# children}}",
|
||||
},
|
||||
{ totalChildren: childrenInRoom.length }
|
||||
)
|
||||
guestsParts.push(childrenMsg)
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<div
|
||||
className={styles.addOns}
|
||||
data-testid={`summary-room-${roomNumber}`}
|
||||
>
|
||||
<div>
|
||||
{rooms.length > 1 ? (
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Room" })} {roomNumber}
|
||||
</Body>
|
||||
) : null}
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">{room.roomType}</Body>
|
||||
<Body color={showMemberPrice ? "red" : "uiTextHighContrast"}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
room.roomPrice.perStay.local.price,
|
||||
room.roomPrice.perStay.local.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{guestsParts.join(", ")}
|
||||
</Caption>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{room.cancellationText}
|
||||
</Caption>
|
||||
<Modal
|
||||
trigger={
|
||||
<Button intent="text">
|
||||
<Caption color="burgundy" type="underline">
|
||||
{intl.formatMessage({ id: "Rate details" })}
|
||||
</Caption>
|
||||
</Button>
|
||||
}
|
||||
title={room.cancellationText}
|
||||
>
|
||||
<div className={styles.terms}>
|
||||
{room.rateDetails?.map((info) => (
|
||||
<Body
|
||||
key={info}
|
||||
color="uiTextHighContrast"
|
||||
className={styles.termsText}
|
||||
>
|
||||
<CheckIcon
|
||||
color="uiSemanticSuccess"
|
||||
width={20}
|
||||
height={20}
|
||||
className={styles.termsIcon}
|
||||
></CheckIcon>
|
||||
{info}
|
||||
</Body>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
{room.roomFeatures
|
||||
? room.roomFeatures.map((feature) => (
|
||||
<div className={styles.entry} key={feature.code}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{feature.description}
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
<Body color="uiTextHighContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
parseInt(feature.localPrice.price),
|
||||
feature.localPrice.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
{room.bedType ? (
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{room.bedType.description}
|
||||
</Body>
|
||||
|
||||
<Body color="uiTextHighContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
0,
|
||||
room.roomPrice.perStay.local.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
) : null}
|
||||
{childBedCrib ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Crib (child) × {count}" },
|
||||
{ count: childBedCrib }
|
||||
)}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Based on availability" })}
|
||||
</Caption>
|
||||
</div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
0,
|
||||
room.roomPrice.perStay.local.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
) : null}
|
||||
{childBedExtraBed ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Extra bed (child) × {count}" },
|
||||
{
|
||||
count: childBedExtraBed,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
0,
|
||||
room.roomPrice.perStay.local.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
) : null}
|
||||
{breakfastIncluded ? (
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast included" })}
|
||||
</Body>
|
||||
</div>
|
||||
) : room.breakfast === false ? (
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "No breakfast" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
0,
|
||||
room.roomPrice.perStay.local.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
) : null}
|
||||
{room.breakfast ? (
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast buffet" })}
|
||||
</Body>
|
||||
<div className={styles.entry}>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "{totalAdults, plural, one {# adult} other {# adults}}",
|
||||
},
|
||||
{ totalAdults: adults }
|
||||
)}
|
||||
</Caption>
|
||||
<Body color="uiTextHighContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
parseInt(room.breakfast.localPrice.totalPrice),
|
||||
room.breakfast.localPrice.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
{childrenInRoom?.length ? (
|
||||
<div className={styles.entry}>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "{totalChildren, plural, one {# child} other {# children}}",
|
||||
},
|
||||
{ totalChildren: childrenInRoom.length }
|
||||
)}
|
||||
</Caption>
|
||||
<Body color="uiTextHighContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
0,
|
||||
room.breakfast.localPrice.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
<div className={styles.total}>
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body>
|
||||
{intl.formatMessage(
|
||||
{ id: "<b>Total price</b> (incl VAT)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</Body>
|
||||
<PriceDetailsModal
|
||||
fromDate={booking.fromDate}
|
||||
toDate={booking.toDate}
|
||||
rooms={rooms.map((r) => ({
|
||||
adults: r.adults,
|
||||
childrenInRoom: r.childrenInRoom,
|
||||
roomPrice: r.roomPrice,
|
||||
roomType: r.roomType,
|
||||
bedType: r.bedType,
|
||||
breakfast: r.breakfast,
|
||||
}))}
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Body textTransform="bold" data-testid="total-price">
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPrice.local.price,
|
||||
totalPrice.local.currency
|
||||
)}
|
||||
</Body>
|
||||
{totalPrice.requested && (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Approx. {value}" },
|
||||
{
|
||||
value: formatPrice(
|
||||
intl,
|
||||
totalPrice.requested.price,
|
||||
totalPrice.requested.currency
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
|
||||
</div>
|
||||
{showSignupPromo && memberPrice ? (
|
||||
<SignupPromoDesktop memberPrice={memberPrice} badgeContent={"✌️"} />
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
.summary {
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-areas: "title button" "date button";
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.chevronButton {
|
||||
grid-area: button;
|
||||
justify-self: end;
|
||||
align-items: center;
|
||||
margin-right: calc(0px - var(--Spacing-x2));
|
||||
}
|
||||
|
||||
.date {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
justify-content: flex-start;
|
||||
grid-area: date;
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.addOns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.rateDetailsPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.entry > :last-child {
|
||||
justify-items: flex-end;
|
||||
}
|
||||
|
||||
.total {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.bottomDivider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
width: 560px;
|
||||
}
|
||||
|
||||
.terms {
|
||||
margin-top: var(--Spacing-x3);
|
||||
margin-bottom: var(--Spacing-x3);
|
||||
}
|
||||
.termsText:nth-child(n) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
.terms .termsIcon {
|
||||
margin-right: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.bottomDivider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.summary .header .chevronButton {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, test } from "@jest/globals"
|
||||
import { act, cleanup, render, screen, within } from "@testing-library/react"
|
||||
import { type IntlConfig, IntlProvider } from "react-intl"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
|
||||
import {
|
||||
bedType,
|
||||
booking,
|
||||
breakfastPackage,
|
||||
guestDetailsMember,
|
||||
guestDetailsNonMember,
|
||||
roomPrice,
|
||||
roomRate,
|
||||
} from "@/__mocks__/hotelReservation"
|
||||
import { initIntl } from "@/i18n"
|
||||
|
||||
import SummaryUI from "./UI"
|
||||
|
||||
import type { PropsWithChildren } from "react"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type { RoomState } from "@/types/stores/enter-details"
|
||||
|
||||
jest.mock("@/lib/api", () => ({
|
||||
fetchRetry: jest.fn((fn) => fn),
|
||||
}))
|
||||
|
||||
function createWrapper(intlConfig: IntlConfig) {
|
||||
return function Wrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<IntlProvider
|
||||
messages={intlConfig.messages}
|
||||
locale={intlConfig.locale}
|
||||
defaultLocale={intlConfig.defaultLocale}
|
||||
>
|
||||
{children}
|
||||
</IntlProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const rooms: RoomState[] = [
|
||||
{
|
||||
adults: 2,
|
||||
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
|
||||
bedType: {
|
||||
description: bedType.queen.description,
|
||||
roomTypeCode: bedType.queen.value,
|
||||
},
|
||||
breakfast: breakfastPackage,
|
||||
guest: guestDetailsNonMember,
|
||||
roomRate: roomRate,
|
||||
roomPrice: roomPrice,
|
||||
roomType: "Standard",
|
||||
rateDetails: [],
|
||||
cancellationText: "Non-refundable",
|
||||
roomFeatures: [],
|
||||
},
|
||||
{
|
||||
adults: 1,
|
||||
childrenInRoom: [],
|
||||
bedType: {
|
||||
description: bedType.king.description,
|
||||
roomTypeCode: bedType.king.value,
|
||||
},
|
||||
breakfast: undefined,
|
||||
guest: guestDetailsMember,
|
||||
roomRate: roomRate,
|
||||
roomPrice: roomPrice,
|
||||
roomType: "Standard",
|
||||
rateDetails: [],
|
||||
cancellationText: "Non-refundable",
|
||||
roomFeatures: [],
|
||||
},
|
||||
]
|
||||
|
||||
describe("EnterDetails Summary", () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
test("render with single room correctly", async () => {
|
||||
const intl = await initIntl(Lang.en)
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<SummaryUI
|
||||
booking={booking}
|
||||
rooms={rooms.slice(0, 1)}
|
||||
isMember={false}
|
||||
breakfastIncluded={false}
|
||||
totalPrice={{
|
||||
requested: {
|
||||
currency: "EUR",
|
||||
price: 133,
|
||||
},
|
||||
local: {
|
||||
currency: "SEK",
|
||||
price: 1500,
|
||||
},
|
||||
}}
|
||||
vat={12}
|
||||
toggleSummaryOpen={jest.fn()}
|
||||
/>,
|
||||
{
|
||||
wrapper: createWrapper(intl),
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
screen.getByText("2 adults, 1 child")
|
||||
screen.getByText("Standard")
|
||||
screen.getByText("1,525 SEK")
|
||||
screen.getByText(bedType.queen.description)
|
||||
screen.getByText("Breakfast buffet")
|
||||
screen.getByText("1,500 SEK")
|
||||
screen.getByTestId("signup-promo-desktop")
|
||||
})
|
||||
|
||||
test("render with multiple rooms correctly", async () => {
|
||||
const intl = await initIntl(Lang.en)
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<SummaryUI
|
||||
booking={booking}
|
||||
rooms={rooms}
|
||||
isMember={false}
|
||||
breakfastIncluded={false}
|
||||
totalPrice={{
|
||||
requested: {
|
||||
currency: "EUR",
|
||||
price: 133,
|
||||
},
|
||||
local: {
|
||||
currency: "SEK",
|
||||
price: 1500,
|
||||
},
|
||||
}}
|
||||
vat={12}
|
||||
toggleSummaryOpen={jest.fn()}
|
||||
/>,
|
||||
{
|
||||
wrapper: createWrapper(intl),
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const room1 = within(screen.getByTestId("summary-room-1"))
|
||||
room1.getByText("Standard")
|
||||
room1.getByText("2 adults, 1 child")
|
||||
room1.getByText(bedType.queen.description)
|
||||
room1.getByText("Breakfast buffet")
|
||||
|
||||
const room2 = within(screen.getByTestId("summary-room-2"))
|
||||
room2.getByText("Standard")
|
||||
room2.getByText("1 adult")
|
||||
const room2Breakfast = room2.queryByText("Breakfast buffet")
|
||||
expect(room2Breakfast).not.toBeInTheDocument()
|
||||
|
||||
room2.getByText(bedType.king.description)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user