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>
|
||||
|
||||
@@ -40,13 +40,14 @@ export function AddedAncillaries({
|
||||
</div>
|
||||
|
||||
{booking.ancillaries.map((ancillary) => {
|
||||
const ancillaryItem = ancillaries?.find((a) => a.id === ancillary.code)
|
||||
const ancillaryTitle =
|
||||
ancillaries?.find((a) => a.id === ancillary.code)?.title ?? ""
|
||||
|
||||
return (
|
||||
<>
|
||||
<Accordion className={styles.ancillaryMobile}>
|
||||
<AccordionItem
|
||||
title={ancillaryItem?.title ?? ""}
|
||||
title={ancillaryTitle}
|
||||
icon={<CheckCircleIcon color="uiSemanticSuccess" />}
|
||||
>
|
||||
<div>
|
||||
@@ -93,8 +94,8 @@ export function AddedAncillaries({
|
||||
<RemoveButton
|
||||
confirmationNumber={booking.confirmationNumber}
|
||||
code={ancillary.code}
|
||||
title={ancillaryItem?.title}
|
||||
onSuccess={() => router.refresh()}
|
||||
title={ancillaryTitle}
|
||||
onSuccess={router.refresh}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -105,7 +106,7 @@ export function AddedAncillaries({
|
||||
<div className={styles.specification}>
|
||||
<div className={styles.name}>
|
||||
<CheckCircleIcon color="uiSemanticSuccess" />
|
||||
<Body textTransform="bold">{ancillaryItem?.title}</Body>
|
||||
<Body textTransform="bold">{ancillaryTitle}</Body>
|
||||
<Body textTransform="bold">{`X${ancillary.totalUnit}`}</Body>
|
||||
</div>
|
||||
<div className={styles.payment}>
|
||||
@@ -149,8 +150,8 @@ export function AddedAncillaries({
|
||||
<RemoveButton
|
||||
confirmationNumber={booking.confirmationNumber}
|
||||
code={ancillary.code}
|
||||
title={ancillaryItem?.title}
|
||||
onSuccess={() => router.refresh()}
|
||||
title={ancillaryTitle}
|
||||
onSuccess={router.refresh}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
.modalTrigger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
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: 28px;
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: var(--Base-Surface-Subtle-Normal);
|
||||
}
|
||||
|
||||
.chip.selected {
|
||||
background: var(--Base-Text-High-contrast);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.modalContent {
|
||||
width: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1052px) {
|
||||
.modalContent {
|
||||
width: 833px;
|
||||
}
|
||||
|
||||
.modalTrigger {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import Modal from "@/components/Modal"
|
||||
import { AncillaryCard } from "@/components/TempDesignSystem/AncillaryCard"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import styles from "./ancillaryGridModal.module.css"
|
||||
|
||||
import type {
|
||||
Ancillary,
|
||||
AncillaryGridModalProps,
|
||||
} from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
export default function AncillaryGridModal({
|
||||
ancillaries,
|
||||
selectedCategory,
|
||||
setSelectedCategory,
|
||||
handleCardClick,
|
||||
}: AncillaryGridModalProps) {
|
||||
const intl = useIntl()
|
||||
const { isGridOpen, setGridIsOpen, setOpenedFrom } = useAddAncillaryStore()
|
||||
|
||||
const handleClick = (ancillary: Ancillary["ancillaryContent"][number]) => {
|
||||
handleCardClick(ancillary)
|
||||
setOpenedFrom("grid")
|
||||
setGridIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.modalTrigger}>
|
||||
<Button
|
||||
theme="base"
|
||||
variant="icon"
|
||||
intent="text"
|
||||
size="small"
|
||||
onClick={() => setGridIsOpen(true)}
|
||||
>
|
||||
{intl.formatMessage({ id: "View all" })}
|
||||
<ChevronRightSmallIcon
|
||||
width={20}
|
||||
height={20}
|
||||
color="baseButtonTextOnFillNormal"
|
||||
/>
|
||||
</Button>
|
||||
<Modal
|
||||
isOpen={isGridOpen}
|
||||
onToggle={() => setGridIsOpen(!isGridOpen)}
|
||||
title={intl.formatMessage({ id: "Upgrade your stay" })}
|
||||
>
|
||||
<div className={styles.modalContent}>
|
||||
<div className={styles.tabs}>
|
||||
{ancillaries.map((category) => (
|
||||
<button
|
||||
key={category.categoryName}
|
||||
className={`${styles.chip} ${category.categoryName === selectedCategory ? styles.selected : ""}`}
|
||||
onClick={() => setSelectedCategory(category.categoryName)}
|
||||
>
|
||||
<Body
|
||||
color={
|
||||
category.categoryName === selectedCategory
|
||||
? "pale"
|
||||
: "baseTextHighContrast"
|
||||
}
|
||||
>
|
||||
{category.categoryName}
|
||||
</Body>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.grid}>
|
||||
{ancillaries
|
||||
.find((category) => category.categoryName === selectedCategory)
|
||||
?.ancillaryContent.map(({ description, ...ancillary }) => (
|
||||
<div
|
||||
key={ancillary.id}
|
||||
onClick={() => handleClick({ description, ...ancillary })}
|
||||
>
|
||||
<AncillaryCard key={ancillary.id} ancillary={ancillary} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
import { clearAncillarySessionData, getAncillarySessionData } from "../utils"
|
||||
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
export default function GuaranteeAncillaryHandler({
|
||||
confirmationNumber,
|
||||
returnUrl,
|
||||
lang,
|
||||
}: {
|
||||
confirmationNumber: string
|
||||
returnUrl: string
|
||||
lang: Lang
|
||||
}) {
|
||||
const router = useRouter()
|
||||
|
||||
const addAncillary = trpc.booking.packages.useMutation({
|
||||
onSuccess: () => {
|
||||
clearAncillarySessionData()
|
||||
router.replace(returnUrl)
|
||||
},
|
||||
onError: () => {
|
||||
router.replace(`${returnUrl}&errorCode=AncillaryFailed`)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (addAncillary.isPending || addAncillary.submittedAt) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionData = getAncillarySessionData()
|
||||
if (!sessionData?.formData || !sessionData?.selectedAncillary) {
|
||||
router.replace(`${returnUrl}&errorCode=AncillaryFailed`)
|
||||
return
|
||||
}
|
||||
|
||||
const { formData, selectedAncillary } = sessionData
|
||||
const packages = []
|
||||
|
||||
if (selectedAncillary?.id && formData.quantityWithCard) {
|
||||
packages.push({
|
||||
code: selectedAncillary.id,
|
||||
quantity: formData.quantityWithCard,
|
||||
comment: formData.optionalText || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (selectedAncillary?.loyaltyCode && formData.quantityWithPoints) {
|
||||
packages.push({
|
||||
code: selectedAncillary.loyaltyCode,
|
||||
quantity: formData.quantityWithPoints,
|
||||
comment: formData.optionalText || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
addAncillary.mutate({
|
||||
confirmationNumber,
|
||||
ancillaryComment: formData.optionalText,
|
||||
ancillaryDeliveryTime: selectedAncillary.requiresDeliveryTime
|
||||
? formData.deliveryTime
|
||||
: undefined,
|
||||
packages,
|
||||
language: lang,
|
||||
})
|
||||
}, [confirmationNumber, returnUrl, addAncillary, lang, router])
|
||||
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
export default function ViewAllAncillaries() {
|
||||
const intl = useIntl()
|
||||
const openModal = useAddAncillaryStore((state) => state.openModal)
|
||||
return (
|
||||
<Button
|
||||
theme="base"
|
||||
variant="icon"
|
||||
intent="text"
|
||||
size="small"
|
||||
onClick={openModal}
|
||||
>
|
||||
{intl.formatMessage({ id: "View all" })}
|
||||
<ChevronRightSmallIcon
|
||||
width={20}
|
||||
height={20}
|
||||
color="baseButtonTextOnFillNormal"
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client"
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import { Carousel } from "@/components/Carousel"
|
||||
import { AncillaryCard } from "@/components/TempDesignSystem/AncillaryCard"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { AddAncillaryProvider } from "@/providers/AddAncillaryProvider"
|
||||
|
||||
import AddAncillaryFlowModal from "./AddAncillaryFlow/AddAncillaryFlowModal"
|
||||
import AncillaryFlowModalWrapper from "./AddAncillaryFlow/AncillaryFlowModalWrapper"
|
||||
import WrappedAncillaryCard from "./AddAncillaryFlow/WrappedAncillaryCard"
|
||||
import { AddedAncillaries } from "./AddedAncillaries"
|
||||
import AncillaryGridModal from "./AncillaryGridModal"
|
||||
import ViewAllAncillaries from "./ViewAllAncillaries"
|
||||
|
||||
import styles from "./ancillaries.module.css"
|
||||
|
||||
@@ -20,110 +19,89 @@ import type {
|
||||
Ancillary,
|
||||
} from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
export function Ancillaries({ ancillaries, booking, user }: AncillariesProps) {
|
||||
export function Ancillaries({
|
||||
ancillaries,
|
||||
booking,
|
||||
user,
|
||||
savedCreditCards,
|
||||
refId,
|
||||
}: AncillariesProps) {
|
||||
const intl = useIntl()
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(
|
||||
() => {
|
||||
return ancillaries?.[0]?.categoryName ?? null
|
||||
}
|
||||
)
|
||||
|
||||
const { setSelectedAncillary, setConfirmationNumber, setOpenedFrom } =
|
||||
useAddAncillaryStore()
|
||||
const [isModalOpen, setModalOpen] = useState(false)
|
||||
|
||||
if (!ancillaries?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
function mergeAncillaries(
|
||||
function filterPoints(ancillaries: Ancillaries) {
|
||||
return ancillaries.map((ancillary) => {
|
||||
return {
|
||||
...ancillary,
|
||||
ancillaryContent: ancillary.ancillaryContent.map(
|
||||
({ points, ...ancillary }) => ({
|
||||
...ancillary,
|
||||
points: user ? points : undefined,
|
||||
})
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function generateUniqueAncillaries(
|
||||
ancillaries: Ancillaries
|
||||
): Ancillary["ancillaryContent"] {
|
||||
const uniqueAncillaries = new Map(
|
||||
ancillaries
|
||||
.flatMap((category) => category.ancillaryContent)
|
||||
.map((ancillary) => [ancillary.id, ancillary])
|
||||
ancillaries.flatMap((a) =>
|
||||
a.ancillaryContent.map((ancillary) => [ancillary.id, ancillary])
|
||||
)
|
||||
)
|
||||
return [...uniqueAncillaries.values()]
|
||||
}
|
||||
|
||||
const allAncillaries = mergeAncillaries(ancillaries)
|
||||
|
||||
const handleCardClick = (
|
||||
ancillary: Ancillary["ancillaryContent"][number]
|
||||
) => {
|
||||
if (booking?.confirmationNumber) {
|
||||
setConfirmationNumber(booking.confirmationNumber)
|
||||
}
|
||||
setSelectedAncillary(ancillary)
|
||||
setOpenedFrom("list")
|
||||
setModalOpen(true)
|
||||
}
|
||||
const allAncillaries = filterPoints(ancillaries)
|
||||
const uniqueAncillaries = generateUniqueAncillaries(allAncillaries)
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.title}>
|
||||
<Title as="h5">{intl.formatMessage({ id: "Upgrade your stay" })}</Title>
|
||||
<AncillaryGridModal
|
||||
ancillaries={ancillaries}
|
||||
selectedCategory={selectedCategory}
|
||||
setSelectedCategory={setSelectedCategory}
|
||||
handleCardClick={handleCardClick}
|
||||
/>
|
||||
<AddAncillaryProvider booking={booking} ancillaries={allAncillaries}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.title}>
|
||||
<Title as="h5">
|
||||
{intl.formatMessage({ id: "Upgrade your stay" })}
|
||||
</Title>
|
||||
<ViewAllAncillaries />
|
||||
</div>
|
||||
|
||||
<div className={styles.ancillaries}>
|
||||
{uniqueAncillaries.slice(0, 4).map((ancillary) => (
|
||||
<WrappedAncillaryCard ancillary={ancillary} key={ancillary.id} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.mobileAncillaries}>
|
||||
<Carousel>
|
||||
<Carousel.Content className={styles.carouselContainer}>
|
||||
{uniqueAncillaries.map((ancillary) => {
|
||||
return (
|
||||
<Carousel.Item key={ancillary.id}>
|
||||
<WrappedAncillaryCard ancillary={ancillary} />
|
||||
</Carousel.Item>
|
||||
)
|
||||
})}
|
||||
</Carousel.Content>
|
||||
<Carousel.Previous className={styles.navigationButton} />
|
||||
<Carousel.Next className={styles.navigationButton} />
|
||||
<Carousel.Dots />
|
||||
</Carousel>
|
||||
</div>
|
||||
|
||||
<AddedAncillaries booking={booking} ancillaries={uniqueAncillaries} />
|
||||
<AncillaryFlowModalWrapper>
|
||||
<AddAncillaryFlowModal
|
||||
user={user}
|
||||
booking={booking}
|
||||
refId={refId}
|
||||
savedCreditCards={savedCreditCards}
|
||||
/>
|
||||
</AncillaryFlowModalWrapper>
|
||||
</div>
|
||||
|
||||
<div className={styles.ancillaries}>
|
||||
{allAncillaries
|
||||
.slice(0, 4)
|
||||
.map(({ description, points, ...ancillary }) => {
|
||||
const ancillaryData = !!user ? { points, ...ancillary } : ancillary
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ancillary.id}
|
||||
onClick={() =>
|
||||
handleCardClick({ description, points, ...ancillary })
|
||||
}
|
||||
>
|
||||
<AncillaryCard key={ancillary.id} ancillary={ancillaryData} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.mobileAncillaries}>
|
||||
<Carousel>
|
||||
<Carousel.Content className={styles.carouselContainer}>
|
||||
{allAncillaries.map(({ description, points, ...ancillary }) => {
|
||||
const ancillaryData = !!user
|
||||
? { points, ...ancillary }
|
||||
: ancillary
|
||||
return (
|
||||
<Carousel.Item
|
||||
key={ancillary.id}
|
||||
onClick={() =>
|
||||
handleCardClick({ description, points, ...ancillary })
|
||||
}
|
||||
>
|
||||
<AncillaryCard key={ancillary.id} ancillary={ancillaryData} />
|
||||
</Carousel.Item>
|
||||
)
|
||||
})}
|
||||
</Carousel.Content>
|
||||
<Carousel.Previous className={styles.navigationButton} />
|
||||
<Carousel.Next className={styles.navigationButton} />
|
||||
<Carousel.Dots />
|
||||
</Carousel>
|
||||
</div>
|
||||
|
||||
<AddedAncillaries booking={booking} ancillaries={allAncillaries} />
|
||||
|
||||
<AddAncillaryFlowModal
|
||||
user={user}
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
booking={booking}
|
||||
/>
|
||||
</div>
|
||||
</AddAncillaryProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,53 @@
|
||||
import { dt } from "@/lib/dt"
|
||||
import type { Ancillary } from "@/types/components/myPages/myStay/ancillaries"
|
||||
import type { AncillaryFormData } from "./AddAncillaryFlow/schema"
|
||||
|
||||
export const generateDeliveryOptions = (checkInDate: Date) => {
|
||||
const start = dt(checkInDate).startOf("day")
|
||||
export const generateDeliveryOptions = () => {
|
||||
const timeSlots = ["16:00-17:00", "17:00-18:00", "18:00-19:00", "19:00-20:00"]
|
||||
|
||||
return timeSlots.map((slot) => ({
|
||||
label: `${start.format("YYYY-MM-DD")} ${slot}`,
|
||||
value: `${start.format("YYYY-MM-DD")} ${slot}`,
|
||||
label: slot,
|
||||
value: slot,
|
||||
}))
|
||||
}
|
||||
const ancillarySessionKey = "ancillarySessionData"
|
||||
export const getAncillarySessionData = ():
|
||||
| {
|
||||
formData?: AncillaryFormData
|
||||
selectedAncillary?: Ancillary["ancillaryContent"][number] | null
|
||||
}
|
||||
| undefined => {
|
||||
if (typeof window === "undefined") return undefined
|
||||
|
||||
try {
|
||||
const storedData = sessionStorage.getItem(ancillarySessionKey)
|
||||
return storedData ? JSON.parse(storedData) : undefined
|
||||
} catch (error) {
|
||||
console.error("Error reading from session storage:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function setAncillarySessionData({
|
||||
formData,
|
||||
selectedAncillary,
|
||||
}: {
|
||||
formData?: AncillaryFormData
|
||||
selectedAncillary?: Ancillary["ancillaryContent"][number] | null
|
||||
}) {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
try {
|
||||
const currentData = getAncillarySessionData() || {}
|
||||
sessionStorage.setItem(
|
||||
ancillarySessionKey,
|
||||
JSON.stringify({ ...currentData, formData, selectedAncillary })
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Error writing to session storage:", error)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAncillarySessionData() {
|
||||
if (typeof window === "undefined") return
|
||||
sessionStorage.removeItem(ancillarySessionKey)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
} from "@/constants/currentWebHrefs"
|
||||
import { guaranteeCallback } from "@/constants/routes/hotelReservation"
|
||||
import { env } from "@/env/client"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import { ModalContentWithActions } from "@/components/Modal/ModalContentWithActions"
|
||||
@@ -23,7 +21,7 @@ import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||
import { useGuaranteeBooking } from "@/hooks/booking/useGuaranteeBooking"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
@@ -36,9 +34,6 @@ import styles from "./guaranteeLateArrival.module.css"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
import type { CreditCard } from "@/types/user"
|
||||
|
||||
const maxRetries = 15
|
||||
const retryInterval = 2000
|
||||
|
||||
export interface GuaranteeLateArrivalProps {
|
||||
booking: BookingConfirmation["booking"]
|
||||
handleCloseModal: () => void
|
||||
@@ -57,6 +52,7 @@ export default function GuaranteeLateArrival({
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const router = useRouter()
|
||||
|
||||
const methods = useForm<GuaranteeFormData>({
|
||||
defaultValues: {
|
||||
paymentMethod: savedCreditCards?.length
|
||||
@@ -68,56 +64,14 @@ export default function GuaranteeLateArrival({
|
||||
reValidateMode: "onChange",
|
||||
resolver: zodResolver(paymentSchema),
|
||||
})
|
||||
const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
|
||||
useState(false)
|
||||
const guaranteeRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}${guaranteeCallback(lang)}`
|
||||
|
||||
const handlePaymentError = useCallback(() => {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "We had an issue guaranteeing your booking. Please try again.",
|
||||
})
|
||||
)
|
||||
}, [intl])
|
||||
|
||||
const guaranteeBooking = trpc.booking.guarantee.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result) {
|
||||
setIsPollingForBookingStatus(true)
|
||||
} else {
|
||||
handlePaymentError()
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "Something went wrong!",
|
||||
})
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const bookingStatus = useHandleBookingStatus({
|
||||
const { guaranteeBooking, isLoading } = useGuaranteeBooking({
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
expectedStatuses: [BookingStatusEnum.BookingCompleted],
|
||||
maxRetries,
|
||||
retryInterval,
|
||||
enabled: isPollingForBookingStatus,
|
||||
handleBookingCompleted: router.refresh,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingStatus?.data?.paymentUrl) {
|
||||
router.push(bookingStatus.data.paymentUrl)
|
||||
} else if (bookingStatus.isTimeout) {
|
||||
handlePaymentError()
|
||||
}
|
||||
}, [bookingStatus, router, intl, handlePaymentError])
|
||||
|
||||
if (
|
||||
guaranteeBooking.isPending ||
|
||||
(isPollingForBookingStatus &&
|
||||
!bookingStatus.data?.paymentUrl &&
|
||||
!bookingStatus.isTimeout)
|
||||
) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.loading}>
|
||||
<LoadingSpinner />
|
||||
@@ -129,7 +83,6 @@ export default function GuaranteeLateArrival({
|
||||
const savedCreditCard = savedCreditCards?.find(
|
||||
(card) => card.id === data.paymentMethod
|
||||
)
|
||||
const guaranteeRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}${guaranteeCallback(lang)}`
|
||||
if (booking.confirmationNumber) {
|
||||
const card = savedCreditCard
|
||||
? {
|
||||
@@ -142,14 +95,14 @@ export default function GuaranteeLateArrival({
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
language: lang,
|
||||
...(card !== undefined && { card }),
|
||||
success: `${guaranteeRedirectUrl}/success/${encodeURIComponent(refId)}`,
|
||||
error: `${guaranteeRedirectUrl}/error/${encodeURIComponent(refId)}`,
|
||||
cancel: `${guaranteeRedirectUrl}/cancel/${encodeURIComponent(refId)}`,
|
||||
success: `${guaranteeRedirectUrl}?status=success&RefId=${encodeURIComponent(refId)}`,
|
||||
error: `${guaranteeRedirectUrl}?status=error&RefId=${encodeURIComponent(refId)}`,
|
||||
cancel: `${guaranteeRedirectUrl}?status=cancel&RefId=${encodeURIComponent(refId)}`,
|
||||
})
|
||||
} else {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "Confirmation number is missing!",
|
||||
id: "Something went wrong!",
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { BookingStatusEnum } from "@/constants/booking"
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import { BookingCodeIcon } from "@/components/Icons"
|
||||
import { BookingCodeIcon, CheckCircleIcon } from "@/components/Icons"
|
||||
import CrossCircleIcon from "@/components/Icons/CrossCircle"
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
@@ -149,6 +151,21 @@ export function ReferenceCard({
|
||||
{`${toDate.format("dddd, D MMMM")} ${intl.formatMessage({ id: "until" })} ${toDate.format("HH:mm")}`}
|
||||
</Caption>
|
||||
</div>
|
||||
{booking.guaranteeInfo && (
|
||||
<div className={styles.guaranteed}>
|
||||
<CheckCircleIcon color="green" height={20} width={20} />
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p className={styles.guaranteedText}>
|
||||
<strong>
|
||||
{intl.formatMessage({ id: "Booking guaranteed." })}
|
||||
</strong>{" "}
|
||||
{intl.formatMessage({
|
||||
id: "Your stay remains available for check-in after 18:00.",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
<Divider color="primaryLightSubtle" className={styles.divider} />
|
||||
<div className={styles.referenceRow}>
|
||||
<Caption
|
||||
|
||||
@@ -40,6 +40,19 @@
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.guaranteed {
|
||||
align-items: flex-start;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: flex;
|
||||
background-color: var(--Surface-Feedback-Succes);
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x1);
|
||||
margin-bottom: var(--Space-x1);
|
||||
}
|
||||
.guaranteedText {
|
||||
color: var(--Surface-Feedback-Succes-Accent);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.titleMobile {
|
||||
display: none;
|
||||
|
||||
@@ -104,6 +104,8 @@ export async function MyStay({ refId }: { refId: string }) {
|
||||
ancillaries={ancillaryPackages}
|
||||
booking={booking}
|
||||
user={user}
|
||||
savedCreditCards={savedCreditCards}
|
||||
refId={refId}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user