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 { trackUpdatePaymentMethod } from "@/utils/tracking"
|
||||
|
||||
import Summary from "../Summary"
|
||||
|
||||
import styles from "./confirmationStep.module.css"
|
||||
|
||||
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.",
|
||||
})
|
||||
|
||||
if (!selectedAncillary) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.modalContent}>
|
||||
{error && <Alert type={error.type} text={error.message} />}
|
||||
@@ -220,6 +226,7 @@ export default function ConfirmationStep({
|
||||
</>
|
||||
)}
|
||||
<TermsAndConditions />
|
||||
<Summary isConfirmation />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x2);
|
||||
}
|
||||
|
||||
.selectContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -6,6 +6,8 @@ import { generateDeliveryOptions } from "@/components/HotelReservation/MyStay/ut
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import Select from "@/components/TempDesignSystem/Form/Select"
|
||||
|
||||
import Summary from "../Summary"
|
||||
|
||||
import styles from "./deliveryDetailsStep.module.css"
|
||||
|
||||
export default function DeliveryMethodStep() {
|
||||
@@ -13,50 +15,53 @@ export default function DeliveryMethodStep() {
|
||||
const deliveryTimeOptions = generateDeliveryOptions()
|
||||
|
||||
return (
|
||||
<div className={styles.selectContainer}>
|
||||
<div className={styles.select}>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<h3>
|
||||
{intl.formatMessage({
|
||||
id: "ancillaries.deliveredAt",
|
||||
defaultMessage: "Delivered at:",
|
||||
})}
|
||||
</h3>
|
||||
</Typography>
|
||||
<Select
|
||||
name="deliveryTime"
|
||||
label={""}
|
||||
items={deliveryTimeOptions}
|
||||
registerOptions={{ required: true }}
|
||||
isNestedInModal
|
||||
/>
|
||||
</div>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<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"
|
||||
/>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.selectContainer}>
|
||||
<div className={styles.select}>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<h3>
|
||||
{intl.formatMessage({
|
||||
id: "ancillaries.deliveredAt",
|
||||
defaultMessage: "Delivered at:",
|
||||
})}
|
||||
</h3>
|
||||
</Typography>
|
||||
<Select
|
||||
name="deliveryTime"
|
||||
label={""}
|
||||
items={deliveryTimeOptions}
|
||||
registerOptions={{ required: true }}
|
||||
isNestedInModal
|
||||
/>
|
||||
</div>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<h3>
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "common.optional",
|
||||
defaultMessage: "Optional",
|
||||
id: "addAncillary.deliveryDetailsStep.deliveryTimeDescription",
|
||||
defaultMessage:
|
||||
"All extras are delivered together. Changes to delivery times will affect previously ordered extras.",
|
||||
})}
|
||||
</h3>
|
||||
</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">
|
||||
<h3>
|
||||
{intl.formatMessage({
|
||||
id: "common.optional",
|
||||
defaultMessage: "Optional",
|
||||
})}
|
||||
</h3>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<Summary />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,28 +5,25 @@ import {
|
||||
|
||||
import ConfirmationStep from "../ConfirmationStep"
|
||||
import DeliveryMethodStep from "../DeliveryDetailsStep"
|
||||
import SelectAncillaryStep from "../SelectAncillaryStep"
|
||||
import SelectQuantityStep from "../SelectQuantityStep"
|
||||
|
||||
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
export default function Desktop({ user, savedCreditCards, error }: StepsProps) {
|
||||
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 (
|
||||
<ConfirmationStep
|
||||
savedCreditCards={savedCreditCards}
|
||||
user={user}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
switch (currentStep) {
|
||||
case AncillaryStepEnum.selectQuantity:
|
||||
return <SelectQuantityStep user={user} />
|
||||
case AncillaryStepEnum.selectDelivery:
|
||||
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) {
|
||||
return (
|
||||
<>
|
||||
<SelectQuantityStep user={user} />
|
||||
<SelectQuantityStep
|
||||
user={user}
|
||||
hideSummary={selectedAncillary?.requiresDeliveryTime}
|
||||
/>
|
||||
{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 { 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 { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
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 { getErrorMessage } from "@/utils/getErrorMessage"
|
||||
|
||||
import Summary from "../Summary"
|
||||
import { BreakfastInfo } from "./BreakfastInfo"
|
||||
|
||||
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 intl = useIntl()
|
||||
const cardQuantityOptions = Array.from({ length: 7 }, (_, i) => ({
|
||||
label: `${i}`,
|
||||
value: i,
|
||||
}))
|
||||
|
||||
export default function SelectQuantityStep({
|
||||
user,
|
||||
hideSummary = false,
|
||||
}: SelectQuantityStepProps) {
|
||||
const { isBreakfast, selectedAncillary } = useAddAncillaryStore((state) => ({
|
||||
isBreakfast: state.isBreakfast,
|
||||
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 {
|
||||
formState: { errors },
|
||||
} = useFormContext()
|
||||
|
||||
if (isBreakfast) {
|
||||
return <BreakfastInfo />
|
||||
}
|
||||
|
||||
const cardQuantityOptions = Array.from({ length: 7 }, (_, i) => ({
|
||||
label: `${i}`,
|
||||
value: i,
|
||||
}))
|
||||
|
||||
const pointsCost = selectedAncillary?.points ?? 0
|
||||
const currentPoints = user?.membership?.currentPoints ?? 0
|
||||
const maxAffordable =
|
||||
@@ -131,95 +162,3 @@ export default function SelectQuantityStep({ user }: SelectQuantityStepProps) {
|
||||
</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 {
|
||||
display: flex;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--Space-x1);
|
||||
padding: var(--Space-x15);
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
}
|
||||
|
||||
.showOnDesktop {
|
||||
display: none;
|
||||
}
|
||||
@@ -23,14 +23,12 @@ interface PriceSummaryProps {
|
||||
}[]
|
||||
}
|
||||
|
||||
export default function PriceSummary({
|
||||
export function PriceSummary({
|
||||
totalPrice,
|
||||
totalPoints,
|
||||
items,
|
||||
}: PriceSummaryProps) {
|
||||
const intl = useIntl()
|
||||
const hasTotalPoints = typeof totalPoints === "number"
|
||||
const hasTotalPrice = typeof totalPrice === "number"
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
@@ -80,7 +78,7 @@ export default function PriceSummary({
|
||||
))}
|
||||
|
||||
<div className={styles.column}>
|
||||
{hasTotalPrice ? (
|
||||
{totalPrice ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
@@ -110,15 +108,15 @@ export default function PriceSummary({
|
||||
)}
|
||||
|
||||
<div className={styles.totalPrice}>
|
||||
{(hasTotalPoints || hasTotalPrice) && (
|
||||
{(totalPoints || totalPrice) && (
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{hasTotalPrice
|
||||
{totalPrice
|
||||
? formatPrice(intl, totalPrice, items[0]?.currency)
|
||||
: null}
|
||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||
{hasTotalPoints && hasTotalPrice ? " + " : null}
|
||||
{hasTotalPoints
|
||||
{totalPoints && totalPrice ? " + " : null}
|
||||
{totalPoints
|
||||
? intl.formatMessage(
|
||||
{
|
||||
id: "common.numberOfPoints",
|
||||
@@ -7,7 +7,6 @@
|
||||
border-radius: var(--Corner-radius-lg);
|
||||
border: 1px solid var(--Border-Divider-Default);
|
||||
background: var(--Surface-Primary-Default);
|
||||
margin: var(--Space-x1);
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -21,74 +13,3 @@
|
||||
display: flex;
|
||||
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 { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
|
||||
import { guaranteeCallback } from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
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 Modal from "@scandic-hotels/design-system/Modal"
|
||||
import { toast } from "@scandic-hotels/design-system/Toast"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { trpc } from "@scandic-hotels/trpc/client"
|
||||
|
||||
import { isWebview } from "@/constants/routes/webviews"
|
||||
import { env } from "@/env/client"
|
||||
import {
|
||||
AncillaryStepEnum,
|
||||
useAddAncillaryStore,
|
||||
} from "@/stores/my-stay/add-ancillary-flow"
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import {
|
||||
buildAncillaryPackages,
|
||||
@@ -41,13 +31,13 @@ import {
|
||||
|
||||
import { isAncillaryError } from "../../../utils"
|
||||
import { type AncillaryFormData, ancillaryFormSchema } from "../schema"
|
||||
import ActionButtons from "./ActionButtons"
|
||||
import PriceDetails from "./PriceDetails"
|
||||
import Description from "./Description"
|
||||
import Steps from "./Steps"
|
||||
import {
|
||||
buildBreakfastPackages,
|
||||
calculateBreakfastData,
|
||||
getErrorMessage,
|
||||
getGuaranteeCallback,
|
||||
} from "./utils"
|
||||
|
||||
import styles from "./addAncillaryFlowModal.module.css"
|
||||
@@ -65,14 +55,12 @@ export default function AddAncillaryFlowModal({
|
||||
savedCreditCards,
|
||||
}: AddAncillaryFlowModalProps) {
|
||||
const {
|
||||
currentStep,
|
||||
selectedAncillary,
|
||||
closeModal,
|
||||
breakfastData,
|
||||
setBreakfastData,
|
||||
isBreakfast,
|
||||
} = useAddAncillaryStore((state) => ({
|
||||
currentStep: state.currentStep,
|
||||
selectedAncillary: state.selectedAncillary,
|
||||
closeModal: state.closeModal,
|
||||
breakfastData: state.breakfastData,
|
||||
@@ -85,14 +73,11 @@ export default function AddAncillaryFlowModal({
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
|
||||
const [isPriceDetailsOpen, setIsPriceDetailsOpen] = useState(false)
|
||||
const [errorMessage, setErrorMessage] =
|
||||
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 defaultDeliveryTime = deliveryTimeOptions[0].value
|
||||
|
||||
const hasInsufficientPoints =
|
||||
(user?.membership?.currentPoints ?? 0) < (selectedAncillary?.points ?? 0)
|
||||
|
||||
@@ -100,8 +85,11 @@ export default function AddAncillaryFlowModal({
|
||||
defaultValues: {
|
||||
quantityWithPoints: null,
|
||||
quantityWithCard:
|
||||
!user || hasInsufficientPoints || isBreakfast ? 1 : null,
|
||||
deliveryTime: booking.ancillary?.deliveryTime ?? defaultDeliveryTime,
|
||||
!user || hasInsufficientPoints || !selectedAncillary?.requiresQuantity
|
||||
? 1
|
||||
: null,
|
||||
deliveryTime:
|
||||
booking.ancillary?.deliveryTime ?? deliveryTimeOptions[0].value,
|
||||
optionalText: "",
|
||||
termsAndConditions: false,
|
||||
paymentMethod: booking.guaranteeInfo
|
||||
@@ -124,17 +112,13 @@ export default function AddAncillaryFlowModal({
|
||||
{ ancillary: selectedAncillary?.title }
|
||||
)
|
||||
|
||||
function togglePriceDetails() {
|
||||
setIsPriceDetailsOpen((isOpen) => !isOpen)
|
||||
}
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const addAncillary = trpc.booking.packages.useMutation()
|
||||
|
||||
const { guaranteeBooking, isLoading, handleGuaranteeError } =
|
||||
useGuaranteeBooking(booking.refId, true, booking.hotelId)
|
||||
|
||||
function handleAncillarySubmission(
|
||||
async function handleAncillarySubmission(
|
||||
data: AncillaryFormData,
|
||||
packages: {
|
||||
code: string
|
||||
@@ -142,7 +126,7 @@ export default function AddAncillaryFlowModal({
|
||||
comment: string | undefined
|
||||
}[]
|
||||
) {
|
||||
addAncillary.mutate(
|
||||
await addAncillary.mutateAsync(
|
||||
{
|
||||
refId: booking.refId,
|
||||
ancillaryComment: data.optionalText,
|
||||
@@ -188,6 +172,7 @@ export default function AddAncillaryFlowModal({
|
||||
breakfastData
|
||||
)
|
||||
toast.error(ancillaryErrorMessage)
|
||||
closeModal()
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
@@ -198,12 +183,13 @@ export default function AddAncillaryFlowModal({
|
||||
breakfastData
|
||||
)
|
||||
toast.error(ancillaryErrorMessage)
|
||||
closeModal()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function handleGuaranteePayment(
|
||||
async function handleGuaranteePayment(
|
||||
data: AncillaryFormData,
|
||||
packages: AncillaryItem[]
|
||||
) {
|
||||
@@ -225,7 +211,7 @@ export default function AddAncillaryFlowModal({
|
||||
cardType: savedCreditCard.cardType,
|
||||
}
|
||||
: undefined
|
||||
guaranteeBooking.mutate({
|
||||
await guaranteeBooking.mutateAsync({
|
||||
refId: booking.refId,
|
||||
language: lang,
|
||||
...(card && { card }),
|
||||
@@ -238,7 +224,7 @@ export default function AddAncillaryFlowModal({
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = (data: AncillaryFormData) => {
|
||||
const onSubmit = async (data: AncillaryFormData) => {
|
||||
const packagesToAdd = !isBreakfast
|
||||
? buildAncillaryPackages(data, selectedAncillary)
|
||||
: breakfastData
|
||||
@@ -261,13 +247,12 @@ export default function AddAncillaryFlowModal({
|
||||
isBreakfast,
|
||||
breakfastData,
|
||||
})
|
||||
const shouldSkipGuarantee =
|
||||
booking.guaranteeInfo || (data.quantityWithCard ?? 0) <= 0
|
||||
const shouldSkipGuarantee = booking.guaranteeInfo || !data.quantityWithCard
|
||||
|
||||
if (shouldSkipGuarantee) {
|
||||
handleAncillarySubmission(data, packagesToAdd)
|
||||
await handleAncillarySubmission(data, packagesToAdd)
|
||||
} 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 (
|
||||
<Modal isOpen={true} onToggle={closeModal} title={modalTitle}>
|
||||
<div
|
||||
className={`${styles.modalWrapper} ${currentStep === AncillaryStepEnum.selectAncillary ? styles.selectAncillarycontainer : ""}`}
|
||||
<FormProvider {...formMethods}>
|
||||
<form
|
||||
onSubmit={formMethods.handleSubmit(onSubmit)}
|
||||
className={styles.form}
|
||||
id="add-ancillary-form-id"
|
||||
>
|
||||
<FormProvider {...formMethods}>
|
||||
<form
|
||||
onSubmit={formMethods.handleSubmit(onSubmit)}
|
||||
className={styles.form}
|
||||
id="add-ancillary-form-id"
|
||||
>
|
||||
<div className={styles.modalScrollable}>
|
||||
{selectedAncillary && (
|
||||
<>
|
||||
{currentStep !== AncillaryStepEnum.confirmation && (
|
||||
<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>
|
||||
<div className={styles.modalScrollable}>
|
||||
<Description />
|
||||
<Steps
|
||||
user={user}
|
||||
savedCreditCards={savedCreditCards}
|
||||
error={errorMessage}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
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 { 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 { IntlShape } from "react-intl"
|
||||
|
||||
@@ -43,27 +48,28 @@ export function calculateBreakfastData(
|
||||
const childPackage = packages.find(
|
||||
(p) => p.code === BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST
|
||||
)
|
||||
const priceAdult = adultPackage?.localPrice.price
|
||||
const priceChild = childPackage?.localPrice.price
|
||||
const priceAdult = adultPackage?.localPrice.price ?? 0
|
||||
const priceChild = childPackage?.localPrice.price ?? 0
|
||||
const currency =
|
||||
adultPackage?.localPrice.currency ?? childPackage?.localPrice.currency
|
||||
|
||||
if (
|
||||
typeof priceAdult !== "number" ||
|
||||
typeof priceChild !== "number" ||
|
||||
typeof currency !== "string"
|
||||
) {
|
||||
const totalPrice =
|
||||
priceAdult * nrOfAdults * nrOfNights +
|
||||
priceChild * nrOfPayingChildren * nrOfNights
|
||||
|
||||
if (!currency) {
|
||||
return null
|
||||
} else {
|
||||
return {
|
||||
nrOfAdults,
|
||||
nrOfPayingChildren,
|
||||
nrOfFreeChildren,
|
||||
nrOfNights,
|
||||
priceAdult,
|
||||
priceChild,
|
||||
currency,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nrOfAdults,
|
||||
nrOfPayingChildren,
|
||||
nrOfFreeChildren,
|
||||
nrOfNights,
|
||||
priceAdult,
|
||||
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 styles from "./wrapper.module.css"
|
||||
|
||||
export default function AncillaryFlowModalWrapper({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
const isOpen = useAddAncillaryStore((state) => state.isOpen)
|
||||
return isOpen ? <>{children}</> : null
|
||||
const { isOpen, closeModal, selectedAncillaryTitle } = useAddAncillaryStore(
|
||||
(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",
|
||||
} 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
|
||||
.object({
|
||||
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 WrappedAncillaryCard from "../../../WrappedAncillaryCard"
|
||||
import WrappedAncillaryCard from "../../Card"
|
||||
|
||||
import styles from "./selectAncillaryStep.module.css"
|
||||
|
||||
export default function SelectAncillaryStep() {
|
||||
export default function SelectAncillaryStep({
|
||||
onClose,
|
||||
}: {
|
||||
onClose: () => void
|
||||
}) {
|
||||
const {
|
||||
ancillariesBySelectedCategory,
|
||||
selectedCategory,
|
||||
@@ -21,6 +25,7 @@ export default function SelectAncillaryStep() {
|
||||
selectCategory: state.selectCategory,
|
||||
}))
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.tabs}>
|
||||
@@ -46,7 +51,11 @@ export default function SelectAncillaryStep() {
|
||||
|
||||
<div className={styles.grid}>
|
||||
{ancillariesBySelectedCategory.map((ancillary) => (
|
||||
<WrappedAncillaryCard key={ancillary.id} ancillary={ancillary} />
|
||||
<WrappedAncillaryCard
|
||||
key={ancillary.id}
|
||||
ancillary={ancillary}
|
||||
onClose={onClose}
|
||||
/>
|
||||
))}
|
||||
</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 {
|
||||
ancillary: SelectedAncillary
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export default function WrappedAncillaryCard({
|
||||
onClose,
|
||||
ancillary,
|
||||
}: WrappedAncillaryProps) {
|
||||
const { description, ...ancillaryWithoutDescription } = ancillary
|
||||
@@ -18,18 +20,22 @@ export default function WrappedAncillaryCard({
|
||||
booking: state.booking,
|
||||
}))
|
||||
|
||||
function clickAncillary() {
|
||||
if (typeof onClose === "function") {
|
||||
onClose()
|
||||
}
|
||||
selectAncillary(ancillary)
|
||||
trackViewAncillary(ancillary, booking)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={() => {
|
||||
selectAncillary(ancillary)
|
||||
trackViewAncillary(ancillary, booking)
|
||||
}}
|
||||
onClick={clickAncillary}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
selectAncillary(ancillary)
|
||||
trackViewAncillary(ancillary, booking)
|
||||
clickAncillary()
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -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"
|
||||
import { use } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Title from "@scandic-hotels/design-system/Title"
|
||||
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useMyStayStore } from "@/stores/my-stay"
|
||||
|
||||
import { Carousel } from "@/components/Carousel"
|
||||
import { useAncillaries } from "@/hooks/useAncillaries"
|
||||
import { AddAncillaryProvider } from "@/providers/AddAncillaryProvider"
|
||||
|
||||
import AddAncillaryFlowModal from "./AddAncillaryFlow/AddAncillaryFlowModal"
|
||||
import AncillaryFlowModalWrapper from "./AddAncillaryFlow/AncillaryFlowModalWrapper"
|
||||
import WrappedAncillaryCard from "./AddAncillaryFlow/WrappedAncillaryCard"
|
||||
import AllAncillariesModal from "./AllAncillariesModal/input"
|
||||
import { AddedAncillaries } from "./AddedAncillaries"
|
||||
import { generateUniqueAncillaries, mapAncillaries } from "./utils"
|
||||
import ViewAllAncillaries from "./ViewAllAncillaries"
|
||||
import WrappedAncillaryCard from "./Card"
|
||||
|
||||
import styles from "./ancillaries.module.css"
|
||||
|
||||
import type {
|
||||
AncillariesProps,
|
||||
SelectedAncillary,
|
||||
} from "@/types/components/myPages/myStay/ancillaries"
|
||||
import type { AncillariesProps } from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
export function Ancillaries({
|
||||
ancillariesPromise,
|
||||
@@ -31,91 +26,35 @@ export function Ancillaries({
|
||||
user,
|
||||
}: AncillariesProps) {
|
||||
const intl = useIntl()
|
||||
const ancillaries = use(ancillariesPromise)
|
||||
const bookedRoom = useMyStayStore((state) => state.bookedRoom)
|
||||
|
||||
if (!bookedRoom || bookedRoom.isCancelled || !bookedRoom.showAncillaries) {
|
||||
const ancillaries = useAncillaries(ancillariesPromise, packages, user)
|
||||
|
||||
if (!ancillaries || !bookedRoom) {
|
||||
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 (
|
||||
<AddAncillaryProvider booking={bookedRoom} ancillaries={allAncillaries}>
|
||||
<AddAncillaryProvider booking={bookedRoom} ancillaries={ancillaries.all}>
|
||||
<div className={styles.container}>
|
||||
{uniqueAncillaries.length > 0 && bookedRoom.canModifyAncillaries && (
|
||||
{ancillaries.unique.length > 0 && bookedRoom.canModifyAncillaries && (
|
||||
<>
|
||||
<div className={styles.title}>
|
||||
<Title as="h5">
|
||||
{intl.formatMessage({
|
||||
id: "ancillaries.upgradeYourStay",
|
||||
defaultMessage: "Upgrade your stay",
|
||||
})}
|
||||
</Title>
|
||||
<Typography variant="Title/Subtitle/lg">
|
||||
<h2>
|
||||
{intl.formatMessage({
|
||||
id: "ancillaries.upgradeYourStay",
|
||||
defaultMessage: "Upgrade your stay",
|
||||
})}
|
||||
</h2>
|
||||
</Typography>
|
||||
<div className={styles.viewAllLink}>
|
||||
<ViewAllAncillaries />
|
||||
<AllAncillariesModal />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.ancillaries}>
|
||||
{uniqueAncillaries.slice(0, 4).map((ancillary) => (
|
||||
{ancillaries.unique.slice(0, 4).map((ancillary) => (
|
||||
<WrappedAncillaryCard
|
||||
ancillary={ancillary}
|
||||
key={ancillary.id}
|
||||
@@ -126,7 +65,7 @@ export function Ancillaries({
|
||||
<div className={styles.mobileAncillaries}>
|
||||
<Carousel>
|
||||
<Carousel.Content>
|
||||
{uniqueAncillaries.map((ancillary) => {
|
||||
{ancillaries.unique.map((ancillary) => {
|
||||
return (
|
||||
<Carousel.Item key={ancillary.id}>
|
||||
<WrappedAncillaryCard ancillary={ancillary} />
|
||||
@@ -142,7 +81,7 @@ export function Ancillaries({
|
||||
|
||||
<AddedAncillaries
|
||||
booking={bookedRoom}
|
||||
ancillaries={uniqueAncillaries}
|
||||
ancillaries={ancillaries.unique}
|
||||
/>
|
||||
|
||||
<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"
|
||||
|
||||
export enum AncillaryStepEnum {
|
||||
selectAncillary = 0,
|
||||
selectQuantity = 1,
|
||||
selectDelivery = 2,
|
||||
confirmation = 3,
|
||||
selectQuantity = 0,
|
||||
selectDelivery = 1,
|
||||
confirmation = 2,
|
||||
}
|
||||
type Step = {
|
||||
step: AncillaryStepEnum
|
||||
isValid: boolean
|
||||
}
|
||||
type Steps = {
|
||||
[AncillaryStepEnum.selectAncillary]?: Step
|
||||
[AncillaryStepEnum.selectQuantity]: Step
|
||||
[AncillaryStepEnum.selectDelivery]: Step
|
||||
[AncillaryStepEnum.confirmation]: Step
|
||||
@@ -39,6 +37,7 @@ export type BreakfastData = {
|
||||
priceAdult: number
|
||||
priceChild: number
|
||||
currency: string
|
||||
totalPrice: number
|
||||
}
|
||||
|
||||
interface AddAncillaryState {
|
||||
@@ -52,8 +51,7 @@ interface AddAncillaryState {
|
||||
ancillariesBySelectedCategory: Ancillary["ancillaryContent"]
|
||||
openModal: () => void
|
||||
closeModal: () => void
|
||||
prevStep: () => void
|
||||
prevStepMobile: () => void
|
||||
prevStep: (isMobile: boolean) => void
|
||||
breakfastData: BreakfastData | null
|
||||
setBreakfastData: (breakfastData: BreakfastData | null) => void
|
||||
isBreakfast: boolean
|
||||
@@ -89,10 +87,6 @@ export const createAddAncillaryStore = (
|
||||
(ancillary) => ancillary.translatedCategoryName
|
||||
)
|
||||
const steps = {
|
||||
[AncillaryStepEnum.selectAncillary]: {
|
||||
step: AncillaryStepEnum.selectAncillary,
|
||||
isValid: true,
|
||||
},
|
||||
[AncillaryStepEnum.selectQuantity]: {
|
||||
step: AncillaryStepEnum.selectQuantity,
|
||||
isValid: false,
|
||||
@@ -112,7 +106,7 @@ export const createAddAncillaryStore = (
|
||||
categories,
|
||||
selectedCategory,
|
||||
ancillariesBySelectedCategory,
|
||||
currentStep: AncillaryStepEnum.selectAncillary,
|
||||
currentStep: AncillaryStepEnum.selectQuantity,
|
||||
selectedAncillary: null,
|
||||
breakfastData: null,
|
||||
isBreakfast: false,
|
||||
@@ -122,12 +116,12 @@ export const createAddAncillaryStore = (
|
||||
set(
|
||||
produce((state: AddAncillaryState) => {
|
||||
state.isOpen = true
|
||||
state.currentStep = AncillaryStepEnum.selectAncillary
|
||||
})
|
||||
),
|
||||
closeModal: () =>
|
||||
set(
|
||||
produce((state: AddAncillaryState) => {
|
||||
state.currentStep = AncillaryStepEnum.selectQuantity
|
||||
state.isOpen = false
|
||||
clearAncillarySessionData()
|
||||
state.selectedAncillary = null
|
||||
@@ -172,34 +166,7 @@ export const createAddAncillaryStore = (
|
||||
})
|
||||
),
|
||||
|
||||
prevStep: () =>
|
||||
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: () =>
|
||||
prevStep: (isMobile) =>
|
||||
set(
|
||||
produce((state: AddAncillaryState) => {
|
||||
if (state.currentStep === AncillaryStepEnum.selectQuantity) {
|
||||
@@ -208,7 +175,10 @@ export const createAddAncillaryStore = (
|
||||
state.selectedAncillary = null
|
||||
state.steps = steps
|
||||
} else {
|
||||
if (state.currentStep === AncillaryStepEnum.confirmation) {
|
||||
if (
|
||||
(!state.selectedAncillary?.requiresDeliveryTime || isMobile) &&
|
||||
state.currentStep === AncillaryStepEnum.confirmation
|
||||
) {
|
||||
state.currentStep = AncillaryStepEnum.selectQuantity
|
||||
} else {
|
||||
state.currentStep = state.currentStep - 1
|
||||
@@ -219,14 +189,8 @@ export const createAddAncillaryStore = (
|
||||
selectAncillary: (ancillary) =>
|
||||
set(
|
||||
produce((state: AddAncillaryState) => {
|
||||
if (state.isOpen) {
|
||||
state.steps[AncillaryStepEnum.selectAncillary]!.isValid = true
|
||||
} else {
|
||||
state.isOpen = true
|
||||
delete state.steps[AncillaryStepEnum.selectAncillary]
|
||||
}
|
||||
state.isOpen = true
|
||||
state.selectedAncillary = ancillary
|
||||
state.currentStep = AncillaryStepEnum.selectQuantity
|
||||
state.isBreakfast =
|
||||
ancillary.id === BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST
|
||||
})
|
||||
|
||||
@@ -40,6 +40,11 @@ export interface AddAncillaryFlowModalProps {
|
||||
|
||||
export interface SelectQuantityStepProps {
|
||||
user: User | null
|
||||
hideSummary?: boolean
|
||||
}
|
||||
export interface InnerSelectQuantityStepProps {
|
||||
user: User | null
|
||||
selectedAncillary: SelectedAncillary
|
||||
}
|
||||
export interface AncillaryErrorMessage {
|
||||
type: AlertTypeEnum
|
||||
|
||||
@@ -506,6 +506,16 @@ export const breakfastPackagesSchema = z
|
||||
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
|
||||
.object({
|
||||
data: z.object({
|
||||
@@ -537,6 +547,7 @@ export const ancillaryPackagesSchema = z
|
||||
requiresDeliveryTime: item.requiresDeliveryTime,
|
||||
translatedCategoryName: ancillary.categoryName,
|
||||
internalCategoryName: ancillary.internalCategoryName,
|
||||
requiresQuantity: getRequiresQuantity(item.unitName),
|
||||
})),
|
||||
}))
|
||||
.filter((ancillary) => ancillary.ancillaryContent.length > 0)
|
||||
|
||||
@@ -40,6 +40,7 @@ export const ancillaryContentSchema = z.object({
|
||||
descriptions: z.object({ html: z.string() }),
|
||||
images: z.array(imageWithoutMetaDataSchema),
|
||||
requiresDeliveryTime: z.boolean(),
|
||||
unitName: z.string().optional(),
|
||||
})
|
||||
|
||||
export const packageSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user