Merged in monorepo-step-1 (pull request #1080)

Migrate to a monorepo setup - step 1

* Move web to subfolder /apps/scandic-web

* Yarn + transitive deps

- Move to yarn
- design-system package removed for now since yarn doesn't
support the parameter for token (ie project currently broken)
- Add missing transitive dependencies as Yarn otherwise
prevents these imports
- VS Code doesn't pick up TS path aliases unless you open
/apps/scandic-web instead of root (will be fixed with monorepo)

* Pin framer-motion to temporarily fix typing issue

https://github.com/adobe/react-spectrum/issues/7494

* Pin zod to avoid typ error

There seems to have been a breaking change in the types
returned by zod where error is now returned as undefined
instead of missing in the type. We should just handle this
but to avoid merge conflicts just pin the dependency for
now.

* Pin react-intl version

Pin version of react-intl to avoid tiny type issue where formatMessage
does not accept a generic any more. This will be fixed in a future
commit, but to avoid merge conflicts just pin for now.

* Pin typescript version

Temporarily pin version as newer versions as stricter and results in
a type error. Will be fixed in future commit after merge.

* Setup workspaces

* Add design-system as a monorepo package

* Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN

* Fix husky for monorepo setup

* Update netlify.toml

* Add lint script to root package.json

* Add stub readme

* Fix react-intl formatMessage types

* Test netlify.toml in root

* Remove root toml

* Update netlify.toml publish path

* Remove package-lock.json

* Update build for branch/preview builds


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-02-26 10:36:17 +00:00
committed by Linus Flood
parent 667cab6fb6
commit 80100e7631
2731 changed files with 30986 additions and 23708 deletions

View File

@@ -0,0 +1,15 @@
.bookingCodeFilter {
display: flex;
justify-content: flex-end;
width: 100%;
}
.bookingCodeFilterSelect {
min-width: 200px;
}
@media screen and (max-width: 767px) {
.bookingCodeFilter {
margin-bottom: var(--Spacing-x3);
}
}

View File

@@ -0,0 +1,56 @@
"use client"
import { useIntl } from "react-intl"
import { useBookingCodeFilterStore } from "@/stores/bookingCode-filter"
import { PriceTagIcon } from "@/components/Icons"
import Select from "@/components/TempDesignSystem/Select"
import styles from "./bookingCodeFilter.module.css"
import type { Key } from "react"
export default function BookingCodeFilter() {
const intl = useIntl()
const activeCodeFilter = useBookingCodeFilterStore(
(state) => state.activeCodeFilter
)
const setFilter = useBookingCodeFilterStore((state) => state.setFilter)
const bookingCodeFilterItems = [
{
label: intl.formatMessage({ id: "Discounted rooms" }),
value: "discounted",
},
{
label: intl.formatMessage({ id: "Full price rooms" }),
value: "regular",
},
{
label: intl.formatMessage({ id: "See all" }),
value: "all",
},
]
function updateFilter(selectedFilter: Key) {
setFilter(selectedFilter as string)
}
return (
<>
<div className={styles.bookingCodeFilter}>
<Select
aria-label="Booking Code Filter"
className={styles.bookingCodeFilterSelect}
name="bookingCodeFilter"
onSelect={updateFilter}
label=""
items={bookingCodeFilterItems}
defaultSelectedKey={activeCodeFilter}
optionsIcon={<PriceTagIcon />}
/>
</div>
</>
)
}

View File

@@ -0,0 +1,221 @@
@keyframes modal-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-slide-up {
from {
bottom: -100%;
}
to {
bottom: 0;
}
}
.overlay {
align-items: center;
background: rgba(0, 0, 0, 0.5);
display: flex;
height: var(--visual-viewport-height);
justify-content: center;
left: 0;
position: fixed;
top: 0;
width: 100vw;
z-index: 100;
&[data-entering] {
animation: modal-fade 200ms;
}
&[data-exiting] {
animation: modal-fade 150ms reverse ease-in;
}
}
.modal {
position: absolute;
left: 0;
bottom: 0;
height: calc(100dvh - 20px);
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium);
box-shadow: var(--modal-box-shadow);
width: 100%;
&[data-entering] {
animation: modal-slide-up 200ms;
}
&[data-existing] {
animation: modal-slide-up 200ms reverse;
}
}
.content {
flex-direction: column;
display: flex;
height: 100%;
}
.sorter {
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2) var(--Spacing-x-half)
var(--Spacing-x2);
flex: 0 0 auto;
}
.badge {
background-color: var(--Base-Text-Accent);
border-radius: var(--Corner-radius-xLarge);
width: 20px;
height: 20px;
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
}
.filters {
padding: var(--Spacing-x2);
padding-top: calc(var(--Spacing-x3) + var(--Spacing-x-half));
flex: 1 1 auto;
overflow-y: auto;
}
.filters ul {
margin-top: var(--Spacing-x3);
}
.filters ul li {
padding-bottom: var(--Spacing-x1);
}
.header {
text-align: right;
padding: var(--Spacing-x-one-and-half);
flex: 0 0 auto;
}
.title {
display: none;
}
.close {
background: none;
border: none;
cursor: pointer;
justify-self: flex-end;
padding: 0;
}
.divider {
display: none;
}
.footer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
padding: var(--Spacing-x3) var(--Spacing-x2);
flex: 0 0 auto;
border-top: 1px solid var(--Base-Border-Subtle);
}
@media screen and (min-width: 768px) {
.modal {
left: 50%;
bottom: 50%;
height: min(80dvh, 680px);
width: min(80dvw, 960px);
translate: -50% 50%;
overflow-y: auto;
}
.divider {
display: block;
padding: 0 var(--Spacing-x3);
}
.header {
display: grid;
grid-template-columns: auto 1fr;
padding: var(--Spacing-x2) var(--Spacing-x3);
align-items: center;
border-bottom: 1px solid var(--Base-Border-Subtle);
position: sticky;
top: 0;
background: var(--Base-Surface-Primary-light-Normal);
z-index: 1;
border-top-left-radius: var(--Corner-radius-large);
border-top-right-radius: var(--Corner-radius-large);
}
.title {
display: block;
}
.content {
gap: var(--Spacing-x4);
height: auto;
}
.filters {
overflow-y: unset;
}
.sorter {
padding: var(--Spacing-x2);
}
.sorter,
.filters,
.footer,
.divider {
padding: 0 var(--Spacing-x3);
}
.footer {
flex-direction: row-reverse;
justify-content: space-between;
position: sticky;
bottom: 0;
background: var(--Base-Surface-Primary-light-Normal);
z-index: 1;
border-bottom-left-radius: var(--Corner-radius-large);
border-bottom-right-radius: var(--Corner-radius-large);
padding: var(--Spacing-x2) var(--Spacing-x3);
}
.filters aside > form {
gap: var(--Spacing-x2);
}
.filters aside form > div:last-child {
margin-top: var(--Spacing-x2);
}
.filters aside ul {
display: grid;
grid-template-columns: 1fr 1fr;
margin-top: var(--Spacing-x1);
}
.filters ul li:hover {
background: var(--UI-Input-Controls-Surface-Hover);
border-radius: var(--Corner-radius-Medium,);
outline: none;
}
.filters ul li {
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
}
}
@media screen and (min-width: 1024) {
.facilities ul {
grid-template-columns: 1fr 1fr 1fr;
}
}

