Merged in fix/STAY-135 (pull request #3368)

Fix/STAY-135 & STAY-127

* fix: make quantity and delivery separate steps in mobile

* fix: update design for delivery step in ancillary flow

* fix: add error state for missing time

* fix: only allow points or cash payment for ancillaries

* fix: break out stepper to design system

* fix: update design of select quantity step in add ancillaries flow

* fix: add error states for quantity

* fix: handle insufficient points case

* fix: update stepper to include optional disabledMessage tooltip

* fix: handle validations

* fix: change name to camel case


Approved-by: Bianca Widstam
Approved-by: Chuma Mcphoy (We Ahead)
This commit is contained in:
Christel Westerberg
2025-12-18 13:31:43 +00:00
parent 2c8b920dd8
commit 6b08d5a113
54 changed files with 1498 additions and 872 deletions

View File

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

View File

@@ -0,0 +1,45 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.totalPrice {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--Space-x1);
border-radius: var(--Corner-radius-md);
}
.showOnDesktop {
display: none;
}
.totalPriceInclVAT {
display: flex;
gap: var(--Space-x15);
flex-wrap: wrap;
}
.totalPriceValue {
display: flex;
gap: var(--Space-x1);
height: 20px;
}
.vatText {
color: var(--Text-Tertiary);
}
@media screen and (min-width: 768px) {
.showOnDesktop {
display: block;
}
.hideOnDesktop {
display: none;
}
.totalPrice {
align-items: baseline;
}
}

View File

@@ -0,0 +1,39 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./priceRow.module.css"
interface PriceRowProps {
title: string
quantity: number
label: string
value: string
}
export default function PriceRow({
title,
quantity,
label,
value,
}: PriceRowProps) {
return (
<>
<div className={styles.column}>
<Typography variant="Body/Paragraph/mdBold">
<h2>{title}</h2>
</Typography>
<Typography variant="Body/Paragraph/mdBold">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<p>{`×${quantity}`}</p>
</Typography>
</div>
<div className={styles.column}>
<Typography variant="Body/Paragraph/mdRegular">
<h2 className={styles.priceText}>{label}</h2>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<h2 className={styles.priceText}>{value}</h2>
</Typography>
</div>
</>
)
}

View File

@@ -0,0 +1,8 @@
.column {
display: flex;
justify-content: space-between;
}
.priceText {
color: var(--Text-Tertiary);
}

View File

