From 0897a398ee53ceb40a23a64712cbf1d7c8fc8c14 Mon Sep 17 00:00:00 2001 From: Simon Emanuelsson Date: Wed, 6 Nov 2024 16:31:03 +0100 Subject: [PATCH] feat(SW-791): make confirmation page dynamic --- .../booking-confirmation/page.module.css | 154 +--------- .../booking-confirmation/page.tsx | 282 +----------------- .../Actions/actions.module.css | 34 +++ .../BookingConfirmation/Actions/index.tsx | 38 +++ .../Details/details.module.css | 31 ++ .../BookingConfirmation/Details/index.tsx | 61 ++++ .../Header/header.module.css | 18 ++ .../BookingConfirmation/Header/index.tsx | 60 ++++ .../HotelImage/image.module.css | 7 + .../BookingConfirmation/HotelImage/index.tsx | 24 ++ .../BookingConfirmation/Summary/index.tsx | 154 ++++++++++ .../Summary/summary.module.css | 31 ++ .../BookingConfirmation/TotalPrice/index.tsx | 136 +++++++++ .../TotalPrice/totalPrice.module.css | 14 + .../bookingConfirmation.module.css | 23 ++ .../BookingConfirmation/index.tsx | 31 ++ .../EnterDetails/Details/index.tsx | 12 +- .../EnterDetails/Details/schema.ts | 6 +- components/Icons/Coffee.tsx | 23 ++ components/Icons/Contract.tsx | 27 ++ components/Icons/CreditCardAdd.tsx | 27 ++ components/Icons/Discount.tsx | 27 ++ components/Icons/DoorClosed.tsx | 27 ++ components/Icons/index.tsx | 5 + .../Text/Subtitle/subtitle.module.css | 4 + .../Text/Subtitle/variants.ts | 1 + i18n/dictionaries/en.json | 22 +- lib/trpc/memoizedRequests/index.ts | 6 + server/routers/booking/output.ts | 36 ++- server/routers/booking/query.ts | 84 +++--- server/routers/hotels/output.ts | 97 ++++-- server/routers/hotels/query.ts | 12 +- server/routers/user/output.ts | 2 +- stores/enter-details.ts | 3 +- .../bookingConfirmation.ts | 41 +-- 35 files changed, 983 insertions(+), 577 deletions(-) create mode 100644 components/HotelReservation/BookingConfirmation/Actions/actions.module.css create mode 100644 components/HotelReservation/BookingConfirmation/Actions/index.tsx create mode 100644 components/HotelReservation/BookingConfirmation/Details/details.module.css create mode 100644 components/HotelReservation/BookingConfirmation/Details/index.tsx create mode 100644 components/HotelReservation/BookingConfirmation/Header/header.module.css create mode 100644 components/HotelReservation/BookingConfirmation/Header/index.tsx create mode 100644 components/HotelReservation/BookingConfirmation/HotelImage/image.module.css create mode 100644 components/HotelReservation/BookingConfirmation/HotelImage/index.tsx create mode 100644 components/HotelReservation/BookingConfirmation/Summary/index.tsx create mode 100644 components/HotelReservation/BookingConfirmation/Summary/summary.module.css create mode 100644 components/HotelReservation/BookingConfirmation/TotalPrice/index.tsx create mode 100644 components/HotelReservation/BookingConfirmation/TotalPrice/totalPrice.module.css create mode 100644 components/HotelReservation/BookingConfirmation/bookingConfirmation.module.css create mode 100644 components/HotelReservation/BookingConfirmation/index.tsx create mode 100644 components/Icons/Coffee.tsx create mode 100644 components/Icons/Contract.tsx create mode 100644 components/Icons/CreditCardAdd.tsx create mode 100644 components/Icons/Discount.tsx create mode 100644 components/Icons/DoorClosed.tsx diff --git a/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.module.css index 06b39ef41..bb1cf59f0 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.module.css +++ b/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.module.css @@ -1,157 +1,7 @@ -.details, -.guest, -.header, -.hgroup, -.hotel, -.list, -.main, -.section, -.receipt, -.total { +.main { display: flex; flex-direction: column; -} - -.main { gap: var(--Spacing-x5); margin: 0 auto; - width: min(calc(100dvw - (var(--Spacing-x3) * 2)), 708px); -} - -.header, -.hgroup { - align-items: center; -} - -.header { - gap: var(--Spacing-x3); -} - -.hgroup { - gap: var(--Spacing-x-half); -} - -.body { - max-width: 560px; -} - -.section { - display: flex; - flex-direction: column; - gap: var(--Spacing-x9); -} - -.booking { - display: grid; - gap: var(--Spacing-x-one-and-half); - grid-template-areas: - "image" - "details" - "actions"; -} - -.actions, -.details { - background-color: var(--Base-Surface-Subtle-Normal); - border-radius: var(--Corner-radius-Medium); -} - -.details { - gap: var(--Spacing-x3); - grid-area: details; - padding: var(--Spacing-x2); -} - -.tempImage { - align-items: center; - background-color: lightgrey; - border-radius: var(--Corner-radius-Medium); - display: flex; - grid-area: image; - justify-content: center; -} - -.actions { - display: grid; - grid-area: actions; - padding: var(--Spacing-x1) var(--Spacing-x2); -} - -.list { - gap: var(--Spacing-x-one-and-half); - list-style: none; - margin: 0; - padding: 0; -} - -.listItem { - align-items: center; - display: flex; - gap: var(--Spacing-x1); - justify-content: space-between; -} - -.summary { - display: grid; - gap: var(--Spacing-x3); -} - -.guest, -.hotel { - gap: var(--Spacing-x-half); -} - -.receipt, -.total { - gap: var(--Spacing-x2); -} - -.divider { - grid-column: 1 / -1; -} - -@media screen and (max-width: 767px) { - .actions { - & > button[class*="btn"][class*="icon"][class*="small"] { - border-bottom: 1px solid var(--Base-Border-Subtle); - border-radius: 0; - justify-content: space-between; - - &:last-of-type { - border-bottom: none; - } - - & > svg { - order: 2; - } - } - } - - .tempImage { - min-height: 250px; - } -} - -@media screen and (min-width: 768px) { - .booking { - grid-template-areas: - "details image" - "actions actions"; - grid-template-columns: 1fr minmax(256px, min(256px, 100%)); - } - - .actions { - gap: var(--Spacing-x7); - grid-template-columns: repeat(auto-fit, minmax(50px, auto)); - justify-content: center; - padding: var(--Spacing-x1) var(--Spacing-x3); - } - - .details { - padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x2); - } - - .summary { - grid-template-columns: 1fr 1fr; - } + width: min(calc(100dvw - (var(--Spacing-x3) * 2)), 948px); } diff --git a/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.tsx index c4a682da9..b81003b9d 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.tsx @@ -1,20 +1,7 @@ -import { dt } from "@/lib/dt" -import { serverClient } from "@/lib/trpc/server" +import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests" -import { - CalendarIcon, - DownloadIcon, - ImageIcon, - PrinterIcon, -} from "@/components/Icons" -import Button from "@/components/TempDesignSystem/Button" -import Divider from "@/components/TempDesignSystem/Divider" -import Link from "@/components/TempDesignSystem/Link" -import Body from "@/components/TempDesignSystem/Text/Body" -import Caption from "@/components/TempDesignSystem/Text/Caption" -import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" -import Title from "@/components/TempDesignSystem/Text/Title" -import { getIntl } from "@/i18n" +import BookingConfirmation from "@/components/HotelReservation/BookingConfirmation" +import { setLang } from "@/i18n/serverContext" import styles from "./page.module.css" @@ -24,269 +11,12 @@ export default async function BookingConfirmationPage({ params, searchParams, }: PageArgs) { + setLang(params.lang) const confirmationNumber = searchParams.confirmationNumber - const booking = await serverClient().booking.confirmation({ - confirmationNumber, - }) - - if (!booking) { - return null - } - - const intl = await getIntl() - const text = intl.formatMessage( - { id: "booking.confirmation.text" }, - { - emailLink: (str) => ( - - {str} - - ), - } - ) - - const fromDate = dt(booking.checkInDate).locale(params.lang) - const toDate = dt(booking.checkOutDate).locale(params.lang) - const nights = intl.formatMessage( - { id: "booking.nights" }, - { - totalNights: dt(toDate.format("YYYY-MM-DD")).diff( - dt(fromDate.format("YYYY-MM-DD")), - "days" - ), - } - ) - + void getBookingConfirmation(confirmationNumber) return (
-
-
- - {intl.formatMessage({ id: "booking.confirmation.title" })} - - - {booking.hotel?.data.attributes.name} - -
- - {text} - -
-
-
-
-
- - {intl.formatMessage( - { id: "Reference #{bookingNr}" }, - { bookingNr: booking.confirmationNumber } - )} - -
-
    -
  • - {intl.formatMessage({ id: "Check-in" })} - - {`${fromDate.format("ddd, D MMM")} ${intl.formatMessage({ id: "from" })} ${fromDate.format("HH:mm")}`} - -
  • -
  • - {intl.formatMessage({ id: "Check-out" })} - - {`${toDate.format("ddd, D MMM")} ${intl.formatMessage({ id: "from" })} ${toDate.format("HH:mm")}`} - -
  • -
  • - {intl.formatMessage({ id: "Breakfast" })} - - {booking.temp.breakfastFrom} - {booking.temp.breakfastTo} - -
  • -
  • - {intl.formatMessage({ id: "Cancellation policy" })} - - {intl.formatMessage({ id: booking.temp.cancelPolicy })} - -
  • -
  • - {intl.formatMessage({ id: "Rebooking" })} - {`${intl.formatMessage({ id: "Free until" })} ${fromDate.subtract(3, "day").format("ddd, D MMM")}`} -
  • -
-
- -
- - - -
-
-
-
- - {intl.formatMessage({ id: "Guest" })} - -
- - {`${booking.guest.firstName} ${booking.guest.lastName}${booking.guest.memberbershipNumber ? ` (${intl.formatMessage({ id: "member no" })} ${booking.guest.memberbershipNumber})` : ""}`} - - {booking.guest.email} - - {booking.guest.phoneNumber} - -
-
-
- - {intl.formatMessage({ id: "Your hotel" })} - -
- - {booking.hotel?.data.attributes.name} - - - {booking.hotel?.data.attributes.contactInformation.email} - - - {booking.hotel?.data.attributes.contactInformation.phoneNumber} - -
-
- -
-
- - {`${booking.temp.room.type}, ${nights}`} - - {booking.temp.room.price} -
- {booking.temp.packages.map((pkg) => ( -
- - {pkg.name} - - {pkg.price} -
- ))} -
-
-
- - {intl.formatMessage({ id: "VAT" })} - - {booking.temp.room.vat} -
-
- - {intl.formatMessage({ id: "Total cost" })} - - - {" "} - {intl.formatMessage( - { id: "{amount} {currency}" }, - { - amount: intl.formatNumber(booking.totalPrice), - currency: booking.currencyCode, - } - )} - - - {`${intl.formatMessage({ id: "Approx." })} ${booking.temp.totalInEuro}`} - -
-
- -
- - {`${intl.formatMessage({ id: "Payment received" })} ${dt(booking.temp.payment).locale(params.lang).format("D MMM YYYY, h:mm z")}`} - - - {intl.formatMessage( - { id: "{card} ending with {cardno}" }, - { - card: "Mastercard", - cardno: "2202", - } - )} - -
-
-
+
) } - -// const { email, hotel, stay, summary } = tempConfirmationData - -// const confirmationNumber = useMemo(() => { -// if (typeof window === "undefined") return "" - -// const storedConfirmationNumber = sessionStorage.getItem( -// BOOKING_CONFIRMATION_NUMBER -// ) -// TODO: cleanup stored values -// sessionStorage.removeItem(BOOKING_CONFIRMATION_NUMBER) -// return storedConfirmationNumber -// }, []) - -// const bookingStatus = useHandleBookingStatus( -// confirmationNumber, -// BookingStatusEnum.BookingCompleted, -// maxRetries, -// retryInterval -// ) - -// if ( -// confirmationNumber === null || -// bookingStatus.isError || -// (bookingStatus.isFetched && !bookingStatus.data) -// ) { -// // TODO: handle error -// throw new Error("Error fetching booking status") -// } - -// if ( -// bookingStatus.data?.reservationStatus === BookingStatusEnum.BookingCompleted -// ) { -// return diff --git a/components/HotelReservation/BookingConfirmation/Actions/actions.module.css b/components/HotelReservation/BookingConfirmation/Actions/actions.module.css new file mode 100644 index 000000000..1e6ba4fb6 --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/Actions/actions.module.css @@ -0,0 +1,34 @@ +.actions { + background-color: var(--Base-Surface-Subtle-Normal); + border-radius: var(--Corner-radius-Medium); + display: grid; + grid-area: actions; + padding: var(--Spacing-x1) var(--Spacing-x2); +} + +@media screen and (max-width: 767px) { + .actions { + & > button[class*="btn"][class*="icon"][class*="small"] { + border-bottom: 1px solid var(--Base-Border-Subtle); + border-radius: 0; + justify-content: space-between; + + &:last-of-type { + border-bottom: none; + } + + & > svg { + order: 2; + } + } + } +} + +@media screen and (min-width: 768px) { + .actions { + gap: var(--Spacing-x1); + grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr; + justify-content: center; + padding: var(--Spacing-x1) var(--Spacing-x3); + } +} diff --git a/components/HotelReservation/BookingConfirmation/Actions/index.tsx b/components/HotelReservation/BookingConfirmation/Actions/index.tsx new file mode 100644 index 000000000..7ad9bc2a0 --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/Actions/index.tsx @@ -0,0 +1,38 @@ +import { + CalendarIcon, + ContractIcon, + DownloadIcon, + PrinterIcon, +} from "@/components/Icons" +import Button from "@/components/TempDesignSystem/Button" +import Divider from "@/components/TempDesignSystem/Divider" +import { getIntl } from "@/i18n" + +import styles from "./actions.module.css" + +export default async function Actions() { + const intl = await getIntl() + return ( +
+ + + + + + + +
+ ) +} diff --git a/components/HotelReservation/BookingConfirmation/Details/details.module.css b/components/HotelReservation/BookingConfirmation/Details/details.module.css new file mode 100644 index 000000000..13460ac82 --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/Details/details.module.css @@ -0,0 +1,31 @@ +.details { + background-color: var(--Base-Surface-Subtle-Normal); + border-radius: var(--Corner-radius-Medium); + display: flex; + flex-direction: column; + gap: var(--Spacing-x3); + grid-area: details; + padding: var(--Spacing-x2); +} + +.list { + display: flex; + flex-direction: column; + gap: var(--Spacing-x-one-and-half); + list-style: none; + margin: 0; + padding: 0; +} + +.listItem { + align-items: center; + display: flex; + gap: var(--Spacing-x1); + justify-content: space-between; +} + +@media screen and (min-width: 768px) { + .details { + padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x2); + } +} diff --git a/components/HotelReservation/BookingConfirmation/Details/index.tsx b/components/HotelReservation/BookingConfirmation/Details/index.tsx new file mode 100644 index 000000000..956ad8e45 --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/Details/index.tsx @@ -0,0 +1,61 @@ +import { dt } from "@/lib/dt" +import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests" + +import Body from "@/components/TempDesignSystem/Text/Body" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import { getIntl } from "@/i18n" +import { getLang } from "@/i18n/serverContext" + +import styles from "./details.module.css" + +import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" + +export default async function Details({ + confirmationNumber, +}: BookingConfirmationProps) { + const intl = await getIntl() + const lang = getLang() + const { booking } = await getBookingConfirmation(confirmationNumber) + + const fromDate = dt(booking.checkInDate).locale(lang) + const toDate = dt(booking.checkOutDate).locale(lang) + + return ( +
+
+ + {intl.formatMessage( + { id: "Reference #{bookingNr}" }, + { bookingNr: booking.confirmationNumber } + )} + +
+
    +
  • + {intl.formatMessage({ id: "Check-in" })} + + {`${fromDate.format("ddd, D MMM")} ${intl.formatMessage({ id: "from" })} ${fromDate.format("HH:mm")}`} + +
  • +
  • + {intl.formatMessage({ id: "Check-out" })} + + {`${toDate.format("ddd, D MMM")} ${intl.formatMessage({ id: "from" })} ${toDate.format("HH:mm")}`} + +
  • +
  • + {intl.formatMessage({ id: "Breakfast" })} + N/A +
  • +
  • + {intl.formatMessage({ id: "Cancellation policy" })} + N/A +
  • +
  • + {intl.formatMessage({ id: "Rebooking" })} + N/A +
  • +
+
+ ) +} diff --git a/components/HotelReservation/BookingConfirmation/Header/header.module.css b/components/HotelReservation/BookingConfirmation/Header/header.module.css new file mode 100644 index 000000000..22c50ac1f --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/Header/header.module.css @@ -0,0 +1,18 @@ +.header, +.hgroup { + align-items: center; + display: flex; + flex-direction: column; +} + +.header { + gap: var(--Spacing-x3); +} + +.hgroup { + gap: var(--Spacing-x-half); +} + +.body { + max-width: 560px; +} diff --git a/components/HotelReservation/BookingConfirmation/Header/index.tsx b/components/HotelReservation/BookingConfirmation/Header/index.tsx new file mode 100644 index 000000000..ac3b94d86 --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/Header/index.tsx @@ -0,0 +1,60 @@ +import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests" + +import Link from "@/components/TempDesignSystem/Link" +import BiroScript from "@/components/TempDesignSystem/Text/BiroScript" +import Body from "@/components/TempDesignSystem/Text/Body" +import Title from "@/components/TempDesignSystem/Text/Title" +import { getIntl } from "@/i18n" + +import styles from "./header.module.css" + +import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" + +export default async function Header({ + confirmationNumber, +}: BookingConfirmationProps) { + const intl = await getIntl() + const { hotel } = await getBookingConfirmation(confirmationNumber) + + const text = intl.formatMessage( + { id: "booking.confirmation.text" }, + { + emailLink: (str) => ( + + {str} + + ), + } + ) + + return ( +
+
+ + {intl.formatMessage({ id: "See you soon!" })} + + + {intl.formatMessage({ id: "booking.confirmation.title" })} + + + {hotel.name} + +
+ + {text} + +
+ ) +} diff --git a/components/HotelReservation/BookingConfirmation/HotelImage/image.module.css b/components/HotelReservation/BookingConfirmation/HotelImage/image.module.css new file mode 100644 index 000000000..34e5748bb --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/HotelImage/image.module.css @@ -0,0 +1,7 @@ +.imageContainer { + align-items: center; + border-radius: var(--Corner-radius-Medium); + display: flex; + grid-area: image; + justify-content: center; +} diff --git a/components/HotelReservation/BookingConfirmation/HotelImage/index.tsx b/components/HotelReservation/BookingConfirmation/HotelImage/index.tsx new file mode 100644 index 000000000..b6d99889e --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/HotelImage/index.tsx @@ -0,0 +1,24 @@ +import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests" + +import Image from "@/components/Image" + +import styles from "./image.module.css" + +import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" + +export default async function HotelImage({ + confirmationNumber, +}: BookingConfirmationProps) { + const { hotel } = await getBookingConfirmation(confirmationNumber) + return ( + + ) +} diff --git a/components/HotelReservation/BookingConfirmation/Summary/index.tsx b/components/HotelReservation/BookingConfirmation/Summary/index.tsx new file mode 100644 index 000000000..05800f2d2 --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/Summary/index.tsx @@ -0,0 +1,154 @@ +import { profile } from "@/constants/routes/myPages" +import { dt } from "@/lib/dt" +import { + getBookingConfirmation, + getProfileSafely, +} from "@/lib/trpc/memoizedRequests" + +import { CreditCardAddIcon, EditIcon, PersonIcon } from "@/components/Icons" +import Divider from "@/components/TempDesignSystem/Divider" +import Link from "@/components/TempDesignSystem/Link" +import Body from "@/components/TempDesignSystem/Text/Body" +import Caption from "@/components/TempDesignSystem/Text/Caption" +import { getIntl } from "@/i18n" +import { getLang } from "@/i18n/serverContext" + +import styles from "./summary.module.css" + +import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" +import { BreakfastPackageEnum } from "@/types/enums/breakfast" + +export default async function Summary({ + confirmationNumber, +}: BookingConfirmationProps) { + const intl = await getIntl() + const lang = getLang() + const { booking, hotel } = await getBookingConfirmation(confirmationNumber) + const user = await getProfileSafely() + const { firstName, lastName } = booking.guest + const membershipNumber = user?.membership?.membershipNumber + const totalNights = dt(booking.checkOutDate.setHours(0, 0, 0)).diff( + dt(booking.checkInDate.setHours(0, 0, 0)), + "days" + ) + + const breakfastPackage = booking.packages.find( + (pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST + ) + return ( +
+
+
+ + {intl.formatMessage({ id: "Guest" })} + + {`${firstName} ${lastName}`} + {membershipNumber ? ( + + {intl.formatMessage( + { id: "membership.no" }, + { membershipNumber } + )} + + ) : null} + {booking.guest.email} + {booking.guest.phoneNumber} +
+ {user ? ( + + + + {intl.formatMessage({ id: "Go to profile" })} + + + ) : null} +
+ +
+
+ + {intl.formatMessage({ id: "Payment" })} + + + {intl.formatMessage( + { id: "guest.paid" }, + { + amount: intl.formatNumber(booking.totalPrice), + currency: booking.currencyCode, + } + )} + + Date information N/A + Card information N/A +
+ {/* # href until more info */} + {user ? ( + + + + {intl.formatMessage({ id: "Save card to profile" })} + + + ) : null} +
+ +
+
+ + {intl.formatMessage({ id: "Booking" })} + + + N/A, {intl.formatMessage({ id: "booking.nights" }, { totalNights })} + ,{" "} + {intl.formatMessage( + { id: "booking.adults" }, + { totalAdults: booking.adults } + )} + + {breakfastPackage ? ( + + {intl.formatMessage({ id: "Breakfast added" })} + + ) : null} + Bedtype N/A +
+ {/* # href until more info */} + + + + {intl.formatMessage({ id: "Manage booking" })} + + +
+ +
+
+ + {intl.formatMessage({ id: "Hotel" })} + + {hotel.name} + + {`${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`} + + + {hotel.contactInformation.phoneNumber} + + + {`${intl.formatMessage({ id: "Longitude" }, { long: hotel.location.longitude })} ∙ ${intl.formatMessage({ id: "Latitude" }, { lat: hotel.location.latitude })}`} + +
+
+ + {hotel.contactInformation.websiteUrl} + + + {hotel.contactInformation.email} + +
+
+
+ ) +} diff --git a/components/HotelReservation/BookingConfirmation/Summary/summary.module.css b/components/HotelReservation/BookingConfirmation/Summary/summary.module.css new file mode 100644 index 000000000..f7f4e6635 --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/Summary/summary.module.css @@ -0,0 +1,31 @@ +.summary { + display: grid; + gap: var(--Spacing-x3); +} + +.container, +.textContainer { + display: flex; + flex-direction: column; +} + +.container { + gap: var(--Spacing-x-one-and-half); +} + +.textContainer { + gap: var(--Spacing-x-half); +} + +.container .textContainer .latLong { + padding-top: var(--Spacing-x1); +} + +.hotelLinks { + display: flex; + flex-direction: column; +} + +.summary .container .link { + gap: var(--Spacing-x1); +} diff --git a/components/HotelReservation/BookingConfirmation/TotalPrice/index.tsx b/components/HotelReservation/BookingConfirmation/TotalPrice/index.tsx new file mode 100644 index 000000000..d7ee0ff11 --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/TotalPrice/index.tsx @@ -0,0 +1,136 @@ +import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests" + +import { + CoffeeIcon, + DiscountIcon, + DoorClosedIcon, + PriceTagIcon, +} from "@/components/Icons" +import Divider from "@/components/TempDesignSystem/Divider" +import Body from "@/components/TempDesignSystem/Text/Body" +import Caption from "@/components/TempDesignSystem/Text/Caption" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import { getIntl } from "@/i18n" + +import styles from "./totalPrice.module.css" + +import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" +import { BreakfastPackageEnum } from "@/types/enums/breakfast" + +export default async function TotalPrice({ + confirmationNumber, +}: BookingConfirmationProps) { + const intl = await getIntl() + const { booking } = await getBookingConfirmation(confirmationNumber) + + const totalPrice = intl.formatNumber(booking.totalPrice, { + currency: booking.currencyCode, + style: "currency", + }) + const breakfastPackage = booking.packages.find( + (p) => p.code === BreakfastPackageEnum.REGULAR_BREAKFAST + ) + return ( +
+
+ + {intl.formatMessage({ id: "Total price" })} + + + {totalPrice} (~ EUR) + +
+
+
+ + + {`${intl.formatMessage({ id: "Room" })}, ${intl.formatMessage({ id: "booking.nights" }, { totalNights: 1 })}`} + + {totalPrice} +
+
+ + + {intl.formatMessage({ id: "Breakfast" })} + + + {breakfastPackage + ? intl.formatNumber(breakfastPackage.totalPrice, { + currency: breakfastPackage.currency, + style: "currency", + }) + : intl.formatMessage({ id: "No breakfast" })} + +
+
+ + + {intl.formatMessage({ id: "Member discount" })} + + N/A +
+
+ + + {intl.formatMessage({ id: "Points used" })} + + N/A +
+
+ +
+
+ + {intl.formatMessage({ id: "Price excl VAT" })} + + + {intl.formatNumber(booking.totalPriceExVat, { + currency: booking.currencyCode, + style: "currency", + })} + +
+
+ + {intl.formatMessage({ id: "VAT" })} + + {booking.vatPercentage}% +
+
+ + {intl.formatMessage({ id: "VAT amount" })} + + + {intl.formatNumber(booking.vatAmount, { + currency: booking.currencyCode, + style: "currency", + })} + +
+
+ + {intl.formatMessage({ id: "Price incl VAT" })} + + + {intl.formatNumber(booking.totalPrice, { + currency: booking.currencyCode, + style: "currency", + })} + +
+
+ + {intl.formatMessage({ id: "Payment method" })} + + N/A +
+
+ + {intl.formatMessage({ id: "Payment status" })} + + N/A +
+
+
+ ) +} diff --git a/components/HotelReservation/BookingConfirmation/TotalPrice/totalPrice.module.css b/components/HotelReservation/BookingConfirmation/TotalPrice/totalPrice.module.css new file mode 100644 index 000000000..0bb8cf70a --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/TotalPrice/totalPrice.module.css @@ -0,0 +1,14 @@ +.container { + background-color: var(--Base-Background-Primary-Normal); + border-radius: var(--Corner-radius-Large); + display: flex; + flex-direction: column; + gap: var(--Spacing-x3); + padding: var(--Spacing-x2) var(--Spacing-x3) var(--Spacing-x3); +} + +.items { + display: grid; + gap: var(--Spacing-x3) var(--Spacing-x1); + grid-template-columns: repeat(4, minmax(100px, 1fr)); +} diff --git a/components/HotelReservation/BookingConfirmation/bookingConfirmation.module.css b/components/HotelReservation/BookingConfirmation/bookingConfirmation.module.css new file mode 100644 index 000000000..51db3d1d8 --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/bookingConfirmation.module.css @@ -0,0 +1,23 @@ +.section { + display: flex; + flex-direction: column; + gap: var(--Spacing-x9); +} + +.booking { + display: grid; + gap: var(--Spacing-x-one-and-half); + grid-template-areas: + "image" + "details" + "actions"; +} + +@media screen and (min-width: 768px) { + .booking { + grid-template-areas: + "details image" + "actions actions"; + grid-template-columns: 1fr minmax(256px, min(256px, 100%)); + } +} diff --git a/components/HotelReservation/BookingConfirmation/index.tsx b/components/HotelReservation/BookingConfirmation/index.tsx new file mode 100644 index 000000000..4e0de5fce --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/index.tsx @@ -0,0 +1,31 @@ +import Actions from "./Actions" +import Details from "./Details" +import Header from "./Header" +import HotelImage from "./HotelImage" +import Summary from "./Summary" +import TotalPrice from "./TotalPrice" + +import styles from "./bookingConfirmation.module.css" + +import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" + +export default function BookingConfirmation({ + confirmationNumber, +}: BookingConfirmationProps) { + return ( + <> +
+
+
+
+ + +
+ {/* Supposed Ancillaries */} + + + {/* Supposed Info Card - Where should it come from?? */} +
+ + ) +} diff --git a/components/HotelReservation/EnterDetails/Details/index.tsx b/components/HotelReservation/EnterDetails/Details/index.tsx index cde67b1a6..7d2b33681 100644 --- a/components/HotelReservation/EnterDetails/Details/index.tsx +++ b/components/HotelReservation/EnterDetails/Details/index.tsx @@ -1,6 +1,5 @@ "use client" import { zodResolver } from "@hookform/resolvers/zod" -import { useCallback } from "react" import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" @@ -44,7 +43,6 @@ export default function Details({ user }: DetailsProps) { firstName: user?.firstName ?? initialData.firstName, lastName: user?.lastName ?? initialData.lastName, phoneNumber: user?.phoneNumber ?? initialData.phoneNumber, - //@ts-expect-error: We use a literal for join to be true or false, which does not convert to a boolean join: initialData.join, dateOfBirth: initialData.dateOfBirth, zipCode: initialData.zipCode, @@ -58,14 +56,6 @@ export default function Details({ user }: DetailsProps) { const completeStep = useEnterDetailsStore((state) => state.completeStep) - const onSubmit = useCallback( - async function (values: DetailsSchema) { - completeStep(values) - }, - - [completeStep] - ) - return (
@@ -77,7 +67,7 @@ export default function Details({ user }: DetailsProps) {
(false), zipCode: z.string().optional(), dateOfBirth: z.string().optional(), termsAccepted: z.boolean().default(false), @@ -21,10 +21,10 @@ export const notJoinDetailsSchema = baseDetailsSchema.merge( export const joinDetailsSchema = baseDetailsSchema.merge( z.object({ - join: z.literal(true), + join: z.literal(true), zipCode: z.string().min(1, { message: "Zip code is required" }), dateOfBirth: z.string().min(1, { message: "Date of birth is required" }), - termsAccepted: z.literal(true, { + termsAccepted: z.literal(true, { errorMap: (err, ctx) => { switch (err.code) { case "invalid_literal": diff --git a/components/Icons/Coffee.tsx b/components/Icons/Coffee.tsx new file mode 100644 index 000000000..b3ee54f38 --- /dev/null +++ b/components/Icons/Coffee.tsx @@ -0,0 +1,23 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function CoffeeIcon({ className, color, ...props }: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/Contract.tsx b/components/Icons/Contract.tsx new file mode 100644 index 000000000..0c8d41e52 --- /dev/null +++ b/components/Icons/Contract.tsx @@ -0,0 +1,27 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function ContractIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/CreditCardAdd.tsx b/components/Icons/CreditCardAdd.tsx new file mode 100644 index 000000000..c6c866562 --- /dev/null +++ b/components/Icons/CreditCardAdd.tsx @@ -0,0 +1,27 @@ +import * as variants from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function CreditCardAddIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = variants.iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/Discount.tsx b/components/Icons/Discount.tsx new file mode 100644 index 000000000..995950ba6 --- /dev/null +++ b/components/Icons/Discount.tsx @@ -0,0 +1,27 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function DiscountIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/DoorClosed.tsx b/components/Icons/DoorClosed.tsx new file mode 100644 index 000000000..307a7f7f4 --- /dev/null +++ b/components/Icons/DoorClosed.tsx @@ -0,0 +1,27 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function DoorClosedIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/index.tsx b/components/Icons/index.tsx index a8be6f48f..f7bdb1abb 100644 --- a/components/Icons/index.tsx +++ b/components/Icons/index.tsx @@ -25,15 +25,20 @@ export { default as ChevronRightSmallIcon } from "./ChevronRightSmall" export { default as CityIcon } from "./City" export { default as CloseIcon } from "./Close" export { default as CloseLargeIcon } from "./CloseLarge" +export { default as CoffeeIcon } from "./Coffee" export { default as CoffeeAltIcon } from "./CoffeeAlt" export { default as ConciergeIcon } from "./Concierge" +export { default as ContractIcon } from "./Contract" export { default as ConvenienceStore24hIcon } from "./ConvenienceStore24h" export { default as CoolIcon } from "./Cool" export { default as CreditCard } from "./CreditCard" +export { default as CreditCardAddIcon } from "./CreditCardAdd" export { default as CrossCircle } from "./CrossCircle" export { default as CulturalIcon } from "./Cultural" export { default as DeleteIcon } from "./Delete" export { default as DeskIcon } from "./Desk" +export { default as DiscountIcon } from "./Discount" +export { default as DoorClosedIcon } from "./DoorClosed" export { default as DoorOpenIcon } from "./DoorOpen" export { default as DownloadIcon } from "./Download" export { default as DresserIcon } from "./Dresser" diff --git a/components/TempDesignSystem/Text/Subtitle/subtitle.module.css b/components/TempDesignSystem/Text/Subtitle/subtitle.module.css index d558d36ca..72842605f 100644 --- a/components/TempDesignSystem/Text/Subtitle/subtitle.module.css +++ b/components/TempDesignSystem/Text/Subtitle/subtitle.module.css @@ -67,6 +67,10 @@ color: var(--UI-Text-Medium-contrast); } +.uiTextPlaceholder { + color: var(--UI-Text-Placeholder); +} + .red { color: var(--Scandic-Brand-Scandic-Red); } diff --git a/components/TempDesignSystem/Text/Subtitle/variants.ts b/components/TempDesignSystem/Text/Subtitle/variants.ts index b29b4405d..253907fd6 100644 --- a/components/TempDesignSystem/Text/Subtitle/variants.ts +++ b/components/TempDesignSystem/Text/Subtitle/variants.ts @@ -11,6 +11,7 @@ const config = { pale: styles.pale, uiTextHighContrast: styles.uiTextHighContrast, uiTextMediumContrast: styles.uiTextMediumContrast, + uiTextPlaceholder: styles.uiTextPlaceholder, red: styles.red, }, textAlign: { diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index d1cd64e00..48f808528 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -45,6 +45,7 @@ "Birth date": "Birth date", "Book": "Book", "Book reward night": "Book reward night", + "Booking": "Booking", "Booking number": "Booking number", "Breakfast": "Breakfast", "Breakfast buffet": "Breakfast buffet", @@ -56,9 +57,12 @@ "Business": "Business", "Cancel": "Cancel", "Cancellation policy": "Cancellation policy", + "Change room": "Change room", "Check in": "Check in", "Check out": "Check out", "Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.": "Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.", + "Check-in": "Check-in", + "Check-out": "Check-out", "Child age is required": "Child age is required", "Children": "Children", "Choose room": "Choose room", @@ -141,6 +145,7 @@ "Go back to edit": "Go back to edit", "Go back to overview": "Go back to overview", "Go to My Benefits": "Go to My Benefits", + "Go to profile": "Go to profile", "Guarantee booking with credit card": "Guarantee booking with credit card", "Guest": "Guest", "Guest information": "Guest information", @@ -168,6 +173,7 @@ "Language": "Language", "Last name": "Last name", "Latest searches": "Latest searches", + "Latitude": "Latitude {lat}", "Left": "left", "Level": "Level", "Level 1": "Level 1", @@ -184,19 +190,23 @@ "Log in here": "Log in here", "Log in/Join": "Log in/Join", "Log out": "Log out", + "Longitude": "Longitude {long}", "MY SAVED CARDS": "MY SAVED CARDS", "Main menu": "Main menu", + "Manage booking": "Manage booking", "Manage preferences": "Manage preferences", "Map": "Map", "Map of HOTEL_NAME": "Map of {hotelName}", "Marketing city": "Marketing city", "Meetings & Conferences": "Meetings & Conferences", + "Member discount": "Member discount", "Member price": "Member price", "Member price from": "Member price from", "Members": "Members", "Membership ID": "Membership ID", "Membership ID copied to clipboard": "Membership ID copied to clipboard", "Membership cards": "Membership cards", + "Membership no": "Membership no", "Membership terms and conditions": "Membership terms and conditions", "Menu": "Menu", "Modify": "Modify", @@ -245,7 +255,9 @@ "Payment": "Payment", "Payment Guarantee": "Payment Guarantee", "Payment info": "Payment info", + "Payment method": "Payment method", "Payment received": "Payment received", + "Payment status": "Payment status", "Pet Room": "Pet room", "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Pet-friendly rooms have an additional fee of 20 EUR per stay", "Phone": "Phone", @@ -259,10 +271,13 @@ "Points may take up to 10 days to be displayed.": "Points may take up to 10 days to be displayed.", "Points needed to level up": "Points needed to level up", "Points needed to stay on level": "Points needed to stay on level", + "Points used": "Points used", "Practical information": "Practial information", "Previous": "Previous", "Previous victories": "Previous victories", "Price details": "Price details", + "Price excl VAT": "Price excl VAT", + "Price incl VAT": "Price incl VAT", "Print confirmation": "Print confirmation", "Proceed to login": "Proceed to login", "Proceed to payment method": "Proceed to payment method", @@ -291,6 +306,7 @@ "Rooms & Guests": "Rooms & Guests", "Sauna and gym": "Sauna and gym", "Save": "Save", + "Save card to profile": "Save card to profile", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", "Scandic's Privacy Policy.": "Scandic's Privacy Policy.", @@ -302,6 +318,7 @@ "See on map": "See on map", "See room details": "See room details", "See rooms": "See rooms", + "See you soon!": "See you soon!", "Select a country": "Select a country", "Select bed": "Select bed", "Select breakfast options": "Select breakfast options", @@ -353,9 +370,11 @@ "Use code/voucher": "Use code/voucher", "User information": "User information", "VAT": "VAT", + "VAT amount": "VAT amount", "Valid through": "Valid through", "View as list": "View as list", "View as map": "View as map", + "View terms": "View terms", "View your booking": "View your booking", "Visiting address": "Visiting address", "We could not add a card right now, please try again later.": "We could not add a card right now, please try again later.", @@ -419,12 +438,13 @@ "from": "from", "guaranteeing": "guaranteeing", "guest": "guest", + "guest.paid": "{amount} {currency} has been paid", "guests": "guests", "hotelPages.rooms.roomCard.persons": "{totalOccupancy, plural, one {# person} other {# persons}}", "hotelPages.rooms.roomCard.seeRoomDetails": "See room details", "km to city center": "km to city center", "lowercase letter": "lowercase letter", - "member no": "member no", + "membership.no": "Scandic Friends No. {membershipNumber}", "n/a": "n/a", "next level:": "next level:", "night": "night", diff --git a/lib/trpc/memoizedRequests/index.ts b/lib/trpc/memoizedRequests/index.ts index 474cc9768..a90dc6907 100644 --- a/lib/trpc/memoizedRequests/index.ts +++ b/lib/trpc/memoizedRequests/index.ts @@ -143,3 +143,9 @@ export const getBreakfastPackages = cache(async function getMemoizedPackages( ) { return serverClient().hotel.packages.breakfast(input) }) + +export const getBookingConfirmation = cache( + function getMemoizedBookingConfirmation(confirmationNumber: string) { + return serverClient().booking.confirmation({ confirmationNumber }) + } +) diff --git a/server/routers/booking/output.ts b/server/routers/booking/output.ts index e67c8805b..5fd34ac00 100644 --- a/server/routers/booking/output.ts +++ b/server/routers/booking/output.ts @@ -2,6 +2,10 @@ import { z } from "zod" import { ChildBedTypeEnum } from "@/constants/booking" +import { phoneValidator } from "@/utils/phoneValidator" + +import { CurrencyEnum } from "@/types/enums/currency" + // MUTATION export const createBookingSchema = z .object({ @@ -42,20 +46,20 @@ const extraBedTypesSchema = z.object({ }) const guestSchema = z.object({ + email: z.string().email().nullable().default(""), firstName: z.string(), lastName: z.string(), - email: z.string().nullable(), - phoneNumber: z.string().nullable(), + phoneNumber: phoneValidator().nullable().default(""), }) -const packagesSchema = z.array( - z.object({ - accessibility: z.boolean().optional(), - allergyFriendly: z.boolean().optional(), - breakfast: z.boolean().optional(), - petFriendly: z.boolean().optional(), - }) -) +const packageSchema = z.object({ + code: z.string().default(""), + currency: z.nativeEnum(CurrencyEnum), + quantity: z.number().int(), + totalPrice: z.number(), + totalQuantity: z.number().int(), + unitPrice: z.number(), +}) export const bookingConfirmationSchema = z .object({ @@ -66,17 +70,21 @@ export const bookingConfirmationSchema = z checkOutDate: z.date({ coerce: true }), createDateTime: z.date({ coerce: true }), childrenAges: z.array(z.number()), - extraBedTypes: z.array(extraBedTypesSchema), + extraBedTypes: z.array(extraBedTypesSchema).default([]), computedReservationStatus: z.string(), confirmationNumber: z.string(), - currencyCode: z.string(), + currencyCode: z.nativeEnum(CurrencyEnum), guest: guestSchema, - hasPayRouting: z.boolean().optional(), hotelId: z.string(), - packages: packagesSchema, + packages: z.array(packageSchema), rateCode: z.string(), reservationStatus: z.string(), + roomPrice: z.number().int(), + roomTypeCode: z.string(), totalPrice: z.number(), + totalPriceExVat: z.number(), + vatAmount: z.number(), + vatPercentage: z.number(), }), id: z.string(), type: z.literal("booking"), diff --git a/server/routers/booking/query.ts b/server/routers/booking/query.ts index 17555f8c4..0780549bb 100644 --- a/server/routers/booking/query.ts +++ b/server/routers/booking/query.ts @@ -1,6 +1,7 @@ import { metrics } from "@opentelemetry/api" import * as api from "@/lib/api" +import { dt } from "@/lib/dt" import { badRequestError, serverErrorByStatus } from "@/server/errors/trpc" import { router, serviceProcedure } from "@/server/trpc" @@ -87,6 +88,28 @@ export const bookingQueryRouter = router({ ctx.serviceToken ) + if (!hotelData) { + getBookingConfirmationFailCounter.add(1, { + confirmationNumber, + hotelId: booking.data.hotelId, + error_type: "http_error", + error: "Couldn`t get hotel", + }) + console.error( + "api.booking.confirmation error", + JSON.stringify({ + query: { confirmationNumber, hotelId: booking.data.hotelId }, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text: "Couldn`t get hotel", + }, + }) + ) + + throw serverErrorByStatus(404) + } + getBookingConfirmationSuccessCounter.add(1, { confirmationNumber }) console.info( "api.booking.confirmation success", @@ -95,44 +118,31 @@ export const bookingQueryRouter = router({ }) ) + /** + * Add hotels check in and out times to booking check in and out date + * as that is date only (YYYY-MM-DD) + */ + const checkInTime = + hotelData.data.attributes.hotelFacts.checkin.checkInTime + const [checkInHour, checkInMinute] = checkInTime.split(":") + const checkIn = dt(booking.data.checkInDate) + .set("hour", Number(checkInHour)) + .set("minute", Number(checkInMinute)) + const checkOutTime = + hotelData.data.attributes.hotelFacts.checkin.checkOutTime + const [checkOutHour, checkOutMinute] = checkOutTime.split(":") + const checkOut = dt(booking.data.checkOutDate) + .set("hour", Number(checkOutHour)) + .set("minute", Number(checkOutMinute)) + + booking.data.checkInDate = checkIn.toDate() + booking.data.checkOutDate = checkOut.toDate() + return { - ...booking.data, - hotel: hotelData, - temp: { - breakfastFrom: "06:30", - breakfastTo: "11:00", - cancelPolicy: "Free rebooking", - fromDate: "2024-10-21 14:00", - packages: [ - { - name: "Breakfast buffet", - price: "150 SEK", - }, - { - name: "Member discount", - price: "-297 SEK", - }, - { - name: "Points used / remaining", - price: "0 / 1044", - }, - ], - payment: "2024-08-09 1:47", - room: { - price: "2 589 SEK", - type: "Cozy Cabin", - vat: "684,79 SEK", - }, - toDate: "2024-10-22 11:00", - total: "2 739 SEK", - totalInEuro: "265 EUR", - }, - guest: { - email: "sarah.obrian@gmail.com", - firstName: "Sarah", - lastName: "O'Brian", - memberbershipNumber: "19822", - phoneNumber: "+46702446688", + booking: booking.data, + hotel: { + ...hotelData.data.attributes, + included: hotelData.included, }, } }), diff --git a/server/routers/hotels/output.ts b/server/routers/hotels/output.ts index badbf8f42..1859284e4 100644 --- a/server/routers/hotels/output.ts +++ b/server/routers/hotels/output.ts @@ -381,11 +381,54 @@ const merchantInformationSchema = z.object({ }), }) +const hotelFacilityDetailSchema = z + .object({ + description: z.string(), + heading: z.string(), + }) + .optional() + +/** Possibly more values */ +const hotelFacilityDetailsSchema = z.object({ + breakfast: hotelFacilityDetailSchema, + checkout: hotelFacilityDetailSchema, + gym: hotelFacilityDetailSchema, + internet: hotelFacilityDetailSchema, + laundry: hotelFacilityDetailSchema, + luggage: hotelFacilityDetailSchema, + shop: hotelFacilityDetailSchema, + telephone: hotelFacilityDetailSchema, +}) + +const hotelInformationSchema = z + .object({ + description: z.string(), + heading: z.string(), + link: z.string().optional(), + }) + .optional() + +const hotelInformationsSchema = z.object({ + accessibility: hotelInformationSchema, + safety: hotelInformationSchema, + sustainability: hotelInformationSchema, +}) + +const hotelFactsSchema = z.object({ + checkin: checkinSchema, + ecoLabels: ecoLabelsSchema, + hotelFacilityDetail: hotelFacilityDetailsSchema.default({}), + hotelInformation: hotelInformationsSchema.default({}), + interior: interiorSchema, + receptionHours: receptionHoursSchema, + yearBuilt: z.string(), +}) + // NOTE: Find schema at: https://aks-test.scandichotels.com/hotel/swagger/v1/index.html export const getHotelDataSchema = z.object({ data: z.object({ id: z.string(), - type: z.string(), // No enum here but the standard return appears to be "hotels". + type: z.literal("hotels"), // No enum here but the standard return appears to be "hotels". language: z.string().transform((val) => { const lang = toLang(val) if (!lang) { @@ -394,44 +437,41 @@ export const getHotelDataSchema = z.object({ return lang }), attributes: z.object({ - name: z.string(), - operaId: z.string(), - keywords: z.array(z.string()), - isPublished: z.boolean(), + accessibilityElevatorPitchText: z.string().optional(), + address: addressSchema, cityId: z.string(), cityName: z.string(), - ratings: ratingsSchema, - address: addressSchema, + conferencesAndMeetings: facilitySchema.optional(), contactInformation: contactInformationSchema, - hotelFacts: z.object({ - checkin: checkinSchema, - ecoLabels: ecoLabelsSchema, - interior: interiorSchema, - receptionHours: receptionHoursSchema, - yearBuilt: z.string(), - }), - location: locationSchema, - hotelContent: hotelContentSchema, detailedFacilities: z .array(detailedFacilitySchema) .transform((facilities) => facilities.sort((a, b) => b.sortOrder - a.sortOrder) ), + gallery: gallerySchema.optional(), + healthAndWellness: facilitySchema.optional(), healthFacilities: z.array(healthFacilitySchema), + hotelContent: hotelContentSchema, + hotelFacts: hotelFactsSchema, + hotelRoomElevatorPitchText: z.string().optional(), + hotelType: z.string().optional(), + isActive: z.boolean(), + isPublished: z.boolean(), + keywords: z.array(z.string()), + location: locationSchema, merchantInformationData: merchantInformationSchema, - rewardNight: rewardNightSchema, + name: z.string(), + operaId: z.string(), + parking: z.array(parkingSchema), pointsOfInterest: z .array(pointOfInterestSchema) .transform((pois) => pois.sort((a, b) => a.distance - b.distance)), - parking: z.array(parkingSchema), - specialNeedGroups: z.array(specialNeedGroupSchema), + ratings: ratingsSchema, + rewardNight: rewardNightSchema, + restaurantImages: facilitySchema.optional(), socialMedia: socialMediaSchema, specialAlerts: specialAlertsSchema, - isActive: z.boolean(), - conferencesAndMeetings: facilitySchema.optional(), - healthAndWellness: facilitySchema.optional(), - restaurantImages: facilitySchema.optional(), - gallery: gallerySchema.optional(), + specialNeedGroups: z.array(specialNeedGroupSchema), }), relationships: relationshipsSchema, }), @@ -631,7 +671,7 @@ export const apiCitiesByCountrySchema = z.object({ }) export interface CitiesByCountry - extends z.output {} + extends z.output { } export type CitiesGroupedByCountry = Record export const apiCountriesSchema = z.object({ @@ -661,7 +701,7 @@ export const apiCountriesSchema = z.object({ }), }) -export interface Countries extends z.output {} +export interface Countries extends z.output { } export const apiLocationCitySchema = z.object({ attributes: z.object({ @@ -802,10 +842,7 @@ export const breakfastPackageSchema = z.object({ description: z.string(), localPrice: breakfastPackagePriceSchema, requestedPrice: breakfastPackagePriceSchema, - packageType: z.enum([ - PackageTypeEnum.BreakfastAdult, - PackageTypeEnum.BreakfastChildren, - ]), + packageType: z.literal(PackageTypeEnum.BreakfastAdult), }) export const breakfastPackagesSchema = z diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index e59d61a02..39771b1c8 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -1062,20 +1062,10 @@ export const hotelQueryRouter = router({ user.membership && ["L6", "L7"].includes(user.membership.membershipLevel) ) { - const originalBreakfastPackage = breakfastPackages.data.find( - (pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST - ) const freeBreakfastPackage = breakfastPackages.data.find( (pkg) => pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST ) - if (freeBreakfastPackage && freeBreakfastPackage.localPrice) { - if ( - originalBreakfastPackage && - originalBreakfastPackage.localPrice - ) { - freeBreakfastPackage.localPrice.price = - originalBreakfastPackage.localPrice.price - } + if (freeBreakfastPackage?.localPrice) { return [freeBreakfastPackage] } } diff --git a/server/routers/user/output.ts b/server/routers/user/output.ts index cf4b57e16..a9e7a0416 100644 --- a/server/routers/user/output.ts +++ b/server/routers/user/output.ts @@ -84,7 +84,7 @@ export const getStaysSchema = z.object({ relationships: z.object({ hotel: z.object({ links: z.object({ - related: z.string(), + related: z.string().nullable().optional(), }), data: z.object({ id: z.string(), diff --git a/stores/enter-details.ts b/stores/enter-details.ts index 8b81a2947..c6d69ab7e 100644 --- a/stores/enter-details.ts +++ b/stores/enter-details.ts @@ -38,7 +38,7 @@ interface EnterDetailsState { step: StepEnum, updatedData?: Record< string, - string | boolean | BreakfastPackage | BedTypeSchema + string | boolean | number | BreakfastPackage | BedTypeSchema > ) => void setCurrentStep: (step: StepEnum) => void @@ -157,7 +157,6 @@ export function initEditDetailsState( const nextStep = state.steps[state.steps.indexOf(state.currentStep) + 1] - // @ts-expect-error: ts has a hard time understanding that "false | true" equals "boolean" state.userData = { ...state.userData, ...updatedData, diff --git a/types/components/hotelReservation/bookingConfirmation/bookingConfirmation.ts b/types/components/hotelReservation/bookingConfirmation/bookingConfirmation.ts index 8d81b458d..aa178efe5 100644 --- a/types/components/hotelReservation/bookingConfirmation/bookingConfirmation.ts +++ b/types/components/hotelReservation/bookingConfirmation/bookingConfirmation.ts @@ -1,40 +1,3 @@ -export type BookingConfirmation = { - email: string - hotel: { - name: string - address: string - location: string - phone: string - image: string - checkIn: string - checkOut: string - breakfast: { - start: string - end: string - } - } - stay: { - nights: number - start: string - end: string - } - summary: { - roomType: string - bedType: string - breakfast: string - flexibility: string - } -} - -export type IntroSectionProps = { - email: BookingConfirmation["email"] -} - -export type StaySectionProps = { - hotel: BookingConfirmation["hotel"] - stay: BookingConfirmation["stay"] -} - -export type SummarySectionProps = { - summary: BookingConfirmation["summary"] +export interface BookingConfirmationProps { + confirmationNumber: string }