Merged in fix/handle-single-ancillaries (pull request #3231)

Fix(STAY-128): Handle single ancillaries

* fix: refactor ancillaries flow

* fix: add logic to determine if an ancillary requires quantity

* fix: breakout ancillary description to its own component

* fix: cleanup

* fix: cleanup


Approved-by: Bianca Widstam
Approved-by: Erik Tiekstra
This commit is contained in:
Christel Westerberg
2025-11-28 15:02:45 +00:00
parent 4eee1cff64
commit 69f194f7bf
40 changed files with 1310 additions and 1197 deletions

View File

@@ -1,18 +0,0 @@
.buttons {
display: flex;
gap: var(--Space-x4);
justify-content: flex-end;
padding: var(--Space-x2) var(--Space-x15) 0 0;
}
.confirmButtons {
display: flex;
padding-left: var(--Space-x15);
justify-content: space-between;
align-items: baseline;
}
.priceButton {
display: flex;
gap: var(--Space-x05);
}

View File

@@ -1,157 +0,0 @@
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import {
AncillaryStepEnum,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import { trackAddAncillary } from "@/utils/tracking/myStay"
import { type AncillaryQuantityFormData, quantitySchema } from "../../schema"
import styles from "./actionButtons.module.css"
import type { ActionButtonsProps } from "@/types/components/myPages/myStay/ancillaries"
export default function ActionButtons({
togglePriceDetails,
isPriceDetailsOpen,
isSubmitting,
}: ActionButtonsProps) {
const {
currentStep,
isBreakfast,
prevStep,
prevStepMobile,
selectQuantity,
selectDeliveryTime,
selectQuantityAndDeliveryTime,
selectedAncillary,
breakfastData,
} = useAddAncillaryStore((state) => ({
currentStep: state.currentStep,
isBreakfast: state.isBreakfast,
prevStep: state.prevStep,
prevStepMobile: state.prevStepMobile,
selectQuantity: state.selectQuantity,
selectDeliveryTime: state.selectDeliveryTime,
selectQuantityAndDeliveryTime: state.selectQuantityAndDeliveryTime,
selectedAncillary: state.selectedAncillary,
breakfastData: state.breakfastData,
}))
const isMobile = useMediaQuery("(max-width: 767px)")
const { setError } = useFormContext()
const intl = useIntl()
const isConfirmStep = currentStep === AncillaryStepEnum.confirmation
const confirmLabel = intl.formatMessage({
id: "common.confirm",
defaultMessage: "Confirm",
})
const continueLabel = intl.formatMessage({
id: "common.continue",
defaultMessage: "Continue",
})
const quantityWithCard = useWatch<AncillaryQuantityFormData>({
name: "quantityWithCard",
})
const quantityWithPoints = useWatch<AncillaryQuantityFormData>({
name: "quantityWithPoints",
})
function handleNextStep() {
if (currentStep === AncillaryStepEnum.selectQuantity) {
const validatedQuantity = quantitySchema.safeParse({
quantityWithCard,
quantityWithPoints,
})
if (validatedQuantity.success) {
trackAddAncillary(
selectedAncillary,
quantityWithCard,
quantityWithPoints,
breakfastData
)
if (isMobile) {
selectQuantityAndDeliveryTime()
} else {
selectQuantity()
}
} else {
setError("quantityWithCard", validatedQuantity.error.issues[0])
}
} else if (currentStep === AncillaryStepEnum.selectDelivery) {
selectDeliveryTime()
}
}
return (
<div className={isConfirmStep ? styles.confirmButtons : ""}>
{isConfirmStep && (
<Button
type="button"
typography="Body/Supporting text (caption)/smBold"
size="Small"
variant="Text"
onPress={togglePriceDetails}
className={styles.priceButton}
>
{intl.formatMessage({
id: "common.priceDetails",
defaultMessage: "Price details",
})}
<MaterialIcon
icon={
isPriceDetailsOpen ? "keyboard_arrow_up" : "keyboard_arrow_down"
}
size={20}
color="CurrentColor"
/>
</Button>
)}
<div className={styles.buttons}>
<Button
typography="Body/Supporting text (caption)/smBold"
type="button"
variant="Text"
size="Small"
color="Primary"
onPress={isMobile ? prevStepMobile : prevStep}
>
{intl.formatMessage({
id: "common.back",
defaultMessage: "Back",
})}
</Button>
{isConfirmStep && (
<Button
type="submit"
typography="Body/Supporting text (caption)/smBold"
variant="Primary"
size="Small"
isDisabled={isSubmitting}
form="add-ancillary-form-id"
>
{confirmLabel}
</Button>
)}
{!isConfirmStep && (
<Button
type="button"
typography="Body/Supporting text (caption)/smBold"
variant={isBreakfast ? "Primary" : "Secondary"}
size="Small"
isDisabled={isSubmitting}
onPress={handleNextStep}
>
{continueLabel}
</Button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
.price {
display: flex;
gap: var(--Space-x2);
align-items: center;
}
.contentContainer {
display: flex;
flex-direction: column;
}
.description {
display: flex;
margin: var(--Space-x2) 0;
}
.pointsDivider {
display: flex;
gap: var(--Space-x2);
height: 24px;
}
.breakfastPriceList {
display: flex;
flex-direction: column;
}
.divider {
display: none;
height: var(--Space-x4);
}
@media screen and (min-width: 768px) {
.breakfastPriceList {
flex-direction: row;
gap: var(--Space-x2);
}
.divider {
display: block;
}
}

View File

@@ -0,0 +1,156 @@
import { useIntl } from "react-intl"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { Typography } from "@scandic-hotels/design-system/Typography"
import {
AncillaryStepEnum,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import styles from "./description.module.css"
export default function Description() {
const intl = useIntl()
const { selectedAncillary, isBreakfast, currentStep } = useAddAncillaryStore(
(state) => ({
selectedAncillary: state.selectedAncillary,
isBreakfast: state.isBreakfast,
currentStep: state.currentStep,
})
)
if (!selectedAncillary || currentStep === AncillaryStepEnum.confirmation) {
return null
}
return (
<div className={styles.contentContainer}>
<div className={styles.price}>
<Typography variant="Body/Paragraph/mdBold">
{isBreakfast ? (
<BreakfastPriceList />
) : (
<p>
{formatPrice(
intl,
selectedAncillary.price.total,
selectedAncillary.price.currency
)}
</p>
)}
</Typography>
{selectedAncillary.points && (
<div className={styles.pointsDivider}>
<Divider variant="vertical" />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage(
{
id: "common.numberOfPoints",
defaultMessage:
"{points, plural, one {# point} other {# points}}",
},
{
points: selectedAncillary.points,
}
)}
</p>
</Typography>
</div>
)}
</div>
<div className={styles.description}>
{selectedAncillary.description && (
<Typography variant="Body/Paragraph/mdRegular">
<p
dangerouslySetInnerHTML={{
__html: selectedAncillary.description,
}}
/>
</Typography>
)}
</div>
</div>
)
}
function BreakfastPriceList() {
const intl = useIntl()
const breakfastData = useAddAncillaryStore((state) => state.breakfastData)
if (!breakfastData) {
return intl.formatMessage({
id: "ancillaries.unableToDisplayBreakfastPrices",
defaultMessage: "Unable to display breakfast prices.",
})
}
return (
<div>
<div className={styles.breakfastPriceList}>
<Typography variant="Body/Paragraph/mdBold">
<span>
{intl.formatMessage(
{
id: "addAncillaryFlowModal.pricePerNightPerAdult",
defaultMessage: "{price}/night per adult",
},
{
price: formatPrice(
intl,
breakfastData.priceAdult,
breakfastData.currency
),
}
)}
</span>
</Typography>
{breakfastData.nrOfPayingChildren > 0 && (
<>
<div className={styles.divider}>
<Divider variant="vertical" color="Border/Divider/Subtle" />
</div>
<Typography variant="Body/Paragraph/mdBold">
<span>
{intl.formatMessage(
{
id: "addAncillaryFlowModal.pricePerNightPerKids",
defaultMessage: "{price}/night for kids (ages 412)",
},
{
price: formatPrice(
intl,
breakfastData.priceChild,
breakfastData.currency
),
}
)}
</span>
</Typography>
</>
)}
{breakfastData.nrOfFreeChildren > 0 && (
<>
<div className={styles.divider}>
<Divider variant="vertical" color="Border/Divider/Subtle" />
</div>
<Typography variant="Body/Paragraph/mdBold">
<span>
{intl.formatMessage({
id: "addAncillaryFlowModal.freeBreakfastForKids",
defaultMessage: "Free for kids (under 4)",
})}
</span>
</Typography>
</>
)}
</div>
</div>
)
}

View File

@@ -1,267 +0,0 @@
import { useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { Typography } from "@scandic-hotels/design-system/Typography"
import {
AncillaryStepEnum,
type BreakfastData,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import PriceSummary from "./PriceSummary"
import styles from "./priceDetails.module.css"
import type { SelectedAncillary } from "@/types/components/myPages/myStay/ancillaries"
interface PriceDetailsProps {
isPriceDetailsOpen: boolean
}
export default function PriceDetails({
isPriceDetailsOpen,
}: PriceDetailsProps) {
const intl = useIntl()
const { currentStep, selectedAncillary, isBreakfast, breakfastData } =
useAddAncillaryStore((state) => ({
currentStep: state.currentStep,
selectedAncillary: state.selectedAncillary,
isBreakfast: state.isBreakfast,
breakfastData: state.breakfastData,
}))
const quantityWithPoints = useWatch({ name: "quantityWithPoints" })
const quantityWithCard = useWatch({ name: "quantityWithCard" })
if (
!selectedAncillary ||
(currentStep !== AncillaryStepEnum.confirmation && !isBreakfast)
) {
return null
}
if (isBreakfast && !breakfastData) {
return null
}
const totalPrice = isBreakfast
? breakfastData!.priceAdult *
breakfastData!.nrOfAdults *
breakfastData!.nrOfNights +
breakfastData!.priceChild *
breakfastData!.nrOfPayingChildren *
breakfastData!.nrOfNights
: quantityWithCard && selectedAncillary
? selectedAncillary.price.total * quantityWithCard
: null
const totalPoints =
quantityWithPoints && selectedAncillary?.points
? selectedAncillary.points * quantityWithPoints
: null
const hasTotalPoints = typeof totalPoints === "number"
const hasTotalPrice = typeof totalPrice === "number"
function getBreakfastItems(
selectedAncillary: SelectedAncillary,
breakfastData: BreakfastData | null
) {
if (!breakfastData) {
return []
}
const items = [
{
title: `${selectedAncillary.title} / ${intl.formatMessage({
id: "common.adult",
defaultMessage: "adult",
})}`,
totalPrice: breakfastData.priceAdult,
currency: breakfastData.currency,
quantityWithCard: breakfastData.nrOfAdults * breakfastData.nrOfNights,
},
]
if (breakfastData.nrOfPayingChildren > 0) {
items.push({
title: `${selectedAncillary.title} / ${intl.formatMessage({
id: "common.children",
defaultMessage: "Children",
})} 4-12`,
totalPrice: breakfastData.priceChild,
currency: breakfastData.currency,
quantityWithCard:
breakfastData.nrOfPayingChildren * breakfastData.nrOfNights,
})
}
if (breakfastData.nrOfFreeChildren > 0) {
items.push({
title: `${selectedAncillary.title} / ${intl.formatMessage(
{
id: "common.childrenUnderAge",
defaultMessage: "Children under {age}",
},
{ age: 4 }
)}`,
totalPrice: 0,
currency: breakfastData.currency,
quantityWithCard:
breakfastData.nrOfFreeChildren * breakfastData.nrOfNights,
})
}
return items
}
const items = isBreakfast
? getBreakfastItems(selectedAncillary, breakfastData)
: [
{
title: selectedAncillary.title,
totalPrice: selectedAncillary.price.total,
currency: selectedAncillary.price.currency,
points: selectedAncillary.points,
quantityWithCard,
quantityWithPoints,
},
]
return (
<>
<div className={styles.totalPrice}>
<div className={styles.totalPriceInclVAT}>
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
id: "common.total",
defaultMessage: "Total",
})}
</p>
</Typography>
{totalPrice && (
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.vatText}>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`(${intl.formatMessage({
id: "common.inclVAT",
defaultMessage: "Incl. VAT",
})})`}
</p>
</Typography>
)}
{isBreakfast && breakfastData ? (
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.hideOnDesktop}>
{intl.formatMessage(
{
id: "booking.numberOfNights",
defaultMessage:
"{totalNights, plural, one {# night} other {# nights}}",
},
{
totalNights: breakfastData.nrOfNights,
}
) +
/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */
" / " +
intl.formatMessage(
{
id: "common.numberOfGuests",
defaultMessage:
"{value, plural, one {# guest} other {# guests}}",
},
{
value:
breakfastData.nrOfAdults +
breakfastData.nrOfPayingChildren +
breakfastData.nrOfFreeChildren,
}
)}
</p>
</Typography>
) : null}
</div>
<div className={styles.totalPriceValue}>
{isBreakfast && breakfastData ? (
<>
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.showOnDesktop}>
{intl.formatMessage(
{
id: "booking.numberOfNights",
defaultMessage:
"{totalNights, plural, one {# night} other {# nights}}",
},
{
totalNights: breakfastData.nrOfNights,
}
) +
/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */
" / " +
intl.formatMessage(
{
id: "common.numberOfGuests",
defaultMessage:
"{value, plural, one {# guest} other {# guests}}",
},
{
value:
breakfastData.nrOfAdults +
breakfastData.nrOfPayingChildren +
breakfastData.nrOfFreeChildren,
}
)}
</p>
</Typography>
<Divider variant="vertical" />
</>
) : null}
{hasTotalPrice && (
<Typography variant="Body/Paragraph/mdBold">
<p>
{formatPrice(
intl,
totalPrice,
selectedAncillary.price.currency
)}
</p>
</Typography>
)}
{hasTotalPoints && hasTotalPrice && <Divider variant="vertical" />}
{hasTotalPoints && (
<div>
<div>
<Divider variant="vertical" />
</div>
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage(
{
id: "common.numberOfPoints",
defaultMessage:
"{points, plural, one {# point} other {# points}}",
},
{ points: totalPoints }
)}
</p>
</Typography>
</div>
)}
</div>
</div>
<Divider />
{isPriceDetailsOpen && currentStep === AncillaryStepEnum.confirmation && (
<PriceSummary
totalPrice={totalPrice}
totalPoints={totalPoints}
items={items}
/>
)}
</>
)
}

