Merged in feat/sw-2873-move-selecthotel-to-booking-flow (pull request #2727)

feat(SW-2873): Move select-hotel to booking flow

* crude setup of select-hotel in partner-sas

* wip

* Fix linting

* restructure tracking files

* Remove dependency on trpc in tracking hooks

* Move pageview tracking to common

* Fix some lint and import issues

* Add AlternativeHotelsPage

* Add SelectHotelMapPage

* Add AlternativeHotelsMapPage

* remove next dependency in tracking store

* Remove dependency on react in tracking hooks

* move isSameBooking to booking-flow

* Inject searchParamsComparator into tracking store

* Move useTrackHardNavigation to common

* Move useTrackSoftNavigation to common

* Add TrackingSDK to partner-sas

* call serverclient in layout

* Remove unused css

* Update types

* Move HotelPin type

* Fix todos

* Merge branch 'master' into feat/sw-2873-move-selecthotel-to-booking-flow

* Merge branch 'master' into feat/sw-2873-move-selecthotel-to-booking-flow

* Fix component


Approved-by: Joakim Jäderberg
This commit is contained in:
Anton Gunnarsson
2025-09-01 08:37:00 +00:00
parent 93a90bef9d
commit 87402a2092
157 changed files with 2026 additions and 1376 deletions

View File

@@ -0,0 +1,216 @@
@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-md);
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-xl);
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 {
display: flex;
justify-content: flex-end;
text-align: right;
padding: var(--Spacing-x-one-and-half);
flex: 0 0 auto;
}
.title {
display: none;
text-align: center;
}
.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: 1fr auto;
padding: var(--Space-x1);
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-lg);
border-top-right-radius: var(--Corner-radius-lg);
}
.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-lg);
border-bottom-right-radius: var(--Corner-radius-lg);
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-md);
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,264 @@
"use client"
import {
usePathname,
useSearchParams,
} from "next/dist/client/components/navigation"
import { useCallback, useEffect, useState } from "react"
import {
Dialog as AriaDialog,
DialogTrigger,
Modal,
ModalOverlay,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import DeprecatedSelect from "@scandic-hotels/design-system/DeprecatedSelect"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import useInitializeFiltersFromUrl from "../../../../hooks/useInitializeFiltersFromUrl"
import { SortOrder } from "../../../../misc/sortOrder"
import { useHotelFilterStore } from "../../../../stores/hotel-filters"
import { DEFAULT_SORT } from "../../HotelSorter"
import FilterContent from "../FilterContent"
import styles from "./filterAndSortModal.module.css"
import type { CategorizedHotelFilters } from "../../../../types"
type SortItem = {
label: string
value: string
}
type FilterAndSortModalProps = {
filters: CategorizedHotelFilters
setShowSkeleton?: (showSkeleton: boolean) => void
}
export default function FilterAndSortModal({
filters,
setShowSkeleton,
}: FilterAndSortModalProps) {
const intl = useIntl()
useInitializeFiltersFromUrl()
const searchParams = useSearchParams()
const pathname = usePathname()
const { resultCount, setFilters, activeFilters, unfilteredResultCount } =
useHotelFilterStore((state) => ({
resultCount: state.resultCount,
setFilters: state.setFilters,
activeFilters: state.activeFilters,
unfilteredResultCount: state.unfilteredResultCount,
}))
const [sort, setSort] = useState(searchParams.get("sort") ?? DEFAULT_SORT)
const [selectedFilters, setSelectedFilters] =
useState<string[]>(activeFilters)
const [filteredCount, setFilteredCount] = useState(resultCount)
useEffect(() => {
if (activeFilters.length) {
setSelectedFilters(activeFilters)
}
}, [activeFilters])
const sortItems: SortItem[] = [
{
label: intl.formatMessage({
defaultMessage: "Distance to city center",
}),
value: SortOrder.Distance,
},
{
label: intl.formatMessage({
defaultMessage: "Name",
}),
value: SortOrder.Name,
},
{
label: intl.formatMessage({
defaultMessage: "Price",
}),
value: SortOrder.Price,
},
{
label: intl.formatMessage({
defaultMessage: "TripAdvisor rating",
}),
value: SortOrder.TripAdvisorRating,
},
]
const handleSortSelect = useCallback((value: string | number) => {
setSort(value.toString())
}, [])
const handleApplyFiltersAndSorting = useCallback(
(close: () => void) => {
setFilters(selectedFilters)
if (setShowSkeleton) {
setShowSkeleton(true)
}
const newSearchParams = new URLSearchParams(searchParams)
const values = selectedFilters.join(",")
if (values === "") {
newSearchParams.delete("filters")
} else {
newSearchParams.set("filters", values)
}
newSearchParams.set("sort", sort)
window.history.replaceState(
null,
"",
`${pathname}?${newSearchParams.toString()}`
)
close()
if (setShowSkeleton) {
setTimeout(() => {
setShowSkeleton(false)
}, 500)
}
},
[pathname, searchParams, sort, setShowSkeleton, selectedFilters, setFilters]
)
return (
<>
<DialogTrigger>
<Button variant="Secondary" size="Small" color="Primary">
<MaterialIcon icon="filter_alt" color="CurrentColor" />
<Typography variant="Body/Supporting text (caption)/smBold">
<p>
{intl.formatMessage({
defaultMessage: "Filter and sort",
})}
</p>
</Typography>
{activeFilters.length > 0 && (
<Typography variant="Label/xsRegular" className={styles.badge}>
<p>{activeFilters.length}</p>
</Typography>
)}
</Button>
<ModalOverlay className={styles.overlay} isDismissable>
<Modal className={styles.modal}>
<AriaDialog role="alertdialog" className={styles.content}>
{({ close }) => (
<>
<header className={styles.header}>
<Typography
variant="Title/Subtitle/md"
className={styles.title}
>
<p>
{intl.formatMessage({
defaultMessage: "Filter and sort",
})}
</p>
</Typography>
<IconButton
theme="Black"
style="Muted"
onPress={close}
aria-label={intl.formatMessage({
defaultMessage: "Close",
})}
>
<MaterialIcon icon="close" />
</IconButton>
</header>
<div className={styles.sorter}>
<DeprecatedSelect
items={sortItems}
defaultSelectedKey={
searchParams.get("sort") ?? DEFAULT_SORT
}
label={intl.formatMessage({
defaultMessage: "Sort by",
})}
name="sort"
showRadioButton
onSelect={handleSortSelect}
/>
</div>
<div className={styles.divider}>
<Divider />
</div>
<div className={styles.filters}>
<FilterContent
filters={filters}
activeFilters={selectedFilters}
onChange={(id) => {
const isSelected = selectedFilters.includes(id)
setSelectedFilters((prev) =>
isSelected
? prev.filter((s) => s !== id)
: [...prev, id]
)
}}
onFilteredCountChange={setFilteredCount}
/>
</div>
<footer className={styles.footer}>
<Button
variant="Tertiary"
color="Primary"
size="Large"
onClick={() => handleApplyFiltersAndSorting(close)}
>
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage(
{
defaultMessage: "See results ({ count })",
},
{
count: filteredCount
? filteredCount
: unfilteredResultCount,
}
)}
</p>
</Typography>
</Button>
<Button
onClick={() => {
setSelectedFilters([])
setFilteredCount(unfilteredResultCount)
}}
variant="Text"
color="Primary"
size="Medium"
>
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Clear all filters",
})}
</p>
</Typography>
</Button>
</footer>
</>
)}
</AriaDialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
</>
)
}

