Merged in feat/SW-1450-destination-page-cs-components (pull request #1204)

feat(SW-1450): added components in destination pages from cs

* feat(SW-1450): added components in destination pages from cs

* feat(SW-1450): added correct refs and removed classNames


Approved-by: Fredrik Thorsson
This commit is contained in:
Erik Tiekstra
2025-01-24 12:06:43 +00:00
parent 7343d873c2
commit a88a033e30
45 changed files with 1237 additions and 195 deletions

View File

@@ -1,10 +0,0 @@
.pageContainer {
display: grid;
max-width: var(--max-width);
}
@media screen and (min-width: 768px) {
.pageContainer {
margin: 0 auto;
}
}

View File

@@ -1,28 +0,0 @@
import { Suspense } from "react"
import { getDestinationCityPage } from "@/lib/trpc/memoizedRequests"
import TrackingSDK from "@/components/TrackingSDK"
import styles from "./destinationCityPage.module.css"
export default async function DestinationCityPage() {
const pageData = await getDestinationCityPage()
if (!pageData) {
return null
}
const { tracking, destinationCityPage } = pageData
return (
<>
<div className={styles.pageContainer}>
<h1>Destination City Page</h1>
</div>
<Suspense fallback={null}>
<TrackingSDK pageData={tracking} />
</Suspense>
</>
)
}

View File

@@ -1,10 +0,0 @@
.pageContainer {
display: grid;
max-width: var(--max-width);
}
@media screen and (min-width: 768px) {
.pageContainer {
margin: 0 auto;
}
}

View File

@@ -1,28 +0,0 @@
import { Suspense } from "react"
import { getDestinationCountryPage } from "@/lib/trpc/memoizedRequests"
import TrackingSDK from "@/components/TrackingSDK"
import styles from "./destinationCountryPage.module.css"
export default async function DestinationCountryPage() {
const pageData = await getDestinationCountryPage()
if (!pageData) {
return null
}
const { tracking, destinationCountryPage } = pageData
return (
<>
<div className={styles.pageContainer}>
<h1>Destination Country Page</h1>
</div>
<Suspense fallback={null}>
<TrackingSDK pageData={tracking} />
</Suspense>
</>
)
}

View File

@@ -0,0 +1,64 @@
.pageContainer {
--map-desktop-width: 23.75rem;
display: grid;
grid-template-areas:
"header"
"sidebar"
"mainSection";
width: 100%;
gap: var(--Spacing-x4);
}
.header {
grid-area: header;
}
.mainSection {
grid-area: mainSection;
padding-bottom: var(--Spacing-x7);
min-height: 500px; /* This is a temporary value because of no content atm */
}
.sidebar {
grid-area: sidebar;
width: 100%;
height: 100%;
background-color: var(--Base-Surface-Subtle-Normal);
}
.experienceList {
list-style: none;
display: flex;
gap: var(--Spacing-x1);
flex-wrap: wrap;
}
.mapWrapper {
width: 100%;
height: 200px;
display: flex;
justify-content: center;
align-items: center;
}
.mapWrapper img {
border-radius: var(--Corner-radius-Large);
overflow: hidden;
}
@media screen and (min-width: 768px) {
.pageContainer {
max-width: var(--max-width-page);
margin: 0 auto;
gap: var(--Spacing-x4);
}
}
@media screen and (min-width: 1367px) {
.pageContainer {
grid-template-areas:
"header sidebar"
"mainSection sidebar";
grid-template-columns: 1fr var(--map-desktop-width);
}
}

View File

@@ -0,0 +1,100 @@
import { Suspense } from "react"
import { getDestinationCityPage } from "@/lib/trpc/memoizedRequests"
import Breadcrumbs from "@/components/Breadcrumbs"
import StaticMap from "@/components/Maps/StaticMap"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import Chip from "@/components/TempDesignSystem/Chip"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n"
import SidebarContentWrapper from "../SidebarContentWrapper"
import DestinationPageSidePeek from "../Sidepeek"
import TopImages from "../TopImages"
import { mapExperiencesToListData } from "../utils"
import styles from "./destinationCityPage.module.css"
import { PageContentTypeEnum } from "@/types/requests/contentType"
export default async function DestinationCityPage() {
const [intl, pageData] = await Promise.all([
getIntl(),
getDestinationCityPage(),
])
if (!pageData) {
return null
}
const { tracking, destinationCityPage } = pageData
const {
images,
heading,
preamble,
experiences,
has_sidepeek,
sidepeek_button_text,
sidepeek_content,
destination_settings,
} = destinationCityPage
const experiencesList = await mapExperiencesToListData(experiences)
return (
<>
<div className={styles.pageContainer}>
<header className={styles.header}>
<Suspense fallback={<BreadcrumbsSkeleton />}>
<Breadcrumbs variant={PageContentTypeEnum.destinationCityPage} />
</Suspense>
<TopImages images={images} />
</header>
<main className={styles.mainSection}>
{/* TODO: Add hotel listing by cityIdentifier */}
{">>>> MAIN CONTENT <<<<"}
</main>
<aside className={styles.sidebar}>
<SidebarContentWrapper>
<Title level="h2">{heading}</Title>
<Body color="uiTextMediumContrast">{preamble}</Body>
<ul className={styles.experienceList}>
{experiencesList.map(({ Icon, name }) => (
<li key={name}>
<Chip variant="tag">
<Icon width={20} height={20} />
{name}
</Chip>
</li>
))}
</ul>
{has_sidepeek && (
<DestinationPageSidePeek
buttonText={sidepeek_button_text}
sidePeekContent={sidepeek_content}
/>
)}
{destination_settings.city && (
<div className={styles.mapWrapper}>
<StaticMap
city={destination_settings.city}
country={destination_settings.country}
width={320}
height={200}
zoomLevel={10}
altText={intl.formatMessage({ id: "Map of the city center" })}
/>
</div>
)}
</SidebarContentWrapper>
</aside>
</div>
<Suspense fallback={null}>
<TrackingSDK pageData={tracking} />
</Suspense>
</>
)
}

View File

@@ -0,0 +1,64 @@
.pageContainer {
--map-desktop-width: 23.75rem;
display: grid;
grid-template-areas:
"header"
"sidebar"
"mainSection";
width: 100%;
gap: var(--Spacing-x4);
}
.header {
grid-area: header;
}
.mainSection {
grid-area: mainSection;
padding-bottom: var(--Spacing-x7);
min-height: 500px; /* This is a temporary value because of no content atm */
}
.sidebar {
grid-area: sidebar;
width: 100%;
height: 100%;
background-color: var(--Base-Surface-Subtle-Normal);
}
.experienceList {
list-style: none;
display: flex;
gap: var(--Spacing-x1);
flex-wrap: wrap;
}
.mapWrapper {
width: 100%;
height: 200px;
display: flex;
justify-content: center;
align-items: center;
}
.mapWrapper img {
border-radius: var(--Corner-radius-Large);
overflow: hidden;
}
@media screen and (min-width: 768px) {
.pageContainer {
max-width: var(--max-width-page);
margin: 0 auto;
gap: var(--Spacing-x4);
}
}
@media screen and (min-width: 1367px) {
.pageContainer {
grid-template-areas:
"header sidebar"
"mainSection sidebar";
grid-template-columns: 1fr var(--map-desktop-width);
}
}

View File

@@ -0,0 +1,97 @@
import { Suspense } from "react"
import { getDestinationCountryPage } from "@/lib/trpc/memoizedRequests"
import Breadcrumbs from "@/components/Breadcrumbs"
import StaticMap from "@/components/Maps/StaticMap"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import Chip from "@/components/TempDesignSystem/Chip"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n"
import SidebarContentWrapper from "../SidebarContentWrapper"
import DestinationPageSidePeek from "../Sidepeek"
import TopImages from "../TopImages"
import { mapExperiencesToListData } from "../utils"
import styles from "./destinationCountryPage.module.css"
import { PageContentTypeEnum } from "@/types/requests/contentType"
export default async function DestinationCountryPage() {
const [intl, pageData] = await Promise.all([
getIntl(),
getDestinationCountryPage(),
])
if (!pageData) {
return null
}
const { tracking, destinationCountryPage } = pageData
const {
images,
heading,
preamble,
experiences,
has_sidepeek,
sidepeek_button_text,
sidepeek_content,
destination_settings,
} = destinationCountryPage
const experiencesList = await mapExperiencesToListData(experiences)
return (
<>
<div className={styles.pageContainer}>
<header className={styles.header}>
<Suspense fallback={<BreadcrumbsSkeleton />}>
<Breadcrumbs variant={PageContentTypeEnum.destinationCityPage} />
</Suspense>
<TopImages images={images} />
</header>
<main className={styles.mainSection}>
{/* TODO: Add city listing by cityIdentifier */}
{">>>> MAIN CONTENT <<<<"}
</main>
<aside className={styles.sidebar}>
<SidebarContentWrapper>
<Title level="h2">{heading}</Title>
<Body color="uiTextMediumContrast">{preamble}</Body>
<ul className={styles.experienceList}>
{experiencesList.map(({ Icon, name }) => (
<li key={name}>
<Chip variant="tag">
<Icon width={20} height={20} />
{name}
</Chip>
</li>
))}
</ul>
{has_sidepeek && (
<DestinationPageSidePeek
buttonText={sidepeek_button_text}
sidePeekContent={sidepeek_content}
/>
)}
<div className={styles.mapWrapper}>
<StaticMap
country={destination_settings.country}
width={320}
height={200}
zoomLevel={3}
altText={intl.formatMessage({ id: "Map of the country" })}
/>
</div>
</SidebarContentWrapper>
</aside>
</div>
<Suspense fallback={null}>
<TrackingSDK pageData={tracking} />
</Suspense>
</>
)
}

View File

@@ -0,0 +1,25 @@
"use client"
import { useRef } from "react"
import { StickyElementNameEnum } from "@/stores/sticky-position"
import useStickyPosition from "@/hooks/useStickyPosition"
import styles from "./sidebarContentWrapper.module.css"
export default function SidebarContentWrapper({
children,
}: React.PropsWithChildren) {
const sidebarRef = useRef<HTMLDivElement>(null)
useStickyPosition({
ref: sidebarRef,
name: StickyElementNameEnum.DESTINATION_SIDEBAR,
})
return (
<div ref={sidebarRef} className={styles.sidebarContent}>
{children}
</div>
)
}

View File

@@ -0,0 +1,12 @@
.sidebarContent {
display: grid;
align-content: start;
gap: var(--Spacing-x2);
padding: var(--Spacing-x4);
}
@media screen and (min-width: 1367px) {
.sidebarContent {
position: sticky;
}
}

View File

@@ -0,0 +1,52 @@
"use client"
import { useState } from "react"
import { ChevronRightSmallIcon } from "@/components/Icons"
import JsonToHtml from "@/components/JsonToHtml"
import Button from "@/components/TempDesignSystem/Button"
import SidePeek from "@/components/TempDesignSystem/SidePeek"
import type { DestinationCityPageData } from "@/types/trpc/routers/contentstack/destinationCityPage"
import type { DestinationCountryPageData } from "@/types/trpc/routers/contentstack/destinationCountryPage"
interface DestinationPageSidepeekProps {
buttonText: string
sidePeekContent: NonNullable<
| DestinationCityPageData["sidepeek_content"]
| DestinationCountryPageData["sidepeek_content"]
>
}
export default function DestinationPageSidepeek({
buttonText,
sidePeekContent,
}: DestinationPageSidepeekProps) {
const [sidePeekIsOpen, setSidePeekIsOpen] = useState(false)
const { heading, content } = sidePeekContent
return (
<div>
<Button
onPress={() => setSidePeekIsOpen(true)}
theme="base"
variant="icon"
intent="text"
size="small"
wrapping
>
{buttonText}
<ChevronRightSmallIcon />
</Button>
<SidePeek
title={heading}
isOpen={sidePeekIsOpen}
handleClose={() => setSidePeekIsOpen(false)}
>
<JsonToHtml
nodes={content.json.children}
embeds={content.embedded_itemsConnection.edges}
/>
</SidePeek>
</div>
)
}

View File

@@ -0,0 +1,4 @@
.ctaContainer {
display: grid;
gap: var(--Spacing-x2);
}

View File

@@ -0,0 +1,34 @@
"use client"
import Image from "@/components/Image"
import styles from "./topImages.module.css"
import type { ImageVaultAsset } from "@/types/components/imageVault"
interface TopImageProps {
images: ImageVaultAsset[]
}
export default function TopImages({ images }: TopImageProps) {
const maxWidth = 1020
return (
<div className={styles.imageWrapper}>
{images.slice(0, 3).map((image, index) => (
<Image
key={image.url}
src={image.url}
alt={image.meta.alt || image.meta.caption || ""}
width={index === 0 ? maxWidth : maxWidth / 3}
height={Math.ceil(
(index === 0 ? maxWidth : maxWidth / 3) /
image.dimensions.aspectRatio
)}
focalPoint={image.focalPoint}
className={styles.image}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,62 @@
.imageWrapper {
max-width: var(--max-width-page);
margin: 0 auto;
}
.image {
height: 200px;
max-height: 40dvh;
width: 100%;
border-radius: var(--Corner-radius-Medium);
}
@media screen and (max-width: 767px) {
.image:not(:first-child) {
display: none;
}
}
@media screen and (min-width: 768px) {
.imageWrapper {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: var(--Spacing-x1);
}
.imageWrapper > .image:first-child {
grid-column: span 2;
grid-row: span 2;
height: 300px;
}
.imageWrapper > .image:only-child {
grid-column: span 3;
}
.imageWrapper > .image:nth-child(2):nth-last-child(1) {
grid-column: span 1;
grid-row: span 2;
height: 300px;
}
.imageWrapper > .image:nth-child(2):nth-last-child(2),
.imageWrapper > .image:nth-child(3) {
grid-column: span 1;
grid-row: span 1;
height: calc(150px - var(--Spacing-x-half));
max-height: calc(20dvh - var(--Spacing-x-half));
}
}
@media screen and (min-width: 1367px) {
.imageWrapper > .image:first-child,
.imageWrapper > .image:nth-child(2):nth-last-child(1) {
height: 400px;
}
.imageWrapper > .image:nth-child(2):nth-last-child(2),
.imageWrapper > .image:nth-child(3) {
height: calc(200px - var(--Spacing-x-half));
}
}

View File

@@ -0,0 +1,59 @@
import {
BikeIcon,
CityIcon,
FamilyIcon,
KayakingIcon,
MuseumIcon,
NightlifeIcon,
StarFilledIcon,
} from "@/components/Icons"
import { getIntl } from "@/i18n"
import type { FC } from "react"
import type { IconProps } from "@/types/components/icon"
export async function mapExperiencesToListData(
experiences: string[]
): Promise<{ Icon: FC<IconProps>; name: string }[]> {
const intl = await getIntl()
return experiences.map((experience) => {
switch (experience) {
case "Hiking":
return {
Icon: StarFilledIcon,
name: intl.formatMessage({ id: "Hiking" }),
}
case "Kayaking":
return {
Icon: KayakingIcon,
name: intl.formatMessage({ id: "Kayaking" }),
}
case "Bike friendly":
return {
Icon: BikeIcon,
name: intl.formatMessage({ id: "Bike friendly" }),
}
case "Museums":
return { Icon: MuseumIcon, name: intl.formatMessage({ id: "Museums" }) }
case "Family friendly":
return {
Icon: FamilyIcon,
name: intl.formatMessage({ id: "Family friendly" }),
}
case "City pulse":
return {
Icon: CityIcon,
name: intl.formatMessage({ id: "City pulse" }),
}
case "Nightlife":
return {
Icon: NightlifeIcon,
name: intl.formatMessage({ id: "Nightlife" }),
}
default:
return { Icon: StarFilledIcon, name: experience }
}
})
}

View File

@@ -31,7 +31,7 @@ export default function HotelCardDialogImage({
/>
)}
<div className={styles.tripAdvisor}>
<Chip intent="secondary" className={styles.tripAdvisor}>
<Chip className={styles.tripAdvisor}>
<TripAdvisorIcon color="burgundy" />
{ratings}
</Chip>

View File

@@ -14,14 +14,16 @@ function getCenter({
city?: string
country?: string
}): string | undefined {
switch (true) {
case !!coordinates:
return `${coordinates.lat},${coordinates.lng}`
case !!country:
return `${city}, ${country}`
default:
return city
if (coordinates) {
return `${coordinates.lat},${coordinates.lng}`
}
if (city && country) {
return `${city}, ${country}`
}
if (country) {
return country
}
return city
}
export default function StaticMap({

View File

@@ -10,7 +10,7 @@
padding-bottom: 0;
}
.hotelHeaderWidth.breadcrumbs {
.headerWidth.breadcrumbs {
max-width: min(var(--max-width-page), calc(100% - var(--max-width-spacing)));
}

View File

@@ -10,10 +10,10 @@ export const breadcrumbsVariants = cva(styles.breadcrumbs, {
[PageContentTypeEnum.accountPage]: styles.fullWidth,
[PageContentTypeEnum.contentPage]: styles.contentWidth,
[PageContentTypeEnum.collectionPage]: styles.contentWidth,
[PageContentTypeEnum.destinationOverviewPage]: styles.contentWidth,
[PageContentTypeEnum.destinationCountryPage]: styles.contentWidth,
[PageContentTypeEnum.destinationCityPage]: styles.contentWidth,
[PageContentTypeEnum.hotelPage]: styles.hotelHeaderWidth,
[PageContentTypeEnum.destinationOverviewPage]: styles.fullWidth,
[PageContentTypeEnum.destinationCountryPage]: styles.fullWidth,
[PageContentTypeEnum.destinationCityPage]: styles.fullWidth,
[PageContentTypeEnum.hotelPage]: styles.headerWidth,
[PageContentTypeEnum.loyaltyPage]: styles.fullWidth,
[PageContentTypeEnum.startPage]: styles.contentWidth,
default: styles.fullWidth,

View File

@@ -1,19 +1,31 @@
div.chip {
align-items: center;
border-radius: var(--Corner-radius-xLarge);
--chip-text-color: var(--Base-Text-High-contrast);
--chip-background-color: var(--Base-Surface-Primary-light-Normal);
display: flex;
gap: var(--Spacing-x-half);
height: 22px;
justify-content: center;
align-items: center;
padding: var(--Spacing-x-half) var(--Spacing-x1);
gap: var(--Spacing-x-half);
border-radius: var(--Corner-radius-Small);
color: var(--chip-text-color);
background-color: var(--chip-background-color);
}
.primary {
background-color: var(--Scandic-Red-90);
color: var(--Primary-Dark-On-Surface-Accent);
.chip *,
.chip svg * {
fill: var(--chip-text-color);
}
.secondary {
background-color: var(--Base-Surface-Primary-light-Normal);
color: var(--Primary-Light-On-Surface-Text);
.chip.burgundy {
--chip-text-color: var(--Primary-Dark-On-Surface-Text);
--chip-background-color: var(--Base-Text-High-contrast);
}
.chip.transparent {
--chip-text-color: var(--UI-Input-Controls-On-Fill-Normal);
--chip-background-color: rgba(64, 57, 55, 0.9);
}
.chip.tag {
--chip-background-color: var(--Base-Surface-Subtle-Hover);
}

View File

@@ -4,15 +4,9 @@ import { chipVariants } from "./variants"
import type { ChipProps } from "./chip"
export default function Chip({
children,
className,
intent,
variant,
}: ChipProps) {
export default function Chip({ children, className, variant }: ChipProps) {
const classNames = chipVariants({
className,
intent,
variant,
})
return (

View File

@@ -4,16 +4,14 @@ import styles from "./chip.module.css"
export const chipVariants = cva(styles.chip, {
variants: {
intent: {
primary: styles.primary,
secondary: styles.secondary,
},
variant: {
default: styles.default,
burgundy: styles.burgundy,
transparent: styles.transparent,
tag: styles.tag,
},
},
defaultVariants: {
intent: "primary",
variant: "default",
},
})