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:
@@ -1,3 +0,0 @@
|
||||
.layout {
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x4);
|
||||
justify-content: flex-end;
|
||||
padding: var(--Space-x15) var(--Space-x15) 0;
|
||||
}
|
||||
|
||||
.confirmButtons {
|
||||
display: flex;
|
||||
padding: 0 var(--Space-x15);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.priceButton {
|
||||
display: flex;
|
||||
gap: var(--Space-x05);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
|
||||
import {
|
||||
AncillaryStepEnum,
|
||||
useAddAncillaryStore,
|
||||
} from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import { ChevronDownSmallIcon, ChevronUpSmallIcon } from "@/components/Icons"
|
||||
|
||||
import { type AncillaryFormData,quantitySchema } from "../../schema"
|
||||
|
||||
import styles from "./actionButtons.module.css"
|
||||
|
||||
import type { ActionButtonsProps } from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
export default function ActionButtons({
|
||||
togglePriceDetails,
|
||||
isPriceDetailsOpen,
|
||||
isSubmitting,
|
||||
}: ActionButtonsProps) {
|
||||
const {
|
||||
currentStep,
|
||||
prevStep,
|
||||
selectQuantity,
|
||||
selectDeliveryTime,
|
||||
selectQuantityAndDeliveryTime,
|
||||
} = useAddAncillaryStore((state) => ({
|
||||
currentStep: state.currentStep,
|
||||
prevStep: state.prevStep,
|
||||
selectQuantity: state.selectQuantity,
|
||||
selectDeliveryTime: state.selectDeliveryTime,
|
||||
selectQuantityAndDeliveryTime: state.selectQuantityAndDeliveryTime,
|
||||
}))
|
||||
const isMobile = useMediaQuery("(max-width: 767px)")
|
||||
const { setError } = useFormContext()
|
||||
|
||||
const intl = useIntl()
|
||||
const isConfirmStep = currentStep === AncillaryStepEnum.confirmation
|
||||
const confirmLabel = intl.formatMessage({ id: "Confirm" })
|
||||
const continueLabel = intl.formatMessage({ id: "Continue" })
|
||||
const quantityWithCard = useWatch<AncillaryFormData>({
|
||||
name: "quantityWithCard",
|
||||
})
|
||||
const quantityWithPoints = useWatch<AncillaryFormData>({
|
||||
name: "quantityWithPoints",
|
||||
})
|
||||
function handleNextStep() {
|
||||
if (currentStep === AncillaryStepEnum.selectQuantity) {
|
||||
const validatedQuantity = quantitySchema.safeParse({
|
||||
quantityWithCard,
|
||||
quantityWithPoints,
|
||||
})
|
||||
if (validatedQuantity.success) {
|
||||
if (isMobile) {
|
||||
selectQuantityAndDeliveryTime()
|
||||
} else {
|
||||
selectQuantity()
|
||||
}
|
||||
} else {
|
||||
setError("quantityWithCard", validatedQuantity.error.issues[0])
|
||||
}
|
||||
} else if (currentStep === AncillaryStepEnum.selectDelivery) {
|
||||
selectDeliveryTime()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={isConfirmStep ? styles.confirmButtons : ""}>
|
||||
{isConfirmStep && (
|
||||
<Button
|
||||
type="button"
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
size="Small"
|
||||
variant="Text"
|
||||
onPress={togglePriceDetails}
|
||||
className={styles.priceButton}
|
||||
>
|
||||
{intl.formatMessage({ id: "Price details" })}
|
||||
{isPriceDetailsOpen ? (
|
||||
<ChevronUpSmallIcon
|
||||
width={20}
|
||||
height={20}
|
||||
color="baseButtonTextOnFillNormal"
|
||||
/>
|
||||
) : (
|
||||
<ChevronDownSmallIcon
|
||||
width={20}
|
||||
height={20}
|
||||
color="baseButtonTextOnFillNormal"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<div className={styles.buttons}>
|
||||
<Button
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
type="button"
|
||||
variant="Text"
|
||||
size="Small"
|
||||
color="Primary"
|
||||
onPress={prevStep}
|
||||
>
|
||||
{intl.formatMessage({ id: "Back" })}
|
||||
</Button>
|
||||
{isConfirmStep && (
|
||||
<Button
|
||||
type="submit"
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
variant="Primary"
|
||||
size="Small"
|
||||
isDisabled={isSubmitting}
|
||||
form="add-ancillary-form-id"
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
)}
|
||||
{!isConfirmStep && (
|
||||
<Button
|
||||
type="button"
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
variant="Secondary"
|
||||
size="Small"
|
||||
isDisabled={isSubmitting}
|
||||
onPress={handleNextStep}
|
||||
>
|
||||
{continueLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./priceSummary.module.css"
|
||||
|
||||
import type { Ancillary } from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
interface PriceSummaryProps {
|
||||
totalPrice: number | null
|
||||
totalPoints: number | null
|
||||
totalUnits: number
|
||||
selectedAncillary: NonNullable<Ancillary["ancillaryContent"][number]>
|
||||
}
|
||||
|
||||
export default function PriceSummary({
|
||||
totalPrice,
|
||||
totalPoints,
|
||||
totalUnits,
|
||||
selectedAncillary,
|
||||
}: PriceSummaryProps) {
|
||||
const intl = useIntl()
|
||||
const hasTotalPoints = typeof totalPoints === "number"
|
||||
const hasTotalPrice = typeof totalPrice === "number"
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<h2>{intl.formatMessage({ id: "Summary" })}</h2>
|
||||
</Typography>
|
||||
<Divider color="subtle" />
|
||||
|
||||
<div className={styles.column}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<h2>{selectedAncillary.title}</h2>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{`X${totalUnits}`}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className={styles.column}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<h2>{intl.formatMessage({ id: "Price including VAT" })}</h2>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<h2>
|
||||
{formatPrice(
|
||||
intl,
|
||||
selectedAncillary.price.total,
|
||||
selectedAncillary.price.currency
|
||||
)}
|
||||
</h2>
|
||||
</Typography>
|
||||
</div>
|
||||
<Divider color="subtle" />
|
||||
<div className={styles.column}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{ id: "<b>Total price</b> (incl VAT)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
<div className={styles.totalPrice}>
|
||||
{hasTotalPoints && (
|
||||
<div>
|
||||
<div>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
</div>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{totalPoints} {intl.formatMessage({ id: "points" })}
|
||||
{hasTotalPrice && "+"}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
{hasTotalPrice && (
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPrice,
|
||||
selectedAncillary.price.currency
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.container {
|
||||
display: flex;
|
||||
padding: var(--Space-x3);
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x2);
|
||||
align-self: stretch;
|
||||
border-radius: var(--Corner-radius-lg);
|
||||
border: 1px solid var(--Border-Divider-Default);
|
||||
background: var(--Surface-Primary-Default);
|
||||
margin: var(--Space-x1);
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.totalPrice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import {
|
||||
AncillaryStepEnum,
|
||||
useAddAncillaryStore,
|
||||
} from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import PriceSummary from "./PriceSummary"
|
||||
|
||||
import styles from "./priceDetails.module.css"
|
||||
|
||||
interface PriceDetailsProps {
|
||||
isPriceDetailsOpen: boolean
|
||||
}
|
||||
|
||||
export default function PriceDetails({
|
||||
isPriceDetailsOpen,
|
||||
}: PriceDetailsProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const { currentStep, selectedAncillary } = useAddAncillaryStore((state) => ({
|
||||
currentStep: state.currentStep,
|
||||
selectedAncillary: state.selectedAncillary,
|
||||
}))
|
||||
const quantityWithPoints = useWatch({ name: "quantityWithPoints" })
|
||||
const quantityWithCard = useWatch({ name: "quantityWithCard" })
|
||||
|
||||
if (!selectedAncillary || currentStep !== AncillaryStepEnum.confirmation) {
|
||||
return null
|
||||
}
|
||||
const totalPrice =
|
||||
quantityWithCard && selectedAncillary
|
||||
? selectedAncillary.price.total * quantityWithCard
|
||||
: null
|
||||
|
||||
const totalPoints =
|
||||
quantityWithPoints && selectedAncillary?.points
|
||||
? selectedAncillary.points * quantityWithPoints
|
||||
: null
|
||||
const totalUnits = (quantityWithCard ?? 0) + (quantityWithPoints ?? 0)
|
||||
return (
|
||||
<>
|
||||
<div className={styles.totalPrice}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{ id: "<b>Total price</b> (incl VAT)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
{totalPrice !== null && (
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{formatPrice(intl, totalPrice, selectedAncillary.price.currency)}
|
||||
</p>
|
||||
</Typography>
|
||||
)}
|
||||
{totalPoints !== null && (
|
||||
<div>
|
||||
<div>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
</div>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{totalPoints} {intl.formatMessage({ id: "points" })}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Divider color="subtle" />
|
||||
{isPriceDetailsOpen && (
|
||||
<PriceSummary
|
||||
totalPrice={totalPrice}
|
||||
totalPoints={totalPoints}
|
||||
totalUnits={totalUnits}
|
||||
selectedAncillary={selectedAncillary}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.totalPrice {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.modalContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.termsAndConditions {
|
||||
display: flex;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { PaymentMethodEnum } from "@/constants/booking"
|
||||
import {
|
||||
bookingTermsAndConditions,
|
||||
privacyPolicy,
|
||||
} from "@/constants/currentWebHrefs"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import MySavedCards from "@/components/HotelReservation/EnterDetails/Payment/MySavedCards"
|
||||
import PaymentOption from "@/components/HotelReservation/EnterDetails/Payment/PaymentOption"
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./confirmationStep.module.css"
|
||||
|
||||
import type { ConfirmationStepProps } from "@/types/components/myPages/myStay/ancillaries"
|
||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
|
||||
export default function ConfirmationStep({
|
||||
savedCreditCards,
|
||||
}: ConfirmationStepProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const { checkInDate, guaranteeInfo } = useAddAncillaryStore((state) => ({
|
||||
checkInDate: state.booking.checkInDate,
|
||||
guaranteeInfo: state.booking.guaranteeInfo,
|
||||
}))
|
||||
const refundableDate = dt(checkInDate)
|
||||
.subtract(1, "day")
|
||||
.locale(lang)
|
||||
.format("23:59, dddd, D MMMM YYYY")
|
||||
|
||||
return (
|
||||
<div className={styles.modalContent}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "All ancillaries are fully refundable until {date}. Time selection and special requests are also modifiable.",
|
||||
},
|
||||
{ date: refundableDate }
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
<header>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h2>
|
||||
{intl.formatMessage({
|
||||
id: "Reserve with Card",
|
||||
})}
|
||||
</h2>
|
||||
</Typography>
|
||||
</header>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "Payment will be made on check-in. The card will be only used to guarantee the ancillary in case of no-show.",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
{guaranteeInfo ? (
|
||||
<PaymentOption
|
||||
name="paymentMethod"
|
||||
value={PaymentMethodEnum.card}
|
||||
cardNumber={guaranteeInfo.maskedCard.slice(-4)}
|
||||
label={intl.formatMessage({ id: "Credit card" })}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
text={intl.formatMessage({
|
||||
id: "By adding a card you also guarantee your room booking for late arrival.",
|
||||
})}
|
||||
/>
|
||||
{savedCreditCards?.length && (
|
||||
<MySavedCards savedCreditCards={savedCreditCards} />
|
||||
)}
|
||||
<>
|
||||
{savedCreditCards?.length && (
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<h4>{intl.formatMessage({ id: "OTHER" })}</h4>
|
||||
</Typography>
|
||||
)}
|
||||
<PaymentOption
|
||||
name="paymentMethod"
|
||||
value={PaymentMethodEnum.card}
|
||||
label={intl.formatMessage({ id: "Credit card" })}
|
||||
/>
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
<div className={styles.termsAndConditions}>
|
||||
<Checkbox
|
||||
name="termsAndConditions"
|
||||
registerOptions={{ required: true }}
|
||||
topAlign
|
||||
>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "Yes, I accept the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. There you can learn more about what data we process, your rights and where to turn if you have questions.",
|
||||
},
|
||||
{
|
||||
termsAndConditionsLink: (str) => (
|
||||
<Typography variant="Link/sm">
|
||||
<Link
|
||||
variant="underscored"
|
||||
href={bookingTermsAndConditions[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
</Typography>
|
||||
),
|
||||
privacyPolicyLink: (str) => (
|
||||
<Typography variant="Link/sm">
|
||||
<Link
|
||||
variant="underscored"
|
||||
href={privacyPolicy[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
</Typography>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { generateDeliveryOptions } from "@/components/HotelReservation/MyStay/Ancillaries/utils"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import Select from "@/components/TempDesignSystem/Form/Select"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
@@ -8,12 +9,9 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./deliveryDetailsStep.module.css"
|
||||
|
||||
import type { DeliveryMethodStepProps } from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
export default function DeliveryMethodStep({
|
||||
deliveryTimeOptions,
|
||||
}: DeliveryMethodStepProps) {
|
||||
export default function DeliveryMethodStep() {
|
||||
const intl = useIntl()
|
||||
const deliveryTimeOptions = generateDeliveryOptions()
|
||||
|
||||
return (
|
||||
<div className={styles.selectContainer}>
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
AncillaryStepEnum,
|
||||
useAddAncillaryStore,
|
||||
} from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import ConfirmationStep from "../ConfirmationStep"
|
||||
import DeliveryMethodStep from "../DeliveryDetailsStep"
|
||||
import SelectAncillaryStep from "../SelectAncillaryStep"
|
||||
import SelectQuantityStep from "../SelectQuantityStep"
|
||||
|
||||
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
export default function Desktop({ user, savedCreditCards }: StepsProps) {
|
||||
const currentStep = useAddAncillaryStore((state) => state.currentStep)
|
||||
if (currentStep === AncillaryStepEnum.selectAncillary) {
|
||||
return <SelectAncillaryStep />
|
||||
}
|
||||
if (currentStep === AncillaryStepEnum.selectQuantity) {
|
||||
return <SelectQuantityStep user={user} />
|
||||
}
|
||||
if (currentStep === AncillaryStepEnum.selectDelivery) {
|
||||
return <DeliveryMethodStep />
|
||||
}
|
||||
|
||||
return <ConfirmationStep savedCreditCards={savedCreditCards} />
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
AncillaryStepEnum,
|
||||
useAddAncillaryStore,
|
||||
} from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import ConfirmationStep from "../ConfirmationStep"
|
||||
import DeliveryMethodStep from "../DeliveryDetailsStep"
|
||||
import SelectQuantityStep from "../SelectQuantityStep"
|
||||
|
||||
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
export default function Mobile({ user, savedCreditCards }: StepsProps) {
|
||||
const { currentStep, selectedAncillary } = useAddAncillaryStore((state) => ({
|
||||
currentStep: state.currentStep,
|
||||
selectedAncillary: state.selectedAncillary,
|
||||
}))
|
||||
|
||||
if (currentStep === AncillaryStepEnum.selectQuantity) {
|
||||
return (
|
||||
<>
|
||||
<SelectQuantityStep user={user} />
|
||||
{selectedAncillary?.requiresDeliveryTime && <DeliveryMethodStep />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return <ConfirmationStep savedCreditCards={savedCreditCards} />
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import WrappedAncillaryCard from "../../../WrappedAncillaryCard"
|
||||
|
||||
import styles from "./selectAncillaryStep.module.css"
|
||||
|
||||
export default function SelectAncillaryStep() {
|
||||
const {
|
||||
ancillariesBySelectedCategory,
|
||||
selectedCategory,
|
||||
categories,
|
||||
selectCategory,
|
||||
} = useAddAncillaryStore((state) => ({
|
||||
categories: state.categories,
|
||||
selectedCategory: state.selectedCategory,
|
||||
ancillariesBySelectedCategory: state.ancillariesBySelectedCategory,
|
||||
selectCategory: state.selectCategory,
|
||||
}))
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.tabs}>
|
||||
{categories.map((categoryName) => (
|
||||
<button
|
||||
key={categoryName}
|
||||
className={`${styles.chip} ${categoryName === selectedCategory ? styles.selected : ""}`}
|
||||
onClick={() => selectCategory(categoryName)}
|
||||
>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p>
|
||||
{categoryName
|
||||
? categoryName
|
||||
: intl.formatMessage({ id: "Other" })}
|
||||
</p>
|
||||
</Typography>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.grid}>
|
||||
{ancillariesBySelectedCategory.map((ancillary) => (
|
||||
<WrappedAncillaryCard key={ancillary.id} ancillary={ancillary} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x3) 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(251px, 1fr));
|
||||
gap: var(--Spacing-x2);
|
||||
height: 470px;
|
||||
overflow-y: auto;
|
||||
padding-right: var(--Spacing-x-one-and-half);
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.chip {
|
||||
border-radius: var(--Corner-radius-Rounded);
|
||||
padding: calc(var(--Space-x1) + var(--Space-x025)) var(--Space-x2);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--Border-Interactive-Default);
|
||||
color: var(--Text-Default);
|
||||
background-color: var(--Background-Secondary);
|
||||
}
|
||||
|
||||
.chip.selected {
|
||||
background: var(--Surface-Brand-Primary-3-Default);
|
||||
color: var(--Text-Inverted);
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import { DiamondIcon } from "@/components/Icons"
|
||||
import ErrorMessage from "@/components/TempDesignSystem/Form/ErrorMessage"
|
||||
import Select from "@/components/TempDesignSystem/Form/Select"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./selectQuantityStep.module.css"
|
||||
|
||||
@@ -15,8 +15,12 @@ import type { SelectQuantityStepProps } from "@/types/components/myPages/myStay/
|
||||
|
||||
export default function SelectQuantityStep({ user }: SelectQuantityStepProps) {
|
||||
const intl = useIntl()
|
||||
const { selectedAncillary } = useAddAncillaryStore()
|
||||
const { formState } = useFormContext()
|
||||
const selectedAncillary = useAddAncillaryStore(
|
||||
(state) => state.selectedAncillary
|
||||
)
|
||||
const {
|
||||
formState: { errors },
|
||||
} = useFormContext()
|
||||
|
||||
const cardQuantityOptions = Array.from({ length: 7 }, (_, i) => ({
|
||||
label: `${i}`,
|
||||
@@ -47,39 +51,41 @@ export default function SelectQuantityStep({ user }: SelectQuantityStepProps) {
|
||||
<div className={styles.selectContainer}>
|
||||
{selectedAncillary?.points && user && (
|
||||
<div className={styles.select}>
|
||||
<Subtitle type="two">
|
||||
{intl.formatMessage({ id: "Pay with points" })}
|
||||
</Subtitle>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h2>{intl.formatMessage({ id: "Pay with points" })}</h2>
|
||||
</Typography>
|
||||
<div className={styles.totalPointsContainer}>
|
||||
<div className={styles.totalPoints}>
|
||||
<DiamondIcon />
|
||||
<Subtitle textTransform="uppercase" type="two">
|
||||
{intl.formatMessage({ id: "Total points" })}
|
||||
</Subtitle>
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<h2>{intl.formatMessage({ id: "Total points" })}</h2>
|
||||
</Typography>
|
||||
</div>
|
||||
<Body>{currentPoints}</Body>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{currentPoints}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<Select
|
||||
name="quantityWithPoints"
|
||||
label={pointsLabel}
|
||||
items={pointsQuantityOptions}
|
||||
disabled={!user || insufficientPoints}
|
||||
disabled={insufficientPoints}
|
||||
isNestedInModal
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.select}>
|
||||
<Subtitle type="two">
|
||||
{intl.formatMessage({ id: "Pay with Card" })}
|
||||
</Subtitle>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h2> {intl.formatMessage({ id: "Pay with Card" })}</h2>
|
||||
</Typography>
|
||||
<Select
|
||||
name="quantityWithCard"
|
||||
label={intl.formatMessage({ id: "Select quantity" })}
|
||||
items={cardQuantityOptions}
|
||||
isNestedInModal
|
||||
/>
|
||||
<ErrorMessage errors={errors} name="quantityWithCard" />
|
||||
</div>
|
||||
<ErrorMessage errors={formState.errors} name="quantityWithCard" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.selectContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x025);
|
||||
margin-bottom: var(--Space-x2);
|
||||
}
|
||||
|
||||
.select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x1);
|
||||
padding: var(--Space-x2) var(--Space-x3);
|
||||
background-color: var(--Surface-Primary-OnSurface-Default);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.totalPointsContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: var(--Surface-Brand-Primary-2-OnSurface-Accent);
|
||||
padding: var(--Space-x1) var(--Space-x15);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
|
||||
.totalPoints {
|
||||
display: flex;
|
||||
gap: var(--Space-x15);
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import Desktop from "./Desktop"
|
||||
import Mobile from "./Mobile"
|
||||
|
||||
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
export default function Steps(props: StepsProps) {
|
||||
const isMobile = useMediaQuery("(max-width: 767px)")
|
||||
|
||||
return isMobile ? <Mobile {...props} /> : <Desktop {...props} />
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
.modalWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 80dvh;
|
||||
max-height: 70dvh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
.price {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
gap: var(--Spacing-x2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -48,24 +48,53 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
.confirmStep {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x4);
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
background: var(--Surface-Primary-OnSurface-Default);
|
||||
border-top: 1px solid var(--Base-Border-Normal);
|
||||
padding-bottom: var(--Space-x15);
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
margin: var(--Spacing-x2) 0;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
background: var(--UI-Opacity-White-100);
|
||||
padding-top: var(--Spacing-x2);
|
||||
border-top: 1px solid var(--Base-Border-Normal);
|
||||
padding-bottom: var(--Space-x025);
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
gap: var(--Space-x2);
|
||||
height: 24px;
|
||||
}
|
||||
@media screen and (min-width: 768px) {
|
||||
.modalWrapper {
|
||||
width: 492px;
|
||||
}
|
||||
.selectAncillarycontainer {
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
height: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1052px) {
|
||||
.selectAncillarycontainer {
|
||||
width: 833px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { PaymentMethodEnum } from "@/constants/booking"
|
||||
import { guaranteeCallback } from "@/constants/routes/hotelReservation"
|
||||
import { env } from "@/env/client"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
import {
|
||||
AncillaryStepEnum,
|
||||
useAddAncillaryStore,
|
||||
} from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import Image from "@/components/Image"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import Modal from "@/components/Modal"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import { useGuaranteeBooking } from "@/hooks/booking/useGuaranteeBooking"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import { generateDeliveryOptions } from "../../utils"
|
||||
import ConfirmationStep from "../ConfirmationStep"
|
||||
import DeliveryMethodStep from "../DeliveryDetailsStep"
|
||||
import {
|
||||
clearAncillarySessionData,
|
||||
generateDeliveryOptions,
|
||||
getAncillarySessionData,
|
||||
setAncillarySessionData,
|
||||
} from "../../utils"
|
||||
import { type AncillaryFormData, ancillaryFormSchema } from "../schema"
|
||||
import SelectQuantityStep from "../SelectQuantityStep"
|
||||
import ActionButtons from "./ActionButtons"
|
||||
import PriceDetails from "./PriceDetails"
|
||||
import Steps from "./Steps"
|
||||
|
||||
import styles from "./addAncillaryFlowModal.module.css"
|
||||
|
||||
import type { AddAncillaryFlowModalProps } from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
type FieldName = keyof AncillaryFormData
|
||||
const STEP_FIELD_MAP: Record<number, FieldName[]> = {
|
||||
1: ["quantityWithPoints", "quantityWithCard"],
|
||||
2: ["deliveryTime"],
|
||||
3: ["termsAndConditions"],
|
||||
}
|
||||
|
||||
export default function AddAncillaryFlowModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
booking,
|
||||
user,
|
||||
savedCreditCards,
|
||||
refId,
|
||||
}: AddAncillaryFlowModalProps) {
|
||||
const {
|
||||
step,
|
||||
nextStep,
|
||||
prevStep,
|
||||
resetStore,
|
||||
selectedAncillary,
|
||||
confirmationNumber,
|
||||
openedFrom,
|
||||
setGridIsOpen,
|
||||
} = useAddAncillaryStore()
|
||||
|
||||
const { currentStep, selectedAncillary, closeModal } = useAddAncillaryStore(
|
||||
(state) => ({
|
||||
currentStep: state.currentStep,
|
||||
selectedAncillary: state.selectedAncillary,
|
||||
closeModal: state.closeModal,
|
||||
})
|
||||
)
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const isMobile = useMediaQuery("(max-width: 767px)")
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
|
||||
const deliveryTimeOptions = generateDeliveryOptions(booking.checkInDate)
|
||||
const [isPriceDetailsOpen, setIsPriceDetailsOpen] = useState(false)
|
||||
const guaranteeRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}${guaranteeCallback(lang)}`
|
||||
const deliveryTimeOptions = generateDeliveryOptions()
|
||||
|
||||
const defaultDeliveryTime = deliveryTimeOptions[0]?.value
|
||||
const defaultDeliveryTime = deliveryTimeOptions[0].value
|
||||
|
||||
const formMethods = useForm<AncillaryFormData>({
|
||||
defaultValues: {
|
||||
@@ -66,233 +73,238 @@ export default function AddAncillaryFlowModal({
|
||||
deliveryTime: defaultDeliveryTime,
|
||||
optionalText: "",
|
||||
termsAndConditions: false,
|
||||
paymentMethod: booking.guaranteeInfo
|
||||
? PaymentMethodEnum.card
|
||||
: savedCreditCards?.length
|
||||
? savedCreditCards[0].id
|
||||
: PaymentMethodEnum.card,
|
||||
},
|
||||
mode: "onSubmit",
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
resolver: zodResolver(ancillaryFormSchema),
|
||||
})
|
||||
|
||||
const { reset, trigger, handleSubmit, formState } = formMethods
|
||||
const ancillaryErrorMessage = intl.formatMessage(
|
||||
{
|
||||
id: "Something went wrong. {ancillary} could not be added to your booking!",
|
||||
},
|
||||
{ ancillary: selectedAncillary?.title }
|
||||
)
|
||||
|
||||
function togglePriceDetails() {
|
||||
setIsPriceDetailsOpen((isOpen) => !isOpen)
|
||||
}
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const addAncillary = trpc.booking.packages.useMutation({
|
||||
onSuccess: (data, variables) => {
|
||||
if (!data) {
|
||||
toast.error(
|
||||
if (data) {
|
||||
clearAncillarySessionData()
|
||||
closeModal()
|
||||
utils.booking.confirmation.invalidate({
|
||||
confirmationNumber: variables.confirmationNumber,
|
||||
})
|
||||
|
||||
toast.success(
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "Something went wrong. {ancillary} could not be added to your booking!",
|
||||
},
|
||||
{ id: "{ancillary} added to your booking!" },
|
||||
{ ancillary: selectedAncillary?.title }
|
||||
)
|
||||
)
|
||||
return
|
||||
} else {
|
||||
toast.error(ancillaryErrorMessage)
|
||||
}
|
||||
const description = variables.ancillaryDeliveryTime
|
||||
? intl.formatMessage(
|
||||
{
|
||||
id: "Delivery between {deliveryTime}. Payment will be made on check-in.",
|
||||
},
|
||||
{ deliveryTime: variables.ancillaryDeliveryTime }
|
||||
)
|
||||
: undefined
|
||||
|
||||
toast.success(
|
||||
intl.formatMessage(
|
||||
{ id: "{ancillary} added to your booking!" },
|
||||
{ ancillary: selectedAncillary?.title }
|
||||
),
|
||||
{ description }
|
||||
)
|
||||
handleClose()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "Something went wrong. {ancillary} could not be added to your booking!",
|
||||
},
|
||||
{ ancillary: selectedAncillary?.title }
|
||||
)
|
||||
)
|
||||
toast.error(ancillaryErrorMessage)
|
||||
},
|
||||
})
|
||||
|
||||
const { guaranteeBooking, isLoading } = useGuaranteeBooking({
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
})
|
||||
|
||||
const onSubmit = (data: AncillaryFormData) => {
|
||||
const packages = []
|
||||
if (data.quantityWithCard) {
|
||||
packages.push({
|
||||
code: selectedAncillary!.id,
|
||||
quantity: data.quantityWithCard,
|
||||
comment: data.optionalText || undefined,
|
||||
if (!data.termsAndConditions) {
|
||||
formMethods.setError("termsAndConditions", {
|
||||
message: "You must accept the terms",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedAncillary?.loyaltyCode && data.quantityWithPoints) {
|
||||
packages.push({
|
||||
code: selectedAncillary.loyaltyCode,
|
||||
quantity: data.quantityWithPoints,
|
||||
comment: data.optionalText || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
addAncillary.mutate({
|
||||
confirmationNumber,
|
||||
ancillaryComment: data.optionalText ?? "",
|
||||
ancillaryDeliveryTime: data.deliveryTime ?? undefined,
|
||||
packages,
|
||||
language: lang,
|
||||
setAncillarySessionData({
|
||||
formData: data,
|
||||
selectedAncillary,
|
||||
})
|
||||
}
|
||||
|
||||
const handleNextStep = async () => {
|
||||
let fieldsToValidate = []
|
||||
|
||||
if (isMobile && step === 1) {
|
||||
fieldsToValidate = [...STEP_FIELD_MAP[1]]
|
||||
if (selectedAncillary?.requiresDeliveryTime) {
|
||||
fieldsToValidate = [...fieldsToValidate, ...STEP_FIELD_MAP[2]]
|
||||
if (booking.guaranteeInfo) {
|
||||
const packages = []
|
||||
if (selectedAncillary?.id && data.quantityWithCard) {
|
||||
packages.push({
|
||||
code: selectedAncillary.id,
|
||||
quantity: data.quantityWithCard,
|
||||
comment: data.optionalText || undefined,
|
||||
})
|
||||
}
|
||||
} else if (step === 2) {
|
||||
fieldsToValidate = selectedAncillary?.requiresDeliveryTime
|
||||
? STEP_FIELD_MAP[2] || []
|
||||
: []
|
||||
|
||||
if (selectedAncillary?.loyaltyCode && data.quantityWithPoints) {
|
||||
packages.push({
|
||||
code: selectedAncillary.loyaltyCode,
|
||||
quantity: data.quantityWithPoints,
|
||||
comment: data.optionalText || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
addAncillary.mutate({
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
ancillaryComment: data.optionalText,
|
||||
ancillaryDeliveryTime: selectedAncillary?.requiresDeliveryTime
|
||||
? data.deliveryTime
|
||||
: undefined,
|
||||
packages,
|
||||
language: lang,
|
||||
})
|
||||
} else {
|
||||
fieldsToValidate = STEP_FIELD_MAP[step] || []
|
||||
}
|
||||
|
||||
if (await trigger(fieldsToValidate)) {
|
||||
nextStep()
|
||||
const savedCreditCard = savedCreditCards?.find(
|
||||
(card) => card.id === data.paymentMethod
|
||||
)
|
||||
if (booking.confirmationNumber) {
|
||||
const card = savedCreditCard
|
||||
? {
|
||||
alias: savedCreditCard.alias,
|
||||
expiryDate: savedCreditCard.expirationDate,
|
||||
cardType: savedCreditCard.cardType,
|
||||
}
|
||||
: undefined
|
||||
guaranteeBooking.mutate({
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
language: lang,
|
||||
...(card && { card }),
|
||||
success: `${guaranteeRedirectUrl}?status=success&RefId=${encodeURIComponent(refId)}&ancillary=1`,
|
||||
error: `${guaranteeRedirectUrl}?status=error&RefId=${encodeURIComponent(refId)}&ancillary=1`,
|
||||
cancel: `${guaranteeRedirectUrl}?status=cancel&RefId=${encodeURIComponent(refId)}&ancillary=1`,
|
||||
})
|
||||
} else {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "Something went wrong!",
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (step > 1) {
|
||||
prevStep()
|
||||
} else {
|
||||
handleClose()
|
||||
if (openedFrom === "grid") setGridIsOpen(true)
|
||||
useEffect(() => {
|
||||
const errorCode = searchParams.get("errorCode")
|
||||
const ancillary = searchParams.get("ancillary")
|
||||
if ((errorCode && ancillary) || errorCode === "AncillaryFailed") {
|
||||
const queryParams = new URLSearchParams(searchParams.toString())
|
||||
if (ancillary) {
|
||||
queryParams.delete("ancillary")
|
||||
}
|
||||
queryParams.delete("errorCode")
|
||||
const savedData = getAncillarySessionData()
|
||||
if (savedData?.formData) {
|
||||
formMethods.reset(savedData.formData)
|
||||
}
|
||||
router.replace(`${pathname}?${queryParams.toString()}`)
|
||||
}
|
||||
}, [searchParams, pathname, formMethods, router])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.loading}>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
reset()
|
||||
resetStore()
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!selectedAncillary) return null
|
||||
|
||||
const confirmLabel = intl.formatMessage({ id: "Confirm" })
|
||||
const continueLabel = intl.formatMessage({ id: "Continue" })
|
||||
const confirmStep =
|
||||
isMobile || (!isMobile && !selectedAncillary.requiresDeliveryTime) ? 2 : 3
|
||||
|
||||
const modalTitle =
|
||||
currentStep === AncillaryStepEnum.selectAncillary
|
||||
? intl.formatMessage({ id: "Upgrade your stay" })
|
||||
: selectedAncillary?.title
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onToggle={handleClose}
|
||||
title={selectedAncillary.title}
|
||||
>
|
||||
<div className={styles.modalWrapper}>
|
||||
<Modal isOpen={true} onToggle={closeModal} title={modalTitle}>
|
||||
<div
|
||||
className={`${styles.modalWrapper} ${currentStep === AncillaryStepEnum.selectAncillary ? styles.selectAncillarycontainer : ""}`}
|
||||
>
|
||||
<FormProvider {...formMethods}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
|
||||
<form
|
||||
onSubmit={formMethods.handleSubmit(onSubmit)}
|
||||
className={styles.form}
|
||||
id="add-ancillary-form-id"
|
||||
>
|
||||
<div className={styles.modalScrollable}>
|
||||
<div className={styles.imageContainer}>
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={selectedAncillary.imageUrl}
|
||||
alt={selectedAncillary.title}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.contentContainer}>
|
||||
<div className={styles.price}>
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
selectedAncillary.price.total,
|
||||
selectedAncillary.price.currency
|
||||
)}
|
||||
</Body>
|
||||
{selectedAncillary.points && (
|
||||
<>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{value} points" },
|
||||
{
|
||||
value: selectedAncillary.points,
|
||||
}
|
||||
{selectedAncillary && (
|
||||
<>
|
||||
<div className={styles.imageContainer}>
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={selectedAncillary.imageUrl}
|
||||
alt={selectedAncillary.title}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
{currentStep !== AncillaryStepEnum.confirmation && (
|
||||
<div className={styles.contentContainer}>
|
||||
<div className={styles.price}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{formatPrice(
|
||||
intl,
|
||||
selectedAncillary.price.total,
|
||||
selectedAncillary.price.currency
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
{selectedAncillary.points && (
|
||||
<div className={styles.divider}>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{ id: "{value} points" },
|
||||
{
|
||||
value: selectedAncillary.points,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{selectedAncillary.description && (
|
||||
<Body asChild color="uiTextHighContrast">
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: selectedAncillary.description,
|
||||
}}
|
||||
/>
|
||||
</Body>
|
||||
)}
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<>
|
||||
{step === 1 && (
|
||||
<>
|
||||
<SelectQuantityStep user={user} />
|
||||
{selectedAncillary.requiresDeliveryTime && (
|
||||
<DeliveryMethodStep
|
||||
deliveryTimeOptions={deliveryTimeOptions}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{step === 2 && <ConfirmationStep />}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{step === 1 && <SelectQuantityStep user={user} />}
|
||||
{step === 2 && selectedAncillary.requiresDeliveryTime && (
|
||||
<DeliveryMethodStep
|
||||
deliveryTimeOptions={deliveryTimeOptions}
|
||||
/>
|
||||
)}
|
||||
{(step === 3 ||
|
||||
(step === 2 &&
|
||||
!selectedAncillary.requiresDeliveryTime)) && (
|
||||
<ConfirmationStep />
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
{selectedAncillary.description && (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: selectedAncillary.description,
|
||||
}}
|
||||
></p>
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.actionButtons}>
|
||||
<Button
|
||||
type="button"
|
||||
theme="base"
|
||||
intent="text"
|
||||
size="small"
|
||||
onClick={handleBack}
|
||||
>
|
||||
{intl.formatMessage({ id: "Back" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
intent={step === confirmStep ? "primary" : "secondary"}
|
||||
size="small"
|
||||
disabled={formState.isSubmitting}
|
||||
onClick={
|
||||
step === confirmStep
|
||||
? () => handleSubmit(onSubmit)()
|
||||
: handleNextStep
|
||||
}
|
||||
>
|
||||
{step === confirmStep ? confirmLabel : continueLabel}
|
||||
</Button>
|
||||
<Steps user={user} savedCreditCards={savedCreditCards} />
|
||||
</div>
|
||||
</form>
|
||||
{currentStep === AncillaryStepEnum.selectAncillary ? null : (
|
||||
<div
|
||||
className={
|
||||
currentStep === AncillaryStepEnum.confirmation
|
||||
? styles.confirmStep
|
||||
: styles.actionButtons
|
||||
}
|
||||
>
|
||||
<PriceDetails isPriceDetailsOpen={isPriceDetailsOpen} />
|
||||
<ActionButtons
|
||||
isPriceDetailsOpen={isPriceDetailsOpen}
|
||||
togglePriceDetails={togglePriceDetails}
|
||||
isSubmitting={addAncillary.isPending || isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</FormProvider>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
export default function AncillaryFlowModalWrapper({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
const isOpen = useAddAncillaryStore((state) => state.isOpen)
|
||||
return isOpen ? <>{children}</> : null
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
.modalContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
margin-bottom: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x-one-and-half);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import {
|
||||
bookingTermsAndConditions,
|
||||
privacyPolicy,
|
||||
} from "@/constants/currentWebHrefs"
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import { CreditCard } from "@/components/Icons"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./confirmationStep.module.css"
|
||||
|
||||
export default function ConfirmationStep() {
|
||||
const { watch } = useFormContext()
|
||||
const { selectedAncillary } = useAddAncillaryStore()
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const quantityWithPoints = watch("quantityWithPoints")
|
||||
const quantityWithCard = watch("quantityWithCard")
|
||||
|
||||
if (!selectedAncillary) {
|
||||
return null
|
||||
}
|
||||
|
||||
const totalPrice = quantityWithCard
|
||||
? selectedAncillary.price.total * quantityWithCard
|
||||
: null
|
||||
|
||||
const totalPoints =
|
||||
quantityWithPoints && selectedAncillary.points
|
||||
? selectedAncillary.points * quantityWithPoints
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className={styles.modalContent}>
|
||||
<header>
|
||||
<Subtitle type="two">
|
||||
{intl.formatMessage({
|
||||
id: "Reserve with Card",
|
||||
})}
|
||||
</Subtitle>
|
||||
</header>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "Payment will be made on check-in. The card will be only used to guarantee the ancillary in case of no-show.",
|
||||
})}
|
||||
</Body>
|
||||
<div className={styles.card}>
|
||||
<CreditCard color="black" />
|
||||
<Body textTransform="bold">{"MasterCard"}</Body>
|
||||
<Body color="uiTextMediumContrast">{"**** 1234"}</Body>
|
||||
</div>
|
||||
<Checkbox
|
||||
name="termsAndConditions"
|
||||
registerOptions={{ required: true }}
|
||||
topAlign
|
||||
>
|
||||
<Caption>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "Yes, I accept the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. There you can learn more about what data we process, your rights and where to turn if you have questions.",
|
||||
},
|
||||
{
|
||||
termsAndConditionsLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
variant="underscored"
|
||||
href={bookingTermsAndConditions[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
privacyPolicyLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
variant="underscored"
|
||||
href={privacyPolicy[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
|
||||
<div className={styles.price}>
|
||||
<Caption>
|
||||
{intl.formatMessage(
|
||||
{ id: "<b>Total price</b> (incl VAT)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</Caption>
|
||||
{totalPrice !== null && (
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{formatPrice(intl, totalPrice, selectedAncillary.price.currency)}
|
||||
</Body>
|
||||
)}
|
||||
{totalPoints !== null && (
|
||||
<div>
|
||||
<div>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
</div>
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{value} points" },
|
||||
{ value: totalPoints }
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
.selectContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-quarter);
|
||||
margin-bottom: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
|
||||
.totalPointsContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: var(--Scandic-Peach-10);
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
|
||||
.totalPoints {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import { AncillaryCard } from "@/components/TempDesignSystem/AncillaryCard"
|
||||
|
||||
import type { SelectedAncillary } from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
interface WrappedAncillaryProps {
|
||||
ancillary: SelectedAncillary
|
||||
}
|
||||
|
||||
export default function WrappedAncillaryCard({
|
||||
ancillary,
|
||||
}: WrappedAncillaryProps) {
|
||||
const { description, ...ancillaryWithoutDescription } = ancillary
|
||||
const selectAncillary = useAddAncillaryStore((state) => state.selectAncillary)
|
||||
return (
|
||||
<div role="button" onClick={() => selectAncillary(ancillary)}>
|
||||
<AncillaryCard ancillary={ancillaryWithoutDescription} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const ancillaryFormSchema = z
|
||||
.object({
|
||||
quantityWithPoints: z.number().nullable(),
|
||||
quantityWithCard: z.number().nullable(),
|
||||
deliveryTime: z.string().nullable().optional(),
|
||||
optionalText: z.string().optional(),
|
||||
termsAndConditions: z
|
||||
.boolean()
|
||||
.refine((val) => val, "You must accept the terms"),
|
||||
})
|
||||
import { nullableStringValidator } from "@/utils/zod/stringValidator"
|
||||
|
||||
const quantitySchemaWithoutRefine = z.object({
|
||||
quantityWithPoints: z.number().nullable(),
|
||||
quantityWithCard: z.number().nullable(),
|
||||
})
|
||||
export const quantitySchema = z
|
||||
.object({})
|
||||
.merge(quantitySchemaWithoutRefine)
|
||||
.refine(
|
||||
(data) =>
|
||||
(data.quantityWithPoints ?? 0) > 0 || (data.quantityWithCard ?? 0) > 0,
|
||||
@@ -19,4 +18,21 @@ export const ancillaryFormSchema = z
|
||||
}
|
||||
)
|
||||
|
||||
export type AncillaryFormData = z.infer<typeof ancillaryFormSchema>
|
||||
export const ancillaryFormSchema = z
|
||||
.object({
|
||||
deliveryTime: z.string(),
|
||||
optionalText: z.string(),
|
||||
termsAndConditions: z.boolean(),
|
||||
paymentMethod: nullableStringValidator,
|
||||
})
|
||||
.merge(quantitySchemaWithoutRefine)
|
||||
.refine(
|
||||
(data) =>
|
||||
(data.quantityWithPoints ?? 0) > 0 || (data.quantityWithCard ?? 0) > 0,
|
||||
{
|
||||
message: "You must select at least one quantity",
|
||||
path: ["quantityWithCard"],
|
||||
}
|
||||
)
|
||||
|
||||
export type AncillaryFormData = z.output<typeof ancillaryFormSchema>
|
||||
|
||||
@@ -40,13 +40,14 @@ export function AddedAncillaries({
|
||||
</div>
|
||||
|
||||
{booking.ancillaries.map((ancillary) => {
|
||||
const ancillaryItem = ancillaries?.find((a) => a.id === ancillary.code)
|
||||
const ancillaryTitle =
|
||||
ancillaries?.find((a) => a.id === ancillary.code)?.title ?? ""
|
||||
|
||||
return (
|
||||
<>
|
||||
<Accordion className={styles.ancillaryMobile}>
|
||||
<AccordionItem
|
||||
title={ancillaryItem?.title ?? ""}
|
||||
title={ancillaryTitle}
|
||||
icon={<CheckCircleIcon color="uiSemanticSuccess" />}
|
||||
>
|
||||
<div>
|
||||
@@ -93,8 +94,8 @@ export function AddedAncillaries({
|
||||
<RemoveButton
|
||||
confirmationNumber={booking.confirmationNumber}
|
||||
code={ancillary.code}
|
||||
title={ancillaryItem?.title}
|
||||
onSuccess={() => router.refresh()}
|
||||
title={ancillaryTitle}
|
||||
onSuccess={router.refresh}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -105,7 +106,7 @@ export function AddedAncillaries({
|
||||
<div className={styles.specification}>
|
||||
<div className={styles.name}>
|
||||
<CheckCircleIcon color="uiSemanticSuccess" />
|
||||
<Body textTransform="bold">{ancillaryItem?.title}</Body>
|
||||
<Body textTransform="bold">{ancillaryTitle}</Body>
|
||||
<Body textTransform="bold">{`X${ancillary.totalUnit}`}</Body>
|
||||
</div>
|
||||
<div className={styles.payment}>
|
||||
@@ -149,8 +150,8 @@ export function AddedAncillaries({
|
||||
<RemoveButton
|
||||
confirmationNumber={booking.confirmationNumber}
|
||||
code={ancillary.code}
|
||||
title={ancillaryItem?.title}
|
||||
onSuccess={() => router.refresh()}
|
||||
title={ancillaryTitle}
|
||||
onSuccess={router.refresh}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
.modalTrigger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x3) 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(251px, 1fr));
|
||||
gap: var(--Spacing-x2);
|
||||
height: 470px;
|
||||
overflow-y: auto;
|
||||
padding-right: var(--Spacing-x-one-and-half);
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.chip {
|
||||
border-radius: 28px;
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: var(--Base-Surface-Subtle-Normal);
|
||||
}
|
||||
|
||||
.chip.selected {
|
||||
background: var(--Base-Text-High-contrast);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.modalContent {
|
||||
width: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1052px) {
|
||||
.modalContent {
|
||||
width: 833px;
|
||||
}
|
||||
|
||||
.modalTrigger {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import Modal from "@/components/Modal"
|
||||
import { AncillaryCard } from "@/components/TempDesignSystem/AncillaryCard"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import styles from "./ancillaryGridModal.module.css"
|
||||
|
||||
import type {
|
||||
Ancillary,
|
||||
AncillaryGridModalProps,
|
||||
} from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
export default function AncillaryGridModal({
|
||||
ancillaries,
|
||||
selectedCategory,
|
||||
setSelectedCategory,
|
||||
handleCardClick,
|
||||
}: AncillaryGridModalProps) {
|
||||
const intl = useIntl()
|
||||
const { isGridOpen, setGridIsOpen, setOpenedFrom } = useAddAncillaryStore()
|
||||
|
||||
const handleClick = (ancillary: Ancillary["ancillaryContent"][number]) => {
|
||||
handleCardClick(ancillary)
|
||||
setOpenedFrom("grid")
|
||||
setGridIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.modalTrigger}>
|
||||
<Button
|
||||
theme="base"
|
||||
variant="icon"
|
||||
intent="text"
|
||||
size="small"
|
||||
onClick={() => setGridIsOpen(true)}
|
||||
>
|
||||
{intl.formatMessage({ id: "View all" })}
|
||||
<ChevronRightSmallIcon
|
||||
width={20}
|
||||
height={20}
|
||||
color="baseButtonTextOnFillNormal"
|
||||
/>
|
||||
</Button>
|
||||
<Modal
|
||||
isOpen={isGridOpen}
|
||||
onToggle={() => setGridIsOpen(!isGridOpen)}
|
||||
title={intl.formatMessage({ id: "Upgrade your stay" })}
|
||||
>
|
||||
<div className={styles.modalContent}>
|
||||
<div className={styles.tabs}>
|
||||
{ancillaries.map((category) => (
|
||||
<button
|
||||
key={category.categoryName}
|
||||
className={`${styles.chip} ${category.categoryName === selectedCategory ? styles.selected : ""}`}
|
||||
onClick={() => setSelectedCategory(category.categoryName)}
|
||||
>
|
||||
<Body
|
||||
color={
|
||||
category.categoryName === selectedCategory
|
||||
? "pale"
|
||||
: "baseTextHighContrast"
|
||||
}
|
||||
>
|
||||
{category.categoryName}
|
||||
</Body>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.grid}>
|
||||
{ancillaries
|
||||
.find((category) => category.categoryName === selectedCategory)
|
||||
?.ancillaryContent.map(({ description, ...ancillary }) => (
|
||||
<div
|
||||
key={ancillary.id}
|
||||
onClick={() => handleClick({ description, ...ancillary })}
|
||||
>
|
||||
<AncillaryCard key={ancillary.id} ancillary={ancillary} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
import { clearAncillarySessionData, getAncillarySessionData } from "../utils"
|
||||
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
export default function GuaranteeAncillaryHandler({
|
||||
confirmationNumber,
|
||||
returnUrl,
|
||||
lang,
|
||||
}: {
|
||||
confirmationNumber: string
|
||||
returnUrl: string
|
||||
lang: Lang
|
||||
}) {
|
||||
const router = useRouter()
|
||||
|
||||
const addAncillary = trpc.booking.packages.useMutation({
|
||||
onSuccess: () => {
|
||||
clearAncillarySessionData()
|
||||
router.replace(returnUrl)
|
||||
},
|
||||
onError: () => {
|
||||
router.replace(`${returnUrl}&errorCode=AncillaryFailed`)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (addAncillary.isPending || addAncillary.submittedAt) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionData = getAncillarySessionData()
|
||||
if (!sessionData?.formData || !sessionData?.selectedAncillary) {
|
||||
router.replace(`${returnUrl}&errorCode=AncillaryFailed`)
|
||||
return
|
||||
}
|
||||
|
||||
const { formData, selectedAncillary } = sessionData
|
||||
const packages = []
|
||||
|
||||
if (selectedAncillary?.id && formData.quantityWithCard) {
|
||||
packages.push({
|
||||
code: selectedAncillary.id,
|
||||
quantity: formData.quantityWithCard,
|
||||
comment: formData.optionalText || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (selectedAncillary?.loyaltyCode && formData.quantityWithPoints) {
|
||||
packages.push({
|
||||
code: selectedAncillary.loyaltyCode,
|
||||
quantity: formData.quantityWithPoints,
|
||||
comment: formData.optionalText || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
addAncillary.mutate({
|
||||
confirmationNumber,
|
||||
ancillaryComment: formData.optionalText,
|
||||
ancillaryDeliveryTime: selectedAncillary.requiresDeliveryTime
|
||||
? formData.deliveryTime
|
||||
: undefined,
|
||||
packages,
|
||||
language: lang,
|
||||
})
|
||||
}, [confirmationNumber, returnUrl, addAncillary, lang, router])
|
||||
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
export default function ViewAllAncillaries() {
|
||||
const intl = useIntl()
|
||||
const openModal = useAddAncillaryStore((state) => state.openModal)
|
||||
return (
|
||||
<Button
|
||||
theme="base"
|
||||
variant="icon"
|
||||
intent="text"
|
||||
size="small"
|
||||
onClick={openModal}
|
||||
>
|
||||
{intl.formatMessage({ id: "View all" })}
|
||||
<ChevronRightSmallIcon
|
||||
width={20}
|
||||
height={20}
|
||||
color="baseButtonTextOnFillNormal"
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client"
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import { Carousel } from "@/components/Carousel"
|
||||
import { AncillaryCard } from "@/components/TempDesignSystem/AncillaryCard"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { AddAncillaryProvider } from "@/providers/AddAncillaryProvider"
|
||||
|
||||
import AddAncillaryFlowModal from "./AddAncillaryFlow/AddAncillaryFlowModal"
|
||||
import AncillaryFlowModalWrapper from "./AddAncillaryFlow/AncillaryFlowModalWrapper"
|
||||
import WrappedAncillaryCard from "./AddAncillaryFlow/WrappedAncillaryCard"
|
||||
import { AddedAncillaries } from "./AddedAncillaries"
|
||||
import AncillaryGridModal from "./AncillaryGridModal"
|
||||
import ViewAllAncillaries from "./ViewAllAncillaries"
|
||||
|
||||
import styles from "./ancillaries.module.css"
|
||||
|
||||
@@ -20,110 +19,89 @@ import type {
|
||||
Ancillary,
|
||||
} from "@/types/components/myPages/myStay/ancillaries"
|
||||
|
||||
export function Ancillaries({ ancillaries, booking, user }: AncillariesProps) {
|
||||
export function Ancillaries({
|
||||
ancillaries,
|
||||
booking,
|
||||
user,
|
||||
savedCreditCards,
|
||||
refId,
|
||||
}: AncillariesProps) {
|
||||
const intl = useIntl()
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(
|
||||
() => {
|
||||
return ancillaries?.[0]?.categoryName ?? null
|
||||
}
|
||||
)
|
||||
|
||||
const { setSelectedAncillary, setConfirmationNumber, setOpenedFrom } =
|
||||
useAddAncillaryStore()
|
||||
const [isModalOpen, setModalOpen] = useState(false)
|
||||
|
||||
if (!ancillaries?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
function mergeAncillaries(
|
||||
function filterPoints(ancillaries: Ancillaries) {
|
||||
return ancillaries.map((ancillary) => {
|
||||
return {
|
||||
...ancillary,
|
||||
ancillaryContent: ancillary.ancillaryContent.map(
|
||||
({ points, ...ancillary }) => ({
|
||||
...ancillary,
|
||||
points: user ? points : undefined,
|
||||
})
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function generateUniqueAncillaries(
|
||||
ancillaries: Ancillaries
|
||||
): Ancillary["ancillaryContent"] {
|
||||
const uniqueAncillaries = new Map(
|
||||
ancillaries
|
||||
.flatMap((category) => category.ancillaryContent)
|
||||
.map((ancillary) => [ancillary.id, ancillary])
|
||||
ancillaries.flatMap((a) =>
|
||||
a.ancillaryContent.map((ancillary) => [ancillary.id, ancillary])
|
||||
)
|
||||
)
|
||||
return [...uniqueAncillaries.values()]
|
||||
}
|
||||
|
||||
const allAncillaries = mergeAncillaries(ancillaries)
|
||||
|
||||
const handleCardClick = (
|
||||
ancillary: Ancillary["ancillaryContent"][number]
|
||||
) => {
|
||||
if (booking?.confirmationNumber) {
|
||||
setConfirmationNumber(booking.confirmationNumber)
|
||||
}
|
||||
setSelectedAncillary(ancillary)
|
||||
setOpenedFrom("list")
|
||||
setModalOpen(true)
|
||||
}
|
||||
const allAncillaries = filterPoints(ancillaries)
|
||||
const uniqueAncillaries = generateUniqueAncillaries(allAncillaries)
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.title}>
|
||||
<Title as="h5">{intl.formatMessage({ id: "Upgrade your stay" })}</Title>
|
||||
<AncillaryGridModal
|
||||
ancillaries={ancillaries}
|
||||
selectedCategory={selectedCategory}
|
||||
setSelectedCategory={setSelectedCategory}
|
||||
handleCardClick={handleCardClick}
|
||||
/>
|
||||
<AddAncillaryProvider booking={booking} ancillaries={allAncillaries}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.title}>
|
||||
<Title as="h5">
|
||||
{intl.formatMessage({ id: "Upgrade your stay" })}
|
||||
</Title>
|
||||
<ViewAllAncillaries />
|
||||
</div>
|
||||
|
||||
<div className={styles.ancillaries}>
|
||||
{uniqueAncillaries.slice(0, 4).map((ancillary) => (
|
||||
<WrappedAncillaryCard ancillary={ancillary} key={ancillary.id} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.mobileAncillaries}>
|
||||
<Carousel>
|
||||
<Carousel.Content className={styles.carouselContainer}>
|
||||
{uniqueAncillaries.map((ancillary) => {
|
||||
return (
|
||||
<Carousel.Item key={ancillary.id}>
|
||||
<WrappedAncillaryCard ancillary={ancillary} />
|
||||
</Carousel.Item>
|
||||
)
|
||||
})}
|
||||
</Carousel.Content>
|
||||
<Carousel.Previous className={styles.navigationButton} />
|
||||
<Carousel.Next className={styles.navigationButton} />
|
||||
<Carousel.Dots />
|
||||
</Carousel>
|
||||
</div>
|
||||
|
||||
<AddedAncillaries booking={booking} ancillaries={uniqueAncillaries} />
|
||||
<AncillaryFlowModalWrapper>
|
||||
<AddAncillaryFlowModal
|
||||
user={user}
|
||||
booking={booking}
|
||||
refId={refId}
|
||||
savedCreditCards={savedCreditCards}
|
||||
/>
|
||||
</AncillaryFlowModalWrapper>
|
||||
</div>
|
||||
|
||||
<div className={styles.ancillaries}>
|
||||
{allAncillaries
|
||||
.slice(0, 4)
|
||||
.map(({ description, points, ...ancillary }) => {
|
||||
const ancillaryData = !!user ? { points, ...ancillary } : ancillary
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ancillary.id}
|
||||
onClick={() =>
|
||||
handleCardClick({ description, points, ...ancillary })
|
||||
}
|
||||
>
|
||||
<AncillaryCard key={ancillary.id} ancillary={ancillaryData} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.mobileAncillaries}>
|
||||
<Carousel>
|
||||
<Carousel.Content className={styles.carouselContainer}>
|
||||
{allAncillaries.map(({ description, points, ...ancillary }) => {
|
||||
const ancillaryData = !!user
|
||||
? { points, ...ancillary }
|
||||
: ancillary
|
||||
return (
|
||||
<Carousel.Item
|
||||
key={ancillary.id}
|
||||
onClick={() =>
|
||||
handleCardClick({ description, points, ...ancillary })
|
||||
}
|
||||
>
|
||||
<AncillaryCard key={ancillary.id} ancillary={ancillaryData} />
|
||||
</Carousel.Item>
|
||||
)
|
||||
})}
|
||||
</Carousel.Content>
|
||||
<Carousel.Previous className={styles.navigationButton} />
|
||||
<Carousel.Next className={styles.navigationButton} />
|
||||
<Carousel.Dots />
|
||||
</Carousel>
|
||||
</div>
|
||||
|
||||
<AddedAncillaries booking={booking} ancillaries={allAncillaries} />
|
||||
|
||||
<AddAncillaryFlowModal
|
||||
user={user}
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
booking={booking}
|
||||
/>
|
||||
</div>
|
||||
</AddAncillaryProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,53 @@
|
||||
import { dt } from "@/lib/dt"
|
||||
import type { Ancillary } from "@/types/components/myPages/myStay/ancillaries"
|
||||
import type { AncillaryFormData } from "./AddAncillaryFlow/schema"
|
||||
|
||||
export const generateDeliveryOptions = (checkInDate: Date) => {
|
||||
const start = dt(checkInDate).startOf("day")
|
||||
export const generateDeliveryOptions = () => {
|
||||
const timeSlots = ["16:00-17:00", "17:00-18:00", "18:00-19:00", "19:00-20:00"]
|
||||
|
||||
return timeSlots.map((slot) => ({
|
||||
label: `${start.format("YYYY-MM-DD")} ${slot}`,
|
||||
value: `${start.format("YYYY-MM-DD")} ${slot}`,
|
||||
label: slot,
|
||||
value: slot,
|
||||
}))
|
||||
}
|
||||
const ancillarySessionKey = "ancillarySessionData"
|
||||
export const getAncillarySessionData = ():
|
||||
| {
|
||||
formData?: AncillaryFormData
|
||||
selectedAncillary?: Ancillary["ancillaryContent"][number] | null
|
||||
}
|
||||
| undefined => {
|
||||
if (typeof window === "undefined") return undefined
|
||||
|
||||
try {
|
||||
const storedData = sessionStorage.getItem(ancillarySessionKey)
|
||||
return storedData ? JSON.parse(storedData) : undefined
|
||||
} catch (error) {
|
||||
console.error("Error reading from session storage:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function setAncillarySessionData({
|
||||
formData,
|
||||
selectedAncillary,
|
||||
}: {
|
||||
formData?: AncillaryFormData
|
||||
selectedAncillary?: Ancillary["ancillaryContent"][number] | null
|
||||
}) {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
try {
|
||||
const currentData = getAncillarySessionData() || {}
|
||||
sessionStorage.setItem(
|
||||
ancillarySessionKey,
|
||||
JSON.stringify({ ...currentData, formData, selectedAncillary })
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Error writing to session storage:", error)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAncillarySessionData() {
|
||||
if (typeof window === "undefined") return
|
||||
sessionStorage.removeItem(ancillarySessionKey)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
} from "@/constants/currentWebHrefs"
|
||||
import { guaranteeCallback } from "@/constants/routes/hotelReservation"
|
||||
import { env } from "@/env/client"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import { ModalContentWithActions } from "@/components/Modal/ModalContentWithActions"
|
||||
@@ -23,7 +21,7 @@ import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||
import { useGuaranteeBooking } from "@/hooks/booking/useGuaranteeBooking"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
@@ -36,9 +34,6 @@ import styles from "./guaranteeLateArrival.module.css"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
import type { CreditCard } from "@/types/user"
|
||||
|
||||
const maxRetries = 15
|
||||
const retryInterval = 2000
|
||||
|
||||
export interface GuaranteeLateArrivalProps {
|
||||
booking: BookingConfirmation["booking"]
|
||||
handleCloseModal: () => void
|
||||
@@ -57,6 +52,7 @@ export default function GuaranteeLateArrival({
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const router = useRouter()
|
||||
|
||||
const methods = useForm<GuaranteeFormData>({
|
||||
defaultValues: {
|
||||
paymentMethod: savedCreditCards?.length
|
||||
@@ -68,56 +64,14 @@ export default function GuaranteeLateArrival({
|
||||
reValidateMode: "onChange",
|
||||
resolver: zodResolver(paymentSchema),
|
||||
})
|
||||
const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
|
||||
useState(false)
|
||||
const guaranteeRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}${guaranteeCallback(lang)}`
|
||||
|
||||
const handlePaymentError = useCallback(() => {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "We had an issue guaranteeing your booking. Please try again.",
|
||||
})
|
||||
)
|
||||
}, [intl])
|
||||
|
||||
const guaranteeBooking = trpc.booking.guarantee.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result) {
|
||||
setIsPollingForBookingStatus(true)
|
||||
} else {
|
||||
handlePaymentError()
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "Something went wrong!",
|
||||
})
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const bookingStatus = useHandleBookingStatus({
|
||||
const { guaranteeBooking, isLoading } = useGuaranteeBooking({
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
expectedStatuses: [BookingStatusEnum.BookingCompleted],
|
||||
maxRetries,
|
||||
retryInterval,
|
||||
enabled: isPollingForBookingStatus,
|
||||
handleBookingCompleted: router.refresh,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingStatus?.data?.paymentUrl) {
|
||||
router.push(bookingStatus.data.paymentUrl)
|
||||
} else if (bookingStatus.isTimeout) {
|
||||
handlePaymentError()
|
||||
}
|
||||
}, [bookingStatus, router, intl, handlePaymentError])
|
||||
|
||||
if (
|
||||
guaranteeBooking.isPending ||
|
||||
(isPollingForBookingStatus &&
|
||||
!bookingStatus.data?.paymentUrl &&
|
||||
!bookingStatus.isTimeout)
|
||||
) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.loading}>
|
||||
<LoadingSpinner />
|
||||
@@ -129,7 +83,6 @@ export default function GuaranteeLateArrival({
|
||||
const savedCreditCard = savedCreditCards?.find(
|
||||
(card) => card.id === data.paymentMethod
|
||||
)
|
||||
const guaranteeRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}${guaranteeCallback(lang)}`
|
||||
if (booking.confirmationNumber) {
|
||||
const card = savedCreditCard
|
||||
? {
|
||||
@@ -142,14 +95,14 @@ export default function GuaranteeLateArrival({
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
language: lang,
|
||||
...(card !== undefined && { card }),
|
||||
success: `${guaranteeRedirectUrl}/success/${encodeURIComponent(refId)}`,
|
||||
error: `${guaranteeRedirectUrl}/error/${encodeURIComponent(refId)}`,
|
||||
cancel: `${guaranteeRedirectUrl}/cancel/${encodeURIComponent(refId)}`,
|
||||
success: `${guaranteeRedirectUrl}?status=success&RefId=${encodeURIComponent(refId)}`,
|
||||
error: `${guaranteeRedirectUrl}?status=error&RefId=${encodeURIComponent(refId)}`,
|
||||
cancel: `${guaranteeRedirectUrl}?status=cancel&RefId=${encodeURIComponent(refId)}`,
|
||||
})
|
||||
} else {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "Confirmation number is missing!",
|
||||
id: "Something went wrong!",
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { BookingStatusEnum } from "@/constants/booking"
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import { BookingCodeIcon } from "@/components/Icons"
|
||||
import { BookingCodeIcon, CheckCircleIcon } from "@/components/Icons"
|
||||
import CrossCircleIcon from "@/components/Icons/CrossCircle"
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
@@ -149,6 +151,21 @@ export function ReferenceCard({
|
||||
{`${toDate.format("dddd, D MMMM")} ${intl.formatMessage({ id: "until" })} ${toDate.format("HH:mm")}`}
|
||||
</Caption>
|
||||
</div>
|
||||
{booking.guaranteeInfo && (
|
||||
<div className={styles.guaranteed}>
|
||||
<CheckCircleIcon color="green" height={20} width={20} />
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p className={styles.guaranteedText}>
|
||||
<strong>
|
||||
{intl.formatMessage({ id: "Booking guaranteed." })}
|
||||
</strong>{" "}
|
||||
{intl.formatMessage({
|
||||
id: "Your stay remains available for check-in after 18:00.",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
<Divider color="primaryLightSubtle" className={styles.divider} />
|
||||
<div className={styles.referenceRow}>
|
||||
<Caption
|
||||
|
||||
@@ -40,6 +40,19 @@
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.guaranteed {
|
||||
align-items: flex-start;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: flex;
|
||||
background-color: var(--Surface-Feedback-Succes);
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x1);
|
||||
margin-bottom: var(--Space-x1);
|
||||
}
|
||||
.guaranteedText {
|
||||
color: var(--Surface-Feedback-Succes-Accent);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.titleMobile {
|
||||
display: none;
|
||||
|
||||
@@ -104,6 +104,8 @@ export async function MyStay({ refId }: { refId: string }) {
|
||||
ancillaries={ancillaryPackages}
|
||||
booking={booking}
|
||||
user={user}
|
||||
savedCreditCards={savedCreditCards}
|
||||
refId={refId}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
5
apps/scandic-web/contexts/AddAncillary.ts
Normal file
5
apps/scandic-web/contexts/AddAncillary.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createContext } from "react"
|
||||
|
||||
import type { AddAncillaryStore } from "@/types/contexts/add-ancillary"
|
||||
|
||||
export const AddAncillaryContext = createContext<AddAncillaryStore | null>(null)
|
||||
78
apps/scandic-web/hooks/booking/useGuaranteeBooking.ts
Normal file
78
apps/scandic-web/hooks/booking/useGuaranteeBooking.ts
Normal 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 }
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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>Scandic’s 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}. 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 updated": "Dit ophold blev opdateret",
|
||||
|
||||
@@ -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>Scandic’s 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}. 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 updated": "Ihr Aufenthalt wurde aktualisiert",
|
||||
|
||||
@@ -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>Scandic’s 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>Scandic’s 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}. 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 updated": "Your stay was updated",
|
||||
|
||||
@@ -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>Scandic’s 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}. 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 updated": "Majoituspäivät päivitettiin",
|
||||
|
||||
@@ -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>Scandic’s 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}. 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 updated": "Ditt ophold ble oppdatert",
|
||||
|
||||
@@ -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>Scandic’s 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}. 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 ut",
|
||||
"Your stay was updated": "Din vistelse uppdaterades",
|
||||
|
||||
@@ -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,
|
||||
|
||||
46
apps/scandic-web/providers/AddAncillaryProvider.tsx
Normal file
46
apps/scandic-web/providers/AddAncillaryProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
3
apps/scandic-web/types/contexts/add-ancillary.ts
Normal file
3
apps/scandic-web/types/contexts/add-ancillary.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { createAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
export type AddAncillaryStore = ReturnType<typeof createAddAncillaryStore>
|
||||
@@ -37,6 +37,7 @@ export interface InitialRoomData {
|
||||
roomRate: RoomRate
|
||||
roomType: string
|
||||
roomTypeCode: string
|
||||
memberMustBeGuaranteed?: boolean
|
||||
}
|
||||
|
||||
export type RoomStep = {
|
||||
|
||||
Reference in New Issue
Block a user