Merged master into feat/1268-mask-private-data

This commit is contained in:
Linus Flood
2024-12-19 10:21:17 +00:00
53 changed files with 1243 additions and 227 deletions

View File

@@ -52,7 +52,7 @@ GOOGLE_STATIC_MAP_SIGNATURE_SECRET=""
GOOGLE_STATIC_MAP_ID="" GOOGLE_STATIC_MAP_ID=""
GOOGLE_DYNAMIC_MAP_ID="" GOOGLE_DYNAMIC_MAP_ID=""
HIDE_FOR_NEXT_RELEASE="false" NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE="false"
ENABLE_BOOKING_FLOW="false" ENABLE_BOOKING_FLOW="false"
ENABLE_BOOKING_WIDGET="false" ENABLE_BOOKING_WIDGET="false"

View File

@@ -42,7 +42,7 @@ GOOGLE_STATIC_MAP_KEY="test"
GOOGLE_STATIC_MAP_SIGNATURE_SECRET="test" GOOGLE_STATIC_MAP_SIGNATURE_SECRET="test"
GOOGLE_STATIC_MAP_ID="test" GOOGLE_STATIC_MAP_ID="test"
GOOGLE_DYNAMIC_MAP_ID="test" GOOGLE_DYNAMIC_MAP_ID="test"
HIDE_FOR_NEXT_RELEASE="true" NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE="true"
SALESFORCE_PREFERENCE_BASE_URL="test" SALESFORCE_PREFERENCE_BASE_URL="test"
USE_NEW_REWARDS_ENDPOINT="true" USE_NEW_REWARDS_ENDPOINT="true"
USE_NEW_REWARD_MODEL="true" USE_NEW_REWARD_MODEL="true"

View File

@@ -0,0 +1,73 @@
import { revalidateTag } from "next/cache"
import { headers } from "next/headers"
import { z } from "zod"
import { Lang } from "@/constants/languages"
import { env } from "@/env/server"
import { badRequest, internalServerError, notFound } from "@/server/errors/next"
import { generateHotelUrlTag } from "@/utils/generateTag"
import type { NextRequest } from "next/server"
const validateJsonBody = z.object({
data: z.object({
content_type: z.object({
uid: z.literal("hotel_page"),
}),
entry: z.object({
hotel_page_id: z.string(),
locale: z.nativeEnum(Lang),
publish_details: z.object({ locale: z.nativeEnum(Lang) }).optional(),
}),
}),
})
export async function POST(request: NextRequest) {
try {
const headersList = headers()
const secret = headersList.get("x-revalidate-secret")
if (secret !== env.REVALIDATE_SECRET) {
console.error(`Invalid Secret`)
console.error({ secret })
return badRequest({ revalidated: false, now: Date.now() })
}
const data = await request.json()
const validatedData = validateJsonBody.safeParse(data)
if (!validatedData.success) {
console.error("Bad validation for `validatedData` in hotel revalidation")
console.error(validatedData.error)
return internalServerError({ revalidated: false, now: Date.now() })
}
const {
data: {
data: { content_type, entry },
},
} = validatedData
// The publish_details.locale is the locale that the entry is published in, regardless if it is "localized" or not
const locale = entry.publish_details?.locale ?? entry.locale
let tag = ""
if (content_type.uid === "hotel_page") {
const tag = generateHotelUrlTag(locale, entry.hotel_page_id)
} else {
console.error(
`Invalid content_type, received ${content_type.uid}, expected "hotel_page"`
)
return notFound({ revalidated: false, now: Date.now() })
}
console.info(`Revalidating hotel url tag: ${tag}`)
revalidateTag(tag)
return Response.json({ revalidated: true, now: Date.now() })
} catch (error) {
console.error("Failed to revalidate tag(s) for hotel")
console.error(error)
return internalServerError({ revalidated: false, now: Date.now() })
}
}

View File

