Merged in feat/SW-1368-1369-Guarantee-late-arrival (pull request #1512)

Feat/SW-1368 1369 Guarantee late arrival

* feat(SW-1368-SW-1369): guarantee late arrival for confirmation page and my stay

* feat(SW-1368-SW-1369): guarantee late arrival updated design

* feat(SW-1368-SW-1369): add translations

* feat(SW-1368-SW-1369): add translations

* feat(SW-1368-SW-1369): fix merge with master

* feat(SW-1368-SW-1369): add translations

* feat(SW-1368-SW-1369): add redirect with refId

* feat(SW-1368-SW-1369): if booking completed redirect to confirmation page

* feat(SW-1368-SW-1369): fix comments pr

* feat(SW-1368-SW-1369): fix comments pr

* feat(SW-1368-SW-1369): fix rebase master

* feat(SW-1368-SW-1369): fix duplicate flex rate check

* feat(SW-1368-SW-1369): if any room is flex, card must be used

* feat(SW-1368-SW-1369): move callback route

* feat(SW-1368-SW-1369): top align checkbox

* feat(SW-1368-SW-1369): top align checkbox


Approved-by: Tobias Johansson
Approved-by: Niclas Edenvin
This commit is contained in:
Bianca Widstam
2025-03-14 10:43:14 +00:00
parent 8ca862e32c
commit abd401c4f4
47 changed files with 1274 additions and 166 deletions

View File

@@ -54,6 +54,19 @@ export default function Room({ booking, img, roomName }: RoomProps) {
</>
)}
</div>
{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>
</div>
)}
</header>
<div className={styles.booking}>
<Image

View File

@@ -5,10 +5,9 @@
}
.header {
align-items: flex-end;
display: grid;
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
grid-template-columns: 1fr;
}
.benefits {

View File

@@ -0,0 +1,64 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
}
.title {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--Spacing-x-half);
}
.guaranteeContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium);
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
width: min(512px, 100%);
}
.checkbox {
display: flex;
gap: var(--Spacing-x-one-and-half);
}
.checkboxContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: 0 var(--Spacing-x2);
width: min(800px, 100%);
}
.modalContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
align-items: center;
width: 100%;
}
.infoButton {
display: flex;
gap: var(--Space-x05);
outline: none;
}
.modalText {
text-align: center;
}
.closeButton {
margin-top: var(--Space-x15);
width: min(164px, 100%);
outline: none;
}
@media screen and (min-width: 768px) {
.modalContainer {
width: 552px;
}
}

View File