View File

@@ -0,0 +1,174 @@
"use client"
import {
usePathname,
useSearchParams,
} from "next/dist/client/components/navigation"
import { useCallback, useState } from "react"
import {
Dialog as AriaDialog,
DialogTrigger,
Modal,
ModalOverlay,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import { CloseLargeIcon, FilterIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Select from "@/components/TempDesignSystem/Select"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useInitializeFiltersFromUrl from "@/hooks/useInitializeFiltersFromUrl"
import HotelFilter from "../HotelFilter"
import { DEFAULT_SORT } from "../HotelSorter"
import styles from "./filterAndSortModal.module.css"
import type { FilterAndSortModalProps } from "@/types/components/hotelReservation/selectHotel/filterAndSortModal"
import {
type SortItem,
SortOrder,
} from "@/types/components/hotelReservation/selectHotel/hotelSorter"
export default function FilterAndSortModal({
filters,
setShowSkeleton,
}: FilterAndSortModalProps) {
const intl = useIntl()
useInitializeFiltersFromUrl()
const searchParams = useSearchParams()
const pathname = usePathname()
const resultCount = useHotelFilterStore((state) => state.resultCount)
const setFilters = useHotelFilterStore((state) => state.setFilters)
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const [sort, setSort] = useState(searchParams.get("sort") ?? DEFAULT_SORT)
const sortItems: SortItem[] = [
{
label: intl.formatMessage({ id: "Distance to city center" }),
value: SortOrder.Distance,
},
{ label: intl.formatMessage({ id: "Name" }), value: SortOrder.Name },
{ label: intl.formatMessage({ id: "Price" }), value: SortOrder.Price },
{
label: intl.formatMessage({ id: "TripAdvisor rating" }),
value: SortOrder.TripAdvisorRating,
},
]
const handleSortSelect = useCallback((value: string | number) => {
setSort(value.toString())
}, [])
const handleApplyFiltersAndSorting = useCallback(
(close: () => void) => {
if (setShowSkeleton) {
setShowSkeleton(true)
}
if (sort === searchParams.get("sort")) {
close()
}
const newSearchParams = new URLSearchParams(searchParams)
newSearchParams.set("sort", sort)
window.history.replaceState(
null,
"",
`${pathname}?${newSearchParams.toString()}`
)
close()
if (setShowSkeleton) {
setTimeout(() => {
setShowSkeleton(false)
}, 500)
}
},
[pathname, searchParams, sort, setShowSkeleton]
)
return (
<>
<DialogTrigger>
<Button intent="secondary" size="small" theme="base" variant="icon">
<FilterIcon color="baseTextHighcontrast" />
{intl.formatMessage({ id: "Filter and sort" })}
{activeFilters.length > 0 && (
<Footnote className={styles.badge}>{activeFilters.length}</Footnote>
)}
</Button>
<ModalOverlay className={styles.overlay} isDismissable>
<Modal className={styles.modal}>
<AriaDialog role="alertdialog" className={styles.content}>
{({ close }) => (
<>
<header className={styles.header}>
<button
onClick={close}
type="button"
className={styles.close}
>
<CloseLargeIcon />
</button>
<Subtitle
type="two"
textAlign="center"
className={styles.title}
>
{intl.formatMessage({ id: "Filter and sort" })}
</Subtitle>
</header>
<div className={styles.sorter}>
<Select
items={sortItems}
defaultSelectedKey={
searchParams.get("sort") ?? DEFAULT_SORT
}
label={intl.formatMessage({ id: "Sort by" })}
name="sort"
showRadioButton
onSelect={handleSortSelect}
/>
</div>
<div className={styles.divider}>
<Divider color="subtle" />
</div>
<div className={styles.filters}>
<HotelFilter filters={filters} />
</div>
<footer className={styles.footer}>
<Button
intent="primary"
size="medium"
theme="base"
onClick={() => handleApplyFiltersAndSorting(close)}
>
{intl.formatMessage(
{ id: "See results ({ count })" },
{
count: resultCount,
}
)}
</Button>
<Button
onClick={() => setFilters([])}
intent="text"
size="medium"
theme="base"
>
{intl.formatMessage({ id: "Clear all filters" })}
</Button>
</footer>
</>
)}
</AriaDialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
</>
)
}

View File

@@ -0,0 +1,23 @@
"use client"
import { useIntl } from "react-intl"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import Preamble from "@/components/TempDesignSystem/Text/Preamble"
export default function HotelCount() {
const intl = useIntl()
const resultCount = useHotelFilterStore((state) => state.resultCount)
return (
<Preamble>
{intl.formatMessage(
{
id: "{amount, plural, one {# hotel} other {# hotels}}",
},
{ amount: resultCount }
)}
</Preamble>
)
}

View File

@@ -0,0 +1,30 @@
.container {
display: flex;
flex-direction: column;
color: var(--text-color);
}
.container[data-selected] .checkbox {
border: none;
background: var(--UI-Input-Controls-Fill-Selected);
}
.checkboxContainer {
display: flex;
align-items: center;
gap: var(--Spacing-x-one-and-half);
}
.checkbox {
width: 24px;
height: 24px;
min-width: 24px;
border: 1px solid var(--UI-Input-Controls-Border-Normal);
border-radius: 4px;
transition: all 200ms;
display: flex;
align-items: center;
justify-content: center;
forced-color-adjust: none;
background: var(--UI-Input-Controls-Surface-Normal);
}

View File

@@ -0,0 +1,35 @@
"use client"
import { Checkbox as AriaCheckbox } from "react-aria-components"
import CheckIcon from "@/components/Icons/Check"
import styles from "./filterCheckbox.module.css"
import type { FilterCheckboxProps } from "@/types/components/hotelReservation/selectHotel/filterCheckbox"
export default function FilterCheckbox({
isSelected,
name,
id,
onChange,
}: FilterCheckboxProps) {
return (
<AriaCheckbox
className={styles.container}
isSelected={isSelected}
onChange={() => onChange(id)}
>
{({ isSelected }) => (
<>
<span className={styles.checkboxContainer}>
<span className={styles.checkbox}>
{isSelected && <CheckIcon color="white" />}
</span>
{name}
</span>
</>
)}
</AriaCheckbox>
)
}

View File

@@ -0,0 +1,43 @@
.container {
min-width: 272px;
}
.container form {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
}
.facilities {
font-family: var(--typography-Body-Bold-fontFamily);
padding-bottom: var(--Spacing-x3);
}
.facilities:first-of-type {
border-bottom: 1px solid var(--Base-Border-Subtle);
}
.facilities ul {
margin-top: var(--Spacing-x2);
}
.facilities:last-child {
padding-bottom: 0;
}
.filter {
display: grid;
grid-template-columns: repeat(2, minmax(min-content, max-content));
gap: var(--Spacing-x-one-and-half);
margin-bottom: var(--Spacing-x1);
align-items: center;
}
.filter:first-child {
margin-top: var(--Spacing-x1);
}
.filter input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
margin: 0;
}

View File

@@ -0,0 +1,96 @@
"use client"
import { usePathname, useSearchParams } from "next/navigation"
import { useEffect } from "react"
import { useIntl } from "react-intl"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Title from "@/components/TempDesignSystem/Text/Title"
import useInitializeFiltersFromUrl from "@/hooks/useInitializeFiltersFromUrl"
import FilterCheckbox from "./FilterCheckbox"
import styles from "./hotelFilter.module.css"
import type { HotelFiltersProps } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
export default function HotelFilter({ className, filters }: HotelFiltersProps) {
const intl = useIntl()
const searchParams = useSearchParams()
const pathname = usePathname()
const toggleFilter = useHotelFilterStore((state) => state.toggleFilter)
useInitializeFiltersFromUrl()
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
// Update the URL when the filters changes
useEffect(() => {
const newSearchParams = new URLSearchParams(searchParams)
const values = activeFilters.join(",")
if (values === "") {
newSearchParams.delete("filters")
} else {
newSearchParams.set("filters", values)
}
if (values !== searchParams.values.toString()) {
window.history.replaceState(
null,
"",
`${pathname}?${newSearchParams.toString()}`
)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeFilters])
if (!filters.facilityFilters.length && !filters.surroundingsFilters.length) {
return null
}
return (
<aside className={`${styles.container} ${className}`}>
<form>
<Title as="h4">{intl.formatMessage({ id: "Filter by" })}</Title>
<div className={styles.facilities}>
<Subtitle>{intl.formatMessage({ id: "Hotel facilities" })}</Subtitle>
<ul>
{filters.facilityFilters.map((filter) => (
<li key={`li-${filter.id}`} className={styles.filter}>
<FilterCheckbox
name={filter.name}
id={filter.id.toString()}
onChange={() => toggleFilter(filter.id.toString())}
isSelected={
!!activeFilters.find((f) => f === filter.id.toString())
}
/>
</li>
))}
</ul>
</div>
<div className={styles.facilities}>
<Subtitle>
{intl.formatMessage({ id: "Hotel surroundings" })}
</Subtitle>
<ul>
{filters.surroundingsFilters.map((filter) => (
<li key={`li-${filter.id}`} className={styles.filter}>
<FilterCheckbox
name={filter.name}
id={filter.id.toString()}
onChange={() => toggleFilter(filter.id.toString())}
isSelected={
!!activeFilters.find((f) => f === filter.id.toString())
}
/>
</li>
))}
</ul>
</div>
</form>
</aside>
)
}

View File

@@ -0,0 +1,65 @@
"use client"
import { usePathname, useSearchParams } from "next/navigation"
import { useCallback } from "react"
import { useIntl } from "react-intl"
import Select from "@/components/TempDesignSystem/Select"
import {
type HotelSorterProps,
type SortItem,
SortOrder,
} from "@/types/components/hotelReservation/selectHotel/hotelSorter"
export const DEFAULT_SORT = SortOrder.Distance
export default function HotelSorter({ discreet }: HotelSorterProps) {
const searchParams = useSearchParams()
const pathname = usePathname()
const intl = useIntl()
const onSelect = useCallback(
(value: string | number) => {
const newSort = value.toString()
if (newSort === searchParams.get("sort")) {
return
}
const newSearchParams = new URLSearchParams(searchParams)
newSearchParams.set("sort", newSort)
window.history.replaceState(
null,
"",
`${pathname}?${newSearchParams.toString()}`
)
},
[pathname, searchParams]
)
const sortItems: SortItem[] = [
{
label: intl.formatMessage({ id: "Distance to city center" }),
value: SortOrder.Distance,
},
{ label: intl.formatMessage({ id: "Name" }), value: SortOrder.Name },
{ label: intl.formatMessage({ id: "Price" }), value: SortOrder.Price },
{
label: intl.formatMessage({ id: "TripAdvisor rating" }),
value: SortOrder.TripAdvisorRating,
},
]
return (
<Select
items={sortItems}
defaultSelectedKey={searchParams.get("sort") ?? DEFAULT_SORT}
label={intl.formatMessage({ id: "Sort by" })}
aria-label={intl.formatMessage({ id: "Sort by" })}
name="sort"
showRadioButton
discreet={discreet}
onSelect={onSelect}
/>
)
}

View File

@@ -0,0 +1,55 @@
"use client"
import { useIntl } from "react-intl"
import {
alternativeHotelsMap,
selectHotelMap,
} from "@/constants/routes/hotelReservation"
import { MapIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"
import FilterAndSortModal from "../FilterAndSortModal"
import styles from "./mobileMapButtonContainer.module.css"
import type { CategorizedFilters } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
export default function MobileMapButtonContainer({
filters,
isAlternative,
}: {
filters: CategorizedFilters
isAlternative?: boolean
}) {
const intl = useIntl()
const lang = useLang()
return (
<div className={styles.buttonContainer}>
<Button
asChild
theme="base"
variant="icon"
intent="secondary"
size="small"
>
<Link
href={
isAlternative ? alternativeHotelsMap(lang) : selectHotelMap(lang)
}
color="baseButtonTextOnFillNormal"
keepSearchParams
weight="bold"
>
<MapIcon color="baseTextHighcontrast" />
{intl.formatMessage({ id: "See on map" })}
</Link>
</Button>
<FilterAndSortModal filters={filters} />
</div>
)
}

View File

@@ -0,0 +1,15 @@
.buttonContainer {
display: flex;
gap: var(--Spacing-x2);
margin-bottom: var(--Spacing-x3);
}
.buttonContainer > * {
flex: 1 1 50%;
}
@media (min-width: 768px) {
.buttonContainer {
display: none;
}
}

View File

@@ -0,0 +1,48 @@
import { alternativeHotels } from "@/constants/routes/hotelReservation"
import Alert from "@/components/TempDesignSystem/Alert"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import type { NoAvailabilityAlertProp } from "@/types/components/hotelReservation/selectHotel/noAvailabilityAlert"
import { AlertTypeEnum } from "@/types/enums/alert"
export default async function NoAvailabilityAlert({
hotels,
isAllUnavailable,
isAlternative,
}: NoAvailabilityAlertProp) {
const intl = await getIntl()
const lang = getLang()
if (!isAllUnavailable) {
return null
}
if (hotels.length === 1 && !isAlternative) {
return (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No availability" })}
text={intl.formatMessage({
id: "Please try and change your search for this destination or see alternative hotels.",
})}
link={{
title: intl.formatMessage({ id: "See alternative hotels" }),
url: `${alternativeHotels(lang)}?hotel=${hotels[0].hotelData.operaId}`,
keepSearchParams: true,
}}
/>
)
}
return (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No availability" })}
text={intl.formatMessage({
id: "There are no rooms available that match your request.",
})}
/>
)
}

View File

@@ -0,0 +1,31 @@
.hotelListing {
display: none;
}
.hotelListingMobile {
display: none;
overflow-x: auto;
position: absolute;
bottom: 32px;
left: 0;
right: 0;
z-index: 10;
}
.hotelListingMobile[data-open="true"] {
display: flex;
}
@media (min-width: 768px) {
.hotelListing {
display: block;
width: 100%;
overflow-y: auto;
padding-top: var(--Spacing-x2);
}
.hotelListingMobile,
.hotelListingMobile[data-open="true"] {
display: none;
}
}

View File

@@ -0,0 +1,28 @@
"use client"
import { useHotelsMapStore } from "@/stores/hotels-map"
import HotelCardDialogListing from "@/components/HotelReservation/HotelCardDialogListing"
import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
import styles from "./hotelListing.module.css"
import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map"
export default function HotelListing({ hotels }: HotelListingProps) {
const { activeHotelPin } = useHotelsMapStore()
return (
<>
<div className={styles.hotelListing}>
<HotelCardListing
hotelData={hotels}
type={HotelCardListingTypeEnum.MapListing}
/>
</div>
<div className={styles.hotelListingMobile} data-open={!!activeHotelPin}>
<HotelCardDialogListing hotels={hotels} />
</div>
</>
)
}

View File

@@ -0,0 +1,166 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { env } from "@/env/server"
import { getCityCoordinates } from "@/lib/trpc/memoizedRequests"
import {
fetchAlternativeHotels,
fetchAvailableHotels,
fetchBookingCodeAvailableHotels,
getFiltersFromHotels,
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
import { getHotelSearchDetails } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/utils"
import TrackingSDK from "@/components/TrackingSDK"
import { getLang } from "@/i18n/serverContext"
import { safeTry } from "@/utils/safeTry"
import { getHotelPins } from "../../HotelCardDialogListing/utils"
import SelectHotelMap from "."
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { SelectHotelMapContainerProps } from "@/types/components/hotelReservation/selectHotel/map"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import {
TrackingChannelEnum,
type TrackingSDKHotelInfo,
type TrackingSDKPageData,
} from "@/types/components/tracking"
export async function SelectHotelMapContainer({
searchParams,
isAlternativeHotels,
}: SelectHotelMapContainerProps) {
const lang = getLang()
const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID
const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY
const getHotelSearchDetailsPromise = safeTry(
getHotelSearchDetails(
{
searchParams: searchParams as SelectHotelSearchParams & {
[key: string]: string
},
},
isAlternativeHotels
)
)
const [searchDetails] = await getHotelSearchDetailsPromise
if (!searchDetails) return notFound()
const {
city,
selectHotelParams,
adultsInRoom,
childrenInRoom,
childrenInRoomString,
hotel: isAlternativeFor,
bookingCode,
} = searchDetails
if (!city) return notFound()
const fetchAvailableHotelsPromise = isAlternativeFor
? safeTry(
fetchAlternativeHotels(isAlternativeFor.id, {
roomStayStartDate: selectHotelParams.fromDate,
roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom[0],
children: childrenInRoomString,
bookingCode,
})
)
: bookingCode
? safeTry(
fetchBookingCodeAvailableHotels({
cityId: city.id,
roomStayStartDate: selectHotelParams.fromDate,
roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom[0],
children: childrenInRoomString,
bookingCode,
})
)
: safeTry(
fetchAvailableHotels({
cityId: city.id,
roomStayStartDate: selectHotelParams.fromDate,
roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom[0],
children: childrenInRoomString,
})
)
const [hotels] = await fetchAvailableHotelsPromise
const validHotels = (hotels?.filter(Boolean) as HotelData[]) || []
const hotelPins = getHotelPins(validHotels)
const filterList = getFiltersFromHotels(validHotels)
const cityCoordinates = await getCityCoordinates({
city: city.name,
hotel: { address: hotels?.[0]?.hotelData?.address.streetAddress },
})
const arrivalDate = new Date(selectHotelParams.fromDate)
const departureDate = new Date(selectHotelParams.toDate)
const pageTrackingData: TrackingSDKPageData = {
pageId: isAlternativeFor ? "alternative-hotels" : "select-hotel",
domainLanguage: lang,
channel: TrackingChannelEnum["hotelreservation"],
pageName: isAlternativeHotels
? "hotelreservation|alternative-hotels|mapview"
: "hotelreservation|select-hotel|mapview",
siteSections: isAlternativeHotels
? "hotelreservation|altervative-hotels|mapview"
: "hotelreservation|select-hotel|mapview",
pageType: "bookinghotelsmapviewpage",
siteVersion: "new-web",
}
const hotelsTrackingData: TrackingSDKHotelInfo = {
availableResults: validHotels.length,
searchTerm: isAlternativeFor
? selectHotelParams.hotelId
: (selectHotelParams.city as string),
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
departureDate: format(departureDate, "yyyy-MM-dd"),
noOfAdults: adultsInRoom[0], // TODO: Handle multiple rooms
noOfChildren: childrenInRoom?.length,
ageOfChildren: childrenInRoom?.map((c) => c.age).join(","),
childBedPreference: childrenInRoom
?.map((c) => ChildBedMapEnum[c.bed])
.join("|"),
noOfRooms: 1, // // TODO: Handle multiple rooms
duration: differenceInCalendarDays(departureDate, arrivalDate),
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
searchType: "destination",
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
country: validHotels?.[0]?.hotelData.address.country,
region: validHotels?.[0]?.hotelData.address.city,
}
return (
<>
<SelectHotelMap
apiKey={googleMapsApiKey}
hotelPins={hotelPins}
mapId={googleMapId}
hotels={validHotels}
filterList={filterList}
cityCoordinates={cityCoordinates}
bookingCode={bookingCode ?? ""}
/>
<Suspense fallback={null}>
<TrackingSDK
pageData={pageTrackingData}
hotelInfo={hotelsTrackingData}
/>
</Suspense>
</>
)
}

View File

@@ -0,0 +1,48 @@
.container {
max-width: var(--max-width);
height: 100vh;
display: flex;
width: 100%;
}
.listingContainer {
display: none;
}
.skeletonContainer {
display: none;
overflow: hidden;
flex-direction: row;
flex-wrap: wrap;
margin-top: 20px;
gap: var(--Spacing-x2);
padding-top: var(--Spacing-x6);
height: 100%;
}
.skeletonItem {
width: 440px;
}
.mapContainer {
flex: 1;
}
@media (min-width: 768px) {
.container {
height: 100%;
}
.listingContainer {
background-color: var(--Base-Surface-Secondary-light-Normal);
padding: var(--Spacing-x3) var(--Spacing-x4);
overflow-y: auto;
max-width: 505px;
position: relative;
height: 100%;
display: block;
}
.skeletonContainer {
display: flex;
width: 360px;
}
}

View File

@@ -0,0 +1,27 @@
import { RoomCardSkeleton } from "@/components/HotelReservation/RoomCardSkeleton/RoomCardSkeleton"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import styles from "./SelectHotelMapContainerSkeleton.module.css"
type Props = {
count?: number
}
export function SelectHotelMapContainerSkeleton({ count = 2 }: Props) {
return (
<div className={styles.container}>
<div className={styles.listingContainer}>
<div className={styles.skeletonContainer}>
{Array.from({ length: count }).map((_, index) => (
<div key={index} className={styles.skeletonItem}>
<RoomCardSkeleton />
</div>
))}
</div>
</div>
<div className={styles.mapContainer}>
<SkeletonShimmer width={"100%"} height="100%" />
</div>
</div>
)
}

View File

@@ -0,0 +1,184 @@
"use client"
import { useMap } from "@vis.gl/react-google-maps"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { selectHotel } from "@/constants/routes/hotelReservation"
import { useBookingCodeFilterStore } from "@/stores/bookingCode-filter"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import { useHotelsMapStore } from "@/stores/hotels-map"
import { RoomCardSkeleton } from "@/components/HotelReservation/RoomCardSkeleton/RoomCardSkeleton"
import { CloseIcon, CloseLargeIcon } from "@/components/Icons"
import InteractiveMap from "@/components/Maps/InteractiveMap"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"
import { useScrollToTop } from "@/hooks/useScrollToTop"
import { debounce } from "@/utils/debounce"
import BookingCodeFilter from "../../BookingCodeFilter"
import FilterAndSortModal from "../../FilterAndSortModal"
import HotelListing from "../HotelListing"
import { getVisibleHotels } from "./utils"
import styles from "./selectHotelMapContent.module.css"
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { SelectHotelMapProps } from "@/types/components/hotelReservation/selectHotel/map"
const SKELETON_LOAD_DELAY = 750
export default function SelectHotelContent({
hotelPins,
cityCoordinates,
mapId,
hotels,
filterList,
bookingCode,
}: Omit<SelectHotelMapProps, "apiKey">) {
const lang = useLang()
const intl = useIntl()
const map = useMap()
const isAboveMobile = useMediaQuery("(min-width: 768px)")
const [visibleHotels, setVisibleHotels] = useState<HotelData[]>([])
const [showSkeleton, setShowSkeleton] = useState<boolean>(true)
const listingContainerRef = useRef<HTMLDivElement | null>(null)
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const { activeHotelCard, activeHotelPin } = useHotelsMapStore()
const { showBackToTop, scrollToTop } = useScrollToTop({
threshold: 490,
elementRef: listingContainerRef,
refScrollable: true,
})
const activeCodeFilter = useBookingCodeFilterStore(
(state) => state.activeCodeFilter
)
const coordinates = useMemo(
() =>
isAboveMobile
? cityCoordinates
: { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 },
[isAboveMobile, cityCoordinates]
)
useEffect(() => {
if (listingContainerRef.current) {
const activeElement =
listingContainerRef.current.querySelector(`[data-active="true"]`)
if (activeElement) {
activeElement.scrollIntoView({ behavior: "smooth", block: "nearest" })
}
}
}, [activeHotelCard, activeHotelPin])
const filteredHotelPins = useMemo(() => {
const updatedHotelsList = bookingCode
? hotelPins.filter(
(hotel) =>
!hotel.publicPrice ||
activeCodeFilter === "all" ||
(activeCodeFilter === "discounted" &&
hotel.rateType?.toLowerCase() !== "regular") ||
activeCodeFilter === hotel.rateType?.toLowerCase()
)
: hotelPins
return updatedHotelsList.filter((hotel) =>
activeFilters.every((filterId) =>
hotel.facilityIds.includes(Number(filterId))
)
)
}, [activeFilters, hotelPins, bookingCode, activeCodeFilter])
const getHotelCards = useCallback(() => {
const visibleHotels = getVisibleHotels(hotels, filteredHotelPins, map)
setVisibleHotels(visibleHotels)
setTimeout(() => {
setShowSkeleton(false)
}, SKELETON_LOAD_DELAY)
}, [hotels, filteredHotelPins, map])
/**
* Updates visible hotels when map viewport changes (zoom/pan)
* - Debounces updates to prevent excessive re-renders during map interaction
* - Shows loading skeleton while map tiles load
* - Triggers on: initial load, zoom, pan, and tile loading completion
*/
const debouncedUpdateHotelCards = useMemo(
() =>
debounce(() => {
if (!map) return
if (isAboveMobile) {
setShowSkeleton(true)
}
getHotelCards()
}, 100),
[map, getHotelCards, isAboveMobile]
)
const closeButton = (
<Button
intent="inverted"
size="small"
theme="base"
className={styles.closeButton}
asChild
>
<Link href={selectHotel(lang)} keepSearchParams prefetch>
<CloseIcon color="burgundy" />
{intl.formatMessage({ id: "Close the map" })}
</Link>
</Button>
)
return (
<div className={styles.container}>
<div className={styles.listingContainer} ref={listingContainerRef}>
<div className={styles.filterContainer}>
<Button
intent="text"
size="small"
variant="icon"
wrapping
className={styles.filterContainerCloseButton}
asChild
>
<Link href={selectHotel(lang)} keepSearchParams>
<CloseLargeIcon />
</Link>
</Button>
<FilterAndSortModal
filters={filterList}
setShowSkeleton={setShowSkeleton}
/>
{bookingCode ? <BookingCodeFilter /> : null}
</div>
{showSkeleton ? (
<div className={styles.skeletonContainer}>
<RoomCardSkeleton />
<RoomCardSkeleton />
</div>
) : (
<HotelListing hotels={visibleHotels} />
)}
{showBackToTop && (
<BackToTopButton position="left" onClick={scrollToTop} />
)}
</div>
<InteractiveMap
closeButton={closeButton}
coordinates={coordinates}
hotelPins={filteredHotelPins}
mapId={mapId}
onTilesLoaded={debouncedUpdateHotelCards}
/>
</div>
)
}

View File

@@ -0,0 +1,69 @@
.container .closeButton {
pointer-events: initial;
box-shadow: var(--button-box-shadow);
gap: var(--Spacing-x-half);
display: none;
}
.container {
height: 100%;
}
.filterContainer {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
background-color: var(--Base-Surface-Secondary-light-Normal);
padding: 0 var(--Spacing-x2);
height: 44px;
}
.container .listingContainer .filterContainer > button {
border: none;
}
.skeletonContainer {
display: none;
}
@media (min-width: 768px) {
.container .closeButton {
display: flex;
}
.container .listingContainer .filterContainer .filterContainerCloseButton {
display: none;
}
.listingContainer {
background-color: var(--Base-Surface-Secondary-light-Normal);
padding: var(--Spacing-x3) var(--Spacing-x4) var(--Spacing-x3)
var(--Layout-Tablet-Margin-Margin-min);
overflow-y: auto;
min-width: 420px;
width: 420px;
position: relative;
}
.container {
display: flex;
}
.filterContainer {
justify-content: flex-end;
padding: 0 0 var(--Spacing-x1);
position: static;
}
.skeletonContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
}
@media (min-width: 1367px) {
.listingContainer {
padding: var(--Spacing-x3) var(--Spacing-x4) var(--Spacing-x3)
var(--Layout-Desktop-Margin-Margin-min);
}
}

View File

@@ -0,0 +1,29 @@
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
export function getVisibleHotelPins(
map: google.maps.Map | null,
filteredHotelPins: HotelPin[]
) {
if (!map || !filteredHotelPins) return []
const bounds = map.getBounds()
if (!bounds) return []
return filteredHotelPins.filter((pin) => {
const { lat, lng } = pin.coordinates
return bounds.contains({ lat, lng })
})
}
export function getVisibleHotels(
hotels: HotelData[],
filteredHotelPins: HotelPin[],
map: google.maps.Map | null
) {
const visibleHotelPins = getVisibleHotelPins(map, filteredHotelPins)
const visibleHotels = hotels.filter((hotel) =>
visibleHotelPins.some((pin) => pin.operaId === hotel.hotelData.operaId)
)
return visibleHotels
}

View File

@@ -0,0 +1,30 @@
"use client"
import { APIProvider } from "@vis.gl/react-google-maps"
import SelectHotelMapContent from "./SelectHotelMapContent"
import type { SelectHotelMapProps } from "@/types/components/hotelReservation/selectHotel/map"
export default function SelectHotelMap({
apiKey,
hotelPins,
mapId,
hotels,
filterList,
cityCoordinates,
bookingCode,
}: SelectHotelMapProps) {
return (
<APIProvider apiKey={apiKey}>
<SelectHotelMapContent
hotelPins={hotelPins}
cityCoordinates={cityCoordinates}
mapId={mapId}
hotels={hotels}
filterList={filterList}
bookingCode={bookingCode}
/>
</APIProvider>
)
}

View File

@@ -0,0 +1,47 @@
import SkeletonShimmer from "@/components/SkeletonShimmer"
import { HotelCardSkeleton } from "../HotelCard/HotelCardSkeleton"
import styles from "./selectHotel.module.css"
type Props = {
count?: number
}
export function SelectHotelSkeleton({ count = 4 }: Props) {
return (
<div className={styles.skeletonContainer}>
<header className={styles.header}>
<div className={styles.headerContent}>
<div className={styles.breadcrumbs}>
<SkeletonShimmer height={"25px"} width={"300px"} />
</div>
<div className={styles.title}>
<div className={styles.cityInformation}>
<SkeletonShimmer height={"25px"} width={"200px"} />
</div>
<div className={styles.sorter}>
<SkeletonShimmer height={"60px"} width={"100%"} />
</div>
</div>
</div>
</header>
<main className={styles.main}>
<div className={styles.sideBar}>
<div className={styles.sideBarItem}>
<SkeletonShimmer height={"280px"} width={"340px"} />
</div>
<div className={styles.sideBarItem}>
<SkeletonShimmer height={"400px"} width={"340px"} />
</div>
</div>
<div className={styles.hotelList}>
{Array.from({ length: count }).map((_, index) => (
<HotelCardSkeleton key={index} />
))}
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,286 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { notFound } from "next/navigation"
import { Suspense } from "react"
import {
alternativeHotels,
alternativeHotelsMap,
selectHotel,
selectHotelMap,
} from "@/constants/routes/hotelReservation"
import {
fetchAlternativeHotels,
fetchAvailableHotels,
fetchBookingCodeAvailableHotels,
getFiltersFromHotels,
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
import { getHotelSearchDetails } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/utils"
import { ChevronRightIcon } from "@/components/Icons"
import StaticMap from "@/components/Maps/StaticMap"
import Breadcrumbs from "@/components/TempDesignSystem/Breadcrumbs"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n"
import { safeTry } from "@/utils/safeTry"
import { convertObjToSearchParams } from "@/utils/url"
import HotelCardListing from "../HotelCardListing"
import BookingCodeFilter from "./BookingCodeFilter"
import HotelCount from "./HotelCount"
import HotelFilter from "./HotelFilter"
import HotelSorter from "./HotelSorter"
import MobileMapButtonContainer from "./MobileMapButtonContainer"
import NoAvailabilityAlert from "./NoAvailabilityAlert"
import styles from "./selectHotel.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { SelectHotelProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import {
TrackingChannelEnum,
type TrackingSDKHotelInfo,
type TrackingSDKPageData,
} from "@/types/components/tracking"
export default async function SelectHotel({
params,
searchParams,
isAlternativeHotels,
}: SelectHotelProps) {
const intl = await getIntl()
const getHotelSearchDetailsPromise = safeTry(
getHotelSearchDetails(
{
searchParams: searchParams as SelectHotelSearchParams & {
[key: string]: string
},
},
isAlternativeHotels
)
)
const [searchDetails] = await getHotelSearchDetailsPromise
if (!searchDetails) return notFound()
const {
city,
selectHotelParams,
adultsInRoom,
childrenInRoomString,
childrenInRoom,
hotel: isAlternativeFor,
bookingCode,
} = searchDetails
if (!city) return notFound()
const hotelsPromise = isAlternativeFor
? safeTry(
fetchAlternativeHotels(isAlternativeFor.id, {
roomStayStartDate: selectHotelParams.fromDate,
roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom[0],
children: childrenInRoomString,
bookingCode,
})
)
: bookingCode
? safeTry(
fetchBookingCodeAvailableHotels({
cityId: city.id,
roomStayStartDate: selectHotelParams.fromDate,
roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom[0],
children: childrenInRoomString,
bookingCode,
})
)
: safeTry(
fetchAvailableHotels({
cityId: city.id,
roomStayStartDate: selectHotelParams.fromDate,
roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom[0],
children: childrenInRoomString,
})
)
const [hotels] = await hotelsPromise
const arrivalDate = new Date(selectHotelParams.fromDate)
const departureDate = new Date(selectHotelParams.toDate)
const isCityWithCountry = (city: any): city is { country: string } =>
"country" in city
const validHotels =
hotels?.filter((hotel): hotel is HotelData => hotel?.hotelData !== null) ||
[]
const filterList = getFiltersFromHotels(validHotels)
const convertedSearchParams = convertObjToSearchParams(selectHotelParams)
const breadcrumbs = [
{
title: intl.formatMessage({ id: "Home" }),
href: `/${params.lang}`,
uid: "home-page",
},
{
title: intl.formatMessage({ id: "Hotel reservation" }),
href: `/${params.lang}/hotelreservation`,
uid: "hotel-reservation",
},
isAlternativeFor
? {
title: intl.formatMessage({ id: "Alternative hotels" }),
href: `${alternativeHotels(params.lang)}/?${convertedSearchParams}`,
uid: "alternative-hotels",
}
: {
title: intl.formatMessage({ id: "Select hotel" }),
href: `${selectHotel(params.lang)}/?${convertedSearchParams}`,
uid: "select-hotel",
},
isAlternativeFor
? {
title: isAlternativeFor.name,
uid: isAlternativeFor.id,
}
: {
title: city.name,
uid: city.id,
},
]
const isAllUnavailable =
hotels?.every((hotel) => hotel.price === undefined) || false
const pageTrackingData: TrackingSDKPageData = {
pageId: isAlternativeFor ? "alternative-hotels" : "select-hotel",
domainLanguage: params.lang,
channel: TrackingChannelEnum["hotelreservation"],
pageName: isAlternativeFor
? "hotelreservation|alternative-hotels"
: "hotelreservation|select-hotel",
siteSections: isAlternativeFor
? "hotelreservation|alternative-hotels"
: "hotelreservation|select-hotel",
pageType: "bookinghotelspage",
siteVersion: "new-web",
}
const hotelsTrackingData: TrackingSDKHotelInfo = {
availableResults: validHotels.length,
searchTerm: isAlternativeFor
? selectHotelParams.hotelId
: (selectHotelParams.city as string),
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
departureDate: format(departureDate, "yyyy-MM-dd"),
noOfAdults: adultsInRoom[0], // TODO: Handle multiple rooms,
noOfChildren: childrenInRoom?.length,
ageOfChildren: childrenInRoom?.map((c) => c.age).join(","),
childBedPreference: childrenInRoom
?.map((c) => ChildBedMapEnum[c.bed])
.join("|"),
noOfRooms: 1, // // TODO: Handle multiple rooms
duration: differenceInCalendarDays(departureDate, arrivalDate),
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
searchType: "destination",
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
country: validHotels?.[0]?.hotelData.address.country,
region: validHotels?.[0]?.hotelData.address.city,
}
return (
<>
<header className={styles.header}>
<div className={styles.headerContent}>
<Breadcrumbs breadcrumbs={breadcrumbs} />
<div className={styles.title}>
<div className={styles.cityInformation}>
<Subtitle>
{isAlternativeFor
? `${intl.formatMessage({ id: "Alternatives for" })} ${isAlternativeFor.name}`
: city.name}
</Subtitle>
<HotelCount />
</div>
<div className={styles.sorter}>
<HotelSorter discreet />
</div>
</div>
<MobileMapButtonContainer filters={filterList} />
</div>
</header>
<main className={styles.main}>
{bookingCode ? <BookingCodeFilter /> : null}
<div className={styles.sideBar}>
{hotels && hotels.length > 0 ? ( // TODO: Temp fix until API returns hotels that are not available
<Link
className={styles.link}
color="burgundy"
href={
isAlternativeFor
? alternativeHotelsMap(params.lang)
: selectHotelMap(params.lang)
}
keepSearchParams
>
<div className={styles.mapContainer}>
<StaticMap
city={city.name}
country={isCityWithCountry(city) ? city.country : undefined}
width={340}
height={180}
zoomLevel={11}
mapType="roadmap"
altText={`Map of ${city.name} city center`}
/>
<Button wrapping size="medium" intent="text" theme="base">
{intl.formatMessage({ id: "See map" })}
<ChevronRightIcon
color="baseButtonTextOnFillNormal"
width={20}
height={20}
/>
</Button>
</div>
</Link>
) : (
<div className={styles.mapContainer}>
<StaticMap
city={city.name}
width={340}
height={180}
zoomLevel={11}
mapType="roadmap"
altText={`Map of ${city.name} city center`}
/>
</div>
)}
<HotelFilter filters={filterList} className={styles.filter} />
</div>
<div className={styles.hotelList}>
<NoAvailabilityAlert
isAlternative={!!isAlternativeFor}
hotels={validHotels}
isAllUnavailable={isAllUnavailable}
/>
<HotelCardListing hotelData={validHotels} />
</div>
</main>
<Suspense fallback={null}>
<TrackingSDK
pageData={pageTrackingData}
hotelInfo={hotelsTrackingData}
/>
</Suspense>
</>
)
}

View File

@@ -0,0 +1,144 @@
.main {
display: flex;
background-color: var(--Scandic-Brand-Warm-White);
min-height: 100dvh;
flex-direction: column;
max-width: var(--max-width-page);
margin: 0 auto;
}
.header {
padding: var(--Spacing-x3) 0 var(--Spacing-x2);
}
.headerContent {
max-width: var(--max-width-page);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.headerContent nav {
display: none;
}
.cityInformation {
display: flex;
flex-wrap: wrap;
gap: var(--Spacing-x1);
align-items: baseline;
}
.sorter {
display: none;
}
.sideBar {
display: flex;
flex-direction: column;
}
.sideBarItem {
display: none;
}
.link {
display: none;
}
.buttonContainer {
display: flex;
gap: var(--Spacing-x2);
margin-bottom: var(--Spacing-x3);
}
.button {
flex: 1;
}
.hotelList {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
}
.filter {
display: none;
}
.skeletonContainer .title {
margin-bottom: var(--Spacing-x3);
}
@media (min-width: 768px) {
.main {
padding: var(--Spacing-x5) 0;
flex-direction: row;
gap: var(--Spacing-x5);
flex-wrap: wrap;
}
.headerContent {
display: block;
}
.header {
background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Spacing-x4) 0 var(--Spacing-x3);
}
.headerContent nav {
display: block;
max-width: var(--max-width-navigation);
padding: 0;
}
.sorter {
display: block;
width: 339px;
}
.title {
margin: var(--Spacing-x3) auto 0;
display: flex;
max-width: var(--max-width-navigation);
align-items: center;
justify-content: space-between;
}
.sideBar {
max-width: 340px;
}
.sideBarItem {
display: block;
}
.filter {
display: block;
}
.link {
display: flex;
padding-bottom: var(--Spacing-x6);
}
.mapContainer {
display: flex;
flex-direction: column;
background: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium);
border: 1px solid var(--Base-Border-Subtle);
}
.buttonContainer {
display: none;
}
.skeletonContainer .title {
margin-bottom: 0;
}
.skeletonContainer .sideBar {
gap: var(--Spacing-x3);
}
.skeletonContainer .breadcrumbs {
margin: 0 auto;
max-width: var(--max-width-navigation);
}
}