diff --git a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/gla-payment-callback/layout.module.css b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/gla-payment-callback/layout.module.css new file mode 100644 index 000000000..1730ffa68 --- /dev/null +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/gla-payment-callback/layout.module.css @@ -0,0 +1,3 @@ +.layout { + background-color: var(--Base-Background-Primary-Normal); +} diff --git a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/gla-payment-callback/layout.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/gla-payment-callback/layout.tsx new file mode 100644 index 000000000..2ab4ad531 --- /dev/null +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/gla-payment-callback/layout.tsx @@ -0,0 +1,9 @@ +import styles from "./layout.module.css" + +import type { LangParams, LayoutArgs } from "@/types/params" + +export default function GuaranteePaymentCallbackLayout({ + children, +}: React.PropsWithChildren>) { + return
{children}
+} diff --git a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/gla-payment-callback/page.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/gla-payment-callback/page.tsx new file mode 100644 index 000000000..04e1a916d --- /dev/null +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/gla-payment-callback/page.tsx @@ -0,0 +1,77 @@ +import { redirect } from "next/navigation" + +import { BookingErrorCodeEnum } from "@/constants/booking" +import { hotelreservation } from "@/constants/routes/hotelReservation" +import { serverClient } from "@/lib/trpc/server" + +import LoadingSpinner from "@/components/LoadingSpinner" + +import type { LangParams, PageArgs } from "@/types/params" + +export default async function GuaranteePaymentCallbackPage({ + params, + searchParams, +}: PageArgs< + LangParams, + { + status: "error" | "success" | "cancel" + refId: string + confirmationNumber?: string + } +>) { + console.log(`[gla-payment-callback] callback started`) + const lang = params.lang + const status = searchParams.status + const confirmationNumber = searchParams.confirmationNumber + const refId = searchParams.refId + const myStayUrl = `${hotelreservation(lang)}/my-stay/${encodeURIComponent(refId)}` + + if (status === "success" && confirmationNumber && refId) { + console.log(`[gla-payment-callback] redirecting to: ${myStayUrl}`) + redirect(myStayUrl) + } + + const searchObject = new URLSearchParams() + + let errorMessage = undefined + + if (confirmationNumber) { + try { + const bookingStatus = await serverClient().booking.status({ + confirmationNumber, + }) + + // TODO: how to handle errors for multiple rooms? + const error = bookingStatus.errors.find((e) => e.errorCode) + + errorMessage = + error?.description ?? + `No error message found for booking ${confirmationNumber}, status: ${status}` + + searchObject.set( + "errorCode", + error + ? error.errorCode.toString() + : BookingErrorCodeEnum.TransactionFailed + ) + } catch { + console.error( + `[gla-payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}` + ) + if (status === "cancel") { + searchObject.set("errorCode", BookingErrorCodeEnum.TransactionCancelled) + } + if (status === "error") { + searchObject.set( + "errorCode", + BookingErrorCodeEnum.TransactionFailed.toString() + ) + errorMessage = `Failed to get booking status for ${confirmationNumber}, status: ${status}` + } + } + console.log(errorMessage) + redirect(`${myStayUrl}?${searchObject.toString()}`) + } + + return +} diff --git a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page.tsx index 68bf17f6a..8995f0111 100644 --- a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page.tsx +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page.tsx @@ -66,6 +66,7 @@ export default async function DetailsPage({ roomStayStartDate: booking.fromDate, roomStayEndDate: booking.toDate, roomTypeCode: room.roomTypeCode, + counterRateCode: room.counterRateCode, bookingCode: booking.bookingCode, } @@ -89,13 +90,13 @@ export default async function DetailsPage({ // redirect back to select-rate if availability call fails redirect(`${selectRate(lang)}?${selectRoomParams.toString()}`) } - rooms.push({ bedTypes: roomAvailability.bedTypes, breakfastIncluded: roomAvailability.breakfastIncluded, - cancellationRule: roomAvailability.cancellationRule, cancellationText: roomAvailability.cancellationText, + isFlexRate: roomAvailability.isFlexRate, mustBeGuaranteed: roomAvailability.mustBeGuaranteed, + memberMustBeGuaranteed: roomAvailability.memberMustBeGuaranteed, packages, rateTitle: roomAvailability.rateTitle, rateDetails: roomAvailability.rateDetails ?? [], @@ -109,8 +110,12 @@ export default async function DetailsPage({ roomAvailability.selectedRoom.status === AvailabilityEnum.Available, }) } - const isCardOnlyPayment = rooms.some((room) => room?.mustBeGuaranteed) + const memberMustBeGuaranteed = rooms.some( + (room) => room?.memberMustBeGuaranteed + ) + const isFlexRate = rooms.some((room) => room.isFlexRate) + const hotelData = await getHotel({ hotelId: booking.hotelId, isCardOnlyPayment, @@ -197,6 +202,9 @@ export default async function DetailsPage({ hotel.merchantInformationData.alternatePaymentOptions } supportedCards={hotel.merchantInformationData.cards} + mustBeGuaranteed={isCardOnlyPayment} + memberMustBeGuaranteed={memberMustBeGuaranteed} + isFlexRate={isFlexRate} /> diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Rooms/Room/index.tsx b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Rooms/Room/index.tsx index 338fc5253..648bdcea5 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Rooms/Room/index.tsx +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Rooms/Room/index.tsx @@ -54,6 +54,19 @@ export default function Room({ booking, img, roomName }: RoomProps) { )} + {booking.guaranteeInfo && ( +
+ + + + {intl.formatMessage({ id: "Booking guaranteed." })} + {" "} + {intl.formatMessage({ + id: "Your room will remain available for check-in even after 18:00.", + })} + +
+ )}
+
+
+
+ + +

+ {intl.formatMessage({ + id: "Guarantee room for late arrival", + })} +

+
+
+ + + setModalOpen(false)}> +
+ +

