Merge remote-tracking branch 'origin' into feat/tracking-payment

This commit is contained in:
Linus Flood
2025-01-10 09:13:04 +01:00
111 changed files with 2371 additions and 875 deletions

View File

@@ -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;
}
}

View File

@@ -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 (
<EnterDetailsProvider
bedTypes={roomAvailability.bedTypes}
booking={booking}
breakfastPackages={breakfastPackages}
showBreakfastStep={showBreakfastStep}
packages={packages}
roomRate={{
memberRate: roomAvailability.memberRate,
@@ -230,13 +235,13 @@ export default async function StepPage({
</SectionAccordion>
) : null}
{breakfastPackages?.length ? (
{showBreakfastStep ? (
<SectionAccordion
header={intl.formatMessage({ id: "Food options" })}
label={intl.formatMessage({ id: "Select breakfast options" })}
step={StepEnum.breakfast}
>
<Breakfast packages={breakfastPackages} />
<Breakfast packages={breakfastPackages!} />
</SectionAccordion>
) : null}

View File

@@ -11,7 +11,9 @@ export default async function BookingWidgetPage({
params,
searchParams,
}: PageArgs<ContentTypeParams, URLSearchParams>) {
if (!env.ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH) return null
if (!env.ENABLE_BOOKING_WIDGET) {
return null
}
preload()

View File

@@ -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"

View File

@@ -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"

View File

@@ -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<RedeemModalState>("unmounted")
const intl = useIntl()
const update = trpc.contentstack.rewards.redeem.useMutation()
const [redeemStep, setRedeemStep] = useState<RedeemStep>("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 (
<DialogTrigger
onOpenChange={(isOpen) => setAnimation(isOpen ? "visible" : "hidden")}
>
<Button intent="primary" fullWidth>
{intl.formatMessage({ id: "Open" })}
</Button>
<MotionOverlay
className={styles.overlay}
isExiting={animation === "hidden"}
onAnimationComplete={modalStateHandler}
variants={variants.fade}
initial="hidden"
animate={animation}
>
<MotionModal
className={styles.modal}
variants={variants.slideInOut}
initial="hidden"
animate={animation}
>
<Dialog className={styles.dialog} aria-label={reward.label}>
{({ close }) => (
<>
<header className={styles.modalHeader}>
<button
onClick={close}
type="button"
className={styles.modalClose}
>
<CloseLargeIcon />
</button>
</header>
<div className={styles.modalContent}>
{redeemStep === "redeemed" && (
<ConfirmationBadge reward={reward} />
)}
<RewardIcon rewardId={reward.reward_id} />
<Title level="h3" textAlign="center" textTransform="regular">
{reward.label}
</Title>
{redeemStep === "initial" && (
<Body textAlign="center">{reward.description}</Body>
)}
{redeemStep === "confirmation" &&
"redeem_description" in reward && (
<Body textAlign="center">
{reward.redeem_description}
</Body>
)}
{redeemStep === "redeemed" &&
isRestaurantOnSiteTierReward(reward) &&
membershipNumber && (
<MembershipNumberBadge
membershipNumber={membershipNumber}
/>
)}
</div>
{redeemStep === "initial" && (
<footer className={styles.modalFooter}>
<Button
onClick={() => setRedeemStep("confirmation")}
intent="primary"
theme="base"
>
{intl.formatMessage({ id: "Redeem benefit" })}
</Button>
</footer>
)}
{redeemStep === "confirmation" && (
<footer className={styles.modalFooter}>
<Button
onClick={onProceed}
disabled={update.isPending}
intent="primary"
theme="base"
>
{intl.formatMessage({ id: "Yes, redeem" })}
</Button>
<Button onClick={close} intent="secondary" theme="base">
{intl.formatMessage({ id: "Go back" })}
</Button>
</footer>
)}
</>
)}
</Dialog>
</MotionModal>
</MotionOverlay>
</DialogTrigger>
)
}
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 (
<div className={styles.badge}>
{isRestaurantOnSiteTierReward(reward) ? (
<ActiveRedeemedBadge />
) : (
<TimedRedeemedBadge />
)}
</div>
)
}
function ActiveRedeemedBadge() {
const intl = useIntl()
return (
<div className={styles.redeemed}>
<motion.div
animate={{
opacity: [1, 0.4, 1],
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut",
}}
>
<CheckCircleIcon color="uiSemanticSuccess" />
</motion.div>
<Caption>{intl.formatMessage({ id: "Active" })}</Caption>
</div>
)
}
function TimedRedeemedBadge() {
const intl = useIntl()
return (
<>
<div className={styles.redeemed}>
<CheckCircleIcon color="uiSemanticSuccess" />
<Caption>
{intl.formatMessage({
id: "Redeemed & valid through:",
})}
</Caption>
</div>
<Countdown />
</>
)
}
function MembershipNumberBadge({
membershipNumber,
}: {
membershipNumber: string
}) {
const intl = useIntl()
return (
<div className={styles.membershipNumberBadge}>
<Caption
textTransform="uppercase"
textAlign="center"
color="uiTextHighContrast"
>
{intl.formatMessage({ id: "Membership ID:" })} {membershipNumber}
</Caption>
</div>
)
}

View File

@@ -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);
}

View File

@@ -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 (
<div className={styles.redeemed}>
<motion.div
animate={{
opacity: [1, 0.4, 1],
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut",
}}
>
<CheckCircleIcon color="uiSemanticSuccess" />
</motion.div>
<Caption>{intl.formatMessage({ id: "Active" })}</Caption>
</div>
)
}

View File

@@ -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 (
<>
<div className={styles.modalContent}>
<RewardIcon rewardId={reward.reward_id} />
<Title level="h3" textAlign="center" textTransform="regular">
{reward.label}
</Title>
<Body textAlign="center">{reward.description}</Body>
<div className={styles.rewardBadge}>
<Caption textAlign="center" color="uiTextHighContrast" type="bold">
{intl.formatMessage({ id: "Promo code" })}
</Caption>
<Caption textAlign="center" color="uiTextHighContrast">
{reward.operaRewardId}
</Caption>
</div>
</div>
<footer className={styles.modalFooter}>
<Button
onClick={handleCopy}
type="button"
variant="icon"
size="small"
theme="base"
intent="primary"
>
<CopyIcon color="pale" />
{intl.formatMessage({ id: "Copy promotion code" })}
</Button>
</footer>
</>
)
}

View File

@@ -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 (
<>
<div className={styles.modalContent}>
{redeemStep === "redeemed" && (
<div className={styles.badge}>
{isRestaurantOnSiteTierReward(reward) ? (
<ActiveRedeemedBadge />
) : (
<TimedRedeemedBadge />
)}
</div>
)}
<RewardIcon rewardId={reward.reward_id} />
<Title level="h3" textAlign="center" textTransform="regular">
{reward.label}
</Title>
{redeemStep === "initial" && (
<Body textAlign="center">{reward.description}</Body>
)}
{redeemStep === "confirmation" && (
<Body textAlign="center">{reward.redeem_description}</Body>
)}
{redeemStep === "redeemed" &&
isRestaurantOnSiteTierReward(reward) &&
membershipNumber && (
<MembershipNumberBadge membershipNumber={membershipNumber} />
)}
</div>
{redeemStep === "initial" && (
<footer className={styles.modalFooter}>
<Button
onClick={() => setRedeemStep("confirmation")}
intent="primary"
theme="base"
>
{intl.formatMessage({ id: "Redeem benefit" })}
</Button>
</footer>
)}
{redeemStep === "confirmation" && (
<footer className={styles.modalFooter}>
<Button
onClick={onRedeem}
disabled={isRedeeming}
intent="primary"
theme="base"
>
{intl.formatMessage({ id: "Yes, redeem" })}
</Button>
<Button onClick={close} intent="secondary" theme="base">
{intl.formatMessage({ id: "Go back" })}
</Button>
</footer>
)}
</>
)
}

View File

@@ -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 (
<div className={styles.rewardBadge}>
<Caption textAlign="center" color="uiTextHighContrast">
{intl.formatMessage({ id: "Membership ID" })}: {membershipNumber}
</Caption>
</div>
)
}

View File

@@ -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 (
<>
<div className={styles.redeemed}>
<CheckCircleIcon color="uiSemanticSuccess" />
<Caption>
{intl.formatMessage({
id: "Redeemed & valid through:",
})}
</Caption>
</div>
<Countdown />
</>
)
}

View File

@@ -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<RedeemModalState>("unmounted")
const intl = useIntl()
const [redeemStep, setRedeemStep] = useState<RedeemStep>("initial")
function modalStateHandler(newAnimationState: RedeemModalState) {
setAnimation((currentAnimationState) =>
newAnimationState === "hidden" && currentAnimationState === "hidden"
? "unmounted"
: currentAnimationState
)
if (newAnimationState === "unmounted") {
setRedeemStep("initial")
}
}
return (
<RedeemContext.Provider value={{ redeemStep, setRedeemStep }}>
<DialogTrigger
onOpenChange={(isOpen) => setAnimation(isOpen ? "visible" : "hidden")}
>
<Button intent="primary" fullWidth>
{intl.formatMessage({ id: "Open" })}
</Button>
<MotionOverlay
className={styles.overlay}
isExiting={animation === "hidden"}
onAnimationComplete={modalStateHandler}
variants={variants.fade}
initial="hidden"
animate={animation}
>
<MotionModal
className={styles.modal}
variants={variants.slideInOut}
initial="hidden"
animate={animation}
>
<Dialog className={styles.dialog} aria-label={reward.label}>
{({ close }) => (
<>
<header className={styles.modalHeader}>
<button
onClick={close}
type="button"
className={styles.modalClose}
>
<CloseLargeIcon />
</button>
</header>
{getRedeemFlow(reward, membershipNumber || "")}
</>
)}
</Dialog>
</MotionModal>
</MotionOverlay>
</DialogTrigger>
</RedeemContext.Provider>
)
}
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 <Campaign reward={reward} />
case "Surprise":
case "Tier":
return <Tier reward={reward} membershipNumber={membershipNumber} />
default:
console.warn("Unsupported reward type for redeem:", reward.rewardType)
return null
}
}

View File

@@ -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);
}

View File

@@ -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<RedeemFlowContext>({
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,
}
}

View File

@@ -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) => {

View File

@@ -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) {
<Body color="grey">
{intl.formatMessage(
{ id: "hotelPages.rooms.roomCard.persons" },
{ size, totalOccupancy: occupancy.total }
{
size,
max: totalOccupancy.max,
range: totalOccupancy.range,
}
)}
</Body>
</div>

View File

@@ -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 }
)}
</Body>
<div className={styles.imageContainer}>

