Merge remote-tracking branch 'origin' into feature/tracking

This commit is contained in:
Linus Flood
2024-12-13 09:02:37 +01:00
329 changed files with 4494 additions and 1910 deletions

View File

@@ -43,7 +43,7 @@ export default function CardsGrid({
return (
<Card
theme={
cards_grid.theme ?? (card.backgroundImage ? "image" : "one")
card.backgroundImage ? "image" : cards_grid.theme ?? "one"
}
key={card.system.uid}
scriptedTopTitle={card.scripted_top_title}

View File

@@ -12,7 +12,7 @@ import SectionWrapper from "../SectionWrapper"
import styles from "./loyaltyLevels.module.css"
import { LoyaltyLevelsProps } from "@/types/components/blocks/dynamicContent"
import type { LoyaltyLevelsProps } from "@/types/components/blocks/dynamicContent"
import type { LevelCardProps } from "@/types/components/overviewTable"
export default async function LoyaltyLevels({

View File

@@ -1,6 +1,6 @@
import { VariantProps } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { heroVariants } from "./heroVariants"
import type { heroVariants } from "./heroVariants"
export interface HeroProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "color">,

View File

@@ -1,6 +1,7 @@
import { HeroProps } from "./hero"
import { heroVariants } from "./heroVariants"
import type { HeroProps } from "./hero"
export default function Hero({ className, color, children }: HeroProps) {
const classNames = heroVariants({ className, color })
return <section className={classNames}>{children}</section>

View File

@@ -7,7 +7,7 @@ import { getMembership } from "@/utils/user"
import PointsContainer from "./Container"
import { NextLevelPointsColumn, YourPointsColumn } from "./PointsColumn"
import { UserProps } from "@/types/components/myPages/user"
import type { UserProps } from "@/types/components/myPages/user"
export default async function Points({ user }: UserProps) {
const intl = await getIntl()

View File

@@ -4,7 +4,7 @@ import { useReducer } from "react"
import { useIntl } from "react-intl"
import {
MembershipLevel,
type MembershipLevel,
MembershipLevelEnum,
} from "@/constants/membershipLevels"
@@ -22,8 +22,8 @@ import styles from "./overviewTable.module.css"
import type { Key } from "react-aria-components"
import {
ComparisonLevel,
DesktopSelectColumns,
type ComparisonLevel,
type DesktopSelectColumns,
type MobileColumnHeaderProps,
OverviewTableActionsEnum,
type OverviewTableClientProps,

View File

@@ -4,7 +4,7 @@ import { serverClient } from "@/lib/trpc/server"
import SectionWrapper from "../SectionWrapper"
import OverviewTableClient from "./Client"
import { OverviewTableProps } from "@/types/components/blocks/dynamicContent"
import type { OverviewTableProps } from "@/types/components/blocks/dynamicContent"
export default async function OverviewTable({
dynamic_content,

View File

@@ -9,7 +9,7 @@ import {
type LevelWithRewards,
OverviewTableActionsEnum,
type OverviewTableClientProps,
OverviewTableReducerAction,
type OverviewTableReducerAction,
} from "@/types/components/overviewTable"
export function getLevel(

View File

@@ -6,11 +6,11 @@ import { useState } from "react"
import { trpc } from "@/lib/trpc/client"
import LoadingSpinner from "@/components/LoadingSpinner"
import Pagination from "@/components/MyPages/Pagination"
import ClientTable from "./ClientTable"
import Pagination from "./Pagination"
import { Transactions } from "@/types/components/myPages/myPage/earnAndBurn"
import type { Transactions } from "@/types/components/myPages/myPage/earnAndBurn"
export default function TransactionTable({
initialJourneyTransactions,

View File

@@ -5,7 +5,7 @@ import SectionHeader from "@/components/Section/Header"
import ExpiringPointsTable from "./ExpiringPointsTable"
import { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default async function ExpiringPoints({
link,

View File

@@ -12,8 +12,8 @@ import {
YourPointsColumn,
} from "../../../Overview/Stats/Points/PointsColumn"
import { UserProps } from "@/types/components/myPages/user"
import { LangParams } from "@/types/params"
import type { UserProps } from "@/types/components/myPages/user"
import type { LangParams } from "@/types/params"
/* TODO */
export default async function Points({ user, lang }: UserProps & LangParams) {

View File

@@ -1,76 +0,0 @@
"use client"
import { trpc } from "@/lib/trpc/client"
import { Reward } from "@/server/routers/contentstack/reward/output"
import LoadingSpinner from "@/components/LoadingSpinner"
import Grids from "@/components/TempDesignSystem/Grids"
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import Title from "@/components/TempDesignSystem/Text/Title"
import useLang from "@/hooks/useLang"
import styles from "./current.module.css"
type CurrentRewardsClientProps = {
initialCurrentRewards: { rewards: Reward[]; nextCursor: number | undefined }
}
export default function ClientCurrentRewards({
initialCurrentRewards,
}: CurrentRewardsClientProps) {
const lang = useLang()
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
trpc.contentstack.rewards.current.useInfiniteQuery(
{
limit: 3,
lang,
},
{
getNextPageParam: (lastPage) => lastPage?.nextCursor,
initialData: {
pageParams: [undefined, 1],
pages: [initialCurrentRewards],
},
}
)
function loadMoreData() {
if (hasNextPage) {
fetchNextPage()
}
}
const filteredRewards =
data?.pages.filter((page) => page && page.rewards) ?? []
const rewards = filteredRewards.flatMap((page) => page?.rewards) as Reward[]
if (isLoading) {
return <LoadingSpinner />
}
if (!rewards.length) {
return null
}
return (
<div>
<Grids.Stackable>
{rewards.map((reward, idx) => (
<article className={styles.card} key={`${reward.reward_id}-${idx}`}>
<Title
as="h4"
level="h3"
textAlign="center"
textTransform="regular"
>
{reward.label}
</Title>
</article>
))}
</Grids.Stackable>
{hasNextPage &&
(isFetching ? (
<LoadingSpinner />
) : (
<ShowMoreButton loadMoreData={loadMoreData} />
))}
</div>
)
}

View File

@@ -1,12 +0,0 @@
.card {
align-items: center;
background-color: var(--UI-Opacity-White-100);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Medium);
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
justify-content: center;
min-height: 280px;
padding: var(--Spacing-x7) var(--Spacing-x3);
}

View File

@@ -0,0 +1,73 @@
"use client"
import { useRef, useState } from "react"
import { RewardIcon } from "@/components/Blocks/DynamicContent/Rewards/RewardIcon"
import Pagination from "@/components/MyPages/Pagination"
import Grids from "@/components/TempDesignSystem/Grids"
import Title from "@/components/TempDesignSystem/Text/Title"
import Redeem from "./Redeem"
import styles from "./current.module.css"
import type { CurrentRewardsClientProps } from "@/types/components/myPages/myPage/accountPage"
export default function ClientCurrentRewards({
rewards,
pageSize,
showRedeem,
}: CurrentRewardsClientProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [currentPage, setCurrentPage] = useState(1)
const totalPages = Math.ceil(rewards.length / pageSize)
const startIndex = (currentPage - 1) * pageSize
const endIndex = startIndex + pageSize
const currentRewards = rewards.slice(startIndex, endIndex)
function handlePageChange(page: number) {
requestAnimationFrame(() => {
setCurrentPage(page)
containerRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
inline: "nearest",
})
})
}
return (
<div ref={containerRef} className={styles.container}>
<Grids.Stackable>
{currentRewards.map((reward, idx) => (
<article className={styles.card} key={`${reward.reward_id}-${idx}`}>
<div className={styles.content}>
<RewardIcon rewardId={reward.reward_id} />
<Title
as="h4"
level="h3"
textAlign="center"
textTransform="regular"
>
{reward.label}
</Title>
</div>
{showRedeem && "redeem_description" in reward && (
<div className={styles.btnContainer}>
<Redeem reward={reward} />
</div>
)}
</article>
))}
</Grids.Stackable>
{totalPages > 1 && (
<Pagination
pageCount={totalPages}
currentPage={currentPage}
handlePageChange={handlePageChange}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,191 @@
"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 { RewardIcon } from "../RewardIcon"
import styles from "./current.module.css"
import type {
RedeemModalState,
RedeemProps,
RedeemStep,
} from "@/types/components/myPages/myPage/accountPage"
const MotionOverlay = motion(ModalOverlay)
const MotionModal = motion(Modal)
export default function Redeem({ reward }: 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" && (
<div className={styles.badge}>
<div className={styles.redeemed}>
<CheckCircleIcon color="uiSemanticSuccess" />
<Caption>
{intl.formatMessage({
id: "Redeemed & valid through:",
})}
</Caption>
</div>
<Countdown />
</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" &&
"redeem_description" in reward && (
<Body textAlign="center">
{reward.redeem_description}
</Body>
)}
</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" },
},
},
}

View File

@@ -0,0 +1,132 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Spacing-x4);
position: relative;
scroll-margin-top: calc(var(--current-mobile-site-header-height) * 2);
}
.card {
background-color: var(--UI-Opacity-White-100);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Medium);
display: flex;
flex-direction: column;
justify-content: space-between;
}
.content {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
align-items: center;
justify-content: center;
padding: var(--Spacing-x3);
}
.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;
}

View File

@@ -1,3 +1,4 @@
import { env } from "@/env/server"
import { serverClient } from "@/lib/trpc/server"
import SectionContainer from "@/components/Section/Container"
@@ -13,19 +14,20 @@ export default async function CurrentRewardsBlock({
subtitle,
link,
}: AccountPageComponentProps) {
const initialCurrentRewards =
await serverClient().contentstack.rewards.current({
limit: 3,
})
const rewardsResponse = await serverClient().contentstack.rewards.current()
if (!initialCurrentRewards) {
if (!rewardsResponse?.rewards.length) {
return null
}
return (
<SectionContainer>
<SectionHeader title={title} link={link} preamble={subtitle} />
<ClientCurrentRewards initialCurrentRewards={initialCurrentRewards} />
<ClientCurrentRewards
rewards={rewardsResponse.rewards}
pageSize={6}
showRedeem={env.USE_NEW_REWARDS_ENDPOINT && env.USE_NEW_REWARD_MODEL}
/>
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)

View File

@@ -0,0 +1,68 @@
import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name"
import { isValidRewardId } from "@/utils/rewards"
import type { FC } from "react"
import { IconName, type IconProps } from "@/types/components/icon"
import { RewardId } from "@/types/enums/rewards"
function getIconForRewardId(rewardId: RewardId): IconName {
switch (rewardId) {
// Food & beverage
case RewardId.TenPercentFood:
case RewardId.FifteenPercentFood:
return IconName.CroissantCoffeeEgg
case RewardId.TwoForOneBreakfast:
return IconName.CutleryTwo
case RewardId.FreeBreakfast:
return IconName.CutleryOne
case RewardId.FreeKidsDrink:
return IconName.KidsMocktail
// Monetary vouchers
case RewardId.Bonus50SEK:
case RewardId.Bonus75SEK:
case RewardId.Bonus100SEK:
case RewardId.Bonus150SEK:
case RewardId.Bonus200SEK:
return IconName.Voucher
// Hotel perks
case RewardId.EarlyCheckin:
return IconName.HandKey
case RewardId.LateCheckout:
return IconName.HotelNight
case RewardId.FreeUpgrade:
return IconName.MagicWand
case RewardId.RoomGuarantee48H:
return IconName.Bed
// Earnings
case RewardId.EarnRate25Percent:
case RewardId.EarnRate50Percent:
return IconName.MoneyHand
case RewardId.StayBoostForKids:
return IconName.Kids
case RewardId.MemberRate:
return IconName.Coin
// Special
case RewardId.YearlyExclusiveGift:
return IconName.GiftOpen
default: {
const unhandledRewardId: never = rewardId
return IconName.GiftOpen
}
}
}
export function mapRewardToIcon(rewardId: string): FC<IconProps> | null {
if (!isValidRewardId(rewardId)) {
// TODO: Update once UX has decided on fallback icon.
return getIconByIconName(IconName.GiftOpen)
}
const iconName = getIconForRewardId(rewardId)
return getIconByIconName(iconName)
}

View File

@@ -0,0 +1,27 @@
import { mapRewardToIcon } from "./data"
import type { RewardIconProps } from "@/types/components/myPages/rewards"
// Original SVG aspect ratio is 358:202 (≈1.77:1)
const sizeMap = {
small: { width: 120, height: 68 }, // 40% of card width
medium: { width: 180, height: 102 }, // 60% of card width
large: { width: 240, height: 135 }, // 80% of card width
} as const
export function RewardIcon({
rewardId,
size = "medium",
...props
}: RewardIconProps) {
const IconComponent = mapRewardToIcon(rewardId)
if (!IconComponent) return null
return (
<IconComponent
{...props}
width={sizeMap[size].width}
height={sizeMap[size].height}
/>
)
}

View File

@@ -6,7 +6,7 @@ import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import SignupForm from "@/components/Forms/Signup"
import { getLang } from "@/i18n/serverContext"
import { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent"
import type { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent"
export default async function SignupFormWrapper({
dynamic_content,

View File

@@ -8,7 +8,7 @@ import Grids from "@/components/TempDesignSystem/Grids"
import StayCard from "../StayCard"
import EmptyUpcomingStaysBlock from "./EmptyUpcomingStays"
import { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default async function SoonestStays({
title,

View File

@@ -9,7 +9,7 @@ import OverviewTable from "@/components/Blocks/DynamicContent/OverviewTable"
import EarnAndBurn from "@/components/Blocks/DynamicContent/Points/EarnAndBurn"
import ExpiringPoints from "@/components/Blocks/DynamicContent/Points/ExpiringPoints"
import PointsOverview from "@/components/Blocks/DynamicContent/Points/Overview"
import CurrentRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/CurrentLevel"
import CurrentRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/CurrentRewards"
import NextLevelRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/NextLevel"
import SignupFormWrapper from "@/components/Blocks/DynamicContent/SignupFormWrapper"
import SignUpVerification from "@/components/Blocks/DynamicContent/SignUpVerification"

View File

@@ -1,4 +1,4 @@
import { UspIcon } from "@/types/components/blocks/uspGrid"
import type { UspIcon } from "@/types/components/blocks/uspGrid"
import { IconName } from "@/types/components/icon"
export function getUspIconName(icon?: UspIcon | null) {

View File

@@ -114,9 +114,9 @@ export default function BookingWidgetClient({
rooms: defaultRoomsData,
},
shouldFocusError: false,
mode: "all",
mode: "onSubmit",
resolver: zodResolver(bookingWidgetSchema),
reValidateMode: "onChange",
reValidateMode: "onSubmit",
})
function closeMobileSearch() {

View File

@@ -36,7 +36,7 @@ export default async function AmenitiesList({
height={20}
/>
)}
<Body color="textMediumContrast">{facility.name}</Body>
<Body color="uiTextMediumContrast">{facility.name}</Body>
</div>
)
})}

View File

@@ -23,6 +23,7 @@ export default function ActivitiesCardGrid(activitiesCard: ActivityCard) {
href: `?s=${activities[lang]}`,
title: activitiesCard.ctaText,
isExternal: false,
scrollOnClick: false,
}
: undefined,
secondaryButton: hasImage
@@ -31,6 +32,7 @@ export default function ActivitiesCardGrid(activitiesCard: ActivityCard) {
href: `?s=${activities[lang]}`,
title: activitiesCard.ctaText,
isExternal: false,
scrollOnClick: false,
},
}
return (

View File

@@ -51,7 +51,7 @@ export default async function IntroSection({
</BiroScript>
<Title level="h2">{hotelName}</Title>
</div>
<Body color="textMediumContrast">{formattedLocationText}</Body>
<Body color="uiTextMediumContrast">{formattedLocationText}</Body>
{hasTripAdvisorData && (
<Link
className={styles.introLink}

View File

@@ -1,4 +1,4 @@
import {
import type {
HotelAddress,
HotelData,
HotelLocation,

View File

@@ -1,6 +1,6 @@
"use client"
import { PropsWithChildren, useRef } from "react"
import { type PropsWithChildren, useRef } from "react"
import { StickyElementNameEnum } from "@/stores/sticky-position"

View File

@@ -28,6 +28,7 @@ export default function PreviewImages({
title={image.metaData.title}
width={index === 0 ? 752 : 292}
height={index === 0 ? 540 : 266}
onClick={() => setLightboxIsOpen(true)}
className={styles.image}
/>
))}

View File

@@ -13,6 +13,7 @@
width: 100%;
height: 100%;
max-height: 30vh;
cursor: pointer;
}
.imageWrapper > :nth-child(2),

View File

@@ -4,9 +4,9 @@ import { useRef, useState } from "react"
import { useIntl } from "react-intl"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import Grids from "@/components/TempDesignSystem/Grids"
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import Title from "@/components/TempDesignSystem/Text/Title"
import { RoomCard } from "./RoomCard"
@@ -35,19 +35,19 @@ export function Rooms({ rooms }: RoomsProps) {
className={styles.roomsContainer}
>
<div ref={scrollRef} className={styles.scrollRef}></div>
<SectionHeader
textTransform="capitalize"
title={intl.formatMessage({ id: "Rooms" })}
preamble={null}
/>
<Title as="h3" level="h2">
{intl.formatMessage({ id: "Rooms" })}
</Title>
<Grids.Stackable
className={`${styles.grid} ${allRoomsVisible ? styles.allVisible : ""}`}
>
{rooms.map((room) => (
<div key={room.id}>
<RoomCard room={room} />
</div>
))}
{rooms
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((room) => (
<div key={room.id}>
<RoomCard room={room} />
</div>
))}
</Grids.Stackable>
{showToggleButton ? (

View File

@@ -40,6 +40,7 @@ export default async function AboutTheHotelSidePeek({
<Divider color="baseSurfaceSubtleHover" />
<Preamble>{descriptions.descriptions.medium}</Preamble>
<Body>{descriptions.facilityInformation}</Body>
<Body>{descriptions.surroundingInformation}</Body>
</section>
</SidePeek>
)

View File

@@ -17,6 +17,7 @@ export default async function AccessibilityAmenity({
<AccordionItem
title={intl.formatMessage({ id: "Accessibility" })}
icon={IconName.Accessibility}
variant="sidepeek"
>
<div className={styles.wrapper}>
{accessibility?.description && (

View File

@@ -9,6 +9,7 @@ export default async function BreakfastAmenity() {
<AccordionItem
title={intl.formatMessage({ id: "Breakfast" })}
icon={IconName.CoffeeAlt}
variant="sidepeek"
>
{/* TODO: breakfast to be implemented */}
</AccordionItem>

View File

@@ -14,6 +14,7 @@ export default async function CheckInAmenity({
<AccordionItem
title={`${intl.formatMessage({ id: "Check-in" })}/${intl.formatMessage({ id: "Check-out" })}`}
icon={IconName.Business}
variant="sidepeek"
>
<Body textTransform="bold">{intl.formatMessage({ id: "Times" })}</Body>
<Body color="uiTextHighContrast">{`${intl.formatMessage({ id: "Check in from" })}: ${checkInTime}`}</Body>

View File

@@ -25,6 +25,7 @@ export default async function ParkingAmenity({
<AccordionItem
title={intl.formatMessage({ id: "Parking" })}
icon={IconName.Parking}
variant="sidepeek"
>
<div className={styles.wrapper}>
{parking.map((data) => (

View File

@@ -1,10 +1,10 @@
.wrapper {
padding: var(--Spacing-x1);
padding: var(--Spacing-x1) var(--Spacing-x0);
border-bottom: 1px solid var(--Base-Border-Subtle);
}
.amenity {
display: flex;
gap: var(--Spacing-x1);
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
padding: var(--Spacing-x-one-and-half) var(--Spacing-x1);
}

View File

@@ -1,5 +1,5 @@
import { HeartIcon } from "@/components/Icons"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { mapFacilityToIcon } from "../../../data"
@@ -15,16 +15,18 @@ export default function FilteredAmenities({
{filteredAmenities?.map((amenity) => {
const Icon = mapFacilityToIcon(amenity.id)
return (
<div key={amenity.name} className={styles.wrapper}>
<li key={amenity.name} className={styles.wrapper}>
<div className={styles.amenity}>
{Icon ? (
<Icon color="burgundy" width={24} height={24} />
) : (
<HeartIcon color="burgundy" width={24} height={24} />
)}
<Body color="burgundy">{amenity.name}</Body>
<Subtitle color="burgundy" type="two">
{amenity.name}
</Subtitle>
</div>
</div>
</li>
)
})}
</>

View File

@@ -6,7 +6,7 @@ import { getIntl } from "@/i18n"
import styles from "./facility.module.css"
import { FacilityProps } from "@/types/components/hotelPage/sidepeek/facility"
import type { FacilityProps } from "@/types/components/hotelPage/sidepeek/facility"
export default async function Facility({ data }: FacilityProps) {
const intl = await getIntl()

View File

@@ -14,6 +14,7 @@
justify-content: flex-start;
padding: 0 var(--Spacing-x2);
width: 100%;
overflow-x: auto;
}
@media screen and (min-width: 768px) {
@@ -26,5 +27,6 @@
.tabsContainer {
padding: 0 var(--Spacing-x5);
max-width: calc(100% - var(--hotel-page-map-desktop-width));
overflow-x: visible;
}
}

View File

@@ -1,8 +1,8 @@
import { FC } from "react"
import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name"
import { IconName, IconProps } from "@/types/components/icon"
import type { FC } from "react"
import { IconName, type IconProps } from "@/types/components/icon"
import { FacilityEnum } from "@/types/enums/facilities"
const facilityToIconMap: Record<FacilityEnum, IconName> = {

View File

@@ -78,7 +78,7 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
const roomCategories =
hotelData.included?.filter((item) => item.type === "roomcategories") || []
const images = gallery?.smallerImages
const description = hotelContent.texts.descriptions.short
const description = hotelContent.texts.descriptions.medium
const activitiesCard = content?.[0]?.upcoming_activities_card || null
const facilities: Facility[] = [

View File

@@ -1,10 +1,9 @@
import { staticPageVariants } from "./variants"
import type { VariantProps } from "class-variance-authority"
import type { TrackingSDKPageData } from "@/types/components/tracking"
import type { CollectionPage } from "@/types/trpc/routers/contentstack/collectionPage"
import type { ContentPage } from "@/types/trpc/routers/contentstack/contentPage"
import type { staticPageVariants } from "./variants"
export interface StaticPageProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "content">,

View File

@@ -0,0 +1,34 @@
"use client"
import { useState } from "react"
import { useInterval } from "usehooks-ts"
import { dt } from "@/lib/dt"
import Title from "@/components/TempDesignSystem/Text/Title"
import type { CountdownProps } from "@/types/components/countdown"
export default function Countdown({
minutes = 30,
seconds = 0,
}: CountdownProps) {
const [time, setTime] = useState(dt.duration({ minutes, seconds }))
const timeSeconds = time.asSeconds()
useInterval(
() => {
setTime((currentTime) => {
const newTime = currentTime.asMilliseconds() - 1000
return dt.duration(newTime)
})
},
timeSeconds > 0 ? 1000 : null
)
return (
<Title as="h1">
<time dateTime={time.toISOString()}>{time.format("m:ss")}</time>
</Title>
)
}

View File

@@ -2,8 +2,7 @@ import { cva } from "class-variance-authority"
import styles from "./list.module.css"
import type { ListItem } from "@/types/requests/blocks/list"
import { BlockListItemsEnum } from "@/types/requests/blocks/list"
import { BlockListItemsEnum, type ListItem } from "@/types/requests/blocks/list"
const config = {
variants: {

View File

@@ -1,7 +1,7 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { Lang, languages } from "@/constants/languages"
import { type Lang, languages } from "@/constants/languages"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"

View File

@@ -1,7 +1,7 @@
"use client"
import { useState } from "react"
import { Lang, languages } from "@/constants/languages"
import { type Lang, languages } from "@/constants/languages"
import useLang from "@/hooks/useLang"

View File

@@ -1,7 +1,7 @@
import Desktop from "./Desktop"
import Mobile from "./Mobile"
import { LanguageSwitcherData } from "@/types/requests/languageSwitcher"
import type { LanguageSwitcherData } from "@/types/requests/languageSwitcher"
type LanguageSwitcherProps = { urls: LanguageSwitcherData }

View File

@@ -3,7 +3,6 @@ import { Fragment } from "react"
import { useIntl } from "react-intl"
import { logout } from "@/constants/routes/handleAuth"
import { navigationQueryRouter } from "@/server/routers/contentstack/myPages/navigation/query"
import useDropdownStore from "@/stores/main-menu"
import Divider from "@/components/TempDesignSystem/Divider"
@@ -14,6 +13,7 @@ import useLang from "@/hooks/useLang"
import styles from "./my-pages-mobile-dropdown.module.css"
import { DropdownTypeEnum } from "@/types/components/dropdown/dropdown"
import type { navigationQueryRouter } from "@/server/routers/contentstack/myPages/navigation/query"
type Navigation = Awaited<ReturnType<(typeof navigationQueryRouter)["get"]>>

View File

@@ -1,4 +1,4 @@
import { Lang } from "@/constants/languages"
import type { Lang } from "@/constants/languages"
type Texts = {
title: string

View File

@@ -3,7 +3,7 @@
import { usePathname, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
import {
import type {
SiteSectionObject,
TrackingData,
TrackingProps,

View File

@@ -7,13 +7,13 @@ import type { EmbedByUid } from "@/types/components/deprecatedjsontohtml"
import { EmbedEnum } from "@/types/requests/utils/embeds"
import type { Attributes } from "@/types/rte/attrs"
import { RTEItemTypeEnum, RTETypeEnum } from "@/types/rte/enums"
import type {
RTEDefaultNode,
RTENext,
RTENode,
RTERegularNode,
import {
type RTEDefaultNode,
RTEMarkType,
type RTENext,
type RTENode,
type RTERegularNode,
} from "@/types/rte/node"
import { RTEMarkType } from "@/types/rte/node"
import type { RenderOptions } from "@/types/rte/option"
function extractPossibleAttributes(attrs: Attributes | undefined) {

View File

@@ -17,7 +17,7 @@ import { hasAvailableParagraphFormat, hasAvailableULFormat } from "./utils"
import styles from "./jsontohtml.module.css"
import type { EmbedByUid } from "@/types/components/deprecatedjsontohtml"
import { ImageVaultAsset } from "@/types/components/imageVault"
import type { ImageVaultAsset } from "@/types/components/imageVault"
import { EmbedEnum } from "@/types/requests/utils/embeds"
import type { Attributes, RTEImageVaultAttrs } from "@/types/rte/attrs"
import {
@@ -25,15 +25,15 @@ import {
RTEItemTypeEnum,
RTETypeEnum,
} from "@/types/rte/enums"
import type {
RTEDefaultNode,
RTEImageNode,
RTENext,
RTENode,
RTERegularNode,
RTETextNode,
import {
type RTEDefaultNode,
type RTEImageNode,
RTEMarkType,
type RTENext,
type RTENode,
type RTERegularNode,
type RTETextNode,
} from "@/types/rte/node"
import { RTEMarkType } from "@/types/rte/node"
import type { RenderOptions } from "@/types/rte/option"
function extractPossibleAttributes(attrs: Attributes | undefined) {

View File

@@ -8,12 +8,13 @@ import {
AvailableULFormatEnum,
RTETypeEnum,
} from "@/types/rte/enums"
import type {
RTENode,
RTERenderOptionComponent,
RTETextNode,
import {
RTEMarkType,
type RTENode,
type RTERenderMark,
type RTERenderOptionComponent,
type RTETextNode,
} from "@/types/rte/node"
import { RTEMarkType, RTERenderMark } from "@/types/rte/node"
import type { RenderOptions } from "@/types/rte/option"
export function groupEmbedsByUid(embedsArray: Node<Embeds>[]) {

View File

@@ -12,7 +12,7 @@ import { getLang } from "@/i18n/serverContext"
import styles from "./details.module.css"
import type { SocialIconsProps } from "@/types/components/footer/socialIcons"
import { IconName } from "@/types/components/icon"
import type { IconName } from "@/types/components/icon"
function SocialIcon({ iconName }: SocialIconsProps) {
const SocialIcon = getIconByIconName(iconName as IconName)

View File

@@ -1,4 +1,5 @@
import React, { forwardRef, InputHTMLAttributes } from "react"
import React, { forwardRef, type InputHTMLAttributes } from "react"
import { Input as InputRAC } from "react-aria-components"
import Body from "@/components/TempDesignSystem/Text/Body"
@@ -10,7 +11,7 @@ const Input = forwardRef<
>(function InputComponent(props, ref) {
return (
<Body asChild>
<input {...props} ref={ref} className={styles.input} />
<InputRAC {...props} ref={ref} className={styles.input} />
</Body>
)
})

View File

@@ -23,7 +23,7 @@ export default function List({
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
index={initialIndex + index}
key={location.id}
key={location.id + index}
location={location}
/>
))}

View File

@@ -29,23 +29,17 @@ export default function SearchList({
}: SearchListProps) {
const intl = useIntl()
const [hasMounted, setHasMounted] = useState(false)
const [isFormSubmitted, setIsFormSubmitted] = useState(false)
const {
clearErrors,
formState: { errors, isSubmitted },
} = useFormContext()
const searchError = errors["search"]
useEffect(() => {
setIsFormSubmitted(isSubmitted)
}, [isSubmitted])
useEffect(() => {
let timeoutID: ReturnType<typeof setTimeout> | null = null
if (searchError && searchError.message === "Required") {
if (searchError) {
timeoutID = setTimeout(() => {
clearErrors("search")
setIsFormSubmitted(false)
// magic number originates from animation
// 5000ms delay + 120ms exectuion
}, 5120)
@@ -66,7 +60,7 @@ export default function SearchList({
return null
}
if (searchError && isFormSubmitted) {
if (searchError && isSubmitted) {
if (typeof searchError.message === "string") {
if (!isOpen) {
if (searchError.message === "Required") {
@@ -87,6 +81,24 @@ export default function SearchList({
</Body>
</Dialog>
)
} else if (searchError.type === "custom") {
return (
<Dialog
className={styles.fadeOut}
getMenuProps={getMenuProps}
variant="error"
>
<Caption className={styles.heading} color="red">
<ErrorCircleIcon color="red" />
{intl.formatMessage({ id: "No results" })}
</Caption>
<Body>
{intl.formatMessage({
id: "We couldn't find a matching location for your search.",
})}
</Body>
</Dialog>
)
}
}
}

View File

@@ -1,9 +1,9 @@
"use client"
import Downshift from "downshift"
import {
ChangeEvent,
FocusEvent,
FormEvent,
type ChangeEvent,
type FocusEvent,
type FormEvent,
useCallback,
useEffect,
useReducer,
@@ -26,8 +26,10 @@ import type { SearchProps } from "@/types/components/search"
import type { Location } from "@/types/trpc/routers/hotel/locations"
const name = "search"
export default function Search({ locations }: SearchProps) {
const { register, setValue, trigger } = useFormContext<BookingWidgetSchema>()
export default function Search({ locations, handlePressEnter }: SearchProps) {
const { register, setValue, unregister } =
useFormContext<BookingWidgetSchema>()
const intl = useIntl()
const value = useWatch({ name })
const [state, dispatch] = useReducer(
@@ -88,7 +90,6 @@ export default function Search({ locations }: SearchProps) {
setValue("location", encodeURIComponent(stringified))
sessionStorage.setItem(sessionStorageKey, stringified)
setValue(name, selectedItem.name)
trigger()
const searchHistoryMap = new Map()
searchHistoryMap.set(selectedItem.name, selectedItem)
@@ -135,6 +136,27 @@ export default function Search({ locations }: SearchProps) {
}
}, [dispatch])
const stayType = state.searchData?.type === "cities" ? "city" : "hotel"
const stayValue =
(value === state.searchData?.name &&
((state.searchData?.type === "cities" && state.searchData?.name) ||
state.searchData?.id)) ||
""
useEffect(() => {
if (stayType === "city") {
unregister("hotel")
setValue(stayType, stayValue, {
shouldValidate: true,
})
} else {
unregister("city")
setValue(stayType, Number(stayValue), {
shouldValidate: true,
})
}
}, [stayType, stayValue, unregister, setValue])
return (
<Downshift
initialSelectedItem={state.searchData}
@@ -142,9 +164,9 @@ export default function Search({ locations }: SearchProps) {
itemToString={(value) => (value ? value.name : "")}
onSelect={handleOnSelect}
onInputValueChange={(inputValue) => dispatchInputValue(inputValue)}
defaultHighlightedIndex={0}
>
{({
closeMenu,
getInputProps,
getItemProps,
getLabelProps,
@@ -155,6 +177,10 @@ export default function Search({ locations }: SearchProps) {
openMenu,
}) => (
<div className={styles.container}>
{value ? (
// Adding hidden input to define hotel or city based on destination selection for basic form submit.
<input type="hidden" {...register(stayType)} />
) : null}
<label {...getLabelProps({ htmlFor: name })} className={styles.label}>
<Caption
type="bold"
@@ -181,11 +207,13 @@ export default function Search({ locations }: SearchProps) {
id: "Destinations & hotels",
}),
...register(name, {
onBlur: function () {
closeMenu()
},
onChange: handleOnChange,
}),
onKeyDown: (e) => {
if (e.key === "Enter" && !isOpen) {
handlePressEnter()
}
},
type: "search",
})}
/>

View File

@@ -41,11 +41,13 @@ export function reducer(state: State, action: Action) {
const search = action.payload.search.toLowerCase()
state.defaultLocations.forEach((location) => {
const locationName = location.name.toLowerCase()
const keyWords = location.keyWords?.map((l) => l.toLowerCase())
const keyWords = location.keyWords?.flatMap((l) =>
l.toLowerCase().split(" ")
)
if (locationName.includes(search.trim())) {
matchesMap.set(location.name, location)
}
if (keyWords?.find((keyWord) => keyWord.includes(search.trim()))) {
if (keyWords?.find((keyWord) => keyWord.startsWith(search.trim()))) {
matchesMap.set(location.name, location)
}
})

View File

@@ -21,6 +21,7 @@ import type { BookingWidgetFormContentProps } from "@/types/components/form/book
export default function FormContent({
locations,
formId,
onSubmit,
}: BookingWidgetFormContentProps) {
const intl = useIntl()
const selectedDate = useWatch({ name: "date" })
@@ -34,7 +35,7 @@ export default function FormContent({
<div className={styles.input}>
<div className={styles.inputContainer}>
<div className={styles.where}>
<Search locations={locations} />
<Search locations={locations} handlePressEnter={onSubmit} />
</div>
<div className={styles.when}>
<Caption color="red" type="bold">

View File

@@ -1,5 +1,6 @@
"use client"
import { useRouter } from "next/navigation"
import { Form as FormRAC } from "react-aria-components"
import { useFormContext } from "react-hook-form"
import { selectHotel, selectRate } from "@/constants/routes/hotelReservation"
@@ -13,7 +14,7 @@ import styles from "./form.module.css"
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
import type { BookingWidgetFormProps } from "@/types/components/form/bookingwidget"
import { Location } from "@/types/trpc/routers/hotel/locations"
import type { Location } from "@/types/trpc/routers/hotel/locations"
const formId = "booking-widget"
@@ -62,14 +63,18 @@ export default function Form({
return (
<section className={classNames}>
<form
<FormRAC
onSubmit={handleSubmit(onSubmit)}
className={styles.form}
id={formId}
>
<input {...register("location")} type="hidden" />
<FormContent locations={locations} formId={formId} />
</form>
<FormContent
locations={locations}
formId={formId}
onSubmit={handleSubmit(onSubmit)}
/>
</FormRAC>
</section>
)
}

View File

@@ -35,30 +35,37 @@ export const guestRoomSchema = z
export const guestRoomsSchema = z.array(guestRoomSchema)
export const bookingWidgetSchema = z.object({
bookingCode: z.string(), // Update this as required when working with booking codes component
date: z.object({
// Update this as required once started working with Date picker in Nights component
fromDate: z.string(),
toDate: z.string(),
}),
location: z.string().refine(
(value) => {
if (value) {
const parsedValue: Location = JSON.parse(decodeURIComponent(value))
switch (parsedValue?.type) {
case "cities":
case "hotels":
return true
default:
return false
export const bookingWidgetSchema = z
.object({
bookingCode: z.string(), // Update this as required when working with booking codes component
date: z.object({
// Update this as required once started working with Date picker in Nights component
fromDate: z.string(),
toDate: z.string(),
}),
location: z.string().refine(
(value) => {
if (value) {
const parsedValue: Location = JSON.parse(decodeURIComponent(value))
switch (parsedValue?.type) {
case "cities":
case "hotels":
return true
default:
return false
}
}
}
},
{ message: "Required" }
),
redemption: z.boolean().default(false),
rooms: guestRoomsSchema,
search: z.string({ coerce: true }).min(1, "Required"),
voucher: z.boolean().default(false),
})
},
{ message: "Required" }
),
redemption: z.boolean().default(false),
rooms: guestRoomsSchema,
search: z.string({ coerce: true }).min(1, "Required"),
voucher: z.boolean().default(false),
hotel: z.number().optional(),
city: z.string().optional(),
})
.refine((value) => value.hotel || value.city, {
message: "Destination required",
path: ["search"],
})

View File

@@ -55,10 +55,10 @@ export default function Form({ user }: EditFormProps) {
const methods = useForm<EditProfileSchema>({
defaultValues: {
address: {
city: user.address.city ?? "",
countryCode: user.address.countryCode ?? "",
streetAddress: user.address.streetAddress ?? "",
zipCode: user.address.zipCode ?? "",
city: user.address?.city ?? "",
countryCode: user.address?.countryCode ?? "",
streetAddress: user.address?.streetAddress ?? "",
zipCode: user.address?.zipCode ?? "",
},
dateOfBirth: user.dateOfBirth,
email: user.email,

View File

@@ -5,7 +5,10 @@ import { useRouter } from "next/navigation"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { privacyPolicy } from "@/constants/currentWebHrefs"
import {
membershipTermsAndConditions,
privacyPolicy,
} from "@/constants/currentWebHrefs"
import { trpc } from "@/lib/trpc/client"
import Button from "@/components/TempDesignSystem/Button"
@@ -23,7 +26,7 @@ import Title from "@/components/TempDesignSystem/Text/Title"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import { SignUpSchema, signUpSchema } from "./schema"
import { type SignUpSchema, signUpSchema } from "./schema"
import styles from "./form.module.css"
@@ -38,9 +41,8 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
const phoneNumber = intl.formatMessage({ id: "Phone number" })
const zipCode = intl.formatMessage({ id: "Zip code" })
const signupButtonText = intl.formatMessage({
id: "Sign up to Scandic Friends",
id: "Join now",
})
const signingUpPendingText = intl.formatMessage({ id: "Signing up..." })
const signup = trpc.user.signup.useMutation({
onSuccess: (data) => {
@@ -157,27 +159,39 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
</Subtitle>
</header>
<Checkbox name="termsAccepted" registerOptions={{ required: true }}>
<Body>
{intl.formatMessage<React.ReactNode>(
{ id: "signupPage.terms" },
{
termsLink: (str) => (
<Link
variant="underscored"
color="peach80"
target="_blank"
href={privacyPolicy[lang]}
>
{str}
</Link>
),
}
)}
</Body>
{intl.formatMessage({ id: "I accept" })}
</Checkbox>
{/* TODO: Update copy once ready */}
<Body>
{intl.formatMessage<React.ReactNode>(
{ id: "signupPage.terms" },
{
termsAndConditions: (str) => (
<Link
variant="underscored"
color="peach80"
target="_blank"
href={membershipTermsAndConditions[lang]}
>
{str}
</Link>
),
privacyPolicy: (str) => (
<Link
variant="underscored"
color="peach80"
target="_blank"
href={privacyPolicy[lang]}
>
{str}
</Link>
),
}
)}
</Body>
</section>
{/*
{/*
This is a manual validation trigger workaround:
- The Controller component (which Input uses) doesn't re-render on submit,
which prevents automatic error display.
@@ -203,9 +217,7 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
disabled={methods.formState.isSubmitting || signup.isPending}
data-testid="submit"
>
{methods.formState.isSubmitting || signup.isPending
? signingUpPendingText
: signupButtonText}
{signupButtonText}
</Button>
)}
</form>

View File

@@ -9,7 +9,7 @@ import Counter from "../Counter"
import styles from "./adult-selector.module.css"
import { SelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
import type { SelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function AdultSelector({
roomIndex = 0,

View File

@@ -10,7 +10,7 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./child-selector.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import {
import type {
ChildBed,
ChildInfoSelectorProps,
} from "@/types/components/bookingWidget/guestsRoomsPicker"

View File

@@ -10,7 +10,7 @@ import ChildInfoSelector from "./ChildInfoSelector"
import styles from "./child-selector.module.css"
import { SelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
import type { SelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function ChildSelector({
roomIndex = 0,

View File

@@ -14,9 +14,9 @@ import ChildSelector from "./ChildSelector"
import styles from "./guests-rooms-picker.module.css"
import { BookingWidgetSchema } from "@/types/components/bookingWidget"
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
import type { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function GuestsRoomsPickerDialog({
rooms,

View File

@@ -18,7 +18,7 @@ import PickerForm from "./Form"
import styles from "./guests-rooms-picker.module.css"
import { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
import type { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function GuestsRoomsPickerForm() {
const { watch, trigger } = useFormContext()

View File

@@ -1,9 +1,9 @@
import Image from "@/components/Image"
import { HeroProps } from "./hero"
import styles from "./hero.module.css"
import type { HeroProps } from "./hero"
export default async function Hero({ alt, src, focalPoint }: HeroProps) {
return (
<Image

View File

@@ -12,7 +12,7 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./receipt.module.css"
import { BookingConfirmationReceiptProps } from "@/types/components/hotelReservation/bookingConfirmation/receipt"
import type { BookingConfirmationReceiptProps } from "@/types/components/hotelReservation/bookingConfirmation/receipt"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default function Receipt({

View File

@@ -10,7 +10,7 @@ import useLang from "@/hooks/useLang"
import styles from "./contact.module.css"
import { ContactProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type { ContactProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
export default function Contact({ hotel }: ContactProps) {
const lang = useLang()

View File

@@ -22,10 +22,7 @@ export default function BedType({ bedTypes }: BedTypeProps) {
const initialBedType = useEnterDetailsStore(
(state) => state.formValues?.bedType?.roomTypeCode
)
const bedType = useEnterDetailsStore((state) => state.bedType?.roomTypeCode)
const completeStep = useEnterDetailsStore(
(state) => state.actions.completeStep
)
const updateBedType = useEnterDetailsStore(
(state) => state.actions.updateBedType
)
@@ -81,9 +78,6 @@ export default function BedType({ bedTypes }: BedTypeProps) {
subtitle={width}
title={roomType.description}
value={roomType.value}
handleSelectedOnClick={
bedType === roomType.value ? completeStep : undefined
}
/>
)
})}

View File

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

View File

@@ -9,6 +9,7 @@ import { useEnterDetailsStore } from "@/stores/enter-details"
import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
import Body from "@/components/TempDesignSystem/Text/Body"
import { breakfastFormSchema } from "./schema"
@@ -30,19 +31,15 @@ export default function Breakfast({ packages }: BreakfastProps) {
? "false"
: undefined
)
const breakfast = useEnterDetailsStore((state) =>
state.breakfast
? state.breakfast.code
: state.breakfast === false
? "false"
: undefined
)
const completeStep = useEnterDetailsStore(
(state) => state.actions.completeStep
)
const updateBreakfast = useEnterDetailsStore(
(state) => state.actions.updateBreakfast
)
const children = useEnterDetailsStore(
(state) => state.booking.rooms[0].children
)
const methods = useForm<BreakfastFormSchema>({
defaultValues: formValuesBreakfast
? { breakfast: formValuesBreakfast }
@@ -75,60 +72,63 @@ export default function Breakfast({ packages }: BreakfastProps) {
return (
<FormProvider {...methods}>
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
{packages.map((pkg) => (
<RadioCard
key={pkg.code}
id={pkg.code}
name="breakfast"
subtitle={
pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST
? intl.formatMessage<React.ReactNode>(
{ id: "breakfast.price.free" },
{
amount: pkg.localPrice.price,
currency: pkg.localPrice.currency,
free: (str) => <Highlight>{str}</Highlight>,
strikethrough: (str) => <s>{str}</s>,
}
)
: intl.formatMessage(
{ id: "breakfast.price" },
{
amount: pkg.localPrice.price,
currency: pkg.localPrice.currency,
}
)
}
text={intl.formatMessage({
id: "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.",
<div className={styles.container}>
{children?.length ? (
<Body>
{intl.formatMessage({
id: "Children's breakfast is always free as part of the adult's breakfast.",
})}
title={intl.formatMessage({ id: "Breakfast buffet" })}
value={pkg.code}
handleSelectedOnClick={
breakfast === pkg.code ? completeStep : undefined
}
</Body>
) : null}
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
{packages.map((pkg) => (
<RadioCard
key={pkg.code}
id={pkg.code}
name="breakfast"
subtitle={
pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST
? intl.formatMessage<React.ReactNode>(
{ id: "breakfast.price.free" },
{
amount: pkg.localPrice.price,
currency: pkg.localPrice.currency,
free: (str) => <Highlight>{str}</Highlight>,
strikethrough: (str) => <s>{str}</s>,
}
)
: intl.formatMessage(
{ id: "breakfast.price" },
{
amount: pkg.localPrice.price,
currency: pkg.localPrice.currency,
}
)
}
text={intl.formatMessage({
id: "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.",
})}
title={intl.formatMessage({ id: "Breakfast buffet" })}
value={pkg.code}
/>
))}
<RadioCard
name="breakfast"
subtitle={intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: "0",
currency: packages[0].localPrice.currency,
}
)}
text={intl.formatMessage({
id: "You can always change your mind later and add breakfast at the hotel.",
})}
title={intl.formatMessage({ id: "No breakfast" })}
value="false"
/>
))}
<RadioCard
name="breakfast"
subtitle={intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: "0",
currency: "SEK",
}
)}
text={intl.formatMessage({
id: "You can always change your mind later and add breakfast at the hotel.",
})}
title={intl.formatMessage({ id: "No breakfast" })}
value="false"
handleSelectedOnClick={
breakfast === "false" ? completeStep : undefined
}
/>
</form>
</form>
</div>
</FormProvider>
)
}

View File

@@ -26,8 +26,7 @@ import type {
const formID = "enter-details"
export default function Details({ user, memberPrice }: DetailsProps) {
const intl = useIntl()
const initialData = useEnterDetailsStore((state) => state.formValues.guest)
const join = useEnterDetailsStore((state) => state.guest.join)
const initialData = useEnterDetailsStore((state) => state.guest)
const updateDetails = useEnterDetailsStore(
(state) => state.actions.updateDetails
)
@@ -42,7 +41,7 @@ export default function Details({ user, memberPrice }: DetailsProps) {
dateOfBirth: initialData.dateOfBirth,
email: user?.email ?? initialData.email,
firstName: user?.firstName ?? initialData.firstName,
join,
join: initialData.join,
lastName: user?.lastName ?? initialData.lastName,
membershipNo: initialData.membershipNo,
phoneNumber: user?.phoneNumber ?? initialData.phoneNumber,
@@ -78,12 +77,14 @@ export default function Details({ user, memberPrice }: DetailsProps) {
</Footnote>
<Input
label={intl.formatMessage({ id: "First name" })}
maxLength={30}
name="firstName"
readOnly={!!user}
registerOptions={{ required: true }}
/>
<Input
label={intl.formatMessage({ id: "Last name" })}
maxLength={30}
name="lastName"
readOnly={!!user}
registerOptions={{ required: true }}

View File

@@ -2,11 +2,27 @@ import { z } from "zod"
import { phoneValidator } from "@/utils/phoneValidator"
// stringMatcher regex is copied from current web as specified by requirements.
const stringMatcher =
/^[A-Za-z¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ0-9-\s]*$/
const isValidString = (key: string) => stringMatcher.test(key)
export const baseDetailsSchema = z.object({
countryCode: z.string(),
email: z.string().email(),
firstName: z.string(),
lastName: z.string(),
countryCode: z.string().min(1, { message: "Country is required" }),
email: z.string().email({ message: "Email address is required" }),
firstName: z
.string()
.min(1, { message: "First name is required" })
.refine(isValidString, {
message: "First name can't contain any special characters",
}),
lastName: z
.string()
.min(1, { message: "Last name is required" })
.refine(isValidString, {
message: "Last name can't contain any special characters",
}),
phoneNumber: phoneValidator(),
})
@@ -26,10 +42,10 @@ export const notJoinDetailsSchema = baseDetailsSchema.merge(
}, "Only digits are allowed")
.refine((num) => {
if (num) {
return num.length === 14
return num.match(/^30812(?!(0|1|2))[0-9]{9}$/)
}
return true
}, "Membership number needs to be 14 digits"),
}, "Invalid membership number format"),
})
)

View File

@@ -9,7 +9,7 @@ import Button from "@/components/TempDesignSystem/Button"
import styles from "./header.module.css"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
import { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
import type { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
export default function ToggleSidePeek({ hotelId }: ToggleSidePeekProps) {
const intl = useIntl()

View File

@@ -8,7 +8,7 @@ import { detailsStorageName } from "@/stores/enter-details"
import { createQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import LoadingSpinner from "@/components/LoadingSpinner"
import type { DetailsState } from "@/types/stores/enter-details"
import type { PersistedState } from "@/types/stores/enter-details"
export default function PaymentCallback({
returnUrl,
@@ -23,12 +23,9 @@ export default function PaymentCallback({
const bookingData = window.sessionStorage.getItem(detailsStorageName)
if (bookingData) {
const detailsStorage: Record<
"state",
Pick<DetailsState, "booking">
> = JSON.parse(bookingData)
const detailsStorage: PersistedState = JSON.parse(bookingData)
const searchParams = createQueryParamsForEnterDetails(
detailsStorage.state.booking,
detailsStorage.booking,
searchObject
)

View File

@@ -36,7 +36,7 @@ import { bedTypeMap } from "../../SelectRate/RoomSelection/utils"
import PriceChangeDialog from "../PriceChangeDialog"
import GuaranteeDetails from "./GuaranteeDetails"
import PaymentOption from "./PaymentOption"
import { PaymentFormData, paymentSchema } from "./schema"
import { type PaymentFormData, paymentSchema } from "./schema"
import styles from "./payment.module.css"
@@ -403,6 +403,9 @@ export default function PaymentClient({
</section>
<div className={styles.submitButton}>
<Button
intent="primary"
theme="base"
size="small"
type="submit"
disabled={
!methods.formState.isValid || methods.formState.isSubmitting

View File

@@ -1,16 +1,19 @@
import Image from "next/image"
import { useFormContext } from "react-hook-form"
import { PAYMENT_METHOD_ICONS, PaymentMethodEnum } from "@/constants/booking"
import {
PAYMENT_METHOD_ICONS,
type PaymentMethodEnum,
} from "@/constants/booking"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { trackUpdatePaymentMethod } from "@/utils/tracking"
import { PaymentOptionProps } from "./paymentOption"
import styles from "./paymentOption.module.css"
import type { PaymentOptionProps } from "./paymentOption"
export default function PaymentOption({
name,
value,

View File

@@ -1,4 +1,4 @@
import { RegisterOptions } from "react-hook-form"
import type { RegisterOptions } from "react-hook-form"
export interface PaymentOptionProps {
name: string

View File

@@ -2,7 +2,7 @@ import { getSavedPaymentCardsSafely } from "@/lib/trpc/memoizedRequests"
import PaymentClient from "./PaymentClient"
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
import type { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
export default async function Payment({
user,

View File

@@ -8,7 +8,7 @@ import Title from "@/components/TempDesignSystem/Text/Title"
import styles from "./priceChangeDialog.module.css"
import { PriceChangeDialogProps } from "@/types/components/hotelReservation/enterDetails/priceChangeDialog"
import type { PriceChangeDialogProps } from "@/types/components/hotelReservation/enterDetails/priceChangeDialog"
export default function PriceChangeDialog({
isOpen,

View File

@@ -7,6 +7,7 @@ import { useEnterDetailsStore } from "@/stores/enter-details"
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useScrollToActiveSection from "@/hooks/booking/useScrollToActiveSection"
import styles from "./sectionAccordion.module.css"
@@ -21,6 +22,7 @@ export default function SectionAccordion({
}: React.PropsWithChildren<SectionAccordionProps>) {
const intl = useIntl()
const currentStep = useEnterDetailsStore((state) => state.currentStep)
const steps = useEnterDetailsStore((state) => state.steps)
const [isComplete, setIsComplete] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const isValid = useEnterDetailsStore((state) => state.isValid[step])
@@ -33,6 +35,9 @@ export default function SectionAccordion({
const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" })
const breakfastTitle = intl.formatMessage({ id: "Breakfast buffet" })
useScrollToActiveSection(step, steps, currentStep === step)
useEffect(() => {
if (step === StepEnum.selectBed && bedType) {
setTitle(bedType.description)

View File

@@ -5,7 +5,7 @@
gap: var(--Spacing-x3);
width: 100%;
padding-top: var(--Spacing-x3);
transition: 0.4s ease-out;
transition: 0.3s ease-out;
display: grid;
grid-template-areas: "circle header" "content content";
@@ -13,6 +13,7 @@
grid-template-rows: var(--header-height) 0fr;
column-gap: var(--Spacing-x-one-and-half);
transform-origin: top;
}
.accordion:last-child {
@@ -90,7 +91,18 @@
.content {
overflow: hidden;
grid-area: content;
opacity: 0;
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
transform-origin: top;
transition: opacity 0.2s linear;
}
.accordion[data-open="true"] .content {
opacity: 1;
}
.content:has([data-open="true"]) {
overflow: visible;
}
@media screen and (min-width: 768px) {

View File

@@ -1,6 +1,6 @@
"use client"
import { PropsWithChildren } from "react"
import { type PropsWithChildren, useEffect, useRef } from "react"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
@@ -14,6 +14,7 @@ import styles from "./bottomSheet.module.css"
export default function SummaryBottomSheet({ children }: PropsWithChildren) {
const intl = useIntl()
const scrollY = useRef(0)
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } =
useEnterDetailsStore((state) => ({
@@ -23,6 +24,27 @@ export default function SummaryBottomSheet({ children }: PropsWithChildren) {
isSubmittingDisabled: state.isSubmittingDisabled,
}))
useEffect(() => {
if (isSummaryOpen) {
scrollY.current = window.scrollY
document.body.style.position = "fixed"
document.body.style.top = `-${scrollY.current}px`
} else {
document.body.style.position = ""
document.body.style.top = ""
window.scrollTo({
top: scrollY.current,
left: 0,
behavior: "instant",
})
}
return () => {
document.body.style.position = ""
document.body.style.top = ""
}
}, [isSummaryOpen])
return (
<div className={styles.wrapper} data-open={isSummaryOpen}>
<div className={styles.content}>{children}</div>
@@ -48,6 +70,7 @@ export default function SummaryBottomSheet({ children }: PropsWithChildren) {
</button>
<Button
intent="primary"
theme="base"
size="large"
type="submit"
disabled={isSubmittingDisabled}

View File

@@ -1,13 +1,30 @@
"use client"
import { useEnterDetailsStore } from "@/stores/enter-details"
import SignupPromoMobile from "@/components/HotelReservation/SignupPromo/Mobile"
import SummaryUI from "../UI"
import SummaryBottomSheet from "./BottomSheet"
import styles from "./mobile.module.css"
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
import type { DetailsState } from "@/types/stores/enter-details"
function storeSelector(state: DetailsState) {
return {
join: state.guest.join,
membershipNo: state.guest.membershipNo,
}
}
export default function MobileSummary(props: SummaryProps) {
const { join, membershipNo } = useEnterDetailsStore(storeSelector)
const showPromo = !props.isMember && !join && !membershipNo
return (
<div className={styles.mobileSummary}>
{showPromo ? <SignupPromoMobile /> : null}
<SummaryBottomSheet>
<div className={styles.wrapper}>
<SummaryUI {...props} />

View File

@@ -4,6 +4,7 @@ import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { useEnterDetailsStore } from "@/stores/enter-details"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import { ArrowRightIcon, ChevronDownSmallIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
@@ -17,7 +18,6 @@ import useLang from "@/hooks/useLang"
import styles from "./ui.module.css"
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
import { CurrencyEnum } from "@/types/enums/currency"
import type { DetailsState } from "@/types/stores/enter-details"
export function storeSelector(state: DetailsState) {
@@ -60,10 +60,14 @@ export default function SummaryUI({
const adults = booking.rooms[0].adults
const children = booking.rooms[0].children
const showMemberPrice = !!(
(isMember || join || membershipNo) &&
roomRate.memberRate
)
const memberPrice = roomRate.memberRate
? {
currency: roomRate.memberRate.localPrice.currency,
amount: roomRate.memberRate.localPrice.pricePerStay,
}
: null
const showMemberPrice = !!(isMember || join || membershipNo)
const diff = dt(booking.toDate).diff(booking.fromDate, "days")
@@ -103,27 +107,26 @@ export default function SummaryUI({
<div>
<div className={styles.entry}>
<Body color="uiTextHighContrast">{roomType}</Body>
<Caption color={showMemberPrice ? "red" : "uiTextHighContrast"}>
<Body color={showMemberPrice ? "red" : "uiTextHighContrast"}>
{intl.formatNumber(roomPrice.local.price, {
currency: roomPrice.local.currency,
style: "currency",
})}
</Caption>
</Body>
</div>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{`${intl.formatMessage(
{ id: "booking.adults" },
{ totalAdults: adults }
)}
)}${
children?.length
? `, ${intl.formatMessage(
{ id: "booking.children" },
{ totalChildren: children.length }
)}`
: ""
}`}
</Caption>
{children?.length ? (
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "booking.children" },
{ totalChildren: children.length }
)}
</Caption>
) : null}
<Caption color="uiTextMediumContrast">{cancellationText}</Caption>
<Popover
placement="bottom left"
@@ -152,12 +155,12 @@ export default function SummaryUI({
</Body>
</div>
<Caption color="uiTextHighContrast">
<Body color="uiTextHighContrast">
{intl.formatNumber(parseInt(roomPackage.localPrice.price), {
currency: roomPackage.localPrice.currency,
style: "currency",
})}
</Caption>
</Body>
</div>
))
: null}
@@ -170,12 +173,12 @@ export default function SummaryUI({
</Caption>
</div>
<Caption color="uiTextHighContrast">
<Body color="uiTextHighContrast">
{intl.formatNumber(0, {
currency: roomPrice.local.currency,
style: "currency",
})}
</Caption>
</Body>
</div>
) : null}
@@ -184,25 +187,49 @@ export default function SummaryUI({
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "No breakfast" })}
</Body>
<Caption color="uiTextMediumContrast">
<Body color="uiTextHighContrast">
{intl.formatNumber(0, {
currency: roomPrice.local.currency,
style: "currency",
})}
</Caption>
</Body>
</div>
) : null}
{breakfast ? (
<div className={styles.entry}>
<div>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast buffet" })}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatNumber(parseInt(breakfast.localPrice.totalPrice), {
currency: breakfast.localPrice.currency,
style: "currency",
})}
</Caption>
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "booking.adults" },
{ totalAdults: adults }
)}
</Caption>
<Body color="uiTextHighContrast">
{intl.formatNumber(parseInt(breakfast.localPrice.totalPrice), {
currency: breakfast.localPrice.currency,
style: "currency",
})}
</Body>
</div>
{children?.length ? (
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "booking.children" },
{ totalChildren: children.length }
)}
</Caption>
<Body color="uiTextHighContrast">
{intl.formatNumber(0, {
currency: breakfast.localPrice.currency,
style: "currency",
})}
</Body>
</div>
) : null}
</div>
) : null}
</div>
@@ -240,6 +267,9 @@ export default function SummaryUI({
</div>
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
</div>
{!showMemberPrice && memberPrice ? (
<SignupPromoDesktop memberPrice={memberPrice} badgeContent={"✌️"} />
) : null}
</section>
)
}

View File

@@ -0,0 +1,63 @@
.card {
font-size: 14px;
display: flex;
flex-direction: column;
background-color: #fff;
border-radius: var(--Corner-radius-Large);
border: 1px solid var(--Base-Border-Subtle);
position: relative;
height: 100%;
justify-content: space-between;
min-height: 200px;
flex: 1;
overflow: hidden;
}
.imageContainer {
aspect-ratio: 16/9;
width: 100%;
height: 200px;
}
.priceVariants {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
padding: var(--Spacing-x2);
flex: 1;
}
.content {
display: flex;
flex-direction: column;
flex: 1;
gap: var(--Spacing-x1);
padding: var(--Spacing-x2);
}
.text {
display: none;
}
@media (min-width: 1367px) {
.content {
padding: var(--Spacing-x2) 0 var(--Spacing-x2) var(--Spacing-x2);
}
.text {
gap: 10px;
display: flex;
flex-direction: column;
}
.card {
flex-direction: row;
}
.imageContainer {
width: 315px;
height: 100%;
}
.priceVariants {
max-width: 260px;
}
}

View File

@@ -0,0 +1,34 @@
import SkeletonShimmer from "@/components/SkeletonShimmer"
import styles from "./HotelCardSkeleton.module.css"
export function HotelCardSkeleton() {
return (
<article className={styles.card}>
{/* image container */}
<div className={styles.imageContainer}>
<SkeletonShimmer width={"100%"} height="100%" />
</div>
<div className={styles.content}>
<SkeletonShimmer height={"65px"} />
<div className={styles.text}>
<SkeletonShimmer height={"20px"} />
<SkeletonShimmer height={"20px"} />
<SkeletonShimmer height={"20px"} />
<SkeletonShimmer height={"20px"} />
</div>
<SkeletonShimmer height={"56px"} />
<SkeletonShimmer height={"52px"} width={"150px"} />
</div>
<div className={styles.priceVariants}>
{/* price variants */}
{Array.from({ length: 2 }).map((_, index) => (
<SkeletonShimmer key={index} height={"100px"} />
))}
<SkeletonShimmer height={"40px"} />
</div>
</article>
)
}

View File

@@ -3,8 +3,8 @@ import { useParams } from "next/dist/client/components/navigation"
import { memo, useCallback } from "react"
import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import { selectHotelMap, selectRate } from "@/constants/routes/hotelReservation"
import { selectRate } from "@/constants/routes/hotelReservation"
import { useHotelsMapStore } from "@/stores/hotels-map"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import ImageGallery from "@/components/ImageGallery"
@@ -27,31 +27,33 @@ import styles from "./hotelCard.module.css"
import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
import type { Lang } from "@/constants/languages"
function HotelCard({
hotel,
type = HotelCardListingTypeEnum.PageListing,
state = "default",
onHotelCardHover,
}: HotelCardProps) {
const params = useParams()
const lang = params.lang as Lang
const intl = useIntl()
const { setActiveHotelPin, setActiveHotelCard } = useHotelsMapStore()
const { hotelData } = hotel
const { price } = hotel
const handleMouseEnter = useCallback(() => {
if (onHotelCardHover && hotelData) {
onHotelCardHover(hotelData.name)
if (hotelData) {
setActiveHotelPin(hotelData.name)
}
}, [onHotelCardHover, hotelData])
}, [setActiveHotelPin, hotelData])
const handleMouseLeave = useCallback(() => {
if (onHotelCardHover) {
onHotelCardHover(null)
if (hotelData) {
setActiveHotelPin(null)
setActiveHotelCard(null)
}
}, [onHotelCardHover])
}, [setActiveHotelPin, hotelData, setActiveHotelCard])
if (!hotel || !hotelData) return null
@@ -96,15 +98,20 @@ function HotelCard({
{hotelData.address.streetAddress}, {hotelData.address.city}
</Caption>
</address>
<Link
className={styles.addressMobile}
href={`${selectHotelMap(lang)}?selectedHotel=${hotelData.name}`}
keepSearchParams
>
<Caption color="baseTextMediumContrast" type="underline">
<Caption color="baseTextMediumContrast" type="underline" asChild>
<Link
href={`https://www.google.com/maps/dir/?api=1&destination=${hotelData.location.latitude},${hotelData.location.longitude}`}
target="_blank"
aria-label={intl.formatMessage({
id: "Driving directions",
})}
title={intl.formatMessage({
id: "Driving directions",
})}
>
{hotelData.address.streetAddress}, {hotelData.address.city}
</Caption>
</Link>
</Link>
</Caption>
<div>
<Divider variant="vertical" color="subtle" />
</div>

View File

@@ -106,8 +106,7 @@
}
@media (min-width: 768px) {
.facilities,
.memberPrice {
.facilities {
display: none;
}
.dialog {

View File

@@ -4,7 +4,6 @@ import { useParams } from "next/navigation"
import { useState } from "react"
import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import { selectRate } from "@/constants/routes/hotelReservation"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
@@ -22,6 +21,7 @@ import NoPriceAvailableCard from "../HotelCard/NoPriceAvailableCard"
import styles from "./hotelCardDialog.module.css"
import type { HotelCardDialogProps } from "@/types/components/hotelReservation/selectHotel/map"
import type { Lang } from "@/constants/languages"
export default function HotelCardDialog({
data,
@@ -103,12 +103,14 @@ export default function HotelCardDialog({
<Caption type="bold">
{intl.formatMessage({ id: "From" })}
</Caption>
<Subtitle type="two">
{publicPrice} {currency}
<Body asChild>
<span>/{intl.formatMessage({ id: "night" })}</span>
</Body>
</Subtitle>
{publicPrice && (
<Subtitle type="two">
{publicPrice} {currency}
<Body asChild>
<span>/{intl.formatMessage({ id: "night" })}</span>
</Body>
</Subtitle>
)}
{memberPrice && (
<Subtitle
type="two"

View File

@@ -3,6 +3,8 @@
import { useCallback, useEffect, useRef } from "react"
import { useMediaQuery } from "usehooks-ts"
import { useHotelsMapStore } from "@/stores/hotels-map"
import useClickOutside from "@/hooks/useClickOutside"
import HotelCardDialog from "../HotelCardDialog"
@@ -14,18 +16,21 @@ import type { HotelCardDialogListingProps } from "@/types/components/hotelReserv
export default function HotelCardDialogListing({
hotels,
activeCard,
onActiveCardChange,
}: HotelCardDialogListingProps) {
const hotelsPinData = hotels ? getHotelPins(hotels) : []
const activeCardRef = useRef<HTMLDivElement | null>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const isMobile = useMediaQuery("(max-width: 768px)")
const { activeHotelCard, setActiveHotelCard, setActiveHotelPin } =
useHotelsMapStore()
useClickOutside(dialogRef, !!activeCard && isMobile, () => {
onActiveCardChange(null)
})
function handleClose() {
setActiveHotelCard(null)
setActiveHotelPin(null)
}
useClickOutside(dialogRef, !!activeHotelCard && isMobile, handleClose)
const handleIntersection = useCallback(
(entries: IntersectionObserverEntry[]) => {
@@ -33,12 +38,12 @@ export default function HotelCardDialogListing({
if (entry.isIntersecting) {
const cardName = entry.target.getAttribute("data-name")
if (cardName) {
onActiveCardChange(cardName)
setActiveHotelCard(cardName)
}
}
})
},
[onActiveCardChange]
[setActiveHotelCard]
)
useEffect(() => {
@@ -73,13 +78,13 @@ export default function HotelCardDialogListing({
elements.forEach((el) => observerRef.current?.observe(el))
}, 1000)
}
}, [activeCard])
}, [activeHotelCard])
return (
<div className={styles.hotelCardDialogListing} ref={dialogRef}>
{!!hotelsPinData?.length &&
hotelsPinData.map((data) => {
const isActive = data.name === activeCard
const isActive = data.name === activeHotelCard
return (
<div
key={data.name}
@@ -88,8 +93,8 @@ export default function HotelCardDialogListing({
>
<HotelCardDialog
data={data}
isOpen={!!activeCard}
handleClose={() => onActiveCardChange(null)}
isOpen={!!activeHotelCard}
handleClose={handleClose}
/>
</div>
)

View File

@@ -12,7 +12,10 @@ export function getHotelPins(hotels: HotelData[]): HotelPin[] {
name: hotel.hotelData.name,
publicPrice: hotel.price?.public?.localPrice.pricePerNight ?? null,
memberPrice: hotel.price?.member?.localPrice.pricePerNight ?? null,
currency: hotel.price?.public?.localPrice.currency || null,
currency:
hotel.price?.public?.localPrice.currency ||
hotel.price?.member?.localPrice.currency ||
null,
images: [
hotel.hotelData.hotelContent.images,
...(hotel.hotelData.gallery?.heroImages ?? []),
@@ -25,5 +28,8 @@ export function getHotelPins(hotels: HotelData[]): HotelPin[] {
.slice(0, 3),
ratings: hotel.hotelData.ratings?.tripAdvisor.rating ?? null,
operaId: hotel.hotelData.operaId,
facilityIds: hotel.hotelData.detailedFacilities.map(
(facility) => facility.id
),
}))
}

View File

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"
import { useIntl } from "react-intl"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import { useHotelsMapStore } from "@/stores/hotels-map"
import Alert from "@/components/TempDesignSystem/Alert"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
@@ -24,14 +25,13 @@ import { AlertTypeEnum } from "@/types/enums/alert"
export default function HotelCardListing({
hotelData,
type = HotelCardListingTypeEnum.PageListing,
activeCard,
onHotelCardHover,
}: HotelCardListingProps) {
const searchParams = useSearchParams()
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
const intl = useIntl()
const { activeHotelCard } = useHotelsMapStore()
const sortBy = useMemo(
() => searchParams.get("sort") ?? DEFAULT_SORT,
@@ -111,13 +111,16 @@ export default function HotelCardListing({
hotels.map((hotel) => (
<div
key={hotel.hotelData.operaId}
data-active={hotel.hotelData.name === activeCard ? "true" : "false"}
data-active={
hotel.hotelData.name === activeHotelCard ? "true" : "false"
}
>
<HotelCard
hotel={hotel}
type={type}
state={hotel.hotelData.name === activeCard ? "active" : "default"}
onHotelCardHover={onHotelCardHover}
state={
hotel.hotelData.name === activeHotelCard ? "active" : "default"
}
/>
</div>
))
@@ -128,7 +131,9 @@ export default function HotelCardListing({
text={intl.formatMessage({ id: "filters.nohotel.text" })}
/>
) : null}
{showBackToTop && <BackToTopButton onClick={scrollToTop} />}
{showBackToTop && (
<BackToTopButton position="right" onClick={scrollToTop} />
)}
</section>
)
}

View File

@@ -7,7 +7,7 @@ import Button from "@/components/TempDesignSystem/Button"
import styles from "./readMore.module.css"
import { ReadMoreProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type { ReadMoreProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
export default function ReadMore({ label, hotelId, showCTA }: ReadMoreProps) {

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