Merge remote-tracking branch 'origin' into feature/tracking

This commit is contained in:
Linus Flood
2024-12-13 09:02:37 +01:00
329 changed files with 4494 additions and 1910 deletions

View File

@@ -22,10 +22,7 @@ export default function BedType({ bedTypes }: BedTypeProps) {
const initialBedType = useEnterDetailsStore(
(state) => state.formValues?.bedType?.roomTypeCode
)
const bedType = useEnterDetailsStore((state) => state.bedType?.roomTypeCode)
const completeStep = useEnterDetailsStore(
(state) => state.actions.completeStep
)
const updateBedType = useEnterDetailsStore(
(state) => state.actions.updateBedType
)
@@ -81,9 +78,6 @@ export default function BedType({ bedTypes }: BedTypeProps) {
subtitle={width}
title={roomType.description}
value={roomType.value}
handleSelectedOnClick={
bedType === roomType.value ? completeStep : undefined
}
/>
)
})}

View File

@@ -1,3 +1,9 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.form {
display: grid;
gap: var(--Spacing-x2);

View File

@@ -9,6 +9,7 @@ import { useEnterDetailsStore } from "@/stores/enter-details"
import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
import Body from "@/components/TempDesignSystem/Text/Body"
import { breakfastFormSchema } from "./schema"
@@ -30,19 +31,15 @@ export default function Breakfast({ packages }: BreakfastProps) {
? "false"
: undefined
)
const breakfast = useEnterDetailsStore((state) =>
state.breakfast
? state.breakfast.code
: state.breakfast === false
? "false"
: undefined
)
const completeStep = useEnterDetailsStore(
(state) => state.actions.completeStep
)
const updateBreakfast = useEnterDetailsStore(
(state) => state.actions.updateBreakfast
)
const children = useEnterDetailsStore(
(state) => state.booking.rooms[0].children
)
const methods = useForm<BreakfastFormSchema>({
defaultValues: formValuesBreakfast
? { breakfast: formValuesBreakfast }
@@ -75,60 +72,63 @@ export default function Breakfast({ packages }: BreakfastProps) {
return (
<FormProvider {...methods}>
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
{packages.map((pkg) => (
<RadioCard
key={pkg.code}
id={pkg.code}
name="breakfast"
subtitle={
pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST
? intl.formatMessage<React.ReactNode>(
{ id: "breakfast.price.free" },
{
amount: pkg.localPrice.price,
currency: pkg.localPrice.currency,
free: (str) => <Highlight>{str}</Highlight>,
strikethrough: (str) => <s>{str}</s>,
}
)
: intl.formatMessage(
{ id: "breakfast.price" },
{
amount: pkg.localPrice.price,
currency: pkg.localPrice.currency,
}
)
}
text={intl.formatMessage({
id: "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.",
<div className={styles.container}>
{children?.length ? (
<Body>
{intl.formatMessage({
id: "Children's breakfast is always free as part of the adult's breakfast.",
})}
title={intl.formatMessage({ id: "Breakfast buffet" })}
value={pkg.code}
handleSelectedOnClick={
breakfast === pkg.code ? completeStep : undefined
}
</Body>
) : null}
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
{packages.map((pkg) => (
<RadioCard
key={pkg.code}
id={pkg.code}
name="breakfast"
subtitle={
pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST
? intl.formatMessage<React.ReactNode>(
{ id: "breakfast.price.free" },
{
amount: pkg.localPrice.price,
currency: pkg.localPrice.currency,
free: (str) => <Highlight>{str}</Highlight>,
strikethrough: (str) => <s>{str}</s>,
}
)
: intl.formatMessage(
{ id: "breakfast.price" },
{
amount: pkg.localPrice.price,
currency: pkg.localPrice.currency,
}
)
}
text={intl.formatMessage({
id: "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.",
})}
title={intl.formatMessage({ id: "Breakfast buffet" })}
value={pkg.code}
/>
))}
<RadioCard
name="breakfast"
subtitle={intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: "0",
currency: packages[0].localPrice.currency,
}
)}
text={intl.formatMessage({
id: "You can always change your mind later and add breakfast at the hotel.",
})}
title={intl.formatMessage({ id: "No breakfast" })}
value="false"
/>
))}
<RadioCard
name="breakfast"
subtitle={intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: "0",
currency: "SEK",
}
)}
text={intl.formatMessage({
id: "You can always change your mind later and add breakfast at the hotel.",
})}
title={intl.formatMessage({ id: "No breakfast" })}
value="false"
handleSelectedOnClick={
breakfast === "false" ? completeStep : undefined
}
/>
</form>
</form>
</div>
</FormProvider>
)
}

View File

@@ -26,8 +26,7 @@ import type {
const formID = "enter-details"
export default function Details({ user, memberPrice }: DetailsProps) {
const intl = useIntl()
const initialData = useEnterDetailsStore((state) => state.formValues.guest)
const join = useEnterDetailsStore((state) => state.guest.join)
const initialData = useEnterDetailsStore((state) => state.guest)
const updateDetails = useEnterDetailsStore(
(state) => state.actions.updateDetails
)
@@ -42,7 +41,7 @@ export default function Details({ user, memberPrice }: DetailsProps) {
dateOfBirth: initialData.dateOfBirth,
email: user?.email ?? initialData.email,
firstName: user?.firstName ?? initialData.firstName,
join,
join: initialData.join,
lastName: user?.lastName ?? initialData.lastName,
membershipNo: initialData.membershipNo,
phoneNumber: user?.phoneNumber ?? initialData.phoneNumber,
@@ -78,12 +77,14 @@ export default function Details({ user, memberPrice }: DetailsProps) {
</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 }}

View File

@@ -2,11 +2,27 @@ import { z } from "zod"
import { phoneValidator } from "@/utils/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 const baseDetailsSchema = z.object({
countryCode: z.string(),
email: z.string().email(),
firstName: z.string(),
lastName: z.string(),
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(),
})
@@ -26,10 +42,10 @@ export const notJoinDetailsSchema = baseDetailsSchema.merge(
}, "Only digits are allowed")
.refine((num) => {
if (num) {
return num.length === 14
return num.match(/^30812(?!(0|1|2))[0-9]{9}$/)
}
return true
}, "Membership number needs to be 14 digits"),
}, "Invalid membership number format"),
})
)

