Merge branch 'master' into feature/tracking
This commit is contained in:
@@ -13,13 +13,13 @@ export default async function MembershipNumber({
|
||||
color,
|
||||
membership,
|
||||
}: MembershipNumberProps) {
|
||||
const { formatMessage } = await getIntl()
|
||||
const intl = await getIntl()
|
||||
const classNames = membershipNumberVariants({ className, color })
|
||||
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<Caption color="pale">
|
||||
{formatMessage({ id: "Membership ID" })}
|
||||
{intl.formatMessage({ id: "Membership ID" })}
|
||||
{": "}
|
||||
</Caption>
|
||||
<span className={styles.icon}>
|
||||
|
||||
@@ -18,7 +18,7 @@ export default async function Friend({
|
||||
membership,
|
||||
name,
|
||||
}: FriendProps) {
|
||||
const { formatMessage } = await getIntl()
|
||||
const intl = await getIntl()
|
||||
if (!membership?.membershipLevel) {
|
||||
return null
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export default async function Friend({
|
||||
<section className={styles.friend}>
|
||||
<header className={styles.header}>
|
||||
<Body color="white" textTransform="bold" textAlign="center">
|
||||
{formatMessage(
|
||||
{intl.formatMessage(
|
||||
isHighestLevel
|
||||
? { id: "Highest level" }
|
||||
: { id: `Level ${membershipLevels[membership.membershipLevel]}` }
|
||||
|
||||
@@ -4,7 +4,6 @@ import { dt } from "@/lib/dt"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import { formatNumber } from "@/utils/format"
|
||||
import { getMembership } from "@/utils/user"
|
||||
|
||||
import type { UserProps } from "@/types/components/myPages/user"
|
||||
@@ -27,7 +26,7 @@ export default async function ExpiringPoints({ user }: UserProps) {
|
||||
{intl.formatMessage(
|
||||
{ id: "spendable points expiring by" },
|
||||
{
|
||||
points: formatNumber(membership.pointsToExpire),
|
||||
points: intl.formatNumber(membership.pointsToExpire),
|
||||
date: d.format(dateFormat),
|
||||
}
|
||||
)}
|
||||
|
||||
@@ -44,7 +44,14 @@ async function PointsColumn({
|
||||
title,
|
||||
subtitle,
|
||||
}: PointsColumnProps) {
|
||||
const { formatMessage } = await getIntl()
|
||||
const intl = await getIntl()
|
||||
|
||||
let number = "N/A"
|
||||
if (typeof points === "number") {
|
||||
number = intl.formatNumber(points)
|
||||
} else if (typeof nights === "number") {
|
||||
number = intl.formatNumber(nights)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={styles.article}>
|
||||
@@ -54,16 +61,16 @@ async function PointsColumn({
|
||||
textAlign="center"
|
||||
className={styles.firstRow}
|
||||
>
|
||||
{formatMessage({
|
||||
{intl.formatMessage({
|
||||
id: title,
|
||||
})}
|
||||
</Body>
|
||||
<Title color="white" level="h2" textAlign="center">
|
||||
{points ?? nights ?? "N/A"}
|
||||
{number}
|
||||
</Title>
|
||||
{subtitle ? (
|
||||
<Body color="white" textAlign="center">
|
||||
{formatMessage({ id: subtitle })}
|
||||
{intl.formatMessage({ id: subtitle })}
|
||||
</Body>
|
||||
) : null}
|
||||
</article>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { NextLevelPointsColumn, YourPointsColumn } from "./PointsColumn"
|
||||
import { UserProps } from "@/types/components/myPages/user"
|
||||
|
||||
export default async function Points({ user }: UserProps) {
|
||||
const { formatMessage } = await getIntl()
|
||||
const intl = await getIntl()
|
||||
|
||||
const membership = getMembership(user.memberships)
|
||||
|
||||
@@ -27,7 +27,7 @@ export default async function Points({ user }: UserProps) {
|
||||
{nextLevel && (
|
||||
<NextLevelPointsColumn
|
||||
points={membership?.pointsRequiredToNextlevel}
|
||||
subtitle={`${formatMessage({ id: "next level:" })} ${nextLevel.name}`}
|
||||
subtitle={`${intl.formatMessage({ id: "next level:" })} ${nextLevel.name}`}
|
||||
/>
|
||||
)}
|
||||
{/* TODO: Show NextLevelNightsColumn when nightsToTopTier data is correct from Antavo */}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import { formatNumber } from "@/utils/format"
|
||||
|
||||
import { awardPointsVariants } from "./awardPointsVariants"
|
||||
|
||||
@@ -34,7 +33,7 @@ export default function AwardPoints({
|
||||
return (
|
||||
<Body textTransform="bold" className={classNames}>
|
||||
{isCalculated
|
||||
? formatNumber(awardPoints)
|
||||
? intl.formatNumber(awardPoints)
|
||||
: intl.formatMessage({ id: "Points being calculated" })}
|
||||
</Body>
|
||||
)
|
||||
|
||||
@@ -41,7 +41,11 @@ export default function Pagination({
|
||||
handlePageChange(currentPage - 1)
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon className={styles.chevronLeft} />
|
||||
<ChevronRightIcon
|
||||
className={styles.chevronLeft}
|
||||
height={20}
|
||||
width={20}
|
||||
/>
|
||||
</PaginationButton>
|
||||
{[...Array(pageCount)].map((_, idx) => (
|
||||
<PaginationButton
|
||||
@@ -61,7 +65,7 @@ export default function Pagination({
|
||||
handlePageChange(currentPage + 1)
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
<ChevronRightIcon height={20} width={20} />
|
||||
</PaginationButton>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ import { LangParams } from "@/types/params"
|
||||
|
||||
/* TODO */
|
||||
export default async function Points({ user, lang }: UserProps & LangParams) {
|
||||
const { formatMessage } = await getIntl()
|
||||
const intl = await getIntl()
|
||||
|
||||
const membership = getMembership(user.memberships)
|
||||
if (!membership?.nextLevel || !MembershipLevelEnum[membership.nextLevel]) {
|
||||
@@ -34,13 +34,13 @@ export default async function Points({ user, lang }: UserProps & LangParams) {
|
||||
{membership?.currentPoints ? (
|
||||
<StayOnLevelColumn
|
||||
points={membership?.currentPoints} //TODO
|
||||
subtitle={`${formatMessage({ id: "by" })} ${membership?.expirationDate}`}
|
||||
subtitle={`${intl.formatMessage({ id: "by" })} ${membership?.expirationDate}`}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<NextLevelPointsColumn
|
||||
points={membership?.pointsRequiredToNextlevel}
|
||||
subtitle={`${formatMessage({ id: "next level:" })} ${nextLevel.name}`}
|
||||
subtitle={`${intl.formatMessage({ id: "next level:" })} ${nextLevel.name}`}
|
||||
/>
|
||||
{membership?.nightsToTopTier && (
|
||||
<NextLevelNightsColumn
|
||||
|
||||
@@ -4,11 +4,11 @@ import { getIntl } from "@/i18n"
|
||||
import styles from "./emptyPreviousStays.module.css"
|
||||
|
||||
export default async function EmptyPreviousStaysBlock() {
|
||||
const { formatMessage } = await getIntl()
|
||||
const intl = await getIntl()
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<Title as="h4" level="h3" color="red" textAlign="center">
|
||||
{formatMessage({
|
||||
{intl.formatMessage({
|
||||
id: "You have no previous stays.",
|
||||
})}
|
||||
</Title>
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function ShowMoreButton({
|
||||
disabled,
|
||||
loadMoreData,
|
||||
}: ShowMoreButtonParams) {
|
||||
const { formatMessage } = useIntl()
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Button
|
||||
@@ -24,8 +24,8 @@ export default function ShowMoreButton({
|
||||
theme="base"
|
||||
intent="text"
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
{formatMessage({ id: "Show more" })}
|
||||
<ChevronDownIcon width={20} height={20} />
|
||||
{intl.formatMessage({ id: "Show more" })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,14 +10,14 @@ import { getLang } from "@/i18n/serverContext"
|
||||
import styles from "./emptyUpcomingStays.module.css"
|
||||
|
||||
export default async function EmptyUpcomingStaysBlock() {
|
||||
const { formatMessage } = await getIntl()
|
||||
const intl = await getIntl()
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Title as="h4" level="h3" color="red" className={styles.title}>
|
||||
{formatMessage({ id: "You have no upcoming stays." })}
|
||||
{intl.formatMessage({ id: "You have no upcoming stays." })}
|
||||
<span className={styles.burgundyTitle}>
|
||||
{formatMessage({ id: "Where should you go next?" })}
|
||||
{intl.formatMessage({ id: "Where should you go next?" })}
|
||||
</span>
|
||||
</Title>
|
||||
</div>
|
||||
@@ -26,7 +26,7 @@ export default async function EmptyUpcomingStaysBlock() {
|
||||
className={styles.link}
|
||||
color="peach80"
|
||||
>
|
||||
{formatMessage({ id: "Get inspired" })}
|
||||
{intl.formatMessage({ id: "Get inspired" })}
|
||||
<ArrowRightIcon color="peach80" />
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
@@ -10,14 +10,14 @@ import { getLang } from "@/i18n/serverContext"
|
||||
import styles from "./emptyUpcomingStays.module.css"
|
||||
|
||||
export default async function EmptyUpcomingStaysBlock() {
|
||||
const { formatMessage } = await getIntl()
|
||||
const intl = await getIntl()
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Title as="h4" level="h3" color="red" className={styles.title}>
|
||||
{formatMessage({ id: "You have no upcoming stays." })}
|
||||
{intl.formatMessage({ id: "You have no upcoming stays." })}
|
||||
<span className={styles.burgundyTitle}>
|
||||
{formatMessage({ id: "Where should you go next?" })}
|
||||
{intl.formatMessage({ id: "Where should you go next?" })}
|
||||
</span>
|
||||
</Title>
|
||||
</div>
|
||||
@@ -26,7 +26,7 @@ export default async function EmptyUpcomingStaysBlock() {
|
||||
className={styles.link}
|
||||
color="peach80"
|
||||
>
|
||||
{formatMessage({ id: "Get inspired" })}
|
||||
{intl.formatMessage({ id: "Get inspired" })}
|
||||
<ArrowRightIcon color="peach80" />
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function ShortcutsListItems({
|
||||
className,
|
||||
}: ShortcutsListItemsProps) {
|
||||
return (
|
||||
<ul className={className}>
|
||||
<ul className={`${styles.list} ${className}`}>
|
||||
{shortcutsListItems.map((shortcut) => (
|
||||
<li key={shortcut.title} className={styles.listItem}>
|
||||
<Link
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
.link {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
.list {
|
||||
height: fit-content;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
overflow: hidden;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
|
||||
@@ -15,36 +15,36 @@ export default function ShortcutsList({
|
||||
hasTwoColumns,
|
||||
}: ShortcutsListProps) {
|
||||
const middleIndex = Math.ceil(shortcuts.length / 2)
|
||||
const leftColumn = shortcuts.slice(0, middleIndex)
|
||||
const rightColumn = shortcuts.slice(middleIndex)
|
||||
|
||||
const classNames =
|
||||
const columns =
|
||||
hasTwoColumns && shortcuts.length > 1
|
||||
? {
|
||||
section: styles.twoColumnSection,
|
||||
leftColumn: styles.leftColumn,
|
||||
rightColumn: styles.rightColumn,
|
||||
}
|
||||
: {
|
||||
section: styles.oneColumnSection,
|
||||
leftColumn:
|
||||
shortcuts.length === 1
|
||||
? styles.leftColumnBorderBottomNone
|
||||
: styles.leftColumnBorderBottom,
|
||||
}
|
||||
? [
|
||||
{
|
||||
id: "shortcuts-column-1",
|
||||
column: shortcuts.slice(0, middleIndex),
|
||||
},
|
||||
{
|
||||
id: "shortcuts-column-2",
|
||||
column: shortcuts.slice(middleIndex),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: "shortcuts-column",
|
||||
column: shortcuts,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
<SectionHeader preamble={subtitle} title={title} topTitle={firstItem} />
|
||||
<section className={classNames.section}>
|
||||
<ShortcutsListItems
|
||||
shortcutsListItems={leftColumn}
|
||||
className={classNames.leftColumn}
|
||||
/>
|
||||
<ShortcutsListItems
|
||||
shortcutsListItems={rightColumn}
|
||||
className={classNames.rightColumn}
|
||||
/>
|
||||
<section className={styles.section}>
|
||||
{columns.map(({ id, column }) => (
|
||||
<ShortcutsListItems
|
||||
key={id}
|
||||
shortcutsListItems={column}
|
||||
className={styles.column}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
</SectionContainer>
|
||||
)
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
.oneColumnSection,
|
||||
.twoColumnSection {
|
||||
display: grid;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.leftColumn,
|
||||
.leftColumnBorderBottom {
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
|
||||
.leftColumnBorderBottomNone {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.twoColumnSection {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: var(--Spacing-x2);
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.leftColumn,
|
||||
.rightColumn {
|
||||
height: fit-content;
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
@media screen and (max-width: 1366px) {
|
||||
.section {
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
overflow: hidden;
|
||||
}
|
||||
.column {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.column + .column {
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.section {
|
||||
border-radius: 0;
|
||||
}
|
||||
.section:has(.column:nth-child(2)) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export default function TableBlock({ data }: TableBlockProps) {
|
||||
header: col.header,
|
||||
size: col.width,
|
||||
}))
|
||||
const hasHeader = columns.some((col) => col.header)
|
||||
|
||||
const table = useReactTable({
|
||||
columns: columnDefs,
|
||||
@@ -49,9 +50,7 @@ export default function TableBlock({ data }: TableBlockProps) {
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
{heading ? (
|
||||
<SectionHeader preamble={data.preamble} title={heading} />
|
||||
) : null}
|
||||
{heading ? <SectionHeader preamble={preamble} title={heading} /> : null}
|
||||
<div className={styles.tableWrapper}>
|
||||
<ScrollWrapper>
|
||||
<Table
|
||||
@@ -61,23 +60,26 @@ export default function TableBlock({ data }: TableBlockProps) {
|
||||
layout="fixed"
|
||||
borderRadius="none"
|
||||
>
|
||||
<Table.THead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<Table.TR key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<Table.TH
|
||||
key={header.id}
|
||||
width={`${header.column.columnDef.size}%`}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</Table.TH>
|
||||
))}
|
||||
</Table.TR>
|
||||
))}
|
||||
</Table.THead>
|
||||
{hasHeader ? (
|
||||
<Table.THead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<Table.TR key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<Table.TH
|
||||
key={header.id}
|
||||
width={`${header.column.columnDef.size}%`}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</Table.TH>
|
||||
))}
|
||||
</Table.TR>
|
||||
))}
|
||||
</Table.THead>
|
||||
) : null}
|
||||
|
||||
<Table.TBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Table.TR key={row.id}>
|
||||
|
||||
@@ -14,13 +14,11 @@ export default function TextCols({ text_cols }: TextColProps) {
|
||||
return (
|
||||
<section key={col.title} className={styles.column}>
|
||||
<Subtitle>{col.title}</Subtitle>
|
||||
<div className={styles.text}>
|
||||
<JsonToHtml
|
||||
nodes={col.text.json.children}
|
||||
embeds={col.text.embedded_itemsConnection.edges}
|
||||
renderOptions={renderOptions}
|
||||
/>
|
||||
</div>
|
||||
<JsonToHtml
|
||||
nodes={col.text.json.children}
|
||||
embeds={col.text.embedded_itemsConnection.edges}
|
||||
renderOptions={renderOptions}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -5,7 +5,6 @@ import styles from "./textcols.module.css"
|
||||
import type { EmbedByUid } from "@/types/transitionTypes/jsontohtml"
|
||||
import { RTEItemTypeEnum, RTETypeEnum } from "@/types/transitionTypes/rte/enums"
|
||||
import type {
|
||||
RTEDefaultNode,
|
||||
RTENext,
|
||||
RTENode,
|
||||
RTERegularNode,
|
||||
@@ -13,18 +12,6 @@ import type {
|
||||
import type { RenderOptions } from "@/types/transitionTypes/rte/option"
|
||||
|
||||
export const renderOptions: RenderOptions = {
|
||||
[RTETypeEnum.p]: (
|
||||
node: RTEDefaultNode,
|
||||
embeds: EmbedByUid,
|
||||
next: RTENext,
|
||||
fullRenderOptions: RenderOptions
|
||||
) => {
|
||||
return (
|
||||
<p key={node.uid} className={styles.p}>
|
||||
{next(node.children, embeds, fullRenderOptions)}
|
||||
</p>
|
||||
)
|
||||
},
|
||||
[RTETypeEnum.a]: (
|
||||
node: RTERegularNode,
|
||||
embeds: EmbedByUid,
|
||||
|
||||
@@ -1,39 +1,18 @@
|
||||
.columns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.column {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x1);
|
||||
align-content: start;
|
||||
padding-bottom: var(--Spacing-x2);
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
gap: var(--Spacing-x1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.p {
|
||||
color: var(--UI-Text-High-contrast);
|
||||
line-height: var(--Spacing-x3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.a {
|
||||
color: var(--Base-Text-High-contrast);
|
||||
}
|
||||
|
||||
.text > section {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.columns {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.column {
|
||||
flex: 0 0 calc(50% - var(--Spacing-x3));
|
||||
max-width: calc(50% - var(--Spacing-x3));
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,11 @@
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
|
||||
import styles from "./uspgrid.module.css"
|
||||
|
||||
import type { EmbedByUid } from "@/types/transitionTypes/jsontohtml"
|
||||
import { RTEItemTypeEnum, RTETypeEnum } from "@/types/transitionTypes/rte/enums"
|
||||
import type {
|
||||
RTEDefaultNode,
|
||||
RTENext,
|
||||
RTENode,
|
||||
RTERegularNode,
|
||||
} from "@/types/transitionTypes/rte/node"
|
||||
import type { RTENext, RTENode } from "@/types/transitionTypes/rte/node"
|
||||
import type { RenderOptions } from "@/types/transitionTypes/rte/option"
|
||||
|
||||
export const renderOptions: RenderOptions = {
|
||||
[RTETypeEnum.p]: (
|
||||
node: RTEDefaultNode,
|
||||
embeds: EmbedByUid,
|
||||
next: RTENext,
|
||||
fullRenderOptions: RenderOptions
|
||||
) => {
|
||||
return (
|
||||
<p key={node.uid} className={styles.p}>
|
||||
{next(node.children, embeds, fullRenderOptions)}
|
||||
</p>
|
||||
)
|
||||
},
|
||||
[RTETypeEnum.a]: (
|
||||
node: RTERegularNode,
|
||||
embeds: EmbedByUid,
|
||||
next: RTENext,
|
||||
fullRenderOptions: RenderOptions
|
||||
) => {
|
||||
if (node.attrs.url) {
|
||||
return (
|
||||
<a
|
||||
href={node.attrs.url}
|
||||
target={node.attrs.target ?? "_blank"}
|
||||
key={node.uid}
|
||||
className={styles.a}
|
||||
>
|
||||
{next(node.children, embeds, fullRenderOptions)}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return null
|
||||
},
|
||||
[RTETypeEnum.reference]: (
|
||||
node: RTENode,
|
||||
embeds: EmbedByUid,
|
||||
@@ -59,7 +20,12 @@ export const renderOptions: RenderOptions = {
|
||||
: node.attrs.href
|
||||
|
||||
return (
|
||||
<Link href={href} key={node.uid} className={styles.a}>
|
||||
<Link
|
||||
href={href}
|
||||
key={node.uid}
|
||||
variant="underscored"
|
||||
color="burgundy"
|
||||
>
|
||||
{next(node.children, embeds, fullRenderOptions)}
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -2,26 +2,18 @@
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.usp {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 767px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.grid:has(.usp:nth-child(4)) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
.usp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
.p {
|
||||
margin: 0;
|
||||
font-size: var(--typography-Caption-Regular-fontSize);
|
||||
color: var(--UI-Text-Medium-contrast);
|
||||
line-height: 21px; /* Caption variable for line-height is 139.9999976158142%, but it set to 21px in design */
|
||||
}
|
||||
.a {
|
||||
font-size: var(--typography-Caption-Regular-fontSize);
|
||||
color: var(--Base-Text-High-contrast);
|
||||
.grid:has(.usp:nth-child(3)):not(:has(.usp:nth-child(4))) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,14 +36,6 @@ export default function BookingWidgetClient({
|
||||
name: StickyElementNameEnum.BOOKING_WIDGET,
|
||||
})
|
||||
|
||||
const sessionStorageSearchData =
|
||||
typeof window !== "undefined"
|
||||
? sessionStorage.getItem("searchData")
|
||||
: undefined
|
||||
const initialSelectedLocation: Location | undefined = sessionStorageSearchData
|
||||
? JSON.parse(sessionStorageSearchData)
|
||||
: undefined
|
||||
|
||||
const bookingWidgetSearchData: BookingWidgetSearchParams | undefined =
|
||||
searchParams
|
||||
? (getFormattedUrlQueryParams(new URLSearchParams(searchParams), {
|
||||
@@ -85,21 +77,17 @@ export default function BookingWidgetClient({
|
||||
|
||||
const methods = useForm<BookingWidgetSchema>({
|
||||
defaultValues: {
|
||||
search: selectedLocation?.name ?? initialSelectedLocation?.name ?? "",
|
||||
location: selectedLocation
|
||||
? JSON.stringify(selectedLocation)
|
||||
: sessionStorageSearchData
|
||||
? encodeURIComponent(sessionStorageSearchData)
|
||||
: undefined,
|
||||
search: selectedLocation?.name ?? "",
|
||||
location: selectedLocation ? JSON.stringify(selectedLocation) : undefined,
|
||||
date: {
|
||||
// UTC is required to handle requests from far away timezones https://scandichotels.atlassian.net/browse/SWAP-6375 & PET-507
|
||||
// This is specifically to handle timezones falling in different dates.
|
||||
fromDate: isDateParamValid
|
||||
? bookingWidgetSearchData?.fromDate?.toString()
|
||||
: dt().utc().format("YYYY-MM-DD"),
|
||||
? dt(bookingWidgetSearchData?.fromDate).format("YYYY-M-D")
|
||||
: dt().utc().format("YYYY-M-D"),
|
||||
toDate: isDateParamValid
|
||||
? bookingWidgetSearchData?.toDate?.toString()
|
||||
: dt().utc().add(1, "day").format("YYYY-MM-DD"),
|
||||
? dt(bookingWidgetSearchData?.toDate).format("YYYY-M-D")
|
||||
: dt().utc().add(1, "day").format("YYYY-M-D"),
|
||||
},
|
||||
bookingCode: "",
|
||||
redemption: false,
|
||||
@@ -146,6 +134,24 @@ export default function BookingWidgetClient({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const sessionStorageSearchData =
|
||||
typeof window !== "undefined"
|
||||
? sessionStorage.getItem("searchData")
|
||||
: undefined
|
||||
const initialSelectedLocation: Location | undefined =
|
||||
sessionStorageSearchData
|
||||
? JSON.parse(sessionStorageSearchData)
|
||||
: undefined
|
||||
|
||||
!selectedLocation?.name &&
|
||||
initialSelectedLocation?.name &&
|
||||
methods.setValue("search", initialSelectedLocation.name)
|
||||
!selectedLocation &&
|
||||
sessionStorageSearchData &&
|
||||
methods.setValue("location", encodeURIComponent(sessionStorageSearchData))
|
||||
}, [methods, selectedLocation])
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<section ref={bookingWidgetRef} className={styles.containerDesktop}>
|
||||
|
||||
@@ -11,7 +11,12 @@ export default function BreadcrumbsSkeleton() {
|
||||
<span className={styles.homeLink} color="peach80">
|
||||
<HouseIcon color="peach80" />
|
||||
</span>
|
||||
<ChevronRightIcon aria-hidden="true" color="peach80" />
|
||||
<ChevronRightIcon
|
||||
aria-hidden="true"
|
||||
color="peach80"
|
||||
height={20}
|
||||
width={20}
|
||||
/>
|
||||
</li>
|
||||
|
||||
<li className={styles.listItem}>
|
||||
|
||||
@@ -56,7 +56,6 @@ export default function MapCard({ hotelName, pois }: MapCardProps) {
|
||||
intent="secondary"
|
||||
size="small"
|
||||
fullWidth
|
||||
className={styles.ctaButton}
|
||||
onClick={openDynamicMap}
|
||||
>
|
||||
{intl.formatMessage({ id: "Explore nearby" })}
|
||||
|
||||
@@ -10,13 +10,10 @@
|
||||
border-top-right-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
|
||||
.ctaButton {
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.poiList {
|
||||
list-style: none;
|
||||
margin-top: var(--Spacing-x1);
|
||||
margin-bottom: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.poiItem {
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
|
||||
.contentContainer {
|
||||
padding: var(--Spacing-x4) var(--Spacing-x2) 0;
|
||||
max-width: var(--max-width-content);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.content .contentContainer {
|
||||
@@ -67,9 +69,7 @@
|
||||
}
|
||||
|
||||
.contentContainer {
|
||||
max-width: var(--max-width-content);
|
||||
padding: var(--Spacing-x4) 0 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.content .contentContainer {
|
||||
|
||||
@@ -7,10 +7,10 @@ import { useIntl } from "react-intl"
|
||||
import styles from "./bookingButton.module.css"
|
||||
|
||||
export default function BookingButton({ href }: { href: string }) {
|
||||
const { formatMessage } = useIntl()
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<a className={styles.button} href={href}>
|
||||
{formatMessage({ id: "Book" })}
|
||||
{intl.formatMessage({ id: "Book" })}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function MyPagesMobileDropdown({
|
||||
}: {
|
||||
navigation: Navigation
|
||||
}) {
|
||||
const { formatMessage } = useIntl()
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const { toggleDropdown, isMyPagesMobileMenuOpen } = useDropdownStore()
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function MyPagesMobileDropdown({
|
||||
color="burgundy"
|
||||
variant="myPageMobileDropdown"
|
||||
>
|
||||
{formatMessage({ id: "Log out" })}
|
||||
{intl.formatMessage({ id: "Log out" })}
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
|
||||
@@ -21,7 +21,7 @@ export default async function TopMenu({
|
||||
links,
|
||||
languageSwitcher,
|
||||
}: TopMenuProps) {
|
||||
const { formatMessage } = await getIntl()
|
||||
const intl = await getIntl()
|
||||
const user = await getName()
|
||||
return (
|
||||
<div className={styles.topMenu}>
|
||||
@@ -60,7 +60,7 @@ export default async function TopMenu({
|
||||
className={styles.sessionLink}
|
||||
prefetch={false}
|
||||
>
|
||||
{formatMessage({ id: "Log out" })}
|
||||
{intl.formatMessage({ id: "Log out" })}
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
@@ -69,7 +69,7 @@ export default async function TopMenu({
|
||||
trackingId="loginStartTopMenu"
|
||||
className={`${styles.sessionLink} ${styles.loginLink}`}
|
||||
>
|
||||
{formatMessage({ id: "Log in" })}
|
||||
{intl.formatMessage({ id: "Log in" })}
|
||||
</LoginButton>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
@@ -26,12 +27,21 @@ export default function DatePickerDesktop({
|
||||
}: DatePickerProps) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const [month, setMonth] = useState(new Date())
|
||||
|
||||
/** English is default language and doesn't need to be imported */
|
||||
const locale = lang === Lang.en ? undefined : locales[lang]
|
||||
const currentDate = dt().toDate()
|
||||
const startOfMonth = dt(currentDate).set("date", 1).toDate()
|
||||
const yesterday = dt(currentDate).subtract(1, "day").toDate()
|
||||
|
||||
// Max future date allowed to book kept same as of existing prod.
|
||||
const endDate = dt().add(395, "day").toDate()
|
||||
const endOfLastMonth = dt(endDate).endOf("month").toDate()
|
||||
|
||||
function handleMonthChange(selected: Date) {
|
||||
setMonth(selected)
|
||||
}
|
||||
return (
|
||||
<DayPicker
|
||||
classNames={{
|
||||
@@ -49,7 +59,10 @@ export default function DatePickerDesktop({
|
||||
week: styles.week,
|
||||
weekday: `${classNames.weekday} ${styles.weekDay}`,
|
||||
}}
|
||||
disabled={{ from: startOfMonth, to: yesterday }}
|
||||
disabled={[
|
||||
{ from: startOfMonth, to: yesterday },
|
||||
{ from: endDate, to: endOfLastMonth },
|
||||
]}
|
||||
excludeDisabled
|
||||
footer
|
||||
formatters={{
|
||||
@@ -60,12 +73,14 @@ export default function DatePickerDesktop({
|
||||
lang={lang}
|
||||
locale={locale}
|
||||
mode="range"
|
||||
month={month}
|
||||
numberOfMonths={2}
|
||||
onDayClick={handleOnSelect}
|
||||
pagedNavigation
|
||||
onMonthChange={handleMonthChange}
|
||||
required={false}
|
||||
selected={selectedDate}
|
||||
startMonth={currentDate}
|
||||
endMonth={endDate}
|
||||
weekStartsOn={1}
|
||||
components={{
|
||||
Chevron(props) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client"
|
||||
import { type ChangeEvent, useState } from "react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
@@ -17,34 +16,24 @@ import classNames from "react-day-picker/style.module.css"
|
||||
|
||||
import type { DatePickerProps } from "@/types/components/datepicker"
|
||||
|
||||
function addOneYear(_: undefined, i: number) {
|
||||
return new Date().getFullYear() + i
|
||||
}
|
||||
|
||||
const fiftyYearsAhead = Array.from({ length: 50 }, addOneYear)
|
||||
|
||||
export default function DatePickerMobile({
|
||||
close,
|
||||
handleOnSelect,
|
||||
locales,
|
||||
selectedDate,
|
||||
}: DatePickerProps) {
|
||||
const [selectedYear, setSelectedYear] = useState(() => dt().year())
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
|
||||
function handleSelectYear(evt: ChangeEvent<HTMLSelectElement>) {
|
||||
setSelectedYear(Number(evt.currentTarget.value))
|
||||
}
|
||||
|
||||
/** English is default language and doesn't need to be imported */
|
||||
const locale = lang === Lang.en ? undefined : locales[lang]
|
||||
const currentDate = dt().toDate()
|
||||
const startOfCurrentMonth = dt(currentDate).set("date", 1).toDate()
|
||||
const yesterday = dt(currentDate).subtract(1, "day").toDate()
|
||||
|
||||
const startMonth = dt().set("year", selectedYear).startOf("year").toDate()
|
||||
const decemberOfYear = dt().set("year", selectedYear).endOf("year").toDate()
|
||||
// Max future date allowed to book kept same as of existing prod.
|
||||
const endDate = dt().add(395, "day").toDate()
|
||||
const endOfLastMonth = dt(endDate).endOf("month").toDate()
|
||||
return (
|
||||
<DayPicker
|
||||
classNames={{
|
||||
@@ -63,8 +52,11 @@ export default function DatePickerMobile({
|
||||
week: styles.week,
|
||||
weekday: `${classNames.weekday} ${styles.weekDay}`,
|
||||
}}
|
||||
disabled={{ from: startOfCurrentMonth, to: yesterday }}
|
||||
endMonth={decemberOfYear}
|
||||
disabled={[
|
||||
{ from: startOfCurrentMonth, to: yesterday },
|
||||
{ from: endDate, to: endOfLastMonth },
|
||||
]}
|
||||
endMonth={endDate}
|
||||
excludeDisabled
|
||||
footer
|
||||
formatters={{
|
||||
@@ -77,11 +69,11 @@ export default function DatePickerMobile({
|
||||
locale={locale}
|
||||
mode="range"
|
||||
/** Showing full year or what's left of it */
|
||||
numberOfMonths={12}
|
||||
numberOfMonths={13}
|
||||
onDayClick={handleOnSelect}
|
||||
required
|
||||
selected={selectedDate}
|
||||
startMonth={startMonth}
|
||||
startMonth={currentDate}
|
||||
weekStartsOn={1}
|
||||
components={{
|
||||
Footer(props) {
|
||||
@@ -115,17 +107,6 @@ export default function DatePickerMobile({
|
||||
return (
|
||||
<div {...props}>
|
||||
<header className={styles.header}>
|
||||
<select
|
||||
className={styles.select}
|
||||
defaultValue={selectedYear}
|
||||
onChange={handleSelectYear}
|
||||
>
|
||||
{fiftyYearsAhead.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className={styles.close} onClick={close} type="button">
|
||||
<CloseLargeIcon />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
import { da, de, fi, nb, sv } from "date-fns/locale"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
@@ -37,47 +37,73 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
function handleOnClick() {
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen)
|
||||
function showOnFocus() {
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
function handleSelectDate(selected: Date) {
|
||||
if (isSelectingFrom) {
|
||||
setValue(name, {
|
||||
fromDate: dt(selected).format("YYYY-MM-DD"),
|
||||
toDate: undefined,
|
||||
})
|
||||
setIsSelectingFrom(false)
|
||||
} else {
|
||||
const fromDate = dt(selectedDate.fromDate)
|
||||
const toDate = dt(selected)
|
||||
if (toDate.isAfter(fromDate)) {
|
||||
/* check if selected date is not before todays date,
|
||||
which happens when "Enter" key is pressed in any other input field of the form */
|
||||
if (!dt(selected).isBefore(dt(), "day")) {
|
||||
if (isSelectingFrom) {
|
||||
setValue(name, {
|
||||
fromDate: selectedDate.fromDate,
|
||||
toDate: toDate.format("YYYY-MM-DD"),
|
||||
})
|
||||
} else {
|
||||
setValue(name, {
|
||||
fromDate: toDate.format("YYYY-MM-DD"),
|
||||
toDate: selectedDate.fromDate,
|
||||
fromDate: dt(selected).format("YYYY-MM-D"),
|
||||
toDate: undefined,
|
||||
})
|
||||
setIsSelectingFrom(false)
|
||||
} else if (!dt(selectedDate.fromDate).isSame(dt(selected))) {
|
||||
const fromDate = dt(selectedDate.fromDate)
|
||||
const toDate = dt(selected)
|
||||
if (toDate.isAfter(fromDate)) {
|
||||
setValue(name, {
|
||||
fromDate: selectedDate.fromDate,
|
||||
toDate: toDate.format("YYYY-MM-D"),
|
||||
})
|
||||
} else {
|
||||
setValue(name, {
|
||||
fromDate: toDate.format("YYYY-MM-D"),
|
||||
toDate: selectedDate.fromDate,
|
||||
})
|
||||
}
|
||||
setIsSelectingFrom(true)
|
||||
}
|
||||
setIsSelectingFrom(true)
|
||||
}
|
||||
}
|
||||
const closeIfOutside = useCallback(
|
||||
(target: HTMLElement) => {
|
||||
if (ref.current && target && !ref.current.contains(target)) {
|
||||
if (!selectedDate.toDate) {
|
||||
setValue(name, {
|
||||
fromDate: selectedDate.fromDate,
|
||||
toDate: dt(selectedDate.fromDate).add(1, "day").format("YYYY-MM-D"),
|
||||
})
|
||||
setIsSelectingFrom(true)
|
||||
}
|
||||
setIsOpen(false)
|
||||
}
|
||||
},
|
||||
[setIsOpen, setValue, setIsSelectingFrom, selectedDate, name, ref]
|
||||
)
|
||||
|
||||
function closeOnBlur(evt: FocusEvent) {
|
||||
if (isOpen) {
|
||||
const target = evt.relatedTarget as HTMLElement
|
||||
closeIfOutside(target)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(evt: Event) {
|
||||
const target = evt.target as HTMLElement
|
||||
if (ref.current && target && !ref.current.contains(target)) {
|
||||
setIsOpen(false)
|
||||
if (isOpen) {
|
||||
const target = evt.target as HTMLElement
|
||||
closeIfOutside(target)
|
||||
}
|
||||
}
|
||||
document.body.addEventListener("click", handleClickOutside)
|
||||
return () => {
|
||||
document.body.removeEventListener("click", handleClickOutside)
|
||||
}
|
||||
}, [setIsOpen])
|
||||
}, [closeIfOutside, isOpen])
|
||||
|
||||
const selectedFromDate = dt(selectedDate.fromDate)
|
||||
.locale(lang)
|
||||
@@ -87,8 +113,15 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className={styles.container} data-isopen={isOpen} ref={ref}>
|
||||
<button className={styles.btn} onClick={handleOnClick} type="button">
|
||||
<div
|
||||
className={styles.container}
|
||||
onBlur={(e) => {
|
||||
closeOnBlur(e.nativeEvent)
|
||||
}}
|
||||
data-isopen={isOpen}
|
||||
ref={ref}
|
||||
>
|
||||
<button className={styles.btn} onFocus={showOnFocus} type="button">
|
||||
<Body className={styles.body} asChild>
|
||||
<span>
|
||||
{selectedFromDate} - {selectedToDate}
|
||||
|
||||
@@ -60,11 +60,13 @@
|
||||
border-top: 1px solid var(--Base-Text-Medium-contrast);
|
||||
padding-top: var(--Spacing-x2);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.navigationContainer {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
gap: var(--Spacing-x4);
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ function SocialIcon({ iconName }: SocialIconsProps) {
|
||||
|
||||
export default async function FooterDetails() {
|
||||
const lang = getLang()
|
||||
const { formatMessage } = await getIntl()
|
||||
const intl = await getIntl()
|
||||
// preloaded
|
||||
const footer = await getFooter()
|
||||
const languages = await getLanguageSwitcher()
|
||||
@@ -56,9 +56,9 @@ export default async function FooterDetails() {
|
||||
</div>
|
||||
<div className={styles.bottomContainer}>
|
||||
<div className={styles.copyrightContainer}>
|
||||
<Footnote textTransform="uppercase">
|
||||
<Footnote type="label" textTransform="uppercase">
|
||||
© {currentYear}{" "}
|
||||
{formatMessage({ id: "Copyright all rights reserved" })}
|
||||
{intl.formatMessage({ id: "Copyright all rights reserved" })}
|
||||
</Footnote>
|
||||
</div>
|
||||
<div className={styles.navigationContainer}>
|
||||
@@ -66,7 +66,12 @@ export default async function FooterDetails() {
|
||||
{footer?.tertiaryLinks.map(
|
||||
(link) =>
|
||||
link.url && (
|
||||
<Footnote asChild textTransform="uppercase" key={link.title}>
|
||||
<Footnote
|
||||
asChild
|
||||
type="label"
|
||||
textTransform="uppercase"
|
||||
key={link.title}
|
||||
>
|
||||
<Link
|
||||
className={styles.link}
|
||||
color="peach50"
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function FooterMainNav({ mainLinks }: FooterMainNavProps) {
|
||||
<ul className={styles.mainNavigationList}>
|
||||
{mainLinks.map((link) => (
|
||||
<li key={link.title} className={styles.mainNavigationItem}>
|
||||
<Subtitle type="two" asChild>
|
||||
<Subtitle color="baseTextMediumContrast" type="two" asChild>
|
||||
<Link
|
||||
color="burgundy"
|
||||
href={link.url}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
.mainNavigationItem {
|
||||
padding: var(--Spacing-x3) 0;
|
||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider);
|
||||
border-bottom: 1px solid var(--Base-Border-Normal);
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Image from "@/components/Image"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./secondarynav.module.css"
|
||||
@@ -18,9 +19,13 @@ export default function FooterSecondaryNav({
|
||||
<div className={styles.secondaryNavigation}>
|
||||
{appDownloads && (
|
||||
<nav className={styles.secondaryNavigationGroup}>
|
||||
<Body color="baseTextMediumContrast" textTransform="uppercase">
|
||||
<Caption
|
||||
color="textMediumContrast"
|
||||
textTransform="uppercase"
|
||||
type="label"
|
||||
>
|
||||
{appDownloads.title}
|
||||
</Body>
|
||||
</Caption>
|
||||
<ul className={styles.secondaryNavigationList}>
|
||||
{appDownloads.links.map(
|
||||
(link) =>
|
||||
@@ -50,9 +55,13 @@ export default function FooterSecondaryNav({
|
||||
)}
|
||||
{secondaryLinks.map((link) => (
|
||||
<nav className={styles.secondaryNavigationGroup} key={link.title}>
|
||||
<Body color="baseTextMediumContrast" textTransform="uppercase">
|
||||
<Caption
|
||||
color="textMediumContrast"
|
||||
textTransform="uppercase"
|
||||
type="label"
|
||||
>
|
||||
{link.title}
|
||||
</Body>
|
||||
</Caption>
|
||||
<ul className={styles.secondaryNavigationList}>
|
||||
{link?.links?.map((link) => (
|
||||
<li key={link.title} className={styles.secondaryNavigationItem}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.section {
|
||||
background: var(--Primary-Light-Surface-Normal);
|
||||
background: var(--Base-Surface-Subtle-Normal);
|
||||
padding: var(--Spacing-x7) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
FocusEvent,
|
||||
FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useReducer,
|
||||
} from "react"
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
@@ -25,14 +26,14 @@ import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
const name = "search"
|
||||
export default function Search({ locations }: SearchProps) {
|
||||
const { register, setValue, trigger } = useFormContext<BookingWidgetSchema>()
|
||||
const intl = useIntl()
|
||||
const value = useWatch({ name })
|
||||
const [state, dispatch] = useReducer(
|
||||
reducer,
|
||||
{ defaultLocations: locations },
|
||||
init
|
||||
)
|
||||
const { register, setValue, trigger } = useFormContext<BookingWidgetSchema>()
|
||||
const intl = useIntl()
|
||||
const value = useWatch({ name })
|
||||
|
||||
const handleMatchLocations = useCallback(
|
||||
function (searchValue: string) {
|
||||
@@ -113,6 +114,26 @@ export default function Search({ locations }: SearchProps) {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const searchData =
|
||||
typeof window !== "undefined"
|
||||
? sessionStorage.getItem(sessionStorageKey)
|
||||
: undefined
|
||||
const searchHistory =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem(localStorageKey)
|
||||
: null
|
||||
if (searchData || searchHistory) {
|
||||
dispatch({
|
||||
payload: {
|
||||
searchData: searchData ? JSON.parse(searchData) : undefined,
|
||||
searchHistory: searchHistory ? JSON.parse(searchHistory) : null,
|
||||
},
|
||||
type: ActionType.SET_STORAGE_DATA,
|
||||
})
|
||||
}
|
||||
}, [dispatch])
|
||||
|
||||
return (
|
||||
<Downshift
|
||||
initialSelectedItem={state.searchData}
|
||||
|
||||
@@ -4,46 +4,18 @@ import {
|
||||
type InitState,
|
||||
type State,
|
||||
} from "@/types/components/form/bookingwidget"
|
||||
import type { Location, Locations } from "@/types/trpc/routers/hotel/locations"
|
||||
import type { Locations } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
export const localStorageKey = "searchHistory"
|
||||
export function getSearchHistoryFromLocalStorage() {
|
||||
if (typeof window !== "undefined") {
|
||||
const storageSearchHistory = window.localStorage.getItem(localStorageKey)
|
||||
if (storageSearchHistory) {
|
||||
const parsedStorageSearchHistory: Locations =
|
||||
JSON.parse(storageSearchHistory)
|
||||
if (parsedStorageSearchHistory?.length) {
|
||||
return parsedStorageSearchHistory
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const sessionStorageKey = "searchData"
|
||||
export function getSearchDataFromSessionStorage() {
|
||||
if (typeof window !== "undefined") {
|
||||
const storageSearchData = window.sessionStorage.getItem(sessionStorageKey)
|
||||
if (storageSearchData) {
|
||||
const parsedStorageSearchData: Location = JSON.parse(storageSearchData)
|
||||
if (parsedStorageSearchData) {
|
||||
return parsedStorageSearchData
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function init(initState: InitState): State {
|
||||
const searchHistory = getSearchHistoryFromLocalStorage()
|
||||
const searchData = getSearchDataFromSessionStorage()
|
||||
return {
|
||||
defaultLocations: initState.defaultLocations,
|
||||
locations: [],
|
||||
search: "",
|
||||
searchData,
|
||||
searchHistory,
|
||||
searchData: undefined,
|
||||
searchHistory: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +68,13 @@ export function reducer(state: State, action: Action) {
|
||||
searchHistory: action.payload.searchHistory,
|
||||
}
|
||||
}
|
||||
case ActionType.SET_STORAGE_DATA: {
|
||||
return {
|
||||
...state,
|
||||
searchData: action.payload.searchData,
|
||||
searchHistory: action.payload.searchHistory,
|
||||
}
|
||||
}
|
||||
default:
|
||||
const unhandledActionType: never = type
|
||||
console.info(`Unhandled type: ${unhandledActionType}`)
|
||||
|
||||
@@ -25,8 +25,7 @@ export default function Form({ locations, type }: BookingWidgetFormProps) {
|
||||
type,
|
||||
})
|
||||
|
||||
const { formState, handleSubmit, register } =
|
||||
useFormContext<BookingWidgetSchema>()
|
||||
const { handleSubmit, register } = useFormContext<BookingWidgetSchema>()
|
||||
|
||||
function onSubmit(data: BookingWidgetSchema) {
|
||||
const locationData: Location = JSON.parse(decodeURIComponent(data.location))
|
||||
|
||||
@@ -11,7 +11,7 @@ import Counter from "../Counter"
|
||||
|
||||
import styles from "./adult-selector.module.css"
|
||||
|
||||
import { BedTypeEnum } from "@/types/components/bookingWidget/enums"
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import {
|
||||
AdultSelectorProps,
|
||||
Child,
|
||||
@@ -40,14 +40,14 @@ export default function AdultSelector({ roomIndex = 0 }: AdultSelectorProps) {
|
||||
setValue(`rooms.${roomIndex}.adults`, adults - 1)
|
||||
if (childrenInAdultsBed > adults) {
|
||||
const toUpdateIndex = child.findIndex(
|
||||
(child: Child) => child.bed == BedTypeEnum.IN_ADULTS_BED
|
||||
(child: Child) => child.bed == ChildBedMapEnum.IN_ADULTS_BED
|
||||
)
|
||||
if (toUpdateIndex != -1) {
|
||||
setValue(
|
||||
`rooms.${roomIndex}.children.${toUpdateIndex}.bed`,
|
||||
child[toUpdateIndex].age < 3
|
||||
? BedTypeEnum.IN_CRIB
|
||||
: BedTypeEnum.IN_EXTRA_BED
|
||||
? ChildBedMapEnum.IN_CRIB
|
||||
: ChildBedMapEnum.IN_EXTRA_BED
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import styles from "./child-selector.module.css"
|
||||
|
||||
import { BedTypeEnum } from "@/types/components/bookingWidget/enums"
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import {
|
||||
ChildBed,
|
||||
ChildInfoSelectorProps,
|
||||
@@ -59,9 +59,9 @@ export default function ChildInfoSelector({
|
||||
}
|
||||
|
||||
function updateSelectedBed(bed: number) {
|
||||
if (bed == BedTypeEnum.IN_ADULTS_BED) {
|
||||
if (bed == ChildBedMapEnum.IN_ADULTS_BED) {
|
||||
increaseChildInAdultsBed(roomIndex)
|
||||
} else if (child.bed == BedTypeEnum.IN_ADULTS_BED) {
|
||||
} else if (child.bed == ChildBedMapEnum.IN_ADULTS_BED) {
|
||||
decreaseChildInAdultsBed(roomIndex)
|
||||
}
|
||||
updateChildBed(bed, roomIndex, index)
|
||||
@@ -71,15 +71,15 @@ export default function ChildInfoSelector({
|
||||
const allBedTypes: ChildBed[] = [
|
||||
{
|
||||
label: intl.formatMessage({ id: "In adults bed" }),
|
||||
value: BedTypeEnum.IN_ADULTS_BED,
|
||||
value: ChildBedMapEnum.IN_ADULTS_BED,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: "In crib" }),
|
||||
value: BedTypeEnum.IN_CRIB,
|
||||
value: ChildBedMapEnum.IN_CRIB,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: "In extra bed" }),
|
||||
value: BedTypeEnum.IN_EXTRA_BED,
|
||||
value: ChildBedMapEnum.IN_EXTRA_BED,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import styles from "./counter.module.css"
|
||||
|
||||
import { CounterProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||
import type { CounterProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||
|
||||
export default function Counter({
|
||||
count,
|
||||
|
||||
@@ -6,11 +6,9 @@
|
||||
border-radius: var(--Corner-radius-Rounded);
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background-color: var(--Main-Grey-40);
|
||||
background-color: var(--UI-Grey-40);
|
||||
}
|
||||
|
||||
.initials {
|
||||
font-size: 0.75rem;
|
||||
color: var(--Base-Text-Inverted);
|
||||
background-color: var(--Base-Icon-Low-contrast);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PersonIcon } from "@/components/Icons"
|
||||
import Image from "@/components/Image"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
|
||||
import styles from "./avatar.module.css"
|
||||
|
||||
@@ -13,7 +14,11 @@ export default function Avatar({ image, initials }: AvatarProps) {
|
||||
element = <Image src={image.src} alt={image.alt} width={28} height={28} />
|
||||
} else if (initials) {
|
||||
classNames.push(styles.initials)
|
||||
element = <span>{initials}</span>
|
||||
element = (
|
||||
<Footnote type="label" color="white" textTransform="uppercase" asChild>
|
||||
<span>{initials}</span>
|
||||
</Footnote>
|
||||
)
|
||||
}
|
||||
return <span className={classNames.join(" ")}>{element}</span>
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
.menuButton {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
gap: var(--Spacing-x-half);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
color: var(--Base-Text-High-contrast);
|
||||
border-width: 0;
|
||||
padding: 0;
|
||||
padding: var(--Spacing-x-half) var(--Spacing-x1);
|
||||
cursor: pointer;
|
||||
font-family: var(--typography-Body-Bold-fontFamily);
|
||||
font-weight: var(--typography-Body-Bold-fontWeight);
|
||||
font-weight: 500; /* Should be fixed when variables starts working: var(--typography-Body-Bold-fontWeight); */
|
||||
font-size: var(--typography-Body-Bold-fontSize);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import useDropdownStore from "@/stores/main-menu"
|
||||
|
||||
import { ChevronDownIcon } from "@/components/Icons"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { ChevronDownSmallIcon } from "@/components/Icons"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import useClickOutside from "@/hooks/useClickOutside"
|
||||
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
||||
import { getInitials } from "@/utils/user"
|
||||
@@ -47,12 +47,12 @@ export default function MyPagesMenu({
|
||||
onClick={() => toggleDropdown(DropdownTypeEnum.MyPagesMenu)}
|
||||
>
|
||||
<Avatar initials={getInitials(user.firstName, user.lastName)} />
|
||||
<Subtitle type="two" asChild>
|
||||
<Body textTransform="bold" color="textHighContrast" asChild>
|
||||
<span>
|
||||
{intl.formatMessage({ id: "Hi" })} {user.firstName}!
|
||||
</span>
|
||||
</Subtitle>
|
||||
<ChevronDownIcon
|
||||
</Body>
|
||||
<ChevronDownSmallIcon
|
||||
className={`${styles.chevron} ${isMyPagesMenuOpen ? styles.isExpanded : ""}`}
|
||||
color="red"
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
@media screen and (min-width: 768px) {
|
||||
.myPagesMenu {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
@@ -18,7 +17,9 @@
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: 2.875rem; /* 2.875rem is the height of the main menu + bottom padding */
|
||||
top: calc(
|
||||
3.5rem - 2px
|
||||
); /* 3.5rem is the height of the main menu + bottom padding. */
|
||||
right: 0;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
|
||||
@@ -63,7 +63,8 @@ export default function MyPagesMenuContent({
|
||||
href={link.originalUrl || link.url}
|
||||
onClick={toggleOpenStateFn}
|
||||
variant="menu"
|
||||
className={`${styles.link} ${menuItem.display_sign_out_link ? styles.smallLink : ""}`}
|
||||
weight={menuItem.display_sign_out_link ? undefined : "bold"}
|
||||
className={styles.link}
|
||||
>
|
||||
{link.linkText}
|
||||
<ArrowRightIcon className={styles.arrow} color="burgundy" />
|
||||
@@ -76,7 +77,7 @@ export default function MyPagesMenuContent({
|
||||
href={logout[lang]}
|
||||
prefetch={false}
|
||||
variant="menu"
|
||||
className={`${styles.link} ${styles.smallLink}`}
|
||||
className={styles.link}
|
||||
>
|
||||
{intl.formatMessage({ id: "Log out" })}
|
||||
</Link>
|
||||
|
||||
@@ -32,14 +32,6 @@
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.link.smallLink {
|
||||
font-family: var(--typography-Body-Regular-fontFamily);
|
||||
font-size: var(--typography-Body-Regular-fontSize);
|
||||
font-weight: var(--typography-Body-Regular-fontWeight);
|
||||
line-height: var(--typography-Body-Regular-lineHeight);
|
||||
letter-spacing: var(--typography-Body-Regular-letterSpacing);
|
||||
}
|
||||
|
||||
.link:not(:hover) .arrow {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import LoginButton from "@/components/LoginButton"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import Avatar from "../Avatar"
|
||||
import MyPagesMenu from "../MyPagesMenu"
|
||||
@@ -17,7 +16,6 @@ import MyPagesMobileMenu from "../MyPagesMobileMenu"
|
||||
import styles from "./myPagesMenuWrapper.module.css"
|
||||
|
||||
export default async function MyPagesMenuWrapper() {
|
||||
const lang = getLang()
|
||||
const [intl, myPagesNavigation, user, membership] = await Promise.all([
|
||||
getIntl(),
|
||||
getMyPagesNavigation(),
|
||||
@@ -56,7 +54,7 @@ export default async function MyPagesMenuWrapper() {
|
||||
trackingId="loginStartNewTopMenu"
|
||||
>
|
||||
<Avatar />
|
||||
<span className={styles.userName}>
|
||||
<span className={styles.loginText}>
|
||||
{intl.formatMessage({ id: "Log in/Join" })}
|
||||
</span>
|
||||
</LoginButton>
|
||||
|
||||
@@ -4,12 +4,8 @@
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.userName {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.userName {
|
||||
display: inline;
|
||||
@media screen and (max-width: 767px) {
|
||||
.loginText {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function MegaMenu({
|
||||
className={styles.backButton}
|
||||
onClick={() => toggleMegaMenu(false)}
|
||||
>
|
||||
<ChevronLeftIcon color="red" />
|
||||
<ChevronLeftIcon color="red" height={20} width={20} />
|
||||
<Subtitle type="one" color="burgundy" asChild>
|
||||
<span>{title}</span>
|
||||
</Subtitle>
|
||||
@@ -55,6 +55,7 @@ export default function MegaMenu({
|
||||
href={seeAllLink.link.url}
|
||||
color="burgundy"
|
||||
variant="icon"
|
||||
weight="bold"
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
{seeAllLink.title}
|
||||
@@ -65,7 +66,12 @@ export default function MegaMenu({
|
||||
<ul className={styles.submenus}>
|
||||
{submenu.map((item) => (
|
||||
<li key={item.title} className={styles.submenusItem}>
|
||||
<Caption textTransform="uppercase" asChild>
|
||||
<Caption
|
||||
type="label"
|
||||
color="uiTextPlaceholder"
|
||||
textTransform="uppercase"
|
||||
asChild
|
||||
>
|
||||
<span className={styles.submenuTitle}>{item.title}</span>
|
||||
</Caption>
|
||||
<ul className={styles.submenu}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRef } from "react"
|
||||
|
||||
import useDropdownStore from "@/stores/main-menu"
|
||||
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@/components/Icons"
|
||||
import { ChevronDownSmallIcon, ChevronRightIcon } from "@/components/Icons"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import useClickOutside from "@/hooks/useClickOutside"
|
||||
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
||||
@@ -41,9 +41,14 @@ export default function MenuItem({ item, isMobile }: NavigationMenuItemProps) {
|
||||
>
|
||||
{title}
|
||||
{isMobile ? (
|
||||
<ChevronRightIcon className={`${styles.chevron}`} color="red" />
|
||||
<ChevronRightIcon
|
||||
className={`${styles.chevron}`}
|
||||
color="red"
|
||||
height={20}
|
||||
width={20}
|
||||
/>
|
||||
) : (
|
||||
<ChevronDownIcon
|
||||
<ChevronDownSmallIcon
|
||||
className={`${styles.chevron} ${isMegaMenuOpen ? styles.isExpanded : ""}`}
|
||||
color="red"
|
||||
/>
|
||||
@@ -67,7 +72,8 @@ export default function MenuItem({ item, isMobile }: NavigationMenuItemProps) {
|
||||
) : (
|
||||
<Link
|
||||
className={`${styles.navigationMenuItem} ${isMobile ? styles.mobile : styles.desktop}`}
|
||||
color="burgundy"
|
||||
variant="navigation"
|
||||
weight="bold"
|
||||
href={link!.url}
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.navigationMenuItem {
|
||||
font-weight: 500; /* Should be fixed when variables starts working: var(--typography-Body-Bold-fontWeight); */
|
||||
}
|
||||
|
||||
.navigationMenuItem.mobile {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -38,8 +42,9 @@
|
||||
.dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: var(--main-menu-desktop-height);
|
||||
/* top: var(--Spacing-x5); */
|
||||
top: calc(
|
||||
3.5rem - 2px
|
||||
); /* 3.5rem is the height of the main menu + bottom padding. */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
margin: 0;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x4);
|
||||
gap: var(--Spacing-x3);
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: relative;
|
||||
max-width: var(--max-width-navigation);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { getHeader, getLanguageSwitcher } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { GiftIcon, SearchIcon } from "@/components/Icons"
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import HeaderLink from "../HeaderLink"
|
||||
@@ -23,20 +25,27 @@ export default async function TopMenu() {
|
||||
<div className={styles.topMenu}>
|
||||
<div className={styles.content}>
|
||||
{header.data.topLink.link ? (
|
||||
<HeaderLink
|
||||
className={styles.topLink}
|
||||
href={header.data.topLink.link.url}
|
||||
>
|
||||
<GiftIcon width={20} height={20} color="burgundy" />
|
||||
{header.data.topLink.title}
|
||||
</HeaderLink>
|
||||
<Caption type="regular" color="textMediumContrast" asChild>
|
||||
<Link
|
||||
href={header.data.topLink.link.url}
|
||||
color="peach80"
|
||||
variant="icon"
|
||||
>
|
||||
<GiftIcon width={20} height={20} />
|
||||
{header.data.topLink.title}
|
||||
</Link>
|
||||
</Caption>
|
||||
) : null}
|
||||
<div className={styles.options}>
|
||||
<LanguageSwitcher type="desktopHeader" urls={languages.urls} />
|
||||
<HeaderLink href="#">
|
||||
<SearchIcon width={20} height={20} color="burgundy" />
|
||||
{intl.formatMessage({ id: "Find booking" })}
|
||||
</HeaderLink>
|
||||
|
||||
<Caption type="regular" color="textMediumContrast" asChild>
|
||||
<Link href="#" color="peach80" variant="icon">
|
||||
<SearchIcon width={20} height={20} />
|
||||
{intl.formatMessage({ id: "Find booking" })}
|
||||
</Link>
|
||||
</Caption>
|
||||
<HeaderLink href="#"></HeaderLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
display: none;
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
padding: var(--Spacing-x2);
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
.actions {
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: grid;
|
||||
grid-area: actions;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.actions {
|
||||
& > button[class*="btn"][class*="icon"][class*="small"] {
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: 0;
|
||||
justify-content: space-between;
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
& > svg {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.actions {
|
||||
gap: var(--Spacing-x1);
|
||||
grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr;
|
||||
justify-content: center;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
CalendarIcon,
|
||||
ContractIcon,
|
||||
DownloadIcon,
|
||||
PrinterIcon,
|
||||
} from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./actions.module.css"
|
||||
|
||||
export default async function Actions() {
|
||||
const intl = await getIntl()
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
|
||||
<CalendarIcon />
|
||||
{intl.formatMessage({ id: "Add to calendar" })}
|
||||
</Button>
|
||||
<Divider color="subtle" variant="vertical" />
|
||||
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
|
||||
<ContractIcon />
|
||||
{intl.formatMessage({ id: "View terms" })}
|
||||
</Button>
|
||||
<Divider color="subtle" variant="vertical" />
|
||||
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
|
||||
<PrinterIcon />
|
||||
{intl.formatMessage({ id: "Print confirmation" })}
|
||||
</Button>
|
||||
<Divider color="subtle" variant="vertical" />
|
||||
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
|
||||
<DownloadIcon />
|
||||
{intl.formatMessage({ id: "Download invoice" })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
.details {
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
grid-area: details;
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.details {
|
||||
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { dt } from "@/lib/dt"
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./details.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default async function Details({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
const { booking } = await getBookingConfirmation(confirmationNumber)
|
||||
|
||||
const fromDate = dt(booking.checkInDate).locale(lang)
|
||||
const toDate = dt(booking.checkOutDate).locale(lang)
|
||||
|
||||
return (
|
||||
<article className={styles.details}>
|
||||
<header>
|
||||
<Subtitle color="burgundy" type="two">
|
||||
{intl.formatMessage(
|
||||
{ id: "Reference #{bookingNr}" },
|
||||
{ bookingNr: booking.confirmationNumber }
|
||||
)}
|
||||
</Subtitle>
|
||||
</header>
|
||||
<ul className={styles.list}>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Check-in" })}</Body>
|
||||
<Body>
|
||||
{`${fromDate.format("ddd, D MMM")} ${intl.formatMessage({ id: "from" })} ${fromDate.format("HH:mm")}`}
|
||||
</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Check-out" })}</Body>
|
||||
<Body>
|
||||
{`${toDate.format("ddd, D MMM")} ${intl.formatMessage({ id: "from" })} ${toDate.format("HH:mm")}`}
|
||||
</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Breakfast" })}</Body>
|
||||
<Body>N/A</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Cancellation policy" })}</Body>
|
||||
<Body>N/A</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Rebooking" })}</Body>
|
||||
<Body>N/A</Body>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.header,
|
||||
.hgroup {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.hgroup {
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.body {
|
||||
max-width: 560px;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./header.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default async function Header({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const intl = await getIntl()
|
||||
const { hotel } = await getBookingConfirmation(confirmationNumber)
|
||||
|
||||
const text = intl.formatMessage<React.ReactNode>(
|
||||
{ id: "booking.confirmation.text" },
|
||||
{
|
||||
emailLink: (str) => (
|
||||
<Link color="burgundy" href="#" textDecoration="underline">
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<hgroup className={styles.hgroup}>
|
||||
<BiroScript color="red" tilted="small" type="two">
|
||||
{intl.formatMessage({ id: "See you soon!" })}
|
||||
</BiroScript>
|
||||
<Title
|
||||
as="h4"
|
||||
color="red"
|
||||
textAlign="center"
|
||||
textTransform="regular"
|
||||
type="h2"
|
||||
>
|
||||
{intl.formatMessage({ id: "booking.confirmation.title" })}
|
||||
</Title>
|
||||
<Title
|
||||
as="h4"
|
||||
color="burgundy"
|
||||
textAlign="center"
|
||||
textTransform="regular"
|
||||
type="h1"
|
||||
>
|
||||
{hotel.name}
|
||||
</Title>
|
||||
</hgroup>
|
||||
<Body className={styles.body} textAlign="center">
|
||||
{text}
|
||||
</Body>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.imageContainer {
|
||||
align-items: center;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: flex;
|
||||
grid-area: image;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Image from "@/components/Image"
|
||||
|
||||
import styles from "./image.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default async function HotelImage({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const { hotel } = await getBookingConfirmation(confirmationNumber)
|
||||
return (
|
||||
<aside className={styles.imageContainer}>
|
||||
<Image
|
||||
alt={hotel.hotelContent.images.metaData.altText}
|
||||
height={256}
|
||||
src={hotel.hotelContent.images.imageSizes.medium}
|
||||
title={hotel.hotelContent.images.metaData.title}
|
||||
width={256}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { profile } from "@/constants/routes/myPages"
|
||||
import { dt } from "@/lib/dt"
|
||||
import {
|
||||
getBookingConfirmation,
|
||||
getProfileSafely,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { CreditCardAddIcon, EditIcon, PersonIcon } from "@/components/Icons"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default async function Summary({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
const { booking, hotel } = await getBookingConfirmation(confirmationNumber)
|
||||
const user = await getProfileSafely()
|
||||
const { firstName, lastName } = booking.guest
|
||||
const membershipNumber = user?.membership?.membershipNumber
|
||||
const totalNights = dt(booking.checkOutDate.setHours(0, 0, 0)).diff(
|
||||
dt(booking.checkInDate.setHours(0, 0, 0)),
|
||||
"days"
|
||||
)
|
||||
|
||||
const breakfastPackage = booking.packages.find(
|
||||
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
|
||||
)
|
||||
return (
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Guest" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{`${firstName} ${lastName}`}</Body>
|
||||
{membershipNumber ? (
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "membership.no" },
|
||||
{ membershipNumber }
|
||||
)}
|
||||
</Body>
|
||||
) : null}
|
||||
<Body color="uiTextHighContrast">{booking.guest.email}</Body>
|
||||
<Body color="uiTextHighContrast">{booking.guest.phoneNumber}</Body>
|
||||
</div>
|
||||
{user ? (
|
||||
<Link className={styles.link} href={profile[lang]} variant="icon">
|
||||
<PersonIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Go to profile" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Payment" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "guest.paid" },
|
||||
{
|
||||
amount: intl.formatNumber(booking.totalPrice),
|
||||
currency: booking.currencyCode,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">Date information N/A</Body>
|
||||
<Body color="uiTextHighContrast">Card information N/A</Body>
|
||||
</div>
|
||||
{/* # href until more info */}
|
||||
{user ? (
|
||||
<Link className={styles.link} href="#" variant="icon">
|
||||
<CreditCardAddIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Save card to profile" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Booking" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
N/A, {intl.formatMessage({ id: "booking.nights" }, { totalNights })}
|
||||
,{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: booking.adults }
|
||||
)}
|
||||
</Body>
|
||||
{breakfastPackage ? (
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast added" })}
|
||||
</Body>
|
||||
) : null}
|
||||
<Body color="uiTextHighContrast">Bedtype N/A</Body>
|
||||
</div>
|
||||
{/* # href until more info */}
|
||||
<Link className={styles.link} href="#" variant="icon">
|
||||
<EditIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Manage booking" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Hotel" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{hotel.name}</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{`${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{hotel.contactInformation.phoneNumber}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast" className={styles.latLong}>
|
||||
{`${intl.formatMessage({ id: "Longitude" }, { long: hotel.location.longitude })} ∙ ${intl.formatMessage({ id: "Latitude" }, { lat: hotel.location.latitude })}`}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.hotelLinks}>
|
||||
<Link color="peach80" href={hotel.contactInformation.websiteUrl}>
|
||||
{hotel.contactInformation.websiteUrl}
|
||||
</Link>
|
||||
<Link
|
||||
color="peach80"
|
||||
href={`mailto:${hotel.contactInformation.email}`}
|
||||
>
|
||||
{hotel.contactInformation.email}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
.summary {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.container,
|
||||
.textContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.textContainer {
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.container .textContainer .latLong {
|
||||
padding-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.hotelLinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary .container .link {
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import {
|
||||
CoffeeIcon,
|
||||
DiscountIcon,
|
||||
DoorClosedIcon,
|
||||
PriceTagIcon,
|
||||
} from "@/components/Icons"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./totalPrice.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default async function TotalPrice({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const intl = await getIntl()
|
||||
const { booking } = await getBookingConfirmation(confirmationNumber)
|
||||
|
||||
const totalPrice = intl.formatNumber(booking.totalPrice, {
|
||||
currency: booking.currencyCode,
|
||||
style: "currency",
|
||||
})
|
||||
const breakfastPackage = booking.packages.find(
|
||||
(p) => p.code === BreakfastPackageEnum.REGULAR_BREAKFAST
|
||||
)
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<hgroup>
|
||||
<Subtitle color="uiTextPlaceholder" type="two">
|
||||
{intl.formatMessage({ id: "Total price" })}
|
||||
</Subtitle>
|
||||
<Subtitle color="uiTextHighContrast" type="two">
|
||||
{totalPrice} (~ EUR)
|
||||
</Subtitle>
|
||||
</hgroup>
|
||||
<div className={styles.items}>
|
||||
<div>
|
||||
<DoorClosedIcon />
|
||||
<Body color="uiTextPlaceholder">
|
||||
{`${intl.formatMessage({ id: "Room" })}, ${intl.formatMessage({ id: "booking.nights" }, { totalNights: 1 })}`}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{totalPrice}</Body>
|
||||
</div>
|
||||
<div>
|
||||
<CoffeeIcon />
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Breakfast" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{breakfastPackage
|
||||
? intl.formatNumber(breakfastPackage.totalPrice, {
|
||||
currency: breakfastPackage.currency,
|
||||
style: "currency",
|
||||
})
|
||||
: intl.formatMessage({ id: "No breakfast" })}
|
||||
</Body>
|
||||
</div>
|
||||
<div>
|
||||
<DiscountIcon />
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Member discount" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">N/A</Body>
|
||||
</div>
|
||||
<div>
|
||||
<PriceTagIcon height={20} width={20} />
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Points used" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">N/A</Body>
|
||||
</div>
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.items}>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Price excl VAT" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatNumber(booking.totalPriceExVat, {
|
||||
currency: booking.currencyCode,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "VAT" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">{booking.vatPercentage}%</Caption>
|
||||
</div>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "VAT amount" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatNumber(booking.vatAmount, {
|
||||
currency: booking.currencyCode,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Price incl VAT" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatNumber(booking.totalPrice, {
|
||||
currency: booking.currencyCode,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Payment method" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">N/A</Caption>
|
||||
</div>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Payment status" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">N/A</Caption>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.container {
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3) var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.items {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3) var(--Spacing-x1);
|
||||
grid-template-columns: repeat(4, minmax(100px, 1fr));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x9);
|
||||
}
|
||||
|
||||
.booking {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
grid-template-areas:
|
||||
"image"
|
||||
"details"
|
||||
"actions";
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.booking {
|
||||
grid-template-areas:
|
||||
"details image"
|
||||
"actions actions";
|
||||
grid-template-columns: 1fr minmax(256px, min(256px, 100%));
|
||||
}
|
||||
}
|
||||
31
components/HotelReservation/BookingConfirmation/index.tsx
Normal file
31
components/HotelReservation/BookingConfirmation/index.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import Actions from "./Actions"
|
||||
import Details from "./Details"
|
||||
import Header from "./Header"
|
||||
import HotelImage from "./HotelImage"
|
||||
import Summary from "./Summary"
|
||||
import TotalPrice from "./TotalPrice"
|
||||
|
||||
import styles from "./bookingConfirmation.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default function BookingConfirmation({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
return (
|
||||
<>
|
||||
<Header confirmationNumber={confirmationNumber} />
|
||||
<section className={styles.section}>
|
||||
<div className={styles.booking}>
|
||||
<Details confirmationNumber={confirmationNumber} />
|
||||
<HotelImage confirmationNumber={confirmationNumber} />
|
||||
<Actions />
|
||||
</div>
|
||||
{/* Supposed Ancillaries */}
|
||||
<Summary confirmationNumber={confirmationNumber} />
|
||||
<TotalPrice confirmationNumber={confirmationNumber} />
|
||||
{/* Supposed Info Card - Where should it come from?? */}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -3,45 +3,52 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { KingBedIcon } from "@/components/Icons"
|
||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||
|
||||
import { bedTypeSchema } from "./schema"
|
||||
import { bedTypeFormSchema } from "./schema"
|
||||
|
||||
import styles from "./bedOptions.module.css"
|
||||
|
||||
import type {
|
||||
BedTypeFormSchema,
|
||||
BedTypeProps,
|
||||
BedTypeSchema,
|
||||
} from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
|
||||
export default function BedType({ bedTypes }: BedTypeProps) {
|
||||
const intl = useIntl()
|
||||
const bedType = useEnterDetailsStore((state) => state.userData.bedType)
|
||||
|
||||
const methods = useForm<BedTypeSchema>({
|
||||
defaultValues: bedType
|
||||
const methods = useForm<BedTypeFormSchema>({
|
||||
defaultValues: bedType?.roomTypeCode
|
||||
? {
|
||||
bedType,
|
||||
bedType: bedType.roomTypeCode,
|
||||
}
|
||||
: undefined,
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(bedTypeSchema),
|
||||
resolver: zodResolver(bedTypeFormSchema),
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
const completeStep = useEnterDetailsStore((state) => state.completeStep)
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(values: BedTypeSchema) => {
|
||||
completeStep(values)
|
||||
(bedTypeRoomCode: BedTypeFormSchema) => {
|
||||
const matchingRoom = bedTypes.find(
|
||||
(roomType) => roomType.value === bedTypeRoomCode.bedType
|
||||
)
|
||||
if (matchingRoom) {
|
||||
const bedType = {
|
||||
description: matchingRoom.description,
|
||||
roomTypeCode: matchingRoom.value,
|
||||
}
|
||||
completeStep({ bedType })
|
||||
}
|
||||
},
|
||||
[completeStep]
|
||||
[completeStep, bedTypes]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,7 +77,7 @@ export default function BedType({ bedTypes }: BedTypeProps) {
|
||||
name="bedType"
|
||||
subtitle={width}
|
||||
title={roomType.description}
|
||||
value={roomType.description}
|
||||
value={roomType.value}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { BedTypeEnum } from "@/types/enums/bedType"
|
||||
|
||||
export const bedTypeSchema = z.object({
|
||||
bedType: z.object({ description: z.string(), roomTypeCode: z.string() }),
|
||||
})
|
||||
export const bedTypeFormSchema = z.object({
|
||||
bedType: z.string(),
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
.container {
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3) 0px;
|
||||
}
|
||||
|
||||
.form {
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
.country,
|
||||
.email,
|
||||
.membershipNo,
|
||||
.phone {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { registerUserBookingFlow } from "@/actions/registerUserBookingFlow"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import Phone from "@/components/TempDesignSystem/Form/Phone"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
|
||||
import { guestDetailsSchema, signedInDetailsSchema } from "./schema"
|
||||
import Signup from "./Signup"
|
||||
@@ -36,6 +34,7 @@ export default function Details({ user }: DetailsProps) {
|
||||
dateOfBirth: state.userData.dateOfBirth,
|
||||
zipCode: state.userData.zipCode,
|
||||
termsAccepted: state.userData.termsAccepted,
|
||||
membershipNo: state.userData.membershipNo,
|
||||
}))
|
||||
|
||||
const methods = useForm<DetailsSchema>({
|
||||
@@ -45,11 +44,11 @@ export default function Details({ user }: DetailsProps) {
|
||||
firstName: user?.firstName ?? initialData.firstName,
|
||||
lastName: user?.lastName ?? initialData.lastName,
|
||||
phoneNumber: user?.phoneNumber ?? initialData.phoneNumber,
|
||||
//@ts-expect-error: We use a literal for join to be true or false, which does not convert to a boolean
|
||||
join: initialData.join,
|
||||
dateOfBirth: initialData.dateOfBirth,
|
||||
zipCode: initialData.zipCode,
|
||||
termsAccepted: initialData.termsAccepted,
|
||||
membershipNo: initialData.membershipNo,
|
||||
},
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
@@ -59,55 +58,22 @@ export default function Details({ user }: DetailsProps) {
|
||||
|
||||
const completeStep = useEnterDetailsStore((state) => state.completeStep)
|
||||
|
||||
// const errorMessage = intl.formatMessage({
|
||||
// id: "An error occurred. Please try again.",
|
||||
// })
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async function (values: DetailsSchema) {
|
||||
if (values.join) {
|
||||
const signupVals = {
|
||||
firstName: values.firstName,
|
||||
lastName: values.lastName,
|
||||
email: values.email,
|
||||
phoneNumber: values.phoneNumber,
|
||||
address: {
|
||||
zipCode: values.zipCode,
|
||||
countryCode: values.countryCode,
|
||||
},
|
||||
dateOfBirth: values.dateOfBirth,
|
||||
}
|
||||
|
||||
const res = await registerUserBookingFlow(signupVals)
|
||||
if (!res.success) {
|
||||
// if (res.error) {
|
||||
// toast.error(res.error)
|
||||
// } else {
|
||||
// toast.error(errorMessage)
|
||||
// }
|
||||
return
|
||||
}
|
||||
console.log("Signed up user: ", res)
|
||||
}
|
||||
completeStep(values)
|
||||
},
|
||||
|
||||
[completeStep]
|
||||
)
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<section className={styles.container}>
|
||||
<header>
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{intl.formatMessage({ id: "Guest information" })}
|
||||
</Body>
|
||||
</header>
|
||||
<form
|
||||
className={styles.form}
|
||||
id={formID}
|
||||
onSubmit={methods.handleSubmit(onSubmit)}
|
||||
<form
|
||||
className={styles.form}
|
||||
id={formID}
|
||||
onSubmit={methods.handleSubmit(completeStep)}
|
||||
>
|
||||
{user ? null : <Signup name="join" />}
|
||||
<Footnote
|
||||
color="uiTextHighContrast"
|
||||
textTransform="uppercase"
|
||||
type="label"
|
||||
>
|
||||
{intl.formatMessage({ id: "Guest information" })}
|
||||
</Footnote>
|
||||
<div className={styles.container}>
|
||||
<Input
|
||||
label={intl.formatMessage({ id: "First name" })}
|
||||
name="firstName"
|
||||
@@ -141,8 +107,15 @@ export default function Details({ user }: DetailsProps) {
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
{user ? null : <Signup name="join" />}
|
||||
</form>
|
||||
{user ? null : (
|
||||
<Input
|
||||
className={styles.membershipNo}
|
||||
label={intl.formatMessage({ id: "Membership no" })}
|
||||
name="membershipNo"
|
||||
type="tel"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<footer className={styles.footer}>
|
||||
<Button
|
||||
disabled={!methods.formState.isValid}
|
||||
@@ -155,7 +128,7 @@ export default function Details({ user }: DetailsProps) {
|
||||
{intl.formatMessage({ id: "Proceed to payment method" })}
|
||||
</Button>
|
||||
</footer>
|
||||
</section>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,19 +12,34 @@ export const baseDetailsSchema = z.object({
|
||||
|
||||
export const notJoinDetailsSchema = baseDetailsSchema.merge(
|
||||
z.object({
|
||||
join: z.literal(false),
|
||||
join: z.literal<boolean>(false),
|
||||
zipCode: z.string().optional(),
|
||||
dateOfBirth: z.string().optional(),
|
||||
termsAccepted: z.boolean().default(false),
|
||||
membershipNo: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((val) => {
|
||||
if (val) {
|
||||
return !val.match(/[^0-9]/g)
|
||||
}
|
||||
return true
|
||||
}, "Only digits are allowed")
|
||||
.refine((num) => {
|
||||
if (num) {
|
||||
return num.length === 14
|
||||
}
|
||||
return true
|
||||
}, "Membership number needs to be 14 digits"),
|
||||
})
|
||||
)
|
||||
|
||||
export const joinDetailsSchema = baseDetailsSchema.merge(
|
||||
z.object({
|
||||
join: z.literal(true),
|
||||
join: z.literal<boolean>(true),
|
||||
zipCode: z.string().min(1, { message: "Zip code is required" }),
|
||||
dateOfBirth: z.string().min(1, { message: "Date of birth is required" }),
|
||||
termsAccepted: z.literal(true, {
|
||||
termsAccepted: z.literal<boolean>(true, {
|
||||
errorMap: (err, ctx) => {
|
||||
switch (err.code) {
|
||||
case "invalid_literal":
|
||||
@@ -33,6 +48,7 @@ export const joinDetailsSchema = baseDetailsSchema.merge(
|
||||
return { message: ctx.defaultError }
|
||||
},
|
||||
}),
|
||||
membershipNo: z.string().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { isValidPhoneNumber, parsePhoneNumber } from "libphonenumber-js"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Label as AriaLabel } from "react-aria-components"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
@@ -30,13 +31,16 @@ import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { bedTypeMap } from "../../SelectRate/RoomSelection/utils"
|
||||
import GuaranteeDetails from "./GuaranteeDetails"
|
||||
import PaymentOption from "./PaymentOption"
|
||||
import { PaymentFormData, paymentSchema } from "./schema"
|
||||
|
||||
import styles from "./payment.module.css"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
const maxRetries = 40
|
||||
const retryInterval = 2000
|
||||
@@ -46,7 +50,7 @@ function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
|
||||
}
|
||||
|
||||
export default function Payment({
|
||||
hotelId,
|
||||
roomPrice,
|
||||
otherPaymentOptions,
|
||||
savedCreditCards,
|
||||
mustBeGuaranteed,
|
||||
@@ -55,8 +59,22 @@ export default function Payment({
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const queryParams = useSearchParams()
|
||||
const { firstName, lastName, email, phoneNumber, countryCode } =
|
||||
useEnterDetailsStore((state) => state.userData)
|
||||
const { userData, roomData } = useEnterDetailsStore((state) => ({
|
||||
userData: state.userData,
|
||||
roomData: state.roomData,
|
||||
}))
|
||||
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phoneNumber,
|
||||
countryCode,
|
||||
breakfast,
|
||||
bedType,
|
||||
} = userData
|
||||
const { toDate, fromDate, rooms: rooms, hotel } = roomData
|
||||
|
||||
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
|
||||
|
||||
const methods = useForm<PaymentFormData>({
|
||||
@@ -114,34 +132,50 @@ export default function Payment({
|
||||
(card) => card.id === data.paymentMethod
|
||||
)
|
||||
|
||||
let phone: string
|
||||
let phoneCountryCodePrefix: string | null = null
|
||||
|
||||
if (isValidPhoneNumber(phoneNumber)) {
|
||||
const parsedPhone = parsePhoneNumber(phoneNumber)
|
||||
phone = parsedPhone.nationalNumber
|
||||
phoneCountryCodePrefix = parsedPhone.countryCallingCode
|
||||
} else {
|
||||
phone = phoneNumber
|
||||
}
|
||||
|
||||
initiateBooking.mutate({
|
||||
hotelId: hotelId,
|
||||
checkInDate: "2024-12-10",
|
||||
checkOutDate: "2024-12-11",
|
||||
rooms: [
|
||||
{
|
||||
adults: 1,
|
||||
childrenAges: [],
|
||||
rateCode: "SAVEEU",
|
||||
roomTypeCode: "QC",
|
||||
guest: {
|
||||
title: "Mr", // TODO: do we need title?
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phoneCountryCodePrefix: phoneNumber.slice(0, 3),
|
||||
phoneNumber: phoneNumber.slice(3),
|
||||
countryCode,
|
||||
},
|
||||
packages: {
|
||||
breakfast: true,
|
||||
allergyFriendly: true,
|
||||
petFriendly: true,
|
||||
accessibility: true,
|
||||
},
|
||||
smsConfirmationRequested: data.smsConfirmation,
|
||||
hotelId: hotel,
|
||||
checkInDate: fromDate,
|
||||
checkOutDate: toDate,
|
||||
rooms: rooms.map((room) => ({
|
||||
adults: room.adults,
|
||||
childrenAges: room.children?.map((child) => ({
|
||||
age: child.age,
|
||||
bedType: bedTypeMap[parseInt(child.bed.toString())],
|
||||
})),
|
||||
rateCode: room.rateCode,
|
||||
roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step.
|
||||
guest: {
|
||||
title: "",
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phoneCountryCodePrefix,
|
||||
phoneNumber: phone,
|
||||
countryCode,
|
||||
},
|
||||
],
|
||||
packages: {
|
||||
breakfast: breakfast !== BreakfastPackageEnum.NO_BREAKFAST,
|
||||
allergyFriendly:
|
||||
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false,
|
||||
petFriendly:
|
||||
room.packages?.includes(RoomPackageCodeEnum.PET_ROOM) ?? false,
|
||||
accessibility:
|
||||
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false,
|
||||
},
|
||||
smsConfirmationRequested: data.smsConfirmation,
|
||||
roomPrice,
|
||||
})),
|
||||
payment: {
|
||||
paymentMethod,
|
||||
card: savedCreditCard
|
||||
@@ -151,12 +185,7 @@ export default function Payment({
|
||||
cardType: savedCreditCard.cardType,
|
||||
}
|
||||
: undefined,
|
||||
cardHolder: {
|
||||
email: "test.user@scandichotels.com",
|
||||
name: "Test User",
|
||||
phoneCountryCode: "",
|
||||
phoneSubscriber: "",
|
||||
},
|
||||
|
||||
success: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/success`,
|
||||
error: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/error${allQueryParams}`,
|
||||
cancel: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/cancel${allQueryParams}`,
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function SectionAccordion({
|
||||
useEffect(() => {
|
||||
if (step === StepEnum.selectBed) {
|
||||
const value = stepData.bedType
|
||||
value && setTitle(value)
|
||||
value && setTitle(value.description)
|
||||
}
|
||||
// If breakfast step, check if an option has been selected
|
||||
if (step === StepEnum.breakfast && stepData.breakfast) {
|
||||
@@ -100,7 +100,7 @@ export default function SectionAccordion({
|
||||
wrapping
|
||||
>
|
||||
{intl.formatMessage({ id: "Modify" })}{" "}
|
||||
<ChevronDownIcon color="burgundy" />
|
||||
<ChevronDownIcon color="burgundy" width={20} height={20} />
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
@@ -13,25 +13,33 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatNumber } from "@/utils/format"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
import { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
import { RoomsData } from "@/types/components/hotelReservation/enterDetails/bookingData"
|
||||
import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
function parsePrice(price: string | undefined) {
|
||||
return price ? parseInt(price) : 0
|
||||
}
|
||||
|
||||
export default function Summary({
|
||||
isMember,
|
||||
showMemberPrice,
|
||||
room,
|
||||
}: {
|
||||
isMember: boolean
|
||||
showMemberPrice: boolean
|
||||
room: RoomsData
|
||||
}) {
|
||||
const [chosenBed, setChosenBed] = useState<string>()
|
||||
const [chosenBed, setChosenBed] = useState<BedTypeSchema>()
|
||||
const [chosenBreakfast, setChosenBreakfast] = useState<
|
||||
BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST
|
||||
>()
|
||||
const [totalPrice, setTotalPrice] = useState({
|
||||
local: parsePrice(room.localPrice.price),
|
||||
euro: parsePrice(room.euroPrice.price),
|
||||
})
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const { fromDate, toDate, bedType, breakfast } = useEnterDetailsStore(
|
||||
@@ -51,7 +59,7 @@ export default function Summary({
|
||||
)
|
||||
|
||||
let color: "uiTextHighContrast" | "red" = "uiTextHighContrast"
|
||||
if (isMember) {
|
||||
if (showMemberPrice) {
|
||||
color = "red"
|
||||
}
|
||||
|
||||
@@ -60,8 +68,23 @@ export default function Summary({
|
||||
|
||||
if (breakfast) {
|
||||
setChosenBreakfast(breakfast)
|
||||
if (breakfast === BreakfastPackageEnum.NO_BREAKFAST) {
|
||||
setTotalPrice({
|
||||
local: parsePrice(room.localPrice.price),
|
||||
euro: parsePrice(room.euroPrice.price),
|
||||
})
|
||||
} else {
|
||||
setTotalPrice({
|
||||
local:
|
||||
parsePrice(room.localPrice.price) +
|
||||
parsePrice(breakfast.localPrice.totalPrice),
|
||||
euro:
|
||||
parsePrice(room.euroPrice.price) +
|
||||
parsePrice(breakfast.requestedPrice.totalPrice),
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [bedType, breakfast])
|
||||
}, [bedType, breakfast, room.localPrice, room.euroPrice])
|
||||
|
||||
return (
|
||||
<section className={styles.summary}>
|
||||
@@ -82,7 +105,9 @@ export default function Summary({
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: formatNumber(parseInt(room.localPrice.price ?? "0")),
|
||||
amount: intl.formatNumber(
|
||||
parseInt(room.localPrice.price ?? "0")
|
||||
),
|
||||
currency: room.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
@@ -113,7 +138,7 @@ export default function Summary({
|
||||
{chosenBed ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body color="textHighContrast">{chosenBed}</Body>
|
||||
<Body color="textHighContrast">{chosenBed.description}</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Based on availability" })}
|
||||
</Caption>
|
||||
@@ -150,7 +175,7 @@ export default function Summary({
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: chosenBreakfast.localPrice.price,
|
||||
amount: chosenBreakfast.localPrice.totalPrice,
|
||||
currency: chosenBreakfast.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
@@ -178,7 +203,7 @@ export default function Summary({
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: formatNumber(parseInt(room.localPrice.price ?? "0")),
|
||||
amount: intl.formatNumber(totalPrice.local),
|
||||
currency: room.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
@@ -188,7 +213,7 @@ export default function Summary({
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: formatNumber(parseInt(room.euroPrice.price ?? "0")),
|
||||
amount: intl.formatNumber(totalPrice.euro),
|
||||
currency: room.euroPrice.currency,
|
||||
}
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card.active {
|
||||
border: 1px solid var(--Base-Border-Hover);
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
grid-area: image;
|
||||
position: relative;
|
||||
@@ -67,7 +71,7 @@
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.card {
|
||||
.card.pageListing {
|
||||
grid-template-areas:
|
||||
"image header"
|
||||
"image hotel"
|
||||
@@ -76,30 +80,30 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
.pageListing .imageContainer {
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
width: 518px;
|
||||
}
|
||||
|
||||
.tripAdvisor {
|
||||
.pageListing .tripAdvisor {
|
||||
position: absolute;
|
||||
display: block;
|
||||
left: 7px;
|
||||
top: 7px;
|
||||
}
|
||||
|
||||
.hotelInformation {
|
||||
.pageListing .hotelInformation {
|
||||
padding-top: var(--Spacing-x2);
|
||||
padding-right: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.hotel {
|
||||
.pageListing .hotel {
|
||||
gap: var(--Spacing-x2);
|
||||
padding-right: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.prices {
|
||||
.pageListing .prices {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -107,11 +111,11 @@
|
||||
padding-bottom: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.detailsButton {
|
||||
.pageListing .detailsButton {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.button {
|
||||
.pageListing .button {
|
||||
width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,60 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import { PriceTagIcon, ScandicLogoIcon } from "@/components/Icons"
|
||||
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
||||
import Image from "@/components/Image"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Chip from "@/components/TempDesignSystem/Chip"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import ReadMore from "../ReadMore"
|
||||
import ImageGallery from "../SelectRate/ImageGallery"
|
||||
import { hotelCardVariants } from "./variants"
|
||||
|
||||
import styles from "./hotelCard.module.css"
|
||||
|
||||
import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
|
||||
|
||||
export default async function HotelCard({ hotel }: HotelCardProps) {
|
||||
const intl = await getIntl()
|
||||
export default function HotelCard({
|
||||
hotel,
|
||||
type = HotelCardListingTypeEnum.PageListing,
|
||||
state = "default",
|
||||
onHotelCardHover,
|
||||
}: HotelCardProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const { hotelData } = hotel
|
||||
const { price } = hotel
|
||||
|
||||
const amenities = hotelData.detailedFacilities.slice(0, 5)
|
||||
|
||||
const classNames = hotelCardVariants({
|
||||
type,
|
||||
state,
|
||||
})
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (onHotelCardHover) {
|
||||
onHotelCardHover(hotelData.name)
|
||||
}
|
||||
}
|
||||
const handleMouseLeave = () => {
|
||||
if (onHotelCardHover) {
|
||||
onHotelCardHover(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={styles.card}>
|
||||
<article
|
||||
className={classNames}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<section className={styles.imageContainer}>
|
||||
{hotelData.gallery && (
|
||||
<ImageGallery
|
||||
|
||||
20
components/HotelReservation/HotelCard/variants.ts
Normal file
20
components/HotelReservation/HotelCard/variants.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./hotelCard.module.css"
|
||||
|
||||
export const hotelCardVariants = cva(styles.card, {
|
||||
variants: {
|
||||
type: {
|
||||
pageListing: styles.pageListing,
|
||||
mapListing: styles.mapListing,
|
||||
},
|
||||
state: {
|
||||
active: styles.active,
|
||||
default: styles.default,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
type: "pageListing",
|
||||
state: "default",
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
.dialog {
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
bottom: 32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dialogContainer {
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
min-width: 334px;
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
box-shadow: 0px 0px 8px 3px rgba(0, 0, 0, 0.1);
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.closeIcon {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
position: relative;
|
||||
min-width: 177px;
|
||||
}
|
||||
|
||||
.imageContainer img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.tripAdvisor {
|
||||
position: absolute;
|
||||
display: block;
|
||||
left: 7px;
|
||||
top: 7px;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
min-width: 201px;
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
gap: var(--Spacing-x1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.facilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.facilitiesItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.prices {
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
padding: var(--Spacing-x-half) var(--Spacing-x1);
|
||||
background: var(--Base-Surface-Secondary-light-Normal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.perNight {
|
||||
color: var(--Base-Text-Subtle-light-Normal);
|
||||
}
|
||||
|
||||
.content .button {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.facilities,
|
||||
.memberPrice {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
100
components/HotelReservation/HotelCardDialog/index.tsx
Normal file
100
components/HotelReservation/HotelCardDialog/index.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import { CloseLargeIcon } from "@/components/Icons"
|
||||
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
||||
import Image from "@/components/Image"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Chip from "@/components/TempDesignSystem/Chip"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./hotelCardDialog.module.css"
|
||||
|
||||
import type { HotelCardDialogProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export default function HotelCardDialog({
|
||||
pin,
|
||||
isOpen,
|
||||
handleClose,
|
||||
}: HotelCardDialogProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
if (!pin) {
|
||||
return null
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
publicPrice,
|
||||
memberPrice,
|
||||
currency,
|
||||
amenities,
|
||||
images,
|
||||
ratings,
|
||||
} = pin
|
||||
|
||||
const firstImage = images[0]?.imageSizes?.small
|
||||
const altText = images[0]?.metaData?.altText
|
||||
|
||||
return (
|
||||
<dialog open={isOpen} className={styles.dialog}>
|
||||
<div className={styles.dialogContainer}>
|
||||
<CloseLargeIcon
|
||||
onClick={handleClose}
|
||||
className={styles.closeIcon}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
<div className={styles.imageContainer}>
|
||||
<Image src={firstImage} alt={altText} fill />
|
||||
<div className={styles.tripAdvisor}>
|
||||
<Chip intent="primary" className={styles.tripAdvisor}>
|
||||
<TripAdvisorIcon color="white" />
|
||||
{ratings}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<Body textTransform="bold">{name}</Body>
|
||||
<div className={styles.facilities}>
|
||||
{amenities.map((facility) => {
|
||||
const IconComponent = mapFacilityToIcon(facility.id)
|
||||
return (
|
||||
<div className={styles.facilitiesItem} key={facility.id}>
|
||||
{IconComponent && <IconComponent color="grey80" />}
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{facility.name}
|
||||
</Caption>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.prices}>
|
||||
<Caption type="bold">{intl.formatMessage({ id: "From" })}</Caption>
|
||||
<Subtitle type="two">
|
||||
{publicPrice} {currency}
|
||||
<Body asChild>
|
||||
<span>/{intl.formatMessage({ id: "night" })}</span>
|
||||
</Body>
|
||||
</Subtitle>
|
||||
{memberPrice && (
|
||||
<Subtitle type="two" color="red" className={styles.memberPrice}>
|
||||
{memberPrice} {currency}
|
||||
<Body asChild color="red">
|
||||
<span>/{intl.formatMessage({ id: "night" })}</span>
|
||||
</Body>
|
||||
</Subtitle>
|
||||
)}
|
||||
</div>
|
||||
<Button size="small" theme="base" className={styles.button}>
|
||||
{intl.formatMessage({ id: "See rooms" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,85 @@
|
||||
"use client"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useMemo } from "react"
|
||||
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import HotelCard from "../HotelCard"
|
||||
import { DEFAULT_SORT } from "../SelectHotel/HotelSorter"
|
||||
|
||||
import styles from "./hotelCardListing.module.css"
|
||||
|
||||
import { HotelCardListingProps } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import {
|
||||
type HotelCardListingProps,
|
||||
HotelCardListingTypeEnum,
|
||||
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import { SortOrder } from "@/types/components/hotelReservation/selectHotel/hotelSorter"
|
||||
|
||||
export default function HotelCardListing({ hotelData }: HotelCardListingProps) {
|
||||
// TODO: filter with url params
|
||||
export default function HotelCardListing({
|
||||
hotelData,
|
||||
type = HotelCardListingTypeEnum.PageListing,
|
||||
activeCard,
|
||||
onHotelCardHover,
|
||||
}: HotelCardListingProps) {
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const sortBy = useMemo(
|
||||
() => searchParams.get("sort") ?? DEFAULT_SORT,
|
||||
[searchParams]
|
||||
)
|
||||
|
||||
const sortedHotels = useMemo(() => {
|
||||
switch (sortBy) {
|
||||
case SortOrder.Name:
|
||||
return [...hotelData].sort((a, b) =>
|
||||
a.hotelData.name.localeCompare(b.hotelData.name)
|
||||
)
|
||||
case SortOrder.TripAdvisorRating:
|
||||
return [...hotelData].sort(
|
||||
(a, b) =>
|
||||
(b.hotelData.ratings?.tripAdvisor.rating ?? 0) -
|
||||
(a.hotelData.ratings?.tripAdvisor.rating ?? 0)
|
||||
)
|
||||
case SortOrder.Price:
|
||||
return [...hotelData].sort(
|
||||
(a, b) =>
|
||||
parseInt(a.price?.memberAmount ?? "0", 10) -
|
||||
parseInt(b.price?.memberAmount ?? "0", 10)
|
||||
)
|
||||
case SortOrder.Distance:
|
||||
default:
|
||||
return [...hotelData].sort(
|
||||
(a, b) =>
|
||||
a.hotelData.location.distanceToCentre -
|
||||
b.hotelData.location.distanceToCentre
|
||||
)
|
||||
}
|
||||
}, [hotelData, sortBy])
|
||||
|
||||
const hotels = useMemo(() => {
|
||||
const appliedFilters = searchParams.get("filters")?.split(",")
|
||||
if (!appliedFilters || appliedFilters.length === 0) return sortedHotels
|
||||
|
||||
return sortedHotels.filter((hotel) =>
|
||||
appliedFilters.every((appliedFilterId) =>
|
||||
hotel.hotelData.detailedFacilities.some(
|
||||
(facility) => facility.id.toString() === appliedFilterId
|
||||
)
|
||||
)
|
||||
)
|
||||
}, [searchParams, sortedHotels])
|
||||
|
||||
return (
|
||||
<section className={styles.hotelCards}>
|
||||
{hotelData && hotelData.length ? (
|
||||
hotelData.map((hotel) => (
|
||||
<HotelCard key={hotel.hotelData.name} hotel={hotel} />
|
||||
{hotels?.length ? (
|
||||
hotels.map((hotel) => (
|
||||
<HotelCard
|
||||
key={hotel.hotelData.operaId}
|
||||
hotel={hotel}
|
||||
type={type}
|
||||
state={hotel.hotelData.name === activeCard ? "active" : "default"}
|
||||
onHotelCardHover={onHotelCardHover}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Title>No hotels found</Title>
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function ReadMore({ label, hotelId }: ReadMoreProps) {
|
||||
className={styles.detailsButton}
|
||||
>
|
||||
{label}
|
||||
<ChevronRightIcon color="burgundy" />
|
||||
<ChevronRightIcon color="burgundy" height={20} width={20} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import styles from "./hotelFilter.module.css"
|
||||
@@ -8,26 +11,77 @@ import { HotelFiltersProps } from "@/types/components/hotelReservation/selectHot
|
||||
|
||||
export default function HotelFilter({ filters }: HotelFiltersProps) {
|
||||
const intl = useIntl()
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
|
||||
function handleOnChange() {
|
||||
// TODO: Update URL with selected values
|
||||
}
|
||||
const { watch, handleSubmit, getValues, register } = useForm<
|
||||
Record<string, boolean | undefined>
|
||||
>({
|
||||
defaultValues: searchParams
|
||||
?.get("filters")
|
||||
?.split(",")
|
||||
.reduce((acc, curr) => ({ ...acc, [curr]: true }), {}),
|
||||
})
|
||||
|
||||
const submitFilter = useCallback(() => {
|
||||
const newSearchParams = new URLSearchParams(searchParams)
|
||||
const values = Object.entries(getValues())
|
||||
.filter(([_, value]) => !!value)
|
||||
.map(([key, _]) => key)
|
||||
.join(",")
|
||||
|
||||
if (values === "") {
|
||||
newSearchParams.delete("filters")
|
||||
} else {
|
||||
newSearchParams.set("filters", values)
|
||||
}
|
||||
|
||||
if (values !== searchParams.values.toString()) {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${pathname}?${newSearchParams.toString()}`
|
||||
)
|
||||
}
|
||||
}, [getValues, pathname, searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = watch(() => handleSubmit(submitFilter)())
|
||||
return () => subscription.unsubscribe()
|
||||
}, [handleSubmit, watch, submitFilter])
|
||||
|
||||
return (
|
||||
<aside className={styles.container}>
|
||||
<div className={styles.facilities}>
|
||||
{intl.formatMessage({ id: "Hotel facilities" })}
|
||||
<form>
|
||||
<form onSubmit={handleSubmit(submitFilter)}>
|
||||
{intl.formatMessage({ id: "Hotel facilities" })}
|
||||
<ul>
|
||||
{filters.map((data) => (
|
||||
<li key={data.id} className={styles.filter}>
|
||||
{filters.facilityFilters.map((filter) => (
|
||||
<li key={`li-${filter.id}`} className={styles.filter}>
|
||||
<input
|
||||
id={`${data.id}`}
|
||||
name={data.name}
|
||||
type="checkbox"
|
||||
onChange={handleOnChange}
|
||||
id={`checkbox-${filter.id.toString()}`}
|
||||
{...register(filter.id.toString())}
|
||||
/>
|
||||
<label htmlFor={`${data?.id}`}>{data?.name}</label>
|
||||
<label htmlFor={`checkbox-${filter.id.toString()}`}>
|
||||
{filter.name}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{intl.formatMessage({ id: "Hotel surroundings" })}
|
||||
<ul>
|
||||
{filters.surroundingsFilters.map((filter) => (
|
||||
<li key={`li-${filter.id}`} className={styles.filter}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`checkbox-${filter.id.toString()}`}
|
||||
{...register(filter.id.toString())}
|
||||
/>
|
||||
<label htmlFor={`checkbox-${filter.id.toString()}`}>
|
||||
{filter.name}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
import { useCallback } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Select from "@/components/TempDesignSystem/Select"
|
||||
|
||||
import {
|
||||
type SortItem,
|
||||
SortOrder,
|
||||
} from "@/types/components/hotelReservation/selectHotel/hotelSorter"
|
||||
|
||||
const sortItems: SortItem[] = [
|
||||
{ label: "Distance", value: SortOrder.Distance },
|
||||
{ label: "Name", value: SortOrder.Name },
|
||||
{ label: "Price", value: SortOrder.Price },
|
||||
{ label: "TripAdvisor rating", value: SortOrder.TripAdvisorRating },
|
||||
]
|
||||
|
||||
export const DEFAULT_SORT = SortOrder.Distance
|
||||
|
||||
export default function HotelSorter() {
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
const i18n = useIntl()
|
||||
|
||||
const onSelect = useCallback(
|
||||
(value: string | number) => {
|
||||
const newSort = value.toString()
|
||||
if (newSort === searchParams.get("sort")) {
|
||||
return
|
||||
}
|
||||
|
||||
const newSearchParams = new URLSearchParams(searchParams)
|
||||
newSearchParams.set("sort", newSort)
|
||||
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${pathname}?${newSearchParams.toString()}`
|
||||
)
|
||||
},
|
||||
[pathname, searchParams]
|
||||
)
|
||||
|
||||
return (
|
||||
<Select
|
||||
items={sortItems}
|
||||
label={i18n.formatMessage({ id: "Sort by" })}
|
||||
name="sort"
|
||||
showRadioButton
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -5,5 +5,8 @@
|
||||
@media (min-width: 768px) {
|
||||
.hotelListing {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
padding-top: var(--Spacing-x2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
|
||||
|
||||
import styles from "./hotelListing.module.css"
|
||||
|
||||
import { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
// TODO: This component is copied from
|
||||
// components/ContentType/HotelPage/Map/DynamicMap/Sidebar.
|
||||
// Look at that for inspiration on how to do the interaction with the map.
|
||||
|
||||
export default function HotelListing({}: HotelListingProps) {
|
||||
return <section className={styles.hotelListing}>Hotel listing TBI</section>
|
||||
export default function HotelListing({
|
||||
hotels,
|
||||
activeHotelPin,
|
||||
onHotelCardHover,
|
||||
}: HotelListingProps) {
|
||||
return (
|
||||
<div className={styles.hotelListing}>
|
||||
<HotelCardListing
|
||||
hotelData={hotels}
|
||||
type={HotelCardListingTypeEnum.MapListing}
|
||||
activeCard={activeHotelPin}
|
||||
onHotelCardHover={onHotelCardHover}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
import { APIProvider } from "@vis.gl/react-google-maps"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { selectHotel } from "@/constants/routes/hotelReservation"
|
||||
@@ -20,15 +20,39 @@ import { SelectHotelMapProps } from "@/types/components/hotelReservation/selectH
|
||||
export default function SelectHotelMap({
|
||||
apiKey,
|
||||
coordinates,
|
||||
pointsOfInterest,
|
||||
hotelPins,
|
||||
mapId,
|
||||
isModal,
|
||||
hotels,
|
||||
}: SelectHotelMapProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const [activePoi, setActivePoi] = useState<string | null>(null)
|
||||
const [activeHotelPin, setActiveHotelPin] = useState<string | null>(null)
|
||||
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const hotelListingElement = document.querySelector(
|
||||
`.${styles.listingContainer}`
|
||||
)
|
||||
if (!hotelListingElement) return
|
||||
|
||||
const handleScroll = () => {
|
||||
const hasScrolledPast = hotelListingElement.scrollTop > 490
|
||||
setShowBackToTop(hasScrolledPast)
|
||||
}
|
||||
|
||||
hotelListingElement.addEventListener("scroll", handleScroll)
|
||||
return () => hotelListingElement.removeEventListener("scroll", handleScroll)
|
||||
}, [])
|
||||
|
||||
function scrollToTop() {
|
||||
const hotelListingElement = document.querySelector(
|
||||
`.${styles.listingContainer}`
|
||||
)
|
||||
hotelListingElement?.scrollTo({ top: 0, behavior: "smooth" })
|
||||
}
|
||||
|
||||
function handleModalDismiss() {
|
||||
router.back()
|
||||
@@ -53,26 +77,44 @@ export default function SelectHotelMap({
|
||||
return (
|
||||
<APIProvider apiKey={apiKey}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filterContainer}>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
variant="icon"
|
||||
wrapping
|
||||
onClick={isModal ? handleModalDismiss : handlePageRedirect}
|
||||
>
|
||||
<CloseLargeIcon />
|
||||
</Button>
|
||||
<span>Filter and sort</span>
|
||||
{/* TODO: Add filter and sort button */}
|
||||
<div className={styles.listingContainer}>
|
||||
<div className={styles.filterContainer}>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
variant="icon"
|
||||
wrapping
|
||||
onClick={isModal ? handleModalDismiss : handlePageRedirect}
|
||||
className={styles.filterContainerCloseButton}
|
||||
>
|
||||
<CloseLargeIcon />
|
||||
</Button>
|
||||
<span>Filter and sort</span>
|
||||
{/* TODO: Add filter and sort button */}
|
||||
</div>
|
||||
<HotelListing
|
||||
hotels={hotels}
|
||||
activeHotelPin={activeHotelPin}
|
||||
onHotelCardHover={setActiveHotelPin}
|
||||
/>
|
||||
{showBackToTop && (
|
||||
<Button
|
||||
intent="inverted"
|
||||
size="small"
|
||||
theme="base"
|
||||
className={styles.backToTopButton}
|
||||
onClick={scrollToTop}
|
||||
>
|
||||
{intl.formatMessage({ id: "Back to top" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<HotelListing />
|
||||
<InteractiveMap
|
||||
closeButton={closeButton}
|
||||
coordinates={coordinates}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
activePoi={activePoi}
|
||||
onActivePoiChange={setActivePoi}
|
||||
hotelPins={hotelPins}
|
||||
activeHotelPin={activeHotelPin}
|
||||
onActiveHotelPinChange={setActiveHotelPin}
|
||||
mapId={mapId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
.closeButton {
|
||||
.container .closeButton {
|
||||
pointer-events: initial;
|
||||
box-shadow: var(--button-box-shadow);
|
||||
gap: var(--Spacing-x-half);
|
||||
display: none !important;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -23,17 +23,30 @@
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.filterContainer .closeButton {
|
||||
color: var(--UI-Text-High-Contrast);
|
||||
.backToTopButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.closeButton {
|
||||
display: flex !important;
|
||||
.container .closeButton {
|
||||
display: flex;
|
||||
}
|
||||
.filterContainer {
|
||||
.container .listingContainer .filterContainer .filterContainerCloseButton {
|
||||
display: none;
|
||||
}
|
||||
.backToTopButton {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 32px;
|
||||
display: flex;
|
||||
}
|
||||
.listingContainer {
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x4);
|
||||
overflow-y: auto;
|
||||
min-width: 420px;
|
||||
position: relative;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -30,9 +30,9 @@
|
||||
gap: var(--Spacing-x-half);
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
position: absolute;
|
||||
left: var(--Spacing-x2);
|
||||
top: var(--Spacing-x2);
|
||||
padding: 0 var(--Spacing-x1);
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
padding: var(--Spacing-x-quarter) var(--Spacing-x1);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
}
|
||||
|
||||
@@ -42,16 +42,16 @@
|
||||
}
|
||||
|
||||
.hotelInformation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
width: min(607px, 100%);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: var(--Spacing-x2);
|
||||
.hotelAddressDescription {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.facilities {
|
||||
@@ -73,8 +73,10 @@
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.facilityName {
|
||||
color: var(--UI-Text-Medium-contrast);
|
||||
.hotelAlert {
|
||||
max-width: var(--max-width-navigation);
|
||||
margin: 0 auto;
|
||||
padding-top: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
@@ -48,19 +49,17 @@ export default function HotelInfoCard({ hotelData }: HotelInfoCardProps) {
|
||||
</div>
|
||||
<div className={styles.hotelContent}>
|
||||
<div className={styles.hotelInformation}>
|
||||
<Title
|
||||
level="h3"
|
||||
textTransform="uppercase"
|
||||
className={styles.title}
|
||||
>
|
||||
<Title as="h2" textTransform="uppercase">
|
||||
{hotelAttributes.name}
|
||||
</Title>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{`${hotelAttributes.address.streetAddress}, ${hotelAttributes.address.city} ∙ ${hotelAttributes.location.distanceToCentre} ${intl.formatMessage({ id: "km to city center" })}`}
|
||||
</Caption>
|
||||
<Body color="uiTextHighContrast" className={styles.body}>
|
||||
{hotelAttributes.hotelContent.texts.descriptions.medium}
|
||||
</Body>
|
||||
<div className={styles.hotelAddressDescription}>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{`${hotelAttributes.address.streetAddress}, ${hotelAttributes.address.city} ∙ ${hotelAttributes.location.distanceToCentre} ${intl.formatMessage({ id: "km to city center" })}`}
|
||||
</Caption>
|
||||
<Body color="uiTextHighContrast">
|
||||
{hotelAttributes.hotelContent.texts.descriptions.medium}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
<Divider color="subtle" variant="vertical" />
|
||||
<div className={styles.facilities}>
|
||||
@@ -78,9 +77,7 @@ export default function HotelInfoCard({ hotelData }: HotelInfoCardProps) {
|
||||
color="grey80"
|
||||
/>
|
||||
)}
|
||||
<Caption className={styles.facilityName}>
|
||||
{facility.name}
|
||||
</Caption>
|
||||
<Body color="uiTextHighContrast">{facility.name}</Body>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -94,6 +91,18 @@ export default function HotelInfoCard({ hotelData }: HotelInfoCardProps) {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{hotelAttributes?.specialAlerts.map((alert) => {
|
||||
return (
|
||||
<div className={styles.hotelAlert} key={`wrapper_${alert.id}`}>
|
||||
<Alert
|
||||
key={alert.id}
|
||||
type={alert.type}
|
||||
heading={alert.heading}
|
||||
text={alert.text}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function RoomFilter({
|
||||
/>
|
||||
))}
|
||||
<Tooltip text={tooltipText} position="bottom" arrow="right">
|
||||
<InfoCircleIcon className={styles.infoIcon} />
|
||||
<InfoCircleIcon color="uiTextPlaceholder" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -12,11 +12,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.roomsFilter .infoIcon,
|
||||
.roomsFilter .infoIcon path {
|
||||
stroke: var(--UI-Text-Medium-contrast);
|
||||
fill: transparent;
|
||||
}
|
||||
.filterInfo {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user