View File

@@ -18,6 +18,8 @@ import TermsAndConditions from "@/components/HotelReservation/MyStay/TermsAndCon
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import { trackUpdatePaymentMethod } from "@/utils/tracking" import { trackUpdatePaymentMethod } from "@/utils/tracking"
import Summary from "../Summary"
import styles from "./confirmationStep.module.css" import styles from "./confirmationStep.module.css"
import type { ConfirmationStepProps } from "@/types/components/myPages/myStay/ancillaries" import type { ConfirmationStepProps } from "@/types/components/myPages/myStay/ancillaries"
@@ -63,6 +65,10 @@ export default function ConfirmationStep({
"The hotel will hold your booking, even if you arrive after 18:00. Your card will only be charged in the event of a no-show.", "The hotel will hold your booking, even if you arrive after 18:00. Your card will only be charged in the event of a no-show.",
}) })
if (!selectedAncillary) {
return null
}
return ( return (
<div className={styles.modalContent}> <div className={styles.modalContent}>
{error && <Alert type={error.type} text={error.message} />} {error && <Alert type={error.type} text={error.message} />}
@@ -220,6 +226,7 @@ export default function ConfirmationStep({
</> </>
)} )}
<TermsAndConditions /> <TermsAndConditions />
<Summary isConfirmation />
</div> </div>
) )
} }

View File

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

View File