View File

@@ -0,0 +1,44 @@
.container {
display: flex;
flex-direction: column;
color: var(--text-color);
cursor: pointer;
}
.container[data-selected] .checkbox {
border: var(--Surface-UI-Fill-Active);
background: var(--Surface-UI-Fill-Active);
}
.container:focus-within .checkbox {
outline: 2px solid 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);
}
.container[data-disabled] {
color: var(--Text-Interactive-Disabled);
cursor: not-allowed;
}
.container[data-disabled] .checkbox {
border-color: var(--Text-Interactive-Disabled);
background: var(--Surface-Primary-Disabled);
}

View File

@@ -0,0 +1,48 @@
"use client"
import { Checkbox as AriaCheckbox } from "react-aria-components"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./filterCheckbox.module.css"
type FilterCheckboxProps = {
name: string
id: string
isDisabled?: boolean
isSelected: boolean
onChange: (filterId: string) => void
}
export default function FilterCheckbox({
isSelected,
name,
id,
isDisabled,
onChange,
}: FilterCheckboxProps) {
return (
<AriaCheckbox
className={styles.container}
isSelected={isSelected}
onChange={() => onChange(id)}
isDisabled={isDisabled}
>
{({ isSelected }) => (
<>
<span className={styles.checkboxContainer}>
<span className={styles.checkbox}>
{isSelected && (
<MaterialIcon icon="check" color="Icon/Inverted" />
)}
</span>
<Typography variant="Body/Paragraph/mdRegular">
<span>{name}</span>
</Typography>
</span>
</>
)}
</AriaCheckbox>
)
}

View File

@@ -0,0 +1,42 @@
.container {
min-width: 272px;
}
.container > div {
display: flex;
flex-direction: column;
gap: var(--Space-x3);
}
.facilities {
padding-bottom: var(--Space-x3);
}
.facilities:first-of-type {
border-bottom: 1px solid var(--Base-Border-Subtle);
}
.facilities ul {
margin-top: var(--Space-x2);
}
.facilities:last-child {
padding-bottom: 0;
}
.filter {
display: grid;
grid-template-columns: repeat(2, minmax(min-content, max-content));
gap: var(--Space-x15);
margin-bottom: var(--Space-x1);
align-items: center;
}
.filter:first-child {
margin-top: var(--Space-x1);
}
.filter input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
margin: 0;
}

View File

