feat(SW-2278): Added hotel listing to campaign page

Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-06-19 09:36:28 +00:00
parent 105c4d9cf3
commit af92f7183c
31 changed files with 703 additions and 57 deletions

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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>
)
}