@@ -0,0 +1,139 @@
import { Fragment } from "react"
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 { PaymentChoiceEnum } from "../../schema"
import PriceRow from "./PriceRow"
import styles from "./priceSummary.module.css"
interface PriceSummaryProps {
totalPrice: number | null
totalPoints: number | null
paymentChoice: string | null
items: {
title: string
totalPrice: number
currency: string
points?: number
quantity: number
}[]
}
export function PriceSummary({
totalPrice,
totalPoints,
paymentChoice,
items,
}: PriceSummaryProps) {
const intl = useIntl()
const label =
paymentChoice === PaymentChoiceEnum.Points
? intl.formatMessage({
id: "common.points",
defaultMessage: "Points",
})
: intl.formatMessage({
id: "common.price",
defaultMessage: "Price",
})
return (
<div className={styles.container}>
<Typography variant="Body/Paragraph/mdBold">
<h2>
{intl.formatMessage({
id: "common.summary",
defaultMessage: "Summary",
})}
</h2>
</Typography>
<Divider />
{items.map((item) => (
<Fragment key={item.title}>
{!!item.quantity && (
<PriceRow
title={item.title}
quantity={item.quantity}
label={label}
value={
paymentChoice === PaymentChoiceEnum.Points
? intl.formatMessage(
{
id: "common.numberOfPoints",
defaultMessage:
"{points, plural, one {# point} other {# points}}",
},
{ points: item.points }
)
: formatPrice(intl, item.totalPrice, item.currency)
}
/>
)}
<Divider />
</Fragment>
))}
<div className={styles.column}>
{totalPrice ? (
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{
id: "booking.totalPriceInclVat",
defaultMessage: "<b>Total price</b> (incl VAT)",
},
{
b: (str) => (
<Typography variant="Body/Paragraph/mdBold">
<span>{str}</span>
</Typography>
),
}
)}
</p>
</Typography>
) : (
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
id: "common.totalPoints",
defaultMessage: "Total points",
})}
</p>
</Typography>
)}
<div className={styles.totalPrice}>
{(totalPoints || totalPrice) && (
<Typography variant="Body/Paragraph/mdBold">
<p>
{totalPrice
? formatPrice(intl, totalPrice, items[0]?.currency)
: null}
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{totalPoints && totalPrice ? " + " : null}
{totalPoints
? intl.formatMessage(
{
id: "common.numberOfPoints",
defaultMessage:
"{points, plural, one {# point} other {# points}}",
},
{ points: totalPoints }
)
: null}
</p>
</Typography>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
.container {
display: flex;
padding: var(--Space-x3);
flex-direction: column;
gap: var(--Space-x2);
align-self: stretch;
border-radius: var(--Corner-radius-lg);
border: 1px solid var(--Border-Divider-Default);
background: var(--Surface-Primary-Default);
}
.column {
display: flex;
justify-content: space-between;
}
.totalPrice {
display: flex;
align-items: flex-start;
}

View File

@@ -0,0 +1,290 @@
import { cx } from "class-variance-authority"
import { useState } from "react"
import { useFormContext, useWatch } from "react-hook-form"
import { type IntlShape, useIntl } from "react-intl"
import { Button } 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 { PaymentChoiceEnum } from "../schema"
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({ onSubmit }: { onSubmit: () => void }) {
const intl = useIntl()
const [isPriceDetailsOpen, setIsPriceDetailsOpen] = useState(false)
function togglePriceDetails() {
setIsPriceDetailsOpen((isOpen) => !isOpen)
}
const {
prevStep,
selectedAncillary,
isBreakfast,
breakfastData,
currentStep,
selectDeliveryTime,
selectQuantity,
} = useAddAncillaryStore((state) => ({
prevStep: state.prevStep,
currentStep: state.currentStep,
selectedAncillary: state.selectedAncillary,
isBreakfast: state.isBreakfast,
breakfastData: state.breakfastData,
selectQuantity: state.selectQuantity,
selectDeliveryTime: state.selectDeliveryTime,
}))
const {
trigger,
formState: { isSubmitting, errors },
} = useFormContext()
const quantity = useWatch({ name: "quantity" }) as number
const paymentChoice: PaymentChoiceEnum = useWatch({ name: "paymentChoice" })
const isConfirmation = currentStep === AncillaryStepEnum.confirmation
async function handleNextStep() {
switch (currentStep) {
case AncillaryStepEnum.selectQuantity: {
const isValid = await trigger(["quantity", "paymentChoice"])
if (isValid) {
const quantityWithCard =
paymentChoice === PaymentChoiceEnum.Points ? 0 : quantity
const quantityWithPoints =
paymentChoice === PaymentChoiceEnum.Points ? quantity : 0
trackAddAncillary(
selectedAncillary,
quantityWithCard,
quantityWithPoints,
breakfastData
)
selectQuantity()
}
break
}
case AncillaryStepEnum.selectDelivery: {
const isValid = await trigger("deliveryTime")
if (isValid) {
selectDeliveryTime()
}
break
}
case AncillaryStepEnum.confirmation: {
onSubmit()
break
}
}
}
if (!selectedAncillary || (!breakfastData && isBreakfast)) {
return null
}
const isSingleItem = !selectedAncillary.requiresQuantity
const secondaryButtonLabel =
currentStep === AncillaryStepEnum.selectQuantity
? intl.formatMessage({
id: "common.cancel",
defaultMessage: "Cancel",
})
: intl.formatMessage({
id: "common.back",
defaultMessage: "Back",
})
function buttonLabel() {
switch (currentStep) {
case AncillaryStepEnum.selectQuantity:
return isSingleItem
? intl.formatMessage({
id: "common.reviewAndConfirm",
defaultMessage: "Review & Confirm",
})
: intl.formatMessage({
id: "addAncillaryFlowModal.proceedToDelivery",
defaultMessage: "Proceed to delivery",
})
case AncillaryStepEnum.selectDelivery:
return intl.formatMessage({
id: "common.reviewAndConfirm",
defaultMessage: "Review & Confirm",
})
case AncillaryStepEnum.confirmation:
return intl.formatMessage({
id: "addAncillaryFlowModal.addToBooking",
defaultMessage: "Add to booking",
})
}
}
const items = isBreakfast
? getBreakfastItems(intl, selectedAncillary, breakfastData)
: [
{
title: selectedAncillary.title,
totalPrice: selectedAncillary.price.total,
currency: selectedAncillary.price.currency,
points: selectedAncillary.points,
quantity,
},
]
const totalPrice = isBreakfast
? breakfastData!.totalPrice
: paymentChoice === PaymentChoiceEnum.Card && selectedAncillary
? selectedAncillary.price.total * quantity
: null
const totalPoints =
paymentChoice === PaymentChoiceEnum.Points && selectedAncillary?.points
? selectedAncillary.points * quantity
: null
return (
<div className={styles.summary}>
<div
className={cx({
[styles.backgroundBox]: isConfirmation || isSingleItem,
})}
>
{(isSingleItem || isConfirmation) && (
<PriceDetails
totalPrice={totalPrice}
totalPoints={totalPoints}
selectedAncillary={selectedAncillary}
/>
)}
{isConfirmation && isPriceDetailsOpen && (
<PriceSummary
totalPrice={totalPrice}
totalPoints={totalPoints}
items={items}
paymentChoice={paymentChoice}
/>
)}
<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="Medium"
color="Primary"
wrapping={false}
onPress={prevStep}
>
{secondaryButtonLabel}
</Button>
<Button
typography="Body/Supporting text (caption)/smBold"
size="Medium"
isDisabled={
isSubmitting || (isConfirmation && !!Object.keys(errors).length)
}
isPending={isSubmitting}
onPress={handleNextStep}
variant={isSingleItem || isConfirmation ? "Primary" : "Secondary"}
>
{buttonLabel()}
</Button>
</div>
</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,
quantity: 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,
quantity: 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,
quantity: breakfastData.nrOfFreeChildren * breakfastData.nrOfNights,
})
}
return items
}

View File

@@ -0,0 +1,51 @@
.summary {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: var(--Space-x3) var(--Space-x2) var(--Space-x4);
width: 100%;
border-top: 1px solid var(--Border-Default);
}
.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;
flex-direction: column-reverse;
gap: var(--Space-x2);
}
.backgroundBox .buttons {
flex-direction: row;
gap: var(--Space-x2);
justify-content: space-between;
}
.confirmButtons {
display: flex;
padding-left: var(--Space-x15);
justify-content: space-between;
align-items: baseline;
}
.priceButton {
display: flex;
gap: var(--Space-x05);
}
@media screen and (min-width: 768px) {
.summary {
padding: var(--Space-x3) var(--Space-x3) var(--Space-x4);
}
.buttons {
flex-direction: row;
justify-content: space-between;
}
}