diff --git a/app/[lang]/(live)/(protected)/my-pages/profile/profileLayout.css b/app/[lang]/(live)/(protected)/my-pages/profile/profileLayout.css index aefe1b30f..216869dbb 100644 --- a/app/[lang]/(live)/(protected)/my-pages/profile/profileLayout.css +++ b/app/[lang]/(live)/(protected)/my-pages/profile/profileLayout.css @@ -9,10 +9,18 @@ display: grid; gap: var(--Spacing-x4); padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x4); + margin: 0 calc(var(--Layout-Mobile-Margin-Margin-min) * -1); } @media screen and (min-width: 768px) { .profile-layout { padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x4); + margin: 0 calc(var(--Layout-Tablet-Margin-Margin-min) * -1); + } +} + +@media screen and (min-width: 1367px) { + .profile-layout { + margin: 0; } } diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx index 9a5f41990..c09914b6b 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx @@ -126,7 +126,7 @@ export default async function StepPage({ return notFound() } - const mustBeGuaranteed = roomAvailability?.mustBeGuaranteed ?? false + const { mustBeGuaranteed, breakfastIncluded } = roomAvailability const paymentGuarantee = intl.formatMessage({ id: "Payment Guarantee", @@ -190,13 +190,18 @@ export default async function StepPage({ isMember: !!user, rateDetails: roomAvailability.rateDetails, roomType: roomAvailability.selectedRoom.roomType, + breakfastIncluded, } + const showBreakfastStep = Boolean( + breakfastPackages?.length && !breakfastIncluded + ) + return ( ) : null} - {breakfastPackages?.length ? ( + {showBreakfastStep ? ( - + ) : null} diff --git a/app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx b/app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx index 6951d700a..2f3916668 100644 --- a/app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx +++ b/app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx @@ -11,7 +11,9 @@ export default async function BookingWidgetPage({ params, searchParams, }: PageArgs) { - if (!env.ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH) return null + if (!env.ENABLE_BOOKING_WIDGET) { + return null + } preload() diff --git a/app/[lang]/(live-current)/layout.tsx b/app/[lang]/(live-current)/layout.tsx index ef9477a87..3867b76d1 100644 --- a/app/[lang]/(live-current)/layout.tsx +++ b/app/[lang]/(live-current)/layout.tsx @@ -4,6 +4,7 @@ import "@scandic-hotels/design-system/style.css" import Script from "next/script" import TokenRefresher from "@/components/Auth/TokenRefresher" +import BookingWidget from "@/components/BookingWidget" import CookieBotConsent from "@/components/CookieBot" import AdobeScript from "@/components/Current/AdobeScript" import Footer from "@/components/Current/Footer" diff --git a/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx b/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx index 693b8a409..6dab0e030 100644 --- a/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx +++ b/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx @@ -7,7 +7,7 @@ import Pagination from "@/components/MyPages/Pagination" import Grids from "@/components/TempDesignSystem/Grids" import Title from "@/components/TempDesignSystem/Text/Title" -import Redeem from "./Redeem" +import Redeem from "../Redeem" import styles from "./current.module.css" diff --git a/components/Blocks/DynamicContent/Rewards/CurrentRewards/Redeem.tsx b/components/Blocks/DynamicContent/Rewards/CurrentRewards/Redeem.tsx deleted file mode 100644 index 1c18fea56..000000000 --- a/components/Blocks/DynamicContent/Rewards/CurrentRewards/Redeem.tsx +++ /dev/null @@ -1,262 +0,0 @@ -"use client" - -import { motion } from "framer-motion" -import { useState } from "react" -import { - Dialog, - DialogTrigger, - Modal, - ModalOverlay, -} from "react-aria-components" -import { useIntl } from "react-intl" - -import { trpc } from "@/lib/trpc/client" - -import Countdown from "@/components/Countdown" -import { CheckCircleIcon, CloseLargeIcon } from "@/components/Icons" -import Button from "@/components/TempDesignSystem/Button" -import Body from "@/components/TempDesignSystem/Text/Body" -import Caption from "@/components/TempDesignSystem/Text/Caption" -import Title from "@/components/TempDesignSystem/Text/Title" -import { isRestaurantOnSiteTierReward } from "@/utils/rewards" - -import { RewardIcon } from "../RewardIcon" - -import styles from "./current.module.css" - -import type { - RedeemModalState, - RedeemProps, - RedeemStep, -} from "@/types/components/myPages/myPage/accountPage" -import type { Reward } from "@/server/routers/contentstack/reward/output" - -const MotionOverlay = motion(ModalOverlay) -const MotionModal = motion(Modal) - -export default function Redeem({ reward, membershipNumber }: RedeemProps) { - const [animation, setAnimation] = useState("unmounted") - const intl = useIntl() - const update = trpc.contentstack.rewards.redeem.useMutation() - const [redeemStep, setRedeemStep] = useState("initial") - - function onProceed() { - if (reward.id) { - update.mutate( - { rewardId: reward.id }, - { - onSuccess() { - setRedeemStep("redeemed") - }, - onError(error) { - console.error("Failed to redeem", error) - }, - } - ) - } - } - - function modalStateHandler(newAnimationState: RedeemModalState) { - setAnimation((currentAnimationState) => - newAnimationState === "hidden" && currentAnimationState === "hidden" - ? "unmounted" - : currentAnimationState - ) - if (newAnimationState === "unmounted") { - setRedeemStep("initial") - } - } - - return ( - setAnimation(isOpen ? "visible" : "hidden")} - > - - - - - {({ close }) => ( - <> -
- -
-
- {redeemStep === "redeemed" && ( - - )} - - - {reward.label} - - - {redeemStep === "initial" && ( - {reward.description} - )} - - {redeemStep === "confirmation" && - "redeem_description" in reward && ( - - {reward.redeem_description} - - )} - {redeemStep === "redeemed" && - isRestaurantOnSiteTierReward(reward) && - membershipNumber && ( - - )} -
- {redeemStep === "initial" && ( -
- -
- )} - - {redeemStep === "confirmation" && ( -
- - -
- )} - - )} -
-
-
-
- ) -} - -const variants = { - fade: { - hidden: { - opacity: 0, - transition: { duration: 0.4, ease: "easeInOut" }, - }, - visible: { - opacity: 1, - transition: { duration: 0.4, ease: "easeInOut" }, - }, - }, - - slideInOut: { - hidden: { - opacity: 0, - y: 32, - transition: { duration: 0.4, ease: "easeInOut" }, - }, - visible: { - opacity: 1, - y: 0, - transition: { duration: 0.4, ease: "easeInOut" }, - }, - }, -} - -function ConfirmationBadge({ reward }: { reward: Reward }) { - return ( -
- {isRestaurantOnSiteTierReward(reward) ? ( - - ) : ( - - )} -
- ) -} - -function ActiveRedeemedBadge() { - const intl = useIntl() - - return ( -
- - - - {intl.formatMessage({ id: "Active" })} -
- ) -} - -function TimedRedeemedBadge() { - const intl = useIntl() - - return ( - <> -
- - - {intl.formatMessage({ - id: "Redeemed & valid through:", - })} - -
- - - ) -} - -function MembershipNumberBadge({ - membershipNumber, -}: { - membershipNumber: string -}) { - const intl = useIntl() - - return ( -
- - {intl.formatMessage({ id: "Membership ID:" })} {membershipNumber} - -
- ) -} diff --git a/components/Blocks/DynamicContent/Rewards/CurrentRewards/current.module.css b/components/Blocks/DynamicContent/Rewards/CurrentRewards/current.module.css index 605e8835b..ba158485d 100644 --- a/components/Blocks/DynamicContent/Rewards/CurrentRewards/current.module.css +++ b/components/Blocks/DynamicContent/Rewards/CurrentRewards/current.module.css @@ -27,119 +27,3 @@ .btnContainer { padding: 0 var(--Spacing-x3) var(--Spacing-x3); } - -.badge { - border-radius: var(--Small, 4px); - border: 1px solid var(--Base-Border-Subtle); - display: flex; - padding: var(--Spacing-x1) var(--Spacing-x2); - flex-direction: column; - justify-content: center; - align-items: center; -} - -.redeemed { - display: flex; - justify-content: center; - align-items: center; - gap: var(--Spacing-x-half); - align-self: stretch; -} - -.overlay { - background: rgba(0, 0, 0, 0.5); - height: var(--visual-viewport-height); - position: fixed; - top: 0; - left: 0; - width: 100vw; - z-index: 100; -} - -@media screen and (min-width: 768px) { - .overlay { - display: flex; - justify-content: center; - align-items: center; - } -} - -.modal { - background-color: var(--Base-Surface-Primary-light-Normal); - border-radius: var(--Corner-radius-Medium); - box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08); - width: 100%; - position: absolute; - left: 0; - bottom: 0; - z-index: 101; -} - -@media screen and (min-width: 768px) { - .modal { - left: auto; - bottom: auto; - width: 400px; - } -} - -.dialog { - display: flex; - flex-direction: column; - padding-bottom: var(--Spacing-x3); -} - -.modalHeader { - --button-height: 32px; - box-sizing: content-box; - display: flex; - align-items: center; - height: var(--button-height); - position: relative; - justify-content: center; - padding: var(--Spacing-x3) var(--Spacing-x2) 0; -} - -.modalContent { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--Spacing-x2); - padding: 0 var(--Spacing-x3) var(--Spacing-x3); -} - -.modalFooter { - display: flex; - flex-direction: column; - padding: 0 var(--Spacing-x3) var(--Spacing-x1); - gap: var(--Spacing-x-one-and-half); -} - -.modalFooter > button { - flex: 1 0 100%; -} - -.modalClose { - background: none; - border: none; - cursor: pointer; - position: absolute; - right: var(--Spacing-x2); - width: 32px; - height: var(--button-height); - display: flex; - align-items: center; -} - -.active { - display: flex; - align-items: center; - gap: var(--Spacing-x-half); - color: var(--UI-Semantic-Success); -} - -.membershipNumberBadge { - border-radius: var(--Small); - padding: var(--Spacing-x1) var(--Spacing-x-one-and-half); - background: var(--Base-Surface-Secondary-light-Normal); -} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx new file mode 100644 index 000000000..8ff056af3 --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx @@ -0,0 +1,31 @@ +"use client" + +import { motion } from "framer-motion" +import { useIntl } from "react-intl" + +import { CheckCircleIcon } from "@/components/Icons" +import Caption from "@/components/TempDesignSystem/Text/Caption" + +import styles from "./redeem.module.css" + +export default function ActiveRedeemedBadge() { + const intl = useIntl() + + return ( +
+ + + + {intl.formatMessage({ id: "Active" })} +
+ ) +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx new file mode 100644 index 000000000..0adba114b --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx @@ -0,0 +1,58 @@ +"use client" + +import { useIntl } from "react-intl" + +import CopyIcon from "@/components/Icons/Copy" +import Button from "@/components/TempDesignSystem/Button" +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 { RewardIcon } from "../../RewardIcon" + +import styles from "../redeem.module.css" + +import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" + +export default function Campaign({ reward }: { reward: RewardWithRedeem }) { + const intl = useIntl() + + function handleCopy() { + navigator.clipboard.writeText(reward.operaRewardId) + toast.success(intl.formatMessage({ id: "Copied to clipboard" })) + } + + return ( + <> +
+ + + {reward.label} + + {reward.description} +
+ + {intl.formatMessage({ id: "Promo code" })} + + + {reward.operaRewardId} + +
+
+
+ +
+ + ) +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Tier.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Tier.tsx new file mode 100644 index 000000000..0323ba7f8 --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Tier.tsx @@ -0,0 +1,92 @@ +"use client" + +import { useIntl } from "react-intl" + +import Button from "@/components/TempDesignSystem/Button" +import Body from "@/components/TempDesignSystem/Text/Body" +import Title from "@/components/TempDesignSystem/Text/Title" +import { isRestaurantOnSiteTierReward } from "@/utils/rewards" + +import { RewardIcon } from "../../RewardIcon" +import ActiveRedeemedBadge from "../ActiveRedeemedBadge" +import MembershipNumberBadge from "../MembershipNumberBadge" +import TimedRedeemedBadge from "../TimedRedeemedBadge" +import useRedeemFlow from "../useRedeemFlow" + +import styles from "../redeem.module.css" + +import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" + +export default function Tier({ + reward, + membershipNumber, +}: { + reward: RewardWithRedeem + membershipNumber: string +}) { + const { onRedeem, redeemStep, setRedeemStep, isRedeeming } = + useRedeemFlow(reward) + const intl = useIntl() + + return ( + <> +
+ {redeemStep === "redeemed" && ( +
+ {isRestaurantOnSiteTierReward(reward) ? ( + + ) : ( + + )} +
+ )} + + + {reward.label} + + + {redeemStep === "initial" && ( + {reward.description} + )} + + {redeemStep === "confirmation" && ( + {reward.redeem_description} + )} + + {redeemStep === "redeemed" && + isRestaurantOnSiteTierReward(reward) && + membershipNumber && ( + + )} +
+ + {redeemStep === "initial" && ( +
+ +
+ )} + + {redeemStep === "confirmation" && ( +
+ + +
+ )} + + ) +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/MembershipNumberBadge.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/MembershipNumberBadge.tsx new file mode 100644 index 000000000..ab1adae6e --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/MembershipNumberBadge.tsx @@ -0,0 +1,21 @@ +import { useIntl } from "react-intl" + +import Caption from "@/components/TempDesignSystem/Text/Caption" + +import styles from "./redeem.module.css" + +export default function MembershipNumberBadge({ + membershipNumber, +}: { + membershipNumber: string +}) { + const intl = useIntl() + + return ( +
+ + {intl.formatMessage({ id: "Membership ID" })}: {membershipNumber} + +
+ ) +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx new file mode 100644 index 000000000..605187255 --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx @@ -0,0 +1,27 @@ +"use client" + +import { useIntl } from "react-intl" + +import Countdown from "@/components/Countdown" +import { CheckCircleIcon } from "@/components/Icons" +import Caption from "@/components/TempDesignSystem/Text/Caption" + +import styles from "./redeem.module.css" + +export default function TimedRedeemedBadge() { + const intl = useIntl() + + return ( + <> +
+ + + {intl.formatMessage({ + id: "Redeemed & valid through:", + })} + +
+ + + ) +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx new file mode 100644 index 000000000..f1021fb38 --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx @@ -0,0 +1,131 @@ +"use client" + +import { motion } from "framer-motion" +import { useState } from "react" +import { + Dialog, + DialogTrigger, + Modal, + ModalOverlay, +} from "react-aria-components" +import { useIntl } from "react-intl" + +import { CloseLargeIcon } from "@/components/Icons" +import Button from "@/components/TempDesignSystem/Button" + +import Campaign from "./Flows/Campaign" +import Tier from "./Flows/Tier" +import { RedeemContext } from "./useRedeemFlow" + +import styles from "./redeem.module.css" + +import type { + RedeemModalState, + RedeemProps, + RedeemStep, +} from "@/types/components/myPages/myPage/accountPage" +import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" + +const MotionOverlay = motion(ModalOverlay) +const MotionModal = motion(Modal) + +export default function Redeem({ reward, membershipNumber }: RedeemProps) { + const [animation, setAnimation] = useState("unmounted") + const intl = useIntl() + const [redeemStep, setRedeemStep] = useState("initial") + + function modalStateHandler(newAnimationState: RedeemModalState) { + setAnimation((currentAnimationState) => + newAnimationState === "hidden" && currentAnimationState === "hidden" + ? "unmounted" + : currentAnimationState + ) + if (newAnimationState === "unmounted") { + setRedeemStep("initial") + } + } + + return ( + + setAnimation(isOpen ? "visible" : "hidden")} + > + + + + + {({ close }) => ( + <> +
+ +
+ + {getRedeemFlow(reward, membershipNumber || "")} + + )} +
+
+
+
+
+ ) +} + +const variants = { + fade: { + hidden: { + opacity: 0, + transition: { duration: 0.4, ease: "easeInOut" }, + }, + visible: { + opacity: 1, + transition: { duration: 0.4, ease: "easeInOut" }, + }, + }, + + slideInOut: { + hidden: { + opacity: 0, + y: 32, + transition: { duration: 0.4, ease: "easeInOut" }, + }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.4, ease: "easeInOut" }, + }, + }, +} + +function getRedeemFlow(reward: RewardWithRedeem, membershipNumber: string) { + switch (reward.rewardType) { + case "Campaign": + return + case "Surprise": + case "Tier": + return + default: + console.warn("Unsupported reward type for redeem:", reward.rewardType) + return null + } +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/redeem.module.css b/components/Blocks/DynamicContent/Rewards/Redeem/redeem.module.css new file mode 100644 index 000000000..2999e34f9 --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/redeem.module.css @@ -0,0 +1,117 @@ +.badge { + border-radius: var(--Small, 4px); + border: 1px solid var(--Base-Border-Subtle); + display: flex; + padding: var(--Spacing-x1) var(--Spacing-x2); + flex-direction: column; + justify-content: center; + align-items: center; +} + +.redeemed { + display: flex; + justify-content: center; + align-items: center; + gap: var(--Spacing-x-half); + align-self: stretch; +} + +.overlay { + background: rgba(0, 0, 0, 0.5); + height: var(--visual-viewport-height); + position: fixed; + top: 0; + left: 0; + width: 100vw; + z-index: 100; +} + +@media screen and (min-width: 768px) { + .overlay { + display: flex; + justify-content: center; + align-items: center; + } +} + +.modal { + background-color: var(--Base-Surface-Primary-light-Normal); + border-radius: var(--Corner-radius-Medium); + box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08); + width: 100%; + position: absolute; + left: 0; + bottom: 0; + z-index: 101; +} + +@media screen and (min-width: 768px) { + .modal { + left: auto; + bottom: auto; + width: 400px; + } +} + +.dialog { + display: flex; + flex-direction: column; + padding-bottom: var(--Spacing-x3); +} + +.modalHeader { + --button-height: 32px; + box-sizing: content-box; + display: flex; + align-items: center; + height: var(--button-height); + position: relative; + justify-content: center; + padding: var(--Spacing-x3) var(--Spacing-x2) 0; +} + +.modalContent { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--Spacing-x2); + padding: 0 var(--Spacing-x3) var(--Spacing-x3); +} + +.modalFooter { + display: flex; + flex-direction: column; + padding: 0 var(--Spacing-x3) var(--Spacing-x1); + gap: var(--Spacing-x-one-and-half); +} + +.modalFooter > button { + flex: 1 0 100%; +} + +.modalClose { + background: none; + border: none; + cursor: pointer; + position: absolute; + right: var(--Spacing-x2); + width: 32px; + height: var(--button-height); + display: flex; + align-items: center; +} + +.active { + display: flex; + align-items: center; + gap: var(--Spacing-x-half); + color: var(--UI-Semantic-Success); +} + +.rewardBadge { + border-radius: var(--Corner-radius-Small); + padding: var(--Spacing-x1) var(--Spacing-x-one-and-half); + background: var(--Base-Surface-Secondary-light-Normal); + display: grid; + gap: var(--Spacing-x-half); +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts b/components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts new file mode 100644 index 000000000..280bab67a --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts @@ -0,0 +1,44 @@ +"use client" + +import { createContext, useCallback, useContext, useState } from "react" + +import { trpc } from "@/lib/trpc/client" + +import type { RedeemFlowContext } from "@/types/components/myPages/myPage/accountPage" +import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" + +export const RedeemContext = createContext({ + redeemStep: "initial", + setRedeemStep: () => undefined, +}) + +export default function useRedeemFlow(reward: RewardWithRedeem) { + const { redeemStep, setRedeemStep } = useContext(RedeemContext) + + const update = trpc.contentstack.rewards.redeem.useMutation<{ + rewards: RewardWithRedeem[] + }>() + + const onRedeem = useCallback(() => { + if (reward?.id) { + update.mutate( + { rewardId: reward.id }, + { + onSuccess() { + setRedeemStep("redeemed") + }, + onError(error) { + console.error("Failed to redeem", error) + }, + } + ) + } + }, [reward, update, setRedeemStep]) + + return { + onRedeem, + redeemStep, + setRedeemStep, + isRedeeming: update.isPending, + } +} diff --git a/components/ContentType/HotelPage/Facilities/index.tsx b/components/ContentType/HotelPage/Facilities/index.tsx index 3f43cc328..52908b84d 100644 --- a/components/ContentType/HotelPage/Facilities/index.tsx +++ b/components/ContentType/HotelPage/Facilities/index.tsx @@ -17,10 +17,16 @@ import type { export default async function Facilities({ facilities, activitiesCard, + amenities, + healthFacilities, }: FacilitiesProps) { const intl = await getIntl() - const facilityCardGrids = setFacilityCardGrids(facilities) + const facilityCardGrids = setFacilityCardGrids( + facilities, + amenities, + healthFacilities + ) const translatedFacilityGrids: Facilities = facilityCardGrids.map( (cardGrid: FacilityGrid) => { diff --git a/components/ContentType/HotelPage/Rooms/RoomCard/index.tsx b/components/ContentType/HotelPage/Rooms/RoomCard/index.tsx index dba816cb5..1d8214451 100644 --- a/components/ContentType/HotelPage/Rooms/RoomCard/index.tsx +++ b/components/ContentType/HotelPage/Rooms/RoomCard/index.tsx @@ -16,7 +16,7 @@ import styles from "./roomCard.module.css" import type { RoomCardProps } from "@/types/components/hotelPage/room" export function RoomCard({ room }: RoomCardProps) { - const { images, name, roomSize, occupancy } = room + const { images, name, roomSize, totalOccupancy } = room const intl = useIntl() const size = @@ -46,7 +46,11 @@ export function RoomCard({ room }: RoomCardProps) { {intl.formatMessage( { id: "hotelPages.rooms.roomCard.persons" }, - { size, totalOccupancy: occupancy.total } + { + size, + max: totalOccupancy.max, + range: totalOccupancy.range, + } )} diff --git a/components/ContentType/HotelPage/SidePeeks/Room/index.tsx b/components/ContentType/HotelPage/SidePeeks/Room/index.tsx index ded1acf8d..4736c89c3 100644 --- a/components/ContentType/HotelPage/SidePeeks/Room/index.tsx +++ b/components/ContentType/HotelPage/SidePeeks/Room/index.tsx @@ -17,9 +17,8 @@ import type { RoomSidePeekProps } from "@/types/components/hotelPage/sidepeek/ro export default async function RoomSidePeek({ room }: RoomSidePeekProps) { const intl = await getIntl() - const { roomSize, occupancy, descriptions, images } = room + const { roomSize, totalOccupancy, descriptions, images } = room const roomDescription = descriptions.medium - const totalOccupancy = occupancy.total // TODO: Not defined where this should lead. const ctaUrl = "" @@ -34,7 +33,7 @@ export default async function RoomSidePeek({ room }: RoomSidePeekProps) { m².{" "} {intl.formatMessage( { id: "booking.accommodatesUpTo" }, - { nrOfGuests: totalOccupancy } + { range: totalOccupancy.range, max: totalOccupancy.max } )}
diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx index 97ac09a91..6d690a643 100644 --- a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx @@ -14,7 +14,8 @@ import type { WellnessAndExerciseSidePeekProps } from "@/types/components/hotelP export default async function WellnessAndExerciseSidePeek({ healthFacilities, - buttonUrl, + wellnessExerciseButton, + spaPage, }: WellnessAndExerciseSidePeekProps) { const intl = await getIntl() const lang = getLang() @@ -29,13 +30,26 @@ export default async function WellnessAndExerciseSidePeek({ ))}
- {buttonUrl && ( + {(spaPage || wellnessExerciseButton) && (
- + {spaPage && ( + + )} + {wellnessExerciseButton && ( + + )}
)} diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css index 11a410f13..f6f47761e 100644 --- a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css @@ -15,4 +15,6 @@ position: absolute; left: 0; bottom: 0; + display: grid; + gap: var(--Spacing-x2); } diff --git a/components/ContentType/HotelPage/index.tsx b/components/ContentType/HotelPage/index.tsx index 992cdc2d2..a603b5f55 100644 --- a/components/ContentType/HotelPage/index.tsx +++ b/components/ContentType/HotelPage/index.tsx @@ -9,7 +9,6 @@ import Breadcrumbs from "@/components/Breadcrumbs" import SidePeekProvider from "@/components/SidePeeks/SidePeekProvider" import Alert from "@/components/TempDesignSystem/Alert" import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton" -import SidePeek from "@/components/TempDesignSystem/SidePeek" import { getIntl } from "@/i18n" import { getLang } from "@/i18n/serverContext" import { getRestaurantHeading } from "@/utils/facilityCards" @@ -43,6 +42,10 @@ import type { HotelPageProps } from "@/types/components/hotelPage/hotelPage" import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation" import type { Facility } from "@/types/hotel" import { PageContentTypeEnum } from "@/types/requests/contentType" +import type { + ActivitiesCard, + SpaPage, +} from "@/types/trpc/routers/contentstack/hotelPage" export default async function HotelPage({ hotelId }: HotelPageProps) { const lang = getLang() @@ -83,7 +86,8 @@ export default async function HotelPage({ hotelId }: HotelPageProps) { const restaurants = hotelData.included?.restaurants || [] const images = gallery?.smallerImages const description = hotelContent.texts.descriptions.medium - const activitiesCard = content?.[0]?.upcoming_activities_card || null + + const { spaPage, activitiesCard } = content const facilities: Facility[] = [ { @@ -162,7 +166,12 @@ export default async function HotelPage({ hotelId }: HotelPageProps) { ) : null} - + {faq.accordions.length > 0 && ( )} @@ -200,10 +209,15 @@ export default async function HotelPage({ hotelId }: HotelPageProps) { ecoLabels={hotelFacts.ecoLabels} descriptions={hotelContent.texts} /> - + {activitiesCard && ( - + )} setIsOpen(true)} type="button" > - + {selectedFromDate} - {selectedToDate} diff --git a/components/Footer/Details/SocialLink/index.tsx b/components/Footer/Details/SocialLink/index.tsx new file mode 100644 index 000000000..2303c7a24 --- /dev/null +++ b/components/Footer/Details/SocialLink/index.tsx @@ -0,0 +1,29 @@ +"use client" + +import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name" +import { trackSocialMediaClick } from "@/utils/tracking" + +import type { SocialIconsProps } from "@/types/components/footer/socialIcons" +import type { SocialLinkProps } from "@/types/components/footer/socialLink" +import type { IconName } from "@/types/components/icon" + +function SocialIcon({ iconName }: SocialIconsProps) { + const SocialIcon = getIconByIconName(iconName as IconName) + return SocialIcon ? : {iconName} +} + +export default function SocialLink({ link }: SocialLinkProps) { + const { href, title } = link + return ( + trackSocialMediaClick(title)} + > + + + ) +} diff --git a/components/Footer/Details/index.tsx b/components/Footer/Details/index.tsx index 7827f6043..2471fafca 100644 --- a/components/Footer/Details/index.tsx +++ b/components/Footer/Details/index.tsx @@ -1,6 +1,5 @@ import { getFooter, getLanguageSwitcher } from "@/lib/trpc/memoizedRequests" -import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name" import Image from "@/components/Image" import LanguageSwitcher from "@/components/LanguageSwitcher" import SkeletonShimmer from "@/components/SkeletonShimmer" @@ -9,16 +8,10 @@ import Footnote from "@/components/TempDesignSystem/Text/Footnote" import { getIntl } from "@/i18n" import { getLang } from "@/i18n/serverContext" +import SocialLink from "./SocialLink" + import styles from "./details.module.css" -import type { SocialIconsProps } from "@/types/components/footer/socialIcons" -import type { IconName } from "@/types/components/icon" - -function SocialIcon({ iconName }: SocialIconsProps) { - const SocialIcon = getIconByIconName(iconName as IconName) - return SocialIcon ? : {iconName} -} - export default async function FooterDetails() { const lang = getLang() const intl = await getIntl() @@ -40,18 +33,7 @@ export default async function FooterDetails() { diff --git a/components/Footer/Navigation/MainNav/index.tsx b/components/Footer/Navigation/MainNav/index.tsx index d9b499603..6e7b4a861 100644 --- a/components/Footer/Navigation/MainNav/index.tsx +++ b/components/Footer/Navigation/MainNav/index.tsx @@ -1,7 +1,10 @@ +"use client" + import { ArrowRightIcon } from "@/components/Icons" import SkeletonShimmer from "@/components/SkeletonShimmer" import Link from "@/components/TempDesignSystem/Link" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import { trackFooterClick } from "@/utils/tracking" import styles from "./mainnav.module.css" @@ -19,9 +22,9 @@ export default function FooterMainNav({ mainLinks }: FooterMainNavProps) { href={link.url} className={styles.mainNavigationLink} target={link.openInNewTab ? "_blank" : undefined} + onClick={() => trackFooterClick("main", link.title)} > {link.title} - diff --git a/components/Footer/Navigation/SecondaryNav/index.tsx b/components/Footer/Navigation/SecondaryNav/index.tsx index 1af85c16c..e65a34c53 100644 --- a/components/Footer/Navigation/SecondaryNav/index.tsx +++ b/components/Footer/Navigation/SecondaryNav/index.tsx @@ -1,8 +1,11 @@ +"use client" + import Image from "@/components/Image" import SkeletonShimmer from "@/components/SkeletonShimmer" import Link from "@/components/TempDesignSystem/Link" import Caption from "@/components/TempDesignSystem/Text/Caption" -import { getLang } from "@/i18n/serverContext" +import useLang from "@/hooks/useLang" +import { trackFooterClick, trackSocialMediaClick } from "@/utils/tracking" import styles from "./secondarynav.module.css" @@ -13,7 +16,7 @@ export default function FooterSecondaryNav({ secondaryLinks, appDownloads, }: FooterSecondaryNavProps) { - const lang = getLang() + const lang = useLang() return (
@@ -28,18 +31,19 @@ export default function FooterSecondaryNav({
) : null} - {breakfast === false ? ( + {breakfastIncluded ? ( +
+ + {intl.formatMessage({ id: "Breakfast included" })} + +
+ ) : breakfast === false ? (
{intl.formatMessage({ id: "No breakfast" })} diff --git a/components/HotelReservation/EnterDetails/Summary/UI/ui.module.css b/components/HotelReservation/EnterDetails/Summary/UI/ui.module.css index b4d3e41ac..58ff5770c 100644 --- a/components/HotelReservation/EnterDetails/Summary/UI/ui.module.css +++ b/components/HotelReservation/EnterDetails/Summary/UI/ui.module.css @@ -72,6 +72,19 @@ width: 560px; } +.terms { + margin-top: var(--Spacing-x3); + margin-bottom: var(--Spacing-x3); +} +.termsText:nth-child(n) { + display: flex; + align-items: center; + margin-bottom: var(--Spacing-x1); +} +.terms .termsIcon { + margin-right: var(--Spacing-x1); +} + @media screen and (min-width: 1367px) { .bottomDivider { display: block; diff --git a/components/HotelReservation/SelectRate/RoomFilter/index.tsx b/components/HotelReservation/SelectRate/RoomFilter/index.tsx index ac952d518..70752b69b 100644 --- a/components/HotelReservation/SelectRate/RoomFilter/index.tsx +++ b/components/HotelReservation/SelectRate/RoomFilter/index.tsx @@ -65,9 +65,6 @@ export default function RoomFilter({ const tooltipText = intl.formatMessage({ id: "Pet-friendly rooms have an additional fee of 20 EUR per stay", }) - - const showTooltip = isAboveMobile && petFriendly - const submitFilter = useCallback(() => { const data = getValues() onFilter(data) @@ -94,19 +91,14 @@ export default function RoomFilter({
- {!isAboveMobile ? ( - - - + + +
{intl.formatMessage({ id: "Filter" })} - + {Object.entries(selectedFilters) .filter(([_, value]) => value) .map(([key]) => intl.formatMessage({ id: key })) .join(", ")} - - ) : ( - <> - - {intl.formatMessage({ id: "Filter" })} - - - {Object.entries(selectedFilters) - .filter(([_, value]) => value) - .map(([key]) => intl.formatMessage({ id: key })) - .join(", ")} - - - )} +
+
{intl.formatMessage( @@ -170,12 +146,13 @@ export default function RoomFilter({ /> ) - return showTooltip ? ( + return isPetRoom && isAboveMobile ? ( {checkboxChip} diff --git a/components/HotelReservation/SelectRate/RoomFilter/roomFilter.module.css b/components/HotelReservation/SelectRate/RoomFilter/roomFilter.module.css index f94858248..188bca200 100644 --- a/components/HotelReservation/SelectRate/RoomFilter/roomFilter.module.css +++ b/components/HotelReservation/SelectRate/RoomFilter/roomFilter.module.css @@ -12,6 +12,13 @@ align-items: center; } +.filter { + display: flex; + gap: var(--Spacing-x-half); + margin-left: var(--Spacing-x-half); + align-items: baseline; +} + .filterInfo { display: flex; flex-direction: row; diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/flexibilityOption.module.css b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/flexibilityOption.module.css index a38298d27..433127ac7 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/flexibilityOption.module.css +++ b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/flexibilityOption.module.css @@ -77,24 +77,15 @@ input[type="radio"]:checked + .card .checkIcon { margin: 0 auto var(--Spacing-x2); } -.popover { - background-color: var(--Main-Grey-White); - border-radius: var(--Corner-radius-Medium); - left: 0px; - max-height: 400px; - padding: var(--Spacing-x2); - top: calc(55px + var(--Spacing-x1)); - width: 100%; - box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1); +.terms { + margin-top: var(--Spacing-x3); + margin-bottom: var(--Spacing-x3); } - -.popover section:focus-visible { - outline: none; -} -.popover .popoverText { - margin-bottom: var(--Spacing-x-half); -} -.popover .popoverHeading { +.termsText:nth-child(n) { + display: flex; + align-items: center; margin-bottom: var(--Spacing-x1); - font-weight: 600; /* TODO: Remove when this is updated in Design system */ +} +.termsIcon { + margin-right: var(--Spacing-x1); } diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx index 6002b686f..a6da82392 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx @@ -4,9 +4,11 @@ import { useSearchParams } from "next/navigation" import { useEffect, useRef } from "react" import { useIntl } from "react-intl" -import { CheckIcon, InfoCircleIcon } from "@/components/Icons" +import { CheckCircleIcon, CheckIcon, InfoCircleIcon } from "@/components/Icons" +import Modal from "@/components/Modal" +import Button from "@/components/TempDesignSystem/Button" import Label from "@/components/TempDesignSystem/Form/Label" -import Popover from "@/components/TempDesignSystem/Popover" +import Body from "@/components/TempDesignSystem/Text/Body" import Caption from "@/components/TempDesignSystem/Text/Caption" import { RATE_CARD_EQUAL_HEIGHT_CLASS } from "../utils" @@ -116,33 +118,37 @@ export default function FlexibilityOption({ />
- + + + } + title={name} + subtitle={paymentTerm} > - - {name} - - {priceInformation?.map((info) => ( - - {info} - - ))} - +
+ {priceInformation?.map((info) => ( + + + {info} + + ))} +
+
{name} ({paymentTerm}) diff --git a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx index ee15f3b5e..43faad1bd 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx @@ -85,7 +85,7 @@ export default function RoomCard({ ) ) - const { name, roomSize, occupancy, images } = selectedRoom || {} + const { name, roomSize, totalOccupancy, images } = selectedRoom || {} const freeCancelation = intl.formatMessage({ id: "Free cancellation" }) const nonRefundable = intl.formatMessage({ id: "Non-refundable" }) @@ -150,13 +150,13 @@ export default function RoomCard({
- {occupancy?.total && ( + {totalOccupancy && ( {intl.formatMessage( { id: "booking.guests", }, - { nrOfGuests: occupancy.total } + { max: totalOccupancy.max, range: totalOccupancy.range } )} )} diff --git a/components/HotelReservation/SelectRate/RoomSelection/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/index.tsx index ddab2f3f3..b784c94bd 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/index.tsx @@ -1,8 +1,10 @@ "use client" -import { useRouter, useSearchParams } from "next/navigation" +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import { useSession } from "next-auth/react" import { useCallback, useEffect, useMemo, useRef } from "react" import { debounce } from "@/utils/debounce" +import { isValidSession } from "@/utils/session" import RateSummary from "./RateSummary" import RoomCard from "./RoomCard" @@ -20,16 +22,16 @@ import type { RoomSelectionProps } from "@/types/components/hotelReservation/sel export default function RoomSelection({ roomsAvailability, roomCategories, - user, availablePackages, selectedPackages, + isUserLoggedIn, setRateCode, rateSummary, hotelType, }: RoomSelectionProps) { const router = useRouter() + const pathname = usePathname() const searchParams = useSearchParams() - const isUserLoggedIn = !!user const roomRefs = useRef([]) const { roomConfigurations, rateDefinitions } = roomsAvailability @@ -109,9 +111,9 @@ export default function RoomSelection({ rateSummary.member.rateCode ) } - if (selectedPackages.length > 0) { - params.set(`room[${index}].packages`, selectedPackages.join(",")) - } + selectedPackages.length > 0 + ? params.set(`room[${index}].packages`, selectedPackages.join(",")) + : params.delete(`room[${index}].packages`) }) return params @@ -119,6 +121,12 @@ export default function RoomSelection({ function handleSubmit(e: React.FormEvent) { e.preventDefault() + + window.history.replaceState( + null, + "", + `${pathname}?${queryParams.toString()}` + ) router.push(`select-bed?${queryParams}`) } diff --git a/components/HotelReservation/SelectRate/Rooms/RoomsContainer.tsx b/components/HotelReservation/SelectRate/Rooms/RoomsContainer.tsx index d8570e159..ea5f79a26 100644 --- a/components/HotelReservation/SelectRate/Rooms/RoomsContainer.tsx +++ b/components/HotelReservation/SelectRate/Rooms/RoomsContainer.tsx @@ -2,11 +2,12 @@ import { dt } from "@/lib/dt" import { getHotelData, getPackages, - getProfileSafely, getRoomsAvailability, } from "@/lib/trpc/memoizedRequests" +import { auth } from "@/auth" import { safeTry } from "@/utils/safeTry" +import { isValidSession } from "@/utils/session" import { generateChildrenString } from "../RoomSelection/utils" import Rooms from "." @@ -32,8 +33,8 @@ export async function RoomsContainer({ childArray, lang, }: Props) { - const user = await getProfileSafely() - + const session = await auth() + const isUserLoggedIn = isValidSession(session) const fromDateString = dt(fromDate).format("YYYY-MM-DD") const toDateString = dt(toDate).format("YYYY-MM-DD") @@ -92,11 +93,11 @@ export async function RoomsContainer({ return ( ) } diff --git a/components/HotelReservation/SelectRate/Rooms/index.tsx b/components/HotelReservation/SelectRate/Rooms/index.tsx index 01bfe218c..90e2663a1 100644 --- a/components/HotelReservation/SelectRate/Rooms/index.tsx +++ b/components/HotelReservation/SelectRate/Rooms/index.tsx @@ -23,9 +23,9 @@ import type { RoomConfiguration } from "@/server/routers/hotels/output" export default function Rooms({ roomsAvailability, roomCategories = [], - user, availablePackages, hotelType, + isUserLoggedIn, }: SelectRateProps) { const visibleRooms: RoomConfiguration[] = useMemo(() => { const deduped = filterDuplicateRoomTypesByLowestPrice( @@ -184,12 +184,12 @@ export default function Rooms({
) diff --git a/components/Icons/Bed.tsx b/components/Icons/Beds/Bed.tsx similarity index 100% rename from components/Icons/Bed.tsx rename to components/Icons/Beds/Bed.tsx diff --git a/components/Icons/BedDouble.tsx b/components/Icons/Beds/BedDouble.tsx similarity index 97% rename from components/Icons/BedDouble.tsx rename to components/Icons/Beds/BedDouble.tsx index be6e87ac8..1ede5a587 100644 --- a/components/Icons/BedDouble.tsx +++ b/components/Icons/Beds/BedDouble.tsx @@ -1,4 +1,4 @@ -import { iconVariants } from "./variants" +import { iconVariants } from "../variants" import type { IconProps } from "@/types/components/icon" diff --git a/components/Icons/BedSingle.tsx b/components/Icons/Beds/BedSingle.tsx similarity index 97% rename from components/Icons/BedSingle.tsx rename to components/Icons/Beds/BedSingle.tsx index 9ff42333a..8c6ceeb76 100644 --- a/components/Icons/BedSingle.tsx +++ b/components/Icons/Beds/BedSingle.tsx @@ -1,4 +1,4 @@ -import { iconVariants } from "./variants" +import { iconVariants } from "../variants" import type { IconProps } from "@/types/components/icon" diff --git a/components/Icons/Beds/ExtraBunkBed.tsx b/components/Icons/Beds/ExtraBunkBed.tsx new file mode 100644 index 000000000..597643bfa --- /dev/null +++ b/components/Icons/Beds/ExtraBunkBed.tsx @@ -0,0 +1,101 @@ +import { iconVariants } from "../variants" + +import type { IconProps } from "@/types/components/icon" + +export default function ExtraBunkBedIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + + + + + + + + ) +} diff --git a/components/Icons/Beds/ExtraPullOutBed.tsx b/components/Icons/Beds/ExtraPullOutBed.tsx new file mode 100644 index 000000000..649fb7f53 --- /dev/null +++ b/components/Icons/Beds/ExtraPullOutBed.tsx @@ -0,0 +1,29 @@ +import { iconVariants } from "../variants" + +import type { IconProps } from "@/types/components/icon" + +export default function ExtraPullOutBedIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/Beds/ExtraSofaBed.tsx b/components/Icons/Beds/ExtraSofaBed.tsx new file mode 100644 index 000000000..3bd95eb96 --- /dev/null +++ b/components/Icons/Beds/ExtraSofaBed.tsx @@ -0,0 +1,27 @@ +import { iconVariants } from "../variants" + +import type { IconProps } from "@/types/components/icon" + +export default function ExtraSofaBedIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/Beds/ExtraWallBed.tsx b/components/Icons/Beds/ExtraWallBed.tsx new file mode 100644 index 000000000..b6f05c335 --- /dev/null +++ b/components/Icons/Beds/ExtraWallBed.tsx @@ -0,0 +1,29 @@ +import { iconVariants } from "../variants" + +import type { IconProps } from "@/types/components/icon" + +export default function ExtraWallBedIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/Beds/KingBed.tsx b/components/Icons/Beds/KingBed.tsx new file mode 100644 index 000000000..6b65cbc3f --- /dev/null +++ b/components/Icons/Beds/KingBed.tsx @@ -0,0 +1,25 @@ +import { iconVariants } from "../variants" + +import type { IconProps } from "@/types/components/icon" + +export default function KingBedIcon({ className, color, ...props }: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/KingBedSmall.tsx b/components/Icons/Beds/KingBedSmall.tsx similarity index 97% rename from components/Icons/KingBedSmall.tsx rename to components/Icons/Beds/KingBedSmall.tsx index 6c3bc9be6..bdd230642 100644 --- a/components/Icons/KingBedSmall.tsx +++ b/components/Icons/Beds/KingBedSmall.tsx @@ -1,4 +1,4 @@ -import { iconVariants } from "./variants" +import { iconVariants } from "../variants" import type { IconProps } from "@/types/components/icon" diff --git a/components/Icons/Beds/QueenBed.tsx b/components/Icons/Beds/QueenBed.tsx new file mode 100644 index 000000000..2445148a8 --- /dev/null +++ b/components/Icons/Beds/QueenBed.tsx @@ -0,0 +1,29 @@ +import { iconVariants } from "../variants" + +import type { IconProps } from "@/types/components/icon" + +export default function QueenBedIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/Beds/SingleBed.tsx b/components/Icons/Beds/SingleBed.tsx new file mode 100644 index 000000000..a6122139b --- /dev/null +++ b/components/Icons/Beds/SingleBed.tsx @@ -0,0 +1,29 @@ +import { iconVariants } from "../variants" + +import type { IconProps } from "@/types/components/icon" + +export default function SingleBedIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/Beds/TwinBeds.tsx b/components/Icons/Beds/TwinBeds.tsx new file mode 100644 index 000000000..ee55d973c --- /dev/null +++ b/components/Icons/Beds/TwinBeds.tsx @@ -0,0 +1,29 @@ +import { iconVariants } from "../variants" + +import type { IconProps } from "@/types/components/icon" + +export default function TwinBedsIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/KingBed.tsx b/components/Icons/KingBed.tsx deleted file mode 100644 index 5e0f0615d..000000000 --- a/components/Icons/KingBed.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { iconVariants } from "./variants" - -import type { IconProps } from "@/types/components/icon" - -export default function KingBedIcon({ className, color, ...props }: IconProps) { - const classNames = iconVariants({ className, color }) - return ( - - - - ) -} diff --git a/components/Icons/index.tsx b/components/Icons/index.tsx index e5fe75235..1c4a1387d 100644 --- a/components/Icons/index.tsx +++ b/components/Icons/index.tsx @@ -10,11 +10,20 @@ export { default as ArrowUpIcon } from "./ArrowUp" export { default as BalconyIcon } from "./Balcony" export { default as BarIcon } from "./Bar" export { default as BathtubIcon } from "./Bathtub" -export { default as BedIcon } from "./Bed" -export { default as BedDoubleIcon } from "./BedDouble" export { default as BedHotelIcon } from "./BedHotel" export { default as BedroomParentIcon } from "./BedroomParent" -export { default as BedSingleIcon } from "./BedSingle" +export { default as BedIcon } from "./Beds/Bed" +export { default as BedDoubleIcon } from "./Beds/BedDouble" +export { default as BedSingleIcon } from "./Beds/BedSingle" +export { default as ExtraBunkBedIcon } from "./Beds/ExtraBunkBed" +export { default as ExtraPullOutBedIcon } from "./Beds/ExtraPullOutBed" +export { default as ExtraSofaBedIcon } from "./Beds/ExtraSofaBed" +export { default as ExtraWallBedIcon } from "./Beds/ExtraWallBed" +export { default as KingBedIcon } from "./Beds/KingBed" +export { default as KingBedSmallIcon } from "./Beds/KingBedSmall" +export { default as QueenBedIcon } from "./Beds/QueenBed" +export { default as SingleBedIcon } from "./Beds/SingleBed" +export { default as TwinBedsIcon } from "./Beds/TwinBeds" export { default as BikeIcon } from "./Bike" export { default as BikingIcon } from "./Biking" export { default as BreakfastIcon } from "./Breakfast" @@ -99,8 +108,6 @@ export { default as KayakingIcon } from "./Kayaking" export { default as KettleIcon } from "./Kettle" export { default as KidsIcon } from "./Kids" export { default as KidsMocktailIcon } from "./KidsMocktail" -export { default as KingBedIcon } from "./KingBed" -export { default as KingBedSmallIcon } from "./KingBedSmall" export { default as LampIcon } from "./Lamp" export { default as LaptopIcon } from "./Laptop" export { default as LaundryMachineIcon } from "./LaundryMachine" diff --git a/components/LoginButton/index.tsx b/components/LoginButton/index.tsx index 6e0b1ee1c..e52b5e0f5 100644 --- a/components/LoginButton/index.tsx +++ b/components/LoginButton/index.tsx @@ -1,7 +1,5 @@ "use client" -import { type PropsWithChildren } from "react" - import { login } from "@/constants/routes/handleAuth" import Link from "@/components/TempDesignSystem/Link" @@ -9,6 +7,8 @@ import useLang from "@/hooks/useLang" import { useLazyPathname } from "@/hooks/useLazyPathname" import { trackLoginClick } from "@/utils/tracking" +import type { PropsWithChildren } from "react" + import type { TrackingPosition } from "@/types/components/tracking" import type { LinkProps } from "@/components/TempDesignSystem/Link/link" diff --git a/components/HotelReservation/EnterDetails/Modal/index.tsx b/components/Modal/index.tsx similarity index 84% rename from components/HotelReservation/EnterDetails/Modal/index.tsx rename to components/Modal/index.tsx index 50f8b838c..8ea3e5312 100644 --- a/components/HotelReservation/EnterDetails/Modal/index.tsx +++ b/components/Modal/index.tsx @@ -11,6 +11,7 @@ import { import { useIntl } from "react-intl" import { CloseLargeIcon } from "@/components/Icons" +import Preamble from "@/components/TempDesignSystem/Text/Preamble" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import { @@ -34,6 +35,7 @@ function InnerModal({ isOpen, children, title, + subtitle, }: PropsWithChildren) { const intl = useIntl() function modalStateHandler(newAnimationState: AnimationState) { @@ -74,11 +76,18 @@ function InnerModal({ {({ close }) => ( <>
- {title && ( - - {title} - - )} +
+ {title && ( + + {title} + + )} + {subtitle && ( + + {subtitle} + + )} +
+ + + ) +} + +async function selectOption(user: UserEvent, name: RegExp, value: string) { + // since its not a proper Select element selectOptions from userEvent doesn't work + const select = screen.queryByRole("button", { name }) + if (select) { + await user.click(select) + + const option = screen.queryByRole("option", { name: value }) + if (option) { + await user.click(option) + } else { + await user.click(select) // click select again to close it + } + } +} + +const testCases = [ + { + description: "date is set and submitted successfully", + defaultValue: "", + dateOfBirth: "1987-12-05", + expectedOutput: { + dateOfBirth: "1987-12-05", + year: 1987, + month: 12, + day: 5, + }, + }, + { + description: "sets default value and submits successfully", + defaultValue: "2000-01-01", + dateOfBirth: "", + expectedOutput: { + dateOfBirth: "2000-01-01", + year: 2000, + month: 1, + day: 1, + }, + }, + { + description: "accepts date exactly 18 years old", + defaultValue: "", + dateOfBirth: dt().subtract(18, "year").format("YYYY-MM-DD"), + expectedOutput: { + dateOfBirth: dt().subtract(18, "year").format("YYYY-MM-DD"), + }, + }, + { + description: "rejects date below 18 years old - by year", + defaultValue: "", + dateOfBirth: dt().subtract(17, "year").format("YYYY-MM-DD"), + expectedOutput: { + dateOfBirth: "", + }, + }, + { + description: "rejects date below 18 years old - by month", + defaultValue: "", + dateOfBirth: dt().subtract(18, "year").add(1, "month").format("YYYY-MM-DD"), + expectedOutput: { + dateOfBirth: "", + }, + }, + { + description: "rejects date below 18 years old - by day", + defaultValue: "", + dateOfBirth: dt().subtract(18, "year").add(1, "day").format("YYYY-MM-DD"), + expectedOutput: { + dateOfBirth: "", + }, + }, +] + +describe("Date input", () => { + test.each(testCases)( + "$description", + async ({ defaultValue, dateOfBirth, expectedOutput }) => { + const user = userEvent.setup() + const handleSubmit = jest.fn() + + render( + + + + ) + + const date = dt(dateOfBirth).toDate() + const year = date.getFullYear() + const month = date.getMonth() + 1 + const day = date.getDate() + + await selectOption(user, /year/i, year.toString()) + await selectOption(user, /month/i, getLocalizedMonthName(month, Lang.en)) + await selectOption(user, /day/i, day.toString()) + + const submitButton = screen.getByRole("button", { name: /submit/i }) + await user.click(submitButton) + + expect(handleSubmit).toHaveBeenCalledWith( + expect.objectContaining(expectedOutput) + ) + } + ) +}) diff --git a/components/TempDesignSystem/Form/Date/index.tsx b/components/TempDesignSystem/Form/Date/index.tsx index ae9d98818..008213b01 100644 --- a/components/TempDesignSystem/Form/Date/index.tsx +++ b/components/TempDesignSystem/Form/Date/index.tsx @@ -19,6 +19,8 @@ import styles from "./date.module.css" export default function DateSelect({ name, registerOptions = {} }: DateProps) { const intl = useIntl() + const lang = useLang() + const { control, setValue, formState, watch } = useFormContext() const { field, fieldState } = useController({ control, @@ -31,14 +33,20 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { const month = watch(DateName.month) const day = watch(DateName.day) - const lang = useLang() - const months = rangeArray(1, 12).map((month) => ({ + const minAgeDate = dt().subtract(18, "year").toDate() // age 18 + const minAgeYear = minAgeDate.getFullYear() + const minAgeMonth = year === minAgeYear ? minAgeDate.getMonth() + 1 : null + const minAgeDay = + Number(year) === minAgeYear && Number(month) === minAgeMonth + ? minAgeDate.getDate() + : null + + const months = rangeArray(1, minAgeMonth ?? 12).map((month) => ({ value: month, label: getLocalizedMonthName(month, lang), })) - const currentYear = new Date().getFullYear() - const years = rangeArray(1900, currentYear - 18) + const years = rangeArray(1900, minAgeYear) .reverse() .map((year) => ({ value: year, label: year.toString() })) @@ -48,7 +56,7 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { month ? Number(month) - 1 : null ) - const days = rangeArray(1, daysInMonth).map((day) => ({ + const days = rangeArray(1, minAgeDay ?? daysInMonth).map((day) => ({ value: day, label: `${day}`, })) @@ -119,7 +127,6 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { ref={field.ref} value={dateValue} data-testid={name} - className={styles.datePicker} > diff --git a/components/TempDesignSystem/Toasts/index.tsx b/components/TempDesignSystem/Toasts/index.tsx index daa555dbe..a33cf64f5 100644 --- a/components/TempDesignSystem/Toasts/index.tsx +++ b/components/TempDesignSystem/Toasts/index.tsx @@ -47,7 +47,7 @@ export function Toast({ children, message, onClose, variant }: ToastsProps) {
{children}
)} {onClose ? ( - ) : null} diff --git a/constants/booking.ts b/constants/booking.ts index 76e27b0e8..a7c9cf32f 100644 --- a/constants/booking.ts +++ b/constants/booking.ts @@ -1,3 +1,16 @@ +import { + ExtraBunkBedIcon, + ExtraPullOutBedIcon, + ExtraSofaBedIcon, + ExtraWallBedIcon, + KingBedIcon, + QueenBedIcon, + SingleBedIcon, + TwinBedsIcon, +} from "@/components/Icons" + +import type { IconProps } from "@/types/components/icon" + export enum BookingStatusEnum { BookingCompleted = "BookingCompleted", Cancelled = "Cancelled", @@ -101,3 +114,35 @@ export const PAYMENT_METHOD_ICONS: Record< chinaUnionPay: "/_static/icons/payment/china-union-pay.svg", discover: "/_static/icons/payment/discover.svg", } + +export enum BedTypeEnum { + King = "King", + Queen = "Queen", + Single = "Single", + Twin = "Twin", + Other = "Other", +} + +export enum ExtraBedTypeEnum { + SofaBed = "SofaBed", + WallBed = "WallBed", + PullOutBed = "PullOutBed", + BunkBed = "BunkBed", +} + +type BedTypes = keyof typeof BedTypeEnum | keyof typeof ExtraBedTypeEnum + +export const BED_TYPE_ICONS: Record< + BedTypes, + (props: IconProps) => JSX.Element +> = { + King: KingBedIcon, + Queen: QueenBedIcon, + Single: SingleBedIcon, + Twin: TwinBedsIcon, + SofaBed: ExtraSofaBedIcon, + WallBed: ExtraWallBedIcon, + BunkBed: ExtraBunkBedIcon, + PullOutBed: ExtraPullOutBedIcon, + Other: SingleBedIcon, +} diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index 7051fac39..7050f7cb8 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -494,16 +494,18 @@ "Zoom in": "Zoom ind", "Zoom out": "Zoom ud", "as of today": "pr. dags dato", - "booking.accommodatesUpTo": "Plads til {nrOfGuests, plural, one {# person} other {op til # personer}}", + "booking.accommodatesUpTo": "Plads til {max, plural, one {{range} person} other {op til {range} personer}}", "booking.adults": "{totalAdults, plural, one {# voksen} other {# voksne}}", "booking.adults.breakfasts": "{totalAdults, plural, one {# voksen} other {# voksne}}, {totalBreakfasts, plural, one {# morgenmad} other {# morgenmad}}", "booking.basedOnAvailability": "Baseret på tilgængelighed", "booking.bedOptions": "Sengemuligheder", "booking.children": "{totalChildren, plural, one {# barn} other {# børn}}", "booking.children.breakfasts": "{totalChildren, plural, one {# barn} other {# børn}}, {totalBreakfasts, plural, one {# morgenmad} other {# morgenmad}}", + "booking.confirmation.membershipInfo.heading": "Medlemskab ikke verificeret", + "booking.confirmation.membershipInfo.text": "Din booking er bekræftet, men vi kunne ikke verificere dit medlemskab. Hvis du har booket med et medlemstilbud, skal du enten vise dit eksisterende medlemskab ved check-in, blive medlem eller betale prisdifferencen ved check-in. Tilmelding er foretrukket online før opholdet.", "booking.confirmation.text": "Tak fordi du bookede hos os! Vi glæder os til at byde dig velkommen og håber du får et behageligt ophold. Hvis du har spørgsmål eller har brug for at foretage ændringer i din reservation, bedes du kontakte os.", "booking.confirmation.title": "Booking bekræftelse", - "booking.guests": "Maks {nrOfGuests, plural, one {# gæst} other {# gæster}}", + "booking.guests": "Maks {max, plural, one {{range} gæst} other {{range} gæster}}", "booking.nights": "{totalNights, plural, one {# nat} other {# nætter}}", "booking.rooms": "{totalRooms, plural, one {# værelse} other {# værelser}}", "booking.selectRoom": "Zimmer auswählen", @@ -522,7 +524,7 @@ "guaranteeing": "garanti", "guest": "gæst", "guests": "gæster", - "hotelPages.rooms.roomCard.persons": "{size} ({totalOccupancy, plural, one {# person} other {# personer}})", + "hotelPages.rooms.roomCard.persons": "{size} ({max, plural, one {{range} person} other {{range} personer}})", "hotelPages.rooms.roomCard.seeRoomDetails": "Se værelsesdetaljer", "km to city center": "km til byens centrum", "lowercase letter": "lille bogstav", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index 7954874e0..f42805b1d 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -493,16 +493,18 @@ "Zoom in": "Vergrößern", "Zoom out": "Verkleinern", "as of today": "Stand heute", - "booking.accommodatesUpTo": "Bietet Platz für {nrOfGuests, plural, one {# Person } other {bis zu # Personen}}", + "booking.accommodatesUpTo": "Bietet Platz für {max, plural, one {{range} Person } other {bis zu {range} Personen}}", "booking.adults": "{totalAdults, plural, one {# erwachsene} other {# erwachsene}}", "booking.adults.breakfasts": "{totalAdults, plural, one {# erwachsene} other {# erwachsene}}, {totalBreakfasts, plural, one {# frühstück} other {# frühstücke}}", "booking.basedOnAvailability": "Abhängig von der Verfügbarkeit", "booking.bedOptions": "Bettoptionen", "booking.children": "{totalChildren, plural, one {# kind} other {# kinder}}", "booking.children.breakfasts": "{totalChildren, plural, one {# kind} other {# kinder}}, {totalBreakfasts, plural, one {# frühstück} other {# frühstücke}}", + "booking.confirmation.membershipInfo.heading": "Medlemskab nicht verifiziert", + "booking.confirmation.membershipInfo.text": "Ihre Buchung ist bestätigt, aber wir konnten Ihr Mitglied nicht verifizieren. Wenn Sie mit einem Mitgliederrabatt gebucht haben, müssen Sie entweder Ihr vorhandenes Mitgliedschaftsnummer bei der Anreise präsentieren, ein Mitglied werden oder die Preisdifferenz bei der Anreise bezahlen. Die Anmeldung ist vorzugsweise online vor der Aufenthaltsdauer erfolgreich.", "booking.confirmation.text": "Vielen Dank, dass Sie bei uns gebucht haben! Wir freuen uns, Sie bei uns begrüßen zu dürfen und wünschen Ihnen einen angenehmen Aufenthalt. Wenn Sie Fragen haben oder Änderungen an Ihrer Buchung vornehmen müssen, kontaktieren Sie uns bitte..", "booking.confirmation.title": "Buchungsbestätigung", - "booking.guests": "Max {nrOfGuests, plural, one {# gast} other {# gäste}}", + "booking.guests": "Max {max, plural, one {{range} gast} other {{range} gäste}}", "booking.nights": "{totalNights, plural, one {# nacht} other {# Nächte}}", "booking.rooms": "{totalRooms, plural, one {# zimmer} other {# räume}}", "booking.selectRoom": "Vælg værelse", @@ -521,7 +523,7 @@ "guaranteeing": "garantiert", "guest": "gast", "guests": "gäste", - "hotelPages.rooms.roomCard.persons": "{size} ({totalOccupancy, plural, one {# person} other {# personen}})", + "hotelPages.rooms.roomCard.persons": "{size} ({max, plural, one {{range} person} other {{range} personen}})", "hotelPages.rooms.roomCard.seeRoomDetails": "Zimmerdetails ansehen", "km to city center": "km bis zum Stadtzentrum", "lowercase letter": "Kleinbuchstabe", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index 771290e0a..782f8c114 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -103,6 +103,8 @@ "Contact information": "Contact information", "Contact us": "Contact us", "Continue": "Continue", + "Copied to clipboard": "Copied to clipboard", + "Copy promotion code": "Copy promotion code", "Copyright all rights reserved": "Scandic AB All rights reserved", "Could not find requested resource": "Could not find requested resource", "Country": "Country", @@ -363,6 +365,7 @@ "Print confirmation": "Print confirmation", "Proceed to login": "Proceed to login", "Proceed to payment method": "Proceed to payment method", + "Promo code": "Promo code", "Provide a payment card in the next step": "Provide a payment card in the next step", "Public price from": "Public price from", "Public transport": "Public transport", @@ -537,16 +540,18 @@ "Zoom in": "Zoom in", "Zoom out": "Zoom out", "as of today": "as of today", - "booking.accommodatesUpTo": "Accommodates up to {nrOfGuests, plural, one {# person} other {# people}}", + "booking.accommodatesUpTo": "Accommodates up to {max, plural, one {{range} person} other {{range} people}}", "booking.adults": "{totalAdults, plural, one {# adult} other {# adults}}", "booking.adults.breakfasts": "{totalAdults, plural, one {# adult} other {# adults}}, {totalBreakfasts, plural, one {# breakfast} other {# breakfasts}}", "booking.basedOnAvailability": "Based on availability", "booking.bedOptions": "Bed options", "booking.children": "{totalChildren, plural, one {# child} other {# children}}", "booking.children.breakfasts": "{totalChildren, plural, one {# child} other {# children}}, {totalBreakfasts, plural, one {# breakfast} other {# breakfasts}}", + "booking.confirmation.membershipInfo.heading": "Failed to verify membership", + "booking.confirmation.membershipInfo.text": "Your booking(s) is confirmed but we could not verify your membership. If you have booked with a member discount, you'll either need to present your existing membership number upon check-in, become a member or pay the price difference at the hotel. Signing up is preferably done online before the stay.", "booking.confirmation.text": "Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please contact us.", "booking.confirmation.title": "Booking confirmation", - "booking.guests": "Max {nrOfGuests, plural, one {# guest} other {# guests}}", + "booking.guests": "Max {max, plural, one {{range} guest} other {{range} guests}}", "booking.nights": "{totalNights, plural, one {# night} other {# nights}}", "booking.rooms": "{totalRooms, plural, one {# room} other {# rooms}}", "booking.selectRoom": "Select room", @@ -567,7 +572,7 @@ "guest.paid": "{amount} {currency} has been paid", "guests": "guests", "has been paid": "has been paid", - "hotelPages.rooms.roomCard.persons": "{size} ({totalOccupancy, plural, one {# person} other {# persons}})", + "hotelPages.rooms.roomCard.persons": "{size} ({max, plural, one {{range} person} other {{range} persons}})", "hotelPages.rooms.roomCard.seeRoomDetails": "See room details", "km to city center": "km to city center", "lowercase letter": "lowercase letter", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index 4b242c84e..6e20af368 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -492,16 +492,18 @@ "Zoom in": "Lähennä", "Zoom out": "Loitonna", "as of today": "tänään", - "booking.accommodatesUpTo": "Huoneeseen {nrOfGuests, plural, one {# person} other {mahtuu 2 henkilöä}}", + "booking.accommodatesUpTo": "Huoneeseen {max, plural, one {{range} henkilö} other {mahtuu enintään {range} henkilöä}", "booking.adults": "{totalAdults, plural, one {# aikuinen} other {# aikuiset}}", "booking.adults.breakfasts": "{totalAdults, plural, one {# aikuinen} other {# aikuiset}}, {totalBreakfasts, plural, one {# aamiainen} other {# aamiaista}}", "booking.basedOnAvailability": "Saatavuuden mukaan", "booking.bedOptions": "Vuodevaihtoehdot", "booking.children": "{totalChildren, plural, one {# lapsi} other {# lasta}}", "booking.children.breakfasts": "{totalChildren, plural, one {# lapsi} other {# lasta}}, {totalBreakfasts, plural, one {# aamiainen} other {# aamiaista}}", + "booking.confirmation.membershipInfo.heading": "Jäsenyys ei verifioitu", + "booking.confirmation.membershipInfo.text": "Varauksesi on vahvistettu, mutta jäsenyytesi ei voitu vahvistaa. Jos olet bookeutunut jäsenyysalennoilla, sinun on joko esitettävä olemassa olevan jäsenyysnumero tarkistukseen, tulla jäseneksi tai maksamaan hinnan eron hotellissa. Jäsenyyden tilittäminen on suositeltavampaa tehdä verkkoon ennen majoittumista.", "booking.confirmation.text": "Kiitos, että teit varauksen meiltä! Toivotamme sinut tervetulleeksi ja toivomme sinulle miellyttävää oleskelua. Jos sinulla on kysyttävää tai haluat tehdä muutoksia varaukseesi, ota meihin yhteyttä.", "booking.confirmation.title": "Varausvahvistus", - "booking.guests": "Max {nrOfGuests, plural, one {# vieras} other {# vieraita}}", + "booking.guests": "Max {max, plural, one {{range} vieras} other {{range} vieraita}}", "booking.nights": "{totalNights, plural, one {# yö} other {# yötä}}", "booking.rooms": "{totalRooms, plural, one {# huone} other {# sviitti}}", "booking.selectRoom": "Valitse huone", @@ -520,7 +522,7 @@ "guaranteeing": "varmistetaan", "guest": "Vieras", "guests": "Vieraita", - "hotelPages.rooms.roomCard.persons": "{size} ({totalOccupancy, plural, one {# henkilö} other {# Henkilöä}})", + "hotelPages.rooms.roomCard.persons": "{size} ({max, plural, one {{range} henkilö} other {{range} Henkilöä}})", "hotelPages.rooms.roomCard.seeRoomDetails": "Katso huoneen tiedot", "km to city center": "km keskustaan", "lowercase letter": "pien kirjain", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index 3fa2f494f..28237b83b 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -492,16 +492,18 @@ "Zoom in": "Zoom inn", "Zoom out": "Zoom ut", "as of today": "per i dag", - "booking.accommodatesUpTo": "Plass til {nrOfGuests, plural, one {# person} other {opptil # personer}}", + "booking.accommodatesUpTo": "Plass til {max, plural, one {{range} person} other {opptil {range} personer}}", "booking.adults": "{totalAdults, plural, one {# voksen} other {# voksne}}", "booking.adults.breakfasts": "{totalAdults, plural, one {# voksen} other {# voksne}}, {totalBreakfasts, plural, one {# frokost} other {# frokoster}}", "booking.basedOnAvailability": "Basert på tilgjengelighet", "booking.bedOptions": "Sengemuligheter", "booking.children": "{totalChildren, plural, one {# barn} other {# barn}}", "booking.children.breakfasts": "{totalChildren, plural, one {# barn} other {# barn}}, {totalBreakfasts, plural, one {# frokost} other {# frokoster}}", + "booking.confirmation.membershipInfo.heading": "Medlemskap ikke verifisert", + "booking.confirmation.membershipInfo.text": "Din bestilling er bekreftet, men vi kunne ikke verifisere medlemskapet ditt. Hvis du har booke ut med et medlemsrabatt, må du enten presentere eksisterende medlemsnummer ved check-in, bli medlem eller betale prisdifferansen ved hotellet. Registrering er foretrukket gjort online før oppholdet.", "booking.confirmation.text": "Takk for at du booket hos oss! Vi ser frem til å ønske deg velkommen og håper du får et hyggelig opphold. Hvis du har spørsmål eller trenger å gjøre endringer i bestillingen din, vennligst kontakt oss.", "booking.confirmation.title": "Bestillingsbekreftelse", - "booking.guests": "Maks {nrOfGuests, plural, one {# gjest} other {# gjester}}", + "booking.guests": "Maks {max, plural, one {{range} gjest} other {{range} gjester}}", "booking.nights": "{totalNights, plural, one {# natt} other {# netter}}", "booking.rooms": "{totalRooms, plural, one {# rom} other {# rom}}", "booking.selectRoom": "Velg rom", @@ -520,7 +522,7 @@ "guaranteeing": "garantiert", "guest": "gjest", "guests": "gjester", - "hotelPages.rooms.roomCard.persons": "{size} ({totalOccupancy, plural, one {# person} other {# personer}})", + "hotelPages.rooms.roomCard.persons": "{size} ({max, plural, one {{range} person} other {{range} personer}})", "hotelPages.rooms.roomCard.seeRoomDetails": "Se detaljer om rommet", "km to city center": "km til sentrum", "lowercase letter": "liten bokstav", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index 1fae7caf6..d7c9327d7 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -492,16 +492,18 @@ "Zoom in": "Zooma in", "Zoom out": "Zooma ut", "as of today": "från och med idag", - "booking.accommodatesUpTo": "Rymmer {nrOfGuests, plural, one {# person} other {upp till # personer}}", + "booking.accommodatesUpTo": "Rymmer {max, plural, one {{range} person} other {upp till {range} personer}}", "booking.adults": "{totalAdults, plural, one {# vuxen} other {# vuxna}}", "booking.adults.breakfasts": "{totalAdults, plural, one {# vuxen} other {# vuxna}}, {totalBreakfasts, plural, one {# frukost} other {# frukostar}}", "booking.basedOnAvailability": "Baserat på tillgänglighet", "booking.bedOptions": "Sängalternativ", "booking.children": "{totalChildren, plural, one {# barn} other {# barn}}", "booking.children.breakfasts": "{totalChildren, plural, one {# barn} other {# barn}}, {totalBreakfasts, plural, one {# frukost} other {# frukostar}}", + "booking.confirmation.membershipInfo.heading": "Medlemskap inte verifierat", + "booking.confirmation.membershipInfo.text": "Din bokning är bekräftad, men vi kunde inte verifiera ditt medlemskap. Om du har bokat med ett medlemsrabatt måste du antingen presentera ditt befintliga medlemsnummer vid check-in, bli medlem eller betala prisdifferensen vid hotell. Registrering är föredragen gjord online före vistelsen.", "booking.confirmation.text": "Tack för att du bokar hos oss! Vi ser fram emot att välkomna dig och hoppas att du får en trevlig vistelse. Om du har några frågor eller behöver göra ändringar i din bokning, vänligen kontakta oss.", "booking.confirmation.title": "Bokningsbekräftelse", - "booking.guests": "Max {nrOfGuests, plural, one {# gäst} other {# gäster}}", + "booking.guests": "Max {max, plural, one {{range} gäst} other {{range} gäster}}", "booking.nights": "{totalNights, plural, one {# natt} other {# nätter}}", "booking.rooms": "{totalRooms, plural, one {# rum} other {# rum}}", "booking.selectRoom": "Välj rum", @@ -520,7 +522,7 @@ "guaranteeing": "garanterar", "guest": "gäst", "guests": "gäster", - "hotelPages.rooms.roomCard.persons": "{size} ({totalOccupancy, plural, one {# person} other {# personer}})", + "hotelPages.rooms.roomCard.persons": "{size} ({max, plural, one {{range} person} other {{range} personer}})", "hotelPages.rooms.roomCard.seeRoomDetails": "Se information om rummet", "km to city center": "km till stadens centrum", "lowercase letter": "liten bokstav", diff --git a/jest.config.ts b/jest.config.ts index 28055b8c6..bf51303a6 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -154,7 +154,7 @@ const config: Config = { // snapshotSerializers: [], // The test environment that will be used for testing - // testEnvironment: "jest-environment-node", + testEnvironment: "jest-environment-jsdom", // Options that will be passed to the testEnvironment // testEnvironmentOptions: {}, diff --git a/jest.setup.ts b/jest.setup.ts index df6631eeb..6d866f13c 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1 +1,13 @@ import "@testing-library/jest-dom" + +jest.mock("react-intl", () => ({ + useIntl: () => ({ + formatMessage: (message: { id: string }) => message.id, + }), +})) + +jest.mock("next/navigation", () => ({ + useRouter: jest.fn(), + usePathname: jest.fn().mockReturnValue("/"), + useParams: jest.fn().mockReturnValue({ lang: "en" }), +})) diff --git a/lib/graphql/Query/HotelPage/HotelPage.graphql b/lib/graphql/Query/HotelPage/HotelPage.graphql index 82136fdda..a6fd8e47c 100644 --- a/lib/graphql/Query/HotelPage/HotelPage.graphql +++ b/lib/graphql/Query/HotelPage/HotelPage.graphql @@ -1,4 +1,5 @@ #import "../../Fragments/PageLink/AccountPageLink.graphql" +#import "../../Fragments/PageLink/CollectionPageLink.graphql" #import "../../Fragments/PageLink/ContentPageLink.graphql" #import "../../Fragments/PageLink/HotelPageLink.graphql" #import "../../Fragments/PageLink/LoyaltyPageLink.graphql" @@ -58,6 +59,19 @@ query GetHotelPage($locale: String!, $uid: String!) { } } } + ... on HotelPageContentSpaPage { + spa_page { + button_cta + pageConnection { + edges { + node { + ...CollectionPageLink + ...ContentPageLink + } + } + } + } + } } system { ...System diff --git a/package-lock.json b/package-lock.json index f4a2819b4..8ec681ea8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,7 @@ "libphonenumber-js": "^1.10.60", "nanoid": "^5.0.9", "next": "^14.2.18", - "next-auth": "^5.0.0-beta.19", + "next-auth": "5.0.0-beta.19", "react": "^18", "react-day-picker": "^9.0.8", "react-dom": "^18", @@ -129,15 +129,15 @@ } }, "node_modules/@auth/core": { - "version": "0.37.2", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.37.2.tgz", - "integrity": "sha512-kUvzyvkcd6h1vpeMAojK2y7+PAV5H+0Cc9+ZlKYDFhDY31AlvsB+GW5vNO4qE3Y07KeQgvNO9U0QUx/fN62kBw==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.32.0.tgz", + "integrity": "sha512-3+ssTScBd+1fd0/fscAyQN1tSygXzuhysuVVzB942ggU4mdfiTbv36P0ccVnExKWYJKvu3E2r3/zxXCCAmTOrg==", "dependencies": { - "@panva/hkdf": "^1.2.1", + "@panva/hkdf": "^1.1.1", "@types/cookie": "0.6.0", - "cookie": "0.7.1", - "jose": "^5.9.3", - "oauth4webapi": "^3.0.0", + "cookie": "0.6.0", + "jose": "^5.1.3", + "oauth4webapi": "^2.9.0", "preact": "10.11.3", "preact-render-to-string": "5.2.3" }, @@ -10911,9 +10911,9 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "engines": { "node": ">= 0.6" } @@ -17904,16 +17904,16 @@ } }, "node_modules/next-auth": { - "version": "5.0.0-beta.25", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.25.tgz", - "integrity": "sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog==", + "version": "5.0.0-beta.19", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.19.tgz", + "integrity": "sha512-YHu1igcAxZPh8ZB7GIM93dqgY6gcAzq66FOhQFheAdOx1raxNcApt05nNyNCSB6NegSiyJ4XOPsaNow4pfDmsg==", "dependencies": { - "@auth/core": "0.37.2" + "@auth/core": "0.32.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", - "next": "^14.0.0-0 || ^15.0.0-0", + "next": "^14 || ^15.0.0-0", "nodemailer": "^6.6.5", "react": "^18.2.0 || ^19.0.0-0" }, @@ -18043,9 +18043,9 @@ "dev": true }, "node_modules/oauth4webapi": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.1.2.tgz", - "integrity": "sha512-KQZkNU+xn02lWrFu5Vjqg9E81yPtDSxUZorRHlLWVoojD+H/0GFbH59kcnz5Thdjj7c4/mYMBPj/mhvGe/kKXA==", + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz", + "integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==", "funding": { "url": "https://github.com/sponsors/panva" } diff --git a/package.json b/package.json index fb5f0a86f..45a3c811d 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "libphonenumber-js": "^1.10.60", "nanoid": "^5.0.9", "next": "^14.2.18", - "next-auth": "^5.0.0-beta.19", + "next-auth": "5.0.0-beta.19", "react": "^18", "react-day-picker": "^9.0.8", "react-dom": "^18", diff --git a/providers/EnterDetailsProvider.tsx b/providers/EnterDetailsProvider.tsx index 547130e7e..826116eb3 100644 --- a/providers/EnterDetailsProvider.tsx +++ b/providers/EnterDetailsProvider.tsx @@ -25,7 +25,7 @@ import type { DetailsState, InitialState } from "@/types/stores/enter-details" export default function EnterDetailsProvider({ bedTypes, booking, - breakfastPackages, + showBreakfastStep, children, packages, roomRate, @@ -44,7 +44,7 @@ export default function EnterDetailsProvider({ roomTypeCode: bedTypes[0].value, } } - if (!breakfastPackages?.length) { + if (!showBreakfastStep) { initialData.breakfast = false } diff --git a/server/routers/contentstack/contentPage/output.ts b/server/routers/contentstack/contentPage/output.ts index 039cfb1d1..163be3012 100644 --- a/server/routers/contentstack/contentPage/output.ts +++ b/server/routers/contentstack/contentPage/output.ts @@ -209,12 +209,6 @@ export const contentPageSchema = z.object({ }), }) -export const contentPageSchemaBlocks = z.object({ - content_page: z.object({ - blocks: discriminatedUnionArray(blocksSchema.options).nullable(), - }), -}) - /** REFS */ const contentPageCardsRefs = z .object({ diff --git a/server/routers/contentstack/hotelPage/output.ts b/server/routers/contentstack/hotelPage/output.ts index bbedd39a0..81521d8b6 100644 --- a/server/routers/contentstack/hotelPage/output.ts +++ b/server/routers/contentstack/hotelPage/output.ts @@ -9,9 +9,14 @@ import { activitiesCardSchema, } from "../schemas/blocks/activitiesCard" import { hotelFaqRefsSchema, hotelFaqSchema } from "../schemas/blocks/hotelFaq" +import { spaPageRefSchema, spaPageSchema } from "../schemas/blocks/spaPage" import { systemSchema } from "../schemas/system" import { HotelPageEnum } from "@/types/enums/hotelPage" +import type { + ActivitiesCard, + SpaPage, +} from "@/types/trpc/routers/contentstack/hotelPage" const contentBlockActivities = z .object({ @@ -19,13 +24,38 @@ const contentBlockActivities = z }) .merge(activitiesCardSchema) +const contentBlockSpaPage = z + .object({ + __typename: z.literal(HotelPageEnum.ContentStack.blocks.SpaPage), + }) + .merge(spaPageSchema) + export const contentBlock = z.discriminatedUnion("__typename", [ contentBlockActivities, + contentBlockSpaPage, ]) export const hotelPageSchema = z.object({ hotel_page: z.object({ - content: discriminatedUnionArray(contentBlock.options).nullable(), + content: discriminatedUnionArray(contentBlock.options) + .nullable() + .transform((data) => { + let spaPage: SpaPage | undefined + let activitiesCard: ActivitiesCard | undefined + data?.map((block) => { + switch (block.typename) { + case HotelPageEnum.ContentStack.blocks.ActivitiesCard: + activitiesCard = block + break + case HotelPageEnum.ContentStack.blocks.SpaPage: + spaPage = block + break + default: + break + } + }) + return { spaPage, activitiesCard } + }), faq: hotelFaqSchema, hotel_page_id: z.string(), title: z.string(), @@ -40,14 +70,21 @@ export const hotelPageSchema = z.object({ }) /** REFS */ -const hotelPageActiviesCardRefs = z +const hotelPageActivitiesCardRefs = z .object({ __typename: z.literal(HotelPageEnum.ContentStack.blocks.ActivitiesCard), }) .merge(activitiesCardRefSchema) +const hotelPageSpaPageRefs = z + .object({ + __typename: z.literal(HotelPageEnum.ContentStack.blocks.SpaPage), + }) + .merge(spaPageRefSchema) + const hotelPageBlockRefsItem = z.discriminatedUnion("__typename", [ - hotelPageActiviesCardRefs, + hotelPageActivitiesCardRefs, + hotelPageSpaPageRefs, ]) export const hotelPageRefsSchema = z.object({ diff --git a/server/routers/contentstack/reward/output.ts b/server/routers/contentstack/reward/output.ts index f3a38b11b..0c5875310 100644 --- a/server/routers/contentstack/reward/output.ts +++ b/server/routers/contentstack/reward/output.ts @@ -37,6 +37,7 @@ const SurpriseReward = z.object({ rewardType: z.string().optional(), endsAt: z.string().datetime({ offset: true }).optional(), coupons: z.array(Coupon).optional(), + operaRewardId: z.string().default(""), }) export const validateApiRewardSchema = z @@ -53,6 +54,7 @@ export const validateApiRewardSchema = z autoApplyReward: z.boolean().default(false), rewardType: z.string().optional(), rewardTierLevel: z.string().optional(), + operaRewardId: z.string().default(""), }), SurpriseReward, ]) @@ -87,6 +89,7 @@ export const validateApiTierRewardsSchema = z.record( autoApplyReward: z.boolean().default(false), rewardType: z.string().optional(), rewardTierLevel: z.string().optional(), + operaRewardId: z.string().default(""), }) ) ) @@ -99,7 +102,7 @@ export const validateCmsRewardsSchema = z z.object({ taxonomies: z.array( z.object({ - term_uid: z.string().optional(), + term_uid: z.string().optional().default(""), }) ), label: z.string().optional(), @@ -123,7 +126,7 @@ export const validateCmsRewardsWithRedeemSchema = z z.object({ taxonomies: z.array( z.object({ - term_uid: z.string().optional(), + term_uid: z.string().optional().default(""), }) ), label: z.string().optional(), @@ -163,12 +166,14 @@ export type Reward = CMSReward & { id: string | undefined rewardType: string | undefined redeemLocation: string | undefined + operaRewardId: string } export type RewardWithRedeem = CMSRewardWithRedeem & { id: string | undefined rewardType: string | undefined redeemLocation: string | undefined + operaRewardId: string } // New endpoint related types and schemas. @@ -178,16 +183,15 @@ const BenefitReward = z.object({ id: z.string().optional(), redeemLocation: z.string().optional(), rewardId: z.string().optional(), - rewardType: z.string().optional(), + rewardType: z.string().optional(), // TODO: Should be "Tier" but can't because of backwards compatibility rewardTierLevel: z.string().optional(), status: z.string().optional(), }) -const CouponState = z.enum(["claimed", "redeemed", "viewed"]) const CouponData = z.object({ couponCode: z.string().optional(), unwrapped: z.boolean().default(false), - state: CouponState, + state: z.enum(["claimed", "redeemed", "viewed"]), expiresAt: z.string().datetime({ offset: true }).optional(), }) @@ -195,8 +199,9 @@ const CouponReward = z.object({ title: z.string().optional(), id: z.string().optional(), rewardId: z.string().optional(), - rewardType: z.string().optional(), + rewardType: z.enum(["Surprise", "Campaign"]), redeemLocation: z.string().optional(), + operaRewardId: z.string().default(""), status: z.string().optional(), coupon: z.array(CouponData).optional(), }) diff --git a/server/routers/contentstack/reward/query.ts b/server/routers/contentstack/reward/query.ts index 3a3165aaa..f2e6e1ed9 100644 --- a/server/routers/contentstack/reward/query.ts +++ b/server/routers/contentstack/reward/query.ts @@ -202,6 +202,7 @@ export const rewardQueryRouter = router({ } const data = await apiResponse.json() + const validatedApiRewards = isNewEndpoint ? validateCategorizedRewardsSchema.safeParse(data) : validateApiRewardSchema.safeParse(data) @@ -256,6 +257,10 @@ export const rewardQueryRouter = router({ id: apiReward?.id, rewardType: apiReward?.rewardType, redeemLocation: apiReward?.redeemLocation, + operaRewardId: + apiReward && "operaRewardId" in apiReward + ? apiReward.operaRewardId + : "", } }) diff --git a/server/routers/contentstack/schemas/blocks/spaPage.ts b/server/routers/contentstack/schemas/blocks/spaPage.ts new file mode 100644 index 000000000..f353bd736 --- /dev/null +++ b/server/routers/contentstack/schemas/blocks/spaPage.ts @@ -0,0 +1,68 @@ +import { z } from "zod" + +import * as pageLinks from "@/server/routers/contentstack/schemas/pageLinks" + +import { removeMultipleSlashes } from "@/utils/url" + +import { HotelPageEnum } from "@/types/enums/hotelPage" + +export const spaPageSchema = z.object({ + typename: z + .literal(HotelPageEnum.ContentStack.blocks.SpaPage) + .optional() + .default(HotelPageEnum.ContentStack.blocks.SpaPage), + spa_page: z + .object({ + button_cta: z.string(), + pageConnection: z.object({ + edges: z.array( + z.object({ + node: z.object({ + title: z.string(), + url: z.string(), + system: z.object({ + content_type_uid: z.string(), + locale: z.string(), + uid: z.string(), + }), + web: z.object({ original_url: z.string() }), + }), + }) + ), + }), + }) + .transform((data) => { + let url = "" + if (data.pageConnection.edges.length) { + const page = data.pageConnection.edges[0].node + if (page.web.original_url) { + url = page.web.original_url + } else { + url = removeMultipleSlashes(`/${page.system.locale}/${page.url}`) + } + } + return { + buttonCTA: data.button_cta, + url: url, + } + }), +}) + +export const spaPageRefSchema = z.object({ + spa_page: z + .object({ + pageConnection: z.object({ + edges: z.array( + z.object({ + node: z.discriminatedUnion("__typename", [ + pageLinks.contentPageRefSchema, + pageLinks.collectionPageRefSchema, + ]), + }) + ), + }), + }) + .transform((data) => { + return data.pageConnection.edges.flatMap(({ node }) => node.system) || [] + }), +}) diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index 86d615ebd..fc6a118ae 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -554,14 +554,20 @@ export const hotelQueryRouter = router({ (room) => room.roomType === selectedRoom?.roomType ) if (!selectedRoom) { + selectedRoomAvailabilityFailCounter.add(1, { + hotelId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + error_type: "not_found", + error: `Couldn't find selected room with input: ${roomTypeCode}`, + }) console.error("No matching room found") return null } - const rateDetails = validateAvailabilityData.data.rateDefinitions.find( - (rateDef) => rateDef.rateCode === rateCode - )?.generalTerms - const rateTypes = selectedRoom.products.find( (rate) => rate.productType.public?.rateCode === rateCode || @@ -569,20 +575,25 @@ export const hotelQueryRouter = router({ ) if (!rateTypes) { + selectedRoomAvailabilityFailCounter.add(1, { + hotelId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + error_type: "not_found", + error: `Couldn't find rateTypes for selected room: ${JSON.stringify(selectedRoom)}`, + }) console.error("No matching rate found") return null } const rates = rateTypes.productType - const mustBeGuaranteed = - validateAvailabilityData.data.rateDefinitions.filter( - (rate) => rate.rateCode === rateCode - )[0].mustBeGuaranteed - - const cancellationText = + const rateDefinition = validateAvailabilityData.data.rateDefinitions.find( (rate) => rate.rateCode === rateCode - )?.cancellationText ?? "" + ) const bedTypes = availableRoomsInCategory .map((availRoom) => { @@ -601,6 +612,13 @@ export const hotelQueryRouter = router({ description: matchingRoom.description, size: matchingRoom.mainBed.widthRange, value: matchingRoom.code, + type: matchingRoom.mainBed.type, + extraBed: matchingRoom.fixedExtraBed + ? { + type: matchingRoom.fixedExtraBed.type, + description: matchingRoom.fixedExtraBed.description, + } + : undefined, } } }) @@ -623,9 +641,10 @@ export const hotelQueryRouter = router({ return { selectedRoom, - rateDetails, - mustBeGuaranteed, - cancellationText, + rateDetails: rateDefinition?.generalTerms, + cancellationText: rateDefinition?.cancellationText ?? "", + mustBeGuaranteed: !!rateDefinition?.mustBeGuaranteed, + breakfastIncluded: !!rateDefinition?.breakfastIncluded, memberRate: rates?.member, publicRate: rates.public, bedTypes, diff --git a/server/routers/hotels/schemas/room.ts b/server/routers/hotels/schemas/room.ts index 1de76227d..caf6317a0 100644 --- a/server/routers/hotels/schemas/room.ts +++ b/server/routers/hotels/schemas/room.ts @@ -1,5 +1,7 @@ import { z } from "zod" +import { BedTypeEnum, ExtraBedTypeEnum } from "@/constants/booking" + import { imageSchema } from "./image" const roomContentSchema = z.object({ @@ -17,22 +19,40 @@ const roomTypesSchema = z.object({ description: z.string(), code: z.string(), roomCount: z.number(), - mainBed: z.object({ - type: z.string(), - description: z.string(), - widthRange: z.object({ - min: z.number(), - max: z.number(), + mainBed: z + .object({ + type: z.string(), + description: z.string(), + widthRange: z.object({ + min: z.number(), + max: z.number(), + }), + }) + .transform((data) => ({ + type: + data.type in BedTypeEnum + ? (data.type as BedTypeEnum) + : BedTypeEnum.Other, + description: data.description, + widthRange: data.widthRange, + })), + fixedExtraBed: z + .object({ + type: z.string(), + description: z.string().optional(), + widthRange: z.object({ + min: z.number(), + max: z.number(), + }), + }) + .transform((data) => { + return data.type in ExtraBedTypeEnum + ? { + type: data.type as ExtraBedTypeEnum, + description: data.description, + } + : undefined }), - }), - fixedExtraBed: z.object({ - type: z.string(), - description: z.string().optional(), - widthRange: z.object({ - min: z.number(), - max: z.number(), - }), - }), roomSize: z.object({ min: z.number(), max: z.number(), @@ -63,9 +83,8 @@ export const roomSchema = z roomTypes: z.array(roomTypesSchema), roomFacilities: z.array(roomFacilitiesSchema), occupancy: z.object({ - total: z.number().optional(), - adults: z.number().optional(), - children: z.number().optional(), + min: z.number(), + max: z.number(), }), roomSize: z.object({ min: z.number(), @@ -82,6 +101,16 @@ export const roomSchema = z images: data.attributes.content.images, name: data.attributes.name, occupancy: data.attributes.occupancy, + totalOccupancy: + data.attributes.occupancy.min === data.attributes.occupancy.max + ? { + max: data.attributes.occupancy.max, + range: `${data.attributes.occupancy.max}`, + } + : { + max: data.attributes.occupancy.max, + range: `${data.attributes.occupancy.min}-${data.attributes.occupancy.max}`, + }, roomSize: data.attributes.roomSize, roomTypes: data.attributes.roomTypes, sortOrder: data.attributes.sortOrder, diff --git a/stores/enter-details/useEnterDetailsStore.test.tsx b/stores/enter-details/useEnterDetailsStore.test.tsx new file mode 100644 index 000000000..8b57e270f --- /dev/null +++ b/stores/enter-details/useEnterDetailsStore.test.tsx @@ -0,0 +1,214 @@ +import { describe, expect, test } from "@jest/globals" +import { act, renderHook } from "@testing-library/react" +import { type PropsWithChildren } from "react" + +import { BedTypeEnum } from "@/constants/booking" +import { Lang } from "@/constants/languages" + +import EnterDetailsProvider from "@/providers/EnterDetailsProvider" + +import { detailsStorageName, useEnterDetailsStore } from "." + +import { BreakfastPackageEnum } from "@/types/enums/breakfast" +import { CurrencyEnum } from "@/types/enums/currency" +import { PackageTypeEnum } from "@/types/enums/packages" +import { StepEnum } from "@/types/enums/step" +import type { PersistedState } from "@/types/stores/enter-details" + +jest.mock("react", () => ({ + ...jest.requireActual("react"), + cache: jest.fn(), +})) + +jest.mock("@/server/utils", () => ({ + toLang: () => Lang.en, +})) + +jest.mock("@/lib/api", () => ({ + fetchRetry: jest.fn((fn) => fn), +})) + +const booking = { + hotel: "123", + fromDate: "2100-01-01", + toDate: "2100-01-02", + rooms: [ + { + adults: 1, + roomTypeCode: "SKS", + rateCode: "SAVEEU", + counterRateCode: "PLSA2BEU", + }, + ], +} + +const bedTypes = [ + { + type: BedTypeEnum.King, + description: "King-size bed", + value: "SKS", + size: { + min: 180, + max: 200, + }, + roomTypeCode: "SKS", + extraBed: undefined, + }, + { + type: BedTypeEnum.Queen, + description: "Queen-size bed", + value: "QZ", + size: { + min: 160, + max: 200, + }, + roomTypeCode: "QZ", + extraBed: undefined, + }, +] + +const guest = { + countryCode: "SE", + dateOfBirth: "", + email: "test@test.com", + firstName: "Tester", + lastName: "Testersson", + join: false, + membershipNo: "12345678901234", + phoneNumber: "+46700000000", + zipCode: "", +} + +const breakfastPackages = [ + { + code: BreakfastPackageEnum.REGULAR_BREAKFAST, + description: "Breakfast with reservation", + localPrice: { + currency: CurrencyEnum.SEK, + price: "99", + totalPrice: "99", + }, + requestedPrice: { + currency: CurrencyEnum.EUR, + price: "9", + totalPrice: "9", + }, + packageType: PackageTypeEnum.BreakfastAdult as const, + }, +] + +function Wrapper({ children }: PropsWithChildren) { + return ( + + {children} + + ) +} + +describe("Enter Details Store", () => { + beforeEach(() => { + window.sessionStorage.clear() + }) + + test("initialize with correct default values", () => { + const { result } = renderHook( + () => useEnterDetailsStore((state) => state), + { + wrapper: Wrapper, + } + ) + const state = result.current + + expect(state.currentStep).toBe(StepEnum.selectBed) + expect(state.booking).toEqual(booking) + expect(state.bedType).toEqual(undefined) + expect(state.breakfast).toEqual(undefined) + expect(Object.values(state.guest).every((value) => value === "")) + }) + + test("initialize with correct values from sessionStorage", async () => { + const storage: PersistedState = { + bedType: bedTypes[1], + breakfast: breakfastPackages[0], + booking, + guest, + } + + window.sessionStorage.setItem(detailsStorageName, JSON.stringify(storage)) + + const { result } = renderHook( + () => useEnterDetailsStore((state) => state), + { + wrapper: Wrapper, + } + ) + const state = result.current + + expect(state.bedType).toEqual(storage.bedType) + expect(state.guest).toEqual(storage.guest) + expect(state.booking).toEqual(storage.booking) + expect(state.breakfast).toEqual(storage.breakfast) + }) + + test("complete step and navigate to next step", async () => { + const { result } = renderHook( + () => useEnterDetailsStore((state) => state), + { + wrapper: Wrapper, + } + ) + + expect(result.current.currentStep).toEqual(StepEnum.selectBed) + + await act(async () => { + result.current.actions.updateBedType(bedTypes[0]) + }) + + expect(result.current.isValid[StepEnum.selectBed]).toEqual(true) + expect(result.current.currentStep).toEqual(StepEnum.breakfast) + expect(window.location.pathname.slice(1)).toBe(StepEnum.breakfast) + + await act(async () => { + result.current.actions.updateBreakfast(breakfastPackages[0]) + }) + + expect(result.current.isValid[StepEnum.breakfast]).toEqual(true) + expect(result.current.currentStep).toEqual(StepEnum.details) + expect(window.location.pathname.slice(1)).toBe(StepEnum.details) + + await act(async () => { + result.current.actions.updateDetails(guest) + }) + + expect(result.current.isValid[StepEnum.details]).toEqual(true) + expect(result.current.currentStep).toEqual(StepEnum.payment) + expect(window.location.pathname.slice(1)).toBe(StepEnum.payment) + }) +}) diff --git a/types/components/blocks/surprises.ts b/types/components/blocks/surprises.ts index 281a8134c..9e3b8d52c 100644 --- a/types/components/blocks/surprises.ts +++ b/types/components/blocks/surprises.ts @@ -1,6 +1,6 @@ import type { Reward } from "@/server/routers/contentstack/reward/output" -export interface Surprise extends Reward { +export interface Surprise extends Omit { coupons: { couponCode?: string; expiresAt?: string }[] } diff --git a/types/components/bookingWidget/guestsRoomsPicker.ts b/types/components/bookingWidget/guestsRoomsPicker.ts index a075c9f8d..b672df7ea 100644 --- a/types/components/bookingWidget/guestsRoomsPicker.ts +++ b/types/components/bookingWidget/guestsRoomsPicker.ts @@ -8,7 +8,7 @@ export type Child = { bed: number } -export type GuestsRoom = { +export type TGuestsRoom = { adults: number child: Child[] } diff --git a/types/components/bookingWidget/index.ts b/types/components/bookingWidget/index.ts index 7e9c79d16..e8eda85c8 100644 --- a/types/components/bookingWidget/index.ts +++ b/types/components/bookingWidget/index.ts @@ -4,7 +4,7 @@ import type { z } from "zod" import type { Locations } from "@/types/trpc/routers/hotel/locations" import type { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema" import type { bookingWidgetVariants } from "@/components/Forms/BookingWidget/variants" -import type { GuestsRoom } from "./guestsRoomsPicker" +import type { TGuestsRoom } from "./guestsRoomsPicker" export type BookingWidgetSchema = z.output @@ -13,7 +13,7 @@ export type BookingWidgetSearchParams = { hotel?: string fromDate?: string toDate?: string - room?: GuestsRoom[] + room?: TGuestsRoom[] } export type BookingWidgetType = VariantProps< diff --git a/types/components/footer/socialLink.ts b/types/components/footer/socialLink.ts new file mode 100644 index 000000000..5b41e87f2 --- /dev/null +++ b/types/components/footer/socialLink.ts @@ -0,0 +1,6 @@ +export interface SocialLinkProps { + link: { + href: string + title: string + } +} diff --git a/types/components/hotelPage/facilities.ts b/types/components/hotelPage/facilities.ts index 5c753797a..13a1047bd 100644 --- a/types/components/hotelPage/facilities.ts +++ b/types/components/hotelPage/facilities.ts @@ -1,10 +1,12 @@ -import type { Facility } from "@/types/hotel" +import type { Amenities, Facility, HealthFacilities } from "@/types/hotel" import type { ActivityCard } from "@/types/trpc/routers/contentstack/hotelPage" import type { CardProps } from "@/components/TempDesignSystem/Card/card" export type FacilitiesProps = { facilities: Facility[] - activitiesCard: ActivityCard | null + activitiesCard?: ActivityCard + amenities: Amenities + healthFacilities: HealthFacilities } export type FacilityImage = { @@ -22,7 +24,7 @@ export type FacilityCard = { scrollOnClick: boolean } heading: string - scriptedTopTitle: string + scriptedTopTitle?: string theme: CardProps["theme"] id: string } @@ -47,3 +49,19 @@ export enum RestaurantHeadings { restaurant = "Restaurant", breakfastRestaurant = "Breakfast restaurant", } + +export enum WellnessHeadings { + GymPool = "Gym & Pool", + GymSauna = "Gym & Sauna", + GymPoolSaunaRelax = "Gym, Pool, Sauna & Relax", + GymJacuzziSaunaRelax = "Gym, Jacuzzi, Sauna & Relax", +} + +export enum HealthFacilitiesEnum { + Jacuzzi = "Jacuzzi", + Gym = "Gym", + Sauna = "Sauna", + Relax = "Relax", + IndoorPool = "IndoorPool", + OutdoorPool = "OutdoorPool", +} diff --git a/types/components/hotelPage/sidepeek/wellnessAndExercise.ts b/types/components/hotelPage/sidepeek/wellnessAndExercise.ts index 828f3ee8b..4e70b9210 100644 --- a/types/components/hotelPage/sidepeek/wellnessAndExercise.ts +++ b/types/components/hotelPage/sidepeek/wellnessAndExercise.ts @@ -2,5 +2,9 @@ import type { Hotel } from "@/types/hotel" export type WellnessAndExerciseSidePeekProps = { healthFacilities: Hotel["healthFacilities"] - buttonUrl?: string + wellnessExerciseButton?: string + spaPage?: { + buttonCTA: string + url: string + } } diff --git a/types/components/hotelReservation/enterDetails/bedType.ts b/types/components/hotelReservation/enterDetails/bedType.ts index 9f1fae723..0d4e0e11f 100644 --- a/types/components/hotelReservation/enterDetails/bedType.ts +++ b/types/components/hotelReservation/enterDetails/bedType.ts @@ -4,6 +4,7 @@ import type { bedTypeFormSchema, bedTypeSchema, } from "@/components/HotelReservation/EnterDetails/BedType/schema" +import type { BedTypeEnum, ExtraBedTypeEnum } from "@/constants/booking" export type BedTypeSelection = { description: string @@ -12,6 +13,13 @@ export type BedTypeSelection = { max: number } value: string + type: BedTypeEnum + extraBed: + | { + description: string + type: ExtraBedTypeEnum + } + | undefined } export type BedTypeProps = { bedTypes: BedTypeSelection[] diff --git a/types/components/hotelReservation/selectRate/roomSelection.ts b/types/components/hotelReservation/selectRate/roomSelection.ts index 3993176f9..7a0faf0af 100644 --- a/types/components/hotelReservation/selectRate/roomSelection.ts +++ b/types/components/hotelReservation/selectRate/roomSelection.ts @@ -7,18 +7,18 @@ import type { Rate, RateCode } from "./selectRate" export interface RoomSelectionProps { roomsAvailability: RoomsAvailability roomCategories: RoomData[] - user: SafeUser availablePackages: RoomPackageData | undefined selectedPackages: RoomPackageCodes[] setRateCode: React.Dispatch> rateSummary: Rate | null hotelType: string | undefined + isUserLoggedIn: boolean } export interface SelectRateProps { roomsAvailability: RoomsAvailability roomCategories: RoomData[] - user: SafeUser availablePackages: RoomPackageData hotelType: string | undefined + isUserLoggedIn: boolean } diff --git a/types/components/hotelReservation/summary.ts b/types/components/hotelReservation/summary.ts index e705f3751..50fd2ed66 100644 --- a/types/components/hotelReservation/summary.ts +++ b/types/components/hotelReservation/summary.ts @@ -15,4 +15,5 @@ export interface SummaryProps extends Pick, Pick { isMember: boolean + breakfastIncluded: boolean } diff --git a/types/components/myPages/myPage/accountPage.ts b/types/components/myPages/myPage/accountPage.ts index e5133c758..d545df0ce 100644 --- a/types/components/myPages/myPage/accountPage.ts +++ b/types/components/myPages/myPage/accountPage.ts @@ -1,3 +1,4 @@ +import type { Dispatch, ReactNode, SetStateAction } from "react" import type { z } from "zod" import type { DynamicContent } from "@/types/trpc/routers/contentstack/blocks" @@ -37,3 +38,8 @@ export interface RedeemProps { export type RedeemModalState = "unmounted" | "hidden" | "visible" export type RedeemStep = "initial" | "confirmation" | "redeemed" + +export type RedeemFlowContext = { + redeemStep: RedeemStep + setRedeemStep: Dispatch> +} diff --git a/types/enums/hotelPage.ts b/types/enums/hotelPage.ts index bfae4bfbe..8cc5f8839 100644 --- a/types/enums/hotelPage.ts +++ b/types/enums/hotelPage.ts @@ -3,6 +3,7 @@ export namespace HotelPageEnum { export const enum blocks { Faq = "HotelPageFaq", ActivitiesCard = "HotelPageContentUpcomingActivitiesCard", + SpaPage = "HotelPageContentSpaPage", } } } diff --git a/types/hotel.ts b/types/hotel.ts index 0ccdaecf7..2435d8617 100644 --- a/types/hotel.ts +++ b/types/hotel.ts @@ -21,6 +21,8 @@ export type Hotel = HotelData["data"]["attributes"] export type HotelAddress = HotelData["data"]["attributes"]["address"] export type HotelLocation = HotelData["data"]["attributes"]["location"] export type Amenities = HotelData["data"]["attributes"]["detailedFacilities"] +export type HealthFacilities = + HotelData["data"]["attributes"]["healthFacilities"] type HotelRatings = HotelData["data"]["attributes"]["ratings"] export type HotelTripAdvisor = diff --git a/types/providers/enter-details.ts b/types/providers/enter-details.ts index 0862abb4d..0659d77ed 100644 --- a/types/providers/enter-details.ts +++ b/types/providers/enter-details.ts @@ -9,7 +9,7 @@ import type { Packages } from "../requests/packages" export interface DetailsProviderProps extends React.PropsWithChildren { booking: BookingData bedTypes: BedTypeSelection[] - breakfastPackages: BreakfastPackage[] | null + showBreakfastStep: boolean packages: Packages | null roomRate: Pick searchParamsStr: string diff --git a/types/trpc/routers/contentstack/hotelPage.ts b/types/trpc/routers/contentstack/hotelPage.ts index 6b98f5ca2..7a983d419 100644 --- a/types/trpc/routers/contentstack/hotelPage.ts +++ b/types/trpc/routers/contentstack/hotelPage.ts @@ -7,13 +7,16 @@ import type { hotelPageUrlSchema, } from "@/server/routers/contentstack/hotelPage/output" import type { activitiesCardSchema } from "@/server/routers/contentstack/schemas/blocks/activitiesCard" +import type { spaPageSchema } from "@/server/routers/contentstack/schemas/blocks/spaPage" export interface GetHotelPageData extends z.input {} export interface HotelPage extends z.output {} export interface ActivitiesCard extends z.output {} export type ActivityCard = ActivitiesCard["upcoming_activities_card"] -export interface ContentBlock extends z.output {} + +export interface SpaPage extends z.output {} +export type ContentBlock = z.output export interface GetHotelPageRefsSchema extends z.input {} diff --git a/utils/facilityCards.ts b/utils/facilityCards.ts index f5e1ffc6a..8dd934c23 100644 --- a/utils/facilityCards.ts +++ b/utils/facilityCards.ts @@ -13,10 +13,12 @@ import { FacilityCardTypeEnum, type FacilityGrid, type FacilityImage, + HealthFacilitiesEnum, RestaurantHeadings, + WellnessHeadings, } from "@/types/components/hotelPage/facilities" import { FacilityEnum } from "@/types/enums/facilities" -import type { Amenities, Facility } from "@/types/hotel" +import type { Amenities, Facility, HealthFacilities } from "@/types/hotel" import type { CardProps } from "@/components/TempDesignSystem/Card/card" export function isFacilityCard(card: FacilityCardType): card is FacilityCard { @@ -32,7 +34,7 @@ function setCardProps( heading: string, buttonText: string, href: string, - scriptedTopTitle: string + scriptedTopTitle?: string ): FacilityCard { return { theme, @@ -48,7 +50,11 @@ function setCardProps( } } -export function setFacilityCardGrids(facilities: Facility[]): Facilities { +export function setFacilityCardGrids( + facilities: Facility[], + amenities: Amenities, + healthFacilities: HealthFacilities +): Facilities { const lang = getLang() const cards: Facilities = facilities.map((facility) => { @@ -74,12 +80,13 @@ export function setFacilityCardGrids(facilities: Facility[]): Facilities { switch (facility.id) { case FacilityCardTypeEnum.wellness: + const wellnessTitle = getWellnessHeading(healthFacilities) card = setCardProps( "one", - "Sauna and gym", + facility.headingText, "Read more about wellness & exercise", wellnessAndExercise[lang], - facility.headingText + wellnessTitle ) grid.unshift(card) break @@ -87,22 +94,22 @@ export function setFacilityCardGrids(facilities: Facility[]): Facilities { case FacilityCardTypeEnum.conference: card = setCardProps( "primaryDim", - "Events that make an impression", + facility.headingText, "About meetings & conferences", meetingsAndConferences[lang], - facility.headingText + "Events that make an impression" ) grid.push(card) break case FacilityCardTypeEnum.restaurant: - //const title = getRestaurantHeading(amenities) // TODO will be used later + const restaurantTitle = getRestaurantHeading(amenities) card = setCardProps( "primaryDark", - "Enjoy relaxed restaurant experiences", + facility.headingText, "Read more & book a table", restaurantAndBar[lang], - facility.headingText + restaurantTitle ) grid.unshift(card) break @@ -133,6 +140,39 @@ export function getRestaurantHeading(amenities: Amenities): RestaurantHeadings { return RestaurantHeadings.breakfastRestaurant } +export function getWellnessHeading( + healthFacilities: HealthFacilities +): WellnessHeadings | undefined { + const hasGym = healthFacilities.some( + (facility) => facility.type === HealthFacilitiesEnum.Gym + ) + const hasSauna = healthFacilities.some( + (faility) => faility.type === HealthFacilitiesEnum.Sauna + ) + const hasRelax = healthFacilities.some( + (facility) => facility.type === HealthFacilitiesEnum.Relax + ) + const hasJacuzzi = healthFacilities.some( + (facility) => facility.type === HealthFacilitiesEnum.Jacuzzi + ) + const hasPool = healthFacilities.some( + (facility) => + facility.type === HealthFacilitiesEnum.IndoorPool || + facility.type === HealthFacilitiesEnum.OutdoorPool + ) + + if (hasGym && hasJacuzzi && hasSauna && hasRelax) { + return WellnessHeadings.GymJacuzziSaunaRelax + } else if (hasGym && hasPool && hasSauna && hasRelax) { + return WellnessHeadings.GymPoolSaunaRelax + } else if (hasGym && hasSauna) { + return WellnessHeadings.GymSauna + } else if (hasGym && hasPool) { + return WellnessHeadings.GymPool + } + return undefined +} + export function filterFacilityCards(cards: FacilityGrid) { const card = cards.filter((card) => isFacilityCard(card)) const images = cards.filter((card) => isFacilityImage(card)) diff --git a/utils/rewards.ts b/utils/rewards.ts index 272ce3954..77e57d8e9 100644 --- a/utils/rewards.ts +++ b/utils/rewards.ts @@ -4,7 +4,7 @@ import type { RestaurantRewardId, RewardId, } from "@/types/components/myPages/rewards" -import type { Reward } from "@/server/routers/contentstack/reward/output" +import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" export function isValidRewardId(id: string): id is RewardId { return Object.values(REWARD_IDS).includes(id) @@ -17,22 +17,26 @@ export function isRestaurantReward( } export function redeemLocationIsOnSite( - location: Reward["redeemLocation"] + location: RewardWithRedeem["redeemLocation"] ): location is "On-site" { return location === "On-site" } -export function isTierType(type: Reward["rewardType"]): type is "Tier" { +export function isTierType( + type: RewardWithRedeem["rewardType"] +): type is "Tier" { return type === "Tier" } -export function isOnSiteTierReward(reward: Reward): boolean { +export function isOnSiteTierReward(reward: RewardWithRedeem): boolean { return ( redeemLocationIsOnSite(reward.redeemLocation) && isTierType(reward.rewardType) ) } -export function isRestaurantOnSiteTierReward(reward: Reward): boolean { +export function isRestaurantOnSiteTierReward( + reward: RewardWithRedeem +): boolean { return isOnSiteTierReward(reward) && isRestaurantReward(reward.reward_id) } diff --git a/utils/tracking.ts b/utils/tracking.ts index c646ea9c4..4fd838002 100644 --- a/utils/tracking.ts +++ b/utils/tracking.ts @@ -20,7 +20,7 @@ export function trackPageViewStart() { } export function trackLoginClick(position: TrackingPosition) { - const loginEvent = { + const event = { event: "loginStart", login: { position, @@ -28,7 +28,27 @@ export function trackLoginClick(position: TrackingPosition) { ctaName: "login", }, } - pushToDataLayer(loginEvent) + pushToDataLayer(event) +} + +export function trackSocialMediaClick(socialMediaName: string) { + const event = { + event: "social media", + social: { + socialIconClicked: socialMediaName, + }, + } + pushToDataLayer(event) +} + +export function trackFooterClick(group: string, name: string) { + const event = { + event: "footer link", + footer: { + footerLinkClicked: `${group}:${name}`, + }, + } + pushToDataLayer(event) } export function trackUpdatePaymentMethod(hotelId: string, method: string) { @@ -80,7 +100,7 @@ export function createSDKPageObject( return { ...trackingData, - domain: window.location.host, + domain: typeof window !== "undefined" ? window.location.host : "", pageName: pageName, siteSections: siteSections, }