+ {intl.formatMessage({ id: "Guarantee for late arrival" })} +

+
+ +

+ {intl.formatMessage({ + id: "When guaranteeing your booking with a credit card, we will hold the booking until 07:00 the day after check-in.", + })} +

+
+ +

+ {intl.formatMessage({ + id: "In case of a no-show, your credit card will be charged for the first night.", + })} +

+
+ +
+
+
+ + +

+ {intl.formatMessage({ + id: "I may arrive later than 18:00 and want to guarantee my booking with a credit card.", + })} +

+
+ {savedCreditCards?.length && guarantee ? ( + + ) : null} + {guarantee && ( + <> + {savedCreditCards?.length && ( + +

{intl.formatMessage({ id: "OTHER" })}

+
+ )} + + + )} +
+
+ + +

+ {intl.formatMessage({ + id: "I would like to get my booking confirmation via sms", + })} +

+
+
+
+ + +

+ {intl.formatMessage( + { + id: "By paying with any of the payment methods available, I accept the terms for this booking and the general Terms & Conditions, and understand that Scandic will process my personal data for this booking in accordance with Scandic's Privacy policy. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.", + }, + { + termsAndConditionsLink: (str) => ( + + {str} + + ), + privacyPolicyLink: (str) => ( + + {str} + + ), + } + )} +

