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:
53
apps/partner-sas/app/[lang]/debug/page.tsx
Normal file
53
apps/partner-sas/app/[lang]/debug/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
.page {
|
||||
padding-left: 200px;
|
||||
padding-top: 200px;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
BIN
apps/partner-sas/app/favicon.ico
Normal file
BIN
apps/partner-sas/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
11
apps/partner-sas/app/utils/tracking.ts
Normal file
11
apps/partner-sas/app/utils/tracking.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
export function trackBookingSearchClick(
|
||||
searchTerm: string,
|
||||
searchType: "hotel" | "destination"
|
||||
) {
|
||||
console.log("TODO: Implement trackBookingSearchClick", {
|
||||
searchTerm,
|
||||
searchType,
|
||||
})
|
||||
}
|
||||
24
apps/partner-sas/components/IntlProvider.tsx
Normal file
24
apps/partner-sas/components/IntlProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
107
apps/partner-sas/globals.css
Normal file
107
apps/partner-sas/globals.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
1
apps/partner-sas/public/_static/icons/cancel.svg
Normal file
1
apps/partner-sas/public/_static/icons/cancel.svg
Normal 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 |
206
apps/partner-sas/tests/booking-widget.spec.ts
Normal file
206
apps/partner-sas/tests/booking-widget.spec.ts
Normal 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()
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
padding: 0 var(--Spacing-x1);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"],
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user