feat(SW-2278): Added hotel listing to campaign page
Approved-by: Matilda Landström
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
|
||||
import HotelListingItemSkeleton from "./HotelListingItem/HotelListingItemSkeleton"
|
||||
|
||||
import styles from "./campaignHotelListing.module.css"
|
||||
|
||||
export default function CampaignHotelListingSkeleton() {
|
||||
return (
|
||||
<section className={styles.hotelListingSection}>
|
||||
<header className={styles.header}>
|
||||
<Typography variant="Title/md">
|
||||
<SkeletonShimmer width="40ch" />
|
||||
</Typography>
|
||||
</header>
|
||||
<ul className={styles.list}>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<li key={index} className={styles.listItem}>
|
||||
<HotelListingItemSkeleton />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client"
|
||||
|
||||
import { cx } from "class-variance-authority"
|
||||
import { useRef, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import {
|
||||
MaterialIcon,
|
||||
type MaterialIconProps,
|
||||
} from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import HotelListingItem from "./HotelListingItem"
|
||||
|
||||
import styles from "./campaignHotelListing.module.css"
|
||||
|
||||
import type { HotelDataWithUrl } from "@/types/hotel"
|
||||
|
||||
interface CampaignHotelListingClientProps {
|
||||
heading: string
|
||||
hotels: HotelDataWithUrl[]
|
||||
}
|
||||
|
||||
export default function CampaignHotelListingClient({
|
||||
heading,
|
||||
hotels,
|
||||
}: CampaignHotelListingClientProps) {
|
||||
const intl = useIntl()
|
||||
const isMobile = useMediaQuery("(max-width: 767px)")
|
||||
const scrollRef = useRef<HTMLElement>(null)
|
||||
|
||||
const initialCount = isMobile ? 3 : 6 // Initial number of hotels to show
|
||||
const thresholdCount = isMobile ? 6 : 9 // This is the threshold at which we start showing the "Show More" button
|
||||
const showAllThreshold = isMobile ? 9 : 18 // This is the threshold at which we show the "Show All" button
|
||||
const incrementCount = isMobile ? 3 : 6 // Number of hotels to increment when the button is clicked
|
||||
|
||||
const [visibleCount, setVisibleCount] = useState(() =>
|
||||
// Set initial visible count based on the number of hotels and the threshold
|
||||
hotels.length <= thresholdCount ? hotels.length : initialCount
|
||||
)
|
||||
|
||||
// Only show the show more/less button if the length of hotels exceeds the threshold count
|
||||
const showButton = hotels.length >= thresholdCount
|
||||
|
||||
// Determine if we are at the stage where the user can click to show all hotels
|
||||
const canShowAll =
|
||||
hotels.length > visibleCount &&
|
||||
(visibleCount + incrementCount > showAllThreshold ||
|
||||
visibleCount + incrementCount >= hotels.length)
|
||||
|
||||
function handleButtonClick() {
|
||||
if (visibleCount < hotels.length) {
|
||||
if (canShowAll) {
|
||||
setVisibleCount(hotels.length)
|
||||
} else {
|
||||
setVisibleCount((prev) =>
|
||||
Math.min(prev + incrementCount, hotels.length)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
setVisibleCount(initialCount)
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let buttonText = intl.formatMessage({
|
||||
defaultMessage: "Show more",
|
||||
})
|
||||
let iconDirection: MaterialIconProps["icon"] = "keyboard_arrow_down"
|
||||
|
||||
if (visibleCount === hotels.length) {
|
||||
buttonText = intl.formatMessage({
|
||||
defaultMessage: "Show less",
|
||||
})
|
||||
iconDirection = "keyboard_arrow_up"
|
||||
} else if (canShowAll) {
|
||||
buttonText = intl.formatMessage({
|
||||
defaultMessage: "Show all",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.hotelListingSection} ref={scrollRef}>
|
||||
<header className={styles.header}>
|
||||
<Typography variant="Title/md">
|
||||
<h2 className={styles.heading}>{heading}</h2>
|
||||
</Typography>
|
||||
</header>
|
||||
<ul className={styles.list}>
|
||||
{hotels.map(({ hotel, url }, index) => (
|
||||
<li
|
||||
key={hotel.id}
|
||||
className={cx(styles.listItem, {
|
||||
[styles.hidden]: index >= visibleCount,
|
||||
})}
|
||||
>
|
||||
<HotelListingItem hotel={hotel} url={url} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{showButton ? (
|
||||
<Button
|
||||
variant="Text"
|
||||
color="Primary"
|
||||
size="Medium"
|
||||
typography="Body/Paragraph/mdBold"
|
||||
onPress={handleButtonClick}
|
||||
>
|
||||
<MaterialIcon icon={iconDirection} color="CurrentColor" />
|
||||
{buttonText}
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
|
||||
import styles from "./hotelListingItem.module.css"
|
||||
|
||||
export default function HotelListingItemSkeleton() {
|
||||
return (
|
||||
<article className={styles.hotelListingItem}>
|
||||
<div className={styles.imageWrapper}>
|
||||
<SkeletonShimmer width="100%" height="220px" />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.intro}>
|
||||
<SkeletonShimmer width="20ch" height="30px" />
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<SkeletonShimmer width="25ch" />
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<div className={styles.captions}>
|
||||
<SkeletonShimmer width="10ch" />
|
||||
<span>
|
||||
<Divider
|
||||
className={styles.divider}
|
||||
variant="vertical"
|
||||
color="Border/Divider/Default"
|
||||
/>
|
||||
</span>
|
||||
<SkeletonShimmer width="20ch" />
|
||||
</div>
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
<SkeletonShimmer width="100%" />
|
||||
<SkeletonShimmer width="70%" />
|
||||
<SkeletonShimmer width="35%" />
|
||||
</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<ul className={styles.amenityList}>
|
||||
{Array.from({ length: 5 }).map((_, index) => {
|
||||
return (
|
||||
<li className={styles.amenityItem} key={index}>
|
||||
<SkeletonShimmer width="20px" height="20px" />
|
||||
<SkeletonShimmer width="15ch" />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.ctaWrapper}>
|
||||
<SkeletonShimmer width="100%" height="40px" />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
.hotelListingItem {
|
||||
border-radius: var(--Corner-radius-md);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: var(--Space-x2);
|
||||
height: 100%;
|
||||
padding-bottom: var(--Space-x2);
|
||||
}
|
||||
|
||||
.imageWrapper {
|
||||
height: 220px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tripAdvisor {
|
||||
position: absolute;
|
||||
top: var(--Space-x2);
|
||||
left: var(--Space-x2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Space-x05);
|
||||
background-color: var(--Surface-Primary-Default);
|
||||
padding: var(--Space-x025) var(--Space-x1);
|
||||
border-radius: var(--Corner-radius-sm);
|
||||
color: var(--Text-Interactive-Default);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 var(--Space-x2);
|
||||
display: grid;
|
||||
gap: var(--Space-x15);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.intro {
|
||||
display: grid;
|
||||
gap: var(--Space-x05);
|
||||
}
|
||||
|
||||
.captions {
|
||||
display: flex;
|
||||
column-gap: var(--Space-x1);
|
||||
flex-wrap: wrap;
|
||||
color: var(--Text-Tertiary);
|
||||
}
|
||||
|
||||
.amenityList {
|
||||
display: flex;
|
||||
gap: var(--Space-x025) var(--Space-x1);
|
||||
flex-wrap: wrap;
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.amenityItem {
|
||||
display: flex;
|
||||
gap: var(--Space-x05);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ctaWrapper {
|
||||
padding: 0 var(--Space-x2);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import HotelLogoIcon from "@scandic-hotels/design-system/Icons/HotelLogoIcon"
|
||||
import TripadvisorIcon from "@scandic-hotels/design-system/Icons/TripadvisorIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import ButtonLink from "@/components/ButtonLink"
|
||||
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import ImageGallery from "@/components/ImageGallery"
|
||||
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
|
||||
}
|
||||
|
||||
export default function HotelListingItem({
|
||||
hotel,
|
||||
url,
|
||||
}: HotelListingItemProps) {
|
||||
const intl = useIntl()
|
||||
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
|
||||
const tripadvisorRating = hotel.ratings?.tripAdvisor.rating
|
||||
const address = `${hotel.address.streetAddress}, ${hotel.address.city}`
|
||||
const amenities = hotel.detailedFacilities.slice(0, 5)
|
||||
const hotelDescription = hotel.hotelContent.texts.descriptions?.short
|
||||
|
||||
return (
|
||||
<article className={styles.hotelListingItem}>
|
||||
<div className={styles.imageWrapper}>
|
||||
<ImageGallery
|
||||
images={galleryImages}
|
||||
fill
|
||||
sizes="(min-width: 768px) 450px, 100vw"
|
||||
title={intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{title} - Image gallery",
|
||||
},
|
||||
{ title: hotel.name }
|
||||
)}
|
||||
/>
|
||||
{tripadvisorRating ? (
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<div className={styles.tripAdvisor}>
|
||||
<TripadvisorIcon color="CurrentColor" />
|
||||
<span>{tripadvisorRating}</span>
|
||||
</div>
|
||||
</Typography>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.intro}>
|
||||
<HotelLogoIcon
|
||||
hotelId={hotel.operaId}
|
||||
hotelType={hotel.hotelType}
|
||||
height={30}
|
||||
/>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h3>{hotel.name}</h3>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<div className={styles.captions}>
|
||||
<span>{address}</span>
|
||||
<span>
|
||||
<Divider
|
||||
className={styles.divider}
|
||||
variant="vertical"
|
||||
color="Border/Divider/Default"
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{number} km to city center",
|
||||
},
|
||||
{
|
||||
number: getSingleDecimal(
|
||||
hotel.location.distanceToCentre / 1000
|
||||
),
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</Typography>
|
||||
</div>
|
||||
{hotelDescription ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{hotelDescription}</p>
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<ul className={styles.amenityList}>
|
||||
{amenities.map((amenity) => {
|
||||
return (
|
||||
<li className={styles.amenityItem} key={amenity.id}>
|
||||
<FacilityToIcon
|
||||
id={amenity.id}
|
||||
color="CurrentColor"
|
||||
size={20}
|
||||
/>
|
||||
{amenity.name}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.ctaWrapper}>
|
||||
<ButtonLink
|
||||
href={url}
|
||||
variant="Tertiary"
|
||||
color="Primary"
|
||||
size="Small"
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "See hotel details",
|
||||
})}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
.hotelListingSection {
|
||||
--scroll-margin-top: calc(
|
||||
var(--booking-widget-mobile-height) + var(--Spacing-x2)
|
||||
);
|
||||
|
||||
display: grid;
|
||||
gap: var(--Space-x3);
|
||||
scroll-margin-top: var(--scroll-margin-top);
|
||||
}
|
||||
|
||||
.heading {
|
||||
color: var(--Text-Heading);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: var(--Space-x4);
|
||||
}
|
||||
|
||||
.listItem.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.hotelListingSection {
|
||||
--scroll-margin-top: calc(
|
||||
var(--booking-widget-tablet-height) + var(--Spacing-x2)
|
||||
);
|
||||
gap: var(--Space-x5);
|
||||
}
|
||||
.list {
|
||||
row-gap: var(--Space-x5);
|
||||
column-gap: var(--Space-x2);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) and (max-width: 949px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 950px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.hotelListingSection {
|
||||
--scroll-margin-top: calc(
|
||||
var(--booking-widget-desktop-height) + var(--Spacing-x2)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getHotelsByCSFilter } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import CampaignHotelListingClient from "./Client"
|
||||
|
||||
interface CampaignHotelListingProps {
|
||||
heading: string
|
||||
hotelIds: string[]
|
||||
}
|
||||
|
||||
export default async function CampaignHotelListing({
|
||||
heading,
|
||||
hotelIds,
|
||||
}: CampaignHotelListingProps) {
|
||||
const hotels = await getHotelsByCSFilter({ hotelsToInclude: hotelIds })
|
||||
|
||||
if (!hotels.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <CampaignHotelListingClient heading={heading} hotels={hotels} />
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export default async function Essentials({ content }: EssentialsProps) {
|
||||
return (
|
||||
<section className={styles.essentialsSection}>
|
||||
<header className={styles.header}>
|
||||
<Typography variant="Title/sm">
|
||||
<Typography variant="Title/smRegular">
|
||||
<h3 className={styles.heading}>{title}</h3>
|
||||
</Typography>
|
||||
{preamble ? (
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function Blocks({ blocks }: BlocksProps) {
|
||||
key={`${block.card_gallery.heading}-${idx}`}
|
||||
/>
|
||||
)
|
||||
case BlocksEnums.block.HotelListing:
|
||||
case BlocksEnums.block.ContentPageHotelListing:
|
||||
const { heading, contentType, locationFilter, hotelsToInclude } =
|
||||
block.hotel_listing
|
||||
if (!locationFilter && !hotelsToInclude.length) {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Suspense } from "react"
|
||||
|
||||
import AccordionSection from "@/components/Blocks/Accordion"
|
||||
import CampaignHotelListing from "@/components/Blocks/CampaignHotelListing"
|
||||
import CampaignHotelListingSkeleton from "@/components/Blocks/CampaignHotelListing/CampaignHotelListingSkeleton"
|
||||
import CarouselCards from "@/components/Blocks/CarouselCards"
|
||||
import Essentials from "@/components/Blocks/Essentials"
|
||||
|
||||
@@ -27,6 +31,15 @@ export default function Blocks({ blocks }: BlocksProps) {
|
||||
key={block.accordion.title}
|
||||
/>
|
||||
)
|
||||
case BlocksEnums.block.CampaignPageHotelListing:
|
||||
return (
|
||||
<Suspense fallback={<CampaignHotelListingSkeleton />}>
|
||||
<CampaignHotelListing
|
||||
heading={block.hotel_listing.heading}
|
||||
hotelIds={block.hotel_listing.hotelIds}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
|
||||
.tripAdvisor {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
top: var(--Space-x2);
|
||||
left: var(--Space-x2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Space-x05);
|
||||
|
||||
Reference in New Issue
Block a user