From 8b32abbefc81dd79f7136904afb9762c5c6b25e6 Mon Sep 17 00:00:00 2001 From: Erik Tiekstra Date: Fri, 2 May 2025 06:27:30 +0000 Subject: [PATCH 01/10] Fix/SW-1563 accessibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(SW-1563): Added new IconButton component to the design system and removed Icon variant inside the Button component * fix(SW-1563): Added buttons around clickable images and changed to design system components * fix(SW-1563): Renamed variants to match Figma * fix(SW-1563): Renamed AriaButton to ButtonRAC Approved-by: Michael Zetterberg Approved-by: Matilda Landström --- .../CityMap/HotelListItem/index.tsx | 6 +- .../DestinationPage/HotelMapCard/index.tsx | 10 +- .../HotelMapPage/Sidebar/index.tsx | 10 +- .../HotelPage/PreviewImages/index.tsx | 62 +++- .../PreviewImages/previewImages.module.css | 15 +- .../EnterDetails/Summary/UI/index.tsx | 8 +- .../RateSummary/MobileSummary/Summary.tsx | 5 +- .../RoomsHeader/RoomPackageFilter/Modal.tsx | 10 +- .../RoomsHeader/RoomPackageFilter/index.tsx | 6 +- .../Lightbox/FullView/fullView.module.css | 126 +++++++ .../{FullView.tsx => FullView/index.tsx} | 64 ++-- .../Lightbox/Gallery/gallery.module.css | 160 ++++++++ .../{Gallery.tsx => Gallery/index.tsx} | 147 ++++---- .../components/Lightbox/Lightbox.module.css | 348 ------------------ .../scandic-web/components/Lightbox/index.tsx | 2 +- .../components/Lightbox/lightbox.module.css | 57 +++ .../lib/components/Button/Button.stories.tsx | 25 +- .../lib/components/Button/Button.tsx | 2 - .../lib/components/Button/button.module.css | 9 +- .../lib/components/Button/variants.ts | 1 - .../IconButton/IconButton.stories.tsx | 141 +++++++ .../lib/components/IconButton/IconButton.tsx | 20 + .../IconButton/iconButton.module.css | 102 +++++ .../lib/components/IconButton/index.tsx | 1 + .../lib/components/IconButton/types.ts | 10 + .../lib/components/IconButton/variants.ts | 78 ++++ .../components/RateCard/Campaign/index.tsx | 6 +- .../lib/components/RateCard/Code/index.tsx | 6 +- .../RateCard/NoRateAvailable/index.tsx | 6 +- .../lib/components/RateCard/Points/index.tsx | 6 +- .../lib/components/RateCard/Regular/index.tsx | 6 +- packages/design-system/package.json | 1 + 32 files changed, 909 insertions(+), 547 deletions(-) create mode 100644 apps/scandic-web/components/Lightbox/FullView/fullView.module.css rename apps/scandic-web/components/Lightbox/{FullView.tsx => FullView/index.tsx} (65%) create mode 100644 apps/scandic-web/components/Lightbox/Gallery/gallery.module.css rename apps/scandic-web/components/Lightbox/{Gallery.tsx => Gallery/index.tsx} (58%) delete mode 100644 apps/scandic-web/components/Lightbox/Lightbox.module.css create mode 100644 apps/scandic-web/components/Lightbox/lightbox.module.css create mode 100644 packages/design-system/lib/components/IconButton/IconButton.stories.tsx create mode 100644 packages/design-system/lib/components/IconButton/IconButton.tsx create mode 100644 packages/design-system/lib/components/IconButton/iconButton.module.css create mode 100644 packages/design-system/lib/components/IconButton/index.tsx create mode 100644 packages/design-system/lib/components/IconButton/types.ts create mode 100644 packages/design-system/lib/components/IconButton/variants.ts 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 && ( <> + {rooms.map(({ room }, idx) => { 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/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/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", From 0cd2e9c89f92d59b1214a23fb96ee1c0759e90f9 Mon Sep 17 00:00:00 2001 From: Christian Andolf Date: Wed, 30 Apr 2025 10:52:53 +0200 Subject: [PATCH 02/10] fix(SW-2463): scroll to payment error --- .../Payment/PaymentAlert/index.tsx | 19 +++++++++++++++++-- .../Summary/Mobile/BottomSheet/index.tsx | 18 ++++++++++++------ apps/scandic-web/hooks/useStickyPosition.ts | 4 ++-- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentAlert/index.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentAlert/index.tsx index 3392eccf6..4f7c395b5 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentAlert/index.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentAlert/index.tsx @@ -1,13 +1,14 @@ "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 { useEnterDetailsStore } from "@/stores/enter-details" import Alert from "@/components/TempDesignSystem/Alert" +import useStickyPosition from "@/hooks/useStickyPosition" import styles from "./paymentAlert.module.css" @@ -64,16 +65,30 @@ export default function PaymentAlert({ isVisible = false }: PaymentAlertProps) { const { showAlert, errorMessage, severityLevel, discardAlert, setShowAlert } = useBookingErrorAlert() + const ref = useRef(null) + const { getTopOffset } = useStickyPosition() + useEffect(() => { if (isVisible) { setShowAlert(true) } }, [isVisible, setShowAlert]) + useEffect(() => { + const el = ref.current + + if (showAlert && el) { + window.scrollTo({ + top: el.offsetTop - getTopOffset(), + behavior: "smooth", + }) + } + }, [showAlert, getTopOffset]) + if (!showAlert) return null return ( -
    +
    ({ @@ -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/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, From f0dbf294d89ad51beb2d0b259aa58851e04614c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matilda=20Landstr=C3=B6m?= Date: Tue, 29 Apr 2025 10:20:47 +0200 Subject: [PATCH 03/10] feat(SW-2532): update room sidepeek amenities --- .../HotelPage/SidePeeks/Room/index.tsx | 31 ++++++++++------- .../HotelPage/SidePeeks/Room/room.module.css | 4 --- .../BookedRoomSidePeek/RoomDetails.tsx | 33 ++++++++++++++----- 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/apps/scandic-web/components/ContentType/HotelPage/SidePeeks/Room/index.tsx b/apps/scandic-web/components/ContentType/HotelPage/SidePeeks/Room/index.tsx index 752e46a48..89f46c0f5 100644 --- a/apps/scandic-web/components/ContentType/HotelPage/SidePeeks/Room/index.tsx +++ b/apps/scandic-web/components/ContentType/HotelPage/SidePeeks/Room/index.tsx @@ -104,25 +104,32 @@ export default async function RoomSidePeek({
      {room.roomFacilities + .filter((facility) => !!facility.isUniqueSellingPoint) .sort((a, b) => a.sortOrder - b.sortOrder) .map((facility) => { - const Icon = ( - - ) + const facilityName = facility.availableInAllRooms + ? facility.name + : intl.formatMessage( + { + defaultMessage: "{facility} (available in some rooms)", + }, + { + facility: facility.name, + } + ) + return (
    • - {Icon && Icon} + - {facility.name} + {facilityName}
    • ) diff --git a/apps/scandic-web/components/ContentType/HotelPage/SidePeeks/Room/room.module.css b/apps/scandic-web/components/ContentType/HotelPage/SidePeeks/Room/room.module.css index 8067df657..87843f876 100644 --- a/apps/scandic-web/components/ContentType/HotelPage/SidePeeks/Room/room.module.css +++ b/apps/scandic-web/components/ContentType/HotelPage/SidePeeks/Room/room.module.css @@ -45,10 +45,6 @@ justify-content: flex-start; } -.noIcon { - margin-left: var(--Spacing-x4); -} - .buttonContainer { background-color: var(--Base-Background-Primary-Normal); border-top: 1px solid var(--Base-Border-Subtle); 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, + } + )} + + +
      • + ) + )}
    From 43bdd80dff00bb5702307710911c759c7bc3e752 Mon Sep 17 00:00:00 2001 From: Christian Andolf Date: Fri, 2 May 2025 07:37:23 +0000 Subject: [PATCH 04/10] Merged in fix/SW-2508-new-api-cancel-booking-contract (pull request #1906) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ending up doing some extra things: Consolidated booking queries: We had both cancel and cancelMany, but functionally they’re the same, only one accepts an array and the other doesn’t. Didn’t see much point in keeping the single cancel as it wasn’t used anywhere. Thus, I could rename cancelMany to be the one stop method. remove method for API now properly supports body so we don’t have to hijack the typing in certain places * fix(SW-2508): now sending additional params to cancel api for new contract Approved-by: Niclas Edenvin --- .../Steps/FinalConfirmation/index.tsx | 2 +- apps/scandic-web/lib/api/index.ts | 9 ++++++-- .../server/routers/booking/input.ts | 9 ++------ .../server/routers/booking/mutation.ts | 14 +++--------- .../server/routers/booking/utils.ts | 22 +++++++++---------- 5 files changed, 24 insertions(+), 32 deletions(-) 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..8bd45aadf 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) { 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/server/routers/booking/input.ts b/apps/scandic-web/server/routers/booking/input.ts index 8a87965a1..bbf444181 100644 --- a/apps/scandic-web/server/routers/booking/input.ts +++ b/apps/scandic-web/server/routers/booking/input.ts @@ -126,14 +126,9 @@ export const priceChangeInput = z.object({ confirmationNumber: z.string(), }) -export const cancelBookingInput = z.object({ - confirmationNumber: z.string(), - language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]), -}) - -export const cancelManyBookingsInput = z.object({ +export const cancelBookingsInput = z.object({ confirmationNumbers: z.array(z.string()), - language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]), + language: z.nativeEnum(Lang), }) export const guaranteeBookingInput = z.object({ diff --git a/apps/scandic-web/server/routers/booking/mutation.ts b/apps/scandic-web/server/routers/booking/mutation.ts index f0d15af5a..ce648e7ec 100644 --- a/apps/scandic-web/server/routers/booking/mutation.ts +++ b/apps/scandic-web/server/routers/booking/mutation.ts @@ -5,8 +5,7 @@ import { router, safeProtectedServiceProcedure } from "@/server/trpc" import { addPackageInput, - cancelBookingInput, - cancelManyBookingsInput, + cancelBookingsInput, createBookingInput, guaranteeBookingInput, priceChangeInput, @@ -113,14 +112,7 @@ export const bookingMutationRouter = router({ return verifiedData.data }), cancel: safeProtectedServiceProcedure - .input(cancelBookingInput) - .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) + .input(cancelBookingsInput) .mutation(async function ({ ctx, input }) { const token = ctx.session?.token.access_token ?? ctx.serviceToken const { confirmationNumbers, language } = input @@ -297,7 +289,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/utils.ts b/apps/scandic-web/server/routers/booking/utils.ts index 5149a618c..c983407b4 100644 --- a/apps/scandic-web/server/routers/booking/utils.ts +++ b/apps/scandic-web/server/routers/booking/utils.ts @@ -80,17 +80,11 @@ export async function getBooking( export async function cancelBooking( confirmationNumber: string, - language: string, + language: Lang, token: string ) { - const cancellationReason = { - reasonCode: "WEB-CANCEL", - reason: "WEB-CANCEL", - } - const cancelBookingCounter = createCounter("booking", "cancel") const metricsCancelBooking = cancelBookingCounter.init({ - cancellationReason, confirmationNumber, language, }) @@ -101,18 +95,24 @@ export async function cancelBooking( Authorization: `Bearer ${token}`, } + const booking = await getBooking(confirmationNumber, language, token) + 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, - body: JSON.stringify(cancellationReason), - } as RequestInit, - { language } + body: { firstName, lastName, email }, + }, + { language: toApiLang(language) } ) if (!apiResponse.ok) { await metricsCancelBooking.httpError(apiResponse) - return false + return null } const apiJson = await apiResponse.json() From d7708b682a7173d62e477ce08e54d2145641157b Mon Sep 17 00:00:00 2001 From: Niclas Edenvin Date: Fri, 2 May 2025 07:45:37 +0000 Subject: [PATCH 05/10] Merged in fix/sw-2543-toc-text (pull request #1921) fix(SW-2543): Change terms and conditions text for flex * fix(SW-2543): Change terms and conditions text for flex Approved-by: Michael Zetterberg --- .../EnterDetails/Confirm/index.tsx | 4 +- .../EnterDetails/Payment/PaymentClient.tsx | 2 +- .../Payment/TermsAndConditions/index.tsx | 103 ++++++++++++------ .../GuaranteeLateArrival/Form/index.tsx | 2 +- .../hotelReservation/enterDetails/payment.ts | 4 + 5 files changed, 78 insertions(+), 37 deletions(-) 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/Payment/PaymentClient.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx index 15431a792..3d8e0e3fd 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx @@ -586,7 +586,7 @@ export default function PaymentClient({
    - +
    )} 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/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..911d976f5 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 @@ -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/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 +} From 862d4abbe3007c1d5cf39da95c9d25662cf74808 Mon Sep 17 00:00:00 2001 From: "Simon.Emanuelsson" Date: Fri, 2 May 2025 08:12:33 +0000 Subject: [PATCH 06/10] Merged in feat/SW-2202 (pull request #1909) feat: trigger validation on form upon autofill in enter details * feat: trigger validation on form upon autofill in enter details Approved-by: Niclas Edenvin --- .../Details/RoomOne/AutoFillDetector.tsx | 31 +++++++++++++++++++ .../EnterDetails/Details/RoomOne/index.tsx | 2 ++ 2 files changed, 33 insertions(+) create mode 100644 apps/scandic-web/components/HotelReservation/EnterDetails/Details/RoomOne/AutoFillDetector.tsx 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 }} />
    + ) From bf791682166703f34978079fde30ae167f9a7fa6 Mon Sep 17 00:00:00 2001 From: Tobias Johansson Date: Fri, 2 May 2025 08:35:29 +0000 Subject: [PATCH 07/10] Merged in fix/SW-2462-room-availability-error (pull request #1920) Fix/SW-2462 room availability error * fix: added toast error when availability fails and you get redirect to select-rate * fix: added support for showing alert when availability error happens * fix: rename PaymentAlert -> BookingAlert Approved-by: Erik Tiekstra --- .../(standard)/details/page.tsx | 28 +-------- .../bookingAlert.module.css} | 0 .../{PaymentAlert => BookingAlert}/index.tsx | 58 +++++++++++++++++-- .../EnterDetails/Payment/PaymentClient.tsx | 25 ++++---- .../SelectRate/AvailabilityError.tsx | 38 ++++++++++++ .../HotelReservation/SelectRate/index.tsx | 3 + 6 files changed, 109 insertions(+), 43 deletions(-) rename apps/scandic-web/components/HotelReservation/EnterDetails/Payment/{PaymentAlert/paymentAlert.module.css => BookingAlert/bookingAlert.module.css} (100%) rename apps/scandic-web/components/HotelReservation/EnterDetails/Payment/{PaymentAlert => BookingAlert}/index.tsx (62%) create mode 100644 apps/scandic-web/components/HotelReservation/SelectRate/AvailabilityError.tsx 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/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 62% 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 4f7c395b5..ba0bb3780 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentAlert/index.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/BookingAlert/index.tsx @@ -5,12 +5,14 @@ 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" @@ -19,6 +21,7 @@ function useBookingErrorAlert() { (state) => state.actions.updateSeachParamString ) const intl = useIntl() + const lang = useLang() const searchParams = useSearchParams() const pathname = usePathname() @@ -31,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: @@ -54,16 +64,39 @@ 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() @@ -87,6 +120,9 @@ export default function PaymentAlert({ isVisible = false }: PaymentAlertProps) { if (!showAlert) return null + const isAvailabilityError = + errorCode === BookingErrorCodeEnum.AvailabilityError + return (
    ) diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx index 3d8e0e3fd..8b8d3dea7 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx @@ -1,7 +1,7 @@ "use client" import { zodResolver } from "@hookform/resolvers/zod" -import { useRouter, useSearchParams } from "next/navigation" +import { usePathname, useRouter, useSearchParams } from "next/navigation" import { useCallback, useEffect, useState } from "react" import { Label } from "react-aria-components" import { FormProvider, useForm } from "react-hook-form" @@ -11,7 +11,6 @@ import { Typography } from "@scandic-hotels/design-system/Typography" import { BOOKING_CONFIRMATION_NUMBER, - BookingErrorCodeEnum, BookingStatusEnum, PAYMENT_METHOD_TITLES, PaymentMethodEnum, @@ -42,10 +41,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 +70,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, @@ -135,11 +135,14 @@ 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 } @@ -196,7 +199,7 @@ export default function PaymentClient({ const handlePaymentError = useCallback( (errorMessage: string) => { - setShowPaymentAlert(true) + setShowBookingAlert(true) const currentPaymentMethod = methods.getValues("paymentMethod") const smsEnable = methods.getValues("smsConfirmation") @@ -480,7 +483,7 @@ export default function PaymentClient({ ? confirm : payment} - +
    { + 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/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} /> + + ) } From a839d05e09a37931be52446940a330fd7e4c1842 Mon Sep 17 00:00:00 2001 From: Arvid Norlin Date: Wed, 23 Apr 2025 13:50:40 +0200 Subject: [PATCH 08/10] feat(SW-2116): remove payment-callback rewrite logic --- .../payment-callback/{page.tsx => _page.tsx} | 0 .../payment-callback/cancel/page.tsx | 29 ++++++++ .../payment-callback/error/page.tsx | 67 +++++++++++++++++++ .../payment-callback/success/page.tsx | 33 +++++++++ apps/scandic-web/next.config.js | 5 -- 5 files changed, 129 insertions(+), 5 deletions(-) rename apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/{page.tsx => _page.tsx} (100%) create mode 100644 apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/cancel/page.tsx create mode 100644 apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/error/page.tsx create mode 100644 apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/success/page.tsx 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 similarity index 100% rename from apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/page.tsx rename to apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/_page.tsx 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..7cbfd8aec --- /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 PaymentCallbackPage({ + 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..425882235 --- /dev/null +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/error/page.tsx @@ -0,0 +1,67 @@ +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 type { LangParams, PageArgs } from "@/types/params" + +export default async function PaymentCallbackPage({ + 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) { + 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}` + + 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/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..a25f2a58d --- /dev/null +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/success/page.tsx @@ -0,0 +1,33 @@ +import { BOOKING_CONFIRMATION_NUMBER } from "@/constants/booking" +import { bookingConfirmation } from "@/constants/routes/hotelReservation" + +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, + { + confirmationNumber: string + } +>) { + console.log(`[payment-callback] success callback started`) + const lang = params.lang + + const confirmationNumber = searchParams.confirmationNumber + + const confirmationUrl = `${bookingConfirmation(lang)}?${BOOKING_CONFIRMATION_NUMBER}=${confirmationNumber}` + console.log( + `[payment-callback] rendering success callback with confirmation number: ${confirmationNumber}` + ) + + return ( + + ) +} 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, From 7eeb0bbcacd3618c8165fa38aef91ebf698db4bb Mon Sep 17 00:00:00 2001 From: Arvid Norlin Date: Wed, 23 Apr 2025 15:47:18 +0200 Subject: [PATCH 09/10] feat(SW-2116): avoid passing entire booking object to Room client component --- .../Rooms/LinkedReservation/index.tsx | 13 ++++-- .../BookingConfirmation/Rooms/Room/index.tsx | 40 ++++++++++--------- .../BookingConfirmation/Rooms/index.tsx | 7 +++- .../BookingConfirmation/index.tsx | 8 +--- .../HotelReservation/MyStay/Receipt/index.tsx | 3 +- .../server/routers/booking/query.ts | 5 ++- apps/scandic-web/server/routers/user/utils.ts | 2 +- .../bookingConfirmation/promos.ts | 12 ++---- .../bookingConfirmation/rooms/room.ts | 7 +++- .../{server/routers => }/utils/encryption.ts | 0 apps/scandic-web/utils/refId.ts | 21 ++++++++++ 11 files changed, 75 insertions(+), 43 deletions(-) rename apps/scandic-web/{server/routers => }/utils/encryption.ts (100%) create mode 100644 apps/scandic-web/utils/refId.ts diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Rooms/LinkedReservation/index.tsx b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Rooms/LinkedReservation/index.tsx index 1b76ff457..40e6e2104 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Rooms/LinkedReservation/index.tsx +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Rooms/LinkedReservation/index.tsx @@ -78,14 +78,19 @@ export function LinkedReservation({ if (!data?.room) { 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..991d62212 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Rooms/index.tsx +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Rooms/index.tsx @@ -34,10 +34,15 @@ export default async function Rooms({ ) : null}
    diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/index.tsx b/apps/scandic-web/components/HotelReservation/BookingConfirmation/index.tsx index 91f39fb4f..8be66fd31 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/index.tsx +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/index.tsx @@ -1,7 +1,6 @@ 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" @@ -12,6 +11,7 @@ import SidePanel from "@/components/HotelReservation/SidePanel" import Divider from "@/components/TempDesignSystem/Divider" import { getIntl } from "@/i18n" import BookingConfirmationProvider from "@/providers/BookingConfirmationProvider" +import { encrypt } from "@/utils/encryption" import Alerts from "./Alerts" import Confirmation from "./Confirmation" @@ -66,11 +66,7 @@ export default async function BookingConfirmation({ - +
    diff --git a/apps/scandic-web/components/HotelReservation/MyStay/Receipt/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/Receipt/index.tsx index b7f5923c3..70e17a294 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/Receipt/index.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/Receipt/index.tsx @@ -10,9 +10,9 @@ import { getBookingConfirmation, getProfileSafely, } from "@/lib/trpc/memoizedRequests" -import { decrypt } from "@/server/routers/utils/encryption" import { getIntl } from "@/i18n" +import { decrypt } from "@/utils/encryption" import AdditionalInfoForm from "../../FindMyBooking/AdditionalInfoForm" import accessBooking, { @@ -132,6 +132,7 @@ export async function Receipt({ refId }: { refId: string }) {
    diff --git a/apps/scandic-web/server/routers/booking/query.ts b/apps/scandic-web/server/routers/booking/query.ts index 280a49769..76067970c 100644 --- a/apps/scandic-web/server/routers/booking/query.ts +++ b/apps/scandic-web/server/routers/booking/query.ts @@ -7,8 +7,9 @@ import { serviceProcedure, } from "@/server/trpc" +import { calculateRefId } from "@/utils/refId" + import { getHotel } from "../hotels/utils" -import { encrypt } from "../utils/encryption" import { createRefIdInput, getBookingInput, @@ -169,7 +170,7 @@ export const bookingQueryRouter = router({ .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/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/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/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/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], + } +} From 74d37dad9382b6c1c7251e537c56a21f0c173fca Mon Sep 17 00:00:00 2001 From: Arvid Norlin Date: Fri, 25 Apr 2025 13:44:49 +0200 Subject: [PATCH 10/10] feat(SW-2116): RefId instead of confirmationNumber --- .../booking-confirmation/page.module.css} | 23 ++ .../booking-confirmation/page.tsx | 78 ++++- .../gla-payment-callback/page.tsx | 33 +- .../payment-callback/_page.tsx | 93 ------ .../payment-callback/cancel/page.tsx | 2 +- .../payment-callback/error/page.tsx | 10 +- .../payment-callback/success/page.tsx | 35 ++- .../hotelreservation/my-stay/page.tsx | 28 +- .../my-stay/receipt/loading.tsx | 1 + .../my-stay/receipt/page.module.css} | 0 .../hotelreservation/my-stay/receipt/page.tsx | 173 +++++++++- .../webview/hotelreservation/my-stay/page.tsx | 34 +- .../HotelReservation/AddToCalendar/index.tsx | 1 + .../Confirmation/index.tsx | 24 -- .../Header/Actions/ManageBooking.tsx | 9 +- .../BookingConfirmation/Header/index.tsx | 12 +- .../BookingConfirmation/Promos/index.tsx | 18 +- .../Rooms/LinkedReservation/index.tsx | 13 +- .../BookingConfirmation/Rooms/index.tsx | 46 ++- .../BookingConfirmation/Tracking/index.tsx | 2 +- .../BookingConfirmation/Tracking/tracking.ts | 2 +- .../bookingConfirmation.module.css | 22 -- .../BookingConfirmation/index.tsx | 83 ----- .../BookingConfirmation/utils.ts | 4 +- .../PaymentCallback/HandleSuccessCallback.tsx | 10 +- .../EnterDetails/Payment/PaymentClient.tsx | 75 +++-- .../FindMyBooking/AdditionalInfoForm.tsx | 6 +- .../AddAncillaryFlowModal/index.tsx | 8 +- .../AddedAncillaries/RemoveButton.tsx | 6 +- .../Ancillaries/AddedAncillaries/index.tsx | 5 +- .../Ancillaries/GuaranteeCallback/index.tsx | 6 +- .../MyStay/Ancillaries/index.tsx | 6 +- .../MyStay/GuestDetails/Details.tsx | 10 +- .../MyStay/GuestDetails/index.tsx | 1 + .../HotelReservation/MyStay/Receipt/index.tsx | 169 ---------- .../Steps/FinalConfirmation/index.tsx | 23 +- .../ChangeDates/Steps/Confirmation/index.tsx | 10 +- .../GuaranteeLateArrival/Form/index.tsx | 4 +- .../MyStay/utils/mapRoomDetails.ts | 1 + apps/scandic-web/constants/booking.ts | 2 - .../hooks/booking/useGuaranteeBooking.ts | 14 +- .../hooks/booking/useHandleBookingStatus.ts | 6 +- .../lib/trpc/memoizedRequests/index.ts | 16 +- apps/scandic-web/providers/MyStay.tsx | 26 +- .../server/routers/booking/input.ts | 39 ++- .../server/routers/booking/mutation.ts | 100 ++++-- .../server/routers/booking/output.ts | 24 +- .../server/routers/booking/query.ts | 296 +++++++++++++----- .../server/routers/booking/utils.ts | 165 ++++++---- apps/scandic-web/server/tokenManager.ts | 11 + apps/scandic-web/stores/my-stay/index.ts | 29 +- .../actions/addToCalendar.ts | 8 +- .../actions/manageBooking.ts | 2 +- .../bookingConfirmation.ts | 10 +- .../bookingConfirmation/header.ts | 8 +- .../bookingConfirmation/hotelDetails.ts | 2 +- .../bookingConfirmation/rooms.ts | 7 - .../rooms/linkedReservation.ts | 2 +- .../components/myPages/myStay/ancillaries.ts | 1 + apps/scandic-web/types/stores/my-stay.ts | 1 + .../trpc/routers/booking/confirmation.ts | 20 +- 61 files changed, 1032 insertions(+), 843 deletions(-) rename apps/scandic-web/{components/HotelReservation/BookingConfirmation/Confirmation/confirmation.module.css => app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.module.css} (61%) delete mode 100644 apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/_page.tsx create mode 100644 apps/scandic-web/app/[lang]/(no-layout)/hotelreservation/my-stay/receipt/loading.tsx rename apps/scandic-web/{components/HotelReservation/MyStay/Receipt/receipt.module.css => app/[lang]/(no-layout)/hotelreservation/my-stay/receipt/page.module.css} (100%) delete mode 100644 apps/scandic-web/components/HotelReservation/BookingConfirmation/Confirmation/index.tsx delete mode 100644 apps/scandic-web/components/HotelReservation/BookingConfirmation/bookingConfirmation.module.css delete mode 100644 apps/scandic-web/components/HotelReservation/BookingConfirmation/index.tsx delete mode 100644 apps/scandic-web/components/HotelReservation/MyStay/Receipt/index.tsx 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/_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/cancel/page.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/cancel/page.tsx index 7cbfd8aec..773da8fc6 100644 --- 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 @@ -8,7 +8,7 @@ import HandleErrorCallback from "@/components/HotelReservation/EnterDetails/Paym import type { LangParams, PageArgs } from "@/types/params" -export default async function PaymentCallbackPage({ +export default async function PaymentCallbackCancelPage({ params, }: PageArgs) { console.log(`[payment-callback] cancel callback started`) 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 index 425882235..3dc556d5e 100644 --- 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 @@ -6,10 +6,11 @@ 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 PaymentCallbackPage({ +export default async function PaymentCallbackErrorPage({ params, searchParams, }: PageArgs< @@ -19,6 +20,7 @@ export default async function PaymentCallbackPage({ } >) { console.log(`[payment-callback] error callback started`) + const lang = params.lang const confirmationNumber = searchParams.confirmationNumber @@ -28,9 +30,11 @@ export default async function PaymentCallbackPage({ let errorMessage = undefined if (confirmationNumber) { + const refId = calculateRefId(confirmationNumber, "") + try { - const bookingStatus = await serverClient().booking.status({ - confirmationNumber, + const bookingStatus = await serverClient().booking.confirmationError({ + refId, }) // TODO: how to handle errors for multiple rooms? 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 index a25f2a58d..fa6185a71 100644 --- 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 @@ -1,33 +1,44 @@ -import { BOOKING_CONFIRMATION_NUMBER } from "@/constants/booking" +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 PaymentCallbackPage({ +export default async function PaymentCallbackSuccessPage({ params, searchParams, }: PageArgs< LangParams, { - confirmationNumber: string + confirmationNumber?: string } >) { - console.log(`[payment-callback] success callback started`) - const lang = params.lang - const confirmationNumber = searchParams.confirmationNumber - const confirmationUrl = `${bookingConfirmation(lang)}?${BOOKING_CONFIRMATION_NUMBER}=${confirmationNumber}` - console.log( - `[payment-callback] rendering success callback with confirmation number: ${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/my-stay/page.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/my-stay/page.tsx index 3308028c0..06574a7c2 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 @@ -13,7 +13,6 @@ import { getProfileSafely, getSavedPaymentCardsSafely, } from "@/lib/trpc/memoizedRequests" -import { decrypt } from "@/server/routers/utils/encryption" import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm" import accessBooking, { @@ -32,6 +31,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 { getCurrentWebUrl } from "@/utils/url" import styles from "./page.module.css" @@ -44,29 +44,26 @@ 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 [confirmationNumber, lastName] = value.split(",") - const bookingConfirmation = await getBookingConfirmation(confirmationNumber) + const bookingConfirmation = await getBookingConfirmation(refId, params.lang) if (!bookingConfirmation) { return notFound() } - const { additionalData, booking, hotel, roomCategories } = bookingConfirmation + const { booking, hotelData } = bookingConfirmation + const { hotel } = hotelData const user = await getProfileSafely() const bv = cookies().get("bv")?.value const intl = await getIntl() + const { lastName } = parseRefId(refId) const access = accessBooking(booking.guest, lastName, user, bv) if (access === ACCESS_GRANTED) { @@ -74,9 +71,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, @@ -121,7 +116,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" @@ -138,7 +133,7 @@ export default async function MyStay({ lang={params.lang} linkedReservationsPromise={linkedReservationsPromise} refId={refId} - roomCategories={roomCategories} + roomCategories={hotelData.roomCategories} savedCreditCards={savedCreditCards} >
    @@ -197,10 +192,7 @@ export default async function MyStay({ 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..5c6890136 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,177 @@ +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 { + getAncillaryPackages, + getBookingConfirmation, + getProfileSafely, +} from "@/lib/trpc/memoizedRequests" + +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 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() } - return ( - }> - - + + const { confirmationNumber, lastName } = parseRefId(refId) + + const bookingConfirmation = await getBookingConfirmation( + confirmationNumber, + params.lang ) + + if (!bookingConfirmation) { + return notFound() + } + + const { booking, hotelData, room } = bookingConfirmation + const { hotel } = hotelData + + const intl = await getIntl() + const user = await getProfileSafely() + const bv = cookies().get("bv")?.value + + 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() } 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 e083c1cde..06574a7c2 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 @@ -13,7 +13,6 @@ import { getProfileSafely, getSavedPaymentCardsSafely, } from "@/lib/trpc/memoizedRequests" -import { decrypt } from "@/server/routers/utils/encryption" import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm" import accessBooking, { @@ -32,6 +31,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 { getCurrentWebUrl } from "@/utils/url" import styles from "./page.module.css" @@ -44,29 +44,26 @@ 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 [confirmationNumber, lastName] = value.split(",") - const bookingConfirmation = await getBookingConfirmation(confirmationNumber) + const bookingConfirmation = await getBookingConfirmation(refId, params.lang) if (!bookingConfirmation) { return notFound() } - const { additionalData, booking, hotel, roomCategories } = bookingConfirmation + const { booking, hotelData } = bookingConfirmation + const { hotel } = hotelData const user = await getProfileSafely() const bv = cookies().get("bv")?.value const intl = await getIntl() + const { lastName } = parseRefId(refId) const access = accessBooking(booking.guest, lastName, user, bv) if (access === ACCESS_GRANTED) { @@ -74,9 +71,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, @@ -98,9 +93,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) @@ -112,7 +107,7 @@ export default async function MyStay({ }) let breakfastPackages = null - if (alreadyHasABreakfastSelection) { + if (shouldFetchBreakfastPackages) { breakfastPackages = await getPackages(packagesInput) } const savedCreditCards = await getSavedPaymentCardsSafely( @@ -121,7 +116,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" @@ -138,7 +133,7 @@ export default async function MyStay({ lang={params.lang} linkedReservationsPromise={linkedReservationsPromise} refId={refId} - roomCategories={roomCategories} + roomCategories={hotelData.roomCategories} savedCreditCards={savedCreditCards} >
    @@ -197,10 +192,7 @@ export default async function MyStay({ return (
    - +
    ) diff --git a/apps/scandic-web/components/HotelReservation/AddToCalendar/index.tsx b/apps/scandic-web/components/HotelReservation/AddToCalendar/index.tsx index 4830d7543..be8849787 100644 --- a/apps/scandic-web/components/HotelReservation/AddToCalendar/index.tsx +++ b/apps/scandic-web/components/HotelReservation/AddToCalendar/index.tsx @@ -1,4 +1,5 @@ "use client" + import { createEvent } from "ics" import { useIntl } from "react-intl" diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Confirmation/index.tsx b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Confirmation/index.tsx deleted file mode 100644 index 88b99038c..000000000 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Confirmation/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"use client" -import { useRef } from "react" - -import Header from "@/components/HotelReservation/BookingConfirmation/Header" - -import styles from "./confirmation.module.css" - -import type { ConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" - -export default function Confirmation({ - booking, - hotel, - children, - refId, -}: React.PropsWithChildren) { - const mainRef = useRef(null) - - return ( -
    -
    - {children} -
    - ) -} diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Header/Actions/ManageBooking.tsx b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Header/Actions/ManageBooking.tsx index 28a81406d..ef49a8988 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Header/Actions/ManageBooking.tsx +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Header/Actions/ManageBooking.tsx @@ -1,15 +1,22 @@ "use client" + import { useIntl } from "react-intl" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" +import { myStay } from "@/constants/routes/myStay" + import Button from "@/components/TempDesignSystem/Button" import Link from "@/components/TempDesignSystem/Link" +import useLang from "@/hooks/useLang" import type { ManageBookingProps } from "@/types/components/hotelReservation/bookingConfirmation/actions/manageBooking" -export default function ManageBooking({ bookingUrl }: ManageBookingProps) { +export default function ManageBooking({ refId }: ManageBookingProps) { const intl = useIntl() + const lang = useLang() + + const bookingUrl = `${myStay[lang]}?RefId=${refId}` return (
    ) 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 (
    { @@ -78,7 +81,9 @@ export function LinkedReservation({ if (!data?.room) { return } + const { booking, room } = data + 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} (
    - -

    - {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 8be66fd31..000000000 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/index.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { notFound } from "next/navigation" - -import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests" - -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 { encrypt } from "@/utils/encryption" - -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/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 8b8d3dea7..1b5938e1b 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx @@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { usePathname, useRouter, useSearchParams } from "next/navigation" -import { useCallback, useEffect, useState } from "react" +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,7 +10,6 @@ import { useIntl } from "react-intl" import { Typography } from "@scandic-hotels/design-system/Typography" import { - BOOKING_CONFIRMATION_NUMBER, BookingStatusEnum, PAYMENT_METHOD_TITLES, PaymentMethodEnum, @@ -30,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" @@ -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) @@ -146,13 +144,14 @@ export default function PaymentClient({ 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) { @@ -174,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") @@ -189,13 +188,39 @@ 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) => { @@ -245,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( @@ -458,7 +477,7 @@ export default function PaymentClient({ initiateBooking.isPending || (isPollingForBookingStatus && !bookingStatus.data?.paymentUrl && - !bookingStatus.isTimeout) + retries.current < maxRetries) ) { return } @@ -620,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/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 70e17a294..000000000 --- a/apps/scandic-web/components/HotelReservation/MyStay/Receipt/index.tsx +++ /dev/null @@ -1,169 +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 { - getAncillaryPackages, - getBookingConfirmation, - getProfileSafely, -} from "@/lib/trpc/memoizedRequests" - -import { getIntl } from "@/i18n" -import { decrypt } from "@/utils/encryption" - -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 [confirmationNumber, lastName] = value.split(",") - const bookingConfirmation = await getBookingConfirmation(confirmationNumber) - if (!bookingConfirmation) { - return notFound() - } - - const { booking, hotel, room } = bookingConfirmation - const user = await getProfileSafely() - const bv = cookies().get("bv")?.value - 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() -} 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 8bd45aadf..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 @@ -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 911d976f5..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)}`, 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/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/lib/trpc/memoizedRequests/index.ts b/apps/scandic-web/lib/trpc/memoizedRequests/index.ts index 9dd7eb0ba..6abc2db19 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,14 +136,20 @@ 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, + }) } ) 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/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 bbf444181..38d6858b5 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,22 +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 cancelBookingsInput = z.object({ - confirmationNumbers: z.array(z.string()), - language: z.nativeEnum(Lang), + 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(), @@ -156,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 @@ -168,22 +168,27 @@ 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 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 ce648e7ec..c7c04076f 100644 --- a/apps/scandic-web/server/routers/booking/mutation.ts +++ b/apps/scandic-web/server/routers/booking/mutation.ts @@ -1,8 +1,11 @@ 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, cancelBookingsInput, @@ -12,7 +15,7 @@ import { removePackageInput, updateBookingInput, } from "./input" -import { bookingConfirmationSchema, createBookingSchema } from "./output" +import { bookingSchema, createBookingSchema } from "./output" import { cancelBooking } from "./utils" export const bookingMutationRouter = router({ @@ -73,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 }) @@ -109,17 +121,29 @@ export const bookingMutationRouter = router({ metricsPriceChange.success() - return verifiedData.data + return verifiedData.data.id }), cancel: safeProtectedServiceProcedure .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 { confirmationNumbers, language } = input + const { confirmationNumbers } = ctx + const { lang } = input const responses = await Promise.allSettled( confirmationNumbers.map((confirmationNumber) => - cancelBooking(confirmationNumber, language, token) + cancelBooking(confirmationNumber, lang) ) ) @@ -144,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 }) @@ -183,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, @@ -225,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, @@ -236,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}`, }, } ) @@ -253,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 @@ -261,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" 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 76067970c..beb685d12 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,46 @@ import { safeProtectedServiceProcedure, serviceProcedure, } from "@/server/trpc" +import { getBookedHotelRoom } from "@/stores/my-stay" -import { calculateRefId } from "@/utils/refId" +import { calculateRefId, parseRefId } from "@/utils/refId" import { getHotel } from "../hotels/utils" import { + bookingConfirmationInput, createRefIdInput, - getBookingInput, + getBookingConfirmationErrorInput, getBookingStatusInput, + getConfirmationCompletedInput, getLinkedReservationsInput, } from "./input" import { createBookingSchema } from "./output" -import { getBookedHotelRoom, getBooking } from "./utils" +import { 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 +61,9 @@ export const bookingQueryRouter = router({ { hotelId: booking.hotelId, isCardOnlyPayment: false, - language: ctx.lang, + language: lang, }, - ctx.serviceToken + serviceToken ) if (!hotelData) { @@ -68,104 +77,243 @@ 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, } }), 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 }) { diff --git a/apps/scandic-web/server/routers/booking/utils.ts b/apps/scandic-web/server/routers/booking/utils.ts index c983407b4..61dcba01d 100644 --- a/apps/scandic-web/server/routers/booking/utils.ts +++ b/apps/scandic-web/server/routers/booking/utils.ts @@ -1,101 +1,134 @@ 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), - { - headers: { - Authorization: `Bearer ${token}`, - }, + 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 }, - { language: toApiLang(lang) } + "1h" ) - 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 = bookingConfirmationSchema.safeParse(apiJson) - if (!booking.success) { - metricsGetBooking.validationError(booking.error) - throw badRequestError() - } - metricsGetBooking.success() - return booking.data + return result } -export async function cancelBooking( +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, - language: Lang, - token: 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, - language, + lang, }) metricsCancelBooking.start() + const token = getUserOrServiceToken() const headers = { Authorization: `Bearer ${token}`, } - const booking = await getBooking(confirmationNumber, language, token) + const booking = await getBooking(confirmationNumber, lang) if (!booking) { metricsCancelBooking.noDataError({ confirmationNumber }) return null @@ -107,7 +140,7 @@ export async function cancelBooking( headers, body: { firstName, lastName, email }, }, - { language: toApiLang(language) } + { language: toApiLang(lang) } ) if (!apiResponse.ok) { 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/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/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 }