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:
Bianca Widstam
2025-03-21 07:29:04 +00:00
parent 2bc14a6eeb
commit 3c1eee88b1
62 changed files with 1838 additions and 912 deletions

View File

@@ -2,6 +2,8 @@
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { CancellationRuleEnum } from "@/constants/booking"
import { dt } from "@/lib/dt"
@@ -57,14 +59,16 @@ export default function Room({ booking, img, roomName }: RoomProps) {
{booking.guaranteeInfo && (
<div className={styles.benefits}>
<CheckCircleIcon color="green" height={20} width={20} />
<Caption>
<strong>
{intl.formatMessage({ id: "Booking guaranteed." })}
</strong>{" "}
{intl.formatMessage({
id: "Your room will remain available for check-in even after 18:00.",
})}
</Caption>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
<strong>
{intl.formatMessage({ id: "Booking guaranteed." })}
</strong>{" "}
{intl.formatMessage({
id: "Your room will remain available for check-in even after 18:00.",
})}
</p>
</Typography>
</div>
)}
</header>

View File

@@ -58,9 +58,7 @@ export const formId = "submit-booking"
export default function PaymentClient({
otherPaymentOptions,
savedCreditCards,
mustBeGuaranteed,
memberMustBeGuaranteed,
isFlexRate,
isUserLoggedIn,
}: PaymentClientProps) {
const router = useRouter()
const lang = useLang()
@@ -77,13 +75,20 @@ export default function PaymentClient({
totalPrice: state.totalPrice,
}))
const bookingMustBeGuaranteed = rooms.some(
({ room }, idx) =>
const bookingMustBeGuaranteed = rooms.some(({ room }, idx) => {
if (idx === 0 && isUserLoggedIn && room.memberMustBeGuaranteed) {
return true
}
if (
(room.guest.join || room.guest.membershipNo) &&
booking.rooms[idx].counterRateCode
)
? memberMustBeGuaranteed
: mustBeGuaranteed
) {
return room.memberMustBeGuaranteed
}
return room.mustBeGuaranteed
})
const setIsSubmittingDisabled = useEnterDetailsStore(
(state) => state.actions.setIsSubmittingDisabled
@@ -102,6 +107,7 @@ export default function PaymentClient({
const hasPrepaidRates = rooms.some(hasPrepaidRate)
const hasFlexRates = rooms.some(hasFlexibleRate)
const hasOnlyFlexRates = rooms.every(hasFlexibleRate)
const hasMixedRates = hasPrepaidRates && hasFlexRates
const methods = useForm<PaymentFormData>({
@@ -221,21 +227,21 @@ export default function PaymentClient({
setIsSubmittingDisabled,
])
const getPaymentMethod = (
isFlexRate: boolean,
paymentMethod: string | null | undefined
): PaymentMethodEnum => {
if (isFlexRate) {
return PaymentMethodEnum.card
}
return paymentMethod && isPaymentMethodEnum(paymentMethod)
? paymentMethod
: PaymentMethodEnum.card
}
const getPaymentMethod = useCallback(
(paymentMethod: string | null | undefined): PaymentMethodEnum => {
if (hasFlexRates) {
return PaymentMethodEnum.card
}
return paymentMethod && isPaymentMethodEnum(paymentMethod)
? paymentMethod
: PaymentMethodEnum.card
},
[hasFlexRates]
)
const handleSubmit = useCallback(
(data: PaymentFormData) => {
const paymentMethod = getPaymentMethod(isFlexRate, data.paymentMethod)
const paymentMethod = getPaymentMethod(data.paymentMethod)
const savedCreditCard = savedCreditCards?.find(
(card) => card.id === data.paymentMethod
@@ -253,7 +259,8 @@ export default function PaymentClient({
}
: {}
const shouldUsePayment = !isFlexRate || guarantee
const shouldUsePayment =
guarantee || bookingMustBeGuaranteed || !hasOnlyFlexRates
const payment = shouldUsePayment
? {
@@ -264,7 +271,6 @@ export default function PaymentClient({
cancel: `${paymentRedirectUrl}/cancel`,
}
: undefined
trackPaymentEvent({
event: "paymentAttemptStart",
hotelId,
@@ -351,7 +357,9 @@ export default function PaymentClient({
toDate,
rooms,
booking,
isFlexRate,
getPaymentMethod,
hasOnlyFlexRates,
bookingMustBeGuaranteed,
]
)
@@ -380,9 +388,9 @@ export default function PaymentClient({
>
<header>
<Title level="h2" as="h4">
{bookingMustBeGuaranteed
{hasOnlyFlexRates && bookingMustBeGuaranteed
? paymentGuarantee
: isFlexRate
: hasOnlyFlexRates
? confirm
: payment}
</Title>
@@ -394,11 +402,11 @@ export default function PaymentClient({
onSubmit={methods.handleSubmit(handleSubmit)}
id={formId}
>
{isFlexRate && !bookingMustBeGuaranteed ? (
{hasOnlyFlexRates && !bookingMustBeGuaranteed ? (
<ConfirmBooking savedCreditCards={savedCreditCards} />
) : (
<>
{bookingMustBeGuaranteed ? (
{hasOnlyFlexRates && bookingMustBeGuaranteed ? (
<section className={styles.section}>
<Body>
{intl.formatMessage({
@@ -435,18 +443,19 @@ export default function PaymentClient({
value={PaymentMethodEnum.card}
label={intl.formatMessage({ id: "Credit card" })}
/>
{availablePaymentOptions.map((paymentMethod) => (
<PaymentOption
key={paymentMethod}
name="paymentMethod"
value={paymentMethod}
label={
PAYMENT_METHOD_TITLES[
paymentMethod as PaymentMethodEnum
]
}
/>
))}
{!hasMixedRates &&
availablePaymentOptions.map((paymentMethod) => (
<PaymentOption
key={paymentMethod}
name="paymentMethod"
value={paymentMethod}
label={
PAYMENT_METHOD_TITLES[
paymentMethod as PaymentMethodEnum
]
}
/>
))}
</div>
{hasMixedRates ? (
<MixedRatePaymentBreakdown

View File

@@ -1,27 +1,27 @@
import { getSavedPaymentCardsSafely } from "@/lib/trpc/memoizedRequests"
import { auth } from "@/auth"
import { isValidSession } from "@/utils/session"
import PaymentClient from "./PaymentClient"
import type { PaymentProps } from "@/types/components/hotelReservation/enterDetails/payment"
export default async function Payment({
otherPaymentOptions,
mustBeGuaranteed,
memberMustBeGuaranteed,
supportedCards,
isFlexRate,
}: PaymentProps) {
const savedCreditCards = await getSavedPaymentCardsSafely({
supportedCards,
})
const session = await auth()
const isUserLoggedIn = isValidSession(session)
return (
<PaymentClient
otherPaymentOptions={otherPaymentOptions}
savedCreditCards={savedCreditCards}
mustBeGuaranteed={mustBeGuaranteed}
memberMustBeGuaranteed={memberMustBeGuaranteed}
isFlexRate={isFlexRate}
isUserLoggedIn={isUserLoggedIn}
/>
)
}

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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}
/>
)}
</>
)
}

View File

@@ -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);
}

View File

@@ -0,0 +1,10 @@
.modalContent {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.termsAndConditions {
display: flex;
gap: var(--Space-x1);
}

View File

@@ -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>
)
}

View File

@@ -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}>

View File

@@ -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} />
}

View File

@@ -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} />
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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} />
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -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 />
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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)
}

View File

@@ -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!",
})
)
}

View File

@@ -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

View File

@@ -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;

View File

@@ -104,6 +104,8 @@ export async function MyStay({ refId }: { refId: string }) {
ancillaries={ancillaryPackages}
booking={booking}
user={user}
savedCreditCards={savedCreditCards}
refId={refId}
/>
)}
<div>