Merge remote-tracking branch 'origin' into feat/tracking-payment

This commit is contained in:
Linus Flood
2025-01-10 09:13:04 +01:00
111 changed files with 2371 additions and 875 deletions

View File

@@ -10,3 +10,8 @@
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
width: min(600px, 100%);
}
.iconContainer {
display: flex;
gap: var(--Spacing-x-one-and-half);
}

View File

@@ -4,9 +4,13 @@ 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 { KingBedIcon } from "@/components/Icons"
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
import BedTypeInfo from "./BedTypeInfo"
@@ -18,6 +22,7 @@ import type {
BedTypeFormSchema,
BedTypeProps,
} from "@/types/components/hotelReservation/enterDetails/bedType"
import type { IconProps } from "@/types/components/icon"
export default function BedType({ bedTypes }: BedTypeProps) {
const initialBedType = useEnterDetailsStore(
@@ -74,8 +79,13 @@ export default function BedType({ bedTypes }: BedTypeProps) {
return (
<RadioCard
key={roomType.value}
Icon={KingBedIcon}
iconWidth={46}
Icon={(props) => (
<BedIconRenderer
mainBedType={roomType.type}
extraBedType={roomType.extraBed?.type}
props={props}
/>
)}
id={roomType.value}
name="bedType"
subtitle={width}
@@ -89,3 +99,25 @@ export default function BedType({ bedTypes }: BedTypeProps) {
</FormProvider>
)
}
function BedIconRenderer({
mainBedType,
extraBedType,
props,
}: {
mainBedType: BedTypeEnum
extraBedType: ExtraBedTypeEnum | undefined
props: IconProps
}) {
const MainBedIcon = BED_TYPE_ICONS[mainBedType]
const ExtraBedIcon = extraBedType ? BED_TYPE_ICONS[extraBedType] : null
return (
<div className={`${props.className} ${styles.iconContainer}`}>
<MainBedIcon height={32} color="uiTextMediumContrast" />
{ExtraBedIcon && (
<ExtraBedIcon height={32} color="uiTextMediumContrast" />
)}
</div>
)
}

View File

@@ -5,14 +5,13 @@ import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
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 Modal from "../../Modal"
import styles from "./modal.module.css"
import type { Dispatch, SetStateAction } from "react"

View File

@@ -1,5 +1,7 @@
import { z } from "zod"
import { dt } from "@/lib/dt"
import { phoneValidator } from "@/utils/phoneValidator"
// stringMatcher regex is copied from current web as specified by requirements.
@@ -78,7 +80,18 @@ 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" }),
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(""),
})
)

View File

@@ -1,150 +0,0 @@
"use client"
import { motion } from "framer-motion"
import { type PropsWithChildren, useEffect, useState } from "react"
import {
Dialog,
DialogTrigger,
Modal as AriaModal,
ModalOverlay,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { CloseLargeIcon } from "@/components/Icons"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import {
type AnimationState,
AnimationStateEnum,
type InnerModalProps,
type ModalProps,
} from "./modal"
import { fade, slideInOut } from "./motionVariants"
import styles from "./modal.module.css"
const MotionOverlay = motion(ModalOverlay)
const MotionModal = motion(AriaModal)
function InnerModal({
animation,
onAnimationComplete = () => undefined,
setAnimation,
onToggle,
isOpen,
children,
title,
}: PropsWithChildren<InnerModalProps>) {
const intl = useIntl()
function modalStateHandler(newAnimationState: AnimationState) {
setAnimation((currentAnimationState) =>
newAnimationState === AnimationStateEnum.hidden &&
currentAnimationState === AnimationStateEnum.hidden
? AnimationStateEnum.unmounted
: currentAnimationState
)
if (newAnimationState === AnimationStateEnum.visible) {
onAnimationComplete()
}
}
function onOpenChange(state: boolean) {
onToggle!(state)
}
return (
<MotionOverlay
animate={animation}
className={styles.overlay}
initial={"hidden"}
isDismissable
isExiting={animation === AnimationStateEnum.hidden}
onAnimationComplete={modalStateHandler}
variants={fade}
isOpen={isOpen}
onOpenChange={onOpenChange}
>
<MotionModal
className={styles.modal}
variants={slideInOut}
animate={animation}
initial={"hidden"}
>
<Dialog className={styles.dialog} aria-label="Dialog">
{({ close }) => (
<>
<header className={styles.header}>
{title && (
<Subtitle type="one" color="uiTextHighContrast">
{title}
</Subtitle>
)}
<button onClick={close} type="button" className={styles.close}>
<CloseLargeIcon color="uiTextMediumContrast" />
</button>
</header>
<section className={styles.content}>{children}</section>
</>
)}
</Dialog>
</MotionModal>
</MotionOverlay>
)
}
export default function Modal({
onAnimationComplete = () => undefined,
trigger,
isOpen,
onToggle,
title,
children,
}: PropsWithChildren<ModalProps>) {
const [animation, setAnimation] = useState<AnimationState>(
AnimationStateEnum.visible
)
useEffect(() => {
if (typeof isOpen === "boolean") {
setAnimation(
isOpen ? AnimationStateEnum.visible : AnimationStateEnum.hidden
)
}
}, [isOpen])
if (!trigger) {
return (
<InnerModal
onAnimationComplete={onAnimationComplete}
animation={animation}
setAnimation={setAnimation}
onToggle={onToggle}
isOpen={isOpen}
title={title}
>
{children}
</InnerModal>
)
}
return (
<DialogTrigger
onOpenChange={(isOpen) =>
setAnimation(
isOpen ? AnimationStateEnum.visible : AnimationStateEnum.hidden
)
}
>
{trigger}
<InnerModal
onAnimationComplete={onAnimationComplete}
animation={animation}
setAnimation={setAnimation}
title={title}
>
{children}
</InnerModal>
</DialogTrigger>
)
}

View File

@@ -1,75 +0,0 @@
.overlay {
background: rgba(0, 0, 0, 0.5);
height: var(--visual-viewport-height);
position: fixed;
top: 0;
left: 0;
width: 100vw;
z-index: var(--default-modal-overlay-z-index);
}
.modal {
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0;
box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08);
width: 100%;
position: absolute;
left: 0;
bottom: 0;
z-index: var(--default-modal-z-index);
}
.dialog {
display: flex;
flex-direction: column;
/* for supporting animations within content */
position: relative;
overflow: hidden;
}
.header {
--button-dimension: 32px;
box-sizing: content-box;
display: flex;
align-items: center;
height: var(--button-dimension);
position: relative;
padding: var(--Spacing-x2) var(--Spacing-x3) 0;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x2);
padding: 0 var(--Spacing-x3) var(--Spacing-x1);
}
.close {
background: none;
border: none;
cursor: pointer;
position: absolute;
right: var(--Spacing-x2);
width: var(--button-dimension);
height: var(--button-dimension);
display: flex;
align-items: center;
}
@media screen and (min-width: 768px) {
.overlay {
display: flex;
justify-content: center;
align-items: center;
}
.modal {
left: auto;
bottom: auto;
width: auto;
border-radius: var(--Corner-radius-Medium);
}
}