View File

@@ -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({
<Facility key={facility.type} data={facility} />
))}
</div>
{buttonUrl && (
{(spaPage || wellnessExerciseButton) && (
<div className={styles.buttonContainer}>
<Button fullWidth theme="base" intent="secondary" asChild>
<Link href={buttonUrl} weight="bold" color="burgundy">
{intl.formatMessage({ id: "Show wellness & exercise" })}
</Link>
</Button>
{spaPage && (
<Button fullWidth theme="base" intent="tertiary" asChild>
<Link weight="bold" href={spaPage.url}>
{spaPage.buttonCTA}
</Link>
</Button>
)}
{wellnessExerciseButton && (
<Button fullWidth theme="base" intent="secondary" asChild>
<Link
href={wellnessExerciseButton}
weight="bold"
color="burgundy"
>
{intl.formatMessage({ id: "Show wellness & exercise" })}
</Link>
</Button>
)}
</div>
)}
</SidePeek>

View File

@@ -15,4 +15,6 @@
position: absolute;
left: 0;
bottom: 0;
display: grid;
gap: var(--Spacing-x2);
}

View File

@@ -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}
</div>
<Rooms rooms={roomCategories} />
<Facilities facilities={facilities} activitiesCard={activitiesCard} />
<Facilities
facilities={facilities}
activitiesCard={activitiesCard?.upcoming_activities_card}
amenities={detailedFacilities}
healthFacilities={healthFacilities}
/>
{faq.accordions.length > 0 && (
<AccordionSection accordion={faq.accordions} title={faq.title} />
)}
@@ -200,10 +209,15 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
ecoLabels={hotelFacts.ecoLabels}
descriptions={hotelContent.texts}
/>
<WellnessAndExerciseSidePeek healthFacilities={healthFacilities} />
<WellnessAndExerciseSidePeek
healthFacilities={healthFacilities}
spaPage={spaPage?.spa_page}
/>
<RestaurantBarSidePeek restaurants={restaurants} />
{activitiesCard && (
<ActivitiesSidePeek contentPage={activitiesCard.contentPage} />
<ActivitiesSidePeek
contentPage={activitiesCard.upcoming_activities_card.contentPage}
/>
)}
<MeetingsAndConferencesSidePeek
meetingFacilities={conferencesAndMeetings}

View File

@@ -129,7 +129,7 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
onClick={() => setIsOpen(true)}
type="button"
>
<Body className={styles.body} asChild>
<Body className={styles.body} asChild color="uiTextHighContrast">
<span>
{selectedFromDate} - {selectedToDate}
</span>

View File

@@ -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 ? <SocialIcon color="white" /> : <span>{iconName}</span>
}
export default function SocialLink({ link }: SocialLinkProps) {
const { href, title } = link
return (
<a
color="white"
href={href}
key={title}
target="_blank"
aria-label={title}
onClick={() => trackSocialMediaClick(title)}
>
<SocialIcon iconName={title} />
</a>
)
}

View File

