{hotelPins.map((pin) => {
@@ -44,13 +50,13 @@ export default function HotelListingMapContent({
zIndex={isActiveOrHovered ? 2 : 0}
onMouseEnter={() => setHoveredHotelPin(pin.name)}
onMouseLeave={() => setHoveredHotelPin(null)}
- onClick={() =>
+ onClick={() => {
toggleActiveHotelPin(
activeHotelPin === pin.name ? null : pin.name
)
- }
+ }}
>
-
+
void }) => {
diff --git a/components/Maps/InteractiveMap/HotelMapContent/index.tsx b/components/Maps/InteractiveMap/HotelMapContent/index.tsx
index 04c796c2c..662c66667 100644
--- a/components/Maps/InteractiveMap/HotelMapContent/index.tsx
+++ b/components/Maps/InteractiveMap/HotelMapContent/index.tsx
@@ -35,9 +35,9 @@ export default function HotelMapContent({
position={poi.coordinates}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
zIndex={activePoi === poi.name ? 2 : 0}
- onMouseEnter={() => onActivePoiChange?.(poi.name)}
+ onMouseEnter={() => onActivePoiChange?.(poi.name ?? null)}
onMouseLeave={() => onActivePoiChange?.(null)}
- onClick={() => toggleActivePoi(poi.name)}
+ onClick={() => toggleActivePoi(poi.name ?? "")}
>
+
+
+
+ {children}
+
+ )
+}
diff --git a/components/MyPages/Surprises/Client.tsx b/components/MyPages/Surprises/Client.tsx
new file mode 100644
index 000000000..f2adf4186
--- /dev/null
+++ b/components/MyPages/Surprises/Client.tsx
@@ -0,0 +1,214 @@
+"use client"
+
+import { AnimatePresence, motion } from "framer-motion"
+import { usePathname } from "next/navigation"
+import React, { useState } from "react"
+import { Dialog, Modal, ModalOverlay } from "react-aria-components"
+import { useIntl } from "react-intl"
+
+import { benefits } from "@/constants/routes/myPages"
+import { trpc } from "@/lib/trpc/client"
+
+import Link from "@/components/TempDesignSystem/Link"
+import Caption from "@/components/TempDesignSystem/Text/Caption"
+import { toast } from "@/components/TempDesignSystem/Toasts"
+import useLang from "@/hooks/useLang"
+
+import confetti from "./confetti"
+import Header from "./Header"
+import Initial from "./Initial"
+import Navigation from "./Navigation"
+import Slide from "./Slide"
+
+import styles from "./surprises.module.css"
+
+import type { SurprisesProps } from "@/types/components/blocks/surprises"
+
+const MotionModal = motion(Modal)
+
+export default function SurprisesNotification({
+ surprises,
+ membershipNumber,
+}: SurprisesProps) {
+ const lang = useLang()
+ const intl = useIntl()
+ const pathname = usePathname()
+ const [open, setOpen] = useState(true)
+ const [[selectedSurprise, direction], setSelectedSurprise] = useState([0, 0])
+ const [showSurprises, setShowSurprises] = useState(false)
+ const unwrap = trpc.contentstack.rewards.unwrap.useMutation({
+ onSuccess: () => {
+ if (pathname.indexOf(benefits[lang]) !== 0) {
+ toast.success(
+ <>
+ {intl.formatMessage(
+ { id: "Gift(s) added to your benefits" },
+ { amount: surprises.length }
+ )}
+
+
+ {intl.formatMessage({ id: "Go to My Benefits" })}
+
+ >
+ )
+ }
+ },
+ onError: (error) => {
+ console.error("Failed to unwrap surprise", error)
+ },
+ })
+
+ const totalSurprises = surprises.length
+ if (!totalSurprises) {
+ return null
+ }
+
+ const surprise = surprises[selectedSurprise]
+
+ function showSurprise(newDirection: number) {
+ setSelectedSurprise(([currentIndex]) => [
+ currentIndex + newDirection,
+ newDirection,
+ ])
+ }
+
+ async function viewRewards() {
+ const updates = surprises
+ .map((surprise) => {
+ const coupons = surprise.coupons
+ ?.map((coupon) => {
+ if (coupon?.couponCode) {
+ return {
+ rewardId: surprise.id,
+ couponCode: coupon.couponCode,
+ }
+ }
+ })
+ .filter(
+ (coupon): coupon is { rewardId: string; couponCode: string } =>
+ !!coupon
+ )
+
+ return coupons
+ })
+ .flat()
+
+ unwrap.mutate(updates)
+ }
+
+ return (
+
+
+
+ {open && (
+
+
+ {({ close }) => {
+ return (
+ <>
+ {
+ viewRewards()
+ close()
+ }}
+ >
+ {showSurprises && totalSurprises > 1 && (
+
+ {intl.formatMessage(
+ { id: "{amount} out of {total}" },
+ {
+ amount: selectedSurprise + 1,
+ total: totalSurprises,
+ }
+ )}
+
+ )}
+
+
+ {showSurprises ? (
+ <>
+
+
+
+
+
+
+ {totalSurprises > 1 && (
+
+ )}
+ >
+ ) : (
+ {
+ setShowSurprises(true)
+ }}
+ />
+ )}
+ >
+ )
+ }}
+
+
+ )}
+
+
+ )
+}
+
+const variants = {
+ enter: (direction: number) => {
+ return {
+ x: direction > 0 ? 1000 : -1000,
+ opacity: 0,
+ }
+ },
+ center: {
+ x: 0,
+ opacity: 1,
+ },
+ exit: (direction: number) => {
+ return {
+ x: direction < 0 ? 1000 : -1000,
+ opacity: 0,
+ }
+ },
+}
diff --git a/components/MyPages/Surprises/Header.tsx b/components/MyPages/Surprises/Header.tsx
new file mode 100644
index 000000000..3a25400a9
--- /dev/null
+++ b/components/MyPages/Surprises/Header.tsx
@@ -0,0 +1,16 @@
+import { CloseLargeIcon } from "@/components/Icons"
+
+import styles from "./surprises.module.css"
+
+import type { HeaderProps } from "@/types/components/blocks/surprises"
+
+export default function Header({ onClose, children }: HeaderProps) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
diff --git a/components/MyPages/Surprises/Initial.tsx b/components/MyPages/Surprises/Initial.tsx
new file mode 100644
index 000000000..8e3a60b9c
--- /dev/null
+++ b/components/MyPages/Surprises/Initial.tsx
@@ -0,0 +1,62 @@
+import { useIntl } from "react-intl"
+
+import Button from "@/components/TempDesignSystem/Button"
+import Body from "@/components/TempDesignSystem/Text/Body"
+import Caption from "@/components/TempDesignSystem/Text/Caption"
+
+import Card from "./Card"
+
+import type { InitialProps } from "@/types/components/blocks/surprises"
+
+export default function Initial({ totalSurprises, onOpen }: InitialProps) {
+ const intl = useIntl()
+
+ return (
+
+
+ {totalSurprises > 1 ? (
+ <>
+ {intl.formatMessage(
+ {
+ id: "You have # gifts waiting for you!",
+ },
+ {
+ amount: totalSurprises,
+ b: (str) => {str} ,
+ }
+ )}
+
+ {intl.formatMessage({
+ id: "Hurry up and use them before they expire!",
+ })}
+ >
+ ) : (
+ intl.formatMessage({
+ id: "We have a special gift waiting for you!",
+ })
+ )}
+
+
+ {intl.formatMessage({
+ id: "You'll find all your gifts in 'My benefits'",
+ })}
+
+
+
+ {intl.formatMessage(
+ {
+ id: "Open gift(s)",
+ },
+ { amount: totalSurprises }
+ )}
+
+
+ )
+}
diff --git a/components/MyPages/Surprises/Navigation.tsx b/components/MyPages/Surprises/Navigation.tsx
new file mode 100644
index 000000000..2a1296f01
--- /dev/null
+++ b/components/MyPages/Surprises/Navigation.tsx
@@ -0,0 +1,45 @@
+import { useIntl } from "react-intl"
+
+import { ChevronRightSmallIcon } from "@/components/Icons"
+import Button from "@/components/TempDesignSystem/Button"
+
+import styles from "./surprises.module.css"
+
+import type { NavigationProps } from "@/types/components/blocks/surprises"
+
+export default function Navigation({
+ selectedSurprise,
+ totalSurprises,
+ showSurprise,
+}: NavigationProps) {
+ const intl = useIntl()
+
+ return (
+
+ showSurprise(-1)}
+ size="small"
+ >
+
+ {intl.formatMessage({ id: "Previous" })}
+
+ showSurprise(1)}
+ size="small"
+ >
+ {intl.formatMessage({ id: "Next" })}
+
+
+
+ )
+}
diff --git a/components/MyPages/Surprises/Slide.tsx b/components/MyPages/Surprises/Slide.tsx
new file mode 100644
index 000000000..2a4173fca
--- /dev/null
+++ b/components/MyPages/Surprises/Slide.tsx
@@ -0,0 +1,49 @@
+import { useIntl } from "react-intl"
+
+import { dt } from "@/lib/dt"
+
+import Body from "@/components/TempDesignSystem/Text/Body"
+import Caption from "@/components/TempDesignSystem/Text/Caption"
+import useLang from "@/hooks/useLang"
+
+import Card from "./Card"
+
+import styles from "./surprises.module.css"
+
+import type { SlideProps } from "@/types/components/blocks/surprises"
+
+export default function Slide({ surprise, membershipNumber }: SlideProps) {
+ const lang = useLang()
+ const intl = useIntl()
+
+ const earliestExpirationDate = surprise.coupons?.reduce(
+ (earliestDate, coupon) => {
+ const expiresAt = dt(coupon.expiresAt)
+ return earliestDate.isBefore(expiresAt) ? earliestDate : expiresAt
+ },
+ dt()
+ )
+ return (
+
+ {surprise.description}
+
+
+ {intl.formatMessage(
+ { id: "Expires at the earliest" },
+ {
+ date: dt(earliestExpirationDate)
+ .locale(lang)
+ .format("D MMM YYYY"),
+ }
+ )}
+
+
+ {intl.formatMessage({
+ id: "Membership ID",
+ })}{" "}
+ {membershipNumber}
+
+
+
+ )
+}
diff --git a/components/MyPages/Surprises/SurprisesNotification.tsx b/components/MyPages/Surprises/SurprisesNotification.tsx
deleted file mode 100644
index 6b71c6703..000000000
--- a/components/MyPages/Surprises/SurprisesNotification.tsx
+++ /dev/null
@@ -1,247 +0,0 @@
-"use client"
-
-import { usePathname } from "next/navigation"
-import React, { useState } from "react"
-import { Dialog, Modal, ModalOverlay } from "react-aria-components"
-import { useIntl } from "react-intl"
-
-import { benefits } from "@/constants/routes/myPages"
-import { dt } from "@/lib/dt"
-import { trpc } from "@/lib/trpc/client"
-
-import { ChevronRightSmallIcon, CloseLargeIcon } from "@/components/Icons"
-import Image from "@/components/Image"
-import Button from "@/components/TempDesignSystem/Button"
-import Link from "@/components/TempDesignSystem/Link"
-import Body from "@/components/TempDesignSystem/Text/Body"
-import Caption from "@/components/TempDesignSystem/Text/Caption"
-import Title from "@/components/TempDesignSystem/Text/Title"
-import { toast } from "@/components/TempDesignSystem/Toasts"
-import useLang from "@/hooks/useLang"
-
-import styles from "./surprises.module.css"
-
-import type { SurprisesProps } from "@/types/components/blocks/surprises"
-
-export default function SurprisesNotification({
- surprises,
- membershipNumber,
-}: SurprisesProps) {
- const lang = useLang()
- const pathname = usePathname()
- const [open, setOpen] = useState(true)
- const [selectedSurprise, setSelectedSurprise] = useState(0)
- const [showSurprises, setShowSurprises] = useState(false)
- const update = trpc.contentstack.rewards.update.useMutation()
- const intl = useIntl()
-
- if (!surprises.length) {
- return null
- }
-
- const surprise = surprises[selectedSurprise]
-
- function showSurprise(n: number) {
- setSelectedSurprise((surprise) => surprise + n)
- }
-
- function viewRewards() {
- if (surprise.reward_id) {
- update.mutate({ id: surprise.reward_id })
- }
- }
-
- function closeModal(close: VoidFunction) {
- viewRewards()
- close()
-
- if (pathname.indexOf(benefits[lang]) !== 0) {
- toast.success(
- <>
- {intl.formatMessage(
- { id: "Gift(s) added to your benefits" },
- { amount: surprises.length }
- )}
-
-
- {intl.formatMessage({ id: "Go to My Benefits" })}
-
- >
- )
- }
- }
-
- return (
-
-
-
- {({ close }) => {
- return (
- <>
-
- {surprises.length > 1 && showSurprises && (
-
- {intl.formatMessage(
- { id: "{amount} out of {total}" },
- {
- amount: selectedSurprise + 1,
- total: surprises.length,
- }
- )}
-
- )}
- closeModal(close)}
- type="button"
- className={styles.close}
- >
-
-
-
- {showSurprises ? (
- <>
-
-
- {surprise.description}
-
-
- {intl.formatMessage({ id: "Valid through" })}{" "}
- {dt(surprise.endsAt)
- .locale(lang)
- .format("DD MMM YYYY")}
-
-
- {intl.formatMessage({ id: "Membership ID" })}{" "}
- {membershipNumber}
-
-
-
-
- {surprises.length > 1 && (
- <>
-
- showSurprise(-1)}
- size="small"
- >
-
- {intl.formatMessage({ id: "Previous" })}
-
- showSurprise(1)}
- size="small"
- >
- {intl.formatMessage({ id: "Next" })}
-
-
-
- >
- )}
- >
- ) : (
-
- {surprises.length > 1 ? (
-
-
- {intl.formatMessage(
- {
- id: "You have # gifts waiting for you!",
- },
- {
- amount: surprises.length,
- b: (str) => {str} ,
- }
- )}
-
- {intl.formatMessage({
- id: "Hurry up and use them before they expire!",
- })}
-
-
- {intl.formatMessage({
- id: "You'll find all your gifts in 'My benefits'",
- })}
-
-
- ) : (
-
-
- {intl.formatMessage({
- id: "We have a special gift waiting for you!",
- })}
-
-
- {intl.formatMessage({
- id: "You'll find all your gifts in 'My benefits'",
- })}
-
-
- )}
-
- {
- viewRewards()
- setShowSurprises(true)
- }}
- size="medium"
- theme="base"
- fullWidth
- autoFocus
- >
- {intl.formatMessage(
- {
- id: "Open gift(s)",
- },
- { amount: surprises.length }
- )}
-
-
- )}
- >
- )
- }}
-
-
-
- )
-}
-
-function Surprise({
- title,
- children,
-}: {
- title?: string
- children?: React.ReactNode
-}) {
- return (
- <>
-
-
- {title}
-
-
- {children}
- >
- )
-}
diff --git a/components/MyPages/Surprises/confetti.ts b/components/MyPages/Surprises/confetti.ts
new file mode 100644
index 000000000..2990fade4
--- /dev/null
+++ b/components/MyPages/Surprises/confetti.ts
@@ -0,0 +1,13 @@
+import { confetti as particlesConfetti } from "@tsparticles/confetti"
+
+export default function confetti() {
+ particlesConfetti("surprise-confetti", {
+ count: 300,
+ spread: 150,
+ position: {
+ y: 60,
+ },
+ colors: ["#cd0921", "#4d001b", "#fff"],
+ shapes: ["star", "square", "circle", "polygon"],
+ })
+}
diff --git a/components/MyPages/Surprises/index.tsx b/components/MyPages/Surprises/index.tsx
index 131ae2b28..2ae414a3a 100644
--- a/components/MyPages/Surprises/index.tsx
+++ b/components/MyPages/Surprises/index.tsx
@@ -1,9 +1,14 @@
+import { env } from "@/env/server"
import { getProfile } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
-import SurprisesNotification from "./SurprisesNotification"
+import SurprisesClient from "./Client"
export default async function Surprises() {
+ if (env.HIDE_FOR_NEXT_RELEASE) {
+ return null
+ }
+
const user = await getProfile()
if (!user || "error" in user) {
@@ -17,7 +22,7 @@ export default async function Surprises() {
}
return (
-
diff --git a/components/MyPages/Surprises/surprises.module.css b/components/MyPages/Surprises/surprises.module.css
index 4bde7c46d..9a80761f4 100644
--- a/components/MyPages/Surprises/surprises.module.css
+++ b/components/MyPages/Surprises/surprises.module.css
@@ -1,4 +1,4 @@
-@keyframes modal-fade {
+@keyframes fade {
from {
opacity: 0;
}
@@ -8,16 +8,6 @@
}
}
-@keyframes slide-up {
- from {
- transform: translateY(100%);
- }
-
- to {
- transform: translateY(0);
- }
-}
-
.overlay {
background: rgba(0, 0, 0, 0.5);
height: var(--visual-viewport-height);
@@ -28,10 +18,10 @@
z-index: 100;
&[data-entering] {
- animation: modal-fade 200ms;
+ animation: fade 400ms ease-in;
}
&[data-exiting] {
- animation: modal-fade 200ms reverse ease-in;
+ animation: fade 400ms reverse ease-in;
}
}
@@ -43,6 +33,19 @@
}
}
+@media screen and (min-width: 768px) and (prefers-reduced-motion) {
+ .overlay:before {
+ background-image: url("/_static/img/confetti.svg");
+ background-repeat: no-repeat;
+ background-position: center 40%;
+ content: "";
+ width: 100%;
+ height: 100%;
+ animation: fade 400ms ease-in;
+ display: block;
+ }
+}
+
.modal {
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium);
@@ -51,20 +54,7 @@
position: absolute;
left: 0;
bottom: 0;
-
- &[data-entering] {
- animation: slide-up 200ms;
- }
- &[data-exiting] {
- animation: slide-up 200ms reverse ease-in-out;
- }
-}
-
-.dialog {
- display: flex;
- flex-direction: column;
- gap: var(--Spacing-x2);
- padding-bottom: var(--Spacing-x2);
+ z-index: 102;
}
@media screen and (min-width: 768px) {
@@ -75,6 +65,17 @@
}
}
+.dialog {
+ display: flex;
+ flex-direction: column;
+ gap: var(--Spacing-x2);
+ padding-bottom: var(--Spacing-x2);
+
+ /* to hide sliding cards */
+ position: relative;
+ overflow: hidden;
+}
+
.top {
--button-height: 32px;
box-sizing: content-box;
@@ -90,8 +91,10 @@
display: flex;
flex-direction: column;
align-items: center;
+ justify-content: center;
padding: 0 var(--Spacing-x3);
gap: var(--Spacing-x2);
+ min-height: 350px;
}
.nav {
@@ -103,6 +106,8 @@
}
.nav button {
+ user-select: none;
+
&:nth-child(1) {
padding-left: 0;
}
@@ -141,3 +146,12 @@
display: flex;
align-items: center;
}
+
+/*
+ * temporary fix until next version of tsparticles is released
+ * https://github.com/tsparticles/tsparticles/issues/5375
+ */
+.confetti {
+ position: relative;
+ z-index: 101;
+}
diff --git a/components/SidePeeks/HotelSidePeek/hotelSidePeek.module.css b/components/SidePeeks/HotelSidePeek/hotelSidePeek.module.css
index 57bb78db4..37fb3aeab 100644
--- a/components/SidePeeks/HotelSidePeek/hotelSidePeek.module.css
+++ b/components/SidePeeks/HotelSidePeek/hotelSidePeek.module.css
@@ -13,8 +13,14 @@
font-family: var(--typography-Body-Regular-fontFamily);
border-bottom: 1px solid var(--Base-Border-Subtle);
/* padding set to align with AccordionItem which has a different composition */
- padding: var(--Spacing-x2)
- calc(var(--Spacing-x1) + var(--Spacing-x-one-and-half));
+ padding: calc(var(--Spacing-x1) + var(--Spacing-x-one-and-half))
+ var(--Spacing-x3);
+ display: flex;
+ gap: var(--Spacing-x1);
+}
+
+.noIcon {
+ margin-left: var(--Spacing-x4);
}
.list {
diff --git a/components/SidePeeks/HotelSidePeek/index.tsx b/components/SidePeeks/HotelSidePeek/index.tsx
index 06ac3e5f2..b26570bb7 100644
--- a/components/SidePeeks/HotelSidePeek/index.tsx
+++ b/components/SidePeeks/HotelSidePeek/index.tsx
@@ -1,6 +1,8 @@
import { useIntl } from "react-intl"
+import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import Contact from "@/components/HotelReservation/Contact"
+import { AccessibilityIcon } from "@/components/Icons"
import Accordion from "@/components/TempDesignSystem/Accordion"
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
import Button from "@/components/TempDesignSystem/Button"
@@ -10,10 +12,11 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./hotelSidePeek.module.css"
-import { HotelSidePeekProps } from "@/types/components/hotelReservation/hotelSidePeek"
-import { ParkingProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
+import type { HotelSidePeekProps } from "@/types/components/hotelReservation/hotelSidePeek"
+import type { ParkingProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
-import { Amenities, Hotel } from "@/types/hotel"
+import { IconName } from "@/types/components/icon"
+import type { Amenities, Hotel } from "@/types/hotel"
function getAmenitiesList(hotel: Hotel) {
const detailedAmenities: Amenities = hotel.detailedFacilities.filter(
@@ -46,19 +49,37 @@ export default function HotelSidePeek({
{/* parking */}
{hotel.parking.length ? (
-
+
{hotel.parking.map((p) => (
))}
) : null}
+
{intl.formatMessage({ id: "Accessibility" })}
{amenitiesList.map((amenity) => {
+ const Icon = mapFacilityToIcon(amenity.id)
return (
- {amenity.name}
+ {Icon && (
+
+ )}
+
+ {amenity.name}
+
)
})}
diff --git a/components/SidePeeks/RoomSidePeek/facilityIcon.ts b/components/SidePeeks/RoomSidePeek/facilityIcon.ts
index d45ce7b30..f86329504 100644
--- a/components/SidePeeks/RoomSidePeek/facilityIcon.ts
+++ b/components/SidePeeks/RoomSidePeek/facilityIcon.ts
@@ -2,21 +2,61 @@ import { FC } from "react"
import {
AcIcon,
+ AirplaneIcon,
+ BalconyIcon,
BathtubIcon,
BedDoubleIcon,
+ BedHotelIcon,
+ BedroomParentIcon,
+ BedSingleIcon,
+ BikeIcon,
+ BreakfastIcon,
+ BusinessIcon,
+ CableIcon,
ChairIcon,
CityIcon,
+ CoffeeAltIcon,
+ CoffeeIcon,
+ CoffeeMakerIcon,
+ ConciergeIcon,
+ CoolIcon,
DeskIcon,
+ DiningIcon,
+ ElectricBikeIcon,
+ FamilyIcon,
+ FitnessIcon,
+ FootstoolIcon,
HairdryerIcon,
HandSoapIcon,
+ HealthBeautyIcon,
HeartIcon,
IronIcon,
+ KayakingIcon,
+ KettleIcon,
+ LaptopIcon,
+ LaundryMachineIcon,
+ LocalBarIcon,
+ LuggageIcon,
+ MicrowaveIcon,
MirrorIcon,
NatureIcon,
+ NightlifeIcon,
NoSmokingIcon,
+ OutdoorFurnitureIcon,
+ ParkingIcon,
+ PetsIcon,
+ PhoneIcon,
+ RestaurantIcon,
+ RoomServiceIcon,
SafetyBoxIcon,
+ SaunaIcon,
ShowerIcon,
+ SpeakerIcon,
+ StoreIcon,
StreetIcon,
+ SwimIcon,
+ TshirtWashIcon,
+ TvCastingIcon,
WifiIcon,
WindowCurtainsAltIcon,
WindowNotAvailableIcon,
@@ -27,98 +67,174 @@ import {
import { IconProps } from "@/types/components/icon"
-export function getFacilityIcon(name: string): FC | null {
+export function getFacilityIcon(
+ name: string | undefined
+): FC | null {
+ if (!name) {
+ return HeartIcon
+ }
+
+ const normalizedName = name.toLowerCase()
+
const iconMappings = [
- {
- icon: DeskIcon,
- texts: ["Desk and chair"],
- },
- {
- icon: HairdryerIcon,
- texts: ["Hairdryer"],
- },
- {
- icon: AcIcon,
- texts: ["Air Condition"],
- },
- {
- icon: ChairIcon,
- texts: ["Armchair / armchairs"],
- },
- {
- icon: BathtubIcon,
- texts: ["Bathroom with shower or bathtub"],
- },
- {
- icon: WindowCurtainsAltIcon,
- texts: ["Blackout curtains"],
- },
- {
- icon: MirrorIcon,
- texts: ["Cosmetic mirror"],
- },
- {
- icon: WifiIcon,
- texts: ["Free WiFi"],
- },
- {
- icon: ChairIcon,
- texts: ["Connecting rooms"],
- },
- {
- icon: YardIcon,
- texts: ["View - atrium view"],
- },
- {
- icon: CityIcon,
- texts: ["View - city view"],
- },
- {
- icon: NatureIcon,
- texts: ["View - park view"],
- },
- {
- icon: StreetIcon,
- texts: ["View - street view"],
- },
- {
- icon: WineBarIcon,
- texts: ["Minibar"],
- },
- {
- icon: NoSmokingIcon,
- texts: ["Non smoking"],
- },
- {
- icon: ShowerIcon,
- texts: ["Rain shower"],
- },
- {
- icon: SafetyBoxIcon,
- texts: ["Safety box"],
- },
- {
- icon: BedDoubleIcon,
- texts: ["Set of two pillows"],
- },
- {
- icon: IronIcon,
- texts: ["Iron and ironing board"],
- },
- {
- icon: HandSoapIcon,
- texts: ["Toiletries"],
- },
- {
- icon: WoodFloorIcon,
- texts: ["Wooden floor"],
- },
- {
- icon: WindowNotAvailableIcon,
- texts: ["Not window"],
- },
+ { icon: AcIcon, name: "aircondition" },
+ { icon: AirplaneIcon, name: "airport" },
+ { icon: BalconyIcon, name: "balcony" },
+ { icon: BalconyIcon, name: "balconyorterrace" },
+ { icon: BalconyIcon, name: "frenchbalcony" },
+ { icon: BathtubIcon, name: "bathroomwithbathtub" },
+ { icon: BathtubIcon, name: "bathroomwithshowerandbathtub" },
+ { icon: BathtubIcon, name: "bathroomwithshowerorbathtub" },
+ { icon: BedDoubleIcon, name: "adjustablebeds" },
+ { icon: BedDoubleIcon, name: "setoftwopillows" },
+ { icon: BedHotelIcon, name: "armchairbed" },
+ { icon: BedHotelIcon, name: "pulloutbed" },
+ { icon: BedroomParentIcon, name: "separatebedroom" },
+ { icon: BedSingleIcon, name: "extrabed" },
+ { icon: BikeIcon, name: "bikeforloan" },
+ { icon: BreakfastIcon, name: "breakfast" },
+ { icon: BreakfastIcon, name: "servesbreakfastalwaysincluded" },
+ { icon: BusinessIcon, name: "meetingconferencefacilities" },
+ { icon: BusinessIcon, name: "meetingrooms" },
+ { icon: CableIcon, name: "internetwithcableintheroom" },
+ { icon: ChairIcon, name: "armchair" },
+ { icon: ChairIcon, name: "connectingrooms" },
+ { icon: CityIcon, name: "viewcityview" },
+ { icon: CoffeeAltIcon, name: "nespressomachine" },
+ { icon: CoffeeIcon, name: "café" },
+ { icon: CoffeeIcon, name: "coffee" },
+ { icon: CoffeeMakerIcon, name: "coffeemachine" },
+ { icon: ConciergeIcon, name: "lifestyleconcierge" },
+ { icon: CoolIcon, name: "aircooling" },
+ { icon: CoolIcon, name: "cooler" },
+ { icon: CoolIcon, name: "coolingcabinet" },
+ { icon: DeskIcon, name: "deskandchair" },
+ { icon: DiningIcon, name: "diningarea" },
+ { icon: DiningIcon, name: "tablefordining" },
+ { icon: ElectricBikeIcon, name: "ebikeschargingstation" },
+ { icon: FamilyIcon, name: "extrafamilyfriendly" },
+ { icon: FitnessIcon, name: "gym" },
+ { icon: FootstoolIcon, name: "footstool" },
+ { icon: HairdryerIcon, name: "hairdryer" },
+ { icon: HandSoapIcon, name: "toiletries" },
+ { icon: HealthBeautyIcon, name: "beautysalon" },
+ { icon: HeartIcon, name: "bathrobes" },
+ { icon: HeartIcon, name: "bathroom2separatebathrooms" },
+ { icon: HeartIcon, name: "bodycareproducts" },
+ { icon: HeartIcon, name: "bodylotion" },
+ { icon: HeartIcon, name: "bowling" },
+ { icon: HeartIcon, name: "bunkbed" },
+ { icon: HeartIcon, name: "bunkbed80x188cm" },
+ { icon: HeartIcon, name: "carpetingwalltowallcarpet" },
+ { icon: HeartIcon, name: "cashfree800pmtill0600am" },
+ { icon: HeartIcon, name: "cashfreehotel" },
+ { icon: HeartIcon, name: "coffeevoucher" },
+ { icon: HeartIcon, name: "complimentarycoldrefreshments" },
+ { icon: HeartIcon, name: "conditioner" },
+ { icon: HeartIcon, name: "conventioncentre" },
+ { icon: HeartIcon, name: "disabledparking" },
+ { icon: HeartIcon, name: "dockingstationforipodipad" },
+ { icon: HeartIcon, name: "dryingcabinet" },
+ { icon: HeartIcon, name: "easyaccess" },
+ { icon: HeartIcon, name: "garmentsteamer" },
+ { icon: HeartIcon, name: "highfloor" },
+ { icon: HeartIcon, name: "icemachine" },
+ { icon: HeartIcon, name: "icemachinereception" },
+ { icon: HeartIcon, name: "jaccuzzi" },
+ { icon: HeartIcon, name: "jacuzzi" },
+ { icon: HeartIcon, name: "kitchen" },
+ { icon: HeartIcon, name: "kitchenette" },
+ { icon: HeartIcon, name: "latecheckoutuntil1400guaranteed" },
+ { icon: HeartIcon, name: "livemusicexhibitions" },
+ { icon: HeartIcon, name: "massage" },
+ { icon: HeartIcon, name: "meetingarea" },
+ { icon: HeartIcon, name: "minibarincludedinroomrate" },
+ { icon: HeartIcon, name: "overnightsecurity" },
+ { icon: HeartIcon, name: "parkingadditionalcost" },
+ { icon: HeartIcon, name: "privatesauna" },
+ { icon: HeartIcon, name: "refrigerator" },
+ { icon: HeartIcon, name: "seatingarea" },
+ { icon: HeartIcon, name: "security24hoours" },
+ { icon: HeartIcon, name: "separatelivingroom" },
+ { icon: HeartIcon, name: "separatetoilet" },
+ { icon: HeartIcon, name: "servicesecurity24h" },
+ { icon: HeartIcon, name: "shampoo" },
+ { icon: HeartIcon, name: "slippers" },
+ { icon: HeartIcon, name: "sofabed" },
+ { icon: HeartIcon, name: "sofas" },
+ { icon: HeartIcon, name: "sofawithtable" },
+ { icon: HeartIcon, name: "spaciousroom" },
+ { icon: HeartIcon, name: "stillandsparklingwater" },
+ { icon: HeartIcon, name: "table" },
+ { icon: HeartIcon, name: "tassimocoffeemaker" },
+ { icon: HeartIcon, name: "terrace" },
+ { icon: HeartIcon, name: "trouserpress" },
+ { icon: HeartIcon, name: "tv" },
+ { icon: HeartIcon, name: "tvwithcomplimentarymoviechannels" },
+ { icon: HeartIcon, name: "tvwithmoviechannels" },
+ { icon: HeartIcon, name: "tvwithstreamingoption" },
+ { icon: HeartIcon, name: "ventilationinroom" },
+ { icon: HeartIcon, name: "view" },
+ { icon: HeartIcon, name: "viewfjordview" },
+ { icon: HeartIcon, name: "viewlakeview" },
+ { icon: HeartIcon, name: "viewpanoramicview" },
+ { icon: HeartIcon, name: "viewseaview" },
+ { icon: HeartIcon, name: "wallbed" },
+ { icon: HeartIcon, name: "wallmountedcyclerack" },
+ { icon: HeartIcon, name: "wardrobe" },
+ { icon: HeartIcon, name: "wellnessandsaunaentrancefeeadmission16years" },
+ { icon: HeartIcon, name: "wellnesspoolsaunaentrancefeeadmission16years" },
+ { icon: HeartIcon, name: "windownook" },
+ { icon: IronIcon, name: "ironandironingboard" },
+ { icon: IronIcon, name: "ironingroom" },
+ { icon: KayakingIcon, name: "kayaksforloan" },
+ { icon: KettleIcon, name: "kettle" },
+ { icon: KettleIcon, name: "kettlewithcoffeetea" },
+ { icon: LaptopIcon, name: "laptopsafe" },
+ { icon: LaptopIcon, name: "laptoptray" },
+ { icon: LaundryMachineIcon, name: "laundryservice" },
+ { icon: LocalBarIcon, name: "bar" },
+ { icon: LocalBarIcon, name: "rooftopbar" },
+ { icon: LocalBarIcon, name: "skybar" },
+ { icon: LuggageIcon, name: "luggagelockers" },
+ { icon: MicrowaveIcon, name: "microwave" },
+ { icon: MirrorIcon, name: "cosmeticmirror" },
+ { icon: NatureIcon, name: "viewparkview" },
+ { icon: NightlifeIcon, name: "disconightclub" },
+ { icon: NoSmokingIcon, name: "nonsmoking" },
+ { icon: OutdoorFurnitureIcon, name: "outdoorterrace" },
+ { icon: ParkingIcon, name: "parking" },
+ { icon: ParkingIcon, name: "parkingfreeparking" },
+ { icon: PetsIcon, name: "petfriendlyrooms" },
+ { icon: PhoneIcon, name: "directdialphoneandvoicemail" },
+ { icon: RestaurantIcon, name: "restaurant" },
+ { icon: RoomServiceIcon, name: "roomservice" },
+ { icon: SafetyBoxIcon, name: "safetybox" },
+ { icon: SaunaIcon, name: "sauna" },
+ { icon: ShowerIcon, name: "bathroomwithshower" },
+ { icon: ShowerIcon, name: "rainshower" },
+ { icon: ShowerIcon, name: "sharedbathroomwithshower" },
+ { icon: ShowerIcon, name: "showergel" },
+ { icon: ShowerIcon, name: "showerproductsscentbygrandcentral" },
+ { icon: SpeakerIcon, name: "modernvinylplayer" },
+ { icon: SpeakerIcon, name: "musicplayer" },
+ { icon: StoreIcon, name: "hairdresser" },
+ { icon: StoreIcon, name: "shop" },
+ { icon: StreetIcon, name: "viewstreetview" },
+ { icon: SwimIcon, name: "pool" },
+ { icon: TshirtWashIcon, name: "handwash" },
+ { icon: TvCastingIcon, name: "tvwithchromecast" },
+ { icon: WifiIcon, name: "freewifi" },
+ { icon: WindowCurtainsAltIcon, name: "blackoutcurtains" },
+ { icon: WindowNotAvailableIcon, name: "nowindow" },
+ { icon: WineBarIcon, name: "minibar" },
+ { icon: WoodFloorIcon, name: "woodenfloor" },
+ { icon: YardIcon, name: "viewatriumview" },
]
- const icon = iconMappings.find((icon) => icon.texts.includes(name))
+ const icon = iconMappings.find(
+ (icon) => icon.name.toLowerCase() === normalizedName
+ )
+
return icon ? icon.icon : HeartIcon
}
diff --git a/components/SidePeeks/RoomSidePeek/index.tsx b/components/SidePeeks/RoomSidePeek/index.tsx
index da46a6f42..175eb14a0 100644
--- a/components/SidePeeks/RoomSidePeek/index.tsx
+++ b/components/SidePeeks/RoomSidePeek/index.tsx
@@ -56,10 +56,16 @@ export default function RoomSidePeek({
{room.roomFacilities
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((facility) => {
- const Icon = getFacilityIcon(facility.name)
+ const Icon = getFacilityIcon(facility.icon)
return (
- {Icon && }
+ {Icon && (
+
+ )}
diff --git a/components/Breadcrumbs/breadcrumbs.module.css b/components/TempDesignSystem/Breadcrumbs/breadcrumbs.module.css
similarity index 82%
rename from components/Breadcrumbs/breadcrumbs.module.css
rename to components/TempDesignSystem/Breadcrumbs/breadcrumbs.module.css
index 93bedd5a6..8a535c4f8 100644
--- a/components/Breadcrumbs/breadcrumbs.module.css
+++ b/components/TempDesignSystem/Breadcrumbs/breadcrumbs.module.css
@@ -1,8 +1,6 @@
.breadcrumbs {
display: block;
- padding-left: var(--Spacing-x2);
- padding-right: var(--Spacing-x2);
- padding-top: var(--Spacing-x2);
+ padding: var(--Spacing-x2) var(--Spacing-x2) 0;
max-width: var(--max-width);
margin: 0 auto;
width: 100%;
diff --git a/components/TempDesignSystem/Breadcrumbs/breadcrumbs.ts b/components/TempDesignSystem/Breadcrumbs/breadcrumbs.ts
new file mode 100644
index 000000000..6a5a7468b
--- /dev/null
+++ b/components/TempDesignSystem/Breadcrumbs/breadcrumbs.ts
@@ -0,0 +1,9 @@
+type Breadcrumb = {
+ title: string
+ uid: string
+ href?: string
+}
+
+export interface BreadcrumbsProps {
+ breadcrumbs: Breadcrumb[]
+}
diff --git a/components/TempDesignSystem/Breadcrumbs/index.tsx b/components/TempDesignSystem/Breadcrumbs/index.tsx
new file mode 100644
index 000000000..ed40cd519
--- /dev/null
+++ b/components/TempDesignSystem/Breadcrumbs/index.tsx
@@ -0,0 +1,61 @@
+import { HouseIcon } from "@/components/Icons"
+import ChevronRightSmallIcon from "@/components/Icons/ChevronRightSmall"
+import Link from "@/components/TempDesignSystem/Link"
+import Footnote from "@/components/TempDesignSystem/Text/Footnote"
+
+import styles from "./breadcrumbs.module.css"
+
+import type { BreadcrumbsProps } from "@/components/TempDesignSystem/Breadcrumbs/breadcrumbs"
+
+export default function Breadcrumbs({ breadcrumbs }: BreadcrumbsProps) {
+ if (!breadcrumbs?.length) {
+ return null
+ }
+
+ const homeBreadcrumb = breadcrumbs.shift()
+ return (
+
+
+ {homeBreadcrumb ? (
+
+
+
+
+
+
+ ) : null}
+
+ {breadcrumbs.map((breadcrumb) => {
+ if (breadcrumb.href) {
+ return (
+
+
+ {breadcrumb.title}
+
+
+
+ )
+ }
+
+ return (
+
+
+ {breadcrumb.title}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/components/TempDesignSystem/Divider/divider.module.css b/components/TempDesignSystem/Divider/divider.module.css
index 6753e7533..c992bc096 100644
--- a/components/TempDesignSystem/Divider/divider.module.css
+++ b/components/TempDesignSystem/Divider/divider.module.css
@@ -51,3 +51,7 @@
.opacity8 {
opacity: 0.08;
}
+
+.baseSurfaceSubtleHover {
+ background-color: var(--Base-Surface-Subtle-Hover);
+}
diff --git a/components/TempDesignSystem/Divider/variants.ts b/components/TempDesignSystem/Divider/variants.ts
index 43f7899e0..a9f25b044 100644
--- a/components/TempDesignSystem/Divider/variants.ts
+++ b/components/TempDesignSystem/Divider/variants.ts
@@ -13,6 +13,7 @@ export const dividerVariants = cva(styles.divider, {
primaryLightSubtle: styles.primaryLightSubtle,
subtle: styles.subtle,
white: styles.white,
+ baseSurfaceSutbleHover: styles.baseSurfaceSubtleHover,
},
opacity: {
100: styles.opacity100,
diff --git a/components/TempDesignSystem/Form/ChoiceCard/_Card/card.ts b/components/TempDesignSystem/Form/ChoiceCard/_Card/card.ts
index 7d24e46d7..c733dac50 100644
--- a/components/TempDesignSystem/Form/ChoiceCard/_Card/card.ts
+++ b/components/TempDesignSystem/Form/ChoiceCard/_Card/card.ts
@@ -12,6 +12,7 @@ interface BaseCardProps
title: React.ReactNode
type: "checkbox" | "radio"
value?: string
+ handleSelectedOnClick?: () => void
}
interface ListCardProps extends BaseCardProps {
diff --git a/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx b/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx
index 7d6ef8105..29ce8e531 100644
--- a/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx
+++ b/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx
@@ -25,10 +25,22 @@ export default function Card({
title,
type,
value,
+ handleSelectedOnClick,
}: CardProps) {
const { register } = useFormContext()
+
+ function onLabelClick(event: React.MouseEvent) {
+ // Preventing click event on label elements firing twice: https://github.com/facebook/react/issues/14295
+ event.preventDefault()
+ handleSelectedOnClick?.()
+ }
return (
-
+
{title}
diff --git a/constants/routes/hotelReservation.js b/constants/routes/hotelReservation.js
index ed7ae99c5..dffc0acb2 100644
--- a/constants/routes/hotelReservation.js
+++ b/constants/routes/hotelReservation.js
@@ -5,7 +5,7 @@
/**
* @param {Lang} lang
*/
-function base(lang) {
+export function hotelreservation(lang) {
return `/${lang}/hotelreservation`
}
@@ -13,47 +13,47 @@ function base(lang) {
* @param {Lang} lang
*/
export function bookingConfirmation(lang) {
- return `${base(lang)}/booking-confirmation`
+ return `${hotelreservation(lang)}/booking-confirmation`
}
/**
* @param {Lang} lang
*/
export function details(lang) {
- return `${base(lang)}/details`
+ return `${hotelreservation(lang)}/details`
}
/**
* @param {Lang} lang
*/
export function payment(lang) {
- return `${base(lang)}/payment`
+ return `${hotelreservation(lang)}/payment`
}
/**
* @param {Lang} lang
*/
export function selectBed(lang) {
- return `${base(lang)}/select-bed`
+ return `${hotelreservation(lang)}/select-bed`
}
/**
* @param {Lang} lang
*/
export function selectHotel(lang) {
- return `${base(lang)}/select-hotel`
+ return `${hotelreservation(lang)}/select-hotel`
}
/**
* @param {Lang} lang
*/
export function selectHotelMap(lang) {
- return `${base(lang)}/select-hotel/map`
+ return `${hotelreservation(lang)}/select-hotel/map`
}
/**
* @param {Lang} lang
*/
export function selectRate(lang) {
- return `${base(lang)}/select-rate`
+ return `${hotelreservation(lang)}/select-rate`
}
diff --git a/env/client.ts b/env/client.ts
index 4eafd5592..467100c01 100644
--- a/env/client.ts
+++ b/env/client.ts
@@ -5,14 +5,10 @@ export const env = createEnv({
client: {
NEXT_PUBLIC_NODE_ENV: z.enum(["development", "test", "production"]),
NEXT_PUBLIC_PORT: z.string().default("3000"),
- NEXT_PUBLIC_PAYMENT_CALLBACK_URL: z
- .string()
- .default("/api/web/payment-callback"),
},
emptyStringAsUndefined: true,
runtimeEnv: {
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_PORT: process.env.NEXT_PUBLIC_PORT,
- NEXT_PUBLIC_PAYMENT_CALLBACK_URL: `${process.env.NODE_ENV === "development" ? `http://localhost:${process.env.NEXT_PUBLIC_PORT}` : ""}/api/web/payment-callback`,
},
})
diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json
index a0bc269bd..8a5d64ac4 100644
--- a/i18n/dictionaries/da.json
+++ b/i18n/dictionaries/da.json
@@ -118,6 +118,7 @@
"Enter destination or hotel": "Indtast destination eller hotel",
"Enter your details": "Indtast dine oplysninger",
"Events that make an impression": "Events that make an impression",
+ "Expires at the earliest": "Udløber tidligst {date}",
"Explore all levels and benefits": "Udforsk alle niveauer og fordele",
"Explore nearby": "Udforsk i nærheden",
"Extras to your booking": "Tillæg til din booking",
@@ -150,9 +151,11 @@
"Gym": "Fitnesscenter",
"Hi": "Hei",
"Highest level": "Højeste niveau",
+ "Home": "Hjem",
"Hospital": "Hospital",
"Hotel": "Hotel",
"Hotel facilities": "Hotel faciliteter",
+ "Hotel reservation": "Hotel reservation",
"Hotel surroundings": "Hotel omgivelser",
"Hotel(s)": "{amount} {amount, plural, one {hotel} other {hoteller}}",
"Hotels": "Hoteller",
@@ -325,6 +328,7 @@
"Select country of residence": "Vælg bopælsland",
"Select date of birth": "Vælg fødselsdato",
"Select dates": "Vælg datoer",
+ "Select hotel": "Vælg hotel",
"Select language": "Vælg sprog",
"Select payment method": "Vælg betalingsmetode",
"Select your language": "Vælg dit sprog",
@@ -377,7 +381,6 @@
"Use bonus cheque": "Brug Bonus Cheque",
"Use code/voucher": "Brug kode/voucher",
"User information": "Brugeroplysninger",
- "Valid through": "Gyldig igennem",
"View as list": "Vis som liste",
"View as map": "Vis som kort",
"View your booking": "Se din booking",
diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json
index 228f08007..7e35d2aab 100644
--- a/i18n/dictionaries/de.json
+++ b/i18n/dictionaries/de.json
@@ -118,6 +118,7 @@
"Enter destination or hotel": "Reiseziel oder Hotel eingeben",
"Enter your details": "Geben Sie Ihre Daten ein",
"Events that make an impression": "Events that make an impression",
+ "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",
"Extras to your booking": "Extras zu Ihrer Buchung",
@@ -150,9 +151,11 @@
"Gym": "Fitnessstudio",
"Hi": "Hallo",
"Highest level": "Höchstes Level",
+ "Home": "Heim",
"Hospital": "Krankenhaus",
"Hotel": "Hotel",
"Hotel facilities": "Hotel-Infos",
+ "Hotel reservation": "Hotelreservierung",
"Hotel surroundings": "Umgebung des Hotels",
"Hotel(s)": "{amount} {amount, plural, one {hotel} other {hotels}}",
"Hotels": "Hotels",
@@ -324,6 +327,7 @@
"Select country of residence": "Wählen Sie das Land Ihres Wohnsitzes aus",
"Select date of birth": "Geburtsdatum auswählen",
"Select dates": "Datum auswählen",
+ "Select hotel": "Hotel auswählen",
"Select language": "Sprache auswählen",
"Select payment method": "Zahlungsart auswählen",
"Select your language": "Wählen Sie Ihre Sprache",
@@ -375,7 +379,6 @@
"Use bonus cheque": "Bonusscheck nutzen",
"Use code/voucher": "Code/Gutschein nutzen",
"User information": "Nutzerinformation",
- "Valid through": "Gültig bis",
"View as list": "Als Liste anzeigen",
"View as map": "Als Karte anzeigen",
"View your booking": "Ihre Buchung ansehen",
diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json
index c4870959c..42b4b9316 100644
--- a/i18n/dictionaries/en.json
+++ b/i18n/dictionaries/en.json
@@ -127,6 +127,7 @@
"Enter destination or hotel": "Enter destination or hotel",
"Enter your details": "Enter your details",
"Events that make an impression": "Events that make an impression",
+ "Expires at the earliest": "Expires at the earliest {date}",
"Explore all levels and benefits": "Explore all levels and benefits",
"Explore nearby": "Explore nearby",
"Extras to your booking": "Extras to your booking",
@@ -162,9 +163,11 @@
"Gym": "Gym",
"Hi": "Hi",
"Highest level": "Highest level",
+ "Home": "Home",
"Hospital": "Hospital",
"Hotel": "Hotel",
"Hotel facilities": "Hotel facilities",
+ "Hotel reservation": "Hotel reservation",
"Hotel surroundings": "Hotel surroundings",
"Hotel(s)": "{amount} {amount, plural, one {hotel} other {hotels}}",
"Hotels": "Hotels",
@@ -354,6 +357,7 @@
"Select country of residence": "Select country of residence",
"Select date of birth": "Select date of birth",
"Select dates": "Select dates",
+ "Select hotel": "Select hotel",
"Select language": "Select language",
"Select payment method": "Select payment method",
"Select your language": "Select your language",
@@ -407,7 +411,6 @@
"User information": "User information",
"VAT": "VAT",
"VAT amount": "VAT amount",
- "Valid through": "Valid through",
"View as list": "View as list",
"View as map": "View as map",
"View terms": "View terms",
@@ -470,6 +473,8 @@
"breakfast.price.free": "{amount} {currency} 0 {currency} /night",
"by": "by",
"characters": "characters",
+ "filters.nohotel.heading": "No hotels match your filters",
+ "filters.nohotel.text": "It looks like no hotels match your filters. Try adjusting your search to find the perfect stay.",
"from": "from",
"guaranteeing": "guaranteeing",
"guest": "guest",
diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json
index e6f40b19f..94d09060c 100644
--- a/i18n/dictionaries/fi.json
+++ b/i18n/dictionaries/fi.json
@@ -118,6 +118,7 @@
"Enter destination or hotel": "Anna kohde tai hotelli",
"Enter your details": "Anna tietosi",
"Events that make an impression": "Events that make an impression",
+ "Expires at the earliest": "Päättyy aikaisintaan {date}",
"Explore all levels and benefits": "Tutustu kaikkiin tasoihin ja etuihin",
"Explore nearby": "Tutustu lähialueeseen",
"Extras to your booking": "Varauksessa lisäpalveluita",
@@ -150,9 +151,11 @@
"Gym": "Kuntosali",
"Hi": "Hi",
"Highest level": "Korkein taso",
+ "Home": "Kotiin",
"Hospital": "Sairaala",
"Hotel": "Hotelli",
"Hotel facilities": "Hotellin palvelut",
+ "Hotel reservation": "Hotellivaraukset",
"Hotel surroundings": "Hotellin ympäristö",
"Hotel(s)": "{amount} {amount, plural, one {hotelli} other {hotellit}}",
"Hotels": "Hotellit",
@@ -326,6 +329,7 @@
"Select country of residence": "Valitse asuinmaa",
"Select date of birth": "Valitse syntymäaika",
"Select dates": "Valitse päivämäärät",
+ "Select hotel": "Valitse hotelli",
"Select language": "Valitse kieli",
"Select payment method": "Valitse maksutapa",
"Select your language": "Valitse kieli",
@@ -377,7 +381,6 @@
"Use bonus cheque": "Käytä bonussekkiä",
"Use code/voucher": "Käytä koodia/voucheria",
"User information": "Käyttäjän tiedot",
- "Valid through": "Voimassa läpi",
"View as list": "Näytä listana",
"View as map": "Näytä kartalla",
"View your booking": "Näytä varauksesi",
diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json
index c7d7dc1ed..bffe5c2ac 100644
--- a/i18n/dictionaries/no.json
+++ b/i18n/dictionaries/no.json
@@ -117,6 +117,7 @@
"Enter destination or hotel": "Skriv inn destinasjon eller hotell",
"Enter your details": "Skriv inn detaljene dine",
"Events that make an impression": "Events that make an impression",
+ "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",
"Extras to your booking": "Tilvalg til bestillingen din",
@@ -149,9 +150,11 @@
"Gym": "Treningsstudio",
"Hi": "Hei",
"Highest level": "Høyeste nivå",
+ "Home": "Hjem",
"Hospital": "Sykehus",
"Hotel": "Hotel",
"Hotel facilities": "Hotelfaciliteter",
+ "Hotel reservation": "Hotellreservasjon",
"Hotel surroundings": "Hotellomgivelser",
"Hotel(s)": "{amount} {amount, plural, one {hotell} other {hoteller}}",
"Hotels": "Hoteller",
@@ -323,6 +326,7 @@
"Select country of residence": "Velg bostedsland",
"Select date of birth": "Velg fødselsdato",
"Select dates": "Velg datoer",
+ "Select hotel": "Velg hotell",
"Select language": "Velg språk",
"Select payment method": "Velg betalingsmetode",
"Select your language": "Velg språk",
@@ -374,7 +378,6 @@
"Use bonus cheque": "Bruk bonussjekk",
"Use code/voucher": "Bruk kode/voucher",
"User information": "Brukerinformasjon",
- "Valid through": "Gyldig gjennom",
"View as list": "Vis som liste",
"View as map": "Vis som kart",
"View your booking": "Se din bestilling",
diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json
index 7b89c06c1..883ee8e07 100644
--- a/i18n/dictionaries/sv.json
+++ b/i18n/dictionaries/sv.json
@@ -117,6 +117,7 @@
"Enter destination or hotel": "Ange destination eller hotell",
"Enter your details": "Ange dina uppgifter",
"Events that make an impression": "Events that make an impression",
+ "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",
"Extras to your booking": "Extra tillval till din bokning",
@@ -149,9 +150,11 @@
"Gym": "Gym",
"Hi": "Hej",
"Highest level": "Högsta nivå",
+ "Home": "Hem",
"Hospital": "Sjukhus",
"Hotel": "Hotell",
"Hotel facilities": "Hotellfaciliteter",
+ "Hotel reservation": "Hotellbokning",
"Hotel surroundings": "Hotellomgivning",
"Hotel(s)": "{amount} hotell",
"Hotels": "Hotell",
@@ -323,6 +326,7 @@
"Select country of residence": "Välj bosättningsland",
"Select date of birth": "Välj födelsedatum",
"Select dates": "Välj datum",
+ "Select hotel": "Välj hotell",
"Select language": "Välj språk",
"Select payment method": "Välj betalningsmetod",
"Select your language": "Välj ditt språk",
@@ -374,7 +378,6 @@
"Use bonus cheque": "Använd bonuscheck",
"Use code/voucher": "Använd kod/voucher",
"User information": "Användarinformation",
- "Valid through": "Giltig t.o.m.",
"View as list": "Visa som lista",
"View as map": "Visa som karta",
"View your booking": "Visa din bokning",
diff --git a/lib/graphql/Fragments/Breadcrumbs/Breadcrumbs.graphql b/lib/graphql/Fragments/Breadcrumbs/Breadcrumbs.graphql
index 86b35eeef..26efd5704 100644
--- a/lib/graphql/Fragments/Breadcrumbs/Breadcrumbs.graphql
+++ b/lib/graphql/Fragments/Breadcrumbs/Breadcrumbs.graphql
@@ -1,4 +1,5 @@
#import "./AccountPage.graphql"
+#import "./CollectionPage.graphql"
#import "./ContentPage.graphql"
#import "./LoyaltyPage.graphql"
@@ -9,6 +10,7 @@ fragment Breadcrumbs on Breadcrumbs {
node {
__typename
...AccountPageBreadcrumb
+ ...CollectionPageBreadcrumb
...ContentPageBreadcrumb
...LoyaltyPageBreadcrumb
}
@@ -23,6 +25,7 @@ fragment BreadcrumbsRefs on Breadcrumbs {
node {
__typename
...AccountPageBreadcrumbRef
+ ...CollectionPageBreadcrumbRef
...ContentPageBreadcrumbRef
...LoyaltyPageBreadcrumbRef
}
diff --git a/lib/graphql/Fragments/Breadcrumbs/CollectionPage.graphql b/lib/graphql/Fragments/Breadcrumbs/CollectionPage.graphql
new file mode 100644
index 000000000..debb6b1e8
--- /dev/null
+++ b/lib/graphql/Fragments/Breadcrumbs/CollectionPage.graphql
@@ -0,0 +1,24 @@
+#import "../System.graphql"
+
+fragment CollectionPageBreadcrumb on CollectionPage {
+ web {
+ breadcrumbs {
+ title
+ }
+ }
+ system {
+ ...System
+ }
+ url
+}
+
+fragment CollectionPageBreadcrumbRef on CollectionPage {
+ web {
+ breadcrumbs {
+ title
+ }
+ }
+ system {
+ ...System
+ }
+}
diff --git a/lib/graphql/Fragments/Metadata.graphql b/lib/graphql/Fragments/Metadata.graphql
new file mode 100644
index 000000000..b649e213a
--- /dev/null
+++ b/lib/graphql/Fragments/Metadata.graphql
@@ -0,0 +1,6 @@
+fragment Metadata on SeoMetadata {
+ noindex
+ description
+ title
+ seo_image
+}
diff --git a/lib/graphql/Query/AccountPage/MetaData.graphql b/lib/graphql/Query/AccountPage/MetaData.graphql
deleted file mode 100644
index 966f38fa1..000000000
--- a/lib/graphql/Query/AccountPage/MetaData.graphql
+++ /dev/null
@@ -1,26 +0,0 @@
-#import "../../Fragments/Image.graphql"
-#import "../../Fragments/System.graphql"
-
-query GetMyPagesMetaData($locale: String!, $uid: String!) {
- account_page(locale: $locale, uid: $uid) {
- system {
- ...System
- }
- web {
- breadcrumbs {
- title
- }
- seo_metadata {
- description
- title
- imageConnection {
- edges {
- node {
- ...Image
- }
- }
- }
- }
- }
- }
-}
diff --git a/lib/graphql/Query/AccountPage/Metadata.graphql b/lib/graphql/Query/AccountPage/Metadata.graphql
new file mode 100644
index 000000000..94073ac91
--- /dev/null
+++ b/lib/graphql/Query/AccountPage/Metadata.graphql
@@ -0,0 +1,18 @@
+#import "../../Fragments/Metadata.graphql"
+#import "../../Fragments/System.graphql"
+
+query GetAccountPageMetadata($locale: String!, $uid: String!) {
+ account_page(locale: $locale, uid: $uid) {
+ web {
+ breadcrumbs {
+ title
+ }
+ seo_metadata {
+ ...Metadata
+ }
+ }
+ system {
+ ...System
+ }
+ }
+}
diff --git a/lib/graphql/Query/CollectionPage/Metadata.graphql b/lib/graphql/Query/CollectionPage/Metadata.graphql
new file mode 100644
index 000000000..e404ee230
--- /dev/null
+++ b/lib/graphql/Query/CollectionPage/Metadata.graphql
@@ -0,0 +1,23 @@
+#import "../../Fragments/Metadata.graphql"
+#import "../../Fragments/System.graphql"
+
+query GetCollectionPageMetadata($locale: String!, $uid: String!) {
+ collection_page(locale: $locale, uid: $uid) {
+ web {
+ breadcrumbs {
+ title
+ }
+ seo_metadata {
+ ...Metadata
+ }
+ }
+ header {
+ heading
+ preamble
+ }
+ hero_image
+ system {
+ ...System
+ }
+ }
+}
diff --git a/lib/graphql/Query/ContentPage/Metadata.graphql b/lib/graphql/Query/ContentPage/Metadata.graphql
new file mode 100644
index 000000000..c156c8cad
--- /dev/null
+++ b/lib/graphql/Query/ContentPage/Metadata.graphql
@@ -0,0 +1,32 @@
+#import "../../Fragments/Metadata.graphql"
+#import "../../Fragments/System.graphql"
+
+query GetContentPageMetadata($locale: String!, $uid: String!) {
+ content_page(locale: $locale, uid: $uid) {
+ web {
+ breadcrumbs {
+ title
+ }
+ seo_metadata {
+ ...Metadata
+ }
+ }
+ header {
+ heading
+ preamble
+ }
+ hero_image
+ blocks {
+ ... on ContentPageBlocksContent {
+ content {
+ content {
+ json
+ }
+ }
+ }
+ }
+ system {
+ ...System
+ }
+ }
+}
diff --git a/lib/graphql/Query/LoyaltyPage/MetaData.graphql b/lib/graphql/Query/LoyaltyPage/MetaData.graphql
deleted file mode 100644
index a1233dc8c..000000000
--- a/lib/graphql/Query/LoyaltyPage/MetaData.graphql
+++ /dev/null
@@ -1,26 +0,0 @@
-#import "../../Fragments/Image.graphql"
-#import "../../Fragments/System.graphql"
-
-query GetLoyaltyPageMetaData($locale: String!, $uid: String!) {
- loyalty_page(locale: $locale, uid: $uid) {
- system {
- ...System
- }
- web {
- seo_metadata {
- description
- title
- imageConnection {
- edges {
- node {
- ...Image
- }
- }
- }
- }
- breadcrumbs {
- title
- }
- }
- }
-}
diff --git a/lib/graphql/Query/LoyaltyPage/Metadata.graphql b/lib/graphql/Query/LoyaltyPage/Metadata.graphql
new file mode 100644
index 000000000..b084d8738
--- /dev/null
+++ b/lib/graphql/Query/LoyaltyPage/Metadata.graphql
@@ -0,0 +1,31 @@
+#import "../../Fragments/Metadata.graphql"
+#import "../../Fragments/System.graphql"
+
+query GetLoyaltyPageMetadata($locale: String!, $uid: String!) {
+ loyalty_page(locale: $locale, uid: $uid) {
+ web {
+ breadcrumbs {
+ title
+ }
+ seo_metadata {
+ ...Metadata
+ }
+ }
+ heading
+ preamble
+ hero_image
+ blocks {
+ ... on LoyaltyPageBlocksContent {
+ __typename
+ content {
+ content {
+ json
+ }
+ }
+ }
+ }
+ system {
+ ...System
+ }
+ }
+}
diff --git a/lib/graphql/batchRequest.ts b/lib/graphql/batchRequest.ts
index b6b5dbe3f..86361d527 100644
--- a/lib/graphql/batchRequest.ts
+++ b/lib/graphql/batchRequest.ts
@@ -2,30 +2,14 @@ import "server-only"
import deepmerge from "deepmerge"
+import { arrayMerge } from "@/utils/merge"
+
import { request } from "./request"
import type { BatchRequestDocument } from "graphql-request"
import type { Data } from "@/types/request"
-function arrayMerge(
- target: any[],
- source: any[],
- options: deepmerge.ArrayMergeOptions | undefined
-) {
- const destination = target.slice()
- source.forEach((item, index) => {
- if (typeof destination[index] === "undefined") {
- destination[index] = options?.cloneUnlessOtherwiseSpecified(item, options)
- } else if (options?.isMergeableObject(item)) {
- destination[index] = deepmerge(target[index], item, options)
- } else if (target.indexOf(item) === -1) {
- destination.push(item)
- }
- })
- return destination
-}
-
export async function batchRequest(
queries: (BatchRequestDocument & { options?: RequestInit })[]
): Promise> {
diff --git a/lib/trpc/memoizedRequests/index.ts b/lib/trpc/memoizedRequests/index.ts
index 4ab1aa5ea..75e58765f 100644
--- a/lib/trpc/memoizedRequests/index.ts
+++ b/lib/trpc/memoizedRequests/index.ts
@@ -158,3 +158,9 @@ export const getBookingConfirmation = cache(
return serverClient().booking.confirmation({ confirmationNumber })
}
)
+
+export const getCityCoordinates = cache(
+ async function getMemoizedCityCoordinates(input: { city: string }) {
+ return serverClient().hotel.map.city(input)
+ }
+)
diff --git a/next.config.js b/next.config.js
index 222f085ac..72121b713 100644
--- a/next.config.js
+++ b/next.config.js
@@ -136,7 +136,7 @@ const nextConfig = {
{
key: "fromDate",
type: "query",
- value: "^([12]\\d{3}-(0[1-9]|1[0-2])-([1-9]|[12]\\d|3[01]))$",
+ value: "^([12]\\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01]))$",
},
],
permanent: false,
@@ -168,7 +168,7 @@ const nextConfig = {
{
key: "toDate",
type: "query",
- value: "^([12]\\d{3}-(0[1-9]|1[0-2])-([1-9]|[12]\\d|3[01]))$",
+ value: "^([12]\\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01]))$",
},
],
permanent: false,
@@ -282,6 +282,11 @@ const nextConfig = {
"/:lang/hotelreservation/:step(breakfast|details|payment|select-bed)",
destination: "/:lang/hotelreservation/step?step=:step",
},
+ {
+ source: "/:lang/hotelreservation/payment-callback/:status",
+ destination:
+ "/:lang/hotelreservation/payment-callback?status=:status",
+ },
],
}
},
diff --git a/package-lock.json b/package-lock.json
index e611379b4..87aa057cc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,6 +27,7 @@
"@trpc/client": "^11.0.0-rc.467",
"@trpc/react-query": "^11.0.0-rc.467",
"@trpc/server": "^11.0.0-rc.467",
+ "@tsparticles/confetti": "^3.5.0",
"@vercel/otel": "^1.9.1",
"@vis.gl/react-google-maps": "^1.2.0",
"class-variance-authority": "^0.7.0",
@@ -80,6 +81,7 @@
"lint-staged": "^15.2.2",
"netlify-plugin-cypress": "^2.2.1",
"prettier": "^3.2.5",
+ "schema-dts": "^1.1.2",
"start-server-and-test": "^2.0.3",
"ts-node": "^10.9.2",
"typescript": "^5",
@@ -6402,6 +6404,279 @@
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true
},
+ "node_modules/@tsparticles/basic": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/basic/-/basic-3.5.0.tgz",
+ "integrity": "sha512-oty33TxM2aHWrzcwWRic1bQ04KBCdpnvzv8JXEkx5Uyp70vgVegUbtKmwGki3shqKZIt3v2qE4I8NsK6onhLrA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/matteobruni"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/tsparticles"
+ },
+ {
+ "type": "buymeacoffee",
+ "url": "https://www.buymeacoffee.com/matteobruni"
+ }
+ ],
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0",
+ "@tsparticles/move-base": "^3.5.0",
+ "@tsparticles/shape-circle": "^3.5.0",
+ "@tsparticles/updater-color": "^3.5.0",
+ "@tsparticles/updater-opacity": "^3.5.0",
+ "@tsparticles/updater-out-modes": "^3.5.0",
+ "@tsparticles/updater-size": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/confetti": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/confetti/-/confetti-3.5.0.tgz",
+ "integrity": "sha512-wS3nqtanbCvAbNlyAffKJq6lgIPzHFljEOO3JSCDgRD6rG5X/jvidhw2vR3kLrjBTV40c+Xv6MpJgSgTRWkogg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/matteobruni"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/tsparticles"
+ },
+ {
+ "type": "buymeacoffee",
+ "url": "https://www.buymeacoffee.com/matteobruni"
+ }
+ ],
+ "dependencies": {
+ "@tsparticles/basic": "^3.5.0",
+ "@tsparticles/engine": "^3.5.0",
+ "@tsparticles/plugin-emitters": "^3.5.0",
+ "@tsparticles/plugin-motion": "^3.5.0",
+ "@tsparticles/shape-cards": "^3.5.0",
+ "@tsparticles/shape-emoji": "^3.5.0",
+ "@tsparticles/shape-heart": "^3.5.0",
+ "@tsparticles/shape-image": "^3.5.0",
+ "@tsparticles/shape-polygon": "^3.5.0",
+ "@tsparticles/shape-square": "^3.5.0",
+ "@tsparticles/shape-star": "^3.5.0",
+ "@tsparticles/updater-life": "^3.5.0",
+ "@tsparticles/updater-roll": "^3.5.0",
+ "@tsparticles/updater-rotate": "^3.5.0",
+ "@tsparticles/updater-tilt": "^3.5.0",
+ "@tsparticles/updater-wobble": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/engine": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/engine/-/engine-3.5.0.tgz",
+ "integrity": "sha512-RCwrJ2SvSYdhXJ24oUCjSUKEZQ9lXwObOWMvfMC9vS6/bk+Qo0N7Xx8AfumqzP/LebB1YJdlCvuoJMauAon0Pg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/matteobruni"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/tsparticles"
+ },
+ {
+ "type": "buymeacoffee",
+ "url": "https://www.buymeacoffee.com/matteobruni"
+ }
+ ],
+ "hasInstallScript": true
+ },
+ "node_modules/@tsparticles/move-base": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/move-base/-/move-base-3.5.0.tgz",
+ "integrity": "sha512-9oDk7zTxyhUCstj3lHTNTiWAgqIBzWa2o1tVQFK63Qwq+/WxzJCSwZOocC9PAHGM1IP6nA4zYJSfjbMBTrUocA==",
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/plugin-emitters": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/plugin-emitters/-/plugin-emitters-3.5.0.tgz",
+ "integrity": "sha512-8Vg6wAPS75ibkukqtTM7yoC+8NnfXBl8xVUUbTaoeQCE0WDWwztboMf5L4pUgWe9WA52ZgFkWtT/mFH5wk5T9g==",
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/plugin-motion": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/plugin-motion/-/plugin-motion-3.5.0.tgz",
+ "integrity": "sha512-juP8f9ABjlhQmg4SO+tTofLYJwvwLPfKWJYvG8c6HU2rlJxJ/6eeWe9kDpv/T8nun3kXYHtrLhcJAmvWg/b5qA==",
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/shape-cards": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/shape-cards/-/shape-cards-3.5.0.tgz",
+ "integrity": "sha512-rU7rp1Yn1leHpCNA/7vrfY6tcLjvrG6A6sOT11dSanIj2J8zgLNXnbVtRJPtU13x+masft9Ta1tpw3dFRdtHcA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/matteobruni"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/tsparticles"
+ },
+ {
+ "type": "buymeacoffee",
+ "url": "https://www.buymeacoffee.com/matteobruni"
+ }
+ ],
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/shape-circle": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/shape-circle/-/shape-circle-3.5.0.tgz",
+ "integrity": "sha512-59TmXkeeI6Jzv5vt/D3TkclglabaoEXQi2kbDjSCBK68SXRHzlQu29mSAL41Y5S0Ft5ZJKkAQHX1IqEnm8Hyjg==",
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/shape-emoji": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/shape-emoji/-/shape-emoji-3.5.0.tgz",
+ "integrity": "sha512-cxWHxQxnG5vLDltkoxdo7KS87uKPwQgf4SDWy/WCxW4Psm1TEeeSGYMJPVed+wWPspOKmLb7u8OaEexgE2pHHQ==",
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/shape-heart": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/shape-heart/-/shape-heart-3.5.0.tgz",
+ "integrity": "sha512-MvOxW6X7w1jHH+KRJShvHMDhRZ+bpei2mAqQOFR5HY+2D6KFzaDVgtfGFwoiaX8Pm6oP6OQssQ3QnDtrywLRFw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/matteobruni"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/tsparticles"
+ },
+ {
+ "type": "buymeacoffee",
+ "url": "https://www.buymeacoffee.com/matteobruni"
+ }
+ ],
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/shape-image": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/shape-image/-/shape-image-3.5.0.tgz",
+ "integrity": "sha512-lWYg7DTv74dSOnXy+4dr7t1/OSuUmxDpIo12Lbxgx/QBN7A5I/HoqbKcs13TSA0RS1hcuMgtti07BcDTEYW3Dw==",
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/shape-polygon": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/shape-polygon/-/shape-polygon-3.5.0.tgz",
+ "integrity": "sha512-sqYL+YXpnq3nSWcOEGZaJ4Z7Cb7x8M0iORSLpPdNEIvwDKdPczYyQM95D8ep19Pv1CV5L0uRthV36wg7UpnJ9Q==",
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/shape-square": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/shape-square/-/shape-square-3.5.0.tgz",
+ "integrity": "sha512-rPHpA4Pzm1W5DIIow+lQS+VS7D2thSBQQbV9eHxb933Wh0/QC3me3w4vovuq7hdtVANhsUVO04n44Gc/2TgHkw==",
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/shape-star": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/shape-star/-/shape-star-3.5.0.tgz",
+ "integrity": "sha512-EDEJc4MYv3UbOeA3wrZjuJVtZ08PdCzzBij3T/7Tp3HUCf/p9XnfHBd/CPR5Mo6X0xpGfrein8UQN9CjGLHwUA==",
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/updater-color": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/updater-color/-/updater-color-3.5.0.tgz",
+ "integrity": "sha512-TGGgiLixIg37sst2Fj9IV4XbdMwkT6PYanM7qEqyfmv4hJ/RHMQlCznEe6b7OhChQVBg5ov5EMl/BT4/fIWEYw==",
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/updater-life": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/updater-life/-/updater-life-3.5.0.tgz",
+ "integrity": "sha512-jlMEq16dwN+rZmW/UmLdqaCe4W0NFrVdmXkZV8QWYgu06a+Ucslz337nHYaP89/9rZWpNua/uq1JDjDzaVD5Jg==",
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/updater-opacity": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/updater-opacity/-/updater-opacity-3.5.0.tgz",
+ "integrity": "sha512-T2YfqdIFV/f5VOg1JQsXu6/owdi9g9K2wrJlBfgteo+IboVp6Lcuo4PGAfilWVkWrTdp1Nz4mz39NrLHfOce2g==",
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/updater-out-modes": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/updater-out-modes/-/updater-out-modes-3.5.0.tgz",
+ "integrity": "sha512-y6NZe2OSk5SrYdaLwUIQnHICsNEDIdPPJHQ2nAWSvAuPJphlSKjUknc7OaGiFwle6l0OkhWoZZe1rV1ktbw/lA==",
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/updater-roll": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/updater-roll/-/updater-roll-3.5.0.tgz",
+ "integrity": "sha512-K3NfBGqVIu2zyJv72oNPlYLMDQKmUXTaCvnxUjzBEJJCYRdx7KhZPQVjAsfVYLHd7m7D7/+wKlkXmdYYAd67bg==",
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/updater-rotate": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/updater-rotate/-/updater-rotate-3.5.0.tgz",
+ "integrity": "sha512-j4qPHQd1eUmDoGnIJOsVswHLqtTof1je+b2GTOLB3WIoKmlyUpzQYjVc7PNfLMuCEUubwpZCfcd/vC80VZeWkg==",
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/updater-size": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/updater-size/-/updater-size-3.5.0.tgz",
+ "integrity": "sha512-TnWlOChBsVZffT2uO0S4ALGSzxT6UAMIVlhGMGFgSeIlktKMqM+dxDGAPrYa1n8IS2dkVGisiXzsV0Ss6Ceu1A==",
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/updater-tilt": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/updater-tilt/-/updater-tilt-3.5.0.tgz",
+ "integrity": "sha512-ovK6jH4fAmTav1kCC5Z1FW/pPjKxtK+X+w9BZJEddpS5cyBEdWD4FgvNgLnmZYpK0xad/nb+xxqeDkpSu/O51Q==",
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
+ "node_modules/@tsparticles/updater-wobble": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@tsparticles/updater-wobble/-/updater-wobble-3.5.0.tgz",
+ "integrity": "sha512-fpN0XPvAf3dJ5UU++C+ETVDLurpnkzje02w865Ar4ubPBgGpMhowr6AbtFUe37Zl8rFUTYntBOSEoxqNYJAUgQ==",
+ "dependencies": {
+ "@tsparticles/engine": "^3.5.0"
+ }
+ },
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
@@ -17699,6 +17974,15 @@
"loose-envify": "^1.1.0"
}
},
+ "node_modules/schema-dts": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/schema-dts/-/schema-dts-1.1.2.tgz",
+ "integrity": "sha512-MpNwH0dZJHinVxk9bT8XUdjKTxMYrA5bLtrrGmFA6PTLwlOKnhi67XoRd6/ty+Djt6ZC0slR57qFhZDNMI6DhQ==",
+ "dev": true,
+ "peerDependencies": {
+ "typescript": ">=4.1.0"
+ }
+ },
"node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
diff --git a/package.json b/package.json
index 5871b48b4..effaf858e 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,7 @@
"@trpc/client": "^11.0.0-rc.467",
"@trpc/react-query": "^11.0.0-rc.467",
"@trpc/server": "^11.0.0-rc.467",
+ "@tsparticles/confetti": "^3.5.0",
"@vercel/otel": "^1.9.1",
"@vis.gl/react-google-maps": "^1.2.0",
"class-variance-authority": "^0.7.0",
@@ -95,6 +96,7 @@
"lint-staged": "^15.2.2",
"netlify-plugin-cypress": "^2.2.1",
"prettier": "^3.2.5",
+ "schema-dts": "^1.1.2",
"start-server-and-test": "^2.0.3",
"ts-node": "^10.9.2",
"typescript": "^5",
diff --git a/public/_static/img/confetti.svg b/public/_static/img/confetti.svg
new file mode 100644
index 000000000..54f4306bd
--- /dev/null
+++ b/public/_static/img/confetti.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/server/routers/booking/output.ts b/server/routers/booking/output.ts
index ae2e14cf4..5c8879c00 100644
--- a/server/routers/booking/output.ts
+++ b/server/routers/booking/output.ts
@@ -17,13 +17,14 @@ export const createBookingSchema = z
paymentUrl: z.string().nullable(),
metadata: z
.object({
- errorCode: z.number().optional(),
- errorMessage: z.string().optional(),
+ errorCode: z.number().nullable().optional(),
+ errorMessage: z.string().nullable().optional(),
priceChangedMetadata: z
.object({
- roomPrice: z.number().optional(),
- totalPrice: z.number().optional(),
+ roomPrice: z.number().nullable().optional(),
+ totalPrice: z.number().nullable().optional(),
})
+ .nullable()
.optional(),
})
.nullable(),
diff --git a/server/routers/contentstack/accountPage/output.ts b/server/routers/contentstack/accountPage/output.ts
index 479765756..413025ff4 100644
--- a/server/routers/contentstack/accountPage/output.ts
+++ b/server/routers/contentstack/accountPage/output.ts
@@ -11,7 +11,6 @@ import {
shortcutsSchema,
} from "../schemas/blocks/shortcuts"
import { textContentSchema } from "../schemas/blocks/textContent"
-import { page } from "../schemas/metadata"
import { systemSchema } from "../schemas/system"
import { AccountPageEnum } from "@/types/enums/accountPage"
@@ -84,7 +83,3 @@ export const accountPageRefsSchema = z.object({
system: systemSchema,
}),
})
-
-export const accountPageMetadataSchema = z.object({
- account_page: page,
-})
diff --git a/server/routers/contentstack/accountPage/query.ts b/server/routers/contentstack/accountPage/query.ts
index 7e1b0f3b3..0c951552b 100644
--- a/server/routers/contentstack/accountPage/query.ts
+++ b/server/routers/contentstack/accountPage/query.ts
@@ -5,7 +5,6 @@ import {
GetAccountPage,
GetAccountPageRefs,
} from "@/lib/graphql/Query/AccountPage/AccountPage.graphql"
-import { GetMyPagesMetaData } from "@/lib/graphql/Query/AccountPage/MetaData.graphql"
import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
@@ -16,13 +15,7 @@ import {
generateTagsFromSystem,
} from "@/utils/generateTag"
-import { removeEmptyObjects } from "../../utils"
-import { getMetaData, getResponse } from "../metadata/utils"
-import {
- accountPageMetadataSchema,
- accountPageRefsSchema,
- accountPageSchema,
-} from "./output"
+import { accountPageRefsSchema, accountPageSchema } from "./output"
import { getConnections } from "./utils"
import {
@@ -30,7 +23,6 @@ import {
type TrackingSDKPageData,
} from "@/types/components/tracking"
import type {
- GetAccountpageMetadata,
GetAccountPageRefsSchema,
GetAccountPageSchema,
} from "@/types/trpc/routers/contentstack/accountPage"
@@ -206,30 +198,4 @@ export const accountPageQueryRouter = router({
tracking,
}
}),
- metadata: router({
- get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
- const variables = {
- locale: ctx.lang,
- uid: ctx.uid,
- }
- const response = await getResponse(
- GetMyPagesMetaData,
- variables
- )
-
- const validatedMetadata = accountPageMetadataSchema.safeParse(
- response.data
- )
-
- if (!validatedMetadata.success) {
- console.error(
- `Failed to validate My Page MetaData Data - (uid: ${variables.uid})`
- )
- console.error(validatedMetadata.error)
- return null
- }
-
- return getMetaData(validatedMetadata.data.account_page)
- }),
- }),
})
diff --git a/server/routers/contentstack/base/utils.ts b/server/routers/contentstack/base/utils.ts
index 27ec2304f..daf4575a0 100644
--- a/server/routers/contentstack/base/utils.ts
+++ b/server/routers/contentstack/base/utils.ts
@@ -82,6 +82,8 @@ export function getSiteConfigConnections(refs: GetSiteConfigRefData) {
const siteConfigData = refs.all_site_config.items[0]
const connections: System["system"][] = []
+ if (!siteConfigData) return connections
+
const alertConnection = siteConfigData.sitewide_alert.alertConnection
alertConnection.edges.forEach(({ node }) => {
diff --git a/server/routers/contentstack/breadcrumbs/output.ts b/server/routers/contentstack/breadcrumbs/output.ts
index d9b8b2f95..ac08ff7bc 100644
--- a/server/routers/contentstack/breadcrumbs/output.ts
+++ b/server/routers/contentstack/breadcrumbs/output.ts
@@ -1,16 +1,11 @@
import { z } from "zod"
+import { removeMultipleSlashes } from "@/utils/url"
+
import { systemSchema } from "../schemas/system"
+import { homeBreadcrumbs } from "./utils"
-export const getBreadcrumbsSchema = z.array(
- z.object({
- href: z.string().optional(),
- title: z.string(),
- uid: z.string(),
- })
-)
-
-const breadcrumbsRefs = z.object({
+export const breadcrumbsRefsSchema = z.object({
web: z
.object({
breadcrumbs: z
@@ -32,43 +27,7 @@ const breadcrumbsRefs = z.object({
system: systemSchema,
})
-export type BreadcrumbsRefs = z.infer
-
-export const validateMyPagesBreadcrumbsRefsContentstackSchema = z.object({
- account_page: breadcrumbsRefs,
-})
-
-export type GetMyPagesBreadcrumbsRefsData = z.infer<
- typeof validateMyPagesBreadcrumbsRefsContentstackSchema
->
-
-export const validateLoyaltyPageBreadcrumbsRefsContentstackSchema = z.object({
- loyalty_page: breadcrumbsRefs,
-})
-
-export type GetLoyaltyPageBreadcrumbsRefsData = z.infer<
- typeof validateLoyaltyPageBreadcrumbsRefsContentstackSchema
->
-
-export const validateCollectionPageBreadcrumbsRefsContentstackSchema = z.object(
- {
- collection_page: breadcrumbsRefs,
- }
-)
-
-export type GetCollectionPageBreadcrumbsRefsData = z.infer<
- typeof validateCollectionPageBreadcrumbsRefsContentstackSchema
->
-
-export const validateContentPageBreadcrumbsRefsContentstackSchema = z.object({
- content_page: breadcrumbsRefs,
-})
-
-export type GetContentPageBreadcrumbsRefsData = z.infer<
- typeof validateContentPageBreadcrumbsRefsContentstackSchema
->
-
-const page = z.object({
+export const rawBreadcrumbsDataSchema = z.object({
web: z.object({
breadcrumbs: z.object({
title: z.string(),
@@ -92,36 +51,24 @@ const page = z.object({
system: systemSchema,
})
-export type Page = z.infer
+export const breadcrumbsSchema = rawBreadcrumbsDataSchema.transform((data) => {
+ const { parentsConnection, title } = data.web.breadcrumbs
+ const parentBreadcrumbs = parentsConnection.edges.map((breadcrumb) => {
+ return {
+ href: removeMultipleSlashes(
+ `/${breadcrumb.node.system.locale}/${breadcrumb.node.url}`
+ ),
+ title: breadcrumb.node.web.breadcrumbs.title,
+ uid: breadcrumb.node.system.uid,
+ }
+ })
-export const validateMyPagesBreadcrumbsContentstackSchema = z.object({
- account_page: page,
+ const pageBreadcrumb = {
+ title,
+ uid: data.system.uid,
+ href: undefined,
+ }
+ const homeBreadcrumb = homeBreadcrumbs[data.system.locale]
+
+ return [homeBreadcrumb, parentBreadcrumbs, pageBreadcrumb].flat()
})
-
-export type GetMyPagesBreadcrumbsData = z.infer<
- typeof validateMyPagesBreadcrumbsContentstackSchema
->
-
-export const validateLoyaltyPageBreadcrumbsContentstackSchema = z.object({
- loyalty_page: page,
-})
-
-export type GetLoyaltyPageBreadcrumbsData = z.infer<
- typeof validateLoyaltyPageBreadcrumbsContentstackSchema
->
-
-export const validateContentPageBreadcrumbsContentstackSchema = z.object({
- content_page: page,
-})
-
-export type GetContentPageBreadcrumbsData = z.infer<
- typeof validateContentPageBreadcrumbsContentstackSchema
->
-
-export const validateCollectionPageBreadcrumbsContentstackSchema = z.object({
- collection_page: page,
-})
-
-export type GetCollectionPageBreadcrumbsData = z.infer<
- typeof validateCollectionPageBreadcrumbsContentstackSchema
->
diff --git a/server/routers/contentstack/breadcrumbs/query.ts b/server/routers/contentstack/breadcrumbs/query.ts
index ff96fcc49..4d6e24a0c 100644
--- a/server/routers/contentstack/breadcrumbs/query.ts
+++ b/server/routers/contentstack/breadcrumbs/query.ts
@@ -1,3 +1,6 @@
+import { metrics } from "@opentelemetry/api"
+import { cache } from "react"
+
import {
GetMyPagesBreadcrumbs,
GetMyPagesBreadcrumbsRefs,
@@ -14,237 +17,199 @@ import {
GetLoyaltyPageBreadcrumbs,
GetLoyaltyPageBreadcrumbsRefs,
} from "@/lib/graphql/Query/Breadcrumbs/LoyaltyPage.graphql"
+import { request } from "@/lib/graphql/request"
+import { notFound } from "@/server/errors/trpc"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
-import {
- GetCollectionPageBreadcrumbsData,
- GetCollectionPageBreadcrumbsRefsData,
- type GetContentPageBreadcrumbsData,
- type GetContentPageBreadcrumbsRefsData,
- type GetLoyaltyPageBreadcrumbsData,
- type GetLoyaltyPageBreadcrumbsRefsData,
- type GetMyPagesBreadcrumbsData,
- type GetMyPagesBreadcrumbsRefsData,
- validateCollectionPageBreadcrumbsContentstackSchema,
- validateCollectionPageBreadcrumbsRefsContentstackSchema,
- validateContentPageBreadcrumbsContentstackSchema,
- validateContentPageBreadcrumbsRefsContentstackSchema,
- validateLoyaltyPageBreadcrumbsContentstackSchema,
- validateLoyaltyPageBreadcrumbsRefsContentstackSchema,
- validateMyPagesBreadcrumbsContentstackSchema,
- validateMyPagesBreadcrumbsRefsContentstackSchema,
-} from "./output"
-import {
- getBreadcrumbs,
- getRefsResponse,
- getResponse,
- getTags,
- Variables,
-} from "./utils"
+import { breadcrumbsRefsSchema, breadcrumbsSchema } from "./output"
+import { getTags } from "./utils"
import { PageTypeEnum } from "@/types/requests/pageType"
+import type {
+ BreadcrumbsRefsSchema,
+ RawBreadcrumbsSchema,
+} from "@/types/trpc/routers/contentstack/breadcrumbs"
+import type { Lang } from "@/constants/languages"
-async function getLoyaltyPageBreadcrumbs(variables: Variables) {
- const refsResponse = await getRefsResponse(
- GetLoyaltyPageBreadcrumbsRefs,
- variables
- )
+const meter = metrics.getMeter("trpc.breadcrumbs")
- const validatedRefsData =
- validateLoyaltyPageBreadcrumbsRefsContentstackSchema.safeParse(
- refsResponse.data
- )
+// OpenTelemetry metrics
+const getBreadcrumbsRefsCounter = meter.createCounter(
+ "trpc.contentstack.breadcrumbs.refs.get"
+)
+const getBreadcrumbsRefsSuccessCounter = meter.createCounter(
+ "trpc.contentstack.breadcrumbs.refs.get-success"
+)
+const getBreadcrumbsRefsFailCounter = meter.createCounter(
+ "trpc.contentstack.breadcrumbs.refs.get-fail"
+)
+const getBreadcrumbsCounter = meter.createCounter(
+ "trpc.contentstack.breadcrumbs.get"
+)
+const getBreadcrumbsSuccessCounter = meter.createCounter(
+ "trpc.contentstack.breadcrumbs.get-success"
+)
+const getBreadcrumbsFailCounter = meter.createCounter(
+ "trpc.contentstack.breadcrumbs.get-fail"
+)
- if (!validatedRefsData.success) {
- console.error(
- `Failed to validate Loyaltypage Breadcrumbs Refs - (uid: ${variables.uid})`
- )
- console.error(validatedRefsData.error)
- return null
- }
-
- const tags = getTags(validatedRefsData.data.loyalty_page, variables)
-
- const response = await getResponse(
- GetLoyaltyPageBreadcrumbs,
- variables,
- tags
- )
-
- if (!response.data.loyalty_page.web?.breadcrumbs?.title) {
- return null
- }
-
- const validatedBreadcrumbsData =
- validateLoyaltyPageBreadcrumbsContentstackSchema.safeParse(response.data)
-
- if (!validatedBreadcrumbsData.success) {
- console.error(
- `Failed to validate Loyaltypage Breadcrumbs Data - (uid: ${variables.uid})`
- )
- console.error(validatedBreadcrumbsData.error)
- return null
- }
-
- return getBreadcrumbs(
- validatedBreadcrumbsData.data.loyalty_page,
- variables.locale
- )
+interface BreadcrumbsPageData {
+ dataKey: keyof T
+ refQuery: string
+ query: string
}
-async function getCollectionPageBreadcrumbs(variables: Variables) {
- const refsResponse =
- await getRefsResponse(
- GetCollectionPageBreadcrumbsRefs,
- variables
- )
- const validatedRefsData =
- validateCollectionPageBreadcrumbsRefsContentstackSchema.safeParse(
- refsResponse.data
- )
+const getBreadcrumbs = cache(async function fetchMemoizedBreadcrumbs(
+ { dataKey, refQuery, query }: BreadcrumbsPageData,
+ { uid, lang }: { uid: string; lang: Lang }
+) {
+ getBreadcrumbsRefsCounter.add(1, { lang, uid })
+ console.info(
+ "contentstack.breadcrumbs refs get start",
+ JSON.stringify({ query: { lang, uid } })
+ )
+ const refsResponse = await request<{ [K in keyof T]: BreadcrumbsRefsSchema }>(
+ refQuery,
+ { locale: lang, uid }
+ )
+
+ const validatedRefsData = breadcrumbsRefsSchema.safeParse(
+ refsResponse.data[dataKey]
+ )
if (!validatedRefsData.success) {
+ getBreadcrumbsRefsFailCounter.add(1, {
+ error_type: "validation_error",
+ error: JSON.stringify(validatedRefsData.error),
+ })
console.error(
- `Failed to validate CollectionPpage Breadcrumbs Refs - (uid: ${variables.uid})`
+ "contentstack.breadcrumbs refs validation error",
+ JSON.stringify({
+ error: validatedRefsData.error,
+ })
)
- console.error(validatedRefsData.error)
- return null
- }
- const tags = getTags(validatedRefsData.data.collection_page, variables)
- const response = await getResponse(
- GetCollectionPageBreadcrumbs,
- variables,
- tags
- )
- if (!response.data.collection_page.web?.breadcrumbs?.title) {
- return null
- }
- const validatedBreadcrumbsData =
- validateCollectionPageBreadcrumbsContentstackSchema.safeParse(response.data)
- if (!validatedBreadcrumbsData.success) {
- console.error(
- `Failed to validate Collectionpage Breadcrumbs Data - (uid: ${variables.uid})`
- )
- console.error(validatedBreadcrumbsData.error)
- return null
- }
- return getBreadcrumbs(
- validatedBreadcrumbsData.data.collection_page,
- variables.locale
- )
-}
-
-async function getContentPageBreadcrumbs(variables: Variables) {
- const refsResponse = await getRefsResponse(
- GetContentPageBreadcrumbsRefs,
- variables
- )
-
- const validatedRefsData =
- validateContentPageBreadcrumbsRefsContentstackSchema.safeParse(
- refsResponse.data
- )
-
- if (!validatedRefsData.success) {
- console.error(
- `Failed to validate Contentpage Breadcrumbs Refs - (uid: ${variables.uid})`
- )
- console.error(validatedRefsData.error)
- return null
- }
-
- const tags = getTags(validatedRefsData.data.content_page, variables)
-
- const response = await getResponse(
- GetContentPageBreadcrumbs,
- variables,
- tags
- )
-
- if (!response.data.content_page.web?.breadcrumbs?.title) {
- return null
- }
-
- const validatedBreadcrumbsData =
- validateContentPageBreadcrumbsContentstackSchema.safeParse(response.data)
-
- if (!validatedBreadcrumbsData.success) {
- console.error(
- `Failed to validate Contentpage Breadcrumbs Data - (uid: ${variables.uid})`
- )
- console.error(validatedBreadcrumbsData.error)
- return null
- }
-
- return getBreadcrumbs(
- validatedBreadcrumbsData.data.content_page,
- variables.locale
- )
-}
-
-async function getMyPagesBreadcrumbs(variables: Variables) {
- const refsResponse = await getRefsResponse(
- GetMyPagesBreadcrumbsRefs,
- variables
- )
-
- const validatedRefsData =
- validateMyPagesBreadcrumbsRefsContentstackSchema.safeParse(
- refsResponse.data
- )
- if (!validatedRefsData.success) {
- console.error(
- `Failed to validate My Page Breadcrumbs Refs - (uid: ${variables.uid})`
- )
- console.error(validatedRefsData.error)
- return null
- }
-
- const tags = getTags(validatedRefsData.data.account_page, variables)
-
- const response = await getResponse(
- GetMyPagesBreadcrumbs,
- variables,
- tags
- )
-
- if (!response.data.account_page.web?.breadcrumbs?.title) {
return []
}
- const validatedBreadcrumbsData =
- validateMyPagesBreadcrumbsContentstackSchema.safeParse(response.data)
+ getBreadcrumbsRefsSuccessCounter.add(1, { lang, uid })
+ console.info(
+ "contentstack.breadcrumbs refs get success",
+ JSON.stringify({ query: { lang, uid } })
+ )
- if (!validatedBreadcrumbsData.success) {
+ const tags = getTags(validatedRefsData.data, lang)
+
+ getBreadcrumbsCounter.add(1, { lang, uid })
+ console.info(
+ "contentstack.breadcrumbs get start",
+ JSON.stringify({ query: { lang, uid } })
+ )
+ const response = await request(
+ query,
+ { locale: lang, uid },
+ {
+ cache: "force-cache",
+ next: { tags },
+ }
+ )
+
+ if (!response.data) {
+ const notFoundError = notFound(response)
+ getBreadcrumbsFailCounter.add(1, {
+ lang,
+ uid,
+ error_type: "not_found",
+ error: JSON.stringify({ code: notFoundError.code }),
+ })
console.error(
- `Failed to validate My Page Breadcrumbs Data - (uid: ${variables.uid})`
+ "contentstack.breadcrumbs get not found error",
+ JSON.stringify({
+ query: { lang, uid },
+ error: { code: notFoundError.code },
+ })
)
- console.error(validatedBreadcrumbsData.error)
- return null
+ throw notFoundError
}
- return getBreadcrumbs(
- validatedBreadcrumbsData.data.account_page,
- variables.locale
+ const validatedBreadcrumbs = breadcrumbsSchema.safeParse(
+ response.data[dataKey]
)
-}
+
+ if (!validatedBreadcrumbs.success) {
+ getBreadcrumbsFailCounter.add(1, {
+ error_type: "validation_error",
+ error: JSON.stringify(validatedBreadcrumbs.error),
+ })
+ console.error(
+ "contentstack.breadcrumbs validation error",
+ JSON.stringify({
+ error: validatedBreadcrumbs.error,
+ })
+ )
+ return []
+ }
+
+ getBreadcrumbsSuccessCounter.add(1, { lang, uid })
+ console.info(
+ "contentstack.breadcrumbs get success",
+ JSON.stringify({ query: { lang, uid } })
+ )
+
+ return validatedBreadcrumbs.data
+})
export const breadcrumbsQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
const variables = {
- locale: ctx.lang,
+ lang: ctx.lang,
uid: ctx.uid,
}
switch (ctx.contentType) {
case PageTypeEnum.accountPage:
- return await getMyPagesBreadcrumbs(variables)
+ return await getBreadcrumbs<{
+ account_page: RawBreadcrumbsSchema
+ }>(
+ {
+ dataKey: "account_page",
+ refQuery: GetMyPagesBreadcrumbsRefs,
+ query: GetMyPagesBreadcrumbs,
+ },
+ variables
+ )
case PageTypeEnum.collectionPage:
- return await getCollectionPageBreadcrumbs(variables)
+ return await getBreadcrumbs<{
+ collection_page: RawBreadcrumbsSchema
+ }>(
+ {
+ dataKey: "collection_page",
+ refQuery: GetCollectionPageBreadcrumbsRefs,
+ query: GetCollectionPageBreadcrumbs,
+ },
+ variables
+ )
case PageTypeEnum.contentPage:
- return await getContentPageBreadcrumbs(variables)
+ return await getBreadcrumbs<{
+ content_page: RawBreadcrumbsSchema
+ }>(
+ {
+ dataKey: "content_page",
+ refQuery: GetContentPageBreadcrumbsRefs,
+ query: GetContentPageBreadcrumbs,
+ },
+ variables
+ )
case PageTypeEnum.loyaltyPage:
- return await getLoyaltyPageBreadcrumbs(variables)
+ return await getBreadcrumbs<{
+ loyalty_page: RawBreadcrumbsSchema
+ }>(
+ {
+ dataKey: "loyalty_page",
+ refQuery: GetLoyaltyPageBreadcrumbsRefs,
+ query: GetLoyaltyPageBreadcrumbs,
+ },
+ variables
+ )
default:
return []
}
diff --git a/server/routers/contentstack/breadcrumbs/utils.ts b/server/routers/contentstack/breadcrumbs/utils.ts
index acb908083..65845fc87 100644
--- a/server/routers/contentstack/breadcrumbs/utils.ts
+++ b/server/routers/contentstack/breadcrumbs/utils.ts
@@ -1,33 +1,17 @@
import { Lang } from "@/constants/languages"
-import { request } from "@/lib/graphql/request"
-import { internalServerError, notFound } from "@/server/errors/trpc"
-import {
- generateRefsResponseTag,
- generateTag,
- generateTags,
-} from "@/utils/generateTag"
-import { removeMultipleSlashes } from "@/utils/url"
-
-import { type BreadcrumbsRefs, getBreadcrumbsSchema, Page } from "./output"
+import { generateTag, generateTags } from "@/utils/generateTag"
import type { Edges } from "@/types/requests/utils/edges"
import type { NodeRefs } from "@/types/requests/utils/refs"
-
-export function getConnections(refs: BreadcrumbsRefs) {
- const connections: Edges[] = []
-
- if (refs.web?.breadcrumbs) {
- connections.push(refs.web.breadcrumbs.parentsConnection)
- }
-
- return connections
-}
+import type { BreadcrumbsRefsSchema } from "@/types/trpc/routers/contentstack/breadcrumbs"
export const affix = "breadcrumbs"
// TODO: Make these editable in CMS?
-export const homeBreadcrumbs = {
+export const homeBreadcrumbs: {
+ [key in keyof typeof Lang]: { href: string; title: string; uid: string }
+} = {
[Lang.da]: {
href: "/da",
title: "Hjem",
@@ -60,76 +44,19 @@ export const homeBreadcrumbs = {
},
}
-export type Variables = {
- locale: Lang
- uid: string
-}
+export function getConnections(data: BreadcrumbsRefsSchema) {
+ const connections: Edges[] = []
-export async function getRefsResponse(query: string, variables: Variables) {
- const refsResponse = await request(query, variables, {
- cache: "force-cache",
- next: {
- tags: [generateRefsResponseTag(variables.locale, variables.uid, affix)],
- },
- })
- if (!refsResponse.data) {
- throw notFound(refsResponse)
+ if (data.web?.breadcrumbs) {
+ connections.push(data.web.breadcrumbs.parentsConnection)
}
- return refsResponse
+ return connections
}
-export function getTags(page: BreadcrumbsRefs, variables: Variables) {
- const connections = getConnections(page)
- const tags = generateTags(variables.locale, connections)
- tags.push(generateTag(variables.locale, page.system.uid, affix))
+export function getTags(data: BreadcrumbsRefsSchema, lang: Lang) {
+ const connections = getConnections(data)
+ const tags = generateTags(lang, connections)
+ tags.push(generateTag(lang, data.system.uid, affix))
return tags
}
-
-export async function getResponse(
- query: string,
- variables: Variables,
- tags: string[]
-) {
- const response = await request(query, variables, {
- cache: "force-cache",
- next: { tags },
- })
- if (!response.data) {
- throw notFound(response)
- }
-
- return response
-}
-
-export function getBreadcrumbs(page: Page, lang: Lang) {
- const parentBreadcrumbs = page.web.breadcrumbs.parentsConnection.edges.map(
- (breadcrumb) => {
- return {
- href: removeMultipleSlashes(
- `/${breadcrumb.node.system.locale}/${breadcrumb.node.url}`
- ),
- title: breadcrumb.node.web.breadcrumbs.title,
- uid: breadcrumb.node.system.uid,
- }
- }
- )
-
- const pageBreadcrumb = {
- title: page.web.breadcrumbs.title,
- uid: page.system.uid,
- }
-
- const breadcrumbs = [
- homeBreadcrumbs[lang],
- parentBreadcrumbs,
- pageBreadcrumb,
- ].flat()
-
- const validatedBreadcrumbs = getBreadcrumbsSchema.safeParse(breadcrumbs)
- if (!validatedBreadcrumbs.success) {
- throw internalServerError(validatedBreadcrumbs.error)
- }
-
- return validatedBreadcrumbs.data
-}
diff --git a/server/routers/contentstack/index.ts b/server/routers/contentstack/index.ts
index 2456c4f97..68764c6ac 100644
--- a/server/routers/contentstack/index.ts
+++ b/server/routers/contentstack/index.ts
@@ -10,7 +10,7 @@ import { hotelPageRouter } from "./hotelPage"
import { languageSwitcherRouter } from "./languageSwitcher"
import { loyaltyLevelRouter } from "./loyaltyLevel"
import { loyaltyPageRouter } from "./loyaltyPage"
-import { metaDataRouter } from "./metadata"
+import { metadataRouter } from "./metadata"
import { myPagesRouter } from "./myPages"
import { rewardRouter } from "./reward"
@@ -25,7 +25,7 @@ export const contentstackRouter = router({
collectionPage: collectionPageRouter,
contentPage: contentPageRouter,
myPages: myPagesRouter,
- metaData: metaDataRouter,
+ metadata: metadataRouter,
rewards: rewardRouter,
loyaltyLevels: loyaltyLevelRouter,
})
diff --git a/server/routers/contentstack/metadata/index.ts b/server/routers/contentstack/metadata/index.ts
index fa2618123..4da22bdee 100644
--- a/server/routers/contentstack/metadata/index.ts
+++ b/server/routers/contentstack/metadata/index.ts
@@ -1,5 +1,5 @@
import { mergeRouters } from "@/server/trpc"
-import { metaDataQueryRouter } from "./query"
+import { metadataQueryRouter } from "./query"
-export const metaDataRouter = mergeRouters(metaDataQueryRouter)
+export const metadataRouter = mergeRouters(metadataQueryRouter)
diff --git a/server/routers/contentstack/metadata/output.ts b/server/routers/contentstack/metadata/output.ts
index e641a3d99..9474dd3fe 100644
--- a/server/routers/contentstack/metadata/output.ts
+++ b/server/routers/contentstack/metadata/output.ts
@@ -1,11 +1,94 @@
import { z } from "zod"
-import { page } from "../schemas/metadata"
+import { tempImageVaultAssetSchema } from "../schemas/imageVault"
+import { getDescription, getImages, getTitle } from "./utils"
-export const getLoyaltyPageMetadataSchema = z.object({
- loyalty_page: page,
+import type { Metadata } from "next"
+
+import { RTETypeEnum } from "@/types/rte/enums"
+
+const metaDataJsonSchema = z.object({
+ children: z.array(
+ z.object({
+ type: z.nativeEnum(RTETypeEnum),
+ children: z.array(
+ z.object({
+ text: z.string().optional(),
+ })
+ ),
+ })
+ ),
})
-export type GetLoyaltyPageMetaDataData = z.infer<
- typeof getLoyaltyPageMetadataSchema
->
+const metaDataBlocksSchema = z
+ .array(
+ z.object({
+ content: z
+ .object({
+ content: z
+ .object({
+ json: metaDataJsonSchema,
+ })
+ .optional()
+ .nullable(),
+ })
+ .optional()
+ .nullable(),
+ })
+ )
+ .optional()
+ .nullable()
+
+export const rawMetadataSchema = z.object({
+ web: z
+ .object({
+ seo_metadata: z
+ .object({
+ title: z.string().optional().nullable(),
+ description: z.string().optional().nullable(),
+ noindex: z.boolean().optional().nullable(),
+ seo_image: tempImageVaultAssetSchema.nullable(),
+ })
+ .optional()
+ .nullable(),
+ breadcrumbs: z
+ .object({
+ title: z.string().optional().nullable(),
+ })
+ .optional()
+ .nullable(),
+ })
+ .optional()
+ .nullable(),
+ heading: z.string().optional().nullable(),
+ preamble: z.string().optional().nullable(),
+ header: z
+ .object({
+ heading: z.string().optional().nullable(),
+ preamble: z.string().optional().nullable(),
+ })
+ .optional()
+ .nullable(),
+ hero_image: tempImageVaultAssetSchema.nullable(),
+ blocks: metaDataBlocksSchema,
+})
+
+export const metadataSchema = rawMetadataSchema.transform((data) => {
+ const noIndex = !!data.web?.seo_metadata?.noindex
+
+ const metadata: Metadata = {
+ title: getTitle(data),
+ description: getDescription(data),
+ openGraph: {
+ images: getImages(data),
+ },
+ }
+
+ if (noIndex) {
+ metadata.robots = {
+ index: false,
+ follow: true,
+ }
+ }
+ return metadata
+})
diff --git a/server/routers/contentstack/metadata/query.ts b/server/routers/contentstack/metadata/query.ts
index 3de11938d..df239d44a 100644
--- a/server/routers/contentstack/metadata/query.ts
+++ b/server/routers/contentstack/metadata/query.ts
@@ -1,45 +1,144 @@
-import { GetLoyaltyPageMetaData } from "@/lib/graphql/Query/LoyaltyPage/MetaData.graphql"
+import { metrics } from "@opentelemetry/api"
+import { cache } from "react"
+
+import { GetAccountPageMetadata } from "@/lib/graphql/Query/AccountPage/Metadata.graphql"
+import { GetCollectionPageMetadata } from "@/lib/graphql/Query/CollectionPage/Metadata.graphql"
+import { GetContentPageMetadata } from "@/lib/graphql/Query/ContentPage/Metadata.graphql"
+import { GetLoyaltyPageMetadata } from "@/lib/graphql/Query/LoyaltyPage/Metadata.graphql"
+import { request } from "@/lib/graphql/request"
+import { notFound } from "@/server/errors/trpc"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
-import {
- type GetLoyaltyPageMetaDataData,
- getLoyaltyPageMetadataSchema,
-} from "./output"
-import { getMetaData, getResponse, type Variables } from "./utils"
+import { generateTag } from "@/utils/generateTag"
+
+import { metadataSchema } from "./output"
+import { affix } from "./utils"
import { PageTypeEnum } from "@/types/requests/pageType"
+import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
+import type { Lang } from "@/constants/languages"
-async function getLoyaltyPageMetaData(variables: Variables) {
- const response = await getResponse(
- GetLoyaltyPageMetaData,
- variables
+const meter = metrics.getMeter("trpc.metadata")
+
+// OpenTelemetry metrics
+const fetchMetadataCounter = meter.createCounter(
+ "trpc.contentstack.metadata.get"
+)
+const fetchMetadataSuccessCounter = meter.createCounter(
+ "trpc.contentstack.metadata.get-success"
+)
+const fetchMetadataFailCounter = meter.createCounter(
+ "trpc.contentstack.metadata.get-fail"
+)
+const transformMetadataCounter = meter.createCounter(
+ "trpc.contentstack.metadata.transform"
+)
+const transformMetadataSuccessCounter = meter.createCounter(
+ "trpc.contentstack.metadata.transform-success"
+)
+const transformMetadataFailCounter = meter.createCounter(
+ "trpc.contentstack.metadata.transform-fail"
+)
+
+const fetchMetadata = cache(async function fetchMemoizedMetadata(
+ query: string,
+ { uid, lang }: { uid: string; lang: Lang }
+) {
+ fetchMetadataCounter.add(1, { lang, uid })
+ console.info(
+ "contentstack.metadata fetch start",
+ JSON.stringify({ query: { lang, uid } })
+ )
+ const response = await request(
+ query,
+ { locale: lang, uid },
+ {
+ cache: "force-cache",
+ next: {
+ tags: [generateTag(lang, uid, affix)],
+ },
+ }
+ )
+ if (!response.data) {
+ const notFoundError = notFound(response)
+ fetchMetadataFailCounter.add(1, {
+ lang,
+ uid,
+ error_type: "not_found",
+ error: JSON.stringify({ code: notFoundError.code }),
+ })
+ console.error(
+ "contentstack.metadata fetch not found error",
+ JSON.stringify({
+ query: { lang, uid },
+ error: { code: notFoundError.code },
+ })
+ )
+ throw notFoundError
+ }
+
+ fetchMetadataSuccessCounter.add(1, { lang, uid })
+ console.info(
+ "contentstack.metadata fetch success",
+ JSON.stringify({ query: { lang, uid } })
)
- const validatedMetadata = getLoyaltyPageMetadataSchema.safeParse(
- response.data
- )
+ return response.data
+})
+
+function getTransformedMetadata(data: unknown) {
+ transformMetadataCounter.add(1)
+ console.info("contentstack.metadata transform start")
+ const validatedMetadata = metadataSchema.safeParse(data)
if (!validatedMetadata.success) {
+ transformMetadataFailCounter.add(1, {
+ error_type: "validation_error",
+ error: JSON.stringify(validatedMetadata.error),
+ })
console.error(
- `Failed to validate Loyaltypage MetaData Data - (uid: ${variables.uid})`
+ "contentstack.metadata validation error",
+ JSON.stringify({
+ error: validatedMetadata.error,
+ })
)
- console.error(validatedMetadata.error)
return null
}
- return getMetaData(validatedMetadata.data.loyalty_page)
+ transformMetadataSuccessCounter.add(1)
+ console.info("contentstack.metadata transform success")
+
+ return validatedMetadata.data
}
-export const metaDataQueryRouter = router({
+export const metadataQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
const variables = {
- locale: ctx.lang,
+ lang: ctx.lang,
uid: ctx.uid,
}
switch (ctx.contentType) {
+ case PageTypeEnum.accountPage:
+ const accountPageResponse = await fetchMetadata<{
+ account_page: RawMetadataSchema
+ }>(GetAccountPageMetadata, variables)
+ return getTransformedMetadata(accountPageResponse.account_page)
+ case PageTypeEnum.collectionPage:
+ const collectionPageResponse = await fetchMetadata<{
+ collection_page: RawMetadataSchema
+ }>(GetCollectionPageMetadata, variables)
+ return getTransformedMetadata(collectionPageResponse.collection_page)
+ case PageTypeEnum.contentPage:
+ const contentPageResponse = await fetchMetadata<{
+ content_page: RawMetadataSchema
+ }>(GetContentPageMetadata, variables)
+ return getTransformedMetadata(contentPageResponse.content_page)
case PageTypeEnum.loyaltyPage:
- return await getLoyaltyPageMetaData(variables)
+ const loyaltyPageResponse = await fetchMetadata<{
+ loyalty_page: RawMetadataSchema
+ }>(GetLoyaltyPageMetadata, variables)
+ return getTransformedMetadata(loyaltyPageResponse.loyalty_page)
default:
return null
}
diff --git a/server/routers/contentstack/metadata/utils.ts b/server/routers/contentstack/metadata/utils.ts
index c1dc47a1d..3cd9abdd0 100644
--- a/server/routers/contentstack/metadata/utils.ts
+++ b/server/routers/contentstack/metadata/utils.ts
@@ -1,44 +1,129 @@
-import { Lang } from "@/constants/languages"
-import { request } from "@/lib/graphql/request"
-import { internalServerError, notFound } from "@/server/errors/trpc"
-
-import { generateTag } from "@/utils/generateTag"
-
-import { getMetaDataSchema, Page } from "../schemas/metadata"
-
-export type Variables = {
- locale: Lang
- uid: string
-}
+import { RTETypeEnum } from "@/types/rte/enums"
+import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
export const affix = "metadata"
-export async function getResponse(query: string, variables: Variables) {
- const response = await request(query, variables, {
- cache: "force-cache",
- next: {
- tags: [generateTag(variables.locale, variables.uid, affix)],
- },
- })
- if (!response.data) {
- throw notFound(response)
+/**
+ * Truncates the given text "intelligently" based on the last period found near the max length.
+ *
+ * - If a period exists within the extended range (`maxLength` to `maxLength + maxExtension`),
+ * the function truncates after the closest period to `maxLength`.
+ * - If no period is found in the range, it truncates the text after the last period found in the max length of the text.
+ * - If no periods exist at all, it truncates at `maxLength` and appends ellipsis (`...`).
+ *
+ * @param {string} text - The input text to be truncated.
+ * @param {number} [maxLength=150] - The desired maximum length of the truncated text.
+ * @param {number} [minLength=120] - The minimum allowable length for the truncated text.
+ * @param {number} [maxExtension=10] - The maximum number of characters to extend beyond `maxLength` to find a period.
+ * @returns {string} - The truncated text.
+ */
+function truncateTextAfterLastPeriod(
+ text: string,
+ maxLength: number = 150,
+ minLength: number = 120,
+ maxExtension: number = 10
+): string {
+ if (text.length <= maxLength) {
+ return text
}
- return response
+ // Define the extended range
+ const extendedEnd = Math.min(text.length, maxLength + maxExtension)
+ const extendedText = text.slice(0, extendedEnd)
+
+ // Find all periods within the extended range and filter after minLength to get valid periods
+ const periodsInRange = [...extendedText.matchAll(/\./g)].map(
+ ({ index }) => index
+ )
+ const validPeriods = periodsInRange.filter((index) => index + 1 >= minLength)
+
+ if (validPeriods.length > 0) {
+ // Find the period closest to maxLength
+ const closestPeriod = validPeriods.reduce((closest, index) =>
+ Math.abs(index + 1 - maxLength) < Math.abs(closest + 1 - maxLength)
+ ? index
+ : closest
+ )
+ return extendedText.slice(0, closestPeriod + 1)
+ }
+
+ // Fallback: If no period is found within the valid range, look for the last period in the truncated text
+ const maxLengthText = text.slice(0, maxLength)
+ const lastPeriodIndex = maxLengthText.lastIndexOf(".")
+ if (lastPeriodIndex !== -1) {
+ return text.slice(0, lastPeriodIndex + 1)
+ }
+
+ // Final fallback: Return maxLength text including ellipsis
+ return `${maxLengthText}...`
}
-export function getMetaData(page: Page) {
- const pageMetaData = {
- breadcrumbsTitle: page.web.breadcrumbs.title,
- title: page.web.seo_metadata.title,
- description: page.web.seo_metadata.description,
- imageConnection: page.web.seo_metadata.imageConnection,
- uid: page.system.uid,
+export function getTitle(data: RawMetadataSchema) {
+ const metadata = data.web?.seo_metadata
+ if (metadata?.title) {
+ return metadata.title
}
- const validatedMetaData = getMetaDataSchema.safeParse(pageMetaData)
- if (!validatedMetaData.success) {
- throw internalServerError(validatedMetaData.error)
+ if (data.web?.breadcrumbs?.title) {
+ return data.web.breadcrumbs.title
}
-
- return validatedMetaData.data
+ if (data.heading) {
+ return data.heading
+ }
+ if (data.header?.heading) {
+ return data.header.heading
+ }
+ return ""
+}
+
+export function getDescription(data: RawMetadataSchema) {
+ const metadata = data.web?.seo_metadata
+ if (metadata?.description) {
+ return metadata.description
+ }
+ if (data.preamble) {
+ return truncateTextAfterLastPeriod(data.preamble)
+ }
+ if (data.header?.preamble) {
+ return truncateTextAfterLastPeriod(data.header.preamble)
+ }
+ if (data.blocks?.length) {
+ const jsonData = data.blocks[0].content?.content?.json
+ // Finding the first paragraph with text
+ const firstParagraph = jsonData?.children?.find(
+ (child) => child.type === RTETypeEnum.p && child.children[0].text
+ )
+
+ if (firstParagraph?.children?.length) {
+ return firstParagraph.children[0].text
+ ? truncateTextAfterLastPeriod(firstParagraph.children[0].text)
+ : ""
+ }
+ }
+ return ""
+}
+
+export function getImages(data: RawMetadataSchema) {
+ const metadataImage = data.web?.seo_metadata?.seo_image
+ const heroImage = data.hero_image
+
+ // Currently we don't have the possibility to get smaller images from ImageVault (2024-11-15)
+ if (metadataImage) {
+ return [
+ {
+ url: metadataImage.url,
+ width: metadataImage.dimensions.width,
+ height: metadataImage.dimensions.height,
+ },
+ ]
+ }
+ if (heroImage) {
+ return [
+ {
+ url: heroImage.url,
+ width: heroImage.dimensions.width,
+ height: heroImage.dimensions.height,
+ },
+ ]
+ }
+ return []
}
diff --git a/server/routers/contentstack/reward/input.ts b/server/routers/contentstack/reward/input.ts
index acf4a67f8..50add5aa4 100644
--- a/server/routers/contentstack/reward/input.ts
+++ b/server/routers/contentstack/reward/input.ts
@@ -18,6 +18,9 @@ export const rewardsCurrentInput = z.object({
lang: z.nativeEnum(Lang).optional(),
})
-export const rewardsUpdateInput = z.object({
- id: z.string(),
-})
+export const rewardsUpdateInput = z.array(
+ z.object({
+ rewardId: z.string(),
+ couponCode: z.string(),
+ })
+)
diff --git a/server/routers/contentstack/reward/query.ts b/server/routers/contentstack/reward/query.ts
index b1f5ccb8c..5fdf7da82 100644
--- a/server/routers/contentstack/reward/query.ts
+++ b/server/routers/contentstack/reward/query.ts
@@ -4,6 +4,7 @@ import { notFound } from "@/server/errors/trpc"
import {
contentStackBaseWithProtectedProcedure,
contentStackBaseWithServiceProcedure,
+ protectedProcedure,
router,
} from "@/server/trpc"
@@ -16,7 +17,6 @@ import {
} from "./input"
import {
Reward,
- SurpriseReward,
validateApiRewardSchema,
validateCategorizedRewardsSchema,
} from "./output"
@@ -34,10 +34,11 @@ import {
getCurrentRewardFailCounter,
getCurrentRewardSuccessCounter,
getUniqueRewardIds,
+ getUnwrapSurpriseCounter,
+ getUnwrapSurpriseFailCounter,
+ getUnwrapSurpriseSuccessCounter,
} from "./utils"
-import { Surprise } from "@/types/components/blocks/surprises"
-
const ONE_HOUR = 60 * 60
export const rewardQueryRouter = router({
@@ -327,44 +328,100 @@ export const rewardQueryRouter = router({
getCurrentRewardSuccessCounter.add(1)
- const surprises =
- validatedApiRewards.data
- .filter(
- (reward): reward is SurpriseReward =>
- reward?.type === "coupon" && reward?.rewardType === "Surprise"
+ const surprises = validatedApiRewards.data
+ // TODO: Add predicates once legacy endpoints are removed
+ .filter((reward) => {
+ if (reward?.rewardType !== "Surprise") {
+ return false
+ }
+
+ if (!("coupon" in reward)) {
+ return false
+ }
+
+ const unwrappedCoupons =
+ reward.coupon?.filter((coupon) => !coupon.unwrapped) || []
+ if (unwrappedCoupons.length === 0) {
+ return false
+ }
+
+ return true
+ })
+ .map((surprise) => {
+ const reward = cmsRewards.find(
+ ({ reward_id }) => surprise.rewardId === reward_id
)
- .map((surprise) => {
- const reward = cmsRewards.find(
- ({ reward_id }) => surprise.rewardId === reward_id
- )
- if (!reward) {
- return null
- }
+ if (!reward) {
+ return null
+ }
- return {
- ...reward,
- id: surprise.id,
- endsAt: surprise.endsAt,
- }
- })
- .filter((surprise): surprise is Surprise => !!surprise) ?? []
+ return {
+ ...reward,
+ id: surprise.id,
+ coupons: "coupon" in surprise ? surprise.coupon || [] : [],
+ }
+ })
+ .flatMap((surprises) => (surprises ? [surprises] : []))
return surprises
}),
- update: contentStackBaseWithProtectedProcedure
+ unwrap: protectedProcedure
.input(rewardsUpdateInput)
.mutation(async ({ input, ctx }) => {
- const response = await Promise.resolve({ ok: true })
- // const response = await api.post(api.endpoints.v1.rewards, {
- // body: {
- // ids: [input.id],
- // },
- // })
- if (!response.ok) {
- return false
+ getUnwrapSurpriseCounter.add(1)
+
+ const promises = input.map(({ rewardId, couponCode }) => {
+ return api.post(api.endpoints.v1.Profile.Reward.unwrap, {
+ body: {
+ rewardId,
+ couponCode,
+ },
+ headers: {
+ Authorization: `Bearer ${ctx.session.token.access_token}`,
+ },
+ })
+ })
+
+ const responses = await Promise.all(promises)
+
+ const errors = await Promise.all(
+ responses.map(async (apiResponse) => {
+ if (!apiResponse.ok) {
+ const text = await apiResponse.text()
+
+ getUnwrapSurpriseFailCounter.add(1, {
+ error_type: "http_error",
+ error: JSON.stringify({
+ status: apiResponse.status,
+ statusText: apiResponse.statusText,
+ text,
+ }),
+ })
+
+ console.error(
+ "contentstack.unwrap API error",
+ JSON.stringify({
+ error: {
+ status: apiResponse.status,
+ statusText: apiResponse.statusText,
+ text,
+ },
+ query: {},
+ })
+ )
+ return false
+ }
+ return true
+ })
+ )
+
+ if (errors.filter((ok) => !ok).length > 0) {
+ return null
}
+ getUnwrapSurpriseSuccessCounter.add(1)
+
return true
}),
})
diff --git a/server/routers/contentstack/reward/utils.ts b/server/routers/contentstack/reward/utils.ts
index d06f2666b..d0f2e73c9 100644
--- a/server/routers/contentstack/reward/utils.ts
+++ b/server/routers/contentstack/reward/utils.ts
@@ -44,6 +44,15 @@ export const getByLevelRewardFailCounter = meter.createCounter(
export const getByLevelRewardSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.byLevel-success"
)
+export const getUnwrapSurpriseCounter = meter.createCounter(
+ "trpc.contentstack.reward.unwrap"
+)
+export const getUnwrapSurpriseFailCounter = meter.createCounter(
+ "trpc.contentstack.reward.unwrap-fail"
+)
+export const getUnwrapSurpriseSuccessCounter = meter.createCounter(
+ "trpc.contentstack.reward.unwrap-success"
+)
const ONE_HOUR = 60 * 60
diff --git a/server/routers/contentstack/schemas/metadata.ts b/server/routers/contentstack/schemas/metadata.ts
deleted file mode 100644
index 9399f3a61..000000000
--- a/server/routers/contentstack/schemas/metadata.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { z } from "zod"
-
-import { systemSchema } from "./system"
-
-export const getMetaDataSchema = z.object({
- breadcrumbsTitle: z.string().optional(),
- title: z.string().optional(),
- description: z.string().optional(),
- imageConnection: z
- .object({
- edges: z.array(
- z.object({
- node: z.object({
- url: z.string(),
- }),
- })
- ),
- })
- .optional(),
-})
-
-export const page = z.object({
- web: z.object({
- seo_metadata: z.object({
- title: z.string().optional(),
- description: z.string().optional(),
- imageConnection: z
- .object({
- edges: z.array(
- z.object({
- node: z.object({
- url: z.string(),
- }),
- })
- ),
- })
- .optional(),
- }),
- breadcrumbs: z.object({
- title: z.string(),
- }),
- }),
- system: systemSchema,
-})
-
-export type Page = z.infer
diff --git a/server/routers/hotels/input.ts b/server/routers/hotels/input.ts
index 9bfecaf6a..7fe4c02fd 100644
--- a/server/routers/hotels/input.ts
+++ b/server/routers/hotels/input.ts
@@ -74,3 +74,6 @@ export const getRoomPackagesInputSchema = z.object({
children: z.number().optional().default(0),
packageCodes: z.array(z.string()).optional().default([]),
})
+export const getCityCoordinatesInputSchema = z.object({
+ city: z.string(),
+})
diff --git a/server/routers/hotels/output.ts b/server/routers/hotels/output.ts
index f9a5710de..9d3c71194 100644
--- a/server/routers/hotels/output.ts
+++ b/server/routers/hotels/output.ts
@@ -125,6 +125,7 @@ const detailedFacilitySchema = z.object({
public: z.boolean(),
sortOrder: z.number(),
filter: z.string().optional(),
+ icon: z.string().optional(),
})
export const facilitySchema = z.object({
@@ -200,14 +201,14 @@ const rewardNightSchema = z.object({
export const pointOfInterestSchema = z
.object({
- name: z.string(),
- distance: z.number(),
+ name: z.string().optional(),
+ distance: z.number().optional(),
category: z.object({
- name: z.string(),
- group: z.string(),
+ name: z.string().optional(),
+ group: z.string().optional(),
}),
- location: locationSchema,
- isHighlighted: z.boolean(),
+ location: locationSchema.optional(),
+ isHighlighted: z.boolean().optional(),
})
.transform((poi) => ({
name: poi.name,
@@ -215,8 +216,8 @@ export const pointOfInterestSchema = z
categoryName: poi.category.name,
group: getPoiGroupByCategoryName(poi.category.name),
coordinates: {
- lat: poi.location.latitude,
- lng: poi.location.longitude,
+ lat: poi.location?.latitude ?? 0,
+ lng: poi.location?.longitude ?? 0,
},
}))
@@ -463,7 +464,9 @@ export const getHotelDataSchema = z.object({
parking: z.array(parkingSchema),
pointsOfInterest: z
.array(pointOfInterestSchema)
- .transform((pois) => pois.sort((a, b) => a.distance - b.distance)),
+ .transform((pois) =>
+ pois.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0))
+ ),
ratings: ratingsSchema,
rewardNight: rewardNightSchema,
restaurantImages: facilitySchema.optional(),
diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts
index eb31aacb6..900b31a89 100644
--- a/server/routers/hotels/query.ts
+++ b/server/routers/hotels/query.ts
@@ -30,6 +30,7 @@ import {
import { getVerifiedUser, parsedUser } from "../user/query"
import {
getBreakfastPackageInputSchema,
+ getCityCoordinatesInputSchema,
getHotelDataInputSchema,
getHotelsAvailabilityInputSchema,
getRatesInputSchema,
@@ -342,7 +343,7 @@ export const hotelQueryRouter = router({
return {
hotelId,
hotelName: hotelAttributes.name,
- hotelDescription: hotelAttributes.hotelContent.texts.descriptions.short,
+ hotelDescriptions: hotelAttributes.hotelContent.texts,
hotelLocation: hotelAttributes.location,
hotelAddress: hotelAttributes.address,
hotelRatings: hotelAttributes.ratings,
@@ -355,6 +356,9 @@ export const hotelQueryRouter = router({
alerts: hotelAlerts,
faq: contentstackData?.faq,
healthFacilities: hotelAttributes.healthFacilities,
+ contact: hotelAttributes.contactInformation,
+ socials: hotelAttributes.socialMedia,
+ ecoLabels: hotelAttributes.hotelFacts.ecoLabels,
}
}),
availability: router({
@@ -1078,4 +1082,19 @@ export const hotelQueryRouter = router({
)
}),
}),
+ map: router({
+ city: serviceProcedure
+ .input(getCityCoordinatesInputSchema)
+ .query(async function ({ input }) {
+ const apiKey = process.env.GOOGLE_STATIC_MAP_KEY
+ const { city } = input
+ const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(city)}&key=${apiKey}`
+
+ const response = await fetch(url)
+ const data = await response.json()
+ const { lat, lng } = data.results[0].geometry.location
+
+ return { lat, lng }
+ }),
+ }),
})
diff --git a/server/routers/hotels/schemas/room.ts b/server/routers/hotels/schemas/room.ts
index 8b9291c1f..5141086ed 100644
--- a/server/routers/hotels/schemas/room.ts
+++ b/server/routers/hotels/schemas/room.ts
@@ -56,6 +56,7 @@ const roomFacilitiesSchema = z.object({
name: z.string(),
isUniqueSellingPoint: z.boolean(),
sortOrder: z.number(),
+ icon: z.string().optional(),
})
export const roomSchema = z
diff --git a/server/routers/hotels/utils.ts b/server/routers/hotels/utils.ts
index 63ba1970f..3e5b5f5e4 100644
--- a/server/routers/hotels/utils.ts
+++ b/server/routers/hotels/utils.ts
@@ -18,7 +18,8 @@ import { HotelLocation } from "@/types/trpc/routers/hotel/locations"
import type { Lang } from "@/constants/languages"
import type { Endpoint } from "@/lib/api/endpoints"
-export function getPoiGroupByCategoryName(category: string) {
+export function getPoiGroupByCategoryName(category: string | undefined) {
+ if (!category) return PointOfInterestGroupEnum.LOCATION
switch (category) {
case "Airport":
case "Bus terminal":
diff --git a/stores/details.ts b/stores/details.ts
index a382ad7d7..250262ade 100644
--- a/stores/details.ts
+++ b/stores/details.ts
@@ -11,11 +11,12 @@ import {
signedInDetailsSchema,
} from "@/components/HotelReservation/EnterDetails/Details/schema"
import { DetailsContext } from "@/contexts/Details"
+import { arrayMerge } from "@/utils/merge"
import { StepEnum } from "@/types/enums/step"
import type { DetailsState, InitialState } from "@/types/stores/details"
-export const storageName = "details-storage"
+export const detailsStorageName = "details-storage"
export function createDetailsStore(
initialState: InitialState,
isMember: boolean
@@ -27,13 +28,15 @@ export function createDetailsStore(
* we cannot use the data as `defaultValues` for our forms.
* RHF caches defaultValues on mount.
*/
- const detailsStorageUnparsed = sessionStorage.getItem(storageName)
+ const detailsStorageUnparsed = sessionStorage.getItem(detailsStorageName)
if (detailsStorageUnparsed) {
const detailsStorage: Record<
"state",
Pick
> = JSON.parse(detailsStorageUnparsed)
- initialState = merge(initialState, detailsStorage.state.data)
+ initialState = merge(detailsStorage.state.data, initialState, {
+ arrayMerge,
+ })
}
}
return create()(
@@ -98,13 +101,6 @@ export function createDetailsStore(
})
)
},
- updateValidity(property, isValid) {
- return set(
- produce((state: DetailsState) => {
- state.isValid[property] = isValid
- })
- )
- },
},
data: merge(
@@ -135,40 +131,39 @@ export function createDetailsStore(
},
totalPrice: {
- euro: { currency: "", price: 0 },
- local: { currency: "", price: 0 },
+ euro: { currency: "", amount: 0 },
+ local: { currency: "", amount: 0 },
},
}),
{
- name: storageName,
- onRehydrateStorage() {
+ name: detailsStorageName,
+ onRehydrateStorage(prevState) {
return function (state) {
if (state) {
const validatedBedType = bedTypeSchema.safeParse(state.data)
- if (validatedBedType.success) {
- state.actions.updateValidity(StepEnum.selectBed, true)
- } else {
- state.actions.updateValidity(StepEnum.selectBed, false)
+ if (validatedBedType.success !== state.isValid["select-bed"]) {
+ state.isValid["select-bed"] = validatedBedType.success
}
const validatedBreakfast = breakfastStoreSchema.safeParse(
state.data
)
- if (validatedBreakfast.success) {
- state.actions.updateValidity(StepEnum.breakfast, true)
- } else {
- state.actions.updateValidity(StepEnum.breakfast, false)
+ if (validatedBreakfast.success !== state.isValid.breakfast) {
+ state.isValid.breakfast = validatedBreakfast.success
}
const detailsSchema = isMember
? signedInDetailsSchema
: guestDetailsSchema
const validatedDetails = detailsSchema.safeParse(state.data)
- if (validatedDetails.success) {
- state.actions.updateValidity(StepEnum.details, true)
- } else {
- state.actions.updateValidity(StepEnum.details, false)
+ if (validatedDetails.success !== state.isValid.details) {
+ state.isValid.details = validatedDetails.success
}
+
+ const mergedState = merge(state.data, prevState.data, {
+ arrayMerge,
+ })
+ state.data = mergedState
}
}
},
diff --git a/stores/roomAvailability.ts b/stores/roomAvailability.ts
deleted file mode 100644
index ad01453e4..000000000
--- a/stores/roomAvailability.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-"use client"
-
-import { create } from "zustand"
-
-interface RoomAvailabilityState {
- noRoomsAvailable: boolean
- setNoRoomsAvailable: () => void
- setRoomsAvailable: () => void
-}
-
-const useRoomAvailableStore = create((set) => ({
- noRoomsAvailable: false,
- setNoRoomsAvailable: () => set(() => ({ noRoomsAvailable: true })),
- setRoomsAvailable: () => set(() => ({ noRoomsAvailable: false })),
-}))
-
-export default useRoomAvailableStore
diff --git a/stores/steps.ts b/stores/steps.ts
index cf14f6768..efa356c8b 100644
--- a/stores/steps.ts
+++ b/stores/steps.ts
@@ -13,7 +13,7 @@ import {
} from "@/components/HotelReservation/EnterDetails/Details/schema"
import { StepsContext } from "@/contexts/Steps"
-import { storageName as detailsStorageName } from "./details"
+import { detailsStorageName as detailsStorageName } from "./details"
import { StepEnum } from "@/types/enums/step"
import type { DetailsState } from "@/types/stores/details"
diff --git a/types/breadcrumbs.ts b/types/breadcrumbs.ts
deleted file mode 100644
index b79abcbb4..000000000
--- a/types/breadcrumbs.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { z } from "zod"
-
-import { getBreadcrumbsSchema } from "@/server/routers/contentstack/breadcrumbs/output"
-
-export interface Breadcrumbs extends z.infer {}
diff --git a/types/components/blocks/surprises.ts b/types/components/blocks/surprises.ts
index 00c8fadfa..674c52d39 100644
--- a/types/components/blocks/surprises.ts
+++ b/types/components/blocks/surprises.ts
@@ -1,14 +1,35 @@
-import {
- Reward,
- SurpriseReward,
-} from "@/server/routers/contentstack/reward/output"
+import { Reward } from "@/server/routers/contentstack/reward/output"
export interface Surprise extends Reward {
- endsAt: SurpriseReward["endsAt"]
- id: SurpriseReward["id"]
+ coupons: { couponCode?: string; expiresAt?: string }[]
+ id?: string
}
export interface SurprisesProps {
surprises: Surprise[]
membershipNumber?: string
}
+
+export interface NavigationProps {
+ selectedSurprise: number
+ totalSurprises: number
+ showSurprise: (direction: number) => void
+}
+
+export interface CardProps extends React.PropsWithChildren {
+ title?: string
+}
+
+export interface InitialProps {
+ totalSurprises: number
+ onOpen: VoidFunction
+}
+
+export interface SlideProps {
+ surprise: Surprise
+ membershipNumber?: string
+}
+
+export interface HeaderProps extends React.PropsWithChildren {
+ onClose: VoidFunction
+}
diff --git a/types/components/hotelPage/sidepeek/aboutTheHotel.ts b/types/components/hotelPage/sidepeek/aboutTheHotel.ts
new file mode 100644
index 000000000..bb9633b60
--- /dev/null
+++ b/types/components/hotelPage/sidepeek/aboutTheHotel.ts
@@ -0,0 +1,10 @@
+import type { Hotel, HotelAddress, HotelLocation } from "@/types/hotel"
+
+export type AboutTheHotelSidePeekProps = {
+ hotelAddress: HotelAddress
+ coordinates: HotelLocation
+ contact: Hotel["contactInformation"]
+ socials: Hotel["socialMedia"]
+ ecoLabels: Hotel["hotelFacts"]["ecoLabels"]
+ descriptions: Hotel["hotelContent"]["texts"]
+}
diff --git a/types/components/hotelPage/sidepeek/contactInformation.ts b/types/components/hotelPage/sidepeek/contactInformation.ts
new file mode 100644
index 000000000..f1eb73196
--- /dev/null
+++ b/types/components/hotelPage/sidepeek/contactInformation.ts
@@ -0,0 +1,6 @@
+import type { AboutTheHotelSidePeekProps } from "./aboutTheHotel"
+
+export type ContactInformationProps = Omit<
+ AboutTheHotelSidePeekProps,
+ "descriptions"
+>
diff --git a/types/components/hotelReservation/enterDetails/bookingData.ts b/types/components/hotelReservation/enterDetails/bookingData.ts
index 0afabf91a..0683c4739 100644
--- a/types/components/hotelReservation/enterDetails/bookingData.ts
+++ b/types/components/hotelReservation/enterDetails/bookingData.ts
@@ -7,6 +7,7 @@ interface Room {
adults: number
roomTypeCode: string
rateCode: string
+ counterRateCode: string
children?: Child[]
packages?: RoomPackageCodeEnum[]
}
@@ -18,14 +19,24 @@ export interface BookingData {
}
type Price = {
- price: number
+ amount: number
currency: string
}
export type RoomsData = {
roomType: string
- localPrice: Price
- euroPrice: Price | undefined
+ prices: {
+ public: {
+ local: Price
+ euro: Price | undefined
+ }
+ member:
+ | {
+ local: Price
+ euro: Price | undefined
+ }
+ | undefined
+ }
adults: number
children?: Child[]
rateDetails?: string[]
diff --git a/types/components/hotelReservation/selectHotel/hotelFilters.ts b/types/components/hotelReservation/selectHotel/hotelFilters.ts
index fbfd926cf..7004e2b2f 100644
--- a/types/components/hotelReservation/selectHotel/hotelFilters.ts
+++ b/types/components/hotelReservation/selectHotel/hotelFilters.ts
@@ -15,6 +15,7 @@ export type Filter = {
public: boolean
sortOrder: number
filter?: string
+ icon: string
}
export type HotelFilterModalProps = {
diff --git a/types/components/hotelReservation/selectHotel/map.ts b/types/components/hotelReservation/selectHotel/map.ts
index 490f8d06e..4ec21f86f 100644
--- a/types/components/hotelReservation/selectHotel/map.ts
+++ b/types/components/hotelReservation/selectHotel/map.ts
@@ -22,6 +22,7 @@ export interface SelectHotelMapProps {
mapId: string
hotels: HotelData[]
filterList: CategorizedFilters
+ cityCoordinates: Coordinates
}
type ImageSizes = z.infer
diff --git a/types/components/hotelReservation/selectRate/flexibilityOption.ts b/types/components/hotelReservation/selectRate/flexibilityOption.ts
index 811afc139..5baef06a4 100644
--- a/types/components/hotelReservation/selectRate/flexibilityOption.ts
+++ b/types/components/hotelReservation/selectRate/flexibilityOption.ts
@@ -23,7 +23,10 @@ export type FlexibilityOptionProps = {
roomTypeCode: RoomConfiguration["roomTypeCode"]
features: RoomConfiguration["features"]
petRoomPackage: RoomPackage | undefined
- handleSelectRate: (rate: Rate) => void
+ handleSelectRate: (rateCode: {
+ publicRateCode: string
+ roomTypeCode: string
+ }) => void
}
export interface PriceListProps {
diff --git a/types/components/hotelReservation/selectRate/hotelInfoCardProps.ts b/types/components/hotelReservation/selectRate/hotelInfoCardProps.ts
deleted file mode 100644
index 088c54e51..000000000
--- a/types/components/hotelReservation/selectRate/hotelInfoCardProps.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import type { HotelData } from "@/types/hotel"
-
-export type HotelInfoCardProps = {
- hotelData: HotelData | null
- noAvailability: boolean
-}
diff --git a/types/components/hotelReservation/selectRate/roomCard.ts b/types/components/hotelReservation/selectRate/roomCard.ts
index aa0d647be..cc7836a62 100644
--- a/types/components/hotelReservation/selectRate/roomCard.ts
+++ b/types/components/hotelReservation/selectRate/roomCard.ts
@@ -19,7 +19,10 @@ export type RoomCardProps = {
roomCategories: RoomData[]
selectedPackages: RoomPackageCodes[]
packages: RoomPackageData | undefined
- handleSelectRate: (rate: Rate) => void
+ handleSelectRate: (rateCode: {
+ publicRateCode: string
+ roomTypeCode: string
+ }) => void
}
type RoomPackagePriceSchema = z.output
diff --git a/types/components/hotelReservation/selectRate/roomSelection.ts b/types/components/hotelReservation/selectRate/roomSelection.ts
index cd1e48775..abc07cf6a 100644
--- a/types/components/hotelReservation/selectRate/roomSelection.ts
+++ b/types/components/hotelReservation/selectRate/roomSelection.ts
@@ -10,7 +10,10 @@ export interface RoomSelectionProps {
user: SafeUser
availablePackages: RoomPackageData | undefined
selectedPackages: RoomPackageCodes[]
- setRateSummary: (rateSummary: Rate) => void
+ setRateCode: (rateCode: {
+ publicRateCode: string
+ roomTypeCode: string
+ }) => void
rateSummary: Rate | null
}
diff --git a/types/components/hotelReservation/selectRate/section.ts b/types/components/hotelReservation/selectRate/section.ts
index 05d86ff6c..eaea36f2e 100644
--- a/types/components/hotelReservation/selectRate/section.ts
+++ b/types/components/hotelReservation/selectRate/section.ts
@@ -1,4 +1,4 @@
-import { CreditCard } from "@/types/user"
+import { CreditCard, SafeUser } from "@/types/user"
export interface SectionProps {
nextPath: string
@@ -28,6 +28,7 @@ export interface BreakfastSelectionProps extends SectionProps {
export interface DetailsProps extends SectionProps {}
export interface PaymentProps {
+ user: SafeUser
roomPrice: { publicPrice: number; memberPrice: number | undefined }
otherPaymentOptions: string[]
savedCreditCards: CreditCard[] | null
diff --git a/types/components/hotelReservation/selectRate/selectRate.ts b/types/components/hotelReservation/selectRate/selectRate.ts
index a1da31b84..12eb83eb1 100644
--- a/types/components/hotelReservation/selectRate/selectRate.ts
+++ b/types/components/hotelReservation/selectRate/selectRate.ts
@@ -11,7 +11,7 @@ interface Room {
adults: number
roomtype: string
ratecode: string
- counterratecode?: string
+ counterratecode: string
child?: Child[]
packages?: string
}
diff --git a/types/components/metadata/index.ts b/types/components/metadata/index.ts
deleted file mode 100644
index 6c4eedc4a..000000000
--- a/types/components/metadata/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { z } from "zod"
-
-import { getMetaDataSchema } from "@/server/routers/contentstack/schemas/metadata"
-
-export interface MetaData extends z.infer {}
diff --git a/types/stores/details.ts b/types/stores/details.ts
index 459f44efe..72b7f490b 100644
--- a/types/stores/details.ts
+++ b/types/stores/details.ts
@@ -12,7 +12,6 @@ export interface DetailsState {
updateBedType: (data: BedTypeSchema) => void
updateBreakfast: (data: BreakfastPackage | false) => void
updateDetails: (data: DetailsSchema) => void
- updateValidity: (property: StepEnum, isValid: boolean) => void
}
data: DetailsSchema & {
bedType: BedTypeSchema | undefined
@@ -31,7 +30,7 @@ export interface InitialState extends Partial {
interface Price {
currency: string
- price: number
+ amount: number
}
export interface TotalPrice {
diff --git a/types/trpc/routers/contentstack/accountPage.ts b/types/trpc/routers/contentstack/accountPage.ts
index 4bdf2b490..703228d8f 100644
--- a/types/trpc/routers/contentstack/accountPage.ts
+++ b/types/trpc/routers/contentstack/accountPage.ts
@@ -1,7 +1,6 @@
import { z } from "zod"
import {
- accountPageMetadataSchema,
accountPageRefsSchema,
accountPageSchema,
blocksSchema,
@@ -18,10 +17,4 @@ export interface GetAccountPageSchema
export interface AccountPage extends z.output {}
-export interface GetAccountpageMetadata
- extends z.output {}
-
-export interface AccountPageMetadata
- extends z.output {}
-
export type Block = z.output
diff --git a/types/trpc/routers/contentstack/breadcrumbs.ts b/types/trpc/routers/contentstack/breadcrumbs.ts
new file mode 100644
index 000000000..78aa29b05
--- /dev/null
+++ b/types/trpc/routers/contentstack/breadcrumbs.ts
@@ -0,0 +1,15 @@
+import { z } from "zod"
+
+import {
+ breadcrumbsRefsSchema,
+ breadcrumbsSchema,
+ rawBreadcrumbsDataSchema,
+} from "@/server/routers/contentstack/breadcrumbs/output"
+
+export interface BreadcrumbsRefsSchema
+ extends z.input {}
+
+export interface RawBreadcrumbsSchema
+ extends z.input {}
+
+export interface Breadcrumbs extends z.output {}
diff --git a/types/trpc/routers/contentstack/metadata.ts b/types/trpc/routers/contentstack/metadata.ts
new file mode 100644
index 000000000..a02e0d858
--- /dev/null
+++ b/types/trpc/routers/contentstack/metadata.ts
@@ -0,0 +1,5 @@
+import { z } from "zod"
+
+import { rawMetadataSchema } from "@/server/routers/contentstack/metadata/output"
+
+export interface RawMetadataSchema extends z.output {}
diff --git a/utils/generateMetadata.ts b/utils/generateMetadata.ts
index c75579587..39d7fae1f 100644
--- a/utils/generateMetadata.ts
+++ b/utils/generateMetadata.ts
@@ -1,59 +1,5 @@
import { serverClient } from "@/lib/trpc/server"
export async function generateMetadata() {
- const metaData = await serverClient().contentstack.metaData.get()
-
- if (!metaData) {
- return {
- title: "",
- description: "",
- openGraph: {
- images: [],
- },
- }
- }
-
- const title = metaData?.breadcrumbsTitle ?? metaData?.title ?? ""
- const description = metaData?.description ?? ""
- const images =
- metaData?.imageConnection?.edges?.map((edge) => ({
- url: edge.node.url,
- })) || []
-
- return {
- title,
- description,
- openGraph: {
- images,
- },
- }
-}
-
-export async function generateMetadataAccountPage() {
- const metaData = await serverClient().contentstack.accountPage.metadata.get()
-
- if (!metaData) {
- return {
- title: "",
- description: "",
- openGraph: {
- images: [],
- },
- }
- }
-
- const title = metaData?.breadcrumbsTitle ?? metaData?.title ?? ""
- const description = metaData?.description ?? ""
- const images =
- metaData?.imageConnection?.edges?.map((edge) => ({
- url: edge.node.url,
- })) || []
-
- return {
- title,
- description,
- openGraph: {
- images,
- },
- }
+ return await serverClient().contentstack.metadata.get()
}
diff --git a/utils/jsonSchemas.ts b/utils/jsonSchemas.ts
new file mode 100644
index 000000000..4a1308d0d
--- /dev/null
+++ b/utils/jsonSchemas.ts
@@ -0,0 +1,27 @@
+import { env } from "@/env/server"
+
+import type { BreadcrumbList, ListItem, WithContext } from "schema-dts"
+
+import type { Breadcrumbs } from "@/types/trpc/routers/contentstack/breadcrumbs"
+
+export function generateBreadcrumbsSchema(breadcrumbs: Breadcrumbs) {
+ const itemListElement: ListItem[] = breadcrumbs.map((item, index) => ({
+ "@type": "ListItem",
+ position: index + 1,
+ name: item.title,
+ // Only include "item" if "href" exists; otherwise, omit it
+ ...(item.href ? { item: `${env.PUBLIC_URL}${item.href}` } : {}),
+ }))
+
+ const jsonLd: WithContext = {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ itemListElement,
+ }
+
+ return {
+ key: "breadcrumbs",
+ type: "application/ld+json",
+ jsonLd,
+ }
+}
diff --git a/utils/merge.ts b/utils/merge.ts
new file mode 100644
index 000000000..84aaae145
--- /dev/null
+++ b/utils/merge.ts
@@ -0,0 +1,19 @@
+import merge from "deepmerge"
+
+export function arrayMerge(
+ target: any[],
+ source: any[],
+ options: merge.ArrayMergeOptions
+) {
+ const destination = target.slice()
+ source.forEach((item, index) => {
+ if (typeof destination[index] === "undefined") {
+ destination[index] = options.cloneUnlessOtherwiseSpecified(item, options)
+ } else if (options?.isMergeableObject(item)) {
+ destination[index] = merge(target[index], item, options)
+ } else if (target.indexOf(item) === -1) {
+ destination.push(item)
+ }
+ })
+ return destination
+}
diff --git a/utils/safeTry.ts b/utils/safeTry.ts
new file mode 100644
index 000000000..d3bb81596
--- /dev/null
+++ b/utils/safeTry.ts
@@ -0,0 +1,11 @@
+export type SafeTryResult = Promise<
+ [T, undefined] | [undefined, Error | unknown]
+>
+
+export async function safeTry(func: Promise): SafeTryResult {
+ try {
+ return [await func, undefined]
+ } catch (err) {
+ return [undefined, err]
+ }
+}