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

@@ -1,3 +0,0 @@
.layout {
background-color: var(--Base-Background-Primary-Normal);
}

View File

@@ -1,9 +0,0 @@
import styles from "./layout.module.css"
import type { LangParams, LayoutArgs } from "@/types/params"
export default function GuaranteePaymentCallbackLayout({
children,
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
return <div className={styles.layout}>{children}</div>
}

View File

@@ -1,4 +1,4 @@
import { redirect } from "next/navigation"
import { notFound, redirect } from "next/navigation"
import {
BookingErrorCodeEnum,
@@ -7,6 +7,7 @@ import {
import { myStay } from "@/constants/routes/myStay"
import { serverClient } from "@/lib/trpc/server"
import GuaranteeCallback from "@/components/HotelReservation/MyStay/Ancillaries/GuaranteeCallback"
import LoadingSpinner from "@/components/LoadingSpinner"
import type { LangParams, PageArgs } from "@/types/params"
@@ -18,28 +19,38 @@ export default async function GuaranteePaymentCallbackPage({
LangParams,
{
status: PaymentCallbackStatusEnum
refId: string
RefId: string
confirmationNumber?: string
ancillary?: string
}
>) {
console.log(`[gla-payment-callback] callback started`)
const lang = params.lang
const status = searchParams.status
const confirmationNumber = searchParams.confirmationNumber
const refId = searchParams.refId
const myStayUrl = `${myStay[lang]}?RefId=${refId}`
if (
status === PaymentCallbackStatusEnum.Success &&
confirmationNumber &&
refId
) {
console.log(`[gla-payment-callback] redirecting to: ${myStayUrl}`)
redirect(myStayUrl)
const refId = searchParams.RefId
if (!refId) {
notFound()
}
const isAncillaryFlow = searchParams.ancillary
const myStayUrl = `${myStay[lang]}?RefId=${encodeURIComponent(refId)}`
const searchObject = new URLSearchParams()
if (status === PaymentCallbackStatusEnum.Success && confirmationNumber) {
if (isAncillaryFlow) {
return (
<GuaranteeCallback
returnUrl={myStayUrl}
confirmationNumber={confirmationNumber}
lang={lang}
/>
)
}
console.log(`[gla-payment-callback] redirecting to: ${myStayUrl}`)
return redirect(myStayUrl)
}
let errorMessage = undefined
if (confirmationNumber) {
@@ -48,9 +59,7 @@ export default async function GuaranteePaymentCallbackPage({
confirmationNumber,
})
// TODO: how to handle errors for multiple rooms?
const error = bookingStatus.errors.find((e) => e.errorCode)
errorMessage =
error?.description ??
`No error message found for booking ${confirmationNumber}, status: ${status}`
@@ -67,17 +76,17 @@ export default async function GuaranteePaymentCallbackPage({
)
if (status === PaymentCallbackStatusEnum.Cancel) {
searchObject.set("errorCode", BookingErrorCodeEnum.TransactionCancelled)
}
if (status === PaymentCallbackStatusEnum.Error) {
searchObject.set(
"errorCode",
BookingErrorCodeEnum.TransactionFailed.toString()
)
} else if (status === PaymentCallbackStatusEnum.Error) {
searchObject.set("errorCode", BookingErrorCodeEnum.TransactionFailed)
errorMessage = `Failed to get booking status for ${confirmationNumber}, status: ${status}`
}
}
console.log(errorMessage)
redirect(`${myStayUrl}?${searchObject.toString()}`)
if (isAncillaryFlow) {
searchObject.set("ancillary", "ancillary")
}
redirect(`${myStayUrl}&${searchObject.toString()}`)
}
return <LoadingSpinner />

View File

@@ -62,14 +62,14 @@ export default async function DetailsPage({
const packages = room.packages
? await getPackages({
adults: room.adults,
children: room.childrenInRoom?.length,
endDate: booking.toDate,
hotelId: booking.hotelId,
packageCodes: room.packages,
startDate: booking.fromDate,
lang,
})
adults: room.adults,
children: room.childrenInRoom?.length,
endDate: booking.toDate,
hotelId: booking.hotelId,
packageCodes: room.packages,
startDate: booking.fromDate,
lang,
})
: null
const roomAvailability = await getSelectedRoomAvailability({
@@ -113,10 +113,6 @@ export default async function DetailsPage({
})
}
const isCardOnlyPayment = rooms.some((room) => room?.mustBeGuaranteed)
const memberMustBeGuaranteed = rooms.some(
(room) => room?.memberMustBeGuaranteed
)
const isFlexRate = rooms.some((room) => room.isFlexRate)
const hotelData = await getHotel({
hotelId: booking.hotelId,
@@ -191,9 +187,6 @@ export default async function DetailsPage({
hotel.merchantInformationData.alternatePaymentOptions
}
supportedCards={hotel.merchantInformationData.cards}
mustBeGuaranteed={isCardOnlyPayment}
memberMustBeGuaranteed={memberMustBeGuaranteed}
isFlexRate={isFlexRate}
/>
</Suspense>
</div>

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>

View File

@@ -13,7 +13,6 @@ export default function Select({
name,
isNestedInModal = false,
registerOptions = {},
defaultSelectedKey,
}: SelectProps) {
const { control } = useFormContext()
const { field } = useController({
@@ -25,7 +24,7 @@ export default function Select({
return (
<ReactAriaSelect
className={className}
defaultSelectedKey={defaultSelectedKey || field.value}
defaultSelectedKey={field.value}
disabled={disabled || field.disabled}
items={items}
label={label}

View File

@@ -0,0 +1,5 @@
import { createContext } from "react"
import type { AddAncillaryStore } from "@/types/contexts/add-ancillary"
export const AddAncillaryContext = createContext<AddAncillaryStore | null>(null)

View File

@@ -0,0 +1,78 @@
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
import { useIntl } from "react-intl"
import { BookingStatusEnum } from "@/constants/booking"
import { trpc } from "@/lib/trpc/client"
import { toast } from "@/components/TempDesignSystem/Toasts"
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
const maxRetries = 15
const retryInterval = 2000
export function useGuaranteeBooking({
confirmationNumber,
handleBookingCompleted = () => {},
}: {
confirmationNumber: string
handleBookingCompleted?: () => void
}) {
const intl = useIntl()
const router = useRouter()
const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
useState(false)
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) {
if (result.reservationStatus == BookingStatusEnum.BookingCompleted) {
handleBookingCompleted()
} else {
setIsPollingForBookingStatus(true)
}
} else {
handlePaymentError()
}
},
onError: () => {
toast.error(
intl.formatMessage({
id: "Something went wrong!",
})
)
},
})
const bookingStatus = useHandleBookingStatus({
confirmationNumber,
expectedStatuses: [BookingStatusEnum.BookingCompleted],
maxRetries,
retryInterval,
enabled: isPollingForBookingStatus,
})
useEffect(() => {
if (bookingStatus?.data?.paymentUrl) {
router.push(bookingStatus.data.paymentUrl)
} else if (bookingStatus.isTimeout) {
handlePaymentError()
}
}, [bookingStatus, router, intl, handlePaymentError])
const isLoading =
guaranteeBooking.isPending ||
(isPollingForBookingStatus &&
!bookingStatus.data?.paymentUrl &&
!bookingStatus.isTimeout)
return { guaranteeBooking, isLoading }
}

View File

@@ -21,6 +21,10 @@ export function useGuaranteePaymentFailedToast() {
return intl.formatMessage({
id: "You have cancelled to process to guarantee your booking.",
})
case "AncillaryFailed":
return intl.formatMessage({
id: "The product could not be added. Your booking is guaranteed. Please try again.",
})
default:
return intl.formatMessage({
id: "We had an issue guaranteeing your booking. Please try again.",
@@ -30,10 +34,9 @@ export function useGuaranteePaymentFailedToast() {
[intl]
)
const errorCode = searchParams.get("errorCode")
const errorMessage = getErrorMessage(errorCode)
useEffect(() => {
const errorCode = searchParams.get("errorCode")
const errorMessage = getErrorMessage(errorCode)
if (!errorCode) return
// setTimeout is needed to show toasts on page load: https://sonner.emilkowal.ski/toast#render-toast-on-page-load
@@ -45,10 +48,14 @@ export function useGuaranteePaymentFailedToast() {
toast[toastType](errorMessage)
})
const ancillary = searchParams.get("ancillary")
if ((errorCode && ancillary) || errorCode === "AncillaryFailed") {
return
}
const queryParams = new URLSearchParams(searchParams.toString())
queryParams.delete("errorCode")
router.push(`${pathname}?${queryParams.toString()}`)
}, [searchParams, pathname, errorCode, errorMessage, router])
}, [searchParams, pathname, router, getErrorMessage])
}

View File

@@ -39,6 +39,7 @@
"Age": "Alder",
"Airport": "Lufthavn",
"All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.": "Alle tillæg leveres på samme tid. Ændringer i leveringstider vil påvirke tidligere tillæg.",
"All ancillaries are fully refundable until {date}. Time selection and special requests are also modifiable.": "Alle tilknyttede tjenester kan refunderes fuldt ud indtil {date}. Valg af tidspunkt og særlige anmodninger kan også ændres.",
"All categories": "Alle kategorier",
"All countries": "Alle lande",
"All hotels and offices": "Alle hoteller og kontorer",
@@ -131,6 +132,7 @@
"Bus terminal": "Busstation",
"Business": "Forretning",
"By accepting the <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.": "Ved at acceptere <termsAndConditionsLink>vilkårene og betingelserne for Scandic Friends</termsAndConditionsLink>, forstår jeg, at mine personlige oplysninger vil blive behandlet i overensstemmelse med <privacyPolicy>Scandics privatlivspolitik</privacyPolicy>.",
"By adding a card you also guarantee your room booking for late arrival.": "Ved at tilføje et kort garanterer du også din værelsesreservation for sen ankomst.",
"By guaranteeing with any of the payment methods available, I accept the terms for this stay and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand Scandic will process my personal data for this stay in accordance with <privacyPolicyLink>Scandics Privacy Policy</privacyPolicyLink>. I accept Scandic requiring a valid credit card during my visit in case anything is left unpaid.": "Ved at garantere med en af de tilgængelige betalingsmetoder, accepterer jeg vilkårene for dette ophold og de generelle <termsAndConditionsLink>Vilkår og betingelser</termsAndConditionsLink>, og forstår, at Scandic vil behandle min personlige data i forbindelse med dette ophold i henhold til <privacyPolicyLink>Scandics Privatlivspolitik</privacyPolicyLink>. Jeg accepterer, at Scandic kræver et gyldigt kreditkort under mit besøg i tilfælde af, at noget er tilbagebetalt.",
"By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.": "By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.",
"By paying with any of the payment methods available, I accept the terms for this booking and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.": "Ved at betale med en af de tilgængelige betalingsmetoder, accepterer jeg vilkårene for denne booking og de generelle <termsAndConditionsLink>Vilkår og betingelser</termsAndConditionsLink>, og forstår, at Scandic vil behandle min personlige data i forbindelse med denne booking i henhold til <privacyPolicyLink>Scandics Privatlivspolitik</privacyPolicyLink>. Jeg accepterer, at Scandic kræver et gyldigt kreditkort under min besøg i tilfælde af, at noget er tilbagebetalt.",
@@ -529,6 +531,7 @@
"Open {amount, plural, one {gift} other {gifts}}": "Åbne {amount, plural, one {gave} other {gaver}}",
"Opening hours": "Åbningstider",
"Optional": "Valgfri",
"Other": "Øvrigt",
"Other Requests": "Other Requests",
"Other requests": "Andre ønsker",
"Outdoor": "Udendørs",
@@ -730,6 +733,7 @@
"The new price is": "Nyprisen er",
"The price has increased": "Prisen er steget",
"The price has increased since you selected your room.": "Prisen er steget, efter at du har valgt dit værelse.",
"The product could not be added. Your booking is guaranteed. Please try again.": "Produktet kunne ikke tilføjes. Din reservation er garanteret. Prøv venligst igen.",
"Theatre": "Teater",
"There are no rooms available that match your request": "Der er ingen ledige værelser, der matcher din anmodning",
"There are no rooms available that match your request.": "Der er ingen værelser tilgængelige, der matcher din forespørgsel.",
@@ -868,6 +872,7 @@
"Your room": "Dit værelse",
"Your room will remain available for check-in even after 18:00.": "Dit værelse vil forblive tilgængeligt til check-in selv efter kl. 18.00.",
"Your selected bed type will be provided based on availability": "Din valgte sengtype vil blive stillet til rådighed baseret på tilgængelighed",
"Your stay remains available for check-in after 18:00.": "Dit ophold forbliver tilgængeligt for check-in efter kl. 18.00.",
"Your stay was cancelled. Cancellation cost: 0 {currency}. We're sorry to see that the plans didn't work out": "Dit ophold blev annulleret. Annullereringspris: 0 {currency}. Vi beklager, at planerne ikke fungerede ud",
"Your stay was cancelled. Cancellation cost: 0 {currency}. Were sorry to see that the plans didnt work out": "Dit ophold blev annulleret. Annullereringspris: 0 {currency}. Vi beklager, at planerne ikke fungerede ud",
"Your stay was updated": "Dit ophold blev opdateret",

View File

@@ -39,6 +39,7 @@
"Age": "Alter",
"Airport": "Flughafen",
"All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.": "Alle Add-ons werden gleichzeitig geliefert. Änderungen der Lieferzeiten wirken sich auf frühere Add-ons aus.",
"All ancillaries are fully refundable until {date}. Time selection and special requests are also modifiable.": "Alle Zusatzleistungen sind bis {date} vollständig erstattungsfähig. Zeitauswahl und Sonderwünsche sind ebenfalls änderbar.",
"All categories": "Alle Kategorien",
"All countries": "Alle Länder",
"All hotels and offices": "Alle Hotels und Büros",
@@ -132,6 +133,7 @@
"Bus terminal": "Bus terminal",
"Business": "Geschäft",
"By accepting the <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.": "Mit der Annahme der <termsAndConditionsLink>Allgemeinen Geschäftsbedingungen für Scandic Friends</termsAndConditionsLink> erkläre ich mich damit einverstanden, dass meine persönlichen Daten in Übereinstimmung mit der <privacyPolicy>Datenschutzrichtlinie von Scandic verarbeitet werden</privacyPolicy>.",
"By adding a card you also guarantee your room booking for late arrival.": "Durch das Hinzufügen einer Karte garantieren Sie Ihre Zimmerbuchung auch bei später Ankunft.",
"By guaranteeing with any of the payment methods available, I accept the terms for this stay and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand Scandic will process my personal data for this stay in accordance with <privacyPolicyLink>Scandics Privacy Policy</privacyPolicyLink>. I accept Scandic requiring a valid credit card during my visit in case anything is left unpaid.": "Mit der Garantie durch eine der verfügbaren Zahlungsmethoden akzeptiere ich die Bedingungen für diesen Aufenthalt und die allgemeinen <termsAndConditionsLink>Geschäftsbedingungen</termsAndConditionsLink> und verstehe, dass Scandic meine personenbezogenen Daten für diesen Aufenthalt gemäß der <privacyPolicyLink>Scandic-Datenschutzrichtlinie</privacyPolicyLink> verarbeitet. Ich akzeptiere, dass Scandic während meines Besuchs eine gültige Kreditkarte benötigt, falls etwas unbezahlt bleibt.",
"By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.": "By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.",
"By paying with any of the payment methods available, I accept the terms for this booking and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.": "Mit der Zahlung über eine der verfügbaren Zahlungsmethoden akzeptiere ich die Buchungsbedingungen und die allgemeinen <termsAndConditionsLink>Geschäftsbedingungen</termsAndConditionsLink> und verstehe, dass Scandic meine personenbezogenen Daten im Zusammenhang mit dieser Buchung gemäß der <privacyPolicyLink>Scandic Datenschutzrichtlinie</privacyPolicyLink> verarbeitet. Ich akzeptiere, dass Scandic während meines Aufenthalts eine gültige Kreditkarte für eventuelle Rückerstattungen benötigt.",
@@ -530,6 +532,7 @@
"Open {amount, plural, one {gift} other {gifts}}": "{amount, plural, one {Geschenk} other {Geschenke}} öffnen",
"Opening hours": "Öffnungszeiten",
"Optional": "Optional",
"Other": "Sonstiges",
"Other Requests": "Andere Anfragen",
"Outdoor": "Im Freien",
"Outdoor pool": "Außenpool",
@@ -729,6 +732,7 @@
"The new price is": "Der neue Preis beträgt",
"The price has increased": "Der Preis ist gestiegen",
"The price has increased since you selected your room.": "Der Preis ist gestiegen, nachdem Sie Ihr Zimmer ausgewählt haben.",
"The product could not be added. Your booking is guaranteed. Please try again.": "Das Produkt konnte nicht hinzugefügt werden. Ihre Buchung ist garantiert. Bitte versuchen Sie es erneut.",
"Theatre": "Theater",
"There are no rooms available that match your request.": "Es sind keine Zimmer verfügbar, die Ihrer Anfrage entsprechen.",
"There are no transactions to display": "Es sind keine Transaktionen zum Anzeigen vorhanden",
@@ -866,6 +870,7 @@
"Your room": "Ihr Zimmer",
"Your room will remain available for check-in even after 18:00.": "Ihr Zimmer bleibt auch nach 18:00 Uhr zum Check-in verfügbar.",
"Your selected bed type will be provided based on availability": "Ihre ausgewählte Bettart wird basierend auf der Verfügbarkeit bereitgestellt",
"Your stay remains available for check-in after 18:00.": "Ihr Aufenthalt bleibt für den Check-in nach 18:00 Uhr verfügbar.",
"Your stay was cancelled. Cancellation cost: 0 {currency}. We're sorry to see that the plans didn't work out": "Ihr Aufenthalt wurde storniert. Stornierungskosten: 0 {currency}. Es tut uns leid, dass die Pläne nicht funktionierten",
"Your stay was cancelled. Cancellation cost: 0 {currency}. Were sorry to see that the plans didnt work out": "Ihr Aufenthalt wurde storniert. Stornierungskosten: 0 {currency}. Es tut uns leid, dass die Pläne nicht funktionierten",
"Your stay was updated": "Ihr Aufenthalt wurde aktualisiert",

View File

@@ -39,6 +39,7 @@
"Age": "Age",
"Airport": "Airport",
"All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.": "All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.",
"All ancillaries are fully refundable until {date}. Time selection and special requests are also modifiable.": "All ancillaries are fully refundable until {date}. Time selection and special requests are also modifiable.",
"All categories": "All categories",
"All countries": "All countries",
"All hotels and offices": "All hotels and offices",
@@ -130,6 +131,7 @@
"Bus terminal": "Bus terminal",
"Business": "Business",
"By accepting the <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.": "By accepting the <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.",
"By adding a card you also guarantee your room booking for late arrival.": "By adding a card you also guarantee your room booking for late arrival.",
"By guaranteeing with any of the payment methods available, I accept the terms for this stay and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand Scandic will process my personal data for this stay in accordance with <privacyPolicyLink>Scandics Privacy Policy</privacyPolicyLink>. I accept Scandic requiring a valid credit card during my visit in case anything is left unpaid.": "By guaranteeing with any of the payment methods available, I accept the terms for this stay and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand Scandic will process my personal data for this stay in accordance with <privacyPolicyLink>Scandics Privacy Policy</privacyPolicyLink>. I accept Scandic requiring a valid credit card during my visit in case anything is left unpaid.",
"By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.": "By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.",
"By paying with any of the payment methods available, I accept the terms for this booking and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.": "By paying with any of the payment methods available, I accept the terms for this booking and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.",
@@ -529,6 +531,7 @@
"Open {amount, plural, one {gift} other {gifts}}": "Open {amount, plural, one {gift} other {gifts}}",
"Opening hours": "Opening hours",
"Optional": "Optional",
"Other": "Other",
"Other Requests": "Other Requests",
"Outdoor": "Outdoor",
"Outdoor pool": "Outdoor pool",
@@ -728,6 +731,7 @@
"The new price is": "The new price is",
"The price has increased": "The price has increased",
"The price has increased since you selected your room.": "The price has increased since you selected your room.",
"The product could not be added. Your booking is guaranteed. Please try again.": "The product could not be added. Your booking is guaranteed. Please try again.",
"Theatre": "Theatre",
"There are no rooms available that match your request.": "There are no rooms available that match your request.",
"There are no transactions to display": "There are no transactions to display",
@@ -864,6 +868,7 @@
"Your room": "Your room",
"Your room will remain available for check-in even after 18:00.": "Your room will remain available for check-in even after 18:00.",
"Your selected bed type will be provided based on availability": "Your selected bed type will be provided based on availability",
"Your stay remains available for check-in after 18:00.": "Your stay remains available for check-in after 18:00.",
"Your stay was cancelled. Cancellation cost: 0 {currency}. We're sorry to see that the plans didn't work out": "Your stay was cancelled. Cancellation cost: 0 {currency}. We're sorry to see that the plans didn't work out",
"Your stay was cancelled. Cancellation cost: 0 {currency}. Were sorry to see that the plans didnt work out": "Your stay was cancelled. Cancellation cost: 0 {currency}. Were sorry to see that the plans didnt work out",
"Your stay was updated": "Your stay was updated",

View File

@@ -39,6 +39,7 @@
"Age": "Ikä",
"Airport": "Lentokenttä",
"All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.": "Kaikki lisäosat toimitetaan samanaikaisesti. Toimitusaikojen muutokset vaikuttavat aiempiin lisäosiin.",
"All ancillaries are fully refundable until {date}. Time selection and special requests are also modifiable.": "Kaikki liitännäiskulut ovat täysin hyvitettävissä {date} asti. Aikavalinta ja erikoispyynnöt ovat myös muokattavissa.",
"All categories": "Kaikki luokat",
"All countries": "Kaikki maat",
"All hotels and offices": "Kaikki hotellit ja toimistot",
@@ -130,6 +131,7 @@
"Bus terminal": "Bussiasema",
"Business": "Business",
"By accepting the <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.": "Kyllä, <termsAndConditionsLink>hyväksyn Scandic Friends -jäsenyyttä</termsAndConditionsLink> koskevat ehdot ja ymmärrän, että Scandic käsittelee henkilötietojani <privacyPolicy>Scandicin Tietosuojaselosteen mukaisesti</privacyPolicy>.",
"By adding a card you also guarantee your room booking for late arrival.": "Lisäämällä kortin takaat myös huonevarauksesi myöhäisestä saapumisesta.",
"By guaranteeing with any of the payment methods available, I accept the terms for this stay and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand Scandic will process my personal data for this stay in accordance with <privacyPolicyLink>Scandics Privacy Policy</privacyPolicyLink>. I accept Scandic requiring a valid credit card during my visit in case anything is left unpaid.": "Maksamalla minkä tahansa saatavilla olevan maksutavan avulla hyväksyn tämän majoituksen ehdot ja yleiset <termsAndConditionsLink>ehdot ja ehtoja</termsAndConditionsLink>, ja ymmärrän, että Scandic käsittelee minun henkilötietoni tässä majoituksessa mukaisesti <privacyPolicyLink>Scandicin tietosuojavaltuuden</privacyPolicyLink> mukaisesti. Hyväksyn myös, että Scandic vaatii validin luottokortin majoituksen ajan, jos jokin jää maksamatta.",
"By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.": "By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.",
"By paying with any of the payment methods available, I accept the terms for this booking and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.": "Maksamalla minkä tahansa saatavilla olevan maksutavan avulla hyväksyn tämän varauksen ehdot ja yleiset <termsAndConditionsLink>ehdot ja ehtoja</termsAndConditionsLink>, ja ymmärrän, että Scandic käsittelee minun henkilötietoni tässä varauksessa mukaisesti <privacyPolicyLink>Scandicin tietosuojavaltuuden</privacyPolicyLink> mukaisesti. Hyväksyn myös, että Scandic vaatii validin luottokortin majoituksen ajan, jos jokin jää maksamatta.",
@@ -529,6 +531,7 @@
"Open {amount, plural, one {gift} other {gifts}}": "{amount, plural, one {Avoin lahja} other {Avoimet lahjat}}",
"Opening hours": "Aukioloajat",
"Optional": "Valinnainen",
"Other": "Muut",
"Other Requests": "Muut pyynnöt",
"Outdoor": "Ulkona",
"Outdoor pool": "Ulkouima-allas",
@@ -729,6 +732,7 @@
"The new price is": "Uusi hinta on",
"The price has increased": "Hinta on noussut",
"The price has increased since you selected your room.": "Hinta on noussut, koska valitsit huoneen.",
"The product could not be added. Your booking is guaranteed. Please try again.": "Tuotetta ei voitu lisätä. Varauksesi on taattu. Yritä uudelleen.",
"Theatre": "Teatteri",
"There are no rooms available that match your request.": "Ei huoneita saatavilla, jotka vastaavat pyyntöäsi.",
"There are no transactions to display": "Näytettäviä tapahtumia ei ole",
@@ -866,6 +870,7 @@
"Your room": "Sinun huoneesi",
"Your room will remain available for check-in even after 18:00.": "Huoneesi on käytettävissä sisäänkirjautumista varten myös klo 18.00 jälkeen.",
"Your selected bed type will be provided based on availability": "Valitun vuodetyypin toimitetaan saatavuuden mukaan",
"Your stay remains available for check-in after 18:00.": "Majoituksesi on edelleen saatavilla sisäänkirjautumiseen klo 18:00 jälkeen.",
"Your stay was cancelled. Cancellation cost: 0 {currency}. We're sorry to see that the plans didn't work out": "Majoituksesi peruutettiin. Peruutusmaksu: 0 {currency}. Emme voi käyttää sitä, että suunnitellut majoitukset eivät toiminneet",
"Your stay was cancelled. Cancellation cost: 0 {currency}. Were sorry to see that the plans didnt work out": "Majoituksesi peruutettiin. Peruutusmaksu: 0 {currency}. Emme voi käyttää sitä, että suunnitellut majoitukset eivät toiminneet",
"Your stay was updated": "Majoituspäivät päivitettiin",

View File

@@ -39,6 +39,7 @@
"Age": "Alder",
"Airport": "Flyplass",
"All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.": "Alle tilvalg leveres samtidig. Endringer i leveringstidspunktene vil påvirke tidligere tilvalg.",
"All ancillaries are fully refundable until {date}. Time selection and special requests are also modifiable.": "Alle hjelpemidler er fullt refunderbare til {date}. Tidsvalg og spesielle forespørsler kan også endres.",
"All categories": "Alle kategorier",
"All countries": "Alle land",
"All hotels and offices": "Alle hoteller og kontorer",
@@ -130,6 +131,7 @@
"Bus terminal": "Bussterminal",
"Business": "Forretnings",
"By accepting the <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.": "Ved å akseptere <termsAndConditionsLink>vilkårene og betingelsene for Scandic Friends</termsAndConditionsLink>, er jeg inneforstått med at mine personopplysninger vil bli behandlet i samsvar med <privacyPolicy>Scandics personvernpolicy</privacyPolicy>.",
"By adding a card you also guarantee your room booking for late arrival.": "Ved å legge til et kort garanterer du også rombestillingen din for sen ankomst.",
"By guaranteeing with any of the payment methods available, I accept the terms for this stay and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand Scandic will process my personal data for this stay in accordance with <privacyPolicyLink>Scandics Privacy Policy</privacyPolicyLink>. I accept Scandic requiring a valid credit card during my visit in case anything is left unpaid.": "Ved å garantere med en av de tilgjengelige betalingsmetodene, aksepterer jeg vilkårene for dette oppholdet og de generelle <termsAndConditionsLink>vilkårene og betingelsene</termsAndConditionsLink>, og forstår at Scandic vil behandle mine personopplysninger for dette oppholdet i samsvar med <privacyPolicyLink>Scandics personvernpolicy</privacyPolicyLink>. Jeg aksepterer at Scandic krever et gyldig kredittkort under besøket mitt i tilfelle noe står ubetalt.",
"By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.": "By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.",
"By paying with any of the payment methods available, I accept the terms for this booking and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.": "Ved å betale med en av de tilgjengelige betalingsmetodene godtar jeg vilkårene og betingelsene for denne bestillingen og de generelle <termsAndConditionsLink>vilkårene</termsAndConditionsLink>, og forstår at Scandic vil behandle mine personopplysninger i forbindelse med denne bestillingen i henhold til <privacyPolicyLink> Scandics personvernpolicy</privacyPolicyLink>. Jeg aksepterer at Scandic krever et gyldig kredittkort under mitt besøk i tilfelle noe blir refundert.",
@@ -528,6 +530,7 @@
"Open {amount, plural, one {gift} other {gifts}}": "{amount, plural, one {Åpen gave} other {Åpnen gaver}}",
"Opening hours": "Åpningstider",
"Optional": "Valgfritt",
"Other": "Øvrig",
"Other Requests": "Andre ønsker",
"Outdoor": "Utendørs",
"Outdoor pool": "Utendørs basseng",
@@ -726,6 +729,7 @@
"The new price is": "Den nye prisen er",
"The price has increased": "Prisen er steget",
"The price has increased since you selected your room.": "Prisen er steget, etter at du har valgt rommet.",
"The product could not be added. Your booking is guaranteed. Please try again.": "Produktet kunne ikke legges til. Bestillingen din er garantert. Vennligst prøv igjen.",
"Theatre": "Teater",
"There are no rooms available that match your request.": "Det er ingen rom tilgjengelige som matcher din forespørsel.",
"There are no transactions to display": "Det er ingen transaksjoner å vise",
@@ -862,6 +866,7 @@
"Your room": "Rommet ditt",
"Your room will remain available for check-in even after 18:00.": "Rommet ditt vil være tilgjengelig for innsjekking selv etter kl. 18.00.",
"Your selected bed type will be provided based on availability": "Din valgte sengtype vil blive stillet til rådighed baseret på tilgængelighed",
"Your stay remains available for check-in after 18:00.": "Ditt opphold forblir tilgjengelig for innsjekking etter kl. 18.00.",
"Your stay was cancelled. Cancellation cost: 0 {currency}. We're sorry to see that the plans didn't work out": "Ditt ophold ble annulleret. Annullereringspris: 0 {currency}. Vi beklager at planene ikke fungerte ut",
"Your stay was cancelled. Cancellation cost: 0 {currency}. Were sorry to see that the plans didnt work out": "Dit ophold blev annulleret. Annullereringspris: 0 {currency}. Vi beklager, at planerne ikke fungerede ud",
"Your stay was updated": "Ditt ophold ble oppdatert",

View File

@@ -39,6 +39,7 @@
"Age": "Ålder",
"Airport": "Flygplats",
"All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.": "Alla tillägg levereras samtidigt. Ändringar av leveranstider kommer att påverka tidigare tillägg.",
"All ancillaries are fully refundable until {date}. Time selection and special requests are also modifiable.": "Alla tillägg är helt återbetalningsbara fram till {date}. Val av tid och särskilda önskemål kan också ändras.",
"All categories": "Alla kategorier",
"All countries": "Alla länder",
"All hotels and offices": "Alla hotell och kontor",
@@ -130,6 +131,7 @@
"Bus terminal": "Bussterminal",
"Business": "Business",
"By accepting the <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.": "Genom att acceptera <termsAndConditionsLink>villkoren för Scandic Friends</termsAndConditionsLink> förstår jag att mina personuppgifter kommer att behandlas i enlighet med <privacyPolicy>Scandics Integritetspolicy</privacyPolicy>.",
"By adding a card you also guarantee your room booking for late arrival.": "Genom att lägga till ett kort garanterar du även din rumsbokning för sen ankomst.",
"By guaranteeing with any of the payment methods available, I accept the terms for this stay and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand Scandic will process my personal data for this stay in accordance with <privacyPolicyLink>Scandics Privacy Policy</privacyPolicyLink>. I accept Scandic requiring a valid credit card during my visit in case anything is left unpaid.": "Genom att garantera med någon av de tillgängliga betalningsmetoderna accepterar jag villkoren för denna vistelse och de allmänna <termsAndConditionsLink>allmänna villkoren</termsAndConditionsLink>, och förstår att Scandic kommer att behandla mina personuppgifter för denna vistelse i enlighet med <privacyPolicyLink>Scandics Integritetspolicy</privacyPolicyLink>. Jag accepterar att Scandic kräver ett giltigt kreditkort under mitt besök om något lämnas obetalt.",
"By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.": "By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.",
"By paying with any of the payment methods available, I accept the terms for this booking and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.": "Genom att betala med någon av de tillgängliga betalningsmetoderna accepterar jag villkoren för denna bokning och de generella <termsAndConditionsLink>Villkoren och villkoren</termsAndConditionsLink>, och förstår att Scandic kommer att behandla min personliga data i samband med denna bokning i enlighet med <privacyPolicyLink>Scandics integritetspolicy</privacyPolicyLink>. Jag accepterar att Scandic kräver ett giltigt kreditkort under min besök i fall att något är tillbaka betalt.",
@@ -528,6 +530,7 @@
"Open {amount, plural, one {gift} other {gifts}}": "Öppna {amount, plural, one {gåva} other {gåvor}}",
"Opening hours": "Öppettider",
"Optional": "Valfritt",
"Other": "Övrigt",
"Other Requests": "Övriga önskemål",
"Outdoor": "Utomhus",
"Outdoor pool": "Utomhuspool",
@@ -727,6 +730,7 @@
"The new price is": "Det nya priset är",
"The price has increased": "Priset har ökat",
"The price has increased since you selected your room.": "Priset har ökat sedan du valde ditt rum.",
"The product could not be added. Your booking is guaranteed. Please try again.": "Produkten kunde inte läggas till. Din bokning är garanterad. Vänligen försök igen.",
"Theatre": "Teater",
"There are no rooms available that match your request.": "Det finns inga rum tillgängliga som matchar din begäran.",
"There are no transactions to display": "Det finns inga transaktioner att visa",
@@ -864,6 +868,7 @@
"Your room": "Ditt rum",
"Your room will remain available for check-in even after 18:00.": "Ditt rum kommer att förbli tillgängligt för incheckning även efter kl. 18.00.",
"Your selected bed type will be provided based on availability": "Din valda sängtyp kommer att tillhandahållas baserat på tillgänglighet",
"Your stay remains available for check-in after 18:00.": "Din vistelse förblir tillgänglig för incheckning efter kl. 18.00.",
"Your stay was cancelled. Cancellation cost: 0 {currency}. We're sorry to see that the plans didn't work out": "Din vistelse blev avbokad. Avbokningskostnad: 0 {currency}. Vi beklagar att planerna inte fungerade.",
"Your stay was cancelled. Cancellation cost: 0 {currency}. Were sorry to see that the plans didnt work out": "Din vistelse blev avbokad. Avbokningskostnad: 0 {currency}. Vi beklagar att planerna inte fungerade ut",
"Your stay was updated": "Din vistelse uppdaterades",

View File

@@ -279,11 +279,6 @@ const nextConfig = {
destination:
"/:lang/hotelreservation/payment-callback?status=:status",
},
{
source: "/:lang/hotelreservation/gla-payment-callback/:status/:refId",
destination:
"/:lang/hotelreservation/gla-payment-callback?status=:status&refId=:refId",
},
// Find my booking
{
source: findMyBooking.en,

View File

@@ -0,0 +1,46 @@
"use client"
import { useEffect, useRef } from "react"
import {
AncillaryStepEnum,
createAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import { getAncillarySessionData } from "@/components/HotelReservation/MyStay/Ancillaries/utils"
import { AddAncillaryContext } from "@/contexts/AddAncillary"
import type { Ancillaries } from "@/types/components/myPages/myStay/ancillaries"
import type { AddAncillaryStore } from "@/types/contexts/add-ancillary"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export function AddAncillaryProvider({
ancillaries,
booking,
children,
}: {
ancillaries: Ancillaries
booking: BookingConfirmation["booking"]
children: React.ReactNode
}) {
const storeRef = useRef<AddAncillaryStore>()
if (!storeRef.current) {
storeRef.current = createAddAncillaryStore(booking, ancillaries)
}
useEffect(() => {
const savedData = getAncillarySessionData()
if (savedData?.selectedAncillary) {
storeRef.current?.setState({
selectedAncillary: savedData.selectedAncillary,
currentStep: AncillaryStepEnum.confirmation,
isOpen: true,
})
}
}, [])
return (
<AddAncillaryContext.Provider value={storeRef.current}>
{children}
</AddAncillaryContext.Provider>
)
}

View File

@@ -104,7 +104,7 @@ export const createBookingInput = z.object({
export const addPackageInput = z.object({
confirmationNumber: z.string(),
ancillaryComment: z.string(),
ancillaryDeliveryTime: z.string().optional(),
ancillaryDeliveryTime: z.string().nullish(),
packages: z.array(
z.object({
code: z.string(),

View File

@@ -1,6 +1,6 @@
import { z } from "zod"
import { ChildBedTypeEnum } from "@/constants/booking"
import { BookingStatusEnum, ChildBedTypeEnum } from "@/constants/booking"
import { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator"
import { phoneValidator } from "@/utils/zod/phoneValidator"
@@ -239,7 +239,8 @@ export const bookingConfirmationSchema = z
extraBedTypes: data.attributes.childBedPreferences,
showAncillaries: !!(
data.links.addAncillary ||
data.attributes.packages.some((p) => p.type === "Ancillary")
data.attributes.packages.some((p) => p.type === "Ancillary") ||
data.attributes.reservationStatus === BookingStatusEnum.Cancelled
),
isCancelable: !!data.links.cancel,
isModifiable: !!data.links.modify,

View File

@@ -1,42 +1,206 @@
import { create } from "zustand"
import { produce } from "immer"
import { useContext } from "react"
import { create, useStore } from "zustand"
import type { Ancillary } from "@/types/components/myPages/myStay/ancillaries"
import { clearAncillarySessionData } from "@/components/HotelReservation/MyStay/Ancillaries/utils"
import { AddAncillaryContext } from "@/contexts/AddAncillary"
interface AddAncillaryState {
step: number
totalSteps: number
nextStep: () => void
prevStep: () => void
resetStore: () => void
selectedAncillary: Ancillary["ancillaryContent"][number] | null
setSelectedAncillary: (
ancillary: Ancillary["ancillaryContent"][number]
) => void
confirmationNumber: string
setConfirmationNumber: (confirmationNumber: string) => void
openedFrom: "list" | "grid" | null
setOpenedFrom: (source: "list" | "grid") => void
isGridOpen: boolean
setGridIsOpen: (isOpen: boolean) => void
import type {
Ancillaries,
Ancillary,
SelectedAncillary,
} from "@/types/components/myPages/myStay/ancillaries"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export enum AncillaryStepEnum {
selectAncillary = 0,
selectQuantity = 1,
selectDelivery = 2,
confirmation = 3,
}
type Step = {
step: AncillaryStepEnum
isValid: boolean
}
type Steps = {
[AncillaryStepEnum.selectAncillary]?: Step
[AncillaryStepEnum.selectQuantity]: Step
[AncillaryStepEnum.selectDelivery]: Step
[AncillaryStepEnum.confirmation]: Step
}
export const useAddAncillaryStore = create<AddAncillaryState>((set) => ({
step: 1,
totalSteps: 3,
nextStep: () =>
set((state) =>
state.step < state.totalSteps ? { step: state.step + 1 } : {}
),
prevStep: () =>
set((state) => (state.step > 1 ? { step: state.step - 1 } : {})),
resetStore: () => set({ step: 1 }),
selectedAncillary: null,
setSelectedAncillary: (ancillary) => set({ selectedAncillary: ancillary }),
confirmationNumber: "",
setConfirmationNumber: (confirmationNumber) =>
set({ confirmationNumber: confirmationNumber }),
openedFrom: null,
setOpenedFrom: (source) => set({ openedFrom: source }),
isGridOpen: false,
setGridIsOpen: (isOpen) => set({ isGridOpen: isOpen }),
}))
export interface AddAncillaryState {
currentStep: number
steps: Steps
booking: BookingConfirmation["booking"]
ancillaries: Ancillaries
categories: Ancillary["categoryName"][]
selectedCategory: string
selectCategory: (category: string) => void
ancillariesBySelectedCategory: Ancillary["ancillaryContent"]
openModal: VoidFunction
closeModal: VoidFunction
prevStep: VoidFunction
isOpen: boolean
selectedAncillary: SelectedAncillary | null
selectAncillary: (ancillary: SelectedAncillary) => void
selectQuantity: VoidFunction
selectDeliveryTime: VoidFunction
selectQuantityAndDeliveryTime: VoidFunction
}
function findAncillaryByCategory(
ancillaries: Ancillaries,
selectedCategory: string
) {
return (
ancillaries.find((ancillary) => ancillary.categoryName === selectedCategory)
?.ancillaryContent ?? []
)
}
export const createAddAncillaryStore = (
booking: BookingConfirmation["booking"],
ancillaries: Ancillaries
) => {
const selectedCategory = ancillaries[0].categoryName
const ancillariesBySelectedCategory = findAncillaryByCategory(
ancillaries,
selectedCategory
)
const categories = ancillaries.map((ancillary) => ancillary.categoryName)
const steps = {
[AncillaryStepEnum.selectAncillary]: {
step: AncillaryStepEnum.selectAncillary,
isValid: true,
},
[AncillaryStepEnum.selectQuantity]: {
step: AncillaryStepEnum.selectQuantity,
isValid: false,
},
[AncillaryStepEnum.selectDelivery]: {
step: AncillaryStepEnum.selectDelivery,
isValid: false,
},
[AncillaryStepEnum.confirmation]: {
step: AncillaryStepEnum.confirmation,
isValid: false,
},
}
return create<AddAncillaryState>((set) => ({
booking,
ancillaries,
categories,
selectedCategory,
ancillariesBySelectedCategory,
currentStep: AncillaryStepEnum.selectAncillary,
selectedAncillary: null,
isOpen: false,
steps,
openModal: () =>
set(
produce((state: AddAncillaryState) => {
state.isOpen = true
state.currentStep = AncillaryStepEnum.selectAncillary
})
),
closeModal: () =>
set(
produce((state: AddAncillaryState) => {
state.isOpen = false
clearAncillarySessionData()
state.selectedAncillary = null
state.steps = steps
})
),
selectCategory: (category) =>
set(
produce((state: AddAncillaryState) => {
state.selectedCategory = category
state.ancillariesBySelectedCategory = findAncillaryByCategory(
state.ancillaries,
category
)
})
),
selectQuantity: () =>
set(
produce((state: AddAncillaryState) => {
if (state.selectedAncillary?.requiresDeliveryTime) {
state.currentStep = AncillaryStepEnum.selectDelivery
} else {
state.steps[AncillaryStepEnum.selectDelivery].isValid = true
state.currentStep = AncillaryStepEnum.confirmation
}
state.steps[AncillaryStepEnum.selectQuantity].isValid = true
})
),
selectQuantityAndDeliveryTime: () =>
set(
produce((state: AddAncillaryState) => {
state.steps[AncillaryStepEnum.selectQuantity].isValid = true
state.steps[AncillaryStepEnum.selectDelivery].isValid = true
state.currentStep = AncillaryStepEnum.confirmation
})
),
selectDeliveryTime: () =>
set(
produce((state: AddAncillaryState) => {
state.steps[AncillaryStepEnum.selectDelivery].isValid = true
state.currentStep = AncillaryStepEnum.confirmation
})
),
prevStep: () =>
set(
produce((state: AddAncillaryState) => {
if (
state.currentStep === AncillaryStepEnum.selectAncillary ||
(state.currentStep === AncillaryStepEnum.selectQuantity &&
!state.steps[AncillaryStepEnum.selectAncillary])
) {
state.isOpen = false
clearAncillarySessionData()
state.selectedAncillary = null
state.steps = steps
} else {
if (
!state.selectedAncillary?.requiresDeliveryTime &&
state.currentStep === AncillaryStepEnum.confirmation
) {
state.currentStep = AncillaryStepEnum.selectQuantity
} else if (state.currentStep === AncillaryStepEnum.selectQuantity) {
state.currentStep = state.currentStep - 1
state.selectedAncillary = null
} else {
state.currentStep = state.currentStep - 1
}
}
})
),
selectAncillary: (ancillary) =>
set(
produce((state: AddAncillaryState) => {
if (state.isOpen) {
state.steps[AncillaryStepEnum.selectAncillary]!.isValid = true
} else {
state.isOpen = true
delete state.steps[AncillaryStepEnum.selectAncillary]
}
state.selectedAncillary = ancillary
state.currentStep = AncillaryStepEnum.selectQuantity
})
),
}))
}
export const useAddAncillaryStore = <T>(
selector: (state: AddAncillaryState) => T
) => {
const store = useContext(AddAncillaryContext)
if (!store) {
throw new Error(
"useAddAncillaryStore must be used within AddAncillaryProvider"
)
}
return useStore(store, selector)
}

View File

@@ -3,15 +3,13 @@ import type { PaymentMethodEnum } from "@/constants/booking"
export interface PaymentProps {
otherPaymentOptions: PaymentMethodEnum[]
mustBeGuaranteed: boolean
memberMustBeGuaranteed: boolean
supportedCards: PaymentMethodEnum[]
isFlexRate: boolean
}
export interface PaymentClientProps
extends Omit<PaymentProps, "supportedCards"> {
savedCreditCards: CreditCard[] | null
isUserLoggedIn: boolean
}
export type PriceChangeData = Array<{

View File

@@ -1,15 +1,18 @@
import type { z } from "zod"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
import type { User } from "@/types/user"
import type { CreditCard, User } from "@/types/user"
import type { ancillaryPackagesSchema } from "@/server/routers/hotels/output"
export type Ancillaries = z.output<typeof ancillaryPackagesSchema>
export type Ancillary = Ancillaries[number]
export type SelectedAncillary = Ancillary["ancillaryContent"][number]
export interface AncillariesProps extends Pick<BookingConfirmation, "booking"> {
ancillaries: Ancillaries | null
user: User | null
savedCreditCards: CreditCard[] | null
refId: string
}
export interface AddedAncillariesProps {
@@ -27,25 +30,39 @@ export interface MyStayProps extends BookingConfirmation {
export interface AncillaryGridModalProps {
ancillaries: Ancillaries
selectedCategory: string | null
setSelectedCategory: (category: string) => void
handleCardClick: (ancillary: Ancillary["ancillaryContent"][number]) => void
user: User | null
}
export interface AddAncillaryFlowModalProps
extends Pick<BookingConfirmation, "booking"> {
isOpen: boolean
onClose: () => void
refId: string
user: User | null
savedCreditCards: CreditCard[] | null
}
export type DeliveryTimeOption = {
label: string
value: string
}
export interface DeliveryMethodStepProps {
deliveryTimeOptions: {
label: string
value: string
}[]
deliveryTimeOptions: DeliveryTimeOption[]
}
export interface SelectQuantityStepProps {
user: User | null
}
export interface ConfirmationStepProps {
savedCreditCards: CreditCard[] | null
}
export interface StepsProps {
user: User | null
savedCreditCards: CreditCard[] | null
}
export interface ActionButtonsProps {
isPriceDetailsOpen: boolean
togglePriceDetails: VoidFunction
isSubmitting: boolean
}

View File

@@ -0,0 +1,3 @@
import type { createAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
export type AddAncillaryStore = ReturnType<typeof createAddAncillaryStore>

View File

@@ -37,6 +37,7 @@ export interface InitialRoomData {
roomRate: RoomRate
roomType: string
roomTypeCode: string
memberMustBeGuaranteed?: boolean
}
export type RoomStep = {