Merged in feat/sw-1681-add-breakfast (pull request #1635)

Feat/sw-1681 add breakfast
This implements the add breakfast flow

Approved-by: Pontus Dreij
This commit is contained in:
Niclas Edenvin
2025-03-27 12:40:54 +00:00
parent bed490d79a
commit 8eec465afa
15 changed files with 508 additions and 78 deletions

View File

@@ -9,22 +9,24 @@ import PriceRow from "./PriceRow"
import styles from "./priceSummary.module.css"
import type { Ancillary } from "@/types/components/myPages/myStay/ancillaries"
interface PriceSummaryProps {
totalPrice: number | null
totalPoints: number | null
quantityWithPoints: number
quantityWithCard: number
selectedAncillary: NonNullable<Ancillary["ancillaryContent"][number]>
items: {
title: string
totalPrice: number
currency: string
points?: number
quantityWithCard?: number
quantityWithPoints?: number
}[]
}
export default function PriceSummary({
totalPrice,
totalPoints,
quantityWithPoints,
quantityWithCard,
selectedAncillary,
items,
}: PriceSummaryProps) {
const intl = useIntl()
const hasTotalPoints = typeof totalPoints === "number"
@@ -37,27 +39,28 @@ export default function PriceSummary({
</Typography>
<Divider color="subtle" />
{hasTotalPrice && (
<PriceRow
title={selectedAncillary.title}
quantity={quantityWithCard}
label={intl.formatMessage({ id: "Price including VAT" })}
value={formatPrice(
intl,
selectedAncillary.price.totalPrice,
selectedAncillary.price.currency
{items.map((item) => (
<>
{item.quantityWithCard && (
<PriceRow
title={item.title}
quantity={item.quantityWithCard}
label={intl.formatMessage({ id: "Price including VAT" })}
value={formatPrice(intl, item.totalPrice, item.currency)}
/>
)}
/>
)}
{hasTotalPoints && (
<PriceRow
title={selectedAncillary.title}
quantity={quantityWithPoints}
label={intl.formatMessage({ id: "Points" })}
value={`${selectedAncillary.points} ${intl.formatMessage({ id: "points" })}`}
/>
)}
<Divider color="subtle" />
{item.quantityWithPoints && (
<PriceRow
title={item.title}
quantity={item.quantityWithPoints}
label={intl.formatMessage({ id: "Points" })}
value={`${item.points} ${intl.formatMessage({ id: "points" })}`}
/>
)}
<Divider color="subtle" />
</>
))}
<div className={styles.column}>
<Typography variant="Body/Paragraph/mdBold">
<p>
@@ -71,11 +74,7 @@ export default function PriceSummary({
<Typography variant="Body/Paragraph/mdBold">
<p>
{hasTotalPrice &&
formatPrice(
intl,
totalPrice,
selectedAncillary.price.currency
)}
formatPrice(intl, totalPrice, items[0]?.currency)}
{hasTotalPoints && hasTotalPrice && " + "}
{hasTotalPoints &&
`${totalPoints} ${intl.formatMessage({ id: "points" })}`}

View File

@@ -5,6 +5,7 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import {
AncillaryStepEnum,
type BreakfastData,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
@@ -15,6 +16,8 @@ import PriceSummary from "./PriceSummary"
import styles from "./priceDetails.module.css"
import type { SelectedAncillary } from "@/types/components/myPages/myStay/ancillaries"
interface PriceDetailsProps {
isPriceDetailsOpen: boolean
}
@@ -24,18 +27,28 @@ export default function PriceDetails({
}: PriceDetailsProps) {
const intl = useIntl()
const { currentStep, selectedAncillary } = useAddAncillaryStore((state) => ({
currentStep: state.currentStep,
selectedAncillary: state.selectedAncillary,
}))
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) {
return null
}
const totalPrice =
quantityWithCard && selectedAncillary
if (isBreakfast && !breakfastData) {
return null
}
const totalPrice = isBreakfast
? breakfastData!.priceAdult * breakfastData!.nrOfAdults +
breakfastData!.priceChild * breakfastData!.nrOfPayingChildren
: quantityWithCard && selectedAncillary
? selectedAncillary.price.totalPrice * quantityWithCard
: null
@@ -47,6 +60,57 @@ export default function PriceDetails({
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: "Adult" })}`,
totalPrice: breakfastData.priceAdult,
currency: breakfastData.currency,
quantityWithCard: breakfastData.nrOfAdults,
},
]
if (breakfastData.nrOfPayingChildren > 0) {
items.push({
title: `${selectedAncillary.title} / ${intl.formatMessage({ id: "Children" })} 4-12`,
totalPrice: breakfastData.priceChild,
currency: breakfastData.currency,
quantityWithCard: breakfastData.nrOfPayingChildren,
})
}
if (breakfastData.nrOfFreeChildren > 0) {
items.push({
title: `${selectedAncillary.title} / ${intl.formatMessage({ id: "Children under {age}" }, { age: 4 })}`,
totalPrice: 0,
currency: breakfastData.currency,
quantityWithCard: breakfastData.nrOfFreeChildren,
})
}
return items
}
const items = isBreakfast
? getBreakfastItems(selectedAncillary, breakfastData)
: [
{
title: selectedAncillary.title,
totalPrice: selectedAncillary.price.totalPrice,
currency: selectedAncillary.price.currency,
points: selectedAncillary.points,
quantityWithCard,
quantityWithPoints,
},
]
return (
<>
<div className={styles.totalPrice}>
@@ -94,9 +158,7 @@ export default function PriceDetails({
<PriceSummary
totalPrice={totalPrice}
totalPoints={totalPoints}
quantityWithCard={quantityWithCard ?? 0}
quantityWithPoints={quantityWithPoints ?? 0}
selectedAncillary={selectedAncillary}
items={items}
/>
)}
</>

View File

@@ -1,3 +1,4 @@
import { useEffect } from "react"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
@@ -6,23 +7,30 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import Alert from "@/components/TempDesignSystem/Alert"
import ErrorMessage from "@/components/TempDesignSystem/Form/ErrorMessage"
import Select from "@/components/TempDesignSystem/Form/Select"
import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "./selectQuantityStep.module.css"
import type { SelectQuantityStepProps } from "@/types/components/myPages/myStay/ancillaries"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { AlertTypeEnum } from "@/types/enums/alert"
export default function SelectQuantityStep({ user }: SelectQuantityStepProps) {
const intl = useIntl()
const selectedAncillary = useAddAncillaryStore(
(state) => state.selectedAncillary
)
const { isBreakfast, selectedAncillary } = useAddAncillaryStore((state) => ({
isBreakfast: state.isBreakfast,
selectedAncillary: state.selectedAncillary,
}))
const {
formState: { errors },
} = useFormContext()
if (isBreakfast) {
return <BreakfastInfo />
}
const cardQuantityOptions = Array.from({ length: 7 }, (_, i) => ({
label: `${i}`,
value: i,
@@ -47,13 +55,6 @@ export default function SelectQuantityStep({ user }: SelectQuantityStepProps) {
? intl.formatMessage({ id: "Insufficient points" })
: intl.formatMessage({ id: "Select quantity" })
// TODO: Remove this when add breakfast is implemented
if (
selectedAncillary?.id === BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST
) {
return "Breakfast TBI"
}
return (
<div className={styles.selectContainer}>
{selectedAncillary?.points && user && (
@@ -96,3 +97,77 @@ export default function SelectQuantityStep({ user }: SelectQuantityStepProps) {
</div>
)
}
function BreakfastInfo() {
const intl = useIntl()
const breakfastData = useAddAncillaryStore((state) => state.breakfastData)
const { setValue } = useFormContext()
setValue("quantityWithCard", 1)
if (!breakfastData) {
return intl.formatMessage({
id: "Can not show breakfast prices.",
})
}
return (
<div className={styles.breakfastContainer}>
<Alert
type={AlertTypeEnum.Info}
text={intl.formatMessage({
id: "Breakfast can only be added for the entire duration of the stayand 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>
{`${breakfastData.nrOfAdults} X ${intl.formatMessage({ id: "Adults" })}`}
</dt>
<dd>
<Body>
{`${breakfastData.priceAdult} ${breakfastData.currency}`}
</Body>
</dd>
</div>
</div>
{breakfastData.nrOfPayingChildren > 0 && (
<div className={styles.breakfastPriceBox}>
<MaterialIcon icon="check_circle" className={styles.icon} />
<div>
<dt>
{`${breakfastData.nrOfPayingChildren} X ${intl.formatMessage({ id: "ages" })} 4-12`}
</dt>
<dd>
<Body>
{`${breakfastData.priceChild} ${breakfastData.currency}`}
</Body>
</dd>
</div>
</div>
)}
{breakfastData.nrOfFreeChildren > 0 && (
<div className={`${styles.breakfastPriceBox} ${styles.free}`}>
<MaterialIcon icon="check_circle" className={styles.icon} />
<div>
<dt>
{`${breakfastData.nrOfFreeChildren} X ${intl.formatMessage({ id: "under" })} 4`}
</dt>
<dd>
<Body>{intl.formatMessage({ id: "Free" })}</Body>
</dd>
</div>
</div>
)}
</dl>
)}
</div>
)
}

View File

@@ -28,3 +28,52 @@
gap: var(--Space-x15);
align-items: center;
}
.breakfastContainer {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.breakfastPrices {
display: flex;
align-items: center;
gap: var(--Spacing-x1);
align-self: stretch;
}
.breakfastPriceBox {
display: flex;
padding: var(--Space-15);
flex: 1 0 0;
border-radius: var(--Corner-radius-md);
border: 1px solid var(--Base-Border-Subtle);
background: var(--Surface-Feedback-Information);
align-items: center;
gap: var(--Spacing-x1);
align-self: stretch;
}
.free {
background: var(--Surface-Feedback-Succes);
}
.breakfastPriceBox dt {
color: var(--Text-Secondary);
font-family: var(--Tag-Font-family);
font-size: var(--Tag-Size);
font-style: normal;
font-weight: var(--Tag-Font-weight);
text-transform: uppercase;
}
.icon {
display: none;
}
@media screen and (min-width: 768px) {
.icon {
display: block;
}
}

View File

@@ -97,3 +97,24 @@
width: 833px;
}
}
.breakfastPriceList {
display: flex;
flex-direction: column;
}
.divider {
display: none;
height: var(--Space-x4);
}
@media screen and (min-width: 768px) {
.breakfastPriceList {
flex-direction: row;
gap: var(--Space-x2);
}
.divider {
display: block;
}
}

View File

@@ -14,6 +14,7 @@ import { env } from "@/env/client"
import { trpc } from "@/lib/trpc/client"
import {
AncillaryStepEnum,
type BreakfastData,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
@@ -39,22 +40,34 @@ import Steps from "./Steps"
import styles from "./addAncillaryFlowModal.module.css"
import type { AddAncillaryFlowModalProps } from "@/types/components/myPages/myStay/ancillaries"
import type {
AddAncillaryFlowModalProps,
Packages,
} from "@/types/components/myPages/myStay/ancillaries"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default function AddAncillaryFlowModal({
booking,
packages,
user,
savedCreditCards,
refId,
}: AddAncillaryFlowModalProps) {
const { currentStep, selectedAncillary, closeModal } = useAddAncillaryStore(
(state) => ({
currentStep: state.currentStep,
selectedAncillary: state.selectedAncillary,
closeModal: state.closeModal,
})
)
const {
currentStep,
selectedAncillary,
closeModal,
breakfastData,
setBreakfastData,
isBreakfast,
} = useAddAncillaryStore((state) => ({
currentStep: state.currentStep,
selectedAncillary: state.selectedAncillary,
closeModal: state.closeModal,
breakfastData: state.breakfastData,
setBreakfastData: state.setBreakfastData,
isBreakfast: state.isBreakfast,
}))
const intl = useIntl()
const lang = useLang()
const router = useRouter()
@@ -139,17 +152,44 @@ export default function AddAncillaryFlowModal({
selectedAncillary,
})
if (booking.guaranteeInfo) {
const packages = []
const packagesToAdd = []
if (selectedAncillary?.id && data.quantityWithCard) {
packages.push({
code: selectedAncillary.id,
quantity: data.quantityWithCard,
comment: data.optionalText || undefined,
})
if (!isBreakfast) {
packagesToAdd.push({
code: selectedAncillary.id,
quantity: data.quantityWithCard,
comment: data.optionalText || undefined,
})
} else {
if (!breakfastData) {
toast.error(
intl.formatMessage({
id: "Something went wrong!",
})
)
return
}
packagesToAdd.push({
code: BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST,
quantity: breakfastData.nrOfAdults,
comment: data.optionalText || undefined,
})
packagesToAdd.push({
code: BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST,
quantity: breakfastData.nrOfPayingChildren,
comment: data.optionalText || undefined,
})
packagesToAdd.push({
code: BreakfastPackageEnum.FREE_CHILD_BREAKFAST,
quantity: breakfastData.nrOfFreeChildren,
comment: data.optionalText || undefined,
})
}
}
if (selectedAncillary?.loyaltyCode && data.quantityWithPoints) {
packages.push({
packagesToAdd.push({
code: selectedAncillary.loyaltyCode,
quantity: data.quantityWithPoints,
comment: data.optionalText || undefined,
@@ -162,7 +202,7 @@ export default function AddAncillaryFlowModal({
ancillaryDeliveryTime: selectedAncillary?.requiresDeliveryTime
? data.deliveryTime
: undefined,
packages,
packages: packagesToAdd,
language: lang,
})
} else {
@@ -212,9 +252,26 @@ export default function AddAncillaryFlowModal({
}
}, [searchParams, pathname, formMethods, router])
useEffect(() => {
setBreakfastData(
calculateBreakfastData(
isBreakfast,
packages,
booking.adults,
booking.childrenAges
)
)
}, [
booking.adults,
booking.childrenAges,
isBreakfast,
packages,
setBreakfastData,
])
if (isLoading) {
return (
<div className={styles.loading}>
<div>
<LoadingSpinner />
</div>
)
@@ -251,10 +308,14 @@ export default function AddAncillaryFlowModal({
<div className={styles.price}>
<Typography variant="Body/Paragraph/mdBold">
<p>
{formatPrice(
intl,
selectedAncillary.price.totalPrice,
selectedAncillary.price.currency
{isBreakfast ? (
<BreakfastPriceList />
) : (
formatPrice(
intl,
selectedAncillary.price.totalPrice,
selectedAncillary.price.currency
)
)}
</p>
</Typography>
@@ -292,10 +353,7 @@ export default function AddAncillaryFlowModal({
<Steps user={user} savedCreditCards={savedCreditCards} />
</div>
</form>
{/* TODO: Remove the berakfast check when add breakfast is implemented */}
{currentStep === AncillaryStepEnum.selectAncillary ||
selectedAncillary?.id ===
BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST ? null : (
{currentStep === AncillaryStepEnum.selectAncillary ? null : (
<div
className={
currentStep === AncillaryStepEnum.confirmation
@@ -316,3 +374,96 @@ export default function AddAncillaryFlowModal({
</Modal>
)
}
function BreakfastPriceList() {
const intl = useIntl()
const breakfastData = useAddAncillaryStore((state) => state.breakfastData)
if (!breakfastData) {
return intl.formatMessage({
id: "Can not show breakfast prices.",
})
}
return (
<div>
<div className={styles.breakfastPriceList}>
<Typography variant="Body/Paragraph/mdBold">
<span>{`${breakfastData.priceAdult} ${breakfastData.currency} / ${intl.formatMessage({ id: "Adult" })}`}</span>
</Typography>
{breakfastData.nrOfPayingChildren > 0 && (
<>
<div className={styles.divider}>
<Divider variant="vertical" color="baseSurfaceSubtleNormal" />
</div>
<Typography variant="Body/Paragraph/mdBold">
<span>{`${breakfastData.priceChild} ${breakfastData.currency} / ${intl.formatMessage({ id: "Years" })} 4-12`}</span>
</Typography>
</>
)}
{breakfastData.nrOfFreeChildren > 0 && (
<>
<div className={styles.divider}>
<Divider variant="vertical" color="baseSurfaceSubtleNormal" />
</div>
<Typography variant="Body/Paragraph/mdBold">
<span>{`${intl.formatMessage({ id: "Free" })} / ${intl.formatMessage({ id: "Under {age} years" }, { age: 4 })}`}</span>
</Typography>
</>
)}
</div>
</div>
)
}
/**
* This function calculates some breakfast data in the store.
* It is used in various places in the add flow, but only needs
* to be calculated once.
*/
function calculateBreakfastData(
isBreakfast: boolean,
packages: Packages | null,
nrOfAdults: number,
childrenAges: number[]
): BreakfastData | null {
if (!isBreakfast) {
return null
}
const [nrOfPayingChildren, nrOfFreeChildren] = childrenAges.reduce(
(acc, curr) => (curr >= 4 ? [acc[0] + 1, acc[1]] : [acc[0], acc[1] + 1]),
[0, 0]
)
const priceAdult = packages?.find(
(p) => p.code === BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST
)?.localPrice.price
const priceChild = packages?.find(
(p) => p.code === BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST
)?.localPrice.price
const currency = packages?.find(
(p) => p.code === BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST
)?.localPrice.currency
if (
typeof priceAdult !== "number" ||
typeof priceChild !== "number" ||
typeof currency !== "string"
) {
return null
} else {
return {
nrOfAdults,
nrOfPayingChildren,
nrOfFreeChildren,
priceAdult,
priceChild,
currency,
}
}
}

View File

@@ -197,6 +197,7 @@ export function Ancillaries({
<AddAncillaryFlowModal
user={user}
booking={booking}
packages={packages}
refId={refId}
savedCreditCards={savedCreditCards}
/>