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:
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 4–12)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
@@ -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 {
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 4–12)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
185
apps/scandic-web/hooks/useAncillaries.ts
Normal file
185
apps/scandic-web/hooks/useAncillaries.ts
Normal 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,
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user