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:
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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:
|
||||
"You’ve reached your points limit and can’t 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.ancillaryCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
|
||||
@@ -73,6 +73,10 @@ export function useAncillaries(
|
||||
defaultMessage: "Food",
|
||||
}),
|
||||
requiresQuantity: false,
|
||||
unitName: intl.formatMessage({
|
||||
id: "common.person",
|
||||
defaultMessage: "person",
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user