Merged in feat/sw-2879-booking-widget-to-booking-flow-package (pull request #2532)

feat(SW-2879): Move BookingWidget to booking-flow package

* Fix lockfile

* Fix styling

* a tiny little booking widget test

* Tiny fixes

* Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package

* Remove unused scripts

* lint:fix

* Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package

* Tiny lint fixes

* update test

* Update Input in booking-flow

* Clean up comments etc

* Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package

* Setup tracking context for booking-flow

* Add missing use client

* Fix temp tracking function

* Pass booking to booking-widget

* Remove comment

* Add use client to booking widget tracking provider

* Add use client to tracking functions

* Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package

* Move debug page

* Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package

* Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package

* Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package


Approved-by: Bianca Widstam
This commit is contained in:
Anton Gunnarsson
2025-08-05 09:20:20 +00:00
parent 03c9244fdf
commit 1bd8fe6821
206 changed files with 1936 additions and 796 deletions

View File

@@ -0,0 +1,53 @@
import { BookingWidget } from "@scandic-hotels/booking-flow/BookingWidget"
import { parseBookingWidgetSearchParams } from "@scandic-hotels/booking-flow/utils/url"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { serverClient } from "@/lib/trpc"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { ClientComponent } from "../../../components/ClientComponent"
type SearchParams<S = {}> = {
searchParams: Promise<S & { [key: string]: string }>
}
export default async function Debug(props: SearchParams) {
const searchParams = await props.searchParams
const intl = await getIntl()
const lang = await getLang()
const caller = await serverClient()
const destinations = await caller.autocomplete.destinations({
lang,
includeTypes: ["hotels"],
query: "Göteborg",
})
const hotel = destinations.hits.hotels[0].name
const booking = parseBookingWidgetSearchParams(searchParams)
return (
<div style={{ padding: "20px" }}>
<Typography>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<p>from booking-flow package:</p>
</Typography>
<BookingWidget booking={booking} lang={lang} />
<hr />
<Typography variant="Title/Decorative/lg">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<p>hello world with data: {hotel}</p>
</Typography>
<Typography>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<p>
translated:
{intl.formatMessage({ defaultMessage: "Map of the city" })}
</p>
</Typography>
<hr />
<ClientComponent />
</div>
)
}

View File

@@ -1,7 +1,9 @@
import "@scandic-hotels/design-system/style.css"
import "@scandic-hotels/design-system/fonts.css"
import "@scandic-hotels/design-system/style.css"
import "@/public/_static/css/design-system-new-deprecated.css"
import "../../globals.css"
import { BookingFlowTrackingProvider } from "@scandic-hotels/booking-flow/BookingFlowTrackingProvider"
import { Lang } from "@scandic-hotels/common/constants/language"
import { TrpcProvider } from "@scandic-hotels/trpc/Provider"
@@ -9,6 +11,8 @@ import { getMessages } from "@/i18n"
import ClientIntlProvider from "@/i18n/Provider"
import { setLang } from "@/i18n/serverContext"
import { trackBookingSearchClick } from "../utils/tracking"
import type { Metadata } from "next"
export const metadata: Metadata = {
@@ -35,21 +39,39 @@ export default async function RootLayout(props: RootLayoutProps) {
return (
<html lang="en">
<head>
{/* eslint-disable-next-line @next/next/no-css-tags */}
<link rel="stylesheet" href="/_static/css/core.css" />
{/* eslint-disable-next-line @next/next/no-css-tags */}
<link rel="stylesheet" href="/_static/css/scandic.css" />
</head>
<head>{/* TODO */}</head>
<body className="scandic">
<ClientIntlProvider
defaultLocale={Lang.en}
locale={params.lang}
messages={messages}
>
{/* TODO handle onError */}
<TrpcProvider>{children}</TrpcProvider>
</ClientIntlProvider>
<div className="root">
<ClientIntlProvider
defaultLocale={Lang.en}
locale={params.lang}
messages={messages}
>
{/* TODO handle onError */}
<TrpcProvider>
<BookingFlowTrackingProvider
trackingFunctions={{
trackBookingSearchClick,
}}
>
<header
style={{
height: 64,
backgroundColor: "dodgerblue",
color: "white",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<h1>SAS</h1>
</header>
<main>{children}</main>
</BookingFlowTrackingProvider>
</TrpcProvider>
</ClientIntlProvider>
</div>
</body>
</html>
)

View File

@@ -1,4 +0,0 @@
.page {
padding-left: 200px;
padding-top: 200px;
}

View File

@@ -1,44 +1,29 @@
import { Temp } from "@scandic-hotels/booking-flow/test-entry"
import { Lang } from "@scandic-hotels/common/constants/language"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { BookingWidget } from "@scandic-hotels/booking-flow/BookingWidget"
import { parseBookingWidgetSearchParams } from "@scandic-hotels/booking-flow/utils/url"
import { serverClient } from "@/lib/trpc"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { ClientComponent } from "./ClientComponent"
import type { Lang } from "@scandic-hotels/common/constants/language"
import styles from "./page.module.css"
type SearchParams<S = {}> = {
searchParams: Promise<S & { [key: string]: string }>
}
export default async function Home() {
const intl = await getIntl()
const caller = await serverClient()
const destinations = await caller.autocomplete.destinations({
lang: Lang.en,
includeTypes: ["hotels"],
query: "Göteborg",
})
const hotel = destinations.hits.hotels[0].name
export default async function Home(props: SearchParams<{ lang: Lang }>) {
const searchParams = await props.searchParams
// TODO we need this import right now to ensure configureServerClient is called,
// but we should ensure it's called in a layout instead.
const _caller = await serverClient()
const lang = await getLang()
const booking = parseBookingWidgetSearchParams(searchParams)
return (
<div className={styles.page}>
<main>
<Typography variant="Title/Decorative/lg">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<p>hello world with data: {hotel}</p>
</Typography>
<Typography>
<p>{intl.formatMessage({ defaultMessage: "Map of the city" })}</p>
</Typography>
<hr />
<ClientComponent />
<hr />
<Typography>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<p>from booking-flow package:</p>
</Typography>
<Temp />
</main>
<div>
<BookingWidget booking={booking} lang={lang} />
</div>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@@ -0,0 +1,11 @@
"use client"
export function trackBookingSearchClick(
searchTerm: string,
searchType: "hotel" | "destination"
) {
console.log("TODO: Implement trackBookingSearchClick", {
searchTerm,
searchType,
})
}

View File

@@ -0,0 +1,24 @@
"use client"
import { type IntlConfig, IntlProvider } from "react-intl"
type ClientIntlProviderProps = React.PropsWithChildren<
Pick<IntlConfig, "defaultLocale" | "locale" | "messages">
>
export default function ClientIntlProvider({
children,
locale,
defaultLocale,
messages,
}: ClientIntlProviderProps) {
return (
<IntlProvider
locale={locale}
defaultLocale={defaultLocale}
messages={messages}
>
{children}
</IntlProvider>
)
}

View File

@@ -0,0 +1,107 @@
:root {
--current-max-width: 113.5rem;
--max-width: 94.5rem;
--max-width-content: min(calc(100dvw - var(--max-width-spacing)), 74.75rem);
--max-width-text-block: 49.5rem;
--current-mobile-site-header-height: 52.41px;
--max-width-navigation: 89.5rem;
--max-width-single-spacing: var(--Layout-Mobile-Margin-Margin-min);
--max-width-spacing: calc(var(--max-width-single-spacing) * 2);
--max-width-page: min(
calc(100dvw - var(--max-width-spacing)),
var(--max-width-navigation)
);
--sitewide-alert-height: 0px; /* Will be overridden when a sitewide alert is visible */
--main-menu-mobile-height: 75px;
--main-menu-desktop-height: 125px;
--booking-widget-mobile-height: 75px;
--booking-widget-tablet-height: 150px;
--booking-widget-desktop-height: 77px;
--hotel-page-map-desktop-width: 23.75rem;
/* Z-INDEX */
--header-z-index: 11;
--menu-overlay-z-index: 11;
--booking-widget-z-index: 10;
--booking-widget-open-z-index: 100;
--dialog-z-index: 9;
--back-to-top-button: 80;
--language-switcher-z-index: 85;
--sidepeek-z-index: 100;
--lightbox-z-index: 150;
--default-modal-overlay-z-index: 100;
--default-modal-z-index: 101;
--modal-box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08);
--popup-box-shadow: 0 0 14px 6px rgba(0, 0, 0, 0.1);
@supports (interpolate-size: allow-keywords) {
interpolate-size: allow-keywords;
}
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
scroll-behavior: smooth;
}
body {
min-height: 100dvh;
overflow-x: hidden;
display: flex;
flex-direction: column;
color: var(--Text-Default);
}
body.overflow-hidden {
overflow: hidden;
}
.root {
isolation: isolate;
}
/* From Tailwind */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
ul {
padding-inline-start: 0;
margin-block-start: 0;
margin-block-end: 0;
}
@media screen and (min-width: 768px) {
:root {
--max-width-single-spacing: var(--Layout-Tablet-Margin-Margin-min);
}
body.overflow-hidden {
overflow: auto;
overflow-x: hidden;
}
}
@media screen and (min-width: 1367px) {
:root {
--max-width-single-spacing: var(--Layout-Desktop-Margin-Margin-min);
}
}

View File

@@ -12,6 +12,17 @@ const nextConfig: NextConfig = {
],
output: "standalone",
experimental: {
swcPlugins: [
[
"@swc/plugin-formatjs",
{
ast: true,
},
],
],
},
webpack: function (config: any) {
config.module.rules.push(
{
@@ -27,17 +38,6 @@ const nextConfig: NextConfig = {
return config
},
experimental: {
swcPlugins: [
[
"@swc/plugin-formatjs",
{
ast: true,
},
],
],
},
}
export default Sentry.withSentryConfig(nextConfig, {

View File

@@ -34,6 +34,7 @@
"@playwright/test": "^1.53.1",
"@scandic-hotels/common": "workspace:*",
"@scandic-hotels/typescript-config": "workspace:*",
"@swc/plugin-formatjs": "^3.2.2",
"@types/node": "^20",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.0",

View File

@@ -28,25 +28,37 @@ export default defineConfig({
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:3001",
/* How long to wait for actions to complete. */
actionTimeout: 15 * 1000,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
trace: process.env.CI ? "on-first-retry" : "retain-on-failure",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
use: {
...devices["Desktop Chrome"],
viewport: { width: 1400, height: 720 },
},
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
use: {
...devices["Desktop Firefox"],
viewport: { width: 1400, height: 720 },
},
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
use: {
...devices["Desktop Safari"],
viewport: { width: 1400, height: 720 },
},
},
/* Test against mobile viewports. */

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#57514E"><path d="m336-280 144-144 144 144 56-56-144-144 144-144-56-56-144 144-144-144-56 56 144 144-144 144 56 56ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 507 B

View File

@@ -0,0 +1,206 @@
import { expect, type Page, test } from "@playwright/test"
import { serializeBookingSearchParams } from "@scandic-hotels/booking-flow/utils/url"
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
test("can make a search with city", async ({ page }) => {
await page.goto("/")
// Search for city
const combobox = page.getByRole("combobox", { name: /where to/i })
await combobox.click()
await combobox.fill("stockholm")
await page.getByRole("option", { name: /stockholm sweden/i }).click()
// Open datepicker
// If we had better accessibility for our datepicker this would be so much easier
const today = new Date()
const tomorrow = new Date(today)
tomorrow.setDate(today.getDate() + 1)
await page
.getByRole("button", {
name: `${formatDate(today)} - ${formatDate(tomorrow)}`,
})
.click()
// Select future dates
const twoDaysFromNow = new Date(today)
twoDaysFromNow.setDate(today.getDate() + 2)
await clickDatePickerDate(page, twoDaysFromNow)
const threeDaysFromNow = new Date(today)
threeDaysFromNow.setDate(today.getDate() + 3)
await clickDatePickerDate(page, threeDaysFromNow)
await page
.getByRole("button", {
name: /select dates/i,
})
.click()
// Select rooms and guests
// Once again, better accessibility would make this so much easier
await page.getByRole("button", { name: /1 room, 1 adult/i }).click()
const roomsDialog = page.getByRole("dialog")
const room1section = roomsDialog.getByText(/room 1/i).locator("..")
// Add 1 adult
await room1section
.locator("section")
.filter({ hasText: /adults/i })
.getByRole("button", { name: /add/i })
.click()
// Add 1 child aged 10
await room1section
.locator("section")
.filter({ hasText: /children/i })
.getByRole("button", { name: /add/i })
.click()
await room1section.getByRole("button", { name: /age/i }).click()
await page.getByRole("option", { name: /10/i }).click()
await page.getByRole("button", { name: /add room/i }).click()
const room2section = roomsDialog.getByText(/room 2/i).locator("..")
// Add 2 adults
await room2section
.locator("section")
.filter({ hasText: /adults/i })
.getByRole("button", { name: /add/i })
.click({ clickCount: 2 })
await roomsDialog.getByRole("button", { name: /done/i }).click()
await page.getByRole("button", { name: /search/i }).click()
// Assert that we navigated to the correct URL
const expectedSearchParams = serializeBookingSearchParams({
rooms: [
{
adults: 2,
childrenInRoom: [{ age: 10, bed: ChildBedMapEnum.IN_EXTRA_BED }],
},
{
adults: 3,
childrenInRoom: [],
},
],
fromDate: twoDaysFromNow.toISOString().split("T")[0],
toDate: threeDaysFromNow.toISOString().split("T")[0],
city: "STOCKHOLM",
})
await expect(page).toHaveURL(
`/en/hotelreservation/select-hotel?${expectedSearchParams}`
)
})
test("can make a search with hotel", async ({ page }) => {
await page.goto("/")
// Search for hotel
const combobox = page.getByRole("combobox", { name: /where to/i })
await combobox.click()
await combobox.fill("downtown camper")
await page.getByRole("option", { name: /downtown camper/i }).click()
// Open datepicker
// If we had better accessibility for our datepicker this would be so much easier
const today = new Date()
const tomorrow = new Date(today)
tomorrow.setDate(today.getDate() + 1)
await page
.getByRole("button", {
name: `${formatDate(today)} - ${formatDate(tomorrow)}`,
})
.click()
// Select future dates
const twoDaysFromNow = new Date(today)
twoDaysFromNow.setDate(today.getDate() + 2)
await clickDatePickerDate(page, twoDaysFromNow)
const threeDaysFromNow = new Date(today)
threeDaysFromNow.setDate(today.getDate() + 3)
await clickDatePickerDate(page, threeDaysFromNow)
await page
.getByRole("button", {
name: /select dates/i,
})
.click()
// Select rooms and guests
// Once again, better accessibility would make this so much easier
await page.getByRole("button", { name: /1 room, 1 adult/i }).click()
const roomsDialog = page.getByRole("dialog")
const room1section = roomsDialog.getByText(/room 1/i).locator("..")
// Add 1 adult
await room1section
.locator("section")
.filter({ hasText: /adults/i })
.getByRole("button", { name: /add/i })
.click()
// Add 1 child aged 10
await room1section
.locator("section")
.filter({ hasText: /children/i })
.getByRole("button", { name: /add/i })
.click()
await room1section.getByRole("button", { name: /age/i }).click()
await page.getByRole("option", { name: /10/i }).click()
await page.getByRole("button", { name: /add room/i }).click()
const room2section = roomsDialog.getByText(/room 2/i).locator("..")
// Add 2 adults
await room2section
.locator("section")
.filter({ hasText: /adults/i })
.getByRole("button", { name: /add/i })
.click({ clickCount: 2 })
await roomsDialog.getByRole("button", { name: /done/i }).click()
await page.getByRole("button", { name: /search/i }).click()
// Assert that we navigated to the correct URL
const expectedSearchParams = serializeBookingSearchParams({
rooms: [
{
adults: 2,
childrenInRoom: [{ age: 10, bed: ChildBedMapEnum.IN_EXTRA_BED }],
},
{
adults: 3,
childrenInRoom: [],
},
],
fromDate: twoDaysFromNow.toISOString().split("T")[0],
toDate: threeDaysFromNow.toISOString().split("T")[0],
hotelId: "879", // Downtown Camper
})
await expect(page).toHaveURL(
`/en/hotelreservation/select-rate?${expectedSearchParams}`
)
})
const formatDate = (date: Date) => {
const day = date.getDate()
const month = date.toLocaleDateString("en-US", { month: "short" })
const weekday = date.toLocaleDateString("en-US", { weekday: "short" })
return `${weekday}, ${day} ${month}`
}
const clickDatePickerDate = async (page: Page, date: Date) => {
const dateString = date.toISOString().split("T")[0] // YYYY-MM-DD format
await page.locator(`[data-day="${dateString}"]`).getByRole("button").click()
}

View File

@@ -1,7 +0,0 @@
import { expect, test } from "@playwright/test"
test("has text", async ({ page }) => {
await page.goto("/")
await expect(page.getByText(/hello world/i)).toBeVisible()
})

View File

@@ -1,5 +1,6 @@
import { parseBookingWidgetSearchParams } from "@scandic-hotels/booking-flow/utils/url"
import StartPage from "@/components/ContentType/StartPage"
import { parseBookingWidgetSearchParams } from "@/utils/url"
import type { NextSearchParams, PageArgs } from "@/types/params"

View File

@@ -1,5 +1,9 @@
import { notFound } from "next/navigation"
import {
bookingConfirmation,
details,
} from "@scandic-hotels/common/constants/routes/hotelReservation"
import { logger } from "@scandic-hotels/common/logger"
import { getServiceToken } from "@scandic-hotels/common/tokenManager"
import { BookingErrorCodeEnum } from "@scandic-hotels/trpc/enums/bookingErrorCode"
@@ -8,10 +12,6 @@ import { encrypt } from "@scandic-hotels/trpc/utils/encryption"
import { isValidSession } from "@scandic-hotels/trpc/utils/session"
import { PaymentCallbackStatusEnum } from "@/constants/booking"
import {
bookingConfirmation,
details,
} from "@/constants/routes/hotelReservation"
import { serverClient } from "@/lib/trpc/server"
import { auth } from "@/auth"

View File

@@ -1,10 +1,11 @@
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { parseSelectHotelSearchParams } from "@scandic-hotels/booking-flow/utils/url"
import { SelectHotelMapContainer } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainer"
import { SelectHotelMapContainerSkeleton } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainerSkeleton"
import { MapContainer } from "@/components/MapContainer"
import { parseSelectHotelSearchParams } from "@/utils/url"
import styles from "./page.module.css"

View File

@@ -3,8 +3,10 @@ import { cookies } from "next/headers"
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { parseSelectHotelSearchParams } from "@scandic-hotels/booking-flow/utils/url"
import { alternativeHotelsMap } from "@scandic-hotels/common/constants/routes/hotelReservation"
import { FamilyAndFriendsCodes } from "@/constants/booking"
import { alternativeHotelsMap } from "@/constants/routes/hotelReservation"
import FnFNotAllowedAlert from "@/components/HotelReservation/FnFNotAllowedAlert/FnFNotAllowedAlert"
import SelectHotel from "@/components/HotelReservation/SelectHotel"
@@ -13,7 +15,6 @@ import { getTracking } from "@/components/HotelReservation/SelectHotel/tracking"
import TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n"
import { getHotelSearchDetails } from "@/utils/hotelSearchDetails"
import { parseSelectHotelSearchParams } from "@/utils/url"
import {
type LangParams,

View File

@@ -2,6 +2,8 @@ import { cookies } from "next/headers"
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { parseDetailsSearchParams } from "@scandic-hotels/booking-flow/utils/url"
import { FamilyAndFriendsCodes } from "@/constants/booking"
import {
getBreakfastPackages,
@@ -20,7 +22,6 @@ import EnterDetailsTrackingWrapper from "@/components/HotelReservation/EnterDeta
import FnFNotAllowedAlert from "@/components/HotelReservation/FnFNotAllowedAlert/FnFNotAllowedAlert"
import RoomProvider from "@/providers/Details/RoomProvider"
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
import { parseDetailsSearchParams } from "@/utils/url"
import styles from "./page.module.css"

View File

@@ -2,10 +2,11 @@ import stringify from "json-stable-stringify-without-jsonify"
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { parseSelectHotelSearchParams } from "@scandic-hotels/booking-flow/utils/url"
import { SelectHotelMapContainer } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainer"
import { SelectHotelMapContainerSkeleton } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainerSkeleton"
import { MapContainer } from "@/components/MapContainer"
import { parseSelectHotelSearchParams } from "@/utils/url"
import styles from "./page.module.css"

View File

@@ -3,8 +3,10 @@ import { cookies } from "next/headers"
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { parseSelectHotelSearchParams } from "@scandic-hotels/booking-flow/utils/url"
import { selectHotelMap } from "@scandic-hotels/common/constants/routes/hotelReservation"
import { FamilyAndFriendsCodes } from "@/constants/booking"
import { selectHotelMap } from "@/constants/routes/hotelReservation"
import FnFNotAllowedAlert from "@/components/HotelReservation/FnFNotAllowedAlert/FnFNotAllowedAlert"
import SelectHotel from "@/components/HotelReservation/SelectHotel"
@@ -12,7 +14,6 @@ import { getHotels } from "@/components/HotelReservation/SelectHotel/helpers"
import { getTracking } from "@/components/HotelReservation/SelectHotel/tracking"
import TrackingSDK from "@/components/TrackingSDK"
import { getHotelSearchDetails } from "@/utils/hotelSearchDetails"
import { parseSelectHotelSearchParams } from "@/utils/url"
import type { LangParams, NextSearchParams, PageArgs } from "@/types/params"

View File

@@ -1,11 +1,11 @@
import { notFound } from "next/navigation"
import { parseSelectRateSearchParams } from "@scandic-hotels/booking-flow/utils/url"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import { combineRegExps, rateTypeRegex } from "@/constants/booking"
import SelectRate from "@/components/HotelReservation/SelectRate"
import { parseSelectRateSearchParams } from "@/utils/url"
import type { LangParams, NextSearchParams, PageArgs } from "@/types/params"

View File

@@ -1,7 +1,9 @@
import { BookingWidget } from "@scandic-hotels/booking-flow/BookingWidget"
import { parseBookingWidgetSearchParams } from "@scandic-hotels/booking-flow/utils/url"
import { getDestinationCityPage } from "@/lib/trpc/memoizedRequests"
import { BookingWidget } from "@/components/BookingWidget"
import { parseBookingWidgetSearchParams } from "@/utils/url"
import { getLang } from "@/i18n/serverContext"
import type { NextSearchParams, PageArgs } from "@/types/params"
@@ -20,5 +22,7 @@ export default async function BookingWidgetDestinationCityPage(
const booking = parseBookingWidgetSearchParams(bookingWidgetSearchParams)
return <BookingWidget booking={booking} />
const lang = await getLang()
return <BookingWidget booking={booking} lang={lang} />
}

View File

@@ -1,8 +1,9 @@
import { BookingWidget } from "@scandic-hotels/booking-flow/BookingWidget"
import { parseBookingWidgetSearchParams } from "@scandic-hotels/booking-flow/utils/url"
import { getHotel, getHotelPage } from "@/lib/trpc/memoizedRequests"
import { BookingWidget } from "@/components/BookingWidget"
import { getLang } from "@/i18n/serverContext"
import { parseBookingWidgetSearchParams } from "@/utils/url"
import type { NextSearchParams, PageArgs } from "@/types/params"
@@ -12,9 +13,10 @@ export default async function BookingWidgetHotelPage(
const searchParams = await props.searchParams
const hotelPageData = await getHotelPage()
const lang = await getLang()
const hotelData = await getHotel({
hotelId: hotelPageData?.hotel_page_id || "",
language: await getLang(),
language: lang,
isCardOnlyPayment: false,
})
@@ -34,5 +36,5 @@ export default async function BookingWidgetHotelPage(
const booking = parseBookingWidgetSearchParams(bookingWidgetSearchParams)
return <BookingWidget booking={booking} />
return <BookingWidget booking={booking} lang={lang} />
}

View File

@@ -1,4 +1,4 @@
import { BookingWidgetSkeleton } from "@/components/BookingWidget/Client"
import { BookingWidgetSkeleton } from "@scandic-hotels/booking-flow/BookingWidget/Skeleton"
// This file is crucial for displaying a loading
// state immediately in the booking flow.

View File

@@ -1,5 +1,7 @@
import { BookingWidget } from "@/components/BookingWidget"
import { parseBookingWidgetSearchParams } from "@/utils/url"
import { BookingWidget } from "@scandic-hotels/booking-flow/BookingWidget"
import { parseBookingWidgetSearchParams } from "@scandic-hotels/booking-flow/utils/url"
import { getLang } from "@/i18n/serverContext"
import type { LangParams, NextSearchParams, PageArgs } from "@/types/params"
@@ -10,5 +12,7 @@ export default async function BookingWidgetPage(
const booking = parseBookingWidgetSearchParams(searchParams)
return <BookingWidget booking={booking} />
const lang = await getLang()
return <BookingWidget booking={booking} lang={lang} />
}

View File

@@ -1,5 +1,7 @@
import { BookingWidget } from "@/components/BookingWidget"
import { parseBookingWidgetSearchParams } from "@/utils/url"
import { BookingWidget } from "@scandic-hotels/booking-flow/BookingWidget"
import { parseBookingWidgetSearchParams } from "@scandic-hotels/booking-flow/utils/url"
import { getLang } from "@/i18n/serverContext"
import type { LangParams, NextSearchParams, PageArgs } from "@/types/params"
@@ -10,5 +12,7 @@ export default async function BookingWidgetPage(
const booking = parseBookingWidgetSearchParams(searchParams)
return <BookingWidget booking={booking} />
const lang = await getLang()
return <BookingWidget booking={booking} lang={lang} />
}

View File

@@ -7,6 +7,7 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import Script from "next/script"
import { SessionProvider } from "next-auth/react"
import { BookingFlowTrackingProvider } from "@scandic-hotels/booking-flow/BookingFlowTrackingProvider"
import { Lang } from "@scandic-hotels/common/constants/language"
import TrpcProvider from "@/lib/trpc/Provider"
@@ -26,6 +27,7 @@ import { FontPreload } from "@/fonts/font-preloading"
import { getMessages } from "@/i18n"
import ClientIntlProvider from "@/i18n/Provider"
import { setLang } from "@/i18n/serverContext"
import { trackBookingSearchClick } from "@/utils/tracking/booking"
import type { LangParams, LayoutArgs } from "@/types/params"
@@ -65,17 +67,23 @@ export default async function RootLayout(
>
<TrpcProvider>
<RACRouterProvider>
<RouteChange />
<SitewideAlert />
<Header />
{bookingwidget}
{children}
<Footer />
<ToastHandler />
<SessionRefresher />
<StorageCleaner />
<CookieBotConsent />
<ReactQueryDevtools initialIsOpen={false} />
<BookingFlowTrackingProvider
trackingFunctions={{
trackBookingSearchClick,
}}
>
<RouteChange />
<SitewideAlert />
<Header />
{bookingwidget}
{children}
<Footer />
<ToastHandler />
<SessionRefresher />
<StorageCleaner />
<CookieBotConsent />
<ReactQueryDevtools initialIsOpen={false} />
</BookingFlowTrackingProvider>
</RACRouterProvider>
</TrpcProvider>
</ClientIntlProvider>

View File

@@ -1,13 +1,13 @@
import Link from "next/link"
import React, { Suspense } from "react"
import { hotelreservation } from "@scandic-hotels/common/constants/routes/hotelReservation"
import { partnerSas } from "@scandic-hotels/common/constants/routes/myPages"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { hotelreservation } from "@/constants/routes/hotelReservation"
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import Image from "@/components/Image"

View File

@@ -1,254 +0,0 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useSearchParams } from "next/navigation"
import { use, useEffect, useRef, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { dt } from "@scandic-hotels/common/dt"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { trpc } from "@scandic-hotels/trpc/client"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import { StickyElementNameEnum } from "@/stores/sticky-position"
import Form, {
BookingWidgetFormSkeleton,
} from "@/components/Forms/BookingWidget"
import { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema"
import useLang from "@/hooks/useLang"
import useStickyPosition from "@/hooks/useStickyPosition"
import { debounce } from "@/utils/debounce"
import isValidJson from "@/utils/isValidJson"
import MobileToggleButton, {
MobileToggleButtonSkeleton,
} from "./MobileToggleButton"
import {
bookingWidgetContainerVariants,
formContainerVariants,
} from "./variant"
import styles from "./bookingWidget.module.css"
import type {
BookingCodeSchema,
BookingWidgetClientProps,
BookingWidgetSchema,
} from "@/types/components/bookingWidget"
export default function BookingWidgetClient({
type,
data,
pageSettingsBookingCodePromise,
}: BookingWidgetClientProps) {
const [isOpen, setIsOpen] = useState(false)
const bookingWidgetRef = useRef(null)
const lang = useLang()
const [originalOverflowY, setOriginalOverflowY] = useState<string | null>(
null
)
const shouldFetchAutoComplete = !!data.hotelId || !!data.city
const { data: destinationsData, isPending } =
trpc.autocomplete.destinations.useQuery(
{
lang,
query: "",
includeTypes: ["hotels", "cities"],
selectedHotelId: data.hotelId ? data.hotelId.toString() : undefined,
selectedCity: data.city,
},
{ enabled: shouldFetchAutoComplete }
)
const shouldShowSkeleton = shouldFetchAutoComplete && isPending
useStickyPosition({
ref: bookingWidgetRef,
name: StickyElementNameEnum.BOOKING_WIDGET,
})
const now = dt()
// if fromDate or toDate is undefined, dt will return value that represents the same as 'now' above.
// this is fine as isDateParamValid will catch this and default the values accordingly.
let fromDate = dt(data.fromDate)
let toDate = dt(data.toDate)
const isDateParamValid =
fromDate.isValid() &&
toDate.isValid() &&
fromDate.isSameOrAfter(now, "day") &&
toDate.isAfter(fromDate)
if (!isDateParamValid) {
fromDate = now
toDate = now.add(1, "day")
}
let selectedLocation =
destinationsData?.currentSelection.hotel ??
destinationsData?.currentSelection.city
// if bookingCode is not provided in the search params,
// we will fetch it from the page settings stored in Contentstack.
const selectedBookingCode =
data.bookingCode ||
(pageSettingsBookingCodePromise !== null
? use(pageSettingsBookingCodePromise)
: "")
const defaultRoomsData: BookingWidgetSchema["rooms"] = data.rooms?.map(
(room) => ({
adults: room.adults,
childrenInRoom: room.childrenInRoom || [],
})
) ?? [
{
adults: 1,
childrenInRoom: [],
},
]
const hotelId = data.hotelId ? parseInt(data.hotelId) : undefined
const methods = useForm({
defaultValues: {
search: selectedLocation?.name ?? "",
// Only used for displaying the selected location for mobile, not for actual form input
selectedSearch: selectedLocation?.name ?? "",
date: {
fromDate: fromDate.format("YYYY-MM-DD"),
toDate: toDate.format("YYYY-MM-DD"),
},
bookingCode: {
value: selectedBookingCode,
remember: false,
},
redemption: data.searchType === SEARCH_TYPE_REDEMPTION,
rooms: defaultRoomsData,
city: data.city || undefined,
hotel: hotelId,
},
shouldFocusError: false,
mode: "onSubmit",
resolver: zodResolver(bookingWidgetSchema),
reValidateMode: "onSubmit",
})
const searchParams = useSearchParams()
const bookingCodeFromSearchParams = searchParams.get("bookingCode") || ""
const [bookingCode, setBookingCode] = useState(bookingCodeFromSearchParams)
if (bookingCode !== bookingCodeFromSearchParams) {
methods.setValue("bookingCode", {
value: bookingCodeFromSearchParams,
})
setBookingCode(bookingCodeFromSearchParams)
}
useEffect(() => {
if (!selectedLocation) return
/*
If `trpc.hotel.locations.get.useQuery` hasn't been fetched previously and is hence async
we need to update the default values when data is available
*/
methods.setValue("search", selectedLocation.name)
methods.setValue("selectedSearch", selectedLocation.name)
}, [selectedLocation, methods])
function closeMobileSearch() {
setIsOpen(false)
const overflowY = originalOverflowY ?? "visible"
document.body.style.overflowY = overflowY
}
function openMobileSearch() {
setIsOpen(true)
setOriginalOverflowY(document.body.style.overflowY)
document.body.style.overflowY = "hidden"
}
useEffect(() => {
const observer = new ResizeObserver(
debounce(([entry]) => {
if (entry.contentRect.width > 768) {
setIsOpen(false)
document.body.style.removeProperty("overflow-y")
}
})
)
observer.observe(document.body)
return () => {
observer.unobserve(document.body)
}
}, [])
useEffect(() => {
if (!window?.sessionStorage || !window?.localStorage) return
if (!selectedBookingCode) {
const storedBookingCode = localStorage.getItem("bookingCode")
const initialBookingCode: BookingCodeSchema | undefined =
storedBookingCode && isValidJson(storedBookingCode)
? JSON.parse(storedBookingCode)
: undefined
initialBookingCode?.remember &&
methods.setValue("bookingCode", initialBookingCode)
}
}, [methods, selectedBookingCode])
if (shouldShowSkeleton) {
return <BookingWidgetSkeleton type={type} />
}
const classNames = bookingWidgetContainerVariants({
type,
})
const formContainerClassNames = formContainerVariants({
type,
})
return (
<FormProvider {...methods}>
<section ref={bookingWidgetRef} className={classNames} data-open={isOpen}>
<MobileToggleButton openMobileSearch={openMobileSearch} />
<div className={formContainerClassNames}>
<button
className={styles.close}
onClick={closeMobileSearch}
type="button"
>
<MaterialIcon icon="close" />
</button>
<Form type={type} onClose={closeMobileSearch} />
</div>
</section>
<div className={styles.backdrop} onClick={closeMobileSearch} />
</FormProvider>
)
}
export function BookingWidgetSkeleton({
type = "full",
}: {
type?: BookingWidgetClientProps["type"]
}) {
const classNames = bookingWidgetContainerVariants({
type,
})
return (
<>
<section className={classNames} style={{ top: 0 }}>
<MobileToggleButtonSkeleton />
<div className={styles.formContainer}>
<BookingWidgetFormSkeleton type={type} />
</div>
</section>
</>
)
}

View File

@@ -1,28 +0,0 @@
.floatingBookingWidget {
width: var(--max-width-content);
margin: 0 auto;
min-height: 84px;
z-index: 1000;
position: relative;
.floatingBackground {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
}
&[data-intersecting="true"] {
.floatingBackground {
background: var(--Surface-UI-Fill-Default);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
margin-top: var(--sitewide-alert-sticky-height);
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
}
}
}

View File

@@ -1,65 +0,0 @@
"use client"
import { useEffect, useRef, useState } from "react"
import BookingWidgetClient from "../Client"
import styles from "./FloatingBookingWidget.module.css"
import type { BookingWidgetClientProps } from "@/types/components/bookingWidget"
type Props = Omit<BookingWidgetClientProps, "type">
export function FloatingBookingWidgetClient(props: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const [stickyTop, setStickyTop] = useState(false)
useEffect(() => {
observerRef.current = new IntersectionObserver(
([entry]) => {
const hasScrolledPastTop = entry.boundingClientRect.top < 0
setStickyTop(hasScrolledPastTop)
},
{ threshold: 0, rootMargin: "0px 0px -100% 0px" }
)
if (containerRef.current) {
observerRef.current?.observe(containerRef.current)
}
return () => {
observerRef.current?.disconnect()
}
}, [])
useEffect(() => {
/*
Re-observe the element on an interval to ensure the observer is up to date
This is a workaround for the fact that the observer doesn't always trigger
when the element is scrolled out of view if you do it too fast
*/
const interval = setInterval(() => {
if (!containerRef.current || !observerRef.current) {
return
}
observerRef.current.unobserve(containerRef.current)
observerRef.current.observe(containerRef.current)
}, 500)
return () => clearInterval(interval)
}, [])
return (
<div
className={styles.floatingBookingWidget}
data-intersecting={stickyTop}
ref={containerRef}
>
<div className={styles.floatingBackground}>
<BookingWidgetClient {...props} type={"compact"} />
</div>
</div>
)
}

View File

@@ -1,30 +0,0 @@
import {
getPageSettingsBookingCode,
isBookingWidgetHidden,
} from "@/lib/trpc/memoizedRequests"
import { FloatingBookingWidgetClient } from "./FloatingBookingWidgetClient"
import type { BookingWidgetProps } from "@/types/components/bookingWidget"
export async function FloatingBookingWidget({
booking,
}: Omit<BookingWidgetProps, "type">) {
const isHidden = await isBookingWidgetHidden()
if (isHidden) {
return null
}
let pageSettingsBookingCodePromise: Promise<string> | null = null
if (!booking.bookingCode) {
pageSettingsBookingCodePromise = getPageSettingsBookingCode()
}
return (
<FloatingBookingWidgetClient
data={booking}
pageSettingsBookingCodePromise={pageSettingsBookingCodePromise}
/>
)
}

View File

@@ -1,67 +0,0 @@
.complete,
.partial {
border: none;
align-items: center;
box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.16);
cursor: pointer;
display: grid;
gap: var(--Spacing-x-one-and-half);
padding: var(--Spacing-x2);
z-index: 1;
background-color: var(--Base-Surface-Primary-light-Normal);
width: 100%;
/* In some cases the lingering pressend event will trigger the */
/* webkit tap styling (but not triggering the buttons press event) */
/* To avoid this "flash" the styling is set to transparent) */
/* It is a non-standard css proprty, so shouldn't have too much of an effect on accessibility. */
-webkit-tap-highlight-color: transparent;
border-radius: 10px;
}
.complete {
grid-template-columns: 1fr 36px;
}
.partial {
grid-template-columns:
minmax(auto, 120px) min-content 1fr
auto;
}
.block {
display: block;
}
.block > * {
display: block;
text-align: start;
}
.blockLabel {
color: var(--Scandic-Red-Default);
}
.locationAndDate {
color: var(--Scandic-Grey-100);
}
.placeholder {
color: var(--Text-Interactive-Placeholder);
}
.icon {
align-items: center;
background-color: var(--Base-Button-Primary-Fill-Normal);
border-radius: 50%;
display: flex;
height: 36px;
justify-content: center;
justify-self: flex-end;
width: 36px;
}
@media screen and (min-width: 768px) {
.complete,
.partial {
display: none;
}
}

View File

@@ -1,213 +0,0 @@
"use client"
import { Button } from "react-aria-components"
import { useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { dt } from "@scandic-hotels/common/dt"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { shortDateFormat } from "@/constants/dateFormats"
import useLang from "@/hooks/useLang"
import styles from "./button.module.css"
import type {
BookingWidgetSchema,
BookingWidgetToggleButtonProps,
} from "@/types/components/bookingWidget"
export default function MobileToggleButton({
openMobileSearch,
}: BookingWidgetToggleButtonProps) {
const intl = useIntl()
const lang = useLang()
const date = useWatch<BookingWidgetSchema, "date">({ name: "date" })
const rooms = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" })
const searchTerm = useWatch<BookingWidgetSchema, "search">({ name: "search" })
const selectedSearchTerm = useWatch<BookingWidgetSchema, "selectedSearch">({
name: "selectedSearch",
})
const selectedFromDate = dt(date.fromDate)
.locale(lang)
.format(shortDateFormat[lang])
const selectedToDate = dt(date.toDate)
.locale(lang)
.format(shortDateFormat[lang])
const locationAndDateIsSet = searchTerm && date
const totalNights = dt(date.toDate).diff(dt(date.fromDate), "days")
const totalRooms = rooms.length
const totalAdults = rooms.reduce((acc, room) => {
if (room.adults) {
acc = acc + room.adults
}
return acc
}, 0)
const totalChildren = rooms.reduce((acc, room) => {
if (room.childrenInRoom) {
acc = acc + room.childrenInRoom.length
}
return acc
}, 0)
const totalNightsMsg = intl.formatMessage(
{
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights }
)
const totalAdultsMsg = intl.formatMessage(
{
defaultMessage: "{totalAdults, plural, one {# adult} other {# adults}}",
},
{ totalAdults }
)
const totalChildrenMsg = intl.formatMessage(
{
defaultMessage:
"{totalChildren, plural, one {# child} other {# children}}",
},
{ totalChildren }
)
const totalRoomsMsg = intl.formatMessage(
{
defaultMessage: "{totalRooms, plural, one {# room} other {# rooms}}",
},
{ totalRooms }
)
const totalDetails = [totalAdultsMsg]
if (totalChildren > 0) {
totalDetails.push(totalChildrenMsg)
}
totalDetails.push(totalRoomsMsg)
return (
<Button
className={locationAndDateIsSet ? styles.complete : styles.partial}
onPress={openMobileSearch}
>
{!locationAndDateIsSet && (
<>
<span className={styles.block}>
<Typography variant={"Body/Supporting text (caption)/smBold"}>
<span className={styles.blockLabel}>
{intl.formatMessage({
defaultMessage: "Where to?",
})}
</span>
</Typography>
<Typography variant={"Body/Paragraph/mdRegular"}>
<span className={styles.placeholder}>
{searchTerm
? searchTerm
: intl.formatMessage({
defaultMessage: "Destination",
})}
</span>
</Typography>
</span>
{/* Button can't contain HR elements */}
<Divider color="Border/Divider/Subtle" variant="vertical" />
<span className={styles.block}>
<Typography variant={"Body/Supporting text (caption)/smBold"}>
<span className={styles.blockLabel}>{totalNightsMsg}</span>
</Typography>
<Typography variant={"Body/Paragraph/mdRegular"}>
<span className={styles.placeholder}>
{intl.formatMessage(
{
defaultMessage: "{selectedFromDate} - {selectedToDate}",
},
{
selectedFromDate,
selectedToDate,
}
)}
</span>
</Typography>
</span>
<span className={styles.icon}>
<MaterialIcon icon="search" color="Icon/Inverted" />
</span>
</>
)}
{locationAndDateIsSet && (
<>
<span className={styles.block}>
<Typography variant={"Body/Supporting text (caption)/smRegular"}>
<span className={styles.blockLabel}>{selectedSearchTerm}</span>
</Typography>
<Typography variant={"Body/Supporting text (caption)/smRegular"}>
<span className={styles.locationAndDate}>
{intl.formatMessage(
{
defaultMessage:
"{selectedFromDate} - {selectedToDate} ({totalNights}) {details}",
},
{
selectedFromDate,
selectedToDate,
totalNights: totalNightsMsg,
details: totalDetails.join(", "),
}
)}
</span>
</Typography>
</span>
<span className={styles.icon}>
<MaterialIcon icon="edit_square" color="Icon/Inverted" />
</span>
</>
)}
</Button>
)
}
export function MobileToggleButtonSkeleton() {
const intl = useIntl()
return (
<div className={styles.partial}>
<span className={styles.block}>
<Typography variant={"Body/Supporting text (caption)/smBold"}>
<span className={styles.blockLabel}>
{intl.formatMessage({
defaultMessage: "Where to?",
})}
</span>
</Typography>
<SkeletonShimmer display={"block"} height="20px" width="11ch" />
</span>
<Divider color="Border/Divider/Subtle" variant="vertical" />
<span className={styles.block}>
<Typography variant="Body/Supporting text (caption)/smBold">
<span className={styles.blockLabel}>
{intl.formatMessage(
{
defaultMessage:
"{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights: 0 }
)}
</span>
</Typography>
<SkeletonShimmer display={"block"} height="20px" width="13ch" />
</span>
<span className={styles.icon}>
<MaterialIcon icon="search" color="Icon/Inverted" />
</span>
</div>
)
}

View File

@@ -1,87 +0,0 @@
.wrapper {
position: sticky;
z-index: var(--booking-widget-z-index);
width: 100%;
}
/* Make sure Date Picker is placed on top of other sticky/fixed components */
.wrapper:has([data-isopen="true"]) {
z-index: 100;
}
.formContainer {
display: grid;
grid-template-rows: auto 1fr;
background-color: var(--UI-Input-Controls-Surface-Normal);
border-radius: 0;
gap: var(--Spacing-x3);
height: calc(100dvh - max(var(--sitewide-alert-height), 20px));
width: 100%;
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x7);
position: fixed;
left: 0;
bottom: -100%;
transition: bottom 300ms ease;
}
.compact {
.formContainer {
border-radius: var(--Corner-radius-lg);
}
}
@media screen and (max-width: 767px) {
.formContainer {
border-radius: var(--Corner-radius-lg) var(--Corner-radius-lg) 0 0;
}
}
.wrapper[data-open="true"] {
z-index: var(--booking-widget-open-z-index);
}
.wrapper[data-open="true"] .formContainer {
left: 0;
bottom: 0;
}
.close {
background: none;
border: none;
cursor: pointer;
justify-self: flex-end;
padding: 0;
}
.wrapper[data-open="true"] + .backdrop {
background-color: rgba(0, 0, 0, 0.4);
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
z-index: calc(var(--booking-widget-open-z-index) - 1);
}
@media screen and (min-width: 768px) {
.wrapper {
top: 0;
}
.formContainer {
display: block;
background-color: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
height: auto;
position: static;
padding: 0;
&.compactFormContainer {
box-shadow: none;
}
}
.close {
display: none;
}
}

View File

@@ -1,39 +0,0 @@
import { Suspense } from "react"
import {
getPageSettingsBookingCode,
isBookingWidgetHidden,
} from "@/lib/trpc/memoizedRequests"
import BookingWidgetClient, { BookingWidgetSkeleton } from "./Client"
import type { BookingWidgetProps } from "@/types/components/bookingWidget"
export async function BookingWidget(props: BookingWidgetProps) {
return (
<Suspense fallback={<BookingWidgetSkeleton />}>
<InternalBookingWidget {...props} />
</Suspense>
)
}
async function InternalBookingWidget({ type, booking }: BookingWidgetProps) {
const isHidden = await isBookingWidgetHidden()
if (isHidden) {
return null
}
let pageSettingsBookingCodePromise: Promise<string> | null = null
if (!booking.bookingCode) {
pageSettingsBookingCodePromise = getPageSettingsBookingCode()
}
return (
<BookingWidgetClient
type={type}
data={booking}
pageSettingsBookingCodePromise={pageSettingsBookingCodePromise}
/>
)
}

View File

@@ -1,29 +0,0 @@
import { cva } from "class-variance-authority"
import styles from "./bookingWidget.module.css"
export const bookingWidgetContainerVariants = cva(styles.wrapper, {
variants: {
type: {
default: "",
full: "",
compact: styles.compact,
},
},
defaultVariants: {
type: "full",
},
})
export const formContainerVariants = cva(styles.formContainer, {
variants: {
type: {
default: "",
full: "",
compact: styles.compactFormContainer,
},
},
defaultVariants: {
type: "full",
},
})

View File

@@ -4,12 +4,12 @@ import { useMap, useMapsLibrary } from "@vis.gl/react-google-maps"
import { useEffect, useMemo, useState } from "react"
import { useIntl } from "react-intl"
import { debounce } from "@scandic-hotels/common/utils/debounce"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useDestinationDataStore } from "@/stores/destination-data"
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
import { debounce } from "@/utils/debounce"
import HotelListContent from "./Content"
import HotelListSkeleton from "./HotelListSkeleton"

View File

@@ -5,7 +5,8 @@ import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { z } from "zod"
import { Search } from "@/components/Forms/BookingWidget/FormContent/Search"
import { Search } from "@scandic-hotels/booking-flow/BookingWidget/BookingWidgetForm/FormContent/Search"
import { toast } from "@/components/TempDesignSystem/Toasts"
const destinationSearchFormSchema = z.object({

View File

@@ -11,6 +11,7 @@ import {
} from "react"
import { useIntl } from "react-intl"
import { debounce } from "@scandic-hotels/common/utils/debounce"
import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
@@ -20,7 +21,6 @@ import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hote
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
import { useScrollToTop } from "@/hooks/useScrollToTop"
import { debounce } from "@/utils/debounce"
import DynamicMap from "./DynamicMap"
import MapContent from "./MapContent"

View File

@@ -2,12 +2,10 @@
import { useRef } from "react"
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
import { StickyElementNameEnum } from "@scandic-hotels/common/stores/sticky-position"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { StickyElementNameEnum } from "@/stores/sticky-position"
import useStickyPosition from "@/hooks/useStickyPosition"
import styles from "./sidebarContentWrapper.module.css"
interface SidebarContentWrapperProps extends React.PropsWithChildren {

View File

@@ -10,11 +10,11 @@ import {
} from "react"
import { useIntl } from "react-intl"
import { debounce } from "@scandic-hotels/common/utils/debounce"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import InteractiveMap from "@/components/Maps/InteractiveMap"
import { debounce } from "@/utils/debounce"
import Sidebar from "./Sidebar"

View File

@@ -2,9 +2,8 @@
import { type PropsWithChildren, useRef } from "react"
import { StickyElementNameEnum } from "@/stores/sticky-position"
import useStickyPosition from "@/hooks/useStickyPosition"
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
import { StickyElementNameEnum } from "@scandic-hotels/common/stores/sticky-position"
import styles from "./mapWithCard.module.css"

View File

@@ -1,11 +1,10 @@
import Link from "next/link"
import { selectRateWithParams } from "@scandic-hotels/common/constants/routes/hotelReservation"
import { dt } from "@scandic-hotels/common/dt"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { selectRateWithParams } from "@/constants/routes/hotelReservation"
import ImageGallery from "@/components/ImageGallery"
import SidePeek from "@/components/TempDesignSystem/SidePeek"
import { getIntl } from "@/i18n"

View File

@@ -11,13 +11,12 @@ import {
useState,
} from "react"
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
import { StickyElementNameEnum } from "@scandic-hotels/common/stores/sticky-position"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { StickyElementNameEnum } from "@/stores/sticky-position"
import useScrollShadows from "@/hooks/useScrollShadows"
import useScrollSpy from "@/hooks/useScrollSpy"
import useStickyPosition from "@/hooks/useStickyPosition"
import { trackHotelTabClick } from "@/utils/tracking"
import styles from "./tabNavigation.module.css"

View File

@@ -1,3 +1,4 @@
import { FloatingBookingWidget } from "@scandic-hotels/booking-flow/BookingWidget/FloatingBookingWidget"
import Title from "@scandic-hotels/design-system/Title"
import { BlocksEnums } from "@scandic-hotels/trpc/types/blocksEnum"
@@ -5,13 +6,13 @@ import { getStartPage } from "@/lib/trpc/memoizedRequests"
import Blocks from "@/components/Blocks"
import FullWidthCampaign from "@/components/Blocks/FullWidthCampaign"
import { FloatingBookingWidget } from "@/components/BookingWidget/FloatingBookingWidget"
import Image from "@/components/Image"
import TrackingSDK from "@/components/TrackingSDK"
import { getLang } from "@/i18n/serverContext"
import styles from "./startPage.module.css"
import type { BookingWidgetSearchData } from "@/types/components/bookingWidget"
import type { BookingWidgetSearchData } from "@scandic-hotels/booking-flow/BookingWidget"
export default async function StartPage({
booking,
@@ -24,6 +25,7 @@ export default async function StartPage({
}
const { header, blocks } = content.startPage
const lang = await getLang()
return (
<div className={styles.background}>
@@ -32,7 +34,7 @@ export default async function StartPage({
<Title color="white" textAlign="center">
{header.heading}
</Title>
<FloatingBookingWidget booking={booking} />
<FloatingBookingWidget booking={booking} lang={lang} />
</div>
{header.hero_image ? (
<Image

View File

@@ -1,140 +0,0 @@
"use client"
import { useState } from "react"
import { DayPicker } from "react-day-picker"
import { useIntl } from "react-intl"
import { Lang } from "@scandic-hotels/common/constants/language"
import { dt } from "@scandic-hotels/common/dt"
import Caption from "@scandic-hotels/design-system/Caption"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import { Typography } from "@scandic-hotels/design-system/Typography"
import useLang from "@/hooks/useLang"
import { locales } from "../locales"
import styles from "./desktop.module.css"
import classNames from "react-day-picker/style.module.css"
import type { DatePickerRangeProps } from "@/types/components/datepicker"
export default function DatePickerRangeDesktop({
close,
handleOnSelect,
selectedRange,
}: DatePickerRangeProps) {
const lang = useLang()
const intl = useIntl()
const [month, setMonth] = useState(selectedRange?.from ?? new Date())
/** English is default language and doesn't need to be imported */
const locale = lang === Lang.en ? undefined : locales[lang]
const currentDate = dt().toDate()
const lastDayOfPreviousMonth = dt(currentDate)
.set("date", 1)
.subtract(1, "day")
.toDate()
const yesterday = dt(currentDate).subtract(1, "day").toDate()
// Max future date allowed to book kept same as of existing prod.
const endDate = dt(currentDate).add(395, "day").toDate()
const endOfLastMonth = dt(endDate).endOf("month").toDate()
function handleMonthChange(selected: Date) {
setMonth(selected)
}
return (
<DayPicker
classNames={{
...classNames,
caption_label: `${classNames.caption_label} ${styles.captionLabel}`,
day: `${classNames.day} ${styles.day}`,
day_button: `${classNames.day_button} ${styles.dayButton}`,
footer: styles.footer,
month_caption: `${classNames.month_caption} ${styles.monthCaption}`,
months: `${classNames.months} ${styles.months}`,
range_end: styles.rangeEnd,
range_middle: styles.rangeMiddle,
range_start: styles.rangeStart,
root: `${classNames.root} ${styles.container}`,
week: styles.week,
weekday: `${classNames.weekday} ${styles.weekDay}`,
nav: `${classNames.nav} ${styles.nav}`,
button_next: `${classNames.button_next} ${styles.button_next}`,
button_previous: `${classNames.button_previous} ${styles.button_previous}`,
}}
disabled={[
{ from: lastDayOfPreviousMonth, to: yesterday },
{ from: endDate, to: endOfLastMonth },
]}
excludeDisabled
footer
formatters={{
formatWeekdayName(weekday) {
return dt(weekday).locale(lang).format("ddd")
},
}}
lang={lang}
locale={locale}
mode="range"
month={month}
numberOfMonths={2}
onSelect={handleOnSelect}
onMonthChange={handleMonthChange}
required={false}
selected={selectedRange}
startMonth={currentDate}
endMonth={endDate}
weekStartsOn={1}
components={{
Chevron(props) {
return (
<MaterialIcon
icon="chevron_left"
className={props.className}
size={20}
/>
)
},
Footer(props) {
return (
<>
<Divider
className={styles.divider}
color="Border/Divider/Subtle"
/>
<footer className={props.className}>
<Button
intent="tertiary"
onPress={close}
size="small"
theme="base"
>
<Caption color="white" type="bold" asChild>
<span>
{intl.formatMessage({
defaultMessage: "Select dates",
})}
</span>
</Caption>
</Button>
</footer>
</>
)
},
MonthCaption(props) {
return (
<div className={props.className}>
<Typography variant="Title/Subtitle/md">
<h3>{props.children}</h3>
</Typography>
</div>
)
},
}}
/>
)
}

View File

@@ -1,146 +0,0 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { type DateRange, DayPicker } from "react-day-picker"
import { useIntl } from "react-intl"
import { Lang } from "@scandic-hotels/common/constants/language"
import { dt } from "@scandic-hotels/common/dt"
import Body from "@scandic-hotels/design-system/Body"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import { Typography } from "@scandic-hotels/design-system/Typography"
import useLang from "@/hooks/useLang"
import { locales } from "../locales"
import styles from "./mobile.module.css"
import classNames from "react-day-picker/style.module.css"
import type { DatePickerRangeProps } from "@/types/components/datepicker"
export default function DatePickerRangeMobile({
close,
handleOnSelect,
selectedRange,
}: DatePickerRangeProps) {
const lang = useLang()
const intl = useIntl()
/** English is default language and doesn't need to be imported */
const locale = lang === Lang.en ? undefined : locales[lang]
const monthsRef = useRef<HTMLDivElement | null>(null)
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true)
const currentDate = dt().toDate()
const lastDayOfPreviousMonth = dt(currentDate)
.set("date", 1)
.subtract(1, "day")
.toDate()
const yesterday = dt(currentDate).subtract(1, "day").toDate()
useEffect(() => {
if (!monthsRef.current || !selectedRange?.from || !autoScrollEnabled) return
const selectedDay = monthsRef.current.querySelector(
'td[aria-selected="true"]:not([data-outside="true"])'
)
const targetMonth = selectedDay?.closest(`.${styles.month}`)
if (targetMonth) {
targetMonth.scrollIntoView({ block: "start" })
}
}, [selectedRange, autoScrollEnabled])
function handleSelectWrapper(
dateRange: DateRange | undefined,
selectedDay: Date
) {
setAutoScrollEnabled(false)
handleOnSelect(dateRange, selectedDay)
}
// Max future date allowed to book kept same as of existing prod.
const endDate = dt(currentDate).add(395, "day").toDate()
const endOfLastMonth = dt(endDate).endOf("month").toDate()
return (
<div className={styles.container} ref={monthsRef}>
<header className={styles.header}>
<button className={styles.close} onClick={close} type="button">
<MaterialIcon icon="close" />
</button>
</header>
<DayPicker
classNames={{
...classNames,
caption_label: `${classNames.caption_label} ${styles.captionLabel}`,
day: `${classNames.day} ${styles.day}`,
day_button: `${classNames.day_button} ${styles.dayButton}`,
month: styles.month,
month_caption: `${classNames.month_caption} ${styles.monthCaption}`,
months: styles.months,
range_end: styles.rangeEnd,
range_middle: styles.rangeMiddle,
range_start: styles.rangeStart,
root: `${classNames.root} ${styles.root}`,
week: styles.week,
weekday: `${classNames.weekday} ${styles.weekDay}`,
}}
disabled={[
{ from: lastDayOfPreviousMonth, to: yesterday },
{ from: endDate, to: endOfLastMonth },
]}
endMonth={endDate}
excludeDisabled
formatters={{
formatWeekdayName(weekday) {
return dt(weekday).locale(lang).format("ddd")
},
}}
hideNavigation
lang={lang}
locale={locale}
mode="range"
/** Showing full year or what's left of it */
numberOfMonths={13}
onSelect={(dateRange, selectedDay) =>
handleSelectWrapper(dateRange, selectedDay)
}
required
selected={selectedRange}
startMonth={currentDate}
weekStartsOn={1}
components={{
MonthCaption(props) {
return (
<div className={props.className}>
<Typography variant="Title/Subtitle/md">
<h3>{props.children}</h3>
</Typography>
</div>
)
},
}}
/>
<footer className={styles.footer}>
<Button
className={styles.button}
intent="tertiary"
onPress={close}
size="large"
theme="base"
>
<Body color="white" textTransform="bold" asChild>
<span>
{intl.formatMessage({
defaultMessage: "Select dates",
})}
</span>
</Body>
</Button>
</footer>
</div>
)
}

View File

@@ -1,128 +0,0 @@
@media screen and (max-width: 1366px) {
.container {
display: none;
}
}
div.months {
flex-wrap: nowrap;
}
.monthCaption {
justify-content: center;
}
.captionLabel {
text-transform: capitalize;
}
td.day,
td.rangeEnd,
td.rangeStart {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
letter-spacing: var(--typography-Body-Bold-letterSpacing);
line-height: var(--typography-Body-Bold-lineHeight);
text-decoration: var(--typography-Body-Bold-textDecoration);
}
td.rangeEnd,
td.rangeStart {
background: var(--Background-Primary);
}
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) {
border-radius: 0 50% 50% 0;
}
td.rangeStart[aria-selected="true"] {
border-radius: 50% 0 0 50%;
}
td.rangeEnd[aria-selected="true"] button.dayButton:hover,
td.rangeStart[aria-selected="true"] button.dayButton:hover {
background: var(--Primary-Light-On-Surface-Accent);
border-radius: 50%;
}
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
td.rangeStart[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
td.day[aria-selected="true"] button.dayButton {
background: var(--Primary-Light-On-Surface-Accent);
border: none;
color: var(--Base-Button-Inverted-Fill-Normal);
}
td.day,
td.day[data-today="true"] {
color: var(--UI-Text-High-contrast);
height: 40px;
padding: var(--Spacing-x-half);
width: 40px;
}
td.day button.dayButton:hover {
background: var(--Base-Surface-Secondary-light-Hover);
}
td.day[data-outside="true"] button.dayButton {
border: none;
}
td.day.rangeMiddle[aria-selected="true"],
td.rangeMiddle[aria-selected="true"] button.dayButton {
background: var(--Background-Primary);
border: none;
border-radius: 0;
color: var(--UI-Text-High-contrast);
}
td.day[data-disabled="true"],
td.day[data-disabled="true"] button.dayButton,
td.day[data-outside="true"] ~ td.day[data-disabled="true"],
td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
.week:has(td.day[data-outside="true"] ~ td.day[data-disabled="true"])
td.day[data-outside="true"]
button.dayButton {
background: none;
color: var(--Base-Text-Disabled);
cursor: not-allowed;
}
.weekDay {
color: var(--UI-Text-Placeholder);
font-family: var(--typography-Footnote-Labels-fontFamily);
font-size: var(--typography-Footnote-Labels-fontSize);
font-weight: var(--typography-Footnote-Labels-fontWeight);
letter-spacing: var(--typography-Footnote-Labels-letterSpacing);
line-height: var(--typography-Footnote-Labels-lineHeight);
text-decoration: var(--typography-Footnote-Labels-textDecoration);
text-transform: uppercase;
}
.footer {
display: flex;
justify-content: flex-end;
margin-top: var(--Spacing-x2);
}
.divider {
margin-top: var(--Spacing-x2);
}
.nav {
width: 100%;
display: flex;
justify-content: space-between;
}
.nav .button_next {
transform: rotate(180deg);
margin-left: auto;
}
.nav .button_previous:disabled,
.nav .button_next:disabled {
display: none;
}

View File

@@ -1,182 +0,0 @@
.container {
--header-height: 72px;
--sticky-button-height: 124px;
display: grid;
grid-template-areas:
"header"
"content";
grid-template-rows: var(--header-height) calc(100dvh - var(--header-height));
position: relative;
}
.container.noHeader {
grid-template-areas: "content";
grid-template-rows: auto;
}
.root {
display: grid;
grid-area: content;
}
.header {
align-self: flex-end;
background-color: var(--Main-Grey-White);
grid-area: header;
padding: var(--Spacing-x3) var(--Spacing-x2);
position: sticky;
top: 0;
z-index: 10;
display: flex;
justify-content: flex-end;
}
.select {
justify-self: center;
min-width: 100px;
transform: translateX(24px);
}
.close {
align-items: center;
background: none;
border: none;
cursor: pointer;
display: flex;
justify-self: flex-end;
}
div.months {
display: grid;
overflow-y: scroll;
scroll-snap-type: y mandatory;
}
.month {
display: grid;
justify-items: center;
padding-top: var(--Space-x3);
scroll-snap-align: start;
}
.month:last-of-type {
padding-bottom: calc(var(--sticky-button-height) + var(--Spacing-x2));
}
.monthCaption {
justify-content: center;
}
.captionLabel {
text-transform: capitalize;
}
.footer {
align-self: flex-start;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 7.5%,
#ffffff 82.5%
);
display: flex;
grid-area: content;
padding: var(--Spacing-x1) var(--Spacing-x2) var(--Spacing-x7);
position: fixed;
bottom: 0;
right: 0;
left: 0;
z-index: 10;
}
.footer .button {
width: 100%;
}
td.day,
td.rangeEnd,
td.rangeStart {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
letter-spacing: var(--typography-Body-Bold-letterSpacing);
line-height: var(--typography-Body-Bold-lineHeight);
text-decoration: var(--typography-Body-Bold-textDecoration);
}
td.rangeEnd,
td.rangeStart {
background: var(--Background-Primary);
}
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) {
border-radius: 0 50% 50% 0;
}
td.rangeStart[aria-selected="true"] {
border-radius: 50% 0 0 50%;
}
td.rangeEnd[aria-selected="true"] button.dayButton:hover,
td.rangeStart[aria-selected="true"] button.dayButton:hover {
background: var(--Primary-Light-On-Surface-Accent);
border-radius: 50%;
}
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
td.rangeStart[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
td.day[aria-selected="true"] button.dayButton {
background: var(--Primary-Light-On-Surface-Accent);
border: none;
color: var(--Base-Button-Inverted-Fill-Normal);
}
td.day,
td.day[data-today="true"] {
color: var(--UI-Text-High-contrast);
height: 40px;
padding: var(--Spacing-x-half);
width: 40px;
}
td.day[data-outside="true"] button.dayButton {
border: none;
}
td.day.rangeMiddle[aria-selected="true"],
td.rangeMiddle[aria-selected="true"] button.dayButton {
background: var(--Background-Primary);
border: none;
border-radius: 0;
color: var(--UI-Text-High-contrast);
}
td.day[data-disabled="true"],
td.day[data-disabled="true"] button.dayButton,
td.day[data-outside="true"] ~ td.day[data-disabled="true"],
td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
.week:has(td.day[data-outside="true"] ~ td.day[data-disabled="true"])
td.day[data-outside="true"]
button.dayButton {
background: none;
color: var(--Base-Text-Disabled);
cursor: not-allowed;
}
.weekDay {
color: var(--Base-Text-Medium-contrast);
opacity: 1;
font-family: var(--typography-Caption-Labels-fontFamily);
font-size: var(--typography-Caption-Labels-fontSize);
font-weight: var(--typography-Caption-Labels-fontWeight);
letter-spacing: var(--typography-Caption-Labels-letterSpacing);
line-height: var(--typography-Caption-Labels-lineHeight);
text-decoration: var(--typography-Caption-Labels-textDecoration);
text-transform: uppercase;
}
@media screen and (min-width: 1367px) {
.container {
display: none;
}
}

View File

@@ -19,7 +19,13 @@ import { locales } from "../locales"
import styles from "./desktop.module.css"
import classNames from "react-day-picker/style.module.css"
import type { DatePickerSingleProps } from "@/types/components/datepicker"
type DatePickerSingleProps = {
close: () => void
startMonth?: Date
hideHeader?: boolean
selectedDate: Date
handleOnSelect: (selected: Date) => void
}
export default function DatePickerSingleDesktop({
close,

View File

@@ -15,7 +15,13 @@ import { locales } from "../locales"
import styles from "./mobile.module.css"
import classNames from "react-day-picker/style.module.css"
import type { DatePickerSingleProps } from "@/types/components/datepicker"
type DatePickerSingleProps = {
close: () => void
startMonth?: Date
hideHeader?: boolean
selectedDate: Date
handleOnSelect: (selected: Date) => void
}
export default function DatePickerSingleMobile({
close,

View File

@@ -1,66 +0,0 @@
.btn {
background: none;
border: none;
cursor: pointer;
outline: none;
padding: 0;
width: 100%;
text-align: left;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
padding: 20px var(--Spacing-x-one-and-half) 0;
}
.body {
opacity: 0.8;
}
.hideWrapper {
background-color: var(--Main-Grey-White);
display: none;
}
.container[data-isopen="true"] .hideWrapper {
display: block;
}
@media screen and (max-width: 1366px) {
.container {
z-index: 10001;
height: 24px;
}
.hideWrapper {
bottom: 0;
left: 0;
overflow: hidden;
position: fixed;
right: 0;
top: 100%;
transition: top 300ms ease;
z-index: 10001;
}
.container[data-isopen="true"] .hideWrapper {
border-radius: var(--Corner-radius-lg) var(--Corner-radius-lg) 0 0;
top: calc(max(var(--sitewide-alert-height), 20px));
}
}
@media screen and (min-width: 1367px) {
.hideWrapper {
border-radius: var(--Corner-radius-lg);
box-shadow: var(--popup-box-shadow);
padding: var(--Spacing-x2) var(--Spacing-x3);
position: absolute;
/**
BookingWidget padding +
border-width +
wanted space below booking widget
*/
top: calc(100% + var(--Spacing-x1) + 1px + var(--Spacing-x4));
}
}

View File

@@ -1,197 +0,0 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { dt } from "@scandic-hotels/common/dt"
import Body from "@scandic-hotels/design-system/Body"
import { longDateFormat } from "@/constants/dateFormats"
import useLang from "@/hooks/useLang"
import DatePickerRangeDesktop from "./Range/Desktop"
import DatePickerRangeMobile from "./Range/Mobile"
import styles from "./date-picker.module.css"
import type { DateRange } from "react-day-picker"
import type { DatePickerFormProps } from "@/types/components/datepicker"
export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
const lang = useLang()
const intl = useIntl()
const [isOpen, setIsOpen] = useState(false)
const selectedDate = useWatch({ name })
const { register, setValue } = useFormContext()
const ref = useRef<HTMLDivElement | null>(null)
const close = useCallback(() => {
if (!selectedDate.toDate) {
setValue(
name,
{
fromDate: selectedDate.fromDate,
toDate: dt(selectedDate.fromDate).add(1, "day").format("YYYY-MM-DD"),
},
{ shouldDirty: true }
)
}
setIsOpen(false)
}, [name, setValue, selectedDate])
function showOnFocus() {
setIsOpen(true)
}
function handleSelectDate(
_nextRange: DateRange | undefined,
selectedDay: Date
) {
const now = dt()
const dateClicked = dt(selectedDay)
const dateClickedFormatted = dateClicked.format("YYYY-MM-DD")
/* check if selected date is not before todays date,
which happens when "Enter" key is pressed in any other input field of the form */
if (!dateClicked.isBefore(now, "day")) {
// Handle form value updates based on the requirements
if (selectedDate.fromDate && selectedDate.toDate) {
// Both dates were previously selected, starting fresh with new date
setValue(
name,
{
fromDate: dateClickedFormatted,
toDate: undefined,
},
{ shouldDirty: true }
)
} else if (selectedDate.fromDate && !selectedDate.toDate) {
// If the selected day is the same as the first date, we don't need to update the form value
if (dateClicked.isSame(selectedDate.fromDate)) {
return
}
// We're selecting the second date
if (dateClicked.isBefore(selectedDate.fromDate)) {
// If second selected date is before first date, swap them
setValue(
name,
{
fromDate: dateClickedFormatted,
toDate: selectedDate.fromDate,
},
{ shouldDirty: true }
)
} else {
// If second selected date is after first date, keep order
setValue(
name,
{
fromDate: selectedDate.fromDate,
toDate: dateClickedFormatted,
},
{ shouldDirty: true }
)
}
}
}
}
const closeIfOutside = useCallback(
(target: HTMLElement) => {
if (ref.current && target && !ref.current.contains(target)) {
close()
}
},
[close, ref]
)
function closeOnBlur(evt: FocusEvent) {
if (isOpen) {
const target = evt.relatedTarget as HTMLElement
closeIfOutside(target)
}
}
useEffect(() => {
function handleClickOutside(evt: Event) {
if (isOpen) {
const target = evt.target as HTMLElement
closeIfOutside(target)
}
}
document.body.addEventListener("click", handleClickOutside)
return () => {
document.body.removeEventListener("click", handleClickOutside)
}
}, [closeIfOutside, isOpen])
const selectedFromDate = dt(selectedDate.fromDate)
.locale(lang)
.format(longDateFormat[lang])
const selectedToDate = !!selectedDate.toDate
? dt(selectedDate.toDate).locale(lang).format(longDateFormat[lang])
: ""
return (
<div
className={styles.container}
onBlur={(e) => {
closeOnBlur(e.nativeEvent)
}}
data-isopen={isOpen}
ref={ref}
>
<button
className={styles.btn}
onFocus={showOnFocus}
onClick={() => setIsOpen(true)}
type="button"
>
<Body className={styles.body} asChild color="uiTextHighContrast">
<span>
{intl.formatMessage(
{
defaultMessage: "{selectedFromDate} - {selectedToDate}",
},
{
selectedFromDate,
selectedToDate,
}
)}
</span>
</Body>
</button>
<input {...register("date.fromDate")} type="hidden" />
<input {...register("date.toDate")} type="hidden" />
<div aria-modal className={styles.hideWrapper} role="dialog">
<DatePickerRangeDesktop
close={close}
handleOnSelect={handleSelectDate}
// DayPicker lib needs Daterange in form as below to show appropriate UI
selectedRange={{
from: dt(selectedDate.fromDate).toDate(),
to: selectedDate.toDate
? dt(selectedDate.toDate).toDate()
: undefined,
}}
/>
{isOpen && (
<DatePickerRangeMobile
close={close}
handleOnSelect={handleSelectDate}
// DayPicker lib needs Daterange in form as below to show appropriate UI
selectedRange={{
from: dt(selectedDate.fromDate).toDate(),
to: selectedDate.toDate
? dt(selectedDate.toDate).toDate()
: undefined,
}}
/>
)}
</div>
</div>
)
}

View File

@@ -1,129 +0,0 @@
.container {
position: relative;
display: grid;
gap: var(--Spacing-x1);
}
.bookingCode {
height: 60px;
background-color: var(--Background-Primary);
border-radius: var(--Corner-radius-md);
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
}
.bookingCodeLabel {
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
position: relative;
color: var(--Text-Secondary);
}
.errorContainer {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.error {
display: flex;
gap: var(--Space-x1);
white-space: break-spaces;
color: var(--UI-Text-Error);
}
.bookingCodeRemember,
.bookingCodeRememberVisible {
display: none;
gap: var(--Spacing-x1);
}
.bookingCodeRememberVisible {
display: flex;
width: 100%;
}
.bookingCodeTooltip {
max-width: 560px;
margin-top: var(--Spacing-x2);
}
.bookingCodeRememberVisible label {
align-items: center;
}
.removeButton {
width: 100%;
}
@media screen and (max-width: 767px) {
.hideOnMobile {
display: none;
}
.removeButton {
display: none;
}
}
@media screen and (min-width: 768px) {
.bookingCode {
height: auto;
background-color: transparent;
}
.bookingCodeRememberVisible {
align-items: center;
background: var(--Base-Surface-Primary-light-Normal);
justify-content: space-between;
border-radius: var(--Spacing-x-one-and-half);
}
.error {
color: var(--Text-Default);
}
}
@media screen and (min-width: 768px) and (max-width: 1366px) {
.container {
display: flex;
}
.codePopover {
background: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Spacing-x-one-and-half);
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
padding: var(--Spacing-x2);
width: 320px;
}
.popover {
display: grid;
gap: var(--Spacing-x2);
}
.overlayTrigger {
position: absolute;
top: 0;
bottom: 0;
display: block;
left: 0;
right: var(--Spacing-x3);
}
}
@media screen and (min-width: 1367px) {
.container:hover {
background-color: var(--Surface-Primary-Hover);
border-radius: var(--Corner-radius-md);
}
.container:focus-within,
.container:has([data-focused="true"]),
.container:has([data-pressed="true"]) {
background-color: var(--Surface-Primary-Hover);
border-radius: var(--Corner-radius-md);
border: 1px solid var(--Border-Interactive-Focus);
}
.bookingCodeRememberVisible {
padding: var(--Spacing-x2);
position: absolute;
top: calc(100% + var(--Spacing-x3));
left: calc(0% - var(--Spacing-x-half));
width: 360px;
box-shadow: var(--popup-box-shadow);
}
}

View File

@@ -1,442 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { Dialog, DialogTrigger, Popover } from "react-aria-components"
import { type FieldError, useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import Body from "@scandic-hotels/design-system/Body"
import Caption from "@scandic-hotels/design-system/Caption"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import {
type ButtonProps,
OldDSButton as Button,
} from "@scandic-hotels/design-system/OldDSButton"
import Switch from "@scandic-hotels/design-system/Switch"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import Modal from "@/components/Modal"
import Input from "@/components/TempDesignSystem/Form/Input"
import { getErrorMessage } from "@/utils/getErrorMessage"
import { Input as BookingWidgetInput } from "../Input"
import { isMultiRoomError } from "../utils"
import styles from "./booking-code.module.css"
import type {
BookingCodeSchema,
BookingWidgetSchema,
} from "@/types/components/bookingWidget"
export default function BookingCode() {
const intl = useIntl()
const checkIsTablet = useMediaQuery(
"(min-width: 768px) and (max-width: 1366px)"
)
const checkIsDesktop = useMediaQuery("(min-width: 1367px)")
const [isTablet, setIsTablet] = useState(false)
const [isDesktop, setIsDesktop] = useState(false)
const {
setValue,
formState: { errors },
getValues,
trigger,
} = useFormContext<BookingWidgetSchema>()
const bookingCode: BookingCodeSchema = getValues("bookingCode")
const [showRemember, setShowRemember] = useState(false)
const [showRememberMobile, setShowRememberMobile] = useState(false)
const codeError = errors["bookingCode"]?.value
const codeVoucher = intl.formatMessage({
defaultMessage: "Code / Voucher",
})
const addCode = intl.formatMessage({
defaultMessage: "Add code",
})
const ref = useRef<HTMLDivElement | null>(null)
const removeExtraRoomsText = intl.formatMessage({
defaultMessage: "Remove extra rooms",
})
function updateBookingCodeFormValue(value: string) {
// Set value and show error if validation fails
setValue("bookingCode.value", value.toUpperCase(), {
shouldValidate: true,
shouldDirty: true,
})
if (getValues(SEARCH_TYPE_REDEMPTION)) {
// Remove the redemption as user types booking code and show notification for the same
// Add delay to handle table mode rendering
setTimeout(function () {
setValue(SEARCH_TYPE_REDEMPTION, false, {
shouldValidate: true,
shouldDirty: true,
})
})
// Hide the above notification popup after 5 seconds by re-triggering validation
// This is kept consistent with location search field error notification timeout
setTimeout(function () {
trigger("bookingCode.value")
}, 5000)
}
}
const closeIfOutside = useCallback(
(target: HTMLElement) => {
if (
ref.current &&
target &&
!ref.current.contains(target) &&
// This is for mobile layout having "Remove extra rooms" button outside the container
target.innerText !== removeExtraRoomsText
) {
setShowRemember(false)
if (codeError) {
setValue("bookingCode.value", "", {
shouldValidate: true,
shouldDirty: true,
})
}
}
},
[setShowRemember, setValue, removeExtraRoomsText, ref, codeError]
)
function showRememberCheck() {
setShowRemember(true)
}
useEffect(() => {
setIsTablet(checkIsTablet)
}, [checkIsTablet])
useEffect(() => {
setIsDesktop(checkIsDesktop)
}, [checkIsDesktop])
const isRememberMobileVisible =
!isDesktop && (showRemember || !!bookingCode?.remember)
useEffect(() => {
setShowRememberMobile(isRememberMobileVisible)
}, [isRememberMobileVisible])
useEffect(() => {
function handleClickOutside(evt: Event) {
if (showRemember) {
const target = evt.target as HTMLElement
closeIfOutside(target)
}
}
document.body.addEventListener("click", handleClickOutside)
return () => {
document.body.removeEventListener("click", handleClickOutside)
}
}, [closeIfOutside, showRemember])
return isTablet ? (
<TabletBookingCode
bookingCode={bookingCode}
updateValue={updateBookingCodeFormValue}
/>
) : (
<div
className={styles.container}
ref={ref}
onFocus={showRememberCheck}
onBlur={(e) => closeIfOutside(e.nativeEvent.relatedTarget as HTMLElement)}
>
<div className={styles.bookingCode}>
<div className={styles.bookingCodeLabel}>
<Typography variant="Body/Supporting text (caption)/smBold">
<span>{codeVoucher}</span>
</Typography>
<CodeRulesModal />
</div>
<BookingWidgetInput
className="input"
type="search"
placeholder={addCode}
name="bookingCode.value"
id="booking-code"
onChange={(event) => updateBookingCodeFormValue(event.target.value)}
autoComplete="off"
value={bookingCode?.value}
/>
</div>
{isDesktop ? (
<div
className={
showRemember
? styles.bookingCodeRememberVisible
: styles.bookingCodeRemember
}
>
{codeError?.message ? (
<BookingCodeError codeError={codeError} isDesktop />
) : (
<CodeRemember
bookingCodeValue={bookingCode?.value}
onApplyClick={() => setShowRemember(false)}
/>
)}
</div>
) : (
<>
{codeError?.message ? (
<BookingCodeError codeError={codeError} />
) : (
<div
className={
showRememberMobile
? styles.bookingCodeRememberVisible
: styles.bookingCodeRemember
}
>
<Switch name="bookingCode.remember" className="mobile-switch">
<Caption asChild>
<span>
{intl.formatMessage({
defaultMessage: "Remember code",
})}
</span>
</Caption>
</Switch>
</div>
)}
</>
)}
</div>
)
}
type CodeRememberProps = {
bookingCodeValue: string | undefined
onApplyClick: () => void
}
function CodeRulesModal() {
const intl = useIntl()
const codeVoucher = intl.formatMessage({
defaultMessage: "Code / Voucher",
})
const bookingCodeTooltipText = intl.formatMessage({
defaultMessage:
"If you're booking a promotional offer or a Corporate negotiated rate you'll need a special booking code. Don't use any special characters such as (.) (,) (-) (:). If you would like to make a booking with code VOF, please call us +46 8 517 517 20.Save your booking code for the next time you visit the page by ticking the box “Remember”. Don't tick the box if you're using a public computer to avoid unauthorized access to your booking code.",
})
return (
<Modal
trigger={
<Button intent="text">
<MaterialIcon
icon="info"
color="Icon/Interactive/Placeholder"
size={20}
/>
</Button>
}
title={codeVoucher}
>
<Body color="uiTextHighContrast" className={styles.bookingCodeTooltip}>
{bookingCodeTooltipText}
</Body>
</Modal>
)
}
function CodeRemember({ bookingCodeValue, onApplyClick }: CodeRememberProps) {
const intl = useIntl()
return (
<>
<Checkbox name="bookingCode.remember">
<Caption asChild>
<span>
{intl.formatMessage({
defaultMessage: "Remember code",
})}
</span>
</Caption>
</Checkbox>
{bookingCodeValue ? (
<Button
size="small"
className={styles.hideOnMobile}
intent="tertiary"
theme="base"
type="button"
onClick={onApplyClick}
>
{intl.formatMessage({
defaultMessage: "Apply",
})}
</Button>
) : null}
</>
)
}
function BookingCodeError({
codeError,
isDesktop = false,
}: {
codeError: FieldError
isDesktop?: boolean
}) {
const intl = useIntl()
const isMultiroomError = isMultiRoomError(codeError.message)
return (
<div className={styles.errorContainer}>
<Typography
className={styles.error}
variant="Body/Supporting text (caption)/smRegular"
>
<span>
<MaterialIcon
size={20}
icon="error"
color="Icon/Feedback/Error"
isFilled={!isDesktop}
/>
{getErrorMessage(intl, codeError.message)}
</span>
</Typography>
{isMultiroomError ? (
<div className={styles.removeButton}>
<RemoveExtraRooms fullWidth />
</div>
) : null}
</div>
)
}
export function RemoveExtraRooms({ ...props }: ButtonProps) {
const intl = useIntl()
const { getValues, setValue, trigger } = useFormContext<BookingWidgetSchema>()
function removeExtraRooms() {
// Timeout to delay the event scheduling issue with touch events on mobile
window.setTimeout(() => {
const rooms = getValues("rooms")[0]
setValue("rooms", [rooms], { shouldValidate: true, shouldDirty: true })
trigger("bookingCode.value")
trigger(SEARCH_TYPE_REDEMPTION)
}, 300)
}
return (
<Button
type="button"
onClick={removeExtraRooms}
size="small"
intent="secondary"
{...props}
>
{intl.formatMessage({
defaultMessage: "Remove extra rooms",
})}
</Button>
)
}
function TabletBookingCode({
bookingCode,
updateValue,
}: {
bookingCode: BookingCodeSchema
updateValue: (value: string) => void
}) {
const intl = useIntl()
const [isOpen, setIsOpen] = useState(!!bookingCode?.value)
const {
setValue,
register,
formState: { errors },
} = useFormContext<BookingWidgetSchema>()
const codeError = errors["bookingCode"]?.value
const codeVoucher = intl.formatMessage({
defaultMessage: "Code / Voucher",
})
function toggleModal(isOpen: boolean) {
if (document.body) {
if (isOpen) {
document.body.style.overflow = "visible"
} else {
// !important needed to override 'overflow: hidden' set by react-aria.
// 'overflow: hidden' does not work in combination with other sticky positioned elements, which clip does.
document.body.style.overflow = "clip !important"
}
}
if (!isOpen && !bookingCode?.value) {
setValue("bookingCode.flag", false, { shouldDirty: true })
setIsOpen(isOpen)
} else if (!codeError || isOpen) {
setIsOpen(isOpen)
if (isOpen || bookingCode?.value) {
setValue("bookingCode.flag", true, { shouldDirty: true })
}
}
}
return (
<div className={styles.container}>
<DialogTrigger isOpen={isOpen} onOpenChange={toggleModal}>
<Button type="button" intent="text">
{/* For some reason Checkbox click triggers twice modal state change, which returns the modal back to old state. So we are using overlay as trigger for modal */}
<div className={styles.overlayTrigger}></div>
<Checkbox
checked={!!bookingCode?.value}
{...register("bookingCode.flag", {
onChange: function () {
if (bookingCode?.value || isOpen) {
setValue("bookingCode.flag", true, { shouldDirty: true })
}
},
})}
>
<Caption color="uiTextMediumContrast" type="bold" asChild>
<span>{codeVoucher}</span>
</Caption>
</Checkbox>
</Button>
<Popover
className={styles.codePopover}
placement="bottom start"
offset={36}
>
<Dialog>
{({ close }) => (
<div className={styles.popover}>
<Input
label={intl.formatMessage({
defaultMessage: "Add code",
})}
{...register("bookingCode.value", {
onChange: (e) => updateValue(e.target.value),
})}
autoComplete="off"
hideError
/>
<div className={styles.bookingCodeRememberVisible}>
{codeError?.message ? (
<BookingCodeError codeError={codeError} />
) : (
<CodeRemember
bookingCodeValue={bookingCode?.value}
onApplyClick={close}
/>
)}
</div>
</div>
)}
</Dialog>
</Popover>
</DialogTrigger>
<CodeRulesModal />
</div>
)
}

View File

@@ -1,17 +0,0 @@
import React, { forwardRef, type InputHTMLAttributes } from "react"
import { Input as InputRAC } from "react-aria-components"
import Body from "@scandic-hotels/design-system/Body"
import styles from "./input.module.css"
export const Input = forwardRef<
HTMLInputElement,
InputHTMLAttributes<HTMLInputElement>
>(function InputComponent(props, ref) {
return (
<Body asChild color="uiTextHighContrast">
<InputRAC {...props} ref={ref} className={styles.input} />
</Body>
)
})

View File

@@ -1,27 +0,0 @@
.input {
background-color: transparent;
border: none;
height: 24px;
outline: none;
position: relative;
width: 100%;
z-index: 2;
&:placeholder-shown::-webkit-search-cancel-button {
display: none;
background-image: url("/_static/icons/cancel.svg");
}
&:not(:placeholder-shown)::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
background-image: url("/_static/icons/cancel.svg");
height: 24px;
width: 24px;
}
}
.input:disabled,
.input:disabled::placeholder {
color: var(--Base-Text-Disabled);
}

View File

@@ -1,147 +0,0 @@
"use client"
import { useCallback, useEffect, useRef } from "react"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import Caption from "@scandic-hotels/design-system/Caption"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import Modal from "@/components/Modal"
import { getErrorMessage } from "@/utils/getErrorMessage"
import { RemoveExtraRooms } from "../BookingCode"
import { isMultiRoomError } from "../utils"
import styles from "./reward-night.module.css"
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
export default function RewardNight() {
const intl = useIntl()
const {
setValue,
getValues,
formState: { errors },
trigger,
} = useFormContext<BookingWidgetSchema>()
const ref = useRef<HTMLDivElement | null>(null)
const reward = intl.formatMessage({
defaultMessage: "Reward Night",
})
const rewardNightTooltip = intl.formatMessage({
defaultMessage:
"To book a reward night, make sure you're logged in to your Scandic Friends account.",
})
const redemptionErr = errors[SEARCH_TYPE_REDEMPTION]
const isDesktop = useMediaQuery("(min-width: 767px)")
function validateRedemption(value: boolean) {
// Validate redemption as per the rules defined in the schema
trigger(SEARCH_TYPE_REDEMPTION)
if (value && getValues("bookingCode.value")) {
setValue("bookingCode.flag", false)
setValue("bookingCode.value", "", { shouldValidate: true })
// Hide the notification popup after 5 seconds by re-triggering validation
// This is kept consistent with location search field error notification timeout
setTimeout(() => {
trigger(SEARCH_TYPE_REDEMPTION)
}, 5000)
}
}
const resetOnMultiroomError = useCallback(() => {
if (isMultiRoomError(redemptionErr?.message) && isDesktop) {
setValue(SEARCH_TYPE_REDEMPTION, false, { shouldValidate: true })
}
}, [redemptionErr?.message, setValue, isDesktop])
function closeOnBlur(evt: FocusEvent) {
const target = evt.relatedTarget as HTMLElement
if (ref.current && target && !ref.current.contains(target)) {
resetOnMultiroomError()
}
}
useEffect(() => {
const clearIfOutside = function (evt: Event) {
const target = evt.target as HTMLElement
if (ref.current && target && !ref.current.contains(target)) {
resetOnMultiroomError()
}
}
document.body.addEventListener("click", clearIfOutside)
return () => {
document.body.removeEventListener("click", clearIfOutside)
}
}, [resetOnMultiroomError, ref])
return (
<div ref={ref} onBlur={(e) => closeOnBlur(e.nativeEvent)}>
<Checkbox
hideError
name={SEARCH_TYPE_REDEMPTION}
registerOptions={{
onChange: (e) => {
validateRedemption(e.target.value)
},
}}
>
<div className={styles.rewardNightLabel}>
<Caption color="uiTextMediumContrast" asChild>
<span>{reward}</span>
</Caption>
<Modal
trigger={
<Button intent="text">
<MaterialIcon
icon="info"
size={20}
color="Icon/Interactive/Placeholder"
className={styles.errorIcon}
/>
</Button>
}
title={reward}
>
<Typography
variant="Body/Paragraph/mdRegular"
className={styles.rewardNightTooltip}
>
<span>{rewardNightTooltip}</span>
</Typography>
</Modal>
</div>
</Checkbox>
{redemptionErr && (
<div className={styles.errorContainer}>
<Typography
className={styles.error}
variant="Body/Supporting text (caption)/smRegular"
>
<span>
<MaterialIcon
icon="error"
size={20}
color="Icon/Feedback/Error"
className={styles.errorIcon}
isFilled={!isDesktop}
/>
{getErrorMessage(intl, redemptionErr.message)}
</span>
</Typography>
{isMultiRoomError(redemptionErr.message) ? (
<div className={styles.hideOnMobile}>
<RemoveExtraRooms fullWidth />
</div>
) : null}
</div>
)}
</div>
)
}

View File

@@ -1,48 +0,0 @@
.errorContainer {
display: grid;
gap: var(--Space-x2);
margin-top: var(--Space-x2);
}
.error {
display: flex;
gap: var(--Space-x1);
color: var(--UI-Text-Error);
white-space: break-spaces;
}
.errorIcon {
min-width: 20px;
}
.rewardNightLabel {
align-items: center;
display: flex;
gap: var(--Spacing-x1);
}
.rewardNightTooltip {
max-width: 560px;
margin-top: var(--Spacing-x2);
}
@media screen and (max-width: 767px) {
.hideOnMobile {
display: none;
}
}
@media screen and (min-width: 768px) {
.errorContainer {
border-radius: var(--Space-x15);
padding: var(--Space-x2);
background: var(--Base-Surface-Primary-light-Normal);
position: absolute;
top: calc(100% + 16px);
width: 320px;
margin-top: 0;
box-shadow: var(--popup-box-shadow);
}
.error {
color: var(--Text-Default);
}
}

View File

@@ -1,19 +0,0 @@
.button {
align-items: center;
border: none;
border-radius: var(--Corner-radius-md);
cursor: pointer;
display: flex;
gap: var(--Spacing-x-half);
outline: none;
padding: var(--Spacing-x1);
}
.default {
background-color: transparent;
}
.active,
.button:focus {
background-color: var(--Surface-Primary-Hover);
}

View File

@@ -1,45 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import Caption from "@scandic-hotels/design-system/Caption"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { buttonVariants } from "./variants"
import type { ClearSearchButtonProps } from "@/types/components/search"
export default function ClearSearchButton({
getItemProps,
handleClearSearchHistory,
highlightedIndex,
index,
}: ClearSearchButtonProps) {
const intl = useIntl()
const classNames = buttonVariants({
variant: index === highlightedIndex ? "active" : "default",
})
return (
<button
{...getItemProps({
className: classNames,
id: "clear-search",
index,
item: "clear-search",
role: "button",
})}
onClick={handleClearSearchHistory}
tabIndex={0}
type="button"
>
<MaterialIcon icon="delete" color="Icon/Interactive/Default" size={20} />
<Caption color="burgundy" type="bold" asChild>
<span>
{intl.formatMessage({
defaultMessage: "Clear searches",
})}
</span>
</Caption>
</button>
)
}

View File

@@ -1,17 +0,0 @@
import { cva } from "class-variance-authority"
import styles from "./button.module.css"
const config = {
variants: {
variant: {
active: styles.active,
default: styles.default,
},
},
defaultVariants: {
variant: "default",
},
} as const
export const buttonVariants = cva(styles.button, config)

View File

@@ -1,45 +0,0 @@
.dialog {
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-lg);
display: flex;
flex-direction: column;
left: 0;
list-style: none;
overflow-y: auto;
padding: var(--Spacing-x2) var(--Spacing-x3);
position: fixed;
top: calc(140px + max(var(--sitewide-alert-height), 25px));
width: 100%;
height: calc(100% - 200px);
z-index: 10010;
}
.default {
gap: var(--Spacing-x1);
}
.error {
gap: var(--Spacing-x-half);
}
.search {
gap: var(--Spacing-x3);
}
@media (min-width: 768px) {
.dialog {
position: absolute;
width: 360px;
/**
* var(--Spacing-x4) to account for padding inside
* the bookingwidget and to add the padding for the
* box itself
*/
top: calc(100% + var(--Spacing-x4));
z-index: 99;
box-shadow: var(--popup-box-shadow);
max-height: 380px;
height: auto;
}
}

View File

@@ -1,13 +0,0 @@
import { dialogVariants } from "./variants"
import type { DialogProps } from "@/types/components/search"
export default function Dialog({
children,
className,
getMenuProps,
variant,
}: DialogProps) {
const classNames = dialogVariants({ className, variant })
return <div {...getMenuProps({ className: classNames })}>{children}</div>
}

View File

@@ -1,18 +0,0 @@
import { cva } from "class-variance-authority"
import styles from "./dialog.module.css"
const config = {
variants: {
variant: {
default: styles.default,
error: styles.error,
search: styles.search,
},
},
defaultVariants: {
variant: "default",
},
} as const
export const dialogVariants = cva(styles.dialog, config)

View File

@@ -1,13 +0,0 @@
import Footnote from "@scandic-hotels/design-system/Footnote"
import styles from "./list.module.css"
export default function Label({ children }: React.PropsWithChildren) {
return (
<li className={styles.label}>
<Footnote color="uiTextPlaceholder" textTransform="uppercase">
{children}
</Footnote>
</li>
)
}

View File

@@ -1,60 +0,0 @@
import Body from "@scandic-hotels/design-system/Body"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { listItemVariants } from "./variants"
import type { AutoCompleteLocation } from "@scandic-hotels/trpc/routers/autocomplete/schema"
import type { SearchListProps } from "@/types/components/search"
export interface ListItemProps
extends Pick<SearchListProps, "getItemProps" | "highlightedIndex"> {
index: number
location: AutoCompleteLocation
}
export default function ListItem({
getItemProps,
highlightedIndex,
index,
location,
}: ListItemProps) {
const classNames = listItemVariants({
variant: index === highlightedIndex ? "active" : "default",
})
return (
<li
{...getItemProps({
className: classNames,
index,
item: location,
})}
>
<Body color="black" textTransform="bold">
{location.name}
</Body>
{location.destination && (
<Body color="uiTextPlaceholder">{location.destination}</Body>
)}
</li>
)
}
export function ListItemSkeleton() {
const classNames = listItemVariants({
variant: "default",
})
return (
<li className={classNames}>
<div style={{ marginBottom: "0.25rem" }}>
<SkeletonShimmer width={"200px"} height="18px" display="block" />
</div>
<div>
<SkeletonShimmer width={"70px"} height="18px" display="block" />
</div>
</li>
)
}

View File

@@ -1,13 +0,0 @@
.listItem {
border-radius: var(--Corner-radius-md);
cursor: pointer;
padding: var(--Spacing-x1);
}
.default {
background-color: transparent;
}
.active {
background-color: var(--Surface-Primary-Hover);
}

View File

@@ -1,17 +0,0 @@
import { cva } from "class-variance-authority"
import styles from "./listItem.module.css"
const config = {
variants: {
variant: {
active: styles.active,
default: styles.default,
},
},
defaultVariants: {
variant: "default",
},
} as const
export const listItemVariants = cva(styles.listItem, config)

View File

@@ -1,56 +0,0 @@
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import Label from "./Label"
import ListItem, { ListItemSkeleton } from "./ListItem"
import styles from "./list.module.css"
import type { AutoCompleteLocation } from "@scandic-hotels/trpc/routers/autocomplete/schema"
import type { SearchListProps } from "@/types/components/search"
interface ListProps
extends Pick<SearchListProps, "getItemProps" | "highlightedIndex"> {
initialIndex?: number
label?: string
locations: AutoCompleteLocation[]
}
export default function List({
getItemProps,
highlightedIndex,
initialIndex = 0,
label,
locations,
}: ListProps) {
if (!locations.length) {
return null
}
return (
<ul className={styles.list}>
{label ? <Label>{label}</Label> : null}
{locations.map((location, index) => (
<ListItem
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
index={initialIndex + index}
key={location.id + index}
location={location}
/>
))}
</ul>
)
}
export function ListSkeleton() {
return (
<ul className={styles.list}>
<Label>
<SkeletonShimmer width="50px" height="15px" display="block" />
</Label>
{Array.from({ length: 2 }, (_, index) => (
<ListItemSkeleton key={index} />
))}
</ul>
)
}

View File

@@ -1,9 +0,0 @@
.list {
display: flex;
flex-direction: column;
list-style: none;
}
.label {
padding: 0 var(--Spacing-x1);
}

View File

@@ -1,284 +0,0 @@
"use client"
import { useEffect } from "react"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useDebounceValue } from "usehooks-ts"
import Body from "@scandic-hotels/design-system/Body"
import Caption from "@scandic-hotels/design-system/Caption"
import { Divider } from "@scandic-hotels/design-system/Divider"
import Footnote from "@scandic-hotels/design-system/Footnote"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { trpc } from "@scandic-hotels/trpc/client"
import useLang from "@/hooks/useLang"
import ClearSearchButton from "./ClearSearchButton"
import Dialog from "./Dialog"
import List, { ListSkeleton } from "./List"
import styles from "./searchList.module.css"
import type { SearchListProps } from "@/types/components/search"
export default function SearchList({
searchInputName,
getItemProps,
getMenuProps,
handleClearSearchHistory,
highlightedIndex,
isOpen,
search,
searchHistory,
includeTypes,
}: SearchListProps) {
const lang = useLang()
const intl = useIntl()
const {
clearErrors,
formState: { errors, isSubmitted },
} = useFormContext()
const searchError = errors[searchInputName]
const [debouncedSearch, setDebouncedSearch] = useDebounceValue(search, 300)
useEffect(() => {
setDebouncedSearch(search)
}, [search, setDebouncedSearch])
const autocompleteQueryEnabled = !!debouncedSearch
const {
data: autocompleteData,
isPending,
isError,
} = trpc.autocomplete.destinations.useQuery(
{ query: debouncedSearch, lang, includeTypes },
{ enabled: autocompleteQueryEnabled }
)
const typeFilteredSearchHistory = searchHistory?.filter((item) => {
return includeTypes.includes(item.type)
})
useEffect(() => {
clearErrors(searchInputName)
}, [search, clearErrors, searchInputName])
useEffect(() => {
let timeoutID: ReturnType<typeof setTimeout> | null = null
if (searchError) {
timeoutID = setTimeout(() => {
clearErrors(searchInputName)
// magic number originates from animation
// 5000ms delay + 120ms exectuion
}, 5120)
}
return () => {
if (timeoutID) {
clearTimeout(timeoutID)
}
}
}, [clearErrors, searchError, searchInputName])
if (searchError && isSubmitted && typeof searchError.message === "string") {
if (searchError.message === "Required") {
return (
<SearchListError
getMenuProps={getMenuProps}
caption={intl.formatMessage({
defaultMessage: "Enter destination or hotel",
})}
body={intl.formatMessage({
defaultMessage:
"A destination or hotel name is needed to be able to search for a hotel room.",
})}
/>
)
}
if (searchError.type === "custom") {
return (
<SearchListError
getMenuProps={getMenuProps}
caption={intl.formatMessage({
defaultMessage: "No results",
})}
body={intl.formatMessage({
defaultMessage:
"We couldn't find a matching location for your search.",
})}
/>
)
}
}
if (isError) {
return (
<SearchListError
getMenuProps={getMenuProps}
caption={intl.formatMessage({
defaultMessage: "Unable to search",
})}
body={intl.formatMessage({
defaultMessage:
"An error occurred while searching, please try again.",
})}
/>
)
}
if (!isOpen) {
return null
}
if (
(autocompleteQueryEnabled && isPending) ||
(search !== debouncedSearch && search)
) {
return (
<Dialog getMenuProps={getMenuProps}>
<ListSkeleton />
</Dialog>
)
}
const hasAutocompleteItems =
!!autocompleteData &&
(autocompleteData.hits.cities.length > 0 ||
autocompleteData.hits.hotels.length > 0)
if (!hasAutocompleteItems && debouncedSearch) {
return (
<Dialog getMenuProps={getMenuProps} variant="error">
<Body className={styles.text} textTransform="bold">
{intl.formatMessage({
defaultMessage: "No results",
})}
</Body>
<Body className={styles.text} color="uiTextPlaceholder">
{intl.formatMessage({
defaultMessage:
"We couldn't find a matching location for your search.",
})}
</Body>
{typeFilteredSearchHistory && typeFilteredSearchHistory.length > 0 && (
<>
<Divider className={styles.noResultsDivider} />
<Footnote
className={styles.text}
color="uiTextPlaceholder"
textTransform="uppercase"
>
{intl.formatMessage({
defaultMessage: "Latest searches",
})}
</Footnote>
<List
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
locations={typeFilteredSearchHistory}
/>
<Divider className={styles.divider} />
<ClearSearchButton
getItemProps={getItemProps}
handleClearSearchHistory={handleClearSearchHistory}
highlightedIndex={highlightedIndex}
index={typeFilteredSearchHistory.length}
/>
</>
)}
</Dialog>
)
}
const displaySearchHistory =
!debouncedSearch && typeFilteredSearchHistory?.length
if (displaySearchHistory) {
return (
<Dialog getMenuProps={getMenuProps}>
<Footnote color="uiTextPlaceholder" textTransform="uppercase">
{intl.formatMessage({
defaultMessage: "Latest searches",
})}
</Footnote>
<List
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
locations={typeFilteredSearchHistory}
/>
<Divider className={styles.divider} />
<ClearSearchButton
getItemProps={getItemProps}
handleClearSearchHistory={handleClearSearchHistory}
highlightedIndex={highlightedIndex}
index={typeFilteredSearchHistory.length}
/>
</Dialog>
)
}
if (!search) {
return null
}
return (
<Dialog getMenuProps={getMenuProps} variant="search">
<List
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
label={intl.formatMessage({
defaultMessage: "Countries",
})}
locations={autocompleteData?.hits.countries ?? []}
/>
<List
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
initialIndex={autocompleteData?.hits.countries.length ?? 0}
label={intl.formatMessage({
defaultMessage: "Cities",
})}
locations={autocompleteData?.hits.cities ?? []}
/>
<List
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
initialIndex={
(autocompleteData?.hits.countries.length ?? 0) +
(autocompleteData?.hits.cities.length ?? 0)
}
label={intl.formatMessage({
defaultMessage: "Hotels",
})}
locations={autocompleteData?.hits.hotels ?? []}
/>
</Dialog>
)
}
function SearchListError({
caption,
body,
getMenuProps,
}: {
caption: string
body: string
getMenuProps: SearchListProps["getMenuProps"]
}) {
return (
<Dialog
className={`${styles.fadeOut} ${styles.searchError}`}
getMenuProps={getMenuProps}
variant="error"
>
<Caption className={styles.heading} color="red">
<MaterialIcon icon="error" color="Icon/Interactive/Accent" />
{caption}
</Caption>
<Body>{body}</Body>
</Dialog>
)
}

View File

@@ -1,35 +0,0 @@
.searchError {
white-space: normal;
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.fadeOut {
animation: fade-out 120ms ease-out 5000ms forwards;
}
.divider {
margin: var(--Spacing-x2) var(--Spacing-x0) var(--Spacing-x1);
}
.noResultsDivider {
margin: var(--Spacing-x2) 0;
}
.heading {
align-items: center;
display: flex;
gap: var(--Spacing-x1);
}
.text {
padding: 0 var(--Spacing-x1);
}

View File

@@ -1,280 +0,0 @@
"use client"
import { cva } from "class-variance-authority"
import Downshift from "downshift"
import { type ChangeEvent, type FormEvent, useId } from "react"
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { logger } from "@scandic-hotels/common/logger"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useSearchHistory } from "@/hooks/useSearchHistory"
import { Input } from "../Input"
import SearchList from "./SearchList"
import styles from "./search.module.css"
import type { AutoCompleteLocation } from "@scandic-hotels/trpc/routers/autocomplete/schema"
interface SearchProps {
autoFocus?: boolean
alwaysShowResults?: boolean
className?: string
handlePressEnter: () => void
inputName: string
onSelect?: (selectedItem: AutoCompleteLocation) => void
variant?: "rounded" | "default"
withSearchButton?: boolean
selectOnBlur?: boolean
includeTypes: ("cities" | "hotels" | "countries")[]
}
export function Search({
autoFocus,
alwaysShowResults,
handlePressEnter,
inputName: SEARCH_TERM_NAME,
onSelect,
variant,
withSearchButton = false,
selectOnBlur = false,
includeTypes,
}: SearchProps) {
const { register, setValue, setFocus } = useFormContext()
const intl = useIntl()
const searchLabelId = useId()
const searchTerm = useWatch({ name: SEARCH_TERM_NAME }) as string
const { searchHistory, insertSearchHistoryItem, clearHistory } =
useSearchHistory()
function handleOnChange(
evt: FormEvent<HTMLInputElement> | ChangeEvent<HTMLInputElement>
) {
const newValue = evt.currentTarget.value
setValue(SEARCH_TERM_NAME, newValue)
}
function handleOnSelect(selectedItem: AutoCompleteLocation | null) {
if (!selectedItem) {
return
}
setValue("selectedSearch", selectedItem.name)
setValue(SEARCH_TERM_NAME, selectedItem.name)
insertSearchHistoryItem(selectedItem)
switch (selectedItem.type) {
case "cities":
setValue("hotel", undefined)
setValue("city", selectedItem.cityIdentifier)
break
case "hotels":
setValue("hotel", +selectedItem.id)
setValue("city", undefined)
break
default:
logger.error("Unhandled type:", selectedItem.type)
break
}
onSelect?.(selectedItem)
}
function handleClearSearchHistory() {
clearHistory()
}
const searchInputClassName = searchInputVariants({
withSearchButton: withSearchButton,
})
const clearButtonClassName = clearButtonVariants({
visible: !!searchTerm?.trim(),
})
return (
<Downshift
inputValue={searchTerm}
itemToString={(value) => (value ? value.name : "")}
onSelect={handleOnSelect}
defaultHighlightedIndex={0}
isOpen={alwaysShowResults}
>
{({
getInputProps,
getItemProps,
getLabelProps,
getMenuProps,
getRootProps,
highlightedIndex,
isOpen,
openMenu,
selectHighlightedItem,
}) => (
<div className={searchContainerVariants({ variant })}>
<div className={styles.inputContainer}>
<label
{...getLabelProps({
htmlFor: SEARCH_TERM_NAME,
id: searchLabelId,
})}
className={styles.label}
>
<Typography variant="Body/Supporting text (caption)/smBold">
<span>
{intl.formatMessage({ defaultMessage: "Where to?" })}
</span>
</Typography>
<div
{...getRootProps(
{ "aria-labelledby": searchLabelId },
{ suppressRefError: true }
)}
>
<div className={searchInputClassName}>
<Input
{...getInputProps({
id: SEARCH_TERM_NAME,
"aria-labelledby": searchLabelId,
onFocus() {
openMenu()
},
placeholder: intl.formatMessage({
defaultMessage: "Hotels & Destinations",
}),
value: searchTerm,
...register(SEARCH_TERM_NAME, {
onChange: handleOnChange,
onBlur: () => {
if (selectOnBlur) {
selectHighlightedItem()
}
},
}),
onKeyDown: (e) => {
if (e.key === "Enter" && !isOpen) {
handlePressEnter()
}
},
type: "search",
})}
autoFocus={autoFocus}
/>
</div>
</div>
</label>
{withSearchButton && (
<div className={styles.searchButtonContainer}>
<Typography variant="Body/Supporting text (caption)/smBold">
<Button
variant="Text"
size="Small"
aria-label={intl.formatMessage({ defaultMessage: "Clear" })}
onPress={() => {
setValue(SEARCH_TERM_NAME, "")
}}
className={clearButtonClassName}
>
<MaterialIcon icon="close" />
</Button>
</Typography>
<Button
className={styles.searchButton}
variant="Primary"
size="Small"
type="submit"
onPress={() => {
if (!searchTerm) {
setFocus(SEARCH_TERM_NAME)
return
}
openMenu()
setTimeout(() => {
// This is a workaround to ensure that the menu is open before selecting the highlighted item
// Otherwise there is no highlighted item.
// Would need to keep track of the last highlighted item otherwise
selectHighlightedItem()
}, 0)
}}
>
<Typography variant="Body/Supporting text (caption)/smBold">
<span>
<MaterialIcon icon="search" color="CurrentColor" />
{intl.formatMessage({ defaultMessage: "Search" })}
</span>
</Typography>
</Button>
</div>
)}
</div>
<SearchList
getItemProps={getItemProps}
getMenuProps={getMenuProps}
handleClearSearchHistory={handleClearSearchHistory}
highlightedIndex={highlightedIndex}
isOpen={isOpen}
search={searchTerm}
searchHistory={searchHistory}
searchInputName={SEARCH_TERM_NAME}
includeTypes={includeTypes}
/>
</div>
)}
</Downshift>
)
}
export function SearchSkeleton() {
const intl = useIntl()
return (
<div className={styles.container}>
<div className={`${styles.label} ${styles.red}`}>
<Typography variant="Body/Supporting text (caption)/smBold">
<span>{intl.formatMessage({ defaultMessage: "Where to?" })}</span>
</Typography>
</div>
<div>
<SkeletonShimmer width={"100%"} display="block" height="16px" />
</div>
</div>
)
}
const searchContainerVariants = cva(styles.container, {
variants: {
variant: {
default: "",
rounded: styles.rounded,
},
},
defaultVariants: {
variant: "default",
},
})
const searchInputVariants = cva(styles.searchInput, {
variants: {
withSearchButton: {
true: styles.withSearchButton,
false: "",
},
},
defaultVariants: {
withSearchButton: false,
},
})
const clearButtonVariants = cva(styles.clearButton, {
variants: {
visible: {
true: styles.clearButtonVisible,
false: "",
},
},
})

View File

@@ -1,93 +0,0 @@
.container {
border-color: transparent;
border-style: solid;
border-width: 1px;
border-radius: var(--Corner-radius-md);
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
position: relative;
height: 60px;
&.rounded {
background-color: var(--Base-Surface-Primary-light-Normal);
padding: var(--Space-x15) var(--Space-x15) var(--Space-x15) var(--Space-x3);
border: 1px solid var(--Border-Intense);
border-radius: var(--Corner-radius-rounded);
height: auto;
}
&:hover,
&:has(input:active, input:focus, input:focus-within) {
background-color: var(--Surface-Primary-Hover);
}
&:has(input:active, input:focus, input:focus-within) {
border-color: 1px solid var(--Border-Interactive-Focus);
}
}
.label {
flex: 1;
color: var(--Text-Accent-Primary);
&:focus-within,
&:focus,
&:active {
color: var(--Text-Interactive-Focus);
}
}
.searchButtonContainer {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--Space-x05);
}
.searchButton {
display: flex;
align-items: center;
gap: var(--Space-x05);
cursor: pointer;
}
.inputContainer {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
width: 100%;
height: 100%;
}
.searchInput {
left: 0;
top: 0;
right: 0;
bottom: 0;
height: 100%;
align-items: center;
display: grid;
& input[type="search"] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.withSearchButton {
& input[type="search"]::-webkit-search-cancel-button {
display: none;
}
}
}
.clearButton {
opacity: 0;
transition: opacity 0.1s ease;
pointer-events: none;
&.clearButtonVisible {
opacity: 1;
pointer-events: all;
}
}

View File

@@ -1,31 +0,0 @@
import { useIntl } from "react-intl"
import Caption from "@scandic-hotels/design-system/Caption"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import styles from "./validationError.module.css"
export default function ValidationError() {
const intl = useIntl()
return (
<div className={styles.container}>
<Caption className={styles.title} color="red" type="bold">
<MaterialIcon
icon="error_circle_rounded"
color="Icon/Feedback/Error"
size={20}
/>
{intl.formatMessage({
defaultMessage: "Enter destination or hotel",
})}
</Caption>
<Caption className={styles.message} type="regular">
{intl.formatMessage({
defaultMessage:
"A destination or hotel name is needed to be able to search for a hotel room.",
})}
</Caption>
</div>
)
}

View File

@@ -1,36 +0,0 @@
.container {
position: absolute;
top: calc(100% + var(--Space-x2));
background: var(--Surface-Primary-Default);
border-radius: var(--Corner-radius-lg);
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
padding: var(--Space-x15);
max-width: min(100vw, calc(360px - var(--Space-x2)));
width: 360px;
display: flex;
flex-direction: column;
padding: var(--Space-x15);
align-items: flex-start;
gap: var(--Space-x05);
z-index: var(--dialog-z-index);
}
.title {
display: flex;
align-items: center;
gap: var(--Space-x1);
}
.message {
text-wrap: auto;
}
@media screen and (min-width: 1367px) {
.container {
top: calc(100% + var(--Space-x1) + var(--Space-x2));
left: calc(var(--Space-x1) * -1);
padding: var(--Space-x2);
max-width: 360px;
}
}

View File

@@ -1,60 +0,0 @@
"use client"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import Caption from "@scandic-hotels/design-system/Caption"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import BookingCode from "../BookingCode"
import RewardNight from "../RewardNight"
import styles from "./voucher.module.css"
export default function Voucher() {
return (
<div className={styles.optionsContainer}>
<BookingCode />
<div className={styles.options}>
<div className={styles.option}>
<RewardNight />
</div>
</div>
</div>
)
}
export function VoucherSkeleton() {
const intl = useIntl()
const vouchers = intl.formatMessage({
defaultMessage: "Code / Voucher",
})
const reward = intl.formatMessage({
defaultMessage: "Reward Night",
})
const form = useForm()
return (
<FormProvider {...form}>
<div className={styles.optionsContainer}>
<div className={styles.voucherSkeletonContainer}>
<label>
<Caption type="bold" color="red" asChild>
<span>{vouchers}</span>
</Caption>
</label>
<SkeletonShimmer width={"100%"} display={"block"} />
</div>
<div className={styles.options}>
<div className={styles.option}>
<SkeletonShimmer width="24px" height="24px" />
<Caption color="uiTextMediumContrast" asChild>
<span>{reward}</span>
</Caption>
</div>
</div>
</div>
</FormProvider>
)
}

View File

@@ -1,53 +0,0 @@
.options {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
}
.option {
display: flex;
gap: var(--Spacing-x2);
margin-top: var(--Spacing-x2);
align-items: center;
}
.optionsContainer {
display: flex;
flex-direction: column;
}
.voucherSkeletonContainer {
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
}
.checkbox {
width: 24px;
height: 24px;
}
@media screen and (min-width: 768px) {
.options {
flex-direction: row;
gap: var(--Spacing-x4);
}
.option {
margin-top: 0;
gap: var(--Spacing-x-one-and-half);
}
.optionsContainer {
display: grid;
grid-template-columns: auto auto;
column-gap: var(--Spacing-x2);
}
}
@media screen and (min-width: 1367px) {
.options {
flex-direction: column;
max-width: 190px;
gap: var(--Spacing-x-half);
}
.option:hover {
cursor: not-allowed;
}
}

View File

@@ -1,188 +0,0 @@
.vouchersHeader {
display: flex;
gap: var(--Spacing-x-one-and-half);
}
.checkbox {
width: 24px;
height: 24px;
}
.icon {
display: none;
}
.where,
.rooms,
.when {
position: relative;
}
.buttonContainer {
display: grid;
gap: var(--Spacing-x1);
}
.showOnTablet {
display: none;
}
.label {
color: var(--Text-Accent-Primary);
}
.when:has([data-isopen="true"]) .label,
.rooms:has([data-pressed="true"]) .label {
color: var(--Text-Interactive-Focus);
}
@media screen and (max-width: 767px) {
.voucherContainer {
padding: var(--Spacing-x2) 0 var(--Spacing-x4);
}
}
@media screen and (max-width: 1366px) {
.inputContainer {
display: grid;
gap: var(--Spacing-x2);
}
.rooms,
.when,
.where {
background-color: var(--Background-Primary);
border-radius: var(--Corner-radius-md);
}
.rooms,
.when {
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
}
.button {
align-self: flex-end;
justify-content: center;
width: 100%;
}
.rooms {
height: 60px;
}
}
.voucherContainer {
height: fit-content;
}
.input {
display: flex;
flex-direction: column;
}
@media screen and (min-width: 768px) {
.input {
display: flex;
align-items: center;
flex-direction: row;
}
.inputContainer {
display: flex;
flex: 2;
gap: var(--Spacing-x2);
}
.voucherContainer {
flex: 1;
}
.rooms,
.when,
.where {
width: 100%;
}
.inputContainer input[type="text"] {
border: none;
height: 24px;
}
.rooms,
.when {
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
border-radius: var(--Corner-radius-md);
}
.when:hover,
.rooms:hover {
background-color: var(--Surface-Primary-Hover);
}
.when:has([data-isopen="true"]),
.rooms:has([data-focus-visible="true"], [data-pressed="true"]) {
background-color: var(--Surface-Primary-Hover);
border: 1px solid var(--Border-Interactive-Focus);
color: var(--Text-Interactive-Focus);
}
.where {
position: relative;
}
.button {
justify-content: center;
width: 118px;
}
.showOnMobile {
display: none;
}
}
.buttonContainer {
margin-top: auto;
@media screen and (min-width: 768px) {
margin-top: 0;
}
}
@media screen and (min-width: 768px) and (max-width: 1366px) {
.input {
flex-wrap: wrap;
}
.inputContainer {
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x2)
var(--Layout-Tablet-Margin-Margin-min);
flex-basis: 80%;
}
.buttonContainer {
padding-right: var(--Layout-Tablet-Margin-Margin-min);
margin: 0;
}
.input .buttonContainer .button {
padding: var(--Spacing-x1);
width: 48px;
height: 48px;
}
.buttonText {
display: none;
}
.icon {
display: flex;
}
.voucherRow {
display: flex;
background: var(--Base-Surface-Primary-light-Hover);
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
padding: var(--Spacing-x2) var(--Layout-Tablet-Margin-Margin-min);
}
.showOnTablet {
display: flex;
}
.hideOnTablet {
display: none;
}
}
@media screen and (min-width: 1367px) {
.input {
gap: var(--Spacing-x2);
}
}

View File

@@ -1,215 +0,0 @@
"use client"
import { usePathname } from "next/navigation"
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { dt } from "@scandic-hotels/common/dt"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import { hotelreservation } from "@/constants/routes/hotelReservation"
import DatePicker from "@/components/DatePicker"
import GuestsRoomsPickerForm from "@/components/GuestsRoomsPicker"
import useLang from "@/hooks/useLang"
import { RemoveExtraRooms } from "./BookingCode"
import { Search, SearchSkeleton } from "./Search"
import { isMultiRoomError } from "./utils"
import ValidationError from "./ValidationError"
import Voucher, { VoucherSkeleton } from "./Voucher"
import styles from "./formContent.module.css"
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
import type { BookingWidgetFormContentProps } from "@/types/components/form/bookingwidget"
export default function FormContent({
formId,
onSubmit,
isSearching,
}: BookingWidgetFormContentProps) {
const intl = useIntl()
const {
formState: { errors, isDirty },
} = useFormContext<BookingWidgetSchema>()
const lang = useLang()
const pathName = usePathname()
const isBookingFlow = pathName.includes(hotelreservation(lang))
const selectedDate = useWatch<BookingWidgetSchema, "date">({ name: "date" })
const nights = dt(selectedDate.toDate).diff(dt(selectedDate.fromDate), "days")
return (
<div className={styles.input}>
<div className={styles.inputContainer}>
<div className={styles.where}>
<Search
handlePressEnter={onSubmit}
selectOnBlur={true}
inputName="search"
includeTypes={["cities", "hotels"]}
/>
{errors.search && <ValidationError />}
</div>
<div className={styles.when}>
<Typography
variant="Body/Supporting text (caption)/smBold"
className={styles.label}
>
<label htmlFor="date">
{nights > 0
? intl.formatMessage(
{
defaultMessage:
"{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights: nights }
)
: intl.formatMessage({
defaultMessage: "Check in",
})}
</label>
</Typography>
<DatePicker name="date" />
</div>
<div className={styles.rooms}>
<Typography
variant="Body/Supporting text (caption)/smBold"
className={styles.label}
>
<label id="rooms-and-guests">
{intl.formatMessage({
defaultMessage: "Rooms & Guests",
})}
</label>
</Typography>
<GuestsRoomsPickerForm ariaLabelledBy="rooms-and-guests" />
</div>
</div>
<div className={`${styles.buttonContainer} ${styles.showOnTablet}`}>
<Button
className={styles.button}
form={formId}
intent="primary"
theme="base"
type="submit"
>
<span className={styles.icon}>
<MaterialIcon icon="search" color="Icon/Inverted" size={28} />
</span>
</Button>
</div>
<div className={`${styles.voucherContainer} ${styles.voucherRow}`}>
<Voucher />
</div>
<div className={`${styles.buttonContainer} ${styles.hideOnTablet}`}>
{isMultiRoomError(errors.bookingCode?.value?.message) ||
isMultiRoomError(errors[SEARCH_TYPE_REDEMPTION]?.message) ? (
<div className={styles.showOnMobile}>
<RemoveExtraRooms size="medium" fullWidth />
</div>
) : null}
<Button
className={styles.button}
form={formId}
intent="primary"
theme="base"
type="submit"
disabled={isSearching}
>
<Typography
variant="Body/Supporting text (caption)/smBold"
className={styles.buttonText}
>
<span>
{isDirty && isBookingFlow
? intl.formatMessage({ defaultMessage: "Update" })
: intl.formatMessage({ defaultMessage: "Search" })}
</span>
</Typography>
<span className={styles.icon}>
<MaterialIcon icon="search" color="Icon/Inverted" size={28} />
</span>
</Button>
</div>
</div>
)
}
export function BookingWidgetFormContentSkeleton() {
const intl = useIntl()
return (
<div className={styles.input}>
<div className={styles.inputContainer}>
<div className={styles.where}>
<SearchSkeleton />
</div>
<div className={styles.when}>
<Typography
variant="Body/Supporting text (caption)/smBold"
className={styles.label}
>
<label>
{intl.formatMessage(
{
defaultMessage:
"{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights: 0 }
)}
</label>
</Typography>
<SkeletonShimmer width={"100%"} display={"block"} />
</div>
<div className={styles.rooms}>
<Typography
variant="Body/Supporting text (caption)/smBold"
className={styles.label}
>
<label id="rooms-and-guests">
{intl.formatMessage({
defaultMessage: "Rooms & Guests",
})}
</label>
</Typography>
<SkeletonShimmer width={"100%"} display={"block"} />
</div>
</div>
<div className={styles.voucherContainer}>
<VoucherSkeleton />
</div>
<div className={styles.buttonContainer}>
<Button
className={styles.button}
intent="primary"
theme="base"
type="submit"
disabled
>
<Typography
variant="Body/Supporting text (caption)/smBold"
className={styles.buttonText}
>
<span>
{intl.formatMessage({
defaultMessage: "Search",
})}
</span>
</Typography>
<span className={styles.icon}>
<MaterialIcon icon="search" color="Icon/Inverted" size={28} />
</span>
</Button>
</div>
</div>
)
}

View File

@@ -1,8 +0,0 @@
import { bookingWidgetErrors } from "../schema"
export function isMultiRoomError(errorMessage: string | undefined): boolean {
return (
errorMessage === bookingWidgetErrors.MULTIROOM_BOOKING_CODE_UNAVAILABLE ||
errorMessage === bookingWidgetErrors.MULTIROOM_REWARD_NIGHT_UNAVAILABLE
)
}

View File

@@ -1,50 +0,0 @@
.section {
align-items: center;
display: grid;
margin: 0 auto;
width: 100%;
}
.form {
display: grid;
height: 100%;
}
@media screen and (max-width: 767px) {
.section {
max-width: var(--max-width-page);
}
.form {
align-self: flex-start;
}
}
@media screen and (min-width: 768px) {
.default {
border-radius: var(--Corner-radius-md);
}
}
@media screen and (min-width: 1367px) {
.default {
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2)
var(--Spacing-x-one-and-half) var(--Spacing-x1);
}
.full {
padding: var(--Spacing-x1) 0;
}
.form {
width: 100%;
max-width: var(--max-width-page);
margin: 0 auto;
}
.compact {
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2)
var(--Spacing-x-one-and-half) var(--Spacing-x1);
white-space: nowrap;
}
}

View File

@@ -1,125 +0,0 @@
"use client"
import { usePathname, useRouter } from "next/navigation"
import { useTransition } from "react"
import { Form as FormRAC } from "react-aria-components"
import { useFormContext } from "react-hook-form"
import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import {
selectHotel,
selectHotelMap,
} from "@/constants/routes/hotelReservation"
import { useBookingCodeFilterStore } from "@/stores/bookingCode-filter"
import useLang from "@/hooks/useLang"
import { trackBookingSearchClick } from "@/utils/tracking/booking"
import { serializeBookingSearchParams } from "@/utils/url"
import FormContent, { BookingWidgetFormContentSkeleton } from "./FormContent"
import { bookingWidgetVariants } from "./variants"
import styles from "./form.module.css"
import type {
BookingWidgetSchema,
BookingWidgetType,
} from "@/types/components/bookingWidget"
import type { BookingWidgetFormProps } from "@/types/components/form/bookingwidget"
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
const formId = "booking-widget"
export default function Form({ type, onClose }: BookingWidgetFormProps) {
const router = useRouter()
const pathname = usePathname()
const lang = useLang()
const [isPending, startTransition] = useTransition()
const setBookingCodeFilter = useBookingCodeFilterStore(
(state) => state.setFilter
)
const classNames = bookingWidgetVariants({
type,
})
const { handleSubmit, setValue, reset } =
useFormContext<BookingWidgetSchema>()
function onSubmit(data: BookingWidgetSchema) {
trackBookingSearchClick(data.search, data.hotel ? "hotel" : "destination")
const isMapView = pathname.endsWith("/map")
const bookingFlowPage = data.hotel
? selectRate(lang)
: isMapView
? selectHotelMap(lang)
: selectHotel(lang)
const bookingWidgetParams = serializeBookingSearchParams({
rooms: data.rooms,
...data.date,
...(data.city ? { city: data.city } : {}),
...(data.hotel ? { hotel: data.hotel } : {}),
...(data.bookingCode?.value
? { bookingCode: data.bookingCode.value }
: {}),
// Followed current url structure to keep searchtype=redemption param incase of reward night
...(data.redemption ? { searchType: SEARCH_TYPE_REDEMPTION } : {}),
})
onClose()
startTransition(() => {
router.push(`${bookingFlowPage}?${bookingWidgetParams.toString()}`)
})
if (data.bookingCode?.value) {
// Reset the booking code filter if changed by user to "All rates"
setBookingCodeFilter(BookingCodeFilterEnum.Discounted)
if (data.bookingCode.remember) {
localStorage.setItem("bookingCode", JSON.stringify(data.bookingCode))
}
} else {
setValue("bookingCode.remember", false, {
shouldDirty: true,
})
localStorage.removeItem("bookingCode")
}
reset(data)
}
return (
<section className={classNames}>
<FormRAC
onSubmit={handleSubmit(onSubmit)}
className={styles.form}
id={formId}
>
<FormContent
formId={formId}
onSubmit={handleSubmit(onSubmit)}
isSearching={isPending}
/>
</FormRAC>
</section>
)
}
export function BookingWidgetFormSkeleton({
type,
}: {
type: BookingWidgetType
}) {
const classNames = bookingWidgetVariants({
type,
})
return (
<section className={classNames}>
<form className={styles.form}>
<BookingWidgetFormContentSkeleton />
</form>
</section>
)
}

View File

@@ -1,127 +0,0 @@
import { z } from "zod"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
export const bookingWidgetErrors = {
AGE_REQUIRED: "AGE_REQUIRED",
BED_CHOICE_REQUIRED: "BED_CHOICE_REQUIRED",
CHILDREN_EXCEEDS_ADULTS: "CHILDREN_EXCEEDS_ADULTS",
BOOKING_CODE_INVALID: "BOOKING_CODE_INVALID",
REQUIRED: "REQUIRED",
DESTINATION_REQUIRED: "DESTINATION_REQUIRED",
MULTIROOM_BOOKING_CODE_UNAVAILABLE: "MULTIROOM_BOOKING_CODE_UNAVAILABLE",
MULTIROOM_REWARD_NIGHT_UNAVAILABLE: "MULTIROOM_REWARD_NIGHT_UNAVAILABLE",
CODE_VOUCHER_REWARD_NIGHT_UNAVAILABLE:
"CODE_VOUCHER_REWARD_NIGHT_UNAVAILABLE",
} as const
export const guestRoomSchema = z
.object({
adults: z.number().default(1),
childrenInRoom: z
.array(
z.object({
age: z.number().min(0, bookingWidgetErrors.AGE_REQUIRED),
bed: z.number().min(0, bookingWidgetErrors.BED_CHOICE_REQUIRED),
})
)
.default([]),
})
.superRefine((value, ctx) => {
const childrenInAdultsBed = value.childrenInRoom.filter(
(c) => c.bed === ChildBedMapEnum.IN_ADULTS_BED
)
if (value.adults < childrenInAdultsBed.length) {
const lastAdultBedIndex = value.childrenInRoom
.map((c) => c.bed)
.lastIndexOf(ChildBedMapEnum.IN_ADULTS_BED)
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: bookingWidgetErrors.CHILDREN_EXCEEDS_ADULTS,
path: ["childrenInRoom", lastAdultBedIndex],
})
}
})
export const guestRoomsSchema = z.array(guestRoomSchema)
export const bookingCodeSchema = z
.object({
value: z
.string()
.refine(
(value) =>
!value ||
/(^D\d*$)|(^DSH[0-9a-z]*$)|(^L\d*$)|(^LH[0-9a-z]*$)|(^B[a-z]{3}\d{6})|(^VO[0-9a-z]*$)|^[0-9a-z]*$/i.test(
value
),
{ message: bookingWidgetErrors.BOOKING_CODE_INVALID }
)
.default(""),
remember: z.boolean().default(false),
flag: z.boolean().default(false),
})
.optional()
export const bookingWidgetSchema = z
.object({
bookingCode: bookingCodeSchema,
date: z.object({
// Update this as required once started working with Date picker in Nights component
fromDate: z.string(),
toDate: z.string(),
}),
redemption: z.boolean().default(false),
rooms: guestRoomsSchema,
search: z.string({ coerce: true }).min(1, bookingWidgetErrors.REQUIRED),
selectedSearch: z.string().optional(),
hotel: z.number().optional(),
city: z.string().optional(),
})
.superRefine((value, ctx) => {
if (!value.hotel && !value.city) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: bookingWidgetErrors.DESTINATION_REQUIRED,
path: ["search"],
})
}
if (value.rooms.length > 1 && value.bookingCode?.value.startsWith("VO")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: bookingWidgetErrors.MULTIROOM_BOOKING_CODE_UNAVAILABLE,
path: ["bookingCode.value"],
})
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: bookingWidgetErrors.MULTIROOM_BOOKING_CODE_UNAVAILABLE,
path: ["rooms"],
})
}
if (value.rooms.length > 1 && value.redemption) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: bookingWidgetErrors.MULTIROOM_REWARD_NIGHT_UNAVAILABLE,
path: [SEARCH_TYPE_REDEMPTION],
})
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: bookingWidgetErrors.MULTIROOM_REWARD_NIGHT_UNAVAILABLE,
path: ["rooms"],
})
}
if (value.bookingCode?.value && value.redemption) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: bookingWidgetErrors.CODE_VOUCHER_REWARD_NIGHT_UNAVAILABLE,
path: [SEARCH_TYPE_REDEMPTION],
})
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: bookingWidgetErrors.CODE_VOUCHER_REWARD_NIGHT_UNAVAILABLE,
path: ["bookingCode.value"],
})
}
})

View File

@@ -1,16 +0,0 @@
import { cva } from "class-variance-authority"
import styles from "./form.module.css"
export const bookingWidgetVariants = cva(styles.section, {
variants: {
type: {
default: styles.default,
full: styles.full,
compact: styles.compact,
},
},
defaultVariants: {
type: "full",
},
})

View File

@@ -1,5 +0,0 @@
.container {
display: flex;
justify-content: space-between;
align-items: center;
}

View File

@@ -1,51 +0,0 @@
"use client"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import Caption from "@scandic-hotels/design-system/Caption"
import Counter from "../Counter"
import styles from "./adult-selector.module.css"
import type { SelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function AdultSelector({
roomIndex = 0,
currentAdults,
}: SelectorProps) {
const name = `rooms.${roomIndex}.adults`
const intl = useIntl()
const adultsLabel = intl.formatMessage({
defaultMessage: "Adults",
})
const { setValue } = useFormContext()
function increaseAdultsCount() {
if (currentAdults < 6) {
setValue(name, currentAdults + 1, { shouldDirty: true })
}
}
function decreaseAdultsCount() {
if (currentAdults > 1) {
setValue(name, currentAdults - 1, { shouldDirty: true })
}
}
return (
<section className={styles.container}>
<Caption color="uiTextHighContrast" type="bold">
{adultsLabel}
</Caption>
<Counter
count={currentAdults}
handleOnDecrease={decreaseAdultsCount}
handleOnIncrease={increaseAdultsCount}
disableDecrease={currentAdults == 1}
disableIncrease={currentAdults == 6}
/>
</section>
)
}

View File

@@ -1,148 +0,0 @@
"use client"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import Caption from "@scandic-hotels/design-system/Caption"
import DeprecatedSelect from "@scandic-hotels/design-system/DeprecatedSelect"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
import styles from "./child-selector.module.css"
import type {
ChildBed,
ChildInfoSelectorProps,
} from "@/types/components/bookingWidget/guestsRoomsPicker"
const ageList = [...Array(13)].map((_, i) => ({
label: i.toString(),
value: i,
}))
const childDefaultValues = { age: -1, bed: -1 }
export default function ChildInfoSelector({
child,
childrenInAdultsBed,
adults,
index = 0,
roomIndex = 0,
}: ChildInfoSelectorProps) {
const ageFieldName = `rooms.${roomIndex}.childrenInRoom.${index}.age`
const bedFieldName = `rooms.${roomIndex}.childrenInRoom.${index}.bed`
const intl = useIntl()
const ageLabel = intl.formatMessage({
defaultMessage: "Age",
})
const bedLabel = intl.formatMessage({
defaultMessage: "Bed preference",
})
const errorMessage = intl.formatMessage({
defaultMessage: "Child age is required",
})
const { setValue, formState } = useFormContext()
function updateSelectedBed(bed: number) {
setValue(`rooms.${roomIndex}.childrenInRoom.${index}.bed`, bed)
}
function updateSelectedAge(age: number) {
setValue(`rooms.${roomIndex}.childrenInRoom.${index}.age`, age)
const availableBedTypes = getAvailableBeds(age)
updateSelectedBed(availableBedTypes[0].value)
}
const allBedTypes: ChildBed[] = [
{
label: intl.formatMessage({
defaultMessage: "In adult's bed",
}),
value: ChildBedMapEnum.IN_ADULTS_BED,
},
{
label: intl.formatMessage({
defaultMessage: "In crib",
}),
value: ChildBedMapEnum.IN_CRIB,
},
{
label: intl.formatMessage({
defaultMessage: "In extra bed",
}),
value: ChildBedMapEnum.IN_EXTRA_BED,
},
]
function getAvailableBeds(age: number) {
let availableBedTypes: ChildBed[] = []
if (age <= 5 && (adults > childrenInAdultsBed || child.bed === 0)) {
availableBedTypes.push(allBedTypes[0])
}
if (age < 3) {
availableBedTypes.push(allBedTypes[1])
}
if (age > 2) {
availableBedTypes.push(allBedTypes[2])
}
return availableBedTypes
}
const roomErrors =
//@ts-expect-error: formState is typed with FormValues
formState.errors.rooms?.[roomIndex]?.childrenInRoom?.[index]
const ageError = roomErrors?.age
const bedError = roomErrors?.bed
return (
<>
<div key={index} className={styles.childInfoContainer}>
<div>
<DeprecatedSelect
required={true}
items={ageList}
label={ageLabel}
aria-label={ageLabel}
value={child.age ?? childDefaultValues.age}
onSelect={(key) => {
updateSelectedAge(key as number)
}}
maxHeight={180}
name={ageFieldName}
isNestedInModal={true}
/>
</div>
<div>
{child.age >= 0 ? (
<DeprecatedSelect
items={getAvailableBeds(child.age)}
label={bedLabel}
aria-label={bedLabel}
value={child.bed ?? childDefaultValues.bed}
onSelect={(key) => {
updateSelectedBed(key as number)
}}
name={bedFieldName}
isNestedInModal={true}
/>
) : null}
</div>
</div>
{roomErrors && roomErrors.message ? (
<Caption color="red" className={styles.error}>
<MaterialIcon icon="error" color="Icon/Interactive/Accent" />
{roomErrors.message}
</Caption>
) : null}
{ageError || bedError ? (
<Caption color="red" className={styles.error}>
<MaterialIcon icon="error" color="Icon/Interactive/Accent" />
{errorMessage}
</Caption>
) : null}
</>
)
}

View File

@@ -1,21 +0,0 @@
.container {
display: flex;
justify-content: space-between;
align-items: center;
}
.captionBold {
font-weight: 600;
}
.childInfoContainer {
display: grid;
gap: var(--Spacing-x2);
grid-template-columns: 1fr 2fr;
}
.error {
display: flex;
align-items: center;
gap: var(--Spacing-x1);
}

View File

@@ -1,78 +0,0 @@
"use client"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import Caption from "@scandic-hotels/design-system/Caption"
import Counter from "../Counter"
import ChildInfoSelector from "./ChildInfoSelector"
import styles from "./child-selector.module.css"
import type { SelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function ChildSelector({
roomIndex = 0,
currentAdults,
childrenInAdultsBed,
currentChildren,
}: SelectorProps) {
const intl = useIntl()
const childrenLabel = intl.formatMessage({
defaultMessage: "Children",
})
const { setValue } = useFormContext()
function increaseChildrenCount(roomIndex: number) {
if (currentChildren.length < 5) {
setValue(
`rooms.${roomIndex}.childrenInRoom.${currentChildren.length}`,
{
age: undefined,
bed: undefined,
},
{ shouldDirty: true }
)
}
}
function decreaseChildrenCount(roomIndex: number) {
if (currentChildren.length > 0) {
currentChildren.pop()
setValue(`rooms.${roomIndex}.childrenInRoom`, currentChildren, {
shouldDirty: true,
})
}
}
return (
<>
<section className={styles.container}>
<Caption color="uiTextHighContrast" type="bold">
{childrenLabel}
</Caption>
<Counter
count={currentChildren.length}
handleOnDecrease={() => {
decreaseChildrenCount(roomIndex)
}}
handleOnIncrease={() => {
increaseChildrenCount(roomIndex)
}}
disableDecrease={currentChildren.length == 0}
disableIncrease={currentChildren.length == 5}
/>
</section>
{currentChildren.map((child, index) => (
<ChildInfoSelector
roomIndex={roomIndex}
index={index}
child={child}
adults={currentAdults}
key={"child_" + index}
childrenInAdultsBed={childrenInAdultsBed}
/>
))}
</>
)
}

View File

@@ -1,13 +0,0 @@
.counterContainer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 20px;
}
.counterBtn {
width: 40px;
height: 40px;
}
.counterBtn:not([disabled]) {
box-shadow: 0px 0px 8px 1px rgba(0, 0, 0, 0.1);
}

View File

@@ -1,49 +0,0 @@
"use client"
import Body from "@scandic-hotels/design-system/Body"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import styles from "./counter.module.css"
import type { CounterProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function Counter({
count,
handleOnIncrease,
handleOnDecrease,
disableIncrease,
disableDecrease,
}: CounterProps) {
return (
<div className={styles.counterContainer}>
<Button
className={styles.counterBtn}
intent="inverted"
onClick={handleOnDecrease}
size="small"
theme="base"
variant="icon"
wrapping={true}
disabled={disableDecrease}
>
<MaterialIcon icon="remove" color="CurrentColor" />
</Button>
<Body color="baseTextHighContrast" textAlign="center">
{count}
</Body>
<Button
className={styles.counterBtn}
onClick={handleOnIncrease}
intent="inverted"
variant="icon"
theme="base"
wrapping={true}
size="small"
disabled={disableIncrease}
>
<MaterialIcon icon="add" color="CurrentColor" />
</Button>
</div>
)
}

View File

@@ -1,227 +0,0 @@
"use client"
import { useCallback, useEffect } from "react"
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import { Tooltip } from "@scandic-hotels/design-system/Tooltip"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import { GuestsRoom } from "./GuestsRoom"
import styles from "./guests-rooms-picker.module.css"
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
import type { GuestsRoom as TGuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
const MAX_ROOMS = 4
interface GuestsRoomsPickerDialogProps {
rooms: TGuestsRoom[]
onClose: () => void
}
export default function GuestsRoomsPickerDialog({
rooms,
onClose,
}: GuestsRoomsPickerDialogProps) {
const intl = useIntl()
const { getFieldState, trigger, setValue, getValues } =
useFormContext<BookingWidgetSchema>()
const roomsValue = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" })
const addRoomLabel = intl.formatMessage({
defaultMessage: "Add room",
})
const doneLabel = intl.formatMessage({
defaultMessage: "Done",
})
// Disable add room if booking code is either voucher or corporate cheque, or reward night is enabled
const addRoomDisabledTextForSpecialRate = getValues(SEARCH_TYPE_REDEMPTION)
? intl.formatMessage({
defaultMessage:
"Multi-room booking is not available with reward night.",
})
: getValues("bookingCode.value")?.toLowerCase().startsWith("vo") &&
intl.formatMessage({
defaultMessage:
"Multi-room booking is not available with this booking code.",
})
const handleClose = useCallback(async () => {
const isValid = await trigger("rooms")
if (isValid) onClose()
}, [trigger, onClose])
const handleAddRoom = useCallback(() => {
setValue("rooms", [...roomsValue, { adults: 1, childrenInRoom: [] }], {
shouldValidate: true,
shouldDirty: true,
})
}, [roomsValue, setValue])
const handleRemoveRoom = useCallback(
(index: number) => {
const updatedRooms = roomsValue.filter((_, i) => i !== index)
setValue("rooms", updatedRooms, {
shouldValidate: true,
shouldDirty: true,
})
if (updatedRooms.length === 1) {
trigger("bookingCode.value")
trigger(SEARCH_TYPE_REDEMPTION)
}
},
[roomsValue, trigger, setValue]
)
// Validate rooms when they change
useEffect(() => {
const fieldState = getFieldState("rooms")
if (fieldState.invalid) trigger("rooms")
}, [roomsValue, getFieldState, trigger])
const isInvalid =
getFieldState("rooms").invalid ||
roomsValue.some((room) =>
room.childrenInRoom.some((child) => child.age === undefined)
)
const canAddRooms = rooms.length < MAX_ROOMS
return (
<>
<section className={styles.contentWrapper}>
<header className={styles.header}>
<button type="button" className={styles.close} onClick={onClose}>
<MaterialIcon icon="close" />
</button>
</header>
<div className={styles.contentContainer}>
{rooms.map((room, index) => (
<GuestsRoom
key={index}
room={room}
index={index}
onRemove={handleRemoveRoom}
/>
))}
{addRoomDisabledTextForSpecialRate ? (
<div className={styles.addRoomMobileContainer}>
<Button
intent="text"
variant="icon"
wrapping
theme="base"
fullWidth
onPress={handleAddRoom}
disabled
>
<MaterialIcon icon="add" color="CurrentColor" />
{addRoomLabel}
</Button>
<div className={styles.errorContainer}>
<Typography
className={styles.error}
variant="Body/Supporting text (caption)/smRegular"
>
<span>
<MaterialIcon
icon="error"
size={20}
color="Icon/Feedback/Error"
className={styles.errorIcon}
isFilled
/>
{addRoomDisabledTextForSpecialRate}
</span>
</Typography>
</div>
</div>
) : (
canAddRooms && (
<div className={styles.addRoomMobileContainer}>
<Button
className={styles.addRoomBtn}
intent="text"
variant="icon"
wrapping
theme="base"
fullWidth
onPress={handleAddRoom}
>
<MaterialIcon icon="add" color="CurrentColor" />
{addRoomLabel}
</Button>
</div>
)
)}
</div>
</section>
<footer className={styles.footer}>
{addRoomDisabledTextForSpecialRate ? (
<div className={styles.hideOnMobile}>
<Tooltip
text={addRoomDisabledTextForSpecialRate}
position="bottom"
arrow="left"
>
<Button
intent="text"
variant="icon"
wrapping
theme="base"
disabled
onPress={handleAddRoom}
>
<MaterialIcon icon="add_circle" color="CurrentColor" />
{addRoomLabel}
</Button>
</Tooltip>
</div>
) : (
canAddRooms && (
<div className={styles.hideOnMobile}>
<Button
className={styles.addRoomBtn}
intent="text"
variant="icon"
wrapping
theme="base"
onPress={handleAddRoom}
>
<MaterialIcon icon="add_circle" color="CurrentColor" />
{addRoomLabel}
</Button>
</div>
)
)}
<Button
onPress={handleClose}
disabled={isInvalid}
className={styles.hideOnDesktop}
intent="tertiary"
theme="base"
size="large"
>
{doneLabel}
</Button>
<Button
onPress={handleClose}
disabled={isInvalid}
className={styles.hideOnMobile}
intent="tertiary"
theme="base"
size="small"
>
{doneLabel}
</Button>
</footer>
</>
)
}

View File

@@ -1,77 +0,0 @@
import { useIntl } from "react-intl"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
import AdultSelector from "../AdultSelector"
import ChildSelector from "../ChildSelector"
import styles from "../guests-rooms-picker.module.css"
import type { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
export function GuestsRoom({
room,
index,
onRemove,
}: {
room: GuestsRoom
index: number
onRemove: (index: number) => void
}) {
const intl = useIntl()
const roomLabel = intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{
roomIndex: index + 1,
}
)
const childrenInAdultsBed = room.childrenInRoom.filter(
(child) => child.bed === ChildBedMapEnum.IN_ADULTS_BED
).length
return (
<div className={styles.roomContainer}>
<section className={styles.roomDetailsContainer}>
<Subtitle type="two" className={styles.roomHeading}>
{roomLabel}
</Subtitle>
<AdultSelector
roomIndex={index}
currentAdults={room.adults}
currentChildren={room.childrenInRoom}
childrenInAdultsBed={childrenInAdultsBed}
/>
<ChildSelector
roomIndex={index}
currentAdults={room.adults}
currentChildren={room.childrenInRoom}
childrenInAdultsBed={childrenInAdultsBed}
/>
{index !== 0 && (
<Button
intent="text"
variant="icon"
wrapping
theme="secondaryLight"
onPress={() => onRemove(index)}
size="small"
className={styles.roomActionsButton}
>
<MaterialIcon icon="delete" color="CurrentColor" />
{intl.formatMessage({
defaultMessage: "Remove room",
})}
</Button>
)}
</section>
<Divider color="Border/Divider/Subtle" />
</div>
)
}

View File

@@ -1,246 +0,0 @@
.triggerDesktop {
display: none;
}
.errorContainer {
display: flex;
padding: var(--Space-x2);
}
.error {
display: flex;
gap: var(--Space-x1);
color: var(--UI-Text-Error);
}
.pickerContainerMobile {
--header-height: 72px;
--sticky-button-height: 140px;
background-color: var(--Main-Grey-White);
border-radius: var(--Corner-radius-lg) var(--Corner-radius-lg) 0 0;
bottom: 0;
left: 0;
position: fixed;
right: 0;
top: 20px;
transition: top 300ms ease;
z-index: 100;
}
.contentWrapper {
display: grid;
grid-template-areas:
"header"
"content";
grid-template-rows: var(--header-height) calc(100dvh - var(--header-height));
}
.pickerContainerDesktop {
display: none;
}
.roomContainer {
display: grid;
gap: var(--Spacing-x2);
}
.roomDetailsContainer {
display: grid;
gap: var(--Spacing-x2);
padding-bottom: var(--Spacing-x1);
}
.roomHeading {
margin-bottom: var(--Spacing-x1);
}
.btn {
background: none;
border: none;
color: var(--Text-Default);
cursor: pointer;
outline: none;
padding: 0;
width: 100%;
text-align: left;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
padding: 20px var(--Spacing-x-one-and-half) 0;
}
.guestsAndRooms {
color: var(--Text-Default);
}
.footer {
display: flex;
flex-direction: row;
gap: var(--Spacing-x1);
}
.roomContainer {
padding: var(--Spacing-x2);
}
.roomContainer:last-of-type {
padding-bottom: calc(var(--sticky-button-height) + 20px);
}
.roomActionsButton {
margin-left: auto;
color: var(--Base-Text-Accent);
}
.footer button {
width: 100%;
}
.contentWrapper
.addRoomMobileContainer
.addRoomBtn:is(:focus, :focus-visible, :focus-within),
.footer .hideOnMobile .addRoomBtn:is(:focus, :focus-visible, :focus-within),
.roomActionsButton:is(:focus, :focus-visible, :focus-within) {
outline: -webkit-focus-ring-color auto 1px;
text-decoration: none;
}
@media screen and (max-width: 1366px) {
.contentContainer {
grid-area: content;
overflow-y: scroll;
scroll-snap-type: y mandatory;
}
.header {
display: grid;
grid-area: header;
padding: var(--Spacing-x3) var(--Spacing-x2) 0;
}
.close {
background: none;
border: none;
cursor: pointer;
display: flex;
justify-self: flex-end;
padding: 0;
}
.footer {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 7.5%,
#ffffff 82.5%
);
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x7);
position: sticky;
bottom: 0;
width: 100%;
}
.footer .hideOnMobile {
display: none;
}
.addRoomMobileContainer {
display: grid;
padding-bottom: calc(var(--sticky-button-height) + 20px);
}
.addRoomMobileContainer button {
width: 150px;
margin: 0 auto;
}
.addRoomMobileContainer .addRoomMobileDisabledText {
padding: var(--Spacing-x1) var(--Spacing-x2);
background-color: var(--Background-Primary);
margin: 0 var(--Spacing-x2);
border-radius: var(--Corner-radius-md);
display: flex;
gap: var(--Spacing-x1);
}
}
@media screen and (min-width: 1367px) {
.container {
height: 24px;
}
.pickerContainerMobile {
display: none;
}
.contentWrapper {
grid-template-rows: auto;
}
.roomContainer {
padding: var(--Spacing-x2) 0 0 0;
}
.roomContainer:first-of-type {
padding-top: 0;
}
.roomContainer:last-of-type {
padding-bottom: 0;
}
.contentContainer {
overflow-y: visible;
}
.triggerMobile {
display: none;
}
.triggerDesktop {
display: block;
}
.triggerDesktop > span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.pickerContainerDesktop {
--header-height: 72px;
--sticky-button-height: 140px;
background-color: var(--Main-Grey-White);
display: grid;
border-radius: var(--Corner-radius-lg);
box-shadow: var(--popup-box-shadow);
max-width: calc(100vw - 20px);
padding: var(--Spacing-x2) var(--Spacing-x3);
width: 360px;
}
.pickerContainerDesktop:focus-visible {
outline: none;
}
.header {
display: none;
}
.footer {
grid-template-columns: auto auto;
padding-top: var(--Spacing-x2);
}
.footer button {
margin-left: auto;
width: 125px;
}
.footer .hideOnDesktop,
.addRoomMobileContainer {
display: none;
}
}

View File

@@ -1,190 +0,0 @@
"use client"
import { useCallback, useEffect, useId, useState } from "react"
import {
Button,
Dialog,
DialogTrigger,
Modal,
Popover,
} from "react-aria-components"
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { Typography } from "@scandic-hotels/design-system/Typography"
import PickerForm from "./Form"
import styles from "./guests-rooms-picker.module.css"
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
import type { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function GuestsRoomsPickerForm({
ariaLabelledBy,
}: {
ariaLabelledBy?: string
}) {
const { trigger } = useFormContext<BookingWidgetSchema>()
const rooms = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" })
const popoverId = useId()
const checkIsDesktop = useMediaQuery("(min-width: 1367px)")
const [isDesktop, setIsDesktop] = useState(true)
const [isOpen, setIsOpen] = useState(false)
const [containerHeight, setContainerHeight] = useState(0)
const childCount = rooms[0]?.childrenInRoom.length ?? 0 // ToDo Update for multiroom later
//isOpen is the 'old state', so isOpen === true means "The modal is open and WILL be closed".
async function setOverflowClip(isOpen: boolean) {
const bodyElement = document.body
if (bodyElement) {
if (isOpen) {
bodyElement.style.overflow = "visible"
} else {
// !important needed to override 'overflow: hidden' set by react-aria.
// 'overflow: hidden' does not work in combination with other sticky positioned elements, which clip does.
bodyElement.style.overflow = "clip !important"
}
}
if (!isOpen) {
const state = await trigger("rooms")
if (state) {
setIsOpen(isOpen)
}
}
}
useEffect(() => {
setIsDesktop(checkIsDesktop)
}, [checkIsDesktop])
const updateHeight = useCallback(() => {
// Get available space for picker to show without going beyond screen
const bookingWidget = document.getElementById("booking-widget")
let maxHeight =
window.innerHeight -
(bookingWidget?.getBoundingClientRect().bottom ?? 0) -
50
const innerContainerHeight = document
.getElementsByClassName(popoverId)[0]
?.getBoundingClientRect().height
if (
maxHeight != containerHeight &&
innerContainerHeight &&
maxHeight <= innerContainerHeight
) {
setContainerHeight(maxHeight)
} else if (
containerHeight &&
innerContainerHeight &&
maxHeight > innerContainerHeight
) {
setContainerHeight(0)
}
}, [containerHeight, popoverId])
useEffect(() => {
if (isDesktop && rooms.length > 0) {
updateHeight()
}
}, [childCount, isDesktop, updateHeight, rooms])
return isDesktop ? (
<DialogTrigger onOpenChange={setOverflowClip} isOpen={isOpen}>
<Trigger
rooms={rooms}
className={styles.triggerDesktop}
triggerFn={() => {
setIsOpen(true)
}}
/>
<Popover
className={popoverId}
placement="bottom start"
offset={36}
style={containerHeight ? { overflow: "auto" } : undefined}
>
<Dialog className={styles.pickerContainerDesktop}>
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
</Dialog>
</Popover>
</DialogTrigger>
) : (
<DialogTrigger onOpenChange={setOverflowClip} isOpen={isOpen}>
<Trigger
rooms={rooms}
className={styles.triggerMobile}
triggerFn={() => {
setIsOpen(true)
}}
ariaLabelledBy={ariaLabelledBy}
/>
<Modal>
<Dialog className={styles.pickerContainerMobile}>
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
)
}
function Trigger({
rooms,
className,
triggerFn,
ariaLabelledBy,
}: {
rooms: GuestsRoom[]
className: string
triggerFn?: () => void
ariaLabelledBy?: string
}) {
const intl = useIntl()
const parts = [
intl.formatMessage(
{
defaultMessage: "{totalRooms, plural, one {# room} other {# rooms}}",
},
{ totalRooms: rooms.length }
),
intl.formatMessage(
{
defaultMessage: "{totalAdults, plural, one {# adult} other {# adults}}",
},
{ totalAdults: rooms.reduce((acc, room) => acc + room.adults, 0) }
),
]
if (rooms.some((room) => room.childrenInRoom.length > 0)) {
parts.push(
intl.formatMessage(
{
defaultMessage:
"{totalChildren, plural, one {# child} other {# children}}",
},
{
totalChildren: rooms.reduce(
(acc, room) => acc + room.childrenInRoom.length,
0
),
}
)
)
}
return (
<Button
className={`${className} ${styles.btn}`}
type="button"
onPress={triggerFn}
aria-labelledby={ariaLabelledBy}
>
<Typography variant="Body/Paragraph/mdRegular">
<span className={styles.guestsAndRooms}>{parts.join(", ")}</span>
</Typography>
</Button>
)
}

Some files were not shown because too many files have changed in this diff Show More