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:
@@ -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%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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} m²`
|
? `${roomSize.min} m²`
|
||||||
: `${roomSize.min} - ${roomSize.max} m²`
|
: `${roomSize.min} - ${roomSize.max} m²`
|
||||||
|
|
||||||
|
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 }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
34
utils/imageGallery.ts
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user