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:
committed by
Linus Flood
parent
667cab6fb6
commit
80100e7631
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user