feat: merge stores, fix auto navigation, split summary

This commit is contained in:
Simon Emanuelsson
2024-11-12 15:30:59 +01:00
parent a69d14ff61
commit ccb15593ea
82 changed files with 2149 additions and 1842 deletions

View File

@@ -4,8 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useDetailsStore } from "@/stores/details"
import { useStepsStore } from "@/stores/steps"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { KingBedIcon } from "@/components/Icons"
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
@@ -20,9 +19,15 @@ import type {
} from "@/types/components/hotelReservation/enterDetails/bedType"
export default function BedType({ bedTypes }: BedTypeProps) {
const bedType = useDetailsStore((state) => state.data.bedType?.roomTypeCode)
const completeStep = useStepsStore((state) => state.completeStep)
const updateBedType = useDetailsStore((state) => state.actions.updateBedType)
const bedType = useEnterDetailsStore(
(state) => state.formValues?.bedType?.roomTypeCode
)
const completeStep = useEnterDetailsStore(
(state) => state.actions.completeStep
)
const updateBedType = useEnterDetailsStore(
(state) => state.actions.updateBedType
)
const methods = useForm<BedTypeFormSchema>({
defaultValues: bedType ? { bedType } : undefined,
@@ -43,10 +48,9 @@ export default function BedType({ bedTypes }: BedTypeProps) {
roomTypeCode: matchingRoom.value,
}
updateBedType(bedType)
completeStep()
}
},
[bedTypes, completeStep, updateBedType]
[bedTypes, updateBedType]
)
useEffect(() => {

View File

@@ -5,8 +5,7 @@ import { useCallback, useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useDetailsStore } from "@/stores/details"
import { useStepsStore } from "@/stores/steps"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
@@ -24,20 +23,30 @@ import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default function Breakfast({ packages }: BreakfastProps) {
const intl = useIntl()
const breakfast = useDetailsStore(({ data }) =>
data.breakfast
? data.breakfast.code
: data.breakfast === false
const formValuesBreakfast = useEnterDetailsStore(({ formValues }) =>
formValues?.breakfast
? formValues.breakfast.code
: formValues?.breakfast === false
? "false"
: data.breakfast
: undefined
)
const updateBreakfast = useDetailsStore(
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 completeStep = useStepsStore((state) => state.completeStep)
const methods = useForm<BreakfastFormSchema>({
defaultValues: breakfast ? { breakfast } : undefined,
defaultValues: formValuesBreakfast
? { breakfast: formValuesBreakfast }
: undefined,
criteriaMode: "all",
mode: "all",
resolver: zodResolver(breakfastFormSchema),
@@ -52,9 +61,8 @@ export default function Breakfast({ packages }: BreakfastProps) {
} else {
updateBreakfast(false)
}
completeStep()
},
[completeStep, packages, updateBreakfast]
[packages, updateBreakfast]
)
useEffect(() => {

View File

@@ -14,7 +14,7 @@ import useLang from "@/hooks/useLang"
import styles from "./joinScandicFriendsCard.module.css"
import { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
import type { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
export default function JoinScandicFriendsCard({
name,
@@ -65,7 +65,6 @@ export default function JoinScandicFriendsCard({
position="enter details"
trackingId="join-scandic-friends-enter-details"
variant="breadcrumb"
target="_blank"
>
{intl.formatMessage({ id: "Log in" })}
</LoginButton>

View File

@@ -4,8 +4,7 @@ import { useCallback } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useDetailsStore } from "@/stores/details"
import { useStepsStore } from "@/stores/steps"
import { useEnterDetailsStore } from "@/stores/enter-details"
import Button from "@/components/TempDesignSystem/Button"
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
@@ -27,20 +26,11 @@ import type {
const formID = "enter-details"
export default function Details({ user, memberPrice }: DetailsProps) {
const intl = useIntl()
const initialData = useDetailsStore((state) => ({
countryCode: state.data.countryCode,
email: state.data.email,
firstName: state.data.firstName,
lastName: state.data.lastName,
phoneNumber: state.data.phoneNumber,
join: state.data.join,
dateOfBirth: state.data.dateOfBirth,
zipCode: state.data.zipCode,
membershipNo: state.data.membershipNo,
}))
const initialData = useEnterDetailsStore((state) => state.formValues.guest)
const updateDetails = useDetailsStore((state) => state.actions.updateDetails)
const completeStep = useStepsStore((state) => state.completeStep)
const updateDetails = useEnterDetailsStore(
(state) => state.actions.updateDetails
)
const methods = useForm<DetailsSchema>({
defaultValues: {
@@ -63,9 +53,8 @@ export default function Details({ user, memberPrice }: DetailsProps) {
const onSubmit = useCallback(
(values: DetailsSchema) => {
updateDetails(values)
completeStep()
},
[completeStep, updateDetails]
[updateDetails]
)
return (

View File

@@ -38,7 +38,7 @@ export const joinDetailsSchema = baseDetailsSchema.merge(
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" }),
membershipNo: z.string().optional(),
membershipNo: z.string().default(""),
})
)
@@ -50,13 +50,16 @@ export const guestDetailsSchema = z.discriminatedUnion("join", [
// 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().optional(),
email: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
phoneNumber: z.string().optional(),
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(""),
})

View File

@@ -2,11 +2,11 @@
import { useCallback, useEffect } from "react"
import { useStepsStore } from "@/stores/steps"
import { useEnterDetailsStore } from "@/stores/enter-details"
export default function HistoryStateManager() {
const setCurrentStep = useStepsStore((state) => state.setStep)
const currentStep = useStepsStore((state) => state.currentStep)
const setCurrentStep = useEnterDetailsStore((state) => state.actions.setStep)
const currentStep = useEnterDetailsStore((state) => state.currentStep)
const handleBackButton = useCallback(
(event: PopStateEvent) => {

View File

@@ -0,0 +1,64 @@
.header {
background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Spacing-x3) var(--Spacing-x2);
}
.wrapper {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
justify-content: center;
}
.titleContainer {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: var(--Spacing-x1);
}
.descriptionContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.address {
display: flex;
gap: var(--Spacing-x-one-and-half);
font-style: normal;
}
.dividerContainer {
display: none;
}
@media (min-width: 768px) {
.header {
padding: var(--Spacing-x4) 0;
}
.wrapper {
flex-direction: row;
gap: var(--Spacing-x6);
margin: 0 auto;
/* simulates padding on viewport smaller than --max-width-navigation */
width: min(
calc(100dvw - (var(--Spacing-x2) * 2)),
var(--max-width-navigation)
);
}
.titleContainer > h1 {
white-space: nowrap;
}
.dividerContainer {
display: block;
}
.address {
gap: var(--Spacing-x3);
}
}

View File

@@ -0,0 +1,51 @@
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import getSingleDecimal from "@/utils/numberFormatting"
import styles from "./header.module.css"
import type { HotelHeaderProps } from "@/types/components/hotelReservation/enterDetails/hotelHeader"
export default async function HotelHeader({ hotel }: HotelHeaderProps) {
const intl = await getIntl()
return (
<header className={styles.header}>
<div className={styles.wrapper}>
<div className={styles.titleContainer}>
<Title as="h3" level="h1">
{hotel.name}
</Title>
<address className={styles.address}>
<Caption color="textMediumContrast">
{hotel.address.streetAddress}, {hotel.address.city}
</Caption>
<div>
<Divider variant="vertical" color="subtle" />
</div>
<Caption color="textMediumContrast">
{intl.formatMessage(
{ id: "Distance in km to city centre" },
{
number: getSingleDecimal(
hotel.location.distanceToCentre / 1000
),
}
)}
</Caption>
</address>
</div>
<div className={styles.dividerContainer}>
<Divider variant="vertical" color="subtle" />
</div>
<div className={styles.descriptionContainer}>
<Body color="baseTextHighContrast">
{hotel.hotelContent.texts.descriptions.short}
</Body>
</div>
</div>
</header>
)
}

View File

@@ -3,12 +3,12 @@
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { detailsStorageName } from "@/stores/details"
import { detailsStorageName } from "@/stores/enter-details"
import { createQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import LoadingSpinner from "@/components/LoadingSpinner"
import { DetailsState } from "@/types/stores/details"
import type { DetailsState } from "@/types/stores/enter-details"
export default function PaymentCallback({
returnUrl,
@@ -25,10 +25,10 @@ export default function PaymentCallback({
if (bookingData) {
const detailsStorage: Record<
"state",
Pick<DetailsState, "data">
Pick<DetailsState, "booking">
> = JSON.parse(bookingData)
const searchParams = createQueryParamsForEnterDetails(
detailsStorage.state.data.booking,
detailsStorage.state.booking,
searchObject
)

View File

@@ -18,7 +18,7 @@ import {
import { selectRate } from "@/constants/routes/hotelReservation"
import { env } from "@/env/client"
import { trpc } from "@/lib/trpc/client"
import { useDetailsStore } from "@/stores/details"
import { useEnterDetailsStore } from "@/stores/enter-details"
import LoadingSpinner from "@/components/LoadingSpinner"
import Button from "@/components/TempDesignSystem/Button"
@@ -41,7 +41,7 @@ import { PaymentFormData, paymentSchema } from "./schema"
import styles from "./payment.module.css"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
import type { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
const maxRetries = 4
const retryInterval = 2000
@@ -63,9 +63,14 @@ export default function Payment({
const lang = useLang()
const intl = useIntl()
const searchParams = useSearchParams()
const { booking, ...userData } = useDetailsStore((state) => state.data)
const totalPrice = useDetailsStore((state) => state.totalPrice)
const setIsSubmittingDisabled = useDetailsStore(
const totalPrice = useEnterDetailsStore((state) => state.totalPrice)
const { bedType, booking, breakfast } = useEnterDetailsStore((state) => ({
bedType: state.bedType,
booking: state.booking,
breakfast: state.breakfast,
}))
const userData = useEnterDetailsStore((state) => state.guest)
const setIsSubmittingDisabled = useEnterDetailsStore(
(state) => state.actions.setIsSubmittingDisabled
)
@@ -181,8 +186,6 @@ export default function Payment({
email,
phoneNumber,
countryCode,
breakfast,
bedType,
membershipNo,
join,
dateOfBirth,
@@ -243,10 +246,10 @@ export default function Payment({
paymentMethod,
card: savedCreditCard
? {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
}
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
}
: undefined,
success: `${paymentRedirectUrl}/success`,
@@ -256,6 +259,8 @@ export default function Payment({
})
},
[
breakfast,
bedType,
userData,
booking,
roomPrice,
@@ -308,7 +313,7 @@ export default function Payment({
value={savedCreditCard.id}
label={
PAYMENT_METHOD_TITLES[
savedCreditCard.cardType as PaymentMethodEnum
savedCreditCard.cardType as PaymentMethodEnum
]
}
cardNumber={savedCreditCard.truncatedNumber}

View File

@@ -2,8 +2,7 @@
import { useEffect, useState } from "react"
import { useIntl } from "react-intl"
import { useDetailsStore } from "@/stores/details"
import { useStepsStore } from "@/stores/steps"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
@@ -11,53 +10,50 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./sectionAccordion.module.css"
import { StepStoreKeys } from "@/types/components/hotelReservation/enterDetails/step"
import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
import type { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
import { StepEnum } from "@/types/enums/step"
export default function SectionAccordion({
children,
header,
label,
step,
children,
}: React.PropsWithChildren<SectionAccordionProps>) {
const intl = useIntl()
const currentStep = useStepsStore((state) => state.currentStep)
const currentStep = useEnterDetailsStore((state) => state.currentStep)
const [isComplete, setIsComplete] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const isValid = useDetailsStore((state) => state.isValid[step])
const navigate = useStepsStore((state) => state.navigate)
const stepData = useDetailsStore((state) => state.data)
const stepStoreKey = StepStoreKeys[step]
const isValid = useEnterDetailsStore((state) => state.isValid[step])
const navigate = useEnterDetailsStore((state) => state.actions.navigate)
const { bedType, breakfast } = useEnterDetailsStore((state) => ({
bedType: state.bedType,
breakfast: state.breakfast,
}))
const [title, setTitle] = useState(label)
const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" })
const breakfastTitle = intl.formatMessage({ id: "Breakfast buffet" })
useEffect(() => {
if (step === StepEnum.selectBed) {
const value = stepData.bedType
value && setTitle(value.description)
if (step === StepEnum.selectBed && bedType) {
setTitle(bedType.description)
}
// If breakfast step, check if an option has been selected
if (
step === StepEnum.breakfast &&
(stepData.breakfast || stepData.breakfast === false)
) {
const value = stepData.breakfast
if (value === false) {
setTitle(intl.formatMessage({ id: "No breakfast" }))
if (step === StepEnum.breakfast && breakfast !== undefined) {
if (breakfast === false) {
setTitle(noBreakfastTitle)
} else {
setTitle(intl.formatMessage({ id: "Breakfast buffet" }))
setTitle(breakfastTitle)
}
}
}, [stepData, stepStoreKey, step, intl])
}, [bedType, breakfast, setTitle, step, breakfastTitle, noBreakfastTitle])
useEffect(() => {
// We need to set the state on mount because of hydration errors
setIsComplete(isValid)
}, [isValid])
}, [isValid, setIsComplete])
useEffect(() => {
setIsOpen(currentStep === step)
}, [currentStep, step])
}, [currentStep, setIsOpen, step])
function onModify() {
navigate(step)

View File

@@ -8,7 +8,7 @@ import ChevronRight from "@/components/Icons/ChevronRight"
import Button from "@/components/TempDesignSystem/Button"
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,

View File

@@ -14,7 +14,7 @@ import ToggleSidePeek from "./ToggleSidePeek"
import styles from "./selectedRoom.module.css"
import { SelectedRoomProps } from "@/types/components/hotelReservation/enterDetails/room"
import type { SelectedRoomProps } from "@/types/components/hotelReservation/enterDetails/room"
export default function SelectedRoom({
hotelId,

View File

@@ -3,7 +3,7 @@ import { usePathname } from "next/navigation"
import { useEffect } from "react"
import { hotelreservation } from "@/constants/routes/hotelReservation"
import { detailsStorageName } from "@/stores/details"
import { detailsStorageName } from "@/stores/enter-details"
import useLang from "@/hooks/useLang"

View File

@@ -1,70 +0,0 @@
.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) var(--Spacing-x3) var(--Spacing-x5)
var(--Spacing-x3);
align-items: flex-start;
transition: 0.5s ease-in-out;
}
.priceDetailsButton {
display: block;
border: none;
background: none;
text-align: start;
transition: padding 0.5s ease-in-out;
cursor: pointer;
white-space: nowrap;
}
.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;
}
.content,
.priceDetailsButton {
overflow: hidden;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View File

@@ -1,62 +0,0 @@
"use client"
import { PropsWithChildren } from "react"
import { useIntl } from "react-intl"
import { useDetailsStore } from "@/stores/details"
import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { formId } from "../../Payment"
import styles from "./bottomSheet.module.css"
export function SummaryBottomSheet({ children }: PropsWithChildren) {
const intl = useIntl()
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } =
useDetailsStore((state) => ({
isSummaryOpen: state.isSummaryOpen,
toggleSummaryOpen: state.actions.toggleSummaryOpen,
totalPrice: state.totalPrice,
isSubmittingDisabled: state.isSubmittingDisabled,
}))
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>
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.local.amount),
currency: totalPrice.local.currency,
}
)}
</Subtitle>
<Caption color="baseTextHighContrast" type="underline">
{intl.formatMessage({ id: "See details" })}
</Caption>
</button>
<Button
intent="primary"
size="large"
type="submit"
disabled={isSubmittingDisabled}
form={formId}
>
{intl.formatMessage({ id: "Complete booking" })}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,95 @@
"use client"
import { useEnterDetailsStore } from "@/stores/enter-details"
import Summary from "@/components/HotelReservation/Summary"
import { SummaryBottomSheet } from "@/components/HotelReservation/Summary/BottomSheet"
import styles from "./summary.module.css"
import type { ClientSummaryProps } from "@/types/components/hotelReservation/enterDetails/summary"
import type { DetailsState } from "@/types/stores/enter-details"
function storeSelector(state: DetailsState) {
return {
bedType: state.bedType,
breakfast: state.breakfast,
fromDate: state.booking.fromDate,
join: state.guest.join,
membershipNo: state.guest.membershipNo,
packages: state.packages,
roomRate: state.roomRate,
roomPrice: state.roomPrice,
toDate: state.booking.toDate,
toggleSummaryOpen: state.actions.toggleSummaryOpen,
totalPrice: state.totalPrice,
}
}
export default function ClientSummary({
adults,
cancellationText,
isMember,
kids,
memberRate,
rateDetails,
roomType,
}: ClientSummaryProps) {
const {
bedType,
breakfast,
fromDate,
join,
membershipNo,
packages,
roomPrice,
toDate,
totalPrice,
} = useEnterDetailsStore(storeSelector)
const showMemberPrice = !!(isMember && memberRate) || join || !!membershipNo
const room = {
adults,
cancellationText,
children: kids,
packages,
rateDetails,
roomPrice,
roomType,
}
return (
<>
<div className={styles.mobileSummary}>
<SummaryBottomSheet>
<div className={styles.summary}>
<Summary
bedType={bedType}
breakfast={breakfast}
fromDate={fromDate}
showMemberPrice={showMemberPrice}
room={room}
toDate={toDate}
totalPrice={totalPrice}
/>
</div>
</SummaryBottomSheet>
</div>
<div className={styles.desktopSummary}>
<div className={styles.hider} />
<div className={styles.summary}>
<Summary
bedType={bedType}
breakfast={breakfast}
fromDate={fromDate}
showMemberPrice={showMemberPrice}
room={room}
toDate={toDate}
totalPrice={totalPrice}
/>
</div>
<div className={styles.shadow} />
</div>
</>
)
}

View File

@@ -1,325 +1,57 @@
"use client"
import { redirect } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import { ChevronDown } from "react-feather"
import { useIntl } from "react-intl"
import { selectRate } from "@/constants/routes/hotelReservation"
import {
getProfileSafely,
getSelectedRoomAvailability,
} from "@/lib/trpc/memoizedRequests"
import { dt } from "@/lib/dt"
import { useDetailsStore } from "@/stores/details"
import { generateChildrenString } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { getLang } from "@/i18n/serverContext"
import { ArrowRightIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
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 ClientSummary from "./Client"
import styles from "./summary.module.css"
import type { SummaryPageProps } from "@/types/components/hotelReservation/summary"
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
import type { SummaryProps } from "@/types/components/hotelReservation/enterDetails/summary"
import type { DetailsState } from "@/types/stores/details"
export default async function Summary({
adults,
fromDate,
hotelId,
kids,
packageCodes,
rateCode,
roomTypeCode,
toDate,
}: SummaryPageProps) {
const lang = getLang()
function storeSelector(state: DetailsState) {
return {
fromDate: state.data.booking.fromDate,
toDate: state.data.booking.toDate,
bedType: state.data.bedType,
breakfast: state.data.breakfast,
toggleSummaryOpen: state.actions.toggleSummaryOpen,
setTotalPrice: state.actions.setTotalPrice,
totalPrice: state.totalPrice,
join: state.data.join,
membershipNo: state.data.membershipNo,
const availability = await getSelectedRoomAvailability({
adults,
children: kids ? generateChildrenString(kids) : undefined,
hotelId,
packageCodes,
rateCode,
roomStayStartDate: fromDate,
roomStayEndDate: toDate,
roomTypeCode,
})
const user = await getProfileSafely()
if (!availability || !availability.selectedRoom) {
console.error("No hotel or availability data", availability)
// TODO: handle this case
redirect(selectRate(lang))
}
}
export default function Summary({ showMemberPrice, room }: SummaryProps) {
const [chosenBed, setChosenBed] = useState<BedTypeSchema>()
const [chosenBreakfast, setChosenBreakfast] = useState<
BreakfastPackage | false
>()
const intl = useIntl()
const lang = useLang()
const {
bedType,
breakfast,
fromDate,
setTotalPrice,
toDate,
toggleSummaryOpen,
totalPrice,
join,
membershipNo,
} = useDetailsStore(storeSelector)
const diff = dt(toDate).diff(fromDate, "days")
const nights = intl.formatMessage(
{ id: "booking.nights" },
{ totalNights: diff }
)
const color = useRef<"uiTextHighContrast" | "red">("uiTextHighContrast")
const [price, setPrice] = useState(room.prices.public)
const additionalPackageCost = room.packages?.reduce(
(acc, curr) => {
acc.local = acc.local + parseInt(curr.localPrice.totalPrice)
acc.euro = acc.euro + parseInt(curr.requestedPrice.totalPrice)
return acc
},
{ local: 0, euro: 0 }
) || { local: 0, euro: 0 }
const roomsPriceLocal = price.local.amount + additionalPackageCost.local
const roomsPriceEuro = price.euro
? price.euro.amount + additionalPackageCost.euro
: undefined
useEffect(() => {
if (showMemberPrice || join || membershipNo) {
color.current = "red"
if (room.prices.member) {
setPrice(room.prices.member)
}
} else {
color.current = "uiTextHighContrast"
setPrice(room.prices.public)
}
}, [showMemberPrice, join, membershipNo, room.prices])
useEffect(() => {
setChosenBed(bedType)
if (breakfast || breakfast === false) {
setChosenBreakfast(breakfast)
if (breakfast === false) {
setTotalPrice({
local: {
amount: roomsPriceLocal,
currency: price.local.currency,
},
euro:
price.euro && roomsPriceEuro
? {
amount: roomsPriceEuro,
currency: price.euro.currency,
}
: undefined,
})
} else {
setTotalPrice({
local: {
amount: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
currency: price.local.currency,
},
euro:
price.euro && roomsPriceEuro
? {
amount:
roomsPriceEuro +
parseInt(breakfast.requestedPrice.totalPrice),
currency: price.euro.currency,
}
: undefined,
})
}
}
}, [
bedType,
breakfast,
roomsPriceLocal,
price.local.currency,
price.euro,
roomsPriceEuro,
setTotalPrice,
])
return (
<section className={styles.summary}>
<header className={styles.header}>
<Subtitle className={styles.title} type="two">
{intl.formatMessage({ id: "Summary" })}
</Subtitle>
<Body className={styles.date} color="baseTextMediumContrast">
{dt(fromDate).locale(lang).format("ddd, D MMM")}
<ArrowRightIcon color="peach80" height={15} width={15} />
{dt(toDate).locale(lang).format("ddd, D MMM")} ({nights})
</Body>
<Button
intent="text"
size="small"
className={styles.chevronButton}
onClick={toggleSummaryOpen}
>
<ChevronDown height="20" width="20" />
</Button>
</header>
<Divider color="primaryLightSubtle" />
<div className={styles.addOns}>
<div>
<div className={styles.entry}>
<Body color="uiTextHighContrast">{room.roomType}</Body>
<Caption color={color.current}>
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(price.local.amount),
currency: price.local.currency,
}
)}
</Caption>
</div>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "booking.adults" },
{ totalAdults: room.adults }
)}
</Caption>
{room.children?.length ? (
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "booking.children" },
{ totalChildren: room.children.length }
)}
</Caption>
) : null}
<Caption color="uiTextMediumContrast">
{room.cancellationText}
</Caption>
<Popover
placement="bottom left"
triggerContent={
<Caption color="burgundy" type="underline">
{intl.formatMessage({ id: "Rate details" })}
</Caption>
}
>
<aside className={styles.rateDetailsPopover}>
<header>
<Caption type="bold">{room.cancellationText}</Caption>
</header>
{room.rateDetails?.map((detail, idx) => (
<Caption key={`rateDetails-${idx}`}>{detail}</Caption>
))}
</aside>
</Popover>
</div>
{room.packages
? room.packages.map((roomPackage) => (
<div className={styles.entry} key={roomPackage.code}>
<div>
<Body color="uiTextHighContrast">
{roomPackage.description}
</Body>
</div>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: roomPackage.localPrice.price,
currency: roomPackage.localPrice.currency,
}
)}
</Caption>
</div>
))
: null}
{chosenBed ? (
<div className={styles.entry}>
<div>
<Body color="uiTextHighContrast">{chosenBed.description}</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Based on availability" })}
</Caption>
</div>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "0", currency: price.local.currency }
)}
</Caption>
</div>
) : null}
{chosenBreakfast === false ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "No breakfast" })}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "0", currency: price.local.currency }
)}
</Caption>
</div>
) : chosenBreakfast?.code ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast buffet" })}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: chosenBreakfast.localPrice.totalPrice,
currency: chosenBreakfast.localPrice.currency,
}
)}
</Caption>
</div>
) : null}
</div>
<Divider color="primaryLightSubtle" />
<div className={styles.total}>
<div className={styles.entry}>
<div>
<Body>
{intl.formatMessage<React.ReactNode>(
{ id: "<b>Total price</b> (incl VAT)" },
{ b: (str) => <b>{str}</b> }
)}
</Body>
<Link color="burgundy" href="#" variant="underscored" size="small">
{intl.formatMessage({ id: "Price details" })}
</Link>
</div>
<div>
{totalPrice.local.amount > 0 && (
<Body textTransform="bold">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.local.amount),
currency: totalPrice.local.currency,
}
)}
</Body>
)}
{totalPrice.euro && totalPrice.euro.amount > 0 && (
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}{" "}
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.euro.amount),
currency: totalPrice.euro.currency,
}
)}
</Caption>
)}
</div>
</div>
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
</div>
</section>
<ClientSummary
adults={adults}
cancellationText={availability.cancellationText}
isMember={!!user}
kids={kids}
memberRate={availability.memberRate}
rateDetails={availability.rateDetails}
roomType={availability.selectedRoom.roomType}
/>
)
}

