fix: improve loading on destination overview page

- Only load data from Contentstack
- Use static JSON for destination list
- Some logic improvements to data handling and types
This commit is contained in:
Michael Zetterberg
2025-03-26 11:38:10 +01:00
parent f010a6869a
commit 65f75c11ef
37 changed files with 6619 additions and 185 deletions

View File

@@ -12,7 +12,6 @@ import DestinationCityPage from "@/components/ContentType/DestinationPage/Destin
import DestinationCityPageSkeleton from "@/components/ContentType/DestinationPage/DestinationCityPage/DestinationCityPageSkeleton" import DestinationCityPageSkeleton from "@/components/ContentType/DestinationPage/DestinationCityPage/DestinationCityPageSkeleton"
import DestinationCountryPage from "@/components/ContentType/DestinationPage/DestinationCountryPage" import DestinationCountryPage from "@/components/ContentType/DestinationPage/DestinationCountryPage"
import DestinationCountryPageSkeleton from "@/components/ContentType/DestinationPage/DestinationCountryPage/DestinationCountryPageSkeleton" import DestinationCountryPageSkeleton from "@/components/ContentType/DestinationPage/DestinationCountryPage/DestinationCountryPageSkeleton"
import DestinationOverviewPage from "@/components/ContentType/DestinationPage/DestinationOverviewPage"
import HotelPage from "@/components/ContentType/HotelPage" import HotelPage from "@/components/ContentType/HotelPage"
import HotelSubpage from "@/components/ContentType/HotelSubpage" import HotelSubpage from "@/components/ContentType/HotelSubpage"
import LoyaltyPage from "@/components/ContentType/LoyaltyPage" import LoyaltyPage from "@/components/ContentType/LoyaltyPage"
@@ -23,8 +22,8 @@ import { getLang } from "@/i18n/serverContext"
import { isValidSession } from "@/utils/session" import { isValidSession } from "@/utils/session"
import type { import type {
ContentTypeParams,
LangParams, LangParams,
NonAppRouterContentTypeParams,
PageArgs, PageArgs,
UIDParams, UIDParams,
} from "@/types/params" } from "@/types/params"
@@ -36,7 +35,7 @@ export default async function ContentTypePage({
params, params,
searchParams, searchParams,
}: PageArgs< }: PageArgs<
LangParams & ContentTypeParams & UIDParams, LangParams & NonAppRouterContentTypeParams & UIDParams,
{ subpage?: string; filterFromUrl?: string } { subpage?: string; filterFromUrl?: string }
>) { >) {
const pathname = headers().get("x-pathname") || "" const pathname = headers().get("x-pathname") || ""
@@ -62,11 +61,6 @@ export default async function ContentTypePage({
} }
case PageContentTypeEnum.loyaltyPage: case PageContentTypeEnum.loyaltyPage:
return <LoyaltyPage /> return <LoyaltyPage />
case PageContentTypeEnum.destinationOverviewPage:
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
return <DestinationOverviewPage />
case PageContentTypeEnum.destinationCountryPage: case PageContentTypeEnum.destinationCountryPage:
if (env.HIDE_FOR_NEXT_RELEASE) { if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound() return notFound()

View File

@@ -0,0 +1,3 @@
export default function DestinationOverviewPageBreadcrumbs() {
return null
}

View File

@@ -0,0 +1 @@
export { default } from "../../../[contentType]/[uid]/@preview/loading"

View File

@@ -0,0 +1 @@
export { default } from "../../../[contentType]/[uid]/@preview/page"

View File

@@ -0,0 +1,37 @@
"use client"
import * as Sentry from "@sentry/nextjs"
import { useEffect } from "react"
import { useIntl } from "react-intl"
import { DestinationOverviewPageError } from "@/components/ContentType/DestinationPage/DestinationOverviewPage/error"
export default function Error({
error,
}: {
error: Error & { digest?: string }
}) {
const intl = useIntl()
useEffect(() => {
if (!error) return
console.error(error)
Sentry.captureException(error)
}, [error])
return (
<DestinationOverviewPageError>
<p>
<strong>
{intl.formatMessage(
{ id: "An error occurred ({errorId})" },
{
errorId: `${error.digest}@${Date.now()}`,
}
)}
</strong>
</p>
</DestinationOverviewPageError>
)
}

View File

@@ -0,0 +1 @@
export { default } from "../../[contentType]/[uid]/layout"

View File

@@ -0,0 +1 @@
export { DestinationOverviewPageLoading as default } from "@/components/ContentType/DestinationPage/DestinationOverviewPage"

View File

@@ -0,0 +1,15 @@
import { notFound } from "next/navigation"
import { env } from "@/env/server"
import DestinationOverviewPage from "@/components/ContentType/DestinationPage/DestinationOverviewPage"
export { generateMetadata } from "@/utils/generateMetadata"
export default function DestinationOverviewPagePage() {
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
return <DestinationOverviewPage />
}

View File

@@ -27,21 +27,23 @@ export default async function Destination({
<AccordionItem title={country} subtitle={accordionSubtitle}> <AccordionItem title={country} subtitle={accordionSubtitle}>
<div className={styles.container}> <div className={styles.container}>
<ul className={styles.citiesList}> <ul className={styles.citiesList}>
{cities.map((city) => ( {cities.map((city) =>
<li key={city.id}> city.hotelCount > 0 ? (
{city.url ? ( <li key={city.id}>
<Link {city.url ? (
href={city.url} <Link
color="baseTextMediumContrast" href={city.url}
textDecoration="underline" color="baseTextMediumContrast"
> textDecoration="underline"
{`${city.name} (${city.hotelCount})`} >
</Link> {`${city.name} (${city.hotelCount})`}
) : ( </Link>
<Body>{`${city.name} (${city.hotelCount})`}</Body> ) : (
)} <Body>{`${city.name} (${city.hotelCount})`}</Body>
</li> )}
))} </li>
) : null
)}
</ul> </ul>
{countryUrl && ( {countryUrl && (
<Link href={countryUrl} variant="icon" color="burgundy" weight="bold"> <Link href={countryUrl} variant="icon" color="burgundy" weight="bold">

View File

@@ -7,9 +7,7 @@ import styles from "./destinationsList.module.css"
import type { DestinationsListProps } from "@/types/components/destinationOverviewPage/destinationsList/destinationsData" import type { DestinationsListProps } from "@/types/components/destinationOverviewPage/destinationsList/destinationsData"
export default function DestinationsList({ export function DestinationsList({ destinations }: DestinationsListProps) {
destinations,
}: DestinationsListProps) {
const middleIndex = Math.ceil(destinations.length / 2) const middleIndex = Math.ceil(destinations.length / 2)
const accordionLeft = destinations.slice(0, middleIndex) const accordionLeft = destinations.slice(0, middleIndex)
const accordionRight = destinations.slice(middleIndex) const accordionRight = destinations.slice(middleIndex)
@@ -17,27 +15,31 @@ export default function DestinationsList({
return ( return (
<div className={styles.listContainer}> <div className={styles.listContainer}>
<Accordion className={styles.accordion}> <Accordion className={styles.accordion}>
{accordionLeft.map((data) => ( {accordionLeft.map((data) =>
<Destination data.numberOfHotels > 0 ? (
key={data.country} <Destination
country={data.country} key={data.country}
countryUrl={data.countryUrl} country={data.country}
numberOfHotels={data.numberOfHotels} countryUrl={data.countryUrl}
cities={data.cities} numberOfHotels={data.numberOfHotels}
/> cities={data.cities}
))} />
) : null
)}
</Accordion> </Accordion>
<Divider color="subtle" className={styles.divider} /> <Divider color="subtle" className={styles.divider} />
<Accordion className={styles.accordion}> <Accordion className={styles.accordion}>
{accordionRight.map((data) => ( {accordionRight.map((data) =>
<Destination data.numberOfHotels > 0 ? (
key={data.country} <Destination
country={data.country} key={data.country}
countryUrl={data.countryUrl} country={data.country}
numberOfHotels={data.numberOfHotels} countryUrl={data.countryUrl}
cities={data.cities} numberOfHotels={data.numberOfHotels}
/> cities={data.cities}
))} />
) : null
)}
</Accordion> </Accordion>
</div> </div>
) )

View File

@@ -1,16 +1,19 @@
import { getDestinationsList } from "@/lib/trpc/memoizedRequests"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import DestinationsList from "./DestinationsList" import { DestinationsList } from "./DestinationsList"
import styles from "./hotelsSection.module.css" import styles from "./hotelsSection.module.css"
import type { HotelsSectionProps } from "@/types/components/destinationOverviewPage/destinationsList/destinationsData" export default async function HotelsSection() {
export default async function HotelsSection({
destinations,
}: HotelsSectionProps) {
const intl = await getIntl() const intl = await getIntl()
const destinations = await getDestinationsList()
if (destinations.length === 0) {
return null
}
return ( return (
<section className={styles.container}> <section className={styles.container}>

View File

@@ -6,17 +6,8 @@ import MapContent from "../../Map/MapContent"
import MapProvider from "../../Map/MapProvider" import MapProvider from "../../Map/MapProvider"
import { getHotelMapMarkers, mapMarkerDataToGeoJson } from "../../Map/utils" import { getHotelMapMarkers, mapMarkerDataToGeoJson } from "../../Map/utils"
import ActiveMapCard from "./ActiveMapCard" import ActiveMapCard from "./ActiveMapCard"
import InputForm from "./InputForm"
import type { MapLocation } from "@/types/components/mapLocation" export default async function OverviewMapContainer() {
interface OverviewMapContainerProps {
defaultLocation: MapLocation
}
export default async function OverviewMapContainer({
defaultLocation,
}: OverviewMapContainerProps) {
const hotelData = await getAllHotels() const hotelData = await getAllHotels()
if (!hotelData) { if (!hotelData) {
@@ -28,22 +19,13 @@ export default async function OverviewMapContainer({
const markers = getHotelMapMarkers(hotelData) const markers = getHotelMapMarkers(hotelData)
const geoJson = mapMarkerDataToGeoJson(markers) const geoJson = mapMarkerDataToGeoJson(markers)
const defaultCoordinates = defaultLocation
? {
lat: defaultLocation.latitude,
lng: defaultLocation.longitude,
}
: null
const defaultZoom = defaultLocation?.default_zoom ?? 3
return ( return (
<MapProvider apiKey={googleMapsApiKey} pageType="overview"> <MapProvider apiKey={googleMapsApiKey} pageType="overview">
<InputForm />
<DynamicMap <DynamicMap
mapId={googleMapId} mapId={googleMapId}
markers={markers} markers={markers}
defaultCoordinates={defaultCoordinates} fitBounds
defaultZoom={defaultZoom}
gestureHandling="cooperative" gestureHandling="cooperative"
> >
<MapContent geojson={geoJson} /> <MapContent geojson={geoJson} />

View File

@@ -2,11 +2,21 @@
position: relative; position: relative;
display: grid; display: grid;
width: 100%; width: 100%;
max-width: var(--max-width); height: 610px;
height: 700px;
margin: 0 auto; margin: 0 auto;
} }
@media screen and (min-width: 768px) {
.mapContainer {
height: 580px;
}
}
@media screen and (min-width: 1367px) {
.mapContainer {
height: 560px;
}
}
.main { .main {
display: grid; display: grid;
gap: var(--Spacing-x9); gap: var(--Spacing-x9);

View File

@@ -0,0 +1,13 @@
"use client"
import styles from "./destinationOverviewPage.module.css"
import type { PropsWithChildren } from "react"
export function DestinationOverviewPageError({ children }: PropsWithChildren) {
return (
<main className={styles.main}>
<div className={styles.blocks}>{children}</div>
</main>
)
}

View File

@@ -1,9 +1,7 @@
import { import { getDestinationOverviewPage } from "@/lib/trpc/memoizedRequests"
getDestinationOverviewPage,
getDestinationsList,
} from "@/lib/trpc/memoizedRequests"
import Blocks from "@/components/Blocks" import Blocks from "@/components/Blocks"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import TrackingSDK from "@/components/TrackingSDK" import TrackingSDK from "@/components/TrackingSDK"
import HotelsSection from "./HotelsSection" import HotelsSection from "./HotelsSection"
@@ -12,10 +10,7 @@ import OverviewMapContainer from "./OverviewMapContainer"
import styles from "./destinationOverviewPage.module.css" import styles from "./destinationOverviewPage.module.css"
export default async function DestinationOverviewPage() { export default async function DestinationOverviewPage() {
const [pageData, destinationsData] = await Promise.all([ const pageData = await getDestinationOverviewPage()
getDestinationOverviewPage(),
getDestinationsList(),
])
if (!pageData) { if (!pageData) {
return null return null
@@ -26,21 +21,35 @@ export default async function DestinationOverviewPage() {
return ( return (
<> <>
<div className={styles.mapContainer}> <div className={styles.mapContainer}>
<OverviewMapContainer <OverviewMapContainer />
defaultLocation={destinationOverviewPage.location}
/>
</div> </div>
<main className={styles.main}> <main className={styles.main}>
<div className={styles.blocks}> <div className={styles.blocks}>
<Blocks blocks={destinationOverviewPage.blocks} /> <Blocks blocks={destinationOverviewPage.blocks} />
</div> </div>
</main> </main>
{destinationsData && ( <aside className={styles.hotelsAccordions}>
<aside className={styles.hotelsAccordions}> <HotelsSection />
<HotelsSection destinations={destinationsData} /> </aside>
</aside>
)}
<TrackingSDK pageData={tracking} /> <TrackingSDK pageData={tracking} />
</> </>
) )
} }
export function DestinationOverviewPageLoading() {
return (
<>
<div className={styles.mapContainer}>
<SkeletonShimmer width={"100%"} height={"100%"} />
</div>
<main className={styles.main}>
<div className={styles.blocks}>
<SkeletonShimmer width={"100%"} height={"100%"} />
</div>
</main>
<aside className={styles.hotelsAccordions}>
<SkeletonShimmer width={"100%"} height={"100%"} />
</aside>
</>
)
}

View File

@@ -6,7 +6,7 @@
z-index: 0; z-index: 0;
} }
.mapWrapper::after { .mapWrapperWithCloseButton:after {
content: ""; content: "";
position: absolute; position: absolute;
top: 0; top: 0;

View File

@@ -3,6 +3,7 @@
import "client-only" import "client-only"
import { Map, type MapProps, useMap } from "@vis.gl/react-google-maps" import { Map, type MapProps, useMap } from "@vis.gl/react-google-maps"
import { cx } from "class-variance-authority"
import { type PropsWithChildren, useEffect, useRef } from "react" import { type PropsWithChildren, useEffect, useRef } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
@@ -28,8 +29,8 @@ const BACKUP_COORDINATES = {
interface DynamicMapProps { interface DynamicMapProps {
markers: DestinationMarker[] markers: DestinationMarker[]
mapId: string mapId: string
defaultCoordinates: google.maps.LatLngLiteral | null defaultCenter?: google.maps.LatLngLiteral
defaultZoom: number defaultZoom?: number
fitBounds?: boolean fitBounds?: boolean
gestureHandling?: "greedy" | "cooperative" | "auto" | "none" gestureHandling?: "greedy" | "cooperative" | "auto" | "none"
onClose?: () => void onClose?: () => void
@@ -38,8 +39,8 @@ interface DynamicMapProps {
export default function DynamicMap({ export default function DynamicMap({
markers, markers,
mapId, mapId,
defaultCoordinates, defaultCenter = BACKUP_COORDINATES,
defaultZoom, defaultZoom = 3,
fitBounds = true, fitBounds = true,
onClose, onClose,
gestureHandling = "auto", gestureHandling = "auto",
@@ -61,18 +62,14 @@ export default function DynamicMap({
}, [activeMarker, pageType]) }, [activeMarker, pageType])
useEffect(() => { useEffect(() => {
if (map && fitBounds) { if (map && fitBounds && markers?.length) {
if (markers.length) { const bounds = new google.maps.LatLngBounds()
const bounds = new google.maps.LatLngBounds() markers.forEach((marker) => {
markers.forEach((marker) => { bounds.extend(marker.coordinates)
bounds.extend(marker.coordinates) })
}) map.fitBounds(bounds, 100)
map.fitBounds(bounds, 100)
} else if (defaultCoordinates) {
map.setCenter(defaultCoordinates)
}
} }
}, [map, markers, defaultCoordinates, fitBounds]) }, [map, fitBounds, markers])
useHandleKeyUp((event: KeyboardEvent) => { useHandleKeyUp((event: KeyboardEvent) => {
if (event.key === "Escape" && onClose) { if (event.key === "Escape" && onClose) {
@@ -94,7 +91,7 @@ export default function DynamicMap({
} }
const mapOptions: MapProps = { const mapOptions: MapProps = {
defaultCenter: defaultCoordinates || BACKUP_COORDINATES, // Default center will be overridden by the bounds defaultCenter, // Default center will be overridden by the bounds
minZoom: 3, minZoom: 3,
maxZoom: 18, maxZoom: 18,
defaultZoom, defaultZoom,
@@ -105,7 +102,13 @@ export default function DynamicMap({
} }
return ( return (
<div className={styles.mapWrapper} ref={ref}> <div
className={cx(
styles.mapWrapper,
onClose && styles.mapWrapperWithCloseButton
)}
ref={ref}
>
<ErrorBoundary fallback={<h2>Unable to display map</h2>}> <ErrorBoundary fallback={<h2>Unable to display map</h2>}>
<Map {...mapOptions}>{children}</Map> <Map {...mapOptions}>{children}</Map>
</ErrorBoundary> </ErrorBoundary>

View File

@@ -71,7 +71,7 @@ export default function Map({
const markers = getHotelMapMarkers(hotels) const markers = getHotelMapMarkers(hotels)
const geoJson = mapMarkerDataToGeoJson(markers) const geoJson = mapMarkerDataToGeoJson(markers)
const defaultCoordinates = activeHotel const defaultCenter = activeHotel
? { ? {
lat: activeHotel.hotel.location.latitude, lat: activeHotel.hotel.location.latitude,
lng: activeHotel.hotel.location.longitude, lng: activeHotel.hotel.location.longitude,
@@ -81,7 +81,7 @@ export default function Map({
lat: defaultLocation.latitude, lat: defaultLocation.latitude,
lng: defaultLocation.longitude, lng: defaultLocation.longitude,
} }
: null : undefined
const defaultZoom = activeHotel const defaultZoom = activeHotel
? 15 ? 15
: (defaultLocation?.default_zoom ?? (pageType === "city" ? 10 : 3)) : (defaultLocation?.default_zoom ?? (pageType === "city" ? 10 : 3))
@@ -171,7 +171,7 @@ export default function Map({
markers={markers} markers={markers}
mapId={mapId} mapId={mapId}
onClose={handleClose} onClose={handleClose}
defaultCoordinates={defaultCoordinates} defaultCenter={defaultCenter}
defaultZoom={defaultZoom} defaultZoom={defaultZoom}
fitBounds={!activeHotel} fitBounds={!activeHotel}
gestureHandling="greedy" gestureHandling="greedy"

View File

@@ -57,6 +57,7 @@
"Amenities": "Faciliteter", "Amenities": "Faciliteter",
"Amusement park": "Forlystelsespark", "Amusement park": "Forlystelsespark",
"An account with this email already exists. Please try signing in instead.": "Der findes allerede en konto med denne e-mailadresse. Log venligst ind i stedet.", "An account with this email already exists. Please try signing in instead.": "Der findes allerede en konto med denne e-mailadresse. Log venligst ind i stedet.",
"An error occurred ({errorId})": "Der opstod en fejl ({errorId})",
"An error occurred trying to manage your preferences, please try again later.": "Der opstod en fejl under forsøget på at administrere dine præferencer. Prøv venligst igen senere.", "An error occurred trying to manage your preferences, please try again later.": "Der opstod en fejl under forsøget på at administrere dine præferencer. Prøv venligst igen senere.",
"An error occurred when adding a credit card, please try again later.": "Der opstod en fejl under tilføjelse af et kreditkort. Prøv venligst igen senere.", "An error occurred when adding a credit card, please try again later.": "Der opstod en fejl under tilføjelse af et kreditkort. Prøv venligst igen senere.",
"An error occurred when trying to update profile.": "Der opstod en fejl under forsøg på at opdatere profilen.", "An error occurred when trying to update profile.": "Der opstod en fejl under forsøg på at opdatere profilen.",

View File

@@ -57,6 +57,7 @@
"Amenities": "Annehmlichkeiten", "Amenities": "Annehmlichkeiten",
"Amusement park": "Vergnügungspark", "Amusement park": "Vergnügungspark",
"An account with this email already exists. Please try signing in instead.": "Ein Konto mit dieser E-Mail-Adresse existiert bereits. Bitte melden Sie sich stattdessen an.", "An account with this email already exists. Please try signing in instead.": "Ein Konto mit dieser E-Mail-Adresse existiert bereits. Bitte melden Sie sich stattdessen an.",
"An error occurred ({errorId})": "Ein Fehler ist aufgetreten ({errorId})",
"An error occurred trying to manage your preferences, please try again later.": "Beim Versuch, Ihre Einstellungen zu verwalten, ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.", "An error occurred trying to manage your preferences, please try again later.": "Beim Versuch, Ihre Einstellungen zu verwalten, ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.",
"An error occurred when adding a credit card, please try again later.": "Beim Hinzufügen einer Kreditkarte ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.", "An error occurred when adding a credit card, please try again later.": "Beim Hinzufügen einer Kreditkarte ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.",
"An error occurred when trying to update profile.": "Beim Versuch, das Profil zu aktualisieren, ist ein Fehler aufgetreten.", "An error occurred when trying to update profile.": "Beim Versuch, das Profil zu aktualisieren, ist ein Fehler aufgetreten.",

View File

@@ -57,6 +57,7 @@
"Amenities": "Amenities", "Amenities": "Amenities",
"Amusement park": "Amusement park", "Amusement park": "Amusement park",
"An account with this email already exists. Please try signing in instead.": "An account with this email already exists. Please try signing in instead.", "An account with this email already exists. Please try signing in instead.": "An account with this email already exists. Please try signing in instead.",
"An error occurred ({errorId})": "An error occurred ({errorId})",
"An error occurred trying to manage your preferences, please try again later.": "An error occurred trying to manage your preferences, please try again later.", "An error occurred trying to manage your preferences, please try again later.": "An error occurred trying to manage your preferences, please try again later.",
"An error occurred when adding a credit card, please try again later.": "An error occurred when adding a credit card, please try again later.", "An error occurred when adding a credit card, please try again later.": "An error occurred when adding a credit card, please try again later.",
"An error occurred when trying to update profile.": "An error occurred when trying to update profile.", "An error occurred when trying to update profile.": "An error occurred when trying to update profile.",

View File

@@ -57,6 +57,7 @@
"Amenities": "Mukavuudet", "Amenities": "Mukavuudet",
"Amusement park": "Huvipuisto", "Amusement park": "Huvipuisto",
"An account with this email already exists. Please try signing in instead.": "Tällä sähköpostiosoitteella on jo olemassa tili. Joten käytä sitä kirjautuaksesi sisään.", "An account with this email already exists. Please try signing in instead.": "Tällä sähköpostiosoitteella on jo olemassa tili. Joten käytä sitä kirjautuaksesi sisään.",
"An error occurred ({errorId})": "Tapahtui virhe ({errorId})",
"An error occurred trying to manage your preferences, please try again later.": "Asetusten hallinnassa tapahtui virhe. Yritä myöhemmin uudelleen.", "An error occurred trying to manage your preferences, please try again later.": "Asetusten hallinnassa tapahtui virhe. Yritä myöhemmin uudelleen.",
"An error occurred when adding a credit card, please try again later.": "Luottokorttia lisättäessä tapahtui virhe. Yritä myöhemmin uudelleen.", "An error occurred when adding a credit card, please try again later.": "Luottokorttia lisättäessä tapahtui virhe. Yritä myöhemmin uudelleen.",
"An error occurred when trying to update profile.": "Profiilia päivitettäessä tapahtui virhe.", "An error occurred when trying to update profile.": "Profiilia päivitettäessä tapahtui virhe.",

View File

@@ -57,6 +57,7 @@
"Amenities": "Fasiliteter", "Amenities": "Fasiliteter",
"Amusement park": "Tivoli", "Amusement park": "Tivoli",
"An account with this email already exists. Please try signing in instead.": "En konto med denne e-postadressen eksisterer allerede. Vennligst logg inn i stedet.", "An account with this email already exists. Please try signing in instead.": "En konto med denne e-postadressen eksisterer allerede. Vennligst logg inn i stedet.",
"An error occurred ({errorId})": "Det oppstod en feil ({errorId})",
"An error occurred trying to manage your preferences, please try again later.": "Det oppstod en feil under forsøket på å administrere innstillingene dine. Prøv igjen senere.", "An error occurred trying to manage your preferences, please try again later.": "Det oppstod en feil under forsøket på å administrere innstillingene dine. Prøv igjen senere.",
"An error occurred when adding a credit card, please try again later.": "Det oppstod en feil ved å legge til et kredittkort. Prøv igjen senere.", "An error occurred when adding a credit card, please try again later.": "Det oppstod en feil ved å legge til et kredittkort. Prøv igjen senere.",
"An error occurred when trying to update profile.": "Det oppstod en feil under forsøk på å oppdatere profilen.", "An error occurred when trying to update profile.": "Det oppstod en feil under forsøk på å oppdatere profilen.",

View File

@@ -57,6 +57,7 @@
"Amenities": "Bekvämligheter", "Amenities": "Bekvämligheter",
"Amusement park": "Nöjespark", "Amusement park": "Nöjespark",
"An account with this email already exists. Please try signing in instead.": "Ett konto med denna mailadress finns redan. Vänligen försök logga in istället.", "An account with this email already exists. Please try signing in instead.": "Ett konto med denna mailadress finns redan. Vänligen försök logga in istället.",
"An error occurred ({errorId})": "Ett fel uppstod ({errorId})",
"An error occurred trying to manage your preferences, please try again later.": "Ett fel uppstod när du försökte hantera dina inställningar, försök igen senare.", "An error occurred trying to manage your preferences, please try again later.": "Ett fel uppstod när du försökte hantera dina inställningar, försök igen senare.",
"An error occurred when adding a credit card, please try again later.": "Ett fel uppstod när ett kreditkort lades till, försök igen senare.", "An error occurred when adding a credit card, please try again later.": "Ett fel uppstod när ett kreditkort lades till, försök igen senare.",
"An error occurred when trying to update profile.": "Ett fel uppstod när du försökte uppdatera profilen.", "An error occurred when trying to update profile.": "Ett fel uppstod när du försökte uppdatera profilen.",

View File

@@ -4,7 +4,7 @@
query GetDestinationOverviewPage($locale: String!, $uid: String!) { query GetDestinationOverviewPage($locale: String!, $uid: String!) {
destination_overview_page(uid: $uid, locale: $locale) { destination_overview_page(uid: $uid, locale: $locale) {
title heading
url url
blocks { blocks {
__typename __typename

View File

@@ -181,6 +181,7 @@ export const getDestinationOverviewPage = cache(
return serverClient().contentstack.destinationOverviewPage.get() return serverClient().contentstack.destinationOverviewPage.get()
} }
) )
export const getDestinationsList = cache( export const getDestinationsList = cache(
async function getMemoizedDestinationsList() { async function getMemoizedDestinationsList() {
return serverClient().contentstack.destinationOverviewPage.destinations.get() return serverClient().contentstack.destinationOverviewPage.destinations.get()

View File

@@ -25,7 +25,7 @@ export const blocksSchema = z.discriminatedUnion("__typename", [
export const destinationOverviewPageSchema = z.object({ export const destinationOverviewPageSchema = z.object({
destination_overview_page: z.object({ destination_overview_page: z.object({
title: z.string(), heading: z.string().nullish(),
blocks: discriminatedUnionArray(blocksSchema.options), blocks: discriminatedUnionArray(blocksSchema.options),
location: mapLocationSchema, location: mapLocationSchema,
system: systemSchema.merge( system: systemSchema.merge(

View File

@@ -1,3 +1,4 @@
import { Lang } from "@/constants/languages"
import { import {
GetDestinationOverviewPage, GetDestinationOverviewPage,
GetDestinationOverviewPageRefs, GetDestinationOverviewPageRefs,
@@ -20,6 +21,12 @@ import {
} from "../../hotels/utils" } from "../../hotels/utils"
import { getCityPageUrls } from "../destinationCityPage/utils" import { getCityPageUrls } from "../destinationCityPage/utils"
import { getCountryPageUrls } from "../destinationCountryPage/utils" import { getCountryPageUrls } from "../destinationCountryPage/utils"
import destinationsDataDa from "./destinations-da.json" with { assert: "json" }
import destinationsDataDe from "./destinations-de.json" with { assert: "json" }
import destinationsDataEn from "./destinations-en.json" with { assert: "json" }
import destinationsDataFi from "./destinations-fi.json" with { assert: "json" }
import destinationsDataNo from "./destinations-no.json" with { assert: "json" }
import destinationsDataSv from "./destinations-sv.json" with { assert: "json" }
import { import {
destinationOverviewPageRefsSchema, destinationOverviewPageRefsSchema,
destinationOverviewPageSchema, destinationOverviewPageSchema,
@@ -200,80 +207,123 @@ export const destinationOverviewPageQueryRouter = router({
} }
}), }),
destinations: router({ destinations: router({
get: serviceProcedure.query(async function ({ ctx }) { get: serviceProcedure.query(async function ({
const countries = await getCountries({ ctx,
lang: ctx.lang, }): Promise<DestinationsData> {
serviceToken: ctx.serviceToken, // For go live we are using static data here, as it rarely changes.
}) // This also improves operational reliance as we are not hammering
// a lot of endpoints for a lot of data.
// Re-implement once we have better API support and established caching
// patterns and mechanisms.
const countryPages = await getCountryPageUrls(ctx.lang) // NOTE: To update the static data set `useStaticData = false`.
// Then go to the "Hotels & Destinations" page and visit every language.
// At the time of commit http://localhost:3000/en/destinations.
// This will update the JSON file locally, each page load for each language,
// if all data loads correctly.
// Set back `useStaticData = true` again and test with the updated JSON file.
// Add, commit and push the updated JSON files with useStaticData = true here.
const useStaticData = true
if (!countries) { if (useStaticData) {
return null switch (ctx.lang) {
case Lang.da:
return destinationsDataDa
case Lang.de:
return destinationsDataDe
case Lang.fi:
return destinationsDataFi
case Lang.en:
return destinationsDataEn
case Lang.no:
return destinationsDataNo
case Lang.sv:
return destinationsDataSv
default:
return []
}
} else {
return await updateJSONOnDisk()
} }
const countryNames = countries.data.map((country) => country.name) async function updateJSONOnDisk() {
const { lang } = ctx
const citiesByCountry = await getCitiesByCountry({ const countries = await getCountries({
lang: ctx.lang, lang,
countries: countryNames, serviceToken: ctx.serviceToken,
serviceToken: ctx.serviceToken, })
onlyPublished: true,
})
const cityPages = await getCityPageUrls(ctx.lang) if (!countries) {
return []
}
const destinations: DestinationsData = await Promise.all( const countryNames = countries.data.map((country) => country.name)
Object.entries(citiesByCountry).map(async ([country, cities]) => {
const citiesWithHotelCount = await Promise.all(
cities.map(async (city) => {
const [hotels] = await safeTry(
getHotelIdsByCityId({
cityId: city.id,
serviceToken: ctx.serviceToken,
})
)
const cityPage = cityPages.find( const citiesByCountry = await getCitiesByCountry({
(cityPage) => cityPage.city === city.cityIdentifier lang,
) countries: countryNames,
serviceToken: ctx.serviceToken,
onlyPublished: true,
})
if (!cityPage) { const cityPages = await getCityPageUrls(lang)
return null
}
return { const destinations = await Promise.all(
id: city.id, Object.entries(citiesByCountry).map(async ([country, cities]) => {
name: city.name, const activeCitiesWithHotelCount: Cities = await Promise.all(
hotelIds: hotels || [], cities.map(async (city) => {
hotelCount: hotels?.length ?? 0, const [hotels] = await safeTry(
url: cityPage.url, getHotelIdsByCityId({
} cityId: city.id,
}) serviceToken: ctx.serviceToken,
) })
)
const activeCitiesWithHotelCount: Cities = const cityPage = cityPages.find(
citiesWithHotelCount.filter( (cityPage) => cityPage.city === city.cityIdentifier
(city): city is Cities[number] => !!city )
return {
id: city.id,
name: city.name,
hotelIds: hotels || [],
hotelCount: hotels ? hotels.length : 0,
url: cityPage?.url,
}
})
) )
const countryPage = countryPages.find( const countryPages = await getCountryPageUrls(lang)
(countryPage) => countryPage.country === country const countryPage = countryPages.find(
) (countryPage) => countryPage.country === country
)
return { return {
country, country,
countryUrl: countryPage?.url, countryUrl: countryPage?.url,
numberOfHotels: activeCitiesWithHotelCount.reduce( numberOfHotels: activeCitiesWithHotelCount.reduce(
(acc, city) => acc + city.hotelCount, (acc, city) => acc + city.hotelCount,
0 0
), ),
cities: activeCitiesWithHotelCount, cities: activeCitiesWithHotelCount,
}
})
)
const data = destinations.sort((a, b) =>
a.country.localeCompare(b.country)
)
const fs = await import("node:fs")
fs.writeFileSync(
`./server/routers/contentstack/destinationOverviewPage/destinations-${lang}.json`,
JSON.stringify(data),
{
encoding: "utf-8",
} }
}) )
) return data
}
return destinations.sort((a, b) => a.country.localeCompare(b.country))
}), }),
}), }),
}) })

View File

@@ -1,4 +1,5 @@
import { CancellationRuleEnum } from "@/constants/booking" import { CancellationRuleEnum } from "@/constants/booking"
import { Lang } from "@/constants/languages"
import { env } from "@/env/server" import { env } from "@/env/server"
import * as api from "@/lib/api" import * as api from "@/lib/api"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
@@ -79,7 +80,6 @@ import type {
} from "@/types/trpc/routers/hotel/availability" } from "@/types/trpc/routers/hotel/availability"
import type { HotelInput } from "@/types/trpc/routers/hotel/hotel" import type { HotelInput } from "@/types/trpc/routers/hotel/hotel"
import type { CityLocation } from "@/types/trpc/routers/hotel/locations" import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
import type { Lang } from "@/constants/routes/hotelReservation"
export const getHotel = cache( export const getHotel = cache(
async (input: HotelInput, serviceToken: string) => { async (input: HotelInput, serviceToken: string) => {
@@ -1198,7 +1198,9 @@ export const hotelQueryRouter = router({
}) { }) {
const lang = input?.lang ?? ctx.lang const lang = input?.lang ?? ctx.lang
const countries = await getCountries({ const countries = await getCountries({
lang: lang, // Countries need to be in English regardless of incoming lang because
// we use the names as input for API endpoints.
lang: Lang.en,
serviceToken: ctx.serviceToken, serviceToken: ctx.serviceToken,
}) })

View File

@@ -1,17 +1,21 @@
export type DestinationsData = { export type City = {
country: string id: string
countryUrl: string | undefined name: string
numberOfHotels: number hotelIds: string[]
cities: { hotelCount: number
id: string url?: string
name: string }
hotelIds: string[]
hotelCount: number
url: string
}[]
}[]
export type Cities = DestinationsData[number]["cities"] export type DestinationCountry = {
country: string
countryUrl?: string
numberOfHotels: number
cities: City[]
}
export type DestinationsData = DestinationCountry[]
export type Cities = DestinationCountry["cities"]
export type HotelsSectionProps = { export type HotelsSectionProps = {
destinations: DestinationsData destinations: DestinationsData

View File

@@ -29,6 +29,21 @@ export type ContentTypeParams = {
| PageContentTypeEnum.startPage | PageContentTypeEnum.startPage
} }
// This is purely for use in `apps/scandic-web/app/[lang]/(live)/(public)/[contentType]/[uid]/page.tsx`
// It is meant as an interim solution while we move away from the switch-case
// approach into routing these based on App router.
// Folders in `apps/scandic-web/app/[lang]/(live)/(public)` should not be in this type.
export type NonAppRouterContentTypeParams = {
contentType:
| PageContentTypeEnum.loyaltyPage
| PageContentTypeEnum.contentPage
| PageContentTypeEnum.hotelPage
| PageContentTypeEnum.collectionPage
| PageContentTypeEnum.destinationCountryPage
| PageContentTypeEnum.destinationCityPage
| PageContentTypeEnum.startPage
}
export type ContentTypeWebviewParams = { export type ContentTypeWebviewParams = {
contentType: "loyalty-page" | "account-page" contentType: "loyalty-page" | "account-page"
} }