Merge branch 'develop' into test

This commit is contained in:
Michael Zetterberg
2024-09-20 14:13:40 +02:00
432 changed files with 17638 additions and 1689 deletions

View File

@@ -5,9 +5,9 @@ import styles from "./layout.module.css"
export default async function MyPagesLayout({
breadcrumbs,
children,
}: React.PropsWithChildren & {
}: React.PropsWithChildren<{
breadcrumbs: React.ReactNode
}) {
}>) {
return (
<section className={styles.layout}>
{breadcrumbs}

View File

@@ -23,7 +23,7 @@ export default async function CreditCardSlot({ params }: PageArgs<LangParams>) {
<section className={styles.container}>
<article className={styles.content}>
<Subtitle type="two" color="black">
{formatMessage({ id: "My credit cards" })}
{formatMessage({ id: "My payment cards" })}
</Subtitle>
<Body color="black">
{formatMessage({

View File

@@ -4,4 +4,5 @@
font-family: var(--typography-Body-Regular-fontFamily);
gap: var(--Spacing-x3);
grid-template-rows: auto 1fr;
position: relative;
}

View File

@@ -1,5 +1,7 @@
import { notFound } from "next/navigation"
import { env } from "@/env/server"
import ContentPage from "@/components/ContentType/ContentPage"
import HotelPage from "@/components/ContentType/HotelPage"
import LoyaltyPage from "@/components/ContentType/LoyaltyPage"
@@ -21,10 +23,16 @@ export default async function ContentTypePage({
switch (params.contentType) {
case "content-page":
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
return <ContentPage />
case "loyalty-page":
return <LoyaltyPage />
case "hotel-page":
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
return <HotelPage />
default:
const type: never = params.contentType

View File

@@ -0,0 +1,27 @@
# Booking flow
The booking flow is the user journey of booking one or more rooms at our
hotels. Everything from choosing the date to payment and confirmation is
part of the booking flow.
## Booking widget
On most of the pages on the website we have a booking widget. This is where
the user starts the booking flow, by filling the form and submit. If they
entered a city as the destination they will land on the select hotel page
and if they entered a specific hotel they will land on the select rate page.
## Select hotel
Lists available hotels based on the search criteria. When the user selects
a hotel they land on the select rate page.
## Select rate, room, breakfast etc
This is a page with an accordion like design, but every accordion is handled
as its own page with its own URL.
## State management
The state, like search parameters and selected alternatives, is kept
throughout the booking flow in the URL.

View File

@@ -1,7 +1,3 @@
.hotelInfo {
margin-bottom: 64px;
}
.page {
min-height: 100dvh;
padding-top: var(--Spacing-x6);
@@ -12,6 +8,18 @@
.content {
max-width: 1134px;
margin-top: var(--Spacing-x5);
margin-left: auto;
margin-right: auto;
display: flex;
justify-content: space-between;
gap: var(--Spacing-x7);
}
.main {
flex-grow: 1;
}
.summary {
max-width: 340px;
}

View File

@@ -0,0 +1,181 @@
import { serverClient } from "@/lib/trpc/server"
import tempHotelData from "@/server/routers/hotels/tempHotelData.json"
import BedSelection from "@/components/HotelReservation/SelectRate/BedSelection"
import BreakfastSelection from "@/components/HotelReservation/SelectRate/BreakfastSelection"
import Details from "@/components/HotelReservation/SelectRate/Details"
import Payment from "@/components/HotelReservation/SelectRate/Payment"
import RoomSelection from "@/components/HotelReservation/SelectRate/RoomSelection"
import SectionAccordion from "@/components/HotelReservation/SelectRate/SectionAccordion"
import Summary from "@/components/HotelReservation/SelectRate/Summary"
import { getIntl } from "@/i18n"
import { setLang } from "@/i18n/serverContext"
import styles from "./page.module.css"
import { SectionPageProps } from "@/types/components/hotelReservation/selectRate/section"
import { LangParams, PageArgs } from "@/types/params"
const bedAlternatives = [
{
value: "queen",
name: "Queen bed",
payment: "160 cm",
pricePerNight: 0,
membersPricePerNight: 0,
currency: "SEK",
},
{
value: "king",
name: "King bed",
payment: "160 cm",
pricePerNight: 0,
membersPricePerNight: 0,
currency: "SEK",
},
{
value: "twin",
name: "Twin bed",
payment: "90 cm + 90 cm",
pricePerNight: 82,
membersPricePerNight: 67,
currency: "SEK",
},
]
const breakfastAlternatives = [
{
value: "no",
name: "No breakfast",
payment: "Always cheeper to get it online",
pricePerNight: 0,
currency: "SEK",
},
{
value: "buffe",
name: "Breakfast buffé",
payment: "Always cheeper to get it online",
pricePerNight: 150,
currency: "SEK",
},
]
const getFlexibilityMessage = (value: string) => {
switch (value) {
case "non-refundable":
return "Non refundable"
case "free-rebooking":
return "Free rebooking"
case "free-cancellation":
return "Free cancellation"
}
return undefined
}
export default async function SectionsPage({
params,
searchParams,
}: PageArgs<LangParams & { section: string }, SectionPageProps>) {
setLang(params.lang)
// TODO: Use real endpoint.
const hotel = tempHotelData.data.attributes
const rooms = await serverClient().hotel.rates.get({
// TODO: pass the correct hotel ID and all other parameters that should be included in the search
hotelId: "1",
})
const intl = await getIntl()
const selectedBed = searchParams.bed
? bedAlternatives.find((a) => a.value === searchParams.bed)?.name
: undefined
const selectedBreakfast = searchParams.breakfast
? breakfastAlternatives.find((a) => a.value === searchParams.breakfast)
?.name
: undefined
const selectedRoom = searchParams.roomClass
? rooms.find((room) => room.id.toString() === searchParams.roomClass)?.name
: undefined
const selectedFlexibility = searchParams.flexibility
? getFlexibilityMessage(searchParams.flexibility)
: undefined
const currentSearchParams = new URLSearchParams(searchParams).toString()
return (
<div>
{/* TODO: Add Hotel Listing Card */}
<div>Hotel Listing Card TBI</div>
<div className={styles.content}>
<div className={styles.main}>
<SectionAccordion
header={intl.formatMessage({ id: "Room & Terms" })}
selection={
selectedRoom
? [
selectedRoom,
intl.formatMessage({ id: selectedFlexibility }),
]
: undefined
}
path={`select-rate?${currentSearchParams}`}
>
{params.section === "select-rate" && (
<RoomSelection
alternatives={rooms}
nextPath="select-bed"
// TODO: Get real value
nrOfNights={1}
// TODO: Get real value
nrOfAdults={1}
/>
)}
</SectionAccordion>
<SectionAccordion
header={intl.formatMessage({ id: "Bed type" })}
selection={selectedBed}
path={`select-bed?${currentSearchParams}`}
>
{params.section === "select-bed" && (
<BedSelection
nextPath="breakfast"
alternatives={bedAlternatives}
/>
)}
</SectionAccordion>
<SectionAccordion
header={intl.formatMessage({ id: "Breakfast" })}
selection={selectedBreakfast}
path={`breakfast?${currentSearchParams}`}
>
{params.section === "breakfast" && (
<BreakfastSelection
alternatives={breakfastAlternatives}
nextPath="details"
/>
)}
</SectionAccordion>
<SectionAccordion
header={intl.formatMessage({ id: "Your details" })}
path={`details?${currentSearchParams}`}
>
{params.section === "details" && <Details nextPath="payment" />}
</SectionAccordion>
<SectionAccordion
header={intl.formatMessage({ id: "Payment info" })}
path={`payment?${currentSearchParams}`}
>
{params.section === "payment" && <Payment />}
</SectionAccordion>
</div>
<div className={styles.summary}>
<Summary />
</div>
</div>
</div>
)
}

View File

@@ -1,3 +1,7 @@
import { notFound } from "next/navigation"
import { env } from "@/env/server"
import styles from "./layout.module.css"
import { LangParams, LayoutArgs } from "@/types/params"
@@ -5,5 +9,8 @@ import { LangParams, LayoutArgs } from "@/types/params"
export default function HotelReservationLayout({
children,
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
return <div className={styles.layout}>{children}</div>
}

View File

@@ -1,19 +1,17 @@
.main {
display: grid;
grid-template-columns: repeat(2, minmax(min-content, max-content));
display: flex;
gap: var(--Spacing-x4);
padding: var(--Spacing-x4) var(--Spacing-x4) 0 var(--Spacing-x4);
height: 100dvh;
background-color: var(--Scandic-Brand-Warm-White);
min-height: 100dvh;
}
.hotelCards {
display: grid;
gap: var(--Spacing-x4);
.section {
display: flex;
flex-direction: column;
}
.link {
display: flex;
align-items: center;
padding: var(--Spacing-x2) var(--Spacing-x0);
}

View File

@@ -1,7 +1,6 @@
import { serverClient } from "@/lib/trpc/server"
import tempHotelData from "@/server/routers/hotels/tempHotelData.json"
import HotelCard from "@/components/HotelReservation/HotelCard"
import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter"
import { ChevronRightIcon } from "@/components/Icons"
import StaticMap from "@/components/Maps/StaticMap"
@@ -11,45 +10,98 @@ import { getLang, setLang } from "@/i18n/serverContext"
import styles from "./page.module.css"
import { AvailabilityInput } from "@/types/components/hotelReservation/selectHotel/availabilityInput"
import { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import { LangParams, PageArgs } from "@/types/params"
async function getAvailableHotels(
input: AvailabilityInput
): Promise<HotelData[]> {
const getAvailableHotels = await serverClient().hotel.availability.get(input)
if (!getAvailableHotels) throw new Error()
const { availability } = getAvailableHotels
const hotels = availability.map(async (hotel) => {
const hotelData = await serverClient().hotel.hotelData.get({
hotelId: hotel.hotelId.toString(),
language: getLang(),
})
if (!hotelData) throw new Error()
return {
hotelData: hotelData.data.attributes,
price: hotel.bestPricePerNight,
}
})
return await Promise.all(hotels)
}
export default async function SelectHotelPage({
params,
}: PageArgs<LangParams>) {
const intl = await getIntl()
setLang(params.lang)
// TODO: Use real endpoint.
const hotel = tempHotelData.data.attributes
const hotels = [hotel]
const tempSearchTerm = "Stockholm"
const intl = await getIntl()
const hotelFilters = await serverClient().hotel.filters.get({
hotelId: "879",
const hotels = await getAvailableHotels({
cityId: "8ec4bba3-1c38-4606-82d1-bbe3f6738e54",
roomStayStartDate: "2024-11-02",
roomStayEndDate: "2024-11-03",
adults: 1,
})
const tempSearchTerm = "Stockholm"
const filters = hotels.flatMap((data) => data.hotelData.detailedFacilities)
const filterIds = [...new Set(filters.map((data) => data.id))]
const filterList: {
name: string
id: number
applyToAllHotels: boolean
public: boolean
icon: string
sortOrder: number
code?: string
iconName?: string
}[] = filterIds
.map((id) => filters.find((find) => find.id === id))
.filter(
(
filter
): filter is {
name: string
id: number
applyToAllHotels: boolean
public: boolean
icon: string
sortOrder: number
code?: string
iconName?: string
} => filter !== undefined
)
return (
<main className={styles.main}>
<section>
<section className={styles.section}>
<StaticMap
city={tempSearchTerm}
width={340}
height={180}
zoomLevel={11}
mapType="roadmap"
altText={`Map of ${tempSearchTerm} city center`}
/>
<Link className={styles.link} color="burgundy" href="#">
{intl.formatMessage({ id: "Show map" })}
<ChevronRightIcon color="burgundy" className={styles.icon} />
<ChevronRightIcon color="burgundy" />
</Link>
<HotelFilter filters={hotelFilters} />
</section>
<section className={styles.hotelCards}>
{hotels.map((hotel) => (
<HotelCard key={hotel.name} hotel={hotel} />
))}
<HotelFilter filters={filterList} />
</section>
<HotelCardListing hotelData={hotels} />
</main>
)
}

View File

@@ -1,39 +0,0 @@
import { serverClient } from "@/lib/trpc/server"
import tempHotelData from "@/server/routers/hotels/tempHotelData.json"
import HotelCard from "@/components/HotelReservation/HotelCard"
import BedSelection from "@/components/HotelReservation/SelectRate/BedSelection"
import BreakfastSelection from "@/components/HotelReservation/SelectRate/BreakfastSelection"
import FlexibilitySelection from "@/components/HotelReservation/SelectRate/FlexibilitySelection"
import RoomSelection from "@/components/HotelReservation/SelectRate/RoomSelection"
import { setLang } from "@/i18n/serverContext"
import styles from "./page.module.css"
import { LangParams, PageArgs } from "@/types/params"
export default async function SelectRate({ params }: PageArgs<LangParams>) {
setLang(params.lang)
// TODO: Use real endpoint.
const hotel = tempHotelData.data.attributes
const rooms = await serverClient().hotel.rates.get({
// TODO: pass the correct hotel ID and all other parameters that should be included in the search
hotelId: "1",
})
return (
<div className={styles.page}>
<main className={styles.content}>
<div className={styles.hotelInfo}>
<HotelCard hotel={hotel} />
</div>
<RoomSelection rooms={rooms} />
<FlexibilitySelection />
<BreakfastSelection />
<BedSelection />
</main>
</div>
)
}

View File

@@ -0,0 +1 @@
export { default } from "../page"

View File

@@ -0,0 +1 @@
export { default } from "../../page"

View File

@@ -0,0 +1 @@
export { default } from "./page"

View File

@@ -0,0 +1 @@
export { default } from "../../page"

View File

@@ -0,0 +1,17 @@
import { serverClient } from "@/lib/trpc/server"
import BookingWidget from "@/components/BookingWidget"
export default async function BookingWidgetPage() {
// Get the booking widget show/hide status based on page specific settings
const bookingWidgetToggle =
await serverClient().contentstack.bookingwidget.getToggle()
return (
<>
{bookingWidgetToggle && bookingWidgetToggle.hideBookingWidget ? null : (
<BookingWidget />
)}
</>
)
}

View File

@@ -1,9 +0,0 @@
"use client"
import { baseUrls } from "@/constants/routes/baseUrls"
import LanguageSwitcher from "@/components/Current/Header/LanguageSwitcher"
export default function Error() {
return <LanguageSwitcher urls={baseUrls} />
}

View File

@@ -1,18 +0,0 @@
import { serverClient } from "@/lib/trpc/server"
import LanguageSwitcher from "@/components/Current/Header/LanguageSwitcher"
import { setLang } from "@/i18n/serverContext"
import { LangParams, PageArgs } from "@/types/params"
export default async function LanguageSwitcherRoute({
params,
}: PageArgs<LangParams>) {
setLang(params.lang)
const data = await serverClient().contentstack.languageSwitcher.get()
if (!data) {
return null
}
return <LanguageSwitcher urls={data.urls} />
}

View File

@@ -1,15 +0,0 @@
import { serverClient } from "@/lib/trpc/server"
import MyPagesMobileDropdown from "@/components/Current/Header/MyPagesMobileDropdown"
import { setLang } from "@/i18n/serverContext"
import { LangParams, PageArgs } from "@/types/params"
export default async function MyPagesMobileDropdownPage({
params,
}: PageArgs<LangParams>) {
setLang(params.lang)
const navigation = await serverClient().contentstack.myPages.navigation.get()
if (!navigation) return null
return <MyPagesMobileDropdown navigation={navigation} />
}

View File

@@ -1,21 +0,0 @@
import Header from "@/components/Current/Header"
import { setLang } from "@/i18n/serverContext"
import type { LangParams, LayoutArgs } from "@/types/params"
export default function HeaderLayout({
languageSwitcher,
myPagesMobileDropdown,
params,
}: LayoutArgs<LangParams> & {
languageSwitcher: React.ReactNode
myPagesMobileDropdown: React.ReactNode
}) {
setLang(params.lang)
return (
<Header
myPagesMobileDropdown={myPagesMobileDropdown}
languageSwitcher={languageSwitcher}
/>
)
}

View File

@@ -1,8 +1 @@
import { setLang } from "@/i18n/serverContext"
import type { LangParams, PageArgs } from "@/types/params"
export default function EmptyHeaderPage({ params }: PageArgs<LangParams>) {
setLang(params.lang)
return null
}
export { default } from "../page"

View File

@@ -0,0 +1 @@
export { default } from "../../page"

View File

@@ -0,0 +1 @@
export { default } from "./page"

View File

@@ -0,0 +1 @@
export { default } from "../../page"

View File

@@ -1,21 +1,17 @@
import { baseUrls } from "@/constants/routes/baseUrls"
import { serverClient } from "@/lib/trpc/server"
import { env } from "@/env/server"
import Header from "@/components/Current/Header"
import LanguageSwitcher from "@/components/Current/Header/LanguageSwitcher"
import MyPagesMobileDropdown from "@/components/Current/Header/MyPagesMobileDropdown"
import CurrentHeader from "@/components/Current/Header"
import Header from "@/components/Header"
import { setLang } from "@/i18n/serverContext"
import { LangParams, PageArgs } from "@/types/params"
export default async function HeaderPage({ params }: PageArgs<LangParams>) {
export default function HeaderPage({ params }: PageArgs<LangParams>) {
setLang(params.lang)
const navigation = await serverClient().contentstack.myPages.navigation.get()
return (
<Header
myPagesMobileDropdown={<MyPagesMobileDropdown navigation={navigation} />}
languageSwitcher={<LanguageSwitcher urls={baseUrls} />}
/>
)
if (env.HIDE_FOR_NEXT_RELEASE) {
return <CurrentHeader />
}
return <Header />
}

View File

@@ -2,13 +2,17 @@ import "@/app/globals.css"
import "@scandic-hotels/design-system/style.css"
import Script from "next/script"
import { Suspense } from "react"
import { env } from "@/env/server"
import TrpcProvider from "@/lib/trpc/Provider"
import TokenRefresher from "@/components/Auth/TokenRefresher"
import AdobeSDKScript from "@/components/Current/AdobeSDKScript"
import Footer from "@/components/Current/Footer"
import CurrentFooter from "@/components/Current/Footer"
import VwoScript from "@/components/Current/VwoScript"
import Footer from "@/components/Footer"
import LoadingSpinner from "@/components/LoadingSpinner"
import { ToastHandler } from "@/components/TempDesignSystem/Toasts"
import { preloadUserTracking } from "@/components/TrackingSDK"
import { getIntl } from "@/i18n"
@@ -21,9 +25,11 @@ export default async function RootLayout({
children,
params,
header,
bookingwidget,
}: React.PropsWithChildren<
LayoutArgs<LangParams> & {
header: React.ReactNode
bookingwidget: React.ReactNode
}
>) {
setLang(params.lang)
@@ -52,9 +58,12 @@ export default async function RootLayout({
<ServerIntlProvider intl={{ defaultLocale, locale, messages }}>
<TrpcProvider>
{header}
{!env.HIDE_FOR_NEXT_RELEASE && <>{bookingwidget}</>}
{children}
<ToastHandler />
<Footer />
<Suspense fallback={<LoadingSpinner />}>
{env.HIDE_FOR_NEXT_RELEASE ? <CurrentFooter /> : <Footer />}
</Suspense>
<TokenRefresher />
</TrpcProvider>
</ServerIntlProvider>

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function Loading() {
return <LoadingSpinner />
}

View File

@@ -0,0 +1,10 @@
import Header from "@/components/Current/Header"
import { setLang } from "@/i18n/serverContext"
import { LangParams, PageArgs } from "@/types/params"
export default async function HeaderPage({ params }: PageArgs<LangParams>) {
setLang(params.lang)
return <Header />
}

View File

@@ -1,18 +0,0 @@
import { serverClient } from "@/lib/trpc/server"
import LanguageSwitcher from "@/components/Current/Header/LanguageSwitcher"
import { setLang } from "@/i18n/serverContext"
import { LangParams, PageArgs } from "@/types/params"
export default async function LanguageSwitcherRoute({
params,
}: PageArgs<LangParams>) {
setLang(params.lang)
const data = await serverClient().contentstack.languageSwitcher.get()
if (!data) {
return null
}
return <LanguageSwitcher urls={data.urls} />
}

View File

@@ -1,9 +0,0 @@
"use client"
import { baseUrls } from "@/constants/routes/baseUrls"
import LanguageSwitcher from "@/components/Current/Header/LanguageSwitcher"
export default function Error() {
return <LanguageSwitcher urls={baseUrls} />
}

View File

@@ -1,18 +0,0 @@
import { serverClient } from "@/lib/trpc/server"
import MyPagesMobileDropdown from "@/components/Current/Header/MyPagesMobileDropdown"
import { setLang } from "@/i18n/serverContext"
import { LangParams, PageArgs } from "@/types/params"
export default async function MyPagesMobileDropdownPage({
params,
}: PageArgs<LangParams>) {
setLang(params.lang)
const navigation = await serverClient().contentstack.myPages.navigation.get()
if (!navigation) {
return null
}
return <MyPagesMobileDropdown navigation={navigation} />
}

View File

@@ -1,7 +0,0 @@
"use client"
import MyPagesMobileDropdown from "@/components/Current/Header/MyPagesMobileDropdown"
export default function Error() {
return <MyPagesMobileDropdown navigation={null} />
}

View File

@@ -4,9 +4,9 @@ import "@scandic-hotels/design-system/style.css"
import Script from "next/script"
import TokenRefresher from "@/components/Auth/TokenRefresher"
import BookingWidget from "@/components/BookingWidget"
import AdobeScript from "@/components/Current/AdobeScript"
import Footer from "@/components/Current/Footer"
import Header from "@/components/Current/Header"
import LangPopup from "@/components/Current/LangPopup"
import SkipToMainContent from "@/components/SkipToMainContent"
import { getIntl } from "@/i18n"
@@ -26,12 +26,9 @@ export const metadata: Metadata = {
export default async function RootLayout({
children,
params,
languageSwitcher,
myPagesMobileDropdown,
header,
}: React.PropsWithChildren<
LayoutArgs<LangParams> & { languageSwitcher: React.ReactNode } & {
myPagesMobileDropdown: React.ReactNode
}
LayoutArgs<LangParams> & { header: React.ReactNode }
>) {
setLang(params.lang)
const { defaultLocale, locale, messages } = await getIntl()
@@ -67,10 +64,8 @@ export default async function RootLayout({
<LangPopup />
<SkipToMainContent />
<ServerIntlProvider intl={{ defaultLocale, locale, messages }}>
<Header
myPagesMobileDropdown={myPagesMobileDropdown}
languageSwitcher={languageSwitcher}
/>
{header}
<BookingWidget />
{children}
<Footer />
<TokenRefresher />