@@ -6,6 +6,8 @@ import { generateDeliveryOptions } from "@/components/HotelReservation/MyStay/ut
import Input from "@/components/TempDesignSystem/Form/Input" import Input from "@/components/TempDesignSystem/Form/Input"
import Select from "@/components/TempDesignSystem/Form/Select" import Select from "@/components/TempDesignSystem/Form/Select"
import Summary from "../Summary"
import styles from "./deliveryDetailsStep.module.css" import styles from "./deliveryDetailsStep.module.css"
export default function DeliveryMethodStep() { export default function DeliveryMethodStep() {
@@ -13,50 +15,53 @@ export default function DeliveryMethodStep() {
const deliveryTimeOptions = generateDeliveryOptions() const deliveryTimeOptions = generateDeliveryOptions()
return ( return (
<div className={styles.selectContainer}> <div className={styles.container}>
<div className={styles.select}> <div className={styles.selectContainer}>
<Typography variant="Body/Supporting text (caption)/smBold"> <div className={styles.select}>
<h3> <Typography variant="Body/Supporting text (caption)/smBold">
{intl.formatMessage({ <h3>
id: "ancillaries.deliveredAt", {intl.formatMessage({
defaultMessage: "Delivered at:", id: "ancillaries.deliveredAt",
})} defaultMessage: "Delivered at:",
</h3> })}
</Typography> </h3>
<Select </Typography>
name="deliveryTime" <Select
label={""} name="deliveryTime"
items={deliveryTimeOptions} label={""}
registerOptions={{ required: true }} items={deliveryTimeOptions}
isNestedInModal registerOptions={{ required: true }}
/> isNestedInModal
</div> />
<Typography variant="Body/Supporting text (caption)/smRegular"> </div>
<p>
{intl.formatMessage({
id: "addAncillary.deliveryDetailsStep.deliveryTimeDescription",
defaultMessage:
"All extras are delivered together. Changes to delivery times will affect previously ordered extras.",
})}
</p>
</Typography>
<div className={styles.select}>
<Input
label={intl.formatMessage({
id: "addAncillary.deliveryDetailsStep.optionalTextLabel",
defaultMessage: "Other Requests",
})}
name="optionalText"
/>
<Typography variant="Body/Supporting text (caption)/smRegular"> <Typography variant="Body/Supporting text (caption)/smRegular">
<h3> <p>
{intl.formatMessage({ {intl.formatMessage({
id: "common.optional", id: "addAncillary.deliveryDetailsStep.deliveryTimeDescription",
defaultMessage: "Optional", defaultMessage:
"All extras are delivered together. Changes to delivery times will affect previously ordered extras.",
})} })}
</h3> </p>
</Typography> </Typography>
<div className={styles.select}>
<Input
label={intl.formatMessage({
id: "addAncillary.deliveryDetailsStep.optionalTextLabel",
defaultMessage: "Other Requests",
})}
name="optionalText"
/>
<Typography variant="Body/Supporting text (caption)/smRegular">
<h3>
{intl.formatMessage({
id: "common.optional",
defaultMessage: "Optional",
})}
</h3>
</Typography>
</div>
</div> </div>
<Summary />
</div> </div>
) )
} }

View File

@@ -5,28 +5,25 @@ import {
import ConfirmationStep from "../ConfirmationStep" import ConfirmationStep from "../ConfirmationStep"
import DeliveryMethodStep from "../DeliveryDetailsStep" import DeliveryMethodStep from "../DeliveryDetailsStep"
import SelectAncillaryStep from "../SelectAncillaryStep"
import SelectQuantityStep from "../SelectQuantityStep" import SelectQuantityStep from "../SelectQuantityStep"
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries" import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
export default function Desktop({ user, savedCreditCards, error }: StepsProps) { export default function Desktop({ user, savedCreditCards, error }: StepsProps) {
const currentStep = useAddAncillaryStore((state) => state.currentStep) const currentStep = useAddAncillaryStore((state) => state.currentStep)
if (currentStep === AncillaryStepEnum.selectAncillary) {
return <SelectAncillaryStep />
}
if (currentStep === AncillaryStepEnum.selectQuantity) {
return <SelectQuantityStep user={user} />
}
if (currentStep === AncillaryStepEnum.selectDelivery) {
return <DeliveryMethodStep />
}
return ( switch (currentStep) {
<ConfirmationStep case AncillaryStepEnum.selectQuantity:
savedCreditCards={savedCreditCards} return <SelectQuantityStep user={user} />
user={user} case AncillaryStepEnum.selectDelivery:
error={error} return <DeliveryMethodStep />
/> case AncillaryStepEnum.confirmation:
) return (
<ConfirmationStep
savedCreditCards={savedCreditCards}
user={user}
error={error}
/>
)
}
} }

View File

@@ -18,7 +18,10 @@ export default function Mobile({ user, savedCreditCards, error }: StepsProps) {
if (currentStep === AncillaryStepEnum.selectQuantity) { if (currentStep === AncillaryStepEnum.selectQuantity) {
return ( return (
<> <>
<SelectQuantityStep user={user} /> <SelectQuantityStep
user={user}
hideSummary={selectedAncillary?.requiresDeliveryTime}
/>
{selectedAncillary?.requiresDeliveryTime && <DeliveryMethodStep />} {selectedAncillary?.requiresDeliveryTime && <DeliveryMethodStep />}
</> </>
) )

View File

@@ -0,0 +1,104 @@
import { useIntl } from "react-intl"
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
import { Alert } from "@scandic-hotels/design-system/Alert"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import styles from "./selectQuantityStep.module.css"
export function BreakfastInfo() {
const intl = useIntl()
const breakfastData = useAddAncillaryStore((state) => state.breakfastData)
if (!breakfastData) {
return intl.formatMessage({
id: "ancillaries.unableToDisplayBreakfastPrices",
defaultMessage: "Unable to display breakfast prices.",
})
}
return (
<div className={styles.breakfastContainer}>
<Alert
type={AlertTypeEnum.Info}
text={intl.formatMessage({
id: "addAncillary.selectQuantityStep.breakfastInfoMessage",
defaultMessage:
"Breakfast can only be added for the entire duration of the stay and for all guests.",
})}
/>
{(breakfastData.nrOfPayingChildren > 0 ||
breakfastData.nrOfFreeChildren > 0) && (
<dl className={styles.breakfastPrices}>
<div className={styles.breakfastPriceBox}>
<MaterialIcon icon="check_circle" className={styles.icon} />
<div>
<dt>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${breakfastData.nrOfAdults} × ${intl.formatMessage({
id: "common.adults",
defaultMessage: "Adults",
})}`}
</dt>
<dd>
<Typography variant="Tag/sm">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<p>{`${breakfastData.priceAdult * breakfastData.nrOfAdults} ${breakfastData.currency}`}</p>
</Typography>
</dd>
</div>
</div>
{breakfastData.nrOfPayingChildren > 0 && (
<div className={styles.breakfastPriceBox}>
<MaterialIcon icon="check_circle" className={styles.icon} />
<div>
<dt>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${breakfastData.nrOfPayingChildren} × ${intl.formatMessage({
id: "common.ages",
defaultMessage: "ages",
})} 4-12`}
</dt>
<dd>
<Typography variant="Tag/sm">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<p>{`${breakfastData.priceChild * breakfastData.nrOfPayingChildren} ${breakfastData.currency}`}</p>
</Typography>
</dd>
</div>
</div>
)}
{breakfastData.nrOfFreeChildren > 0 && (
<div className={`${styles.breakfastPriceBox} ${styles.free}`}>
<MaterialIcon icon="check_circle" className={styles.icon} />
<div>
<dt>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${breakfastData.nrOfFreeChildren} × ${intl.formatMessage({
defaultMessage: "under",
id: "common.under",
})} 4`}
</dt>
<dd>
<Typography variant="Tag/sm">
<p>
{intl.formatMessage({
defaultMessage: "Free",
id: "common.free",
})}
</p>
</Typography>
</dd>
</div>
</div>
)}
</dl>
)}
</div>
)
}

View File

@@ -1,9 +1,7 @@
import { type ReactNode } from "react"
import { useFormContext } from "react-hook-form" import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
import { Alert } from "@scandic-hotels/design-system/Alert"
import Body from "@scandic-hotels/design-system/Body"
import { ErrorMessage } from "@scandic-hotels/design-system/Form/ErrorMessage" import { ErrorMessage } from "@scandic-hotels/design-system/Form/ErrorMessage"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
@@ -13,29 +11,62 @@ import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import Select from "@/components/TempDesignSystem/Form/Select" import Select from "@/components/TempDesignSystem/Form/Select"
import { getErrorMessage } from "@/utils/getErrorMessage" import { getErrorMessage } from "@/utils/getErrorMessage"
import Summary from "../Summary"
import { BreakfastInfo } from "./BreakfastInfo"
import styles from "./selectQuantityStep.module.css" import styles from "./selectQuantityStep.module.css"
import type { SelectQuantityStepProps } from "@/types/components/myPages/myStay/ancillaries" import type {
InnerSelectQuantityStepProps,
SelectQuantityStepProps,
} from "@/types/components/myPages/myStay/ancillaries"
export default function SelectQuantityStep({ user }: SelectQuantityStepProps) { const cardQuantityOptions = Array.from({ length: 7 }, (_, i) => ({
const intl = useIntl() label: `${i}`,
value: i,
}))
export default function SelectQuantityStep({
user,
hideSummary = false,
}: SelectQuantityStepProps) {
const { isBreakfast, selectedAncillary } = useAddAncillaryStore((state) => ({ const { isBreakfast, selectedAncillary } = useAddAncillaryStore((state) => ({
isBreakfast: state.isBreakfast, isBreakfast: state.isBreakfast,
selectedAncillary: state.selectedAncillary, selectedAncillary: state.selectedAncillary,
})) }))
let content: ReactNode
if (isBreakfast) {
content = <BreakfastInfo />
} else if (!selectedAncillary?.requiresQuantity) {
content = null
} else {
content = (
<InnerSelectQuantityStep
user={user}
selectedAncillary={selectedAncillary}
/>
)
}
return (
<div className={styles.container}>
{content}
{!hideSummary && <Summary />}
</div>
)
}
function InnerSelectQuantityStep({
user,
selectedAncillary,
}: InnerSelectQuantityStepProps) {
const intl = useIntl()
const { const {
formState: { errors }, formState: { errors },
} = useFormContext() } = useFormContext()
if (isBreakfast) {
return <BreakfastInfo />
}
const cardQuantityOptions = Array.from({ length: 7 }, (_, i) => ({
label: `${i}`,
value: i,
}))
const pointsCost = selectedAncillary?.points ?? 0 const pointsCost = selectedAncillary?.points ?? 0
const currentPoints = user?.membership?.currentPoints ?? 0 const currentPoints = user?.membership?.currentPoints ?? 0
const maxAffordable = const maxAffordable =
@@ -131,95 +162,3 @@ export default function SelectQuantityStep({ user }: SelectQuantityStepProps) {
</div> </div>
) )
} }
function BreakfastInfo() {
const intl = useIntl()
const breakfastData = useAddAncillaryStore((state) => state.breakfastData)
if (!breakfastData) {
return intl.formatMessage({
id: "ancillaries.unableToDisplayBreakfastPrices",
defaultMessage: "Unable to display breakfast prices.",
})
}
return (
<div className={styles.breakfastContainer}>
<Alert
type={AlertTypeEnum.Info}
text={intl.formatMessage({
id: "addAncillary.selectQuantityStep.breakfastInfoMessage",
defaultMessage:
"Breakfast can only be added for the entire duration of the stay and for all guests.",
})}
/>
{(breakfastData.nrOfPayingChildren > 0 ||
breakfastData.nrOfFreeChildren > 0) && (
<dl className={styles.breakfastPrices}>
<div className={styles.breakfastPriceBox}>
<MaterialIcon icon="check_circle" className={styles.icon} />
<div>
<dt>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${breakfastData.nrOfAdults} × ${intl.formatMessage({
id: "common.adults",
defaultMessage: "Adults",
})}`}
</dt>
<dd>
<Body>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${breakfastData.priceAdult * breakfastData.nrOfAdults} ${breakfastData.currency}`}
</Body>
</dd>
</div>
</div>
{breakfastData.nrOfPayingChildren > 0 && (
<div className={styles.breakfastPriceBox}>
<MaterialIcon icon="check_circle" className={styles.icon} />
<div>
<dt>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${breakfastData.nrOfPayingChildren} × ${intl.formatMessage({
id: "common.ages",
defaultMessage: "ages",
})} 4-12`}
</dt>
<dd>
<Body>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${breakfastData.priceChild * breakfastData.nrOfPayingChildren} ${breakfastData.currency}`}
</Body>
</dd>
</div>
</div>
)}
{breakfastData.nrOfFreeChildren > 0 && (
<div className={`${styles.breakfastPriceBox} ${styles.free}`}>
<MaterialIcon icon="check_circle" className={styles.icon} />
<div>
<dt>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${breakfastData.nrOfFreeChildren} × ${intl.formatMessage({
defaultMessage: "under",
id: "common.under",
})} 4`}
</dt>
<dd>
<Body>
{intl.formatMessage({
defaultMessage: "Free",
id: "common.free",
})}
</Body>
</dd>
</div>
</div>
)}
</dl>
)}
</div>
)
}

View File

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

View File

@@ -0,0 +1,154 @@
import { useIntl } from "react-intl"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import styles from "./priceDetails.module.css"
import type { SelectedAncillary } from "@/types/components/myPages/myStay/ancillaries"
export default function PriceDetails({
totalPoints,
totalPrice,
selectedAncillary,
}: {
totalPoints: number | null
totalPrice: number | null
selectedAncillary: SelectedAncillary
}) {
const intl = useIntl()
const { isBreakfast, breakfastData } = useAddAncillaryStore((state) => ({
isBreakfast: state.isBreakfast,
breakfastData: state.breakfastData,
}))
if (isBreakfast && !breakfastData) {
return null
}
return (
<div className={styles.container}>
<div className={styles.totalPrice}>
<div className={styles.totalPriceInclVAT}>
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
id: "common.total",
defaultMessage: "Total",
})}
</p>
</Typography>
{totalPrice && (
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.vatText}>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`(${intl.formatMessage({
id: "common.inclVAT",
defaultMessage: "Incl. VAT",
})})`}
</p>
</Typography>
)}
{isBreakfast && breakfastData ? (
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.hideOnDesktop}>
{intl.formatMessage(
{
id: "booking.numberOfNights",
defaultMessage:
"{totalNights, plural, one {# night} other {# nights}}",
},
{
totalNights: breakfastData.nrOfNights,
}
) +
/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */
" / " +
intl.formatMessage(
{
id: "common.numberOfGuests",
defaultMessage:
"{value, plural, one {# guest} other {# guests}}",
},
{
value:
breakfastData.nrOfAdults +
breakfastData.nrOfPayingChildren +
breakfastData.nrOfFreeChildren,
}
)}
</p>
</Typography>
) : null}
</div>
<div className={styles.totalPriceValue}>
{isBreakfast && breakfastData ? (
<>
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.showOnDesktop}>
{intl.formatMessage(
{
id: "booking.numberOfNights",
defaultMessage:
"{totalNights, plural, one {# night} other {# nights}}",
},
{
totalNights: breakfastData.nrOfNights,
}
) +
/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */
" / " +
intl.formatMessage(
{
id: "common.numberOfGuests",
defaultMessage:
"{value, plural, one {# guest} other {# guests}}",
},
{
value:
breakfastData.nrOfAdults +
breakfastData.nrOfPayingChildren +
breakfastData.nrOfFreeChildren,
}
)}
</p>
</Typography>
<Divider variant="vertical" />
</>
) : null}
{totalPrice && (
<Typography variant="Body/Paragraph/mdBold">
<p>
{formatPrice(
intl,
totalPrice,
selectedAncillary.price.currency
)}
</p>
</Typography>
)}
{totalPoints && totalPrice && <Divider variant="vertical" />}
{totalPoints && (
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage(
{
id: "common.numberOfPoints",
defaultMessage:
"{points, plural, one {# point} other {# points}}",
},
{ points: totalPoints }
)}
</p>
</Typography>
)}
</div>
</div>
<Divider />
</div>
)
}