+
+
+
+
+
+ ) +} diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/MySavedCards/index.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/MySavedCards/index.tsx new file mode 100644 index 000000000..3771e9327 --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/MySavedCards/index.tsx @@ -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 ( +
+ +

{intl.formatMessage({ id: "MY SAVED CARDS" })}

+
+
+ {savedCreditCards?.map((savedCreditCard) => ( + + ))} +
+
+ ) +} diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/MySavedCards/mySavedCards.module.css b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/MySavedCards/mySavedCards.module.css new file mode 100644 index 000000000..ded8d1b5b --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/MySavedCards/mySavedCards.module.css @@ -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); +} diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx index 4f20e7470..bf29da71c 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx @@ -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 (
- {mustBeGuaranteed ? paymentGuarantee : payment} + {bookingMustBeGuaranteed + ? paymentGuarantee + : isFlexRate + ? confirm + : payment}
@@ -343,127 +391,115 @@ export default function PaymentClient({ onSubmit={methods.handleSubmit(handleSubmit)} id={formId} > - {mustBeGuaranteed ? ( -
- - {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.", - })} - - -
- ) : null} + {isFlexRate && !bookingMustBeGuaranteed ? ( + + ) : ( + <> + {bookingMustBeGuaranteed ? ( +
+ + {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.", + })} + + +
+ ) : null} - {hasMixedRates ? ( - - {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.", - })} - - ) : null} + {hasMixedRates ? ( + + {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.", + })} + + ) : null} - {savedCreditCards?.length ? ( -
- - {intl.formatMessage({ id: "MY SAVED CARDS" })} - -
- {savedCreditCards?.map((savedCreditCard) => ( + {savedCreditCards?.length ? ( +
+ +
+ ) : null} + +
+ {savedCreditCards?.length ? ( + + {intl.formatMessage({ id: "OTHER PAYMENT METHODS" })} + + ) : null} +
- ))} -
-
- ) : null} + {availablePaymentOptions.map((paymentMethod) => ( + + ))} +
+ {hasMixedRates ? ( + + ) : null} +
-
- {savedCreditCards?.length ? ( - - {intl.formatMessage({ id: "OTHER PAYMENT METHODS" })} - - ) : null} -
- - {availablePaymentOptions.map((paymentMethod) => ( - - ))} -
- {hasMixedRates ? ( - - ) : null} -
- -
- - {intl.formatMessage( - { - id: "By paying with any of the payment methods available, I accept the terms for this booking and the general Terms & Conditions, and understand that Scandic will process my personal data for this booking in accordance with Scandic's Privacy policy. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.", - }, - { - termsAndConditionsLink: (str) => ( - - {str} - - ), - privacyPolicyLink: (str) => ( - - {str} - - ), - } - )} - - - - {intl.formatMessage({ - id: "I accept the terms and conditions", - })} - - - - - {intl.formatMessage({ - id: "I would like to get my booking confirmation via sms", - })} - - -
+
+ + {intl.formatMessage( + { + id: "By paying with any of the payment methods available, I accept the terms for this booking and the general Terms & Conditions, and understand that Scandic will process my personal data for this booking in accordance with Scandic's Privacy policy. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.", + }, + { + termsAndConditionsLink: (str) => ( + + {str} + + ), + privacyPolicyLink: (str) => ( + + {str} + + ), + } + )} + + + + {intl.formatMessage({ + id: "I accept the terms and conditions", + })} + + + + + {intl.formatMessage({ + id: "I would like to get my booking confirmation via sms", + })} + + +
+ + )}
- + {intl.formatMessage( { diff --git a/apps/scandic-web/components/HotelReservation/MyStay/GuaranteeLateArrival/guaranteeLateArrival.module.css b/apps/scandic-web/components/HotelReservation/MyStay/GuaranteeLateArrival/guaranteeLateArrival.module.css new file mode 100644 index 000000000..224f06fa9 --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/MyStay/GuaranteeLateArrival/guaranteeLateArrival.module.css @@ -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%; +} diff --git a/apps/scandic-web/components/HotelReservation/MyStay/GuaranteeLateArrival/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/GuaranteeLateArrival/index.tsx new file mode 100644 index 000000000..90b897922 --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/MyStay/GuaranteeLateArrival/index.tsx @@ -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({ + 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 ( +
+ +
+ ) + } + + 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 ( + + + + {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.", + })} + + + {intl.formatMessage({ + id: "In case of no-show you will be charged for the first night.", + })} + + {savedCreditCards?.length ? ( + <> + + + {intl.formatMessage({ id: "OTHER" })} + + + ) : null} + +
+ + + {intl.formatMessage( + { + id: "By guaranteeing with any of the payment methods available, I accept the terms for this stay and the general Terms & Conditions, and understand Scandic will process my personal data for this stay in accordance with Scandic’s Privacy Policy. I accept Scandic requiring a valid credit card during my visit in case anything is left unpaid.", + }, + { + termsAndConditionsLink: (str) => ( + + {str} + + ), + privacyPolicyLink: (str) => ( + + {str} + + ), + } + )} + + +
+
+
+ + {intl.formatMessage({ id: "Guarantee cost" })} + + + {intl.formatMessage({ + id: "Your card will only be used for authorisation", + })} + +
+ + + {formatPrice(intl, 0, booking.currencyCode)} + +
+ + } + primaryAction={{ + label: intl.formatMessage({ id: "Guarantee" }), + onClick: methods.handleSubmit(handleGuaranteeLateArrival), + intent: "primary", + }} + secondaryAction={{ + label: intl.formatMessage({ id: "Back" }), + onClick: handleBackToManageStay, + intent: "text", + }} + /> +
+ ) +} diff --git a/apps/scandic-web/components/HotelReservation/MyStay/GuaranteeLateArrival/schema.ts b/apps/scandic-web/components/HotelReservation/MyStay/GuaranteeLateArrival/schema.ts new file mode 100644 index 000000000..3867ada6f --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/MyStay/GuaranteeLateArrival/schema.ts @@ -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 {} diff --git a/apps/scandic-web/components/HotelReservation/MyStay/ManageStay/ActionPanel/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/ManageStay/ActionPanel/index.tsx index af368e031..b18593393 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/ManageStay/ActionPanel/index.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/ManageStay/ActionPanel/index.tsx @@ -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" })} - + {showGuaranteeButton && ( + + )} 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 ( + + ) default: return ( setActiveView("cancelStay")} + onGuaranteeClick={() => setActiveView("guaranteeLateArrival")} showCancelStayButton={showCancelStayButton} + showGuaranteeButton={showGuaranteeButton} /> ) } diff --git a/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/index.tsx index adb0ef530..da27a9b49 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/index.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/index.tsx @@ -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} />