@@ -0,0 +1,126 @@
import { useEffect, useState } from "react"
import { useIntl } from "react-intl"
import Title from "@scandic-hotels/design-system/Title"
import { Typography } from "@scandic-hotels/design-system/Typography"
import FilterCheckbox from "./FilterCheckbox"
import styles from "./filterContent.module.css"
import type { CategorizedHotelFilters, HotelFilter } from "../../../../types"
interface FilterContentProps {
filters: CategorizedHotelFilters
activeFilters: string[]
onChange: (id: string) => void
onFilteredCountChange?: (count: number) => void
className?: string
}
export default function FilterContent({
filters,
activeFilters,
onChange,
className,
onFilteredCountChange = () => undefined,
}: FilterContentProps) {
const intl = useIntl()
const [filteredHotelIds, setFilteredHotelIds] = useState<string[]>([])
useEffect(() => {
if (activeFilters.length) {
const allFilters = [
...filters.facilityFilters,
...filters.surroundingsFilters,
]
setFilteredHotelIds(
allFilters
.filter((f) => activeFilters.includes(f.id.toString()))
.map((f) => f.hotelIds)
.reduce((accumulatedHotelIds, currentHotelIds) =>
accumulatedHotelIds.filter((hotelId) =>
currentHotelIds.includes(hotelId)
)
)
)
} else {
setFilteredHotelIds([])
}
}, [filters, activeFilters, setFilteredHotelIds])
useEffect(() => {
onFilteredCountChange(filteredHotelIds.length)
}, [filteredHotelIds, onFilteredCountChange])
if (!filters.facilityFilters.length && !filters.surroundingsFilters.length) {
return null
}
function filterOutput(filters: HotelFilter[]) {
return filters.map((filter) => {
const isDisabled = filteredHotelIds.length
? !filter.hotelIds.some((hotelId) => filteredHotelIds.includes(hotelId))
: false
const combinedFiltersCount = filteredHotelIds.filter((id) =>
filter.hotelIds.includes(id)
).length
const filterCount = filter.hotelIds.length
return (
<li key={filter.id} className={styles.filter}>
<FilterCheckbox
name={filter.name}
id={filter.id.toString()}
onChange={onChange}
isSelected={activeFilters.some((f) => f === filter.id.toString())}
isDisabled={isDisabled}
/>
{!isDisabled && (
<span>{`(${combinedFiltersCount > 0 ? combinedFiltersCount : filterCount})`}</span>
)}
</li>
)
})
}
return (
<aside className={`${styles.container} ${className}`}>
<div>
<Title as="h4">
{intl.formatMessage({
defaultMessage: "Filter by",
})}
</Title>
<div className={styles.facilities}>
<Typography variant="Title/Subtitle/md">
<p>
{intl.formatMessage({
defaultMessage: "Hotel facilities",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<ul>{filterOutput(filters.facilityFilters)}</ul>
</Typography>
</div>
<div className={styles.facilities}>
<Typography variant="Title/Subtitle/md">
<p>
{intl.formatMessage({
defaultMessage: "Hotel surroundings",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<ul>{filterOutput(filters.surroundingsFilters)}</ul>
</Typography>
</div>
</div>
</aside>
)
}

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,91 @@
"use client"
import { usePathname, useSearchParams } from "next/navigation"
import { useCallback, useEffect } from "react"
import useInitializeFiltersFromUrl from "../../../../hooks/useInitializeFiltersFromUrl"
import { useHotelFilterStore } from "../../../../stores/hotel-filters"
import { useTrackingContext } from "../../../../trackingContext"
import FilterContent from "../FilterContent"
import type { CategorizedHotelFilters } from "../../../../types"
type HotelFiltersProps = {
filters: CategorizedHotelFilters
className?: string
}
export default function HotelFilter({ className, filters }: HotelFiltersProps) {
const tracking = useTrackingContext()
const searchParams = useSearchParams()
const pathname = usePathname()
useInitializeFiltersFromUrl()
const { toggleFilter, activeFilters } = useHotelFilterStore((state) => ({
toggleFilter: state.toggleFilter,
activeFilters: state.activeFilters,
}))
const trackFiltersEvent = useCallback(() => {
const facilityMap = new Map(
filters.facilityFilters.map((f) => [f.id.toString(), f.name])
)
const surroundingsMap = new Map(
filters.surroundingsFilters.map((f) => [f.id.toString(), f.name])
)
const hotelFacilitiesFilter = activeFilters
.filter((id) => facilityMap.has(id))
.map((id) => facilityMap.get(id))
.join(",")
const hotelSurroundingsFilter = activeFilters
.filter((id) => surroundingsMap.has(id))
.map((id) => surroundingsMap.get(id))
.join(",")
tracking.trackGenericEvent({
event: "filterUsed",
filter: {
filtersUsed: `Filters values - hotelfacilities:${hotelFacilitiesFilter}|hotelsurroundings:${hotelSurroundingsFilter}`,
},
})
}, [
tracking,
activeFilters,
filters.facilityFilters,
filters.surroundingsFilters,
])
// 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.get("filters")) {
if (values) {
trackFiltersEvent()
}
window.history.replaceState(
null,
"",
`${pathname}?${newSearchParams.toString()}`
)
}
}, [activeFilters, pathname, searchParams, trackFiltersEvent])
return (
<FilterContent
className={className}
filters={filters}
activeFilters={activeFilters}
onChange={toggleFilter}
/>
)
}

View File

@@ -0,0 +1,2 @@
export { default as FilterAndSortModal } from "./FilterAndSortModal"
export { default as HotelFilter } from "./HotelFilter"

View File

@@ -0,0 +1,25 @@
"use client"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useHotelFilterStore } from "../../../stores/hotel-filters"
export default function HotelCount() {
const intl = useIntl()
const resultCount = useHotelFilterStore((state) => state.resultCount)
return (
<Typography variant="Title/Subtitle/md">
<span>
{intl.formatMessage(
{
defaultMessage: "{amount, plural, one {# hotel} other {# hotels}}",
},
{ amount: resultCount }
)}
</span>
</Typography>
)
}

View File

@@ -0,0 +1,101 @@
"use client"
import { usePathname, useSearchParams } from "next/navigation"
import { useCallback } from "react"
import { useIntl } from "react-intl"
import DeprecatedSelect from "@scandic-hotels/design-system/DeprecatedSelect"
import { useTrackingContext } from "../../../trackingContext"
const enum SortOrder {
Distance = "distance",
Name = "name",
Price = "price",
TripAdvisorRating = "tripadvisor",
}
type SortItem = {
label: string
value: string
}
export const DEFAULT_SORT = SortOrder.Distance
type HotelSorterProps = {
discreet?: boolean
}
export default function HotelSorter({ discreet }: HotelSorterProps) {
const tracking = useTrackingContext()
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)
tracking.trackGenericEvent({
event: "sortOptionClick",
filter: {
sortOptions: newSort,
},
})
window.history.replaceState(
null,
"",
`${pathname}?${newSearchParams.toString()}`
)
},
[tracking, pathname, searchParams]
)
const sortItems: SortItem[] = [
{
label: intl.formatMessage({
defaultMessage: "Distance to city center",
}),
value: SortOrder.Distance,
},
{
label: intl.formatMessage({
defaultMessage: "Name",
}),
value: SortOrder.Name,
},
{
label: intl.formatMessage({
defaultMessage: "Price",
}),
value: SortOrder.Price,
},
{
label: intl.formatMessage({
defaultMessage: "TripAdvisor rating",
}),
value: SortOrder.TripAdvisorRating,
},
]
return (
<DeprecatedSelect
items={sortItems}
defaultSelectedKey={searchParams.get("sort") ?? DEFAULT_SORT}
label={intl.formatMessage({
defaultMessage: "Sort by",
})}
aria-label={intl.formatMessage({
defaultMessage: "Sort by",
})}
name="sort"
showRadioButton
discreet={discreet}
onSelect={onSelect}
/>
)
}

View File

@@ -0,0 +1,28 @@
"use client"
import { useIntl } from "react-intl"
import { FakeButton } from "@scandic-hotels/design-system/FakeButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import styles from "./mapWithButtonWrapper.module.css"
export function MapWithButtonWrapper({ children }: React.PropsWithChildren) {
const intl = useIntl()
return (
<div className={styles.container}>
{children}
<FakeButton
variant="Primary"
color="Inverted"
size="Small"
typography="Body/Supporting text (caption)/smBold"
className={styles.button}
>
<MaterialIcon icon="map" color="CurrentColor" size={20} />
{intl.formatMessage({
defaultMessage: "See on map",
})}
</FakeButton>
</div>
)
}