@@ -0,0 +1,180 @@
"use client"
import { useState } from "react"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { PaymentMethodEnum } from "@/constants/booking"
import {
bookingTermsAndConditions,
privacyPolicy,
} from "@/constants/currentWebHrefs"
import { InfoCircleIcon } from "@/components/Icons"
import Modal from "@/components/Modal"
import Divider from "@/components/TempDesignSystem/Divider"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"
import MySavedCards from "../Payment/MySavedCards"
import PaymentOption from "../Payment/PaymentOption"
import styles from "./confirm.module.css"
import type { CreditCard } from "@/types/user"
interface ConfirmBookingProps {
savedCreditCards: CreditCard[] | null
}
export default function ConfirmBooking({
savedCreditCards,
}: ConfirmBookingProps) {
const intl = useIntl()
const lang = useLang()
const [isModalOpen, setModalOpen] = useState(false)
const { watch } = useFormContext()
const guarantee = watch("guarantee")
return (
<div className={styles.container}>
<div className={styles.guaranteeContainer}>
<div className={styles.title}>
<div className={styles.checkbox}>
<Checkbox name="guarantee" />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
id: "Guarantee room for late arrival",
})}
</p>
</Typography>
</div>
<Button
variant="Text"
size="Small"
typography="Body/Supporting text (caption)/smBold"
className={styles.infoButton}
onPress={() => setModalOpen(true)}
>
<InfoCircleIcon
width={20}
height={20}
color="uiTextMediumContrast"
/>
{intl.formatMessage({ id: "How does it work" })}
</Button>
<Modal isOpen={isModalOpen} onToggle={() => setModalOpen(false)}>
<div className={styles.modalContainer}>
<Typography variant="Title/smRegular">
<h3>
{intl.formatMessage({ id: "Guarantee for late arrival" })}
</h3>
</Typography>
<Typography variant="Body/Lead text">
<p className={styles.modalText}>
{intl.formatMessage({
id: "When guaranteeing your booking with a credit card, we will hold the booking until 07:00 the day after check-in.",
})}
</p>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.modalText}>
{intl.formatMessage({
id: "In case of a no-show, your credit card will be charged for the first night.",
})}
</p>
</Typography>
<Button
typography="Body/Paragraph/mdBold"
variant="Secondary"
size="Small"
onPress={() => setModalOpen(false)}
className={styles.closeButton}
>
{intl.formatMessage({ id: "Close" })}
</Button>
</div>
</Modal>
</div>
<Divider color="subtle" />
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage({
id: "I may arrive later than 18:00 and want to guarantee my booking with a credit card.",
})}
</p>
</Typography>
{savedCreditCards?.length && guarantee ? (
<MySavedCards savedCreditCards={savedCreditCards} />
) : null}
{guarantee && (
<>
{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>
<div className={styles.checkboxContainer}>
<Checkbox name="smsConfirmation">
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage({
id: "I would like to get my booking confirmation via sms",
})}
</p>
</Typography>
</Checkbox>
<div className={styles.checkbox}>
<Checkbox name="termsAndConditions" topAlign>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage(
{
id: "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.",
},
{
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>
),
}
)}
</p>
</Typography>
</Checkbox>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { PAYMENT_METHOD_TITLES ,type PaymentMethodEnum } from "@/constants/booking"
import PaymentOption from "../PaymentOption"
import styles from "./mySavedCards.module.css"
import type { CreditCard } from "@/types/user"
interface MySavedCardsProps {
savedCreditCards: CreditCard[] | null
}
export default function MySavedCards({ savedCreditCards }: MySavedCardsProps) {
const intl = useIntl()
return (
<section className={styles.section}>
<Typography variant="Title/Overline/sm">
<h4>{intl.formatMessage({ id: "MY SAVED CARDS" })}</h4>
</Typography>
<div className={styles.paymentOptionContainer}>
{savedCreditCards?.map((savedCreditCard) => (
<PaymentOption
key={savedCreditCard.id}
name="paymentMethod"
value={savedCreditCard.id}
label={
PAYMENT_METHOD_TITLES[
savedCreditCard.cardType as PaymentMethodEnum
]
}
cardNumber={savedCreditCard.truncatedNumber}
/>
))}
</div>
</section>
)
}

View File

@@ -0,0 +1,11 @@
.paymentOptionContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.section {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}

View File

