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

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

* Fix lockfile

* Fix styling

* a tiny little booking widget test

* Tiny fixes

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

* Remove unused scripts

* lint:fix

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

* Tiny lint fixes

* update test

* Update Input in booking-flow

* Clean up comments etc

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

* Setup tracking context for booking-flow

* Add missing use client

* Fix temp tracking function

* Pass booking to booking-widget

* Remove comment

* Add use client to booking widget tracking provider

* Add use client to tracking functions

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

* Move debug page

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

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

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


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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 507 B

View File

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

View File

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