View File

@@ -0,0 +1,16 @@
.container {
display: flex;
position: relative;
border-radius: var(--Corner-radius-md);
overflow: hidden;
flex-direction: column;
align-items: center;
}
.button {
position: absolute;
bottom: var(--Space-x2);
right: var(--Space-x2);
box-shadow: 0px 0px 8px 1px rgba(0, 0, 0, 0.1);
}

View File

@@ -0,0 +1,55 @@
"use client"
import { useIntl } from "react-intl"
import {
alternativeHotelsMap,
selectHotelMap,
} from "@scandic-hotels/common/constants/routes/hotelReservation"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Link from "@scandic-hotels/design-system/Link"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import useLang from "../../../hooks/useLang"
import FilterAndSortModal from "../Filters/FilterAndSortModal"
import styles from "./mobileMapButtonContainer.module.css"
import type { CategorizedHotelFilters } from "../../../types"
export default function MobileMapButtonContainer({
filters,
isAlternative,
}: {
filters: CategorizedHotelFilters
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)
}
keepSearchParams
weight="bold"
>
<MaterialIcon icon="map" color="CurrentColor" />
{intl.formatMessage({
defaultMessage: "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,89 @@
"use client"
import { useIntl } from "react-intl"
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
import { alternativeHotels } from "@scandic-hotels/common/constants/routes/hotelReservation"
import { Alert } from "@scandic-hotels/design-system/Alert"
import useLang from "../../hooks/useLang"
import type { Hotel } from "@scandic-hotels/trpc/types/hotel"
type NoAvailabilityAlertProps = {
hotelsLength: number
bookingCode?: string
isAllUnavailable: boolean
isAlternative?: boolean
isBookingCodeRateNotAvailable?: boolean
operaId: Hotel["operaId"]
}
export default function NoAvailabilityAlert({
hotelsLength,
bookingCode,
isAllUnavailable,
isAlternative,
isBookingCodeRateNotAvailable,
operaId,
}: NoAvailabilityAlertProps) {
const intl = useIntl()
const lang = useLang()
if (bookingCode && isBookingCodeRateNotAvailable && hotelsLength > 0) {
const bookingCodeText = intl.formatMessage(
{
defaultMessage:
"We found no available rooms using this booking code ({bookingCode}). See available rates below.",
},
{ bookingCode }
)
return (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({
defaultMessage: "No availability",
})}
text={bookingCodeText}
/>
)
}
if (!isAllUnavailable) {
return null
}
if (hotelsLength === 1 && !isAlternative && operaId) {
return (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({
defaultMessage: "No availability",
})}
text={intl.formatMessage({
defaultMessage:
"Please try and change your search for this destination or see alternative hotels.",
})}
link={{
title: intl.formatMessage({
defaultMessage: "See alternative hotels",
}),
url: `${alternativeHotels(lang)}?hotel=${operaId}`,
keepSearchParams: true,
}}
/>
)
}
return (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({
defaultMessage: "No availability",
})}
text={intl.formatMessage({
defaultMessage: "There are no rooms available that match your request.",
})}
/>
)
}

View File

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

View File

@@ -0,0 +1,43 @@
"use client"
import { useMediaQuery } from "usehooks-ts"
import { useHotelsMapStore } from "../../../../stores/hotels-map"
import HotelCardDialogListing from "../../../HotelCardDialogListing"
import HotelCardListing, {
HotelCardListingTypeEnum,
} from "../../../HotelCardListing"
import styles from "./hotelListing.module.css"
import type { HotelResponse } from "../../helpers"
interface HotelListingProps {
hotels: HotelResponse[]
unfilteredHotelCount: number
}
export default function HotelListing({
hotels,
unfilteredHotelCount,
}: HotelListingProps) {
const { activeHotel } = useHotelsMapStore()
const isMobile = useMediaQuery("(max-width: 899px)")
return isMobile ? (
<div className={styles.hotelListingMobile} data-open={!!activeHotel}>
<HotelCardDialogListing
hotels={hotels}
unfilteredHotelCount={unfilteredHotelCount}
/>
</div>
) : (
<div className={styles.hotelListing}>
<HotelCardListing
hotelData={hotels}
type={HotelCardListingTypeEnum.MapListing}
unfilteredHotelCount={unfilteredHotelCount}
/>
</div>
)
}

View File

