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 {
display: flex;
gap: var(--Space-x2);
gap: var(--Space-x05);
align-items: center;
}
.contentContainer {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
}
.description {
display: flex;
margin: var(--Space-x2) 0;
}
.pointsDivider {
@@ -30,6 +30,15 @@
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) {
.breakfastPriceList {
flex-direction: row;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,14 @@
.form {
display: flex;
flex-direction: column;
flex-grow: 1;
.modal {
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"
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 { useEffect } from "react"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
import { dt } from "@scandic-hotels/common/dt"
import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner"
import { toast } from "@scandic-hotels/design-system/Toast"
import { trpc } from "@scandic-hotels/trpc/client"
import Modal from "@scandic-hotels/design-system/Modal"
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 AddAncillaryFlow from "./Modal"
import { type AncillaryFormData, ancillaryFormSchema } from "./schema"
import {
buildBreakfastPackages,
calculateBreakfastData,
getErrorMessage,
getGuaranteeCallback,
} from "./utils"
import Form from "./Form"
import { calculateBreakfastData } from "./utils"
import styles from "./addAncillaryFlowModal.module.css"
import type {
AddAncillaryFlowModalProps,
AncillaryErrorMessage,
AncillaryItem,
} from "@/types/components/myPages/myStay/ancillaries"
import type { AddAncillaryFlowModalProps } from "@/types/components/myPages/myStay/ancillaries"
export default function AddAncillaryFlowModal({
booking,
@@ -54,229 +21,18 @@ export default function AddAncillaryFlowModal({
savedCreditCards,
}: AddAncillaryFlowModalProps) {
const {
selectedAncillary,
isOpen,
closeModal,
breakfastData,
selectedAncillary,
setBreakfastData,
isBreakfast,
} = useAddAncillaryStore((state) => ({
selectedAncillary: state.selectedAncillary,
closeModal: state.closeModal,
breakfastData: state.breakfastData,
setBreakfastData: state.setBreakfastData,
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(() => {
setBreakfastData(
@@ -300,27 +56,16 @@ export default function AddAncillaryFlowModal({
setBreakfastData,
])
if (isLoading) {
return (
<div>
<LoadingSpinner />
</div>
)
}
return (
<FormProvider {...formMethods}>
<form
onSubmit={formMethods.handleSubmit(onSubmit)}
className={styles.form}
id="add-ancillary-form-id"
>
<AddAncillaryFlow
error={errorMessage}
savedCreditCards={savedCreditCards}
user={user}
/>
</form>
</FormProvider>
<Modal
isOpen={isOpen}
onToggle={closeModal}
title={selectedAncillary?.title || ""}
withActions
contentClassName={styles.modalContent}
className={styles.modal}
>
<Form user={user} savedCreditCards={savedCreditCards} booking={booking} />
</Modal>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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