View File

@@ -1,26 +0,0 @@
import type { Dispatch, SetStateAction } from "react"
export enum AnimationStateEnum {
unmounted = "unmounted",
hidden = "hidden",
visible = "visible",
}
export type AnimationState = keyof typeof AnimationStateEnum
export type ModalProps = {
onAnimationComplete?: VoidFunction
title?: string
} & (
| { trigger: JSX.Element; isOpen?: never; onToggle?: never }
| {
trigger?: never
isOpen: boolean
onToggle: Dispatch<SetStateAction<boolean>>
}
)
export type InnerModalProps = Omit<ModalProps, "trigger"> & {
animation: AnimationState
setAnimation: Dispatch<SetStateAction<AnimationState>>
}

View File

@@ -1,23 +0,0 @@
export const fade = {
hidden: {
opacity: 0,
transition: { duration: 0.4, ease: "easeInOut" },
},
visible: {
opacity: 1,
transition: { duration: 0.4, ease: "easeInOut" },
},
}
export const slideInOut = {
hidden: {
opacity: 0,
y: 32,
transition: { duration: 0.4, ease: "easeInOut" },
},
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeInOut" },
},
}

View File

@@ -8,19 +8,19 @@ import { useEnterDetailsStore } from "@/stores/enter-details"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import {
ArrowRightIcon,
CheckIcon,
ChevronDownSmallIcon,
ChevronRightSmallIcon,
} from "@/components/Icons"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Popover from "@/components/TempDesignSystem/Popover"
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 Modal from "../../Modal"
import PriceDetailsTable from "../PriceDetailsTable"
import styles from "./ui.module.css"
@@ -51,6 +51,7 @@ export default function SummaryUI({
isMember,
rateDetails,
roomType,
breakfastIncluded,
}: SummaryProps) {
const intl = useIntl()
const lang = useLang()
@@ -169,23 +170,34 @@ export default function SummaryUI({
}`}
</Caption>
<Caption color="uiTextMediumContrast">{cancellationText}</Caption>
<Popover
placement="bottom left"
triggerContent={
<Caption color="burgundy" type="underline">
{intl.formatMessage({ id: "Rate details" })}
</Caption>
<Modal
trigger={
<Button intent="text">
<Caption color="burgundy" type="underline">
{intl.formatMessage({ id: "Rate details" })}
</Caption>
</Button>
}
title={cancellationText}
>
<aside className={styles.rateDetailsPopover}>
<header>
<Caption type="bold">{cancellationText}</Caption>
</header>
{rateDetails?.map((detail, idx) => (
<Caption key={`rateDetails-${idx}`}>{detail}</Caption>
<div className={styles.terms}>
{rateDetails?.map((info) => (
<Body
key={info}
color="uiTextHighContrast"
className={styles.termsText}
>
<CheckIcon
color="uiSemanticSuccess"
width={20}
height={20}
className={styles.termsIcon}
></CheckIcon>
{info}
</Body>
))}
</aside>
</Popover>
</div>
</Modal>
</div>
{packages
? packages.map((roomPackage) => (
@@ -242,7 +254,13 @@ export default function SummaryUI({
</Body>
</div>
) : null}
{breakfast === false ? (
{breakfastIncluded ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast included" })}
</Body>
</div>
) : breakfast === false ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "No breakfast" })}

View File

@@ -72,6 +72,19 @@
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;