Merged in feat/SW-1521-image-gallery-lightbox (pull request #1226)

Feat/SW-1521 image gallery lightbox

* feat(SW-1453): added city listing component

* feat(SW-1521): added more generic types to ImageGallery and Lightbox components

* feat(SW-1521): added lightbox functionality for top images

* feat(SW-1521): added support for setting activeIndex on open inside Lightbox


Approved-by: Fredrik Thorsson
Approved-by: Chuma Mcphoy (We Ahead)
This commit is contained in:
Erik Tiekstra
2025-01-30 13:30:58 +00:00
parent 4b39df44bc
commit b9a3e697be
25 changed files with 229 additions and 88 deletions

View File

@@ -5,9 +5,13 @@
overflow: hidden; overflow: hidden;
} }
.image { .imageWrapper {
position: relative;
height: 200px;
width: 100%; width: 100%;
max-height: 200px; }
.imageWrapper img {
object-fit: cover; object-fit: cover;
} }
@@ -23,7 +27,7 @@
grid-template-columns: minmax(250px, 350px) auto; grid-template-columns: minmax(250px, 350px) auto;
} }
.image { .imageWrapper {
max-height: none; max-height: none;
height: 100%; height: 100%;
} }

View File

@@ -1,11 +1,12 @@
import Link from "next/link" import Link from "next/link"
import Image from "@/components/Image" import ImageGallery from "@/components/ImageGallery"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider" import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery"
import ExperienceList from "../../ExperienceList" import ExperienceList from "../../ExperienceList"
@@ -19,17 +20,20 @@ interface CityListingItemProps {
export default async function CityListingItem({ city }: CityListingItemProps) { export default async function CityListingItem({ city }: CityListingItemProps) {
const intl = await getIntl() const intl = await getIntl()
const firstImage = city.images[0] const galleryImages = mapImageVaultImagesToGalleryImages(city.images)
return ( return (
<article className={styles.container}> <article className={styles.container}>
<Image <div className={styles.imageWrapper}>
src={firstImage.url} <ImageGallery
alt={firstImage.meta.alt || firstImage.meta.caption || ""} images={galleryImages}
width={300} fill
height={200} title={intl.formatMessage(
className={styles.image} { id: "{title} - Image gallery" },
{ title: city.cityName }
)}
/> />
</div>
<section className={styles.content}> <section className={styles.content}>
<Subtitle asChild> <Subtitle asChild>
<h3>{city.heading}</h3> <h3>{city.heading}</h3>

View File

@@ -48,7 +48,11 @@ export default async function DestinationCityPage() {
<Suspense fallback={<BreadcrumbsSkeleton />}> <Suspense fallback={<BreadcrumbsSkeleton />}>
<Breadcrumbs variant={PageContentTypeEnum.destinationCityPage} /> <Breadcrumbs variant={PageContentTypeEnum.destinationCityPage} />
</Suspense> </Suspense>
<TopImages images={images} /> {/* TODO: fetch translated city name from API when fetching hotel listing */}
<TopImages
images={images}
destinationName={destination_settings.city}
/>
</header> </header>
<main className={styles.mainSection}> <main className={styles.mainSection}>
{/* TODO: Add hotel listing by cityIdentifier */} {/* TODO: Add hotel listing by cityIdentifier */}

View File

@@ -8,6 +8,7 @@ import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import TrackingSDK from "@/components/TrackingSDK" import TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import CityListing from "../CityListing" import CityListing from "../CityListing"
import ExperienceList from "../ExperienceList" import ExperienceList from "../ExperienceList"
@@ -21,6 +22,7 @@ import styles from "./destinationCountryPage.module.css"
import { PageContentTypeEnum } from "@/types/requests/contentType" import { PageContentTypeEnum } from "@/types/requests/contentType"
export default async function DestinationCountryPage() { export default async function DestinationCountryPage() {
const lang = getLang()
const [intl, pageData] = await Promise.all([ const [intl, pageData] = await Promise.all([
getIntl(), getIntl(),
getDestinationCountryPage(), getDestinationCountryPage(),
@@ -30,7 +32,8 @@ export default async function DestinationCountryPage() {
return null return null
} }
const { tracking, destinationCountryPage, cities } = pageData const { tracking, destinationCountryPage, cities, translatedCountry } =
pageData
const { const {
images, images,
heading, heading,
@@ -49,7 +52,7 @@ export default async function DestinationCountryPage() {
<Suspense fallback={<BreadcrumbsSkeleton />}> <Suspense fallback={<BreadcrumbsSkeleton />}>
<Breadcrumbs variant={PageContentTypeEnum.destinationCityPage} /> <Breadcrumbs variant={PageContentTypeEnum.destinationCityPage} />
</Suspense> </Suspense>
<TopImages images={images} /> <TopImages images={images} destinationName={translatedCountry} />
</header> </header>
<main className={styles.mainSection}> <main className={styles.mainSection}>
<CityListing cities={cities} /> <CityListing cities={cities} />

View File

@@ -1,6 +1,12 @@
"use client" "use client"
import { useState } from "react"
import { useIntl } from "react-intl"
import Image from "@/components/Image" import Image from "@/components/Image"
import Lightbox from "@/components/Lightbox"
import Button from "@/components/TempDesignSystem/Button"
import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery"
import styles from "./topImages.module.css" import styles from "./topImages.module.css"
@@ -8,27 +14,65 @@ import type { ImageVaultAsset } from "@/types/components/imageVault"
interface TopImageProps { interface TopImageProps {
images: ImageVaultAsset[] images: ImageVaultAsset[]
destinationName: string
} }
export default function TopImages({ images }: TopImageProps) { export default function TopImages({ images, destinationName }: TopImageProps) {
const maxWidth = 1020 const intl = useIntl()
const [lightboxState, setLightboxState] = useState({
open: false,
activeIndex: 0,
})
const lightboxImages = mapImageVaultImagesToGalleryImages(images)
const maxWidth = 1366 // 1366px is the max width of the image container
const visibleImages = images.slice(0, 3)
return ( return (
<div className={styles.topImages}>
<div className={styles.imageWrapper}> <div className={styles.imageWrapper}>
{images.slice(0, 3).map((image, index) => ( {visibleImages.map((image, index) => (
<Image <Image
key={image.url} key={image.url}
src={image.url} src={image.url}
alt={image.meta.alt || image.meta.caption || ""} alt={image.meta.alt || image.meta.caption || ""}
width={index === 0 ? maxWidth : maxWidth / 3} width={index === 0 ? maxWidth : maxWidth / visibleImages.length}
height={Math.ceil( height={Math.ceil(
(index === 0 ? maxWidth : maxWidth / 3) / (index === 0 ? maxWidth : maxWidth / visibleImages.length) /
image.dimensions.aspectRatio image.dimensions.aspectRatio
)} )}
focalPoint={image.focalPoint} focalPoint={image.focalPoint}
className={styles.image} className={`${styles.image} ${images.length > 1 ? styles.clickable : ""}`}
onClick={() =>
images.length
? setLightboxState({ open: true, activeIndex: index })
: null
}
/> />
))} ))}
</div> </div>
{images.length > 1 && (
<>
<Button
theme="base"
intent="inverted"
size="small"
onClick={() => setLightboxState({ open: true, activeIndex: 0 })}
className={styles.seeAllButton}
>
{intl.formatMessage({ id: "See all photos" })}
</Button>
<Lightbox
images={lightboxImages}
dialogTitle={intl.formatMessage(
{ id: "{title} - Image gallery" },
{ title: destinationName }
)}
isOpen={lightboxState.open}
activeIndex={lightboxState.activeIndex}
onClose={() => setLightboxState({ open: false, activeIndex: 0 })}
/>
</>
)}
</div>
) )
} }

View File

@@ -1,3 +1,7 @@
.topImages {
position: relative;
}
.imageWrapper { .imageWrapper {
max-width: var(--max-width-page); max-width: var(--max-width-page);
margin: 0 auto; margin: 0 auto;
@@ -10,6 +14,16 @@
border-radius: var(--Corner-radius-Medium); border-radius: var(--Corner-radius-Medium);
} }
.image.clickable {
cursor: pointer;
}
.seeAllButton {
position: absolute;
bottom: var(--Spacing-x2);
right: var(--Spacing-x4);
}
@media screen and (max-width: 767px) { @media screen and (max-width: 767px) {
.image:not(:first-child) { .image:not(:first-child) {
display: none; display: none;

View File

@@ -6,6 +6,7 @@ import { useIntl } from "react-intl"
import Image from "@/components/Image" import Image from "@/components/Image"
import Lightbox from "@/components/Lightbox/" import Lightbox from "@/components/Lightbox/"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import styles from "./previewImages.module.css" import styles from "./previewImages.module.css"
@@ -18,6 +19,8 @@ export default function PreviewImages({
const intl = useIntl() const intl = useIntl()
const [lightboxIsOpen, setLightboxIsOpen] = useState(false) const [lightboxIsOpen, setLightboxIsOpen] = useState(false)
const lightboxImages = mapApiImagesToGalleryImages(images)
return ( return (
<div className={styles.imageWrapper}> <div className={styles.imageWrapper}>
{images.slice(0, 3).map((image, index) => ( {images.slice(0, 3).map((image, index) => (
@@ -44,7 +47,7 @@ export default function PreviewImages({
{intl.formatMessage({ id: "See all photos" })} {intl.formatMessage({ id: "See all photos" })}
</Button> </Button>
<Lightbox <Lightbox
images={images} images={lightboxImages}
dialogTitle={intl.formatMessage( dialogTitle={intl.formatMessage(
{ id: "{title} - Image gallery" }, { id: "{title} - Image gallery" },
{ title: hotelName } { title: hotelName }

View File

@@ -8,6 +8,7 @@ import ImageGallery from "@/components/ImageGallery"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import { getRoomNameAsParam } from "../../utils" import { getRoomNameAsParam } from "../../utils"
@@ -24,11 +25,13 @@ export function RoomCard({ room }: RoomCardProps) {
? `${roomSize.min}` ? `${roomSize.min}`
: `${roomSize.min} - ${roomSize.max}` : `${roomSize.min} - ${roomSize.max}`
const galleryImages = mapApiImagesToGalleryImages(images)
return ( return (
<article className={styles.roomCard}> <article className={styles.roomCard}>
<div className={styles.imageContainer}> <div className={styles.imageContainer}>
<ImageGallery <ImageGallery
images={images} images={galleryImages}
title={intl.formatMessage( title={intl.formatMessage(
{ id: "{title} - Image gallery" }, { id: "{title} - Image gallery" },
{ title: name } { title: name }

View File

@@ -8,6 +8,7 @@ import SidePeek from "@/components/TempDesignSystem/SidePeek"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import { getRoomNameAsParam } from "../../utils" import { getRoomNameAsParam } from "../../utils"
@@ -22,6 +23,7 @@ export default async function RoomSidePeek({ room }: RoomSidePeekProps) {
// TODO: Not defined where this should lead. // TODO: Not defined where this should lead.
const ctaUrl = "" const ctaUrl = ""
const galleryImages = mapApiImagesToGalleryImages(images)
return ( return (
<SidePeek contentKey={getRoomNameAsParam(room.name)} title={room.name}> <SidePeek contentKey={getRoomNameAsParam(room.name)} title={room.name}>
<div className={styles.content}> <div className={styles.content}>
@@ -51,7 +53,11 @@ export default async function RoomSidePeek({ room }: RoomSidePeekProps) {
)} )}
</Body> </Body>
<div className={styles.imageContainer}> <div className={styles.imageContainer}>
<ImageGallery images={images} title={room.name} height={280} /> <ImageGallery
images={galleryImages}
title={room.name}
height={280}
/>
</div> </div>
<Body color="uiTextHighContrast">{roomDescription}</Body> <Body color="uiTextHighContrast">{roomDescription}</Body>
</div> </div>

View File

@@ -16,6 +16,7 @@ import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import { getSingleDecimal } from "@/utils/numberFormatting" import { getSingleDecimal } from "@/utils/numberFormatting"
import ReadMore from "../ReadMore" import ReadMore from "../ReadMore"
@@ -66,6 +67,9 @@ function HotelCard({
}) })
const addressStr = `${hotelData.address.streetAddress}, ${hotelData.address.city}` const addressStr = `${hotelData.address.streetAddress}, ${hotelData.address.city}`
const galleryImages = mapApiImagesToGalleryImages(
hotelData.galleryImages || []
)
return ( return (
<article <article
@@ -75,11 +79,7 @@ function HotelCard({
> >
<div> <div>
<div className={styles.imageContainer}> <div className={styles.imageContainer}>
<ImageGallery <ImageGallery title={hotelData.name} images={galleryImages} fill />
title={hotelData.name}
images={hotelData.galleryImages}
fill
/>
{hotelData.ratings?.tripAdvisor && ( {hotelData.ratings?.tripAdvisor && (
<TripAdvisorChip rating={hotelData.ratings.tripAdvisor.rating} /> <TripAdvisorChip rating={hotelData.ratings.tripAdvisor.rating} />
)} )}

View File

@@ -10,6 +10,7 @@ import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import { getSingleDecimal } from "@/utils/numberFormatting" import { getSingleDecimal } from "@/utils/numberFormatting"
import ReadMore from "../../ReadMore" import ReadMore from "../../ReadMore"
@@ -38,16 +39,14 @@ export default async function HotelInfoCard({
.sort((a, b) => b.sortOrder - a.sortOrder) .sort((a, b) => b.sortOrder - a.sortOrder)
.slice(0, 5) .slice(0, 5)
const galleryImages = mapApiImagesToGalleryImages(hotel?.galleryImages || [])
return ( return (
<article className={styles.container}> <article className={styles.container}>
{hotel && ( {hotel && (
<section className={styles.wrapper}> <section className={styles.wrapper}>
<div className={styles.imageWrapper}> <div className={styles.imageWrapper}>
<ImageGallery <ImageGallery title={hotel.name} images={galleryImages} fill />
title={hotel.name}
images={hotel.galleryImages}
fill
/>
{hotel.ratings?.tripAdvisor && ( {hotel.ratings?.tripAdvisor && (
<TripAdvisorChip rating={hotel.ratings.tripAdvisor.rating} /> <TripAdvisorChip rating={hotel.ratings.tripAdvisor.rating} />
)} )}

View File

@@ -13,6 +13,7 @@ import ImageGallery from "@/components/ImageGallery"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote" import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import FlexibilityOption from "../FlexibilityOption" import FlexibilityOption from "../FlexibilityOption"
import { cardVariants } from "./cardVariants" import { cardVariants } from "./cardVariants"
@@ -184,6 +185,7 @@ export default function RoomCard({
}) })
} }
} }
const galleryImages = mapApiImagesToGalleryImages(images || [])
return ( return (
<li className={classNames}> <li className={classNames}>
@@ -213,10 +215,8 @@ export default function RoomCard({
</span> </span>
))} ))}
</div> </div>
{/*NOTE: images from the test API are hosted on test3.scandichotels.com,
which can't be accessed unless on Scandic's Wifi or using Citrix. */}
<ImageGallery <ImageGallery
images={images} images={galleryImages}
title={roomConfiguration.roomType} title={roomConfiguration.roomType}
fill fill
/> />

View File

@@ -28,6 +28,8 @@ function ImageGallery({
return <div className={styles.imagePlaceholder} /> return <div className={styles.imagePlaceholder} />
} }
const firstImage = images[0]
return ( return (
<> <>
<div <div
@@ -38,8 +40,8 @@ function ImageGallery({
> >
<Image <Image
className={styles.image} className={styles.image}
src={images[0].imageSizes.medium} src={firstImage.src}
alt={images[0].metaData.altText} alt={firstImage.alt}
onError={() => setImageError(true)} onError={() => setImageError(true)}
{...imageProps} {...imageProps}
/> />

View File

@@ -77,7 +77,7 @@ export default function FullView({
<div className={styles.fullViewImageContainer}> <div className={styles.fullViewImageContainer}>
<AnimatePresence initial={false} custom={animateLeft}> <AnimatePresence initial={false} custom={animateLeft}>
<motion.div <motion.div
key={image.imageSizes.medium} key={image.src}
custom={animateLeft} custom={animateLeft}
variants={variants} variants={variants}
initial="initial" initial="initial"
@@ -89,16 +89,15 @@ export default function FullView({
onDragEnd={(e, info) => handleSwipe(info.offset.x)} onDragEnd={(e, info) => handleSwipe(info.offset.x)}
> >
<Image <Image
alt={image.metaData.altText} alt={image.alt}
fill fill
src={image.imageSizes.medium} sizes="(min-width: 1500px) 1500px, 100vw"
src={image.src}
style={{ objectFit: "cover" }} style={{ objectFit: "cover" }}
/> />
<div className={styles.fullViewFooter}> <div className={styles.fullViewFooter}>
{image.metaData.title && ( {image.caption && <Body color="white">{image.caption}</Body>}
<Body color="white">{image.metaData.title}</Body>
)}
</div> </div>
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>

View File

@@ -79,18 +79,16 @@ export default function Gallery({
{/* Desktop Gallery */} {/* Desktop Gallery */}
<div className={styles.desktopGallery}> <div className={styles.desktopGallery}>
<div className={styles.galleryHeader}> <div className={styles.galleryHeader}>
{mainImage.metaData.title && ( {mainImage.caption && (
<div className={styles.imageCaption}> <div className={styles.imageCaption}>
<Caption color="textMediumContrast"> <Caption color="textMediumContrast">{mainImage.caption}</Caption>
{mainImage.metaData.title}
</Caption>
</div> </div>
)} )}
</div> </div>
<div className={styles.mainImageWrapper}> <div className={styles.mainImageWrapper}>
<AnimatePresence initial={false} custom={animateLeft}> <AnimatePresence initial={false} custom={animateLeft}>
<motion.div <motion.div
key={mainImage.imageSizes.medium} key={mainImage.src}
className={styles.mainImageContainer} className={styles.mainImageContainer}
custom={animateLeft} custom={animateLeft}
variants={variants} variants={variants}
@@ -100,9 +98,10 @@ export default function Gallery({
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
> >
<Image <Image
src={mainImage.imageSizes.medium} src={mainImage.src}
alt={mainImage.metaData.altText} alt={mainImage.alt}
fill fill
sizes="(min-width: 1000px) 1000px, 100vw"
className={styles.image} className={styles.image}
onClick={onImageClick} onClick={onImageClick}
/> />
@@ -128,7 +127,7 @@ export default function Gallery({
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{getThumbImages().map((image, index) => ( {getThumbImages().map((image, index) => (
<motion.div <motion.div
key={image.imageSizes.tiny} key={image.smallSrc || image.src}
className={styles.thumbnailContainer} className={styles.thumbnailContainer}
onClick={() => onSelectImage(image)} onClick={() => onSelectImage(image)}
initial={{ opacity: 0, x: 50 }} initial={{ opacity: 0, x: 50 }}
@@ -137,9 +136,10 @@ export default function Gallery({
transition={{ duration: 0.2, delay: index * 0.05 }} transition={{ duration: 0.2, delay: index * 0.05 }}
> >
<Image <Image
src={image.imageSizes.tiny} src={image.smallSrc || image.src}
alt={image.metaData.altText} alt={image.alt}
fill fill
sizes="200px"
className={styles.image} className={styles.image}
/> />
</motion.div> </motion.div>
@@ -154,7 +154,7 @@ export default function Gallery({
<div className={styles.thumbnailGrid}> <div className={styles.thumbnailGrid}>
{images.map((image, index) => ( {images.map((image, index) => (
<motion.div <motion.div
key={image.imageSizes.small} key={image.smallSrc || image.src}
className={`${styles.thumbnailContainer} ${index % 3 === 0 ? styles.fullWidthImage : ""}`} className={`${styles.thumbnailContainer} ${index % 3 === 0 ? styles.fullWidthImage : ""}`}
onClick={() => { onClick={() => {
onSelectImage(image) onSelectImage(image)
@@ -165,9 +165,10 @@ export default function Gallery({
transition={{ duration: 0.3, delay: index * 0.05 }} transition={{ duration: 0.3, delay: index * 0.05 }}
> >
<Image <Image
src={image.imageSizes.small} src={image.smallSrc || image.src}
alt={image.metaData.altText} alt={image.alt}
fill fill
sizes="100vw"
className={styles.image} className={styles.image}
/> />
</motion.div> </motion.div>

View File

@@ -210,7 +210,7 @@
} }
} }
@media (min-width: 1367px) { @media (min-width: 768px) {
.mobileGallery, .mobileGallery,
.thumbnailGrid { .thumbnailGrid {
display: none; display: none;
@@ -229,6 +229,7 @@
.galleryContent { .galleryContent {
width: 1090px; width: 1090px;
width: min(var(--max-width-page), 1090px);
height: min(725px, 85dvh); height: min(725px, 85dvh);
} }

View File

@@ -15,17 +15,26 @@ export default function Lightbox({
dialogTitle, dialogTitle,
onClose, onClose,
isOpen, isOpen,
activeIndex = 0,
}: LightboxProps) { }: LightboxProps) {
const [selectedImageIndex, setSelectedImageIndex] = useState(0) const [selectedImageIndex, setSelectedImageIndex] = useState(activeIndex)
const [isFullView, setIsFullView] = useState(false) const [isFullView, setIsFullView] = useState(false)
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setSelectedImageIndex(0)
setIsFullView(false) setIsFullView(false)
} }
}, [isOpen]) }, [isOpen])
useEffect(() => {
setSelectedImageIndex(activeIndex)
}, [activeIndex])
function handleClose() {
setSelectedImageIndex(0)
onClose()
}
function handleNext() { function handleNext() {
setSelectedImageIndex((prevIndex) => (prevIndex + 1) % images.length) setSelectedImageIndex((prevIndex) => (prevIndex + 1) % images.length)
} }
@@ -39,7 +48,7 @@ export default function Lightbox({
return ( return (
<ModalOverlay <ModalOverlay
isOpen={isOpen} isOpen={isOpen}
onOpenChange={onClose} onOpenChange={handleClose}
className={styles.overlay} className={styles.overlay}
isDismissable isDismissable
> >

View File

@@ -4,6 +4,7 @@ import ImageGallery from "@/components/ImageGallery"
import SidePeek from "@/components/TempDesignSystem/SidePeek" import SidePeek from "@/components/TempDesignSystem/SidePeek"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import { getBedIcon } from "./bedIcon" import { getBedIcon } from "./bedIcon"
import { getFacilityIcon } from "./facilityIcon" import { getFacilityIcon } from "./facilityIcon"
@@ -23,7 +24,7 @@ export default function RoomSidePeek({
const roomSize = room.roomSize const roomSize = room.roomSize
const totalOccupancy = room.occupancy const totalOccupancy = room.occupancy
const roomDescription = room.descriptions.medium const roomDescription = room.descriptions.medium
const images = room.images const galleryImages = mapApiImagesToGalleryImages(room.images)
return ( return (
<SidePeek <SidePeek
@@ -58,7 +59,11 @@ export default function RoomSidePeek({
)} )}
</Body> </Body>
<div className={styles.imageContainer}> <div className={styles.imageContainer}>
<ImageGallery images={images} title={room.name} height={280} /> <ImageGallery
images={galleryImages}
title={room.name}
height={280}
/>
</div> </div>
<Body color="uiTextHighContrast">{roomDescription}</Body> <Body color="uiTextHighContrast">{roomDescription}</Body>
</div> </div>

View File

@@ -202,6 +202,7 @@ export const destinationCountryPageQueryRouter = router({
return { return {
...validatedResponse.data, ...validatedResponse.data,
translatedCountry: apiCountry,
cities: cityPages cities: cityPages
.flat() .flat()
.filter((city): city is NonNullable<typeof city> => !!city), .filter((city): city is NonNullable<typeof city> => !!city),

View File

@@ -1,6 +1,6 @@
import type { GalleryImage } from "@/types/hotel" import type { ApiImage } from "@/types/hotel"
export type PreviewImagesProps = { export type PreviewImagesProps = {
images: GalleryImage[] images: ApiImage[]
hotelName: string hotelName: string
} }

View File

@@ -1,3 +1,3 @@
import type { GalleryImage } from "@/types/hotel" import type { ApiImage } from "@/types/hotel"
export type ImageGalleryProps = { images?: GalleryImage[]; title: string } export type ImageGalleryProps = { images?: ApiImage[]; title: string }

View File

@@ -1,4 +1,9 @@
import type { GalleryImage } from "@/types/hotel" export interface GalleryImage {
src: string
alt: string
caption?: string | null
smallSrc?: string | null
}
export type ImageGalleryProps = { export type ImageGalleryProps = {
images?: GalleryImage[] images?: GalleryImage[]

View File

@@ -1,10 +1,11 @@
import type { GalleryImage } from "@/types/hotel" import type { GalleryImage } from "../imageGallery"
export interface LightboxProps { export interface LightboxProps {
images: GalleryImage[] images: GalleryImage[]
dialogTitle: string /* Accessible title for dialog screen readers */ dialogTitle: string /* Accessible title for dialog screen readers */
onClose: () => void onClose: () => void
isOpen: boolean isOpen: boolean
activeIndex?: number
} }
export interface GalleryProps { export interface GalleryProps {

View File

@@ -1,6 +1,11 @@
import type { z } from "zod" import type { z } from "zod"
import type { hotelSchema } from "@/server/routers/hotels/output" import type { hotelSchema } from "@/server/routers/hotels/output"
import type {
extraPageSchema,
facilitySchema,
transformAdditionalData,
} from "@/server/routers/hotels/schemas/additionalData"
import type { citySchema } from "@/server/routers/hotels/schemas/city" import type { citySchema } from "@/server/routers/hotels/schemas/city"
import type { attributesSchema } from "@/server/routers/hotels/schemas/hotel" import type { attributesSchema } from "@/server/routers/hotels/schemas/hotel"
import type { addressSchema } from "@/server/routers/hotels/schemas/hotel/address" import type { addressSchema } from "@/server/routers/hotels/schemas/hotel/address"
@@ -20,11 +25,6 @@ import type { parkingSchema } from "@/server/routers/hotels/schemas/hotel/parkin
import type { pointOfInterestSchema } from "@/server/routers/hotels/schemas/hotel/poi" import type { pointOfInterestSchema } from "@/server/routers/hotels/schemas/hotel/poi"
import type { ratingsSchema } from "@/server/routers/hotels/schemas/hotel/rating" import type { ratingsSchema } from "@/server/routers/hotels/schemas/hotel/rating"
import type { imageSchema } from "@/server/routers/hotels/schemas/image" import type { imageSchema } from "@/server/routers/hotels/schemas/image"
import type {
extraPageSchema,
facilitySchema,
transformAdditionalData,
} from "@/server/routers/hotels/schemas/additionalData"
export type HotelData = z.output<typeof hotelSchema> export type HotelData = z.output<typeof hotelSchema>
@@ -33,7 +33,7 @@ export type CheckInData = z.output<typeof checkinSchema>
type CitySchema = z.output<typeof citySchema> type CitySchema = z.output<typeof citySchema>
export type City = Pick<CitySchema, "id" | "type"> & CitySchema["attributes"] export type City = Pick<CitySchema, "id" | "type"> & CitySchema["attributes"]
export type Facility = z.output<typeof facilitySchema> & { id: string } export type Facility = z.output<typeof facilitySchema> & { id: string }
export type GalleryImage = z.output<typeof imageSchema> export type ApiImage = z.output<typeof imageSchema>
export type HealthFacility = z.output<typeof healthFacilitySchema> export type HealthFacility = z.output<typeof healthFacilitySchema>
export type HealthFacilities = HealthFacility[] export type HealthFacilities = HealthFacility[]
export type Hotel = z.output<typeof attributesSchema> export type Hotel = z.output<typeof attributesSchema>
@@ -68,4 +68,3 @@ export type HotelTripAdvisor =
export type AdditionalData = ReturnType<typeof transformAdditionalData> export type AdditionalData = ReturnType<typeof transformAdditionalData>
export type ExtraPageSchema = z.output<typeof extraPageSchema> export type ExtraPageSchema = z.output<typeof extraPageSchema>

34
utils/imageGallery.ts Normal file
View File

@@ -0,0 +1,34 @@
import type { GalleryImage } from "@/types/components/imageGallery"
import type { ImageVaultAsset } from "@/types/components/imageVault"
import type { ApiImage } from "@/types/hotel"
function mapApiImageToGalleryImage(apiImage: ApiImage): GalleryImage {
return {
src: apiImage.imageSizes.medium,
alt: apiImage.metaData.altText || apiImage.metaData.title,
caption: apiImage.metaData.title,
smallSrc: apiImage.imageSizes.small,
}
}
export function mapApiImagesToGalleryImages(
apiImages: ApiImage[]
): GalleryImage[] {
return apiImages.map(mapApiImageToGalleryImage)
}
function mapImageVaultImageToGalleryImage(
imageVaultImage: ImageVaultAsset
): GalleryImage {
return {
src: imageVaultImage.url,
alt: imageVaultImage.meta.alt || imageVaultImage.meta.caption || "",
caption: imageVaultImage.meta.caption,
}
}
export function mapImageVaultImagesToGalleryImages(
imageVaultImages: ImageVaultAsset[]
): GalleryImage[] {
return imageVaultImages.map(mapImageVaultImageToGalleryImage)
}