View File

@@ -1,83 +1,68 @@
.mobileSummary {
display: block;
}
.desktopSummary {
display: none;
}
.summary {
border-radius: var(--Corner-radius-Large);
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x3);
height: 100%;
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;
}
.header {
display: grid;
grid-template-areas: "title button" "date button";
.hider {
display: none;
}
.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);
}
.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 {
.shadow {
display: none;
}
@media screen and (min-width: 1367px) {
.bottomDivider {
display: block;
}
.header {
display: block;
}
.chevronButton {
.mobileSummary {
display: none;
}
.desktopSummary {
display: grid;
grid-template-rows: auto auto 1fr;
margin-top: calc(0px - var(--Spacing-x9));
}
.summary {
position: sticky;
top: calc(
var(--booking-widget-desktop-height) + var(--Spacing-x2) +
var(--Spacing-x-half)
);
z-index: 9;
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
margin-top: calc(0px - var(--Spacing-x9));
}
.shadow {
display: block;
background-color: var(--Main-Grey-White);
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
border-style: solid;
border-left-width: 1px;
border-right-width: 1px;
border-top: none;
border-bottom: none;
}
.hider {
display: block;
background-color: var(--Scandic-Brand-Warm-White);
position: sticky;
top: calc(var(--booking-widget-desktop-height) - 6px);
margin-top: var(--Spacing-x4);
height: 40px;
}
}