View File

@@ -9,7 +9,7 @@ import Button from "@/components/TempDesignSystem/Button"
import styles from "./header.module.css"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
import { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
import type { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
export default function ToggleSidePeek({ hotelId }: ToggleSidePeekProps) {
const intl = useIntl()

View File

@@ -8,7 +8,7 @@ import { detailsStorageName } from "@/stores/enter-details"
import { createQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import LoadingSpinner from "@/components/LoadingSpinner"
import type { DetailsState } from "@/types/stores/enter-details"
import type { PersistedState } from "@/types/stores/enter-details"
export default function PaymentCallback({
returnUrl,
@@ -23,12 +23,9 @@ export default function PaymentCallback({
const bookingData = window.sessionStorage.getItem(detailsStorageName)
if (bookingData) {
const detailsStorage: Record<
"state",
Pick<DetailsState, "booking">
> = JSON.parse(bookingData)
const detailsStorage: PersistedState = JSON.parse(bookingData)
const searchParams = createQueryParamsForEnterDetails(
detailsStorage.state.booking,
detailsStorage.booking,
searchObject
)

View File

@@ -36,7 +36,7 @@ import { bedTypeMap } from "../../SelectRate/RoomSelection/utils"
import PriceChangeDialog from "../PriceChangeDialog"
import GuaranteeDetails from "./GuaranteeDetails"
import PaymentOption from "./PaymentOption"
import { PaymentFormData, paymentSchema } from "./schema"
import { type PaymentFormData, paymentSchema } from "./schema"
import styles from "./payment.module.css"
@@ -403,6 +403,9 @@ export default function PaymentClient({
</section>
<div className={styles.submitButton}>
<Button
intent="primary"
theme="base"
size="small"
type="submit"
disabled={
!methods.formState.isValid || methods.formState.isSubmitting

View File

@@ -1,16 +1,19 @@
import Image from "next/image"
import { useFormContext } from "react-hook-form"
import { PAYMENT_METHOD_ICONS, PaymentMethodEnum } from "@/constants/booking"
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 { PaymentOptionProps } from "./paymentOption"
import styles from "./paymentOption.module.css"
import type { PaymentOptionProps } from "./paymentOption"
export default function PaymentOption({
name,
value,

View File

@@ -1,4 +1,4 @@
import { RegisterOptions } from "react-hook-form"
import type { RegisterOptions } from "react-hook-form"
export interface PaymentOptionProps {
name: string

View File

@@ -2,7 +2,7 @@ import { getSavedPaymentCardsSafely } from "@/lib/trpc/memoizedRequests"
import PaymentClient from "./PaymentClient"
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
import type { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
export default async function Payment({
user,

View File

@@ -8,7 +8,7 @@ import Title from "@/components/TempDesignSystem/Text/Title"
import styles from "./priceChangeDialog.module.css"
import { PriceChangeDialogProps } from "@/types/components/hotelReservation/enterDetails/priceChangeDialog"
import type { PriceChangeDialogProps } from "@/types/components/hotelReservation/enterDetails/priceChangeDialog"
export default function PriceChangeDialog({
isOpen,

View File

@@ -7,6 +7,7 @@ import { useEnterDetailsStore } from "@/stores/enter-details"
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useScrollToActiveSection from "@/hooks/booking/useScrollToActiveSection"
import styles from "./sectionAccordion.module.css"
@@ -21,6 +22,7 @@ export default function SectionAccordion({
}: React.PropsWithChildren<SectionAccordionProps>) {
const intl = useIntl()
const currentStep = useEnterDetailsStore((state) => state.currentStep)
const steps = useEnterDetailsStore((state) => state.steps)
const [isComplete, setIsComplete] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const isValid = useEnterDetailsStore((state) => state.isValid[step])
@@ -33,6 +35,9 @@ export default function SectionAccordion({
const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" })
const breakfastTitle = intl.formatMessage({ id: "Breakfast buffet" })
useScrollToActiveSection(step, steps, currentStep === step)
useEffect(() => {
if (step === StepEnum.selectBed && bedType) {
setTitle(bedType.description)

View File

@@ -5,7 +5,7 @@
gap: var(--Spacing-x3);
width: 100%;
padding-top: var(--Spacing-x3);
transition: 0.4s ease-out;
transition: 0.3s ease-out;
display: grid;
grid-template-areas: "circle header" "content content";
@@ -13,6 +13,7 @@
grid-template-rows: var(--header-height) 0fr;
column-gap: var(--Spacing-x-one-and-half);
transform-origin: top;
}
.accordion:last-child {
@@ -90,7 +91,18 @@
.content {
overflow: hidden;
grid-area: content;
opacity: 0;
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
transform-origin: top;
transition: opacity 0.2s linear;
}
.accordion[data-open="true"] .content {
opacity: 1;
}
.content:has([data-open="true"]) {
overflow: visible;
}
@media screen and (min-width: 768px) {

View File

@@ -1,6 +1,6 @@
"use client"
import { PropsWithChildren } from "react"
import { type PropsWithChildren, useEffect, useRef } from "react"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
@@ -14,6 +14,7 @@ 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) => ({
@@ -23,6 +24,27 @@ export default function SummaryBottomSheet({ children }: PropsWithChildren) {
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>
@@ -48,6 +70,7 @@ export default function SummaryBottomSheet({ children }: PropsWithChildren) {
</button>
<Button
intent="primary"
theme="base"
size="large"
type="submit"
disabled={isSubmittingDisabled}

View File

@@ -1,13 +1,30 @@
"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"
import type { DetailsState } from "@/types/stores/enter-details"
function storeSelector(state: DetailsState) {
return {
join: state.guest.join,
membershipNo: state.guest.membershipNo,
}
}
export default function MobileSummary(props: SummaryProps) {
const { join, membershipNo } = useEnterDetailsStore(storeSelector)
const showPromo = !props.isMember && !join && !membershipNo
return (
<div className={styles.mobileSummary}>
{showPromo ? <SignupPromoMobile /> : null}
<SummaryBottomSheet>
<div className={styles.wrapper}>
<SummaryUI {...props} />

View File

@@ -4,6 +4,7 @@ import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { useEnterDetailsStore } from "@/stores/enter-details"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import { ArrowRightIcon, ChevronDownSmallIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
@@ -17,7 +18,6 @@ import useLang from "@/hooks/useLang"
import styles from "./ui.module.css"
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
import { CurrencyEnum } from "@/types/enums/currency"
import type { DetailsState } from "@/types/stores/enter-details"
export function storeSelector(state: DetailsState) {
@@ -60,10 +60,14 @@ export default function SummaryUI({
const adults = booking.rooms[0].adults
const children = booking.rooms[0].children
const showMemberPrice = !!(
(isMember || join || membershipNo) &&
roomRate.memberRate
)
const memberPrice = roomRate.memberRate
? {
currency: roomRate.memberRate.localPrice.currency,
amount: roomRate.memberRate.localPrice.pricePerStay,
}
: null
const showMemberPrice = !!(isMember || join || membershipNo)
const diff = dt(booking.toDate).diff(booking.fromDate, "days")
@@ -103,27 +107,26 @@ export default function SummaryUI({
<div>
<div className={styles.entry}>
<Body color="uiTextHighContrast">{roomType}</Body>
<Caption color={showMemberPrice ? "red" : "uiTextHighContrast"}>
<Body color={showMemberPrice ? "red" : "uiTextHighContrast"}>
{intl.formatNumber(roomPrice.local.price, {
currency: roomPrice.local.currency,
style: "currency",
})}
</Caption>
</Body>
</div>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{`${intl.formatMessage(
{ id: "booking.adults" },
{ totalAdults: adults }
)}
)}${
children?.length
? `, ${intl.formatMessage(
{ id: "booking.children" },
{ totalChildren: children.length }
)}`
: ""
}`}
</Caption>
{children?.length ? (
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "booking.children" },
{ totalChildren: children.length }
)}
</Caption>
) : null}
<Caption color="uiTextMediumContrast">{cancellationText}</Caption>
<Popover
placement="bottom left"
@@ -152,12 +155,12 @@ export default function SummaryUI({
</Body>
</div>
<Caption color="uiTextHighContrast">
<Body color="uiTextHighContrast">
{intl.formatNumber(parseInt(roomPackage.localPrice.price), {
currency: roomPackage.localPrice.currency,
style: "currency",
})}
</Caption>
</Body>
</div>
))
: null}
@@ -170,12 +173,12 @@ export default function SummaryUI({
</Caption>
</div>
<Caption color="uiTextHighContrast">
<Body color="uiTextHighContrast">
{intl.formatNumber(0, {
currency: roomPrice.local.currency,
style: "currency",
})}
</Caption>
</Body>
</div>
) : null}
@@ -184,25 +187,49 @@ export default function SummaryUI({
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "No breakfast" })}
</Body>
<Caption color="uiTextMediumContrast">
<Body color="uiTextHighContrast">
{intl.formatNumber(0, {
currency: roomPrice.local.currency,
style: "currency",
})}
</Caption>
</Body>
</div>
) : null}
{breakfast ? (
<div className={styles.entry}>
<div>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast buffet" })}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatNumber(parseInt(breakfast.localPrice.totalPrice), {
currency: breakfast.localPrice.currency,
style: "currency",
})}
</Caption>
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "booking.adults" },
{ totalAdults: adults }
)}
</Caption>
<Body color="uiTextHighContrast">
{intl.formatNumber(parseInt(breakfast.localPrice.totalPrice), {
currency: breakfast.localPrice.currency,
style: "currency",
})}
</Body>
</div>
{children?.length ? (
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "booking.children" },
{ totalChildren: children.length }
)}
</Caption>
<Body color="uiTextHighContrast">
{intl.formatNumber(0, {
currency: breakfast.localPrice.currency,
style: "currency",
})}
</Body>
</div>
) : null}
</div>
) : null}
</div>
@@ -240,6 +267,9 @@ export default function SummaryUI({
</div>
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
</div>
{!showMemberPrice && memberPrice ? (
<SignupPromoDesktop memberPrice={memberPrice} badgeContent={"✌️"} />
) : null}
</section>
)
}