Merged in feat/SW-1370/Guarantee-my-stay-ancillaries (pull request #1545)
Feat/SW-1370/Guarantee my stay ancillaries * feat(SW-1370): guarantee for ancillaries * feat(SW-1370): remove console log * feat(SW-1370): add translations * feat(SW-1370): small fix * feat(SW-1370): fix must be guaranteed * feat(SW-1370): fix logic and comments pr * feat(SW-1370): fix comments pr * feat(SW-1370): fix comments pr * feat(SW-1370): add translation * feat(SW-1370): add translation and fix pr comment * feat(SW-1370): fix pr comment * feat(SW-1370): fix encoding path refId issue * feat(SW-1370): refactor AddAncillaryStore usage and introduce context provider * feat(SW-1370): refactor * feat(SW-1370): refactor ancillaries * feat(SW-1370): fix merge Approved-by: Simon.Emanuelsson
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x4);
|
||||
justify-content: flex-end;
|
||||
padding: var(--Space-x15) var(--Space-x15) 0;
|
||||
}
|
||||
|
||||
.confirmButtons {
|
||||
display: flex;
|
||||
padding: 0 var(--Space-x15);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.priceButton {
|
||||
display: flex;
|
||||
gap: var(--Space-x05);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
|
||||
import {
|
||||
AncillaryStepEnum,
|
||||
useAddAncillaryStore,
|
||||
} from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import { ChevronDownSmallIcon, ChevronUpSmallIcon } from "@/components/Icons"
|
||||
|
||||
import { type AncillaryFormData,quantitySchema } from "../../schema"
|
||||
|
||||
import styles from "./actionButtons.module.css"
|
||||
|
||||
import type { ActionButtonsProps } from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
export default function ActionButtons({
|
||||
togglePriceDetails,
|
||||
isPriceDetailsOpen,
|
||||
isSubmitting,
|
||||
}: ActionButtonsProps) {
|
||||
const {
|
||||
currentStep,
|
||||
prevStep,
|
||||
selectQuantity,
|
||||
selectDeliveryTime,
|
||||
selectQuantityAndDeliveryTime,
|
||||
} = useAddAncillaryStore((state) => ({
|
||||
currentStep: state.currentStep,
|
||||
prevStep: state.prevStep,
|
||||
selectQuantity: state.selectQuantity,
|
||||
selectDeliveryTime: state.selectDeliveryTime,
|
||||
selectQuantityAndDeliveryTime: state.selectQuantityAndDeliveryTime,
|
||||
}))
|
||||
const isMobile = useMediaQuery("(max-width: 767px)")
|
||||
const { setError } = useFormContext()
|
||||
|
||||
const intl = useIntl()
|
||||
const isConfirmStep = currentStep === AncillaryStepEnum.confirmation
|
||||
const confirmLabel = intl.formatMessage({ id: "Confirm" })
|
||||
const continueLabel = intl.formatMessage({ id: "Continue" })
|
||||
const quantityWithCard = useWatch<AncillaryFormData>({
|
||||
name: "quantityWithCard",
|
||||
})
|
||||
const quantityWithPoints = useWatch<AncillaryFormData>({
|
||||
name: "quantityWithPoints",
|
||||
})
|
||||
function handleNextStep() {
|
||||
if (currentStep === AncillaryStepEnum.selectQuantity) {
|
||||
const validatedQuantity = quantitySchema.safeParse({
|
||||
quantityWithCard,
|
||||
quantityWithPoints,
|
||||
})
|
||||
if (validatedQuantity.success) {
|
||||
if (isMobile) {
|
||||
selectQuantityAndDeliveryTime()
|
||||
} else {
|
||||
selectQuantity()
|
||||
}
|
||||
} else {
|
||||
setError("quantityWithCard", validatedQuantity.error.issues[0])
|
||||
}
|
||||
} else if (currentStep === AncillaryStepEnum.selectDelivery) {
|
||||
selectDeliveryTime()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={isConfirmStep ? styles.confirmButtons : ""}>
|
||||
{isConfirmStep && (
|
||||
<Button
|
||||
type="button"
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
size="Small"
|
||||
variant="Text"
|
||||
onPress={togglePriceDetails}
|
||||
className={styles.priceButton}
|
||||
>
|
||||
{intl.formatMessage({ id: "Price details" })}
|
||||
{isPriceDetailsOpen ? (
|
||||
<ChevronUpSmallIcon
|
||||
width={20}
|
||||
height={20}
|
||||
color="baseButtonTextOnFillNormal"
|
||||
/>
|
||||
) : (
|
||||
<ChevronDownSmallIcon
|
||||
width={20}
|
||||
height={20}
|
||||
color="baseButtonTextOnFillNormal"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<div className={styles.buttons}>
|
||||
<Button
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
type="button"
|
||||
variant="Text"
|
||||
size="Small"
|
||||
color="Primary"
|
||||
onPress={prevStep}
|
||||
>
|
||||
{intl.formatMessage({ id: "Back" })}
|
||||
</Button>
|
||||
{isConfirmStep && (
|
||||
<Button
|
||||
type="submit"
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
variant="Primary"
|
||||
size="Small"
|
||||
isDisabled={isSubmitting}
|
||||
form="add-ancillary-form-id"
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
)}
|
||||
{!isConfirmStep && (
|
||||
<Button
|
||||
type="button"
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
variant="Secondary"
|
||||
size="Small"
|
||||
isDisabled={isSubmitting}
|
||||
onPress={handleNextStep}
|
||||
>
|
||||
{continueLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./priceSummary.module.css"
|
||||
|
||||
import type { Ancillary } from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
interface PriceSummaryProps {
|
||||
totalPrice: number | null
|
||||
totalPoints: number | null
|
||||
totalUnits: number
|
||||
selectedAncillary: NonNullable<Ancillary["ancillaryContent"][number]>
|
||||
}
|
||||
|
||||
export default function PriceSummary({
|
||||
totalPrice,
|
||||
totalPoints,
|
||||
totalUnits,
|
||||
selectedAncillary,
|
||||
}: PriceSummaryProps) {
|
||||
const intl = useIntl()
|
||||
const hasTotalPoints = typeof totalPoints === "number"
|
||||
const hasTotalPrice = typeof totalPrice === "number"
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<h2>{intl.formatMessage({ id: "Summary" })}</h2>
|
||||
</Typography>
|
||||
<Divider color="subtle" />
|
||||
|
||||
<div className={styles.column}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<h2>{selectedAncillary.title}</h2>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{`X${totalUnits}`}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className={styles.column}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<h2>{intl.formatMessage({ id: "Price including VAT" })}</h2>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<h2>
|
||||
{formatPrice(
|
||||
intl,
|
||||
selectedAncillary.price.total,
|
||||
selectedAncillary.price.currency
|
||||
)}
|
||||
</h2>
|
||||
</Typography>
|
||||
</div>
|
||||
<Divider color="subtle" />
|
||||
<div className={styles.column}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{ id: "<b>Total price</b> (incl VAT)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
<div className={styles.totalPrice}>
|
||||
{hasTotalPoints && (
|
||||
<div>
|
||||
<div>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
</div>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{totalPoints} {intl.formatMessage({ id: "points" })}
|
||||
{hasTotalPrice && "+"}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
{hasTotalPrice && (
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPrice,
|
||||
selectedAncillary.price.currency
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.container {
|
||||
display: flex;
|
||||
padding: var(--Space-x3);
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x2);
|
||||
align-self: stretch;
|
||||
border-radius: var(--Corner-radius-lg);
|
||||
border: 1px solid var(--Border-Divider-Default);
|
||||
background: var(--Surface-Primary-Default);
|
||||
margin: var(--Space-x1);
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.totalPrice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import {
|
||||
AncillaryStepEnum,
|
||||
useAddAncillaryStore,
|
||||
} from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import PriceSummary from "./PriceSummary"
|
||||
|
||||
import styles from "./priceDetails.module.css"
|
||||
|
||||
interface PriceDetailsProps {
|
||||
isPriceDetailsOpen: boolean
|
||||
}
|
||||
|
||||
export default function PriceDetails({
|
||||
isPriceDetailsOpen,
|
||||
}: PriceDetailsProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const { currentStep, selectedAncillary } = useAddAncillaryStore((state) => ({
|
||||
currentStep: state.currentStep,
|
||||
selectedAncillary: state.selectedAncillary,
|
||||
}))
|
||||
const quantityWithPoints = useWatch({ name: "quantityWithPoints" })
|
||||
const quantityWithCard = useWatch({ name: "quantityWithCard" })
|
||||
|
||||
if (!selectedAncillary || currentStep !== AncillaryStepEnum.confirmation) {
|
||||
return null
|
||||
}
|
||||
const totalPrice =
|
||||
quantityWithCard && selectedAncillary
|
||||
? selectedAncillary.price.total * quantityWithCard
|
||||
: null
|
||||
|
||||
const totalPoints =
|
||||
quantityWithPoints && selectedAncillary?.points
|
||||
? selectedAncillary.points * quantityWithPoints
|
||||
: null
|
||||
const totalUnits = (quantityWithCard ?? 0) + (quantityWithPoints ?? 0)
|
||||
return (
|
||||
<>
|
||||
<div className={styles.totalPrice}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{ id: "<b>Total price</b> (incl VAT)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
{totalPrice !== null && (
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{formatPrice(intl, totalPrice, selectedAncillary.price.currency)}
|
||||
</p>
|
||||
</Typography>
|
||||
)}
|
||||
{totalPoints !== null && (
|
||||
<div>
|
||||
<div>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
</div>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{totalPoints} {intl.formatMessage({ id: "points" })}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Divider color="subtle" />
|
||||
{isPriceDetailsOpen && (
|
||||
<PriceSummary
|
||||
totalPrice={totalPrice}
|
||||
totalPoints={totalPoints}
|
||||
totalUnits={totalUnits}
|
||||
selectedAncillary={selectedAncillary}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.totalPrice {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.modalContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.termsAndConditions {
|
||||
display: flex;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { PaymentMethodEnum } from "@/constants/booking"
|
||||
import {
|
||||
bookingTermsAndConditions,
|
||||
privacyPolicy,
|
||||
} from "@/constants/currentWebHrefs"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import MySavedCards from "@/components/HotelReservation/EnterDetails/Payment/MySavedCards"
|
||||
import PaymentOption from "@/components/HotelReservation/EnterDetails/Payment/PaymentOption"
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./confirmationStep.module.css"
|
||||
|
||||
import type { ConfirmationStepProps } from "@/types/components/myPages/myStay/ancillaries"
|
||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
|
||||
export default function ConfirmationStep({
|
||||
savedCreditCards,
|
||||
}: ConfirmationStepProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const { checkInDate, guaranteeInfo } = useAddAncillaryStore((state) => ({
|
||||
checkInDate: state.booking.checkInDate,
|
||||
guaranteeInfo: state.booking.guaranteeInfo,
|
||||
}))
|
||||
const refundableDate = dt(checkInDate)
|
||||
.subtract(1, "day")
|
||||
.locale(lang)
|
||||
.format("23:59, dddd, D MMMM YYYY")
|
||||
|
||||
return (
|
||||
<div className={styles.modalContent}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "All ancillaries are fully refundable until {date}. Time selection and special requests are also modifiable.",
|
||||
},
|
||||
{ date: refundableDate }
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
<header>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h2>
|
||||
{intl.formatMessage({
|
||||
id: "Reserve with Card",
|
||||
})}
|
||||
</h2>
|
||||
</Typography>
|
||||
</header>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "Payment will be made on check-in. The card will be only used to guarantee the ancillary in case of no-show.",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
{guaranteeInfo ? (
|
||||
<PaymentOption
|
||||
name="paymentMethod"
|
||||
value={PaymentMethodEnum.card}
|
||||
cardNumber={guaranteeInfo.maskedCard.slice(-4)}
|
||||
label={intl.formatMessage({ id: "Credit card" })}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
text={intl.formatMessage({
|
||||
id: "By adding a card you also guarantee your room booking for late arrival.",
|
||||
})}
|
||||
/>
|
||||
{savedCreditCards?.length && (
|
||||
<MySavedCards savedCreditCards={savedCreditCards} />
|
||||
)}
|
||||
<>
|
||||
{savedCreditCards?.length && (
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<h4>{intl.formatMessage({ id: "OTHER" })}</h4>
|
||||
</Typography>
|
||||
)}
|
||||
<PaymentOption
|
||||
name="paymentMethod"
|
||||
value={PaymentMethodEnum.card}
|
||||
label={intl.formatMessage({ id: "Credit card" })}
|
||||
/>
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
<div className={styles.termsAndConditions}>
|
||||
<Checkbox
|
||||
name="termsAndConditions"
|
||||
registerOptions={{ required: true }}
|
||||
topAlign
|
||||
>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "Yes, I accept the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. There you can learn more about what data we process, your rights and where to turn if you have questions.",
|
||||
},
|
||||
{
|
||||
termsAndConditionsLink: (str) => (
|
||||
<Typography variant="Link/sm">
|
||||
<Link
|
||||
variant="underscored"
|
||||
href={bookingTermsAndConditions[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
</Typography>
|
||||
),
|
||||
privacyPolicyLink: (str) => (
|
||||
<Typography variant="Link/sm">
|
||||
<Link
|
||||
variant="underscored"
|
||||
href={privacyPolicy[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
</Typography>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { generateDeliveryOptions } from "@/components/HotelReservation/MyStay/Ancillaries/utils"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import Select from "@/components/TempDesignSystem/Form/Select"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
@@ -8,12 +9,9 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./deliveryDetailsStep.module.css"
|
||||
|
||||
import type { DeliveryMethodStepProps } from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
export default function DeliveryMethodStep({
|
||||
deliveryTimeOptions,
|
||||
}: DeliveryMethodStepProps) {
|
||||
export default function DeliveryMethodStep() {
|
||||
const intl = useIntl()
|
||||
const deliveryTimeOptions = generateDeliveryOptions()
|
||||
|
||||
return (
|
||||
<div className={styles.selectContainer}>
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
AncillaryStepEnum,
|
||||
useAddAncillaryStore,
|
||||
} from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import ConfirmationStep from "../ConfirmationStep"
|
||||
import DeliveryMethodStep from "../DeliveryDetailsStep"
|
||||
import SelectAncillaryStep from "../SelectAncillaryStep"
|
||||
import SelectQuantityStep from "../SelectQuantityStep"
|
||||
|
||||
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
export default function Desktop({ user, savedCreditCards }: StepsProps) {
|
||||
const currentStep = useAddAncillaryStore((state) => state.currentStep)
|
||||
if (currentStep === AncillaryStepEnum.selectAncillary) {
|
||||
return <SelectAncillaryStep />
|
||||
}
|
||||
if (currentStep === AncillaryStepEnum.selectQuantity) {
|
||||
return <SelectQuantityStep user={user} />
|
||||
}
|
||||
if (currentStep === AncillaryStepEnum.selectDelivery) {
|
||||
return <DeliveryMethodStep />
|
||||
}
|
||||
|
||||
return <ConfirmationStep savedCreditCards={savedCreditCards} />
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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 }: 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} />
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import WrappedAncillaryCard from "../../../WrappedAncillaryCard"
|
||||
|
||||
import styles from "./selectAncillaryStep.module.css"
|
||||
|
||||
export default function SelectAncillaryStep() {
|
||||
const {
|
||||
ancillariesBySelectedCategory,
|
||||
selectedCategory,
|
||||
categories,
|
||||
selectCategory,
|
||||
} = useAddAncillaryStore((state) => ({
|
||||
categories: state.categories,
|
||||
selectedCategory: state.selectedCategory,
|
||||
ancillariesBySelectedCategory: state.ancillariesBySelectedCategory,
|
||||
selectCategory: state.selectCategory,
|
||||
}))
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.tabs}>
|
||||
{categories.map((categoryName) => (
|
||||
<button
|
||||
key={categoryName}
|
||||
className={`${styles.chip} ${categoryName === selectedCategory ? styles.selected : ""}`}
|
||||
onClick={() => selectCategory(categoryName)}
|
||||
>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p>
|
||||
{categoryName
|
||||
? categoryName
|
||||
: intl.formatMessage({ id: "Other" })}
|
||||
</p>
|
||||
</Typography>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.grid}>
|
||||
{ancillariesBySelectedCategory.map((ancillary) => (
|
||||
<WrappedAncillaryCard key={ancillary.id} ancillary={ancillary} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x3) 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(251px, 1fr));
|
||||
gap: var(--Spacing-x2);
|
||||
height: 470px;
|
||||
overflow-y: auto;
|
||||
padding-right: var(--Spacing-x-one-and-half);
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.chip {
|
||||
border-radius: var(--Corner-radius-Rounded);
|
||||
padding: calc(var(--Space-x1) + var(--Space-x025)) var(--Space-x2);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--Border-Interactive-Default);
|
||||
color: var(--Text-Default);
|
||||
background-color: var(--Background-Secondary);
|
||||
}
|
||||
|
||||
.chip.selected {
|
||||
background: var(--Surface-Brand-Primary-3-Default);
|
||||
color: var(--Text-Inverted);
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import { DiamondIcon } from "@/components/Icons"
|
||||
import ErrorMessage from "@/components/TempDesignSystem/Form/ErrorMessage"
|
||||
import Select from "@/components/TempDesignSystem/Form/Select"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./selectQuantityStep.module.css"
|
||||
|
||||
@@ -15,8 +15,12 @@ import type { SelectQuantityStepProps } from "@/types/components/myPages/myStay/
|
||||
|
||||
export default function SelectQuantityStep({ user }: SelectQuantityStepProps) {
|
||||
const intl = useIntl()
|
||||
const { selectedAncillary } = useAddAncillaryStore()
|
||||
const { formState } = useFormContext()
|
||||
const selectedAncillary = useAddAncillaryStore(
|
||||
(state) => state.selectedAncillary
|
||||
)
|
||||
const {
|
||||
formState: { errors },
|
||||
} = useFormContext()
|
||||
|
||||
const cardQuantityOptions = Array.from({ length: 7 }, (_, i) => ({
|
||||
label: `${i}`,
|
||||
@@ -47,39 +51,41 @@ export default function SelectQuantityStep({ user }: SelectQuantityStepProps) {
|
||||
<div className={styles.selectContainer}>
|
||||
{selectedAncillary?.points && user && (
|
||||
<div className={styles.select}>
|
||||
<Subtitle type="two">
|
||||
{intl.formatMessage({ id: "Pay with points" })}
|
||||
</Subtitle>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h2>{intl.formatMessage({ id: "Pay with points" })}</h2>
|
||||
</Typography>
|
||||
<div className={styles.totalPointsContainer}>
|
||||
<div className={styles.totalPoints}>
|
||||
<DiamondIcon />
|
||||
<Subtitle textTransform="uppercase" type="two">
|
||||
{intl.formatMessage({ id: "Total points" })}
|
||||
</Subtitle>
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<h2>{intl.formatMessage({ id: "Total points" })}</h2>
|
||||
</Typography>
|
||||
</div>
|
||||
<Body>{currentPoints}</Body>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{currentPoints}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<Select
|
||||
name="quantityWithPoints"
|
||||
label={pointsLabel}
|
||||
items={pointsQuantityOptions}
|
||||
disabled={!user || insufficientPoints}
|
||||
disabled={insufficientPoints}
|
||||
isNestedInModal
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.select}>
|
||||
<Subtitle type="two">
|
||||
{intl.formatMessage({ id: "Pay with Card" })}
|
||||
</Subtitle>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h2> {intl.formatMessage({ id: "Pay with Card" })}</h2>
|
||||
</Typography>
|
||||
<Select
|
||||
name="quantityWithCard"
|
||||
label={intl.formatMessage({ id: "Select quantity" })}
|
||||
items={cardQuantityOptions}
|
||||
isNestedInModal
|
||||
/>
|
||||
<ErrorMessage errors={errors} name="quantityWithCard" />
|
||||
</div>
|
||||
<ErrorMessage errors={formState.errors} name="quantityWithCard" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.selectContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x025);
|
||||
margin-bottom: var(--Space-x2);
|
||||
}
|
||||
|
||||
.select {
|
||||
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-Medium);
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.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-Medium);
|
||||
}
|
||||
|
||||
.totalPoints {
|
||||
display: flex;
|
||||
gap: var(--Space-x15);
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import Desktop from "./Desktop"
|
||||
import Mobile from "./Mobile"
|
||||
|
||||
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
export default function Steps(props: StepsProps) {
|
||||
const isMobile = useMediaQuery("(max-width: 767px)")
|
||||
|
||||
return isMobile ? <Mobile {...props} /> : <Desktop {...props} />
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
.modalWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 80dvh;
|
||||
max-height: 70dvh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
.price {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
gap: var(--Spacing-x2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -48,24 +48,53 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
.confirmStep {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x4);
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
background: var(--Surface-Primary-OnSurface-Default);
|
||||
border-top: 1px solid var(--Base-Border-Normal);
|
||||
padding-bottom: var(--Space-x15);
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
margin: var(--Spacing-x2) 0;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
background: var(--UI-Opacity-White-100);
|
||||
padding-top: var(--Spacing-x2);
|
||||
border-top: 1px solid var(--Base-Border-Normal);
|
||||
padding-bottom: var(--Space-x025);
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
gap: var(--Space-x2);
|
||||
height: 24px;
|
||||
}
|
||||
@media screen and (min-width: 768px) {
|
||||
.modalWrapper {
|
||||
width: 492px;
|
||||
}
|
||||
.selectAncillarycontainer {
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
height: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1052px) {
|
||||
.selectAncillarycontainer {
|
||||
width: 833px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,70 @@
|
||||
"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 { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { PaymentMethodEnum } from "@/constants/booking"
|
||||
import { guaranteeCallback } from "@/constants/routes/hotelReservation"
|
||||
import { env } from "@/env/client"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
import {
|
||||
AncillaryStepEnum,
|
||||
useAddAncillaryStore,
|
||||
} from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import Image from "@/components/Image"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import Modal from "@/components/Modal"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import { useGuaranteeBooking } from "@/hooks/booking/useGuaranteeBooking"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import { generateDeliveryOptions } from "../../utils"
|
||||
import ConfirmationStep from "../ConfirmationStep"
|
||||
import DeliveryMethodStep from "../DeliveryDetailsStep"
|
||||
import {
|
||||
clearAncillarySessionData,
|
||||
generateDeliveryOptions,
|
||||
getAncillarySessionData,
|
||||
setAncillarySessionData,
|
||||
} from "../../utils"
|
||||
import { type AncillaryFormData, ancillaryFormSchema } from "../schema"
|
||||
import SelectQuantityStep from "../SelectQuantityStep"
|
||||
import ActionButtons from "./ActionButtons"
|
||||
import PriceDetails from "./PriceDetails"
|
||||
import Steps from "./Steps"
|
||||
|
||||
import styles from "./addAncillaryFlowModal.module.css"
|
||||
|
||||
import type { AddAncillaryFlowModalProps } from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
type FieldName = keyof AncillaryFormData
|
||||
const STEP_FIELD_MAP: Record<number, FieldName[]> = {
|
||||
1: ["quantityWithPoints", "quantityWithCard"],
|
||||
2: ["deliveryTime"],
|
||||
3: ["termsAndConditions"],
|
||||
}
|
||||
|
||||
export default function AddAncillaryFlowModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
booking,
|
||||
user,
|
||||
savedCreditCards,
|
||||
refId,
|
||||
}: AddAncillaryFlowModalProps) {
|
||||
const {
|
||||
step,
|
||||
nextStep,
|
||||
prevStep,
|
||||
resetStore,
|
||||
selectedAncillary,
|
||||
confirmationNumber,
|
||||
openedFrom,
|
||||
setGridIsOpen,
|
||||
} = useAddAncillaryStore()
|
||||
|
||||
const { currentStep, selectedAncillary, closeModal } = useAddAncillaryStore(
|
||||
(state) => ({
|
||||
currentStep: state.currentStep,
|
||||
selectedAncillary: state.selectedAncillary,
|
||||
closeModal: state.closeModal,
|
||||
})
|
||||
)
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const isMobile = useMediaQuery("(max-width: 767px)")
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
|
||||
const deliveryTimeOptions = generateDeliveryOptions(booking.checkInDate)
|
||||
const [isPriceDetailsOpen, setIsPriceDetailsOpen] = useState(false)
|
||||
const guaranteeRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}${guaranteeCallback(lang)}`
|
||||
const deliveryTimeOptions = generateDeliveryOptions()
|
||||
|
||||
const defaultDeliveryTime = deliveryTimeOptions[0]?.value
|
||||
const defaultDeliveryTime = deliveryTimeOptions[0].value
|
||||
|
||||
const formMethods = useForm<AncillaryFormData>({
|
||||
defaultValues: {
|
||||
@@ -66,233 +73,238 @@ export default function AddAncillaryFlowModal({
|
||||
deliveryTime: defaultDeliveryTime,
|
||||
optionalText: "",
|
||||
termsAndConditions: false,
|
||||
paymentMethod: booking.guaranteeInfo
|
||||
? PaymentMethodEnum.card
|
||||
: savedCreditCards?.length
|
||||
? savedCreditCards[0].id
|
||||
: PaymentMethodEnum.card,
|
||||
},
|
||||
mode: "onSubmit",
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
resolver: zodResolver(ancillaryFormSchema),
|
||||
})
|
||||
|
||||
const { reset, trigger, handleSubmit, formState } = formMethods
|
||||
const ancillaryErrorMessage = intl.formatMessage(
|
||||
{
|
||||
id: "Something went wrong. {ancillary} could not be added to your booking!",
|
||||
},
|
||||
{ ancillary: selectedAncillary?.title }
|
||||
)
|
||||
|
||||
function togglePriceDetails() {
|
||||
setIsPriceDetailsOpen((isOpen) => !isOpen)
|
||||
}
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const addAncillary = trpc.booking.packages.useMutation({
|
||||
onSuccess: (data, variables) => {
|
||||
if (!data) {
|
||||
toast.error(
|
||||
if (data) {
|
||||
clearAncillarySessionData()
|
||||
closeModal()
|
||||
utils.booking.confirmation.invalidate({
|
||||
confirmationNumber: variables.confirmationNumber,
|
||||
})
|
||||
|
||||
toast.success(
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "Something went wrong. {ancillary} could not be added to your booking!",
|
||||
},
|
||||
{ id: "{ancillary} added to your booking!" },
|
||||
{ ancillary: selectedAncillary?.title }
|
||||
)
|
||||
)
|
||||
return
|
||||
} else {
|
||||
toast.error(ancillaryErrorMessage)
|
||||
}
|
||||
const description = variables.ancillaryDeliveryTime
|
||||
? intl.formatMessage(
|
||||
{
|
||||
id: "Delivery between {deliveryTime}. Payment will be made on check-in.",
|
||||
},
|
||||
{ deliveryTime: variables.ancillaryDeliveryTime }
|
||||
)
|
||||
: undefined
|
||||
|
||||
toast.success(
|
||||
intl.formatMessage(
|
||||
{ id: "{ancillary} added to your booking!" },
|
||||
{ ancillary: selectedAncillary?.title }
|
||||
),
|
||||
{ description }
|
||||
)
|
||||
handleClose()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "Something went wrong. {ancillary} could not be added to your booking!",
|
||||
},
|
||||
{ ancillary: selectedAncillary?.title }
|
||||
)
|
||||
)
|
||||
toast.error(ancillaryErrorMessage)
|
||||
},
|
||||
})
|
||||
|
||||
const { guaranteeBooking, isLoading } = useGuaranteeBooking({
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
})
|
||||
|
||||
const onSubmit = (data: AncillaryFormData) => {
|
||||
const packages = []
|
||||
if (data.quantityWithCard) {
|
||||
packages.push({
|
||||
code: selectedAncillary!.id,
|
||||
quantity: data.quantityWithCard,
|
||||
comment: data.optionalText || undefined,
|
||||
if (!data.termsAndConditions) {
|
||||
formMethods.setError("termsAndConditions", {
|
||||
message: "You must accept the terms",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedAncillary?.loyaltyCode && data.quantityWithPoints) {
|
||||
packages.push({
|
||||
code: selectedAncillary.loyaltyCode,
|
||||
quantity: data.quantityWithPoints,
|
||||
comment: data.optionalText || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
addAncillary.mutate({
|
||||
confirmationNumber,
|
||||
ancillaryComment: data.optionalText ?? "",
|
||||
ancillaryDeliveryTime: data.deliveryTime ?? undefined,
|
||||
packages,
|
||||
language: lang,
|
||||
setAncillarySessionData({
|
||||
formData: data,
|
||||
selectedAncillary,
|
||||
})
|
||||
}
|
||||
|
||||
const handleNextStep = async () => {
|
||||
let fieldsToValidate = []
|
||||
|
||||
if (isMobile && step === 1) {
|
||||
fieldsToValidate = [...STEP_FIELD_MAP[1]]
|
||||
if (selectedAncillary?.requiresDeliveryTime) {
|
||||
fieldsToValidate = [...fieldsToValidate, ...STEP_FIELD_MAP[2]]
|
||||
if (booking.guaranteeInfo) {
|
||||
const packages = []
|
||||
if (selectedAncillary?.id && data.quantityWithCard) {
|
||||
packages.push({
|
||||
code: selectedAncillary.id,
|
||||
quantity: data.quantityWithCard,
|
||||
comment: data.optionalText || undefined,
|
||||
})
|
||||
}
|
||||
} else if (step === 2) {
|
||||
fieldsToValidate = selectedAncillary?.requiresDeliveryTime
|
||||
? STEP_FIELD_MAP[2] || []
|
||||
: []
|
||||
|
||||
if (selectedAncillary?.loyaltyCode && data.quantityWithPoints) {
|
||||
packages.push({
|
||||
code: selectedAncillary.loyaltyCode,
|
||||
quantity: data.quantityWithPoints,
|
||||
comment: data.optionalText || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
addAncillary.mutate({
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
ancillaryComment: data.optionalText,
|
||||
ancillaryDeliveryTime: selectedAncillary?.requiresDeliveryTime
|
||||
? data.deliveryTime
|
||||
: undefined,
|
||||
packages,
|
||||
language: lang,
|
||||
})
|
||||
} else {
|
||||
fieldsToValidate = STEP_FIELD_MAP[step] || []
|
||||
}
|
||||
|
||||
if (await trigger(fieldsToValidate)) {
|
||||
nextStep()
|
||||
const savedCreditCard = savedCreditCards?.find(
|
||||
(card) => card.id === data.paymentMethod
|
||||
)
|
||||
if (booking.confirmationNumber) {
|
||||
const card = savedCreditCard
|
||||
? {
|
||||
alias: savedCreditCard.alias,
|
||||
expiryDate: savedCreditCard.expirationDate,
|
||||
cardType: savedCreditCard.cardType,
|
||||
}
|
||||
: undefined
|
||||
guaranteeBooking.mutate({
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
language: lang,
|
||||
...(card && { card }),
|
||||
success: `${guaranteeRedirectUrl}?status=success&RefId=${encodeURIComponent(refId)}&ancillary=1`,
|
||||
error: `${guaranteeRedirectUrl}?status=error&RefId=${encodeURIComponent(refId)}&ancillary=1`,
|
||||
cancel: `${guaranteeRedirectUrl}?status=cancel&RefId=${encodeURIComponent(refId)}&ancillary=1`,
|
||||
})
|
||||
} else {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "Something went wrong!",
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (step > 1) {
|
||||
prevStep()
|
||||
} else {
|
||||
handleClose()
|
||||
if (openedFrom === "grid") setGridIsOpen(true)
|
||||
useEffect(() => {
|
||||
const errorCode = searchParams.get("errorCode")
|
||||
const ancillary = searchParams.get("ancillary")
|
||||
if ((errorCode && ancillary) || errorCode === "AncillaryFailed") {
|
||||
const queryParams = new URLSearchParams(searchParams.toString())
|
||||
if (ancillary) {
|
||||
queryParams.delete("ancillary")
|
||||
}
|
||||
queryParams.delete("errorCode")
|
||||
const savedData = getAncillarySessionData()
|
||||
if (savedData?.formData) {
|
||||
formMethods.reset(savedData.formData)
|
||||
}
|
||||
router.replace(`${pathname}?${queryParams.toString()}`)
|
||||
}
|
||||
}, [searchParams, pathname, formMethods, router])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.loading}>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
reset()
|
||||
resetStore()
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!selectedAncillary) return null
|
||||
|
||||
const confirmLabel = intl.formatMessage({ id: "Confirm" })
|
||||
const continueLabel = intl.formatMessage({ id: "Continue" })
|
||||
const confirmStep =
|
||||
isMobile || (!isMobile && !selectedAncillary.requiresDeliveryTime) ? 2 : 3
|
||||
|
||||
const modalTitle =
|
||||
currentStep === AncillaryStepEnum.selectAncillary
|
||||
? intl.formatMessage({ id: "Upgrade your stay" })
|
||||
: selectedAncillary?.title
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onToggle={handleClose}
|
||||
title={selectedAncillary.title}
|
||||
>
|
||||
<div className={styles.modalWrapper}>
|
||||
<Modal isOpen={true} onToggle={closeModal} title={modalTitle}>
|
||||
<div
|
||||
className={`${styles.modalWrapper} ${currentStep === AncillaryStepEnum.selectAncillary ? styles.selectAncillarycontainer : ""}`}
|
||||
>
|
||||
<FormProvider {...formMethods}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
|
||||
<form
|
||||
onSubmit={formMethods.handleSubmit(onSubmit)}
|
||||
className={styles.form}
|
||||
id="add-ancillary-form-id"
|
||||
>
|
||||
<div className={styles.modalScrollable}>
|
||||
<div className={styles.imageContainer}>
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={selectedAncillary.imageUrl}
|
||||
alt={selectedAncillary.title}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.contentContainer}>
|
||||
<div className={styles.price}>
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
selectedAncillary.price.total,
|
||||
selectedAncillary.price.currency
|
||||
)}
|
||||
</Body>
|
||||
{selectedAncillary.points && (
|
||||
<>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{value} points" },
|
||||
{
|
||||
value: selectedAncillary.points,
|
||||
}
|
||||
{selectedAncillary && (
|
||||
<>
|
||||
<div className={styles.imageContainer}>
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={selectedAncillary.imageUrl}
|
||||
alt={selectedAncillary.title}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
{currentStep !== AncillaryStepEnum.confirmation && (
|
||||
<div className={styles.contentContainer}>
|
||||
<div className={styles.price}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{formatPrice(
|
||||
intl,
|
||||
selectedAncillary.price.total,
|
||||
selectedAncillary.price.currency
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
{selectedAncillary.points && (
|
||||
<div className={styles.divider}>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{ id: "{value} points" },
|
||||
{
|
||||
value: selectedAncillary.points,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{selectedAncillary.description && (
|
||||
<Body asChild color="uiTextHighContrast">
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: selectedAncillary.description,
|
||||
}}
|
||||
/>
|
||||
</Body>
|
||||
)}
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<>
|
||||
{step === 1 && (
|
||||
<>
|
||||
<SelectQuantityStep user={user} />
|
||||
{selectedAncillary.requiresDeliveryTime && (
|
||||
<DeliveryMethodStep
|
||||
deliveryTimeOptions={deliveryTimeOptions}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{step === 2 && <ConfirmationStep />}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{step === 1 && <SelectQuantityStep user={user} />}
|
||||
{step === 2 && selectedAncillary.requiresDeliveryTime && (
|
||||
<DeliveryMethodStep
|
||||
deliveryTimeOptions={deliveryTimeOptions}
|
||||
/>
|
||||
)}
|
||||
{(step === 3 ||
|
||||
(step === 2 &&
|
||||
!selectedAncillary.requiresDeliveryTime)) && (
|
||||
<ConfirmationStep />
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
{selectedAncillary.description && (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: selectedAncillary.description,
|
||||
}}
|
||||
></p>
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.actionButtons}>
|
||||
<Button
|
||||
type="button"
|
||||
theme="base"
|
||||
intent="text"
|
||||
size="small"
|
||||
onClick={handleBack}
|
||||
>
|
||||
{intl.formatMessage({ id: "Back" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
intent={step === confirmStep ? "primary" : "secondary"}
|
||||
size="small"
|
||||
disabled={formState.isSubmitting}
|
||||
onClick={
|
||||
step === confirmStep
|
||||
? () => handleSubmit(onSubmit)()
|
||||
: handleNextStep
|
||||
}
|
||||
>
|
||||
{step === confirmStep ? confirmLabel : continueLabel}
|
||||
</Button>
|
||||
<Steps user={user} savedCreditCards={savedCreditCards} />
|
||||
</div>
|
||||
</form>
|
||||
{currentStep === AncillaryStepEnum.selectAncillary ? null : (
|
||||
<div
|
||||
className={
|
||||
currentStep === AncillaryStepEnum.confirmation
|
||||
? styles.confirmStep
|
||||
: styles.actionButtons
|
||||
}
|
||||
>
|
||||
<PriceDetails isPriceDetailsOpen={isPriceDetailsOpen} />
|
||||
<ActionButtons
|
||||
isPriceDetailsOpen={isPriceDetailsOpen}
|
||||
togglePriceDetails={togglePriceDetails}
|
||||
isSubmitting={addAncillary.isPending || isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</FormProvider>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
export default function AncillaryFlowModalWrapper({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
const isOpen = useAddAncillaryStore((state) => state.isOpen)
|
||||
return isOpen ? <>{children}</> : null
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
.modalContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
margin-bottom: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x-one-and-half);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import {
|
||||
bookingTermsAndConditions,
|
||||
privacyPolicy,
|
||||
} from "@/constants/currentWebHrefs"
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import { CreditCard } from "@/components/Icons"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./confirmationStep.module.css"
|
||||
|
||||
export default function ConfirmationStep() {
|
||||
const { watch } = useFormContext()
|
||||
const { selectedAncillary } = useAddAncillaryStore()
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const quantityWithPoints = watch("quantityWithPoints")
|
||||
const quantityWithCard = watch("quantityWithCard")
|
||||
|
||||
if (!selectedAncillary) {
|
||||
return null
|
||||
}
|
||||
|
||||
const totalPrice = quantityWithCard
|
||||
? selectedAncillary.price.total * quantityWithCard
|
||||
: null
|
||||
|
||||
const totalPoints =
|
||||
quantityWithPoints && selectedAncillary.points
|
||||
? selectedAncillary.points * quantityWithPoints
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className={styles.modalContent}>
|
||||
<header>
|
||||
<Subtitle type="two">
|
||||
{intl.formatMessage({
|
||||
id: "Reserve with Card",
|
||||
})}
|
||||
</Subtitle>
|
||||
</header>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "Payment will be made on check-in. The card will be only used to guarantee the ancillary in case of no-show.",
|
||||
})}
|
||||
</Body>
|
||||
<div className={styles.card}>
|
||||
<CreditCard color="black" />
|
||||
<Body textTransform="bold">{"MasterCard"}</Body>
|
||||
<Body color="uiTextMediumContrast">{"**** 1234"}</Body>
|
||||
</div>
|
||||
<Checkbox
|
||||
name="termsAndConditions"
|
||||
registerOptions={{ required: true }}
|
||||
topAlign
|
||||
>
|
||||
<Caption>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "Yes, I accept the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. There you can learn more about what data we process, your rights and where to turn if you have questions.",
|
||||
},
|
||||
{
|
||||
termsAndConditionsLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
variant="underscored"
|
||||
href={bookingTermsAndConditions[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
privacyPolicyLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
variant="underscored"
|
||||
href={privacyPolicy[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
|
||||
<div className={styles.price}>
|
||||
<Caption>
|
||||
{intl.formatMessage(
|
||||
{ id: "<b>Total price</b> (incl VAT)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</Caption>
|
||||
{totalPrice !== null && (
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{formatPrice(intl, totalPrice, selectedAncillary.price.currency)}
|
||||
</Body>
|
||||
)}
|
||||
{totalPoints !== null && (
|
||||
<div>
|
||||
<div>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
</div>
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{value} points" },
|
||||
{ value: totalPoints }
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
.selectContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-quarter);
|
||||
margin-bottom: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
|
||||
.totalPointsContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: var(--Scandic-Peach-10);
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
|
||||
.totalPoints {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import { AncillaryCard } from "@/components/TempDesignSystem/AncillaryCard"
|
||||
|
||||
import type { SelectedAncillary } from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
interface WrappedAncillaryProps {
|
||||
ancillary: SelectedAncillary
|
||||
}
|
||||
|
||||
export default function WrappedAncillaryCard({
|
||||
ancillary,
|
||||
}: WrappedAncillaryProps) {
|
||||
const { description, ...ancillaryWithoutDescription } = ancillary
|
||||
const selectAncillary = useAddAncillaryStore((state) => state.selectAncillary)
|
||||
return (
|
||||
<div role="button" onClick={() => selectAncillary(ancillary)}>
|
||||
<AncillaryCard ancillary={ancillaryWithoutDescription} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const ancillaryFormSchema = z
|
||||
.object({
|
||||
quantityWithPoints: z.number().nullable(),
|
||||
quantityWithCard: z.number().nullable(),
|
||||
deliveryTime: z.string().nullable().optional(),
|
||||
optionalText: z.string().optional(),
|
||||
termsAndConditions: z
|
||||
.boolean()
|
||||
.refine((val) => val, "You must accept the terms"),
|
||||
})
|
||||
import { nullableStringValidator } from "@/utils/zod/stringValidator"
|
||||
|
||||
const quantitySchemaWithoutRefine = z.object({
|
||||
quantityWithPoints: z.number().nullable(),
|
||||
quantityWithCard: z.number().nullable(),
|
||||
})
|
||||
export const quantitySchema = z
|
||||
.object({})
|
||||
.merge(quantitySchemaWithoutRefine)
|
||||
.refine(
|
||||
(data) =>
|
||||
(data.quantityWithPoints ?? 0) > 0 || (data.quantityWithCard ?? 0) > 0,
|
||||
@@ -19,4 +18,21 @@ export const ancillaryFormSchema = z
|
||||
}
|
||||
)
|
||||
|
||||
export type AncillaryFormData = z.infer<typeof ancillaryFormSchema>
|
||||
export const ancillaryFormSchema = z
|
||||
.object({
|
||||
deliveryTime: z.string(),
|
||||
optionalText: z.string(),
|
||||
termsAndConditions: z.boolean(),
|
||||
paymentMethod: nullableStringValidator,
|
||||
})
|
||||
.merge(quantitySchemaWithoutRefine)
|
||||
.refine(
|
||||
(data) =>
|
||||
(data.quantityWithPoints ?? 0) > 0 || (data.quantityWithCard ?? 0) > 0,
|
||||
{
|
||||
message: "You must select at least one quantity",
|
||||
path: ["quantityWithCard"],
|
||||
}
|
||||
)
|
||||
|
||||
export type AncillaryFormData = z.output<typeof ancillaryFormSchema>
|
||||
|
||||
Reference in New Issue
Block a user