View File

@@ -1,12 +1,17 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.totalPrice { .totalPrice {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--Space-x1); gap: var(--Space-x1);
padding: var(--Space-x15);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-radius: var(--Corner-radius-md); border-radius: var(--Corner-radius-md);
} }
.showOnDesktop { .showOnDesktop {
display: none; display: none;
} }

View File

@@ -23,14 +23,12 @@ interface PriceSummaryProps {
}[] }[]
} }
export default function PriceSummary({ export function PriceSummary({
totalPrice, totalPrice,
totalPoints, totalPoints,
items, items,
}: PriceSummaryProps) { }: PriceSummaryProps) {
const intl = useIntl() const intl = useIntl()
const hasTotalPoints = typeof totalPoints === "number"
const hasTotalPrice = typeof totalPrice === "number"
return ( return (
<div className={styles.container}> <div className={styles.container}>
@@ -80,7 +78,7 @@ export default function PriceSummary({
))} ))}
<div className={styles.column}> <div className={styles.column}>
{hasTotalPrice ? ( {totalPrice ? (
<Typography variant="Body/Paragraph/mdRegular"> <Typography variant="Body/Paragraph/mdRegular">
<p> <p>
{intl.formatMessage( {intl.formatMessage(
@@ -110,15 +108,15 @@ export default function PriceSummary({
)} )}
<div className={styles.totalPrice}> <div className={styles.totalPrice}>
{(hasTotalPoints || hasTotalPrice) && ( {(totalPoints || totalPrice) && (
<Typography variant="Body/Paragraph/mdBold"> <Typography variant="Body/Paragraph/mdBold">
<p> <p>
{hasTotalPrice {totalPrice
? formatPrice(intl, totalPrice, items[0]?.currency) ? formatPrice(intl, totalPrice, items[0]?.currency)
: null} : null}
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{hasTotalPoints && hasTotalPrice ? " + " : null} {totalPoints && totalPrice ? " + " : null}
{hasTotalPoints {totalPoints
? intl.formatMessage( ? intl.formatMessage(
{ {
id: "common.numberOfPoints", id: "common.numberOfPoints",

View File

@@ -7,7 +7,6 @@
border-radius: var(--Corner-radius-lg); border-radius: var(--Corner-radius-lg);
border: 1px solid var(--Border-Divider-Default); border: 1px solid var(--Border-Divider-Default);
background: var(--Surface-Primary-Default); background: var(--Surface-Primary-Default);
margin: var(--Space-x1);
} }
.column { .column {

View File

@@ -0,0 +1,267 @@
import { cx } from "class-variance-authority"
import { useState } from "react"
import { useFormContext } from "react-hook-form"
import { type IntlShape, useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { Button, type ButtonProps } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import {
AncillaryStepEnum,
type BreakfastData,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import { trackAddAncillary } from "@/utils/tracking/myStay"
import PriceDetails from "./PriceDetails"
import { PriceSummary } from "./PriceSummary"
import styles from "./summary.module.css"
import type { SelectedAncillary } from "@/types/components/myPages/myStay/ancillaries"
export default function Summary({
isConfirmation = false,
}: {
isConfirmation?: boolean
}) {
const intl = useIntl()
const isMobile = useMediaQuery("(max-width: 767px)")
const [isPriceDetailsOpen, setIsPriceDetailsOpen] = useState(false)
function togglePriceDetails() {
setIsPriceDetailsOpen((isOpen) => !isOpen)
}
const {
prevStep,
selectedAncillary,
isBreakfast,
breakfastData,
currentStep,
selectQuantityAndDeliveryTime,
selectDeliveryTime,
selectQuantity,
} = useAddAncillaryStore((state) => ({
prevStep: state.prevStep,
currentStep: state.currentStep,
selectedAncillary: state.selectedAncillary,
isBreakfast: state.isBreakfast,
breakfastData: state.breakfastData,
selectQuantityAndDeliveryTime: state.selectQuantityAndDeliveryTime,
selectQuantity: state.selectQuantity,
selectDeliveryTime: state.selectDeliveryTime,
}))
const {
watch,
trigger,
formState: { isSubmitting },
} = useFormContext()
const quantityWithCard = watch("quantityWithCard") as number
const quantityWithPoints = watch("quantityWithPoints") as number
async function handleNextStep() {
if (currentStep === AncillaryStepEnum.selectQuantity) {
const isValid = await trigger(["quantityWithCard", "quantityWithPoints"])
if (isValid) {
trackAddAncillary(
selectedAncillary,
quantityWithCard,
quantityWithPoints,
breakfastData
)
if (isMobile) {
selectQuantityAndDeliveryTime()
} else {
selectQuantity()
}
}
} else if (currentStep === AncillaryStepEnum.selectDelivery) {
selectDeliveryTime()
}
}
if (!selectedAncillary || (!breakfastData && isBreakfast)) {
return null
}
const isSingleItem = !selectedAncillary.requiresQuantity
const buttonProps: ButtonProps = isConfirmation
? {
type: "submit",
form: "add-ancillary-form-id",
variant: "Primary",
}
: {
type: "button",
onPress: handleNextStep,
variant: isSingleItem ? "Primary" : "Secondary",
}
const buttonLabel = isConfirmation
? intl.formatMessage({
id: "common.confirm",
defaultMessage: "Confirm",
})
: intl.formatMessage({
id: "common.continue",
defaultMessage: "Continue",
})
const items = isBreakfast
? getBreakfastItems(intl, selectedAncillary, breakfastData)
: [
{
title: selectedAncillary.title,
totalPrice: selectedAncillary.price.total,
currency: selectedAncillary.price.currency,
points: selectedAncillary.points,
quantityWithCard,
quantityWithPoints,
},
]
const totalPrice = isBreakfast
? breakfastData!.totalPrice
: quantityWithCard && selectedAncillary
? selectedAncillary.price.total * quantityWithCard
: null
const totalPoints =
quantityWithPoints && selectedAncillary?.points
? selectedAncillary.points * quantityWithPoints
: null
return (
<div
className={cx(styles.summary, {
[styles.backgroundBox]: isConfirmation || isSingleItem,
})}
>
{(isSingleItem || isConfirmation) && (
<PriceDetails
totalPrice={totalPrice}
totalPoints={totalPoints}
selectedAncillary={selectedAncillary}
/>
)}
{isConfirmation && isPriceDetailsOpen && (
<PriceSummary
totalPrice={totalPrice}
totalPoints={totalPoints}
items={items}
/>
)}
<div
className={cx({
[styles.confirmButtons]: isConfirmation,
})}
>
{isConfirmation && (
<Button
type="button"
typography="Body/Supporting text (caption)/smBold"
size="Small"
variant="Text"
onPress={togglePriceDetails}
className={styles.priceButton}
>
{intl.formatMessage({
id: "commonpriceDetails",
defaultMessage: "Price details",
})}
<MaterialIcon
icon={
isPriceDetailsOpen ? "keyboard_arrow_up" : "keyboard_arrow_down"
}
size={20}
color="CurrentColor"
/>
</Button>
)}
<div className={styles.buttons}>
<Button
typography="Body/Supporting text (caption)/smBold"
type="button"
variant="Text"
size="Small"
color="Primary"
onPress={() => prevStep(isMobile)}
>
{intl.formatMessage({
id: "common.back",
defaultMessage: "Back",
})}
</Button>
<Button
typography="Body/Supporting text (caption)/smBold"
size="Small"
isDisabled={isSubmitting}
isPending={isSubmitting}
{...buttonProps}
>
{buttonLabel}
</Button>
</div>
</div>
</div>
)
}
function getBreakfastItems(
intl: IntlShape,
selectedAncillary: SelectedAncillary,
breakfastData: BreakfastData | null
) {
if (!breakfastData) {
return []
}
const items = [
{
title: `${selectedAncillary.title} / ${intl.formatMessage({
id: "common.adult",
defaultMessage: "adult",
})}`,
totalPrice: breakfastData.priceAdult,
currency: breakfastData.currency,
quantityWithCard: breakfastData.nrOfAdults * breakfastData.nrOfNights,
},
]
if (breakfastData.nrOfPayingChildren > 0) {
items.push({
title: `${selectedAncillary.title} / ${intl.formatMessage({
id: "common.children",
defaultMessage: "Children",
})} 4-12`,
totalPrice: breakfastData.priceChild,
currency: breakfastData.currency,
quantityWithCard:
breakfastData.nrOfPayingChildren * breakfastData.nrOfNights,
})
}
if (breakfastData.nrOfFreeChildren > 0) {
items.push({
title: `${selectedAncillary.title} / ${intl.formatMessage(
{
id: "common.childrenUnderAge",
defaultMessage: "Children under {age}",
},
{ age: 4 }
)}`,
totalPrice: 0,
currency: breakfastData.currency,
quantityWithCard:
breakfastData.nrOfFreeChildren * breakfastData.nrOfNights,
})
}
return items
}

View File

@@ -0,0 +1,33 @@
.summmary {
display: flex;
flex-direction: column;
justify-content: space-between;
margin-top: var(--Space-x2);
}
.backgroundBox {
display: flex;
flex-direction: column;
background: var(--Surface-Primary-OnSurface-Default);
padding: var(--Space-x15);
gap: var(--Space-x2);
border-radius: var(--Corner-radius-md);
}
.buttons {
display: flex;
gap: var(--Space-x4);
justify-content: flex-end;
}
.confirmButtons {
display: flex;
padding-left: var(--Space-x15);
justify-content: space-between;
align-items: baseline;
}
.priceButton {
display: flex;
gap: var(--Space-x05);
}

View File

@@ -1,11 +1,3 @@
.modalWrapper {
display: flex;
flex-direction: column;
max-height: 70dvh;
width: 100%;
margin-top: var(--Space-x3);
}
.form { .form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -21,74 +13,3 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.price {
display: flex;
gap: var(--Space-x2);
align-items: center;
}
.contentContainer {
display: flex;
flex-direction: column;
}
.confirmStep {
display: flex;
flex-direction: column;
justify-content: space-between;
border-radius: var(--Corner-radius-md);
background: var(--Surface-Primary-OnSurface-Default);
padding-bottom: var(--Space-x15);
margin-top: var(--Space-x2);
}
.description {
display: flex;
margin: var(--Space-x2) 0;
}
.pointsDivider {
display: flex;
gap: var(--Space-x2);
height: 24px;
}
@media screen and (min-width: 768px) {
.modalWrapper {
width: 492px;
}
.selectAncillarycontainer {
width: 600px;
}
.imageContainer {
height: 240px;
}
}
@media screen and (min-width: 1052px) {
.selectAncillarycontainer {
width: 833px;
}
}
.breakfastPriceList {
display: flex;
flex-direction: column;
}
.divider {
display: none;
height: var(--Space-x4);
}
@media screen and (min-width: 768px) {
.breakfastPriceList {
flex-direction: row;
gap: var(--Space-x2);
}
.divider {
display: block;
}
}

View File

@@ -7,22 +7,12 @@ import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod" import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
import { guaranteeCallback } from "@scandic-hotels/common/constants/routes/hotelReservation"
import { dt } from "@scandic-hotels/common/dt" import { dt } from "@scandic-hotels/common/dt"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner" import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner"
import Modal from "@scandic-hotels/design-system/Modal"
import { toast } from "@scandic-hotels/design-system/Toast" import { toast } from "@scandic-hotels/design-system/Toast"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@scandic-hotels/trpc/client" import { trpc } from "@scandic-hotels/trpc/client"
import { isWebview } from "@/constants/routes/webviews" import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import { env } from "@/env/client"
import {
AncillaryStepEnum,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import { import {
buildAncillaryPackages, buildAncillaryPackages,
@@ -41,13 +31,13 @@ import {
import { isAncillaryError } from "../../../utils" import { isAncillaryError } from "../../../utils"
import { type AncillaryFormData, ancillaryFormSchema } from "../schema" import { type AncillaryFormData, ancillaryFormSchema } from "../schema"
import ActionButtons from "./ActionButtons" import Description from "./Description"
import PriceDetails from "./PriceDetails"
import Steps from "./Steps" import Steps from "./Steps"
import { import {
buildBreakfastPackages, buildBreakfastPackages,
calculateBreakfastData, calculateBreakfastData,
getErrorMessage, getErrorMessage,
getGuaranteeCallback,
} from "./utils" } from "./utils"
import styles from "./addAncillaryFlowModal.module.css" import styles from "./addAncillaryFlowModal.module.css"
@@ -65,14 +55,12 @@ export default function AddAncillaryFlowModal({
savedCreditCards, savedCreditCards,
}: AddAncillaryFlowModalProps) { }: AddAncillaryFlowModalProps) {
const { const {
currentStep,
selectedAncillary, selectedAncillary,
closeModal, closeModal,
breakfastData, breakfastData,
setBreakfastData, setBreakfastData,
isBreakfast, isBreakfast,
} = useAddAncillaryStore((state) => ({ } = useAddAncillaryStore((state) => ({
currentStep: state.currentStep,
selectedAncillary: state.selectedAncillary, selectedAncillary: state.selectedAncillary,
closeModal: state.closeModal, closeModal: state.closeModal,
breakfastData: state.breakfastData, breakfastData: state.breakfastData,
@@ -85,14 +73,11 @@ export default function AddAncillaryFlowModal({
const searchParams = useSearchParams() const searchParams = useSearchParams()
const pathname = usePathname() const pathname = usePathname()
const [isPriceDetailsOpen, setIsPriceDetailsOpen] = useState(false)
const [errorMessage, setErrorMessage] = const [errorMessage, setErrorMessage] =
useState<AncillaryErrorMessage | null>(null) useState<AncillaryErrorMessage | null>(null)
const guaranteeRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}${guaranteeCallback(lang, isWebview(pathname))}` const guaranteeRedirectUrl = getGuaranteeCallback(lang, pathname)
const deliveryTimeOptions = generateDeliveryOptions() const deliveryTimeOptions = generateDeliveryOptions()
const defaultDeliveryTime = deliveryTimeOptions[0].value
const hasInsufficientPoints = const hasInsufficientPoints =
(user?.membership?.currentPoints ?? 0) < (selectedAncillary?.points ?? 0) (user?.membership?.currentPoints ?? 0) < (selectedAncillary?.points ?? 0)
@@ -100,8 +85,11 @@ export default function AddAncillaryFlowModal({
defaultValues: { defaultValues: {
quantityWithPoints: null, quantityWithPoints: null,
quantityWithCard: quantityWithCard:
!user || hasInsufficientPoints || isBreakfast ? 1 : null, !user || hasInsufficientPoints || !selectedAncillary?.requiresQuantity
deliveryTime: booking.ancillary?.deliveryTime ?? defaultDeliveryTime, ? 1
: null,
deliveryTime:
booking.ancillary?.deliveryTime ?? deliveryTimeOptions[0].value,
optionalText: "", optionalText: "",
termsAndConditions: false, termsAndConditions: false,
paymentMethod: booking.guaranteeInfo paymentMethod: booking.guaranteeInfo
@@ -124,17 +112,13 @@ export default function AddAncillaryFlowModal({
{ ancillary: selectedAncillary?.title } { ancillary: selectedAncillary?.title }
) )
function togglePriceDetails() {
setIsPriceDetailsOpen((isOpen) => !isOpen)
}
const utils = trpc.useUtils() const utils = trpc.useUtils()
const addAncillary = trpc.booking.packages.useMutation() const addAncillary = trpc.booking.packages.useMutation()
const { guaranteeBooking, isLoading, handleGuaranteeError } = const { guaranteeBooking, isLoading, handleGuaranteeError } =
useGuaranteeBooking(booking.refId, true, booking.hotelId) useGuaranteeBooking(booking.refId, true, booking.hotelId)
function handleAncillarySubmission( async function handleAncillarySubmission(
data: AncillaryFormData, data: AncillaryFormData,
packages: { packages: {
code: string code: string
@@ -142,7 +126,7 @@ export default function AddAncillaryFlowModal({
comment: string | undefined comment: string | undefined
}[] }[]
) { ) {
addAncillary.mutate( await addAncillary.mutateAsync(
{ {
refId: booking.refId, refId: booking.refId,
ancillaryComment: data.optionalText, ancillaryComment: data.optionalText,
@@ -188,6 +172,7 @@ export default function AddAncillaryFlowModal({
breakfastData breakfastData
) )
toast.error(ancillaryErrorMessage) toast.error(ancillaryErrorMessage)
closeModal()
} }
}, },
onError: () => { onError: () => {
@@ -198,12 +183,13 @@ export default function AddAncillaryFlowModal({
breakfastData breakfastData
) )
toast.error(ancillaryErrorMessage) toast.error(ancillaryErrorMessage)
closeModal()
}, },
} }
) )
} }
function handleGuaranteePayment( async function handleGuaranteePayment(
data: AncillaryFormData, data: AncillaryFormData,
packages: AncillaryItem[] packages: AncillaryItem[]
) { ) {
@@ -225,7 +211,7 @@ export default function AddAncillaryFlowModal({
cardType: savedCreditCard.cardType, cardType: savedCreditCard.cardType,
} }
: undefined : undefined
guaranteeBooking.mutate({ await guaranteeBooking.mutateAsync({
refId: booking.refId, refId: booking.refId,
language: lang, language: lang,
...(card && { card }), ...(card && { card }),
@@ -238,7 +224,7 @@ export default function AddAncillaryFlowModal({
} }
} }
const onSubmit = (data: AncillaryFormData) => { const onSubmit = async (data: AncillaryFormData) => {
const packagesToAdd = !isBreakfast const packagesToAdd = !isBreakfast
? buildAncillaryPackages(data, selectedAncillary) ? buildAncillaryPackages(data, selectedAncillary)
: breakfastData : breakfastData
@@ -261,13 +247,12 @@ export default function AddAncillaryFlowModal({
isBreakfast, isBreakfast,
breakfastData, breakfastData,
}) })
const shouldSkipGuarantee = const shouldSkipGuarantee = booking.guaranteeInfo || !data.quantityWithCard
booking.guaranteeInfo || (data.quantityWithCard ?? 0) <= 0
if (shouldSkipGuarantee) { if (shouldSkipGuarantee) {
handleAncillarySubmission(data, packagesToAdd) await handleAncillarySubmission(data, packagesToAdd)
} else { } else {
handleGuaranteePayment(data, packagesToAdd) await handleGuaranteePayment(data, packagesToAdd)
} }
} }
@@ -323,175 +308,22 @@ export default function AddAncillaryFlowModal({
) )
} }
const modalTitle =
currentStep === AncillaryStepEnum.selectAncillary
? intl.formatMessage({
id: "ancillaries.upgradeYourStay",
defaultMessage: "Upgrade your stay",
})
: selectedAncillary?.title
return ( return (
<Modal isOpen={true} onToggle={closeModal} title={modalTitle}> <FormProvider {...formMethods}>
<div <form
className={`${styles.modalWrapper} ${currentStep === AncillaryStepEnum.selectAncillary ? styles.selectAncillarycontainer : ""}`} onSubmit={formMethods.handleSubmit(onSubmit)}
className={styles.form}
id="add-ancillary-form-id"
> >
<FormProvider {...formMethods}> <div className={styles.modalScrollable}>
<form <Description />
onSubmit={formMethods.handleSubmit(onSubmit)} <Steps
className={styles.form} user={user}
id="add-ancillary-form-id" savedCreditCards={savedCreditCards}
> error={errorMessage}
<div className={styles.modalScrollable}> />
{selectedAncillary && ( </div>
<> </form>
{currentStep !== AncillaryStepEnum.confirmation && ( </FormProvider>
<div className={styles.contentContainer}>
<div className={styles.price}>
<Typography variant="Body/Paragraph/mdBold">
{isBreakfast ? (
<BreakfastPriceList />
) : (
<p>
{formatPrice(
intl,
selectedAncillary.price.total,
selectedAncillary.price.currency
)}
</p>
)}
</Typography>
{selectedAncillary.points && (
<div className={styles.pointsDivider}>
<Divider variant="vertical" />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage(
{
id: "common.numberOfPoints",
defaultMessage:
"{points, plural, one {# point} other {# points}}",
},
{
points: selectedAncillary.points,
}
)}
</p>
</Typography>
</div>
)}
</div>
<div className={styles.description}>
{selectedAncillary.description && (
<Typography variant="Body/Paragraph/mdRegular">
<p
dangerouslySetInnerHTML={{
__html: selectedAncillary.description,
}}
></p>
</Typography>
)}
</div>
</div>
)}
</>
)}
<Steps
user={user}
savedCreditCards={savedCreditCards}
error={errorMessage}
/>
{currentStep === AncillaryStepEnum.selectAncillary ? null : (
<div
className={
currentStep === AncillaryStepEnum.confirmation ||
isBreakfast
? styles.confirmStep
: ""
}
>
<PriceDetails isPriceDetailsOpen={isPriceDetailsOpen} />
<ActionButtons
isPriceDetailsOpen={isPriceDetailsOpen}
togglePriceDetails={togglePriceDetails}
isSubmitting={addAncillary.isPending || isLoading}
/>
</div>
)}
</div>
</form>
</FormProvider>
</div>
</Modal>
)
}
function BreakfastPriceList() {
const intl = useIntl()
const breakfastData = useAddAncillaryStore((state) => state.breakfastData)
if (!breakfastData) {
return intl.formatMessage({
id: "ancillaries.unableToDisplayBreakfastPrices",
defaultMessage: "Unable to display breakfast prices.",
})
}
return (
<div>
<div className={styles.breakfastPriceList}>
<Typography variant="Body/Paragraph/mdBold">
<span>
{intl.formatMessage(
{
id: "addAncillaryFlowModal.pricePerNightPerAdult",
defaultMessage: "{price}/night per adult",
},
{
price: `${breakfastData.priceAdult} ${breakfastData.currency}`,
}
)}
</span>
</Typography>
{breakfastData.nrOfPayingChildren > 0 && (
<>
<div className={styles.divider}>
<Divider variant="vertical" color="Border/Divider/Subtle" />
</div>
<Typography variant="Body/Paragraph/mdBold">
<span>
{intl.formatMessage(
{
id: "addAncillaryFlowModal.pricePerNightPerKids",
defaultMessage: "{price}/night for kids (ages 412)",
},
{
price: `${breakfastData.priceChild} ${breakfastData.currency}`,
}
)}
</span>
</Typography>
</>
)}
{breakfastData.nrOfFreeChildren > 0 && (
<>
<div className={styles.divider}>
<Divider variant="vertical" color="Border/Divider/Subtle" />
</div>
<Typography variant="Body/Paragraph/mdBold">
<span>
{intl.formatMessage({
id: "addAncillaryFlowModal.freeBreakfastForKids",
defaultMessage: "Free for kids (under 4)",
})}
</span>
</Typography>
</>
)}
</div>
</div>
) )
} }

View File

@@ -1,7 +1,12 @@
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert" import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
import { guaranteeCallback } from "@scandic-hotels/common/constants/routes/hotelReservation"
import { BookingErrorCodeEnum } from "@scandic-hotels/trpc/enums/bookingErrorCode" import { BookingErrorCodeEnum } from "@scandic-hotels/trpc/enums/bookingErrorCode"
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast" import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
import { isWebview } from "@/constants/routes/webviews"
import { env } from "@/env/client"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { Packages } from "@scandic-hotels/trpc/types/packages" import type { Packages } from "@scandic-hotels/trpc/types/packages"
import type { IntlShape } from "react-intl" import type { IntlShape } from "react-intl"
@@ -43,27 +48,28 @@ export function calculateBreakfastData(
const childPackage = packages.find( const childPackage = packages.find(
(p) => p.code === BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST (p) => p.code === BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST
) )
const priceAdult = adultPackage?.localPrice.price const priceAdult = adultPackage?.localPrice.price ?? 0
const priceChild = childPackage?.localPrice.price const priceChild = childPackage?.localPrice.price ?? 0
const currency = const currency =
adultPackage?.localPrice.currency ?? childPackage?.localPrice.currency adultPackage?.localPrice.currency ?? childPackage?.localPrice.currency
if ( const totalPrice =
typeof priceAdult !== "number" || priceAdult * nrOfAdults * nrOfNights +
typeof priceChild !== "number" || priceChild * nrOfPayingChildren * nrOfNights
typeof currency !== "string"
) { if (!currency) {
return null return null
} else { }
return {
nrOfAdults, return {
nrOfPayingChildren, nrOfAdults,
nrOfFreeChildren, nrOfPayingChildren,
nrOfNights, nrOfFreeChildren,
priceAdult, nrOfNights,
priceChild, priceAdult,
currency, priceChild,
} currency,
totalPrice,
} }
} }
@@ -134,3 +140,7 @@ export function getErrorMessage(
} }
} }
} }
export function getGuaranteeCallback(lang: Lang, pathname: string) {
return `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}${guaranteeCallback(lang, isWebview(pathname))}`
}

View File

@@ -1,8 +1,22 @@
import Modal from "@scandic-hotels/design-system/Modal"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow" import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import styles from "./wrapper.module.css"
export default function AncillaryFlowModalWrapper({ export default function AncillaryFlowModalWrapper({
children, children,
}: React.PropsWithChildren) { }: React.PropsWithChildren) {
const isOpen = useAddAncillaryStore((state) => state.isOpen) const { isOpen, closeModal, selectedAncillaryTitle } = useAddAncillaryStore(
return isOpen ? <>{children}</> : null (state) => ({
isOpen: state.isOpen,
closeModal: state.closeModal,
selectedAncillaryTitle: state.selectedAncillary?.title,
})
)
return (
<Modal isOpen={isOpen} onToggle={closeModal} title={selectedAncillaryTitle}>
<div className={styles.modalWrapper}>{children}</div>
</Modal>
)
} }

View File

@@ -0,0 +1,13 @@
.modalWrapper {
display: flex;
flex-direction: column;
max-height: 70dvh;
width: 100%;
margin-top: var(--Space-x3);
}
@media screen and (min-width: 768px) {
.modalWrapper {
width: 492px;
}
}

View File

@@ -12,18 +12,6 @@ export const ancillaryError = {
MIN_QUANTITY_NOT_REACHED: "MIN_QUANTITY_NOT_REACHED", MIN_QUANTITY_NOT_REACHED: "MIN_QUANTITY_NOT_REACHED",
} as const } as const
export const quantitySchema = z
.object({})
.merge(quantitySchemaWithoutRefine)
.refine(
(data) =>
(data.quantityWithPoints ?? 0) > 0 || (data.quantityWithCard ?? 0) > 0,
{
message: ancillaryError.MIN_QUANTITY_NOT_REACHED,
path: ["quantityWithCard"],
}
)
export const ancillaryFormSchema = z export const ancillaryFormSchema = z
.object({ .object({
deliveryTime: z.string(), deliveryTime: z.string(),

View File

@@ -4,11 +4,15 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow" import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import WrappedAncillaryCard from "../../../WrappedAncillaryCard" import WrappedAncillaryCard from "../../Card"
import styles from "./selectAncillaryStep.module.css" import styles from "./selectAncillaryStep.module.css"
export default function SelectAncillaryStep() { export default function SelectAncillaryStep({
onClose,
}: {
onClose: () => void
}) {
const { const {
ancillariesBySelectedCategory, ancillariesBySelectedCategory,
selectedCategory, selectedCategory,
@@ -21,6 +25,7 @@ export default function SelectAncillaryStep() {
selectCategory: state.selectCategory, selectCategory: state.selectCategory,
})) }))
const intl = useIntl() const intl = useIntl()
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.tabs}> <div className={styles.tabs}>
@@ -46,7 +51,11 @@ export default function SelectAncillaryStep() {
<div className={styles.grid}> <div className={styles.grid}>
{ancillariesBySelectedCategory.map((ancillary) => ( {ancillariesBySelectedCategory.map((ancillary) => (
<WrappedAncillaryCard key={ancillary.id} ancillary={ancillary} /> <WrappedAncillaryCard
key={ancillary.id}
ancillary={ancillary}
onClose={onClose}
/>
))} ))}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,19 @@
.modalWrapper {
display: flex;
flex-direction: column;
max-height: 70dvh;
width: 100%;
margin-top: var(--Space-x3);
}
@media screen and (min-width: 768px) {
.modalWrapper {
width: 600px;
}
}
@media screen and (min-width: 1052px) {
.modalWrapper {
width: 833px;
}
}

View File

@@ -0,0 +1,44 @@
"use client"
import { useState } from "react"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Modal from "@scandic-hotels/design-system/Modal"
import SelectAncillaryStep from "./SelectAncillaryStep"
import styles from "./allAncillariesModal.module.css"
export default function AllAncillariesModal() {
const [isOpen, setIsOpen] = useState(false)
const intl = useIntl()
const modalTitle = intl.formatMessage({
id: "ancillaries.upgradeYourStay",
defaultMessage: "Upgrade your stay",
})
return (
<div>
<Button
variant="Text"
size="Small"
color="Primary"
onPress={() => setIsOpen(true)}
>
{intl.formatMessage({
id: "common.seeAll",
defaultMessage: "See all",
})}
<MaterialIcon icon="chevron_right" size={20} color="CurrentColor" />
</Button>
<Modal isOpen={isOpen} onToggle={setIsOpen} title={modalTitle}>
<div className={styles.modalWrapper}>
<SelectAncillaryStep onClose={() => setIsOpen(false)} />
</div>
</Modal>
</div>
)
}

View File

@@ -7,9 +7,11 @@ import type { SelectedAncillary } from "@/types/components/myPages/myStay/ancill
interface WrappedAncillaryProps { interface WrappedAncillaryProps {
ancillary: SelectedAncillary ancillary: SelectedAncillary
onClose?: () => void
} }
export default function WrappedAncillaryCard({ export default function WrappedAncillaryCard({
onClose,
ancillary, ancillary,
}: WrappedAncillaryProps) { }: WrappedAncillaryProps) {
const { description, ...ancillaryWithoutDescription } = ancillary const { description, ...ancillaryWithoutDescription } = ancillary
@@ -18,18 +20,22 @@ export default function WrappedAncillaryCard({
booking: state.booking, booking: state.booking,
})) }))
function clickAncillary() {
if (typeof onClose === "function") {
onClose()
}
selectAncillary(ancillary)
trackViewAncillary(ancillary, booking)
}
return ( return (
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onClick={() => { onClick={clickAncillary}
selectAncillary(ancillary)
trackViewAncillary(ancillary, booking)
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
selectAncillary(ancillary) clickAncillary()
trackViewAncillary(ancillary, booking)
} }
}} }}
> >

View File

@@ -1,26 +0,0 @@
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
export default function ViewAllAncillaries() {
const intl = useIntl()
const openModal = useAddAncillaryStore((state) => state.openModal)
return (
<Button
theme="base"
variant="icon"
intent="text"
size="small"
onClick={openModal}
>
{intl.formatMessage({
id: "common.seeAll",
defaultMessage: "See all",
})}
<MaterialIcon icon="chevron_right" size={20} color="CurrentColor" />
</Button>
)
}

View File

@@ -1,28 +1,23 @@
"use client" "use client"
import { use } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import Title from "@scandic-hotels/design-system/Title" import { Typography } from "@scandic-hotels/design-system/Typography"
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
import { useMyStayStore } from "@/stores/my-stay" import { useMyStayStore } from "@/stores/my-stay"
import { Carousel } from "@/components/Carousel" import { Carousel } from "@/components/Carousel"
import { useAncillaries } from "@/hooks/useAncillaries"
import { AddAncillaryProvider } from "@/providers/AddAncillaryProvider" import { AddAncillaryProvider } from "@/providers/AddAncillaryProvider"
import AddAncillaryFlowModal from "./AddAncillaryFlow/AddAncillaryFlowModal" import AddAncillaryFlowModal from "./AddAncillaryFlow/AddAncillaryFlowModal"
import AncillaryFlowModalWrapper from "./AddAncillaryFlow/AncillaryFlowModalWrapper" import AncillaryFlowModalWrapper from "./AddAncillaryFlow/AncillaryFlowModalWrapper"
import WrappedAncillaryCard from "./AddAncillaryFlow/WrappedAncillaryCard" import AllAncillariesModal from "./AllAncillariesModal/input"
import { AddedAncillaries } from "./AddedAncillaries" import { AddedAncillaries } from "./AddedAncillaries"
import { generateUniqueAncillaries, mapAncillaries } from "./utils" import WrappedAncillaryCard from "./Card"
import ViewAllAncillaries from "./ViewAllAncillaries"
import styles from "./ancillaries.module.css" import styles from "./ancillaries.module.css"
import type { import type { AncillariesProps } from "@/types/components/myPages/myStay/ancillaries"
AncillariesProps,
SelectedAncillary,
} from "@/types/components/myPages/myStay/ancillaries"
export function Ancillaries({ export function Ancillaries({
ancillariesPromise, ancillariesPromise,
@@ -31,91 +26,35 @@ export function Ancillaries({
user, user,
}: AncillariesProps) { }: AncillariesProps) {
const intl = useIntl() const intl = useIntl()
const ancillaries = use(ancillariesPromise)
const bookedRoom = useMyStayStore((state) => state.bookedRoom) const bookedRoom = useMyStayStore((state) => state.bookedRoom)
if (!bookedRoom || bookedRoom.isCancelled || !bookedRoom.showAncillaries) { const ancillaries = useAncillaries(ancillariesPromise, packages, user)
if (!ancillaries || !bookedRoom) {
return null return null
} }
const alreadyHasBreakfast =
bookedRoom.rateDefinition.breakfastIncluded || bookedRoom.breakfast
const breakfastPackageAdults = alreadyHasBreakfast
? undefined
: packages?.find(
(p) => p.code === BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST
)
/**
* A constructed ancillary for breakfast
*
* This is a "fake" ancillary for breakfast, since breakfast isn't really an
* ancillary in the system. This makes it play nicely with the add ancillary
* flow. If the user shouldn't be able to add breakfast this will be `undefined`.
*/
const breakfastAncillary: SelectedAncillary | undefined =
breakfastPackageAdults
? {
description: intl.formatMessage({
id: "common.buffet",
defaultMessage: "Buffet",
}),
id: breakfastPackageAdults.code,
title: intl.formatMessage({
id: "common.breakfast",
defaultMessage: "Breakfast",
}),
price: {
currency: breakfastPackageAdults.localPrice.currency,
total: breakfastPackageAdults.localPrice.totalPrice,
},
imageUrl:
"https://images.scandichotels.com/publishedmedia/inyre69evkpzgtygjnvp/Breakfast_-_Scandic_Sweden_-_Free_to_use.jpg",
requiresDeliveryTime: false,
loyaltyCode: undefined,
points: undefined,
hotelId: Number(bookedRoom.hotelId),
internalCategoryName: "Food",
translatedCategoryName: intl.formatMessage({
id: "common.food",
defaultMessage: "Food",
}),
}
: undefined
const allAncillaries = mapAncillaries(
intl,
ancillaries,
breakfastAncillary,
user
)
if (!allAncillaries.length) {
return null
}
const uniqueAncillaries = generateUniqueAncillaries(allAncillaries)
return ( return (
<AddAncillaryProvider booking={bookedRoom} ancillaries={allAncillaries}> <AddAncillaryProvider booking={bookedRoom} ancillaries={ancillaries.all}>
<div className={styles.container}> <div className={styles.container}>
{uniqueAncillaries.length > 0 && bookedRoom.canModifyAncillaries && ( {ancillaries.unique.length > 0 && bookedRoom.canModifyAncillaries && (
<> <>
<div className={styles.title}> <div className={styles.title}>
<Title as="h5"> <Typography variant="Title/Subtitle/lg">
{intl.formatMessage({ <h2>
id: "ancillaries.upgradeYourStay", {intl.formatMessage({
defaultMessage: "Upgrade your stay", id: "ancillaries.upgradeYourStay",
})} defaultMessage: "Upgrade your stay",
</Title> })}
</h2>
</Typography>
<div className={styles.viewAllLink}> <div className={styles.viewAllLink}>
<ViewAllAncillaries /> <AllAncillariesModal />
</div> </div>
</div> </div>
<div className={styles.ancillaries}> <div className={styles.ancillaries}>
{uniqueAncillaries.slice(0, 4).map((ancillary) => ( {ancillaries.unique.slice(0, 4).map((ancillary) => (
<WrappedAncillaryCard <WrappedAncillaryCard
ancillary={ancillary} ancillary={ancillary}
key={ancillary.id} key={ancillary.id}
@@ -126,7 +65,7 @@ export function Ancillaries({
<div className={styles.mobileAncillaries}> <div className={styles.mobileAncillaries}>
<Carousel> <Carousel>
<Carousel.Content> <Carousel.Content>
{uniqueAncillaries.map((ancillary) => { {ancillaries.unique.map((ancillary) => {
return ( return (
<Carousel.Item key={ancillary.id}> <Carousel.Item key={ancillary.id}>
<WrappedAncillaryCard ancillary={ancillary} /> <WrappedAncillaryCard ancillary={ancillary} />
@@ -142,7 +81,7 @@ export function Ancillaries({
<AddedAncillaries <AddedAncillaries
booking={bookedRoom} booking={bookedRoom}
ancillaries={uniqueAncillaries} ancillaries={ancillaries.unique}
/> />
<AncillaryFlowModalWrapper> <AncillaryFlowModalWrapper>

View File

@@ -1,101 +0,0 @@
import type { User } from "@scandic-hotels/trpc/types/user"
import type { IntlShape } from "react-intl"
import type {
Ancillaries,
Ancillary,
SelectedAncillary,
} from "@/types/components/myPages/myStay/ancillaries"
function filterPoints(ancillaries: Ancillaries, user: User | null) {
return ancillaries.map((ancillary) => {
return {
...ancillary,
ancillaryContent: ancillary.ancillaryContent.map(
({ points, ...ancillary }) => ({
...ancillary,
points: user ? points : undefined,
})
),
}
})
}
export function generateUniqueAncillaries(
ancillaries: Ancillaries
): Ancillary["ancillaryContent"] {
const uniqueAncillaries = new Map(
ancillaries.flatMap((a) => {
return a.ancillaryContent.map((ancillary) => [ancillary.id, ancillary])
})
)
return [...uniqueAncillaries.values()]
}
/**
* Adds the breakfast package to the ancillaries
*
* Returns the ancillaries array with the breakfast package added to the
* specified category. If the category doesn't exist it's created.
*/
function addBreakfastPackage(
ancillaries: Ancillaries,
breakfast: SelectedAncillary | undefined,
internalCategoryName: string,
translatedCategoryName: string
): Ancillaries {
if (!breakfast) return ancillaries
const category = ancillaries.find(
(a) => a.internalCategoryName === internalCategoryName
)
if (category) {
const newCategory = {
...category,
ancillaryContent: [breakfast, ...category.ancillaryContent],
}
return ancillaries.map((ancillary) =>
ancillary.internalCategoryName === internalCategoryName
? newCategory
: ancillary
)
}
return [
{
internalCategoryName,
translatedCategoryName,
ancillaryContent: [breakfast],
},
...ancillaries,
]
}
export function mapAncillaries(
intl: IntlShape,
ancillaries: Ancillaries | null,
breakfastAncillary: SelectedAncillary | undefined,
user: User | null
) {
const withBreakfastPopular = addBreakfastPackage(
ancillaries ?? [],
breakfastAncillary,
"Popular",
intl.formatMessage({
defaultMessage: "Popular",
id: "myStay.ancillaries.popularCategory",
})
)
const withBreakfastFood = addBreakfastPackage(
withBreakfastPopular,
breakfastAncillary,
"Food",
intl.formatMessage({
id: "common.food",
defaultMessage: "Food",
})
)
return filterPoints(withBreakfastFood, user)
}

View File

@@ -0,0 +1,185 @@
import { use } from "react"
import { type IntlShape, useIntl } from "react-intl"
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
import { useMyStayStore } from "@/stores/my-stay"
import type { User } from "@scandic-hotels/trpc/types/user"
import type {
Ancillaries,
Ancillary,
Packages,
SelectedAncillary,
} from "@/types/components/myPages/myStay/ancillaries"
export function useAncillaries(
ancillariesPromise: Promise<Ancillaries | null>,
packages: Packages | null,
user: User | null
) {
const intl = useIntl()
const bookedRoom = useMyStayStore((state) => state.bookedRoom)
if (!bookedRoom || bookedRoom.isCancelled || !bookedRoom.showAncillaries) {
return null
}
const ancillaries = use(ancillariesPromise)
const alreadyHasBreakfast =
bookedRoom.rateDefinition.breakfastIncluded || bookedRoom.breakfast
const breakfastPackageAdults = alreadyHasBreakfast
? undefined
: packages?.find(
(p) => p.code === BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST
)
/**
* A constructed ancillary for breakfast
*
* This is a "fake" ancillary for breakfast, since breakfast isn't really an
* ancillary in the system. This makes it play nicely with the add ancillary
* flow. If the user shouldn't be able to add breakfast this will be `undefined`.
*/
const breakfastAncillary: SelectedAncillary | undefined =
breakfastPackageAdults
? {
description: intl.formatMessage({
id: "common.buffet",
defaultMessage: "Buffet",
}),
id: breakfastPackageAdults.code,
title: intl.formatMessage({
id: "common.breakfast",
defaultMessage: "Breakfast",
}),
price: {
currency: breakfastPackageAdults.localPrice.currency,
total: breakfastPackageAdults.localPrice.totalPrice,
},
imageUrl:
"https://images.scandichotels.com/publishedmedia/inyre69evkpzgtygjnvp/Breakfast_-_Scandic_Sweden_-_Free_to_use.jpg",
requiresDeliveryTime: false,
loyaltyCode: undefined,
points: undefined,
hotelId: Number(bookedRoom.hotelId),
internalCategoryName: "Food",
translatedCategoryName: intl.formatMessage({
id: "common.food",
defaultMessage: "Food",
}),
requiresQuantity: false,
}
: undefined
const allAncillaries = mapAncillaries(
intl,
ancillaries,
breakfastAncillary,
user
)
if (!allAncillaries.length) {
return null
}
const uniqueAncillaries = generateUniqueAncillaries(allAncillaries)
return { all: allAncillaries, unique: uniqueAncillaries }
}
function mapAncillaries(
intl: IntlShape,
ancillaries: Ancillaries | null,
breakfastAncillary: SelectedAncillary | undefined,
user: User | null
) {
const withBreakfastPopular = addBreakfastPackage(
ancillaries ?? [],
breakfastAncillary,
"Popular",
intl.formatMessage({
defaultMessage: "Popular",
id: "myStay.ancillaries.popularCategory",
})
)
const withBreakfastFood = addBreakfastPackage(
withBreakfastPopular,
breakfastAncillary,
"Food",
intl.formatMessage({
id: "common.food",
defaultMessage: "Food",
})
)
return filterPoints(withBreakfastFood, user)
}
function filterPoints(ancillaries: Ancillaries, user: User | null) {
return ancillaries.map((ancillary) => {
return {
...ancillary,
ancillaryContent: ancillary.ancillaryContent.map(
({ points, ...ancillary }) => ({
...ancillary,
points: user ? points : undefined,
})
),
}
})
}
export function generateUniqueAncillaries(
ancillaries: Ancillaries
): Ancillary["ancillaryContent"] {
const uniqueAncillaries = new Map(
ancillaries.flatMap((a) => {
return a.ancillaryContent.map((ancillary) => [ancillary.id, ancillary])
})
)
return [...uniqueAncillaries.values()]
}
/**
* Adds the breakfast package to the ancillaries
*
* Returns the ancillaries array with the breakfast package added to the
* specified category. If the category doesn't exist it's created.
*/
function addBreakfastPackage(
ancillaries: Ancillaries,
breakfast: SelectedAncillary | undefined,
internalCategoryName: string,
translatedCategoryName: string
): Ancillaries {
if (!breakfast) return ancillaries
const category = ancillaries.find(
(a) => a.internalCategoryName === internalCategoryName
)
if (category) {
const newCategory = {
...category,
ancillaryContent: [breakfast, ...category.ancillaryContent],
}
return ancillaries.map((ancillary) =>
ancillary.internalCategoryName === internalCategoryName
? newCategory
: ancillary
)
}
return [
{
internalCategoryName,
translatedCategoryName,
ancillaryContent: [breakfast],
},
...ancillaries,
]
}

View File

@@ -15,17 +15,15 @@ import type {
import type { Room } from "@/types/stores/my-stay" import type { Room } from "@/types/stores/my-stay"
export enum AncillaryStepEnum { export enum AncillaryStepEnum {
selectAncillary = 0, selectQuantity = 0,
selectQuantity = 1, selectDelivery = 1,
selectDelivery = 2, confirmation = 2,
confirmation = 3,
} }
type Step = { type Step = {
step: AncillaryStepEnum step: AncillaryStepEnum
isValid: boolean isValid: boolean
} }
type Steps = { type Steps = {
[AncillaryStepEnum.selectAncillary]?: Step
[AncillaryStepEnum.selectQuantity]: Step [AncillaryStepEnum.selectQuantity]: Step
[AncillaryStepEnum.selectDelivery]: Step [AncillaryStepEnum.selectDelivery]: Step
[AncillaryStepEnum.confirmation]: Step [AncillaryStepEnum.confirmation]: Step
@@ -39,6 +37,7 @@ export type BreakfastData = {
priceAdult: number priceAdult: number
priceChild: number priceChild: number
currency: string currency: string
totalPrice: number
} }
interface AddAncillaryState { interface AddAncillaryState {
@@ -52,8 +51,7 @@ interface AddAncillaryState {
ancillariesBySelectedCategory: Ancillary["ancillaryContent"] ancillariesBySelectedCategory: Ancillary["ancillaryContent"]
openModal: () => void openModal: () => void
closeModal: () => void closeModal: () => void
prevStep: () => void prevStep: (isMobile: boolean) => void
prevStepMobile: () => void
breakfastData: BreakfastData | null breakfastData: BreakfastData | null
setBreakfastData: (breakfastData: BreakfastData | null) => void setBreakfastData: (breakfastData: BreakfastData | null) => void
isBreakfast: boolean isBreakfast: boolean
@@ -89,10 +87,6 @@ export const createAddAncillaryStore = (
(ancillary) => ancillary.translatedCategoryName (ancillary) => ancillary.translatedCategoryName
) )
const steps = { const steps = {
[AncillaryStepEnum.selectAncillary]: {
step: AncillaryStepEnum.selectAncillary,
isValid: true,
},
[AncillaryStepEnum.selectQuantity]: { [AncillaryStepEnum.selectQuantity]: {
step: AncillaryStepEnum.selectQuantity, step: AncillaryStepEnum.selectQuantity,
isValid: false, isValid: false,
@@ -112,7 +106,7 @@ export const createAddAncillaryStore = (
categories, categories,
selectedCategory, selectedCategory,
ancillariesBySelectedCategory, ancillariesBySelectedCategory,
currentStep: AncillaryStepEnum.selectAncillary, currentStep: AncillaryStepEnum.selectQuantity,
selectedAncillary: null, selectedAncillary: null,
breakfastData: null, breakfastData: null,
isBreakfast: false, isBreakfast: false,
@@ -122,12 +116,12 @@ export const createAddAncillaryStore = (
set( set(
produce((state: AddAncillaryState) => { produce((state: AddAncillaryState) => {
state.isOpen = true state.isOpen = true
state.currentStep = AncillaryStepEnum.selectAncillary
}) })
), ),
closeModal: () => closeModal: () =>
set( set(
produce((state: AddAncillaryState) => { produce((state: AddAncillaryState) => {
state.currentStep = AncillaryStepEnum.selectQuantity
state.isOpen = false state.isOpen = false
clearAncillarySessionData() clearAncillarySessionData()
state.selectedAncillary = null state.selectedAncillary = null
@@ -172,34 +166,7 @@ export const createAddAncillaryStore = (
}) })
), ),
prevStep: () => prevStep: (isMobile) =>
set(
produce((state: AddAncillaryState) => {
if (
state.currentStep === AncillaryStepEnum.selectAncillary ||
(state.currentStep === AncillaryStepEnum.selectQuantity &&
!state.steps[AncillaryStepEnum.selectAncillary])
) {
state.isOpen = false
clearAncillarySessionData()
state.selectedAncillary = null
state.steps = steps
} else {
if (
!state.selectedAncillary?.requiresDeliveryTime &&
state.currentStep === AncillaryStepEnum.confirmation
) {
state.currentStep = AncillaryStepEnum.selectQuantity
} else if (state.currentStep === AncillaryStepEnum.selectQuantity) {
state.currentStep = state.currentStep - 1
state.selectedAncillary = null
} else {
state.currentStep = state.currentStep - 1
}
}
})
),
prevStepMobile: () =>
set( set(
produce((state: AddAncillaryState) => { produce((state: AddAncillaryState) => {
if (state.currentStep === AncillaryStepEnum.selectQuantity) { if (state.currentStep === AncillaryStepEnum.selectQuantity) {
@@ -208,7 +175,10 @@ export const createAddAncillaryStore = (
state.selectedAncillary = null state.selectedAncillary = null
state.steps = steps state.steps = steps
} else { } else {
if (state.currentStep === AncillaryStepEnum.confirmation) { if (
(!state.selectedAncillary?.requiresDeliveryTime || isMobile) &&
state.currentStep === AncillaryStepEnum.confirmation
) {
state.currentStep = AncillaryStepEnum.selectQuantity state.currentStep = AncillaryStepEnum.selectQuantity
} else { } else {
state.currentStep = state.currentStep - 1 state.currentStep = state.currentStep - 1
@@ -219,14 +189,8 @@ export const createAddAncillaryStore = (
selectAncillary: (ancillary) => selectAncillary: (ancillary) =>
set( set(
produce((state: AddAncillaryState) => { produce((state: AddAncillaryState) => {
if (state.isOpen) { state.isOpen = true
state.steps[AncillaryStepEnum.selectAncillary]!.isValid = true
} else {
state.isOpen = true
delete state.steps[AncillaryStepEnum.selectAncillary]
}
state.selectedAncillary = ancillary state.selectedAncillary = ancillary
state.currentStep = AncillaryStepEnum.selectQuantity
state.isBreakfast = state.isBreakfast =
ancillary.id === BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST ancillary.id === BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST
}) })

View File

@@ -40,6 +40,11 @@ export interface AddAncillaryFlowModalProps {
export interface SelectQuantityStepProps { export interface SelectQuantityStepProps {
user: User | null user: User | null
hideSummary?: boolean
}
export interface InnerSelectQuantityStepProps {
user: User | null
selectedAncillary: SelectedAncillary
} }
export interface AncillaryErrorMessage { export interface AncillaryErrorMessage {
type: AlertTypeEnum type: AlertTypeEnum

View File

@@ -506,6 +506,16 @@ export const breakfastPackagesSchema = z
data.attributes.packages.filter((pkg) => pkg.code?.match(/^(BRF\d+)$/gm)) data.attributes.packages.filter((pkg) => pkg.code?.match(/^(BRF\d+)$/gm))
) )
// Determine if ancillary requires quantity based on unit name. These ancillaries are special
// since they are 1 per booking, but we have no other way than string matching on unit name
// to determine this from the API at the moment.
function getRequiresQuantity(unitName?: string) {
return (unitName && unitName === "Late check-out") ||
unitName === "Early check-in"
? false
: true
}
export const ancillaryPackagesSchema = z export const ancillaryPackagesSchema = z
.object({ .object({
data: z.object({ data: z.object({
@@ -537,6 +547,7 @@ export const ancillaryPackagesSchema = z
requiresDeliveryTime: item.requiresDeliveryTime, requiresDeliveryTime: item.requiresDeliveryTime,
translatedCategoryName: ancillary.categoryName, translatedCategoryName: ancillary.categoryName,
internalCategoryName: ancillary.internalCategoryName, internalCategoryName: ancillary.internalCategoryName,
requiresQuantity: getRequiresQuantity(item.unitName),
})), })),
})) }))
.filter((ancillary) => ancillary.ancillaryContent.length > 0) .filter((ancillary) => ancillary.ancillaryContent.length > 0)

View File

@@ -40,6 +40,7 @@ export const ancillaryContentSchema = z.object({
descriptions: z.object({ html: z.string() }), descriptions: z.object({ html: z.string() }),
images: z.array(imageWithoutMetaDataSchema), images: z.array(imageWithoutMetaDataSchema),
requiresDeliveryTime: z.boolean(), requiresDeliveryTime: z.boolean(),
unitName: z.string().optional(),
}) })
export const packageSchema = z.object({ export const packageSchema = z.object({