Merged in chore/move-enter-details (pull request #2778)
Chore/move enter details Approved-by: Anton Gunnarsson
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
.wrapper {
|
||||
margin-top: var(--Spacing-x3);
|
||||
max-width: min(100%, 620px);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
||||
import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
|
||||
import { Alert } from "@scandic-hotels/design-system/Alert"
|
||||
import { BookingErrorCodeEnum } from "@scandic-hotels/trpc/enums/bookingErrorCode"
|
||||
|
||||
import useLang from "../../../../hooks/useLang"
|
||||
import { useEnterDetailsStore } from "../../../../stores/enter-details"
|
||||
|
||||
import styles from "./bookingAlert.module.css"
|
||||
|
||||
function useBookingErrorAlert() {
|
||||
const updateSearchParams = useEnterDetailsStore(
|
||||
(state) => state.actions.updateSeachParamString
|
||||
)
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
|
||||
const errorCode = searchParams.get("errorCode")
|
||||
const errorMessage = getErrorMessage(errorCode)
|
||||
const severityLevel =
|
||||
errorCode === BookingErrorCodeEnum.TransactionCancelled
|
||||
? AlertTypeEnum.Warning
|
||||
: AlertTypeEnum.Alarm
|
||||
|
||||
const [showAlert, setShowAlert] = useState(!!errorCode)
|
||||
|
||||
const selectRateReturnUrl = getSelectRateReturnUrl()
|
||||
|
||||
useEffect(() => {
|
||||
setShowAlert(!!errorCode)
|
||||
}, [errorCode])
|
||||
|
||||
function getErrorMessage(errorCode: string | null) {
|
||||
switch (errorCode) {
|
||||
case BookingErrorCodeEnum.TransactionCancelled:
|
||||
return intl.formatMessage({
|
||||
defaultMessage: "You have now cancelled your payment.",
|
||||
})
|
||||
case BookingErrorCodeEnum.AvailabilityError:
|
||||
case BookingErrorCodeEnum.NoAvailabilityForRateAndRoomType:
|
||||
return intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Unfortunately, one of the rooms you selected is sold out. Please choose another room to proceed.",
|
||||
})
|
||||
default:
|
||||
return intl.formatMessage({
|
||||
defaultMessage:
|
||||
"We had an issue processing your booking. Please try again. No charges have been made.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function discardAlert() {
|
||||
setShowAlert(false)
|
||||
const queryParams = new URLSearchParams(searchParams.toString())
|
||||
queryParams.delete("errorCode")
|
||||
updateSearchParams(queryParams.toString())
|
||||
|
||||
window.history.replaceState({}, "", `${pathname}?${queryParams.toString()}`)
|
||||
}
|
||||
|
||||
function getSelectRateReturnUrl() {
|
||||
const queryParams = new URLSearchParams(searchParams.toString())
|
||||
queryParams.delete("errorCode")
|
||||
return `${selectRate(lang)}?${queryParams.toString()}`
|
||||
}
|
||||
|
||||
return {
|
||||
showAlert,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
severityLevel,
|
||||
discardAlert,
|
||||
setShowAlert,
|
||||
selectRateReturnUrl,
|
||||
}
|
||||
}
|
||||
|
||||
interface BookingAlertProps {
|
||||
isVisible?: boolean
|
||||
}
|
||||
|
||||
export default function BookingAlert({ isVisible = false }: BookingAlertProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const {
|
||||
showAlert,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
severityLevel,
|
||||
discardAlert,
|
||||
setShowAlert,
|
||||
selectRateReturnUrl,
|
||||
} = useBookingErrorAlert()
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const { getTopOffset } = useStickyPosition()
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
setShowAlert(true)
|
||||
}
|
||||
}, [isVisible, setShowAlert])
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
|
||||
if (showAlert && el) {
|
||||
window.scrollTo({
|
||||
top: el.offsetTop - getTopOffset(),
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
}, [showAlert, getTopOffset])
|
||||
|
||||
if (!showAlert) return null
|
||||
|
||||
const isAvailabilityError =
|
||||
errorCode === BookingErrorCodeEnum.AvailabilityError ||
|
||||
errorCode === BookingErrorCodeEnum.NoAvailabilityForRateAndRoomType
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} ref={ref}>
|
||||
<Alert
|
||||
type={severityLevel}
|
||||
variant="inline"
|
||||
text={errorMessage}
|
||||
close={discardAlert}
|
||||
link={
|
||||
isAvailabilityError
|
||||
? {
|
||||
title: intl.formatMessage({
|
||||
defaultMessage: "Change room",
|
||||
}),
|
||||
url: selectRateReturnUrl,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
padding-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.content ol {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.summary::-webkit-details-marker,
|
||||
.summary::marker {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
|
||||
import styles from "./guaranteeDetails.module.css"
|
||||
|
||||
export default function GuaranteeDetails() {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<details>
|
||||
<Caption color="burgundy" type="bold" asChild>
|
||||
<summary className={styles.summary}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "How it works",
|
||||
})}
|
||||
<MaterialIcon
|
||||
icon="keyboard_arrow_down"
|
||||
color="Icon/Interactive/Default"
|
||||
size={16}
|
||||
/>
|
||||
</summary>
|
||||
</Caption>
|
||||
<section className={styles.content}>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"When guaranteeing your booking, we will hold the booking until 07:00 until the day after check-in. This will provide you as a guest with added flexibility for check-in times.",
|
||||
})}
|
||||
</Body>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "What you have to do to guarantee booking:",
|
||||
})}
|
||||
</Body>
|
||||
<ol>
|
||||
<Body asChild>
|
||||
<li>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Complete the booking",
|
||||
})}
|
||||
</li>
|
||||
</Body>
|
||||
<Body asChild>
|
||||
<li>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Provide a payment card in the next step",
|
||||
})}
|
||||
</li>
|
||||
</Body>
|
||||
</ol>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Please note that this is mandatory, and that your card will only be charged in the event of a no-show.",
|
||||
})}
|
||||
</Body>
|
||||
</section>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import React from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
|
||||
import {
|
||||
calculateTotalRoomPrice,
|
||||
hasFlexibleRate,
|
||||
hasPrepaidRate,
|
||||
} from "../helpers"
|
||||
|
||||
import styles from "./mixedRatePaymentBreakdown.module.css"
|
||||
|
||||
import type { RoomState } from "../../../../stores/enter-details/types"
|
||||
|
||||
type PaymentBreakdownState = {
|
||||
roomsWithPrepaidRate: number[]
|
||||
roomsWithFlexRate: number[]
|
||||
payNowPrice: number
|
||||
payNowComparisonPrice: number
|
||||
payAtCheckInPrice: number
|
||||
payAtCheckInComparisonPrice: number
|
||||
}
|
||||
|
||||
interface MixedRatePaymentBreakdownProps {
|
||||
rooms: RoomState[]
|
||||
currency: string
|
||||
}
|
||||
|
||||
export default function MixedRatePaymentBreakdown({
|
||||
rooms,
|
||||
currency,
|
||||
}: MixedRatePaymentBreakdownProps) {
|
||||
const intl = useIntl()
|
||||
const payNowTitle = intl.formatMessage({
|
||||
defaultMessage: "Pay now",
|
||||
})
|
||||
const payAtCheckInTitle = intl.formatMessage({
|
||||
defaultMessage: "Pay at check-in",
|
||||
})
|
||||
|
||||
const initialState: PaymentBreakdownState = {
|
||||
roomsWithPrepaidRate: [],
|
||||
roomsWithFlexRate: [],
|
||||
payNowPrice: 0,
|
||||
payNowComparisonPrice: 0,
|
||||
payAtCheckInPrice: 0,
|
||||
payAtCheckInComparisonPrice: 0,
|
||||
}
|
||||
|
||||
const {
|
||||
roomsWithPrepaidRate,
|
||||
roomsWithFlexRate,
|
||||
payNowPrice,
|
||||
payNowComparisonPrice,
|
||||
payAtCheckInPrice,
|
||||
payAtCheckInComparisonPrice,
|
||||
} = rooms.reduce((acc, room, idx) => {
|
||||
if (hasPrepaidRate(room)) {
|
||||
acc.roomsWithPrepaidRate.push(idx)
|
||||
const { totalPrice, comparisonPrice } = calculateTotalRoomPrice(room)
|
||||
acc.payNowPrice += totalPrice
|
||||
acc.payNowComparisonPrice += comparisonPrice
|
||||
}
|
||||
if (hasFlexibleRate(room)) {
|
||||
acc.roomsWithFlexRate.push(idx)
|
||||
const { totalPrice, comparisonPrice } = calculateTotalRoomPrice(room)
|
||||
acc.payAtCheckInPrice += totalPrice
|
||||
acc.payAtCheckInComparisonPrice += comparisonPrice
|
||||
}
|
||||
return acc
|
||||
}, initialState)
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<PaymentCard
|
||||
title={payNowTitle}
|
||||
price={payNowPrice}
|
||||
comparisonPrice={payNowComparisonPrice}
|
||||
currency={currency}
|
||||
roomIndexes={roomsWithPrepaidRate}
|
||||
/>
|
||||
<PaymentCard
|
||||
title={payAtCheckInTitle}
|
||||
price={payAtCheckInPrice}
|
||||
comparisonPrice={payAtCheckInComparisonPrice}
|
||||
currency={currency}
|
||||
roomIndexes={roomsWithFlexRate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PaymentCardProps {
|
||||
title: string
|
||||
price: number
|
||||
comparisonPrice: number
|
||||
currency: string
|
||||
roomIndexes: number[]
|
||||
}
|
||||
|
||||
function PaymentCard({
|
||||
title,
|
||||
price,
|
||||
comparisonPrice,
|
||||
currency,
|
||||
roomIndexes,
|
||||
}: PaymentCardProps) {
|
||||
const intl = useIntl()
|
||||
const isMemberRateApplied = price < comparisonPrice
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<Caption
|
||||
type="bold"
|
||||
textTransform="uppercase"
|
||||
className={styles.cardTitle}
|
||||
>
|
||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||
{title}{" "}
|
||||
<span>
|
||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||
{"/ "}
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{
|
||||
roomIndex: roomIndexes.map((idx) => idx + 1).join(" & "),
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</Caption>
|
||||
<Body textTransform="bold" className={styles.priceItem}>
|
||||
{formatPrice(intl, price, currency)}
|
||||
{isMemberRateApplied && comparisonPrice ? (
|
||||
<span>{formatPrice(intl, comparisonPrice, currency)}</span>
|
||||
) : null}
|
||||
</Body>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.container {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
background-color: var(--Scandic-Blue-00);
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cardTitle > span {
|
||||
color: var(--UI-Text-Placeholder);
|
||||
}
|
||||
|
||||
.card.inactive {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.priceItem {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.priceItem > span {
|
||||
font-weight: 400;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { PaymentCallbackStatusEnum } from "@scandic-hotels/common/constants/paymentCallbackStatusEnum"
|
||||
import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner"
|
||||
import { trackEvent } from "@scandic-hotels/tracking/base"
|
||||
|
||||
import { detailsStorageName } from "../../../../stores/enter-details"
|
||||
import { useTrackingContext } from "../../../../trackingContext"
|
||||
import { serializeBookingSearchParams } from "../../../../utils/url"
|
||||
import {
|
||||
clearPaymentInfoSessionStorage,
|
||||
readPaymentInfoFromSessionStorage,
|
||||
} from "../helpers"
|
||||
import { clearGlaSessionStorage, readGlaFromSessionStorage } from "./helpers"
|
||||
|
||||
import type { PersistedState } from "../../../../stores/enter-details/types"
|
||||
|
||||
export function HandleErrorCallback({
|
||||
returnUrl,
|
||||
searchObject,
|
||||
status,
|
||||
errorMessage,
|
||||
}: {
|
||||
returnUrl: string
|
||||
searchObject: URLSearchParams
|
||||
status: PaymentCallbackStatusEnum
|
||||
errorMessage?: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const { trackPaymentEvent } = useTrackingContext()
|
||||
useEffect(() => {
|
||||
const bookingData = window.sessionStorage.getItem(detailsStorageName)
|
||||
|
||||
if (bookingData) {
|
||||
const detailsStorage: PersistedState = JSON.parse(bookingData)
|
||||
const searchParams = serializeBookingSearchParams(
|
||||
detailsStorage.booking,
|
||||
{
|
||||
initialSearchParams: searchObject,
|
||||
}
|
||||
)
|
||||
|
||||
const glaSessionData = readGlaFromSessionStorage()
|
||||
const paymentInfoSessionData = readPaymentInfoFromSessionStorage()
|
||||
|
||||
if (status === PaymentCallbackStatusEnum.Cancel) {
|
||||
if (glaSessionData) {
|
||||
trackEvent({
|
||||
event: "glaCardSaveCancelled",
|
||||
hotelInfo: {
|
||||
hotelId: glaSessionData.hotelId,
|
||||
lateArrivalGuarantee: glaSessionData.lateArrivalGuarantee,
|
||||
guaranteedProduct: "room",
|
||||
},
|
||||
paymentInfo: {
|
||||
hotelId: glaSessionData.hotelId,
|
||||
status: "glacardsavecancelled",
|
||||
type: glaSessionData.paymentMethod,
|
||||
isSavedCreditCard: glaSessionData.isSavedCreditCard,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
trackPaymentEvent({
|
||||
event: "paymentCancel",
|
||||
hotelId: detailsStorage.booking.hotelId,
|
||||
status: "cancelled",
|
||||
method: paymentInfoSessionData?.paymentMethod,
|
||||
isSavedCreditCard: paymentInfoSessionData?.isSavedCreditCard,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (status === PaymentCallbackStatusEnum.Error) {
|
||||
if (glaSessionData) {
|
||||
trackEvent({
|
||||
event: "glaCardSaveFailed",
|
||||
hotelInfo: {
|
||||
hotelId: glaSessionData.hotelId,
|
||||
lateArrivalGuarantee: glaSessionData.lateArrivalGuarantee,
|
||||
guaranteedProduct: "room",
|
||||
},
|
||||
paymentInfo: {
|
||||
hotelId: glaSessionData.hotelId,
|
||||
status: "glacardsavefailed",
|
||||
type: glaSessionData.paymentMethod,
|
||||
isSavedCreditCard: glaSessionData.isSavedCreditCard,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
trackPaymentEvent({
|
||||
event: "paymentFail",
|
||||
hotelId: detailsStorage.booking.hotelId,
|
||||
errorMessage,
|
||||
status: "failed",
|
||||
method: paymentInfoSessionData?.paymentMethod,
|
||||
isSavedCreditCard: paymentInfoSessionData?.isSavedCreditCard,
|
||||
})
|
||||
}
|
||||
}
|
||||
clearGlaSessionStorage()
|
||||
clearPaymentInfoSessionStorage()
|
||||
|
||||
if (searchParams.size > 0) {
|
||||
router.replace(`${returnUrl}?${searchParams.toString()}`)
|
||||
}
|
||||
}
|
||||
}, [returnUrl, router, searchObject, status, errorMessage, trackPaymentEvent])
|
||||
|
||||
return <LoadingSpinner fullPage />
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner"
|
||||
import { BookingStatusEnum } from "@scandic-hotels/trpc/enums/bookingStatus"
|
||||
|
||||
import { useHandleBookingStatus } from "../../../../hooks/useHandleBookingStatus"
|
||||
import { MEMBERSHIP_FAILED_ERROR } from "../../../../types/membershipFailedError"
|
||||
import TimeoutSpinner from "./TimeoutSpinner"
|
||||
import { trackGuaranteeBookingSuccess } from "./tracking"
|
||||
|
||||
const validBookingStatuses = [
|
||||
BookingStatusEnum.PaymentSucceeded,
|
||||
BookingStatusEnum.BookingCompleted,
|
||||
]
|
||||
|
||||
interface HandleStatusPollingProps {
|
||||
refId: string
|
||||
sig: string
|
||||
successRedirectUrl: string
|
||||
cardType?: string
|
||||
}
|
||||
|
||||
export function HandleSuccessCallback({
|
||||
refId,
|
||||
sig,
|
||||
successRedirectUrl,
|
||||
cardType,
|
||||
}: HandleStatusPollingProps) {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
// Cookie is used by Booking Confirmation page to validate that the user came from payment callback
|
||||
document.cookie = `bcsig=${sig}; Path=/; Max-Age=60; Secure; SameSite=Strict`
|
||||
}, [sig])
|
||||
|
||||
const {
|
||||
data: bookingStatus,
|
||||
error,
|
||||
isTimeout,
|
||||
} = useHandleBookingStatus({
|
||||
refId,
|
||||
expectedStatuses: validBookingStatuses,
|
||||
maxRetries: 10,
|
||||
retryInterval: 2000,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!bookingStatus?.booking.reservationStatus) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
validBookingStatuses.includes(
|
||||
bookingStatus.booking.reservationStatus as BookingStatusEnum
|
||||
)
|
||||
) {
|
||||
trackGuaranteeBookingSuccess(cardType)
|
||||
// a successful booking can still have membership errors
|
||||
const membershipFailedError = bookingStatus.booking.errors.find(
|
||||
(e) => e.errorCode === MEMBERSHIP_FAILED_ERROR
|
||||
)
|
||||
const errorParam = membershipFailedError
|
||||
? `&errorCode=${membershipFailedError.errorCode}`
|
||||
: ""
|
||||
|
||||
router.replace(`${successRedirectUrl}${errorParam}`)
|
||||
}
|
||||
}, [bookingStatus, cardType, successRedirectUrl, router])
|
||||
|
||||
if (isTimeout || error) {
|
||||
return <TimeoutSpinner />
|
||||
}
|
||||
|
||||
return <LoadingSpinner fullPage />
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { customerService } from "@scandic-hotels/common/constants/routes/customerService"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Link from "@scandic-hotels/design-system/Link"
|
||||
import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner"
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
|
||||
import useLang from "../../../../../hooks/useLang"
|
||||
|
||||
import styles from "./timeoutSpinner.module.css"
|
||||
|
||||
export default function TimeoutSpinner() {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<LoadingSpinner />
|
||||
<Subtitle className={styles.heading}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Taking longer than usual",
|
||||
})}
|
||||
</Subtitle>
|
||||
<Body textAlign="center" className={styles.messageContainer}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"We are still confirming your booking. This is usually a matter of minutes and we do apologise for the wait. Please check your inbox for a booking confirmation email and if you still haven't received it by end of day, please contact our <link>customer support</link>.",
|
||||
},
|
||||
{
|
||||
link: (text) => (
|
||||
<Link
|
||||
href={customerService[lang]}
|
||||
textDecoration="underline"
|
||||
target="_blank"
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: var(--Spacing-x2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.container .heading {
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.messageContainer {
|
||||
max-width: 435px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import "client-only"
|
||||
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
|
||||
export const glaStorageName = "gla-storage"
|
||||
|
||||
type GlaSessionData = {
|
||||
lateArrivalGuarantee: string
|
||||
hotelId: string
|
||||
paymentMethod?: string
|
||||
isSavedCreditCard?: boolean
|
||||
}
|
||||
|
||||
export function readGlaFromSessionStorage(): GlaSessionData | null {
|
||||
try {
|
||||
const glaSessionData = sessionStorage.getItem(glaStorageName)
|
||||
if (!glaSessionData) return null
|
||||
return JSON.parse(glaSessionData)
|
||||
} catch (error) {
|
||||
logger.error("Error reading from session storage:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeGlaToSessionStorage(
|
||||
lateArrivalGuarantee: string,
|
||||
hotelId: string,
|
||||
paymentMethod: string,
|
||||
isSavedCreditCard: boolean
|
||||
) {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
glaStorageName,
|
||||
JSON.stringify({
|
||||
lateArrivalGuarantee,
|
||||
hotelId,
|
||||
paymentMethod,
|
||||
isSavedCreditCard,
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error("Error writing to session storage:", error)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearGlaSessionStorage() {
|
||||
sessionStorage.removeItem(glaStorageName)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { trackEvent } from "@scandic-hotels/tracking/base"
|
||||
|
||||
import { clearGlaSessionStorage, readGlaFromSessionStorage } from "./helpers"
|
||||
|
||||
export function trackGuaranteeBookingSuccess(cardType?: string) {
|
||||
const glaSessionData = readGlaFromSessionStorage()
|
||||
if (glaSessionData) {
|
||||
trackEvent({
|
||||
event: "guaranteeBookingSuccess",
|
||||
hotelInfo: {
|
||||
lateArrivalGuarantee: glaSessionData.lateArrivalGuarantee,
|
||||
hotelId: glaSessionData.hotelId,
|
||||
guaranteedProduct: "room",
|
||||
},
|
||||
paymentInfo: {
|
||||
hotelId: glaSessionData.hotelId,
|
||||
type: cardType ?? glaSessionData.paymentMethod,
|
||||
isSavedCreditCard: glaSessionData.isSavedCreditCard,
|
||||
},
|
||||
})
|
||||
}
|
||||
clearGlaSessionStorage()
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { cx } from "class-variance-authority"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Label } from "react-aria-components"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import {
|
||||
PAYMENT_METHOD_TITLES,
|
||||
PaymentMethodEnum,
|
||||
} from "@scandic-hotels/common/constants/paymentMethod"
|
||||
import {
|
||||
bookingConfirmation,
|
||||
selectRate,
|
||||
} from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
import { formatPhoneNumber } from "@scandic-hotels/common/utils/phone"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
|
||||
import { PaymentOption } from "@scandic-hotels/design-system/Form/PaymentOption"
|
||||
import { PaymentOptionsGroup } from "@scandic-hotels/design-system/Form/PaymentOptionsGroup"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { trackEvent } from "@scandic-hotels/tracking/base"
|
||||
import { trpc } from "@scandic-hotels/trpc/client"
|
||||
import { bedTypeMap } from "@scandic-hotels/trpc/constants/bedTypeMap"
|
||||
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
|
||||
import { BookingStatusEnum } from "@scandic-hotels/trpc/enums/bookingStatus"
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import { env } from "../../../../env/client"
|
||||
import { useAvailablePaymentOptions } from "../../../hooks/useAvailablePaymentOptions"
|
||||
import { useHandleBookingStatus } from "../../../hooks/useHandleBookingStatus"
|
||||
import { useIsLoggedIn } from "../../../hooks/useIsLoggedIn"
|
||||
import useLang from "../../../hooks/useLang"
|
||||
import { useEnterDetailsStore } from "../../../stores/enter-details"
|
||||
import { useTrackingContext } from "../../../trackingContext"
|
||||
import ConfirmBooking, { ConfirmBookingRedemption } from "../Confirm"
|
||||
import PriceChangeDialog from "../PriceChangeDialog"
|
||||
import { writeGlaToSessionStorage } from "./PaymentCallback/helpers"
|
||||
import BookingAlert from "./BookingAlert"
|
||||
import GuaranteeDetails from "./GuaranteeDetails"
|
||||
import {
|
||||
hasFlexibleRate,
|
||||
hasPrepaidRate,
|
||||
isPaymentMethodEnum,
|
||||
writePaymentInfoToSessionStorage,
|
||||
} from "./helpers"
|
||||
import MixedRatePaymentBreakdown from "./MixedRatePaymentBreakdown"
|
||||
import { type PaymentFormData, paymentSchema } from "./schema"
|
||||
import TermsAndConditions from "./TermsAndConditions"
|
||||
|
||||
import styles from "./payment.module.css"
|
||||
|
||||
import type { CreditCard } from "@scandic-hotels/trpc/types/user"
|
||||
|
||||
import type { PriceChangeData } from "../PriceChangeData"
|
||||
|
||||
const maxRetries = 15
|
||||
const retryInterval = 2000
|
||||
|
||||
export type PaymentClientProps = {
|
||||
otherPaymentOptions: PaymentMethodEnum[]
|
||||
savedCreditCards: CreditCard[] | null
|
||||
}
|
||||
|
||||
export const formId = "submit-booking"
|
||||
export default function PaymentClient({
|
||||
otherPaymentOptions,
|
||||
savedCreditCards,
|
||||
}: PaymentClientProps) {
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const isUserLoggedIn = useIsLoggedIn()
|
||||
const { getTopOffset } = useStickyPosition({})
|
||||
const { trackPaymentEvent, trackGlaSaveCardAttempt } = useTrackingContext()
|
||||
|
||||
const [showBookingAlert, setShowBookingAlert] = useState(false)
|
||||
|
||||
const {
|
||||
booking,
|
||||
rooms,
|
||||
totalPrice,
|
||||
isSubmitting,
|
||||
preSubmitCallbacks,
|
||||
setIsSubmitting,
|
||||
} = useEnterDetailsStore((state) => ({
|
||||
booking: state.booking,
|
||||
rooms: state.rooms,
|
||||
totalPrice: state.totalPrice,
|
||||
preSubmitCallbacks: state.preSubmitCallbacks,
|
||||
isSubmitting: state.isSubmitting,
|
||||
setIsSubmitting: state.actions.setIsSubmitting,
|
||||
}))
|
||||
|
||||
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
|
||||
) {
|
||||
return room.memberMustBeGuaranteed
|
||||
}
|
||||
|
||||
return room.mustBeGuaranteed
|
||||
})
|
||||
|
||||
const [refId, setRefId] = useState("")
|
||||
const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
|
||||
useState(false)
|
||||
|
||||
const availablePaymentOptions =
|
||||
useAvailablePaymentOptions(otherPaymentOptions)
|
||||
const [priceChangeData, setPriceChangeData] =
|
||||
useState<PriceChangeData | null>(null)
|
||||
|
||||
const { toDate, fromDate, hotelId } = booking
|
||||
|
||||
const hasPrepaidRates = rooms.some(hasPrepaidRate)
|
||||
const hasFlexRates = rooms.some(hasFlexibleRate)
|
||||
const hasOnlyFlexRates = rooms.every(hasFlexibleRate)
|
||||
const hasMixedRates = hasPrepaidRates && hasFlexRates
|
||||
|
||||
const methods = useForm<PaymentFormData>({
|
||||
defaultValues: {
|
||||
paymentMethod: savedCreditCards?.length
|
||||
? savedCreditCards[0].id
|
||||
: PaymentMethodEnum.card,
|
||||
smsConfirmation: false,
|
||||
termsAndConditions: false,
|
||||
guarantee: false,
|
||||
},
|
||||
mode: "all",
|
||||
reValidateMode: "onChange",
|
||||
resolver: zodResolver(paymentSchema),
|
||||
})
|
||||
|
||||
const initiateBooking = trpc.booking.create.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result) {
|
||||
if ("error" in result) {
|
||||
const queryParams = new URLSearchParams(searchParams.toString())
|
||||
queryParams.set("errorCode", result.cause)
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${pathname}?${queryParams.toString()}`
|
||||
)
|
||||
handlePaymentError(result.cause)
|
||||
return
|
||||
}
|
||||
|
||||
const { booking } = result
|
||||
const mainRoom = booking.rooms[0]
|
||||
|
||||
if (booking.reservationStatus == BookingStatusEnum.BookingCompleted) {
|
||||
// Cookie is used by Booking Confirmation page to validate that the user came from payment callback
|
||||
document.cookie = `bcsig=${result.sig}; Path=/; Max-Age=60; Secure; SameSite=Strict`
|
||||
const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(mainRoom.refId)}`
|
||||
router.push(confirmationUrl)
|
||||
return
|
||||
}
|
||||
|
||||
setRefId(mainRoom.refId)
|
||||
|
||||
const hasPriceChange = booking.rooms.some((r) => r.priceChangedMetadata)
|
||||
if (hasPriceChange) {
|
||||
const priceChangeData = booking.rooms.map(
|
||||
(room) => room.priceChangedMetadata || null
|
||||
)
|
||||
setPriceChangeData(priceChangeData)
|
||||
} else {
|
||||
setIsPollingForBookingStatus(true)
|
||||
}
|
||||
} else {
|
||||
handlePaymentError("No confirmation number")
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error("Booking error", error)
|
||||
handlePaymentError(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const priceChange = trpc.booking.priceChange.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result?.id) {
|
||||
setIsPollingForBookingStatus(true)
|
||||
} else {
|
||||
handlePaymentError("No confirmation number")
|
||||
}
|
||||
setPriceChangeData(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error("Price change error", error)
|
||||
setPriceChangeData(null)
|
||||
handlePaymentError(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const bookingStatus = useHandleBookingStatus({
|
||||
refId,
|
||||
expectedStatuses: [BookingStatusEnum.BookingCompleted],
|
||||
maxRetries,
|
||||
retryInterval,
|
||||
enabled: isPollingForBookingStatus,
|
||||
})
|
||||
|
||||
const handlePaymentError = useCallback(
|
||||
(errorMessage: string) => {
|
||||
setShowBookingAlert(true)
|
||||
setIsSubmitting(false)
|
||||
|
||||
const currentPaymentMethod = methods.getValues("paymentMethod")
|
||||
const smsEnable = methods.getValues("smsConfirmation")
|
||||
const guarantee = methods.getValues("guarantee")
|
||||
const savedCreditCard = savedCreditCards?.find(
|
||||
(card) => card.id === currentPaymentMethod
|
||||
)
|
||||
|
||||
const isSavedCreditCard = !!savedCreditCard
|
||||
|
||||
if (guarantee || (bookingMustBeGuaranteed && hasOnlyFlexRates)) {
|
||||
const lateArrivalGuarantee = guarantee ? "yes" : "mandatory"
|
||||
trackEvent({
|
||||
event: "glaCardSaveFailed",
|
||||
hotelInfo: {
|
||||
hotelId,
|
||||
lateArrivalGuarantee,
|
||||
guaranteedProduct: "room",
|
||||
},
|
||||
paymentInfo: {
|
||||
isSavedCreditCard,
|
||||
hotelId,
|
||||
status: "glacardsavefailed",
|
||||
type: savedCreditCard ? savedCreditCard.type : currentPaymentMethod,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
trackPaymentEvent({
|
||||
event: "paymentFail",
|
||||
hotelId,
|
||||
method: savedCreditCard ? savedCreditCard.type : currentPaymentMethod,
|
||||
isSavedCreditCard,
|
||||
smsEnable,
|
||||
errorMessage,
|
||||
status: "failed",
|
||||
})
|
||||
}
|
||||
},
|
||||
[
|
||||
methods,
|
||||
savedCreditCards,
|
||||
hotelId,
|
||||
bookingMustBeGuaranteed,
|
||||
hasOnlyFlexRates,
|
||||
setIsSubmitting,
|
||||
trackPaymentEvent,
|
||||
]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingStatus?.data?.booking.paymentUrl) {
|
||||
router.push(bookingStatus.data.booking.paymentUrl)
|
||||
} else if (
|
||||
bookingStatus?.data?.booking.reservationStatus ===
|
||||
BookingStatusEnum.BookingCompleted
|
||||
) {
|
||||
const mainRoom = bookingStatus.data.booking.rooms[0]
|
||||
// Cookie is used by Booking Confirmation page to validate that the user came from payment callback
|
||||
document.cookie = `bcsig=${bookingStatus.data.sig}; Path=/; Max-Age=60; Secure; SameSite=Strict`
|
||||
const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(mainRoom.refId)}`
|
||||
router.push(confirmationUrl)
|
||||
} else if (bookingStatus.isTimeout) {
|
||||
handlePaymentError("Timeout")
|
||||
}
|
||||
}, [
|
||||
bookingStatus.data,
|
||||
bookingStatus.isTimeout,
|
||||
router,
|
||||
intl,
|
||||
lang,
|
||||
handlePaymentError,
|
||||
])
|
||||
|
||||
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) => {
|
||||
setIsSubmitting(true)
|
||||
|
||||
Object.values(preSubmitCallbacks).forEach((callback) => {
|
||||
callback()
|
||||
})
|
||||
const firstIncompleteRoomIndex = rooms.findIndex(
|
||||
(room) => !room.isComplete
|
||||
)
|
||||
|
||||
// If any room is not complete/valid, scroll to it
|
||||
if (firstIncompleteRoomIndex !== -1) {
|
||||
const roomElement = document.getElementById(
|
||||
`room-${firstIncompleteRoomIndex + 1}`
|
||||
)
|
||||
|
||||
if (!roomElement) {
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
const roomElementTop =
|
||||
roomElement.getBoundingClientRect().top + window.scrollY
|
||||
|
||||
window.scrollTo({
|
||||
top: roomElementTop - getTopOffset() - 20,
|
||||
behavior: "smooth",
|
||||
})
|
||||
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const paymentMethod = getPaymentMethod(data.paymentMethod)
|
||||
|
||||
const savedCreditCard = savedCreditCards?.find(
|
||||
(card) => card.id === data.paymentMethod
|
||||
)
|
||||
|
||||
const paymentRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback`
|
||||
const guarantee = data.guarantee
|
||||
const useSavedCard = savedCreditCard
|
||||
? {
|
||||
card: {
|
||||
alias: savedCreditCard.alias,
|
||||
expiryDate: savedCreditCard.expirationDate,
|
||||
cardType: savedCreditCard.cardType,
|
||||
},
|
||||
}
|
||||
: {}
|
||||
|
||||
const shouldUsePayment =
|
||||
guarantee || bookingMustBeGuaranteed || !hasOnlyFlexRates
|
||||
const payment = shouldUsePayment
|
||||
? {
|
||||
paymentMethod: paymentMethod,
|
||||
...useSavedCard,
|
||||
success: `${paymentRedirectUrl}/success`,
|
||||
error: `${paymentRedirectUrl}/error`,
|
||||
cancel: `${paymentRedirectUrl}/cancel`,
|
||||
}
|
||||
: undefined
|
||||
const paymentMethodType = savedCreditCard
|
||||
? savedCreditCard.type
|
||||
: paymentMethod
|
||||
if (guarantee || (bookingMustBeGuaranteed && hasOnlyFlexRates)) {
|
||||
const lateArrivalGuarantee = guarantee ? "yes" : "mandatory"
|
||||
writeGlaToSessionStorage(
|
||||
lateArrivalGuarantee,
|
||||
hotelId,
|
||||
paymentMethodType,
|
||||
!!savedCreditCard
|
||||
)
|
||||
trackGlaSaveCardAttempt({
|
||||
hotelId,
|
||||
hasSavedCreditCard: !!savedCreditCard,
|
||||
creditCardType: savedCreditCard?.cardType,
|
||||
lateArrivalGuarantee,
|
||||
})
|
||||
} else if (!hasOnlyFlexRates) {
|
||||
trackPaymentEvent({
|
||||
event: "paymentAttemptStart",
|
||||
hotelId,
|
||||
method: paymentMethodType,
|
||||
isSavedCreditCard: !!savedCreditCard,
|
||||
smsEnable: data.smsConfirmation,
|
||||
status: "attempt",
|
||||
})
|
||||
}
|
||||
writePaymentInfoToSessionStorage(paymentMethodType, !!savedCreditCard)
|
||||
|
||||
const payload = {
|
||||
checkInDate: fromDate,
|
||||
checkOutDate: toDate,
|
||||
hotelId,
|
||||
language: lang,
|
||||
payment,
|
||||
rooms: rooms.map(({ room }, idx) => {
|
||||
const isMainRoom = idx === 0
|
||||
let rateCode = ""
|
||||
if (isMainRoom && isUserLoggedIn) {
|
||||
rateCode = booking.rooms[idx].rateCode
|
||||
} else if (
|
||||
(room.guest.join || room.guest.membershipNo) &&
|
||||
booking.rooms[idx].counterRateCode
|
||||
) {
|
||||
rateCode = booking.rooms[idx].counterRateCode
|
||||
} else {
|
||||
rateCode = booking.rooms[idx].rateCode
|
||||
}
|
||||
|
||||
const phoneNumber = formatPhoneNumber(
|
||||
room.guest.phoneNumber,
|
||||
room.guest.phoneNumberCC
|
||||
)
|
||||
|
||||
return {
|
||||
adults: room.adults,
|
||||
bookingCode: room.roomRate.bookingCode,
|
||||
childrenAges: room.childrenInRoom?.map((child) => ({
|
||||
age: child.age,
|
||||
bedType: bedTypeMap[parseInt(child.bed.toString())],
|
||||
})),
|
||||
guest: {
|
||||
becomeMember: room.guest.join,
|
||||
countryCode: room.guest.countryCode,
|
||||
email: room.guest.email,
|
||||
firstName: room.guest.firstName,
|
||||
lastName: room.guest.lastName,
|
||||
membershipNumber: room.guest.membershipNo,
|
||||
phoneNumber,
|
||||
// Only allowed for room one
|
||||
...(idx === 0 && {
|
||||
dateOfBirth:
|
||||
"dateOfBirth" in room.guest && room.guest.dateOfBirth
|
||||
? room.guest.dateOfBirth
|
||||
: undefined,
|
||||
postalCode:
|
||||
"zipCode" in room.guest && room.guest.zipCode
|
||||
? room.guest.zipCode
|
||||
: undefined,
|
||||
}),
|
||||
},
|
||||
packages: {
|
||||
accessibility:
|
||||
room.roomFeatures?.some(
|
||||
(feature) =>
|
||||
feature.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
|
||||
) ?? false,
|
||||
allergyFriendly:
|
||||
room.roomFeatures?.some(
|
||||
(feature) => feature.code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||
) ?? false,
|
||||
breakfast: !!(room.breakfast && room.breakfast.code),
|
||||
petFriendly:
|
||||
room.roomFeatures?.some(
|
||||
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||
) ?? false,
|
||||
},
|
||||
rateCode,
|
||||
roomPrice: {
|
||||
memberPrice:
|
||||
"member" in room.roomRate
|
||||
? room.roomRate.member?.localPrice.pricePerStay
|
||||
: undefined,
|
||||
publicPrice:
|
||||
"public" in room.roomRate
|
||||
? room.roomRate.public?.localPrice.pricePerStay
|
||||
: undefined,
|
||||
},
|
||||
roomTypeCode: room.bedType!.roomTypeCode, // A selection has been made in order to get to this step.
|
||||
smsConfirmationRequested: data.smsConfirmation,
|
||||
specialRequest: {
|
||||
comment: room.specialRequest.comment
|
||||
? room.specialRequest.comment
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
initiateBooking.mutate(payload)
|
||||
},
|
||||
[
|
||||
savedCreditCards,
|
||||
lang,
|
||||
initiateBooking,
|
||||
hotelId,
|
||||
fromDate,
|
||||
toDate,
|
||||
rooms,
|
||||
booking.rooms,
|
||||
getPaymentMethod,
|
||||
hasOnlyFlexRates,
|
||||
bookingMustBeGuaranteed,
|
||||
preSubmitCallbacks,
|
||||
isUserLoggedIn,
|
||||
getTopOffset,
|
||||
setIsSubmitting,
|
||||
trackGlaSaveCardAttempt,
|
||||
trackPaymentEvent,
|
||||
]
|
||||
)
|
||||
|
||||
const finalStep = intl.formatMessage({ defaultMessage: "Final step" })
|
||||
const selectPayment = intl.formatMessage({
|
||||
defaultMessage: "Select payment method",
|
||||
})
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cx(styles.paymentSection, {
|
||||
[styles.disabled]: isSubmitting,
|
||||
})}
|
||||
>
|
||||
<header>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<span>{hasOnlyFlexRates ? finalStep : selectPayment}</span>
|
||||
</Typography>
|
||||
<BookingAlert isVisible={showBookingAlert} />
|
||||
</header>
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.paymentContainer}
|
||||
onSubmit={methods.handleSubmit(handleSubmit)}
|
||||
id={formId}
|
||||
>
|
||||
{booking.searchType === SEARCH_TYPE_REDEMPTION ? (
|
||||
<ConfirmBookingRedemption />
|
||||
) : hasOnlyFlexRates && !bookingMustBeGuaranteed ? (
|
||||
<ConfirmBooking savedCreditCards={savedCreditCards} />
|
||||
) : (
|
||||
<>
|
||||
{hasOnlyFlexRates && bookingMustBeGuaranteed ? (
|
||||
<section className={styles.section}>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.",
|
||||
})}
|
||||
</Body>
|
||||
<GuaranteeDetails />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{hasMixedRates ? (
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"As your booking includes rooms with different terms, we will be charging part of the booking now and the remainder will be collected by the reception at check-in.",
|
||||
})}
|
||||
</Body>
|
||||
) : null}
|
||||
|
||||
<section className={styles.section}>
|
||||
<PaymentOptionsGroup
|
||||
name="paymentMethod"
|
||||
className={styles.paymentOptionContainer}
|
||||
>
|
||||
<Label className="sr-only">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Payment methods",
|
||||
})}
|
||||
</Label>
|
||||
|
||||
{savedCreditCards?.length ? (
|
||||
<>
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "MY SAVED CARDS",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
|
||||
{savedCreditCards.map((savedCreditCard) => (
|
||||
<PaymentOption
|
||||
key={savedCreditCard.id}
|
||||
value={savedCreditCard.id as PaymentMethodEnum}
|
||||
label={
|
||||
PAYMENT_METHOD_TITLES[
|
||||
savedCreditCard.cardType as PaymentMethodEnum
|
||||
]
|
||||
}
|
||||
cardNumber={savedCreditCard.truncatedNumber}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "OTHER PAYMENT METHODS",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</>
|
||||
) : null}
|
||||
<PaymentOption
|
||||
value={PaymentMethodEnum.card}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Credit card",
|
||||
})}
|
||||
/>
|
||||
{!hasMixedRates &&
|
||||
availablePaymentOptions.map((paymentMethod) => (
|
||||
<PaymentOption
|
||||
key={paymentMethod}
|
||||
value={paymentMethod}
|
||||
label={
|
||||
PAYMENT_METHOD_TITLES[
|
||||
paymentMethod as PaymentMethodEnum
|
||||
]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</PaymentOptionsGroup>
|
||||
{hasMixedRates ? (
|
||||
<MixedRatePaymentBreakdown
|
||||
rooms={rooms}
|
||||
currency={totalPrice.local.currency}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<div className={styles.checkboxContainer}>
|
||||
<Checkbox name="smsConfirmation">
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"I would like to get my booking confirmation via sms",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<section className={styles.section}>
|
||||
<TermsAndConditions isFlexBookingTerms={hasOnlyFlexRates} />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
<div className={styles.submitButton}>
|
||||
<Button
|
||||
type="submit"
|
||||
isDisabled={isSubmitting}
|
||||
isPending={isSubmitting}
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Complete booking",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
{priceChangeData ? (
|
||||
<PriceChangeDialog
|
||||
isOpen={!!priceChangeData}
|
||||
priceChangeData={priceChangeData}
|
||||
prevTotalPrice={totalPrice.local.price}
|
||||
currency={totalPrice.local.currency}
|
||||
onCancel={() => {
|
||||
const allSearchParams = searchParams.size
|
||||
? `?${searchParams.toString()}`
|
||||
: ""
|
||||
router.push(`${selectRate(lang)}${allSearchParams}`)
|
||||
}}
|
||||
onAccept={() => priceChange.mutate({ refId })}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { bookingTermsAndConditionsRoutes } from "@scandic-hotels/common/constants/routes/bookingTermsAndConditionsRoutes"
|
||||
import { privacyPolicyRoutes } from "@scandic-hotels/common/constants/routes/privacyPolicyRoutes"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
|
||||
import Link from "@scandic-hotels/design-system/Link"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import useLang from "../../../../hooks/useLang"
|
||||
|
||||
import styles from "../payment.module.css"
|
||||
|
||||
type TermsAndConditionsProps = {
|
||||
isFlexBookingTerms: boolean
|
||||
}
|
||||
export default function TermsAndConditions({
|
||||
isFlexBookingTerms,
|
||||
}: TermsAndConditionsProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Caption>
|
||||
{isFlexBookingTerms
|
||||
? intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"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>.",
|
||||
},
|
||||
{
|
||||
termsAndConditionsLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
textDecoration="underline"
|
||||
href={bookingTermsAndConditionsRoutes[lang]}
|
||||
target="_blank"
|
||||
weight="bold"
|
||||
size="small"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
privacyPolicyLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
textDecoration="underline"
|
||||
href={privacyPolicyRoutes[lang]}
|
||||
target="_blank"
|
||||
weight="bold"
|
||||
size="small"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"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 requires a valid payment card during my visit in case anything is left unpaid.",
|
||||
},
|
||||
{
|
||||
termsAndConditionsLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
textDecoration="underline"
|
||||
href={bookingTermsAndConditionsRoutes[lang]}
|
||||
target="_blank"
|
||||
weight="bold"
|
||||
size="small"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
privacyPolicyLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
textDecoration="underline"
|
||||
href={privacyPolicyRoutes[lang]}
|
||||
target="_blank"
|
||||
weight="bold"
|
||||
size="small"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
<Checkbox name="termsAndConditions">
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "I accept the terms and conditions",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</Checkbox>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
|
||||
import type { RoomState } from "../../../stores/enter-details/types"
|
||||
|
||||
export function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
|
||||
return Object.values<string>(PaymentMethodEnum).includes(value)
|
||||
}
|
||||
|
||||
export function hasFlexibleRate({ room }: RoomState): boolean {
|
||||
return room.isFlexRate
|
||||
}
|
||||
|
||||
export function hasPrepaidRate({ room }: RoomState): boolean {
|
||||
return !room.isFlexRate
|
||||
}
|
||||
|
||||
export function calculateTotalRoomPrice(
|
||||
{ room }: RoomState,
|
||||
initialRoomPrice?: number
|
||||
) {
|
||||
let totalPrice = initialRoomPrice ?? room.roomPrice.perStay.local.price
|
||||
|
||||
if (room.breakfast) {
|
||||
totalPrice += Number(room.breakfast.localPrice.totalPrice) * room.adults
|
||||
}
|
||||
|
||||
if (room.roomFeatures) {
|
||||
room.roomFeatures.forEach((pkg) => {
|
||||
totalPrice += Number(pkg.localPrice.price)
|
||||
})
|
||||
}
|
||||
|
||||
let comparisonPrice = totalPrice
|
||||
|
||||
const isMember = room.guest.join || room.guest.membershipNo
|
||||
if (isMember && "member" in room.roomRate) {
|
||||
const publicPrice = room.roomRate.public?.localPrice.pricePerStay ?? 0
|
||||
const memberPrice = room.roomRate.member?.localPrice.pricePerStay ?? 0
|
||||
const diff = publicPrice - memberPrice
|
||||
comparisonPrice = totalPrice + diff
|
||||
}
|
||||
|
||||
return {
|
||||
totalPrice,
|
||||
comparisonPrice,
|
||||
}
|
||||
}
|
||||
|
||||
export const paymentInfoStorageName = "payment-info-storage"
|
||||
|
||||
type PaymentInfoSessionData = {
|
||||
paymentMethod: string
|
||||
isSavedCreditCard: boolean
|
||||
}
|
||||
|
||||
export function readPaymentInfoFromSessionStorage():
|
||||
| PaymentInfoSessionData
|
||||
| undefined {
|
||||
try {
|
||||
const paymentInfoSessionData = sessionStorage.getItem(
|
||||
paymentInfoStorageName
|
||||
)
|
||||
if (!paymentInfoSessionData) return undefined
|
||||
return JSON.parse(paymentInfoSessionData)
|
||||
} catch (error) {
|
||||
logger.error("Error reading from session storage:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function writePaymentInfoToSessionStorage(
|
||||
paymentMethod: string,
|
||||
isSavedCreditCard: boolean
|
||||
) {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
paymentInfoStorageName,
|
||||
JSON.stringify({
|
||||
paymentMethod,
|
||||
isSavedCreditCard,
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error("Error writing to session storage:", error)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPaymentInfoSessionStorage() {
|
||||
sessionStorage.removeItem(paymentInfoStorageName)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { getSavedPaymentCardsSafely } from "../../../trpc/memoizedRequests/getSavedPaymentCardsSafely"
|
||||
import PaymentClient from "./PaymentClient"
|
||||
|
||||
import type { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
|
||||
|
||||
type PaymentProps = {
|
||||
otherPaymentOptions: PaymentMethodEnum[]
|
||||
supportedCards: PaymentMethodEnum[]
|
||||
}
|
||||
|
||||
export default async function Payment({
|
||||
otherPaymentOptions,
|
||||
supportedCards,
|
||||
}: PaymentProps) {
|
||||
const savedCreditCards = await getSavedPaymentCardsSafely({
|
||||
supportedCards,
|
||||
})
|
||||
|
||||
return (
|
||||
<PaymentClient
|
||||
otherPaymentOptions={otherPaymentOptions}
|
||||
savedCreditCards={savedCreditCards}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.paymentSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.paymentContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x4);
|
||||
max-width: 696px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.paymentOptionContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.paymentContainer .link {
|
||||
font-weight: 500;
|
||||
font-size: var(--Typography-Caption-Regular-fontSize);
|
||||
}
|
||||
|
||||
.terms {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.checkboxContainer {
|
||||
background-color: var(--Surface-Secondary-Default);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.submitButton {
|
||||
display: flex;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
.paymentContainer {
|
||||
margin-bottom: 200px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const paymentSchema = z.object({
|
||||
paymentMethod: z.string().nullish(),
|
||||
smsConfirmation: z.boolean(),
|
||||
termsAndConditions: z.boolean().refine((value) => value === true, {
|
||||
message: "You must accept the terms and conditions",
|
||||
}),
|
||||
guarantee: z.boolean(),
|
||||
})
|
||||
|
||||
export interface PaymentFormData extends z.output<typeof paymentSchema> {}
|
||||
Reference in New Issue
Block a user