diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Confirmation/confirmation.module.css b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.module.css similarity index 61% rename from apps/scandic-web/components/HotelReservation/BookingConfirmation/Confirmation/confirmation.module.css rename to apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.module.css index e3d91663e..528a447cf 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Confirmation/confirmation.module.css +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.module.css @@ -19,3 +19,26 @@ padding-top: var(--Spacing-x9); } } + +.booking { + display: flex; + flex-direction: column; + gap: var(--Spacing-x5); + grid-area: booking; + padding-bottom: var(--Spacing-x9); +} + +.aside { + display: none; +} + +@media screen and (min-width: 1367px) { + .mobileReceipt { + display: none; + } + + .aside { + display: grid; + grid-area: receipt; + } +} diff --git a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.tsx index 2e9ab9faa..0cc1b897c 100644 --- a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.tsx +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.tsx @@ -1,14 +1,84 @@ +import { notFound } from "next/navigation" + import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests" -import BookingConfirmation from "@/components/HotelReservation/BookingConfirmation" +import Alerts from "@/components/HotelReservation/BookingConfirmation/Alerts" +import Header from "@/components/HotelReservation/BookingConfirmation/Header" +import HotelDetails from "@/components/HotelReservation/BookingConfirmation/HotelDetails" +import PaymentDetails from "@/components/HotelReservation/BookingConfirmation/PaymentDetails" +import Promos from "@/components/HotelReservation/BookingConfirmation/Promos" +import Receipt from "@/components/HotelReservation/BookingConfirmation/Receipt" +import Rooms from "@/components/HotelReservation/BookingConfirmation/Rooms" +import Tracking from "@/components/HotelReservation/BookingConfirmation/Tracking" +import { mapRoomState } from "@/components/HotelReservation/BookingConfirmation/utils" +import SidePanel from "@/components/HotelReservation/SidePanel" +import Divider from "@/components/TempDesignSystem/Divider" +import { getIntl } from "@/i18n" +import BookingConfirmationProvider from "@/providers/BookingConfirmationProvider" + +import styles from "./page.module.css" import type { LangParams, PageArgs } from "@/types/params" export default async function BookingConfirmationPage({ + params, searchParams, -}: PageArgs) { - void getBookingConfirmation(searchParams.confirmationNumber) +}: PageArgs) { + const refId = searchParams.RefId + + if (!refId) { + notFound() + } + + const bookingConfirmation = await getBookingConfirmation(refId, params.lang) + + if (!bookingConfirmation) { + notFound() + } + + const { booking, hotelData, room } = bookingConfirmation + const { hotel } = hotelData + + const intl = await getIntl() + return ( - + +
+
+
+ + + + + + +
+ +
+
+ +
+ +
) } diff --git a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/gla-payment-callback/page.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/gla-payment-callback/page.tsx index 571a44d89..951141819 100644 --- a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/gla-payment-callback/page.tsx +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/gla-payment-callback/page.tsx @@ -6,10 +6,12 @@ import { } from "@/constants/booking" import { myStay } from "@/constants/routes/myStay" import { serverClient } from "@/lib/trpc/server" +import { createCounter } from "@/server/telemetry" import GuaranteeCallback from "@/components/HotelReservation/MyStay/Ancillaries/GuaranteeCallback" import TrackGuarantee from "@/components/HotelReservation/MyStay/TrackGuarantee" import LoadingSpinner from "@/components/LoadingSpinner" +import { setLang } from "@/i18n/serverContext" import type { LangParams, PageArgs } from "@/types/params" @@ -19,45 +21,56 @@ export default async function GuaranteePaymentCallbackPage({ }: PageArgs< LangParams, { - status: PaymentCallbackStatusEnum - RefId: string + status?: PaymentCallbackStatusEnum + RefId?: string confirmationNumber?: string ancillary?: string } >) { - console.log(`[gla-payment-callback] callback started`) const lang = params.lang const status = searchParams.status - const confirmationNumber = searchParams.confirmationNumber const refId = searchParams.RefId - if (!refId) { - notFound() - } + const confirmationNumber = searchParams.confirmationNumber const isAncillaryFlow = searchParams.ancillary + setLang(params.lang) + + if (!status || !confirmationNumber || !refId) { + notFound() + } + + const glaSuccessCounter = createCounter("gla", "success") + const metricsGlaSuccess = glaSuccessCounter.init({ + confirmationNumber, + }) + + metricsGlaSuccess.start() + const myStayUrl = `${myStay[lang]}?RefId=${encodeURIComponent(refId)}` - const searchObject = new URLSearchParams() if (status === PaymentCallbackStatusEnum.Success && confirmationNumber) { if (isAncillaryFlow) { return ( ) } - console.log(`[gla-payment-callback] redirecting to: ${myStayUrl}`) + return } let errorMessage = undefined if (confirmationNumber) { + const searchObject = new URLSearchParams() + try { const bookingStatus = await serverClient().booking.status({ - confirmationNumber, + refId, }) const error = bookingStatus.errors.find((e) => e.errorCode) diff --git a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/cancel/page.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/cancel/page.tsx new file mode 100644 index 000000000..773da8fc6 --- /dev/null +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/cancel/page.tsx @@ -0,0 +1,29 @@ +import { + BookingErrorCodeEnum, + PaymentCallbackStatusEnum, +} from "@/constants/booking" +import { details } from "@/constants/routes/hotelReservation" + +import HandleErrorCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleErrorCallback" + +import type { LangParams, PageArgs } from "@/types/params" + +export default async function PaymentCallbackCancelPage({ + params, +}: PageArgs) { + console.log(`[payment-callback] cancel callback started`) + const lang = params.lang + + const returnUrl = details(lang) + const searchObject = new URLSearchParams() + + searchObject.set("errorCode", BookingErrorCodeEnum.TransactionCancelled) + + return ( + + ) +} diff --git a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/error/page.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/error/page.tsx new file mode 100644 index 000000000..3dc556d5e --- /dev/null +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/error/page.tsx @@ -0,0 +1,71 @@ +import { + BookingErrorCodeEnum, + PaymentCallbackStatusEnum, +} from "@/constants/booking" +import { details } from "@/constants/routes/hotelReservation" +import { serverClient } from "@/lib/trpc/server" + +import HandleErrorCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleErrorCallback" +import { calculateRefId } from "@/utils/refId" + +import type { LangParams, PageArgs } from "@/types/params" + +export default async function PaymentCallbackErrorPage({ + params, + searchParams, +}: PageArgs< + LangParams, + { + confirmationNumber?: string + } +>) { + console.log(`[payment-callback] error callback started`) + + const lang = params.lang + const confirmationNumber = searchParams.confirmationNumber + + const returnUrl = details(lang) + const searchObject = new URLSearchParams() + + let errorMessage = undefined + + if (confirmationNumber) { + const refId = calculateRefId(confirmationNumber, "") + + try { + const bookingStatus = await serverClient().booking.confirmationError({ + refId, + }) + + // TODO: how to handle errors for multiple rooms? + const error = bookingStatus.errors.find((e) => e.errorCode) + + errorMessage = + error?.description ?? + `No error message found for booking ${confirmationNumber}` + + searchObject.set( + "errorCode", + error + ? error.errorCode.toString() + : BookingErrorCodeEnum.TransactionFailed + ) + } catch { + console.error( + `[payment-callback] failed to get booking status for ${confirmationNumber}` + ) + + searchObject.set("errorCode", BookingErrorCodeEnum.TransactionFailed) + errorMessage = `Failed to get booking status for ${confirmationNumber}` + } + } + + return ( + + ) +} diff --git a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/page.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/page.tsx deleted file mode 100644 index 74984d9e0..000000000 --- a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/page.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { - BOOKING_CONFIRMATION_NUMBER, - BookingErrorCodeEnum, - PaymentCallbackStatusEnum, -} from "@/constants/booking" -import { - bookingConfirmation, - details, -} from "@/constants/routes/hotelReservation" -import { serverClient } from "@/lib/trpc/server" - -import HandleErrorCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleErrorCallback" -import HandleSuccessCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleSuccessCallback" - -import type { LangParams, PageArgs } from "@/types/params" - -export default async function PaymentCallbackPage({ - params, - searchParams, -}: PageArgs< - LangParams, - { - status: PaymentCallbackStatusEnum - confirmationNumber?: string - hotel?: string - } ->) { - console.log(`[payment-callback] callback started`) - const lang = params.lang - const status = searchParams.status - const confirmationNumber = searchParams.confirmationNumber - - if (status === PaymentCallbackStatusEnum.Success && confirmationNumber) { - const confirmationUrl = `${bookingConfirmation(lang)}?${BOOKING_CONFIRMATION_NUMBER}=${confirmationNumber}` - console.log( - `[payment-callback] rendering success callback with confirmation number: ${confirmationNumber}` - ) - - return ( - - ) - } - - const returnUrl = details(lang) - const searchObject = new URLSearchParams() - - let errorMessage = undefined - - if (confirmationNumber) { - try { - const bookingStatus = await serverClient().booking.status({ - confirmationNumber, - }) - - // TODO: how to handle errors for multiple rooms? - const error = bookingStatus.errors.find((e) => e.errorCode) - - errorMessage = - error?.description ?? - `No error message found for booking ${confirmationNumber}, status: ${status}` - - searchObject.set( - "errorCode", - error - ? error.errorCode.toString() - : BookingErrorCodeEnum.TransactionFailed - ) - } catch { - console.error( - `[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}` - ) - if (status === PaymentCallbackStatusEnum.Cancel) { - searchObject.set("errorCode", BookingErrorCodeEnum.TransactionCancelled) - } - if (status === PaymentCallbackStatusEnum.Error) { - searchObject.set("errorCode", BookingErrorCodeEnum.TransactionFailed) - errorMessage = `Failed to get booking status for ${confirmationNumber}, status: ${status}` - } - } - } - - return ( - - ) -} diff --git a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/success/page.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/success/page.tsx new file mode 100644 index 000000000..fa6185a71 --- /dev/null +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/success/page.tsx @@ -0,0 +1,44 @@ +import { notFound } from "next/navigation" + +import { bookingConfirmation } from "@/constants/routes/hotelReservation" +import { createCounter } from "@/server/telemetry" + +import HandleSuccessCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleSuccessCallback" +import { setLang } from "@/i18n/serverContext" +import { calculateRefId } from "@/utils/refId" + +import type { LangParams, PageArgs } from "@/types/params" + +export default async function PaymentCallbackSuccessPage({ + params, + searchParams, +}: PageArgs< + LangParams, + { + confirmationNumber?: string + } +>) { + const confirmationNumber = searchParams.confirmationNumber + + setLang(params.lang) + + if (!confirmationNumber) { + notFound() + } + + const paymentSuccessCounter = createCounter("payment", "success") + const metricsPaymentSuccess = paymentSuccessCounter.init({ + confirmationNumber, + }) + + metricsPaymentSuccess.start() + + const refId = calculateRefId(confirmationNumber, "") + + return ( + + ) +} diff --git a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page.tsx index a649e3f9f..ca2f08674 100644 --- a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page.tsx +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page.tsx @@ -1,6 +1,7 @@ import { notFound, redirect } from "next/navigation" import { Suspense } from "react" +import { BookingErrorCodeEnum } from "@/constants/booking" import { selectRate } from "@/constants/routes/hotelReservation" import { getBreakfastPackages, @@ -16,8 +17,6 @@ import RoomOne from "@/components/HotelReservation/EnterDetails/Room/One" import DesktopSummary from "@/components/HotelReservation/EnterDetails/Summary/Desktop" import MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile" import EnterDetailsTrackingWrapper from "@/components/HotelReservation/EnterDetails/Tracking" -import Alert from "@/components/TempDesignSystem/Alert" -import { getIntl } from "@/i18n" import RoomProvider from "@/providers/Details/RoomProvider" import EnterDetailsProvider from "@/providers/EnterDetailsProvider" import { convertSearchParamsToObj } from "@/utils/url" @@ -25,7 +24,6 @@ import { convertSearchParamsToObj } from "@/utils/url" import styles from "./page.module.css" import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" -import { AlertTypeEnum } from "@/types/enums/alert" import type { LangParams, PageArgs } from "@/types/params" import type { Room } from "@/types/providers/details/room" @@ -71,6 +69,7 @@ export default async function DetailsPage({ // (possibly also add an error case to url?) // ------------------------------------------------------- // redirect back to select-rate if availability call fails + selectRoomParams.set("errorCode", BookingErrorCodeEnum.AvailabilityError) redirect(`${selectRate(lang)}?${selectRoomParams.toString()}`) } @@ -94,12 +93,9 @@ export default async function DetailsPage({ hotel.merchantInformationData.alternatePaymentOptions = [] } - const intl = await getIntl() - const firstRoom = rooms[0] const multirooms = rooms.slice(1) - const isRoomNotAvailable = rooms.some((room) => !room.isAvailable) return (
- {isRoomNotAvailable && ( - - )}
diff --git a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/my-stay/page.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/my-stay/page.tsx index 17be92f63..b9bc7083a 100644 --- a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/my-stay/page.tsx +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/my-stay/page.tsx @@ -14,7 +14,6 @@ import { getProfileSafely, getSavedPaymentCardsSafely, } from "@/lib/trpc/memoizedRequests" -import { decrypt } from "@/server/routers/utils/encryption" import { auth } from "@/auth" import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm" @@ -34,6 +33,7 @@ import Image from "@/components/Image" import { getIntl } from "@/i18n" import { setLang } from "@/i18n/serverContext" import MyStayProvider from "@/providers/MyStay" +import { parseRefId } from "@/utils/refId" import { isValidSession } from "@/utils/session" import { getCurrentWebUrl } from "@/utils/url" @@ -47,25 +47,20 @@ export default async function MyStay({ searchParams, }: PageArgs) { setLang(params.lang) + const refId = searchParams.RefId if (!refId) { notFound() } - const value = decrypt(refId) - if (!value) { - return notFound() - } - const session = await auth() const isLoggedIn = isValidSession(session) - - const [confirmationNumber, lastName] = value.split(",") + const { confirmationNumber, lastName } = parseRefId(refId) const bv = cookies().get("bv")?.value let bookingConfirmation if (isLoggedIn) { - bookingConfirmation = await getBookingConfirmation(confirmationNumber) + bookingConfirmation = await getBookingConfirmation(refId, params.lang) } else if (bv) { const params = new URLSearchParams(bv) const firstName = params.get("firstName") @@ -79,27 +74,18 @@ export default async function MyStay({ email ) } else { - return ( - - ) + return } } else { - return ( - - ) + return } if (!bookingConfirmation) { return notFound() } - const { additionalData, booking, hotel, roomCategories } = bookingConfirmation + const { booking, hotelData } = bookingConfirmation + const { hotel } = hotelData const user = await getProfileSafely() @@ -112,9 +98,7 @@ export default async function MyStay({ const fromDate = dt(booking.checkInDate).format("YYYY-MM-DD") const toDate = dt(booking.checkOutDate).format("YYYY-MM-DD") - const linkedReservationsPromise = getLinkedReservations({ - rooms: booking.linkedReservations, - }) + const linkedReservationsPromise = getLinkedReservations(refId, params.lang) const packagesInput = { adults: booking.adults, @@ -159,7 +143,7 @@ export default async function MyStay({ const imageSrc = hotel.hotelContent.images.imageSizes.large ?? - additionalData.gallery?.heroImages[0]?.imageSizes.large ?? + hotelData.additionalData.gallery?.heroImages[0]?.imageSizes.large ?? hotel.galleryImages[0]?.imageSizes.large const baseUrl = env.PUBLIC_URL || "https://www.scandichotels.com" @@ -176,7 +160,7 @@ export default async function MyStay({ lang={params.lang} linkedReservationsPromise={linkedReservationsPromise} refId={refId} - roomCategories={roomCategories} + roomCategories={hotelData.roomCategories} savedCreditCards={savedCreditCards} >
@@ -235,10 +219,7 @@ export default async function MyStay({ return (
- +
) @@ -272,19 +253,16 @@ export default async function MyStay({ } function RenderAdditionalInfoForm({ - confirmationNumber, + refId, lastName, }: { - confirmationNumber: string + refId: string lastName: string }) { return (
- +
) diff --git a/apps/scandic-web/app/[lang]/(no-layout)/hotelreservation/my-stay/receipt/loading.tsx b/apps/scandic-web/app/[lang]/(no-layout)/hotelreservation/my-stay/receipt/loading.tsx new file mode 100644 index 000000000..4b9d47ee6 --- /dev/null +++ b/apps/scandic-web/app/[lang]/(no-layout)/hotelreservation/my-stay/receipt/loading.tsx @@ -0,0 +1 @@ +export { MyStaySkeleton } from "@/components/HotelReservation/MyStay/myStaySkeleton" diff --git a/apps/scandic-web/components/HotelReservation/MyStay/Receipt/receipt.module.css b/apps/scandic-web/app/[lang]/(no-layout)/hotelreservation/my-stay/receipt/page.module.css similarity index 100% rename from apps/scandic-web/components/HotelReservation/MyStay/Receipt/receipt.module.css rename to apps/scandic-web/app/[lang]/(no-layout)/hotelreservation/my-stay/receipt/page.module.css diff --git a/apps/scandic-web/app/[lang]/(no-layout)/hotelreservation/my-stay/receipt/page.tsx b/apps/scandic-web/app/[lang]/(no-layout)/hotelreservation/my-stay/receipt/page.tsx index a5a66c0e0..ba0961c62 100644 --- a/apps/scandic-web/app/[lang]/(no-layout)/hotelreservation/my-stay/receipt/page.tsx +++ b/apps/scandic-web/app/[lang]/(no-layout)/hotelreservation/my-stay/receipt/page.tsx @@ -1,20 +1,215 @@ +import { cookies } from "next/headers" import { notFound } from "next/navigation" -import { Suspense } from "react" -import { MyStaySkeleton } from "@/components/HotelReservation/MyStay/myStaySkeleton" -import { Receipt } from "@/components/HotelReservation/MyStay/Receipt" +import ScandicLogoIcon from "@scandic-hotels/design-system/Icons/ScandicLogoIcon" +import { Typography } from "@scandic-hotels/design-system/Typography" +import { dt } from "@/lib/dt" +import { + findBooking, + getAncillaryPackages, + getBookingConfirmation, + getProfileSafely, +} from "@/lib/trpc/memoizedRequests" + +import { auth } from "@/auth" +import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm" +import accessBooking, { + ACCESS_GRANTED, + ERROR_BAD_REQUEST, + ERROR_UNAUTHORIZED, +} from "@/components/HotelReservation/MyStay/accessBooking" +import Footer from "@/components/HotelReservation/MyStay/Receipt/Footer" +import Specification from "@/components/HotelReservation/MyStay/Receipt/Specification" +import Total from "@/components/HotelReservation/MyStay/Receipt/Total" +import { getIntl } from "@/i18n" +import { parseRefId } from "@/utils/refId" +import { isValidSession } from "@/utils/session" + +import styles from "./page.module.css" + +import { CurrencyEnum } from "@/types/enums/currency" import type { LangParams, PageArgs } from "@/types/params" export default async function ReceiptPage({ + params, searchParams, }: PageArgs) { - if (!searchParams.RefId) { + const refId = searchParams.RefId + + if (!refId) { notFound() } + + const session = await auth() + const isLoggedIn = isValidSession(session) + const { confirmationNumber, lastName } = parseRefId(refId) + + const bv = cookies().get("bv")?.value + let bookingConfirmation + if (isLoggedIn) { + bookingConfirmation = await getBookingConfirmation(refId, params.lang) + } else if (bv) { + const params = new URLSearchParams(bv) + const firstName = params.get("firstName") + const email = params.get("email") + + if (firstName && email) { + bookingConfirmation = await findBooking( + confirmationNumber, + lastName, + firstName, + email + ) + } else { + return + } + } else { + return + } + + if (!bookingConfirmation) { + return notFound() + } + + const { booking, hotelData, room } = bookingConfirmation + const { hotel } = hotelData + + const intl = await getIntl() + const user = await getProfileSafely() + + const access = accessBooking(booking.guest, lastName, user, bv) + + if (access === ACCESS_GRANTED) { + const ancillaryPackages = await getAncillaryPackages({ + fromDate: dt(booking.checkInDate).format("YYYY-MM-DD"), + hotelId: hotel.operaId, + toDate: dt(booking.checkOutDate).format("YYYY-MM-DD"), + }) + + const currency = + booking.currencyCode !== CurrencyEnum.POINTS + ? booking.currencyCode + : (booking.ancillaries.find((a) => a.currency !== CurrencyEnum.POINTS) + ?.currency ?? + booking.packages.find((p) => p.currency !== CurrencyEnum.POINTS) + ?.currency) + + return ( +
+
+ +
+
+ +
{hotel.name}
+
+ +
+ {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} + {`${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`} +
+
+ +
+ {hotel.contactInformation.email} +
+
+ +
+ {hotel.contactInformation.phoneNumber} +
+
+
+
+ + {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} +
{`${booking.guest.firstName} ${booking.guest.lastName}`}
+
+ {booking.guest.membershipNumber && ( + + {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} +
{`${intl.formatMessage({ + defaultMessage: "Member", + })} ${booking.guest.membershipNumber}`}
+
+ )} + +
+ {booking.guest.email} +
+
+ +
+ {booking.guest.phoneNumber} +
+
+
+
+
+ + + +
+ +
+
+ ) + } + + if (access === ERROR_BAD_REQUEST) { + return ( +
+
+ +
+
+ ) + } + + if (access === ERROR_UNAUTHORIZED) { + return ( +
+
+ +

+ {intl.formatMessage({ + defaultMessage: "You need to be logged in to view your booking", + })} +

+
+ +

+ {intl.formatMessage({ + defaultMessage: + "And you need to be logged in with the same member account that made the booking.", + })} +

+
+
+
+ ) + } + + return notFound() +} + +function RenderAdditionalInfoForm({ + refId, + lastName, +}: { + refId: string + lastName: string +}) { return ( - }> - - +
+
+ +
+
) } diff --git a/apps/scandic-web/app/[lang]/webview/hotelreservation/my-stay/page.tsx b/apps/scandic-web/app/[lang]/webview/hotelreservation/my-stay/page.tsx index c87a6a567..69743c69a 100644 --- a/apps/scandic-web/app/[lang]/webview/hotelreservation/my-stay/page.tsx +++ b/apps/scandic-web/app/[lang]/webview/hotelreservation/my-stay/page.tsx @@ -14,7 +14,6 @@ import { getProfileSafely, getSavedPaymentCardsSafely, } from "@/lib/trpc/memoizedRequests" -import { decrypt } from "@/server/routers/utils/encryption" import { auth } from "@/auth" import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm" @@ -34,6 +33,7 @@ import Image from "@/components/Image" import { getIntl } from "@/i18n" import { setLang } from "@/i18n/serverContext" import MyStayProvider from "@/providers/MyStay" +import { parseRefId } from "@/utils/refId" import { isValidSession } from "@/utils/session" import { getCurrentWebUrl } from "@/utils/url" @@ -47,24 +47,21 @@ export default async function MyStay({ searchParams, }: PageArgs) { setLang(params.lang) + const refId = searchParams.RefId if (!refId) { notFound() } - const value = decrypt(refId) - if (!value) { - return notFound() - } const session = await auth() const isLoggedIn = isValidSession(session) + const { confirmationNumber, lastName } = parseRefId(refId) - const [confirmationNumber, lastName] = value.split(",") const bv = cookies().get("bv")?.value let bookingConfirmation if (isLoggedIn) { - bookingConfirmation = await getBookingConfirmation(confirmationNumber) + bookingConfirmation = await getBookingConfirmation(refId, params.lang) } else if (bv) { const params = new URLSearchParams(bv) const firstName = params.get("firstName") @@ -78,26 +75,17 @@ export default async function MyStay({ email ) } else { - return ( - - ) + return } } else { - return ( - - ) + return } if (!bookingConfirmation) { return notFound() } - const { additionalData, booking, hotel, roomCategories } = bookingConfirmation + const { booking, hotelData } = bookingConfirmation + const { hotel } = hotelData const user = await getProfileSafely() const intl = await getIntl() @@ -109,9 +97,7 @@ export default async function MyStay({ const fromDate = dt(booking.checkInDate).format("YYYY-MM-DD") const toDate = dt(booking.checkOutDate).format("YYYY-MM-DD") - const linkedReservationsPromise = getLinkedReservations({ - rooms: booking.linkedReservations, - }) + const linkedReservationsPromise = getLinkedReservations(refId, params.lang) const packagesInput = { adults: booking.adults, @@ -133,9 +119,9 @@ export default async function MyStay({ (pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST ) const breakfastIncluded = booking.rateDefinition.breakfastIncluded - const alreadyHasABreakfastSelection = + const shouldFetchBreakfastPackages = !hasBreakfastPackage && !breakfastIncluded - if (alreadyHasABreakfastSelection) { + if (shouldFetchBreakfastPackages) { void getPackages(packagesInput) } void getSavedPaymentCardsSafely(savedPaymentCardsInput) @@ -147,7 +133,7 @@ export default async function MyStay({ }) let breakfastPackages = null - if (alreadyHasABreakfastSelection) { + if (shouldFetchBreakfastPackages) { breakfastPackages = await getPackages(packagesInput) } const savedCreditCards = await getSavedPaymentCardsSafely( @@ -156,7 +142,7 @@ export default async function MyStay({ const imageSrc = hotel.hotelContent.images.imageSizes.large ?? - additionalData.gallery?.heroImages[0]?.imageSizes.large ?? + hotelData.additionalData.gallery?.heroImages[0]?.imageSizes.large ?? hotel.galleryImages[0]?.imageSizes.large const baseUrl = env.PUBLIC_URL || "https://www.scandichotels.com" @@ -173,7 +159,7 @@ export default async function MyStay({ lang={params.lang} linkedReservationsPromise={linkedReservationsPromise} refId={refId} - roomCategories={roomCategories} + roomCategories={hotelData.roomCategories} savedCreditCards={savedCreditCards} >
@@ -232,10 +218,7 @@ export default async function MyStay({ return (
- +
) @@ -269,19 +252,16 @@ export default async function MyStay({ } function RenderAdditionalInfoForm({ - confirmationNumber, + refId, lastName, }: { - confirmationNumber: string + refId: string lastName: string }) { return (
- +
) diff --git a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelListItem/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelListItem/index.tsx index a4715ed85..14e21d550 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelListItem/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelListItem/index.tsx @@ -1,7 +1,7 @@ "use client" import { useCallback, useEffect, useRef } from "react" -import { Button as AriaButton } from "react-aria-components" +import { Button as ButtonRAC } from "react-aria-components" import { useIntl } from "react-intl" import HotelLogoIcon from "@scandic-hotels/design-system/Icons/HotelLogoIcon" @@ -94,12 +94,12 @@ export default function HotelListItem(data: DestinationPagesHotelData) {
- setActiveMarker(hotel.id)} > {address} - +

diff --git a/apps/scandic-web/components/ContentType/DestinationPage/HotelMapCard/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/HotelMapCard/index.tsx index 69f96c5dd..d3241d9c0 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/HotelMapCard/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/HotelMapCard/index.tsx @@ -2,7 +2,7 @@ import { useState } from "react" import { useIntl } from "react-intl" -import { Button } from "@scandic-hotels/design-system/Button" +import { IconButton } from "@scandic-hotels/design-system/IconButton" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { Typography } from "@scandic-hotels/design-system/Typography" @@ -48,9 +48,9 @@ export default function HotelMapCard({ return (

- + {image ? ( - {isFullScreenSidebar ? viewAsMapMsg : viewAsListMsg} - +
@@ -168,7 +168,7 @@ export default function Sidebar({ {pois.map((poi) => (
  • - - +
  • ))} diff --git a/apps/scandic-web/components/ContentType/HotelPage/PreviewImages/index.tsx b/apps/scandic-web/components/ContentType/HotelPage/PreviewImages/index.tsx index 2e462d5e7..f4a362632 100644 --- a/apps/scandic-web/components/ContentType/HotelPage/PreviewImages/index.tsx +++ b/apps/scandic-web/components/ContentType/HotelPage/PreviewImages/index.tsx @@ -1,11 +1,13 @@ "use client" import { useState } from "react" +import { Button as ButtonRAC } from "react-aria-components" import { useIntl } from "react-intl" +import { Button } from "@scandic-hotels/design-system/Button" + import Image from "@/components/Image" import Lightbox from "@/components/Lightbox/" -import Button from "@/components/TempDesignSystem/Button" import { mapApiImagesToGalleryImages } from "@/utils/imageGallery" import styles from "./previewImages.module.css" @@ -17,31 +19,52 @@ export default function PreviewImages({ hotelName, }: PreviewImagesProps) { const intl = useIntl() - const [lightboxIsOpen, setLightboxIsOpen] = useState(false) + const [lightboxState, setLightboxState] = useState({ + activeIndex: 0, + isOpen: false, + }) const lightboxImages = mapApiImagesToGalleryImages(images) return (
    - {images.slice(0, 3).map((image, index) => ( - {image.metaData.altText} setLightboxIsOpen(true)} - className={styles.image} - /> + {lightboxImages.slice(0, 3).map((image, index) => ( + + setLightboxState({ + activeIndex: index, + isOpen: true, + }) + } + > + {image.alt} + ))} {images.length > 1 && ( <>
    ) diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Promos/index.tsx b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Promos/index.tsx index c35b08676..f32191901 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Promos/index.tsx +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Promos/index.tsx @@ -1,9 +1,8 @@ "use client" + import { useIntl } from "react-intl" -import { homeHrefs } from "@/constants/homeHrefs" -import { myBooking } from "@/constants/myBooking" -import { env } from "@/env/client" +import { myStay } from "@/constants/routes/myStay" import useLang from "@/hooks/useLang" @@ -13,22 +12,17 @@ import styles from "./promos.module.css" import type { PromosProps } from "@/types/components/hotelReservation/bookingConfirmation/promos" -export default function Promos({ - confirmationNumber, - hotelId, - lastName, -}: PromosProps) { +export default function Promos({ refId, hotelId }: PromosProps) { const intl = useIntl() const lang = useLang() - const homeUrl = homeHrefs[env.NEXT_PUBLIC_NODE_ENV][lang] - const myBookingUrl = myBooking[env.NEXT_PUBLIC_NODE_ENV][lang] + return (
    { @@ -79,13 +82,20 @@ export function LinkedReservation({ return } + const { booking, room } = data + return ( ) } diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Rooms/Room/index.tsx b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Rooms/Room/index.tsx index 33d20db5e..263b82929 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Rooms/Room/index.tsx +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Rooms/Room/index.tsx @@ -20,24 +20,28 @@ import styles from "./room.module.css" import type { RoomProps } from "@/types/components/hotelReservation/bookingConfirmation/rooms/room" export default function Room({ - booking, + checkInDate, + checkOutDate, checkInTime, checkOutTime, + confirmationNumber, + guaranteeInfo, + guest, img, + rateDefinition, roomName, }: RoomProps) { const intl = useIntl() const lang = useLang() - const guestName = `${booking.guest.firstName} ${booking.guest.lastName}` - const fromDate = dt(booking.checkInDate).locale(lang) - const toDate = dt(booking.checkOutDate).locale(lang) - + const guestName = `${guest.firstName} ${guest.lastName}` + const fromDate = dt(checkInDate).locale(lang) + const toDate = dt(checkOutDate).locale(lang) const isFlexBooking = - booking.rateDefinition.cancellationRule === + rateDefinition.cancellationRule === CancellationRuleEnum.CancellableBefore6PM const isChangeBooking = - booking.rateDefinition.cancellationRule === CancellationRuleEnum.Changeable + rateDefinition.cancellationRule === CancellationRuleEnum.Changeable return (
    @@ -47,11 +51,11 @@ export default function Room({ { defaultMessage: "Booking number {value}", }, - { value: booking.confirmationNumber } + { value: confirmationNumber } )} - {booking.rateDefinition.isMemberRate ? ( + {rateDefinition.isMemberRate ? (
    <>
    ) : null} - {booking.guaranteeInfo && ( + {guaranteeInfo && (
    - {booking.rateDefinition.cancellationText} + {rateDefinition.cancellationText} {isFlexBooking || isChangeBooking ? ( @@ -196,25 +200,23 @@ export default function Room({ })} {guestName} - {booking.guest.membershipNumber ? ( + {guest.membershipNumber ? ( {intl.formatMessage( { defaultMessage: "Friend no. {value}", }, { - value: booking.guest.membershipNumber, + value: guest.membershipNumber, } )} ) : null} - {booking.guest.phoneNumber ? ( - - {booking.guest.phoneNumber} - + {guest.phoneNumber ? ( + {guest.phoneNumber} ) : null} - {booking.guest.email ? ( - {booking.guest.email} + {guest.email ? ( + {guest.email} ) : null}
    diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Rooms/index.tsx b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Rooms/index.tsx index 32dec7c98..096fe370e 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Rooms/index.tsx +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Rooms/index.tsx @@ -9,55 +9,56 @@ import styles from "./rooms.module.css" import type { BookingConfirmationRoomsProps } from "@/types/components/hotelReservation/bookingConfirmation/rooms" +async function RoomTitle({ nr }: { nr: number }) { + const intl = await getIntl() + + return ( + +

    + {intl.formatMessage( + { + defaultMessage: "Room {roomIndex}", + }, + { roomIndex: nr } + )} +

    +
    + ) +} + export default async function Rooms({ booking, checkInTime, checkOutTime, mainRoom, - linkedReservations, }: BookingConfirmationRoomsProps) { - const intl = await getIntl() + const { linkedReservations } = booking return (
    - {linkedReservations.length ? ( - -

    - {intl.formatMessage( - { - defaultMessage: "Room {roomIndex}", - }, - { roomIndex: 1 } - )} -

    -
    - ) : null} + {linkedReservations.length ? : null}
    {linkedReservations.map((reservation, idx) => (
    - -

    - {intl.formatMessage( - { - defaultMessage: "Room {roomIndex}", - }, - { roomIndex: idx + 2 } - )} -

    -
    +
    diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Tracking/index.tsx b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Tracking/index.tsx index da10c4dd5..46a358d46 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Tracking/index.tsx +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Tracking/index.tsx @@ -27,7 +27,7 @@ export default function Tracking({ getTracking( lang, bookingConfirmation.booking, - bookingConfirmation.hotel, + bookingConfirmation.hotelData.hotel, rooms ) diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Tracking/tracking.ts b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Tracking/tracking.ts index 665eeb9c8..940eb0974 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Tracking/tracking.ts +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Tracking/tracking.ts @@ -67,7 +67,7 @@ function mapAncillaryPackage( export function getTracking( lang: Lang, booking: BookingConfirmation["booking"], - hotel: BookingConfirmation["hotel"], + hotel: BookingConfirmation["hotelData"]["hotel"], rooms: Room[] ) { const arrivalDate = new Date(booking.checkInDate) diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/bookingConfirmation.module.css b/apps/scandic-web/components/HotelReservation/BookingConfirmation/bookingConfirmation.module.css deleted file mode 100644 index f7555e753..000000000 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/bookingConfirmation.module.css +++ /dev/null @@ -1,22 +0,0 @@ -.booking { - display: flex; - flex-direction: column; - gap: var(--Spacing-x5); - grid-area: booking; - padding-bottom: var(--Spacing-x9); -} - -.aside { - display: none; -} - -@media screen and (min-width: 1367px) { - .mobileReceipt { - display: none; - } - - .aside { - display: grid; - grid-area: receipt; - } -} diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/index.tsx b/apps/scandic-web/components/HotelReservation/BookingConfirmation/index.tsx deleted file mode 100644 index 91f39fb4f..000000000 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/index.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { notFound } from "next/navigation" - -import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests" -import { encrypt } from "@/server/routers/utils/encryption" - -import HotelDetails from "@/components/HotelReservation/BookingConfirmation/HotelDetails" -import PaymentDetails from "@/components/HotelReservation/BookingConfirmation/PaymentDetails" -import Promos from "@/components/HotelReservation/BookingConfirmation/Promos" -import Receipt from "@/components/HotelReservation/BookingConfirmation/Receipt" -import Rooms from "@/components/HotelReservation/BookingConfirmation/Rooms" -import SidePanel from "@/components/HotelReservation/SidePanel" -import Divider from "@/components/TempDesignSystem/Divider" -import { getIntl } from "@/i18n" -import BookingConfirmationProvider from "@/providers/BookingConfirmationProvider" - -import Alerts from "./Alerts" -import Confirmation from "./Confirmation" -import Tracking from "./Tracking" -import { mapRoomState } from "./utils" - -import styles from "./bookingConfirmation.module.css" - -import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" - -export default async function BookingConfirmation({ - confirmationNumber, -}: BookingConfirmationProps) { - const bookingConfirmation = await getBookingConfirmation(confirmationNumber) - - if (!bookingConfirmation) { - return notFound() - } - const { booking, hotel, room } = bookingConfirmation - if (!room) { - return notFound() - } - - const refId = encrypt( - `${booking.confirmationNumber},${booking.guest.lastName}` - ) - - const intl = await getIntl() - return ( - - -
    - - - - - - -
    - -
    -
    - -
    - -
    - ) -} diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/utils.ts b/apps/scandic-web/components/HotelReservation/BookingConfirmation/utils.ts index 2e8428983..a34223227 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/utils.ts +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/utils.ts @@ -5,10 +5,10 @@ import type { IntlShape } from "react-intl" import type { BookingConfirmationRoom } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" import { BreakfastPackageEnum } from "@/types/enums/breakfast" import { CurrencyEnum } from "@/types/enums/currency" -import type { BookingConfirmationSchema } from "@/types/trpc/routers/booking/confirmation" +import type { BookingSchema } from "@/types/trpc/routers/booking/confirmation" export function mapRoomState( - booking: BookingConfirmationSchema, + booking: BookingSchema, room: BookingConfirmationRoom, intl: IntlShape ) { diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Confirm/index.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Confirm/index.tsx index 6cff51a81..fc918b96b 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Confirm/index.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Confirm/index.tsx @@ -135,7 +135,7 @@ export default function ConfirmBooking({ ) : null}
    - +
    ) @@ -156,7 +156,7 @@ export function ConfirmBookingRedemption() {
    - +
    ) diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Details/RoomOne/AutoFillDetector.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Details/RoomOne/AutoFillDetector.tsx new file mode 100644 index 000000000..7024fe665 --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Details/RoomOne/AutoFillDetector.tsx @@ -0,0 +1,31 @@ +"use client" +import { useEffect } from "react" +import { useFormContext } from "react-hook-form" + +export default function AutoFillDetector() { + const { + formState: { dirtyFields, isDirty, touchedFields }, + trigger, + watch, + } = useFormContext() + + useEffect(() => { + const dirtyFieldKeys = Object.keys(dirtyFields) + const touchedFieldKeys = Object.keys(touchedFields) + const hasDirtyUnTouchedFields = dirtyFieldKeys.some( + (field) => !touchedFieldKeys.includes(field) + ) + const subscription = watch((_, field) => { + if (!field.type) { + if (isDirty && hasDirtyUnTouchedFields) { + trigger(field.name) + trigger("countryCode") + } + } + }) + + return () => subscription.unsubscribe() + }, [dirtyFields, isDirty, touchedFields, trigger, watch]) + + return null +} diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Details/RoomOne/index.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Details/RoomOne/index.tsx index 88d1ebd0b..4250f8186 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Details/RoomOne/index.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Details/RoomOne/index.tsx @@ -11,6 +11,7 @@ import Phone from "@/components/TempDesignSystem/Form/Phone" import Footnote from "@/components/TempDesignSystem/Text/Footnote" import { useRoomContext } from "@/contexts/Details/Room" +import AutoFillDetector from "./AutoFillDetector" import JoinScandicFriendsCard from "./JoinScandicFriendsCard" import { guestDetailsSchema, signedInDetailsSchema } from "./schema" import Signup from "./Signup" @@ -150,6 +151,7 @@ export default function Details({ user }: DetailsProps) { registerOptions={{ required: true, onBlur: updateDetailsStore }} />
    + ) diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentAlert/paymentAlert.module.css b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/BookingAlert/bookingAlert.module.css similarity index 100% rename from apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentAlert/paymentAlert.module.css rename to apps/scandic-web/components/HotelReservation/EnterDetails/Payment/BookingAlert/bookingAlert.module.css diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentAlert/index.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/BookingAlert/index.tsx similarity index 50% rename from apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentAlert/index.tsx rename to apps/scandic-web/components/HotelReservation/EnterDetails/Payment/BookingAlert/index.tsx index 3392eccf6..ba0bb3780 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentAlert/index.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/BookingAlert/index.tsx @@ -1,15 +1,18 @@ "use client" import { usePathname, useSearchParams } from "next/navigation" -import { useEffect, useState } from "react" +import { useEffect, useRef, useState } from "react" import { useIntl } from "react-intl" import { BookingErrorCodeEnum } from "@/constants/booking" +import { selectRate } from "@/constants/routes/hotelReservation" import { useEnterDetailsStore } from "@/stores/enter-details" import Alert from "@/components/TempDesignSystem/Alert" +import useLang from "@/hooks/useLang" +import useStickyPosition from "@/hooks/useStickyPosition" -import styles from "./paymentAlert.module.css" +import styles from "./bookingAlert.module.css" import { AlertTypeEnum } from "@/types/enums/alert" @@ -18,6 +21,7 @@ function useBookingErrorAlert() { (state) => state.actions.updateSeachParamString ) const intl = useIntl() + const lang = useLang() const searchParams = useSearchParams() const pathname = usePathname() @@ -30,12 +34,19 @@ function useBookingErrorAlert() { const [showAlert, setShowAlert] = useState(!!errorCode) + const selectRateReturnUrl = getSelectRateReturnUrl() + function getErrorMessage(errorCode: string | null) { switch (errorCode) { case BookingErrorCodeEnum.TransactionCancelled: return intl.formatMessage({ defaultMessage: "You have now cancelled your payment.", }) + case BookingErrorCodeEnum.AvailabilityError: + return intl.formatMessage({ + defaultMessage: + "Unfortunately, one of the rooms you selected is sold out. Please choose another room to proceed.", + }) default: return intl.formatMessage({ defaultMessage: @@ -53,16 +64,42 @@ function useBookingErrorAlert() { window.history.replaceState({}, "", `${pathname}?${queryParams.toString()}`) } - return { showAlert, errorMessage, severityLevel, discardAlert, setShowAlert } + function getSelectRateReturnUrl() { + const queryParams = new URLSearchParams(searchParams.toString()) + queryParams.delete("errorCode") + return `${selectRate(lang)}?${queryParams.toString()}` + } + + return { + showAlert, + errorCode, + errorMessage, + severityLevel, + discardAlert, + setShowAlert, + selectRateReturnUrl, + } } -interface PaymentAlertProps { +interface BookingAlertProps { isVisible?: boolean } -export default function PaymentAlert({ isVisible = false }: PaymentAlertProps) { - const { showAlert, errorMessage, severityLevel, discardAlert, setShowAlert } = - useBookingErrorAlert() +export default function BookingAlert({ isVisible = false }: BookingAlertProps) { + const intl = useIntl() + + const { + showAlert, + errorCode, + errorMessage, + severityLevel, + discardAlert, + setShowAlert, + selectRateReturnUrl, + } = useBookingErrorAlert() + + const ref = useRef(null) + const { getTopOffset } = useStickyPosition() useEffect(() => { if (isVisible) { @@ -70,15 +107,39 @@ export default function PaymentAlert({ isVisible = false }: PaymentAlertProps) { } }, [isVisible, setShowAlert]) + useEffect(() => { + const el = ref.current + + if (showAlert && el) { + window.scrollTo({ + top: el.offsetTop - getTopOffset(), + behavior: "smooth", + }) + } + }, [showAlert, getTopOffset]) + if (!showAlert) return null + const isAvailabilityError = + errorCode === BookingErrorCodeEnum.AvailabilityError + return ( -
    +
    ) diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleSuccessCallback.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleSuccessCallback.tsx index 20445bb6e..0d34fac40 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleSuccessCallback.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleSuccessCallback.tsx @@ -18,12 +18,12 @@ const validBookingStatuses = [ ] interface HandleStatusPollingProps { - confirmationNumber: string + refId: string successRedirectUrl: string } export default function HandleSuccessCallback({ - confirmationNumber, + refId, successRedirectUrl, }: HandleStatusPollingProps) { const router = useRouter() @@ -33,7 +33,7 @@ export default function HandleSuccessCallback({ error, isTimeout, } = useHandleBookingStatus({ - confirmationNumber, + refId, expectedStatuses: validBookingStatuses, maxRetries: 10, retryInterval: 2000, @@ -70,9 +70,9 @@ export default function HandleSuccessCallback({ ? `&errorCode=${membershipFailedError.errorCode}` : "" - router.replace(`${successRedirectUrl}${errorParam}`) + router.replace(`${successRedirectUrl}?RefId=${refId}${errorParam}`) } - }, [bookingStatus, successRedirectUrl, router]) + }, [bookingStatus, refId, router, successRedirectUrl]) if (isTimeout || error) { return diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx index 15431a792..1b5938e1b 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx @@ -1,8 +1,8 @@ "use client" import { zodResolver } from "@hookform/resolvers/zod" -import { useRouter, useSearchParams } from "next/navigation" -import { useCallback, useEffect, useState } from "react" +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import { useCallback, useEffect, useRef, useState } from "react" import { Label } from "react-aria-components" import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" @@ -10,8 +10,6 @@ import { useIntl } from "react-intl" import { Typography } from "@scandic-hotels/design-system/Typography" import { - BOOKING_CONFIRMATION_NUMBER, - BookingErrorCodeEnum, BookingStatusEnum, PAYMENT_METHOD_TITLES, PaymentMethodEnum, @@ -31,7 +29,6 @@ import Button from "@/components/TempDesignSystem/Button" import Body from "@/components/TempDesignSystem/Text/Body" import Title from "@/components/TempDesignSystem/Text/Title" import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions" -import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus" import useLang from "@/hooks/useLang" import useStickyPosition from "@/hooks/useStickyPosition" import { trackPaymentEvent } from "@/utils/tracking" @@ -42,10 +39,10 @@ import { bedTypeMap } from "../../utils" import ConfirmBooking, { ConfirmBookingRedemption } from "../Confirm" import PriceChangeDialog from "../PriceChangeDialog" import { writeGlaToSessionStorage } from "./PaymentCallback/helpers" +import BookingAlert from "./BookingAlert" import GuaranteeDetails from "./GuaranteeDetails" import { hasFlexibleRate, hasPrepaidRate, isPaymentMethodEnum } from "./helpers" import MixedRatePaymentBreakdown from "./MixedRatePaymentBreakdown" -import PaymentAlert from "./PaymentAlert" import PaymentOptionsGroup from "./PaymentOptionsGroup" import { type PaymentFormData, paymentSchema } from "./schema" import TermsAndConditions from "./TermsAndConditions" @@ -71,10 +68,11 @@ export default function PaymentClient({ const router = useRouter() const lang = useLang() const intl = useIntl() + const pathname = usePathname() const searchParams = useSearchParams() const { getTopOffset } = useStickyPosition({}) - const [showPaymentAlert, setShowPaymentAlert] = useState(false) + const [showBookingAlert, setShowBookingAlert] = useState(false) const { booking, rooms, totalPrice } = useEnterDetailsStore((state) => ({ booking: state.booking, @@ -101,7 +99,7 @@ export default function PaymentClient({ (state) => state.actions.setIsSubmittingDisabled ) - const [bookingNumber, setBookingNumber] = useState("") + const [refId, setRefId] = useState("") const [isPollingForBookingStatus, setIsPollingForBookingStatus] = useState(false) @@ -135,21 +133,25 @@ export default function PaymentClient({ onSuccess: (result) => { if (result) { if ("error" in result) { - if (result.cause === BookingErrorCodeEnum.AvailabilityError) { - window.location.reload() // reload to refetch room data because we dont know which room is unavailable - } else { - handlePaymentError(result.cause) - } + const queryParams = new URLSearchParams(searchParams.toString()) + queryParams.set("errorCode", result.cause) + window.history.replaceState( + {}, + "", + `${pathname}?${queryParams.toString()}` + ) + handlePaymentError(result.cause) return } + const mainRoom = result.rooms[0] if (result.reservationStatus == BookingStatusEnum.BookingCompleted) { - const confirmationUrl = `${bookingConfirmation(lang)}?${BOOKING_CONFIRMATION_NUMBER}=${result.id}` + const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${mainRoom.refId}` router.push(confirmationUrl) return } - setBookingNumber(result.id) + setRefId(mainRoom.refId) const hasPriceChange = result.rooms.some((r) => r.priceChangedMetadata) if (hasPriceChange) { @@ -171,8 +173,8 @@ export default function PaymentClient({ }) const priceChange = trpc.booking.priceChange.useMutation({ - onSuccess: (result) => { - if (result?.id) { + onSuccess: (confirmationNumber) => { + if (confirmationNumber) { setIsPollingForBookingStatus(true) } else { handlePaymentError("No confirmation number") @@ -186,17 +188,43 @@ export default function PaymentClient({ }, }) - const bookingStatus = useHandleBookingStatus({ - confirmationNumber: bookingNumber, - expectedStatuses: [BookingStatusEnum.BookingCompleted], - maxRetries, - retryInterval, - enabled: isPollingForBookingStatus, - }) + // Replaced useHandleBookingStatus with logic specifically used here, since the hook would need + // to handle different parameters based on use case + const retries = useRef(0) + + const bookingStatus = trpc.booking.confirmationCompleted.useQuery( + { + refId, + lang, + }, + { + enabled: isPollingForBookingStatus, + refetchInterval: (query) => { + retries.current = query.state.dataUpdateCount + + if (query.state.error || query.state.dataUpdateCount >= maxRetries) { + return false + } + + if ( + query.state.data?.reservationStatus === + BookingStatusEnum.BookingCompleted + ) { + return false + } + + return retryInterval + }, + refetchIntervalInBackground: true, + refetchOnWindowFocus: false, + refetchOnMount: false, + retry: false, + } + ) const handlePaymentError = useCallback( (errorMessage: string) => { - setShowPaymentAlert(true) + setShowBookingAlert(true) const currentPaymentMethod = methods.getValues("paymentMethod") const smsEnable = methods.getValues("smsConfirmation") @@ -242,18 +270,12 @@ export default function PaymentClient({ ) useEffect(() => { - if (bookingStatus?.data?.paymentUrl) { - router.push(bookingStatus.data.paymentUrl) - } else if ( - bookingStatus?.data?.reservationStatus === - BookingStatusEnum.BookingCompleted - ) { - const confirmationUrl = `${bookingConfirmation(lang)}?${BOOKING_CONFIRMATION_NUMBER}=${bookingStatus?.data?.id}` - router.push(confirmationUrl) - } else if (bookingStatus.isTimeout) { + if (bookingStatus?.data?.redirectUrl) { + router.push(bookingStatus.data.redirectUrl) + } else if (retries.current >= maxRetries) { handlePaymentError("Timeout") } - }, [bookingStatus, router, intl, lang, handlePaymentError]) + }, [bookingStatus, router, handlePaymentError]) useEffect(() => { setIsSubmittingDisabled( @@ -455,7 +477,7 @@ export default function PaymentClient({ initiateBooking.isPending || (isPollingForBookingStatus && !bookingStatus.data?.paymentUrl && - !bookingStatus.isTimeout) + retries.current < maxRetries) ) { return } @@ -480,7 +502,7 @@ export default function PaymentClient({ ? confirm : payment} - +
    - +
    )} @@ -617,9 +639,7 @@ export default function PaymentClient({ : "" router.push(`${selectRate(lang)}${allSearchParams}`) }} - onAccept={() => - priceChange.mutate({ confirmationNumber: bookingNumber }) - } + onAccept={() => priceChange.mutate({ refId })} /> ) : null} diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/TermsAndConditions/index.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/TermsAndConditions/index.tsx index 014ea5e33..1eaa7be40 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/TermsAndConditions/index.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/TermsAndConditions/index.tsx @@ -12,45 +12,82 @@ import useLang from "@/hooks/useLang" import styles from "../payment.module.css" -export default function TermsAndConditions() { +import type { TermsAndConditionsProps } from "@/types/components/hotelReservation/enterDetails/payment" + +export default function TermsAndConditions({ + isFlexBookingTerms, +}: TermsAndConditionsProps) { const intl = useIntl() const lang = useLang() return ( <> - {intl.formatMessage( - { - defaultMessage: - "I accept the terms for this booking and the general Terms & Conditions, and understand that Scandic will process my personal data for this booking in accordance with Scandic's Privacy policy. I also accept that Scandic requires a valid payment card during my visit in case anything is left unpaid.", - }, - { - termsAndConditionsLink: (str) => ( - - {str} - - ), - privacyPolicyLink: (str) => ( - - {str} - - ), - } - )} + {isFlexBookingTerms + ? intl.formatMessage( + { + defaultMessage: + "I accept the terms for this booking and the general Terms & Conditions, and understand that Scandic will process my personal data for this booking in accordance with Scandic's Privacy policy.", + }, + { + termsAndConditionsLink: (str) => ( + + {str} + + ), + privacyPolicyLink: (str) => ( + + {str} + + ), + } + ) + : intl.formatMessage( + { + defaultMessage: + "By paying with any of the payment methods available, I accept the terms for this booking and the general Terms & Conditions, and understand that Scandic will process my personal data for this booking in accordance with Scandic's Privacy policy. I also accept that Scandic requires a valid payment card during my visit in case anything is left unpaid.", + }, + { + termsAndConditionsLink: (str) => ( + + {str} + + ), + privacyPolicyLink: (str) => ( + + {str} + + ), + } + )} diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/Mobile/BottomSheet/index.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/Mobile/BottomSheet/index.tsx index 041c18e7b..71b017e28 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/Mobile/BottomSheet/index.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/Mobile/BottomSheet/index.tsx @@ -1,5 +1,6 @@ "use client" +import { useSearchParams } from "next/navigation" import { type PropsWithChildren, useEffect, useRef } from "react" import { useIntl } from "react-intl" @@ -16,6 +17,8 @@ import styles from "./bottomSheet.module.css" export default function SummaryBottomSheet({ children }: PropsWithChildren) { const intl = useIntl() const scrollY = useRef(0) + const searchParams = useSearchParams() + const errorCode = searchParams.get("errorCode") const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } = useEnterDetailsStore((state) => ({ @@ -33,18 +36,21 @@ export default function SummaryBottomSheet({ children }: PropsWithChildren) { } else { document.body.style.position = "" document.body.style.top = "" - window.scrollTo({ - top: scrollY.current, - left: 0, - behavior: "instant", - }) + + if (!errorCode) { + window.scrollTo({ + top: scrollY.current, + left: 0, + behavior: "instant", + }) + } } return () => { document.body.style.position = "" document.body.style.top = "" } - }, [isSummaryOpen]) + }, [isSummaryOpen, errorCode]) return (
    diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/index.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/index.tsx index 6f7d0c4c8..ac13047ca 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/index.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/index.tsx @@ -4,6 +4,7 @@ import { Fragment } from "react" import { useIntl } from "react-intl" import { Button } from "@scandic-hotels/design-system/Button" +import { IconButton } from "@scandic-hotels/design-system/IconButton" import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { Typography } from "@scandic-hotels/design-system/Typography" @@ -115,17 +116,18 @@ export default function SummaryUI({ {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} {dt(booking.toDate).locale(lang).format("ddd, D MMM")} ({nightsMsg}) - + {rooms.map(({ room }, idx) => { diff --git a/apps/scandic-web/components/HotelReservation/FindMyBooking/AdditionalInfoForm.tsx b/apps/scandic-web/components/HotelReservation/FindMyBooking/AdditionalInfoForm.tsx index 07ee51d7f..a2d1a20f0 100644 --- a/apps/scandic-web/components/HotelReservation/FindMyBooking/AdditionalInfoForm.tsx +++ b/apps/scandic-web/components/HotelReservation/FindMyBooking/AdditionalInfoForm.tsx @@ -18,10 +18,10 @@ import { import styles from "./findMyBooking.module.css" export default function AdditionalInfoForm({ - confirmationNumber, + refId, lastName, }: { - confirmationNumber: string + refId: string lastName: string }) { const router = useRouter() @@ -37,7 +37,7 @@ export default function AdditionalInfoForm({ const values = form.getValues() const value = new URLSearchParams({ ...values, - confirmationNumber, + RefId: refId, lastName, }).toString() document.cookie = `bv=${encodeURIComponent(value)}; Path=/; Max-Age=600; Secure; SameSite=Strict` diff --git a/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/AddAncillaryFlow/AddAncillaryFlowModal/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/AddAncillaryFlow/AddAncillaryFlowModal/index.tsx index 51510f14e..a34a688ca 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/AddAncillaryFlow/AddAncillaryFlowModal/index.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/AddAncillaryFlow/AddAncillaryFlowModal/index.tsx @@ -145,7 +145,7 @@ export default function AddAncillaryFlowModal({ ) { addAncillary.mutate( { - confirmationNumber: booking.confirmationNumber, + refId, ancillaryComment: data.optionalText, ancillaryDeliveryTime: selectedAncillary?.requiresDeliveryTime ? data.deliveryTime @@ -175,8 +175,8 @@ export default function AddAncillaryFlowModal({ ) clearAncillarySessionData() closeModal() - utils.booking.get.invalidate({ - confirmationNumber: booking.confirmationNumber, + utils.booking.confirmation.invalidate({ + refId: booking.refId, }) router.refresh() } else { @@ -211,7 +211,7 @@ export default function AddAncillaryFlowModal({ } : undefined guaranteeBooking.mutate({ - confirmationNumber: booking.confirmationNumber, + refId, language: lang, ...(card && { card }), success: `${guaranteeRedirectUrl}?status=success&RefId=${encodeURIComponent(refId)}&ancillary=1`, diff --git a/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/AddedAncillaries/RemoveButton.tsx b/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/AddedAncillaries/RemoveButton.tsx index 9d346df62..75300af24 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/AddedAncillaries/RemoveButton.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/AddedAncillaries/RemoveButton.tsx @@ -10,12 +10,12 @@ import { toast } from "@/components/TempDesignSystem/Toasts" import useLang from "@/hooks/useLang" export default function RemoveButton({ - confirmationNumber, + refId, codes, title, onSuccess, }: { - confirmationNumber: string + refId: string codes: string[] title?: string onSuccess: () => void @@ -51,7 +51,7 @@ export default function RemoveButton({ removePackage.mutate( { language: lang, - confirmationNumber, + refId, codes, }, { diff --git a/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/AddedAncillaries/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/AddedAncillaries/index.tsx index b8328768f..c967705b9 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/AddedAncillaries/index.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/AddedAncillaries/index.tsx @@ -25,6 +25,7 @@ import type { export function AddedAncillaries({ ancillaries, booking, + refId, }: AddedAncillariesProps) { const intl = useIntl() const router = useRouter() @@ -126,7 +127,7 @@ export function AddedAncillaries({ {booking.confirmationNumber && ancillary.code ? (
    } diff --git a/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/index.tsx index a35fa8514..734c16c82 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/index.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/index.tsx @@ -213,7 +213,11 @@ export function Ancillaries({ )} - + setIsLoading(true), - onSuccess: (data) => { - if (data) { - utils.booking.get.invalidate({ - confirmationNumber: data.confirmationNumber, + onSuccess: (refId) => { + if (refId) { + utils.booking.confirmation.invalidate({ + refId, }) toast.success( @@ -99,7 +99,7 @@ export default function Details({ booking, user }: DetailsProps) { async function onSubmit(data: ModifyContactSchema) { updateGuest.mutate({ - confirmationNumber: booking.confirmationNumber, + refId: booking.refId, guest: { email: data.email, phoneNumber: data.phoneNumber, diff --git a/apps/scandic-web/components/HotelReservation/MyStay/GuestDetails/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/GuestDetails/index.tsx index 7f786c59f..f8dfc3949 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/GuestDetails/index.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/GuestDetails/index.tsx @@ -1,4 +1,5 @@ "use client" + import { useMyStayStore } from "@/stores/my-stay" import Details from "./Details" diff --git a/apps/scandic-web/components/HotelReservation/MyStay/Receipt/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/Receipt/index.tsx deleted file mode 100644 index 420648db7..000000000 --- a/apps/scandic-web/components/HotelReservation/MyStay/Receipt/index.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { cookies } from "next/headers" -import { notFound } from "next/navigation" - -import ScandicLogoIcon from "@scandic-hotels/design-system/Icons/ScandicLogoIcon" -import { Typography } from "@scandic-hotels/design-system/Typography" - -import { dt } from "@/lib/dt" -import { - findBooking, - getAncillaryPackages, - getBookingConfirmation, - getProfileSafely, -} from "@/lib/trpc/memoizedRequests" -import { decrypt } from "@/server/routers/utils/encryption" - -import { auth } from "@/auth" -import { getIntl } from "@/i18n" -import { isValidSession } from "@/utils/session" - -import AdditionalInfoForm from "../../FindMyBooking/AdditionalInfoForm" -import accessBooking, { - ACCESS_GRANTED, - ERROR_BAD_REQUEST, - ERROR_UNAUTHORIZED, -} from "../accessBooking" -import Footer from "./Footer" -import Specification from "./Specification" -import Total from "./Total" - -import styles from "./receipt.module.css" - -import { CurrencyEnum } from "@/types/enums/currency" - -export async function Receipt({ refId }: { refId: string }) { - const value = decrypt(refId) - if (!value) { - return notFound() - } - const session = await auth() - const isLoggedIn = isValidSession(session) - - const [confirmationNumber, lastName] = value.split(",") - const bv = cookies().get("bv")?.value - let bookingConfirmation - if (isLoggedIn) { - bookingConfirmation = await getBookingConfirmation(confirmationNumber) - } else if (bv) { - const params = new URLSearchParams(bv) - const firstName = params.get("firstName") - const email = params.get("email") - - if (firstName && email) { - bookingConfirmation = await findBooking( - confirmationNumber, - lastName, - firstName, - email - ) - } else { - return ( - - ) - } - } else { - return ( - - ) - } - if (!bookingConfirmation) { - return notFound() - } - - const { booking, hotel, room } = bookingConfirmation - const user = await getProfileSafely() - const intl = await getIntl() - - const access = accessBooking(booking.guest, lastName, user, bv) - - if (access === ACCESS_GRANTED) { - const ancillaryPackages = await getAncillaryPackages({ - fromDate: dt(booking.checkInDate).format("YYYY-MM-DD"), - hotelId: hotel.operaId, - toDate: dt(booking.checkOutDate).format("YYYY-MM-DD"), - }) - - const currency = - booking.currencyCode !== CurrencyEnum.POINTS - ? booking.currencyCode - : (booking.ancillaries.find((a) => a.currency !== CurrencyEnum.POINTS) - ?.currency ?? - booking.packages.find((p) => p.currency !== CurrencyEnum.POINTS) - ?.currency) - - return ( -
    -
    - -
    -
    - -
    {hotel.name}
    -
    - -
    - {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} - {`${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`} -
    -
    - -
    - {hotel.contactInformation.email} -
    -
    - -
    - {hotel.contactInformation.phoneNumber} -
    -
    -
    -
    - - {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} -
    {`${booking.guest.firstName} ${booking.guest.lastName}`}
    -
    - {booking.guest.membershipNumber && ( - - {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} -
    {`${intl.formatMessage({ - defaultMessage: "Member", - })} ${booking.guest.membershipNumber}`}
    -
    - )} - -
    - {booking.guest.email} -
    -
    - -
    - {booking.guest.phoneNumber} -
    -
    -
    -
    -
    - - - -
    - -
    -
    - ) - } - - if (access === ERROR_BAD_REQUEST) { - return ( -
    -
    - -
    -
    - ) - } - - if (access === ERROR_UNAUTHORIZED) { - return ( -
    -
    - -

    - {intl.formatMessage({ - defaultMessage: "You need to be logged in to view your booking", - })} -

    -
    - -

    - {intl.formatMessage({ - defaultMessage: - "And you need to be logged in with the same member account that made the booking.", - })} -

    -
    -
    -
    - ) - } - - return notFound() -} - -function RenderAdditionalInfoForm({ - confirmationNumber, - lastName, -}: { - confirmationNumber: string - lastName: string -}) { - return ( -
    -
    - -
    -
    - ) -} diff --git a/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/Actions/NotCancelled/ManageStay/Actions/CancelStay/Steps/FinalConfirmation/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/Actions/NotCancelled/ManageStay/Actions/CancelStay/Steps/FinalConfirmation/index.tsx index 3c5cbca8c..90f5c15c7 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/Actions/NotCancelled/ManageStay/Actions/CancelStay/Steps/FinalConfirmation/index.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/Actions/NotCancelled/ManageStay/Actions/CancelStay/Steps/FinalConfirmation/index.tsx @@ -40,7 +40,7 @@ export default function FinalConfirmation({ defaultMessage: "We’re sorry that things didn’t work out.", }) - const cancelBookingsMutation = trpc.booking.cancelMany.useMutation({ + const cancelBookingsMutation = trpc.booking.cancel.useMutation({ onSuccess(data, variables) { const allCancellationsWentThrough = data.every((cancelled) => cancelled) if (allCancellationsWentThrough) { @@ -57,7 +57,7 @@ export default function FinalConfirmation({ ) } else { const cancelledRooms = rooms.filter((r) => - variables.confirmationNumbers.includes(r.confirmationNumber) + variables.refIds.includes(r.refId) ) for (const cancelledRoom of cancelledRooms) { toast.success( @@ -93,13 +93,16 @@ export default function FinalConfirmation({ ) } - utils.booking.get.invalidate({ - confirmationNumber: bookedRoom.confirmationNumber, - }) - utils.booking.linkedReservations.invalidate({ + utils.booking.confirmation.invalidate({ + refId: bookedRoom.refId, lang, - rooms: bookedRoom.linkedReservations, }) + + utils.booking.linkedReservations.invalidate({ + refId: bookedRoom.refId, + lang, + }) + closeModal() }, onError() { @@ -113,13 +116,13 @@ export default function FinalConfirmation({ function cancelBooking() { if (Array.isArray(formRooms)) { - const confirmationNumbersToCancel = formRooms + const refIdsToCancel = formRooms .filter((r) => r.checked) .map((r) => r.confirmationNumber) - if (confirmationNumbersToCancel.length) { + if (refIdsToCancel.length) { cancelBookingsMutation.mutate({ - confirmationNumbers: confirmationNumbersToCancel, - language: lang, + refIds: refIdsToCancel, + lang, }) } } else { diff --git a/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/Actions/NotCancelled/ManageStay/Actions/ChangeDates/Steps/Confirmation/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/Actions/NotCancelled/ManageStay/Actions/ChangeDates/Steps/Confirmation/index.tsx index 9c75710fc..49f3d6c6c 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/Actions/NotCancelled/ManageStay/Actions/ChangeDates/Steps/Confirmation/index.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/Actions/NotCancelled/ManageStay/Actions/ChangeDates/Steps/Confirmation/index.tsx @@ -54,10 +54,10 @@ export default function Confirmation({ ) const updateBooking = trpc.booking.update.useMutation({ - onSuccess: (updatedBooking) => { - if (updatedBooking) { - utils.booking.get.invalidate({ - confirmationNumber: updatedBooking.confirmationNumber, + onSuccess: (refId) => { + if (refId) { + utils.booking.confirmation.invalidate({ + refId, }) toast.success( @@ -86,7 +86,7 @@ export default function Confirmation({ function handleModifyStay() { updateBooking.mutate({ - confirmationNumber: bookedRoom.confirmationNumber, + refId: bookedRoom.refId, checkInDate, checkOutDate, }) diff --git a/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/Actions/NotCancelled/ManageStay/Actions/GuaranteeLateArrival/Form/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/Actions/NotCancelled/ManageStay/Actions/GuaranteeLateArrival/Form/index.tsx index a042331c1..69d3fbc73 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/Actions/NotCancelled/ManageStay/Actions/GuaranteeLateArrival/Form/index.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/Actions/NotCancelled/ManageStay/Actions/GuaranteeLateArrival/Form/index.tsx @@ -60,7 +60,7 @@ export default function Form() { const guaranteeRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}${guaranteeCallback(lang)}` const { guaranteeBooking, isLoading, handleGuaranteeError } = - useGuaranteeBooking(confirmationNumber, false, hotelId) + useGuaranteeBooking(refId, false, hotelId) if (isLoading) { return ( @@ -85,7 +85,7 @@ export default function Form() { : undefined writeGlaToSessionStorage("yes", hotelId) guaranteeBooking.mutate({ - confirmationNumber, + refId, language: lang, ...(card && { card }), success: `${guaranteeRedirectUrl}?status=success&RefId=${encodeURIComponent(refId)}`, @@ -105,7 +105,7 @@ export default function Form() { const guaranteeMsg = intl.formatMessage( { defaultMessage: - "By guaranteeing with any of the payment methods available, I accept the terms for this stay and the general Terms & Conditions, and understand Scandic will process my personal data for this stay in accordance with Scandic's Privacy Policy. I accept Scandic requiring a valid credit card during my visit in case anything is left unpaid.", + "I accept the terms for this stay and the general Terms & Conditions, and understand Scandic will process my personal data for this stay in accordance with Scandic's Privacy Policy. ", }, { termsAndConditionsLink: (str) => ( diff --git a/apps/scandic-web/components/HotelReservation/MyStay/utils/mapRoomDetails.ts b/apps/scandic-web/components/HotelReservation/MyStay/utils/mapRoomDetails.ts index 185075a63..fd8e5e395 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/utils/mapRoomDetails.ts +++ b/apps/scandic-web/components/HotelReservation/MyStay/utils/mapRoomDetails.ts @@ -143,6 +143,7 @@ export function mapRoomDetails({ priceType, rate, rateDefinition: booking.rateDefinition, + refId: booking.refId, reservationStatus: booking.reservationStatus, room, roomName: room?.name ?? "", diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/AvailabilityError.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/AvailabilityError.tsx new file mode 100644 index 000000000..faddd310f --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/SelectRate/AvailabilityError.tsx @@ -0,0 +1,38 @@ +"use client" + +import { usePathname, useSearchParams } from "next/navigation" +import { useEffect } from "react" +import { useIntl } from "react-intl" + +import { BookingErrorCodeEnum } from "@/constants/booking" + +import { toast } from "@/components/TempDesignSystem/Toasts" + +export default function AvailabilityError() { + const intl = useIntl() + const pathname = usePathname() + const searchParams = useSearchParams() + + const errorCode = searchParams.get("errorCode") + const hasAvailabilityError = + errorCode === BookingErrorCodeEnum.AvailabilityError + + const errorMessage = intl.formatMessage({ + defaultMessage: + "Unfortunately, one of the rooms you selected is sold out. Please choose another room to proceed.", + }) + + useEffect(() => { + if (!hasAvailabilityError) { + return + } + + toast.error(errorMessage) + + const newParams = new URLSearchParams(searchParams.toString()) + newParams.delete("errorCode") + window.history.replaceState({}, "", `${pathname}?${newParams.toString()}`) + }, [errorMessage, hasAvailabilityError, pathname, searchParams]) + + return null +} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Summary.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Summary.tsx index 3490af59c..07f258ccc 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Summary.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Summary.tsx @@ -3,6 +3,7 @@ import { Fragment } from "react" import { useIntl } from "react-intl" import { Button } from "@scandic-hotels/design-system/Button" +import { IconButton } from "@scandic-hotels/design-system/IconButton" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { dt } from "@/lib/dt" @@ -88,13 +89,13 @@ export default function Summary({ {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} {dt(booking.toDate).locale(lang).format("ddd, D MMM")} ({nights}) - + {rooms.map((room, idx) => { diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Modal.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Modal.tsx index e048de418..8119b0c39 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Modal.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Modal.tsx @@ -7,8 +7,8 @@ import { } from "react-aria-components" import { useIntl } from "react-intl" -import { Button } from "@scandic-hotels/design-system/Button" import { ChipButton } from "@scandic-hotels/design-system/ChipButton" +import { IconButton } from "@scandic-hotels/design-system/IconButton" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { Typography } from "@scandic-hotels/design-system/Typography" @@ -39,9 +39,13 @@ export default function RoomPackageFilterModal() { {intl.formatMessage({ defaultMessage: "Special needs" })} - +
    setIsOpen(false)} /> diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/index.tsx index d16c67147..cc4095c9f 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/index.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/index.tsx @@ -1,5 +1,5 @@ "use client" -import { Button as AriaButton } from "react-aria-components" +import { Button as ButtonRAC } from "react-aria-components" import { useMediaQuery } from "usehooks-ts" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" @@ -69,12 +69,12 @@ export default function RoomPackageFilter() { color="CurrentColor" /> {pkg.description} - deleteSelectedPackage(pkg.code)} className={styles.removeButton} > - + ))} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/index.tsx index ac63d5361..83250b678 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/index.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/index.tsx @@ -12,6 +12,7 @@ import { setLang } from "@/i18n/serverContext" import { getHotelSearchDetails } from "@/utils/hotelSearchDetails" import { convertSearchParamsToObj } from "@/utils/url" +import AvailabilityError from "./AvailabilityError" import { getValidDates } from "./getValidDates" import { getTracking } from "./tracking" @@ -90,6 +91,8 @@ export default async function SelectRatePage({ hotelInfo={hotelsTrackingData} /> + + ) } diff --git a/apps/scandic-web/components/Lightbox/FullView/fullView.module.css b/apps/scandic-web/components/Lightbox/FullView/fullView.module.css new file mode 100644 index 000000000..1985516e1 --- /dev/null +++ b/apps/scandic-web/components/Lightbox/FullView/fullView.module.css @@ -0,0 +1,126 @@ +.fullViewContainer { + background-color: var(--UI-Text-High-contrast); + height: 100%; + padding: var(--Spacing-x3) var(--Spacing-x2); + position: relative; + align-items: center; + justify-items: center; + display: grid; + grid-template-rows: auto 1fr auto; + grid-template-columns: 1fr; + place-content: center; + gap: var(--Spacing-x5); +} + +.closeButton { + position: absolute; + top: var(--Space-x2); + right: var(--Space-x2); + z-index: 1; +} + +.header { + display: flex; + justify-content: center; + width: 100%; +} + +.imageCount { + background-color: var(--Overlay-90); + padding: var(--Space-x025) var(--Space-x05); + border-radius: var(--Corner-radius-Small); + color: var(--Text-Inverted); +} + +.imageContainer { + position: relative; + width: 100%; + height: 100%; + max-height: 25rem; + margin-bottom: var(--Spacing-x5); +} + +.imageWrapper { + position: absolute; + width: 100%; + height: 100%; +} + +.image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.footer { + color: var(--Text-Inverted); + position: absolute; + bottom: calc(-1 * var(--Spacing-x5)); +} + +@media screen and (max-width: 767px) { + .navigationButton { + display: none; + } +} + +@media screen and (min-width: 768px) and (max-width: 1366px) { + .fullViewContainer { + padding: var(--Spacing-x5); + } + + .imageContainer { + height: 100%; + max-height: 560px; + } +} + +@media screen and (min-width: 768px) { + .closeButton { + position: fixed; + top: var(--Spacing-x-one-and-half); + right: var(--Spacing-x-half); + } + + .fullViewContainer { + margin-top: 0; + padding: var(--Spacing-x5); + grid-template-rows: auto 1fr auto; + width: 100%; + height: 100%; + } + + .imageContainer { + width: 70%; + max-width: 1454px; + max-height: 700px; + } + + .navigationButton { + position: absolute; + top: 50%; + transform: translateY(-50%); + background-color: var(--Component-Button-Inverted-Fill-Default); + color: var(--Component-Button-Inverted-On-fill-Default); + border-radius: var(--Corner-radius-rounded); + padding: 10px; + cursor: pointer; + border-width: 0; + display: flex; + z-index: 1; + box-shadow: 0px 0px 8px 1px #0000001a; + + &:hover { + background-color: var(--Component-Button-Inverted-Fill-Hover); + color: var(--Component-Button-Inverted-On-fill-Hover); + } + } + + .fullViewNextButton { + right: var(--Spacing-x5); + } + + .fullViewPrevButton { + left: var(--Spacing-x5); + } +} diff --git a/apps/scandic-web/components/Lightbox/FullView.tsx b/apps/scandic-web/components/Lightbox/FullView/index.tsx similarity index 65% rename from apps/scandic-web/components/Lightbox/FullView.tsx rename to apps/scandic-web/components/Lightbox/FullView/index.tsx index 00cc3c44f..768522bce 100644 --- a/apps/scandic-web/components/Lightbox/FullView.tsx +++ b/apps/scandic-web/components/Lightbox/FullView/index.tsx @@ -2,15 +2,15 @@ import { AnimatePresence, motion } from "framer-motion" import { useState } from "react" +import { useIntl } from "react-intl" +import { IconButton } from "@scandic-hotels/design-system/IconButton" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" +import { Typography } from "@scandic-hotels/design-system/Typography" import Image from "@/components/Image" -import Button from "@/components/TempDesignSystem/Button" -import Body from "@/components/TempDesignSystem/Text/Body" -import Caption from "@/components/TempDesignSystem/Text/Caption" -import styles from "./Lightbox.module.css" +import styles from "./fullView.module.css" import type { FullViewProps } from "@/types/components/lightbox/lightbox" @@ -23,6 +23,7 @@ export default function FullView({ totalImages, hideLabel, }: FullViewProps) { + const intl = useIntl() const [animateLeft, setAnimateLeft] = useState(true) function handleSwipe(offset: number) { @@ -54,29 +55,26 @@ export default function FullView({ return (
    - -
    - - + + +
    + + {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} {`${currentIndex + 1} / ${totalImages}`} - - + +
    -
    +
    handleSwipe(info.offset.x)} > @@ -95,14 +93,14 @@ export default function FullView({ fill sizes="(min-width: 1500px) 1500px, 100vw" src={image.src} - style={{ objectFit: "cover" }} + className={styles.image} /> -
    - {image.caption && !hideLabel && ( - {image.caption} - )} -
    + {image.caption && !hideLabel ? ( + +

    {image.caption}

    +
    + ) : null}
    @@ -112,8 +110,8 @@ export default function FullView({ onClick={handlePrev} > @@ -121,7 +119,7 @@ export default function FullView({ className={`${styles.navigationButton} ${styles.fullViewNextButton}`} onClick={handleNext} > - +
    ) diff --git a/apps/scandic-web/components/Lightbox/Gallery/gallery.module.css b/apps/scandic-web/components/Lightbox/Gallery/gallery.module.css new file mode 100644 index 000000000..b8212ca5c --- /dev/null +++ b/apps/scandic-web/components/Lightbox/Gallery/gallery.module.css @@ -0,0 +1,160 @@ +.galleryContainer { + display: grid; + gap: var(--Space-x2); + padding: var(--Space-x2); + height: 100%; + overflow-y: auto; + background-color: var(--Base-Background-Primary-Normal); +} + +.mobileGallery { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--Space-x1); + padding-bottom: var(--Space-x3); +} + +.thumbnailContainer { + position: relative; + height: 242px; +} + +.fullWidthImage { + grid-column: 1 / -1; + height: 240px; +} + +.imageButton { + position: relative; + height: 100%; + width: 100%; + padding: 0; + border-width: 0; + background-color: transparent; + cursor: pointer; + border-radius: var(--Corner-radius-Medium); + overflow: hidden; + z-index: 0; + + &:focus-visible { + outline-offset: -2px; /* Adjust the outline offset as wrappers uses overflow-hidden */ + } +} + +.image { + transition: opacity 0.3s ease-in-out; + object-fit: cover; + z-index: -1; +} + +@media screen and (max-width: 767px) { + .desktopCloseIcon, + .desktopGallery { + display: none; + } + + .closeButton { + justify-self: start; + } +} + +@media screen and (min-width: 768px) { + .mobileGallery, + .mobileCloseIcon { + display: none; + } + + .galleryContainer { + padding: var(--Spacing-x5) var(--Spacing-x6); + } + + .closeButton { + position: absolute; + top: var(--Space-x2); + right: var(--Space-x2); + z-index: 1; + } + + .desktopGallery { + display: grid; + grid-template-rows: 28px 1fr 7.8125rem; + row-gap: var(--Spacing-x-one-and-half); + background-color: var(--Base-Background-Primary-Normal); + height: 100%; + position: relative; + overflow: hidden; + } + + .galleryHeader { + display: flex; + align-items: center; + } + + .imageCaption { + background-color: var(--Base-Surface-Subtle-Normal); + padding: var(--Spacing-x-half) var(--Spacing-x1); + border-radius: var(--Corner-radius-Small); + color: var(--Text-Secondary); + } + + .mainImageWrapper { + position: relative; + width: 100%; + } + + .mainImageContainer { + width: 100%; + height: 100%; + will-change: transform; + position: absolute; + } + + .desktopThumbnailGrid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: var(--Spacing-x1); + max-height: 7.8125rem; + overflow: hidden; + } + + .thumbnailContainer { + height: 125px; + } + + .fullWidthImage { + grid-column: auto; + height: auto; + } + + .thumbnailContainer img { + border-radius: var(--Corner-radius-Small); + } + + .navigationButton { + position: absolute; + top: 50%; + transform: translateY(-50%); + background-color: var(--Component-Button-Inverted-Fill-Default); + color: var(--Component-Button-Inverted-On-fill-Default); + border-radius: var(--Corner-radius-rounded); + padding: 10px; + cursor: pointer; + border-width: 0; + display: flex; + z-index: 1; + box-shadow: 0px 0px 8px 1px #0000001a; + + &:hover { + background-color: var(--Component-Button-Inverted-Fill-Hover); + color: var(--Component-Button-Inverted-On-fill-Hover); + } + } + + .galleryPrevButton { + left: var(--Spacing-x2); + } + + .galleryNextButton { + right: var(--Spacing-x2); + } +} diff --git a/apps/scandic-web/components/Lightbox/Gallery.tsx b/apps/scandic-web/components/Lightbox/Gallery/index.tsx similarity index 58% rename from apps/scandic-web/components/Lightbox/Gallery.tsx rename to apps/scandic-web/components/Lightbox/Gallery/index.tsx index 5d8868376..9368951bf 100644 --- a/apps/scandic-web/components/Lightbox/Gallery.tsx +++ b/apps/scandic-web/components/Lightbox/Gallery/index.tsx @@ -1,15 +1,16 @@ "use client" import { AnimatePresence, motion } from "framer-motion" import { useState } from "react" +import { Button as ButtonRAC } from "react-aria-components" import { useIntl } from "react-intl" +import { IconButton } from "@scandic-hotels/design-system/IconButton" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" +import { Typography } from "@scandic-hotels/design-system/Typography" import Image from "@/components/Image" -import Button from "@/components/TempDesignSystem/Button" -import Caption from "@/components/TempDesignSystem/Text/Caption" -import styles from "./Lightbox.module.css" +import styles from "./gallery.module.css" import type { GalleryProps } from "@/types/components/lightbox/lightbox" @@ -61,36 +62,38 @@ export default function Gallery({ return (
    - + + {/* Desktop Gallery */}
    -
    - {mainImage.caption && !hideLabel && ( -
    - {mainImage.caption} -
    - )} -
    + +

    + {mainImage.caption && !hideLabel && ( + {mainImage.caption} + )} +

    +
    - {mainImage.alt} + + {mainImage.alt} + - + - +
    @@ -139,19 +142,26 @@ export default function Gallery({ onSelectImage(image)} initial={{ opacity: 0, x: 50 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -50 }} transition={{ duration: 0.2, delay: index * 0.05 }} > - {image.alt} + onSelectImage(image)} + aria-label={intl.formatMessage({ + defaultMessage: "Open image", + })} + > + {image.alt} + ))} @@ -160,31 +170,32 @@ export default function Gallery({ {/* Mobile Gallery */}
    -
    -
    - {images.map((image, index) => ( - { - onSelectImage(image) - onImageClick() - }} - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ duration: 0.3, delay: index * 0.05 }} - > - {image.alt} - - ))} -
    -
    + {images.map((image, index) => ( + + { + onSelectImage(image) + onImageClick() + }} + > + {image.alt} + + + ))}
    ) diff --git a/apps/scandic-web/components/Lightbox/Lightbox.module.css b/apps/scandic-web/components/Lightbox/Lightbox.module.css deleted file mode 100644 index 22f98fe5c..000000000 --- a/apps/scandic-web/components/Lightbox/Lightbox.module.css +++ /dev/null @@ -1,348 +0,0 @@ -@keyframes darken-background { - from { - background-color: rgba(0, 0, 0, 0); - } - - to { - background-color: rgba(0, 0, 0, 0.5); - } -} - -.mobileGallery { - height: 100%; - position: relative; - display: flex; - flex-direction: column; - gap: var(--Spacing-x2); -} - -.closeButton { - justify-content: flex-start; - width: fit-content; -} -.closeButton .desktopCloseIcon { - display: none; -} - -.mobileGalleryContent { - display: block; -} - -.fullViewCloseButton { - position: absolute; - top: var(--Spacing-x-one-and-half); - right: var(--Spacing-x-half); - z-index: 1; -} - -.fullViewCloseButton:hover .fullViewCloseIcon { - background-color: var(--UI-Text-Medium-contrast); - border-radius: 50%; -} - -.leftTransformIcon { - transform: scaleX(-1); -} - -.content { - width: 100%; - height: 100%; - border-radius: 0; - position: fixed; - top: 50%; - left: 50%; - z-index: var(--lightbox-z-index); -} - -.overlay { - position: fixed; - inset: 0; - background-color: rgba(0, 0, 0, 0.5); - z-index: var(--lightbox-z-index); -} - -.overlay[data-entering] { - animation: darken-background 0.2s; -} - -.overlay[data-exiting] { - animation: darken-background 0.2s reverse; -} - -.galleryContainer { - background-color: var(--Base-Background-Primary-Normal); - padding: var(--Spacing-x2); - height: 100%; - display: flex; - flex-direction: column; - position: relative; - overflow-y: auto; -} - -.galleryHeader { - display: flex; - justify-content: space-between; - align-items: center; - height: 1.71875rem; -} - -.desktopGallery, -.desktopThumbnailGrid, -.navigationButton { - display: none; -} - -.imageCaption { - background-color: var(--Base-Surface-Subtle-Normal); - padding: var(--Spacing-x-half) var(--Spacing-x1); - border-radius: var(--Corner-radius-Small); -} - -.mainImageWrapper { - position: relative; - width: 100%; -} - -.mainImageContainer { - width: 100%; - height: 100%; - will-change: transform; - overflow: hidden; - position: absolute; -} - -.mainImageContainer img, -.thumbnailContainer img { - border-radius: var(--Corner-radius-Small); - cursor: pointer; - transition: opacity 0.3s ease-in-out; -} - -.thumbnailGrid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--Spacing-x1); - max-height: none; - padding: var(--Spacing-x3) 0; -} - -.thumbnailContainer { - position: relative; - height: 242px; -} - -.fullWidthImage { - grid-column: 1 / -1; - height: 240px; -} - -.thumbnailContainer img { - border-radius: var(--Corner-radius-Medium); -} - -.fullViewContainer { - background-color: var(--UI-Text-High-contrast); - height: 100%; - padding: var(--Spacing-x2); - position: relative; - align-items: center; - justify-items: center; - display: grid; - grid-template-rows: auto 1fr auto; - grid-template-columns: 1fr; - place-content: center; - gap: var(--Spacing-x5); -} - -.fullViewHeader { - display: flex; - justify-content: center; - width: 100%; -} - -.fullViewImageContainer { - position: relative; - width: 100%; - height: 100%; - max-height: 25rem; - margin-bottom: var(--Spacing-x5); -} - -.fullViewImage { - position: absolute; - width: 100%; - height: 100%; - border-radius: var(--Corner-radius-Medium); -} - -.fullViewImageContainer img { - border-radius: var(--Corner-radius-Medium); - width: 100%; - height: 100%; -} - -.fullViewFooter { - position: absolute; - bottom: calc(-1 * var(--Spacing-x5)); -} - -.imagePosition { - background-color: var(--UI-Grey-90); - padding: var(--Spacing-x-quarter) var(--Spacing-x-half); - border-radius: var(--Corner-radius-Small); -} - -.portraitImage { - max-width: 548px; -} - -.image { - object-fit: cover; -} -@media (min-width: 768px) and (max-width: 1366px) { - .fullViewContainer { - padding: var(--Spacing-x5); - } - - .fullViewImageContainer { - height: 100%; - max-height: 35rem; - } -} - -@media (min-width: 768px) { - .mobileGallery, - .thumbnailGrid { - display: none; - } - - .content { - position: fixed; - top: 50%; - left: 50%; - overflow: hidden; - } - - .content:not(.fullViewContent) { - border-radius: var(--Corner-radius-Large); - } - - .galleryContent { - width: 1090px; - width: min(var(--max-width-page), 1090px); - height: min(725px, 85dvh); - } - - .fullViewContent { - width: 100vw; - height: 100vh; - } - - .galleryContainer { - padding: var(--Spacing-x5) var(--Spacing-x6); - } - - .desktopGallery { - display: grid; - grid-template-rows: 1.71875rem 1fr 7.8125rem; - row-gap: var(--Spacing-x-one-and-half); - background-color: var(--Base-Background-Primary-Normal); - height: 100%; - position: relative; - overflow: hidden; - } - - .closeButton { - display: block; - position: absolute; - top: var(--Spacing-x-one-and-half); - right: var(--Spacing-x1); - z-index: 1; - } - - .closeButton .mobileCloseIcon { - display: none; - } - .closeButton .desktopCloseIcon { - display: block; - } - - .closeButton:hover .desktopCloseIcon { - background-color: var(--Base-Surface-Primary-light-Hover-alt); - border-radius: 50%; - } - - .desktopThumbnailGrid { - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: var(--Spacing-x1); - max-height: 7.8125rem; - overflow: hidden; - } - - .thumbnailContainer { - height: 125px; - } - - .fullViewCloseButton { - position: fixed; - top: var(--Spacing-x-one-and-half); - right: var(--Spacing-x-half); - } - - .fullWidthImage { - grid-column: auto; - height: auto; - } - - .thumbnailContainer img { - border-radius: var(--Corner-radius-Small); - } - - .fullViewContainer { - margin-top: 0; - padding: var(--Spacing-x5); - grid-template-rows: auto 1fr auto; - width: 100%; - height: 100%; - } - - .fullViewImageContainer { - width: 70%; - max-width: 90.875rem; - max-height: 43.75rem; - } - - .navigationButton { - position: absolute; - top: 50%; - transform: translateY(-50%); - background-color: var(--Base-Button-Inverted-Fill-Normal); - border-radius: 50%; - padding: var(--Spacing-x1); - cursor: pointer; - border: none; - display: flex; - z-index: 1; - } - - .galleryPrevButton { - left: var(--Spacing-x2); - } - - .galleryNextButton { - right: var(--Spacing-x2); - } - - .fullViewNextButton { - right: var(--Spacing-x5); - } - - .fullViewPrevButton { - left: var(--Spacing-x5); - } - - .fullViewFooter { - text-align: left; - } -} diff --git a/apps/scandic-web/components/Lightbox/index.tsx b/apps/scandic-web/components/Lightbox/index.tsx index 3520dcbd4..1459ac2d8 100644 --- a/apps/scandic-web/components/Lightbox/index.tsx +++ b/apps/scandic-web/components/Lightbox/index.tsx @@ -6,7 +6,7 @@ import { Dialog, Modal, ModalOverlay } from "react-aria-components" import FullView from "./FullView" import Gallery from "./Gallery" -import styles from "./Lightbox.module.css" +import styles from "./lightbox.module.css" import type { LightboxProps } from "@/types/components/lightbox/lightbox" diff --git a/apps/scandic-web/components/Lightbox/lightbox.module.css b/apps/scandic-web/components/Lightbox/lightbox.module.css new file mode 100644 index 000000000..049733103 --- /dev/null +++ b/apps/scandic-web/components/Lightbox/lightbox.module.css @@ -0,0 +1,57 @@ +.overlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: var(--lightbox-z-index); + + &[data-entering] { + animation: darken-background 0.2s; + } + + &[data-exiting] { + animation: darken-background 0.2s reverse; + } +} + +.content { + width: 100%; + height: 100%; + border-radius: 0; + position: fixed; + top: 50%; + left: 50%; + z-index: var(--lightbox-z-index); +} + +@media screen and (min-width: 768px) { + .content { + position: fixed; + top: 50%; + left: 50%; + overflow: hidden; + + &:not(.fullViewContent) { + border-radius: var(--Corner-radius-Large); + } + + &.fullViewContent { + width: 100vw; + height: 100vh; + } + + &.galleryContent { + width: min(var(--max-width-page), 1090px); + height: min(725px, 85dvh); + } + } +} + +@keyframes darken-background { + from { + background-color: rgba(0, 0, 0, 0); + } + + to { + background-color: rgba(0, 0, 0, 0.5); + } +} diff --git a/apps/scandic-web/components/SidePeeks/BookedRoomSidePeek/RoomDetails.tsx b/apps/scandic-web/components/SidePeeks/BookedRoomSidePeek/RoomDetails.tsx index 5dcffa19d..966239d72 100644 --- a/apps/scandic-web/components/SidePeeks/BookedRoomSidePeek/RoomDetails.tsx +++ b/apps/scandic-web/components/SidePeeks/BookedRoomSidePeek/RoomDetails.tsx @@ -19,7 +19,8 @@ export default function RoomDetails({ }: RoomDetailsProps) { const intl = useIntl() - const sortedFacilities = roomFacilities + const filteredSortedFacilities = roomFacilities + .filter((facility) => !!facility.isUniqueSellingPoint) .sort((a, b) => a.sortOrder - b.sortOrder) .map((facility) => { const Icon = @@ -45,14 +46,28 @@ export default function RoomDetails({

      - {sortedFacilities.map(({ name, Icon }) => ( -
    • - {Icon && Icon} - - {name} - -
    • - ))} + {filteredSortedFacilities.map( + ({ name, Icon, availableInAllRooms }) => ( +
    • + {Icon} + + + {availableInAllRooms + ? name + : intl.formatMessage( + { + defaultMessage: + "{facility} (available in some rooms)", + }, + { + facility: name, + } + )} + + +
    • + ) + )}
    diff --git a/apps/scandic-web/constants/booking.ts b/apps/scandic-web/constants/booking.ts index 8f365fad8..9c156369e 100644 --- a/apps/scandic-web/constants/booking.ts +++ b/apps/scandic-web/constants/booking.ts @@ -37,8 +37,6 @@ export enum ChildBedTypeEnum { export const REDEMPTION = "redemption" export const SEARCHTYPE = "searchtype" -export const BOOKING_CONFIRMATION_NUMBER = "confirmationNumber" - export const MEMBERSHIP_FAILED_ERROR = "MembershipFailedError" export enum PaymentMethodEnum { diff --git a/apps/scandic-web/hooks/booking/useGuaranteeBooking.ts b/apps/scandic-web/hooks/booking/useGuaranteeBooking.ts index 21f4205ed..ed0b9b82b 100644 --- a/apps/scandic-web/hooks/booking/useGuaranteeBooking.ts +++ b/apps/scandic-web/hooks/booking/useGuaranteeBooking.ts @@ -13,7 +13,7 @@ const maxRetries = 15 const retryInterval = 2000 export function useGuaranteeBooking( - confirmationNumber: string, + refId: string, isAncillaryFlow = false, hotelId: string ) { @@ -51,10 +51,10 @@ export function useGuaranteeBooking( onSuccess: (result) => { if (result) { if (result.reservationStatus == BookingStatusEnum.BookingCompleted) { - utils.booking.get.invalidate({ confirmationNumber }) + utils.booking.confirmation.invalidate({ refId }) } else { setIsPollingForBookingStatus(true) - utils.booking.status.invalidate({ confirmationNumber }) + utils.booking.status.invalidate({ refId }) } } else { handleGuaranteeError() @@ -66,7 +66,7 @@ export function useGuaranteeBooking( }) const bookingStatus = useHandleBookingStatus({ - confirmationNumber, + refId, expectedStatuses: [BookingStatusEnum.BookingCompleted], maxRetries, retryInterval, @@ -76,7 +76,7 @@ export function useGuaranteeBooking( useEffect(() => { if (bookingStatus?.data?.paymentUrl && isPollingForBookingStatus) { router.push(bookingStatus.data.paymentUrl) - utils.booking.get.invalidate({ confirmationNumber }) + utils.booking.confirmation.invalidate({ refId }) setIsPollingForBookingStatus(false) } else if (bookingStatus.isTimeout) { handleGuaranteeError("Timeout") @@ -87,8 +87,8 @@ export function useGuaranteeBooking( handleGuaranteeError, setIsPollingForBookingStatus, isPollingForBookingStatus, - confirmationNumber, - utils.booking.get, + refId, + utils.booking.confirmation, ]) const isLoading = diff --git a/apps/scandic-web/hooks/booking/useHandleBookingStatus.ts b/apps/scandic-web/hooks/booking/useHandleBookingStatus.ts index c47d01148..f6220b31e 100644 --- a/apps/scandic-web/hooks/booking/useHandleBookingStatus.ts +++ b/apps/scandic-web/hooks/booking/useHandleBookingStatus.ts @@ -7,13 +7,13 @@ import { trpc } from "@/lib/trpc/client" import type { BookingStatusEnum } from "@/constants/booking" export function useHandleBookingStatus({ - confirmationNumber, + refId, expectedStatuses, maxRetries, retryInterval, enabled, }: { - confirmationNumber: string | null + refId: string expectedStatuses: BookingStatusEnum[] maxRetries: number retryInterval: number @@ -22,7 +22,7 @@ export function useHandleBookingStatus({ const retries = useRef(0) const query = trpc.booking.status.useQuery( - { confirmationNumber: confirmationNumber ?? "" }, + { refId }, { enabled, refetchInterval: (query) => { diff --git a/apps/scandic-web/hooks/useStickyPosition.ts b/apps/scandic-web/hooks/useStickyPosition.ts index 8cc9a4eef..e69eb45ed 100644 --- a/apps/scandic-web/hooks/useStickyPosition.ts +++ b/apps/scandic-web/hooks/useStickyPosition.ts @@ -24,7 +24,7 @@ let resizeObserver: ResizeObserver | null = null * This hook registers an element as sticky, calculates its top offset based on * other registered sticky elements, and updates the element's position dynamically. * - * @param {UseStickyPositionProps} props - The properties for configuring the hook. + * @param {UseStickyPositionProps} [props] - The properties for configuring the hook. * @param {React.RefObject} [props.ref] - A reference to the HTML element that should be sticky. Is optional to allow for other components to only get the height of the sticky elements. * @param {StickyElementNameEnum} [props.name] - A unique name for the sticky element, used for tracking. * @param {string} [props.group] - An optional group identifier to make multiple elements share the same top offset. Defaults to the name if not provided. @@ -37,7 +37,7 @@ export default function useStickyPosition({ ref, name, group, -}: UseStickyPositionProps) { +}: UseStickyPositionProps = {}) { const { registerSticky, unregisterSticky, diff --git a/apps/scandic-web/lib/api/index.ts b/apps/scandic-web/lib/api/index.ts index 5fc1173c1..532750f9e 100644 --- a/apps/scandic-web/lib/api/index.ts +++ b/apps/scandic-web/lib/api/index.ts @@ -100,14 +100,19 @@ export async function put( export async function remove( endpoint: Endpoint | `${Endpoint}/${string}`, - options: RequestOptionsWithOutBody, + 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, { method: "DELETE" }, options]) + merge.all([ + defaultOptions, + { body: JSON.stringify(body), method: "DELETE" }, + requestOptions, + ]) ) } diff --git a/apps/scandic-web/lib/trpc/memoizedRequests/index.ts b/apps/scandic-web/lib/trpc/memoizedRequests/index.ts index 05040e27c..7e0ffcba5 100644 --- a/apps/scandic-web/lib/trpc/memoizedRequests/index.ts +++ b/apps/scandic-web/lib/trpc/memoizedRequests/index.ts @@ -1,3 +1,4 @@ +import { getHotel as _getHotel } from "@/server/routers/hotels/utils" import { isDefined } from "@/server/utils" import { getLang } from "@/i18n/serverContext" @@ -17,7 +18,6 @@ import type { HotelInput, } from "@/types/trpc/routers/hotel/hotel" import type { Lang } from "@/constants/languages" -import type { LinkedReservationsInput } from "@/server/routers/booking/input" import type { GetHotelsByCSFilterInput } from "@/server/routers/hotels/input" import type { GetSavedPaymentCardsInput } from "@/server/routers/user/input" @@ -136,8 +136,11 @@ export const getPackages = cache(async function getMemoizedPackages( }) export const getBookingConfirmation = cache( - async function getMemoizedBookingConfirmation(confirmationNumber: string) { - return serverClient().booking.get({ confirmationNumber }) + async function getMemoizedBookingConfirmation(refId: string, lang: Lang) { + return serverClient().booking.confirmation({ + refId, + lang, + }) } ) @@ -156,8 +159,11 @@ export const findBooking = cache(async function getMemoizedFindBooking( }) export const getLinkedReservations = cache( - async function getMemoizedLinkedReservations(input: LinkedReservationsInput) { - return serverClient().booking.linkedReservations(input) + async function getMemoizedLinkedReservations(refId: string, lang: Lang) { + return serverClient().booking.linkedReservations({ + refId, + lang, + }) } ) diff --git a/apps/scandic-web/next.config.js b/apps/scandic-web/next.config.js index 366e8c017..f6257a0be 100644 --- a/apps/scandic-web/next.config.js +++ b/apps/scandic-web/next.config.js @@ -285,11 +285,6 @@ const nextConfig = { source: `${myPages.sv}/:path*`, destination: `/sv/my-pages/:path*`, }, - { - source: "/:lang/hotelreservation/payment-callback/:status", - destination: - "/:lang/hotelreservation/payment-callback?status=:status", - }, // Find my booking { source: findMyBooking.en, diff --git a/apps/scandic-web/providers/MyStay.tsx b/apps/scandic-web/providers/MyStay.tsx index 6a3fa6e2c..e841344de 100644 --- a/apps/scandic-web/providers/MyStay.tsx +++ b/apps/scandic-web/providers/MyStay.tsx @@ -1,9 +1,10 @@ "use client" + import { notFound } from "next/navigation" import { use, useRef } from "react" import { useIntl } from "react-intl" -import { trpc } from "@/lib/trpc/client" +import { type RouterOutput, trpc } from "@/lib/trpc/client" import { createMyStayStore } from "@/stores/my-stay" import { MyStaySkeleton } from "@/components/HotelReservation/MyStay/myStaySkeleton" @@ -12,10 +13,7 @@ import { MyStayContext } from "@/contexts/MyStay" import type { Packages } from "@/types/components/myPages/myStay/ancillaries" import type { MyStayStore } from "@/types/contexts/my-stay" import type { RoomCategories } from "@/types/hotel" -import type { - BookingConfirmation, - BookingConfirmationSchema, -} from "@/types/trpc/routers/booking/confirmation" +import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" import type { CreditCard } from "@/types/user" import type { Lang } from "@/constants/languages" @@ -23,7 +21,9 @@ interface MyStayProviderProps { bookingConfirmation: BookingConfirmation breakfastPackages: Packages | null lang: Lang - linkedReservationsPromise: Promise + linkedReservationsPromise: Promise< + RouterOutput["booking"]["linkedReservations"] + > refId: string roomCategories: RoomCategories savedCreditCards: CreditCard[] | null @@ -39,13 +39,14 @@ export default function MyStayProvider({ roomCategories, savedCreditCards, }: React.PropsWithChildren) { - const storeRef = useRef() const intl = useIntl() + const storeRef = useRef() + const { data, error, isFetching, isFetchedAfterMount } = - trpc.booking.get.useQuery( + trpc.booking.confirmation.useQuery( { - confirmationNumber: bookingConfirmation.booking.confirmationNumber, + refId, lang, }, { @@ -68,7 +69,7 @@ export default function MyStayProvider({ } = trpc.booking.linkedReservations.useQuery( { lang, - rooms: bookingConfirmation.booking.linkedReservations, + refId, }, { initialData: linkedReservationsResponses, @@ -85,15 +86,16 @@ export default function MyStayProvider({ return notFound() } - const rooms = [data.booking, ...linkedReservations] + const rooms = [data.booking].concat(linkedReservations ?? []) const hasInvalidatedQueryAndRefetched = (isFetchedAfterMount && data) || (linkedReservationsIsFetchedAfterMount && linkedReservations) + if (!storeRef.current || hasInvalidatedQueryAndRefetched) { storeRef.current = createMyStayStore({ breakfastPackages, - hotel: bookingConfirmation.hotel, + hotel: bookingConfirmation.hotelData.hotel, intl, refId, roomCategories, diff --git a/apps/scandic-web/server/routers/booking/input.ts b/apps/scandic-web/server/routers/booking/input.ts index c9323d87c..631d5da62 100644 --- a/apps/scandic-web/server/routers/booking/input.ts +++ b/apps/scandic-web/server/routers/booking/input.ts @@ -103,7 +103,7 @@ export const createBookingInput = z.object({ }) export const addPackageInput = z.object({ - confirmationNumber: z.string(), + refId: z.string(), ancillaryComment: z.string(), ancillaryDeliveryTime: z.string().nullish(), packages: z.array( @@ -117,27 +117,22 @@ export const addPackageInput = z.object({ }) export const removePackageInput = z.object({ - confirmationNumber: z.string(), + refId: z.string(), codes: z.array(z.string()), language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]), }) export const priceChangeInput = z.object({ - confirmationNumber: z.string(), + refId: z.string(), }) -export const cancelBookingInput = z.object({ - confirmationNumber: z.string(), - language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]), -}) - -export const cancelManyBookingsInput = z.object({ - confirmationNumbers: z.array(z.string()), - language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]), +export const cancelBookingsInput = z.object({ + refIds: z.array(z.string()), + lang: z.nativeEnum(Lang), }) export const guaranteeBookingInput = z.object({ - confirmationNumber: z.string(), + refId: z.string(), card: z .object({ alias: z.string(), @@ -161,7 +156,7 @@ export const createRefIdInput = z.object({ }) export const updateBookingInput = z.object({ - confirmationNumber: z.string(), + refId: z.string(), checkInDate: z.string().optional(), checkOutDate: z.string().optional(), guest: z @@ -173,20 +168,14 @@ export const updateBookingInput = z.object({ .optional(), }) -// Query -const confirmationNumberInput = z.object({ - confirmationNumber: z.string(), +export const bookingConfirmationInput = z.object({ + refId: z.string(), lang: z.nativeEnum(Lang).optional(), }) -export const getBookingInput = confirmationNumberInput export const getLinkedReservationsInput = z.object({ + refId: z.string(), lang: z.nativeEnum(Lang).optional(), - rooms: z.array( - z.object({ - confirmationNumber: z.string(), - }) - ), }) export const findBookingInput = z.object({ @@ -199,4 +188,15 @@ export const findBookingInput = z.object({ export type LinkedReservationsInput = z.input -export const getBookingStatusInput = confirmationNumberInput +export const getBookingStatusInput = z.object({ + refId: z.string(), +}) + +export const getBookingConfirmationErrorInput = z.object({ + refId: z.string(), +}) + +export const getConfirmationCompletedInput = z.object({ + refId: z.string(), + lang: z.nativeEnum(Lang), +}) diff --git a/apps/scandic-web/server/routers/booking/mutation.ts b/apps/scandic-web/server/routers/booking/mutation.ts index f0d15af5a..c7c04076f 100644 --- a/apps/scandic-web/server/routers/booking/mutation.ts +++ b/apps/scandic-web/server/routers/booking/mutation.ts @@ -1,19 +1,21 @@ import * as api from "@/lib/api" import { getMembershipNumber } from "@/server/routers/user/utils" import { createCounter } from "@/server/telemetry" +import { getUserOrServiceToken } from "@/server/tokenManager" import { router, safeProtectedServiceProcedure } from "@/server/trpc" +import { parseRefId } from "@/utils/refId" + import { addPackageInput, - cancelBookingInput, - cancelManyBookingsInput, + cancelBookingsInput, createBookingInput, guaranteeBookingInput, priceChangeInput, removePackageInput, updateBookingInput, } from "./input" -import { bookingConfirmationSchema, createBookingSchema } from "./output" +import { bookingSchema, createBookingSchema } from "./output" import { cancelBooking } from "./utils" export const bookingMutationRouter = router({ @@ -74,8 +76,17 @@ export const bookingMutationRouter = router({ }), priceChange: safeProtectedServiceProcedure .input(priceChangeInput) + .use(async ({ input, next }) => { + const { confirmationNumber } = parseRefId(input.refId) + + return next({ + ctx: { + confirmationNumber, + }, + }) + }) .mutation(async function ({ ctx, input }) { - const { confirmationNumber } = input + const { confirmationNumber } = ctx const priceChangeCounter = createCounter("trpc.booking", "price-change") const metricsPriceChange = priceChangeCounter.init({ confirmationNumber }) @@ -110,24 +121,29 @@ export const bookingMutationRouter = router({ metricsPriceChange.success() - return verifiedData.data + return verifiedData.data.id }), cancel: safeProtectedServiceProcedure - .input(cancelBookingInput) + .input(cancelBookingsInput) + .use(async ({ input, next }) => { + const confirmationNumbers = input.refIds.map((refId) => { + const { confirmationNumber } = parseRefId(refId) + return confirmationNumber + }) + + return next({ + ctx: { + confirmationNumbers, + }, + }) + }) .mutation(async function ({ ctx, input }) { - const token = ctx.session?.token.access_token ?? ctx.serviceToken - const { confirmationNumber, language } = input - return await cancelBooking(confirmationNumber, language, token) - }), - cancelMany: safeProtectedServiceProcedure - .input(cancelManyBookingsInput) - .mutation(async function ({ ctx, input }) { - const token = ctx.session?.token.access_token ?? ctx.serviceToken - const { confirmationNumbers, language } = input + const { confirmationNumbers } = ctx + const { lang } = input const responses = await Promise.allSettled( confirmationNumbers.map((confirmationNumber) => - cancelBooking(confirmationNumber, language, token) + cancelBooking(confirmationNumber, lang) ) ) @@ -152,10 +168,19 @@ export const bookingMutationRouter = router({ }), packages: safeProtectedServiceProcedure .input(addPackageInput) + .use(async ({ input, next }) => { + const { confirmationNumber } = parseRefId(input.refId) + + return next({ + ctx: { + confirmationNumber, + }, + }) + }) .mutation(async function ({ ctx, input }) { const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken - const { confirmationNumber, ...body } = input - + const { refId, ...body } = input + const { confirmationNumber } = ctx const addPackageCounter = createCounter("trpc.booking", "package.add") const metricsAddPackage = addPackageCounter.init({ confirmationNumber }) @@ -191,10 +216,19 @@ export const bookingMutationRouter = router({ }), guarantee: safeProtectedServiceProcedure .input(guaranteeBookingInput) + .use(async ({ input, next }) => { + const { confirmationNumber } = parseRefId(input.refId) + + return next({ + ctx: { + confirmationNumber, + }, + }) + }) .mutation(async function ({ ctx, input }) { const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken - const { confirmationNumber, language, ...body } = input - + const { refId, language, ...body } = input + const { confirmationNumber } = ctx const guaranteeBookingCounter = createCounter("trpc.booking", "guarantee") const metricsGuaranteeBooking = guaranteeBookingCounter.init({ confirmationNumber, @@ -233,10 +267,16 @@ export const bookingMutationRouter = router({ }), update: safeProtectedServiceProcedure .input(updateBookingInput) + .use(async ({ input, next }) => { + const { confirmationNumber } = parseRefId(input.refId) + return next({ + ctx: { + confirmationNumber, + }, + }) + }) .mutation(async function ({ ctx, input }) { - const accessToken = ctx.session?.token.access_token || ctx.serviceToken - const { confirmationNumber, ...body } = input - + const { confirmationNumber } = ctx const updateBookingCounter = createCounter("trpc.booking", "update") const metricsUpdateBooking = updateBookingCounter.init({ confirmationNumber, @@ -244,12 +284,17 @@ export const bookingMutationRouter = router({ metricsUpdateBooking.start() + const token = getUserOrServiceToken() const apiResponse = await api.put( api.endpoints.v1.Booking.booking(confirmationNumber), { - body, + body: { + checkInDate: input.checkInDate, + checkOutDate: input.checkOutDate, + guest: input.guest, + }, headers: { - Authorization: `Bearer ${accessToken}`, + Authorization: `Bearer ${token}`, }, } ) @@ -261,7 +306,7 @@ export const bookingMutationRouter = router({ const apiJson = await apiResponse.json() - const verifiedData = bookingConfirmationSchema.safeParse(apiJson) + const verifiedData = bookingSchema.safeParse(apiJson) if (!verifiedData.success) { metricsUpdateBooking.validationError(verifiedData.error) return null @@ -269,14 +314,23 @@ export const bookingMutationRouter = router({ metricsUpdateBooking.success() - return verifiedData.data + return verifiedData.data.refId }), removePackage: safeProtectedServiceProcedure .input(removePackageInput) + .use(async ({ input, next }) => { + const { confirmationNumber } = parseRefId(input.refId) + + return next({ + ctx: { + confirmationNumber, + }, + }) + }) .mutation(async function ({ ctx, input }) { const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken - const { confirmationNumber, codes, language } = input - + const { codes, language } = input + const { confirmationNumber } = ctx const removePackageCounter = createCounter( "trpc.booking", "package.remove" @@ -297,7 +351,7 @@ export const bookingMutationRouter = router({ api.endpoints.v1.Booking.packages(confirmationNumber), { headers, - } as RequestInit, + }, [["language", language], ...codes.map((code) => ["codes", code])] ) diff --git a/apps/scandic-web/server/routers/booking/output.ts b/apps/scandic-web/server/routers/booking/output.ts index 072b2681b..df24d20af 100644 --- a/apps/scandic-web/server/routers/booking/output.ts +++ b/apps/scandic-web/server/routers/booking/output.ts @@ -2,6 +2,7 @@ import { z } from "zod" import { BookingStatusEnum, ChildBedTypeEnum } from "@/constants/booking" +import { calculateRefId } from "@/utils/refId" import { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator" import { nullableIntValidator } from "@/utils/zod/numberValidator" import { @@ -78,7 +79,13 @@ export const createBookingSchema = z type: d.data.type, reservationStatus: d.data.attributes.reservationStatus, paymentUrl: d.data.attributes.paymentUrl, - rooms: d.data.attributes.rooms, + rooms: d.data.attributes.rooms.map((room) => { + const lastName = d.data.attributes.guest?.lastName || "" + return { + ...room, + refId: calculateRefId(room.confirmationNumber, lastName), + } + }), errors: d.data.attributes.errors, guest: d.data.attributes.guest, })) @@ -195,7 +202,7 @@ const linksSchema = z.object({ .nullable(), }) -export const bookingConfirmationSchema = z +export const bookingSchema = z .object({ data: z.object({ attributes: z.object({ @@ -248,6 +255,19 @@ export const bookingConfirmationSchema = z }) .transform(({ data }) => ({ ...data.attributes, + refId: calculateRefId( + data.attributes.confirmationNumber, + data.attributes.guest.lastName + ), + linkedReservations: data.attributes.linkedReservations.map( + (linkedReservation) => { + const lastName = data.attributes.guest.lastName + return { + ...linkedReservation, + refId: calculateRefId(linkedReservation.confirmationNumber, lastName), + } + } + ), packages: data.attributes.packages.filter((p) => p.type !== "Ancillary"), ancillaries: data.attributes.packages.filter((p) => p.type === "Ancillary"), extraBedTypes: data.attributes.childBedPreferences, diff --git a/apps/scandic-web/server/routers/booking/query.ts b/apps/scandic-web/server/routers/booking/query.ts index 2c8a518d3..2d84b6c43 100644 --- a/apps/scandic-web/server/routers/booking/query.ts +++ b/apps/scandic-web/server/routers/booking/query.ts @@ -1,3 +1,5 @@ +import { BookingStatusEnum } from "@/constants/booking" +import { bookingConfirmation } from "@/constants/routes/hotelReservation" import * as api from "@/lib/api" import { badRequestError, serverErrorByStatus } from "@/server/errors/trpc" import { createCounter } from "@/server/telemetry" @@ -6,39 +8,47 @@ import { safeProtectedServiceProcedure, serviceProcedure, } from "@/server/trpc" +import { getBookedHotelRoom } from "@/stores/my-stay" + +import { calculateRefId, parseRefId } from "@/utils/refId" import { getHotel } from "../hotels/utils" -import { encrypt } from "../utils/encryption" import { + bookingConfirmationInput, createRefIdInput, findBookingInput, - getBookingInput, + getBookingConfirmationErrorInput, getBookingStatusInput, + getConfirmationCompletedInput, getLinkedReservationsInput, } from "./input" import { createBookingSchema } from "./output" -import { findBooking, getBookedHotelRoom, getBooking } from "./utils" +import { findBooking, getBooking, getLinkedReservations } from "./utils" + +import type { BookingSchema } from "@/types/trpc/routers/booking/confirmation" export const bookingQueryRouter = router({ - get: safeProtectedServiceProcedure - .input(getBookingInput) + confirmation: safeProtectedServiceProcedure + .input(bookingConfirmationInput) .use(async ({ ctx, input, next }) => { const lang = input.lang ?? ctx.lang - const token = ctx.session?.token.access_token ?? ctx.serviceToken + const { confirmationNumber } = parseRefId(input.refId) return next({ ctx: { lang, - token, + confirmationNumber, }, }) }) - .query(async function ({ ctx, input: { confirmationNumber } }) { + .query(async function ({ + ctx: { confirmationNumber, lang, serviceToken }, + }) { const getBookingCounter = createCounter("trpc.booking", "get") const metricsGetBooking = getBookingCounter.init({ confirmationNumber }) metricsGetBooking.start() - const booking = await getBooking(confirmationNumber, ctx.lang, ctx.token) + const booking = await getBooking(confirmationNumber, lang) if (!booking) { metricsGetBooking.dataError( @@ -52,9 +62,9 @@ export const bookingQueryRouter = router({ { hotelId: booking.hotelId, isCardOnlyPayment: false, - language: ctx.lang, + language: lang, }, - ctx.serviceToken + serviceToken ) if (!hotelData) { @@ -68,15 +78,29 @@ export const bookingQueryRouter = router({ throw serverErrorByStatus(404) } + const room = getBookedHotelRoom( + hotelData.roomCategories, + booking.roomTypeCode + ) + + if (!room) { + metricsGetBooking.dataError( + `Failed to extract booked room ${booking.roomTypeCode} from room categories for ${booking.hotelId}`, + { + roomTypeCode: booking.roomTypeCode, + hotelId: booking.hotelId, + } + ) + + throw serverErrorByStatus(404) + } + metricsGetBooking.success() return { - ...hotelData, + hotelData, booking, - room: getBookedHotelRoom( - hotelData.roomCategories, - booking.roomTypeCode - ), + room, } }), findBooking: safeProtectedServiceProcedure @@ -128,109 +152,248 @@ export const bookingQueryRouter = router({ throw serverErrorByStatus(404) } + const room = getBookedHotelRoom( + hotelData.roomCategories, + booking.roomTypeCode + ) + + if (!room) { + metricsFindBooking.dataError( + `Failed to extract booked room ${booking.roomTypeCode} from room categories for ${booking.hotelId}`, + { + roomTypeCode: booking.roomTypeCode, + hotelId: booking.hotelId, + } + ) + + throw serverErrorByStatus(404) + } + metricsFindBooking.success() return { - ...hotelData, + hotelData, booking, - room: getBookedHotelRoom( - hotelData.roomCategories, - booking.roomTypeCode - ), + room, } }), linkedReservations: safeProtectedServiceProcedure .input(getLinkedReservationsInput) .use(async ({ ctx, input, next }) => { const lang = input.lang ?? ctx.lang - const token = ctx.session?.token.access_token ?? ctx.serviceToken + const { confirmationNumber } = parseRefId(input.refId) + return next({ ctx: { lang, - token, + confirmationNumber, }, }) }) - .query(async function ({ ctx, input: { rooms } }) { - const getLinkedReservationsCounter = createCounter( + .query(async function ({ ctx: { confirmationNumber, lang } }) { + const linkedReservationsCounter = createCounter( "trpc.booking", "linkedReservations" ) - const metricsGetLinkedReservations = getLinkedReservationsCounter.init({ - confirmationNumbers: rooms, + const metricsLinkedReservations = linkedReservationsCounter.init({ + confirmationNumber, }) - metricsGetLinkedReservations.start() + metricsLinkedReservations.start() - const linkedReservationsResult = await Promise.allSettled( - rooms.map((room) => - getBooking(room.confirmationNumber, ctx.lang, ctx.token) - ) + const linkedReservations = await getLinkedReservations( + confirmationNumber, + lang ) - const linkedReservations = [] - for (const booking of linkedReservationsResult) { - if (booking.status === "fulfilled") { - if (booking.value) { - linkedReservations.push(booking.value) - } else { - metricsGetLinkedReservations.dataError( - `Unexpected value for linked reservation` - ) - } - } else { - metricsGetLinkedReservations.dataError( - `Failed to get linked reservation` + + if (!linkedReservations) { + metricsLinkedReservations.noDataError() + return null + } + + const validLinkedReservations = linkedReservations.reduce< + BookingSchema[] + >((acc, linkedReservation) => { + if ("error" in linkedReservation) { + metricsLinkedReservations.dataError( + `Failed to get linked reservations ${linkedReservation.confirmationNumber}`, + { + linkedReservationConfirmationNumber: + linkedReservation.confirmationNumber, + } ) + return acc } - } - metricsGetLinkedReservations.success() + acc.push(linkedReservation) + return acc + }, []) - return linkedReservations + metricsLinkedReservations.success() + + return validLinkedReservations }), - status: serviceProcedure.input(getBookingStatusInput).query(async function ({ - ctx, - input, - }) { - const { confirmationNumber } = input + status: serviceProcedure + .input(getBookingStatusInput) + .use(async ({ input, next }) => { + const { confirmationNumber } = parseRefId(input.refId) - const getBookingStatusCounter = createCounter("trpc.booking", "status") - const metricsGetBookingStatus = getBookingStatusCounter.init({ - confirmationNumber, - }) - - metricsGetBookingStatus.start() - - const apiResponse = await api.get( - api.endpoints.v1.Booking.status(confirmationNumber), - { - headers: { - Authorization: `Bearer ${ctx.serviceToken}`, + return next({ + ctx: { + confirmationNumber, }, + }) + }) + .query(async function ({ ctx: { confirmationNumber, serviceToken } }) { + const getBookingStatusCounter = createCounter("trpc.booking", "status") + const metricsGetBookingStatus = getBookingStatusCounter.init({ + confirmationNumber, + }) + + metricsGetBookingStatus.start() + + const apiResponse = await api.get( + api.endpoints.v1.Booking.status(confirmationNumber), + { + headers: { + Authorization: `Bearer ${serviceToken}`, + }, + } + ) + + if (!apiResponse.ok) { + await metricsGetBookingStatus.httpError(apiResponse) + throw serverErrorByStatus(apiResponse.status, apiResponse) } - ) - if (!apiResponse.ok) { - await metricsGetBookingStatus.httpError(apiResponse) - throw serverErrorByStatus(apiResponse.status, apiResponse) - } + const apiJson = await apiResponse.json() + const verifiedData = createBookingSchema.safeParse(apiJson) + if (!verifiedData.success) { + metricsGetBookingStatus.validationError(verifiedData.error) + throw badRequestError() + } - const apiJson = await apiResponse.json() - const verifiedData = createBookingSchema.safeParse(apiJson) - if (!verifiedData.success) { - metricsGetBookingStatus.validationError(verifiedData.error) - throw badRequestError() - } + metricsGetBookingStatus.success() - metricsGetBookingStatus.success() + return verifiedData.data + }), + + confirmationCompleted: serviceProcedure + .input(getConfirmationCompletedInput) + .use(async ({ input, next }) => { + const { confirmationNumber } = parseRefId(input.refId) + + return next({ + ctx: { + confirmationNumber, + }, + }) + }) + .query(async function ({ ctx, input }) { + const { confirmationNumber } = ctx + + const confirmationCompletedCounter = createCounter( + "trpc.booking", + "confirmationCompleted" + ) + const metricsConfirmationCompleted = confirmationCompletedCounter.init({ + confirmationNumber, + }) + + metricsConfirmationCompleted.start() + + const apiResponse = await api.get( + api.endpoints.v1.Booking.status(confirmationNumber), + { + headers: { + Authorization: `Bearer ${ctx.serviceToken}`, + }, + } + ) + + if (!apiResponse.ok) { + await metricsConfirmationCompleted.httpError(apiResponse) + throw serverErrorByStatus(apiResponse.status, apiResponse) + } + + const apiJson = await apiResponse.json() + const verifiedData = createBookingSchema.safeParse(apiJson) + if (!verifiedData.success) { + metricsConfirmationCompleted.validationError(verifiedData.error) + throw badRequestError() + } + + const confirmationUrl = + verifiedData.data.reservationStatus === + BookingStatusEnum.BookingCompleted + ? `${bookingConfirmation(input.lang)}?RefId=${verifiedData.data.rooms[0].refId}` + : "" + + const result = { + ...verifiedData.data, + redirectUrl: verifiedData.data.paymentUrl || confirmationUrl, + } + + metricsConfirmationCompleted.success() + + return result + }), + + confirmationError: serviceProcedure + .input(getBookingConfirmationErrorInput) + .use(async ({ input, next }) => { + const { confirmationNumber } = parseRefId(input.refId) + + return next({ + ctx: { + confirmationNumber, + }, + }) + }) + .query(async function ({ ctx }) { + const { confirmationNumber } = ctx + + const confirmationErrorCounter = createCounter( + "trpc.booking", + "confirmationError" + ) + const metricsConfirmationError = confirmationErrorCounter.init({ + confirmationNumber, + }) + + metricsConfirmationError.start() + + const apiResponse = await api.get( + api.endpoints.v1.Booking.status(confirmationNumber), + { + headers: { + Authorization: `Bearer ${ctx.serviceToken}`, + }, + } + ) + + if (!apiResponse.ok) { + await metricsConfirmationError.httpError(apiResponse) + throw serverErrorByStatus(apiResponse.status, apiResponse) + } + + const apiJson = await apiResponse.json() + const verifiedData = createBookingSchema.safeParse(apiJson) + if (!verifiedData.success) { + metricsConfirmationError.validationError(verifiedData.error) + throw badRequestError() + } + + metricsConfirmationError.success() + + return verifiedData.data + }), - return verifiedData.data - }), createRefId: serviceProcedure .input(createRefIdInput) .mutation(async function ({ input }) { const { confirmationNumber, lastName } = input - const encryptedRefId = encrypt(`${confirmationNumber},${lastName}`) + const encryptedRefId = calculateRefId(confirmationNumber, lastName) if (!encryptedRefId) { throw serverErrorByStatus(422, "Was not able to encrypt ref id") diff --git a/apps/scandic-web/server/routers/booking/utils.ts b/apps/scandic-web/server/routers/booking/utils.ts index 1209f0ad2..eda9b9199 100644 --- a/apps/scandic-web/server/routers/booking/utils.ts +++ b/apps/scandic-web/server/routers/booking/utils.ts @@ -1,81 +1,163 @@ import * as api from "@/lib/api" import { badRequestError, serverErrorByStatus } from "@/server/errors/trpc" import { createCounter } from "@/server/telemetry" +import { getUserOrServiceToken } from "@/server/tokenManager" import { toApiLang } from "@/server/utils" -import { bookingConfirmationSchema, createBookingSchema } from "./output" +import { getCacheClient } from "@/services/dataCache" -import type { Room } from "@/types/hotel" -import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" +import { bookingSchema, createBookingSchema } from "./output" + +import type { BookingSchema } from "@/types/trpc/routers/booking/confirmation" import type { Lang } from "@/constants/languages" -export function getBookedHotelRoom( - rooms: Room[] | undefined, - roomTypeCode: BookingConfirmation["booking"]["roomTypeCode"] -) { - if (!rooms?.length || !roomTypeCode) { - return null - } - const room = rooms?.find((r) => { - return r.roomTypes.find((roomType) => roomType.code === roomTypeCode) - }) - if (!room) { - return null - } - const bedType = room.roomTypes.find( - (roomType) => roomType.code === roomTypeCode - ) - if (!bedType) { - return null - } - return { - ...room, - bedType, - } -} - -export async function getBooking( - confirmationNumber: string, - lang: Lang, - token: string -) { +export async function getBooking(confirmationNumber: string, lang: Lang) { const getBookingCounter = createCounter("booking", "get") const metricsGetBooking = getBookingCounter.init({ confirmationNumber }) metricsGetBooking.start() - const apiResponse = await api.get( - api.endpoints.v1.Booking.booking(confirmationNumber), + const cacheKey = `${lang}:booking:${confirmationNumber}` + const cache = await getCacheClient() + + const result: BookingSchema | null = await cache.cacheOrGet( + cacheKey, + async () => { + const token = getUserOrServiceToken() + + const apiResponse = await api.get( + api.endpoints.v1.Booking.booking(confirmationNumber), + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + { language: toApiLang(lang) } + ) + + if (!apiResponse.ok) { + await metricsGetBooking.httpError(apiResponse) + + // If the booking is not found, return null. + // This scenario is expected to happen when a logged in user trying to access a booking that doesn't belong to them. + if (apiResponse.status === 400) { + return null + } + + throw serverErrorByStatus(apiResponse.status, apiResponse) + } + + const apiJson = await apiResponse.json() + const booking = bookingSchema.safeParse(apiJson) + if (!booking.success) { + metricsGetBooking.validationError(booking.error) + throw badRequestError() + } + + return booking.data + }, + "1h" + ) + + metricsGetBooking.success() + + return result +} + +export async function getBookings(confirmationNumbers: string[], lang: Lang) { + const results = await Promise.allSettled( + confirmationNumbers.map((confirmationNumber) => { + return getBooking(confirmationNumber, lang) + }) + ) + + return results.map((result) => { + if (result.status === "fulfilled" && result.value) { + return result.value + } + return null + }) +} + +export async function getLinkedReservations( + confirmationNumber: string, + lang: Lang +) { + const booking = await getBooking(confirmationNumber, lang) + + if (!booking) { + return null + } + + if (booking.linkedReservations.length > 0) { + const confirmationNumbers = booking.linkedReservations.map( + (linkedReservation) => { + return linkedReservation.confirmationNumber + } + ) + + const bookings = await getBookings(confirmationNumbers, lang) + + const linkedReservations = bookings.map((booking, i) => { + if (booking === null) { + return { + confirmationNumber: confirmationNumbers[i], + error: true, + } as const + } + return booking + }) + + return linkedReservations + } + + return [] +} + +export async function cancelBooking(confirmationNumber: string, lang: Lang) { + const cancelBookingCounter = createCounter("booking", "cancel") + const metricsCancelBooking = cancelBookingCounter.init({ + confirmationNumber, + lang, + }) + + metricsCancelBooking.start() + + const token = getUserOrServiceToken() + const headers = { + Authorization: `Bearer ${token}`, + } + + const booking = await getBooking(confirmationNumber, lang) + if (!booking) { + metricsCancelBooking.noDataError({ confirmationNumber }) + return null + } + const { firstName, lastName, email } = booking.guest + const apiResponse = await api.remove( + api.endpoints.v1.Booking.cancel(confirmationNumber), { - headers: { - Authorization: `Bearer ${token}`, - }, + headers, + body: { firstName, lastName, email }, }, { language: toApiLang(lang) } ) if (!apiResponse.ok) { - await metricsGetBooking.httpError(apiResponse) - - // If the booking is not found, return null. - // This scenario is expected to happen when a logged in user trying to access a booking that doesn't belong to them. - if (apiResponse.status === 400) { - return null - } - - throw serverErrorByStatus(apiResponse.status, apiResponse) + await metricsCancelBooking.httpError(apiResponse) + return null } const apiJson = await apiResponse.json() - const booking = bookingConfirmationSchema.safeParse(apiJson) - if (!booking.success) { - metricsGetBooking.validationError(booking.error) - throw badRequestError() + const verifiedData = createBookingSchema.safeParse(apiJson) + if (!verifiedData.success) { + metricsCancelBooking.validationError(verifiedData.error) + return null } - metricsGetBooking.success() + metricsCancelBooking.success() - return booking.data + return verifiedData.data } export async function findBooking( @@ -124,7 +206,7 @@ export async function findBooking( } const apiJson = await apiResponse.json() - const booking = bookingConfirmationSchema.safeParse(apiJson) + const booking = bookingSchema.safeParse(apiJson) if (!booking.success) { metricsGetBooking.validationError(booking.error) throw badRequestError() @@ -134,52 +216,3 @@ export async function findBooking( return booking.data } - -export async function cancelBooking( - confirmationNumber: string, - language: string, - token: string -) { - const cancellationReason = { - reasonCode: "WEB-CANCEL", - reason: "WEB-CANCEL", - } - - const cancelBookingCounter = createCounter("booking", "cancel") - const metricsCancelBooking = cancelBookingCounter.init({ - cancellationReason, - confirmationNumber, - language, - }) - - metricsCancelBooking.start() - - const headers = { - Authorization: `Bearer ${token}`, - } - - const apiResponse = await api.remove( - api.endpoints.v1.Booking.cancel(confirmationNumber), - { - headers, - body: JSON.stringify(cancellationReason), - } as RequestInit, - { language } - ) - - if (!apiResponse.ok) { - await metricsCancelBooking.httpError(apiResponse) - return false - } - - const apiJson = await apiResponse.json() - const verifiedData = createBookingSchema.safeParse(apiJson) - if (!verifiedData.success) { - metricsCancelBooking.validationError(verifiedData.error) - return null - } - - metricsCancelBooking.success() - - return verifiedData.data -} diff --git a/apps/scandic-web/server/routers/user/utils.ts b/apps/scandic-web/server/routers/user/utils.ts index d0eafd976..fc0b67468 100644 --- a/apps/scandic-web/server/routers/user/utils.ts +++ b/apps/scandic-web/server/routers/user/utils.ts @@ -4,10 +4,10 @@ import { myStay } from "@/constants/routes/myStay" import { env } from "@/env/server" import * as api from "@/lib/api" import { dt } from "@/lib/dt" -import { encrypt } from "@/server/routers/utils/encryption" import { createCounter } from "@/server/telemetry" import { cache } from "@/utils/cache" +import { encrypt } from "@/utils/encryption" import * as maskValue from "@/utils/maskValue" import { isValidSession } from "@/utils/session" import { getCurrentWebUrl } from "@/utils/url" diff --git a/apps/scandic-web/server/tokenManager.ts b/apps/scandic-web/server/tokenManager.ts index 0044ed891..6e7c42343 100644 --- a/apps/scandic-web/server/tokenManager.ts +++ b/apps/scandic-web/server/tokenManager.ts @@ -3,7 +3,9 @@ import { trace, type Tracer } from "@opentelemetry/api" import { env } from "@/env/server" import { createCounter } from "@/server/telemetry" +import { auth } from "@/auth" import { getCacheClient } from "@/services/dataCache" +import { isValidSession } from "@/utils/session" import type { ServiceTokenResponse } from "@/types/tokens" @@ -117,3 +119,12 @@ async function fetchServiceToken(scopes: string[]) { function getServiceTokenCacheKey(scopes: string[]): string { return `serviceToken:${scopes.join(",")}` } + +export async function getUserOrServiceToken() { + const serviceToken = await getServiceToken() + const session = await auth() + + return isValidSession(session) + ? session.token.access_token + : serviceToken.access_token +} diff --git a/apps/scandic-web/stores/my-stay/index.ts b/apps/scandic-web/stores/my-stay/index.ts index fd6544ebc..13bfcf441 100644 --- a/apps/scandic-web/stores/my-stay/index.ts +++ b/apps/scandic-web/stores/my-stay/index.ts @@ -3,8 +3,6 @@ import { produce } from "immer" import { useContext } from "react" import { create, useStore } from "zustand" -import { getBookedHotelRoom } from "@/server/routers/booking/utils" - import { mapRoomDetails } from "@/components/HotelReservation/MyStay/utils/mapRoomDetails" import { MyStayContext } from "@/contexts/MyStay" @@ -14,7 +12,34 @@ import { isAllRoomsCancelled, } from "./helpers" +import type { Room } from "@/types/hotel" import type { InitialState, MyStayState } from "@/types/stores/my-stay" +import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" + +export function getBookedHotelRoom( + rooms: Room[] | undefined, + roomTypeCode: BookingConfirmation["booking"]["roomTypeCode"] +) { + if (!rooms?.length || !roomTypeCode) { + return null + } + const room = rooms?.find((r) => { + return r.roomTypes.find((roomType) => roomType.code === roomTypeCode) + }) + if (!room) { + return null + } + const bedType = room.roomTypes.find( + (roomType) => roomType.code === roomTypeCode + ) + if (!bedType) { + return null + } + return { + ...room, + bedType, + } +} export function createMyStayStore({ breakfastPackages, diff --git a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/actions/addToCalendar.ts b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/actions/addToCalendar.ts index bf7aee437..c62d5cd54 100644 --- a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/actions/addToCalendar.ts +++ b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/actions/addToCalendar.ts @@ -1,12 +1,8 @@ import type { EventAttributes } from "ics" -import type { RouterOutput } from "@/lib/trpc/client" - export interface AddToCalendarProps { - checkInDate: NonNullable< - RouterOutput["booking"]["get"] - >["booking"]["checkInDate"] + checkInDate: Date event: EventAttributes - hotelName: NonNullable["hotel"]["name"] + hotelName: string renderButton: (onPress: () => Promise) => React.ReactNode } diff --git a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/actions/manageBooking.ts b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/actions/manageBooking.ts index 5b10c25fb..2fbd1ef05 100644 --- a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/actions/manageBooking.ts +++ b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/actions/manageBooking.ts @@ -1,3 +1,3 @@ export interface ManageBookingProps { - bookingUrl: string + refId: string } diff --git a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/bookingConfirmation.ts b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/bookingConfirmation.ts index 609fe53d2..dd7842f34 100644 --- a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/bookingConfirmation.ts +++ b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/bookingConfirmation.ts @@ -1,23 +1,19 @@ import type { Room } from "@/types/hotel" import type { BookingConfirmation, - BookingConfirmationSchema, + BookingSchema, } from "@/types/trpc/routers/booking/confirmation" -export interface BookingConfirmationProps { - confirmationNumber: string -} - export interface BookingConfirmationRoom extends Room { bedType: Room["roomTypes"][number] } export interface ConfirmationProps - extends Pick { + extends Pick { room: BookingConfirmationRoom refId: string } export interface BookingConfirmationAlertsProps { - booking: BookingConfirmationSchema + booking: BookingSchema } diff --git a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/header.ts b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/header.ts index 1fe7aaa21..13f80856c 100644 --- a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/header.ts +++ b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/header.ts @@ -1,9 +1,7 @@ -import type { MutableRefObject } from "react" - import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" -export interface BookingConfirmationHeaderProps - extends Pick { - mainRef: MutableRefObject +export interface BookingConfirmationHeaderProps { + booking: BookingConfirmation["booking"] + hotel: BookingConfirmation["hotelData"]["hotel"] refId: string } diff --git a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/hotelDetails.ts b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/hotelDetails.ts index f121e0aba..1e335703a 100644 --- a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/hotelDetails.ts +++ b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/hotelDetails.ts @@ -1,5 +1,5 @@ import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" export interface BookingConfirmationHotelDetailsProps { - hotel: BookingConfirmation["hotel"] + hotel: BookingConfirmation["hotelData"]["hotel"] } diff --git a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/promos.ts b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/promos.ts index 2d786d63c..5fef57ef7 100644 --- a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/promos.ts +++ b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/promos.ts @@ -1,8 +1,4 @@ -import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" - -export interface PromosProps - extends Pick< - BookingConfirmation["booking"], - "confirmationNumber" | "hotelId" - >, - Pick {} +export interface PromosProps { + hotelId: string + refId: string +} diff --git a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/rooms.ts b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/rooms.ts index 1c29fc0d3..a6ce40f75 100644 --- a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/rooms.ts +++ b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/rooms.ts @@ -1,11 +1,5 @@ -import type { z } from "zod" - import type { Room } from "@/types/hotel" import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" -import type { linkedReservationSchema } from "@/server/routers/booking/output" - -export interface LinkedReservationSchema - extends z.output {} export interface BookingConfirmationRoomsProps extends Pick { @@ -14,5 +8,4 @@ export interface BookingConfirmationRoomsProps } checkInTime: string checkOutTime: string - linkedReservations: LinkedReservationSchema[] } diff --git a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/rooms/linkedReservation.ts b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/rooms/linkedReservation.ts index a03adeb20..ddd1209e8 100644 --- a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/rooms/linkedReservation.ts +++ b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/rooms/linkedReservation.ts @@ -1,7 +1,7 @@ export interface LinkedReservationProps { checkInTime: string checkOutTime: string - confirmationNumber: string + refId: string roomIndex: number } diff --git a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/rooms/room.ts b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/rooms/room.ts index bc2ce6b9a..21baa038a 100644 --- a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/rooms/room.ts +++ b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/rooms/room.ts @@ -1,9 +1,14 @@ import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" export interface RoomProps { - booking: BookingConfirmation["booking"] + checkInDate: BookingConfirmation["booking"]["checkInDate"] + checkOutDate: BookingConfirmation["booking"]["checkOutDate"] checkInTime: string checkOutTime: string + confirmationNumber: string + guest: BookingConfirmation["booking"]["guest"] + guaranteeInfo: BookingConfirmation["booking"]["guaranteeInfo"] img: NonNullable["images"][number] + rateDefinition: BookingConfirmation["booking"]["rateDefinition"] roomName: NonNullable["name"] } diff --git a/apps/scandic-web/types/components/hotelReservation/enterDetails/payment.ts b/apps/scandic-web/types/components/hotelReservation/enterDetails/payment.ts index f64f89e6c..816eb1aa4 100644 --- a/apps/scandic-web/types/components/hotelReservation/enterDetails/payment.ts +++ b/apps/scandic-web/types/components/hotelReservation/enterDetails/payment.ts @@ -17,3 +17,7 @@ export type PriceChangeData = Array<{ totalPrice: number packagePrice?: number } | null> + +export interface TermsAndConditionsProps { + isFlexBookingTerms: boolean +} diff --git a/apps/scandic-web/types/components/myPages/myStay/ancillaries.ts b/apps/scandic-web/types/components/myPages/myStay/ancillaries.ts index 6e372a5bd..3ba618866 100644 --- a/apps/scandic-web/types/components/myPages/myStay/ancillaries.ts +++ b/apps/scandic-web/types/components/myPages/myStay/ancillaries.ts @@ -23,6 +23,7 @@ export interface AncillariesProps extends Pick { export interface AddedAncillariesProps { ancillaries: Ancillary["ancillaryContent"][number][] | null booking: BookingConfirmation["booking"] + refId: string } export interface AncillaryProps { diff --git a/apps/scandic-web/types/stores/my-stay.ts b/apps/scandic-web/types/stores/my-stay.ts index a9492ec2b..1f527a85e 100644 --- a/apps/scandic-web/types/stores/my-stay.ts +++ b/apps/scandic-web/types/stores/my-stay.ts @@ -30,6 +30,7 @@ export type Room = Pick< | "linkedReservations" | "multiRoom" | "rateDefinition" + | "refId" | "reservationStatus" | "roomPoints" | "roomTypeCode" diff --git a/apps/scandic-web/types/trpc/routers/booking/confirmation.ts b/apps/scandic-web/types/trpc/routers/booking/confirmation.ts index 4b6e6f5eb..479af5ea3 100644 --- a/apps/scandic-web/types/trpc/routers/booking/confirmation.ts +++ b/apps/scandic-web/types/trpc/routers/booking/confirmation.ts @@ -2,20 +2,20 @@ import type { z } from "zod" import type { HotelData, Room } from "@/types/hotel" import type { - bookingConfirmationSchema, + bookingSchema, packageSchema, } from "@/server/routers/booking/output" -export interface BookingConfirmationSchema - extends z.output {} +export interface BookingSchema extends z.output {} export interface PackageSchema extends z.output {} -export interface BookingConfirmation extends HotelData { - booking: BookingConfirmationSchema - room: - | (Room & { - bedType: Room["roomTypes"][number] - }) - | null +export interface BookingConfirmationRoom extends Room { + bedType: Room["roomTypes"][number] +} + +export interface BookingConfirmation { + booking: BookingSchema + hotelData: HotelData + room: BookingConfirmationRoom } diff --git a/apps/scandic-web/server/routers/utils/encryption.ts b/apps/scandic-web/utils/encryption.ts similarity index 100% rename from apps/scandic-web/server/routers/utils/encryption.ts rename to apps/scandic-web/utils/encryption.ts diff --git a/apps/scandic-web/utils/refId.ts b/apps/scandic-web/utils/refId.ts new file mode 100644 index 000000000..95a0aef05 --- /dev/null +++ b/apps/scandic-web/utils/refId.ts @@ -0,0 +1,21 @@ +import "server-only" + +import { decrypt, encrypt } from "./encryption" + +export function calculateRefId(confirmationNumber: string, lastName: string) { + const encryptedRefId = encrypt(`${confirmationNumber},${lastName}`) + + return encryptedRefId +} + +export function parseRefId(refId: string) { + const data = decrypt(refId) + const parts = data.split(",") + if (parts.length !== 2) { + throw new Error("Invalid refId format") + } + return { + confirmationNumber: parts[0], + lastName: parts[1], + } +} diff --git a/packages/design-system/lib/components/Button/Button.stories.tsx b/packages/design-system/lib/components/Button/Button.stories.tsx index 7311bb957..3b6a40cfe 100644 --- a/packages/design-system/lib/components/Button/Button.stories.tsx +++ b/packages/design-system/lib/components/Button/Button.stories.tsx @@ -36,8 +36,7 @@ const meta: Meta = { control: 'select', options: Object.keys(buttonConfig.variants.size), type: 'string', - description: - 'The size of the button. Defaults to `Large`. This variant does not apply to the `Icon` variant.', + description: 'The size of the button. Defaults to `Large`.', }, wrapping: { control: 'radio', @@ -351,25 +350,3 @@ export const TextWithIconInverted: Story = { color: 'Inverted', }, } - -export const Icon: Story = { - args: { - onPress: fn(), - children: , - variant: 'Icon', - }, -} - -export const IconWithColor: Story = { - args: { - onPress: fn(), - children: ( - - ), - variant: 'Icon', - }, -} diff --git a/packages/design-system/lib/components/Button/Button.tsx b/packages/design-system/lib/components/Button/Button.tsx index c5b7268c2..02c7a7938 100644 --- a/packages/design-system/lib/components/Button/Button.tsx +++ b/packages/design-system/lib/components/Button/Button.tsx @@ -1,5 +1,3 @@ -'use client' - import { Button as ButtonRAC } from 'react-aria-components' import { variants } from './variants' diff --git a/packages/design-system/lib/components/Button/button.module.css b/packages/design-system/lib/components/Button/button.module.css index e6a39a091..12227961e 100644 --- a/packages/design-system/lib/components/Button/button.module.css +++ b/packages/design-system/lib/components/Button/button.module.css @@ -6,6 +6,7 @@ display: flex; align-items: center; justify-content: center; + gap: var(--Space-x05); &:disabled { cursor: unset; @@ -166,11 +167,3 @@ .variant-text.color-inverted:disabled { color: var(--Component-Button-Brand-Secondary-On-fill-Disabled); } - -.variant-icon { - background-color: transparent; - border-color: transparent; - color: inherit; - padding: 0; - margin: 0; -} diff --git a/packages/design-system/lib/components/Button/variants.ts b/packages/design-system/lib/components/Button/variants.ts index 18dd4e6a4..2e5e59ead 100644 --- a/packages/design-system/lib/components/Button/variants.ts +++ b/packages/design-system/lib/components/Button/variants.ts @@ -16,7 +16,6 @@ export const config = { Tertiary: styles['variant-tertiary'], Inverted: styles['variant-inverted'], Text: styles['variant-text'], - Icon: styles['variant-icon'], }, color: { Primary: styles['color-primary'], diff --git a/packages/design-system/lib/components/IconButton/IconButton.stories.tsx b/packages/design-system/lib/components/IconButton/IconButton.stories.tsx new file mode 100644 index 000000000..383786abc --- /dev/null +++ b/packages/design-system/lib/components/IconButton/IconButton.stories.tsx @@ -0,0 +1,141 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { fn } from '@storybook/test' + +import { MaterialIcon } from '../Icons/MaterialIcon' +import { IconButton } from './IconButton' +import { config } from './variants' + +const meta: Meta = { + title: 'Components/IconButton', + component: IconButton, + argTypes: { + onPress: { + table: { + disable: true, + }, + }, + theme: { + control: 'select', + options: Object.keys(config.variants.theme), + default: 'Primary', + }, + style: { + control: 'select', + options: Object.keys(config.variants.style), + default: 'Normal', + type: 'string', + description: `The style variant is only applied on certain variants. The examples below shows the possible combinations of variants and style variants.`, + }, + }, +} + +export default meta + +type Story = StoryObj + +export const PrimaryDefault: Story = { + args: { + onPress: fn(), + children: , + theme: 'Primary', + }, +} + +export const PrimaryDisabled: Story = { + args: { + ...PrimaryDefault.args, + isDisabled: true, + }, +} + +export const InvertedDefault: Story = { + args: { + onPress: fn(), + children: ( + + ), + theme: 'Inverted', + }, +} + +export const InvertedDisabled: Story = { + args: { + ...InvertedDefault.args, + isDisabled: true, + }, +} + +export const InvertedElevated: Story = { + args: { + ...InvertedDefault.args, + style: 'Elevated', + }, +} + +export const InvertedElevatedDisabled: Story = { + args: { + ...InvertedElevated.args, + isDisabled: true, + }, +} + +export const InvertedMuted: Story = { + args: { + ...InvertedDefault.args, + children: , + style: 'Muted', + }, +} + +export const InvertedMutedDisabled: Story = { + args: { + ...InvertedMuted.args, + isDisabled: true, + }, +} + +export const InvertedFaded: Story = { + args: { + ...InvertedDefault.args, + style: 'Faded', + }, +} + +export const InvertedFadedDisabled: Story = { + args: { + ...InvertedFaded.args, + isDisabled: true, + }, +} + +export const TertiaryElevated: Story = { + args: { + onPress: fn(), + children: , + theme: 'Tertiary', + style: 'Elevated', + }, +} + +export const TertiaryDisabled: Story = { + args: { + ...TertiaryElevated.args, + isDisabled: true, + }, +} + +export const BlackMuted: Story = { + args: { + onPress: fn(), + children: , + theme: 'Black', + }, +} + +export const BlackMutedDisabled: Story = { + args: { + ...BlackMuted.args, + isDisabled: true, + }, +} diff --git a/packages/design-system/lib/components/IconButton/IconButton.tsx b/packages/design-system/lib/components/IconButton/IconButton.tsx new file mode 100644 index 000000000..a94ffb08d --- /dev/null +++ b/packages/design-system/lib/components/IconButton/IconButton.tsx @@ -0,0 +1,20 @@ +import { Button as ButtonRAC } from 'react-aria-components' + +import { variants } from './variants' + +import type { IconButtonProps } from './types' + +export function IconButton({ + theme, + style, + className, + ...props +}: IconButtonProps) { + const classNames = variants({ + theme, + style, + className, + }) + + return +} diff --git a/packages/design-system/lib/components/IconButton/iconButton.module.css b/packages/design-system/lib/components/IconButton/iconButton.module.css new file mode 100644 index 000000000..13d09e659 --- /dev/null +++ b/packages/design-system/lib/components/IconButton/iconButton.module.css @@ -0,0 +1,102 @@ +.iconButton { + border-radius: var(--Corner-radius-rounded); + border-width: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 10px; + + &:disabled { + cursor: unset; + } +} + +.theme-primary { + background-color: var(--Component-Button-Brand-Primary-Fill-Default); + color: var(--Component-Button-Brand-Primary-On-fill-Default); + + &:hover:not(:disabled) { + background-color: var(--Component-Button-Brand-Primary-Fill-Hover); + color: var(--Component-Button-Brand-Primary-On-fill-Hover); + } + + &:disabled { + background-color: var(--Component-Button-Brand-Primary-Fill-Disabled); + color: var(--Component-Button-Brand-Primary-On-fill-Disabled); + } +} + +.theme-inverted { + background-color: var(--Component-Button-Inverted-Fill-Default); + color: var(--Component-Button-Inverted-On-fill-Default); + + &:hover:not(:disabled) { + background-color: var(--Component-Button-Inverted-Fill-Hover); + color: var(--Component-Button-Inverted-On-fill-Hover); + } + + &:disabled { + background-color: var(--Component-Button-Inverted-Fill-Disabled); + color: var(--Component-Button-Inverted-On-fill-Disabled); + } + + &.style-muted { + color: var(--Component-Button-Muted-On-fill-Inverted); + + &:hover:not(:disabled) { + color: var(--Component-Button-Muted-On-fill-Inverted); + } + + &:disabled { + color: var(--Component-Button-Muted-On-fill-Disabled); + } + } +} + +.theme-tertiary { + background-color: var(--Component-Button-Brand-Tertiary-Fill-Default); + color: var(--Component-Button-Brand-Tertiary-On-fill-Default); + + &:hover:not(:disabled) { + background-color: var(--Component-Button-Brand-Tertiary-Fill-Hover); + color: var(--Component-Button-Brand-Tertiary-On-fill-Hover); + } + + &:disabled { + background-color: var(--Component-Button-Brand-Tertiary-Fill-Disabled); + color: var(--Component-Button-Brand-Tertiary-On-fill-Disabled); + } +} + +.theme-black { + color: var(--Component-Button-Muted-On-fill-Default); + + &:hover:not(:disabled) { + color: var(--Component-Button-Muted-On-fill-Hover-Inverted); + } + + &:disabled { + color: var(--Component-Button-Muted-On-fill-Disabled); + } +} + +.style-elevated { + box-shadow: 0px 0px 8px 1px #0000001a; +} + +.style-faded { + background-color: var(--Component-Button-Inverted-Fill-Faded); +} + +.style-muted { + background-color: var(--Component-Button-Muted-Fill-Default); + + &:hover:not(:disabled) { + background-color: var(--Component-Button-Muted-Fill-Hover-inverted); + } + + &:disabled { + background-color: var(--Component-Button-Muted-Fill-Disabled-inverted); + } +} diff --git a/packages/design-system/lib/components/IconButton/index.tsx b/packages/design-system/lib/components/IconButton/index.tsx new file mode 100644 index 000000000..a492877d0 --- /dev/null +++ b/packages/design-system/lib/components/IconButton/index.tsx @@ -0,0 +1 @@ +export { IconButton } from './IconButton' diff --git a/packages/design-system/lib/components/IconButton/types.ts b/packages/design-system/lib/components/IconButton/types.ts new file mode 100644 index 000000000..2c84912b8 --- /dev/null +++ b/packages/design-system/lib/components/IconButton/types.ts @@ -0,0 +1,10 @@ +import { Button } from 'react-aria-components' + +import type { VariantProps } from 'class-variance-authority' +import type { ComponentProps } from 'react' + +import type { variants } from './variants' + +export interface IconButtonProps + extends Omit, 'style'>, + VariantProps {} diff --git a/packages/design-system/lib/components/IconButton/variants.ts b/packages/design-system/lib/components/IconButton/variants.ts new file mode 100644 index 000000000..cd361d945 --- /dev/null +++ b/packages/design-system/lib/components/IconButton/variants.ts @@ -0,0 +1,78 @@ +import { cva } from 'class-variance-authority' + +import styles from './iconButton.module.css' + +const variantKeys = { + theme: { + Primary: 'Primary', + Tertiary: 'Tertiary', + Inverted: 'Inverted', + Black: 'Black', + }, + style: { + Normal: 'Normal', + Muted: 'Muted', + Elevated: 'Elevated', + Faded: 'Faded', + }, +} as const + +export const config = { + variants: { + theme: { + [variantKeys.theme.Primary]: styles['theme-primary'], + [variantKeys.theme.Tertiary]: styles['theme-tertiary'], + [variantKeys.theme.Inverted]: styles['theme-inverted'], + [variantKeys.theme.Black]: styles['theme-black'], + }, + // Some variants cannot be used in combination with certain style variants. + // The style variant will be applied using the compoundVariants. + style: { + [variantKeys.style.Normal]: '', + [variantKeys.style.Muted]: '', + [variantKeys.style.Elevated]: '', + [variantKeys.style.Faded]: '', + }, + }, + compoundVariants: [ + // Primary should only use Normal + { theme: variantKeys.theme.Primary, className: styles['style-normal'] }, + + // Tertiary should only use Elevated + { + theme: variantKeys.theme.Tertiary, + className: styles['style-elevated'], + }, + + // Black should only use Muted + { theme: variantKeys.theme.Black, className: styles['style-muted'] }, + + // Inverted can use any style variant + { + theme: variantKeys.theme.Inverted, + style: variantKeys.style.Normal, + className: styles['style-normal'], + }, + { + theme: variantKeys.theme.Inverted, + style: variantKeys.style.Muted, + className: styles['style-muted'], + }, + { + theme: variantKeys.theme.Inverted, + style: variantKeys.style.Elevated, + className: styles['style-elevated'], + }, + { + theme: variantKeys.theme.Inverted, + style: variantKeys.style.Faded, + className: styles['style-faded'], + }, + ], + defaultVariants: { + theme: variantKeys.theme.Primary, + style: variantKeys.style.Normal, + }, +} + +export const variants = cva(styles.iconButton, config) diff --git a/packages/design-system/lib/components/RateCard/Campaign/index.tsx b/packages/design-system/lib/components/RateCard/Campaign/index.tsx index 5be4a0112..6e7a97ea4 100644 --- a/packages/design-system/lib/components/RateCard/Campaign/index.tsx +++ b/packages/design-system/lib/components/RateCard/Campaign/index.tsx @@ -1,7 +1,7 @@ import { Typography } from '../../Typography' import { Rate, RateTermDetails } from '../types' -import { Button } from '../../Button' +import { IconButton } from '../../IconButton' import { MaterialIcon } from '../../Icons/MaterialIcon' import Modal from '../Modal' import styles from '../rate-card.module.css' @@ -67,13 +67,13 @@ export default function CampaignRateCard({ title={rateTitle} subtitle={paymentTerm} trigger={ - + } > {rateTermDetails.map((termGroup) => ( diff --git a/packages/design-system/lib/components/RateCard/Code/index.tsx b/packages/design-system/lib/components/RateCard/Code/index.tsx index 2aaa9f75c..8254e060a 100644 --- a/packages/design-system/lib/components/RateCard/Code/index.tsx +++ b/packages/design-system/lib/components/RateCard/Code/index.tsx @@ -1,6 +1,6 @@ import { Rate, RateTermDetails } from '../types' -import { Button } from '../../Button' +import { IconButton } from '../../IconButton' import { MaterialIcon } from '../../Icons/MaterialIcon' import { Typography } from '../../Typography' import Modal from '../Modal' @@ -63,13 +63,13 @@ export default function CodeRateCard({ title={rateTitle} subtitle={paymentTerm} trigger={ - + } > {rateTermDetails.map((termGroup) => ( diff --git a/packages/design-system/lib/components/RateCard/NoRateAvailable/index.tsx b/packages/design-system/lib/components/RateCard/NoRateAvailable/index.tsx index d1822f9fb..b69fae1f0 100644 --- a/packages/design-system/lib/components/RateCard/NoRateAvailable/index.tsx +++ b/packages/design-system/lib/components/RateCard/NoRateAvailable/index.tsx @@ -1,4 +1,4 @@ -import { Button } from '../../Button' +import { IconButton } from '../../IconButton' import { MaterialIcon } from '../../Icons/MaterialIcon' import { Typography } from '../../Typography' import styles from '../rate-card.module.css' @@ -34,9 +34,9 @@ export default function NoRateAvailableCard({

    - + {`${rateTitle} / ${paymentTerm}`}

    diff --git a/packages/design-system/lib/components/RateCard/Points/index.tsx b/packages/design-system/lib/components/RateCard/Points/index.tsx index 707ddbd1e..f32a430a7 100644 --- a/packages/design-system/lib/components/RateCard/Points/index.tsx +++ b/packages/design-system/lib/components/RateCard/Points/index.tsx @@ -2,7 +2,7 @@ import { Typography } from '../../Typography' import { RatePointsOption, RateTermDetails } from '../types' import { RadioGroup } from 'react-aria-components' -import { Button } from '../../Button' +import { IconButton } from '../../IconButton' import { MaterialIcon } from '../../Icons/MaterialIcon' import { Radio } from '../../Radio' import Modal from '../Modal' @@ -49,9 +49,9 @@ export default function PointsRateCard({ title={rateTitle} subtitle={paymentTerm} trigger={ - + } > {rateTermDetails.map((termGroup) => ( diff --git a/packages/design-system/lib/components/RateCard/Regular/index.tsx b/packages/design-system/lib/components/RateCard/Regular/index.tsx index 180194fb2..7dfe483f2 100644 --- a/packages/design-system/lib/components/RateCard/Regular/index.tsx +++ b/packages/design-system/lib/components/RateCard/Regular/index.tsx @@ -1,6 +1,6 @@ import { Rate, RateTermDetails } from '../types' -import { Button } from '../../Button' +import { IconButton } from '../../IconButton' import { MaterialIcon } from '../../Icons/MaterialIcon' import { Typography } from '../../Typography' import Modal from '../Modal' @@ -56,13 +56,13 @@ export default function RegularRateCard({ title={rateTitle} subtitle={paymentTerm} trigger={ - + } > {rateTermDetails.map((termGroup) => ( diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 3369f3e3e..d63122b2f 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -16,6 +16,7 @@ "./CodeRateCard": "./dist/components/RateCard/Code/index.js", "./PointsRateCard": "./dist/components/RateCard/Points/index.js", "./NoRateAvailableCard": "./dist/components/RateCard/NoRateAvailable/index.js", + "./IconButton": "./dist/components/IconButton/index.js", "./Icons": "./dist/components/Icons/index.js", "./Icons/BathroomCabinetIcon": "./dist/components/Icons/Nucleo/Amenities_Facilities/bathroom-cabinet-2.js", "./Icons/BedHotelIcon": "./dist/components/Icons/Customised/Amenities_Facilities/BedHotel.js",