@@ -1,4 +1,4 @@
import { ScandicLogoIcon } from "@/components/Icons" import HotelLogo from "@/components/Icons/Logos"
import Image from "@/components/Image" import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider" import Divider from "@/components/TempDesignSystem/Divider"
@@ -10,25 +10,27 @@ import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import getSingleDecimal from "@/utils/numberFormatting" import getSingleDecimal from "@/utils/numberFormatting"
import { getTypeSpecificInformation } from "./utils"
import styles from "./hotelListingItem.module.css" import styles from "./hotelListingItem.module.css"
import type { HotelListingItemProps } from "@/types/components/contentPage/hotelListingItem" import type { HotelListingItemProps } from "@/types/components/contentPage/hotelListingItem"
export default async function HotelListingItem({ export default async function HotelListingItem({
imageUrl, hotel,
altText, contentType = "hotel",
name, url,
address,
distanceToCentre,
description,
link,
}: HotelListingItemProps) { }: HotelListingItemProps) {
const intl = await getIntl() const intl = await getIntl()
const { description, imageSrc, altText } = getTypeSpecificInformation(
contentType,
hotel
)
return ( return (
<article className={styles.container}> <article className={styles.container}>
<Image <Image
src={imageUrl} src={imageSrc}
alt={altText} alt={altText}
width={300} width={300}
height={200} height={200}
@@ -36,35 +38,43 @@ export default async function HotelListingItem({
/> />
<section className={styles.content}> <section className={styles.content}>
<div className={styles.intro}> <div className={styles.intro}>
<ScandicLogoIcon color="red" /> <HotelLogo hotelId={hotel.operaId} hotelType={hotel.hotelType} />
<Subtitle asChild> <Subtitle asChild>
<Title as="h3">{name}</Title> <Title as="h3">{hotel.name}</Title>
</Subtitle> </Subtitle>
<div className={styles.captions}> <div className={styles.captions}>
<Caption color="uiTextPlaceholder">{address}</Caption> <Caption color="uiTextPlaceholder">
{hotel.address.streetAddress}
</Caption>
<div className={styles.dividerContainer}> <div className={styles.dividerContainer}>
<Divider variant="vertical" color="beige" /> <Divider variant="vertical" color="beige" />
</div> </div>
<Caption color="uiTextPlaceholder"> <Caption color="uiTextPlaceholder">
{intl.formatMessage( {intl.formatMessage(
{ id: "Distance in km to city centre" }, { id: "Distance in km to city centre" },
{ number: getSingleDecimal(distanceToCentre / 1000) } {
number: getSingleDecimal(
hotel.location.distanceToCentre / 1000
),
}
)} )}
</Caption> </Caption>
</div> </div>
</div> </div>
<Body>{description}</Body> <Body>{description}</Body>
<Button {url && (
intent="primary" <Button
theme="base" intent="primary"
size="small" theme="base"
className={styles.button} size="small"
asChild className={styles.button}
> asChild
<Link href={link} color="white"> >
{intl.formatMessage({ id: "See hotel details" })} <Link href={url} color="white">
</Link> {intl.formatMessage({ id: "See hotel details" })}
</Button> </Link>
</Button>
)}
</section> </section>
</article> </article>
) )

View File

@@ -0,0 +1,36 @@
import type { Hotel } from "@/types/hotel"
import type { HotelListing } from "@/types/trpc/routers/contentstack/blocks"
export function getTypeSpecificInformation(
contentType: HotelListing["contentType"],
hotel: Hotel
) {
const { restaurantsOverviewPage, images } = hotel.hotelContent
const { descriptions, meetingDescription } = hotel.hotelContent.texts
const hotelData = {
description: descriptions.short,
imageSrc: images.imageSizes.small,
altText: images.metaData.altText,
}
switch (contentType) {
case "meeting":
const meetingImage = hotel.conferencesAndMeetings?.heroImages[0]
return {
description: meetingDescription?.short || hotelData.description,
imageSrc: meetingImage?.imageSizes.small || hotelData.imageSrc,
altText: meetingImage?.metaData.altText || hotelData.altText,
}
case "restaurant":
const restaurantImage = hotel.restaurantImages?.heroImages[0]
return {
description:
restaurantsOverviewPage.restaurantsContentDescriptionShort ||
hotelData.description,
imageSrc: restaurantImage?.imageSizes.small || hotelData.imageSrc,
altText: restaurantImage?.metaData.altText || hotelData.altText,
}
case "hotel":
default:
return hotelData
}
}

View File

@@ -0,0 +1,40 @@
import { getHotels } from "@/lib/trpc/memoizedRequests"
import SectionContainer from "@/components/Section/Container"
import Title from "@/components/TempDesignSystem/Text/Title"
import HotelListingItem from "./HotelListingItem"
import type { HotelListingProps } from "@/types/components/blocks/hotelListing"
export default async function HotelListing({
heading,
locationFilter,
hotelsToInclude,
contentType,
}: HotelListingProps) {
const hotels = await getHotels({
locationFilter,
hotelsToInclude: hotelsToInclude,
})
if (!hotels.length) {
return null
}
return (
<SectionContainer>
<Title level="h4" as="h3" textTransform="capitalize">
{heading}
</Title>
{hotels.map(({ data, url }) => (
<HotelListingItem
key={data.name}
hotel={data}
contentType={contentType}
url={url}
/>
))}
</SectionContainer>
)
}

View File

@@ -6,13 +6,14 @@ import UspGrid from "@/components/Blocks/UspGrid"
import JsonToHtml from "@/components/JsonToHtml" import JsonToHtml from "@/components/JsonToHtml"
import AccordionSection from "./Accordion" import AccordionSection from "./Accordion"
import HotelListing from "./HotelListing"
import Table from "./Table" import Table from "./Table"
import type { BlocksProps } from "@/types/components/blocks" import type { BlocksProps } from "@/types/components/blocks"
import { BlocksEnums } from "@/types/enums/blocks" import { BlocksEnums } from "@/types/enums/blocks"
export default function Blocks({ blocks }: BlocksProps) { export default function Blocks({ blocks }: BlocksProps) {
return blocks.map((block, idx) => { return blocks.map(async (block, idx) => {
const firstItem = idx === 0 const firstItem = idx === 0
switch (block.typename) { switch (block.typename) {
case BlocksEnums.block.Accordion: case BlocksEnums.block.Accordion:
@@ -48,6 +49,21 @@ export default function Blocks({ blocks }: BlocksProps) {
key={`${block.dynamic_content.title}-${idx}`} key={`${block.dynamic_content.title}-${idx}`}
/> />
) )
case BlocksEnums.block.HotelListing:
const { heading, contentType, locationFilter, hotelsToInclude } =
block.hotel_listing
if (!locationFilter && !hotelsToInclude.length) {
return null
}
return (
<HotelListing
heading={heading}
locationFilter={locationFilter}
hotelsToInclude={hotelsToInclude}
contentType={contentType}
/>
)
case BlocksEnums.block.Shortcuts: case BlocksEnums.block.Shortcuts:
return ( return (
<ShortcutsList <ShortcutsList

View File

@@ -32,24 +32,28 @@ export default function PreviewImages({
className={styles.image} className={styles.image}
/> />
))} ))}
<Button {images.length > 1 && (
theme="base" <>
intent="inverted" <Button
size="small" theme="base"
onClick={() => setLightboxIsOpen(true)} intent="inverted"
className={styles.seeAllButton} size="small"
> onClick={() => setLightboxIsOpen(true)}
{intl.formatMessage({ id: "See all photos" })} className={styles.seeAllButton}
</Button> >
<Lightbox {intl.formatMessage({ id: "See all photos" })}
images={images} </Button>
dialogTitle={intl.formatMessage( <Lightbox
{ id: "Image gallery" }, images={images}
{ name: hotelName } dialogTitle={intl.formatMessage(
)} { id: "Image gallery" },
isOpen={lightboxIsOpen} { name: hotelName }
onClose={() => setLightboxIsOpen(false)} )}
/> isOpen={lightboxIsOpen}
onClose={() => setLightboxIsOpen(false)}
/>
</>
)}
</div> </div>
) )
} }

View File

@@ -0,0 +1,44 @@
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import Body from "@/components/TempDesignSystem/Text/Body"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { BedTypeInfoProps } from "@/types/components/hotelReservation/enterDetails/bedType"
export default function BedTypeInfo({ hasMultipleBedTypes }: BedTypeInfoProps) {
const intl = useIntl()
const hasChildWithExtraBed = useEnterDetailsStore((state) =>
state.booking.rooms[0].children?.some(
(child) => Number(child.bed) === ChildBedMapEnum.IN_EXTRA_BED
)
)
const availabilityText = intl.formatMessage({
id: "Your selected bed type will be provided based on availability",
})
const extraBedText = intl.formatMessage({
id: "Extra bed will be provided additionally",
})
if (hasMultipleBedTypes && hasChildWithExtraBed) {
return (
<Body>
{availabilityText}. {extraBedText}
</Body>
)
}
if (hasMultipleBedTypes) {
return <Body>{availabilityText}</Body>
}
if (hasChildWithExtraBed) {
return <Body>{extraBedText}</Body>
}
return null
}

View File

@@ -1,3 +1,9 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.form { .form {
display: grid; display: grid;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);

View File

@@ -9,6 +9,7 @@ import { useEnterDetailsStore } from "@/stores/enter-details"
import { KingBedIcon } from "@/components/Icons" import { KingBedIcon } from "@/components/Icons"
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio" import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
import BedTypeInfo from "./BedTypeInfo"
import { bedTypeFormSchema } from "./schema" import { bedTypeFormSchema } from "./schema"
import styles from "./bedOptions.module.css" import styles from "./bedOptions.module.css"
@@ -62,26 +63,29 @@ export default function BedType({ bedTypes }: BedTypeProps) {
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}> <div className={styles.container}>
{bedTypes.map((roomType) => { <BedTypeInfo hasMultipleBedTypes={bedTypes.length > 1} />
const width = <form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
roomType.size.max === roomType.size.min {bedTypes.map((roomType) => {
? `${roomType.size.min} cm` const width =
: `${roomType.size.min} cm - ${roomType.size.max} cm` roomType.size.max === roomType.size.min
return ( ? `${roomType.size.min} cm`
<RadioCard : `${roomType.size.min} cm - ${roomType.size.max} cm`
key={roomType.value} return (
Icon={KingBedIcon} <RadioCard
iconWidth={46} key={roomType.value}
id={roomType.value} Icon={KingBedIcon}
name="bedType" iconWidth={46}
subtitle={width} id={roomType.value}
title={roomType.description} name="bedType"
value={roomType.value} subtitle={width}
/> title={roomType.description}
) value={roomType.value}
})} />
</form> )
})}
</form>
</div>
</FormProvider> </FormProvider>
) )
} }

View File

@@ -22,6 +22,7 @@ import Modal from "../../Modal"
import styles from "./ui.module.css" import styles from "./ui.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { SummaryProps } from "@/types/components/hotelReservation/summary" import type { SummaryProps } from "@/types/components/hotelReservation/summary"
import type { DetailsState } from "@/types/stores/enter-details" import type { DetailsState } from "@/types/stores/enter-details"
@@ -67,6 +68,25 @@ export default function SummaryUI({
const adults = booking.rooms[0].adults const adults = booking.rooms[0].adults
const children = booking.rooms[0].children const children = booking.rooms[0].children
const childrenBeds = children?.reduce(
(acc, value) => {
const bedType = Number(value.bed)
if (bedType === ChildBedMapEnum.IN_ADULTS_BED) {
return acc
}
const count = acc.get(bedType) ?? 0
acc.set(bedType, count + 1)
return acc
},
new Map<ChildBedMapEnum, number>([
[ChildBedMapEnum.IN_CRIB, 0],
[ChildBedMapEnum.IN_EXTRA_BED, 0],
])
)
const childBedCrib = childrenBeds?.get(ChildBedMapEnum.IN_CRIB)
const childBedExtraBed = childrenBeds?.get(ChildBedMapEnum.IN_EXTRA_BED)
const memberPrice = roomRate.memberRate const memberPrice = roomRate.memberRate
? { ? {
currency: roomRate.memberRate.localPrice.currency, currency: roomRate.memberRate.localPrice.currency,
@@ -179,12 +199,7 @@ export default function SummaryUI({
: null} : null}
{bedType ? ( {bedType ? (
<div className={styles.entry}> <div className={styles.entry}>
<div> <Body color="uiTextHighContrast">{bedType.description}</Body>
<Body color="uiTextHighContrast">{bedType.description}</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Based on availability" })}
</Caption>
</div>
<Body color="uiTextHighContrast"> <Body color="uiTextHighContrast">
{intl.formatNumber(0, { {intl.formatNumber(0, {
@@ -194,7 +209,39 @@ export default function SummaryUI({
</Body> </Body>
</div> </div>
) : null} ) : null}
{childBedCrib ? (
<div className={styles.entry}>
<div>
<Body color="uiTextHighContrast">
{`${intl.formatMessage({ id: "Crib (child)" })} × ${childBedCrib}`}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Based on availability" })}
</Caption>
</div>
<Body color="uiTextHighContrast">
{intl.formatNumber(0, {
currency: roomPrice.local.currency,
style: "currency",
})}
</Body>
</div>
) : null}
{childBedExtraBed ? (
<div className={styles.entry}>
<div>
<Body color="uiTextHighContrast">
{`${intl.formatMessage({ id: "Extra bed (child)" })} × ${childBedExtraBed}`}
</Body>
</div>
<Body color="uiTextHighContrast">
{intl.formatNumber(0, {
currency: roomPrice.local.currency,
style: "currency",
})}
</Body>
</div>
) : null}
{breakfast === false ? ( {breakfast === false ? (
<div className={styles.entry}> <div className={styles.entry}>
<Body color="uiTextHighContrast"> <Body color="uiTextHighContrast">

View File

@@ -7,6 +7,7 @@ import { selectRate } from "@/constants/routes/hotelReservation"
import { useHotelsMapStore } from "@/stores/hotels-map" import { useHotelsMapStore } from "@/stores/hotels-map"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data" import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import HotelLogo from "@/components/Icons/Logos"
import ImageGallery from "@/components/ImageGallery" 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"
@@ -18,7 +19,6 @@ import getSingleDecimal from "@/utils/numberFormatting"
import ReadMore from "../ReadMore" import ReadMore from "../ReadMore"
import TripAdvisorChip from "../TripAdvisorChip" import TripAdvisorChip from "../TripAdvisorChip"
import HotelLogo from "./HotelLogo"
import HotelPriceCard from "./HotelPriceCard" import HotelPriceCard from "./HotelPriceCard"
import NoPriceAvailableCard from "./NoPriceAvailableCard" import NoPriceAvailableCard from "./NoPriceAvailableCard"
import { hotelCardVariants } from "./variants" import { hotelCardVariants } from "./variants"

View File

@@ -8,7 +8,7 @@ import {
ScandicLogoIcon, ScandicLogoIcon,
} from "@/components/Icons" } from "@/components/Icons"
import type { HotelLogoProps } from "@/types/components/hotelReservation/selectHotel/hotelLogoProps" import type { HotelLogoProps } from "@/types/components/hotelLogo"
import { HotelTypeEnum } from "@/types/enums/hotelType" import { HotelTypeEnum } from "@/types/enums/hotelType"
import { SignatureHotelEnum } from "@/types/enums/signatureHotel" import { SignatureHotelEnum } from "@/types/enums/signatureHotel"

View File

@@ -52,7 +52,7 @@ export const countriesMap = {
"Congo, The Democratic Republic of the": "CD", "Congo, The Democratic Republic of the": "CD",
"Cook Islands": "CK", "Cook Islands": "CK",
"Costa Rica": "CR", "Costa Rica": "CR",
'Cote D"Ivoire': "CI", "Côte d'Ivoire": "CI",
Croatia: "HR", Croatia: "HR",
Cuba: "CU", Cuba: "CU",
Curacao: "CW", Curacao: "CW",
@@ -109,7 +109,6 @@ export const countriesMap = {
"Isle of Man": "IM", "Isle of Man": "IM",
Israel: "IL", Israel: "IL",
Italy: "IT", Italy: "IT",
"Ivory Coast": "CI",
Jamaica: "JM", Jamaica: "JM",
Japan: "JP", Japan: "JP",
Jersey: "JE", Jersey: "JE",
@@ -171,7 +170,7 @@ export const countriesMap = {
Oman: "OM", Oman: "OM",
Pakistan: "PK", Pakistan: "PK",
Palau: "PW", Palau: "PW",
"Palestinian Territory, Occupied": "PS", Palestine: "PS",
Panama: "PA", Panama: "PA",
"Papua New Guinea": "PG", "Papua New Guinea": "PG",
Paraguay: "PY", Paraguay: "PY",
@@ -215,7 +214,6 @@ export const countriesMap = {
Sudan: "SD", Sudan: "SD",
Suriname: "SR", Suriname: "SR",
"Svalbard and Jan Mayen": "SJ", "Svalbard and Jan Mayen": "SJ",
Swaziland: "SZ",
Sweden: "SE", Sweden: "SE",
Switzerland: "CH", Switzerland: "CH",
"Syrian Arab Republic": "SY", "Syrian Arab Republic": "SY",

8
env/client.ts vendored
View File

@@ -5,10 +5,18 @@ export const env = createEnv({
client: { client: {
NEXT_PUBLIC_NODE_ENV: z.enum(["development", "test", "production"]), NEXT_PUBLIC_NODE_ENV: z.enum(["development", "test", "production"]),
NEXT_PUBLIC_PORT: z.string().default("3000"), NEXT_PUBLIC_PORT: z.string().default("3000"),
NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE: z
.string()
// only allow "true" or "false"
.refine((s) => s === "true" || s === "false")
// transform to boolean
.transform((s) => s === "true"),
}, },
emptyStringAsUndefined: true, emptyStringAsUndefined: true,
runtimeEnv: { runtimeEnv: {
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_PORT: process.env.NEXT_PUBLIC_PORT, NEXT_PUBLIC_PORT: process.env.NEXT_PUBLIC_PORT,
NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE:
process.env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE,
}, },
}) })

2
env/server.ts vendored
View File

@@ -185,7 +185,7 @@ export const env = createEnv({
process.env.GOOGLE_STATIC_MAP_SIGNATURE_SECRET, process.env.GOOGLE_STATIC_MAP_SIGNATURE_SECRET,
GOOGLE_STATIC_MAP_ID: process.env.GOOGLE_STATIC_MAP_ID, GOOGLE_STATIC_MAP_ID: process.env.GOOGLE_STATIC_MAP_ID,
GOOGLE_DYNAMIC_MAP_ID: process.env.GOOGLE_DYNAMIC_MAP_ID, GOOGLE_DYNAMIC_MAP_ID: process.env.GOOGLE_DYNAMIC_MAP_ID,
HIDE_FOR_NEXT_RELEASE: process.env.HIDE_FOR_NEXT_RELEASE, HIDE_FOR_NEXT_RELEASE: process.env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE,
USE_NEW_REWARDS_ENDPOINT: process.env.USE_NEW_REWARDS_ENDPOINT, USE_NEW_REWARDS_ENDPOINT: process.env.USE_NEW_REWARDS_ENDPOINT,
USE_NEW_REWARD_MODEL: process.env.USE_NEW_REWARD_MODEL, USE_NEW_REWARD_MODEL: process.env.USE_NEW_REWARD_MODEL,
ENABLE_BOOKING_FLOW: process.env.ENABLE_BOOKING_FLOW, ENABLE_BOOKING_FLOW: process.env.ENABLE_BOOKING_FLOW,

View File

@@ -1,9 +1,10 @@
"use client" "use client"
import { useEffect } from "react" import { useEffect, useState } from "react"
import { env } from "@/env/client"
import useStickyPositionStore, { import useStickyPositionStore, {
StickyElementNameEnum, type StickyElementNameEnum,
} from "@/stores/sticky-position" } from "@/stores/sticky-position"
import { debounce } from "@/utils/debounce" import { debounce } from "@/utils/debounce"
@@ -44,6 +45,15 @@ export default function useStickyPosition({
getAllElements, getAllElements,
} = useStickyPositionStore() } = useStickyPositionStore()
/* Used for Current mobile header since that doesn't use this hook.
*
* Instead, calculate if the mobile header is shown and add the height of
* that "manually" to all offsets using this hook.
*
* TODO: Remove this and just use 0 when the current header has been removed.
*/
const [baseTopOffset, setBaseTopOffset] = useState(0)
useEffect(() => { useEffect(() => {
if (ref && name) { if (ref && name) {
// Register the sticky element with the given ref, name, and group. // Register the sticky element with the given ref, name, and group.
@@ -79,19 +89,28 @@ export default function useStickyPosition({
const topOffset = stickyElements const topOffset = stickyElements
.slice(0, index) .slice(0, index)
.filter((el) => el.group !== currentGroup) .filter((el) => el.group !== currentGroup)
.reduce((acc, el) => acc + el.height, 0) .reduce((acc, el) => acc + el.height, baseTopOffset)
// Apply the calculated top offset to the current element's style. // Apply the calculated top offset to the current element's style.
// This positions the element at the correct location within the document. // This positions the element at the correct location within the document.
ref.current.style.top = `${topOffset}px` ref.current.style.top = `${topOffset}px`
} }
} }
}, [stickyElements, ref]) }, [baseTopOffset, stickyElements, ref])
useEffect(() => { useEffect(() => {
if (!resizeObserver) { if (!resizeObserver) {
const debouncedResizeHandler = debounce(() => { const debouncedResizeHandler = debounce(() => {
updateHeights() updateHeights()
// Only do this special handling if we have the current header
if (env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE) {
if (document.body.clientWidth > 950) {
setBaseTopOffset(0)
} else {
setBaseTopOffset(52.41) // The height of current mobile header
}
}
}, 100) }, 100)
resizeObserver = new ResizeObserver(debouncedResizeHandler) resizeObserver = new ResizeObserver(debouncedResizeHandler)

View File

@@ -105,6 +105,7 @@
"Creative spaces for meetings": "Kreative rum til møder", "Creative spaces for meetings": "Kreative rum til møder",
"Credit card": "Kreditkort", "Credit card": "Kreditkort",
"Credit card deleted successfully": "Kreditkort blev slettet", "Credit card deleted successfully": "Kreditkort blev slettet",
"Crib (child)": "Kørestol (barn)",
"Currency Code": "DKK", "Currency Code": "DKK",
"Current password": "Nuværende kodeord", "Current password": "Nuværende kodeord",
"Customer service": "Kundeservice", "Customer service": "Kundeservice",
@@ -142,6 +143,8 @@
"Expires at the earliest": "Udløber tidligst {date}", "Expires at the earliest": "Udløber tidligst {date}",
"Explore all levels and benefits": "Udforsk alle niveauer og fordele", "Explore all levels and benefits": "Udforsk alle niveauer og fordele",
"Explore nearby": "Udforsk i nærheden", "Explore nearby": "Udforsk i nærheden",
"Extra bed (child)": "Ekstra seng (barn)",
"Extra bed will be provided additionally": "Der vil blive stillet en ekstra seng til rådighed",
"Extras to your booking": "Tillæg til din booking", "Extras to your booking": "Tillæg til din booking",
"FAQ": "Ofte stillede spørgsmål", "FAQ": "Ofte stillede spørgsmål",
"Failed to delete credit card, please try again later.": "Kunne ikke slette kreditkort. Prøv venligst igen senere.", "Failed to delete credit card, please try again later.": "Kunne ikke slette kreditkort. Prøv venligst igen senere.",
@@ -486,6 +489,7 @@
"Your level": "Dit niveau", "Your level": "Dit niveau",
"Your points to spend": "Dine brugbare point", "Your points to spend": "Dine brugbare point",
"Your room": "Dit værelse", "Your room": "Dit værelse",
"Your selected bed type will be provided based on availability": "Din valgte sengtype vil blive stillet til rådighed baseret på tilgængelighed",
"Zip code": "Postnummer", "Zip code": "Postnummer",
"Zoo": "Zoo", "Zoo": "Zoo",
"Zoom in": "Zoom ind", "Zoom in": "Zoom ind",
@@ -532,7 +536,7 @@
"room type": "værelsestype", "room type": "værelsestype",
"room types": "værelsestyper", "room types": "værelsestyper",
"signup.terms": "Ved at tilmelde dig accepterer du Scandic Friends <termsLink>vilkår og betingelser</termsLink>. Dit medlemskab er gyldigt indtil videre, og du kan til enhver tid opsige dit medlemskab ved at sende en e-mail til Scandics kundeservice", "signup.terms": "Ved at tilmelde dig accepterer du Scandic Friends <termsLink>vilkår og betingelser</termsLink>. Dit medlemskab er gyldigt indtil videre, og du kan til enhver tid opsige dit medlemskab ved at sende en e-mail til Scandics kundeservice",
"signupPage.terms": "Ved at acceptere <termsAndConditions>vilkårene og betingelserne for Scandic Friends</termsAndConditions>, forstår jeg, at mine personlige oplysninger vil blive behandlet i overensstemmelse med<privacyPolicy>Scandics privatlivspolitik</privacyPolicy>.", "signupPage.terms": "Ved at acceptere <termsAndConditions>vilkårene og betingelserne for Scandic Friends</termsAndConditions>, forstår jeg, at mine personlige oplysninger vil blive behandlet i overensstemmelse med <privacyPolicy>Scandics privatlivspolitik</privacyPolicy>.",
"special character": "speciel karakter", "special character": "speciel karakter",
"spendable points expiring by": "{points} Brugbare point udløber den {date}", "spendable points expiring by": "{points} Brugbare point udløber den {date}",
"to": "til", "to": "til",

View File

@@ -105,6 +105,7 @@
"Creative spaces for meetings": "Kreative Räume für Meetings", "Creative spaces for meetings": "Kreative Räume für Meetings",
"Credit card": "Kreditkarte", "Credit card": "Kreditkarte",
"Credit card deleted successfully": "Kreditkarte erfolgreich gelöscht", "Credit card deleted successfully": "Kreditkarte erfolgreich gelöscht",
"Crib (child)": "Kinderbett (Kind)",
"Currency Code": "EUR", "Currency Code": "EUR",
"Current password": "Aktuelles Passwort", "Current password": "Aktuelles Passwort",
"Customer service": "Kundendienst", "Customer service": "Kundendienst",
@@ -142,6 +143,8 @@
"Expires at the earliest": "Läuft frühestens am {date} ab", "Expires at the earliest": "Läuft frühestens am {date} ab",
"Explore all levels and benefits": "Entdecken Sie alle Levels und Vorteile", "Explore all levels and benefits": "Entdecken Sie alle Levels und Vorteile",
"Explore nearby": "Erkunden Sie die Umgebung", "Explore nearby": "Erkunden Sie die Umgebung",
"Extra bed (child)": "Ekstra seng (Kind)",
"Extra bed will be provided additionally": "Ein zusätzliches Bett wird bereitgestellt",
"Extras to your booking": "Extras zu Ihrer Buchung", "Extras to your booking": "Extras zu Ihrer Buchung",
"FAQ": "Häufig gestellte Fragen", "FAQ": "Häufig gestellte Fragen",
"Failed to delete credit card, please try again later.": "Kreditkarte konnte nicht gelöscht werden. Bitte versuchen Sie es später noch einmal.", "Failed to delete credit card, please try again later.": "Kreditkarte konnte nicht gelöscht werden. Bitte versuchen Sie es später noch einmal.",
@@ -485,6 +488,7 @@
"Your level": "Dein level", "Your level": "Dein level",
"Your points to spend": "Meine Punkte", "Your points to spend": "Meine Punkte",
"Your room": "Ihr Zimmer", "Your room": "Ihr Zimmer",
"Your selected bed type will be provided based on availability": "Ihre ausgewählte Bettart wird basierend auf der Verfügbarkeit bereitgestellt",
"Zip code": "PLZ", "Zip code": "PLZ",
"Zoo": "Zoo", "Zoo": "Zoo",
"Zoom in": "Vergrößern", "Zoom in": "Vergrößern",

View File

@@ -112,6 +112,7 @@
"Creative spaces for meetings": "Creative spaces for meetings", "Creative spaces for meetings": "Creative spaces for meetings",
"Credit card": "Credit card", "Credit card": "Credit card",
"Credit card deleted successfully": "Credit card deleted successfully", "Credit card deleted successfully": "Credit card deleted successfully",
"Crib (child)": "Crib (child)",
"Currency Code": "EUR", "Currency Code": "EUR",
"Current password": "Current password", "Current password": "Current password",
"Customer service": "Customer service", "Customer service": "Customer service",
@@ -150,6 +151,8 @@
"Expires at the earliest": "Expires at the earliest {date}", "Expires at the earliest": "Expires at the earliest {date}",
"Explore all levels and benefits": "Explore all levels and benefits", "Explore all levels and benefits": "Explore all levels and benefits",
"Explore nearby": "Explore nearby", "Explore nearby": "Explore nearby",
"Extra bed (child)": "Extra bed (child)",
"Extra bed will be provided additionally": "Extra bed will be provided additionally",
"Extras to your booking": "Extras to your booking", "Extras to your booking": "Extras to your booking",
"FAQ": "FAQ", "FAQ": "FAQ",
"Failed to delete credit card, please try again later.": "Failed to delete credit card, please try again later.", "Failed to delete credit card, please try again later.": "Failed to delete credit card, please try again later.",
@@ -529,6 +532,7 @@
"Your level": "Your level", "Your level": "Your level",
"Your points to spend": "Your points to spend", "Your points to spend": "Your points to spend",
"Your room": "Your room", "Your room": "Your room",
"Your selected bed type will be provided based on availability": "Your selected bed type will be provided based on availability",
"Zip code": "Zip code", "Zip code": "Zip code",
"Zoo": "Zoo", "Zoo": "Zoo",
"Zoom in": "Zoom in", "Zoom in": "Zoom in",

View File

@@ -105,6 +105,7 @@
"Creative spaces for meetings": "Luovia tiloja kokouksille", "Creative spaces for meetings": "Luovia tiloja kokouksille",
"Credit card": "Luottokortti", "Credit card": "Luottokortti",
"Credit card deleted successfully": "Luottokortti poistettu onnistuneesti", "Credit card deleted successfully": "Luottokortti poistettu onnistuneesti",
"Crib (child)": "Körkkä (lasta)",
"Currency Code": "EUR", "Currency Code": "EUR",
"Current password": "Nykyinen salasana", "Current password": "Nykyinen salasana",
"Customer service": "Asiakaspalvelu", "Customer service": "Asiakaspalvelu",
@@ -142,6 +143,8 @@
"Expires at the earliest": "Päättyy aikaisintaan {date}", "Expires at the earliest": "Päättyy aikaisintaan {date}",
"Explore all levels and benefits": "Tutustu kaikkiin tasoihin ja etuihin", "Explore all levels and benefits": "Tutustu kaikkiin tasoihin ja etuihin",
"Explore nearby": "Tutustu lähialueeseen", "Explore nearby": "Tutustu lähialueeseen",
"Extra bed (child)": "Lisävuode (lasta)",
"Extra bed will be provided additionally": "Lisävuode toimitetaan erikseen",
"Extras to your booking": "Varauksessa lisäpalveluita", "Extras to your booking": "Varauksessa lisäpalveluita",
"FAQ": "Usein kysytyt kysymykset", "FAQ": "Usein kysytyt kysymykset",
"Failed to delete credit card, please try again later.": "Luottokortin poistaminen epäonnistui, yritä myöhemmin uudelleen.", "Failed to delete credit card, please try again later.": "Luottokortin poistaminen epäonnistui, yritä myöhemmin uudelleen.",
@@ -412,7 +415,7 @@
"Surprise!": "Yllätys!", "Surprise!": "Yllätys!",
"TUI Points": "TUI Points", "TUI Points": "TUI Points",
"Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Kerro meille, mitä tietoja ja päivityksiä haluat saada ja miten, napsauttamalla alla olevaa linkkiä.", "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Kerro meille, mitä tietoja ja päivityksiä haluat saada ja miten, napsauttamalla alla olevaa linkkiä.",
"Terms and conditions": "Käyttöehdot", "Terms and conditions": "Säännöt ja ehdot",
"Thank you": "Kiitos", "Thank you": "Kiitos",
"The new price is": "Uusi hinta on", "The new price is": "Uusi hinta on",
"The price has increased": "Hinta on noussut", "The price has increased": "Hinta on noussut",
@@ -484,6 +487,7 @@
"Your level": "Tasosi", "Your level": "Tasosi",
"Your points to spend": "Käytettävissä olevat pisteesi", "Your points to spend": "Käytettävissä olevat pisteesi",
"Your room": "Sinun huoneesi", "Your room": "Sinun huoneesi",
"Your selected bed type will be provided based on availability": "Valitun vuodetyypin toimitetaan saatavuuden mukaan",
"Zip code": "Postinumero", "Zip code": "Postinumero",
"Zoo": "Eläintarha", "Zoo": "Eläintarha",
"Zoom in": "Lähennä", "Zoom in": "Lähennä",

View File

@@ -104,6 +104,7 @@
"Country is required": "Land kreves", "Country is required": "Land kreves",
"Creative spaces for meetings": "Kreative rom for møter", "Creative spaces for meetings": "Kreative rom for møter",
"Credit card deleted successfully": "Kredittkort slettet", "Credit card deleted successfully": "Kredittkort slettet",
"Crib (child)": "Kørestol (barn)",
"Currency Code": "NOK", "Currency Code": "NOK",
"Current password": "Nåværende passord", "Current password": "Nåværende passord",
"Customer service": "Kundeservice", "Customer service": "Kundeservice",
@@ -141,6 +142,8 @@
"Expires at the earliest": "Utløper tidligst {date}", "Expires at the earliest": "Utløper tidligst {date}",
"Explore all levels and benefits": "Utforsk alle nivåer og fordeler", "Explore all levels and benefits": "Utforsk alle nivåer og fordeler",
"Explore nearby": "Utforsk i nærheten", "Explore nearby": "Utforsk i nærheten",
"Extra bed (child)": "Ekstra seng (barn)",
"Extra bed will be provided additionally": "Ekstra seng vil bli tilgjengelig",
"Extras to your booking": "Tilvalg til bestillingen din", "Extras to your booking": "Tilvalg til bestillingen din",
"FAQ": "Ofte stilte spørsmål", "FAQ": "Ofte stilte spørsmål",
"Failed to delete credit card, please try again later.": "Kunne ikke slette kredittkortet, prøv igjen senere.", "Failed to delete credit card, please try again later.": "Kunne ikke slette kredittkortet, prøv igjen senere.",
@@ -484,6 +487,7 @@
"Your level": "Ditt nivå", "Your level": "Ditt nivå",
"Your points to spend": "Dine brukbare poeng", "Your points to spend": "Dine brukbare poeng",
"Your room": "Rommet ditt", "Your room": "Rommet ditt",
"Your selected bed type will be provided based on availability": "Din valgte sengtype vil blive stillet til rådighed baseret på tilgængelighed",
"Zip code": "Post kode", "Zip code": "Post kode",
"Zoo": "Dyrehage", "Zoo": "Dyrehage",
"Zoom in": "Zoom inn", "Zoom in": "Zoom inn",

View File

@@ -104,6 +104,7 @@
"Country is required": "Land är obligatoriskt", "Country is required": "Land är obligatoriskt",
"Creative spaces for meetings": "Kreativa utrymmen för möten", "Creative spaces for meetings": "Kreativa utrymmen för möten",
"Credit card deleted successfully": "Kreditkort har tagits bort", "Credit card deleted successfully": "Kreditkort har tagits bort",
"Crib (child)": "Spjälsäng (barn)",
"Currency Code": "SEK", "Currency Code": "SEK",
"Current password": "Nuvarande lösenord", "Current password": "Nuvarande lösenord",
"Customer service": "Kundservice", "Customer service": "Kundservice",
@@ -141,6 +142,8 @@
"Expires at the earliest": "Löper ut tidigast {date}", "Expires at the earliest": "Löper ut tidigast {date}",
"Explore all levels and benefits": "Utforska alla nivåer och fördelar", "Explore all levels and benefits": "Utforska alla nivåer och fördelar",
"Explore nearby": "Utforska i närheten", "Explore nearby": "Utforska i närheten",
"Extra bed (child)": "Extra säng (barn)",
"Extra bed will be provided additionally": "Extra säng kommer att tillhandahållas",
"Extras to your booking": "Extra tillval till din bokning", "Extras to your booking": "Extra tillval till din bokning",
"FAQ": "FAQ", "FAQ": "FAQ",
"Failed to delete credit card, please try again later.": "Det gick inte att ta bort kreditkortet, försök igen senare.", "Failed to delete credit card, please try again later.": "Det gick inte att ta bort kreditkortet, försök igen senare.",
@@ -484,6 +487,7 @@
"Your level": "Din nivå", "Your level": "Din nivå",
"Your points to spend": "Dina spenderbara poäng", "Your points to spend": "Dina spenderbara poäng",
"Your room": "Ditt rum", "Your room": "Ditt rum",
"Your selected bed type will be provided based on availability": "Din valda sängtyp kommer att tillhandahållas baserat på tillgänglighet",
"Zip code": "Postnummer", "Zip code": "Postnummer",
"Zoo": "Djurpark", "Zoo": "Djurpark",
"Zoom in": "Zooma in", "Zoom in": "Zooma in",

View File

@@ -30,20 +30,11 @@ const wrappedFetch = fetchRetry(fetch, {
export async function get( export async function get(
endpoint: Endpoint, endpoint: Endpoint,
options: RequestOptionsWithOutBody, options: RequestOptionsWithOutBody,
params: Record<string, any> = {} params = {}
) { ) {
const url = new URL(env.API_BASEURL) const url = new URL(env.API_BASEURL)
url.pathname = endpoint url.pathname = endpoint
const searchParams = new URLSearchParams() url.search = new URLSearchParams(params).toString()
Object.entries(params).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((val) => searchParams.append(key, val))
} else {
searchParams.set(key, value)
}
})
url.search = searchParams.toString()
return wrappedFetch( return wrappedFetch(
url, url,
merge.all([defaultOptions, { method: "GET" }, options]) merge.all([defaultOptions, { method: "GET" }, options])

View File

@@ -0,0 +1,24 @@
fragment HotelListing on HotelListing {
heading
location_filter {
city_denmark
city_finland
city_germany
city_norway
city_poland
city_sweden
country
excluded
}
manual_filter {
hotels
}
content_type
}
fragment HotelListing_ContentPage on ContentPageBlocksHotelListing {
__typename
hotel_listing {
...HotelListing
}
}

View File

@@ -4,6 +4,7 @@
#import "../../Fragments/Blocks/CardsGrid.graphql" #import "../../Fragments/Blocks/CardsGrid.graphql"
#import "../../Fragments/Blocks/Content.graphql" #import "../../Fragments/Blocks/Content.graphql"
#import "../../Fragments/Blocks/DynamicContent.graphql" #import "../../Fragments/Blocks/DynamicContent.graphql"
#import "../../Fragments/Blocks/HotelListing.graphql"
#import "../../Fragments/Blocks/Shortcuts.graphql" #import "../../Fragments/Blocks/Shortcuts.graphql"
#import "../../Fragments/Blocks/Table.graphql" #import "../../Fragments/Blocks/Table.graphql"
#import "../../Fragments/Blocks/TextCols.graphql" #import "../../Fragments/Blocks/TextCols.graphql"
@@ -65,6 +66,7 @@ query GetContentPageBlocksBatch2($locale: String!, $uid: String!) {
content_page(uid: $uid, locale: $locale) { content_page(uid: $uid, locale: $locale) {
blocks { blocks {
__typename __typename
...HotelListing_ContentPage
...Shortcuts_ContentPage ...Shortcuts_ContentPage
...Table_ContentPage ...Table_ContentPage
...TextCols_ContentPage ...TextCols_ContentPage

View File

@@ -0,0 +1,12 @@
#import "../../Fragments/System.graphql"
query GetHotelPageUrl($locale: String!, $hotelId: String!) {
all_hotel_page(locale: $locale, where: { hotel_page_id: $hotelId }) {
items {
url
system {
...System
}
}
}
}

View File

@@ -8,6 +8,7 @@ import type {
} from "@/types/requests/packages" } from "@/types/requests/packages"
import type { Lang } from "@/constants/languages" import type { Lang } from "@/constants/languages"
import type { import type {
GetHotelsInput,
GetRoomsAvailabilityInput, GetRoomsAvailabilityInput,
GetSelectedRoomAvailabilityInput, GetSelectedRoomAvailabilityInput,
HotelDataInput, HotelDataInput,
@@ -62,6 +63,12 @@ export const getUserTracking = cache(async function getMemoizedUserTracking() {
return serverClient().user.tracking() return serverClient().user.tracking()
}) })
export const getHotels = cache(async function getMemoizedHotels(
input: GetHotelsInput
) {
return serverClient().hotel.hotels.get(input)
})
export const getHotelData = cache(async function getMemoizedHotelData( export const getHotelData = cache(async function getMemoizedHotelData(
input: HotelDataInput input: HotelDataInput
) { ) {

View File

@@ -18,6 +18,7 @@ import {
dynamicContentRefsSchema, dynamicContentRefsSchema,
dynamicContentSchema as blockDynamicContentSchema, dynamicContentSchema as blockDynamicContentSchema,
} from "../schemas/blocks/dynamicContent" } from "../schemas/blocks/dynamicContent"
import { hotelListingSchema } from "../schemas/blocks/hotelListing"
import { import {
shortcutsRefsSchema, shortcutsRefsSchema,
shortcutsSchema, shortcutsSchema,
@@ -103,6 +104,12 @@ export const contentPageAccordion = z
}) })
.merge(accordionSchema) .merge(accordionSchema)
export const contentPageHotelListing = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.HotelListing),
})
.merge(hotelListingSchema)
export const blocksSchema = z.discriminatedUnion("__typename", [ export const blocksSchema = z.discriminatedUnion("__typename", [
contentPageAccordion, contentPageAccordion,
contentPageCards, contentPageCards,
@@ -112,6 +119,7 @@ export const blocksSchema = z.discriminatedUnion("__typename", [
contentPageTable, contentPageTable,
contentPageTextCols, contentPageTextCols,
contentPageUspGrid, contentPageUspGrid,
contentPageHotelListing,
]) ])
export const contentPageSidebarContent = z export const contentPageSidebarContent = z

View File

@@ -2,6 +2,8 @@ import { z } from "zod"
import { discriminatedUnionArray } from "@/lib/discriminatedUnion" import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
import { removeMultipleSlashes } from "@/utils/url"
import { import {
activitiesCardRefSchema, activitiesCardRefSchema,
activitiesCardSchema, activitiesCardSchema,
@@ -58,3 +60,26 @@ export const hotelPageRefsSchema = z.object({
url: z.string(), url: z.string(),
}), }),
}) })
export const hotelPageUrlSchema = z
.object({
all_hotel_page: z.object({
items: z
.array(
z.object({
url: z.string(),
system: systemSchema,
})
)
.max(1),
}),
})
.transform((data) => {
const page = data.all_hotel_page.items[0]
if (!page) {
return null
}
const lang = page.system.locale
return removeMultipleSlashes(`/${lang}/${page.url}`)
})

View File

@@ -1,5 +1,3 @@
import { metrics } from "@opentelemetry/api"
import { GetHotelPage } from "@/lib/graphql/Query/HotelPage/HotelPage.graphql" import { GetHotelPage } from "@/lib/graphql/Query/HotelPage/HotelPage.graphql"
import { request } from "@/lib/graphql/request" import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc" import { notFound } from "@/server/errors/trpc"
@@ -8,20 +6,13 @@ import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
import { generateTag } from "@/utils/generateTag" import { generateTag } from "@/utils/generateTag"
import { hotelPageSchema } from "./output" import { hotelPageSchema } from "./output"
import {
getHotelPageCounter,
getHotelPageFailCounter,
getHotelPageSuccessCounter,
} from "./telemetry"
import { GetHotelPageData } from "@/types/trpc/routers/contentstack/hotelPage" import type { GetHotelPageData } from "@/types/trpc/routers/contentstack/hotelPage"
// OpenTelemetry metrics
const meter = metrics.getMeter("trpc.contentstack.hotelPage")
const getHotelPageCounter = meter.createCounter(
"trpc.contentstack.hotelPage.get"
)
const getHotelPageSuccessCounter = meter.createCounter(
"trpc.contentstack.hotelPage.get-success"
)
const getHotelPageFailCounter = meter.createCounter(
"trpc.contentstack.hotelPage.get-fail"
)
export const hotelPageQueryRouter = router({ export const hotelPageQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => { get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {

View File

@@ -0,0 +1,33 @@
import { metrics } from "@opentelemetry/api"
const meter = metrics.getMeter("trpc.contentstack.hotelPage")
export const getHotelPageRefsCounter = meter.createCounter(
"trpc.contentstack.hotelPage.get"
)
export const getHotelPageRefsFailCounter = meter.createCounter(
"trpc.contentstack.hotelPage.get-fail"
)
export const getHotelPageRefsSuccessCounter = meter.createCounter(
"trpc.contentstack.hotelPage.get-success"
)
export const getHotelPageCounter = meter.createCounter(
"trpc.contentstack.hotelPage.get"
)
export const getHotelPageSuccessCounter = meter.createCounter(
"trpc.contentstack.hotelPage.get-success"
)
export const getHotelPageFailCounter = meter.createCounter(
"trpc.contentstack.hotelPage.get-fail"
)
export const getHotelPageUrlCounter = meter.createCounter(
"trpc.contentstack.hotelPageUrl.get"
)
export const getHotelPageUrlSuccessCounter = meter.createCounter(
"trpc.contentstack.hotelPageUrl.get-success"
)
export const getHotelPageUrlFailCounter = meter.createCounter(
"trpc.contentstack.hotelPageUrl.get-fail"
)

View File

@@ -1,37 +1,32 @@
import { metrics } from "@opentelemetry/api"
import { Lang } from "@/constants/languages"
import { GetHotelPageRefs } from "@/lib/graphql/Query/HotelPage/HotelPage.graphql" import { GetHotelPageRefs } from "@/lib/graphql/Query/HotelPage/HotelPage.graphql"
import { GetHotelPageUrl } from "@/lib/graphql/Query/HotelPage/HotelPageUrl.graphql"
import { request } from "@/lib/graphql/request" import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc" import { notFound } from "@/server/errors/trpc"
import { generateTag, generateTagsFromSystem } from "@/utils/generateTag" import {
generateHotelUrlTag,
generateTag,
generateTagsFromSystem,
} from "@/utils/generateTag"
import { hotelPageRefsSchema } from "./output" import { hotelPageRefsSchema, hotelPageUrlSchema } from "./output"
import {
getHotelPageRefsCounter,
getHotelPageRefsFailCounter,
getHotelPageRefsSuccessCounter,
getHotelPageUrlCounter,
getHotelPageUrlFailCounter,
getHotelPageUrlSuccessCounter,
} from "./telemetry"
import { HotelPageEnum } from "@/types/enums/hotelPage" import { HotelPageEnum } from "@/types/enums/hotelPage"
import { System } from "@/types/requests/system" import type { System } from "@/types/requests/system"
import { import type {
GetHotelPageRefsSchema, GetHotelPageRefsSchema,
GetHotelPageUrlData,
HotelPageRefs, HotelPageRefs,
} from "@/types/trpc/routers/contentstack/hotelPage" } from "@/types/trpc/routers/contentstack/hotelPage"
import type { Lang } from "@/constants/languages"
const meter = metrics.getMeter("trpc.hotelPage")
// OpenTelemetry metrics: HotelPage
export const getHotelPageCounter = meter.createCounter(
"trpc.contentstack.hotelPage.get"
)
const getHotelPageRefsCounter = meter.createCounter(
"trpc.contentstack.hotelPage.get"
)
const getHotelPageRefsFailCounter = meter.createCounter(
"trpc.contentstack.hotelPage.get-fail"
)
const getHotelPageRefsSuccessCounter = meter.createCounter(
"trpc.contentstack.hotelPage.get-success"
)
export async function fetchHotelPageRefs(lang: Lang, uid: string) { export async function fetchHotelPageRefs(lang: Lang, uid: string) {
getHotelPageRefsCounter.add(1, { lang, uid }) getHotelPageRefsCounter.add(1, { lang, uid })
@@ -140,3 +135,64 @@ export function getConnections({ hotel_page }: HotelPageRefs) {
} }
return connections return connections
} }
export async function getHotelPageUrl(lang: Lang, hotelId: string) {
getHotelPageUrlCounter.add(1, { lang, hotelId })
console.info(
"contentstack.hotelPageUrl start",
JSON.stringify({ query: { lang, hotelId } })
)
const response = await request<GetHotelPageUrlData>(
GetHotelPageUrl,
{
locale: lang,
hotelId,
},
{
cache: "force-cache",
next: {
tags: [generateHotelUrlTag(lang, hotelId)],
},
}
)
if (!response.data) {
getHotelPageUrlFailCounter.add(1, {
lang,
hotelId,
error_type: "not_found",
error: `Hotel page not found for hotelId: ${hotelId}`,
})
console.error(
"contentstack.hotelPageUrl not found error",
JSON.stringify({ query: { lang, hotelId } })
)
return null
}
const validatedHotelPageUrl = hotelPageUrlSchema.safeParse(response.data)
if (!validatedHotelPageUrl.success) {
getHotelPageUrlFailCounter.add(1, {
lang,
hotelId,
error_type: "validation_error",
error: JSON.stringify(validatedHotelPageUrl.error),
})
console.error(
"contentstack.hotelPageUrl validation error",
JSON.stringify({
query: { lang, hotelId },
error: validatedHotelPageUrl.error,
})
)
return null
}
getHotelPageUrlSuccessCounter.add(1, { lang, hotelId })
console.info(
"contentstack.hotelPageUrl success",
JSON.stringify({ query: { lang, hotelId } })
)
return validatedHotelPageUrl.data
}

View File

@@ -0,0 +1,62 @@
import { z } from "zod"
import { BlocksEnums } from "@/types/enums/blocks"
import { Country } from "@/types/enums/country"
export const locationFilterSchema = z
.object({
country: z.nativeEnum(Country).nullable(),
city_denmark: z.string().optional().nullable(),
city_finland: z.string().optional().nullable(),
city_germany: z.string().optional().nullable(),
city_poland: z.string().optional().nullable(),
city_norway: z.string().optional().nullable(),
city_sweden: z.string().optional().nullable(),
excluded: z.array(z.string()),
})
.transform((data) => {
const cities = [
data.city_denmark,
data.city_finland,
data.city_germany,
data.city_poland,
data.city_norway,
data.city_sweden,
].filter((city): city is string => Boolean(city))
// When there are multiple city values, we return null as the filter is invalid.
if (cities.length > 1) {
return null
}
return {
country: cities.length ? null : data.country,
city: cities.length ? cities[0] : null,
excluded: data.excluded,
}
})
export const hotelListingSchema = z.object({
typename: z
.literal(BlocksEnums.block.HotelListing)
.default(BlocksEnums.block.HotelListing),
hotel_listing: z
.object({
heading: z.string().optional(),
location_filter: locationFilterSchema,
manual_filter: z
.object({
hotels: z.array(z.string()),
})
.transform((data) => ({ hotels: data.hotels.filter(Boolean) })),
content_type: z.enum(["hotel", "restaurant", "meeting"]),
})
.transform(({ heading, location_filter, manual_filter, content_type }) => {
return {
heading,
locationFilter: location_filter,
hotelsToInclude: manual_filter.hotels,
contentType: content_type,
}
}),
})

View File

@@ -1,6 +1,7 @@
import { z } from "zod" import { z } from "zod"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { Country } from "@/types/enums/country"
export const getHotelsAvailabilityInputSchema = z.object({ export const getHotelsAvailabilityInputSchema = z.object({
cityId: z.string(), cityId: z.string(),
@@ -53,6 +54,18 @@ export const getHotelDataInputSchema = z.object({
export type HotelDataInput = z.input<typeof getHotelDataInputSchema> export type HotelDataInput = z.input<typeof getHotelDataInputSchema>
export const getHotelsInput = z.object({
locationFilter: z
.object({
city: z.string().nullable(),
country: z.nativeEnum(Country).nullable(),
excluded: z.array(z.string()),
})
.nullable(),
hotelsToInclude: z.array(z.string()),
})
export interface GetHotelsInput extends z.infer<typeof getHotelsInput> {}
export const getBreakfastPackageInputSchema = z.object({ export const getBreakfastPackageInputSchema = z.object({
adults: z.number().min(1, { message: "at least one adult is required" }), adults: z.number().min(1, { message: "at least one adult is required" }),
fromDate: z fromDate: z

View File

@@ -124,7 +124,7 @@ const hotelContentSchema = z.object({
}) })
const detailedFacilitySchema = z.object({ const detailedFacilitySchema = z.object({
id: z.nativeEnum(FacilityEnum), id: z.number(),
name: z.string(), name: z.string(),
public: z.boolean(), public: z.boolean(),
sortOrder: z.number(), sortOrder: z.number(),
@@ -389,6 +389,9 @@ const hotelFactsSchema = z.object({
yearBuilt: z.string(), yearBuilt: z.string(),
}) })
type DetailedFacility = { id: FacilityEnum } & z.infer<
typeof detailedFacilitySchema
>
export const hotelAttributesSchema = z.object({ export const hotelAttributesSchema = z.object({
accessibilityElevatorPitchText: z.string().optional(), accessibilityElevatorPitchText: z.string().optional(),
address: addressSchema, address: addressSchema,
@@ -396,11 +399,15 @@ export const hotelAttributesSchema = z.object({
cityName: z.string(), cityName: z.string(),
conferencesAndMeetings: facilitySchema.optional(), conferencesAndMeetings: facilitySchema.optional(),
contactInformation: contactInformationSchema, contactInformation: contactInformationSchema,
detailedFacilities: z detailedFacilities: z.array(detailedFacilitySchema).transform(
.array(detailedFacilitySchema) (facilities) =>
.transform((facilities) => facilities
facilities.sort((a, b) => b.sortOrder - a.sortOrder) // Filter away facilities with ID:s that we don't recognize
), .filter(
(f) => f.id !== undefined && f.id !== null && f.id in FacilityEnum
)
.sort((a, b) => b.sortOrder - a.sortOrder) as DetailedFacility[]
),
gallery: gallerySchema.optional(), gallery: gallerySchema.optional(),
galleryImages: z.array(imageSchema).optional(), galleryImages: z.array(imageSchema).optional(),
healthAndWellness: facilitySchema.optional(), healthAndWellness: facilitySchema.optional(),
@@ -870,3 +877,14 @@ export const getRoomPackagesSchema = z
.optional(), .optional(),
}) })
.transform((data) => data.data?.attributes?.packages ?? []) .transform((data) => data.data?.attributes?.packages ?? [])
export const getHotelIdsByCityIdSchema = z
.object({
data: z.array(
z.object({
// We only care about the hotel id
id: z.string(),
})
),
})
.transform((data) => data.data.map((hotel) => hotel.id))

View File

@@ -1,9 +1,9 @@
import { metrics } from "@opentelemetry/api" import { ApiLang } from "@/constants/languages"
import * as api from "@/lib/api" import * as api from "@/lib/api"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
import { badRequestError } from "@/server/errors/trpc" import { badRequestError } from "@/server/errors/trpc"
import { import {
contentStackBaseWithServiceProcedure,
publicProcedure, publicProcedure,
router, router,
safeProtectedServiceProcedure, safeProtectedServiceProcedure,
@@ -13,12 +13,14 @@ import { toApiLang } from "@/server/utils"
import { cache } from "@/utils/cache" import { cache } from "@/utils/cache"
import { getHotelPageUrl } from "../contentstack/hotelPage/utils"
import { getVerifiedUser, parsedUser } from "../user/query" import { getVerifiedUser, parsedUser } from "../user/query"
import { import {
getBreakfastPackageInputSchema, getBreakfastPackageInputSchema,
getCityCoordinatesInputSchema, getCityCoordinatesInputSchema,
getHotelDataInputSchema, getHotelDataInputSchema,
getHotelsAvailabilityInputSchema, getHotelsAvailabilityInputSchema,
getHotelsInput,
getRatesInputSchema, getRatesInputSchema,
getRoomPackagesInputSchema, getRoomPackagesInputSchema,
getRoomsAvailabilityInputSchema, getRoomsAvailabilityInputSchema,
@@ -33,10 +35,35 @@ import {
getRoomPackagesSchema, getRoomPackagesSchema,
getRoomsAvailabilitySchema, getRoomsAvailabilitySchema,
} from "./output" } from "./output"
import {
breakfastPackagesCounter,
breakfastPackagesFailCounter,
breakfastPackagesSuccessCounter,
getHotelCounter,
getHotelFailCounter,
getHotelsCounter,
getHotelsFailCounter,
getHotelsSuccessCounter,
getHotelSuccessCounter,
getPackagesCounter,
getPackagesFailCounter,
getPackagesSuccessCounter,
hotelsAvailabilityCounter,
hotelsAvailabilityFailCounter,
hotelsAvailabilitySuccessCounter,
roomsAvailabilityCounter,
roomsAvailabilityFailCounter,
roomsAvailabilitySuccessCounter,
selectedRoomAvailabilityCounter,
selectedRoomAvailabilityFailCounter,
selectedRoomAvailabilitySuccessCounter,
} from "./telemetry"
import tempRatesData from "./tempRatesData.json" import tempRatesData from "./tempRatesData.json"
import { import {
getCitiesByCountry, getCitiesByCountry,
getCountries, getCountries,
getHotelIdsByCityId,
getHotelIdsByCountry,
getLocations, getLocations,
TWENTYFOUR_HOURS, TWENTYFOUR_HOURS,
} from "./utils" } from "./utils"
@@ -45,68 +72,21 @@ import type { BedTypeSelection } from "@/types/components/hotelReservation/enter
import { BreakfastPackageEnum } from "@/types/enums/breakfast" import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { HotelTypeEnum } from "@/types/enums/hotelType" import { HotelTypeEnum } from "@/types/enums/hotelType"
import type { RequestOptionsWithOutBody } from "@/types/fetch" import type { RequestOptionsWithOutBody } from "@/types/fetch"
import type { Hotel } from "@/types/hotel"
const meter = metrics.getMeter("trpc.hotels") import type { HotelPageUrl } from "@/types/trpc/routers/contentstack/hotelPage"
const getHotelCounter = meter.createCounter("trpc.hotel.get") import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
const getHotelSuccessCounter = meter.createCounter("trpc.hotel.get-success")
const getHotelFailCounter = meter.createCounter("trpc.hotel.get-fail")
const getPackagesCounter = meter.createCounter("trpc.hotel.packages.get")
const getPackagesSuccessCounter = meter.createCounter(
"trpc.hotel.packages.get-success"
)
const getPackagesFailCounter = meter.createCounter(
"trpc.hotel.packages.get-fail"
)
const hotelsAvailabilityCounter = meter.createCounter(
"trpc.hotel.availability.hotels"
)
const hotelsAvailabilitySuccessCounter = meter.createCounter(
"trpc.hotel.availability.hotels-success"
)
const hotelsAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.hotels-fail"
)
const roomsAvailabilityCounter = meter.createCounter(
"trpc.hotel.availability.rooms"
)
const roomsAvailabilitySuccessCounter = meter.createCounter(
"trpc.hotel.availability.rooms-success"
)
const roomsAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.rooms-fail"
)
const selectedRoomAvailabilityCounter = meter.createCounter(
"trpc.hotel.availability.room"
)
const selectedRoomAvailabilitySuccessCounter = meter.createCounter(
"trpc.hotel.availability.room-success"
)
const selectedRoomAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.room-fail"
)
const breakfastPackagesCounter = meter.createCounter("trpc.package.breakfast")
const breakfastPackagesSuccessCounter = meter.createCounter(
"trpc.package.breakfast-success"
)
const breakfastPackagesFailCounter = meter.createCounter(
"trpc.package.breakfast-fail"
)
export const getHotelData = cache( export const getHotelData = cache(
async (input: HotelDataInput, serviceToken: string) => { async (input: HotelDataInput, serviceToken: string) => {
const { hotelId, language, isCardOnlyPayment } = input const { hotelId, language, isCardOnlyPayment } = input
const params: Record<string, string | string[]> = { const includes = ["RoomCategories", "Restaurants"] // "RoomCategories","NearbyHotels","Restaurants","City",
const params = new URLSearchParams({
hotelId, hotelId,
language, language,
} })
params.include = ["RoomCategories", "Restaurants"] // "RoomCategories","NearbyHotels","Restaurants","City", includes.forEach((include) => params.append("include", include))
getHotelCounter.add(1, { getHotelCounter.add(1, {
hotelId, hotelId,
@@ -695,6 +675,202 @@ export const hotelQueryRouter = router({
return getHotelData(input, ctx.serviceToken) return getHotelData(input, ctx.serviceToken)
}), }),
}), }),
hotels: router({
get: contentStackBaseWithServiceProcedure
.input(getHotelsInput)
.query(async function ({ ctx, input }) {
const { locationFilter, hotelsToInclude } = input
const language = ctx.lang
const apiLang = toApiLang(language)
const options: RequestOptionsWithOutBody = {
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
next: {
revalidate: TWENTYFOUR_HOURS,
},
}
let hotelsToFetch: string[] = []
getHotelsCounter.add(1, {
input: JSON.stringify(input),
language,
})
console.info(
"api.hotel.hotels start",
JSON.stringify({
query: {
...input,
language,
},
})
)
if (hotelsToInclude.length) {
hotelsToFetch = hotelsToInclude
} else if (locationFilter?.city) {
const locationsParams = new URLSearchParams({
language: apiLang,
})
const locations = await getLocations(
language,
options,
locationsParams,
null
)
if (!locations || "error" in locations) {
return []
}
const cityId = locations
.filter((loc): loc is CityLocation => loc.type === "cities")
.find((loc) => loc.cityIdentifier === locationFilter.city)?.id
if (!cityId) {
getHotelsFailCounter.add(1, {
input: JSON.stringify(input),
language,
error_type: "not_found",
error: `CityId not found for cityIdentifier: ${locationFilter.city}`,
})
console.error(
"api.hotel.hotels not found error",
JSON.stringify({
query: { ...input, language },
error: `CityId not found for cityIdentifier: ${locationFilter.city}`,
})
)
return []
}
const hotelIdsParams = new URLSearchParams({
language: apiLang,
city: cityId,
onlyBasicInfo: "true",
})
const hotelIds = await getHotelIdsByCityId(
cityId,
options,
hotelIdsParams
)
if (!hotelIds?.length) {
getHotelsFailCounter.add(1, {
cityId,
language,
error_type: "not_found",
error: `No hotelIds found for cityId: ${cityId}`,
})
console.error(
"api.hotel.hotels not found error",
JSON.stringify({
query: { cityId, language },
error: `No hotelIds found for cityId: ${cityId}`,
})
)
return []
}
const filteredHotelIds = hotelIds.filter(
(id) => !locationFilter.excluded.includes(id)
)
hotelsToFetch = filteredHotelIds
} else if (locationFilter?.country) {
const hotelIdsParams = new URLSearchParams({
language: ApiLang.En,
country: locationFilter.country,
onlyBasicInfo: "true",
})
const hotelIds = await getHotelIdsByCountry(
locationFilter.country,
options,
hotelIdsParams
)
if (!hotelIds?.length) {
getHotelsFailCounter.add(1, {
country: locationFilter.country,
language,
error_type: "not_found",
error: `No hotelIds found for country: ${locationFilter.country}`,
})
console.error(
"api.hotel.hotels not found error",
JSON.stringify({
query: { country: locationFilter.country, language },
error: `No hotelIds found for cityId: ${locationFilter.country}`,
})
)
return []
}
const filteredHotelIds = hotelIds.filter(
(id) => !locationFilter.excluded.includes(id)
)
hotelsToFetch = filteredHotelIds
}
if (!hotelsToFetch.length) {
getHotelsFailCounter.add(1, {
input: JSON.stringify(input),
language,
error_type: "not_found",
error: `Couldn't find any hotels for given input: ${JSON.stringify(input)}`,
})
console.error(
"api.hotel.hotels not found error",
JSON.stringify({
query: JSON.stringify(input),
error: `Couldn't find any hotels for given input: ${JSON.stringify(input)}`,
})
)
return []
}
const hotels = await Promise.all(
hotelsToFetch.map(async (hotelId) => {
const [hotelData, url] = await Promise.all([
getHotelData({ hotelId, language }, ctx.serviceToken),
getHotelPageUrl(language, hotelId),
])
return {
data: hotelData?.data.attributes,
url,
}
})
)
getHotelsSuccessCounter.add(1, {
input: JSON.stringify(input),
language,
})
console.info(
"api.hotels success",
JSON.stringify({
query: {
input: JSON.stringify(input),
language,
},
})
)
return hotels.filter(
(hotel): hotel is { data: Hotel; url: HotelPageUrl } => !!hotel.data
)
}),
}),
locations: router({ locations: router({
get: serviceProcedure.query(async function ({ ctx }) { get: serviceProcedure.query(async function ({ ctx }) {
const searchParams = new URLSearchParams() const searchParams = new URLSearchParams()

View File

@@ -1,7 +1,9 @@
import { dt } from "@/lib/dt"
import { AlertTypeEnum } from "@/types/enums/alert"
import { z } from "zod" import { z } from "zod"
import { dt } from "@/lib/dt"
import { AlertTypeEnum } from "@/types/enums/alert"
const specialAlertSchema = z.object({ const specialAlertSchema = z.object({
type: z.string(), type: z.string(),
title: z.string().optional(), title: z.string().optional(),
@@ -16,10 +18,14 @@ export const specialAlertsSchema = z
.transform((data) => { .transform((data) => {
const now = dt().utc().format("YYYY-MM-DD") const now = dt().utc().format("YYYY-MM-DD")
const filteredAlerts = data.filter((alert) => { const filteredAlerts = data.filter((alert) => {
const shouldShowNow = let shouldShowNow = true
alert.startDate && alert.endDate
? alert.startDate <= now && alert.endDate >= now if (alert.startDate && alert.startDate > now) {
: true shouldShowNow = false
}
if (alert.endDate && alert.endDate < now) {
shouldShowNow = false
}
const hasText = alert.description || alert.title const hasText = alert.description || alert.title
return shouldShowNow && hasText return shouldShowNow && hasText
}) })

View File

@@ -0,0 +1,74 @@
import { metrics } from "@opentelemetry/api"
const meter = metrics.getMeter("trpc.hotels")
export const getHotelCounter = meter.createCounter("trpc.hotel.get")
export const getHotelSuccessCounter = meter.createCounter(
"trpc.hotel.get-success"
)
export const getHotelFailCounter = meter.createCounter("trpc.hotel.get-fail")
export const getPackagesCounter = meter.createCounter("trpc.hotel.packages.get")
export const getPackagesSuccessCounter = meter.createCounter(
"trpc.hotel.packages.get-success"
)
export const getPackagesFailCounter = meter.createCounter(
"trpc.hotel.packages.get-fail"
)
export const hotelsAvailabilityCounter = meter.createCounter(
"trpc.hotel.availability.hotels"
)
export const hotelsAvailabilitySuccessCounter = meter.createCounter(
"trpc.hotel.availability.hotels-success"
)
export const hotelsAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.hotels-fail"
)
export const roomsAvailabilityCounter = meter.createCounter(
"trpc.hotel.availability.rooms"
)
export const roomsAvailabilitySuccessCounter = meter.createCounter(
"trpc.hotel.availability.rooms-success"
)
export const roomsAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.rooms-fail"
)
export const selectedRoomAvailabilityCounter = meter.createCounter(
"trpc.hotel.availability.room"
)
export const selectedRoomAvailabilitySuccessCounter = meter.createCounter(
"trpc.hotel.availability.room-success"
)
export const selectedRoomAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.room-fail"
)
export const breakfastPackagesCounter = meter.createCounter(
"trpc.package.breakfast"
)
export const breakfastPackagesSuccessCounter = meter.createCounter(
"trpc.package.breakfast-success"
)
export const breakfastPackagesFailCounter = meter.createCounter(
"trpc.package.breakfast-fail"
)
export const getHotelsCounter = meter.createCounter("trpc.hotel.hotels.get")
export const getHotelsSuccessCounter = meter.createCounter(
"trpc.hotel.hotels.get-success"
)
export const getHotelsFailCounter = meter.createCounter(
"trpc.hotel.hotels.get-fail"
)
export const getHotelIdsCounter = meter.createCounter(
"trpc.hotel.hotel-ids.get"
)
export const getHotelIdsSuccessCounter = meter.createCounter(
"trpc.hotel.hotel-ids.get-success"
)
export const getHotelIdsFailCounter = meter.createCounter(
"trpc.hotel.hotel-ids.get-fail"
)

View File

@@ -10,11 +10,18 @@ import {
apiLocationsSchema, apiLocationsSchema,
type CitiesGroupedByCountry, type CitiesGroupedByCountry,
type Countries, type Countries,
getHotelIdsByCityIdSchema,
} from "./output" } from "./output"
import {
getHotelIdsCounter,
getHotelIdsFailCounter,
getHotelIdsSuccessCounter,
} from "./telemetry"
import type { Country } from "@/types/enums/country"
import type { RequestOptionsWithOutBody } from "@/types/fetch" import type { RequestOptionsWithOutBody } from "@/types/fetch"
import { PointOfInterestGroupEnum } from "@/types/hotel" import { PointOfInterestGroupEnum } from "@/types/hotel"
import { HotelLocation } from "@/types/trpc/routers/hotel/locations" import type { HotelLocation } from "@/types/trpc/routers/hotel/locations"
import type { Lang } from "@/constants/languages" import type { Lang } from "@/constants/languages"
import type { Endpoint } from "@/lib/api/endpoints" import type { Endpoint } from "@/lib/api/endpoints"
@@ -258,3 +265,145 @@ export async function getLocations(
{ revalidate: TWENTYFOUR_HOURS } { revalidate: TWENTYFOUR_HOURS }
)(params, citiesByCountry) )(params, citiesByCountry)
} }
export async function getHotelIdsByCityId(
cityId: string,
options: RequestOptionsWithOutBody,
params: URLSearchParams
) {
return unstable_cache(
async function (params: URLSearchParams) {
getHotelIdsCounter.add(1, { params: params.toString() })
console.info(
"api.hotel.hotel-ids start",
JSON.stringify({ params: params.toString() })
)
const apiResponse = await api.get(
api.endpoints.v1.Hotel.hotels,
options,
params
)
if (!apiResponse.ok) {
const responseMessage = await apiResponse.text()
getHotelIdsFailCounter.add(1, {
params: params.toString(),
error_type: "http_error",
error: responseMessage,
})
console.error(
"api.hotel.hotel-ids fetch error",
JSON.stringify({
params: params.toString(),
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text: responseMessage,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const validatedHotelIds = getHotelIdsByCityIdSchema.safeParse(apiJson)
if (!validatedHotelIds.success) {
getHotelIdsFailCounter.add(1, {
params: params.toString(),
error_type: "validation_error",
error: JSON.stringify(validatedHotelIds.error),
})
console.error(
"api.hotel.hotel-ids validation error",
JSON.stringify({
params: params.toString(),
error: validatedHotelIds.error,
})
)
return null
}
getHotelIdsSuccessCounter.add(1, { cityId })
console.info(
"api.hotel.hotel-ids success",
JSON.stringify({ params: params.toString() })
)
return validatedHotelIds.data
},
[`hotelsByCityId`, params.toString()],
{ revalidate: TWENTYFOUR_HOURS }
)(params)
}
export async function getHotelIdsByCountry(
country: Country,
options: RequestOptionsWithOutBody,
params: URLSearchParams
) {
return unstable_cache(
async function (params: URLSearchParams) {
getHotelIdsCounter.add(1, { country })
console.info(
"api.hotel.hotel-ids start",
JSON.stringify({ query: { country } })
)
const apiResponse = await api.get(
api.endpoints.v1.Hotel.hotels,
options,
params
)
if (!apiResponse.ok) {
const responseMessage = await apiResponse.text()
getHotelIdsFailCounter.add(1, {
country,
error_type: "http_error",
error: responseMessage,
})
console.error(
"api.hotel.hotel-ids fetch error",
JSON.stringify({
query: { country },
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text: responseMessage,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const validatedHotelIds = getHotelIdsByCityIdSchema.safeParse(apiJson)
if (!validatedHotelIds.success) {
getHotelIdsFailCounter.add(1, {
country,
error_type: "validation_error",
error: JSON.stringify(validatedHotelIds.error),
})
console.error(
"api.hotel.hotel-ids validation error",
JSON.stringify({
query: { country },
error: validatedHotelIds.error,
})
)
return null
}
getHotelIdsSuccessCounter.add(1, { country })
console.info(
"api.hotel.hotel-ids success",
JSON.stringify({ query: { country } })
)
return validatedHotelIds.data
},
[`hotelsByCountry`, params.toString()],
{ revalidate: TWENTYFOUR_HOURS }
)(params)
}

View File

@@ -1,5 +1,4 @@
import { metrics } from "@opentelemetry/api" import { metrics } from "@opentelemetry/api"
import { cache } from "react"
import * as api from "@/lib/api" import * as api from "@/lib/api"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
@@ -10,6 +9,7 @@ import {
} from "@/server/trpc" } from "@/server/trpc"
import { countries } from "@/components/TempDesignSystem/Form/Country/countries" import { countries } from "@/components/TempDesignSystem/Form/Country/countries"
import { cache } from "@/utils/cache"
import * as maskValue from "@/utils/maskValue" import * as maskValue from "@/utils/maskValue"
import { getMembership, getMembershipCards } from "@/utils/user" import { getMembership, getMembershipCards } from "@/utils/user"

View File

@@ -0,0 +1,8 @@
import type { HotelListing } from "@/types/trpc/routers/contentstack/blocks"
export interface HotelListingProps {
heading?: string
locationFilter: HotelListing["locationFilter"]
hotelsToInclude: HotelListing["hotelsToInclude"]
contentType: HotelListing["contentType"]
}

View File

@@ -1,9 +1,8 @@
export type HotelListingItemProps = { import type { Hotel } from "@/types/hotel"
imageUrl: string import type { HotelListing } from "@/types/trpc/routers/contentstack/blocks"
altText: string
name: string export interface HotelListingItemProps {
address: string hotel: Hotel
distanceToCentre: number contentType: HotelListing["contentType"]
description: string url: string | null
link: string
} }

View File

@@ -1,6 +1,6 @@
import { z } from "zod" import type { z } from "zod"
import { import type {
bedTypeFormSchema, bedTypeFormSchema,
bedTypeSchema, bedTypeSchema,
} from "@/components/HotelReservation/EnterDetails/BedType/schema" } from "@/components/HotelReservation/EnterDetails/BedType/schema"
@@ -20,3 +20,7 @@ export type BedTypeProps = {
export interface BedTypeFormSchema extends z.output<typeof bedTypeFormSchema> {} export interface BedTypeFormSchema extends z.output<typeof bedTypeFormSchema> {}
export type BedTypeSchema = z.output<typeof bedTypeSchema>["bedType"] export type BedTypeSchema = z.output<typeof bedTypeSchema>["bedType"]
export type BedTypeInfoProps = {
hasMultipleBedTypes: boolean
}

View File

@@ -9,5 +9,6 @@ export namespace BlocksEnums {
TextCols = "TextCols", TextCols = "TextCols",
TextContent = "TextContent", TextContent = "TextContent",
UspGrid = "UspGrid", UspGrid = "UspGrid",
HotelListing = "HotelListing",
} }
} }

View File

@@ -9,6 +9,7 @@ export namespace ContentPageEnum {
TextCols = "ContentPageBlocksTextCols", TextCols = "ContentPageBlocksTextCols",
UspGrid = "ContentPageBlocksUspGrid", UspGrid = "ContentPageBlocksUspGrid",
Table = "ContentPageBlocksTable", Table = "ContentPageBlocksTable",
HotelListing = "ContentPageBlocksHotelListing",
} }
export const enum sidebar { export const enum sidebar {

8
types/enums/country.ts Normal file
View File

@@ -0,0 +1,8 @@
export enum Country {
Denmark = "Denmark",
Finland = "Finland",
Germany = "Germany",
Norway = "Norway",
Poland = "Poland",
Sweden = "Sweden",
}

View File

@@ -1,15 +1,16 @@
import { z } from "zod" import type { z } from "zod"
import { import type {
cardsGridSchema, cardsGridSchema,
teaserCardBlockSchema, teaserCardBlockSchema,
} from "@/server/routers/contentstack/schemas/blocks/cardsGrid" } from "@/server/routers/contentstack/schemas/blocks/cardsGrid"
import { contentSchema } from "@/server/routers/contentstack/schemas/blocks/content" import type { contentSchema } from "@/server/routers/contentstack/schemas/blocks/content"
import { dynamicContentSchema } from "@/server/routers/contentstack/schemas/blocks/dynamicContent" import type { dynamicContentSchema } from "@/server/routers/contentstack/schemas/blocks/dynamicContent"
import { shortcutsSchema } from "@/server/routers/contentstack/schemas/blocks/shortcuts" import type { hotelListingSchema } from "@/server/routers/contentstack/schemas/blocks/hotelListing"
import { tableSchema } from "@/server/routers/contentstack/schemas/blocks/table" import type { shortcutsSchema } from "@/server/routers/contentstack/schemas/blocks/shortcuts"
import { textColsSchema } from "@/server/routers/contentstack/schemas/blocks/textCols" import type { tableSchema } from "@/server/routers/contentstack/schemas/blocks/table"
import { uspGridSchema } from "@/server/routers/contentstack/schemas/blocks/uspGrid" import type { textColsSchema } from "@/server/routers/contentstack/schemas/blocks/textCols"
import type { uspGridSchema } from "@/server/routers/contentstack/schemas/blocks/uspGrid"
export interface TeaserCard extends z.output<typeof teaserCardBlockSchema> {} export interface TeaserCard extends z.output<typeof teaserCardBlockSchema> {}
export interface CardsGrid extends z.output<typeof cardsGridSchema> {} export interface CardsGrid extends z.output<typeof cardsGridSchema> {}
@@ -21,3 +22,5 @@ export interface TableBlock extends z.output<typeof tableSchema> {}
export type TableData = TableBlock["table"] export type TableData = TableBlock["table"]
export interface TextCols extends z.output<typeof textColsSchema> {} export interface TextCols extends z.output<typeof textColsSchema> {}
export interface UspGrid extends z.output<typeof uspGridSchema> {} export interface UspGrid extends z.output<typeof uspGridSchema> {}
interface GetHotelListing extends z.output<typeof hotelListingSchema> {}
export type HotelListing = GetHotelListing["hotel_listing"]

View File

@@ -1,11 +1,12 @@
import { z } from "zod" import type { z } from "zod"
import { import type {
contentBlock, contentBlock,
hotelPageRefsSchema, hotelPageRefsSchema,
hotelPageSchema, hotelPageSchema,
hotelPageUrlSchema,
} from "@/server/routers/contentstack/hotelPage/output" } from "@/server/routers/contentstack/hotelPage/output"
import { activitiesCardSchema } from "@/server/routers/contentstack/schemas/blocks/activitiesCard" import type { activitiesCardSchema } from "@/server/routers/contentstack/schemas/blocks/activitiesCard"
export interface GetHotelPageData extends z.input<typeof hotelPageSchema> {} export interface GetHotelPageData extends z.input<typeof hotelPageSchema> {}
export interface HotelPage extends z.output<typeof hotelPageSchema> {} export interface HotelPage extends z.output<typeof hotelPageSchema> {}
@@ -18,3 +19,7 @@ export interface GetHotelPageRefsSchema
extends z.input<typeof hotelPageRefsSchema> {} extends z.input<typeof hotelPageRefsSchema> {}
export interface HotelPageRefs extends z.output<typeof hotelPageRefsSchema> {} export interface HotelPageRefs extends z.output<typeof hotelPageRefsSchema> {}
export interface GetHotelPageUrlData
extends z.input<typeof hotelPageUrlSchema> {}
export type HotelPageUrl = z.output<typeof hotelPageUrlSchema>

View File

@@ -1,4 +1,4 @@
import { System } from "@/types/requests/system" import type { System } from "@/types/requests/system"
import type { Edges } from "@/types/requests/utils/edges" import type { Edges } from "@/types/requests/utils/edges"
import type { NodeRefs } from "@/types/requests/utils/refs" import type { NodeRefs } from "@/types/requests/utils/refs"
import type { Lang } from "@/constants/languages" import type { Lang } from "@/constants/languages"
@@ -109,3 +109,14 @@ export function generateLoyaltyConfigTag(
export function generateServiceTokenTag(scopes: string[]) { export function generateServiceTokenTag(scopes: string[]) {
return `service_token:${scopes.join("-")}` return `service_token:${scopes.join("-")}`
} }
/**
* Function to generate tags for hotel page urls
*
* @param lang Lang
* @param hotelId hotelId of reference
* @returns string
*/
export function generateHotelUrlTag(lang: Lang, hotelId: string) {
return `${lang}:hotel_page_url:${hotelId}`
}