Merged in feat/SW-1454-hotel-listing-city-page (pull request #1250)

feat(SW-1454): added hotel listing

* feat(SW-1454): added hotel listing


Approved-by: Fredrik Thorsson
This commit is contained in:
Erik Tiekstra
2025-02-05 13:10:28 +00:00
parent f3e6318d49
commit e3b1bfc414
27 changed files with 522 additions and 103 deletions
@@ -19,7 +19,9 @@ export default async function CityListing({ cities }: CityListingProps) {
<div className={styles.listHeader}>
<Subtitle type="two">
{intl.formatMessage(
{ id: `{count} Locations` },
{
id: `{count, plural, one {{count} Location} other {{count} Locations}}`,
},
{ count: cities.length }
)}
</Subtitle>
@@ -16,7 +16,9 @@
.mainSection {
grid-area: mainSection;
padding-bottom: var(--Spacing-x7);
min-height: 500px; /* This is a temporary value because of no content atm */
max-width: var(--max-width-page);
width: 100%;
margin: 0 auto;
}
.sidebar {
@@ -7,9 +7,9 @@ import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/Bread
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n"
import ExperienceList from "../ExperienceList"
import HotelListing from "../HotelListing"
import SidebarContentWrapper from "../SidebarContentWrapper"
import DestinationPageSidePeek from "../Sidepeek"
import StaticMap from "../StaticMap"
@@ -20,16 +20,13 @@ import styles from "./destinationCityPage.module.css"
import { PageContentTypeEnum } from "@/types/requests/contentType"
export default async function DestinationCityPage() {
const [intl, pageData] = await Promise.all([
getIntl(),
getDestinationCityPage(),
])
const pageData = await getDestinationCityPage()
if (!pageData) {
return null
}
const { tracking, destinationCityPage } = pageData
const { tracking, destinationCityPage, hotels } = pageData
const {
images,
heading,
@@ -55,8 +52,7 @@ export default async function DestinationCityPage() {
/>
</header>
<main className={styles.mainSection}>
{/* TODO: Add hotel listing by cityIdentifier */}
{">>>> MAIN CONTENT <<<<"}
<HotelListing hotels={hotels} />
</main>
<aside className={styles.sidebar}>
<SidebarContentWrapper>
@@ -0,0 +1,54 @@
.container {
background-color: var(--Base-Surface-Primary-light-Normal);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Medium);
overflow: hidden;
}
.content {
display: grid;
gap: var(--Spacing-x2);
padding: var(--Spacing-x2) var(--Spacing-x3);
align-content: start;
justify-items: start;
}
.intro {
display: grid;
gap: var(--Spacing-x-half);
}
.captions {
display: flex;
gap: var(--Spacing-x1);
}
.amenityList {
display: flex;
gap: var(--Spacing-x-one-and-half);
flex-wrap: wrap;
color: var(--UI-Text-Medium-contrast);
font-family: var(--typography-Body-Regular-fontFamily);
font-size: var(--typography-Caption-Underline-fontSize);
}
.amenityItem {
display: flex;
gap: var(--Spacing-x-half);
align-items: center;
}
.ctaWrapper {
justify-self: stretch;
}
@media screen and (min-width: 768px) {
.container {
display: grid;
grid-template-columns: minmax(250px, 350px) auto;
}
.ctaWrapper {
justify-self: end;
}
}
@@ -0,0 +1,101 @@
"use client"
import Link from "next/link"
import { useIntl } from "react-intl"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import { ChevronRightSmallIcon } from "@/components/Icons"
import HotelLogo from "@/components/Icons/Logos"
import ImageGallery from "@/components/ImageGallery"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import { getSingleDecimal } from "@/utils/numberFormatting"
import styles from "./hotelListingItem.module.css"
import type { Hotel } from "@/types/hotel"
interface HotelListingItemProps {
hotel: Hotel
url: string | null
}
export default function HotelListingItem({
hotel,
url,
}: HotelListingItemProps) {
const intl = useIntl()
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
const amenities = hotel.detailedFacilities.slice(0, 5)
return (
<article className={styles.container}>
<ImageGallery
images={galleryImages}
title={intl.formatMessage(
{ id: "{title} - Image gallery" },
{ title: hotel.name }
)}
/>
<div className={styles.content}>
<div className={styles.intro}>
<HotelLogo hotelId={hotel.operaId} hotelType={hotel.hotelType} />
<Subtitle type="one" asChild>
<h3>{hotel.name}</h3>
</Subtitle>
<div className={styles.captions}>
<Caption color="uiTextPlaceholder">
{hotel.address.streetAddress}
</Caption>
<Divider variant="vertical" color="beige" />
<Caption color="uiTextPlaceholder">
{intl.formatMessage(
{ id: "{number} km to city center" },
{
number: getSingleDecimal(
hotel.location.distanceToCentre / 1000
),
}
)}
</Caption>
</div>
</div>
<Body>{hotel.hotelContent.texts.descriptions.short}</Body>
<ul className={styles.amenityList}>
{amenities.map((amenity) => {
const IconComponent = mapFacilityToIcon(amenity.id)
return (
<li className={styles.amenityItem} key={amenity.id}>
{IconComponent && (
<IconComponent color="grey80" width={20} height={20} />
)}
{amenity.name}
</li>
)
})}
</ul>
<Button intent="text" variant="icon" theme="base">
{intl.formatMessage({ id: "See on map" })}
<ChevronRightSmallIcon />
</Button>
{url && (
<>
<Divider variant="horizontal" color="primaryLightSubtle" />
<div className={styles.ctaWrapper}>
<Button intent="tertiary" theme="base" size="small" asChild>
<Link href={url}>
{intl.formatMessage({ id: "See hotel details" })}
</Link>
</Button>
</div>
</>
)}
</div>
</article>
)
}
@@ -0,0 +1,32 @@
.container {
--scroll-margin-top: calc(
var(--booking-widget-mobile-height) + var(--Spacing-x2)
);
position: relative;
display: grid;
gap: var(--Spacing-x2);
scroll-margin-top: var(--scroll-margin-top);
}
.listHeader {
display: flex;
justify-content: space-between;
}
.hotelList {
list-style: none;
display: grid;
gap: var(--Spacing-x2);
}
.hotelList:not(.allVisible) li:nth-child(n + 6) {
display: none;
}
@media screen and (min-width: 768px) {
.container {
--scroll-margin-top: calc(
var(--booking-widget-desktop-height) + var(--Spacing-x2)
);
}
}
@@ -0,0 +1,76 @@
"use client"
import { useRef, useState } from "react"
import { useIntl } from "react-intl"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useScrollToTop } from "@/hooks/useScrollToTop"
import HotelListingItem from "./HotelListingItem"
import styles from "./hotelListing.module.css"
import type { HotelData } from "@/types/hotel"
interface HotelListingProps {
hotels: (HotelData & { url: string | null })[]
}
export default function HotelListing({ hotels }: HotelListingProps) {
const intl = useIntl()
const scrollRef = useRef<HTMLDivElement>(null)
const showToggleButton = hotels.length > 5
const [allHotelsVisible, setAllHotelsVisible] = useState(!showToggleButton)
const { showBackToTop, scrollToTop } = useScrollToTop({
threshold: 300,
elementRef: scrollRef,
})
function handleShowMore() {
if (scrollRef.current && allHotelsVisible) {
scrollRef.current.scrollIntoView({ behavior: "smooth" })
}
setAllHotelsVisible((state) => !state)
}
return (
<section className={styles.container} ref={scrollRef}>
<div className={styles.listHeader}>
<Subtitle type="two">
{intl.formatMessage(
{
id: `{count, plural, one {{count} Hotel} other {{count} Hotels}}`,
},
{ count: hotels.length }
)}
</Subtitle>
</div>
<ul
className={`${styles.hotelList} ${allHotelsVisible ? styles.allVisible : ""}`}
>
{hotels.map(({ hotel, url }) => (
<li key={hotel.name}>
<HotelListingItem hotel={hotel} url={url} />
</li>
))}
</ul>
{showToggleButton ? (
<ShowMoreButton
loadMoreData={handleShowMore}
showLess={allHotelsVisible}
textShowMore={intl.formatMessage({
id: "Show more",
})}
textShowLess={intl.formatMessage({
id: "Show less",
})}
/>
) : null}
{showBackToTop && (
<BackToTopButton position="center" onClick={scrollToTop} />
)}
</section>
)
}
@@ -51,6 +51,7 @@ export default function SelectHotelContent({
const { showBackToTop, scrollToTop } = useScrollToTop({
threshold: 490,
elementRef: listingContainerRef,
refScrollable: true,
})
const coordinates = useMemo(
@@ -13,6 +13,7 @@
}
.triggerArea {
position: relative;
display: flex;
cursor: pointer;
width: 100%;
@@ -21,6 +22,7 @@
.image {
width: 100%;
height: 100%;
object-fit: cover;
}
@@ -35,6 +35,11 @@
right: 32px;
}
.center {
left: 50%;
transform: translateX(-50%);
}
@media (min-width: 768px) {
.backToTopButtonText {
display: initial;
@@ -14,7 +14,7 @@ export function BackToTopButton({
position,
}: {
onClick: () => void
position: "left" | "right"
position: "left" | "right" | "center"
}) {
const intl = useIntl()
return (
@@ -7,6 +7,7 @@ export const backToTopButtonVariants = cva(styles.backToTopButton, {
position: {
left: styles.left,
right: styles.right,
center: styles.center,
},
},
defaultVariants: {