@@ -0,0 +1,314 @@
"use client"
import { useMap } from "@vis.gl/react-google-maps"
import { useCallback, useMemo, useRef, useState } from "react"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import {
alternativeHotels,
selectHotel,
} from "@scandic-hotels/common/constants/routes/hotelReservation"
import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop"
import { debounce } from "@scandic-hotels/common/utils/debounce"
import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Link from "@scandic-hotels/design-system/Link"
import { InteractiveMap } from "@scandic-hotels/design-system/Map/InteractiveMap"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useIsLoggedIn } from "../../../../hooks/useIsLoggedIn"
import useLang from "../../../../hooks/useLang"
import { mapApiImagesToGalleryImages } from "../../../../misc/imageGallery"
import {
BookingCodeFilterEnum,
useBookingCodeFilterStore,
} from "../../../../stores/bookingCode-filter"
import { useHotelFilterStore } from "../../../../stores/hotel-filters"
import { useHotelsMapStore } from "../../../../stores/hotels-map"
import { useTrackingContext } from "../../../../trackingContext"
import BookingCodeFilter from "../../../BookingCodeFilter"
import { getHotelPins } from "../../../HotelCardDialogListing/utils"
import { RoomCardSkeleton } from "../../../RoomCardSkeleton/RoomCardSkeleton"
import FilterAndSortModal from "../../Filters/FilterAndSortModal"
import { type HotelResponse } from "../../helpers"
import HotelListing from "../HotelListing"
import { getVisibleHotels } from "./utils"
import styles from "./selectHotelMapContent.module.css"
import type { CategorizedHotelFilters } from "../../../../types"
const SKELETON_LOAD_DELAY = 750
interface SelectHotelMapContentProps {
mapId: string
hotels: HotelResponse[]
cityCoordinates: {
lat: number
lng: number
}
bookingCode: string | undefined
isBookingCodeRateAvailable?: boolean
isAlternativeHotels?: boolean
filterList: CategorizedHotelFilters
}
export function SelectHotelMapContent({
cityCoordinates,
mapId,
hotels,
bookingCode,
isBookingCodeRateAvailable,
isAlternativeHotels,
filterList,
}: SelectHotelMapContentProps) {
const lang = useLang()
const intl = useIntl()
const map = useMap()
const isUserLoggedIn = useIsLoggedIn()
const tracking = useTrackingContext()
const isAboveMobile = useMediaQuery("(min-width: 900px)")
const [visibleHotels, setVisibleHotels] = useState<HotelResponse[]>([])
const [showSkeleton, setShowSkeleton] = useState<boolean>(true)
const listingContainerRef = useRef<HTMLDivElement | null>(null)
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const hotelMapStore = useHotelsMapStore()
const { showBackToTop, scrollToTop } = useScrollToTop({
threshold: 490,
elementRef: listingContainerRef,
refScrollable: true,
})
const activeCodeFilter = useBookingCodeFilterStore(
(state) => state.activeCodeFilter
)
const hotelPins = getHotelPins(hotels)
const coordinates = useMemo(() => {
if (hotelMapStore.activeHotel) {
const hotel = hotels.find(
(hotel) => hotel.hotel.name === hotelMapStore.activeHotel
)
if (hotel && hotel.hotel.location) {
return isAboveMobile
? {
lat: hotel.hotel.location.latitude,
lng: hotel.hotel.location.longitude,
}
: {
lat: hotel.hotel.location.latitude - 0.003,
lng: hotel.hotel.location.longitude,
}
}
}
return isAboveMobile
? cityCoordinates
: { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 }
}, [hotelMapStore.activeHotel, hotels, isAboveMobile, cityCoordinates])
const showOnlyBookingCodeRates =
bookingCode &&
isBookingCodeRateAvailable &&
activeCodeFilter === BookingCodeFilterEnum.Discounted
const filteredHotelPins = useMemo(() => {
const updatedHotelsList = showOnlyBookingCodeRates
? hotelPins.filter((hotel) => hotel.bookingCode)
: hotelPins
return updatedHotelsList.filter((hotel) =>
activeFilters.every((filterId) =>
hotel.facilityIds.includes(Number(filterId))
)
)
}, [activeFilters, hotelPins, showOnlyBookingCodeRates])
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 closeMapUrl = isAlternativeHotels
? alternativeHotels(lang)
: selectHotel(lang)
const closeButton = (
<Button
variant="Primary"
color="Inverted"
wrapping
size="Small"
className={styles.closeButton}
>
<Link
href={closeMapUrl}
keepSearchParams
prefetch
className={styles.link}
>
<MaterialIcon icon="close" size={20} color="CurrentColor" />
<Typography variant="Body/Supporting text (caption)/smBold">
<p>
{intl.formatMessage({
defaultMessage: "Close the map",
})}
</p>
</Typography>
</Link>
</Button>
)
const isSpecialRate = bookingCode
? hotels.some(
(hotel) =>
hotel.availability.productType?.bonusCheque ||
hotel.availability.productType?.voucher
)
: false
const showBookingCodeFilter =
bookingCode && isBookingCodeRateAvailable && !isSpecialRate
const unfilteredHotelCount = hotelPins.length
return (
<div className={styles.container}>
<div className={styles.listingContainer} ref={listingContainerRef}>
<div className={styles.filterContainer}>
<Button
variant="Text"
type="button"
size="Small"
className={styles.filterContainerCloseButton}
>
<Link href={closeMapUrl} keepSearchParams className={styles.link}>
<MaterialIcon
icon="arrow_back_ios"
color="CurrentColor"
size={20}
/>
<Typography variant="Body/Supporting text (caption)/smBold">
<p>{intl.formatMessage({ defaultMessage: "Back" })}</p>
</Typography>
</Link>
</Button>
<FilterAndSortModal
filters={filterList}
setShowSkeleton={setShowSkeleton}
/>
{showBookingCodeFilter ? (
<div className={styles.bookingCodeFilter}>
<BookingCodeFilter />
</div>
) : null}
</div>
{showSkeleton ? (
<div className={styles.skeletonContainer}>
<RoomCardSkeleton />
<RoomCardSkeleton />
</div>
) : (
<HotelListing
hotels={visibleHotels}
unfilteredHotelCount={unfilteredHotelCount}
/>
)}
{showBackToTop && (
<BackToTopButton
position="left"
onClick={scrollToTop}
label={intl.formatMessage({
defaultMessage: "Back to top",
})}
/>
)}
</div>
<InteractiveMap
closeButton={closeButton}
coordinates={coordinates}
hotelPins={filteredHotelPins.map((pin) => {
const galleryImage = mapApiImagesToGalleryImages(pin.images).at(0)
return {
...pin,
ratings: {
tripAdvisor: pin.ratings ?? null,
},
image: {
alt: galleryImage?.alt ?? "",
url: galleryImage?.src ?? "",
},
}
})}
mapId={mapId}
onTilesLoaded={debouncedUpdateHotelCards}
fitBounds={isAboveMobile || !hotelMapStore.activeHotel}
onHoverHotelPin={(args) => {
if (!args) {
hotelMapStore.disengageAfterDelay()
return
}
hotelMapStore.engage(args.hotelName)
}}
hoveredHotelPin={hotelMapStore.hoveredHotel}
onSetActiveHotelPin={(args) => {
if (!args || args.hotelName === hotelMapStore.activeHotel) {
hotelMapStore.deactivate()
return
}
tracking.trackGenericEvent({
event: "hotelClickMap",
map: {
action: "hotel click - map",
},
hotelInfo: {
hotelId: args.hotelId,
},
})
hotelMapStore.activate(args.hotelName)
}}
onClickHotel={(hotelId) => {
tracking.trackGenericEvent({
event: "hotelClickMap",
map: {
action: "hotel click - map",
},
hotelInfo: {
hotelId,
},
})
}}
lang={lang}
isUserLoggedIn={isUserLoggedIn}
/>
</div>
)
}

