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

@@ -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]: {