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

@@ -1,17 +1,17 @@
.price { .price {
display: flex; display: flex;
gap: var(--Space-x2); gap: var(--Space-x05);
align-items: center; align-items: center;
} }
.contentContainer { .contentContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--Space-x1);
} }
.description { .description {
display: flex; display: flex;
margin: var(--Space-x2) 0;
} }
.pointsDivider { .pointsDivider {
@@ -30,6 +30,15 @@
height: var(--Space-x4); height: var(--Space-x4);
} }
.image {
aspect-ratio: 2 / 1;
border-radius: var(--Corner-radius-md);
object-fit: cover;
width: 100%;
height: auto;
margin-bottom: var(--Space-x15);
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
.breakfastPriceList { .breakfastPriceList {
flex-direction: row; flex-direction: row;

View File

@@ -2,6 +2,7 @@ import { useIntl } from "react-intl"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting" import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Divider } from "@scandic-hotels/design-system/Divider" import { Divider } from "@scandic-hotels/design-system/Divider"
import Image from "@scandic-hotels/design-system/Image"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import { import {
@@ -27,11 +28,18 @@ export default function Description() {
return ( return (
<div className={styles.contentContainer}> <div className={styles.contentContainer}>
<Image
src={selectedAncillary.imageUrl}
width={400}
height={200}
alt={selectedAncillary.title}
className={styles.image}
/>
<div className={styles.price}> <div className={styles.price}>
<Typography variant="Body/Paragraph/mdBold"> {isBreakfast ? (
{isBreakfast ? ( <BreakfastPriceList />
<BreakfastPriceList /> ) : (
) : ( <Typography variant="Body/Paragraph/mdBold">
<p> <p>
{formatPrice( {formatPrice(
intl, intl,
@@ -39,26 +47,37 @@ export default function Description() {
selectedAncillary.price.currency selectedAncillary.price.currency
)} )}
</p> </p>
)} </Typography>
</Typography> )}
{selectedAncillary.points && ( {selectedAncillary.points && (
<div className={styles.pointsDivider}> <Typography variant="Body/Paragraph/mdBold">
<Divider variant="vertical" /> <p>
<Typography variant="Body/Paragraph/mdBold"> {intl.formatMessage(
<p> {
{intl.formatMessage( id: "common.orNumberOfPoints",
{ defaultMessage:
id: "common.numberOfPoints", "or {points, plural, one {# point} other {# points}}",
defaultMessage: },
"{points, plural, one {# point} other {# points}}", {
}, points: selectedAncillary.points,
{ }
points: selectedAncillary.points, )}
} </p>
)} </Typography>
</p> )}
</Typography> {selectedAncillary.requiresQuantity && (
</div> <Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{
id: "addAncillaryFlowModal.perUnit",
defaultMessage: "/per {unit}",
},
{ unit: selectedAncillary.unitName }
)}
</p>
</Typography>
)} )}
</div> </div>
<div className={styles.description}> <div className={styles.description}>

View File

@@ -1,24 +1,21 @@
.modal { .form {
display: flex;
flex-direction: column;
overflow-y: hidden;
width: 100%; width: 100%;
} }
.modalContent {
gap: unset;
}
.modalScrollable { .modalScrollable {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: var(--Space-x1) var(--Space-x2) var(--Space-x2); padding: var(--Space-x1) var(--Space-x2) var(--Space-x2);
} }
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
.modal {
width: 492px;
}
.modalScrollable { .modalScrollable {
padding: var(--Space-x1) var(--Space-x3) var(--Space-x3); padding: var(--Space-x1) var(--Space-x3) var(--Space-x3);
} }

View File

@@ -0,0 +1,311 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner"
import { toast } from "@scandic-hotels/design-system/Toast"
import { trpc } from "@scandic-hotels/trpc/client"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import {
buildAncillaryPackages,
clearAncillarySessionData,
generateDeliveryOptions,
getAncillarySessionData,
setAncillarySessionData,
} from "@/components/HotelReservation/MyStay/utils/ancillaries"
import { useGuaranteeBooking } from "@/hooks/booking/useGuaranteeBooking"
import useLang from "@/hooks/useLang"
import {
trackAncillaryFailed,
trackAncillarySuccess,
trackGlaAncillaryAttempt,
} from "@/utils/tracking/myStay"
import { isAncillaryError } from "../../../utils"
import {
type AncillaryFormData,
ancillaryFormSchema,
PaymentChoiceEnum,
} from "../schema"
import Steps from "../Steps"
import Summary from "../Summary"
import {
buildBreakfastPackages,
getErrorMessage,
getGuaranteeCallback,
} from "../utils"
import styles from "./form.module.css"
import type {
AddAncillaryFormProps,
AncillaryErrorMessage,
AncillaryItem,
} from "@/types/components/myPages/myStay/ancillaries"
export default function Form({
booking,
user,
savedCreditCards,
}: AddAncillaryFormProps) {
const { closeModal, selectedAncillary, breakfastData, isBreakfast, isOpen } =
useAddAncillaryStore((state) => ({
selectedAncillary: state.selectedAncillary,
closeModal: state.closeModal,
breakfastData: state.breakfastData,
isBreakfast: state.isBreakfast,
isOpen: state.isOpen,
}))
const intl = useIntl()
const lang = useLang()
const router = useRouter()
const searchParams = useSearchParams()
const pathname = usePathname()
const [errorMessage, setErrorMessage] =
useState<AncillaryErrorMessage | null>(null)
const guaranteeRedirectUrl = getGuaranteeCallback(lang, pathname)
const deliveryTimeOptions = generateDeliveryOptions()
const hasInsufficientPoints =
(user?.membership?.currentPoints ?? 0) < (selectedAncillary?.points ?? 0)
const onlyCardIsAvailable =
!user || hasInsufficientPoints || !selectedAncillary?.points
const formMethods = useForm({
defaultValues: {
quantity:
onlyCardIsAvailable || !selectedAncillary?.requiresQuantity ? 1 : 0,
paymentChoice: onlyCardIsAvailable ? PaymentChoiceEnum.Card : undefined,
deliveryTime: selectedAncillary?.requiresDeliveryTime
? booking.ancillary?.deliveryTime
: deliveryTimeOptions[0].value,
optionalText: "",
termsAndConditions: false,
paymentMethod: booking.guaranteeInfo
? PaymentMethodEnum.card
: savedCreditCards?.length
? savedCreditCards[0].id
: PaymentMethodEnum.card,
},
shouldFocusError: true,
mode: "onChange",
reValidateMode: "onChange",
resolver: zodResolver(ancillaryFormSchema),
})
useEffect(() => {
if (isAncillaryError(searchParams)) {
const errorCode = searchParams.get("errorCode")
const queryParams = new URLSearchParams(searchParams.toString())
const savedData = getAncillarySessionData()
if (savedData?.formData) {
const updatedFormData = {
...savedData.formData,
paymentMethod: booking?.guaranteeInfo
? PaymentMethodEnum.card
: savedData.formData.paymentMethod,
}
formMethods.reset(updatedFormData)
}
// eslint-disable-next-line react-hooks/set-state-in-effect
setErrorMessage(getErrorMessage(intl, errorCode))
queryParams.delete("ancillary")
queryParams.delete("errorCode")
router.replace(`${pathname}?${queryParams.toString()}`)
}
}, [searchParams, pathname, formMethods, router, booking.guaranteeInfo, intl])
const ancillaryErrorMessage = intl.formatMessage(
{
id: "addAncillaryFlowModal.errorMessage.ancillary",
defaultMessage:
"Something went wrong. {ancillary} could not be added to your booking!",
},
{ ancillary: selectedAncillary?.title }
)
const utils = trpc.useUtils()
const addAncillary = trpc.booking.packages.useMutation()
const { guaranteeBooking, isLoading, handleGuaranteeError } =
useGuaranteeBooking(booking.refId, true, booking.hotelId)
async function handleAncillarySubmission(
data: AncillaryFormData,
packages: {
code: string
quantity: number
comment: string | undefined
}[]
) {
await addAncillary.mutateAsync(
{
refId: booking.refId,
ancillaryComment: data.optionalText,
ancillaryDeliveryTime: selectedAncillary?.requiresDeliveryTime
? data.deliveryTime
: undefined,
packages: packages,
language: lang,
},
{
onSuccess: (result) => {
if (result) {
trackAncillarySuccess(
booking.confirmationNumber,
packages,
data.deliveryTime,
"ancillary",
selectedAncillary,
breakfastData,
booking.guaranteeInfo?.cardType,
booking.roomTypeCode
)
toast.success(
intl.formatMessage(
{
id: "addAncillaryFlowModal.ancillaryAdded",
defaultMessage: "{ancillary} added to your booking!",
},
{ ancillary: selectedAncillary?.title }
)
)
clearAncillarySessionData()
closeModal()
utils.booking.get.invalidate({
refId: booking.refId,
})
router.refresh()
} else {
trackAncillaryFailed(
packages,
data.deliveryTime,
selectedAncillary,
breakfastData
)
toast.error(ancillaryErrorMessage)
closeModal()
}
},
onError: () => {
trackAncillaryFailed(
packages,
data.deliveryTime,
selectedAncillary,
breakfastData
)
toast.error(ancillaryErrorMessage)
closeModal()
},
}
)
}
async function handleGuaranteePayment(
data: AncillaryFormData,
packages: AncillaryItem[]
) {
const savedCreditCard = savedCreditCards?.find(
(card) => card.id === data.paymentMethod
)
trackGlaAncillaryAttempt(
savedCreditCard,
packages,
selectedAncillary,
data.deliveryTime,
breakfastData
)
if (booking.refId) {
const card = savedCreditCard
? {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
}
: undefined
await guaranteeBooking.mutateAsync({
refId: booking.refId,
language: lang,
...(card && { card }),
success: `${guaranteeRedirectUrl}?status=success&ancillary=1&RefId=${encodeURIComponent(booking.refId)}`,
error: `${guaranteeRedirectUrl}?status=error&ancillary=1&RefId=${encodeURIComponent(booking.refId)}`,
cancel: `${guaranteeRedirectUrl}?status=cancel&ancillary=1&RefId=${encodeURIComponent(booking.refId)}`,
})
} else {
handleGuaranteeError("No confirmation number")
}
}
const onSubmit = async (data: AncillaryFormData) => {
const packagesToAdd = !isBreakfast
? buildAncillaryPackages(data, selectedAncillary)
: breakfastData
? buildBreakfastPackages(data, breakfastData)
: []
if (isBreakfast && !breakfastData) {
toast.error(
intl.formatMessage({
id: "errorMessage.somethingWentWrong",
defaultMessage: "Something went wrong!",
})
)
return
}
setAncillarySessionData({
formData: data,
selectedAncillary,
packages: packagesToAdd,
isBreakfast,
breakfastData,
})
const shouldSkipGuarantee =
booking.guaranteeInfo || data.paymentChoice === PaymentChoiceEnum.Points
if (shouldSkipGuarantee) {
await handleAncillarySubmission(data, packagesToAdd)
} else {
await handleGuaranteePayment(data, packagesToAdd)
}
}
useEffect(() => {
if (!isOpen) {
formMethods.reset()
}
}, [isOpen, formMethods])
if (isLoading) {
return (
<div>
<LoadingSpinner />
</div>
)
}
return (
<FormProvider {...formMethods}>
<form className={styles.form} id="add-ancillary-form-id">
<div className={styles.modalScrollable}>
<Steps
user={user}
savedCreditCards={savedCreditCards}
error={errorMessage}
/>
</div>
<Summary onSubmit={formMethods.handleSubmit(onSubmit)} />
</form>
</FormProvider>
)
}

View File

@@ -1,47 +0,0 @@
import Modal from "@scandic-hotels/design-system/Modal"
import {
AncillaryStepEnum,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import Description from "../Description"
import Steps from "../Steps"
import Summary from "./Summary"
import styles from "./addAncillaryModal.module.css"
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
export default function AddAncillaryModal({
error,
savedCreditCards,
user,
}: StepsProps) {
const { isOpen, closeModal, selectedAncillaryTitle, currentStep } =
useAddAncillaryStore((state) => ({
isOpen: state.isOpen,
closeModal: state.closeModal,
selectedAncillaryTitle: state.selectedAncillary?.title,
currentStep: state.currentStep,
}))
return (
<Modal
isOpen={isOpen}
onToggle={closeModal}
title={selectedAncillaryTitle}
withActions
contentClassName={styles.modalContent}
className={styles.modal}
>
<div className={styles.modalScrollable}>
<Description />
<Steps user={user} savedCreditCards={savedCreditCards} error={error} />
</div>
<Summary
isConfirmation={currentStep === AncillaryStepEnum.confirmation}
/>
</Modal>
)
}

View File

@@ -16,6 +16,8 @@ import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import TermsAndConditions from "@/components/HotelReservation/MyStay/TermsAndConditions" import TermsAndConditions from "@/components/HotelReservation/MyStay/TermsAndConditions"
import { trackUpdatePaymentMethod } from "@/utils/tracking" import { trackUpdatePaymentMethod } from "@/utils/tracking"
import { PaymentChoiceEnum } from "../../schema"
import styles from "./confirmationStep.module.css" import styles from "./confirmationStep.module.css"
import type { ConfirmationStepProps } from "@/types/components/myPages/myStay/ancillaries" import type { ConfirmationStepProps } from "@/types/components/myPages/myStay/ancillaries"
@@ -37,12 +39,12 @@ export default function ConfirmationStep({
) )
const mustBeGuaranteed = !guaranteeInfo && booking.isGuaranteeable const mustBeGuaranteed = !guaranteeInfo && booking.isGuaranteeable
const quantityWithCard = useWatch({ name: "quantityWithCard" }) const quantity = useWatch({ name: "quantity" })
const quantityWithPoints = useWatch({ name: "quantityWithPoints" }) const paymentChoice = useWatch({ name: "paymentChoice" })
const currentPoints = user?.membership?.currentPoints ?? 0 const currentPoints = user?.membership?.currentPoints ?? 0
const totalPoints = const totalPoints =
quantityWithPoints && selectedAncillary?.points paymentChoice === PaymentChoiceEnum.Points && selectedAncillary?.points
? selectedAncillary.points * quantityWithPoints ? selectedAncillary.points * quantity
: null : null
const accordionTitle = intl.formatMessage({ const accordionTitle = intl.formatMessage({
@@ -64,7 +66,7 @@ export default function ConfirmationStep({
return ( return (
<div className={styles.modalContent}> <div className={styles.modalContent}>
{error && <Alert type={error.type} text={error.message} />} {error && <Alert type={error.type} text={error.message} />}
{!!quantityWithPoints && ( {paymentChoice === PaymentChoiceEnum.Points && (
<> <>
<Typography variant="Title/Subtitle/md"> <Typography variant="Title/Subtitle/md">
<h2> <h2>
@@ -104,7 +106,7 @@ export default function ConfirmationStep({
</div> </div>
</> </>
)} )}
{!!quantityWithCard ? ( {paymentChoice === PaymentChoiceEnum.Card ? (
<> <>
<Typography variant="Title/Subtitle/md"> <Typography variant="Title/Subtitle/md">
<h2> <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 { .container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--Space-x2); gap: var(--Space-x3);
} }
.selectContainer { .section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--Space-x2); 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); border-radius: var(--Corner-radius-md);
background-color: var(--Surface-Primary-OnSurface-Default);
} }
.select { .changeButton {
display: flex; margin-left: auto;
flex-direction: column; }
gap: var(--Space-x1);
.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 { 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 { Typography } from "@scandic-hotels/design-system/Typography"
import { generateDeliveryOptions } from "@/components/HotelReservation/MyStay/utils/ancillaries" import SelectDeliveryTime from "./SelectDeliveryTime"
import Input from "@/components/TempDesignSystem/Form/Input" import SelectedItemCard from "./SelectedItemCard"
import Select from "@/components/TempDesignSystem/Form/Select"
import styles from "./deliveryDetailsStep.module.css" import styles from "./deliveryDetailsStep.module.css"
export default function DeliveryMethodStep() { export default function DeliveryMethodStep() {
const [showSpecialRequests, setShowSpecialRequests] = useState(false)
const intl = useIntl() const intl = useIntl()
const deliveryTimeOptions = generateDeliveryOptions()
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.selectContainer}> <div className={styles.section}>
<div className={styles.select}> <Typography variant="Title/Overline/sm">
<Typography variant="Body/Supporting text (caption)/smBold"> <h3>
<h3> {intl.formatMessage({
{intl.formatMessage({ id: "ancillaries.deliveryDetailsStep.itemTitle",
id: "ancillaries.deliveredAt", defaultMessage: "Your item",
defaultMessage: "Delivered at:", })}
})} </h3>
</h3> </Typography>
</Typography> <SelectedItemCard />
<Select </div>
name="deliveryTime" <div className={styles.section}>
label="" <Typography variant="Title/Overline/sm">
items={deliveryTimeOptions} <h3>
registerOptions={{ required: true }} {intl.formatMessage({
isNestedInModal id: "ancillaries.deliveryDetailsStep.select.title",
/> defaultMessage: "Select time of delivery",
</div> })}
</h3>
</Typography>
<SelectDeliveryTime />
<Typography variant="Body/Supporting text (caption)/smRegular"> <Typography variant="Body/Supporting text (caption)/smRegular">
<p> <p className={styles.infoText}>
{intl.formatMessage({ {intl.formatMessage({
id: "addAncillary.deliveryDetailsStep.deliveryTimeDescription", id: "addAncillary.deliveryDetailsStep.deliveryTimeDescription",
defaultMessage: defaultMessage:
@@ -41,23 +48,44 @@ export default function DeliveryMethodStep() {
})} })}
</p> </p>
</Typography> </Typography>
<div className={styles.select}> </div>
<Input <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({ label={intl.formatMessage({
id: "addAncillary.deliveryDetailsStep.optionalTextLabel", id: "addAncillary.deliveryDetailsStep.commentLabel",
defaultMessage: "Other Requests", defaultMessage:
"Is there anything else you would like us to know before your arrival?",
})} })}
name="optionalText" 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({ {intl.formatMessage({
id: "common.optional", id: "ancillaries.deliveryDetailsStep.specialRequests.cta",
defaultMessage: "Optional", defaultMessage: "Add special request",
})} })}
</h3> </Button>
</Typography> </div>
</div> )}
</div> </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 { type ReactNode } from "react"
import { RadioGroup } from "react-aria-components"
import { useFormContext } from "react-hook-form" import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl" 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 { 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 { Typography } from "@scandic-hotels/design-system/Typography"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow" import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import Select from "@/components/TempDesignSystem/Form/Select"
import { getErrorMessage } from "@/utils/getErrorMessage" import { getErrorMessage } from "@/utils/getErrorMessage"
import Description from "../../Description"
import { PaymentChoiceEnum } from "../../schema"
import { BreakfastInfo } from "./BreakfastInfo" import { BreakfastInfo } from "./BreakfastInfo"
import { NotEnoughPointsBanner, PaymentOption } from "./PaymentOption"
import styles from "./selectQuantityStep.module.css" import styles from "./selectQuantityStep.module.css"
@@ -20,11 +24,6 @@ import type {
SelectQuantityStepProps, SelectQuantityStepProps,
} from "@/types/components/myPages/myStay/ancillaries" } from "@/types/components/myPages/myStay/ancillaries"
const cardQuantityOptions = Array.from({ length: 7 }, (_, i) => ({
label: `${i}`,
value: i,
}))
export default function SelectQuantityStep({ user }: SelectQuantityStepProps) { export default function SelectQuantityStep({ user }: SelectQuantityStepProps) {
const { isBreakfast, selectedAncillary } = useAddAncillaryStore((state) => ({ const { isBreakfast, selectedAncillary } = useAddAncillaryStore((state) => ({
isBreakfast: state.isBreakfast, 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({ function InnerSelectQuantityStep({
@@ -55,101 +59,147 @@ function InnerSelectQuantityStep({
}: InnerSelectQuantityStepProps) { }: InnerSelectQuantityStepProps) {
const intl = useIntl() const intl = useIntl()
const { const {
watch,
setValue,
formState: { errors }, formState: { errors },
} = useFormContext() } = useFormContext()
const paymentChoice = watch("paymentChoice")
const quantity = watch("quantity") as number
const pointsCost = selectedAncillary?.points ?? 0 const pointsCost = selectedAncillary?.points ?? 0
const currentPoints = user?.membership?.currentPoints ?? 0 const currentPoints = user?.membership?.currentPoints ?? 0
const maxAffordable =
pointsCost > 0 ? Math.min(Math.floor(currentPoints / pointsCost), 7) : 0
const pointsQuantityOptions = Array.from( const hasMultiplePaymentOptions =
{ length: maxAffordable + 1 }, selectedAncillary.price && selectedAncillary?.points && user
(_, i) => ({
label: `${i}`,
value: i,
})
)
const insufficientPoints = currentPoints < pointsCost || currentPoints === 0 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 ( return (
<div className={styles.selectContainer}> <div className={styles.selectContainer}>
{selectedAncillary?.points && user && ( {hasMultiplePaymentOptions && (
<div className={styles.select}> <Typography variant="Body/Paragraph/mdBold">
<Typography variant="Title/Subtitle/md"> <p>
<h2 className={styles.selectTitle}> {intl.formatMessage({
{intl.formatMessage({ id: "addAncillary.selectQuantityStep.selectQuantityTitle",
id: "addAncillary.selectQuantityStep.payWithPoints", defaultMessage: "How would you like to pay?",
defaultMessage: "Pay with points", })}
</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> </div>
) )
} }

View File

@@ -1,45 +1,19 @@
.container { .container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--Space-x2); gap: var(--Space-x3);
} }
.selectContainer { .selectContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--Space-x025); gap: var(--Space-x2);
} }
.select { .radioGroup {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--Space-x1); gap: var(--Space-x2);
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;
} }
.breakfastContainer { .breakfastContainer {
@@ -81,15 +55,3 @@
font-weight: var(--Tag-Font-weight); font-weight: var(--Tag-Font-weight);
text-transform: uppercase; 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 ConfirmationStep from "./ConfirmationStep"
import Mobile from "./Mobile" import DeliveryMethodStep from "./DeliveryDetailsStep"
import SelectQuantityStep from "./SelectQuantityStep"
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries" import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
export default function Steps(props: StepsProps) { export default function Steps({ user, savedCreditCards, error }: StepsProps) {
const isMobile = useMediaQuery("(max-width: 767px)") 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}
/>
)
}
} }

View File

@@ -5,6 +5,7 @@ import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Divider } from "@scandic-hotels/design-system/Divider" import { Divider } from "@scandic-hotels/design-system/Divider"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import { PaymentChoiceEnum } from "../../schema"
import PriceRow from "./PriceRow" import PriceRow from "./PriceRow"
import styles from "./priceSummary.module.css" import styles from "./priceSummary.module.css"
@@ -12,24 +13,36 @@ import styles from "./priceSummary.module.css"
interface PriceSummaryProps { interface PriceSummaryProps {
totalPrice: number | null totalPrice: number | null
totalPoints: number | null totalPoints: number | null
paymentChoice: string | null
items: { items: {
title: string title: string
totalPrice: number totalPrice: number
currency: string currency: string
points?: number points?: number
quantityWithCard?: number quantity: number
quantityWithPoints?: number
}[] }[]
} }
export function PriceSummary({ export function PriceSummary({
totalPrice, totalPrice,
totalPoints, totalPoints,
paymentChoice,
items, items,
}: PriceSummaryProps) { }: PriceSummaryProps) {
const intl = useIntl() const intl = useIntl()
const label =
paymentChoice === PaymentChoiceEnum.Points
? intl.formatMessage({
id: "common.points",
defaultMessage: "Points",
})
: intl.formatMessage({
id: "common.price",
defaultMessage: "Price",
})
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Typography variant="Body/Paragraph/mdBold"> <Typography variant="Body/Paragraph/mdBold">
@@ -44,33 +57,23 @@ export function PriceSummary({
{items.map((item) => ( {items.map((item) => (
<Fragment key={item.title}> <Fragment key={item.title}>
{!!item.quantityWithCard && ( {!!item.quantity && (
<PriceRow <PriceRow
title={item.title} title={item.title}
quantity={item.quantityWithCard} quantity={item.quantity}
label={intl.formatMessage({ label={label}
id: "common.price", value={
defaultMessage: "Price", paymentChoice === PaymentChoiceEnum.Points
})} ? intl.formatMessage(
value={formatPrice(intl, item.totalPrice, item.currency)} {
/> id: "common.numberOfPoints",
)} defaultMessage:
{!!item.quantityWithPoints && ( "{points, plural, one {# point} other {# points}}",
<PriceRow },
title={item.title} { points: item.points }
quantity={item.quantityWithPoints} )
label={intl.formatMessage({ : formatPrice(intl, item.totalPrice, item.currency)
id: "common.points", }
defaultMessage: "Points",
})}
value={intl.formatMessage(
{
id: "common.numberOfPoints",
defaultMessage:
"{points, plural, one {# point} other {# points}}",
},
{ points: item.points }
)}
/> />
)} )}
<Divider /> <Divider />

View File

@@ -1,10 +1,9 @@
import { cx } from "class-variance-authority" import { cx } from "class-variance-authority"
import { useState } from "react" import { useState } from "react"
import { useFormContext } from "react-hook-form" import { useFormContext, useWatch } from "react-hook-form"
import { type IntlShape, useIntl } from "react-intl" import { type IntlShape, useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { Button, type ButtonProps } from "@scandic-hotels/design-system/Button" import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { import {
@@ -15,6 +14,7 @@ import {
import { trackAddAncillary } from "@/utils/tracking/myStay" import { trackAddAncillary } from "@/utils/tracking/myStay"
import { PaymentChoiceEnum } from "../schema"
import PriceDetails from "./PriceDetails" import PriceDetails from "./PriceDetails"
import { PriceSummary } from "./PriceSummary" import { PriceSummary } from "./PriceSummary"
@@ -22,13 +22,8 @@ import styles from "./summary.module.css"
import type { SelectedAncillary } from "@/types/components/myPages/myStay/ancillaries" import type { SelectedAncillary } from "@/types/components/myPages/myStay/ancillaries"
export default function Summary({ export default function Summary({ onSubmit }: { onSubmit: () => void }) {
isConfirmation = false,
}: {
isConfirmation?: boolean
}) {
const intl = useIntl() const intl = useIntl()
const isMobile = useMediaQuery("(max-width: 767px)")
const [isPriceDetailsOpen, setIsPriceDetailsOpen] = useState(false) const [isPriceDetailsOpen, setIsPriceDetailsOpen] = useState(false)
function togglePriceDetails() { function togglePriceDetails() {
setIsPriceDetailsOpen((isOpen) => !isOpen) setIsPriceDetailsOpen((isOpen) => !isOpen)
@@ -39,7 +34,6 @@ export default function Summary({
isBreakfast, isBreakfast,
breakfastData, breakfastData,
currentStep, currentStep,
selectQuantityAndDeliveryTime,
selectDeliveryTime, selectDeliveryTime,
selectQuantity, selectQuantity,
} = useAddAncillaryStore((state) => ({ } = useAddAncillaryStore((state) => ({
@@ -48,38 +42,52 @@ export default function Summary({
selectedAncillary: state.selectedAncillary, selectedAncillary: state.selectedAncillary,
isBreakfast: state.isBreakfast, isBreakfast: state.isBreakfast,
breakfastData: state.breakfastData, breakfastData: state.breakfastData,
selectQuantityAndDeliveryTime: state.selectQuantityAndDeliveryTime,
selectQuantity: state.selectQuantity, selectQuantity: state.selectQuantity,
selectDeliveryTime: state.selectDeliveryTime, selectDeliveryTime: state.selectDeliveryTime,
})) }))
const { const {
watch,
trigger, trigger,
formState: { isSubmitting }, formState: { isSubmitting, errors },
} = useFormContext() } = useFormContext()
const quantityWithCard = watch("quantityWithCard") as number const quantity = useWatch({ name: "quantity" }) as number
const quantityWithPoints = watch("quantityWithPoints") as number const paymentChoice: PaymentChoiceEnum = useWatch({ name: "paymentChoice" })
const isConfirmation = currentStep === AncillaryStepEnum.confirmation
async function handleNextStep() { async function handleNextStep() {
if (currentStep === AncillaryStepEnum.selectQuantity) { switch (currentStep) {
const isValid = await trigger(["quantityWithCard", "quantityWithPoints"]) case AncillaryStepEnum.selectQuantity: {
if (isValid) { const isValid = await trigger(["quantity", "paymentChoice"])
trackAddAncillary(
selectedAncillary, if (isValid) {
quantityWithCard, const quantityWithCard =
quantityWithPoints, paymentChoice === PaymentChoiceEnum.Points ? 0 : quantity
breakfastData const quantityWithPoints =
) paymentChoice === PaymentChoiceEnum.Points ? quantity : 0
if (isMobile) { trackAddAncillary(
selectQuantityAndDeliveryTime() selectedAncillary,
} else { quantityWithCard,
quantityWithPoints,
breakfastData
)
selectQuantity() selectQuantity()
} }
break
}
case AncillaryStepEnum.selectDelivery: {
const isValid = await trigger("deliveryTime")
if (isValid) {
selectDeliveryTime()
}
break
}
case AncillaryStepEnum.confirmation: {
onSubmit()
break
} }
} else if (currentStep === AncillaryStepEnum.selectDelivery) {
selectDeliveryTime()
} }
} }
@@ -88,28 +96,41 @@ export default function Summary({
} }
const isSingleItem = !selectedAncillary.requiresQuantity const isSingleItem = !selectedAncillary.requiresQuantity
const secondaryButtonLabel =
currentStep === AncillaryStepEnum.selectQuantity
? intl.formatMessage({
id: "common.cancel",
defaultMessage: "Cancel",
})
: intl.formatMessage({
id: "common.back",
defaultMessage: "Back",
})
const buttonProps: ButtonProps = isConfirmation function buttonLabel() {
? { switch (currentStep) {
type: "submit", case AncillaryStepEnum.selectQuantity:
form: "add-ancillary-form-id", return isSingleItem
variant: "Primary", ? intl.formatMessage({
} id: "common.reviewAndConfirm",
: { defaultMessage: "Review & Confirm",
type: "button", })
onPress: handleNextStep, : intl.formatMessage({
variant: isSingleItem ? "Primary" : "Secondary", id: "addAncillaryFlowModal.proceedToDelivery",
} defaultMessage: "Proceed to delivery",
})
const buttonLabel = isConfirmation case AncillaryStepEnum.selectDelivery:
? intl.formatMessage({ return intl.formatMessage({
id: "common.confirm", id: "common.reviewAndConfirm",
defaultMessage: "Confirm", defaultMessage: "Review & Confirm",
}) })
: intl.formatMessage({ case AncillaryStepEnum.confirmation:
id: "common.continue", return intl.formatMessage({
defaultMessage: "Continue", id: "addAncillaryFlowModal.addToBooking",
}) defaultMessage: "Add to booking",
})
}
}
const items = isBreakfast const items = isBreakfast
? getBreakfastItems(intl, selectedAncillary, breakfastData) ? getBreakfastItems(intl, selectedAncillary, breakfastData)
@@ -119,20 +140,19 @@ export default function Summary({
totalPrice: selectedAncillary.price.total, totalPrice: selectedAncillary.price.total,
currency: selectedAncillary.price.currency, currency: selectedAncillary.price.currency,
points: selectedAncillary.points, points: selectedAncillary.points,
quantityWithCard, quantity,
quantityWithPoints,
}, },
] ]
const totalPrice = isBreakfast const totalPrice = isBreakfast
? breakfastData!.totalPrice ? breakfastData!.totalPrice
: quantityWithCard && selectedAncillary : paymentChoice === PaymentChoiceEnum.Card && selectedAncillary
? selectedAncillary.price.total * quantityWithCard ? selectedAncillary.price.total * quantity
: null : null
const totalPoints = const totalPoints =
quantityWithPoints && selectedAncillary?.points paymentChoice === PaymentChoiceEnum.Points && selectedAncillary?.points
? selectedAncillary.points * quantityWithPoints ? selectedAncillary.points * quantity
: null : null
return ( return (
@@ -154,6 +174,7 @@ export default function Summary({
totalPrice={totalPrice} totalPrice={totalPrice}
totalPoints={totalPoints} totalPoints={totalPoints}
items={items} items={items}
paymentChoice={paymentChoice}
/> />
)} )}
<div <div
@@ -191,24 +212,24 @@ export default function Summary({
typography="Body/Supporting text (caption)/smBold" typography="Body/Supporting text (caption)/smBold"
type="button" type="button"
variant="Text" variant="Text"
size="Small" size="Medium"
color="Primary" color="Primary"
onPress={() => prevStep(isMobile)} wrapping={false}
onPress={prevStep}
> >
{intl.formatMessage({ {secondaryButtonLabel}
id: "common.back",
defaultMessage: "Back",
})}
</Button> </Button>
<Button <Button
typography="Body/Supporting text (caption)/smBold" typography="Body/Supporting text (caption)/smBold"
size="Small" size="Medium"
isDisabled={isSubmitting} isDisabled={
isSubmitting || (isConfirmation && !!Object.keys(errors).length)
}
isPending={isSubmitting} isPending={isSubmitting}
{...buttonProps} onPress={handleNextStep}
variant={isSingleItem || isConfirmation ? "Primary" : "Secondary"}
> >
{buttonLabel} {buttonLabel()}
</Button> </Button>
</div> </div>
</div> </div>
@@ -234,7 +255,7 @@ function getBreakfastItems(
})}`, })}`,
totalPrice: breakfastData.priceAdult, totalPrice: breakfastData.priceAdult,
currency: breakfastData.currency, currency: breakfastData.currency,
quantityWithCard: breakfastData.nrOfAdults * breakfastData.nrOfNights, quantity: breakfastData.nrOfAdults * breakfastData.nrOfNights,
}, },
] ]
@@ -246,8 +267,7 @@ function getBreakfastItems(
})} 4-12`, })} 4-12`,
totalPrice: breakfastData.priceChild, totalPrice: breakfastData.priceChild,
currency: breakfastData.currency, currency: breakfastData.currency,
quantityWithCard: quantity: breakfastData.nrOfPayingChildren * breakfastData.nrOfNights,
breakfastData.nrOfPayingChildren * breakfastData.nrOfNights,
}) })
} }
@@ -262,8 +282,7 @@ function getBreakfastItems(
)}`, )}`,
totalPrice: 0, totalPrice: 0,
currency: breakfastData.currency, currency: breakfastData.currency,
quantityWithCard: quantity: breakfastData.nrOfFreeChildren * breakfastData.nrOfNights,
breakfastData.nrOfFreeChildren * breakfastData.nrOfNights,
}) })
} }

View File

@@ -2,7 +2,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
padding: var(--Space-x2) var(--Space-x2) var(--Space-x3); padding: var(--Space-x3) var(--Space-x2) var(--Space-x4);
width: 100%; width: 100%;
border-top: 1px solid var(--Border-Default); border-top: 1px solid var(--Border-Default);
} }
@@ -18,8 +18,14 @@
.buttons { .buttons {
display: flex; display: flex;
gap: var(--Space-x4); flex-direction: column-reverse;
justify-content: flex-end; gap: var(--Space-x2);
}
.backgroundBox .buttons {
flex-direction: row;
gap: var(--Space-x2);
justify-content: space-between;
} }
.confirmButtons { .confirmButtons {
@@ -33,3 +39,13 @@
display: flex; display: flex;
gap: var(--Space-x05); 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;
}
}

View File

@@ -1,6 +1,14 @@
.form { .modal {
display: flex;
flex-direction: column;
flex-grow: 1;
width: 100%; width: 100%;
} }
.modalContent {
gap: unset;
}
@media screen and (min-width: 768px) {
/* Override modal width for this specific flow */
.modal {
width: 460px !important;
}
}

View File

@@ -1,51 +1,18 @@
"use client" "use client"
import { zodResolver } from "@hookform/resolvers/zod" import { useEffect } from "react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
import { dt } from "@scandic-hotels/common/dt" import { dt } from "@scandic-hotels/common/dt"
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 { trpc } from "@scandic-hotels/trpc/client"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow" import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import { import Form from "./Form"
buildAncillaryPackages, import { calculateBreakfastData } from "./utils"
clearAncillarySessionData,
generateDeliveryOptions,
getAncillarySessionData,
setAncillarySessionData,
} from "@/components/HotelReservation/MyStay/utils/ancillaries"
import { useGuaranteeBooking } from "@/hooks/booking/useGuaranteeBooking"
import useLang from "@/hooks/useLang"
import {
trackAncillaryFailed,
trackAncillarySuccess,
trackGlaAncillaryAttempt,
} from "@/utils/tracking/myStay"
import { isAncillaryError } from "../../utils"
import AddAncillaryFlow from "./Modal"
import { type AncillaryFormData, ancillaryFormSchema } from "./schema"
import {
buildBreakfastPackages,
calculateBreakfastData,
getErrorMessage,
getGuaranteeCallback,
} from "./utils"
import styles from "./addAncillaryFlowModal.module.css" import styles from "./addAncillaryFlowModal.module.css"
import type { import type { AddAncillaryFlowModalProps } from "@/types/components/myPages/myStay/ancillaries"
AddAncillaryFlowModalProps,
AncillaryErrorMessage,
AncillaryItem,
} from "@/types/components/myPages/myStay/ancillaries"
export default function AddAncillaryFlowModal({ export default function AddAncillaryFlowModal({
booking, booking,
@@ -54,229 +21,18 @@ export default function AddAncillaryFlowModal({
savedCreditCards, savedCreditCards,
}: AddAncillaryFlowModalProps) { }: AddAncillaryFlowModalProps) {
const { const {
selectedAncillary, isOpen,
closeModal, closeModal,
breakfastData, selectedAncillary,
setBreakfastData, setBreakfastData,
isBreakfast, isBreakfast,
} = useAddAncillaryStore((state) => ({ } = useAddAncillaryStore((state) => ({
selectedAncillary: state.selectedAncillary, selectedAncillary: state.selectedAncillary,
closeModal: state.closeModal, closeModal: state.closeModal,
breakfastData: state.breakfastData,
setBreakfastData: state.setBreakfastData, setBreakfastData: state.setBreakfastData,
isBreakfast: state.isBreakfast, isBreakfast: state.isBreakfast,
isOpen: state.isOpen,
})) }))
const intl = useIntl()
const lang = useLang()
const router = useRouter()
const searchParams = useSearchParams()
const pathname = usePathname()
const [errorMessage, setErrorMessage] =
useState<AncillaryErrorMessage | null>(null)
const guaranteeRedirectUrl = getGuaranteeCallback(lang, pathname)
const deliveryTimeOptions = generateDeliveryOptions()
const hasInsufficientPoints =
(user?.membership?.currentPoints ?? 0) < (selectedAncillary?.points ?? 0)
const formMethods = useForm({
defaultValues: {
quantityWithPoints: null,
quantityWithCard:
!user || hasInsufficientPoints || !selectedAncillary?.requiresQuantity
? 1
: null,
deliveryTime:
booking.ancillary?.deliveryTime ?? deliveryTimeOptions[0].value,
optionalText: "",
termsAndConditions: false,
paymentMethod: booking.guaranteeInfo
? PaymentMethodEnum.card
: savedCreditCards?.length
? savedCreditCards[0].id
: PaymentMethodEnum.card,
},
mode: "onChange",
reValidateMode: "onChange",
resolver: zodResolver(ancillaryFormSchema),
})
const ancillaryErrorMessage = intl.formatMessage(
{
id: "addAncillaryFlowModal.errorMessage.ancillary",
defaultMessage:
"Something went wrong. {ancillary} could not be added to your booking!",
},
{ ancillary: selectedAncillary?.title }
)
const utils = trpc.useUtils()
const addAncillary = trpc.booking.packages.useMutation()
const { guaranteeBooking, isLoading, handleGuaranteeError } =
useGuaranteeBooking(booking.refId, true, booking.hotelId)
async function handleAncillarySubmission(
data: AncillaryFormData,
packages: {
code: string
quantity: number
comment: string | undefined
}[]
) {
await addAncillary.mutateAsync(
{
refId: booking.refId,
ancillaryComment: data.optionalText,
ancillaryDeliveryTime: selectedAncillary?.requiresDeliveryTime
? data.deliveryTime
: undefined,
packages: packages,
language: lang,
},
{
onSuccess: (result) => {
if (result) {
trackAncillarySuccess(
booking.confirmationNumber,
packages,
data.deliveryTime,
"ancillary",
selectedAncillary,
breakfastData,
booking.guaranteeInfo?.cardType,
booking.roomTypeCode
)
toast.success(
intl.formatMessage(
{
id: "addAncillaryFlowModal.ancillaryAdded",
defaultMessage: "{ancillary} added to your booking!",
},
{ ancillary: selectedAncillary?.title }
)
)
clearAncillarySessionData()
closeModal()
utils.booking.get.invalidate({
refId: booking.refId,
})
router.refresh()
} else {
trackAncillaryFailed(
packages,
data.deliveryTime,
selectedAncillary,
breakfastData
)
toast.error(ancillaryErrorMessage)
closeModal()
}
},
onError: () => {
trackAncillaryFailed(
packages,
data.deliveryTime,
selectedAncillary,
breakfastData
)
toast.error(ancillaryErrorMessage)
closeModal()
},
}
)
}
async function handleGuaranteePayment(
data: AncillaryFormData,
packages: AncillaryItem[]
) {
const savedCreditCard = savedCreditCards?.find(
(card) => card.id === data.paymentMethod
)
trackGlaAncillaryAttempt(
savedCreditCard,
packages,
selectedAncillary,
data.deliveryTime,
breakfastData
)
if (booking.refId) {
const card = savedCreditCard
? {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
}
: undefined
await guaranteeBooking.mutateAsync({
refId: booking.refId,
language: lang,
...(card && { card }),
success: `${guaranteeRedirectUrl}?status=success&ancillary=1&RefId=${encodeURIComponent(booking.refId)}`,
error: `${guaranteeRedirectUrl}?status=error&ancillary=1&RefId=${encodeURIComponent(booking.refId)}`,
cancel: `${guaranteeRedirectUrl}?status=cancel&ancillary=1&RefId=${encodeURIComponent(booking.refId)}`,
})
} else {
handleGuaranteeError("No confirmation number")
}
}
const onSubmit = async (data: AncillaryFormData) => {
const packagesToAdd = !isBreakfast
? buildAncillaryPackages(data, selectedAncillary)
: breakfastData
? buildBreakfastPackages(data, breakfastData)
: []
if (isBreakfast && !breakfastData) {
toast.error(
intl.formatMessage({
id: "errorMessage.somethingWentWrong",
defaultMessage: "Something went wrong!",
})
)
return
}
setAncillarySessionData({
formData: data,
selectedAncillary,
packages: packagesToAdd,
isBreakfast,
breakfastData,
})
const shouldSkipGuarantee = booking.guaranteeInfo || !data.quantityWithCard
if (shouldSkipGuarantee) {
await handleAncillarySubmission(data, packagesToAdd)
} else {
await handleGuaranteePayment(data, packagesToAdd)
}
}
useEffect(() => {
if (isAncillaryError(searchParams)) {
const errorCode = searchParams.get("errorCode")
const queryParams = new URLSearchParams(searchParams.toString())
const savedData = getAncillarySessionData()
if (savedData?.formData) {
const updatedFormData = {
...savedData.formData,
paymentMethod: booking?.guaranteeInfo
? PaymentMethodEnum.card
: savedData.formData.paymentMethod,
}
formMethods.reset(updatedFormData)
}
// eslint-disable-next-line react-hooks/set-state-in-effect
setErrorMessage(getErrorMessage(intl, errorCode))
queryParams.delete("ancillary")
queryParams.delete("errorCode")
router.replace(`${pathname}?${queryParams.toString()}`)
}
}, [searchParams, pathname, formMethods, router, booking.guaranteeInfo, intl])
useEffect(() => { useEffect(() => {
setBreakfastData( setBreakfastData(
@@ -300,27 +56,16 @@ export default function AddAncillaryFlowModal({
setBreakfastData, setBreakfastData,
]) ])
if (isLoading) {
return (
<div>
<LoadingSpinner />
</div>
)
}
return ( return (
<FormProvider {...formMethods}> <Modal
<form isOpen={isOpen}
onSubmit={formMethods.handleSubmit(onSubmit)} onToggle={closeModal}
className={styles.form} title={selectedAncillary?.title || ""}
id="add-ancillary-form-id" withActions
> contentClassName={styles.modalContent}
<AddAncillaryFlow className={styles.modal}
error={errorMessage} >
savedCreditCards={savedCreditCards} <Form user={user} savedCreditCards={savedCreditCards} booking={booking} />
user={user} </Modal>
/>
</form>
</FormProvider>
) )
} }

View File

@@ -2,36 +2,27 @@ import { z } from "zod"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator" import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
const quantitySchemaWithoutRefine = z.object({
quantityWithPoints: z.number().nullable(),
quantityWithCard: z.number().nullable(),
})
export const ancillaryError = { export const ancillaryError = {
TERMS_NOT_ACCEPTED: "TERMS_NOT_ACCEPTED", TERMS_NOT_ACCEPTED: "TERMS_NOT_ACCEPTED",
MIN_QUANTITY_NOT_REACHED: "MIN_QUANTITY_NOT_REACHED", MIN_QUANTITY_NOT_REACHED: "MIN_QUANTITY_NOT_REACHED",
} as const } as const
export const ancillaryFormSchema = z export enum PaymentChoiceEnum {
.object({ Points = "points",
deliveryTime: z.string(), Card = "card",
optionalText: z.string(), }
termsAndConditions: z
.boolean() export const ancillaryFormSchema = z.object({
.refine((value) => value === true, ancillaryError.TERMS_NOT_ACCEPTED), deliveryTime: z.string(),
paymentMethod: nullableStringValidator, optionalText: z.string(),
}) termsAndConditions: z
.merge(quantitySchemaWithoutRefine) .boolean()
.refine( .refine((value) => value === true, ancillaryError.TERMS_NOT_ACCEPTED),
(data) => paymentMethod: nullableStringValidator,
(data.quantityWithPoints ?? 0) > 0 || (data.quantityWithCard ?? 0) > 0, quantity: z.number().min(1, ancillaryError.MIN_QUANTITY_NOT_REACHED),
{ paymentChoice: z.nativeEnum(PaymentChoiceEnum, {
message: ancillaryError.MIN_QUANTITY_NOT_REACHED, message: ancillaryError.MIN_QUANTITY_NOT_REACHED,
path: ["quantityWithCard"], }),
} })
)
export type AncillaryQuantityFormData = z.output<
typeof quantitySchemaWithoutRefine
>
export type AncillaryFormData = z.output<typeof ancillaryFormSchema> export type AncillaryFormData = z.output<typeof ancillaryFormSchema>

View File

@@ -20,6 +20,7 @@ export default function TermsAndConditions() {
const lang = useLang() const lang = useLang()
const { const {
formState: { errors }, formState: { errors },
register,
} = useFormContext() } = useFormContext()
const termsAndConditionsMsg = intl.formatMessage( const termsAndConditionsMsg = intl.formatMessage(
{ {
@@ -55,9 +56,8 @@ export default function TermsAndConditions() {
return ( return (
<div className={styles.termsAndConditions}> <div className={styles.termsAndConditions}>
<Checkbox <Checkbox
name="termsAndConditions"
registerOptions={{ required: true }}
hideError hideError
{...register("termsAndConditions", { required: true })}
> >
<Typography variant="Body/Supporting text (caption)/smRegular"> <Typography variant="Body/Supporting text (caption)/smRegular">
<span> <span>

View File

@@ -1,10 +1,14 @@
import { logger } from "@scandic-hotels/common/logger" import { logger } from "@scandic-hotels/common/logger"
import {
type AncillaryFormData,
PaymentChoiceEnum,
} from "@/components/HotelReservation/MyStay/Ancillaries/AddAncillaryFlow/schema"
import type { import type {
Ancillary, Ancillary,
SelectedAncillary, SelectedAncillary,
} from "@/types/components/myPages/myStay/ancillaries" } from "@/types/components/myPages/myStay/ancillaries"
import type { AncillaryFormData } from "@/components/HotelReservation/MyStay/Ancillaries/AddAncillaryFlow/schema"
import type { BreakfastData } from "@/stores/my-stay/add-ancillary-flow" import type { BreakfastData } from "@/stores/my-stay/add-ancillary-flow"
export const generateDeliveryOptions = () => { export const generateDeliveryOptions = () => {
@@ -22,18 +26,21 @@ export function buildAncillaryPackages(
) { ) {
const packages = [] const packages = []
if (ancillary?.id && data.quantityWithCard) { if (ancillary?.id && data.paymentChoice === PaymentChoiceEnum.Card) {
packages.push({ packages.push({
code: ancillary.id, code: ancillary.id,
quantity: data.quantityWithCard, quantity: data.quantity,
comment: data.optionalText || undefined, comment: data.optionalText || undefined,
}) })
} }
if (ancillary?.loyaltyCode && data.quantityWithPoints) { if (
ancillary?.loyaltyCode &&
data.paymentChoice === PaymentChoiceEnum.Points
) {
packages.push({ packages.push({
code: ancillary.loyaltyCode, code: ancillary.loyaltyCode,
quantity: data.quantityWithPoints, quantity: data.quantity,
comment: data.optionalText || undefined, comment: data.optionalText || undefined,
}) })
} }

View File

@@ -1,6 +1,7 @@
.ancillaryCard { .ancillaryCard {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
cursor: pointer;
} }
.imageContainer { .imageContainer {

View File

@@ -73,6 +73,10 @@ export function useAncillaries(
defaultMessage: "Food", defaultMessage: "Food",
}), }),
requiresQuantity: false, requiresQuantity: false,
unitName: intl.formatMessage({
id: "common.person",
defaultMessage: "person",
}),
} }
: undefined : undefined

View File

@@ -51,7 +51,7 @@ interface AddAncillaryState {
ancillariesBySelectedCategory: Ancillary["ancillaryContent"] ancillariesBySelectedCategory: Ancillary["ancillaryContent"]
openModal: () => void openModal: () => void
closeModal: () => void closeModal: () => void
prevStep: (isMobile: boolean) => void prevStep: () => void
breakfastData: BreakfastData | null breakfastData: BreakfastData | null
setBreakfastData: (breakfastData: BreakfastData | null) => void setBreakfastData: (breakfastData: BreakfastData | null) => void
isBreakfast: boolean isBreakfast: boolean
@@ -60,7 +60,6 @@ interface AddAncillaryState {
selectAncillary: (ancillary: SelectedAncillary) => void selectAncillary: (ancillary: SelectedAncillary) => void
selectQuantity: () => void selectQuantity: () => void
selectDeliveryTime: () => void selectDeliveryTime: () => void
selectQuantityAndDeliveryTime: () => void
} }
function findAncillaryByCategory( function findAncillaryByCategory(
@@ -150,14 +149,6 @@ export const createAddAncillaryStore = (
state.steps[AncillaryStepEnum.selectQuantity].isValid = true state.steps[AncillaryStepEnum.selectQuantity].isValid = true
}) })
), ),
selectQuantityAndDeliveryTime: () =>
set(
produce((state: AddAncillaryState) => {
state.steps[AncillaryStepEnum.selectQuantity].isValid = true
state.steps[AncillaryStepEnum.selectDelivery].isValid = true
state.currentStep = AncillaryStepEnum.confirmation
})
),
selectDeliveryTime: () => selectDeliveryTime: () =>
set( set(
produce((state: AddAncillaryState) => { produce((state: AddAncillaryState) => {
@@ -166,7 +157,7 @@ export const createAddAncillaryStore = (
}) })
), ),
prevStep: (isMobile) => prevStep: () =>
set( set(
produce((state: AddAncillaryState) => { produce((state: AddAncillaryState) => {
if (state.currentStep === AncillaryStepEnum.selectQuantity) { if (state.currentStep === AncillaryStepEnum.selectQuantity) {
@@ -176,7 +167,7 @@ export const createAddAncillaryStore = (
state.steps = steps state.steps = steps
} else { } else {
if ( if (
(!state.selectedAncillary?.requiresDeliveryTime || isMobile) && !state.selectedAncillary?.requiresDeliveryTime &&
state.currentStep === AncillaryStepEnum.confirmation state.currentStep === AncillaryStepEnum.confirmation
) { ) {
state.currentStep = AncillaryStepEnum.selectQuantity state.currentStep = AncillaryStepEnum.selectQuantity

View File

@@ -38,6 +38,12 @@ export interface AddAncillaryFlowModalProps {
savedCreditCards: CreditCard[] | null savedCreditCards: CreditCard[] | null
} }
export interface AddAncillaryFormProps {
user: User | null
savedCreditCards: CreditCard[] | null
booking: Room
}
export interface SelectQuantityStepProps { export interface SelectQuantityStepProps {
user: User | null user: User | null
} }

View File

@@ -4,8 +4,7 @@ import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import Caption from "@scandic-hotels/design-system/Caption" import Caption from "@scandic-hotels/design-system/Caption"
import Stepper from "@scandic-hotels/design-system/Stepper"
import Counter from "../Counter"
import styles from "./adult-selector.module.css" import styles from "./adult-selector.module.css"
@@ -42,7 +41,7 @@ export default function AdultSelector({
<Caption color="uiTextHighContrast" type="bold"> <Caption color="uiTextHighContrast" type="bold">
{adultsLabel} {adultsLabel}
</Caption> </Caption>
<Counter <Stepper
count={currentAdults} count={currentAdults}
handleOnDecrease={decreaseAdultsCount} handleOnDecrease={decreaseAdultsCount}
handleOnIncrease={increaseAdultsCount} handleOnIncrease={increaseAdultsCount}

View File

@@ -4,8 +4,8 @@ import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import Caption from "@scandic-hotels/design-system/Caption" import Caption from "@scandic-hotels/design-system/Caption"
import Stepper from "@scandic-hotels/design-system/Stepper"
import Counter from "../Counter"
import ChildInfoSelector from "./ChildInfoSelector" import ChildInfoSelector from "./ChildInfoSelector"
import styles from "./child-selector.module.css" import styles from "./child-selector.module.css"
@@ -58,7 +58,7 @@ export default function ChildSelector({
<Caption color="uiTextHighContrast" type="bold"> <Caption color="uiTextHighContrast" type="bold">
{childrenLabel} {childrenLabel}
</Caption> </Caption>
<Counter <Stepper
count={currentChildren.length} count={currentChildren.length}
handleOnDecrease={() => { handleOnDecrease={() => {
decreaseChildrenCount(roomIndex) decreaseChildrenCount(roomIndex)

View File

@@ -1,47 +0,0 @@
"use client"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./counter.module.css"
type CounterProps = {
count: number
handleOnIncrease: () => void
handleOnDecrease: () => void
disableIncrease: boolean
disableDecrease: boolean
}
export default function Counter({
count,
handleOnIncrease,
handleOnDecrease,
disableIncrease,
disableDecrease,
}: CounterProps) {
return (
<div className={styles.counterContainer}>
<IconButton
className={styles.counterBtn}
onPress={handleOnDecrease}
variant="Elevated"
isDisabled={disableDecrease}
>
<MaterialIcon icon="remove" color="CurrentColor" />
</IconButton>
<Typography variant="Body/Paragraph/mdRegular">
<p>{count}</p>
</Typography>
<IconButton
className={styles.counterBtn}
onPress={handleOnIncrease}
variant="Elevated"
isDisabled={disableIncrease}
>
<MaterialIcon icon="add" color="CurrentColor" />
</IconButton>
</div>
)
}

View File

@@ -5,7 +5,7 @@ import { Typography } from '../Typography'
import { TypographyProps } from '../Typography/types' import { TypographyProps } from '../Typography/types'
interface BadgeProps extends VariantProps<typeof config> { interface BadgeProps extends VariantProps<typeof config> {
number: number number: string | number
} }
export function Badge({ number, color, size }: BadgeProps) { export function Badge({ number, color, size }: BadgeProps) {

View File

@@ -12,9 +12,10 @@ type FocalPoint = {
y: number y: number
} }
export type ImageProps = NextImageProps & { export type ImageProps = Omit<NextImageProps, 'src'> & {
focalPoint?: FocalPoint focalPoint?: FocalPoint
dimensions?: { width: number; height: number } dimensions?: { width: number; height: number }
src: NextImageProps['src'] | undefined
} }
// Next/Image adds & instead of ? before the params // Next/Image adds & instead of ? before the params

View File

@@ -9,9 +9,17 @@ interface RadioProps extends PropsWithChildren {
id?: string id?: string
isDisabled?: boolean isDisabled?: boolean
color?: 'Burgundy' color?: 'Burgundy'
wrapping?: boolean
} }
export function Radio({ id, value, children, color, isDisabled }: RadioProps) { export function Radio({
id,
value,
children,
color,
isDisabled,
wrapping = true,
}: RadioProps) {
const inputId = id || `radio-${value}` const inputId = id || `radio-${value}`
const classNames = variants({ const classNames = variants({
@@ -23,10 +31,13 @@ export function Radio({ id, value, children, color, isDisabled }: RadioProps) {
id={inputId} id={inputId}
value={value} value={value}
isDisabled={isDisabled} isDisabled={isDisabled}
className={cx(styles.container, { [styles.disabled]: isDisabled })} className={cx(styles.container, {
[styles.disabled]: isDisabled,
[styles.wrapping]: wrapping,
})}
> >
<div className={`${styles.radio} ${classNames}`} /> <div className={`${styles.radio} ${classNames}`} />
<div>{children}</div> {children && <div>{children}</div>}
</AriaRadio> </AriaRadio>
) )
} }

View File

@@ -2,10 +2,14 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--Space-x15); gap: var(--Space-x15);
padding: var(--Space-x1) 0;
cursor: pointer; cursor: pointer;
} }
.wrapping {
padding: var(--Space-x1) 0;
}
.radio { .radio {
position: relative; position: relative;
width: 24px; width: 24px;

View File

@@ -0,0 +1,58 @@
import { IconButton } from '../IconButton'
import { MaterialIcon } from '../Icons/MaterialIcon'
import { Tooltip } from '../Tooltip'
import { Typography } from '../Typography'
import styles from './stepper.module.css'
type StepperProps = {
count: number
handleOnIncrease: () => void
handleOnDecrease: () => void
disableIncrease: boolean
disableDecrease: boolean
disabledMessage?: string
}
export default function Stepper({
count,
handleOnIncrease,
handleOnDecrease,
disableIncrease,
disableDecrease,
disabledMessage,
}: StepperProps) {
return (
<div className={styles.counterContainer}>
<IconButton
className={styles.counterBtn}
onPress={handleOnDecrease}
variant="Elevated"
isDisabled={disableDecrease}
>
<MaterialIcon icon="remove" color="CurrentColor" />
</IconButton>
<div className={styles.countDisplay}>
<Typography variant="Body/Paragraph/mdRegular">
<p>{count}</p>
</Typography>
</div>
<Tooltip
text={disabledMessage}
isVisible={Boolean(disabledMessage && disableIncrease)}
position="top"
arrow="right"
isTouchable
>
<IconButton
className={styles.counterBtn}
onPress={handleOnIncrease}
variant="Elevated"
isDisabled={disableIncrease}
>
<MaterialIcon icon="add" color="CurrentColor" />
</IconButton>
</Tooltip>
</div>
)
}

View File

@@ -2,7 +2,7 @@
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
gap: 20px; gap: var(--Space-x1);
color: var(--Text-Interactive-Default); color: var(--Text-Interactive-Default);
} }
.counterBtn { .counterBtn {
@@ -12,3 +12,8 @@
.counterBtn:not([disabled]) { .counterBtn:not([disabled]) {
box-shadow: 0px 0px 8px 1px rgba(0, 0, 0, 0.1); box-shadow: 0px 0px 8px 1px rgba(0, 0, 0, 0.1);
} }
.countDisplay {
width: 20px;
text-align: center;
}

View File

@@ -43,6 +43,9 @@ export function Tooltip<P extends TooltipPosition>({
function handleToggle() { function handleToggle() {
setIsActive((prevState) => !prevState) setIsActive((prevState) => !prevState)
setTimeout(() => {
setIsActive(false)
}, 3000)
} }
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) { function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {

View File

@@ -44,6 +44,10 @@
right: 0; right: 0;
} }
.top.arrowRight {
right: 0;
}
.tooltip::before { .tooltip::before {
content: ''; content: '';
position: absolute; position: absolute;

View File

@@ -276,7 +276,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: block; font-display: block;
src: url(/_static/shared/fonts/material-symbols/rounded-b1df8938.woff2) src: url(/_static/shared/fonts/material-symbols/rounded-3e10d67b.woff2)
format('woff2'); format('woff2');
} }

View File

@@ -165,6 +165,7 @@
"./Payment/PaymentMethodIcon": "./lib/components/Payment/PaymentMethodIcon.tsx", "./Payment/PaymentMethodIcon": "./lib/components/Payment/PaymentMethodIcon.tsx",
"./PointsRateCard": "./lib/components/RateCard/Points/index.tsx", "./PointsRateCard": "./lib/components/RateCard/Points/index.tsx",
"./Progress": "./lib/components/Progress/index.tsx", "./Progress": "./lib/components/Progress/index.tsx",
"./Radio": "./lib/components/Radio/index.tsx",
"./RegularRateCard": "./lib/components/RateCard/Regular/index.tsx", "./RegularRateCard": "./lib/components/RateCard/Regular/index.tsx",
"./Select": "./lib/components/Select/index.tsx", "./Select": "./lib/components/Select/index.tsx",
"./SidePeek": "./lib/components/SidePeek/index.tsx", "./SidePeek": "./lib/components/SidePeek/index.tsx",
@@ -172,6 +173,7 @@
"./SidePeekSelfControlled": "./lib/components/SidePeek/SelfControlled.tsx", "./SidePeekSelfControlled": "./lib/components/SidePeek/SelfControlled.tsx",
"./SkeletonShimmer": "./lib/components/SkeletonShimmer/index.tsx", "./SkeletonShimmer": "./lib/components/SkeletonShimmer/index.tsx",
"./StaticMap": "./lib/components/StaticMap/index.tsx", "./StaticMap": "./lib/components/StaticMap/index.tsx",
"./Stepper": "./lib/components/Stepper/index.tsx",
"./Subtitle": "./lib/components/Subtitle/index.tsx", "./Subtitle": "./lib/components/Subtitle/index.tsx",
"./Switch": "./lib/components/Switch/index.tsx", "./Switch": "./lib/components/Switch/index.tsx",
"./Table": "./lib/components/Table/index.tsx", "./Table": "./lib/components/Table/index.tsx",

View File

@@ -496,6 +496,7 @@ export const ancillaryPackagesSchema = z
translatedCategoryName: ancillary.categoryName, translatedCategoryName: ancillary.categoryName,
internalCategoryName: ancillary.internalCategoryName, internalCategoryName: ancillary.internalCategoryName,
requiresQuantity: getRequiresQuantity(item.id), requiresQuantity: getRequiresQuantity(item.id),
unitName: item.unitName,
})), })),
})) }))
.filter((ancillary) => ancillary.ancillaryContent.length > 0) .filter((ancillary) => ancillary.ancillaryContent.length > 0)

View File

@@ -1,26 +1,27 @@
import crypto from "node:crypto"; import crypto from "node:crypto"
import { createWriteStream } from "node:fs"; import { createWriteStream } from "node:fs"
import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"
import { join, resolve } from "node:path"; import { join, resolve } from "node:path"
import { Readable } from "node:stream"; import { Readable } from "node:stream"
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises"
import stringify from "json-stable-stringify-without-jsonify"; import stringify from "json-stable-stringify-without-jsonify"
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url"
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url)
const __dirname = resolve(__filename, ".."); const __dirname = resolve(__filename, "..")
// Defines where the font lives // Defines where the font lives
const FONT_DIR = resolve(__dirname, "../shared/fonts/material-symbols"); const FONT_DIR = resolve(__dirname, "../shared/fonts/material-symbols")
// Defines the settings for the font // Defines the settings for the font
const FONT_BASE_URL = `https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0..1,0`; const FONT_BASE_URL = `https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0..1,0`
// Defines the subset of icons for the font // Defines the subset of icons for the font
const icons = [ const icons = [
"accessibility", "accessibility",
"accessible", "accessible",
"acute",
"add_circle", "add_circle",
"add", "add",
"air_purifier_gen", "air_purifier_gen",
@@ -231,16 +232,16 @@ const icons = [
"water_full", "water_full",
"wifi", "wifi",
"yard", "yard",
].sort(); ].sort()
function createHash(value: unknown) { function createHash(value: unknown) {
const stringified = stringify(value); const stringified = stringify(value)
const hash = crypto.createHash("sha256"); const hash = crypto.createHash("sha256")
hash.update(stringified); hash.update(stringified)
return hash.digest("hex"); return hash.digest("hex")
} }
const hash = createHash(icons).substring(0, 8); const hash = createHash(icons).substring(0, 8)
async function fetchIconUrl(url: string) { async function fetchIconUrl(url: string) {
const response = await fetch(url, { const response = await fetch(url, {
@@ -250,105 +251,105 @@ async function fetchIconUrl(url: string) {
"User-Agent": "User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
}, },
}); })
if (!response.ok) { if (!response.ok) {
console.error(`Unable to fetch woff2 for ${url}`); console.error(`Unable to fetch woff2 for ${url}`)
process.exit(1); process.exit(1)
} }
const text = await response.text(); const text = await response.text()
const isWoff2 = /format\('woff2'\)/.test(text); const isWoff2 = /format\('woff2'\)/.test(text)
if (!isWoff2) { if (!isWoff2) {
console.error(`Unable to identify woff2 font in response`); console.error(`Unable to identify woff2 font in response`)
process.exit(1); process.exit(1)
} }
const srcUrl = text.match(/src: url\(([^)]+)\)/); const srcUrl = text.match(/src: url\(([^)]+)\)/)
if (srcUrl && srcUrl[1]) { if (srcUrl && srcUrl[1]) {
return srcUrl[1]; return srcUrl[1]
} }
return null; return null
} }
async function download(url: string, destFolder: string) { async function download(url: string, destFolder: string) {
const dest = resolve(join(destFolder, `/rounded-${hash}.woff2`)); const dest = resolve(join(destFolder, `/rounded-${hash}.woff2`))
try { try {
const response = await fetch(url); const response = await fetch(url)
if (!response.ok) { if (!response.ok) {
console.error(`Unable to fetch ${url}`); console.error(`Unable to fetch ${url}`)
process.exit(1); process.exit(1)
} }
if (!response.body) { if (!response.body) {
console.error(`Bad response from ${url}`); console.error(`Bad response from ${url}`)
process.exit(1); process.exit(1)
} }
const fileStream = createWriteStream(dest); const fileStream = createWriteStream(dest)
// @ts-expect-error: type mismatch // @ts-expect-error: type mismatch
const readableNodeStream = Readable.fromWeb(response.body); const readableNodeStream = Readable.fromWeb(response.body)
await pipeline(readableNodeStream, fileStream); await pipeline(readableNodeStream, fileStream)
} catch (error) { } catch (error) {
console.error(`Error downloading file from ${url}:`, error); console.error(`Error downloading file from ${url}:`, error)
process.exit(1); process.exit(1)
} }
} }
async function cleanFontDirs() { async function cleanFontDirs() {
await rm(FONT_DIR, { recursive: true, force: true }); await rm(FONT_DIR, { recursive: true, force: true })
await mkdir(FONT_DIR, { recursive: true }); await mkdir(FONT_DIR, { recursive: true })
await writeFile( await writeFile(
join(FONT_DIR, ".auto-generated"), join(FONT_DIR, ".auto-generated"),
`Auto-generated, do not edit. Use scripts/material-symbols-update.mts to update.\nhash=${hash}\ncreated=${new Date().toISOString()}\n`, `Auto-generated, do not edit. Use scripts/material-symbols-update.mts to update.\nhash=${hash}\ncreated=${new Date().toISOString()}\n`,
{ encoding: "utf-8" }, { encoding: "utf-8" }
); )
} }
async function updateFontCSS() { async function updateFontCSS() {
const file = resolve(__dirname, "../packages/design-system/lib/fonts.css"); const file = resolve(__dirname, "../packages/design-system/lib/fonts.css")
const css = await readFile(file, { const css = await readFile(file, {
encoding: "utf-8", encoding: "utf-8",
}); })
await writeFile( await writeFile(
file, file,
css.replace( css.replace(
/url\(\/_static\/shared\/fonts\/material-symbols\/rounded[^)]+\)/, /url\(\/_static\/shared\/fonts\/material-symbols\/rounded[^)]+\)/,
`url(/_static/shared/fonts/material-symbols/rounded-${hash}.woff2)`, `url(/_static/shared/fonts/material-symbols/rounded-${hash}.woff2)`
), ),
{ {
encoding: "utf-8", encoding: "utf-8",
}, }
); )
} }
async function main() { async function main() {
const fontUrl = `${FONT_BASE_URL}&icon_names=${icons.join(",")}&display=block`; const fontUrl = `${FONT_BASE_URL}&icon_names=${icons.join(",")}&display=block`
const iconUrl = await fetchIconUrl(fontUrl); const iconUrl = await fetchIconUrl(fontUrl)
if (iconUrl) { if (iconUrl) {
await cleanFontDirs(); await cleanFontDirs()
await download(iconUrl, FONT_DIR); await download(iconUrl, FONT_DIR)
await updateFontCSS(); await updateFontCSS()
console.log("Successfully updated icons!"); console.log("Successfully updated icons!")
process.exit(0); process.exit(0)
} else { } else {
console.error( console.error(
`Unable to find the icon font src URL in CSS response from Google Fonts at ${fontUrl}`, `Unable to find the icon font src URL in CSS response from Google Fonts at ${fontUrl}`
); )
} }
} }
main(); main()

View File

@@ -1,3 +1,3 @@
Auto-generated, do not edit. Use scripts/material-symbols-update.mts to update. Auto-generated, do not edit. Use scripts/material-symbols-update.mts to update.
hash=b1df8938 hash=3e10d67b
created=2025-12-04T09:28:50.275Z created=2025-12-09T12:38:38.912Z

Binary file not shown.