View File

@@ -0,0 +1,80 @@
.container .closeButton {
pointer-events: initial;
box-shadow: var(--button-box-shadow);
gap: var(--Space-x05);
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: var(--Space-x025) var(--Space-x2);
min-height: 44px;
}
.container .listingContainer .filterContainer > button {
border: none;
text-decoration: none;
}
.skeletonContainer {
display: none;
}
.link {
display: flex;
gap: var(--Space-x05);
align-items: baseline;
}
.bookingCodeFilter {
width: auto;
}
@media (min-width: 900px) {
.container .closeButton {
display: flex;
}
.container .listingContainer .filterContainer .filterContainerCloseButton {
display: none;
}
.listingContainer {
background-color: var(--Base-Surface-Secondary-light-Normal);
padding: var(--Space-x3) var(--Space-x4) var(--Space-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(--Space-x1);
position: static;
}
.skeletonContainer {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
}
@media (min-width: 1367px) {
.listingContainer {
padding: var(--Space-x3) var(--Space-x4) var(--Space-x3)
var(--Layout-Desktop-Margin-Margin-min);
}
}

View File

@@ -0,0 +1,29 @@
import type { HotelPin } from "../../../HotelCardDialogListing/utils"
import type { HotelResponse } from "../../helpers"
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: HotelResponse[],
filteredHotelPins: HotelPin[],
map: google.maps.Map | null
) {
const visibleHotelPins = getVisibleHotelPins(map, filteredHotelPins)
const visibleHotels = hotels.filter((hotel) =>
visibleHotelPins.some((pin) => pin.operaId === hotel.hotel.operaId)
)
return visibleHotels
}

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: 900px) {
.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,28 @@
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { RoomCardSkeleton } from "../../RoomCardSkeleton/RoomCardSkeleton"
import styles from "./SelectHotelMapSkeleton.module.css"
type Props = {
count?: number
}
export function SelectHotelMapSkeleton({ 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,50 @@
"use client"
import { APIProvider } from "@vis.gl/react-google-maps"
import { type HotelResponse } from "../helpers"
import { SelectHotelMapContent } from "./SelectHotelMapContent"
import type { CategorizedHotelFilters } from "../../../types"
export { SelectHotelMapSkeleton } from "./SelectHotelMapSkeleton"
interface Coordinates {
lat: number
lng: number
}
interface SelectHotelMapProps {
apiKey: string
mapId: string
hotels: HotelResponse[]
cityCoordinates: Coordinates
bookingCode: string | undefined
isBookingCodeRateAvailable?: boolean
isAlternativeHotels?: boolean
filterList: CategorizedHotelFilters
}
export function SelectHotelMap({
apiKey,
mapId,
hotels,
cityCoordinates,
bookingCode,
isBookingCodeRateAvailable,
isAlternativeHotels,
filterList,
}: SelectHotelMapProps) {
return (
<APIProvider apiKey={apiKey}>
<SelectHotelMapContent
cityCoordinates={cityCoordinates}
mapId={mapId}
hotels={hotels}
bookingCode={bookingCode}
isBookingCodeRateAvailable={isBookingCodeRateAvailable}
isAlternativeHotels={isAlternativeHotels}
filterList={filterList}
/>
</APIProvider>
)
}

View File

@@ -0,0 +1,42 @@
import { HotelCardSkeleton } from "@scandic-hotels/design-system/HotelCard/HotelCardSkeleton"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
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.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,340 @@
import { dt } from "@scandic-hotels/common/dt"
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
import { generateChildrenString } from "@scandic-hotels/trpc/routers/hotels/helpers"
import { serverClient } from "../../trpc"
import { getHotel } from "../../trpc/memoizedRequests"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { HotelsAvailabilityItem } from "@scandic-hotels/trpc/types/availability"
import type { Child } from "@scandic-hotels/trpc/types/child"
import type {
AdditionalData,
Hotel,
Restaurant,
} from "@scandic-hotels/trpc/types/hotel"
import type {
HotelLocation,
Location,
} from "@scandic-hotels/trpc/types/locations"
import type { CategorizedHotelFilters, HotelFilter } from "../../types"
type AvailabilityInput = {
cityId: string
roomStayStartDate: string
roomStayEndDate: string
adults: number
children?: string
bookingCode?: string
redemption?: boolean
}
type AlternativeHotelsAvailabilityInput = {
roomStayStartDate: string
roomStayEndDate: string
adults: number
children?: string
bookingCode?: string
redemption?: boolean
}
interface AvailabilityResponse {
availability: HotelsAvailabilityItem[]
}
export interface HotelResponse {
availability: HotelsAvailabilityItem
hotel: Hotel
additionalData: AdditionalData
url: string | null
restaurants: Restaurant[]
}
type Result = AvailabilityResponse | null
type SettledResult = PromiseSettledResult<Result>[]
async function enhanceHotels(hotels: HotelsAvailabilityItem[], language: Lang) {
return await Promise.allSettled(
hotels.map(async (availability) => {
const hotelData = await getHotel({
hotelId: availability.hotelId.toString(),
isCardOnlyPayment: false,
language,
})
if (!hotelData) {
return null
}
return {
availability,
hotel: hotelData.hotel,
additionalData: hotelData.additionalData,
url: hotelData.url,
restaurants: hotelData.restaurants,
}
})
)
}
async function fetchAlternativeHotels(
hotelId: string,
input: AlternativeHotelsAvailabilityInput
) {
const caller = await serverClient()
const alternativeHotelIds = await caller.hotel.nearbyHotelIds({
hotelId,
})
if (!alternativeHotelIds) {
return null
}
return await caller.hotel.availability.hotelsByHotelIds({
...input,
hotelIds: alternativeHotelIds,
})
}
async function fetchAvailableHotels(input: AvailabilityInput) {
const caller = await serverClient()
return await caller.hotel.availability.hotelsByCity(input)
}
async function fetchBookingCodeAvailableHotels(input: AvailabilityInput) {
const caller = await serverClient()
return await caller.hotel.availability.hotelsByCityWithBookingCode(input)
}
function getFulfilledResponses<T>(result: PromiseSettledResult<T | null>[]) {
const fulfilledResponses: NonNullable<T>[] = []
for (const res of result) {
if (res.status === "fulfilled" && res.value) {
fulfilledResponses.push(res.value)
}
}
return fulfilledResponses
}
function getHotelAvailabilityItems(hotels: AvailabilityResponse[]) {
return hotels.map((hotel) => hotel.availability)
}
// Filter out hotels that are unavailable for
// at least one room.
function sortAndFilterHotelsByAvailability(
fulfilledHotels: HotelsAvailabilityItem[][]
) {
const availableHotels = new Map<
HotelsAvailabilityItem["hotelId"],
HotelsAvailabilityItem
>()
const unavailableHotels = new Map<
HotelsAvailabilityItem["hotelId"],
HotelsAvailabilityItem
>()
const unavailableHotelIds = new Set<HotelsAvailabilityItem["hotelId"]>()
for (const availabilityHotels of fulfilledHotels) {
for (const hotel of availabilityHotels) {
if (hotel.status === AvailabilityEnum.Available) {
if (availableHotels.has(hotel.hotelId)) {
const currentAddedHotel = availableHotels.get(hotel.hotelId)
// Make sure the cheapest version of the room is the one
// we keep so that it matches the cheapest room on select-rate
if (
(hotel.productType?.public &&
currentAddedHotel?.productType?.public &&
hotel.productType.public.localPrice.pricePerNight <
currentAddedHotel.productType.public.localPrice
.pricePerNight) ||
(hotel.productType?.member &&
currentAddedHotel?.productType?.member &&
hotel.productType.member.localPrice.pricePerNight <
currentAddedHotel.productType.member.localPrice.pricePerNight)
) {
availableHotels.set(hotel.hotelId, hotel)
}
} else {
availableHotels.set(hotel.hotelId, hotel)
}
} else {
unavailableHotels.set(hotel.hotelId, hotel)
unavailableHotelIds.add(hotel.hotelId)
}
}
}
for (const [hotelId] of unavailableHotelIds.entries()) {
if (availableHotels.has(hotelId)) {
availableHotels.delete(hotelId)
}
}
return [
Array.from(availableHotels.values()),
Array.from(unavailableHotels.values()),
].flat()
}
type GetHotelsInput = {
fromDate: string
toDate: string
rooms: {
adults: number
childrenInRoom?: Child[]
}[]
isAlternativeFor: HotelLocation | null
bookingCode: string | undefined
city: Location
redemption: boolean
lang: Lang
}
export async function getHotels({
rooms,
fromDate,
toDate,
isAlternativeFor,
bookingCode,
city,
redemption,
lang,
}: GetHotelsInput) {
let availableHotelsResponse: SettledResult = []
// Return empty array (forced No availability) when search dates are invalid
if (
dt(fromDate).isBefore(dt(), "day") ||
dt(toDate).isSameOrBefore(fromDate, "day")
) {
return []
}
if (isAlternativeFor) {
availableHotelsResponse = await Promise.allSettled(
rooms.map(async (room) => {
return fetchAlternativeHotels(isAlternativeFor.id, {
adults: room.adults,
bookingCode,
children: room.childrenInRoom
? generateChildrenString(room.childrenInRoom)
: undefined,
redemption,
roomStayEndDate: toDate,
roomStayStartDate: fromDate,
})
})
)
} else if (bookingCode) {
availableHotelsResponse = await Promise.allSettled(
rooms.map(async (room) => {
return fetchBookingCodeAvailableHotels({
adults: room.adults,
bookingCode,
children: room.childrenInRoom
? generateChildrenString(room.childrenInRoom)
: undefined,
cityId: city.id,
roomStayStartDate: fromDate,
roomStayEndDate: toDate,
})
})
)
} else {
availableHotelsResponse = await Promise.allSettled(
rooms.map(
async (room) =>
await fetchAvailableHotels({
adults: room.adults,
children: room.childrenInRoom
? generateChildrenString(room.childrenInRoom)
: undefined,
cityId: city.id,
redemption,
roomStayEndDate: toDate,
roomStayStartDate: fromDate,
})
)
)
}
const fulfilledAvailabilities = getFulfilledResponses<AvailabilityResponse>(
availableHotelsResponse
)
const availablilityItems = getHotelAvailabilityItems(fulfilledAvailabilities)
const availableHotels = sortAndFilterHotelsByAvailability(availablilityItems)
if (!availableHotels.length) {
return []
}
const hotelsResponse = await enhanceHotels(availableHotels, lang)
const hotels = getFulfilledResponses<HotelResponse>(hotelsResponse)
return hotels
}
const hotelSurroundingsFilterNames = [
"Hotel surroundings",
"Hotel omgivelser",
"Hotelumgebung",
"Hotellia lähellä",
"Hotellomgivelser",
"Omgivningar",
]
const hotelFacilitiesFilterNames = [
"Hotel facilities",
"Hotellfaciliteter",
"Hotelfaciliteter",
"Hotel faciliteter",
"Hotel-Infos",
"Hotellin palvelut",
]
export function getFiltersFromHotels(
hotels: HotelResponse[]
): CategorizedHotelFilters {
const defaultFilters = { facilityFilters: [], surroundingsFilters: [] }
if (!hotels.length) {
return defaultFilters
}
const filters = hotels.flatMap(({ hotel }) =>
hotel.detailedFacilities.map(
(facility) =>
<HotelFilter>{
...facility,
hotelId: hotel.operaId,
hotelIds: [hotel.operaId],
}
)
)
const uniqueFilterIds = [...new Set(filters.map((filter) => filter.id))]
const filterList: HotelFilter[] = uniqueFilterIds
.map((filterId) => {
const filter = filters.find((f) => f.id === filterId)
// List and include all hotel Ids having same filter / amenity
if (filter) {
filter.hotelIds = filters
.filter((f) => f.id === filterId)
.map((f) => f.hotelId)
}
return filter
})
.filter((filter): filter is HotelFilter => filter !== undefined)
.sort((a, b) => b.sortOrder - a.sortOrder)
return filterList.reduce<CategorizedHotelFilters>((filters, filter) => {
if (filter.filter && hotelSurroundingsFilterNames.includes(filter.filter)) {
filters.surroundingsFilters.push(filter)
}
if (filter.filter && hotelFacilitiesFilterNames.includes(filter.filter)) {
filters.facilityFilters.push(filter)
}
return filters
}, defaultFilters)
}

View File

@@ -0,0 +1,138 @@
import {
alternativeHotelsMap,
selectHotelMap,
} from "@scandic-hotels/common/constants/routes/hotelReservation"
import Link from "@scandic-hotels/design-system/Link"
import { Typography } from "@scandic-hotels/design-system/Typography"
import BookingCodeFilter from "../BookingCodeFilter"
import HotelCardListing from "../HotelCardListing"
import { StaticMap } from "../StaticMap"
import HotelFilter from "./Filters/HotelFilter"
import { getFiltersFromHotels, type HotelResponse } from "./helpers"
import HotelCount from "./HotelCount"
import HotelSorter from "./HotelSorter"
import { MapWithButtonWrapper } from "./MapWithButtonWrapper"
import MobileMapButtonContainer from "./MobileMapButtonContainer"
import NoAvailabilityAlert from "./NoAvailabilityAlert"
import styles from "./selectHotel.module.css"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { Location } from "@scandic-hotels/trpc/types/locations"
import type { ReactNode } from "react"
export { SelectHotelSkeleton } from "./SelectHotelSkeleton"
interface SelectHotelProps {
isAlternative?: boolean
bookingCode?: string
city: Location
hotels: HotelResponse[]
isBookingCodeRateAvailable?: boolean
title: ReactNode
lang: Lang
}
export async function SelectHotel({
bookingCode,
city,
hotels,
isAlternative = false,
isBookingCodeRateAvailable = false,
title,
lang,
}: SelectHotelProps) {
const isAllUnavailable = hotels.every(
(hotel) => hotel.availability.status !== "Available"
)
const isCityWithCountry = (city: any): city is { country: string } =>
"country" in city
// Special rates (corporate cheque, voucher) will not have regular rate hotels availability
const isSpecialRate = hotels.some(
(hotel) =>
hotel.availability.productType?.bonusCheque ||
hotel.availability.productType?.voucher
)
const filterList = getFiltersFromHotels(hotels)
const showBookingCodeFilter = isBookingCodeRateAvailable && !isSpecialRate
return (
<>
<header className={styles.header}>
<div className={styles.headerContent}>
<div className={styles.title}>
<div className={styles.cityInformation}>
<Typography variant="Title/Subtitle/lg">
<p>{title}</p>
</Typography>
<HotelCount />
</div>
<div className={styles.sorter}>
<HotelSorter discreet />
</div>
</div>
<MobileMapButtonContainer filters={filterList} />
</div>
</header>
<main className={styles.main}>
{showBookingCodeFilter ? <BookingCodeFilter /> : null}
<div className={styles.sideBar}>
{hotels.length ? (
<Link
className={styles.link}
href={
isAlternative
? alternativeHotelsMap(lang)
: selectHotelMap(lang)
}
keepSearchParams
>
<MapWithButtonWrapper>
<StaticMap
city={city.name}
country={isCityWithCountry(city) ? city.country : undefined}
width={340}
height={200}
zoomLevel={11}
mapType="roadmap"
altText={`Map of ${city.name} city center`}
/>
</MapWithButtonWrapper>
</Link>
) : (
<div className={styles.mapContainer}>
<StaticMap
city={city.name}
width={340}
height={200}
zoomLevel={11}
mapType="roadmap"
altText={`Map of ${city.name} city center`}
/>
</div>
)}
<HotelFilter filters={filterList} className={styles.filter} />
</div>
<div className={styles.hotelList}>
<NoAvailabilityAlert
hotelsLength={hotels.length}
isAlternative={isAlternative}
isAllUnavailable={isAllUnavailable}
operaId={hotels?.[0]?.hotel.operaId}
bookingCode={bookingCode}
isBookingCodeRateNotAvailable={!isBookingCodeRateAvailable}
/>
<HotelCardListing
hotelData={hotels}
isAlternative={isAlternative}
unfilteredHotelCount={hotels.length}
/>
</div>
</main>
</>
)
}

View File

@@ -0,0 +1,115 @@
.main {
display: flex;
background-color: var(--Scandic-Brand-Warm-White);
min-height: min(100dvh, 750px);
flex-direction: column;
max-width: var(--max-width-page);
margin: 0 auto;
}
.header {
padding: var(--Space-x3) 0 var(--Space-x2);
}
.headerContent {
max-width: var(--max-width-page);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.cityInformation {
display: flex;
flex-wrap: wrap;
gap: var(--Space-x1);
align-items: baseline;
}
.sorter {
display: none;
}
.sideBar {
display: flex;
flex-direction: column;
}
.sideBarItem {
display: none;
}
.link {
display: none;
}
.hotelList {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--Space-x3);
}
.filter {
display: none;
}
.skeletonContainer .title {
margin-bottom: var(--Space-x3);
}
@media (min-width: 768px) {
.main {
padding: var(--Space-x5) 0;
flex-direction: row;
gap: var(--Space-x5);
flex-wrap: wrap;
}
.headerContent {
display: block;
}
.header {
background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Space-x4) 0 var(--Space-x3);
}
.sorter {
display: block;
width: 339px;
}
.title {
margin: 0 auto;
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;
margin-bottom: var(--Space-x6);
}
.skeletonContainer .title {
margin-bottom: 0;
}
.skeletonContainer .sideBar {
gap: var(--Space-x3);
}
}