Merge remote-tracking branch 'origin' into feature/tracking
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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">,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type LevelWithRewards,
|
||||
OverviewTableActionsEnum,
|
||||
type OverviewTableClientProps,
|
||||
OverviewTableReducerAction,
|
||||
type OverviewTableReducerAction,
|
||||
} from "@/types/components/overviewTable"
|
||||
|
||||
export function getLevel(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
68
components/Blocks/DynamicContent/Rewards/RewardIcon/data.ts
Normal file
68
components/Blocks/DynamicContent/Rewards/RewardIcon/data.ts
Normal 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)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -36,7 +36,7 @@ export default async function AmenitiesList({
|
||||
height={20}
|
||||
/>
|
||||
)}
|
||||
<Body color="textMediumContrast">{facility.name}</Body>
|
||||
<Body color="uiTextMediumContrast">{facility.name}</Body>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import type {
|
||||
HotelAddress,
|
||||
HotelData,
|
||||
HotelLocation,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { PropsWithChildren, useRef } from "react"
|
||||
import { type PropsWithChildren, useRef } from "react"
|
||||
|
||||
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 30vh;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.imageWrapper > :nth-child(2),
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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">,
|
||||
|
||||
34
components/Countdown/index.tsx
Normal file
34
components/Countdown/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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"]>>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Lang } from "@/constants/languages"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
type Texts = {
|
||||
title: string
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import {
|
||||
import type {
|
||||
SiteSectionObject,
|
||||
TrackingData,
|
||||
TrackingProps,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>[]) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function List({
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
index={initialIndex + index}
|
||||
key={location.id}
|
||||
key={location.id + index}
|
||||
location={location}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RegisterOptions } from "react-hook-form"
|
||||
import type { RegisterOptions } from "react-hook-form"
|
||||
|
||||
export interface PaymentOptionProps {
|
||||
name: string
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
34
components/HotelReservation/HotelCard/HotelCardSkeleton.tsx
Normal file
34
components/HotelReservation/HotelCard/HotelCardSkeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -106,8 +106,7 @@
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.facilities,
|
||||
.memberPrice {
|
||||
.facilities {
|
||||
display: none;
|
||||
}
|
||||
.dialog {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user