From 70000afe1ff4436ac587707cce55f1ade3aea5ba Mon Sep 17 00:00:00 2001 From: Tobias Johansson Date: Tue, 26 Nov 2024 09:06:41 +0000 Subject: [PATCH] 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 --- .../hotelreservation/(standard)/step/page.tsx | 23 +- .../EnterDetails/Payment/index.tsx | 397 ++++++++++-------- .../EnterDetails/PriceChangeDialog/index.tsx | 73 ++++ .../priceChangeDialog.module.css | 85 ++++ .../Form/Checkbox/checkbox.module.css | 1 + hooks/booking/useAvailablePaymentOptions.ts | 23 + hooks/booking/useHandleBookingStatus.ts | 4 +- hooks/booking/usePaymentFailedToast.ts | 4 +- i18n/dictionaries/da.json | 5 + i18n/dictionaries/de.json | 7 +- i18n/dictionaries/en.json | 5 + i18n/dictionaries/fi.json | 5 + i18n/dictionaries/no.json | 5 + i18n/dictionaries/sv.json | 5 + lib/api/endpoints.ts | 3 + lib/api/index.ts | 51 +-- server/routers/booking/input.ts | 4 + server/routers/booking/mutation.ts | 75 +++- server/routers/booking/output.ts | 4 +- server/routers/hotels/output.ts | 3 +- .../enterDetails/priceChangeDialog.ts | 8 + .../hotelReservation/selectRate/section.ts | 4 +- 22 files changed, 577 insertions(+), 217 deletions(-) create mode 100644 components/HotelReservation/EnterDetails/PriceChangeDialog/index.tsx create mode 100644 components/HotelReservation/EnterDetails/PriceChangeDialog/priceChangeDialog.module.css create mode 100644 hooks/booking/useAvailablePaymentOptions.ts create mode 100644 types/components/hotelReservation/enterDetails/priceChangeDialog.ts diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx index 4fbaae7ff..ade5f7c8f 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx @@ -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} > - + + + diff --git a/components/HotelReservation/EnterDetails/Payment/index.tsx b/components/HotelReservation/EnterDetails/Payment/index.tsx index ab1f78807..9adf47149 100644 --- a/components/HotelReservation/EnterDetails/Payment/index.tsx +++ b/components/HotelReservation/EnterDetails/Payment/index.tsx @@ -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("") - 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 } @@ -241,79 +278,70 @@ export default function Payment({ const paymentVerb = mustBeGuaranteed ? guaranteeing : paying return ( - -
- {mustBeGuaranteed ? ( + <> + + + {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} + {savedCreditCards?.length ? ( +
+ + {intl.formatMessage({ id: "MY SAVED CARDS" })} + +
+ {savedCreditCards?.map((savedCreditCard) => ( + + ))} +
+
+ ) : null}
- - {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} - {savedCreditCards?.length ? ( -
- - {intl.formatMessage({ id: "MY SAVED CARDS" })} - + {savedCreditCards?.length ? ( + + {intl.formatMessage({ id: "OTHER PAYMENT METHODS" })} + + ) : null}
- {savedCreditCards?.map((savedCreditCard) => ( + + {availablePaymentOptions.map((paymentMethod) => ( ))}
- ) : null} -
- {savedCreditCards?.length ? ( - - {intl.formatMessage({ id: "OTHER PAYMENT METHODS" })} - - ) : null} -
- - {availablePaymentOptions.map((paymentMethod) => ( - - ))} -
-
-
- - - {intl.formatMessage({ - id: "I would like to get my booking confirmation via sms", - })} - - - - - +
{intl.formatMessage( { @@ -344,19 +372,48 @@ export default function Payment({ } )} - -
-
- -
- - + + + {intl.formatMessage({ + id: "I accept the terms and conditions", + })} + + + + + {intl.formatMessage({ + id: "I would like to get my booking confirmation via sms", + })} + + +
+
+ +
+ +
+ {priceChangeData ? ( + { + const allSearchParams = searchParams.size + ? `?${searchParams.toString()}` + : "" + router.push(`${selectRate(lang)}${allSearchParams}`) + }} + onAccept={() => priceChange.mutate({ confirmationNumber })} + /> + ) : null} + ) } diff --git a/components/HotelReservation/EnterDetails/PriceChangeDialog/index.tsx b/components/HotelReservation/EnterDetails/PriceChangeDialog/index.tsx new file mode 100644 index 000000000..c909c1232 --- /dev/null +++ b/components/HotelReservation/EnterDetails/PriceChangeDialog/index.tsx @@ -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 ( + + + +
+
+ + + {title} + +
+ + {intl.formatMessage({ + id: "The price has increased since you selected your room.", + })} +
+ {intl.formatMessage({ + id: "You can still book the room but you need to confirm that you accept the new price", + })} +
+ + {intl.formatNumber(oldPrice, { style: "currency", currency })} + {" "} + + {intl.formatNumber(newPrice, { style: "currency", currency })} + + +
+
+ + +
+
+
+
+ ) +} diff --git a/components/HotelReservation/EnterDetails/PriceChangeDialog/priceChangeDialog.module.css b/components/HotelReservation/EnterDetails/PriceChangeDialog/priceChangeDialog.module.css new file mode 100644 index 000000000..b90f4c380 --- /dev/null +++ b/components/HotelReservation/EnterDetails/PriceChangeDialog/priceChangeDialog.module.css @@ -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; +} diff --git a/components/TempDesignSystem/Form/Checkbox/checkbox.module.css b/components/TempDesignSystem/Form/Checkbox/checkbox.module.css index 11c558af9..7c03ccefb 100644 --- a/components/TempDesignSystem/Form/Checkbox/checkbox.module.css +++ b/components/TempDesignSystem/Form/Checkbox/checkbox.module.css @@ -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; diff --git a/hooks/booking/useAvailablePaymentOptions.ts b/hooks/booking/useAvailablePaymentOptions.ts new file mode 100644 index 000000000..0947e3209 --- /dev/null +++ b/hooks/booking/useAvailablePaymentOptions.ts @@ -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 +} diff --git a/hooks/booking/useHandleBookingStatus.ts b/hooks/booking/useHandleBookingStatus.ts index 7c1fafcca..b816d5797 100644 --- a/hooks/booking/useHandleBookingStatus.ts +++ b/hooks/booking/useHandleBookingStatus.ts @@ -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 diff --git a/hooks/booking/usePaymentFailedToast.ts b/hooks/booking/usePaymentFailedToast.ts index cccbe2db0..961ab0c2b 100644 --- a/hooks/booking/usePaymentFailedToast.ts +++ b/hooks/booking/usePaymentFailedToast.ts @@ -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]) } diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index 8a5d64ac4..92281e936 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -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 # gifts waiting for you!": "Du har {amount} gaver, der venter på dig!", "You have no previous stays.": "Du har ingen tidligere ophold.", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index 3b5842619..c4f49ee93 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -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 # gifts waiting for you!": "Es warten {amount} 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 Vilkår og betingelser, og forstår, at Scandic vil behandle min personlige data i forbindelse med denne booking i henhold til Scandics Privatlivspolitik. 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 Geschäftsbedingungen und verstehe, dass Scandic meine personenbezogenen Daten im Zusammenhang mit dieser Buchung gemäß der Scandic Datenschutzrichtlinie 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": "{amount} {currency} 0 {currency}/Nacht", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index 42b4b9316..5b718083d 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -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 # gifts waiting for you!": "You have {amount} gifts waiting for you!", "You have no previous stays.": "You have no previous stays.", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index 94d09060c..ebc7470d7 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -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 # gifts waiting for you!": "Sinulla on {amount} lahjaa odottamassa sinua!", "You have no previous stays.": "Sinulla ei ole aiempia majoituksia.", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index bffe5c2ac..b2501bfc7 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -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 # gifts waiting for you!": "Du har {amount} gaver som venter på deg!", "You have no previous stays.": "Du har ingen tidligere opphold.", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index 883ee8e07..e2b16255e 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -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 # gifts waiting for you!": "Du har {amount} presenter som väntar på dig!", "You have no previous stays.": "Du har inga tidigare vistelser.", diff --git a/lib/api/endpoints.ts b/lib/api/endpoints.ts index 781a19f38..f64b83396 100644 --- a/lib/api/endpoints.ts +++ b/lib/api/endpoints.ts @@ -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`, diff --git a/lib/api/index.ts b/lib/api/index.ts index 46bae9f88..5842b5597 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -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]) diff --git a/server/routers/booking/input.ts b/server/routers/booking/input.ts index f838d201f..b6c82f906 100644 --- a/server/routers/booking/input.ts +++ b/server/routers/booking/input.ts @@ -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(), diff --git a/server/routers/booking/mutation.ts b/server/routers/booking/mutation.ts index dc3bad0fe..06ae675a7 100644 --- a/server/routers/booking/mutation.ts +++ b/server/routers/booking/mutation.ts @@ -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 { @@ -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 }), }) diff --git a/server/routers/booking/output.ts b/server/routers/booking/output.ts index 5c8879c00..997d7baaa 100644 --- a/server/routers/booking/output.ts +++ b/server/routers/booking/output.ts @@ -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(), diff --git a/server/routers/hotels/output.ts b/server/routers/hotels/output.ts index 9d3c71194..e6f5f43dc 100644 --- a/server/routers/hotels/output.ts +++ b/server/routers/hotels/output.ts @@ -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) }), }) diff --git a/types/components/hotelReservation/enterDetails/priceChangeDialog.ts b/types/components/hotelReservation/enterDetails/priceChangeDialog.ts new file mode 100644 index 000000000..32ae4996d --- /dev/null +++ b/types/components/hotelReservation/enterDetails/priceChangeDialog.ts @@ -0,0 +1,8 @@ +export type PriceChangeDialogProps = { + isOpen: boolean + oldPrice: number + newPrice: number + currency: string + onCancel: () => void + onAccept: () => void +} diff --git a/types/components/hotelReservation/selectRate/section.ts b/types/components/hotelReservation/selectRate/section.ts index eaea36f2e..eb8b6c299 100644 --- a/types/components/hotelReservation/selectRate/section.ts +++ b/types/components/hotelReservation/selectRate/section.ts @@ -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 }