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

@@ -16,6 +16,8 @@ import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import TermsAndConditions from "@/components/HotelReservation/MyStay/TermsAndConditions"
import { trackUpdatePaymentMethod } from "@/utils/tracking"
import { PaymentChoiceEnum } from "../../schema"
import styles from "./confirmationStep.module.css"
import type { ConfirmationStepProps } from "@/types/components/myPages/myStay/ancillaries"
@@ -37,12 +39,12 @@ export default function ConfirmationStep({
)
const mustBeGuaranteed = !guaranteeInfo && booking.isGuaranteeable
const quantityWithCard = useWatch({ name: "quantityWithCard" })
const quantityWithPoints = useWatch({ name: "quantityWithPoints" })
const quantity = useWatch({ name: "quantity" })
const paymentChoice = useWatch({ name: "paymentChoice" })
const currentPoints = user?.membership?.currentPoints ?? 0
const totalPoints =
quantityWithPoints && selectedAncillary?.points
? selectedAncillary.points * quantityWithPoints
paymentChoice === PaymentChoiceEnum.Points && selectedAncillary?.points
? selectedAncillary.points * quantity
: null
const accordionTitle = intl.formatMessage({
@@ -64,7 +66,7 @@ export default function ConfirmationStep({
return (
<div className={styles.modalContent}>
{error && <Alert type={error.type} text={error.message} />}
{!!quantityWithPoints && (
{paymentChoice === PaymentChoiceEnum.Points && (
<>
<Typography variant="Title/Subtitle/md">
<h2>
@@ -104,7 +106,7 @@ export default function ConfirmationStep({
</div>
</>
)}
{!!quantityWithCard ? (
{paymentChoice === PaymentChoiceEnum.Card ? (
<>
<Typography variant="Title/Subtitle/md">
<h2>

View File

@@ -0,0 +1,80 @@
"use client"
import { useState } from "react"
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { MessageBanner } from "@scandic-hotels/design-system/MessageBanner"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { generateDeliveryOptions } from "@/components/HotelReservation/MyStay/utils/ancillaries"
import styles from "./selectDeliveryTime.module.css"
export default function SelectDeliveryTime() {
const {
setValue,
formState: { errors },
} = useFormContext()
const selectedTime = useWatch({ name: "deliveryTime" })
const [showChangeTime, setShowChangeTime] = useState(() => !selectedTime)
const intl = useIntl()
const deliveryTimeOptions = generateDeliveryOptions()
const deliveryTimeError = errors.deliveryTime
return showChangeTime ? (
<>
{deliveryTimeError && (
<MessageBanner
type="error"
textColor="error"
text={intl.formatMessage({
id: "ancillaries.deliveryDetailsStep.select.errorMessage",
defaultMessage:
"Select a time for when you want your extras to be delivered.",
})}
/>
)}
<div className={styles.grid}>
{deliveryTimeOptions.map((option) => (
<ChipButton
key={option.value}
onPress={() =>
setValue("deliveryTime", option.value, { shouldValidate: true })
}
variant="FilterRounded"
selected={selectedTime === option.value}
>
<MaterialIcon icon="acute" color="CurrentColor" size={28} />
{option.label}
</ChipButton>
))}
</div>
</>
) : (
<div className={styles.card}>
<MaterialIcon icon="acute" color="CurrentColor" size={28} />
<Typography variant="Body/Paragraph/mdBold">
<p>{selectedTime}</p>
</Typography>
<Button
wrapping={false}
variant="Text"
size="Small"
color="Primary"
className={styles.changeButton}
onPress={() => setShowChangeTime(true)}
>
<MaterialIcon icon="edit_square" color="CurrentColor" />
{intl.formatMessage({
id: "ancillaries.deliveryDetailsStep.changeTime.cta",
defaultMessage: "Change time",
})}
</Button>
</div>
)
}

View File

@@ -0,0 +1,19 @@
.card {
display: flex;
align-items: center;
gap: var(--Space-x15);
padding: var(--Space-x15);
border-radius: var(--Corner-radius-md);
background-color: var(--Surface-Primary-OnSurface-Default);
}
.changeButton {
margin-left: auto;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--Space-x2);
row-gap: var(--Space-x2);
}

View File

@@ -0,0 +1,82 @@
"use client"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Badge } from "@scandic-hotels/design-system/Badge"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Image from "@scandic-hotels/design-system/Image"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import { PaymentChoiceEnum } from "../../../schema"
import styles from "./selectedItemCard.module.css"
export default function SelectedItemCard() {
const intl = useIntl()
const { selectedAncillary } = useAddAncillaryStore((state) => ({
selectedAncillary: state.selectedAncillary,
}))
const { watch } = useFormContext()
const quantity = watch("quantity") as number
const paymentChoice = watch("paymentChoice")
if (!selectedAncillary) {
return null
}
const isPointsPayment = paymentChoice === PaymentChoiceEnum.Points
const cost =
isPointsPayment && selectedAncillary.points
? intl.formatMessage(
{
id: "common.pointsAmountPoints",
defaultMessage: "{pointsAmount, number} points",
},
{ pointsAmount: selectedAncillary.points * quantity }
)
: formatPrice(
intl,
selectedAncillary.price.total * quantity,
selectedAncillary.price.currency
)
const icon = isPointsPayment ? (
<MaterialIcon icon="diamond" />
) : (
<MaterialIcon icon="credit_card" />
)
const amountLabel = `x${quantity}`
return (
<div className={styles.card}>
<Image
src={selectedAncillary.imageUrl}
alt={selectedAncillary.title}
width={56}
height={56}
className={styles.image}
/>
<div className={styles.info}>
<Typography variant="Body/Paragraph/mdRegular">
<p>{selectedAncillary.title}</p>
</Typography>
<Typography variant="Body/Paragraph/mdBold" className={styles.title}>
<span className={styles.cost}>
{icon}
{cost}
</span>
</Typography>
</div>
<div className={styles.badge}>
<Badge color="green" number={amountLabel} size="28" />
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
.card {
display: flex;
padding: var(--Space-x15);
gap: var(--Space-x1);
align-self: stretch;
border-radius: var(--Corner-radius-md);
border: 1px solid var(--Border-Divider-Subtle);
background: var(--Surface-Primary-Default);
}
.image {
border-radius: var(--Corner-radius-sm);
}
.cost {
display: flex;
align-items: center;
gap: var(--Space-x05);
}
.badge {
margin-left: auto;
align-self: center;
}

View File

@@ -1,21 +1,39 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
gap: var(--Space-x3);
}
.selectContainer {
.section {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
padding: var(--Space-x3);
margin-bottom: var(--Space-x05);
background-color: var(--Background-Primary);
}
.card {
display: flex;
align-items: center;
gap: var(--Space-x15);
padding: var(--Space-x15);
border-radius: var(--Corner-radius-md);
background-color: var(--Surface-Primary-OnSurface-Default);
}
.select {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
.changeButton {
margin-left: auto;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--Space-x2);
row-gap: var(--Space-x2);
}
.infoText {
color: var(--Text-Secondary);
}
.requestButton {
margin: auto;
}

View File

@@ -1,39 +1,46 @@
import { useState } from "react"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { Divider } from "@scandic-hotels/design-system/Divider"
import TextArea from "@scandic-hotels/design-system/Form/TextArea"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { generateDeliveryOptions } from "@/components/HotelReservation/MyStay/utils/ancillaries"
import Input from "@/components/TempDesignSystem/Form/Input"
import Select from "@/components/TempDesignSystem/Form/Select"
import SelectDeliveryTime from "./SelectDeliveryTime"
import SelectedItemCard from "./SelectedItemCard"
import styles from "./deliveryDetailsStep.module.css"
export default function DeliveryMethodStep() {
const [showSpecialRequests, setShowSpecialRequests] = useState(false)
const intl = useIntl()
const deliveryTimeOptions = generateDeliveryOptions()
return (
<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>
<div className={styles.section}>
<Typography variant="Title/Overline/sm">
<h3>
{intl.formatMessage({
id: "ancillaries.deliveryDetailsStep.itemTitle",
defaultMessage: "Your item",
})}
</h3>
</Typography>
<SelectedItemCard />
</div>
<div className={styles.section}>
<Typography variant="Title/Overline/sm">
<h3>
{intl.formatMessage({
id: "ancillaries.deliveryDetailsStep.select.title",
defaultMessage: "Select time of delivery",
})}
</h3>
</Typography>
<SelectDeliveryTime />
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
<p className={styles.infoText}>
{intl.formatMessage({
id: "addAncillary.deliveryDetailsStep.deliveryTimeDescription",
defaultMessage:
@@ -41,23 +48,44 @@ export default function DeliveryMethodStep() {
})}
</p>
</Typography>
<div className={styles.select}>
<Input
</div>
<Divider color="Border/Divider/Subtle" />
<div className={styles.section}>
<Typography variant="Title/Overline/sm">
<h3>
{intl.formatMessage({
id: "ancillaries.deliveryDetailsStep.specialRequests.title",
defaultMessage: "Special requests (optional)",
})}
</h3>
</Typography>
{showSpecialRequests ? (
<TextArea
label={intl.formatMessage({
id: "addAncillary.deliveryDetailsStep.optionalTextLabel",
defaultMessage: "Other Requests",
id: "addAncillary.deliveryDetailsStep.commentLabel",
defaultMessage:
"Is there anything else you would like us to know before your arrival?",
})}
name="optionalText"
/>
<Typography variant="Body/Supporting text (caption)/smRegular">
<h3>
) : (
<div className={styles.card}>
<Button
wrapping={false}
variant="Text"
size="Small"
color="Primary"
className={styles.requestButton}
onPress={() => setShowSpecialRequests(true)}
>
<MaterialIcon icon="edit_square" color="CurrentColor" />
{intl.formatMessage({
id: "common.optional",
defaultMessage: "Optional",
id: "ancillaries.deliveryDetailsStep.specialRequests.cta",
defaultMessage: "Add special request",
})}
</h3>
</Typography>
</div>
</Button>
</div>
)}
</div>
</div>
)

View File

@@ -1,29 +0,0 @@
import {
AncillaryStepEnum,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import ConfirmationStep from "../ConfirmationStep"
import DeliveryMethodStep from "../DeliveryDetailsStep"
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)
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}
/>
)
}
}

View File

@@ -1,33 +0,0 @@
import {
AncillaryStepEnum,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import ConfirmationStep from "../ConfirmationStep"
import DeliveryMethodStep from "../DeliveryDetailsStep"
import SelectQuantityStep from "../SelectQuantityStep"
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
export default function Mobile({ user, savedCreditCards, error }: StepsProps) {
const { currentStep, selectedAncillary } = useAddAncillaryStore((state) => ({
currentStep: state.currentStep,
selectedAncillary: state.selectedAncillary,
}))
if (currentStep === AncillaryStepEnum.selectQuantity) {
return (
<>
<SelectQuantityStep user={user} />
{selectedAncillary?.requiresDeliveryTime && <DeliveryMethodStep />}
</>
)
}
return (
<ConfirmationStep
savedCreditCards={savedCreditCards}
user={user}
error={error}
/>
)
}

View File

@@ -0,0 +1,188 @@
"use client"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { Radio } from "@scandic-hotels/design-system/Radio"
import Stepper from "@scandic-hotels/design-system/Stepper"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./paymentOption.module.css"
type PaymentCardProps = {
totalCostMessage: string
icon: React.ReactNode
description: string
selected?: boolean
title?: string
value?: string
spendablePoints?: number
hasReachedMax?: boolean
}
export function PaymentOption(props: PaymentCardProps) {
const isRadio = Boolean(props.value)
const content = <InnerPaymentOption {...props} isRadio={isRadio} />
if (isRadio) {
function handleKeyDown(e: React.KeyboardEvent<HTMLLabelElement>) {
if (e.key === " " || e.key === "Enter") {
e.preventDefault()
// The isRadio checks that this is defined
const radio = document.getElementById(props.value!)
radio?.click()
}
}
return (
<label
className={styles.container}
data-selected={props.selected}
tabIndex={0}
role="radio"
aria-checked={props.selected}
id={props.value}
onKeyDown={handleKeyDown}
>
{content}
</label>
)
}
return <div className={styles.container}>{content}</div>
}
function SpendablePointsBanner({ points }: { points: number }) {
const intl = useIntl()
return (
<div className={styles.spendablePoints}>
<Typography variant="Title/Overline/sm">
<p>
{intl.formatMessage({
id: "myPages.myStay.ancillaries.spendablePointsTitle",
defaultMessage: "Your spendable points",
})}
</p>
</Typography>
<Typography variant="Body/Supporting text (caption)/smBold">
<p>{intl.formatNumber(points)}</p>
</Typography>
</div>
)
}
function InnerPaymentOption({
isRadio = false,
selected = false,
title,
description,
icon,
value,
totalCostMessage,
spendablePoints,
hasReachedMax = false,
}: PaymentCardProps & { isRadio: boolean }) {
const intl = useIntl()
const { setValue, watch } = useFormContext()
const quantity = watch("quantity")
function handleOnIncrease() {
setValue("quantity", quantity + 1, { shouldValidate: true })
}
function handleOnDecrease() {
setValue("quantity", quantity - 1, { shouldValidate: true })
}
return (
<div className={styles.innerContainer}>
{spendablePoints && <SpendablePointsBanner points={spendablePoints} />}
<div className={styles.content}>
<div className={styles.row} data-radio={isRadio}>
{value && <Radio value={value} wrapping={false} />}
<div className={styles.infoContainer}>
{icon}
<div className={styles.wrapping}>
<div className={styles.info}>
<Typography variant="Body/Paragraph/mdBold">
<span>{title}</span>
</Typography>
{selected && (
<Typography variant="Body/Paragraph/mdRegular">
<span>{description}</span>
</Typography>
)}
</div>
{selected && (
<Stepper
count={quantity}
handleOnIncrease={handleOnIncrease}
handleOnDecrease={handleOnDecrease}
disableDecrease={quantity <= 0}
disableIncrease={hasReachedMax}
disabledMessage={intl.formatMessage({
id: "myPages.myStay.ancillaries.reachedMaxPointsStepperMessage",
defaultMessage:
"Youve reached your points limit and cant add more items with points.",
})}
/>
)}
</div>
</div>
</div>
{selected && quantity > 0 && (
<>
<Divider />
<div className={styles.total}>
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
id: "common.total",
defaultMessage: "Total",
})}
</p>
</Typography>
<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>
<Typography variant="Title/Subtitle/md">
<p className={styles.vatText}>{totalCostMessage}</p>
</Typography>
</div>
</>
)}
</div>
</div>
)
}
export function NotEnoughPointsBanner({
spendablePoints,
}: {
spendablePoints: number
}) {
const intl = useIntl()
return (
<div className={styles.container}>
<div className={styles.innerContainer}>
<SpendablePointsBanner points={spendablePoints} />
<div className={styles.content}>
<Typography>
<p className={styles.vatText}>
{intl.formatMessage({
id: "myPages.myStay.ancillaries.insufficientPointsMessage",
defaultMessage: "You don't have enough points for this item",
})}
</p>
</Typography>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,95 @@
.container {
display: flex;
border-radius: var(--Corner-radius-md);
background: var(--Surface-Primary-OnSurface-Default);
width: 100%;
align-items: flex-start;
position: relative;
&label[data-selected="false"] {
cursor: pointer;
align-items: center;
}
&label[data-selected="true"] {
border: 2px solid var(--Border-Interactive-Active);
}
&label:focus-visible {
outline: 2px solid var(--Border-Interactive-Active);
outline-offset: 2px;
}
&label:hover {
background: var(--Surface-Primary-Hover);
}
}
.innerContainer {
width: 100%;
}
.content {
display: grid;
gap: var(--Space-x15);
padding: var(--Space-x15);
}
.spendablePoints {
display: flex;
justify-content: space-between;
border-radius: var(--Corner-radius-md) var(--Corner-radius-md) 0 0;
background: var(--Surface-Brand-Primary-1-Default);
width: 100%;
padding: var(--Space-x1) var(--Space-x15);
}
.row {
display: grid;
gap: var(--Space-x15);
align-items: flex-start;
&[data-radio="true"] {
grid-template-columns: auto 1fr;
}
}
.wrapping {
display: flex;
width: 100%;
flex-wrap: wrap;
gap: var(--Space-x15);
justify-content: space-between;
}
.total {
display: flex;
justify-content: flex-end;
width: 100%;
gap: var(--Space-x1);
align-items: baseline;
}
.radioContainer {
width: 100%;
}
.info {
display: flex;
flex-direction: column;
align-self: flex-start;
.row[data-radio="false"] & {
align-self: center;
}
}
.infoContainer {
display: flex;
gap: var(--Space-x05);
width: 100%;
.row[data-radio="false"] & {
align-items: center;
}
}

View File

@@ -1,17 +1,21 @@
import { type ReactNode } from "react"
import { RadioGroup } from "react-aria-components"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { ErrorMessage } from "@scandic-hotels/design-system/Form/ErrorMessage"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { MessageBanner } from "@scandic-hotels/design-system/MessageBanner"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import Select from "@/components/TempDesignSystem/Form/Select"
import { getErrorMessage } from "@/utils/getErrorMessage"
import Description from "../../Description"
import { PaymentChoiceEnum } from "../../schema"
import { BreakfastInfo } from "./BreakfastInfo"
import { NotEnoughPointsBanner, PaymentOption } from "./PaymentOption"
import styles from "./selectQuantityStep.module.css"
@@ -20,11 +24,6 @@ import type {
SelectQuantityStepProps,
} from "@/types/components/myPages/myStay/ancillaries"
const cardQuantityOptions = Array.from({ length: 7 }, (_, i) => ({
label: `${i}`,
value: i,
}))
export default function SelectQuantityStep({ user }: SelectQuantityStepProps) {
const { isBreakfast, selectedAncillary } = useAddAncillaryStore((state) => ({
isBreakfast: state.isBreakfast,
@@ -46,7 +45,12 @@ export default function SelectQuantityStep({ user }: SelectQuantityStepProps) {
)
}
return <div className={styles.container}>{content}</div>
return (
<div className={styles.container}>
<Description />
{content}
</div>
)
}
function InnerSelectQuantityStep({
@@ -55,101 +59,147 @@ function InnerSelectQuantityStep({
}: InnerSelectQuantityStepProps) {
const intl = useIntl()
const {
watch,
setValue,
formState: { errors },
} = useFormContext()
const paymentChoice = watch("paymentChoice")
const quantity = watch("quantity") as number
const pointsCost = selectedAncillary?.points ?? 0
const currentPoints = user?.membership?.currentPoints ?? 0
const maxAffordable =
pointsCost > 0 ? Math.min(Math.floor(currentPoints / pointsCost), 7) : 0
const pointsQuantityOptions = Array.from(
{ length: maxAffordable + 1 },
(_, i) => ({
label: `${i}`,
value: i,
})
)
const hasMultiplePaymentOptions =
selectedAncillary.price && selectedAncillary?.points && user
const insufficientPoints = currentPoints < pointsCost || currentPoints === 0
const selectionError =
(errors["quantity"]?.message as string | undefined) ||
(errors["paymentChoice"]?.message as string | undefined)
const hasReachedMaxPoints = pointsCost * (quantity + 1) >= currentPoints
return (
<div className={styles.selectContainer}>
{selectedAncillary?.points && user && (
<div className={styles.select}>
<Typography variant="Title/Subtitle/md">
<h2 className={styles.selectTitle}>
{intl.formatMessage({
id: "addAncillary.selectQuantityStep.payWithPoints",
defaultMessage: "Pay with points",
{hasMultiplePaymentOptions && (
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
id: "addAncillary.selectQuantityStep.selectQuantityTitle",
defaultMessage: "How would you like to pay?",
})}
</p>
</Typography>
)}
{selectionError && (
<MessageBanner
type="error"
text={getErrorMessage(intl, selectionError) ?? selectionError}
/>
)}
{hasMultiplePaymentOptions && !insufficientPoints ? (
<RadioGroup
className={styles.radioGroup}
onChange={(val) => {
setValue("quantity", 0)
setValue("paymentChoice", val as PaymentChoiceEnum, {
shouldValidate: true,
})
}}
value={paymentChoice}
>
<PaymentOption
icon={<MaterialIcon color="CurrentColor" icon="credit_card" />}
title={intl.formatMessage({
id: "common.paymentCard",
defaultMessage: "Payment card",
})}
description={intl.formatMessage(
{
id: "addAncillary.selectQuantityStep.costPerUnit",
defaultMessage: "{cost}/per {unit} ",
},
{
cost: formatPrice(
intl,
selectedAncillary.price.total,
selectedAncillary.price.currency
),
unit: selectedAncillary.unitName,
}
)}
selected={paymentChoice === PaymentChoiceEnum.Card}
value={PaymentChoiceEnum.Card}
totalCostMessage={formatPrice(
intl,
selectedAncillary.price.total * quantity,
selectedAncillary.price.currency
)}
/>
<PaymentOption
icon={<MaterialIcon color="CurrentColor" icon="diamond" />}
title={intl.formatMessage({
id: "common.points",
defaultMessage: "Points",
})}
description={intl.formatMessage(
{
id: "addAncillary.selectQuantityStep.costPerUnit",
defaultMessage: "{cost}/per {unit} ",
},
{
cost: intl.formatMessage(
{
id: "common.numberOfPoints",
defaultMessage: "{points} points",
},
{ points: pointsCost }
),
unit: selectedAncillary.unitName,
}
)}
selected={paymentChoice === PaymentChoiceEnum.Points}
value={PaymentChoiceEnum.Points}
totalCostMessage={intl.formatMessage(
{
id: "common.numberOfPoints",
defaultMessage: "{points} points",
},
{ points: pointsCost * quantity }
)}
hasReachedMax={hasReachedMaxPoints}
spendablePoints={currentPoints}
/>
{hasReachedMaxPoints && (
<MessageBanner
type="error"
text={intl.formatMessage({
id: "myPages.myStay.ancillaries.reachedMaxPointsMessage",
defaultMessage: "You have reached your points limit.",
})}
</h2>
</Typography>
<div className={styles.totalPointsContainer}>
<div className={styles.totalPoints}>
<MaterialIcon icon="diamond" />
<Typography variant="Title/Overline/sm">
<h2>
{intl.formatMessage({
id: "common.totalPoints",
defaultMessage: "Total points",
})}
</h2>
</Typography>
</div>
<Typography variant="Body/Paragraph/mdRegular">
<p>{currentPoints}</p>
</Typography>
</div>
{insufficientPoints ? (
<Typography variant="Body/Supporting text (caption)/smRegular">
<h2 className={styles.insufficientPoints}>
{intl.formatMessage({
id: "addAncillary.selectQuantityStep.insufficientPoints",
defaultMessage: "Insufficient points",
})}
</h2>
</Typography>
) : (
<Select
name="quantityWithPoints"
label={intl.formatMessage({
id: "addAncillary.selectQuantityStep.selectQuantityLabel",
defaultMessage: "Select quantity",
})}
items={pointsQuantityOptions}
isNestedInModal
/>
)}
</div>
</RadioGroup>
) : (
<>
<PaymentOption
icon={<MaterialIcon color="CurrentColor" icon="credit_card" />}
description={formatPrice(
intl,
selectedAncillary.price.total,
selectedAncillary.price.currency
)}
totalCostMessage={formatPrice(
intl,
selectedAncillary.price.total * quantity,
selectedAncillary.price.currency
)}
selected={true}
/>
{selectedAncillary.points && insufficientPoints ? (
<NotEnoughPointsBanner spendablePoints={currentPoints} />
) : null}
</>
)}
<div className={styles.select}>
<Typography variant="Title/Subtitle/md">
<h2 className={styles.selectTitle}>
{intl.formatMessage({
id: "addAncillary.selectQuantityStep.payWithCard",
defaultMessage: "Pay with card at the hotel",
})}
</h2>
</Typography>
<Select
name="quantityWithCard"
label={intl.formatMessage({
id: "addAncillary.selectQuantityStep.selectQuantityLabel",
defaultMessage: "Select quantity",
})}
items={cardQuantityOptions}
isNestedInModal
/>
<ErrorMessage
errors={errors}
name="quantityWithCard"
messageLabel={getErrorMessage(
intl,
errors["quantityWithCard"]?.message?.toString()
)}
/>
</div>
</div>
)
}

View File

@@ -1,45 +1,19 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
gap: var(--Space-x3);
}
.selectContainer {
display: flex;
flex-direction: column;
gap: var(--Space-x025);
gap: var(--Space-x2);
}
.select {
.radioGroup {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
padding: var(--Space-x2) var(--Space-x3);
background-color: var(--Surface-Primary-OnSurface-Default);
border-radius: var(--Corner-radius-md);
margin-bottom: var(--Space-x1);
}
.selectTitle {
margin-bottom: var(--Space-x1);
}
.insufficientPoints {
color: var(--Text-Tertiary);
}
.totalPointsContainer {
display: flex;
justify-content: space-between;
background-color: var(--Surface-Brand-Primary-2-OnSurface-Accent);
padding: var(--Space-x1) var(--Space-x15);
border-radius: var(--Corner-radius-md);
}
.totalPoints {
display: flex;
gap: var(--Space-x15);
align-items: center;
gap: var(--Space-x2);
}
.breakfastContainer {
@@ -81,15 +55,3 @@
font-weight: var(--Tag-Font-weight);
text-transform: uppercase;
}
.icon {
display: none;
}
@media screen and (min-width: 768px) {
.select {
margin-bottom: 0;
}
.icon {
display: block;
}
}

View File

@@ -1,12 +1,29 @@
import { useMediaQuery } from "usehooks-ts"
import {
AncillaryStepEnum,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import Desktop from "./Desktop"
import Mobile from "./Mobile"
import ConfirmationStep from "./ConfirmationStep"
import DeliveryMethodStep from "./DeliveryDetailsStep"
import SelectQuantityStep from "./SelectQuantityStep"
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
export default function Steps(props: StepsProps) {
const isMobile = useMediaQuery("(max-width: 767px)")
export default function Steps({ user, savedCreditCards, error }: StepsProps) {
const currentStep = useAddAncillaryStore((state) => state.currentStep)
return isMobile ? <Mobile {...props} /> : <Desktop {...props} />
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}
/>
)
}
}