From 66649ba6af1638ad3762fdba1e8649a10786e0df Mon Sep 17 00:00:00 2001 From: Linus Flood Date: Tue, 17 Dec 2024 17:22:50 +0100 Subject: [PATCH 01/11] feat/user: use our wrapped cache instead of reacts cache --- server/routers/user/query.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/user/query.ts b/server/routers/user/query.ts index 7f149cf3b..1e5145b41 100644 --- a/server/routers/user/query.ts +++ b/server/routers/user/query.ts @@ -1,5 +1,4 @@ import { metrics } from "@opentelemetry/api" -import { cache } from "react" import * as api from "@/lib/api" import { dt } from "@/lib/dt" @@ -10,6 +9,7 @@ import { } from "@/server/trpc" import { countries } from "@/components/TempDesignSystem/Form/Country/countries" +import { cache } from "@/utils/cache" import * as maskValue from "@/utils/maskValue" import { getMembership, getMembershipCards } from "@/utils/user" From 6168e0a034f073cf8a2c3c2359bcef148d1d964f Mon Sep 17 00:00:00 2001 From: Chuma McPhoy Date: Tue, 17 Dec 2024 10:38:13 +0100 Subject: [PATCH 02/11] fix(LOY-44): signup flow translations fixes --- i18n/dictionaries/da.json | 2 +- i18n/dictionaries/fi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index 70281af0e..0a8f7d820 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -532,7 +532,7 @@ "room type": "værelsestype", "room types": "værelsestyper", "signup.terms": "Ved at tilmelde dig accepterer du Scandic Friends vilkår og betingelser. 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 vilkårene og betingelserne for Scandic Friends, forstår jeg, at mine personlige oplysninger vil blive behandlet i overensstemmelse medScandics privatlivspolitik.", + "signupPage.terms": "Ved at acceptere vilkårene og betingelserne for Scandic Friends, forstår jeg, at mine personlige oplysninger vil blive behandlet i overensstemmelse med Scandics privatlivspolitik.", "special character": "speciel karakter", "spendable points expiring by": "{points} Brugbare point udløber den {date}", "to": "til", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index 593cbdb6d..5fb08c874 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -412,7 +412,7 @@ "Surprise!": "Yllätys!", "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ä.", - "Terms and conditions": "Käyttöehdot", + "Terms and conditions": "Säännöt ja ehdot", "Thank you": "Kiitos", "The new price is": "Uusi hinta on", "The price has increased": "Hinta on noussut", From 6bad29a678d599ede3e5af29a4b6c082c5ad1797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matilda=20Landstr=C3=B6m?= Date: Tue, 17 Dec 2024 11:04:12 +0100 Subject: [PATCH 03/11] fix(SW.1240): hide see all photos if only one image --- .../HotelPage/PreviewImages/index.tsx | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/components/ContentType/HotelPage/PreviewImages/index.tsx b/components/ContentType/HotelPage/PreviewImages/index.tsx index b8a925497..5d300afac 100644 --- a/components/ContentType/HotelPage/PreviewImages/index.tsx +++ b/components/ContentType/HotelPage/PreviewImages/index.tsx @@ -32,24 +32,28 @@ export default function PreviewImages({ className={styles.image} /> ))} - - setLightboxIsOpen(false)} - /> + {images.length > 1 && ( + <> + + setLightboxIsOpen(false)} + /> + + )} ) } From 77d449b8cbe1ac6be13504ba958c391b5ff3b6ae Mon Sep 17 00:00:00 2001 From: Niclas Edenvin Date: Wed, 18 Dec 2024 08:51:58 +0000 Subject: [PATCH 04/11] Merged in fix/sw-1160-booking-widget-sticky (pull request #1053) fix(SW-1160): include current header as sticky * fix(SW-1160): include current header as sticky * fix(sw-1160): only count with current header height if its shown Approved-by: Erik Tiekstra --- .env.local.example | 2 +- .env.test | 2 +- env/client.ts | 8 ++++++++ env/server.ts | 2 +- hooks/useStickyPosition.ts | 27 +++++++++++++++++++++++---- 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/.env.local.example b/.env.local.example index 5043646c9..7de28e6ee 100644 --- a/.env.local.example +++ b/.env.local.example @@ -52,7 +52,7 @@ GOOGLE_STATIC_MAP_SIGNATURE_SECRET="" GOOGLE_STATIC_MAP_ID="" GOOGLE_DYNAMIC_MAP_ID="" -HIDE_FOR_NEXT_RELEASE="false" +NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE="false" ENABLE_BOOKING_FLOW="false" ENABLE_BOOKING_WIDGET="false" diff --git a/.env.test b/.env.test index e48b5dc41..b9083de46 100644 --- a/.env.test +++ b/.env.test @@ -42,7 +42,7 @@ GOOGLE_STATIC_MAP_KEY="test" GOOGLE_STATIC_MAP_SIGNATURE_SECRET="test" GOOGLE_STATIC_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" USE_NEW_REWARDS_ENDPOINT="true" USE_NEW_REWARD_MODEL="true" diff --git a/env/client.ts b/env/client.ts index 467100c01..fc57c579a 100644 --- a/env/client.ts +++ b/env/client.ts @@ -5,10 +5,18 @@ export const env = createEnv({ client: { NEXT_PUBLIC_NODE_ENV: z.enum(["development", "test", "production"]), 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, runtimeEnv: { NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_PORT: process.env.NEXT_PUBLIC_PORT, + NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE: + process.env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE, }, }) diff --git a/env/server.ts b/env/server.ts index 3ff18ae24..c1befd972 100644 --- a/env/server.ts +++ b/env/server.ts @@ -185,7 +185,7 @@ export const env = createEnv({ process.env.GOOGLE_STATIC_MAP_SIGNATURE_SECRET, GOOGLE_STATIC_MAP_ID: process.env.GOOGLE_STATIC_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_REWARD_MODEL: process.env.USE_NEW_REWARD_MODEL, ENABLE_BOOKING_FLOW: process.env.ENABLE_BOOKING_FLOW, diff --git a/hooks/useStickyPosition.ts b/hooks/useStickyPosition.ts index 2c7fdf6ea..08fce174b 100644 --- a/hooks/useStickyPosition.ts +++ b/hooks/useStickyPosition.ts @@ -1,9 +1,10 @@ "use client" -import { useEffect } from "react" +import { useEffect, useState } from "react" +import { env } from "@/env/client" import useStickyPositionStore, { - StickyElementNameEnum, + type StickyElementNameEnum, } from "@/stores/sticky-position" import { debounce } from "@/utils/debounce" @@ -44,6 +45,15 @@ export default function useStickyPosition({ getAllElements, } = 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(() => { if (ref && name) { // Register the sticky element with the given ref, name, and group. @@ -79,19 +89,28 @@ export default function useStickyPosition({ const topOffset = stickyElements .slice(0, index) .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. // This positions the element at the correct location within the document. ref.current.style.top = `${topOffset}px` } } - }, [stickyElements, ref]) + }, [baseTopOffset, stickyElements, ref]) useEffect(() => { if (!resizeObserver) { const debouncedResizeHandler = debounce(() => { 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) resizeObserver = new ResizeObserver(debouncedResizeHandler) From 118f1afafa6c2b99742acef6db9fe7d055210ddc Mon Sep 17 00:00:00 2001 From: Tobias Johansson Date: Wed, 18 Dec 2024 09:47:06 +0000 Subject: [PATCH 05/11] Merged in feat/SW-1023-bed-type-information (pull request #1056) Feat/SW-1023 bed type information * feat(SW-1023): add bed type info * fix: formatting of bed type string * fix(SW-1023): refactored bed type info and added default value to children beds * fix(SW-1023): fixes from PR Approved-by: Christel Westerberg Approved-by: Simon.Emanuelsson --- .../BedType/BedTypeInfo/index.tsx | 44 +++++++++++++ .../BedType/bedOptions.module.css | 6 ++ .../EnterDetails/BedType/index.tsx | 44 +++++++------ .../EnterDetails/Summary/UI/index.tsx | 61 ++++++++++++++++--- i18n/dictionaries/da.json | 4 ++ i18n/dictionaries/de.json | 4 ++ i18n/dictionaries/en.json | 4 ++ i18n/dictionaries/fi.json | 4 ++ i18n/dictionaries/no.json | 4 ++ i18n/dictionaries/sv.json | 4 ++ .../hotelReservation/enterDetails/bedType.ts | 8 ++- 11 files changed, 158 insertions(+), 29 deletions(-) create mode 100644 components/HotelReservation/EnterDetails/BedType/BedTypeInfo/index.tsx diff --git a/components/HotelReservation/EnterDetails/BedType/BedTypeInfo/index.tsx b/components/HotelReservation/EnterDetails/BedType/BedTypeInfo/index.tsx new file mode 100644 index 000000000..2dd696349 --- /dev/null +++ b/components/HotelReservation/EnterDetails/BedType/BedTypeInfo/index.tsx @@ -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 ( + + {availabilityText}. {extraBedText} + + ) + } + + if (hasMultipleBedTypes) { + return {availabilityText} + } + + if (hasChildWithExtraBed) { + return {extraBedText} + } + + return null +} diff --git a/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css b/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css index 844ed4a6b..9bde9175b 100644 --- a/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css +++ b/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css @@ -1,3 +1,9 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--Spacing-x2); +} + .form { display: grid; gap: var(--Spacing-x2); diff --git a/components/HotelReservation/EnterDetails/BedType/index.tsx b/components/HotelReservation/EnterDetails/BedType/index.tsx index 4f89eb2f0..9002f344e 100644 --- a/components/HotelReservation/EnterDetails/BedType/index.tsx +++ b/components/HotelReservation/EnterDetails/BedType/index.tsx @@ -9,6 +9,7 @@ import { useEnterDetailsStore } from "@/stores/enter-details" import { KingBedIcon } from "@/components/Icons" import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio" +import BedTypeInfo from "./BedTypeInfo" import { bedTypeFormSchema } from "./schema" import styles from "./bedOptions.module.css" @@ -62,26 +63,29 @@ export default function BedType({ bedTypes }: BedTypeProps) { return ( -
- {bedTypes.map((roomType) => { - const width = - roomType.size.max === roomType.size.min - ? `${roomType.size.min} cm` - : `${roomType.size.min} cm - ${roomType.size.max} cm` - return ( - - ) - })} - +
+ 1} /> +
+ {bedTypes.map((roomType) => { + const width = + roomType.size.max === roomType.size.min + ? `${roomType.size.min} cm` + : `${roomType.size.min} cm - ${roomType.size.max} cm` + return ( + + ) + })} + +
) } diff --git a/components/HotelReservation/EnterDetails/Summary/UI/index.tsx b/components/HotelReservation/EnterDetails/Summary/UI/index.tsx index 2d1e02f77..0139daa28 100644 --- a/components/HotelReservation/EnterDetails/Summary/UI/index.tsx +++ b/components/HotelReservation/EnterDetails/Summary/UI/index.tsx @@ -22,6 +22,7 @@ import Modal from "../../Modal" import styles from "./ui.module.css" +import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums" import type { SummaryProps } from "@/types/components/hotelReservation/summary" import type { DetailsState } from "@/types/stores/enter-details" @@ -67,6 +68,25 @@ export default function SummaryUI({ const adults = booking.rooms[0].adults 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.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 ? { currency: roomRate.memberRate.localPrice.currency, @@ -179,12 +199,7 @@ export default function SummaryUI({ : null} {bedType ? (
-
- {bedType.description} - - {intl.formatMessage({ id: "Based on availability" })} - -
+ {bedType.description} {intl.formatNumber(0, { @@ -194,7 +209,39 @@ export default function SummaryUI({
) : null} - + {childBedCrib ? ( +
+
+ + {`${intl.formatMessage({ id: "Crib (child)" })} × ${childBedCrib}`} + + + {intl.formatMessage({ id: "Based on availability" })} + +
+ + {intl.formatNumber(0, { + currency: roomPrice.local.currency, + style: "currency", + })} + +
+ ) : null} + {childBedExtraBed ? ( +
+
+ + {`${intl.formatMessage({ id: "Extra bed (child)" })} × ${childBedExtraBed}`} + +
+ + {intl.formatNumber(0, { + currency: roomPrice.local.currency, + style: "currency", + })} + +
+ ) : null} {breakfast === false ? (
diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index 0a8f7d820..4378a7b9a 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -105,6 +105,7 @@ "Creative spaces for meetings": "Kreative rum til møder", "Credit card": "Kreditkort", "Credit card deleted successfully": "Kreditkort blev slettet", + "Crib (child)": "Kørestol (barn)", "Currency Code": "DKK", "Current password": "Nuværende kodeord", "Customer service": "Kundeservice", @@ -142,6 +143,8 @@ "Expires at the earliest": "Udløber tidligst {date}", "Explore all levels and benefits": "Udforsk alle niveauer og fordele", "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", "FAQ": "Ofte stillede spørgsmål", "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 points to spend": "Dine brugbare point", "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", "Zoo": "Zoo", "Zoom in": "Zoom ind", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index 6fd41b9fe..03fb803f9 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -105,6 +105,7 @@ "Creative spaces for meetings": "Kreative Räume für Meetings", "Credit card": "Kreditkarte", "Credit card deleted successfully": "Kreditkarte erfolgreich gelöscht", + "Crib (child)": "Kinderbett (Kind)", "Currency Code": "EUR", "Current password": "Aktuelles Passwort", "Customer service": "Kundendienst", @@ -142,6 +143,8 @@ "Expires at the earliest": "Läuft frühestens am {date} ab", "Explore all levels and benefits": "Entdecken Sie alle Levels und Vorteile", "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", "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.", @@ -485,6 +488,7 @@ "Your level": "Dein level", "Your points to spend": "Meine Punkte", "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", "Zoo": "Zoo", "Zoom in": "Vergrößern", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index efd37360f..a0be98b36 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -112,6 +112,7 @@ "Creative spaces for meetings": "Creative spaces for meetings", "Credit card": "Credit card", "Credit card deleted successfully": "Credit card deleted successfully", + "Crib (child)": "Crib (child)", "Currency Code": "EUR", "Current password": "Current password", "Customer service": "Customer service", @@ -150,6 +151,8 @@ "Expires at the earliest": "Expires at the earliest {date}", "Explore all levels and benefits": "Explore all levels and benefits", "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", "FAQ": "FAQ", "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 points to spend": "Your points to spend", "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", "Zoo": "Zoo", "Zoom in": "Zoom in", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index 5fb08c874..1f6ba0145 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -105,6 +105,7 @@ "Creative spaces for meetings": "Luovia tiloja kokouksille", "Credit card": "Luottokortti", "Credit card deleted successfully": "Luottokortti poistettu onnistuneesti", + "Crib (child)": "Körkkä (lasta)", "Currency Code": "EUR", "Current password": "Nykyinen salasana", "Customer service": "Asiakaspalvelu", @@ -142,6 +143,8 @@ "Expires at the earliest": "Päättyy aikaisintaan {date}", "Explore all levels and benefits": "Tutustu kaikkiin tasoihin ja etuihin", "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", "FAQ": "Usein kysytyt kysymykset", "Failed to delete credit card, please try again later.": "Luottokortin poistaminen epäonnistui, yritä myöhemmin uudelleen.", @@ -484,6 +487,7 @@ "Your level": "Tasosi", "Your points to spend": "Käytettävissä olevat pisteesi", "Your room": "Sinun huoneesi", + "Your selected bed type will be provided based on availability": "Valitun vuodetyypin toimitetaan saatavuuden mukaan", "Zip code": "Postinumero", "Zoo": "Eläintarha", "Zoom in": "Lähennä", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index 61d44b3e9..2bf19706c 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -104,6 +104,7 @@ "Country is required": "Land kreves", "Creative spaces for meetings": "Kreative rom for møter", "Credit card deleted successfully": "Kredittkort slettet", + "Crib (child)": "Kørestol (barn)", "Currency Code": "NOK", "Current password": "Nåværende passord", "Customer service": "Kundeservice", @@ -141,6 +142,8 @@ "Expires at the earliest": "Utløper tidligst {date}", "Explore all levels and benefits": "Utforsk alle nivåer og fordeler", "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", "FAQ": "Ofte stilte spørsmål", "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 points to spend": "Dine brukbare poeng", "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", "Zoo": "Dyrehage", "Zoom in": "Zoom inn", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index 245883877..8940f9d42 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -104,6 +104,7 @@ "Country is required": "Land är obligatoriskt", "Creative spaces for meetings": "Kreativa utrymmen för möten", "Credit card deleted successfully": "Kreditkort har tagits bort", + "Crib (child)": "Spjälsäng (barn)", "Currency Code": "SEK", "Current password": "Nuvarande lösenord", "Customer service": "Kundservice", @@ -141,6 +142,8 @@ "Expires at the earliest": "Löper ut tidigast {date}", "Explore all levels and benefits": "Utforska alla nivåer och fördelar", "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", "FAQ": "FAQ", "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 points to spend": "Dina spenderbara poäng", "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", "Zoo": "Djurpark", "Zoom in": "Zooma in", diff --git a/types/components/hotelReservation/enterDetails/bedType.ts b/types/components/hotelReservation/enterDetails/bedType.ts index a222ece2b..9f1fae723 100644 --- a/types/components/hotelReservation/enterDetails/bedType.ts +++ b/types/components/hotelReservation/enterDetails/bedType.ts @@ -1,6 +1,6 @@ -import { z } from "zod" +import type { z } from "zod" -import { +import type { bedTypeFormSchema, bedTypeSchema, } from "@/components/HotelReservation/EnterDetails/BedType/schema" @@ -20,3 +20,7 @@ export type BedTypeProps = { export interface BedTypeFormSchema extends z.output {} export type BedTypeSchema = z.output["bedType"] + +export type BedTypeInfoProps = { + hasMultipleBedTypes: boolean +} From 3939bf7cdc874b6d0e0646014bcfba29f9b076d6 Mon Sep 17 00:00:00 2001 From: Erik Tiekstra Date: Wed, 11 Dec 2024 14:46:38 +0100 Subject: [PATCH 06/11] feat(SW-664): Hotel listing component and queries for content pages --- app/api/web/revalidate/hotel/route.ts | 73 +++++ .../hotelListingItem.module.css | 0 .../HotelListing}/HotelListingItem/index.tsx | 58 ++-- .../HotelListing/HotelListingItem/utils.ts | 36 +++ components/Blocks/HotelListing/index.tsx | 40 +++ components/Blocks/index.tsx | 18 +- .../HotelReservation/HotelCard/index.tsx | 2 +- .../HotelLogo => Icons/Logos}/index.tsx | 2 +- .../Fragments/Blocks/HotelListing.graphql | 24 ++ .../Query/ContentPage/ContentPage.graphql | 2 + .../Query/HotelPage/HotelPageUrl.graphql | 12 + lib/trpc/memoizedRequests/index.ts | 7 + .../contentstack/contentPage/output.ts | 8 + .../routers/contentstack/hotelPage/output.ts | 25 ++ .../routers/contentstack/hotelPage/query.ts | 21 +- .../contentstack/hotelPage/telemetry.ts | 33 +++ .../routers/contentstack/hotelPage/utils.ts | 104 +++++-- .../schemas/blocks/hotelListing.ts | 62 ++++ server/routers/hotels/input.ts | 13 + server/routers/hotels/output.ts | 11 + server/routers/hotels/query.ts | 277 ++++++++++++++---- server/routers/hotels/telemetry.ts | 74 +++++ server/routers/hotels/utils.ts | 151 +++++++++- types/components/blocks/hotelListing.ts | 8 + .../contentPage/hotelListingItem.ts | 15 +- .../hotelLogoProps.ts => hotelLogo.ts} | 0 types/enums/blocks.ts | 1 + types/enums/contentPage.ts | 1 + types/enums/country.ts | 8 + types/trpc/routers/contentstack/blocks.ts | 19 +- types/trpc/routers/contentstack/hotelPage.ts | 11 +- utils/generateTag.ts | 13 +- 32 files changed, 989 insertions(+), 140 deletions(-) create mode 100644 app/api/web/revalidate/hotel/route.ts rename components/{ContentType/ContentPage => Blocks/HotelListing}/HotelListingItem/hotelListingItem.module.css (100%) rename components/{ContentType/ContentPage => Blocks/HotelListing}/HotelListingItem/index.tsx (60%) create mode 100644 components/Blocks/HotelListing/HotelListingItem/utils.ts create mode 100644 components/Blocks/HotelListing/index.tsx rename components/{HotelReservation/HotelCard/HotelLogo => Icons/Logos}/index.tsx (90%) create mode 100644 lib/graphql/Fragments/Blocks/HotelListing.graphql create mode 100644 lib/graphql/Query/HotelPage/HotelPageUrl.graphql create mode 100644 server/routers/contentstack/hotelPage/telemetry.ts create mode 100644 server/routers/contentstack/schemas/blocks/hotelListing.ts create mode 100644 server/routers/hotels/telemetry.ts create mode 100644 types/components/blocks/hotelListing.ts rename types/components/{hotelReservation/selectHotel/hotelLogoProps.ts => hotelLogo.ts} (100%) create mode 100644 types/enums/country.ts diff --git a/app/api/web/revalidate/hotel/route.ts b/app/api/web/revalidate/hotel/route.ts new file mode 100644 index 000000000..061e570cc --- /dev/null +++ b/app/api/web/revalidate/hotel/route.ts @@ -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() }) + } +} diff --git a/components/ContentType/ContentPage/HotelListingItem/hotelListingItem.module.css b/components/Blocks/HotelListing/HotelListingItem/hotelListingItem.module.css similarity index 100% rename from components/ContentType/ContentPage/HotelListingItem/hotelListingItem.module.css rename to components/Blocks/HotelListing/HotelListingItem/hotelListingItem.module.css diff --git a/components/ContentType/ContentPage/HotelListingItem/index.tsx b/components/Blocks/HotelListing/HotelListingItem/index.tsx similarity index 60% rename from components/ContentType/ContentPage/HotelListingItem/index.tsx rename to components/Blocks/HotelListing/HotelListingItem/index.tsx index 18f8f6f7f..fea0d1a1b 100644 --- a/components/ContentType/ContentPage/HotelListingItem/index.tsx +++ b/components/Blocks/HotelListing/HotelListingItem/index.tsx @@ -1,4 +1,4 @@ -import { ScandicLogoIcon } from "@/components/Icons" +import HotelLogo from "@/components/Icons/Logos" import Image from "@/components/Image" import Button from "@/components/TempDesignSystem/Button" import Divider from "@/components/TempDesignSystem/Divider" @@ -10,25 +10,27 @@ import Title from "@/components/TempDesignSystem/Text/Title" import { getIntl } from "@/i18n" import getSingleDecimal from "@/utils/numberFormatting" +import { getTypeSpecificInformation } from "./utils" + import styles from "./hotelListingItem.module.css" import type { HotelListingItemProps } from "@/types/components/contentPage/hotelListingItem" export default async function HotelListingItem({ - imageUrl, - altText, - name, - address, - distanceToCentre, - description, - link, + hotel, + contentType = "hotel", + url, }: HotelListingItemProps) { const intl = await getIntl() + const { description, imageSrc, altText } = getTypeSpecificInformation( + contentType, + hotel + ) return (
{altText}
- + - {name} + {hotel.name}
- {address} + + {hotel.address.streetAddress} +
{intl.formatMessage( { id: "Distance in km to city centre" }, - { number: getSingleDecimal(distanceToCentre / 1000) } + { + number: getSingleDecimal( + hotel.location.distanceToCentre / 1000 + ), + } )}
{description} - + {url && ( + + )}
) diff --git a/components/Blocks/HotelListing/HotelListingItem/utils.ts b/components/Blocks/HotelListing/HotelListingItem/utils.ts new file mode 100644 index 000000000..6fb4a6a40 --- /dev/null +++ b/components/Blocks/HotelListing/HotelListingItem/utils.ts @@ -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 + } +} diff --git a/components/Blocks/HotelListing/index.tsx b/components/Blocks/HotelListing/index.tsx new file mode 100644 index 000000000..06e1d9087 --- /dev/null +++ b/components/Blocks/HotelListing/index.tsx @@ -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 ( + + + {heading} + + {hotels.map(({ data, url }) => ( + + ))} + + ) +} diff --git a/components/Blocks/index.tsx b/components/Blocks/index.tsx index f165b50ba..0382fbfbc 100644 --- a/components/Blocks/index.tsx +++ b/components/Blocks/index.tsx @@ -6,13 +6,14 @@ import UspGrid from "@/components/Blocks/UspGrid" import JsonToHtml from "@/components/JsonToHtml" import AccordionSection from "./Accordion" +import HotelListing from "./HotelListing" import Table from "./Table" import type { BlocksProps } from "@/types/components/blocks" import { BlocksEnums } from "@/types/enums/blocks" export default function Blocks({ blocks }: BlocksProps) { - return blocks.map((block, idx) => { + return blocks.map(async (block, idx) => { const firstItem = idx === 0 switch (block.typename) { case BlocksEnums.block.Accordion: @@ -48,6 +49,21 @@ export default function Blocks({ blocks }: BlocksProps) { 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 ( + + ) case BlocksEnums.block.Shortcuts: return ( { + const page = data.all_hotel_page.items[0] + if (!page) { + return null + } + + const lang = page.system.locale + return removeMultipleSlashes(`/${lang}/${page.url}`) + }) diff --git a/server/routers/contentstack/hotelPage/query.ts b/server/routers/contentstack/hotelPage/query.ts index 9bec62769..c7795710a 100644 --- a/server/routers/contentstack/hotelPage/query.ts +++ b/server/routers/contentstack/hotelPage/query.ts @@ -1,5 +1,3 @@ -import { metrics } from "@opentelemetry/api" - import { GetHotelPage } from "@/lib/graphql/Query/HotelPage/HotelPage.graphql" import { request } from "@/lib/graphql/request" import { notFound } from "@/server/errors/trpc" @@ -8,20 +6,13 @@ import { contentstackExtendedProcedureUID, router } from "@/server/trpc" import { generateTag } from "@/utils/generateTag" import { hotelPageSchema } from "./output" +import { + getHotelPageCounter, + getHotelPageFailCounter, + getHotelPageSuccessCounter, +} from "./telemetry" -import { 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" -) +import type { GetHotelPageData } from "@/types/trpc/routers/contentstack/hotelPage" export const hotelPageQueryRouter = router({ get: contentstackExtendedProcedureUID.query(async ({ ctx }) => { diff --git a/server/routers/contentstack/hotelPage/telemetry.ts b/server/routers/contentstack/hotelPage/telemetry.ts new file mode 100644 index 000000000..81f967a6b --- /dev/null +++ b/server/routers/contentstack/hotelPage/telemetry.ts @@ -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" +) diff --git a/server/routers/contentstack/hotelPage/utils.ts b/server/routers/contentstack/hotelPage/utils.ts index 1b3a0adfe..4090d933f 100644 --- a/server/routers/contentstack/hotelPage/utils.ts +++ b/server/routers/contentstack/hotelPage/utils.ts @@ -1,37 +1,32 @@ -import { metrics } from "@opentelemetry/api" - -import { Lang } from "@/constants/languages" 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 { 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 { System } from "@/types/requests/system" -import { +import type { System } from "@/types/requests/system" +import type { GetHotelPageRefsSchema, + GetHotelPageUrlData, HotelPageRefs, } from "@/types/trpc/routers/contentstack/hotelPage" - -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" -) +import type { Lang } from "@/constants/languages" export async function fetchHotelPageRefs(lang: Lang, uid: string) { getHotelPageRefsCounter.add(1, { lang, uid }) @@ -140,3 +135,64 @@ export function getConnections({ hotel_page }: HotelPageRefs) { } 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( + 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 +} diff --git a/server/routers/contentstack/schemas/blocks/hotelListing.ts b/server/routers/contentstack/schemas/blocks/hotelListing.ts new file mode 100644 index 000000000..e228c95b1 --- /dev/null +++ b/server/routers/contentstack/schemas/blocks/hotelListing.ts @@ -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, + } + }), +}) diff --git a/server/routers/hotels/input.ts b/server/routers/hotels/input.ts index 4a66009b3..17270a2c6 100644 --- a/server/routers/hotels/input.ts +++ b/server/routers/hotels/input.ts @@ -1,6 +1,7 @@ import { z } from "zod" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" +import { Country } from "@/types/enums/country" export const getHotelsAvailabilityInputSchema = z.object({ cityId: z.string(), @@ -53,6 +54,18 @@ export const getHotelDataInputSchema = z.object({ export type HotelDataInput = z.input +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 {} + export const getBreakfastPackageInputSchema = z.object({ adults: z.number().min(1, { message: "at least one adult is required" }), fromDate: z diff --git a/server/routers/hotels/output.ts b/server/routers/hotels/output.ts index bf6fbeca7..ae35da556 100644 --- a/server/routers/hotels/output.ts +++ b/server/routers/hotels/output.ts @@ -870,3 +870,14 @@ export const getRoomPackagesSchema = z .optional(), }) .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)) diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index 45c1f4780..4cebfea69 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -1,9 +1,8 @@ -import { metrics } from "@opentelemetry/api" - import * as api from "@/lib/api" import { dt } from "@/lib/dt" import { badRequestError } from "@/server/errors/trpc" import { + contentStackBaseWithServiceProcedure, publicProcedure, router, safeProtectedServiceProcedure, @@ -13,12 +12,14 @@ import { toApiLang } from "@/server/utils" import { cache } from "@/utils/cache" +import { getHotelPageUrl } from "../contentstack/hotelPage/utils" import { getVerifiedUser, parsedUser } from "../user/query" import { getBreakfastPackageInputSchema, getCityCoordinatesInputSchema, getHotelDataInputSchema, getHotelsAvailabilityInputSchema, + getHotelsInput, getRatesInputSchema, getRoomPackagesInputSchema, getRoomsAvailabilityInputSchema, @@ -33,10 +34,35 @@ import { getRoomPackagesSchema, getRoomsAvailabilitySchema, } 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 { getCitiesByCountry, getCountries, + getHotelIdsByCityId, + getHotelIdsByCountry, getLocations, TWENTYFOUR_HOURS, } from "./utils" @@ -45,57 +71,9 @@ import type { BedTypeSelection } from "@/types/components/hotelReservation/enter import { BreakfastPackageEnum } from "@/types/enums/breakfast" import { HotelTypeEnum } from "@/types/enums/hotelType" import type { RequestOptionsWithOutBody } from "@/types/fetch" - -const meter = metrics.getMeter("trpc.hotels") -const getHotelCounter = meter.createCounter("trpc.hotel.get") -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" -) +import type { Hotel } from "@/types/hotel" +import type { HotelPageUrl } from "@/types/trpc/routers/contentstack/hotelPage" +import type { CityLocation } from "@/types/trpc/routers/hotel/locations" export const getHotelData = cache( async (input: HotelDataInput, serviceToken: string) => { @@ -695,6 +673,199 @@ export const hotelQueryRouter = router({ 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 options: RequestOptionsWithOutBody = { + cache: "force-cache", + 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: toApiLang(ctx.lang), + }) + const locations = await getLocations( + ctx.lang, + 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: ctx.lang, + 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: ctx.lang, + 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({ get: serviceProcedure.query(async function ({ ctx }) { const searchParams = new URLSearchParams() diff --git a/server/routers/hotels/telemetry.ts b/server/routers/hotels/telemetry.ts new file mode 100644 index 000000000..72ea046ca --- /dev/null +++ b/server/routers/hotels/telemetry.ts @@ -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" +) diff --git a/server/routers/hotels/utils.ts b/server/routers/hotels/utils.ts index 3e5b5f5e4..f2b9803e5 100644 --- a/server/routers/hotels/utils.ts +++ b/server/routers/hotels/utils.ts @@ -10,11 +10,18 @@ import { apiLocationsSchema, type CitiesGroupedByCountry, type Countries, + getHotelIdsByCityIdSchema, } from "./output" +import { + getHotelIdsCounter, + getHotelIdsFailCounter, + getHotelIdsSuccessCounter, +} from "./telemetry" +import type { Country } from "@/types/enums/country" import type { RequestOptionsWithOutBody } from "@/types/fetch" 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 { Endpoint } from "@/lib/api/endpoints" @@ -258,3 +265,145 @@ export async function getLocations( { revalidate: TWENTYFOUR_HOURS } )(params, citiesByCountry) } + +export async function getHotelIdsByCityId( + cityId: string, + options: RequestOptionsWithOutBody, + params: URLSearchParams +) { + return unstable_cache( + async function (params: URLSearchParams) { + getHotelIdsCounter.add(1, { cityId }) + console.info( + "api.hotel.hotel-ids start", + JSON.stringify({ query: { cityId } }) + ) + const apiResponse = await api.get( + api.endpoints.v1.Hotel.hotels, + options, + params + ) + + if (!apiResponse.ok) { + const responseMessage = await apiResponse.text() + getHotelIdsFailCounter.add(1, { + cityId, + error_type: "http_error", + error: responseMessage, + }) + console.error( + "api.hotel.hotel-ids fetch error", + JSON.stringify({ + query: { cityId }, + 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, { + cityId, + error_type: "validation_error", + error: JSON.stringify(validatedHotelIds.error), + }) + console.error( + "api.hotel.hotel-ids validation error", + JSON.stringify({ + query: { cityId }, + error: validatedHotelIds.error, + }) + ) + return null + } + + getHotelIdsSuccessCounter.add(1, { cityId }) + console.info( + "api.hotel.hotel-ids success", + JSON.stringify({ query: { cityId } }) + ) + + return validatedHotelIds.data + }, + [`hotels`, 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 + }, + [`hotels`, params.toString()], + { revalidate: TWENTYFOUR_HOURS } + )(params) +} diff --git a/types/components/blocks/hotelListing.ts b/types/components/blocks/hotelListing.ts new file mode 100644 index 000000000..6505ac184 --- /dev/null +++ b/types/components/blocks/hotelListing.ts @@ -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"] +} diff --git a/types/components/contentPage/hotelListingItem.ts b/types/components/contentPage/hotelListingItem.ts index 1065d1c5f..d6bf0283b 100644 --- a/types/components/contentPage/hotelListingItem.ts +++ b/types/components/contentPage/hotelListingItem.ts @@ -1,9 +1,8 @@ -export type HotelListingItemProps = { - imageUrl: string - altText: string - name: string - address: string - distanceToCentre: number - description: string - link: string +import type { Hotel } from "@/types/hotel" +import type { HotelListing } from "@/types/trpc/routers/contentstack/blocks" + +export interface HotelListingItemProps { + hotel: Hotel + contentType: HotelListing["contentType"] + url: string | null } diff --git a/types/components/hotelReservation/selectHotel/hotelLogoProps.ts b/types/components/hotelLogo.ts similarity index 100% rename from types/components/hotelReservation/selectHotel/hotelLogoProps.ts rename to types/components/hotelLogo.ts diff --git a/types/enums/blocks.ts b/types/enums/blocks.ts index 83ee7a13e..73e0992e8 100644 --- a/types/enums/blocks.ts +++ b/types/enums/blocks.ts @@ -9,5 +9,6 @@ export namespace BlocksEnums { TextCols = "TextCols", TextContent = "TextContent", UspGrid = "UspGrid", + HotelListing = "HotelListing", } } diff --git a/types/enums/contentPage.ts b/types/enums/contentPage.ts index b00556a30..55fb12c35 100644 --- a/types/enums/contentPage.ts +++ b/types/enums/contentPage.ts @@ -9,6 +9,7 @@ export namespace ContentPageEnum { TextCols = "ContentPageBlocksTextCols", UspGrid = "ContentPageBlocksUspGrid", Table = "ContentPageBlocksTable", + HotelListing = "ContentPageBlocksHotelListing", } export const enum sidebar { diff --git a/types/enums/country.ts b/types/enums/country.ts new file mode 100644 index 000000000..3ccdd1e4d --- /dev/null +++ b/types/enums/country.ts @@ -0,0 +1,8 @@ +export enum Country { + Denmark = "Denmark", + Finland = "Finland", + Germany = "Germany", + Norway = "Norway", + Poland = "Poland", + Sweden = "Sweden", +} diff --git a/types/trpc/routers/contentstack/blocks.ts b/types/trpc/routers/contentstack/blocks.ts index 149f9b35c..32aedbbfc 100644 --- a/types/trpc/routers/contentstack/blocks.ts +++ b/types/trpc/routers/contentstack/blocks.ts @@ -1,15 +1,16 @@ -import { z } from "zod" +import type { z } from "zod" -import { +import type { cardsGridSchema, teaserCardBlockSchema, } from "@/server/routers/contentstack/schemas/blocks/cardsGrid" -import { contentSchema } from "@/server/routers/contentstack/schemas/blocks/content" -import { dynamicContentSchema } from "@/server/routers/contentstack/schemas/blocks/dynamicContent" -import { shortcutsSchema } from "@/server/routers/contentstack/schemas/blocks/shortcuts" -import { tableSchema } from "@/server/routers/contentstack/schemas/blocks/table" -import { textColsSchema } from "@/server/routers/contentstack/schemas/blocks/textCols" -import { uspGridSchema } from "@/server/routers/contentstack/schemas/blocks/uspGrid" +import type { contentSchema } from "@/server/routers/contentstack/schemas/blocks/content" +import type { dynamicContentSchema } from "@/server/routers/contentstack/schemas/blocks/dynamicContent" +import type { hotelListingSchema } from "@/server/routers/contentstack/schemas/blocks/hotelListing" +import type { shortcutsSchema } from "@/server/routers/contentstack/schemas/blocks/shortcuts" +import type { tableSchema } from "@/server/routers/contentstack/schemas/blocks/table" +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 {} export interface CardsGrid extends z.output {} @@ -21,3 +22,5 @@ export interface TableBlock extends z.output {} export type TableData = TableBlock["table"] export interface TextCols extends z.output {} export interface UspGrid extends z.output {} +interface GetHotelListing extends z.output {} +export type HotelListing = GetHotelListing["hotel_listing"] diff --git a/types/trpc/routers/contentstack/hotelPage.ts b/types/trpc/routers/contentstack/hotelPage.ts index e157eaecf..6b98f5ca2 100644 --- a/types/trpc/routers/contentstack/hotelPage.ts +++ b/types/trpc/routers/contentstack/hotelPage.ts @@ -1,11 +1,12 @@ -import { z } from "zod" +import type { z } from "zod" -import { +import type { contentBlock, hotelPageRefsSchema, hotelPageSchema, + hotelPageUrlSchema, } 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 {} export interface HotelPage extends z.output {} @@ -18,3 +19,7 @@ export interface GetHotelPageRefsSchema extends z.input {} export interface HotelPageRefs extends z.output {} + +export interface GetHotelPageUrlData + extends z.input {} +export type HotelPageUrl = z.output diff --git a/utils/generateTag.ts b/utils/generateTag.ts index b067f536e..df73aab53 100644 --- a/utils/generateTag.ts +++ b/utils/generateTag.ts @@ -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 { NodeRefs } from "@/types/requests/utils/refs" import type { Lang } from "@/constants/languages" @@ -109,3 +109,14 @@ export function generateLoyaltyConfigTag( export function generateServiceTokenTag(scopes: string[]) { 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}` +} From 3af542b075e077686c022283c8d34c3a67eac839 Mon Sep 17 00:00:00 2001 From: Erik Tiekstra Date: Wed, 18 Dec 2024 14:29:29 +0100 Subject: [PATCH 07/11] fix(SW-664): Fixed issue with params when fetching data from API in relation to Hotellisting --- lib/api/index.ts | 13 ++----------- server/routers/hotels/query.ts | 21 +++++++++++++-------- server/routers/hotels/utils.ts | 18 +++++++++--------- 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/lib/api/index.ts b/lib/api/index.ts index 62ae6a8a2..5842b5597 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -30,20 +30,11 @@ const wrappedFetch = fetchRetry(fetch, { export async function get( endpoint: Endpoint, options: RequestOptionsWithOutBody, - params: Record = {} + params = {} ) { const url = new URL(env.API_BASEURL) url.pathname = endpoint - const searchParams = new URLSearchParams() - 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() + url.search = new URLSearchParams(params).toString() return wrappedFetch( url, merge.all([defaultOptions, { method: "GET" }, options]) diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index 4cebfea69..86d615ebd 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -1,3 +1,4 @@ +import { ApiLang } from "@/constants/languages" import * as api from "@/lib/api" import { dt } from "@/lib/dt" import { badRequestError } from "@/server/errors/trpc" @@ -79,12 +80,13 @@ export const getHotelData = cache( async (input: HotelDataInput, serviceToken: string) => { const { hotelId, language, isCardOnlyPayment } = input - const params: Record = { + const includes = ["RoomCategories", "Restaurants"] // "RoomCategories","NearbyHotels","Restaurants","City", + const params = new URLSearchParams({ hotelId, language, - } + }) - params.include = ["RoomCategories", "Restaurants"] // "RoomCategories","NearbyHotels","Restaurants","City", + includes.forEach((include) => params.append("include", include)) getHotelCounter.add(1, { hotelId, @@ -680,8 +682,11 @@ export const hotelQueryRouter = router({ const { locationFilter, hotelsToInclude } = input const language = ctx.lang + const apiLang = toApiLang(language) const options: RequestOptionsWithOutBody = { - cache: "force-cache", + // needs to clear default option as only + // cache or next.revalidate is permitted + cache: undefined, headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, @@ -710,10 +715,10 @@ export const hotelQueryRouter = router({ hotelsToFetch = hotelsToInclude } else if (locationFilter?.city) { const locationsParams = new URLSearchParams({ - language: toApiLang(ctx.lang), + language: apiLang, }) const locations = await getLocations( - ctx.lang, + language, options, locationsParams, null @@ -744,7 +749,7 @@ export const hotelQueryRouter = router({ return [] } const hotelIdsParams = new URLSearchParams({ - language: ctx.lang, + language: apiLang, city: cityId, onlyBasicInfo: "true", }) @@ -779,7 +784,7 @@ export const hotelQueryRouter = router({ hotelsToFetch = filteredHotelIds } else if (locationFilter?.country) { const hotelIdsParams = new URLSearchParams({ - language: ctx.lang, + language: ApiLang.En, country: locationFilter.country, onlyBasicInfo: "true", }) diff --git a/server/routers/hotels/utils.ts b/server/routers/hotels/utils.ts index f2b9803e5..726947538 100644 --- a/server/routers/hotels/utils.ts +++ b/server/routers/hotels/utils.ts @@ -273,10 +273,10 @@ export async function getHotelIdsByCityId( ) { return unstable_cache( async function (params: URLSearchParams) { - getHotelIdsCounter.add(1, { cityId }) + getHotelIdsCounter.add(1, { params: params.toString() }) console.info( "api.hotel.hotel-ids start", - JSON.stringify({ query: { cityId } }) + JSON.stringify({ params: params.toString() }) ) const apiResponse = await api.get( api.endpoints.v1.Hotel.hotels, @@ -287,14 +287,14 @@ export async function getHotelIdsByCityId( if (!apiResponse.ok) { const responseMessage = await apiResponse.text() getHotelIdsFailCounter.add(1, { - cityId, + params: params.toString(), error_type: "http_error", error: responseMessage, }) console.error( "api.hotel.hotel-ids fetch error", JSON.stringify({ - query: { cityId }, + params: params.toString(), error: { status: apiResponse.status, statusText: apiResponse.statusText, @@ -310,14 +310,14 @@ export async function getHotelIdsByCityId( const validatedHotelIds = getHotelIdsByCityIdSchema.safeParse(apiJson) if (!validatedHotelIds.success) { getHotelIdsFailCounter.add(1, { - cityId, + params: params.toString(), error_type: "validation_error", error: JSON.stringify(validatedHotelIds.error), }) console.error( "api.hotel.hotel-ids validation error", JSON.stringify({ - query: { cityId }, + params: params.toString(), error: validatedHotelIds.error, }) ) @@ -327,12 +327,12 @@ export async function getHotelIdsByCityId( getHotelIdsSuccessCounter.add(1, { cityId }) console.info( "api.hotel.hotel-ids success", - JSON.stringify({ query: { cityId } }) + JSON.stringify({ params: params.toString() }) ) return validatedHotelIds.data }, - [`hotels`, params.toString()], + [`hotelsByCityId`, params.toString()], { revalidate: TWENTYFOUR_HOURS } )(params) } @@ -403,7 +403,7 @@ export async function getHotelIdsByCountry( return validatedHotelIds.data }, - [`hotels`, params.toString()], + [`hotelsByCountry`, params.toString()], { revalidate: TWENTYFOUR_HOURS } )(params) } From dddd93a98ab3761c9531222506580091c131ba16 Mon Sep 17 00:00:00 2001 From: Chuma McPhoy Date: Wed, 18 Dec 2024 09:41:43 +0100 Subject: [PATCH 08/11] fix(LOY-90): use double qutes for object keys + country fixes --- components/TempDesignSystem/Form/Country/countries.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/components/TempDesignSystem/Form/Country/countries.ts b/components/TempDesignSystem/Form/Country/countries.ts index 6349414b0..21aed5e9f 100644 --- a/components/TempDesignSystem/Form/Country/countries.ts +++ b/components/TempDesignSystem/Form/Country/countries.ts @@ -52,7 +52,7 @@ export const countriesMap = { "Congo, The Democratic Republic of the": "CD", "Cook Islands": "CK", "Costa Rica": "CR", - 'Cote D"Ivoire': "CI", + "Côte d'Ivoire": "CI", Croatia: "HR", Cuba: "CU", Curacao: "CW", @@ -109,7 +109,6 @@ export const countriesMap = { "Isle of Man": "IM", Israel: "IL", Italy: "IT", - "Ivory Coast": "CI", Jamaica: "JM", Japan: "JP", Jersey: "JE", @@ -171,7 +170,7 @@ export const countriesMap = { Oman: "OM", Pakistan: "PK", Palau: "PW", - "Palestinian Territory, Occupied": "PS", + Palestine: "PS", Panama: "PA", "Papua New Guinea": "PG", Paraguay: "PY", From 528652d603e3979fe916bd3c1b7ace181d2befc8 Mon Sep 17 00:00:00 2001 From: Chuma McPhoy Date: Wed, 18 Dec 2024 17:52:43 +0100 Subject: [PATCH 09/11] fix(LOY-90): remove duplicate country value --- components/TempDesignSystem/Form/Country/countries.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/components/TempDesignSystem/Form/Country/countries.ts b/components/TempDesignSystem/Form/Country/countries.ts index 21aed5e9f..f9a0e72d9 100644 --- a/components/TempDesignSystem/Form/Country/countries.ts +++ b/components/TempDesignSystem/Form/Country/countries.ts @@ -214,7 +214,6 @@ export const countriesMap = { Sudan: "SD", Suriname: "SR", "Svalbard and Jan Mayen": "SJ", - Swaziland: "SZ", Sweden: "SE", Switzerland: "CH", "Syrian Arab Republic": "SY", From 3087bd918b8c1cb56ca91fe254966df9bbc54931 Mon Sep 17 00:00:00 2001 From: Erik Tiekstra Date: Thu, 19 Dec 2024 09:21:33 +0100 Subject: [PATCH 10/11] fix(SW-1211): Fixed issue where special alerts are showing when either startDate or endDate is missing --- server/routers/hotels/schemas/specialAlerts.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/server/routers/hotels/schemas/specialAlerts.ts b/server/routers/hotels/schemas/specialAlerts.ts index 9d5dbd63c..6acd8407a 100644 --- a/server/routers/hotels/schemas/specialAlerts.ts +++ b/server/routers/hotels/schemas/specialAlerts.ts @@ -1,7 +1,9 @@ -import { dt } from "@/lib/dt" -import { AlertTypeEnum } from "@/types/enums/alert" import { z } from "zod" +import { dt } from "@/lib/dt" + +import { AlertTypeEnum } from "@/types/enums/alert" + const specialAlertSchema = z.object({ type: z.string(), title: z.string().optional(), @@ -16,10 +18,14 @@ export const specialAlertsSchema = z .transform((data) => { const now = dt().utc().format("YYYY-MM-DD") const filteredAlerts = data.filter((alert) => { - const shouldShowNow = - alert.startDate && alert.endDate - ? alert.startDate <= now && alert.endDate >= now - : true + let shouldShowNow = true + + if (alert.startDate && alert.startDate > now) { + shouldShowNow = false + } + if (alert.endDate && alert.endDate < now) { + shouldShowNow = false + } const hasText = alert.description || alert.title return shouldShowNow && hasText }) From a3f8c954e266523f87ae16315be995e176b7dd11 Mon Sep 17 00:00:00 2001 From: Niclas Edenvin Date: Thu, 19 Dec 2024 09:29:24 +0000 Subject: [PATCH 11/11] Merged in fix/detailed-facilities-validation (pull request #1108) fix(SW-1260): allow unrecognized hotel facilities * fix(SW-1260): allow unrecognized hotel facilities Before this we only allowed hotel facilities that we know about (ID:s in an enum). Now we instead allow everything, but filter away the ones we don't know about. Approved-by: Christel Westerberg --- server/routers/hotels/output.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/server/routers/hotels/output.ts b/server/routers/hotels/output.ts index ae35da556..2621d5ce8 100644 --- a/server/routers/hotels/output.ts +++ b/server/routers/hotels/output.ts @@ -124,7 +124,7 @@ const hotelContentSchema = z.object({ }) const detailedFacilitySchema = z.object({ - id: z.nativeEnum(FacilityEnum), + id: z.number(), name: z.string(), public: z.boolean(), sortOrder: z.number(), @@ -389,6 +389,9 @@ const hotelFactsSchema = z.object({ yearBuilt: z.string(), }) +type DetailedFacility = { id: FacilityEnum } & z.infer< + typeof detailedFacilitySchema +> export const hotelAttributesSchema = z.object({ accessibilityElevatorPitchText: z.string().optional(), address: addressSchema, @@ -396,11 +399,15 @@ export const hotelAttributesSchema = z.object({ cityName: z.string(), conferencesAndMeetings: facilitySchema.optional(), contactInformation: contactInformationSchema, - detailedFacilities: z - .array(detailedFacilitySchema) - .transform((facilities) => - facilities.sort((a, b) => b.sortOrder - a.sortOrder) - ), + detailedFacilities: z.array(detailedFacilitySchema).transform( + (facilities) => + facilities + // 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(), galleryImages: z.array(imageSchema).optional(), healthAndWellness: facilitySchema.optional(),