Merged in feat/SW-755-price-change-non-happy (pull request #957)

Feat/SW-755 price change non happy

* fix(SW-755): dont show field error if checkbox has no children

* feat(SW-755): Price change route + dialog WIP

* fix(SW-755): minor refactoring

* fix(SW-755): added logging to price change route

* fix(SW-755): remove redundant search param logic

* fix(SW-755): moved enum cast to zod instead

* fix(SW-755): move prop type to types folder

* fix(SW-755): Added suspense to Payment and refactored payment options hook

* fix(SW-755): seperated terms and conditions copy from the checkbox label

* fix(SW-755): add currency format and fixed wrong translation

* fix(SW-755): change from undefined to null

* fix(SW-755): added extra type safety to payment options


Approved-by: Christian Andolf
Approved-by: Simon.Emanuelsson
This commit is contained in:
Tobias Johansson
2024-11-26 09:06:41 +00:00
parent 9fc65b6e53
commit 70000afe1f
22 changed files with 577 additions and 217 deletions

View File

@@ -1,6 +1,7 @@
import "./enterDetailsLayout.css"
import { notFound } from "next/navigation"
import { Suspense } from "react"
import {
getBreakfastPackages,
@@ -170,16 +171,18 @@ export default async function StepPage({
step={StepEnum.payment}
label={mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod}
>
<Payment
user={user}
roomPrice={roomPrice}
otherPaymentOptions={
hotelData.data.attributes.merchantInformationData
.alternatePaymentOptions
}
savedCreditCards={savedCreditCards}
mustBeGuaranteed={mustBeGuaranteed}
/>
<Suspense>
<Payment
user={user}
roomPrice={roomPrice}
otherPaymentOptions={
hotelData.data.attributes.merchantInformationData
.alternatePaymentOptions
}
savedCreditCards={savedCreditCards}
mustBeGuaranteed={mustBeGuaranteed}
/>
</Suspense>
</SectionAccordion>
</section>
</StepsProvider>

View File

@@ -2,8 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
import { Label as AriaLabel } from "react-aria-components"
import { useCallback, useEffect, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
@@ -16,6 +15,7 @@ import {
bookingTermsAndConditions,
privacyPolicy,
} from "@/constants/currentWebHrefs"
import { selectRate } from "@/constants/routes/hotelReservation"
import { env } from "@/env/client"
import { trpc } from "@/lib/trpc/client"
import { useDetailsStore } from "@/stores/details"
@@ -27,11 +27,13 @@ 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 { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions"
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
import { usePaymentFailedToast } from "@/hooks/booking/usePaymentFailedToast"
import useLang from "@/hooks/useLang"
import { bedTypeMap } from "../../SelectRate/RoomSelection/utils"
import PriceChangeDialog from "../PriceChangeDialog"
import GuaranteeDetails from "./GuaranteeDetails"
import PaymentOption from "./PaymentOption"
import { PaymentFormData, paymentSchema } from "./schema"
@@ -60,29 +62,23 @@ export default function Payment({
const router = useRouter()
const lang = useLang()
const intl = useIntl()
const searchParams = useSearchParams()
const { booking, ...userData } = useDetailsStore((state) => state.data)
const totalPrice = useDetailsStore((state) => state.totalPrice)
const setIsSubmittingDisabled = useDetailsStore(
(state) => state.actions.setIsSubmittingDisabled
)
const {
firstName,
lastName,
email,
phoneNumber,
countryCode,
breakfast,
bedType,
membershipNo,
join,
dateOfBirth,
zipCode,
} = userData
const { toDate, fromDate, rooms, hotel } = booking
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
const [availablePaymentOptions, setAvailablePaymentOptions] =
useState(otherPaymentOptions)
const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
useState(false)
const availablePaymentOptions =
useAvailablePaymentOptions(otherPaymentOptions)
const [priceChangeData, setPriceChangeData] = useState<{
oldPrice: number
newPrice: number
} | null>()
usePaymentFailedToast()
@@ -103,6 +99,15 @@ export default function Payment({
onSuccess: (result) => {
if (result?.confirmationNumber) {
setConfirmationNumber(result.confirmationNumber)
if (result.metadata?.priceChangedMetadata) {
setPriceChangeData({
oldPrice: roomPrice.publicPrice,
newPrice: result.metadata.priceChangedMetadata.totalPrice,
})
} else {
setIsPollingForBookingStatus(true)
}
} else {
toast.error(
intl.formatMessage({
@@ -121,25 +126,31 @@ export default function Payment({
},
})
const priceChange = trpc.booking.priceChange.useMutation({
onSuccess: (result) => {
if (result?.confirmationNumber) {
setIsPollingForBookingStatus(true)
} else {
toast.error(intl.formatMessage({ id: "payment.error.failed" }))
}
setPriceChangeData(null)
},
onError: (error) => {
console.error("Error", error)
setPriceChangeData(null)
toast.error(intl.formatMessage({ id: "payment.error.failed" }))
},
})
const bookingStatus = useHandleBookingStatus({
confirmationNumber,
expectedStatus: BookingStatusEnum.BookingCompleted,
maxRetries,
retryInterval,
enabled: isPollingForBookingStatus,
})
useEffect(() => {
if (window.ApplePaySession) {
setAvailablePaymentOptions(otherPaymentOptions)
} else {
setAvailablePaymentOptions(
otherPaymentOptions.filter(
(option) => option !== PaymentMethodEnum.applePay
)
)
}
}, [otherPaymentOptions, setAvailablePaymentOptions])
useEffect(() => {
if (bookingStatus?.data?.paymentUrl) {
router.push(bookingStatus.data.paymentUrl)
@@ -162,76 +173,102 @@ export default function Payment({
setIsSubmittingDisabled,
])
function handleSubmit(data: PaymentFormData) {
// set payment method to card if saved card is submitted
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
? data.paymentMethod
: PaymentMethodEnum.card
const handleSubmit = useCallback(
(data: PaymentFormData) => {
const {
firstName,
lastName,
email,
phoneNumber,
countryCode,
breakfast,
bedType,
membershipNo,
join,
dateOfBirth,
zipCode,
} = userData
const { toDate, fromDate, rooms, hotel } = booking
const savedCreditCard = savedCreditCards?.find(
(card) => card.id === data.paymentMethod
)
// set payment method to card if saved card is submitted
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
? data.paymentMethod
: PaymentMethodEnum.card
const paymentRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback`
const savedCreditCard = savedCreditCards?.find(
(card) => card.id === data.paymentMethod
)
initiateBooking.mutate({
hotelId: hotel,
checkInDate: fromDate,
checkOutDate: toDate,
rooms: rooms.map((room) => ({
adults: room.adults,
childrenAges: room.children?.map((child) => ({
age: child.age,
bedType: bedTypeMap[parseInt(child.bed.toString())],
const paymentRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback`
initiateBooking.mutate({
hotelId: hotel,
checkInDate: fromDate,
checkOutDate: toDate,
rooms: rooms.map((room) => ({
adults: room.adults,
childrenAges: room.children?.map((child) => ({
age: child.age,
bedType: bedTypeMap[parseInt(child.bed.toString())],
})),
rateCode:
user || join || membershipNo ? room.counterRateCode : room.rateCode,
roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step.
guest: {
firstName,
lastName,
email,
phoneNumber,
countryCode,
membershipNumber: membershipNo,
becomeMember: join,
dateOfBirth,
postalCode: zipCode,
},
packages: {
breakfast: !!(breakfast && breakfast.code),
allergyFriendly:
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ??
false,
petFriendly:
room.packages?.includes(RoomPackageCodeEnum.PET_ROOM) ?? false,
accessibility:
room.packages?.includes(RoomPackageCodeEnum.ACCESSIBILITY_ROOM) ??
false,
},
smsConfirmationRequested: data.smsConfirmation,
roomPrice,
})),
rateCode:
user || join || membershipNo ? room.counterRateCode : room.rateCode,
roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step.
guest: {
title: "",
firstName,
lastName,
email,
phoneNumber,
countryCode,
membershipNumber: membershipNo,
becomeMember: join,
dateOfBirth,
postalCode: zipCode,
},
packages: {
breakfast: !!(breakfast && breakfast.code),
allergyFriendly:
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false,
petFriendly:
room.packages?.includes(RoomPackageCodeEnum.PET_ROOM) ?? false,
accessibility:
room.packages?.includes(RoomPackageCodeEnum.ACCESSIBILITY_ROOM) ??
false,
},
smsConfirmationRequested: data.smsConfirmation,
roomPrice,
})),
payment: {
paymentMethod,
card: savedCreditCard
? {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
}
: undefined,
payment: {
paymentMethod,
card: savedCreditCard
? {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
}
: undefined,
success: `${paymentRedirectUrl}/success`,
error: `${paymentRedirectUrl}/error`,
cancel: `${paymentRedirectUrl}/cancel`,
},
})
}
success: `${paymentRedirectUrl}/success`,
error: `${paymentRedirectUrl}/error`,
cancel: `${paymentRedirectUrl}/cancel`,
},
})
},
[
userData,
booking,
roomPrice,
savedCreditCards,
lang,
user,
initiateBooking,
]
)
if (
initiateBooking.isPending ||
(confirmationNumber && !bookingStatus.data?.paymentUrl)
(isPollingForBookingStatus && !bookingStatus.data?.paymentUrl)
) {
return <LoadingSpinner />
}
@@ -241,79 +278,70 @@ export default function Payment({
const paymentVerb = mustBeGuaranteed ? guaranteeing : paying
return (
<FormProvider {...methods}>
<form
className={styles.paymentContainer}
onSubmit={methods.handleSubmit(handleSubmit)}
id={formId}
>
{mustBeGuaranteed ? (
<>
<FormProvider {...methods}>
<form
className={styles.paymentContainer}
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}
{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) => (
<PaymentOption
key={savedCreditCard.id}
name="paymentMethod"
value={savedCreditCard.id}
label={
PAYMENT_METHOD_TITLES[
savedCreditCard.cardType as PaymentMethodEnum
]
}
cardNumber={savedCreditCard.truncatedNumber}
/>
))}
</div>
</section>
) : null}
<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}
{savedCreditCards?.length ? (
<section className={styles.section}>
<Body color="uiTextHighContrast" textTransform="bold">
{intl.formatMessage({ id: "MY SAVED CARDS" })}
</Body>
{savedCreditCards?.length ? (
<Body color="uiTextHighContrast" textTransform="bold">
{intl.formatMessage({ id: "OTHER PAYMENT METHODS" })}
</Body>
) : null}
<div className={styles.paymentOptionContainer}>
{savedCreditCards?.map((savedCreditCard) => (
<PaymentOption
name="paymentMethod"
value={PaymentMethodEnum.card}
label={intl.formatMessage({ id: "Credit card" })}
/>
{availablePaymentOptions.map((paymentMethod) => (
<PaymentOption
key={savedCreditCard.id}
key={paymentMethod}
name="paymentMethod"
value={savedCreditCard.id}
value={paymentMethod}
label={
PAYMENT_METHOD_TITLES[
savedCreditCard.cardType as PaymentMethodEnum
]
PAYMENT_METHOD_TITLES[paymentMethod as PaymentMethodEnum]
}
cardNumber={savedCreditCard.truncatedNumber}
/>
))}
</div>
</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
name="paymentMethod"
value={PaymentMethodEnum.card}
label={intl.formatMessage({ id: "Credit card" })}
/>
{availablePaymentOptions.map((paymentMethod) => (
<PaymentOption
key={paymentMethod}
name="paymentMethod"
value={paymentMethod}
label={
PAYMENT_METHOD_TITLES[paymentMethod as PaymentMethodEnum]
}
/>
))}
</div>
</section>
<section className={styles.section}>
<Checkbox name="smsConfirmation">
<Caption>
{intl.formatMessage({
id: "I would like to get my booking confirmation via sms",
})}
</Caption>
</Checkbox>
<AriaLabel className={styles.terms}>
<Checkbox name="termsAndConditions" />
<section className={styles.section}>
<Caption>
{intl.formatMessage<React.ReactNode>(
{
@@ -344,19 +372,48 @@ export default function Payment({
}
)}
</Caption>
</AriaLabel>
</section>
<div className={styles.submitButton}>
<Button
type="submit"
disabled={
!methods.formState.isValid || methods.formState.isSubmitting
}
>
{intl.formatMessage({ id: "Complete booking" })}
</Button>
</div>
</form>
</FormProvider>
<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
type="submit"
disabled={
!methods.formState.isValid || methods.formState.isSubmitting
}
>
{intl.formatMessage({ id: "Complete booking" })}
</Button>
</div>
</form>
</FormProvider>
{priceChangeData ? (
<PriceChangeDialog
isOpen={!!priceChangeData}
oldPrice={priceChangeData.oldPrice}
newPrice={priceChangeData.newPrice}
currency={totalPrice.local.currency}
onCancel={() => {
const allSearchParams = searchParams.size
? `?${searchParams.toString()}`
: ""
router.push(`${selectRate(lang)}${allSearchParams}`)
}}
onAccept={() => priceChange.mutate({ confirmationNumber })}
/>
) : null}
</>
)
}

View File

@@ -0,0 +1,73 @@
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
import { useIntl } from "react-intl"
import { InfoCircleIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import styles from "./priceChangeDialog.module.css"
import { PriceChangeDialogProps } from "@/types/components/hotelReservation/enterDetails/priceChangeDialog"
export default function PriceChangeDialog({
isOpen,
oldPrice,
newPrice,
currency,
onCancel,
onAccept,
}: PriceChangeDialogProps) {
const intl = useIntl()
const title = intl.formatMessage({ id: "The price has increased" })
return (
<ModalOverlay
className={styles.overlay}
isOpen={isOpen}
isKeyboardDismissDisabled
>
<Modal className={styles.modal}>
<Dialog aria-label={title} className={styles.dialog}>
<header className={styles.header}>
<div className={styles.titleContainer}>
<InfoCircleIcon height={48} width={48} color="burgundy" />
<Title
level="h1"
as="h3"
textAlign="center"
textTransform="regular"
>
{title}
</Title>
</div>
<Body textAlign="center">
{intl.formatMessage({
id: "The price has increased since you selected your room.",
})}
<br />
{intl.formatMessage({
id: "You can still book the room but you need to confirm that you accept the new price",
})}
<br />
<span className={styles.oldPrice}>
{intl.formatNumber(oldPrice, { style: "currency", currency })}
</span>{" "}
<strong className={styles.newPrice}>
{intl.formatNumber(newPrice, { style: "currency", currency })}
</strong>
</Body>
</header>
<footer className={styles.footer}>
<Button intent="secondary" onClick={onCancel}>
{intl.formatMessage({ id: "Cancel" })}
</Button>
<Button onClick={onAccept}>
{intl.formatMessage({ id: "Accept new price" })}
</Button>
</footer>
</Dialog>
</Modal>
</ModalOverlay>
)
}

View File

@@ -0,0 +1,85 @@
@keyframes modal-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.overlay {
align-items: center;
background: rgba(0, 0, 0, 0.5);
display: flex;
height: var(--visual-viewport-height);
justify-content: center;
left: 0;
position: fixed;
top: 0;
width: 100vw;
z-index: 100;
&[data-entering] {
animation: modal-fade 200ms;
}
&[data-exiting] {
animation: modal-fade 150ms reverse ease-in;
}
}
.modal {
&[data-entering] {
animation: slide-up 200ms;
}
&[data-exiting] {
animation: slide-up 200ms reverse ease-in-out;
}
}
.dialog {
background-color: var(--Scandic-Brand-Pale-Peach);
border-radius: var(--Corner-radius-Medium);
box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08);
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x5) var(--Spacing-x4);
}
.header {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.titleContainer {
display: flex;
flex-direction: column;
align-items: center;
}
.footer {
display: flex;
justify-content: center;
gap: var(--Spacing-x2);
}
.oldPrice {
text-decoration: line-through;
}
.newPrice {
font-size: 1.2em;
}

View File

@@ -25,6 +25,7 @@
width: 24px;
height: 24px;
min-width: 24px;
background: var(--UI-Input-Controls-Surface-Normal);
border: 1px solid var(--UI-Input-Controls-Border-Normal);
border-radius: 4px;
transition: all 200ms;

View File

@@ -0,0 +1,23 @@
"use client"
import { useEffect, useState } from "react"
import { PaymentMethodEnum } from "@/constants/booking"
export function useAvailablePaymentOptions(
otherPaymentOptions: PaymentMethodEnum[]
) {
const [availablePaymentOptions, setAvailablePaymentOptions] = useState(
otherPaymentOptions.filter(
(option) => option !== PaymentMethodEnum.applePay
)
)
useEffect(() => {
if (window.ApplePaySession) {
setAvailablePaymentOptions(otherPaymentOptions)
}
}, [otherPaymentOptions, setAvailablePaymentOptions])
return availablePaymentOptions
}

View File

@@ -10,18 +10,20 @@ export function useHandleBookingStatus({
expectedStatus,
maxRetries,
retryInterval,
enabled,
}: {
confirmationNumber: string | null
expectedStatus: BookingStatusEnum
maxRetries: number
retryInterval: number
enabled: boolean
}) {
const retries = useRef(0)
const query = trpc.booking.status.useQuery(
{ confirmationNumber: confirmationNumber ?? "" },
{
enabled: !!confirmationNumber,
enabled,
refetchInterval: (query) => {
retries.current = query.state.dataUpdateCount

View File

@@ -43,6 +43,6 @@ export function usePaymentFailedToast() {
const queryParams = new URLSearchParams(searchParams.toString())
queryParams.delete("errorCode")
router.replace(`${pathname}?${queryParams.toString()}`)
}, [searchParams, router, pathname, errorCode, errorMessage])
router.push(`${pathname}?${queryParams.toString()}`)
}, [searchParams, pathname, errorCode, errorMessage, router])
}

View File

@@ -8,6 +8,7 @@
"ALLG": "Allergi",
"About meetings & conferences": "About meetings & conferences",
"About the hotel": "Om hotellet",
"Accept new price": "Accepter ny pris",
"Accessibility": "Tilgængelighed",
"Accessible Room": "Tilgængelighedsrum",
"Activities": "Aktiviteter",
@@ -162,6 +163,7 @@
"How do you want to sleep?": "Hvordan vil du sove?",
"How it works": "Hvordan det virker",
"Hurry up and use them before they expire!": "Skynd dig og brug dem, før de udløber!",
"I accept the terms and conditions": "Jeg accepterer vilkårene",
"I would like to get my booking confirmation via sms": "Jeg vil gerne få min booking bekræftelse via SMS",
"Image gallery": "{name} - Billedgalleri",
"In adults bed": "i de voksnes seng",
@@ -358,6 +360,8 @@
"Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Fortæl os, hvilke oplysninger og opdateringer du gerne vil modtage, og hvordan, ved at klikke på linket nedenfor.",
"Terms and conditions": "Vilkår og betingelser",
"Thank you": "Tak",
"The price has increased": "Prisen er steget",
"The price has increased since you selected your room.": "Prisen er steget, efter at du har valgt dit værelse.",
"Theatre": "Teater",
"There are no rooms available that match your request": "Der er ingen ledige værelser, der matcher din anmodning",
"There are no rooms available that match your request.": "Der er ingen værelser tilgængelige, der matcher din forespørgsel.",
@@ -407,6 +411,7 @@
"Yes, discard changes": "Ja, kasser ændringer",
"Yes, remove my card": "Ja, fjern mit kort",
"You can always change your mind later and add breakfast at the hotel.": "Du kan altid ombestemme dig senere og tilføje morgenmad på hotellet.",
"You can still book the room but you need to confirm that you accept the new price": "Du kan stadig booke værelset, men du skal bekræfte, at du accepterer den nye pris",
"You canceled adding a new credit card.": "Du har annulleret tilføjelsen af et nyt kreditkort.",
"You have <b>#</b> gifts waiting for you!": "Du har <b>{amount}</b> gaver, der venter på dig!",
"You have no previous stays.": "Du har ingen tidligere ophold.",

View File

@@ -8,6 +8,7 @@
"ALLG": "Allergie",
"About meetings & conferences": "About meetings & conferences",
"About the hotel": "Über das Hotel",
"Accept new price": "Neuen Preis akzeptieren",
"Accessibility": "Zugänglichkeit",
"Accessible Room": "Barrierefreies Zimmer",
"Activities": "Aktivitäten",
@@ -162,6 +163,7 @@
"How do you want to sleep?": "Wie möchtest du schlafen?",
"How it works": "Wie es funktioniert",
"Hurry up and use them before they expire!": "Beeilen Sie sich und nutzen Sie sie, bevor sie ablaufen!",
"I accept the terms and conditions": "Ich akzeptiere die Geschäftsbedingungen",
"I would like to get my booking confirmation via sms": "Ich möchte meine Buchungsbestätigung per SMS erhalten",
"Image gallery": "{name} - Bildergalerie",
"In adults bed": "Im Bett der Eltern",
@@ -358,6 +360,8 @@
"Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Teilen Sie uns mit, welche Informationen und Updates Sie wie erhalten möchten, indem Sie auf den unten stehenden Link klicken.",
"Terms and conditions": "Geschäftsbedingungen",
"Thank you": "Danke",
"The price has increased": "Der Preis ist gestiegen",
"The price has increased since you selected your room.": "Der Preis ist gestiegen, nachdem Sie Ihr Zimmer ausgewählt haben.",
"Theatre": "Theater",
"There are no rooms available that match your request.": "Es sind keine Zimmer verfügbar, die Ihrer Anfrage entsprechen.",
"There are no transactions to display": "Es sind keine Transaktionen zum Anzeigen vorhanden",
@@ -406,6 +410,7 @@
"Yes, discard changes": "Ja, Änderungen verwerfen",
"Yes, remove my card": "Ja, meine Karte entfernen",
"You can always change your mind later and add breakfast at the hotel.": "Sie können es sich später jederzeit anders überlegen und das Frühstück im Hotel hinzufügen.",
"You can still book the room but you need to confirm that you accept the new price": "Sie können das Zimmer noch buchen, aber Sie müssen bestätigen, dass Sie die neue Preis akzeptieren",
"You canceled adding a new credit card.": "Sie haben das Hinzufügen einer neuen Kreditkarte abgebrochen.",
"You have <b>#</b> gifts waiting for you!": "Es warten <b>{amount}</b> Geschenke auf Sie!",
"You have no previous stays.": "Sie haben keine vorherigen Aufenthalte.",
@@ -433,7 +438,7 @@
"booking.nights": "{totalNights, plural, one {# nacht} other {# Nächte}}",
"booking.rooms": "{totalRooms, plural, one {# zimmer} other {# räume}}",
"booking.selectRoom": "Vælg værelse",
"booking.terms": "Ved at betale med en af de tilgængelige betalingsmetoder, accepterer jeg vilkårene for denne booking og de generelle <termsLink>Vilkår og betingelser</termsLink>, og forstår, at Scandic vil behandle min personlige data i forbindelse med denne booking i henhold til <privacyLink>Scandics Privatlivspolitik</privacyLink>. Jeg accepterer, at Scandic kræver et gyldigt kreditkort under min besøg i tilfælde af, at noget er tilbagebetalt.",
"booking.terms": "Mit der Zahlung über eine der verfügbaren Zahlungsmethoden akzeptiere ich die Buchungsbedingungen und die allgemeinen <termsLink>Geschäftsbedingungen</termsLink> und verstehe, dass Scandic meine personenbezogenen Daten im Zusammenhang mit dieser Buchung gemäß der <privacyLink>Scandic Datenschutzrichtlinie</privacyLink> verarbeitet. Ich akzeptiere, dass Scandic während meines Aufenthalts eine gültige Kreditkarte für eventuelle Rückerstattungen benötigt.",
"booking.thisRoomIsEquippedWith": "Dieses Zimmer ist ausgestattet mit",
"breakfast.price": "{amount} {currency}/Nacht",
"breakfast.price.free": "<strikethrough>{amount} {currency}</strikethrough> <free>0 {currency}</free>/Nacht",

View File

@@ -8,6 +8,7 @@
"ALLG": "Allergy",
"About meetings & conferences": "About meetings & conferences",
"About the hotel": "About the hotel",
"Accept new price": "Accept new price",
"Accessibility": "Accessibility",
"Accessible Room": "Accessibility room",
"Activities": "Activities",
@@ -174,6 +175,7 @@
"How do you want to sleep?": "How do you want to sleep?",
"How it works": "How it works",
"Hurry up and use them before they expire!": "Hurry up and use them before they expire!",
"I accept the terms and conditions": "I accept the terms and conditions",
"I would like to get my booking confirmation via sms": "I would like to get my booking confirmation via sms",
"Image gallery": "{name} - Image gallery",
"In adults bed": "In adults bed",
@@ -387,6 +389,8 @@
"Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Tell us what information and updates you'd like to receive, and how, by clicking the link below.",
"Terms and conditions": "Terms and conditions",
"Thank you": "Thank you",
"The price has increased": "The price has increased",
"The price has increased since you selected your room.": "The price has increased since you selected your room.",
"Theatre": "Theatre",
"There are no rooms available that match your request.": "There are no rooms available that match your request.",
"There are no transactions to display": "There are no transactions to display",
@@ -437,6 +441,7 @@
"Yes, discard changes": "Yes, discard changes",
"Yes, remove my card": "Yes, remove my card",
"You can always change your mind later and add breakfast at the hotel.": "You can always change your mind later and add breakfast at the hotel.",
"You can still book the room but you need to confirm that you accept the new price": "You can still book the room but you need to confirm that you accept the new price",
"You canceled adding a new credit card.": "You canceled adding a new credit card.",
"You have <b>#</b> gifts waiting for you!": "You have <b>{amount}</b> gifts waiting for you!",
"You have no previous stays.": "You have no previous stays.",

View File

@@ -8,6 +8,7 @@
"ALLG": "Allergia",
"About meetings & conferences": "About meetings & conferences",
"About the hotel": "Tietoja hotellista",
"Accept new price": "Hyväksy uusi hinta",
"Accessibility": "Saavutettavuus",
"Accessible Room": "Esteetön huone",
"Activities": "Aktiviteetit",
@@ -162,6 +163,7 @@
"How do you want to sleep?": "Kuinka haluat nukkua?",
"How it works": "Kuinka se toimii",
"Hurry up and use them before they expire!": "Ole nopea ja käytä ne ennen kuin ne vanhenevat!",
"I accept the terms and conditions": "Hyväksyn käyttöehdot",
"I would like to get my booking confirmation via sms": "Haluan saada varauksen vahvistuksen SMS-viestillä",
"Image gallery": "{name} - Kuvagalleria",
"In adults bed": "Aikuisten vuoteessa",
@@ -359,6 +361,8 @@
"Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Kerro meille, mitä tietoja ja päivityksiä haluat saada ja miten, napsauttamalla alla olevaa linkkiä.",
"Terms and conditions": "Käyttöehdot",
"Thank you": "Kiitos",
"The price has increased": "Hinta on noussut",
"The price has increased since you selected your room.": "Hinta on noussut, koska valitsit huoneen.",
"Theatre": "Teatteri",
"There are no rooms available that match your request.": "Ei huoneita saatavilla, jotka vastaavat pyyntöäsi.",
"There are no transactions to display": "Näytettäviä tapahtumia ei ole",
@@ -407,6 +411,7 @@
"Yes, discard changes": "Kyllä, hylkää muutokset",
"Yes, remove my card": "Kyllä, poista korttini",
"You can always change your mind later and add breakfast at the hotel.": "Voit aina muuttaa mieltäsi myöhemmin ja lisätä aamiaisen hotelliin.",
"You can still book the room but you need to confirm that you accept the new price": "Voit vielä bookea huoneen, mutta sinun on vahvistettava, että hyväksyt uuden hinnan",
"You canceled adding a new credit card.": "Peruutit uuden luottokortin lisäämisen.",
"You have <b>#</b> gifts waiting for you!": "Sinulla on <b>{amount}</b> lahjaa odottamassa sinua!",
"You have no previous stays.": "Sinulla ei ole aiempia majoituksia.",

View File

@@ -8,6 +8,7 @@
"ALLG": "Allergi",
"About meetings & conferences": "About meetings & conferences",
"About the hotel": "Om hotellet",
"Accept new price": "Aksepterer ny pris",
"Accessibility": "Tilgjengelighet",
"Accessible Room": "Tilgjengelighetsrom",
"Activities": "Aktiviteter",
@@ -161,6 +162,7 @@
"How do you want to sleep?": "Hvordan vil du sove?",
"How it works": "Hvordan det fungerer",
"Hurry up and use them before they expire!": "Skynd deg og bruk dem før de utløper!",
"I accept the terms and conditions": "Jeg aksepterer vilkårene",
"Image gallery": "{name} - Bildegalleri",
"In adults bed": "i voksnes seng",
"In crib": "i sprinkelseng",
@@ -356,6 +358,8 @@
"Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Fortell oss hvilken informasjon og hvilke oppdateringer du ønsker å motta, og hvordan, ved å klikke på lenken nedenfor.",
"Terms and conditions": "Vilkår og betingelser",
"Thank you": "Takk",
"The price has increased": "Prisen er steget",
"The price has increased since you selected your room.": "Prisen er steget, etter at du har valgt rommet.",
"Theatre": "Teater",
"There are no rooms available that match your request.": "Det er ingen rom tilgjengelige som matcher din forespørsel.",
"There are no transactions to display": "Det er ingen transaksjoner å vise",
@@ -404,6 +408,7 @@
"Yes, discard changes": "Ja, forkast endringer",
"Yes, remove my card": "Ja, fjern kortet mitt",
"You can always change your mind later and add breakfast at the hotel.": "Du kan alltid ombestemme deg senere og legge til frokost på hotellet.",
"You can still book the room but you need to confirm that you accept the new price": "Du kan fortsatt booke rommet, men du må bekrefte at du aksepterer den nye prisen",
"You canceled adding a new credit card.": "Du kansellerte å legge til et nytt kredittkort.",
"You have <b>#</b> gifts waiting for you!": "Du har <b>{amount}</b> gaver som venter på deg!",
"You have no previous stays.": "Du har ingen tidligere opphold.",

View File

@@ -8,6 +8,7 @@
"ALLG": "Allergi",
"About meetings & conferences": "About meetings & conferences",
"About the hotel": "Om hotellet",
"Accept new price": "Accepter ny pris",
"Accessibility": "Tillgänglighet",
"Accessible Room": "Tillgänglighetsrum",
"Activities": "Aktiviteter",
@@ -161,6 +162,7 @@
"How do you want to sleep?": "Hur vill du sova?",
"How it works": "Hur det fungerar",
"Hurry up and use them before they expire!": "Skynda dig och använd dem innan de går ut!",
"I accept the terms and conditions": "Jag accepterar villkoren",
"Image gallery": "{name} - Bildgalleri",
"In adults bed": "I vuxens säng",
"In crib": "I spjälsäng",
@@ -356,6 +358,8 @@
"Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Berätta för oss vilken information och vilka uppdateringar du vill få och hur genom att klicka på länken nedan.",
"Terms and conditions": "Allmänna villkor",
"Thank you": "Tack",
"The price has increased": "Priset har ökat",
"The price has increased since you selected your room.": "Priset har ökat sedan du valde ditt rum.",
"Theatre": "Teater",
"There are no rooms available that match your request.": "Det finns inga rum tillgängliga som matchar din begäran.",
"There are no transactions to display": "Det finns inga transaktioner att visa",
@@ -404,6 +408,7 @@
"Yes, discard changes": "Ja, ignorera ändringar",
"Yes, remove my card": "Ja, ta bort mitt kort",
"You can always change your mind later and add breakfast at the hotel.": "Du kan alltid ändra dig senare och lägga till frukost på hotellet.",
"You can still book the room but you need to confirm that you accept the new price": "Du kan fortsatt boka rummet men du måste bekräfta att du accepterar det nya priset",
"You canceled adding a new credit card.": "Du avbröt att lägga till ett nytt kreditkort.",
"You have <b>#</b> gifts waiting for you!": "Du har <b>{amount}</b> presenter som väntar på dig!",
"You have no previous stays.": "Du har inga tidigare vistelser.",

View File

@@ -59,6 +59,9 @@ export namespace endpoints {
export function status(confirmationNumber: string) {
return `${bookings}/${confirmationNumber}/status`
}
export function priceChange(confirmationNumber: string) {
return `${bookings}/${confirmationNumber}/priceChange`
}
export const enum Stays {
future = `${base.path.booking}/${version}/${base.enitity.Stays}/future`,

View File

@@ -34,13 +34,7 @@ export async function get(
) {
const url = new URL(env.API_BASEURL)
url.pathname = endpoint
const searchParams = new URLSearchParams(params)
if (searchParams.size) {
searchParams.forEach((value, key) => {
url.searchParams.append(key, value)
})
url.searchParams.sort()
}
url.search = new URLSearchParams(params).toString()
return wrappedFetch(
url,
merge.all([defaultOptions, { method: "GET" }, options])
@@ -55,13 +49,7 @@ export async function patch(
const { body, ...requestOptions } = options
const url = new URL(env.API_BASEURL)
url.pathname = endpoint
const searchParams = new URLSearchParams(params)
if (searchParams.size) {
searchParams.forEach((value, key) => {
url.searchParams.set(key, value)
})
url.searchParams.sort()
}
url.search = new URLSearchParams(params).toString()
return wrappedFetch(
url,
merge.all([
@@ -80,13 +68,7 @@ export async function post(
const { body, ...requestOptions } = options
const url = new URL(env.API_BASEURL)
url.pathname = endpoint
const searchParams = new URLSearchParams(params)
if (searchParams.size) {
searchParams.forEach((value, key) => {
url.searchParams.set(key, value)
})
url.searchParams.sort()
}
url.search = new URLSearchParams(params).toString()
return wrappedFetch(
url,
merge.all([
@@ -97,6 +79,25 @@ export async function post(
)
}
export async function put(
endpoint: Endpoint | `${Endpoint}/${string}`,
options: RequestOptionsWithJSONBody,
params = {}
) {
const { body, ...requestOptions } = options
const url = new URL(env.API_BASEURL)
url.pathname = endpoint
url.search = new URLSearchParams(params).toString()
return wrappedFetch(
url,
merge.all([
defaultOptions,
{ body: JSON.stringify(body), method: "PUT" },
requestOptions,
])
)
}
export async function remove(
endpoint: Endpoint | `${Endpoint}/${string}`,
options: RequestOptionsWithOutBody,
@@ -104,13 +105,7 @@ export async function remove(
) {
const url = new URL(env.API_BASEURL)
url.pathname = endpoint
const searchParams = new URLSearchParams(params)
if (searchParams.size) {
searchParams.forEach((value, key) => {
url.searchParams.set(key, value)
})
url.searchParams.sort()
}
url.search = new URLSearchParams(params).toString()
return wrappedFetch(
url,
merge.all([defaultOptions, { method: "DELETE" }, options])

View File

@@ -83,6 +83,10 @@ export const createBookingInput = z.object({
payment: paymentSchema,
})
export const priceChangeInput = z.object({
confirmationNumber: z.string(),
})
// Query
const confirmationNumberInput = z.object({
confirmationNumber: z.string(),

View File

@@ -6,7 +6,7 @@ import { router, safeProtectedServiceProcedure } from "@/server/trpc"
import { getMembership } from "@/utils/user"
import { createBookingInput } from "./input"
import { createBookingInput, priceChangeInput } from "./input"
import { createBookingSchema } from "./output"
import type { Session } from "next-auth"
@@ -20,6 +20,14 @@ const createBookingFailCounter = meter.createCounter(
"trpc.bookings.create-fail"
)
const priceChangeCounter = meter.createCounter("trpc.bookings.price-change")
const priceChangeSuccessCounter = meter.createCounter(
"trpc.bookings.price-change-success"
)
const priceChangeFailCounter = meter.createCounter(
"trpc.bookings.price-change-fail"
)
async function getMembershipNumber(
session: Session | null
): Promise<string | undefined> {
@@ -122,6 +130,71 @@ export const bookingMutationRouter = router({
query: loggingAttributes,
})
)
return verifiedData.data
}),
priceChange: safeProtectedServiceProcedure
.input(priceChangeInput)
.mutation(async function ({ ctx, input }) {
const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken
const { confirmationNumber } = input
priceChangeCounter.add(1, { confirmationNumber })
const headers = {
Authorization: `Bearer ${accessToken}`,
}
const apiResponse = await api.put(
api.endpoints.v1.Booking.priceChange(confirmationNumber),
{
headers,
body: input,
}
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
priceChangeFailCounter.add(1, {
confirmationNumber,
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
}),
})
console.error(
"api.booking.priceChange error",
JSON.stringify({
query: { confirmationNumber },
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
error: text,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = createBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
priceChangeFailCounter.add(1, {
confirmationNumber,
error_type: "validation_error",
})
console.error(
"api.booking.priceChange validation error",
JSON.stringify({
query: { confirmationNumber },
error: verifiedData.error,
})
)
return null
}
priceChangeSuccessCounter.add(1, { confirmationNumber })
return verifiedData.data
}),
})

View File

@@ -21,8 +21,8 @@ export const createBookingSchema = z
errorMessage: z.string().nullable().optional(),
priceChangedMetadata: z
.object({
roomPrice: z.number().nullable().optional(),
totalPrice: z.number().nullable().optional(),
roomPrice: z.number(),
totalPrice: z.number(),
})
.nullable()
.optional(),

View File

@@ -1,6 +1,6 @@
import { z } from "zod"
import { ChildBedTypeEnum } from "@/constants/booking"
import { ChildBedTypeEnum, PaymentMethodEnum } from "@/constants/booking"
import { dt } from "@/lib/dt"
import { toLang } from "@/server/utils"
@@ -376,6 +376,7 @@ const merchantInformationSchema = z.object({
return Object.entries(val)
.filter(([_, enabled]) => enabled)
.map(([key]) => key)
.filter((key): key is PaymentMethodEnum => !!key)
}),
})

View File

@@ -0,0 +1,8 @@
export type PriceChangeDialogProps = {
isOpen: boolean
oldPrice: number
newPrice: number
currency: string
onCancel: () => void
onAccept: () => void
}

View File

@@ -1,3 +1,5 @@
import { PaymentMethodEnum } from "@/constants/booking"
import { CreditCard, SafeUser } from "@/types/user"
export interface SectionProps {
@@ -30,7 +32,7 @@ export interface DetailsProps extends SectionProps {}
export interface PaymentProps {
user: SafeUser
roomPrice: { publicPrice: number; memberPrice: number | undefined }
otherPaymentOptions: string[]
otherPaymentOptions: PaymentMethodEnum[]
savedCreditCards: CreditCard[] | null
mustBeGuaranteed: boolean
}