@@ -7,6 +7,7 @@ import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import {
BOOKING_CONFIRMATION_NUMBER,
BookingErrorCodeEnum,
BookingStatusEnum,
PAYMENT_METHOD_TITLES,
@@ -16,7 +17,10 @@ import {
bookingTermsAndConditions,
privacyPolicy,
} from "@/constants/currentWebHrefs"
import { selectRate } from "@/constants/routes/hotelReservation"
import {
bookingConfirmation,
selectRate,
} from "@/constants/routes/hotelReservation"
import { env } from "@/env/client"
import { trpc } from "@/lib/trpc/client"
import { useEnterDetailsStore } from "@/stores/enter-details"
@@ -36,10 +40,12 @@ import useLang from "@/hooks/useLang"
import { trackPaymentEvent } from "@/utils/tracking"
import { bedTypeMap } from "../../utils"
import ConfirmBooking from "../Confirm"
import PriceChangeDialog from "../PriceChangeDialog"
import GuaranteeDetails from "./GuaranteeDetails"
import { hasFlexibleRate, hasPrepaidRate, isPaymentMethodEnum } from "./helpers"
import MixedRatePaymentBreakdown from "./MixedRatePaymentBreakdown"
import MySavedCards from "./MySavedCards"
import PaymentOption from "./PaymentOption"
import { type PaymentFormData, paymentSchema } from "./schema"
@@ -56,6 +62,9 @@ export const formId = "submit-booking"
export default function PaymentClient({
otherPaymentOptions,
savedCreditCards,
mustBeGuaranteed,
memberMustBeGuaranteed,
isFlexRate,
}: PaymentClientProps) {
const router = useRouter()
const lang = useLang()
@@ -70,6 +79,14 @@ export default function PaymentClient({
totalPrice: state.totalPrice,
}))
const bookingMustBeGuaranteed = rooms.some(
({ room }, idx) =>
(room.guest.join || room.guest.membershipNo) &&
booking.rooms[idx].counterRateCode
)
? memberMustBeGuaranteed
: mustBeGuaranteed
const setIsSubmittingDisabled = useEnterDetailsStore(
(state) => state.actions.setIsSubmittingDisabled
)
@@ -87,7 +104,6 @@ export default function PaymentClient({
const { toDate, fromDate, hotelId } = booking
const mustBeGuaranteed = rooms.every((r) => r.room.mustBeGuaranteed)
const hasPrepaidRates = rooms.some(hasPrepaidRate)
const hasFlexRates = rooms.some(hasFlexibleRate)
const hasMixedRates = hasPrepaidRates && hasFlexRates
@@ -101,6 +117,7 @@ export default function PaymentClient({
: PaymentMethodEnum.card,
smsConfirmation: false,
termsAndConditions: false,
guarantee: false,
},
mode: "all",
reValidateMode: "onChange",
@@ -119,6 +136,11 @@ export default function PaymentClient({
return
}
if (result.reservationStatus == BookingStatusEnum.BookingCompleted) {
const confirmationUrl = `${bookingConfirmation(lang)}?${BOOKING_CONFIRMATION_NUMBER}=${result.id}`
router.push(confirmationUrl)
}
setBookingNumber(result.id)
const priceChange = result.rooms.find(
@@ -212,18 +234,49 @@ 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 handleSubmit = useCallback(
(data: PaymentFormData) => {
// set payment method to card if saved card is submitted
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
? data.paymentMethod
: PaymentMethodEnum.card
const paymentMethod = getPaymentMethod(isFlexRate, 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 = !isFlexRate || guarantee
const payment = shouldUsePayment
? {
paymentMethod: paymentMethod,
...useSavedCard,
success: `${paymentRedirectUrl}/success`,
error: `${paymentRedirectUrl}/error`,
cancel: `${paymentRedirectUrl}/cancel`,
}
: undefined
trackPaymentEvent({
event: "paymentAttemptStart",
@@ -284,20 +337,7 @@ export default function PaymentClient({
publicPrice: room.roomRate.publicRate?.localPrice.pricePerStay,
},
})),
payment: {
paymentMethod,
card: savedCreditCard
? {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
}
: undefined,
success: `${paymentRedirectUrl}/success`,
error: `${paymentRedirectUrl}/error`,
cancel: `${paymentRedirectUrl}/cancel`,
},
payment,
})
},
[
@@ -309,6 +349,7 @@ export default function PaymentClient({
toDate,
rooms,
booking,
isFlexRate,
]
)
@@ -327,6 +368,9 @@ export default function PaymentClient({
const payment = intl.formatMessage({
id: "Payment",
})
const confirm = intl.formatMessage({
id: "Confirm booking",
})
return (
<section
@@ -334,7 +378,11 @@ export default function PaymentClient({
>
<header>
<Title level="h2" as="h4">
{mustBeGuaranteed ? paymentGuarantee : payment}
{bookingMustBeGuaranteed
? paymentGuarantee
: isFlexRate
? confirm
: payment}
</Title>
</header>
<FormProvider {...methods}>
@@ -343,127 +391,115 @@ export default function PaymentClient({
onSubmit={methods.handleSubmit(handleSubmit)}
id={formId}
>
{mustBeGuaranteed ? (
<section className={styles.section}>
<Body>
{intl.formatMessage({
id: "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}
{isFlexRate && !bookingMustBeGuaranteed ? (
<ConfirmBooking savedCreditCards={savedCreditCards} />
) : (
<>
{bookingMustBeGuaranteed ? (
<section className={styles.section}>
<Body>
{intl.formatMessage({
id: "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({
id: "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}
{hasMixedRates ? (
<Body>
{intl.formatMessage({
id: "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}
{savedCreditCards?.length ? (
<section className={styles.section}>
<Body color="uiTextHighContrast" textTransform="bold">
{intl.formatMessage({ id: "MY SAVED CARDS" })}
</Body>
<div className={styles.paymentOptionContainer}>
{savedCreditCards?.map((savedCreditCard) => (
{savedCreditCards?.length ? (
<section className={styles.section}>
<MySavedCards savedCreditCards={savedCreditCards} />
</section>
) : null}
<section className={styles.section}>
{savedCreditCards?.length ? (
<Body color="uiTextHighContrast" textTransform="bold">
{intl.formatMessage({ id: "OTHER PAYMENT METHODS" })}
</Body>
) : null}
<div className={styles.paymentOptionContainer}>
<PaymentOption
key={savedCreditCard.id}
name="paymentMethod"
value={savedCreditCard.id}
label={
PAYMENT_METHOD_TITLES[
savedCreditCard.cardType as PaymentMethodEnum
]
}
cardNumber={savedCreditCard.truncatedNumber}
hotelId={hotelId}
value={PaymentMethodEnum.card}
label={intl.formatMessage({ id: "Credit card" })}
/>
))}
</div>
</section>
) : null}
{availablePaymentOptions.map((paymentMethod) => (
<PaymentOption
key={paymentMethod}
name="paymentMethod"
value={paymentMethod}
label={
PAYMENT_METHOD_TITLES[
paymentMethod as PaymentMethodEnum
]
}
/>
))}
</div>
{hasMixedRates ? (
<MixedRatePaymentBreakdown
rooms={rooms}
currency={totalPrice.local.currency}
/>
) : null}
</section>
<section className={styles.section}>
{savedCreditCards?.length ? (
<Body color="uiTextHighContrast" textTransform="bold">
{intl.formatMessage({ id: "OTHER PAYMENT METHODS" })}
</Body>
) : null}
<div className={styles.paymentOptionContainer}>
<PaymentOption
name="paymentMethod"
value={PaymentMethodEnum.card}
label={intl.formatMessage({ id: "Credit card" })}
hotelId={hotelId}
/>
{availablePaymentOptions.map((paymentMethod) => (
<PaymentOption
key={paymentMethod}
name="paymentMethod"
value={paymentMethod}
label={
PAYMENT_METHOD_TITLES[paymentMethod as PaymentMethodEnum]
}
hotelId={hotelId}
/>
))}
</div>
{hasMixedRates ? (
<MixedRatePaymentBreakdown
rooms={rooms}
currency={totalPrice.local.currency}
/>
) : null}
</section>
<section className={styles.section}>
<Caption>
{intl.formatMessage(
{
id: "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.",
},
{
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 name="termsAndConditions">
<Caption>
{intl.formatMessage({
id: "I accept the terms and conditions",
})}
</Caption>
</Checkbox>
<Checkbox name="smsConfirmation">
<Caption>
{intl.formatMessage({
id: "I would like to get my booking confirmation via sms",
})}
</Caption>
</Checkbox>
</section>
<section className={styles.section}>
<Caption>
{intl.formatMessage(
{
id: "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.",
},
{
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 name="termsAndConditions">
<Caption>
{intl.formatMessage({
id: "I accept the terms and conditions",
})}
</Caption>
</Checkbox>
<Checkbox name="smsConfirmation">
<Caption>
{intl.formatMessage({
id: "I would like to get my booking confirmation via sms",
})}
</Caption>
</Checkbox>
</section>
</>
)}
<div className={styles.submitButton}>
<Button
intent="primary"

View File

@@ -7,5 +7,4 @@ export interface PaymentOptionProps {
cardNumber?: string
registerOptions?: RegisterOptions
onChange?: () => void
hotelId: string
}

View File

@@ -1,4 +1,4 @@
import { CancellationRuleEnum, PaymentMethodEnum } from "@/constants/booking"
import { PaymentMethodEnum } from "@/constants/booking"
import type { RoomState } from "@/types/stores/enter-details"
@@ -7,11 +7,11 @@ export function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
}
export function hasFlexibleRate({ room }: RoomState): boolean {
return room.cancellationRule === CancellationRuleEnum.CancellableBefore6PM
return room.isFlexRate
}
export function hasPrepaidRate({ room }: RoomState): boolean {
return room.cancellationRule !== CancellationRuleEnum.CancellableBefore6PM
return !room.isFlexRate
}
export function calculateTotalRoomPrice({ room }: RoomState) {

View File

@@ -6,7 +6,10 @@ import type { PaymentProps } from "@/types/components/hotelReservation/enterDeta
export default async function Payment({
otherPaymentOptions,
mustBeGuaranteed,
memberMustBeGuaranteed,
supportedCards,
isFlexRate,
}: PaymentProps) {
const savedCreditCards = await getSavedPaymentCardsSafely({
supportedCards,
@@ -16,6 +19,9 @@ export default async function Payment({
<PaymentClient
otherPaymentOptions={otherPaymentOptions}
savedCreditCards={savedCreditCards}
mustBeGuaranteed={mustBeGuaranteed}
memberMustBeGuaranteed={memberMustBeGuaranteed}
isFlexRate={isFlexRate}
/>
)
}

View File

@@ -1,11 +1,12 @@
import { z } from "zod"
export const paymentSchema = z.object({
paymentMethod: z.string(),
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> {}

View File

@@ -66,6 +66,7 @@ const rooms: RoomState[] = [
roomTypeCode: "QS",
isAvailable: true,
mustBeGuaranteed: false,
isFlexRate: false,
},
steps: {
[StepEnum.selectBed]: {
@@ -94,7 +95,6 @@ const rooms: RoomState[] = [
bedTypes: [],
breakfast: undefined,
breakfastIncluded: false,
cancellationRule: "",
cancellationText: "Non-refundable",
childrenInRoom: [],
guest: guestDetailsMember,
@@ -106,6 +106,7 @@ const rooms: RoomState[] = [
roomTypeCode: "QS",
isAvailable: true,
mustBeGuaranteed: false,
isFlexRate: false,
},
steps: {
[StepEnum.selectBed]: {

View File

@@ -60,7 +60,11 @@ export default function ConfirmationStep() {
<Body textTransform="bold">{"MasterCard"}</Body>
<Body color="uiTextMediumContrast">{"**** 1234"}</Body>
</div>
<Checkbox name="termsAndConditions" registerOptions={{ required: true }}>
<Checkbox
name="termsAndConditions"
registerOptions={{ required: true }}
topAlign
>
<Caption>
{intl.formatMessage(
{

View File

@@ -0,0 +1,57 @@
.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);
}
.addCreditCard {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.guaranteeCost {
display: flex;
justify-content: flex-end;
padding: var(--Spacing-x2);
align-items: flex-end;
gap: var(--Spacing-x3);
border-radius: var(--Corner-radius-Medium);
background-color: var(--Base-Surface-Subtle-Normal);
}
.guaranteeCostText {
display: flex;
flex-direction: column;
}
.termsAndConditions {
display: flex;
gap: var(--Spacing-x2);
}
.section {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.paymentOptionContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
width: 640px;
max-width: 100%;
height: 640px;
max-height: 100%;
}

View File

@@ -0,0 +1,252 @@
"use client"
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"
import { BookingStatusEnum, PaymentMethodEnum } from "@/constants/booking"
import {
bookingTermsAndConditions,
privacyPolicy,
} 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"
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 { toast } from "@/components/TempDesignSystem/Toasts"
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import MySavedCards from "../../EnterDetails/Payment/MySavedCards"
import PaymentOption from "../../EnterDetails/Payment/PaymentOption"
import { type GuaranteeFormData, paymentSchema } from "./schema"
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
handleBackToManageStay: () => void
savedCreditCards: CreditCard[] | null
refId: string
}
export default function GuaranteeLateArrival({
booking,
handleCloseModal,
handleBackToManageStay,
savedCreditCards,
refId,
}: GuaranteeLateArrivalProps) {
const intl = useIntl()
const lang = useLang()
const router = useRouter()
const methods = useForm<GuaranteeFormData>({
defaultValues: {
paymentMethod: savedCreditCards?.length
? savedCreditCards[0].id
: PaymentMethodEnum.card,
termsAndConditions: false,
},
mode: "all",
reValidateMode: "onChange",
resolver: zodResolver(paymentSchema),
})
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) {
setIsPollingForBookingStatus(true)
} else {
handlePaymentError()
}
},
onError: () => {
toast.error(
intl.formatMessage({
id: "Something went wrong!",
})
)
},
})
const bookingStatus = useHandleBookingStatus({
confirmationNumber: booking.confirmationNumber,
expectedStatus: 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])
if (
guaranteeBooking.isPending ||
(isPollingForBookingStatus &&
!bookingStatus.data?.paymentUrl &&
!bookingStatus.isTimeout)
) {
return (
<div className={styles.loading}>
<LoadingSpinner />
</div>
)
}
const handleGuaranteeLateArrival = (data: GuaranteeFormData) => {
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
? {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
}
: undefined
guaranteeBooking.mutate({
confirmationNumber: booking.confirmationNumber,
language: lang,
...(card !== undefined && { card }),
success: `${guaranteeRedirectUrl}/success/${encodeURIComponent(refId)}`,
error: `${guaranteeRedirectUrl}/error/${encodeURIComponent(refId)}`,
cancel: `${guaranteeRedirectUrl}/cancel/${encodeURIComponent(refId)}`,
})
} else {
toast.error(
intl.formatMessage({
id: "Confirmation number is missing!",
})
)
}
}
return (
<FormProvider {...methods}>
<ModalContentWithActions
title={intl.formatMessage({ id: "Guarantee late arrival" })}
onClose={handleCloseModal}
content={
<>
<Caption>
{intl.formatMessage({
id: "Planning to arrive after 18.00? Secure your room by guaranteeing it with a credit card. Without the guarantee and in case of no-show, the room might be reallocated after 18:00.",
})}
</Caption>
<Caption type="bold">
{intl.formatMessage({
id: "In case of no-show you will be charged for the first night.",
})}
</Caption>
{savedCreditCards?.length ? (
<>
<MySavedCards savedCreditCards={savedCreditCards} />
<Body color="uiTextHighContrast" textTransform="bold">
{intl.formatMessage({ id: "OTHER" })}
</Body>
</>
) : null}
<PaymentOption
name="paymentMethod"
value={PaymentMethodEnum.card}
label={intl.formatMessage({ id: "Credit card" })}
/>
<div className={styles.termsAndConditions}>
<Checkbox topAlign name={"termsAndConditions"}>
<Caption>
{intl.formatMessage(
{
id: "By guaranteeing with any of the payment methods available, I accept the terms for this stay and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand Scandic will process my personal data for this stay in accordance with <privacyPolicyLink>Scandics Privacy Policy</privacyPolicyLink>. I accept Scandic requiring a valid credit card during my visit in case anything is left unpaid.",
},
{
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>
<div className={styles.guaranteeCost}>
<div className={styles.guaranteeCostText}>
<Caption type="bold">
{intl.formatMessage({ id: "Guarantee cost" })}
</Caption>
<Caption color="uiTextHighContrast">
{intl.formatMessage({
id: "Your card will only be used for authorisation",
})}
</Caption>
</div>
<Divider variant="vertical" color="subtle" />
<Body textTransform="bold">
{formatPrice(intl, 0, booking.currencyCode)}
</Body>
</div>
</>
}
primaryAction={{
label: intl.formatMessage({ id: "Guarantee" }),
onClick: methods.handleSubmit(handleGuaranteeLateArrival),
intent: "primary",
}}
secondaryAction={{
label: intl.formatMessage({ id: "Back" }),
onClick: handleBackToManageStay,
intent: "text",
}}
/>
</FormProvider>
)
}

View File

@@ -0,0 +1,10 @@
import { z } from "zod"
export const paymentSchema = z.object({
paymentMethod: z.string().nullable(),
termsAndConditions: z.boolean().refine((value) => value === true, {
message: "You must accept the terms and conditions",
}),
})
export interface GuaranteeFormData extends z.output<typeof paymentSchema> {}

View File

@@ -31,14 +31,18 @@ interface ActionPanelProps {
booking: BookingConfirmation["booking"]
hotel: Hotel
showCancelStayButton: boolean
showGuaranteeButton: boolean
onCancelClick: () => void
onGuaranteeClick: () => void
}
export default function ActionPanel({
booking,
hotel,
showCancelStayButton,
showGuaranteeButton,
onCancelClick,
onGuaranteeClick,
}: ActionPanelProps) {
const intl = useIntl()
const lang = useLang()
@@ -74,15 +78,17 @@ export default function ActionPanel({
{intl.formatMessage({ id: "Modify dates" })}
<CalendarIcon width={24} height={24} color="burgundy" />
</Button>
<Button
variant="icon"
onClick={() => {}}
intent="text"
className={styles.button}
>
{intl.formatMessage({ id: "Guarantee late arrival" })}
<CreditCard width={24} height={24} color="burgundy" />
</Button>
{showGuaranteeButton && (
<Button
variant="icon"
onClick={onGuaranteeClick}
intent="text"
className={styles.button}
>
{intl.formatMessage({ id: "Guarantee late arrival" })}
<CreditCard width={24} height={24} color="burgundy" />
</Button>
)}
<AddToCalendar
checkInDate={booking.checkInDate}
event={event}

View File

@@ -10,18 +10,22 @@ import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import CancelStay from "../CancelStay"
import GuaranteeLateArrival from "../GuaranteeLateArrival"
import ActionPanel from "./ActionPanel"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
import type { CreditCard } from "@/types/user"
type ActiveView = "actionPanel" | "cancelStay"
type ActiveView = "actionPanel" | "cancelStay" | "guaranteeLateArrival"
interface ManageStayProps {
booking: BookingConfirmation["booking"]
hotel: Hotel
setBookingStatus: (status: BookingStatusEnum) => void
bookingStatus: string | null
savedCreditCards: CreditCard[] | null
refId: string
}
export default function ManageStay({
@@ -29,6 +33,8 @@ export default function ManageStay({
hotel,
setBookingStatus,
bookingStatus,
savedCreditCards,
refId,
}: ManageStayProps) {
const [isOpen, setIsOpen] = useState(false)
@@ -39,6 +45,9 @@ export default function ManageStay({
const showCancelStayButton =
bookingStatus !== BookingStatusEnum.Cancelled && booking.isCancelable
const showGuaranteeButton =
bookingStatus !== BookingStatusEnum.Cancelled && !booking.guaranteeInfo
function handleClose() {
setIsOpen(false)
setActiveView("actionPanel")
@@ -61,13 +70,25 @@ export default function ManageStay({
handleBackToManageStay={handleBack}
/>
)
case "guaranteeLateArrival":
return (
<GuaranteeLateArrival
booking={booking}
handleCloseModal={handleClose}
handleBackToManageStay={handleBack}
savedCreditCards={savedCreditCards}
refId={refId}
/>
)
default:
return (
<ActionPanel
booking={booking}
hotel={hotel}
onCancelClick={() => setActiveView("cancelStay")}
onGuaranteeClick={() => setActiveView("guaranteeLateArrival")}
showCancelStayButton={showCancelStayButton}
showGuaranteeButton={showGuaranteeButton}
/>
)
}

View File

@@ -14,6 +14,7 @@ import IconChip from "@/components/TempDesignSystem/IconChip"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useGuaranteePaymentFailedToast } from "@/hooks/booking/useGuaranteePaymentFailedToast"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
@@ -24,13 +25,21 @@ import styles from "./referenceCard.module.css"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
import type { CreditCard } from "@/types/user"
interface ReferenceCardProps {
booking: BookingConfirmation["booking"]
hotel: Hotel
savedCreditCards: CreditCard[] | null
refId: string
}
export function ReferenceCard({ booking, hotel }: ReferenceCardProps) {
export function ReferenceCard({
booking,
hotel,
savedCreditCards,
refId,
}: ReferenceCardProps) {
const [bookingStatus, setBookingStatus] = useState(booking.reservationStatus)
const intl = useIntl()
const lang = useLang()
@@ -40,6 +49,7 @@ export function ReferenceCard({ booking, hotel }: ReferenceCardProps) {
const toDate = dt(booking.checkOutDate).locale(lang)
const isCancelled = bookingStatus === BookingStatusEnum.Cancelled
useGuaranteePaymentFailedToast()
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}`
@@ -173,6 +183,8 @@ export function ReferenceCard({ booking, hotel }: ReferenceCardProps) {
hotel={hotel}
setBookingStatus={setBookingStatus}
bookingStatus={bookingStatus}
savedCreditCards={savedCreditCards}
refId={refId}
/>
<Button fullWidth intent="secondary" asChild>
<Link href={directionsUrl} target="_blank">

View File

@@ -9,6 +9,7 @@ import {
getAncillaryPackages,
getBookingConfirmation,
getProfileSafely,
getSavedPaymentCardsSafely,
} from "@/lib/trpc/memoizedRequests"
import { decrypt } from "@/server/routers/utils/encryption"
@@ -64,6 +65,10 @@ export async function MyStay({ refId }: { refId: string }) {
hotelId: hotel.operaId,
toDate: dt(booking.checkOutDate).format("YYYY-MM-DD"),
})
const supportedCards = hotel.merchantInformationData.cards
const savedCreditCards = await getSavedPaymentCardsSafely({
supportedCards,
})
return (
<main className={styles.main}>
@@ -84,7 +89,12 @@ export async function MyStay({ refId }: { refId: string }) {
<div className={styles.content}>
<div className={styles.headerContainer}>
<Header hotel={hotel} />
<ReferenceCard booking={booking} hotel={hotel} />
<ReferenceCard
booking={booking}
hotel={hotel}
savedCreditCards={savedCreditCards}
refId={refId}
/>
</div>
{booking.showAncillaries && (
<Ancillaries

View File

@@ -35,6 +35,10 @@
forced-color-adjust: none;
}
.topAlign {
align-items: flex-start;
}
.error {
align-items: center;
color: var(--Scandic-Red-60);

View File

@@ -17,6 +17,7 @@ export default function Checkbox({
children,
registerOptions,
hideError,
topAlign = false,
}: React.PropsWithChildren<CheckboxProps>) {
const { control } = useFormContext()
const { field, fieldState } = useController({
@@ -36,7 +37,9 @@ export default function Checkbox({
>
{({ isSelected }) => (
<>
<span className={styles.checkboxContainer}>
<span
className={`${styles.checkboxContainer} ${topAlign ? styles.topAlign : ""}`}
>
<span
className={styles.checkbox}
tabIndex={registerOptions?.disabled ? undefined : 0}