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:
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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>Scandic’s 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>
|
||||
)
|
||||
}
|
||||
@@ -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> {}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user