@@ -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 ? <SocialIcon color="white" /> : <span>{iconName}</span>
}
export default async function FooterDetails() {
const lang = getLang()
const intl = await getIntl()
@@ -40,18 +33,7 @@ export default async function FooterDetails() {
</Link>
<nav className={styles.socialNav}>
{footer?.socialMedia.links.map(
(link) =>
link.href && (
<a
color="white"
href={link.href.href}
key={link.href.title}
target="_blank"
aria-label={link.href.title}
>
<SocialIcon iconName={link.href.title} />
</a>
)
({ href }) => href && <SocialLink link={href} key={href.title} />
)}
</nav>
</div>

View File

@@ -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}
<ArrowRightIcon color="peach80" />
</Link>
</Subtitle>

View File

@@ -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 (
<div className={styles.secondaryNavigation}>
@@ -28,18 +31,19 @@ export default function FooterSecondaryNav({
</Caption>
<ul className={styles.secondaryNavigationList}>
{appDownloads.links.map(
(link) =>
link.href && (
<li key={link.type} className={styles.appDownloadItem}>
({ href, type }) =>
href && (
<li key={type} className={styles.appDownloadItem}>
<a
href={link.href.href}
href={href.href}
target="_blank"
aria-label={link.href.title}
aria-label={href.title}
onClick={() => trackSocialMediaClick(href.title)}
>
<Image
src={
AppDownLoadLinks[
`${link.type}_${lang}` as keyof typeof AppDownLoadLinks
`${type}_${lang}` as keyof typeof AppDownLoadLinks
]
}
alt=""
@@ -53,22 +57,23 @@ export default function FooterSecondaryNav({
</ul>
</nav>
)}
{secondaryLinks.map((link) => (
<nav className={styles.secondaryNavigationGroup} key={link.title}>
{secondaryLinks.map((group) => (
<nav className={styles.secondaryNavigationGroup} key={group.title}>
<Caption
color="textMediumContrast"
textTransform="uppercase"
type="label"
>
{link.title}
{group.title}
</Caption>
<ul className={styles.secondaryNavigationList}>
{link?.links?.map((link) => (
{group?.links?.map((link) => (
<li key={link.title} className={styles.secondaryNavigationItem}>
<Link
href={link.url}
target={link.openInNewTab ? "_blank" : undefined}
color="burgundy"
onClick={() => trackFooterClick(group.title, link.title)}
>
{link.title}
</Link>

View File

@@ -10,7 +10,7 @@ const Input = forwardRef<
InputHTMLAttributes<HTMLInputElement>
>(function InputComponent(props, ref) {
return (
<Body asChild>
<Body asChild color="uiTextHighContrast">
<InputRAC {...props} ref={ref} className={styles.input} />
</Body>
)

View File

@@ -1,61 +1,77 @@
"use client"
import { useEffect } from "react"
import { useCallback, useEffect } from "react"
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { env } from "@/env/client"
import { CloseLargeIcon, PlusCircleIcon, PlusIcon } from "../Icons"
import Button from "../TempDesignSystem/Button"
import Divider from "../TempDesignSystem/Divider"
import Subtitle from "../TempDesignSystem/Text/Subtitle"
import { Tooltip } from "../TempDesignSystem/Tooltip"
import AdultSelector from "./AdultSelector"
import ChildSelector from "./ChildSelector"
import { GuestsRoom } from "./GuestsRoom"
import styles from "./guests-rooms-picker.module.css"
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
import type { TGuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
const MAX_ROOMS = 4
interface GuestsRoomsPickerDialogProps {
rooms: TGuestsRoom[]
onClose: () => void
isOverflowed?: boolean // ToDo Remove once Tooltip below is no longer required
}
export default function GuestsRoomsPickerDialog({
rooms,
onClose,
isOverflowed = false,
}: {
rooms: GuestsRoom[]
onClose: () => void
isOverflowed?: boolean // ToDo Remove once Tooltip below is no longer required
}) {
}: GuestsRoomsPickerDialogProps) {
const intl = useIntl()
const { getFieldState, trigger, setValue } =
useFormContext<BookingWidgetSchema>()
const roomsValue = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" })
const addRoomLabel = intl.formatMessage({ id: "Add Room" })
const doneLabel = intl.formatMessage({ id: "Done" })
const roomLabel = intl.formatMessage({ id: "Room" })
const disabledBookingOptionsHeader = intl.formatMessage({
id: "Disabled booking options header",
})
const disabledBookingOptionsText = intl.formatMessage({
id: "Disabled adding room",
})
const addRoomLabel = intl.formatMessage({ id: "Add Room" })
const { getFieldState, trigger } = useFormContext<BookingWidgetSchema>()
const handleClose = useCallback(async () => {
const isValid = await trigger("rooms")
if (isValid) onClose()
}, [trigger, onClose])
const roomsValue = useWatch({ name: "rooms" })
const handleAddRoom = useCallback(() => {
setValue("rooms", [...roomsValue, { adults: 1, child: [] }], {
shouldValidate: true,
})
}, [roomsValue, setValue])
async function handleOnClose() {
const state = await trigger("rooms")
if (state) {
onClose()
}
}
const fieldState = getFieldState("rooms")
const handleRemoveRoom = useCallback(
(index: number) => {
setValue(
"rooms",
roomsValue.filter((_, i) => i !== index),
{ shouldValidate: true }
)
},
[roomsValue, setValue]
)
// Validate rooms when they change
useEffect(() => {
if (fieldState.invalid) {
trigger("rooms")
}
}, [roomsValue, fieldState.invalid, trigger])
const fieldState = getFieldState("rooms")
if (fieldState.invalid) trigger("rooms")
}, [roomsValue, getFieldState, trigger])
const isInvalid = getFieldState("rooms").invalid
const canAddRooms = rooms.length < MAX_ROOMS
return (
<>
@@ -65,97 +81,99 @@ export default function GuestsRoomsPickerDialog({
<CloseLargeIcon />
</button>
</header>
<div className={styles.contentContainer}>
{rooms.map((room, index) => {
const currentAdults = room.adults
const currentChildren = room.child
const childrenInAdultsBed =
currentChildren.filter(
(child) => child.bed === ChildBedMapEnum.IN_ADULTS_BED
).length ?? 0
return (
<div className={styles.roomContainer} key={index}>
<section className={styles.roomDetailsContainer}>
<Subtitle type="two" className={styles.roomHeading}>
{roomLabel} {index + 1}
</Subtitle>
<AdultSelector
roomIndex={index}
currentAdults={currentAdults}
currentChildren={currentChildren}
childrenInAdultsBed={childrenInAdultsBed}
/>
<ChildSelector
roomIndex={index}
currentAdults={currentAdults}
currentChildren={currentChildren}
childrenInAdultsBed={childrenInAdultsBed}
/>
</section>
<Divider color="primaryLightSubtle" />
</div>
)
})}
<div className={styles.addRoomMobileContainer}>
<Tooltip
heading={disabledBookingOptionsHeader}
text={disabledBookingOptionsText}
position="bottom"
arrow="left"
>
{rooms.length < 4 ? (
<div className={styles.contentContainer}>
{rooms.map((room, index) => (
<GuestsRoom
key={index}
room={room}
index={index}
onRemove={handleRemoveRoom}
/>
))}
{env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE ? (
<div className={styles.addRoomMobileContainer}>
<Tooltip
heading={disabledBookingOptionsHeader}
text={disabledBookingOptionsText}
position="bottom"
arrow="left"
>
<Button
intent="text"
variant="icon"
wrapping
disabled
theme="base"
fullWidth
onPress={handleAddRoom}
disabled
>
<PlusIcon />
{addRoomLabel}
</Button>
) : null}
</Tooltip>
</div>
</Tooltip>
</div>
) : (
canAddRooms && (
<div className={styles.addRoomMobileContainer}>
<Button
intent="text"
variant="icon"
wrapping
theme="base"
fullWidth
onPress={handleAddRoom}
>
<PlusIcon />
{addRoomLabel}
</Button>
</div>
)
)}
</div>
</section>
<footer className={styles.footer}>
<div className={styles.hideOnMobile}>
<Tooltip
heading={disabledBookingOptionsHeader}
text={disabledBookingOptionsText}
position={isOverflowed ? "top" : "bottom"}
arrow="left"
>
{rooms.length < 4 ? (
{env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE ? (
<div className={styles.hideOnMobile}>
<Tooltip
heading={disabledBookingOptionsHeader}
text={disabledBookingOptionsText}
position={isOverflowed ? "top" : "bottom"}
arrow="left"
>
<Button
intent="text"
variant="icon"
wrapping
disabled
theme="base"
disabled
onPress={handleAddRoom}
>
<PlusCircleIcon />
{addRoomLabel}
</Button>
) : null}
</Tooltip>
</div>
</Tooltip>
</div>
) : (
canAddRooms && (
<div className={styles.hideOnMobile}>
<Button
intent="text"
variant="icon"
wrapping
theme="base"
onPress={handleAddRoom}
>
<PlusCircleIcon />
{addRoomLabel}
</Button>
</div>
)
)}
<Button
onPress={handleOnClose}
disabled={getFieldState("rooms").invalid}
className={styles.hideOnMobile}
intent="tertiary"
theme="base"
size="small"
>
{doneLabel}
</Button>
<Button
onPress={handleOnClose}
disabled={getFieldState("rooms").invalid}
onPress={handleClose}
disabled={isInvalid}
className={styles.hideOnDesktop}
intent="tertiary"
theme="base"
@@ -163,6 +181,16 @@ export default function GuestsRoomsPickerDialog({
>
{doneLabel}
</Button>
<Button
onPress={handleClose}
disabled={isInvalid}
className={styles.hideOnMobile}
intent="tertiary"
theme="base"
size="small"
>
{doneLabel}
</Button>
</footer>
</>
)

View File

@@ -0,0 +1,72 @@
import { useIntl } from "react-intl"
import { DeleteIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import AdultSelector from "../AdultSelector"
import ChildSelector from "../ChildSelector"
import styles from "../guests-rooms-picker.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { TGuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
export function GuestsRoom({
room,
index,
onRemove,
}: {
room: TGuestsRoom
index: number
onRemove: (index: number) => void
}) {
const intl = useIntl()
const roomLabel = intl.formatMessage({ id: "Room" })
const childrenInAdultsBed = room.child.filter(
(child) => child.bed === ChildBedMapEnum.IN_ADULTS_BED
).length
return (
<div className={styles.roomContainer}>
<section className={styles.roomDetailsContainer}>
<Subtitle type="two" className={styles.roomHeading}>
{roomLabel} {index + 1}
</Subtitle>
<AdultSelector
roomIndex={index}
currentAdults={room.adults}
currentChildren={room.child}
childrenInAdultsBed={childrenInAdultsBed}
/>
<ChildSelector
roomIndex={index}
currentAdults={room.adults}
currentChildren={room.child}
childrenInAdultsBed={childrenInAdultsBed}
/>
{index !== 0 && (
<div className={styles.roomActions}>
<Button
intent="text"
variant="icon"
wrapping
theme="secondaryLight"
onPress={() => onRemove(index)}
size="small"
className={styles.roomActionsButton}
>
<DeleteIcon color="red" />
<span className={styles.roomActionsLabel}>
{intl.formatMessage({ id: "Remove room" })}
</span>
</Button>
</div>
)}
</section>
<Divider color="primaryLightSubtle" />
</div>
)
}

View File

@@ -56,9 +56,25 @@
}
.footer {
display: grid;
display: flex;
flex-direction: row;
gap: var(--Spacing-x1);
grid-template-columns: auto;
}
.roomContainer {
padding: var(--Spacing-x2);
}
.roomContainer:last-of-type {
padding-bottom: calc(var(--sticky-button-height) + 20px);
}
.roomActionsButton {
margin-left: auto;
color: var(--Base-Text-Accent);
}
.footer button {
width: 100%;
}
@media screen and (max-width: 1366px) {
@@ -71,7 +87,7 @@
.header {
display: grid;
grid-area: header;
padding: var(--Spacing-x3) var(--Spacing-x2);
padding: var(--Spacing-x3) var(--Spacing-x2) 0;
}
.close {
@@ -83,13 +99,6 @@
padding: 0;
}
.roomContainer {
padding: 0 var(--Spacing-x2);
}
.roomContainer:last-of-type {
padding-bottom: calc(var(--sticky-button-height) + 20px);
}
.footer {
background: linear-gradient(
180deg,
@@ -125,6 +134,17 @@
grid-template-rows: auto;
}
.roomContainer {
padding: var(--Spacing-x2) 0 0 0;
}
.roomContainer:first-of-type {
padding-top: 0;
}
.roomContainer:last-of-type {
padding-bottom: 0;
}
.contentContainer {
overflow-y: visible;
}
@@ -163,6 +183,11 @@
padding-top: var(--Spacing-x2);
}
.footer button {
margin-left: auto;
width: 125px;
}
.footer .hideOnDesktop,
.addRoomMobileContainer {
display: none;

View File

@@ -18,11 +18,11 @@ import PickerForm from "./Form"
import styles from "./guests-rooms-picker.module.css"
import type { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
import type { TGuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function GuestsRoomsPickerForm() {
const { watch, trigger } = useFormContext()
const rooms = watch("rooms") as GuestsRoom[]
const rooms = watch("rooms") as TGuestsRoom[]
const checkIsDesktop = useMediaQuery("(min-width: 1367px)")
const [isDesktop, setIsDesktop] = useState(true)
@@ -83,10 +83,10 @@ export default function GuestsRoomsPickerForm() {
}, [containerHeight])
useEffect(() => {
if (typeof window !== undefined && isDesktop) {
if (typeof window !== undefined && isDesktop && rooms.length > 0) {
updateHeight()
}
}, [childCount, isDesktop, updateHeight])
}, [childCount, isDesktop, updateHeight, rooms])
return isDesktop ? (
<DialogTrigger onOpenChange={setOverflowClip} isOpen={isOpen}>
@@ -104,13 +104,7 @@ export default function GuestsRoomsPickerForm() {
style={containerHeight ? { overflow: "auto" } : {}}
>
<Dialog className={styles.pickerContainerDesktop}>
{({ close }) => (
<PickerForm
rooms={rooms}
onClose={close}
isOverflowed={!!containerHeight}
/>
)}
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
</Dialog>
</Popover>
</DialogTrigger>
@@ -137,7 +131,7 @@ function Trigger({
className,
triggerFn,
}: {
rooms: GuestsRoom[]
rooms: TGuestsRoom[]
className: string
triggerFn?: () => void
}) {
@@ -149,27 +143,30 @@ function Trigger({
type="button"
onPress={triggerFn}
>
<Body>
{rooms.map((room, i) => (
<span key={i}>
{intl.formatMessage(
{ id: "booking.rooms" },
{ totalRooms: rooms.length }
)}
{", "}
{intl.formatMessage(
{ id: "booking.adults" },
{ totalAdults: room.adults }
)}
{room.child.length > 0
? ", " +
intl.formatMessage(
{ id: "booking.children" },
{ totalChildren: room.child.length }
)
: null}
</span>
))}
<Body color="uiTextHighContrast">
<span>
{intl.formatMessage(
{ id: "booking.rooms" },
{ totalRooms: rooms.length }
)}
{", "}
{intl.formatMessage(
{ id: "booking.adults" },
{ totalAdults: rooms.reduce((acc, room) => acc + room.adults, 0) }
)}
{rooms.some((room) => room.child.length > 0)
? ", " +
intl.formatMessage(
{ id: "booking.children" },
{
totalChildren: rooms.reduce(
(acc, room) => acc + room.child.length,
0
),
}
)
: null}
</span>
</Body>
</Button>
)

View File

@@ -1,5 +1,6 @@
"use client"
import { useRef } from "react"
import { useIntl } from "react-intl"
import Header from "@/components/HotelReservation/BookingConfirmation/Header"
import HotelDetails from "@/components/HotelReservation/BookingConfirmation/HotelDetails"
@@ -8,22 +9,40 @@ import Promos from "@/components/HotelReservation/BookingConfirmation/Promos"
import Receipt from "@/components/HotelReservation/BookingConfirmation/Receipt"
import Rooms from "@/components/HotelReservation/BookingConfirmation/Rooms"
import SidePanel from "@/components/HotelReservation/SidePanel"
import Alert from "@/components/TempDesignSystem/Alert"
import Divider from "@/components/TempDesignSystem/Divider"
import styles from "./confirmation.module.css"
import type { ConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
import { AlertTypeEnum } from "@/types/enums/alert"
export default function Confirmation({
booking,
hotel,
room,
}: ConfirmationProps) {
const intl = useIntl()
const mainRef = useRef<HTMLElement | null>(null)
const failedToVerifyMembership =
booking.rateDefinition.isMemberRate && !booking.guest.membershipNumber
return (
<main className={styles.main} ref={mainRef}>
<Header booking={booking} hotel={hotel} mainRef={mainRef} />
<div className={styles.booking}>
{failedToVerifyMembership && (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({
id: "booking.confirmation.membershipInfo.heading",
})}
text={intl.formatMessage({
id: "booking.confirmation.membershipInfo.text",
})}
/>
)}
<Rooms booking={booking} room={room} />
<PaymentDetails booking={booking} />
<Divider color="primaryLightSubtle" />

View File

@@ -2,7 +2,12 @@
import { notFound } from "next/navigation"
import { useIntl } from "react-intl"
import { ChevronRightSmallIcon, InfoCircleIcon } from "@/components/Icons"
import {
CheckIcon,
ChevronRightSmallIcon,
InfoCircleIcon,
} from "@/components/Icons"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
@@ -64,16 +69,46 @@ export default function Receipt({
<Caption color="uiTextMediumContrast">
{booking.rateDefinition.cancellationText}
</Caption>
<Link
color="peach80"
href=""
size="small"
textDecoration="underline"
variant="icon"
<Modal
trigger={
<Button intent="text" className={styles.termsLink}>
<Link
color="peach80"
href=""
size="small"
textDecoration="underline"
variant="icon"
>
{intl.formatMessage({ id: "Reservation policy" })}
<InfoCircleIcon color="peach80" />
</Link>
</Button>
}
title={booking.rateDefinition.cancellationText || ""}
subtitle={
booking.rateDefinition.cancellationRule == "CancellableBefore6PM"
? intl.formatMessage({ id: "Pay later" })
: intl.formatMessage({ id: "Pay now" })
}
>
{intl.formatMessage({ id: "Reservation policy" })}
<InfoCircleIcon color="peach80" />
</Link>
<div className={styles.terms}>
{booking.rateDefinition.generalTerms?.map((info) => (
<Body
key={info}
color="uiTextHighContrast"
className={styles.termsText}
>
<CheckIcon
color="uiSemanticSuccess"
width={20}
height={20}
className={styles.termsIcon}
></CheckIcon>
{info}
</Body>
))}
</div>
</Modal>
</header>
<div className={styles.entry}>
<Body color="uiTextHighContrast">{room.bedType.description}</Body>

View File

@@ -33,6 +33,22 @@
padding: 0;
}
.termsLink {
justify-self: flex-start;
}
.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) {
.receipt {
padding: var(--Spacing-x3);

View File

@@ -10,3 +10,8 @@
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
width: min(600px, 100%);
}
.iconContainer {
display: flex;
gap: var(--Spacing-x-one-and-half);
}

View File

@@ -4,9 +4,13 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form"
import {
BED_TYPE_ICONS,
type BedTypeEnum,
type ExtraBedTypeEnum,
} from "@/constants/booking"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { KingBedIcon } from "@/components/Icons"
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
import BedTypeInfo from "./BedTypeInfo"
@@ -18,6 +22,7 @@ import type {
BedTypeFormSchema,
BedTypeProps,
} from "@/types/components/hotelReservation/enterDetails/bedType"
import type { IconProps } from "@/types/components/icon"
export default function BedType({ bedTypes }: BedTypeProps) {
const initialBedType = useEnterDetailsStore(
@@ -74,8 +79,13 @@ export default function BedType({ bedTypes }: BedTypeProps) {
return (
<RadioCard
key={roomType.value}
Icon={KingBedIcon}
iconWidth={46}
Icon={(props) => (
<BedIconRenderer
mainBedType={roomType.type}
extraBedType={roomType.extraBed?.type}
props={props}
/>
)}
id={roomType.value}
name="bedType"
subtitle={width}
@@ -89,3 +99,25 @@ export default function BedType({ bedTypes }: BedTypeProps) {
</FormProvider>
)
}
function BedIconRenderer({
mainBedType,
extraBedType,
props,
}: {
mainBedType: BedTypeEnum
extraBedType: ExtraBedTypeEnum | undefined
props: IconProps
}) {
const MainBedIcon = BED_TYPE_ICONS[mainBedType]
const ExtraBedIcon = extraBedType ? BED_TYPE_ICONS[extraBedType] : null
return (
<div className={`${props.className} ${styles.iconContainer}`}>
<MainBedIcon height={32} color="uiTextMediumContrast" />
{ExtraBedIcon && (
<ExtraBedIcon height={32} color="uiTextMediumContrast" />
)}
</div>
)
}

View File

@@ -5,14 +5,13 @@ import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { MagicWandIcon } from "@/components/Icons"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Title from "@/components/TempDesignSystem/Text/Title"
import { formatPrice } from "@/utils/numberFormatting"
import Modal from "../../Modal"
import styles from "./modal.module.css"
import type { Dispatch, SetStateAction } from "react"

View File

@@ -1,5 +1,7 @@
import { z } from "zod"
import { dt } from "@/lib/dt"
import { phoneValidator } from "@/utils/phoneValidator"
// stringMatcher regex is copied from current web as specified by requirements.
@@ -78,7 +80,18 @@ export const joinDetailsSchema = baseDetailsSchema.merge(
z.object({
join: z.literal<boolean>(true),
zipCode: z.string().min(1, { message: "Zip code is required" }),
dateOfBirth: z.string().min(1, { message: "Date of birth is required" }),
dateOfBirth: z
.string()
.min(1, { message: "Date of birth is required" })
.refine(
(date) => {
const today = dt()
const dob = dt(date)
const age = today.diff(dob, "year")
return age >= 18
},
{ message: "Must be at least 18 years of age to continue" }
),
membershipNo: z.string().default(""),
})
)

View File

@@ -8,19 +8,19 @@ import { useEnterDetailsStore } from "@/stores/enter-details"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import {
ArrowRightIcon,
CheckIcon,
ChevronDownSmallIcon,
ChevronRightSmallIcon,
} from "@/components/Icons"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Popover from "@/components/TempDesignSystem/Popover"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import Modal from "../../Modal"
import PriceDetailsTable from "../PriceDetailsTable"
import styles from "./ui.module.css"
@@ -51,6 +51,7 @@ export default function SummaryUI({
isMember,
rateDetails,
roomType,
breakfastIncluded,
}: SummaryProps) {
const intl = useIntl()
const lang = useLang()
@@ -169,23 +170,34 @@ export default function SummaryUI({
}`}
</Caption>
<Caption color="uiTextMediumContrast">{cancellationText}</Caption>
<Popover
placement="bottom left"
triggerContent={
<Caption color="burgundy" type="underline">
{intl.formatMessage({ id: "Rate details" })}
</Caption>
<Modal
trigger={
<Button intent="text">
<Caption color="burgundy" type="underline">
{intl.formatMessage({ id: "Rate details" })}
</Caption>
</Button>
}
title={cancellationText}
>
<aside className={styles.rateDetailsPopover}>
<header>
<Caption type="bold">{cancellationText}</Caption>
</header>
{rateDetails?.map((detail, idx) => (
<Caption key={`rateDetails-${idx}`}>{detail}</Caption>
<div className={styles.terms}>
{rateDetails?.map((info) => (
<Body
key={info}
color="uiTextHighContrast"
className={styles.termsText}
>
<CheckIcon
color="uiSemanticSuccess"
width={20}
height={20}
className={styles.termsIcon}
></CheckIcon>
{info}
</Body>
))}
</aside>
</Popover>
</div>
</Modal>
</div>
{packages
? packages.map((roomPackage) => (
@@ -242,7 +254,13 @@ export default function SummaryUI({
</Body>
</div>
) : null}
{breakfast === false ? (
{breakfastIncluded ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast included" })}
</Body>
</div>
) : breakfast === false ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "No breakfast" })}

View File

@@ -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;

View File

@@ -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({
</div>
<div className={styles.infoMobile}>
<div className={styles.filterInfo}>
{!isAboveMobile ? (
<Tooltip
text={tooltipText}
position="bottom"
arrow="left"
isTouchable
>
<InfoCircleIcon
color="uiTextHighContrast"
height={20}
width={20}
/>
<Tooltip
text={tooltipText}
position="bottom"
arrow="left"
isTouchable
>
<InfoCircleIcon color="uiTextHighContrast" height={20} width={20} />
<div className={styles.filter}>
<Caption
type="label"
color="baseTextMediumContrast"
@@ -114,30 +106,14 @@ export default function RoomFilter({
>
{intl.formatMessage({ id: "Filter" })}
</Caption>
<Caption type="label" color="baseTextMediumContrast">
<Caption type="regular" color="baseTextMediumContrast">
{Object.entries(selectedFilters)
.filter(([_, value]) => value)
.map(([key]) => intl.formatMessage({ id: key }))
.join(", ")}
</Caption>
</Tooltip>
) : (
<>
<Caption
type="label"
color="baseTextMediumContrast"
textTransform="uppercase"
>
{intl.formatMessage({ id: "Filter" })}
</Caption>
<Caption type="label" color="baseTextMediumContrast">
{Object.entries(selectedFilters)
.filter(([_, value]) => value)
.map(([key]) => intl.formatMessage({ id: key }))
.join(", ")}
</Caption>
</>
)}
</div>
</Tooltip>
</div>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
@@ -170,12 +146,13 @@ export default function RoomFilter({
/>
)
return showTooltip ? (
return isPetRoom && isAboveMobile ? (
<Tooltip
key={option.code}
text={tooltipText}
position="bottom"
arrow="right"
isTouchable
>
{checkboxChip}
</Tooltip>

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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({
/>
<div className={`${styles.card} ${RATE_CARD_EQUAL_HEIGHT_CLASS}`}>
<div className={styles.header}>
<Popover
placement="bottom left"
triggerContent={
<InfoCircleIcon
width={16}
height={16}
color="uiTextMediumContrast"
/>
<Modal
trigger={
<Button intent="text">
<InfoCircleIcon
width={16}
height={16}
color="uiTextMediumContrast"
/>
</Button>
}
title={name}
subtitle={paymentTerm}
>
<Caption
color="uiTextHighContrast"
type="bold"
className={styles.popoverHeading}
>
{name}
</Caption>
{priceInformation?.map((info) => (
<Caption
key={info}
color="uiTextHighContrast"
className={styles.popoverText}
>
{info}
</Caption>
))}
</Popover>
<div className={styles.terms}>
{priceInformation?.map((info) => (
<Body
key={info}
color="uiTextHighContrast"
className={styles.termsText}
>
<CheckIcon
color="uiSemanticSuccess"
width={20}
height={20}
className={styles.termsIcon}
></CheckIcon>
{info}
</Body>
))}
</div>
</Modal>
<div className={styles.priceType}>
<Caption color="uiTextHighContrast">{name}</Caption>
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>

View File

@@ -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({
</div>
<div className={styles.specification}>
{occupancy?.total && (
{totalOccupancy && (
<Caption color="uiTextMediumContrast" className={styles.guests}>
{intl.formatMessage(
{
id: "booking.guests",
},
{ nrOfGuests: occupancy.total }
{ max: totalOccupancy.max, range: totalOccupancy.range }
)}
</Caption>
)}

View File

@@ -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<HTMLLIElement[]>([])
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<HTMLFormElement>) {
e.preventDefault()
window.history.replaceState(
null,
"",
`${pathname}?${queryParams.toString()}`
)
router.push(`select-bed?${queryParams}`)
}

View File

@@ -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 (
<Rooms
user={user}
availablePackages={packages ?? []}
roomsAvailability={roomsAvailability}
roomCategories={hotelData?.included?.rooms ?? []}
hotelType={hotelData?.data.attributes?.hotelType}
isUserLoggedIn={isUserLoggedIn}
/>
)
}

View File

@@ -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({
<RoomSelection
roomsAvailability={rooms}
roomCategories={roomCategories}
user={user}
availablePackages={availablePackages}
selectedPackages={selectedPackages}
setRateCode={setSelectedRate}
rateSummary={rateSummary}
hotelType={hotelType}
isUserLoggedIn={isUserLoggedIn}
/>
</div>
)

View File

@@ -1,4 +1,4 @@
import { iconVariants } from "./variants"
import { iconVariants } from "../variants"
import type { IconProps } from "@/types/components/icon"

View File

@@ -1,4 +1,4 @@
import { iconVariants } from "./variants"
import { iconVariants } from "../variants"
import type { IconProps } from "@/types/components/icon"

View File

@@ -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 (
<svg
className={classNames}
width="75"
height="42"
viewBox="0 0 75 42"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18.0731 3.44172V40.1023H21.3198V3.44172H18.0731ZM16.1792 3.35155C16.1792 2.35541 16.9867 1.54785 17.9829 1.54785H21.41C22.4061 1.54785 23.2137 2.3554 23.2137 3.35155V40.1925C23.2137 41.1886 22.4061 41.9962 21.41 41.9962H17.9829C16.9867 41.9962 16.1792 41.1886 16.1792 40.1925V3.35155Z"
fill="#26201E"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M67.765 5.1551V40.1021H71.0117V5.1551H67.765ZM65.8711 5.06493C65.8711 4.06877 66.6786 3.26123 67.6748 3.26123H71.1019C72.098 3.26123 72.9056 4.06877 72.9056 5.06493V40.1923C72.9056 41.1885 72.098 41.996 71.1019 41.996H67.6748C66.6786 41.996 65.8711 41.1885 65.8711 40.1923V5.06493Z"
fill="#26201E"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M21.3196 31.5352H67.7652V36.8561H21.3196V31.5352ZM23.2134 33.429V34.9622H65.8714V33.429H23.2134Z"
fill="#26201E"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M58.4325 27.9521C52.6201 26.9387 46.6738 26.9667 40.8711 28.0346L40.8711 28.0346L34.9466 29.1249C34.9466 29.1249 34.9466 29.1249 34.9466 29.1249C34.4972 29.2076 34.171 29.5993 34.171 30.0562V30.7681C34.171 31.2911 34.595 31.7151 35.118 31.7151H65.1048C65.6278 31.7151 66.0518 31.2911 66.0518 30.7681V30.0767C66.0518 29.6165 65.7209 29.2229 65.2674 29.1439L65.4138 28.3043L65.2674 29.1439L58.4325 27.9521ZM58.7253 26.273L58.5789 27.1125L58.7253 26.273L65.5602 27.4647C66.8297 27.6861 67.7562 28.7881 67.7562 30.0767V30.7681C67.7562 32.2325 66.5691 33.4195 65.1048 33.4195H35.118C33.6536 33.4195 32.4666 32.2325 32.4666 30.7681V30.0562C32.4666 28.777 33.3799 27.6801 34.6381 27.4486L34.6381 27.4486L40.5626 26.3583C40.5626 26.3583 40.5626 26.3583 40.5626 26.3583C46.564 25.2538 52.7138 25.2248 58.7253 26.273Z"
fill="#26201E"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M23.9798 28.9647C23.4568 28.9647 23.0328 29.3887 23.0328 29.9117V30.7685C23.0328 31.2915 23.4568 31.7154 23.9798 31.7154H30.8339C31.3569 31.7154 31.7809 31.2915 31.7809 30.7685V29.9117C31.7809 29.3887 31.3569 28.9647 30.8339 28.9647H23.9798ZM21.3284 29.9117C21.3284 28.4473 22.5155 27.2603 23.9798 27.2603H30.8339C32.2983 27.2603 33.4854 28.4473 33.4854 29.9117V30.7685C33.4854 32.2328 32.2983 33.4199 30.8339 33.4199H23.9798C22.5155 33.4199 21.3284 32.2328 21.3284 30.7685V29.9117Z"
fill="#26201E"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M25.2138 21.2745C24.5883 21.4597 23.949 21.7397 23.4251 22.168L25.2138 21.2745ZM23.4251 22.168C22.8933 22.6025 22.3724 23.2887 22.3152 24.2343C22.2586 25.1688 22.6812 25.9307 23.1322 26.4496M23.1322 26.4496C23.5824 26.9674 24.1643 27.373 24.7538 27.6876L23.1322 26.4496Z"
fill="#26201E"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M29.6979 20.9796C28.0609 20.8019 26.4746 20.9014 25.2138 21.2745L23.4251 22.168C22.8933 22.6025 22.3724 23.2887 22.3152 24.2343C22.2586 25.1688 22.6812 25.9307 23.1322 26.4496L24.7538 27.6876C25.9405 28.3211 27.487 28.7611 29.1256 28.9389C30.7626 29.1166 32.3489 29.0173 33.6098 28.644C34.2356 28.4588 34.8746 28.1786 35.3985 27.7506C35.9304 27.316 36.451 26.6297 36.5083 25.6843C36.676 23.9652 34.9525 22.6658 34.0698 22.231C32.883 21.5974 31.3365 21.1574 29.6979 20.9796ZM29.4935 22.8624C28.0381 22.7044 26.714 22.8056 25.7514 23.0904C25.2695 23.2332 24.8867 23.4193 24.6235 23.6344C24.3625 23.8477 24.2215 24.086 24.2056 24.3487C24.189 24.6226 24.3082 24.9157 24.5616 25.2071C24.811 25.4941 25.1814 25.7691 25.6455 26.0168C26.574 26.5125 27.8757 26.8983 29.3299 27.0561C30.7856 27.2141 32.1096 27.113 33.0722 26.8281C33.5258 26.7045 34.4698 26.2799 34.6179 25.5698C34.6345 25.2958 34.5152 25.0027 34.2619 24.7114C34.0124 24.4244 33.642 24.1494 33.1778 23.9017C32.2494 23.406 30.9477 23.0202 29.4935 22.8624Z"
fill="#26201E"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M21.3196 10.9722H67.7652V16.2931H21.3196V10.9722ZM23.2134 12.866V14.3992H65.8714V12.866H23.2134Z"
fill="#26201E"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M58.4323 7.39062C52.6198 6.37719 46.6735 6.4051 40.8709 7.47303L34.9464 8.56337C34.497 8.64607 34.1708 9.03779 34.1708 9.49468V10.2066C34.1708 10.7296 34.5948 11.1535 35.1178 11.1535H65.1045C65.6275 11.1535 66.0515 10.7296 66.0515 10.2066V9.51516C66.0515 9.05495 65.7206 8.66136 65.2672 8.5823L58.4323 7.39062ZM40.5624 5.7967C46.5637 4.69221 52.7136 4.66335 58.725 5.71147L65.56 6.90316C66.8295 7.1245 67.756 8.22654 67.756 9.51516V10.2066C67.756 11.6709 66.5689 12.858 65.1045 12.858H35.1178C33.6534 12.858 32.4663 11.6709 32.4663 10.2066V9.49468C32.4663 8.21543 33.3797 7.11859 34.6378 6.88704"
fill="#26201E"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M23.9798 8.40223C23.4568 8.40223 23.0328 8.8262 23.0328 9.3492V10.206C23.0328 10.729 23.4568 11.1529 23.9798 11.1529H30.8339C31.3569 11.1529 31.7809 10.729 31.7809 10.206V9.3492C31.7809 8.8262 31.3569 8.40223 30.8339 8.40223H23.9798ZM21.3284 9.3492C21.3284 7.88485 22.5155 6.69775 23.9798 6.69775H30.8339C32.2983 6.69775 33.4854 7.88485 33.4854 9.3492V10.206C33.4854 11.6703 32.2983 12.8574 30.8339 12.8574H23.9798C22.5155 12.8574 21.3284 11.6703 21.3284 10.206V9.3492Z"
fill="#26201E"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M25.2138 0.441473C24.5883 0.626692 23.949 0.906727 23.4251 1.33497L25.2138 0.441473ZM23.4251 1.33497C22.8933 1.76954 22.3724 2.45571 22.3152 3.40126C22.2586 4.33574 22.6812 5.09771 23.1322 5.6166M23.1322 5.6166C23.5824 6.13441 24.1643 6.53996 24.7538 6.85458L23.1322 5.6166Z"
fill="#26201E"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M29.6979 0.146592C28.0609 -0.0310981 26.4746 0.0683662 25.2138 0.441473L23.4251 1.33497C22.8933 1.76954 22.3724 2.45571 22.3152 3.40126C22.2586 4.33574 22.6812 5.09771 23.1322 5.6166L24.7538 6.85458C25.9405 7.48812 27.487 7.92808 29.1256 8.10592C30.7626 8.28361 32.3489 8.18425 33.6098 7.81102C34.2356 7.6258 34.8746 7.34563 35.3985 6.91763C35.9304 6.48295 36.451 5.79672 36.5083 4.85125C36.676 3.13218 34.9525 1.83278 34.0698 1.39797C32.883 0.764411 31.3365 0.32444 29.6979 0.146592ZM29.4935 2.0294C28.0381 1.87142 26.714 1.97259 25.7514 2.25743C25.2695 2.40017 24.8867 2.58628 24.6235 2.8014C24.3625 3.01472 24.2215 3.25294 24.2056 3.51573C24.189 3.78962 24.3082 4.08266 24.5616 4.3741C24.811 4.66111 25.1814 4.93606 25.6455 5.18381C26.574 5.67946 27.8757 6.06528 29.3299 6.22311C30.7856 6.38111 32.1096 6.27998 33.0722 5.99504C33.5258 5.8715 34.4698 5.44689 34.6179 4.73679C34.6345 4.46283 34.5152 4.1697 34.2619 3.87839C34.0124 3.59138 33.642 3.31641 33.1778 3.06866C32.2494 2.57301 30.9477 2.18723 29.4935 2.0294Z"
fill="#26201E"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.84337 29.9109C14.3386 29.9109 17.9826 26.2668 17.9826 21.7716C17.9826 17.2764 14.3386 13.6323 9.84337 13.6323C5.34817 13.6323 1.7041 17.2764 1.7041 21.7716C1.7041 26.2668 5.34817 29.9109 9.84337 29.9109ZM10.9449 25.931V22.3934H14.5119C15.0469 22.3934 15.4036 22.0397 15.4036 21.509C15.4036 20.9784 15.0469 20.6246 14.5119 20.6246H10.9449V17.0871C10.9449 16.5564 10.5882 16.2026 10.0532 16.2026C9.51814 16.2026 9.16145 16.5564 9.16145 17.0871V20.6246H5.59451C5.05947 20.6246 4.70277 20.9784 4.70277 21.509C4.70277 22.0397 5.05947 22.3934 5.59451 22.3934H9.16145V25.931C9.16145 26.4617 9.51814 26.8155 10.0532 26.8155C10.5882 26.8155 10.9449 26.4617 10.9449 25.931Z"
fill="#26201E"
/>
</svg>
)
}

View File

@@ -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 (
<svg
className={classNames}
width="51"
height="32"
viewBox="0 0 51 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M43.8184 0V12.1806C43.0898 11.9742 42.2571 11.871 41.3204 11.7677C40.4878 8.25806 37.2612 5.57419 33.4102 5.57419H29.9755C25.9163 5.57419 22.5857 8.46452 21.9612 12.2839C20.7122 12.5935 19.5673 13.1097 18.5265 13.8323C18.6306 13.3161 18.7347 12.6968 18.7347 12.0774C18.7347 6.91613 14.5714 2.7871 9.36735 2.7871C4.16327 2.7871 0 6.91613 0 12.0774C0 17.2387 4.16327 21.3677 9.36735 21.3677C11.3449 21.3677 13.2184 20.7484 14.7796 19.6129C14.6755 20.2323 14.5714 20.8516 14.5714 21.6774V27.871H17.798C17.798 27.9742 17.7719 28.0516 17.7459 28.129C17.7199 28.2065 17.6939 28.2839 17.6939 28.3871C17.6939 30.3484 19.3592 32 21.3367 32C23.3143 32 24.9796 30.3484 24.9796 28.3871C24.9796 28.1806 24.9796 28.0774 24.8755 27.871H43.8184V31.7935H51V0H43.8184ZM30.0796 7.53548H33.5143C36.1163 7.53548 38.302 9.1871 39.2388 11.4581C37.7816 11.3548 36.1163 11.3548 34.3469 11.3548H30.3918C28.7265 11.3548 26.5408 11.3548 24.3551 11.6645C25.0837 9.29032 27.3735 7.53548 30.0796 7.53548ZM10.4082 17.1355V13.0065H14.5714C15.1959 13.0065 15.6122 12.5935 15.6122 11.9742C15.6122 11.3548 15.1959 10.9419 14.5714 10.9419H10.4082V6.8129C10.4082 6.19355 9.99184 5.78064 9.36735 5.78064C8.74286 5.78064 8.32653 6.19355 8.32653 6.8129V10.9419H4.16327C3.53878 10.9419 3.12245 11.3548 3.12245 11.9742C3.12245 12.5935 3.53878 13.0065 4.16327 13.0065H8.32653V17.1355C8.32653 17.7548 8.74286 18.1677 9.36735 18.1677C9.99184 18.1677 10.4082 17.7548 10.4082 17.1355ZM21.3367 29.9355C20.5041 29.9355 19.7755 29.2129 19.7755 28.3871C19.7755 28.1806 19.7755 28.0774 19.8796 27.871H22.7939C22.898 28.0774 22.898 28.1806 22.898 28.3871C22.898 29.2129 22.2735 29.9355 21.3367 29.9355ZM16.6531 25.8065H43.7143V23.7419H16.6531V25.8065ZM43.8184 21.6774H16.6531C16.6531 15.071 21.5449 13.4194 30.2878 13.4194H34.2429C38.7184 13.4194 41.7367 13.6258 43.8184 14.2452V21.6774ZM45.9 29.729H48.9184V2.06452H45.9V29.729Z"
fill="#26201E"
/>
</svg>
)
}

View File

@@ -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 (
<svg
className={classNames}
width="70"
height="34"
viewBox="0 0 70 34"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M69.5408 8.73112C69.3353 8.52568 69.0272 8.42296 68.719 8.42296C68.5136 8.42296 65.7402 8.6284 63.2749 9.75831C63.2749 6.88218 63.2749 4.82779 63.2749 4.82779C63.2749 2.1571 58.2417 0 51.9758 0C47.4562 0 43.5529 1.12991 41.7039 2.77341C39.855 1.12991 35.9517 0 31.432 0C25.1662 0 20.1329 2.1571 20.1329 4.82779C20.1329 4.82779 20.1329 6.88218 20.1329 9.75831C18.9003 9.14199 17.565 8.83384 16.5378 8.6284C14.8943 6.4713 12.2236 5.03323 9.24471 5.03323C4.10876 5.03323 0 9.14199 0 14.2779C0 19.4139 4.10876 23.5227 9.24471 23.5227C10.7855 23.5227 12.3263 23.1118 13.6616 22.3927V26.5015C13.6616 27.1178 14.1752 27.5287 14.6888 27.5287H20.1329H20.3384V28.2477C20.3384 29.3776 21.2628 30.4048 22.4955 30.4048H24.3444V32.8701C24.3444 33.4864 24.858 34 25.4743 34C26.0906 34 26.6042 33.4864 26.6042 32.8701V30.4048H57.0091V32.8701C57.0091 33.4864 57.5227 34 58.139 34C58.7553 34 59.2689 33.4864 59.2689 32.8701V30.4048H60.7069C61.8369 30.4048 62.864 29.4804 62.864 28.2477V27.5287H63.3776H68.8218C69.4381 27.5287 69.8489 27.0151 69.8489 26.5015V9.55287C69.8489 9.24471 69.7462 8.93656 69.5408 8.73112ZM49.4079 14.3807H34.5136C34.6163 13.148 34.9245 12.2236 35.6435 11.5045C36.7734 10.3746 38.7251 9.75831 41.6012 9.75831C47.9698 9.65559 49.2024 11.9154 49.4079 14.3807ZM10.2719 19.4139C10.2719 20.0302 9.86103 20.4411 9.24471 20.4411C8.6284 20.4411 8.21752 20.0302 8.21752 19.4139V15.3051H4.10876C3.49245 15.3051 3.08157 14.8943 3.08157 14.2779C3.08157 13.6616 3.49245 13.2508 4.10876 13.2508H8.21752V9.14199C8.21752 8.52568 8.6284 8.1148 9.24471 8.1148C9.86103 8.1148 10.2719 8.52568 10.2719 9.14199V13.2508H14.3807C14.997 13.2508 15.4079 13.6616 15.4079 14.2779C15.4079 14.8943 14.997 15.3051 14.3807 15.3051H10.2719V19.4139ZM22.0846 17.9758C20.3384 20.2356 20.3384 22.1873 20.3384 22.29V25.577H15.716V20.852C17.4622 19.2085 18.4894 16.8459 18.4894 14.2779C18.4894 13.148 18.284 12.1208 17.9758 11.1964C19.9275 11.8127 22.0846 12.9426 22.0846 15.2024V17.9758ZM22.29 11.1964V4.93051C22.3927 4.10876 25.7825 2.25982 31.5347 2.25982C37.0816 2.25982 40.4713 4.10876 40.6767 4.93051C40.6767 4.93051 40.6767 4.93051 40.6767 5.03323C40.6767 5.03323 40.6767 6.06042 40.6767 7.60121C37.6979 7.70393 35.5408 8.52568 34.1027 9.96375C32.9728 11.0937 32.3565 12.6344 32.3565 14.4834H29.8912C27.6314 14.4834 25.6798 14.997 24.2417 16.0242V15.3051C24.139 13.4562 23.4199 12.1208 22.29 11.1964ZM60.7069 28.4532H22.4955V25.3716H60.7069V28.4532ZM22.4955 23.1118V22.29C22.4955 22.29 22.4955 20.4411 24.5498 18.3867C25.7825 17.1541 27.6314 16.435 29.8912 16.435H53.003C55.571 16.435 57.5227 17.1541 58.7553 18.4894C60.8097 20.5438 60.7069 22.1873 60.7069 22.29V23.1118H22.4955ZM61.2205 11.1964C60.0906 12.1208 59.3716 13.4562 59.3716 15.3051V16.2296C57.7281 14.997 55.6737 14.3807 53.1057 14.3807H51.6677C51.4622 10.0665 48.4834 7.80665 42.9366 7.49849C42.9366 5.9577 42.9366 4.93051 42.9366 4.93051C42.9366 4.93051 42.9366 4.93051 42.9366 4.82779C43.2447 4.00604 46.5317 2.1571 52.0786 2.1571C57.8308 2.1571 61.1178 4.10876 61.3233 4.82779L61.2205 11.1964ZM67.7946 25.577H62.864V22.3927C62.864 22.29 62.864 20.5438 61.426 18.4894V15.3051C61.426 12.1208 65.6375 11.0937 67.6918 10.7855V25.577H67.7946Z"
fill="#26201E"
/>
</svg>
)
}

View File

@@ -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 (
<svg
className={classNames}
width="53"
height="34"
viewBox="0 0 53 34"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M50.9 0.149902H47H45V2.1499V13.8499C44.6 13.8499 44.2 13.8499 43.8 13.9499C43.6306 13.8168 43.4714 13.6895 43.3191 13.5678L43.319 13.5677C42.213 12.6834 41.47 12.0894 39.8 11.6499C39.1 7.9499 36 5.1499 32.1 5.1499H28.8C24.9 5.1499 21.7 7.9499 21.1 11.6499C20 11.9499 18.8 12.4499 17.9 13.0499C17.2 8.7499 13.5 5.4499 9 5.4499C4 5.4499 0 9.4499 0 14.4499C0 19.4499 4 23.4499 9 23.4499C10.9 23.4499 12.6 22.8499 14 21.9499V22.8499V24.8499V26.8499H16H45V31.8499V33.8499H47H50.9H52.9V31.8499V2.1499V0.149902H50.9ZM28.8 7.1499H32.1C34.7 7.1499 36.9 8.8499 37.6 11.1499C36 10.9499 34.4 10.8499 32.8 10.8499H29C27.4 10.8499 25.3 10.8499 23.2 11.1499C24.1 8.8499 26.3 7.1499 28.8 7.1499ZM32.9 12.8499H29.1C23.9 12.8499 20 13.5499 17.9 15.5499C17.7 17.2499 17 18.7499 16 20.0499V20.8499H38C38 18.2499 39.4 15.9499 41.5 14.7499C39.3 13.4499 37.1 12.8499 32.9 12.8499ZM10 19.4499C10 20.0499 9.6 20.4499 9 20.4499C8.4 20.4499 8 20.0499 8 19.4499V15.4499H4C3.4 15.4499 3 15.0499 3 14.4499C3 13.8499 3.4 13.4499 4 13.4499H8V9.4499C8 8.8499 8.4 8.4499 9 8.4499C9.6 8.4499 10 8.8499 10 9.4499V13.4499H14C14.6 13.4499 15 13.8499 15 14.4499C15 15.0499 14.6 15.4499 14 15.4499H10V19.4499ZM16 24.8499H45V22.8499H16V24.8499ZM45 17.9499C43.5 18.1499 42.3 19.3499 42.1 20.8499H40C40 18.0499 42.2 15.8499 45 15.8499V17.9499ZM47 31.8499H50.9V2.1499H47V20.8499V22.8499V24.8499V26.8499V31.8499Z"
fill="#26201E"
/>
</svg>
)
}

View File

@@ -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 (
<svg
className={classNames}
width="51"
height="36"
viewBox="0 0 51 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M48.1255 17.2632C49.8873 18.9211 51 21.2237 51 24.2632V24.4474V24.5395V33.75C51 34.6711 50.1655 35.5 49.1455 35.5H46.8273C45.8073 35.5 44.9727 34.6711 44.9727 33.75V29.6053H6.12V33.75C6.12 34.6711 5.28545 35.5 4.26545 35.5H1.85455C0.834545 35.5 0 34.6711 0 33.75V24.4474C0 22.0526 0.556364 20.2105 1.39091 18.6447V18.5526V1.42105C1.39091 0.960526 1.85455 0.5 2.31818 0.5H47.1982C47.7545 0.5 48.1255 0.868421 48.1255 1.42105V17.2632ZM1.85455 23.5263H49.1455C48.8673 19.8421 46.6418 17.6316 43.3036 16.1579C43.2109 16.1579 43.1182 16.1579 43.0255 16.0658C38.2036 14.2237 31.1564 14.2237 24.5727 14.2237C11.6836 14.2237 2.41091 14.9605 1.85455 23.5263ZM6.58364 14.5V13.0263C6.58364 7.4079 12.5182 6.76316 15.1145 6.76316C17.6182 6.76316 23.2745 7.31579 23.6455 12.4737C17.6182 12.4737 11.22 12.5658 6.58364 14.5ZM25.5 12.3816V12.4737C31.1564 12.4737 37.5545 12.5658 42.5618 14.2237V12.3816C42.5618 6.76316 36.6273 6.11842 34.0309 6.11842C31.4345 6.11842 25.5 6.76316 25.5 12.3816ZM46.2709 2.34211V15.7895C45.7145 15.4211 45.0655 15.1447 44.4164 14.8684V12.2895C44.4164 7.31579 40.5218 4.27632 34.0309 4.27632C29.2091 4.27632 25.7782 5.93421 24.3873 8.88158C22.8109 6.48684 19.6582 5.10526 15.2073 5.10526C8.71636 5.10526 4.82182 8.05263 4.82182 13.1184V15.4211C4.26545 15.7895 3.80182 16.1579 3.33818 16.5263V2.34211H46.2709ZM1.85455 33.75H4.26545V29.6053H1.85455V33.75ZM1.85455 27.8553V25.2763H49.1455V27.8553H1.85455ZM46.7345 29.6053V33.75H49.1455V29.6053H46.7345Z"
fill="currentColor"
/>
</svg>
)
}

View File

@@ -1,4 +1,4 @@
import { iconVariants } from "./variants"
import { iconVariants } from "../variants"
import type { IconProps } from "@/types/components/icon"

View File

@@ -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 (
<svg
className={classNames}
width="50"
height="32"
viewBox="0 0 50 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M49.8008 20.6355V20.3364C49.7012 15.352 47.1116 12.4611 43.2271 10.7664V8.17445C43.2271 3.09034 39.3426 0 32.7689 0C27.8884 0 25.2988 1.6947 23.9044 4.68536C22.3108 2.19315 20.0199 0.697819 15.4382 0.697819C8.76494 0.797508 4.88048 3.88785 4.88048 8.97196V11.3645C1.89243 13.1589 0 15.9502 0 20.6355V30.2056C0 31.2025 0.796813 32 1.89243 32H4.38247C5.37849 32 6.2749 31.2025 6.2749 30.2056V25.919H43.7251V30.1059C43.7251 31.1028 44.5219 31.9003 45.6175 31.9003H48.1076C49.1036 31.9003 50 31.1028 50 30.1059V20.6355H49.8008ZM1.89243 24.1246V21.5327H48.008V24.1246H1.89243ZM32.5697 1.89408C29.9801 1.89408 24.9004 2.49221 24.9004 8.27414L24.8008 8.37383V8.47352C30.4781 8.47352 36.1554 8.57321 41.2351 10.1682V8.27414C41.2351 2.49221 35.1594 1.89408 32.5697 1.89408ZM6.67331 8.97196C6.67331 3.19003 12.749 2.5919 15.3386 2.5919C17.8287 2.5919 22.6096 3.19003 23.008 8.37383C16.8327 8.37383 11.3546 8.47352 6.67331 10.3676V8.97196ZM41.7331 12.0623C36.753 10.1682 30.5777 10.1682 23.9044 10.1682C10.8566 10.1682 2.49004 10.866 1.89243 19.6386H47.9084C47.6096 15.8505 45.3187 13.5576 42.0319 12.162C41.9323 12.162 41.8327 12.162 41.7331 12.0623ZM1.89243 30.1059V25.919H4.38247V30.1059H1.89243ZM45.5179 25.919V30.1059H48.008V25.919H45.5179Z"
fill="currentColor"
/>
</svg>
)
}

View File

@@ -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 (
<svg
className={classNames}
width="36"
height="32"
viewBox="0 0 36 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M29.52 9.90228C33.3257 11.6743 35.8971 14.6971 36 19.9088V20.1173V20.2215V30.1238C36 31.1661 35.1771 32 34.1486 32H31.7829C30.7543 32 29.9314 31.1661 29.9314 30.1238V25.6417H6.06857V30.1238C6.06857 31.1661 5.24571 32 4.21714 32H1.85143C0.822857 32 0 31.1661 0 30.1238V20.1173C0 15.3225 1.85143 12.2997 4.73143 10.3192L6.58286 9.38111C7.09714 9.17264 7.71429 8.96417 8.33143 8.7557C8.33143 6.14984 9.05143 3.96091 10.4914 2.50163C12.0343 0.833876 14.5029 0 17.5886 0C24.1714 0 27.5657 3.0228 27.5657 9.0684H27.4629C27.4629 9.17264 27.5657 9.17264 27.6686 9.17264L29.52 9.90228ZM17.6914 2.08469C15.0171 2.08469 13.1657 2.7101 11.9314 3.96091C10.9029 4.89902 10.3886 6.46254 10.3886 8.33876C12.1371 8.02606 14.0914 7.71335 16.0457 7.60912L20.0571 7.71335C22.0114 7.92182 23.9657 8.23453 25.7143 8.65147C25.6114 5.4202 24.48 2.08469 17.6914 2.08469ZM16.0457 9.38111L20.0571 9.48534C23.04 9.79805 25.8171 10.215 28.1829 11.1531C28.2857 11.2573 28.3886 11.2573 28.4914 11.2573C31.7829 12.7166 33.9429 15.114 34.2514 19.0749H1.85143C2.26286 12.0912 7.71428 10.0065 16.0457 9.38111ZM1.85143 30.1238H4.21714V25.6417H1.85143V30.1238ZM16.0457 23.7655H1.85143V21.0554H34.1486V23.7655H16.0457ZM31.7829 25.6417V30.1238H34.1486V25.6417H31.7829Z"
fill="currentColor"
/>
</svg>
)
}

View File

@@ -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 (
<svg
className={classNames}
width="75"
height="32"
viewBox="0 0 75 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M29.8958 9.90228C33.75 11.6743 36.3542 14.6971 36.4583 19.9088V20.1173V20.2215V30.1238C36.4583 31.1661 35.625 32 34.5833 32H32.1875C31.1458 32 30.3125 31.1661 30.3125 30.1238V25.6417H6.14583V30.1238C6.14583 31.1661 5.3125 32 4.27083 32H1.875C0.833333 32 0 31.1661 0 30.1238V20.1173C0 15.3225 1.875 12.2997 4.79167 10.3192L6.66667 9.38111C7.1875 9.17264 7.8125 8.96417 8.4375 8.7557C8.4375 6.14984 9.16667 3.96091 10.625 2.50163C12.1875 0.833876 14.6875 0 17.8125 0C24.4792 0 27.9167 3.0228 27.9167 9.0684H27.8125C27.8125 9.17264 27.9167 9.17264 28.0208 9.17264L29.8958 9.90228ZM17.9167 2.08469C15.2083 2.08469 13.3333 2.7101 12.0833 3.96091C11.0417 4.89902 10.5208 6.46254 10.5208 8.33876C12.2917 8.02606 14.2708 7.71335 16.25 7.60912L20.3125 7.71335C22.2917 7.92182 24.2708 8.23453 26.0417 8.65147C25.9375 5.4202 24.7917 2.08469 17.9167 2.08469ZM16.25 9.38111L20.3125 9.48534C23.3333 9.79805 26.1458 10.215 28.5417 11.1531C28.6458 11.2573 28.75 11.2573 28.8542 11.2573C32.1875 12.7166 34.375 15.114 34.6875 19.0749H1.875C2.29167 12.0912 7.8125 10.0065 16.25 9.38111ZM1.875 30.1238H4.27083V25.6417H1.875V30.1238ZM16.25 23.7655H1.875V21.0554H34.5833V23.7655H16.25ZM32.1875 25.6417V30.1238H34.5833V25.6417H32.1875ZM75 19.9088C74.8958 14.6971 72.2917 11.6743 68.4375 9.90228L66.5625 9.17264C66.4583 9.17264 66.3542 9.17264 66.3542 9.0684H66.4583C66.4583 3.0228 63.0208 0 56.3542 0C53.2292 0 50.7292 0.833876 49.1667 2.50163C47.7083 3.96091 46.9792 6.14984 46.9792 8.7557C46.3542 8.96417 45.7292 9.17264 45.2083 9.38111L43.3333 10.3192C40.4167 12.2997 38.5417 15.3225 38.5417 20.1173V30.1238C38.5417 31.1661 39.375 32 40.4167 32H42.8125C43.8542 32 44.6875 31.1661 44.6875 30.1238V25.6417H68.8542V30.1238C68.8542 31.1661 69.6875 32 70.7292 32H73.125C74.1667 32 75 31.1661 75 30.1238V20.2215V20.1173V19.9088ZM56.4583 2.08469C53.75 2.08469 51.875 2.7101 50.625 3.96091C49.5833 4.89902 49.0625 6.46254 49.0625 8.33876C50.8333 8.02606 52.8125 7.71335 54.7917 7.60912L58.8542 7.71335C60.8333 7.92182 62.8125 8.23453 64.5833 8.65147C64.4792 5.4202 63.3333 2.08469 56.4583 2.08469ZM54.7917 9.38111L58.8542 9.48534C61.875 9.79805 64.6875 10.215 67.0833 11.1531C67.1875 11.2573 67.2917 11.2573 67.3958 11.2573C70.7292 12.7166 72.9167 15.114 73.2292 19.0749H40.4167C40.8333 12.0912 46.3542 10.0065 54.7917 9.38111ZM40.4167 30.1238H42.8125V25.6417H40.4167V30.1238ZM54.7917 23.7655H40.4167V21.0554H73.125V23.7655H54.7917ZM70.7292 25.6417V30.1238H73.125V25.6417H70.7292Z"
fill="#26201E"
/>
</svg>
)
}

View File

@@ -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 (
<svg
className={classNames}
fill="none"
height="33"
viewBox="0 0 46 33"
width="46"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
clipRule="evenodd"
fillRule="evenodd"
d="M43.4073 15.8263C44.9964 17.3421 46 19.4474 46 22.2263V22.3947V22.4789V30.9C46 31.7421 45.2473 32.5 44.3273 32.5H42.2364C41.3164 32.5 40.5636 31.7421 40.5636 30.9V27.1105H5.52V30.9C5.52 31.7421 4.76727 32.5 3.84727 32.5H1.67273C0.752727 32.5 0 31.7421 0 30.9V22.3947C0 20.2053 0.501818 18.5211 1.25455 17.0895V17.0053V1.34211C1.25455 0.921053 1.67273 0.5 2.09091 0.5H42.5709C43.0727 0.5 43.4073 0.836842 43.4073 1.34211V15.8263ZM1.67273 21.5526H44.3273C44.0764 18.1842 42.0691 16.1632 39.0582 14.8158C38.9745 14.8158 38.8909 14.8158 38.8073 14.7316C34.4582 13.0474 28.1018 13.0474 22.1636 13.0474C10.5382 13.0474 2.17455 13.7211 1.67273 21.5526ZM5.93818 13.3V11.9526C5.93818 6.81579 11.2909 6.22632 13.6327 6.22632C15.8909 6.22632 20.9927 6.73158 21.3273 11.4474C15.8909 11.4474 10.12 11.5316 5.93818 13.3ZM23 11.3632V11.4474C28.1018 11.4474 33.8727 11.5316 38.3891 13.0474V11.3632C38.3891 6.22632 33.0364 5.63684 30.6945 5.63684C28.3527 5.63684 23 6.22632 23 11.3632ZM41.7345 2.18421V14.4789C41.2327 14.1421 40.6473 13.8895 40.0618 13.6368V11.2789C40.0618 6.73158 36.5491 3.95263 30.6945 3.95263C26.3455 3.95263 23.2509 5.46842 21.9964 8.16316C20.5745 5.97368 17.7309 4.71053 13.7164 4.71053C7.86182 4.71053 4.34909 7.40526 4.34909 12.0368V14.1421C3.84727 14.4789 3.42909 14.8158 3.01091 15.1526V2.18421H41.7345ZM1.67273 30.9H3.84727V27.1105H1.67273V30.9ZM1.67273 25.5105V23.1526H44.3273V25.5105H1.67273ZM42.1527 27.1105V30.9H44.3273V27.1105H42.1527Z"
/>
</svg>
)
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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<InnerModalProps>) {
const intl = useIntl()
function modalStateHandler(newAnimationState: AnimationState) {
@@ -74,11 +76,18 @@ function InnerModal({
{({ close }) => (
<>
<header className={styles.header}>
{title && (
<Subtitle type="one" color="uiTextHighContrast">
{title}
</Subtitle>
)}
<div>
{title && (
<Subtitle type="one" color="uiTextHighContrast">
{title}
</Subtitle>
)}
{subtitle && (
<Preamble asChild>
<span>{subtitle}</span>
</Preamble>
)}
</div>
<button onClick={close} type="button" className={styles.close}>
<CloseLargeIcon color="uiTextMediumContrast" />
@@ -99,6 +108,7 @@ export default function Modal({
isOpen,
onToggle,
title,
subtitle,
children,
}: PropsWithChildren<ModalProps>) {
const [animation, setAnimation] = useState<AnimationState>(
@@ -111,6 +121,9 @@ export default function Modal({
isOpen ? AnimationStateEnum.visible : AnimationStateEnum.hidden
)
}
if (isOpen === undefined) {
setAnimation(AnimationStateEnum.unmounted)
}
}, [isOpen])
if (!trigger) {
@@ -122,6 +135,7 @@ export default function Modal({
onToggle={onToggle}
isOpen={isOpen}
title={title}
subtitle={subtitle}
>
{children}
</InnerModal>
@@ -142,6 +156,7 @@ export default function Modal({
animation={animation}
setAnimation={setAnimation}
title={title}
subtitle={subtitle}
>
{children}
</InnerModal>

View File

@@ -23,6 +23,9 @@
display: flex;
flex-direction: column;
/* For removing focus outline when modal opens first time */
outline: 0 none;
/* for supporting animations within content */
position: relative;
overflow: hidden;
@@ -33,8 +36,8 @@
box-sizing: content-box;
display: flex;
align-items: center;
height: var(--button-dimension);
align-items: flex-start;
min-height: var(--button-dimension);
position: relative;
padding: var(--Spacing-x2) var(--Spacing-x3) 0;
}
@@ -57,6 +60,8 @@
height: var(--button-dimension);
display: flex;
align-items: center;
padding: 0;
justify-content: center;
}
@media screen and (min-width: 768px) {
@@ -72,4 +77,4 @@
width: auto;
border-radius: var(--Corner-radius-Medium);
}
}
}

View File

@@ -11,6 +11,7 @@ export type AnimationState = keyof typeof AnimationStateEnum
export type ModalProps = {
onAnimationComplete?: VoidFunction
title?: string
subtitle?: string
} & (
| { trigger: JSX.Element; isOpen?: never; onToggle?: never }
| {

View File

@@ -21,7 +21,6 @@ export default function RoomSidePeek({
const intl = useIntl()
const roomSize = room.roomSize
const occupancy = room.occupancy.total
const roomDescription = room.descriptions.medium
const images = room.images
@@ -40,7 +39,7 @@ export default function RoomSidePeek({
m².{" "}
{intl.formatMessage(
{ id: "booking.accommodatesUpTo" },
{ nrOfGuests: occupancy }
{ max: room.totalOccupancy.max, range: room.totalOccupancy.range }
)}
</Body>
<div className={styles.imageContainer}>

View File

@@ -15,7 +15,7 @@
}
.fullWidth .list {
max-width: var(--max-width-navigation);
max-width: var(--max-width-page);
}
.contentWidth .list {

View File

@@ -8,6 +8,7 @@
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
transition: all 200ms ease;
width: min(100%, 600px);
grid-column-gap: var(--Spacing-x2);
}
.label:hover {

View File

@@ -0,0 +1,138 @@
import { describe, expect, test } from "@jest/globals" // importing because of type conflict with globals from Cypress
import { render, screen } from "@testing-library/react"
import { type UserEvent, userEvent } from "@testing-library/user-event"
import { FormProvider, useForm } from "react-hook-form"
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt"
import { getLocalizedMonthName } from "@/utils/dateFormatting"
import Date from "./index"
interface FormWrapperProps {
defaultValues: Record<string, unknown>
children: React.ReactNode
onSubmit: (data: unknown) => void
}
function FormWrapper({ defaultValues, children, onSubmit }: FormWrapperProps) {
const methods = useForm({
defaultValues,
})
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit((data) => onSubmit(data))}>
{children}
<button type="submit">Submit</button>
</form>
</FormProvider>
)
}
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(
<FormWrapper
defaultValues={{ dateOfBirth: defaultValue }}
onSubmit={handleSubmit}
>
<Date name="dateOfBirth" />
</FormWrapper>
)
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)
)
}
)
})

View File

@@ -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}
>
<Group>
<DateInput className={styles.container}>

View File

@@ -47,7 +47,7 @@ export function Toast({ children, message, onClose, variant }: ToastsProps) {
<div className={styles.content}>{children}</div>
)}
{onClose ? (
<Button onClick={onClose} variant="icon" intent="text">
<Button onClick={onClose} variant="icon" intent="tertiary">
<CloseLargeIcon />
</Button>
) : null}

View File

@@ -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,
}

View File

@@ -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 <emailLink>kontakte os.</emailLink>",
"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",

View File

@@ -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, <emailLink>kontaktieren Sie uns bitte.</emailLink>.",
"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",

View File

@@ -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 <emailLink>contact us.</emailLink>",
"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",

View File

@@ -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, <emailLink>ota meihin yhteyttä.</emailLink>",
"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",

View File

@@ -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 <emailLink>kontakt oss.</emailLink>",
"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",

View File

@@ -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 <emailLink>kontakta oss.</emailLink>",
"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",

View File

@@ -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: {},

View File

@@ -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" }),
}))

View File

@@ -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

38
package-lock.json generated
View File

@@ -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"
}

View File

@@ -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",

View File

@@ -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
}

View File

@@ -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({

View File

@@ -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({

View File

@@ -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(),
})

View File

@@ -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
: "",
}
})

View File

@@ -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) || []
}),
})

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 (
<EnterDetailsProvider
bedTypes={bedTypes}
booking={booking}
showBreakfastStep={true}
packages={null}
roomRate={{
memberRate: {
rateCode: "PLSA2BEU",
localPrice: {
currency: CurrencyEnum.EUR,
pricePerNight: 100,
pricePerStay: 200,
},
},
publicRate: {
rateCode: "SAVEEU",
localPrice: {
currency: CurrencyEnum.EUR,
pricePerNight: 100,
pricePerStay: 200,
},
},
}}
searchParamsStr=""
step={StepEnum.selectBed}
user={null}
vat={0}
>
{children}
</EnterDetailsProvider>
)
}
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)
})
})

View File

@@ -1,6 +1,6 @@
import type { Reward } from "@/server/routers/contentstack/reward/output"
export interface Surprise extends Reward {
export interface Surprise extends Omit<Reward, "operaRewardId"> {
coupons: { couponCode?: string; expiresAt?: string }[]
}

View File

@@ -8,7 +8,7 @@ export type Child = {
bed: number
}
export type GuestsRoom = {
export type TGuestsRoom = {
adults: number
child: Child[]
}

View File

@@ -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<typeof bookingWidgetSchema>
@@ -13,7 +13,7 @@ export type BookingWidgetSearchParams = {
hotel?: string
fromDate?: string
toDate?: string
room?: GuestsRoom[]
room?: TGuestsRoom[]
}
export type BookingWidgetType = VariantProps<

View File

@@ -0,0 +1,6 @@
export interface SocialLinkProps {
link: {
href: string
title: string
}
}

View File

@@ -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",
}

View File

@@ -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
}
}

Some files were not shown because too many files have changed in this diff Show More