Merge branch 'develop' into feat/sw-222-staycard-link
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.accordion:not(.allVisible) :nth-child(n + 6) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.accordion:not(.allVisible) :nth-child(5) {
|
||||
border: none;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
import { useState } from "react"
|
||||
|
||||
import JsonToHtml from "@/components/JsonToHtml"
|
||||
import SectionContainer from "@/components/Section/Container"
|
||||
import SectionHeader from "@/components/Section/Header"
|
||||
import Accordion from "@/components/TempDesignSystem/Accordion"
|
||||
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
|
||||
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
|
||||
|
||||
import styles from "./accordion.module.css"
|
||||
|
||||
import type { AccordionProps } from "@/types/components/blocks/Accordion"
|
||||
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
|
||||
|
||||
export default function AccordionSection({ accordion, title }: AccordionProps) {
|
||||
const showToggleButton = accordion.length > 5
|
||||
const [allAccordionsVisible, setAllAccordionsVisible] =
|
||||
useState(!showToggleButton)
|
||||
|
||||
function toggleAccordions() {
|
||||
setAllAccordionsVisible((state) => !state)
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionContainer id={HotelHashValues.faq}>
|
||||
{title && <SectionHeader textTransform="uppercase" title={title} />}
|
||||
<Accordion
|
||||
className={`${styles.accordion} ${allAccordionsVisible ? styles.allVisible : ""}`}
|
||||
theme="light"
|
||||
variant="card"
|
||||
>
|
||||
{accordion.map((acc) => (
|
||||
<AccordionItem key={acc.question} title={acc.question}>
|
||||
<JsonToHtml
|
||||
embeds={acc.answer.embedded_itemsConnection.edges}
|
||||
nodes={acc.answer.json?.children[0].children}
|
||||
/>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
|
||||
{showToggleButton ? (
|
||||
<ShowMoreButton
|
||||
loadMoreData={toggleAccordions}
|
||||
showLess={allAccordionsVisible}
|
||||
textShowMore="See all FAQ"
|
||||
textShowLess="See less FAQ"
|
||||
/>
|
||||
) : null}
|
||||
</SectionContainer>
|
||||
)
|
||||
}
|
||||
@@ -6,12 +6,29 @@ import LoyaltyCard from "@/components/TempDesignSystem/LoyaltyCard"
|
||||
import TeaserCard from "@/components/TempDesignSystem/TeaserCard"
|
||||
|
||||
import type { CardsGridProps } from "@/types/components/blocks/cardsGrid"
|
||||
import { CardsGridEnum } from "@/types/enums/cardsGrid"
|
||||
import { CardsGridEnum, CardsGridLayoutEnum } from "@/types/enums/cardsGrid"
|
||||
import type { StackableGridProps } from "../TempDesignSystem/Grids/Stackable/stackable"
|
||||
|
||||
export default function CardsGrid({
|
||||
cards_grid,
|
||||
firstItem = false,
|
||||
}: CardsGridProps) {
|
||||
let columns: StackableGridProps["columns"]
|
||||
|
||||
switch (cards_grid.layout) {
|
||||
case CardsGridLayoutEnum.ONE_COLUMN:
|
||||
columns = 1
|
||||
break
|
||||
case CardsGridLayoutEnum.TWO_COLUMNS:
|
||||
columns = 2
|
||||
break
|
||||
case CardsGridLayoutEnum.THREE_COLUMNS:
|
||||
columns = 3
|
||||
break
|
||||
default:
|
||||
columns = 3
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
<SectionHeader
|
||||
@@ -19,7 +36,7 @@ export default function CardsGrid({
|
||||
preamble={cards_grid.preamble}
|
||||
topTitle={firstItem}
|
||||
/>
|
||||
<Grids.Stackable>
|
||||
<Grids.Stackable columns={columns}>
|
||||
{cards_grid.cards.map((card) => {
|
||||
switch (card.__typename) {
|
||||
case CardsGridEnum.cards.Card:
|
||||
@@ -43,6 +60,7 @@ export default function CardsGrid({
|
||||
primaryButton={card.primaryButton}
|
||||
secondaryButton={card.secondaryButton}
|
||||
sidePeekButton={card.sidePeekButton}
|
||||
sidePeekContent={card.sidePeekContent}
|
||||
image={card.image}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import HowItWorks from "@/components/Blocks/DynamicContent/HowItWorks"
|
||||
import LoyaltyLevels from "@/components/Blocks/DynamicContent/LoyaltyLevels"
|
||||
import Overview from "@/components/Blocks/DynamicContent/Overview"
|
||||
@@ -26,7 +28,9 @@ export default async function DynamicContent({
|
||||
case DynamicContentEnum.Blocks.components.earn_and_burn:
|
||||
return <EarnAndBurn {...dynamic_content} />
|
||||
case DynamicContentEnum.Blocks.components.expiring_points:
|
||||
return <ExpiringPoints {...dynamic_content} />
|
||||
return env.HIDE_FOR_NEXT_RELEASE ? null : (
|
||||
<ExpiringPoints {...dynamic_content} />
|
||||
)
|
||||
case DynamicContentEnum.Blocks.components.how_it_works:
|
||||
return (
|
||||
<HowItWorks dynamic_content={dynamic_content} firstItem={firstItem} />
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { ArrowRightIcon } from "@/components/Icons"
|
||||
import SectionContainer from "@/components/Section/Container"
|
||||
import SectionHeader from "@/components/Section/Header"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import styles from "./shortcuts.module.css"
|
||||
|
||||
import type { ShortcutsProps } from "@/types/components/myPages/myPage/shortcuts"
|
||||
|
||||
export default function Shortcuts({
|
||||
firstItem = false,
|
||||
shortcuts,
|
||||
subtitle,
|
||||
title,
|
||||
}: ShortcutsProps) {
|
||||
return (
|
||||
<SectionContainer>
|
||||
<SectionHeader preamble={subtitle} title={title} topTitle={firstItem} />
|
||||
<section className={styles.links}>
|
||||
{shortcuts.map((shortcut) => (
|
||||
<Link
|
||||
href={shortcut.url}
|
||||
key={shortcut.title}
|
||||
target={shortcut.openInNewTab ? "_blank" : undefined}
|
||||
variant="shortcut"
|
||||
>
|
||||
<Body textTransform="bold" color="burgundy">
|
||||
<span>{shortcut.text ? shortcut.text : shortcut.title}</span>
|
||||
</Body>
|
||||
<ArrowRightIcon color="burgundy" className={styles.arrowRight} />
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
</SectionContainer>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
.links {
|
||||
display: grid;
|
||||
background-color: var(--Scandic-Brand-Pale-Peach);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
|
||||
.arrowRight {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ArrowRightIcon } from "@/components/Icons"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import styles from "./shortcutsListItems.module.css"
|
||||
|
||||
import type { ShortcutsListItemsProps } from "@/types/components/blocks/shortcuts"
|
||||
|
||||
export default function ShortcutsListItems({
|
||||
shortcutsListItems,
|
||||
className,
|
||||
}: ShortcutsListItemsProps) {
|
||||
return (
|
||||
<ul className={className}>
|
||||
{shortcutsListItems.map((shortcut) => (
|
||||
<li key={shortcut.title} className={styles.listItem}>
|
||||
<Link
|
||||
href={shortcut.url}
|
||||
target={shortcut.openInNewTab ? "_blank" : undefined}
|
||||
variant="shortcut"
|
||||
className={styles.link}
|
||||
>
|
||||
<Body textTransform="bold" color="burgundy">
|
||||
<span>{shortcut.text || shortcut.title}</span>
|
||||
</Body>
|
||||
<ArrowRightIcon color="burgundy" width={24} height={24} />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.link {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
.listItem {
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
|
||||
.listItem:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import SectionContainer from "@/components/Section/Container"
|
||||
import SectionHeader from "@/components/Section/Header"
|
||||
|
||||
import ShortcutsListItems from "./ShortcutsListItems"
|
||||
|
||||
import styles from "./shortcutsList.module.css"
|
||||
|
||||
import type { ShortcutsListProps } from "@/types/components/blocks/shortcuts"
|
||||
|
||||
export default function ShortcutsList({
|
||||
firstItem = false,
|
||||
shortcuts,
|
||||
subtitle,
|
||||
title,
|
||||
hasTwoColumns,
|
||||
}: ShortcutsListProps) {
|
||||
const middleIndex = Math.ceil(shortcuts.length / 2)
|
||||
const leftColumn = shortcuts.slice(0, middleIndex)
|
||||
const rightColumn = shortcuts.slice(middleIndex)
|
||||
|
||||
const classNames =
|
||||
hasTwoColumns && shortcuts.length > 1
|
||||
? {
|
||||
section: styles.twoColumnSection,
|
||||
leftColumn: styles.leftColumn,
|
||||
rightColumn: styles.rightColumn,
|
||||
}
|
||||
: {
|
||||
section: styles.oneColumnSection,
|
||||
leftColumn:
|
||||
shortcuts.length === 1
|
||||
? styles.leftColumnBorderBottomNone
|
||||
: styles.leftColumnBorderBottom,
|
||||
}
|
||||
|
||||
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>
|
||||
</SectionContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
.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);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import { removeMultipleSlashes } from "@/utils/url"
|
||||
|
||||
import styles from "./uspgrid.module.css"
|
||||
|
||||
import { EmbedEnum } from "@/types/requests/utils/embeds"
|
||||
import type { EmbedByUid } from "@/types/transitionTypes/jsontohtml"
|
||||
import { RTEItemTypeEnum, RTETypeEnum } from "@/types/transitionTypes/rte/enums"
|
||||
import type {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import CardsGrid from "@/components/Blocks/CardsGrid"
|
||||
import DynamicContent from "@/components/Blocks/DynamicContent"
|
||||
import Shortcuts from "@/components/Blocks/Shortcuts"
|
||||
import ShortcutsList from "@/components/Blocks/ShortcutsList"
|
||||
import TextCols from "@/components/Blocks/TextCols"
|
||||
import UspGrid from "@/components/Blocks/UspGrid"
|
||||
import JsonToHtml from "@/components/JsonToHtml"
|
||||
|
||||
import AccordionSection from "./Accordion"
|
||||
import Table from "./Table"
|
||||
|
||||
import type { BlocksProps } from "@/types/components/blocks"
|
||||
@@ -14,6 +15,14 @@ export default function Blocks({ blocks }: BlocksProps) {
|
||||
return blocks.map((block, idx) => {
|
||||
const firstItem = idx === 0
|
||||
switch (block.typename) {
|
||||
case BlocksEnums.block.Accordion:
|
||||
return (
|
||||
<AccordionSection
|
||||
accordion={block.accordion.accordions}
|
||||
title={block.accordion.title}
|
||||
key={`${block.typename}-${idx}`}
|
||||
/>
|
||||
)
|
||||
case BlocksEnums.block.CardsGrid:
|
||||
return (
|
||||
<CardsGrid
|
||||
@@ -41,12 +50,13 @@ export default function Blocks({ blocks }: BlocksProps) {
|
||||
)
|
||||
case BlocksEnums.block.Shortcuts:
|
||||
return (
|
||||
<Shortcuts
|
||||
<ShortcutsList
|
||||
firstItem={firstItem}
|
||||
key={`${block.shortcuts.title}-${idx}`}
|
||||
shortcuts={block.shortcuts.shortcuts}
|
||||
subtitle={block.shortcuts.subtitle}
|
||||
title={block.shortcuts.title}
|
||||
hasTwoColumns={block.shortcuts.hasTwoColumns}
|
||||
/>
|
||||
)
|
||||
case BlocksEnums.block.Table:
|
||||
@@ -64,6 +74,7 @@ export default function Blocks({ blocks }: BlocksProps) {
|
||||
)
|
||||
case BlocksEnums.block.UspGrid:
|
||||
return <UspGrid usp_grid={block.usp_grid} />
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { dt } from "@/lib/dt"
|
||||
|
||||
import Form from "@/components/Forms/BookingWidget"
|
||||
import { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema"
|
||||
import { CloseLarge } from "@/components/Icons"
|
||||
import { CloseLargeIcon } from "@/components/Icons"
|
||||
import { debounce } from "@/utils/debounce"
|
||||
|
||||
import MobileToggleButton from "./MobileToggleButton"
|
||||
@@ -98,7 +98,7 @@ export default function BookingWidgetClient({
|
||||
onClick={closeMobileSearch}
|
||||
type="button"
|
||||
>
|
||||
<CloseLarge />
|
||||
<CloseLargeIcon />
|
||||
</button>
|
||||
<Form locations={locations} type={type} />
|
||||
</section>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 9;
|
||||
z-index: 10;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
padding-left: var(--Spacing-x2);
|
||||
padding-right: var(--Spacing-x2);
|
||||
padding-top: var(--Spacing-x2);
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list {
|
||||
|
||||
@@ -48,7 +48,7 @@ export default async function Breadcrumbs() {
|
||||
|
||||
return (
|
||||
<li key={breadcrumb.uid} className={styles.listItem}>
|
||||
<Footnote color="burgundy" textTransform="bold">
|
||||
<Footnote color="burgundy" type="bold">
|
||||
{breadcrumb.title}
|
||||
</Footnote>
|
||||
</li>
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
display: grid;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
width: min(100%, 300px);
|
||||
}
|
||||
|
||||
.amenityItemList {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.cardContainer {
|
||||
scroll-margin-top: var(--hotel-page-scroll-margin-top);
|
||||
}
|
||||
|
||||
.spanOne {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function FacilitiesCardGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
<section id={imageCard.card.id}>
|
||||
<section id={imageCard.card.id} className={styles.cardContainer}>
|
||||
<Grids.Stackable className={styles.desktopGrid}>
|
||||
{facilitiesCardGrid.map((card: FacilityCardType) => (
|
||||
<Card {...card} key={card.id} className={getCardClassName(card)} />
|
||||
|
||||
@@ -11,10 +11,10 @@ import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import { IntroSectionProps } from "./types"
|
||||
|
||||
import styles from "./introSection.module.css"
|
||||
|
||||
import type { IntroSectionProps } from "./types"
|
||||
|
||||
export default async function IntroSection({
|
||||
hotelName,
|
||||
hotelDescription,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
position: relative;
|
||||
max-width: var(--max-width-text-block);
|
||||
max-width: 607px; /* Max width according to Figma */
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import useHotelPageStore from "@/stores/hotel-page"
|
||||
@@ -21,7 +20,6 @@ export default function MobileMapToggle() {
|
||||
onClick={closeDynamicMap}
|
||||
>
|
||||
<HouseIcon
|
||||
className={styles.icon}
|
||||
color={!isDynamicMapOpen ? "white" : "red"}
|
||||
height={24}
|
||||
width={24}
|
||||
@@ -34,7 +32,6 @@ export default function MobileMapToggle() {
|
||||
onClick={openDynamicMap}
|
||||
>
|
||||
<MapIcon
|
||||
className={styles.icon}
|
||||
color={isDynamicMapOpen ? "white" : "red"}
|
||||
height={24}
|
||||
width={24}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
.mobileToggle {
|
||||
position: fixed;
|
||||
position: sticky;
|
||||
bottom: var(--Spacing-x5);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--Spacing-x-half);
|
||||
|
||||
@@ -10,7 +10,7 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./roomCard.module.css"
|
||||
|
||||
import type { RoomCardProps } from "@/types/components/hotelPage/roomCard"
|
||||
import type { RoomCardProps } from "@/types/components/hotelPage/room"
|
||||
|
||||
export function RoomCard({
|
||||
badgeTextTransKey,
|
||||
|
||||
@@ -3,95 +3,88 @@
|
||||
import { useRef, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { ChevronDownIcon } from "@/components/Icons"
|
||||
import SectionContainer from "@/components/Section/Container"
|
||||
import SectionHeader from "@/components/Section/Header"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Grids from "@/components/TempDesignSystem/Grids"
|
||||
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
|
||||
|
||||
import { RoomCard } from "./RoomCard"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
import type { RoomsProps } from "./types"
|
||||
import type { RoomsProps } from "@/types/components/hotelPage/room"
|
||||
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
|
||||
|
||||
export function Rooms({ rooms }: RoomsProps) {
|
||||
const intl = useIntl()
|
||||
const [allRoomsVisible, setAllRoomsVisible] = useState(false)
|
||||
const showToggleButton = rooms.length > 3
|
||||
const [allRoomsVisible, setAllRoomsVisible] = useState(!showToggleButton)
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const mappedRooms = rooms
|
||||
.map((room) => {
|
||||
const size = `${room.attributes.roomSize.min} - ${room.attributes.roomSize.max} m²`
|
||||
const size = `${room.roomSize.min} - ${room.roomSize.max} m²`
|
||||
const personLabel =
|
||||
room.attributes.occupancy.total === 1
|
||||
room.occupancy.total === 1
|
||||
? intl.formatMessage({ id: "hotelPages.rooms.roomCard.person" })
|
||||
: intl.formatMessage({ id: "hotelPages.rooms.roomCard.persons" })
|
||||
|
||||
const subtitle = `${size} (${room.attributes.occupancy.total} ${personLabel})`
|
||||
const subtitle = `${size} (${room.occupancy.total} ${personLabel})`
|
||||
|
||||
return {
|
||||
id: room.id,
|
||||
images: room.attributes.content.images,
|
||||
title: room.attributes.name,
|
||||
images: room.images,
|
||||
title: room.name,
|
||||
subtitle: subtitle,
|
||||
sortOrder: room.attributes.sortOrder,
|
||||
sortOrder: room.sortOrder,
|
||||
popularChoice: null,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
|
||||
function handleToggleShowMore() {
|
||||
function handleShowMore() {
|
||||
if (scrollRef.current && allRoomsVisible) {
|
||||
scrollRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
|
||||
setAllRoomsVisible((previousState) => !previousState)
|
||||
setAllRoomsVisible((state) => !state)
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionContainer id="rooms-section">
|
||||
<div ref={scrollRef}></div>
|
||||
<SectionContainer
|
||||
id={HotelHashValues.rooms}
|
||||
className={styles.roomsContainer}
|
||||
>
|
||||
<div ref={scrollRef} className={styles.scrollRef}></div>
|
||||
<SectionHeader
|
||||
textTransform="capitalize"
|
||||
title={intl.formatMessage({ id: "Rooms" })}
|
||||
preamble={null}
|
||||
/>
|
||||
<Grids.Stackable>
|
||||
{mappedRooms.map(
|
||||
({ id, images, title, subtitle, popularChoice }, index) => (
|
||||
<div
|
||||
key={id}
|
||||
className={
|
||||
!allRoomsVisible && index > 2 ? styles.hiddenRoomCard : ""
|
||||
}
|
||||
>
|
||||
<RoomCard
|
||||
id={id}
|
||||
images={images}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
badgeTextTransKey={popularChoice ? "Popular choice" : null}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<Grids.Stackable
|
||||
className={`${styles.grid} ${allRoomsVisible ? styles.allVisible : ""}`}
|
||||
>
|
||||
{mappedRooms.map(({ id, images, title, subtitle, popularChoice }) => (
|
||||
<div key={id}>
|
||||
<RoomCard
|
||||
id={id}
|
||||
images={images}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
badgeTextTransKey={popularChoice ? "Popular choice" : null}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Grids.Stackable>
|
||||
|
||||
<div className={styles.ctaContainer}>
|
||||
<Button
|
||||
onClick={handleToggleShowMore}
|
||||
theme="base"
|
||||
intent="text"
|
||||
variant="icon"
|
||||
className={`${styles.showMoreButton} ${allRoomsVisible ? styles.showLess : ""}`}
|
||||
>
|
||||
<ChevronDownIcon className={styles.chevron} />
|
||||
{intl.formatMessage({
|
||||
id: allRoomsVisible ? "Show less" : "Show more",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
{showToggleButton ? (
|
||||
<ShowMoreButton
|
||||
loadMoreData={handleShowMore}
|
||||
showLess={allRoomsVisible}
|
||||
textShowMore="Show more rooms"
|
||||
textShowLess="Show less rooms"
|
||||
/>
|
||||
) : null}
|
||||
</SectionContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
.roomsContainer {
|
||||
position: relative;
|
||||
scroll-margin-top: var(--hotel-page-scroll-margin-top);
|
||||
}
|
||||
|
||||
.scrollRef {
|
||||
position: absolute;
|
||||
top: calc(-1 * var(--hotel-page-scroll-margin-top));
|
||||
}
|
||||
|
||||
.ctaContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hiddenRoomCard {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.showMoreButton.showLess .chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.grid:not(.allVisible) :nth-child(n + 4) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { RoomData } from "@/types/hotel"
|
||||
|
||||
export type RoomsProps = {
|
||||
rooms: RoomData[]
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import useHash from "@/hooks/useHash"
|
||||
import useScrollSpy from "@/hooks/useScrollSpy"
|
||||
|
||||
import styles from "./tabNavigation.module.css"
|
||||
|
||||
@@ -11,50 +15,78 @@ import {
|
||||
type TabNavigationProps,
|
||||
} from "@/types/components/hotelPage/tabNavigation"
|
||||
|
||||
export default function TabNavigation({ restaurantTitle }: TabNavigationProps) {
|
||||
export default function TabNavigation({
|
||||
restaurantTitle,
|
||||
hasActivities,
|
||||
hasFAQ,
|
||||
}: TabNavigationProps) {
|
||||
const hash = useHash()
|
||||
const intl = useIntl()
|
||||
const router = useRouter()
|
||||
|
||||
const hotelTabLinks: { href: HotelHashValues | string; text: string }[] = [
|
||||
const tabLinks: { hash: HotelHashValues; text: string }[] = [
|
||||
{
|
||||
href: HotelHashValues.overview,
|
||||
hash: HotelHashValues.overview,
|
||||
text: intl.formatMessage({ id: "Overview" }),
|
||||
},
|
||||
{ href: HotelHashValues.rooms, text: intl.formatMessage({ id: "Rooms" }) },
|
||||
{ hash: HotelHashValues.rooms, text: intl.formatMessage({ id: "Rooms" }) },
|
||||
{
|
||||
href: HotelHashValues.restaurant,
|
||||
hash: HotelHashValues.restaurant,
|
||||
text: intl.formatMessage({ id: restaurantTitle }, { count: 1 }),
|
||||
},
|
||||
{
|
||||
href: HotelHashValues.meetings,
|
||||
hash: HotelHashValues.meetings,
|
||||
text: intl.formatMessage({ id: "Meetings & Conferences" }),
|
||||
},
|
||||
{
|
||||
href: HotelHashValues.wellness,
|
||||
hash: HotelHashValues.wellness,
|
||||
text: intl.formatMessage({ id: "Wellness & Exercise" }),
|
||||
},
|
||||
{
|
||||
href: HotelHashValues.activities,
|
||||
text: intl.formatMessage({ id: "Activities" }),
|
||||
},
|
||||
{ href: HotelHashValues.faq, text: intl.formatMessage({ id: "FAQ" }) },
|
||||
...(hasActivities
|
||||
? [
|
||||
{
|
||||
hash: HotelHashValues.activities,
|
||||
text: intl.formatMessage({ id: "Activities" }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(hasFAQ
|
||||
? [
|
||||
{
|
||||
hash: HotelHashValues.faq,
|
||||
text: intl.formatMessage({ id: "FAQ" }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
const { activeSectionId, pauseScrollSpy } = useScrollSpy(
|
||||
tabLinks.map(({ hash }) => hash)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSectionId) {
|
||||
router.replace(`#${activeSectionId}`, { scroll: false })
|
||||
}
|
||||
}, [activeSectionId, router])
|
||||
|
||||
return (
|
||||
<div className={styles.stickyWrapper}>
|
||||
<nav className={styles.tabsContainer}>
|
||||
{hotelTabLinks.map((link) => {
|
||||
{tabLinks.map((link) => {
|
||||
const isActive =
|
||||
hash === link.href ||
|
||||
(hash === "" && link.href === HotelHashValues.overview)
|
||||
hash === link.hash ||
|
||||
(!hash && link.hash === HotelHashValues.overview)
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
key={link.hash}
|
||||
href={`#${link.hash}`}
|
||||
active={isActive}
|
||||
variant="tab"
|
||||
color="burgundy"
|
||||
textDecoration="none"
|
||||
scroll={true}
|
||||
onClick={pauseScrollSpy}
|
||||
>
|
||||
{intl.formatMessage({ id: link.text })}
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.stickyWrapper {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
top: var(--booking-widget-mobile-height);
|
||||
z-index: 2;
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
overflow-x: auto;
|
||||
@@ -16,6 +16,12 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.stickyWrapper {
|
||||
top: var(--booking-widget-desktop-height);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.tabsContainer {
|
||||
padding: 0 var(--Spacing-x5);
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
.pageContainer {
|
||||
--hotel-page-navigation-height: 59px;
|
||||
--hotel-page-scroll-margin-top: calc(
|
||||
var(--hotel-page-navigation-height) + var(--Spacing-x2)
|
||||
);
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"hotelImages"
|
||||
"tabNavigation"
|
||||
"mainSection"
|
||||
"mapContainer";
|
||||
margin: 0 auto;
|
||||
max-width: var(--max-width);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hotelImages {
|
||||
@@ -24,8 +31,11 @@
|
||||
}
|
||||
|
||||
.introContainer {
|
||||
display: grid;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--Spacing-x4);
|
||||
scroll-margin-top: var(--hotel-page-scroll-margin-top);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
@@ -52,7 +62,7 @@
|
||||
|
||||
.mapWithCard {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
top: var(--booking-widget-desktop-height);
|
||||
min-height: 500px; /* Fixed min to not cover the marker with the card */
|
||||
height: calc(
|
||||
100vh - var(--main-menu-desktop-height) -
|
||||
|
||||
@@ -2,6 +2,7 @@ import hotelPageParams from "@/constants/routes/hotelPageParams"
|
||||
import { env } from "@/env/server"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import AccordionSection from "@/components/Blocks/Accordion"
|
||||
import SidePeekProvider from "@/components/SidePeekProvider"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import { getIntl } from "@/i18n"
|
||||
@@ -21,6 +22,8 @@ import TabNavigation from "./TabNavigation"
|
||||
|
||||
import styles from "./hotelPage.module.css"
|
||||
|
||||
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
|
||||
|
||||
export default async function HotelPage() {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
@@ -45,6 +48,7 @@ export default async function HotelPage() {
|
||||
activitiesCard,
|
||||
pointsOfInterest,
|
||||
facilities,
|
||||
faq,
|
||||
} = hotelData
|
||||
|
||||
const topThreePois = pointsOfInterest.slice(0, 3)
|
||||
@@ -61,9 +65,11 @@ export default async function HotelPage() {
|
||||
</div>
|
||||
<TabNavigation
|
||||
restaurantTitle={getRestaurantHeading(hotelDetailedFacilities)}
|
||||
hasActivities={!!activitiesCard}
|
||||
hasFAQ={!!faq}
|
||||
/>
|
||||
<main className={styles.mainSection}>
|
||||
<div className={styles.introContainer}>
|
||||
<div id={HotelHashValues.overview} className={styles.introContainer}>
|
||||
<IntroSection
|
||||
hotelName={hotelName}
|
||||
hotelDescription={hotelDescription}
|
||||
@@ -71,55 +77,14 @@ export default async function HotelPage() {
|
||||
address={hotelAddress}
|
||||
tripAdvisor={hotelRatings?.tripAdvisor}
|
||||
/>
|
||||
<SidePeekProvider>
|
||||
{/* eslint-disable import/no-named-as-default-member */}
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.amenities[lang]}
|
||||
title={intl.formatMessage({ id: "Amenities" })}
|
||||
>
|
||||
{/* TODO: Render amenities as per the design. */}
|
||||
Read more about the amenities here
|
||||
</SidePeek>
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.about[lang]}
|
||||
title={intl.formatMessage({ id: "Read more about the hotel" })}
|
||||
>
|
||||
Some additional information about the hotel
|
||||
</SidePeek>
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.restaurantAndBar[lang]}
|
||||
title={intl.formatMessage({ id: "Restaurant & Bar" })}
|
||||
>
|
||||
{/* TODO */}
|
||||
Restaurant & Bar
|
||||
</SidePeek>
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.wellnessAndExercise[lang]}
|
||||
title={intl.formatMessage({ id: "Wellness & Exercise" })}
|
||||
>
|
||||
{/* TODO */}
|
||||
Wellness & Exercise
|
||||
</SidePeek>
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.activities[lang]}
|
||||
title={intl.formatMessage({ id: "Activities" })}
|
||||
>
|
||||
{/* TODO */}
|
||||
Activities
|
||||
</SidePeek>
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.meetingsAndConferences[lang]}
|
||||
title={intl.formatMessage({ id: "Meetings & Conferences" })}
|
||||
>
|
||||
{/* TODO */}
|
||||
Meetings & Conferences
|
||||
</SidePeek>
|
||||
{/* eslint-enable import/no-named-as-default-member */}
|
||||
</SidePeekProvider>
|
||||
|
||||
<AmenitiesList detailedFacilities={hotelDetailedFacilities} />
|
||||
</div>
|
||||
<Rooms rooms={roomCategories} />
|
||||
<Facilities facilities={facilities} activitiesCard={activitiesCard} />
|
||||
{faq && (
|
||||
<AccordionSection accordion={faq.accordions} title={faq.title} />
|
||||
)}
|
||||
</main>
|
||||
{googleMapsApiKey ? (
|
||||
<>
|
||||
@@ -139,6 +104,51 @@ export default async function HotelPage() {
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<SidePeekProvider>
|
||||
{/* eslint-disable import/no-named-as-default-member */}
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.amenities[lang]}
|
||||
title={intl.formatMessage({ id: "Amenities" })}
|
||||
>
|
||||
{/* TODO: Render amenities as per the design. */}
|
||||
Read more about the amenities here
|
||||
</SidePeek>
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.about[lang]}
|
||||
title={intl.formatMessage({ id: "Read more about the hotel" })}
|
||||
>
|
||||
Some additional information about the hotel
|
||||
</SidePeek>
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.restaurantAndBar[lang]}
|
||||
title={intl.formatMessage({ id: "Restaurant & Bar" })}
|
||||
>
|
||||
{/* TODO */}
|
||||
Restaurant & Bar
|
||||
</SidePeek>
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.wellnessAndExercise[lang]}
|
||||
title={intl.formatMessage({ id: "Wellness & Exercise" })}
|
||||
>
|
||||
{/* TODO */}
|
||||
Wellness & Exercise
|
||||
</SidePeek>
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.activities[lang]}
|
||||
title={intl.formatMessage({ id: "Activities" })}
|
||||
>
|
||||
{/* TODO */}
|
||||
Activities
|
||||
</SidePeek>
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.meetingsAndConferences[lang]}
|
||||
title={intl.formatMessage({ id: "Meetings & Conferences" })}
|
||||
>
|
||||
{/* TODO */}
|
||||
Meetings & Conferences
|
||||
</SidePeek>
|
||||
{/* eslint-enable import/no-named-as-default-member */}
|
||||
</SidePeekProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
padding-bottom: var(--Spacing-x9);
|
||||
padding-left: var(--Spacing-x0);
|
||||
padding-right: var(--Spacing-x0);
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
|
||||
container-name: loyalty-page;
|
||||
container-type: inline-size;
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.blocks {
|
||||
|
||||
@@ -61,9 +61,9 @@ export default function DatePickerDesktop({
|
||||
locale={locale}
|
||||
mode="range"
|
||||
numberOfMonths={2}
|
||||
onSelect={handleOnSelect}
|
||||
onDayClick={handleOnSelect}
|
||||
pagedNavigation
|
||||
required
|
||||
required={false}
|
||||
selected={selectedDate}
|
||||
startMonth={currentDate}
|
||||
weekStartsOn={1}
|
||||
@@ -82,7 +82,7 @@ export default function DatePickerDesktop({
|
||||
size="small"
|
||||
theme="base"
|
||||
>
|
||||
<Caption color="white" textTransform="bold">
|
||||
<Caption color="white" type="bold">
|
||||
{intl.formatMessage({ id: "Select dates" })}
|
||||
</Caption>
|
||||
</Button>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useIntl } from "react-intl"
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import { CloseLarge } from "@/components/Icons"
|
||||
import { CloseLargeIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
@@ -78,7 +78,7 @@ export default function DatePickerMobile({
|
||||
mode="range"
|
||||
/** Showing full year or what's left of it */
|
||||
numberOfMonths={12}
|
||||
onSelect={handleOnSelect}
|
||||
onDayClick={handleOnSelect}
|
||||
required
|
||||
selected={selectedDate}
|
||||
startMonth={startMonth}
|
||||
@@ -127,7 +127,7 @@ export default function DatePickerMobile({
|
||||
))}
|
||||
</select>
|
||||
<button className={styles.close} onClick={close} type="button">
|
||||
<CloseLarge />
|
||||
<CloseLargeIcon />
|
||||
</button>
|
||||
</header>
|
||||
{children}
|
||||
|
||||
@@ -47,8 +47,8 @@ td.rangeStart[aria-selected="true"] button.dayButton:hover {
|
||||
}
|
||||
|
||||
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
|
||||
td.rangeStart[aria-selected="true"]:not([data-outside="true"])
|
||||
button.dayButton {
|
||||
td.rangeStart[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
|
||||
td.day[aria-selected="true"] button.dayButton {
|
||||
background: var(--Primary-Light-On-Surface-Accent);
|
||||
border: none;
|
||||
color: var(--Base-Button-Inverted-Fill-Normal);
|
||||
@@ -75,6 +75,7 @@ td.rangeMiddle[aria-selected="true"] button.dayButton {
|
||||
background: var(--Base-Background-Primary-Normal);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
color: var(--UI-Text-High-contrast);
|
||||
}
|
||||
|
||||
td.day[data-disabled="true"],
|
||||
|
||||
@@ -113,8 +113,8 @@ td.rangeStart[aria-selected="true"] button.dayButton:hover {
|
||||
}
|
||||
|
||||
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
|
||||
td.rangeStart[aria-selected="true"]:not([data-outside="true"])
|
||||
button.dayButton {
|
||||
td.rangeStart[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
|
||||
td.day[aria-selected="true"] button.dayButton {
|
||||
background: var(--Primary-Light-On-Surface-Accent);
|
||||
border: none;
|
||||
color: var(--Base-Button-Inverted-Fill-Normal);
|
||||
@@ -141,6 +141,7 @@ td.rangeMiddle[aria-selected="true"] button.dayButton {
|
||||
background: var(--Base-Background-Primary-Normal);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
color: var(--UI-Text-High-contrast);
|
||||
}
|
||||
|
||||
td.day[data-disabled="true"],
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
|
||||
.hideWrapper {
|
||||
background-color: var(--Main-Grey-White);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.container[data-isopen="true"] .hideWrapper {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
|
||||
@@ -14,8 +14,6 @@ import DatePickerMobile from "./Screen/Mobile"
|
||||
|
||||
import styles from "./date-picker.module.css"
|
||||
|
||||
import type { DateRange } from "react-day-picker"
|
||||
|
||||
import type { DatePickerFormProps } from "@/types/components/datepicker"
|
||||
|
||||
const locales = {
|
||||
@@ -33,6 +31,8 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
const { register, setValue } = useFormContext()
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const [isSelectingFrom, setIsSelectingFrom] = useState(true)
|
||||
|
||||
function close() {
|
||||
setIsOpen(false)
|
||||
}
|
||||
@@ -41,11 +41,29 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen)
|
||||
}
|
||||
|
||||
function handleSelectDate(selected: DateRange) {
|
||||
setValue(name, {
|
||||
from: dt(selected.from).format("YYYY-MM-DD"),
|
||||
to: dt(selected.to).format("YYYY-MM-DD"),
|
||||
})
|
||||
function handleSelectDate(selected: Date) {
|
||||
if (isSelectingFrom) {
|
||||
setValue(name, {
|
||||
from: dt(selected).format("YYYY-MM-DD"),
|
||||
to: undefined,
|
||||
})
|
||||
setIsSelectingFrom(false)
|
||||
} else {
|
||||
const fromDate = dt(selectedDate.from)
|
||||
const toDate = dt(selected)
|
||||
if (toDate.isAfter(fromDate)) {
|
||||
setValue(name, {
|
||||
from: selectedDate.from,
|
||||
to: toDate.format("YYYY-MM-DD"),
|
||||
})
|
||||
} else {
|
||||
setValue(name, {
|
||||
from: toDate.format("YYYY-MM-DD"),
|
||||
to: selectedDate.from,
|
||||
})
|
||||
}
|
||||
setIsSelectingFrom(true)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -64,7 +82,9 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
const selectedFromDate = dt(selectedDate.from)
|
||||
.locale(lang)
|
||||
.format("ddd D MMM")
|
||||
const selectedToDate = dt(selectedDate.to).locale(lang).format("ddd D MMM")
|
||||
const selectedToDate = !!selectedDate.to
|
||||
? dt(selectedDate.to).locale(lang).format("ddd D MMM")
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className={styles.container} data-isopen={isOpen} ref={ref}>
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function FooterMainNav({ mainLinks }: FooterMainNavProps) {
|
||||
color="burgundy"
|
||||
href={link.url}
|
||||
className={styles.mainNavigationLink}
|
||||
target={link.openInNewTab ? "_blank" : undefined}
|
||||
>
|
||||
{link.title}
|
||||
|
||||
|
||||
@@ -56,26 +56,13 @@ export default function FooterSecondaryNav({
|
||||
<ul className={styles.secondaryNavigationList}>
|
||||
{link?.links?.map((link) => (
|
||||
<li key={link.title} className={styles.secondaryNavigationItem}>
|
||||
{link.isExternal ? (
|
||||
<a
|
||||
href={link.url}
|
||||
key={link.title}
|
||||
target={link.openInNewTab ? "_blank" : "_self"}
|
||||
aria-label={link.title}
|
||||
className={styles.secondaryNavigationLink}
|
||||
>
|
||||
{link.title}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={link.url}
|
||||
key={link.title}
|
||||
target={link.openInNewTab ? "_blank" : "_self"}
|
||||
color="burgundy"
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href={link.url}
|
||||
target={link.openInNewTab ? "_blank" : undefined}
|
||||
color="burgundy"
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ export default function ClearSearchButton({
|
||||
type="button"
|
||||
>
|
||||
<DeleteIcon color="burgundy" height={20} width={20} />
|
||||
<Caption color="burgundy" textTransform="bold">
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Clear searches" })}
|
||||
</Caption>
|
||||
</button>
|
||||
|
||||
+23
-11
@@ -1,23 +1,18 @@
|
||||
.dialog {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
box-shadow: 0 0 14px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
left: 0;
|
||||
list-style: none;
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
position: absolute;
|
||||
/**
|
||||
* var(--Spacing-x4) to account for padding inside
|
||||
* the bookingwidget and to add the padding for the
|
||||
* box itself
|
||||
*/
|
||||
top: calc(100% + var(--Spacing-x4));
|
||||
width: 360px;
|
||||
z-index: 99;
|
||||
position: fixed;
|
||||
top: 170px;
|
||||
width: 100%;
|
||||
height: calc(100% - 200px);
|
||||
z-index: 10010;
|
||||
}
|
||||
|
||||
.default {
|
||||
@@ -31,3 +26,20 @@
|
||||
.search {
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.dialog {
|
||||
position: absolute;
|
||||
width: 360px;
|
||||
/**
|
||||
* var(--Spacing-x4) to account for padding inside
|
||||
* the bookingwidget and to add the padding for the
|
||||
* box itself
|
||||
*/
|
||||
top: calc(100% + var(--Spacing-x4));
|
||||
z-index: 99;
|
||||
box-shadow: 0 0 14px 6px rgba(0, 0, 0, 0.1);
|
||||
max-height: 380px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import Input from "../Input"
|
||||
@@ -49,15 +48,6 @@ export default function Search({ locations }: SearchProps) {
|
||||
dispatch({ type: ActionType.CLEAR_HISTORY_LOCATIONS })
|
||||
}
|
||||
|
||||
function handleOnBlur() {
|
||||
if (!value && state.searchData?.name) {
|
||||
setValue(name, state.searchData.name)
|
||||
// Always need to manually trigger
|
||||
// revalidation when setting value r-h-f
|
||||
trigger()
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnChange(
|
||||
evt: FormEvent<HTMLInputElement> | ChangeEvent<HTMLInputElement>
|
||||
) {
|
||||
@@ -139,7 +129,9 @@ export default function Search({ locations }: SearchProps) {
|
||||
<div className={styles.container}>
|
||||
<label {...getLabelProps({ htmlFor: name })} className={styles.label}>
|
||||
<Caption color={isOpen ? "uiTextActive" : "red"}>
|
||||
{intl.formatMessage({ id: "Where to" })}
|
||||
{state.searchData?.type === "hotels"
|
||||
? state.searchData?.relationships?.city?.name
|
||||
: intl.formatMessage({ id: "Where to" })}
|
||||
</Caption>
|
||||
</label>
|
||||
<div {...getRootProps({}, { suppressRefError: true })}>
|
||||
@@ -155,7 +147,6 @@ export default function Search({ locations }: SearchProps) {
|
||||
}),
|
||||
...register(name, {
|
||||
onBlur: function () {
|
||||
handleOnBlur()
|
||||
closeMenu()
|
||||
},
|
||||
onChange: handleOnChange,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
border-width: 1px;
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.container:hover,
|
||||
@@ -23,8 +24,3 @@
|
||||
p {
|
||||
color: var(--UI-Text-Active);
|
||||
}
|
||||
|
||||
.container:hover:has(input:not(:active, :focus, :focus-within))
|
||||
input::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { Tooltip } from "@/components/TempDesignSystem/Tooltip"
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function Voucher() {
|
||||
>
|
||||
<div className={styles.vouchers}>
|
||||
<label>
|
||||
<Caption color="disabled" textTransform="bold">
|
||||
<Caption color="disabled" type="bold">
|
||||
{vouchers}
|
||||
</Caption>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
@@ -50,17 +50,17 @@ export default function Voucher() {
|
||||
>
|
||||
<div className={styles.options}>
|
||||
<label className={`${styles.option} ${styles.checkboxVoucher}`}>
|
||||
<input type="checkbox" disabled className={styles.checkbox} />
|
||||
<Checkbox name="useVouchers" registerOptions={{ disabled: true }} />
|
||||
<Caption color="disabled">{useVouchers}</Caption>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</label>
|
||||
<label className={styles.option}>
|
||||
<input type="checkbox" disabled className={styles.checkbox} />
|
||||
<Checkbox name="useBonus" registerOptions={{ disabled: true }} />
|
||||
<Caption color="disabled">{bonus}</Caption>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</label>
|
||||
<label className={styles.option}>
|
||||
<input type="checkbox" disabled className={styles.checkbox} />
|
||||
<Checkbox name="useReward" registerOptions={{ disabled: true }} />
|
||||
<Caption color="disabled">{reward}</Caption>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</label>
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
stroke: var(--Base-Text-Disabled);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.vouchers {
|
||||
display: none;
|
||||
@@ -64,7 +68,7 @@
|
||||
.options {
|
||||
flex-direction: column;
|
||||
max-width: 190px;
|
||||
gap: 0;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
.vouchers:hover,
|
||||
.option:hover {
|
||||
@@ -72,6 +76,7 @@
|
||||
}
|
||||
.optionsContainer {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.checkboxVoucher {
|
||||
display: none;
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
.infoIcon {
|
||||
stroke: var(--Base-Text-Disabled);
|
||||
}
|
||||
|
||||
.vouchersHeader {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
@@ -79,8 +75,10 @@
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
}
|
||||
|
||||
.when:hover,
|
||||
.rooms:hover,
|
||||
.when:has([data-isopen="true"]),
|
||||
.rooms:has(.input:active, .input:focus, .input:focus-within) {
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"use client"
|
||||
import { useState } from "react"
|
||||
import { useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import DatePicker from "@/components/DatePicker"
|
||||
import GuestsRoomsPickerForm from "@/components/GuestsRoomsPicker"
|
||||
import { SearchIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import Input from "./Input"
|
||||
import Search from "./Search"
|
||||
import Voucher from "./Voucher"
|
||||
|
||||
@@ -20,7 +21,6 @@ import type { BookingWidgetFormContentProps } from "@/types/components/form/book
|
||||
export default function FormContent({
|
||||
locations,
|
||||
formId,
|
||||
formState,
|
||||
}: BookingWidgetFormContentProps) {
|
||||
const intl = useIntl()
|
||||
const selectedDate = useWatch({ name: "date" })
|
||||
@@ -37,21 +37,21 @@ export default function FormContent({
|
||||
<Search locations={locations} />
|
||||
</div>
|
||||
<div className={styles.when}>
|
||||
<Caption color="red" textTransform="bold">
|
||||
<Caption color="red" type="bold">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: nights }
|
||||
{ totalNights: nights > 0 ? nights : 0 }
|
||||
)}
|
||||
</Caption>
|
||||
<DatePicker />
|
||||
</div>
|
||||
<div className={styles.rooms}>
|
||||
<label>
|
||||
<Caption color="red" textTransform="bold">
|
||||
<Caption color="red" type="bold">
|
||||
{rooms}
|
||||
</Caption>
|
||||
</label>
|
||||
<Input type="text" placeholder={rooms} />
|
||||
<GuestsRoomsPickerForm />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.voucherContainer}>
|
||||
@@ -60,17 +60,12 @@ export default function FormContent({
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
disabled={!formState.isValid}
|
||||
form={formId}
|
||||
intent="primary"
|
||||
theme="base"
|
||||
type="submit"
|
||||
>
|
||||
<Caption
|
||||
color="white"
|
||||
textTransform="bold"
|
||||
className={styles.buttonText}
|
||||
>
|
||||
<Caption color="white" type="bold" className={styles.buttonText}>
|
||||
{intl.formatMessage({ id: "Search" })}
|
||||
</Caption>
|
||||
<div className={styles.icon}>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
@media screen and (min-width: 768px) {
|
||||
.section {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.default {
|
||||
@@ -35,6 +36,13 @@
|
||||
var(--Spacing-x-one-and-half) var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.section {
|
||||
width: min(
|
||||
calc(100dvw - (var(--Spacing-x2) * 2)),
|
||||
var(--max-width-navigation)
|
||||
);
|
||||
}
|
||||
|
||||
.full {
|
||||
padding: var(--Spacing-x1) 0;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
import { selectHotel, selectRate } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import FormContent from "./FormContent"
|
||||
import { bookingWidgetVariants } from "./variants"
|
||||
|
||||
@@ -9,11 +13,13 @@ import styles from "./form.module.css"
|
||||
|
||||
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
|
||||
import type { BookingWidgetFormProps } from "@/types/components/form/bookingwidget"
|
||||
import { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
const formId = "booking-widget"
|
||||
|
||||
export default function Form({ locations, type }: BookingWidgetFormProps) {
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
|
||||
const classNames = bookingWidgetVariants({
|
||||
type,
|
||||
@@ -23,11 +29,32 @@ export default function Form({ locations, type }: BookingWidgetFormProps) {
|
||||
useFormContext<BookingWidgetSchema>()
|
||||
|
||||
function onSubmit(data: BookingWidgetSchema) {
|
||||
data.location = JSON.parse(decodeURIComponent(data.location))
|
||||
console.log(data)
|
||||
// TODO: Parse data and route accordignly to Select hotel or select room-rate page
|
||||
console.log("to be routing")
|
||||
router.push("/en/hotelreservation/select-hotel")
|
||||
const locationData: Location = JSON.parse(decodeURIComponent(data.location))
|
||||
|
||||
const bookingFlowPage =
|
||||
locationData.type == "cities" ? selectHotel[lang] : selectRate[lang]
|
||||
const bookingWidgetParams = new URLSearchParams(data.date)
|
||||
|
||||
if (locationData.type == "cities")
|
||||
bookingWidgetParams.set("city", locationData.name)
|
||||
else bookingWidgetParams.set("hotel", locationData.operaId || "")
|
||||
|
||||
data.rooms.forEach((room, index) => {
|
||||
bookingWidgetParams.set(`room[${index}].adults`, room.adults.toString())
|
||||
|
||||
room.children.forEach((child, childIndex) => {
|
||||
bookingWidgetParams.set(
|
||||
`room[${index}].child[${childIndex}].age`,
|
||||
child.age.toString()
|
||||
)
|
||||
bookingWidgetParams.set(
|
||||
`room[${index}].child[${childIndex}].bed`,
|
||||
child.bed.toString()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
router.push(`${bookingFlowPage}?${bookingWidgetParams.toString()}`)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,18 @@ import { z } from "zod"
|
||||
|
||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
export const guestRoomSchema = z.object({
|
||||
adults: z.number().default(1),
|
||||
children: z.array(
|
||||
z.object({
|
||||
age: z.number().nonnegative(),
|
||||
bed: z.number(),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
export const guestRoomsSchema = z.array(guestRoomSchema)
|
||||
|
||||
export const bookingWidgetSchema = z.object({
|
||||
bookingCode: z.string(), // Update this as required when working with booking codes component
|
||||
date: z.object({
|
||||
@@ -25,18 +37,7 @@ export const bookingWidgetSchema = z.object({
|
||||
{ message: "Required" }
|
||||
),
|
||||
redemption: z.boolean().default(false),
|
||||
rooms: z.array(
|
||||
// This will be updated when working in guests component
|
||||
z.object({
|
||||
adults: z.number().default(1),
|
||||
children: z.array(
|
||||
z.object({
|
||||
age: z.number(),
|
||||
bed: z.number(),
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
rooms: guestRoomsSchema,
|
||||
search: z.string({ coerce: true }).min(1, "Required"),
|
||||
voucher: z.boolean().default(false),
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function FormContent() {
|
||||
const email = `${intl.formatMessage({ id: "Email" })} ${intl.formatMessage({ id: "Address" }).toLowerCase()}`
|
||||
const street = intl.formatMessage({ id: "Address" })
|
||||
const phoneNumber = intl.formatMessage({ id: "Phone number" })
|
||||
const password = intl.formatMessage({ id: "Current password" })
|
||||
const currentPassword = intl.formatMessage({ id: "Current password" })
|
||||
const retypeNewPassword = intl.formatMessage({ id: "Retype new password" })
|
||||
const zipCode = intl.formatMessage({ id: "Zip code" })
|
||||
|
||||
@@ -72,8 +72,10 @@ export default function FormContent() {
|
||||
{intl.formatMessage({ id: "Password" })}
|
||||
</Body>
|
||||
</header>
|
||||
<Input label={password} name="password" type="password" />
|
||||
<NewPassword />
|
||||
<Input label={currentPassword} name="password" type="password" />
|
||||
{/* visibilityToggleable set to false as feature is done for signup first */}
|
||||
{/* likely we can remove the prop altogether once signup launches */}
|
||||
<NewPassword visibilityToggleable={false} />
|
||||
<Input
|
||||
label={retypeNewPassword}
|
||||
name="retypeNewPassword"
|
||||
|
||||
@@ -26,7 +26,7 @@ export const editProfileSchema = z
|
||||
),
|
||||
|
||||
password: z.string().optional(),
|
||||
newPassword: passwordValidator(),
|
||||
newPassword: z.literal("").optional().or(passwordValidator()),
|
||||
retypeNewPassword: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { signupTerms } from "@/constants/routes/signup"
|
||||
import { privacyPolicy } from "@/constants/currentWebHrefs"
|
||||
|
||||
import { registerUser } from "@/actions/registerUser"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
@@ -107,7 +107,7 @@ export default function Form({ link, subtitle, title }: RegisterFormProps) {
|
||||
</div>
|
||||
<div className={styles.dateField}>
|
||||
<header>
|
||||
<Caption textTransform="bold">
|
||||
<Caption type="bold">
|
||||
{intl.formatMessage({ id: "Birth date" })}
|
||||
</Caption>
|
||||
</header>
|
||||
@@ -163,7 +163,7 @@ export default function Form({ link, subtitle, title }: RegisterFormProps) {
|
||||
variant="underscored"
|
||||
color="peach80"
|
||||
target="_blank"
|
||||
href={signupTerms[lang]}
|
||||
href={privacyPolicy[lang]}
|
||||
>
|
||||
{intl.formatMessage({ id: "Scandic's Privacy Policy." })}
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client"
|
||||
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
|
||||
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import Counter from "../Counter"
|
||||
|
||||
import styles from "./adult-selector.module.css"
|
||||
|
||||
import { BedTypeEnum } from "@/types/components/bookingWidget/enums"
|
||||
import {
|
||||
AdultSelectorProps,
|
||||
Child,
|
||||
} from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||
|
||||
export default function AdultSelector({ roomIndex = 0 }: AdultSelectorProps) {
|
||||
const intl = useIntl()
|
||||
const adultsLabel = intl.formatMessage({ id: "Adults" })
|
||||
const { setValue } = useFormContext()
|
||||
const { adults, children, childrenInAdultsBed } = useGuestsRoomsStore(
|
||||
(state) => state.rooms[roomIndex]
|
||||
)
|
||||
const increaseAdults = useGuestsRoomsStore((state) => state.increaseAdults)
|
||||
const decreaseAdults = useGuestsRoomsStore((state) => state.decreaseAdults)
|
||||
|
||||
function increaseAdultsCount(roomIndex: number) {
|
||||
if (adults < 6) {
|
||||
increaseAdults(roomIndex)
|
||||
setValue(`rooms.${roomIndex}.adults`, adults + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function decreaseAdultsCount(roomIndex: number) {
|
||||
if (adults > 1) {
|
||||
decreaseAdults(roomIndex)
|
||||
setValue(`rooms.${roomIndex}.adults`, adults - 1)
|
||||
if (childrenInAdultsBed > adults) {
|
||||
const toUpdateIndex = children.findIndex(
|
||||
(child: Child) => child.bed == BedTypeEnum.IN_ADULTS_BED
|
||||
)
|
||||
if (toUpdateIndex != -1) {
|
||||
setValue(
|
||||
`rooms.${roomIndex}.children.${toUpdateIndex}.bed`,
|
||||
children[toUpdateIndex].age < 3
|
||||
? BedTypeEnum.IN_CRIB
|
||||
: BedTypeEnum.IN_EXTRA_BED
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
{adultsLabel}
|
||||
</Caption>
|
||||
<Counter
|
||||
count={adults}
|
||||
handleOnDecrease={() => {
|
||||
decreaseAdultsCount(roomIndex)
|
||||
}}
|
||||
handleOnIncrease={() => {
|
||||
increaseAdultsCount(roomIndex)
|
||||
}}
|
||||
disableDecrease={adults == 1}
|
||||
disableIncrease={adults == 6}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
|
||||
|
||||
import { ErrorCircleIcon } from "@/components/Icons"
|
||||
import Select from "@/components/TempDesignSystem/Select"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import styles from "./child-selector.module.css"
|
||||
|
||||
import { BedTypeEnum } from "@/types/components/bookingWidget/enums"
|
||||
import {
|
||||
ChildBed,
|
||||
ChildInfoSelectorProps,
|
||||
} from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||
|
||||
export default function ChildInfoSelector({
|
||||
child = { age: -1, bed: -1 },
|
||||
index = 0,
|
||||
roomIndex = 0,
|
||||
}: ChildInfoSelectorProps) {
|
||||
const intl = useIntl()
|
||||
const ageLabel = intl.formatMessage({ id: "Age" })
|
||||
const ageReqdErrMsg = intl.formatMessage({ id: "Child age is required" })
|
||||
const bedLabel = intl.formatMessage({ id: "Bed" })
|
||||
const { setValue, trigger } = useFormContext()
|
||||
const { adults, childrenInAdultsBed } = useGuestsRoomsStore(
|
||||
(state) => state.rooms[roomIndex]
|
||||
)
|
||||
const {
|
||||
isValidated,
|
||||
updateChildAge,
|
||||
updateChildBed,
|
||||
increaseChildInAdultsBed,
|
||||
decreaseChildInAdultsBed,
|
||||
} = useGuestsRoomsStore((state) => ({
|
||||
isValidated: state.isValidated,
|
||||
updateChildAge: state.updateChildAge,
|
||||
updateChildBed: state.updateChildBed,
|
||||
increaseChildInAdultsBed: state.increaseChildInAdultsBed,
|
||||
decreaseChildInAdultsBed: state.decreaseChildInAdultsBed,
|
||||
}))
|
||||
|
||||
const ageList = Array.from(Array(13).keys()).map((age) => ({
|
||||
label: `${age}`,
|
||||
value: age,
|
||||
}))
|
||||
|
||||
function updateSelectedAge(age: number) {
|
||||
updateChildAge(age, roomIndex, index)
|
||||
setValue(`rooms.${roomIndex}.children.${index}.age`, age)
|
||||
const availableBedTypes = getAvailableBeds(age)
|
||||
updateSelectedBed(availableBedTypes[0].value)
|
||||
trigger("rooms")
|
||||
}
|
||||
|
||||
function updateSelectedBed(bed: number) {
|
||||
if (bed == BedTypeEnum.IN_ADULTS_BED) {
|
||||
increaseChildInAdultsBed(roomIndex)
|
||||
} else if (child.bed == BedTypeEnum.IN_ADULTS_BED) {
|
||||
decreaseChildInAdultsBed(roomIndex)
|
||||
}
|
||||
updateChildBed(bed, roomIndex, index)
|
||||
setValue(`rooms.${roomIndex}.children.${index}.bed`, bed)
|
||||
}
|
||||
|
||||
const allBedTypes: ChildBed[] = [
|
||||
{
|
||||
label: intl.formatMessage({ id: "In adults bed" }),
|
||||
value: BedTypeEnum.IN_ADULTS_BED,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: "In crib" }),
|
||||
value: BedTypeEnum.IN_CRIB,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: "In extra bed" }),
|
||||
value: BedTypeEnum.IN_EXTRA_BED,
|
||||
},
|
||||
]
|
||||
|
||||
function getAvailableBeds(age: number) {
|
||||
let availableBedTypes: ChildBed[] = []
|
||||
if (age <= 5 && (adults > childrenInAdultsBed || child.bed === 0)) {
|
||||
availableBedTypes.push(allBedTypes[0])
|
||||
}
|
||||
if (age < 3) {
|
||||
availableBedTypes.push(allBedTypes[1])
|
||||
}
|
||||
if (age > 2) {
|
||||
availableBedTypes.push(allBedTypes[2])
|
||||
}
|
||||
return availableBedTypes
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div key={index} className={styles.childInfoContainer}>
|
||||
<div>
|
||||
<Select
|
||||
required={true}
|
||||
items={ageList}
|
||||
label={ageLabel}
|
||||
aria-label={ageLabel}
|
||||
value={child.age}
|
||||
onSelect={(key) => {
|
||||
updateSelectedAge(key as number)
|
||||
}}
|
||||
name={`rooms.${roomIndex}.children.${index}.age`}
|
||||
placeholder={ageLabel}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{child.age !== -1 ? (
|
||||
<Select
|
||||
items={getAvailableBeds(child.age)}
|
||||
label={bedLabel}
|
||||
aria-label={bedLabel}
|
||||
value={child.bed}
|
||||
onSelect={(key) => {
|
||||
updateSelectedBed(key as number)
|
||||
}}
|
||||
name={`rooms.${roomIndex}.children.${index}.age`}
|
||||
placeholder={bedLabel}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{isValidated && child.age < 0 ? (
|
||||
<Caption color="red" className={styles.error}>
|
||||
<ErrorCircleIcon color="red" />
|
||||
{ageReqdErrMsg}
|
||||
</Caption>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.captionBold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.childInfoContainer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: 1fr 2fr;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
|
||||
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import Counter from "../Counter"
|
||||
import ChildInfoSelector from "./ChildInfoSelector"
|
||||
|
||||
import styles from "./child-selector.module.css"
|
||||
|
||||
import { BookingWidgetSchema } from "@/types/components/bookingWidget"
|
||||
import { ChildSelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||
|
||||
export default function ChildSelector({ roomIndex = 0 }: ChildSelectorProps) {
|
||||
const intl = useIntl()
|
||||
const childrenLabel = intl.formatMessage({ id: "Children" })
|
||||
const { setValue, trigger } = useFormContext<BookingWidgetSchema>()
|
||||
const children = useGuestsRoomsStore(
|
||||
(state) => state.rooms[roomIndex].children
|
||||
)
|
||||
const increaseChildren = useGuestsRoomsStore(
|
||||
(state) => state.increaseChildren
|
||||
)
|
||||
const decreaseChildren = useGuestsRoomsStore(
|
||||
(state) => state.decreaseChildren
|
||||
)
|
||||
|
||||
function increaseChildrenCount(roomIndex: number) {
|
||||
if (children.length < 5) {
|
||||
increaseChildren(roomIndex)
|
||||
setValue(`rooms.${roomIndex}.children.${children.length}`, {
|
||||
age: -1,
|
||||
bed: -1,
|
||||
})
|
||||
trigger("rooms")
|
||||
}
|
||||
}
|
||||
function decreaseChildrenCount(roomIndex: number) {
|
||||
if (children.length > 0) {
|
||||
const newChildrenList = decreaseChildren(roomIndex)
|
||||
setValue(`rooms.${roomIndex}.children`, newChildrenList)
|
||||
trigger("rooms")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={styles.container}>
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
{childrenLabel}
|
||||
</Caption>
|
||||
<Counter
|
||||
count={children.length}
|
||||
handleOnDecrease={() => {
|
||||
decreaseChildrenCount(roomIndex)
|
||||
}}
|
||||
handleOnIncrease={() => {
|
||||
increaseChildrenCount(roomIndex)
|
||||
}}
|
||||
disableDecrease={children.length == 0}
|
||||
disableIncrease={children.length == 5}
|
||||
/>
|
||||
</section>
|
||||
{children.map((child, index) => (
|
||||
<ChildInfoSelector
|
||||
roomIndex={roomIndex}
|
||||
index={index}
|
||||
child={child}
|
||||
key={"child_" + index}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.counterContainer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.counterBtn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.counterBtn:not([disabled]) {
|
||||
box-shadow: 0px 0px 8px 1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { MinusIcon, PlusIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import styles from "./counter.module.css"
|
||||
|
||||
import { CounterProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||
|
||||
export default function Counter({
|
||||
count,
|
||||
handleOnIncrease,
|
||||
handleOnDecrease,
|
||||
disableIncrease,
|
||||
disableDecrease,
|
||||
}: CounterProps) {
|
||||
return (
|
||||
<div className={styles.counterContainer}>
|
||||
<Button
|
||||
className={styles.counterBtn}
|
||||
intent="inverted"
|
||||
onClick={handleOnDecrease}
|
||||
size="small"
|
||||
theme="base"
|
||||
variant="icon"
|
||||
wrapping={true}
|
||||
disabled={disableDecrease}
|
||||
>
|
||||
<MinusIcon color="burgundy" />
|
||||
</Button>
|
||||
<Body color="textHighContrast" textAlign="center">
|
||||
{count}
|
||||
</Body>
|
||||
<Button
|
||||
className={styles.counterBtn}
|
||||
onClick={handleOnIncrease}
|
||||
intent="inverted"
|
||||
variant="icon"
|
||||
theme="base"
|
||||
wrapping={true}
|
||||
size="small"
|
||||
disabled={disableIncrease}
|
||||
>
|
||||
<PlusIcon color="burgundy" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
|
||||
|
||||
import { CloseLargeIcon, PlusCircleIcon, PlusIcon } from "../Icons"
|
||||
import Button from "../TempDesignSystem/Button"
|
||||
import Divider from "../TempDesignSystem/Divider"
|
||||
import Subtitle from "../TempDesignSystem/Text/Subtitle"
|
||||
import { Tooltip } from "../TempDesignSystem/Tooltip"
|
||||
import AdultSelector from "./AdultSelector"
|
||||
import ChildSelector from "./ChildSelector"
|
||||
|
||||
import styles from "./guests-rooms-picker.module.css"
|
||||
|
||||
import { BookingWidgetSchema } from "@/types/components/bookingWidget"
|
||||
import { GuestsRoomsPickerProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||
|
||||
export default function GuestsRoomsPicker({
|
||||
closePicker,
|
||||
}: GuestsRoomsPickerProps) {
|
||||
const intl = useIntl()
|
||||
const doneLabel = intl.formatMessage({ id: "Done" })
|
||||
const roomLabel = intl.formatMessage({ id: "Room" })
|
||||
const disabledBookingOptionsHeader = intl.formatMessage({
|
||||
id: "Disabled booking options header",
|
||||
})
|
||||
const disabledBookingOptionsText = intl.formatMessage({
|
||||
id: "Disabled booking options text",
|
||||
})
|
||||
const addRoomLabel = intl.formatMessage({ id: "Add Room" })
|
||||
|
||||
const { getFieldState } = useFormContext<BookingWidgetSchema>()
|
||||
|
||||
const rooms = useGuestsRoomsStore((state) => state.rooms)
|
||||
|
||||
// Not in MVP
|
||||
// const increaseRoom = useGuestsRoomsStore.use.increaseRoom()
|
||||
// const decreaseRoom = useGuestsRoomsStore.use.decreaseRoom()
|
||||
|
||||
return (
|
||||
<div className={styles.pickerContainer}>
|
||||
<header className={styles.header}>
|
||||
<button type="button" className={styles.close} onClick={closePicker}>
|
||||
<CloseLargeIcon />
|
||||
</button>
|
||||
</header>
|
||||
<div className={styles.contentContainer}>
|
||||
{rooms.map((room, index) => (
|
||||
<div className={styles.roomContainer} key={index}>
|
||||
<section className={styles.roomDetailsContainer}>
|
||||
<Subtitle type="two" className={styles.roomHeading}>
|
||||
{roomLabel} {index + 1}
|
||||
</Subtitle>
|
||||
<AdultSelector roomIndex={index} />
|
||||
<ChildSelector roomIndex={index} />
|
||||
</section>
|
||||
{/* Not in MVP
|
||||
{index > 0 ? (
|
||||
<Button intent="text" onClick={() => decreaseRoom(index)}>
|
||||
Remove Room
|
||||
</Button>
|
||||
) : null} */}
|
||||
<Divider color="primaryLightSubtle" />
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.addRoomMobileContainer}>
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="top"
|
||||
arrow="left"
|
||||
>
|
||||
{rooms.length < 4 ? (
|
||||
<Button
|
||||
intent="text"
|
||||
variant="icon"
|
||||
wrapping
|
||||
disabled
|
||||
theme="base"
|
||||
fullWidth
|
||||
>
|
||||
<PlusIcon />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<footer className={styles.footer}>
|
||||
<div className={styles.hideOnMobile}>
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="top"
|
||||
arrow="left"
|
||||
>
|
||||
{rooms.length < 4 ? (
|
||||
<Button
|
||||
intent="text"
|
||||
variant="icon"
|
||||
wrapping
|
||||
disabled
|
||||
theme="base"
|
||||
>
|
||||
<PlusCircleIcon />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Button
|
||||
onClick={closePicker}
|
||||
disabled={getFieldState("rooms").invalid}
|
||||
className={styles.hideOnMobile}
|
||||
intent="tertiary"
|
||||
theme="base"
|
||||
size="small"
|
||||
>
|
||||
{doneLabel}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={closePicker}
|
||||
disabled={getFieldState("rooms").invalid}
|
||||
className={styles.hideOnDesktop}
|
||||
intent="tertiary"
|
||||
theme="base"
|
||||
size="large"
|
||||
>
|
||||
{doneLabel}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
.container {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
&[data-isopen="true"] {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
.roomContainer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
.roomDetailsContainer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
}
|
||||
.hideWrapper {
|
||||
background-color: var(--Main-Grey-White);
|
||||
}
|
||||
.roomHeading {
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
.btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.body {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.footer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x1);
|
||||
grid-template-columns: auto;
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
.hideWrapper {
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
transition: top 300ms ease;
|
||||
z-index: 10002;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container[data-isopen="true"] .hideWrapper {
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.pickerContainer {
|
||||
--header-height: 72px;
|
||||
--sticky-button-height: 140px;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"content";
|
||||
grid-template-rows: var(--header-height) calc(100dvh - var(--header-height));
|
||||
position: relative;
|
||||
}
|
||||
.contentContainer {
|
||||
grid-area: content;
|
||||
overflow-y: scroll;
|
||||
scroll-snap-type: y mandatory;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: var(--Main-Grey-White);
|
||||
display: grid;
|
||||
grid-area: header;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-self: flex-end;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.roomContainer {
|
||||
padding: 0 var(--Spacing-x2);
|
||||
}
|
||||
.roomContainer:last-of-type {
|
||||
padding-bottom: calc(var(--sticky-button-height) + 20px);
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0) 7.5%,
|
||||
#ffffff 82.5%
|
||||
);
|
||||
padding: var(--Spacing-x1) var(--Spacing-x2) var(--Spacing-x7);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.footer .hideOnMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.addRoomMobileContainer {
|
||||
display: grid;
|
||||
width: 150px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: calc(var(--sticky-button-height) + 20px);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.hideWrapper {
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
|
||||
left: calc((var(--Spacing-x1) + var(--Spacing-x2)) * -1);
|
||||
max-width: calc(100vw - 20px);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
position: absolute;
|
||||
top: calc(100% + var(--Spacing-x2) + 1px + var(--Spacing-x4));
|
||||
width: 360px;
|
||||
max-height: calc(100dvh - 77px - var(--Spacing-x6));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer {
|
||||
grid-template-columns: auto auto;
|
||||
}
|
||||
|
||||
.footer .hideOnDesktop,
|
||||
.addRoomMobileContainer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
|
||||
|
||||
import { guestRoomsSchema } from "@/components/Forms/BookingWidget/schema"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import GuestsRoomsPicker from "./GuestsRoomsPicker"
|
||||
|
||||
import styles from "./guests-rooms-picker.module.css"
|
||||
|
||||
export default function GuestsRoomsPickerForm() {
|
||||
const intl = useIntl()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { rooms, adultCount, childCount, setIsValidated } = useGuestsRoomsStore(
|
||||
(state) => ({
|
||||
rooms: state.rooms,
|
||||
adultCount: state.adultCount,
|
||||
childCount: state.childCount,
|
||||
setIsValidated: state.setIsValidated,
|
||||
})
|
||||
)
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
function handleOnClick() {
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen)
|
||||
}
|
||||
const closePicker = useCallback(() => {
|
||||
const guestRoomsValidData = guestRoomsSchema.safeParse(rooms)
|
||||
if (guestRoomsValidData.success) {
|
||||
setIsOpen(false)
|
||||
setIsValidated(false)
|
||||
} else {
|
||||
setIsValidated(true)
|
||||
}
|
||||
}, [rooms, setIsValidated, setIsOpen])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(evt: Event) {
|
||||
const target = evt.target as HTMLElement
|
||||
if (ref.current && target && !ref.current.contains(target)) {
|
||||
closePicker()
|
||||
}
|
||||
}
|
||||
document.addEventListener("click", handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickOutside)
|
||||
}
|
||||
}, [closePicker])
|
||||
|
||||
return (
|
||||
<div className={styles.container} data-isopen={isOpen} ref={ref}>
|
||||
<button className={styles.btn} onClick={handleOnClick} type="button">
|
||||
<Body className={styles.body}>
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.rooms" },
|
||||
{ totalRooms: rooms.length }
|
||||
)}
|
||||
{", "}
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: adultCount }
|
||||
)}
|
||||
{childCount > 0
|
||||
? ", " +
|
||||
intl.formatMessage(
|
||||
{ id: "booking.children" },
|
||||
{ totalChildren: childCount }
|
||||
)
|
||||
: null}
|
||||
</Body>
|
||||
</button>
|
||||
<div aria-modal className={styles.hideWrapper} role="dialog">
|
||||
<GuestsRoomsPicker closePicker={closePicker} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -32,8 +32,8 @@
|
||||
}
|
||||
|
||||
.ecoLabel {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: var(--Spacing-x-one-and-half);
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 3 / 4;
|
||||
+14
-5
@@ -24,20 +24,26 @@ export default function Contact({ hotel }: ContactProps) {
|
||||
<span className={styles.heading}>
|
||||
{intl.formatMessage({ id: "Address" })}
|
||||
</span>
|
||||
<span>{hotel.address.streetAddress}</span>
|
||||
<span>{hotel.address.city}</span>
|
||||
<span>
|
||||
{`${hotel.address.streetAddress}, ${hotel.address.city}`}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.heading}>
|
||||
{intl.formatMessage({ id: "Driving directions" })}
|
||||
</span>
|
||||
<Link href="#">{intl.formatMessage({ id: "Google Maps" })}</Link>
|
||||
<Link href="#" color="peach80">
|
||||
Google Maps
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.heading}>
|
||||
{intl.formatMessage({ id: "Email" })}
|
||||
</span>
|
||||
<Link href={`mailto:${hotel.contactInformation.email}`}>
|
||||
<Link
|
||||
href={`mailto:${hotel.contactInformation.email}`}
|
||||
color="peach80"
|
||||
>
|
||||
{hotel.contactInformation.email}
|
||||
</Link>
|
||||
</li>
|
||||
@@ -45,7 +51,10 @@ export default function Contact({ hotel }: ContactProps) {
|
||||
<span className={styles.heading}>
|
||||
{intl.formatMessage({ id: "Contact us" })}
|
||||
</span>
|
||||
<Link href={`tel:${hotel.contactInformation.phoneNumber}`}>
|
||||
<Link
|
||||
href={`tel:${hotel.contactInformation.phoneNumber}`}
|
||||
color="peach80"
|
||||
>
|
||||
{hotel.contactInformation.phoneNumber}
|
||||
</Link>
|
||||
</li>
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client"
|
||||
|
||||
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"
|
||||
|
||||
@@ -16,8 +19,14 @@ import { bedTypeEnum } from "@/types/enums/bedType"
|
||||
|
||||
export default function BedType() {
|
||||
const intl = useIntl()
|
||||
const bedType = useEnterDetailsStore((state) => state.data.bedType)
|
||||
|
||||
const methods = useForm<BedTypeSchema>({
|
||||
defaultValues: bedType
|
||||
? {
|
||||
bedType,
|
||||
}
|
||||
: undefined,
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(bedTypeSchema),
|
||||
@@ -28,15 +37,32 @@ export default function BedType() {
|
||||
{ id: "<b>Included</b> (based on availability)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)
|
||||
const completeStep = useEnterDetailsStore((state) => state.completeStep)
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(values: BedTypeSchema) => {
|
||||
completeStep(values)
|
||||
},
|
||||
[completeStep]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (methods.formState.isSubmitting) {
|
||||
return
|
||||
}
|
||||
|
||||
const subscription = methods.watch(() => methods.handleSubmit(onSubmit)())
|
||||
return () => subscription.unsubscribe()
|
||||
}, [methods, onSubmit])
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form className={styles.form}>
|
||||
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
<RadioCard
|
||||
Icon={KingBedIcon}
|
||||
iconWidth={46}
|
||||
id={bedTypeEnum.KING}
|
||||
name="bed"
|
||||
name="bedType"
|
||||
subtitle={intl.formatMessage(
|
||||
{ id: "{width} cm × {length} cm" },
|
||||
{
|
||||
@@ -52,7 +78,7 @@ export default function BedType() {
|
||||
Icon={KingBedIcon}
|
||||
iconWidth={46}
|
||||
id={bedTypeEnum.QUEEN}
|
||||
name="bed"
|
||||
name="bedType"
|
||||
subtitle={intl.formatMessage(
|
||||
{ id: "{width} cm × {length} cm" },
|
||||
{
|
||||
|
||||
@@ -3,5 +3,5 @@ import { z } from "zod"
|
||||
import { bedTypeEnum } from "@/types/enums/bedType"
|
||||
|
||||
export const bedTypeSchema = z.object({
|
||||
bed: z.nativeEnum(bedTypeEnum),
|
||||
bedType: z.nativeEnum(bedTypeEnum),
|
||||
})
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client"
|
||||
|
||||
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 { BreakfastIcon, NoBreakfastIcon } from "@/components/Icons"
|
||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||
|
||||
@@ -17,16 +20,36 @@ import { breakfastEnum } from "@/types/enums/breakfast"
|
||||
export default function Breakfast() {
|
||||
const intl = useIntl()
|
||||
|
||||
const breakfast = useEnterDetailsStore((state) => state.data.breakfast)
|
||||
|
||||
const methods = useForm<BreakfastSchema>({
|
||||
defaultValues: breakfast ? { breakfast } : undefined,
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(breakfastSchema),
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
const completeStep = useEnterDetailsStore((state) => state.completeStep)
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(values: BreakfastSchema) => {
|
||||
completeStep(values)
|
||||
},
|
||||
[completeStep]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (methods.formState.isSubmitting) {
|
||||
return
|
||||
}
|
||||
const subscription = methods.watch(() => methods.handleSubmit(onSubmit)())
|
||||
return () => subscription.unsubscribe()
|
||||
}, [methods, onSubmit])
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form className={styles.form}>
|
||||
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
<RadioCard
|
||||
Icon={BreakfastIcon}
|
||||
id={breakfastEnum.BREAKFAST}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { privacyPolicy } from "@/constants/currentWebHrefs"
|
||||
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import CheckboxCard from "@/components/TempDesignSystem/Form/ChoiceCard/Checkbox"
|
||||
import DateSelect from "@/components/TempDesignSystem/Form/Date"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./signup.module.css"
|
||||
|
||||
export default function Signup({ name }: { name: string }) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
|
||||
const [isJoinChecked, setIsJoinChecked] = useState(false)
|
||||
|
||||
const joinValue = useWatch({ name })
|
||||
|
||||
useEffect(() => {
|
||||
// In order to avoid hydration errors the state needs to be set as side effect,
|
||||
// since the join value can come from search params
|
||||
setIsJoinChecked(joinValue)
|
||||
}, [joinValue])
|
||||
|
||||
const list = [
|
||||
{ title: intl.formatMessage({ id: "Earn bonus nights & points" }) },
|
||||
{ title: intl.formatMessage({ id: "Get member benefits & offers" }) },
|
||||
{ title: intl.formatMessage({ id: "Join at no cost" }) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<CheckboxCard
|
||||
highlightSubtitle
|
||||
list={list}
|
||||
name={name}
|
||||
subtitle={intl.formatMessage(
|
||||
{
|
||||
id: "{difference}{amount} {currency}",
|
||||
},
|
||||
{
|
||||
amount: "491",
|
||||
currency: "SEK",
|
||||
difference: "-",
|
||||
}
|
||||
)}
|
||||
title={intl.formatMessage({ id: "Join Scandic Friends" })}
|
||||
/>
|
||||
{isJoinChecked ? (
|
||||
<div className={styles.additionalFormData}>
|
||||
<div className={styles.dateField}>
|
||||
<header>
|
||||
<Caption type="bold">
|
||||
{intl.formatMessage({ id: "Birth date" })} *
|
||||
</Caption>
|
||||
</header>
|
||||
<DateSelect
|
||||
name="dateOfBirth"
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<Input
|
||||
name="zipCode"
|
||||
label={intl.formatMessage({ id: "Zip code" })}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox name="termsAccepted" registerOptions={{ required: true }}>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with",
|
||||
})}{" "}
|
||||
<Link
|
||||
variant="underscored"
|
||||
color="peach80"
|
||||
target="_blank"
|
||||
href={privacyPolicy[lang]}
|
||||
>
|
||||
{intl.formatMessage({ id: "Scandic's Privacy Policy." })}
|
||||
</Link>
|
||||
</Body>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.container {
|
||||
display: grid;
|
||||
grid-column: 1/-1;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.additionalFormData {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.dateField {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
"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 CheckboxCard from "@/components/TempDesignSystem/Form/ChoiceCard/Checkbox"
|
||||
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 { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
|
||||
import { detailsSchema, signedInDetailsSchema } from "./schema"
|
||||
import Signup from "./Signup"
|
||||
|
||||
import styles from "./details.module.css"
|
||||
|
||||
@@ -19,22 +24,33 @@ import type {
|
||||
DetailsSchema,
|
||||
} from "@/types/components/enterDetails/details"
|
||||
|
||||
const formID = "enter-details"
|
||||
export default function Details({ user }: DetailsProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const list = [
|
||||
{ title: intl.formatMessage({ id: "Earn bonus nights & points" }) },
|
||||
{ title: intl.formatMessage({ id: "Get member benefits & offers" }) },
|
||||
{ title: intl.formatMessage({ id: "Join at no cost" }) },
|
||||
]
|
||||
const initialData = useEnterDetailsStore((state) => ({
|
||||
countryCode: state.data.countryCode,
|
||||
email: state.data.email,
|
||||
firstName: state.data.firstName,
|
||||
lastName: state.data.lastName,
|
||||
phoneNumber: state.data.phoneNumber,
|
||||
join: state.data.join,
|
||||
dateOfBirth: state.data.dateOfBirth,
|
||||
zipCode: state.data.zipCode,
|
||||
termsAccepted: state.data.termsAccepted,
|
||||
}))
|
||||
|
||||
const methods = useForm<DetailsSchema>({
|
||||
defaultValues: {
|
||||
countryCode: user?.address?.countryCode ?? "",
|
||||
email: user?.email ?? "",
|
||||
firstname: user?.firstName ?? "",
|
||||
lastname: user?.lastName ?? "",
|
||||
phoneNumber: user?.phoneNumber ?? "",
|
||||
countryCode: user?.address?.countryCode ?? initialData.countryCode,
|
||||
email: user?.email ?? initialData.email,
|
||||
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,
|
||||
},
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
@@ -42,6 +58,44 @@ export default function Details({ user }: DetailsProps) {
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
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}>
|
||||
@@ -50,16 +104,20 @@ export default function Details({ user }: DetailsProps) {
|
||||
{intl.formatMessage({ id: "Guest information" })}
|
||||
</Body>
|
||||
</header>
|
||||
<form className={styles.form}>
|
||||
<form
|
||||
className={styles.form}
|
||||
id={formID}
|
||||
onSubmit={methods.handleSubmit(onSubmit)}
|
||||
>
|
||||
<Input
|
||||
label={intl.formatMessage({ id: "Firstname" })}
|
||||
name="firstname"
|
||||
label={intl.formatMessage({ id: "First name" })}
|
||||
name="firstName"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<Input
|
||||
label={intl.formatMessage({ id: "Lastname" })}
|
||||
name="lastname"
|
||||
label={intl.formatMessage({ id: "Last name" })}
|
||||
name="lastName"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
@@ -84,31 +142,16 @@ export default function Details({ user }: DetailsProps) {
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
{user ? null : <Signup name="join" />}
|
||||
</form>
|
||||
<footer className={styles.footer}>
|
||||
{user ? null : (
|
||||
<CheckboxCard
|
||||
highlightSubtitle
|
||||
list={list}
|
||||
name="join"
|
||||
subtitle={intl.formatMessage(
|
||||
{
|
||||
id: "{difference}{amount} {currency}",
|
||||
},
|
||||
{
|
||||
amount: "491",
|
||||
currency: "SEK",
|
||||
difference: "-",
|
||||
}
|
||||
)}
|
||||
title={intl.formatMessage({ id: "Join Scandic Friends" })}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
disabled={!methods.formState.isValid}
|
||||
form={formID}
|
||||
intent="secondary"
|
||||
size="small"
|
||||
theme="base"
|
||||
type="submit"
|
||||
>
|
||||
{intl.formatMessage({ id: "Proceed to payment method" })}
|
||||
</Button>
|
||||
|
||||
@@ -2,18 +2,49 @@ import { z } from "zod"
|
||||
|
||||
import { phoneValidator } from "@/utils/phoneValidator"
|
||||
|
||||
export const detailsSchema = z.object({
|
||||
export const baseDetailsSchema = z.object({
|
||||
countryCode: z.string(),
|
||||
email: z.string().email(),
|
||||
firstname: z.string(),
|
||||
lastname: z.string(),
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
phoneNumber: phoneValidator(),
|
||||
})
|
||||
|
||||
export const notJoinDetailsSchema = baseDetailsSchema.merge(
|
||||
z.object({
|
||||
join: z.literal(false),
|
||||
zipCode: z.string().optional(),
|
||||
dateOfBirth: z.string().optional(),
|
||||
termsAccepted: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
|
||||
export const joinDetailsSchema = baseDetailsSchema.merge(
|
||||
z.object({
|
||||
join: z.literal(true),
|
||||
zipCode: z.string().min(1, { message: "Zip code is required" }),
|
||||
dateOfBirth: z.string(),
|
||||
termsAccepted: z.literal(true, {
|
||||
errorMap: (err, ctx) => {
|
||||
switch (err.code) {
|
||||
case "invalid_literal":
|
||||
return { message: "You must accept the terms and conditions" }
|
||||
}
|
||||
return { message: ctx.defaultError }
|
||||
},
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
export const detailsSchema = z.discriminatedUnion("join", [
|
||||
notJoinDetailsSchema,
|
||||
joinDetailsSchema,
|
||||
])
|
||||
|
||||
export const signedInDetailsSchema = z.object({
|
||||
countryCode: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
firstname: z.string().optional(),
|
||||
lastname: z.string().optional(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
phoneNumber: phoneValidator().optional(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect } from "react"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
export default function HistoryStateManager() {
|
||||
const setCurrentStep = useEnterDetailsStore((state) => state.setCurrentStep)
|
||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
||||
|
||||
const handleBackButton = useCallback(
|
||||
(event: PopStateEvent) => {
|
||||
if (event.state.step) {
|
||||
setCurrentStep(event.state.step)
|
||||
}
|
||||
},
|
||||
[setCurrentStep]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("popstate", handleBackButton)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("popstate", handleBackButton)
|
||||
}
|
||||
}, [handleBackButton])
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.history.state.step) {
|
||||
window.history.replaceState(
|
||||
{ step: currentStep },
|
||||
"",
|
||||
document.location.href
|
||||
)
|
||||
}
|
||||
}, [currentStep])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
import { PropsWithChildren, useRef } from "react"
|
||||
|
||||
import {
|
||||
EnterDetailsContext,
|
||||
type EnterDetailsStore,
|
||||
initEditDetailsState,
|
||||
} from "@/stores/enter-details"
|
||||
|
||||
import { StepEnum } from "@/types/components/enterDetails/step"
|
||||
|
||||
export default function EnterDetailsProvider({
|
||||
step,
|
||||
children,
|
||||
}: PropsWithChildren<{ step: StepEnum }>) {
|
||||
const initialStore = useRef<EnterDetailsStore>()
|
||||
if (!initialStore.current) {
|
||||
initialStore.current = initEditDetailsState(step)
|
||||
}
|
||||
|
||||
return (
|
||||
<EnterDetailsContext.Provider value={initialStore.current}>
|
||||
{children}
|
||||
</EnterDetailsContext.Provider>
|
||||
)
|
||||
}
|
||||
+35
-37
@@ -1,9 +1,11 @@
|
||||
"use client"
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
@@ -13,46 +15,36 @@ import { SectionAccordionProps } from "@/types/components/hotelReservation/selec
|
||||
|
||||
export default function SectionAccordion({
|
||||
header,
|
||||
isOpen,
|
||||
isCompleted,
|
||||
label,
|
||||
path,
|
||||
step,
|
||||
children,
|
||||
}: React.PropsWithChildren<SectionAccordionProps>) {
|
||||
const intl = useIntl()
|
||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const circleRef = useRef<HTMLDivElement>(null)
|
||||
const isValid = useEnterDetailsStore((state) => state.isValid[step])
|
||||
const navigate = useEnterDetailsStore((state) => state.navigate)
|
||||
|
||||
useEffect(() => {
|
||||
const content = contentRef.current
|
||||
const circle = circleRef.current
|
||||
if (content) {
|
||||
if (isOpen) {
|
||||
content.style.maxHeight = `${content.scrollHeight}px`
|
||||
} else {
|
||||
content.style.maxHeight = "0"
|
||||
}
|
||||
}
|
||||
// We need to set the state on mount because of hydration errors
|
||||
setIsComplete(isValid)
|
||||
}, [isValid])
|
||||
|
||||
if (circle) {
|
||||
if (isOpen) {
|
||||
circle.style.backgroundColor = `var(--UI-Text-Placeholder);`
|
||||
} else {
|
||||
circle.style.backgroundColor = `var(--Base-Surface-Subtle-Hover);`
|
||||
}
|
||||
}
|
||||
}, [isOpen])
|
||||
useEffect(() => {
|
||||
setIsOpen(currentStep === step)
|
||||
}, [currentStep, step])
|
||||
|
||||
function onModify() {
|
||||
navigate(step)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.wrapper} data-open={isOpen}>
|
||||
<section className={styles.wrapper} data-open={isOpen} data-step={step}>
|
||||
<div className={styles.iconWrapper}>
|
||||
<div
|
||||
className={styles.circle}
|
||||
data-checked={isCompleted}
|
||||
ref={circleRef}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<div className={styles.circle} data-checked={isComplete}>
|
||||
{isComplete ? (
|
||||
<CheckIcon color="white" height="16" width="16" />
|
||||
) : null}
|
||||
</div>
|
||||
@@ -63,6 +55,7 @@ export default function SectionAccordion({
|
||||
<Footnote
|
||||
asChild
|
||||
textTransform="uppercase"
|
||||
type="label"
|
||||
color="uiTextPlaceholder"
|
||||
>
|
||||
<h2>{header}</h2>
|
||||
@@ -75,16 +68,21 @@ export default function SectionAccordion({
|
||||
{label}
|
||||
</Subtitle>
|
||||
</div>
|
||||
{isCompleted && !isOpen && (
|
||||
<Link href={path} color="burgundy" variant="icon">
|
||||
{isComplete && !isOpen && (
|
||||
<Button
|
||||
onClick={onModify}
|
||||
theme="base"
|
||||
size="small"
|
||||
variant="icon"
|
||||
intent="text"
|
||||
wrapping
|
||||
>
|
||||
{intl.formatMessage({ id: "Modify" })}{" "}
|
||||
<ChevronDownIcon color="burgundy" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
<div className={styles.content} ref={contentRef}>
|
||||
{children}
|
||||
</div>
|
||||
<div className={styles.content}>{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
+11
-4
@@ -17,13 +17,18 @@
|
||||
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
}
|
||||
|
||||
.wrapper:last-child .main {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
transition: 0.4s ease-out;
|
||||
grid-template-rows: 2em 0fr;
|
||||
}
|
||||
|
||||
.headerContainer {
|
||||
@@ -66,8 +71,10 @@
|
||||
background-color: var(--Base-Surface-Subtle-Hover);
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] .main {
|
||||
grid-template-rows: 2em 1fr;
|
||||
}
|
||||
|
||||
.content {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.4s ease-out;
|
||||
max-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.spacing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import Contact from "@/components/HotelReservation/Contact"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import styles from "./enterDetailsSidePeek.module.css"
|
||||
|
||||
import {
|
||||
SidePeekEnum,
|
||||
SidePeekProps,
|
||||
} from "@/types/components/enterDetails/sidePeek"
|
||||
|
||||
export default function EnterDetailsSidePeek({ hotel }: SidePeekProps) {
|
||||
const activeSidePeek = useEnterDetailsStore((state) => state.activeSidePeek)
|
||||
const close = useEnterDetailsStore((state) => state.closeSidePeek)
|
||||
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<SidePeek
|
||||
contentKey={SidePeekEnum.hotelDetails}
|
||||
title={intl.formatMessage({ id: "About the hotel" })}
|
||||
isOpen={activeSidePeek === SidePeekEnum.hotelDetails}
|
||||
handleClose={close}
|
||||
>
|
||||
<article className={styles.spacing}>
|
||||
<Contact hotel={hotel} />
|
||||
<Divider />
|
||||
<section className={styles.spacing}>
|
||||
<Body>{hotel.hotelContent.texts.descriptions.medium}</Body>
|
||||
{hotel.hotelContent.texts.facilityInformation
|
||||
.split(/[\n\r]/g)
|
||||
.filter((p) => p)
|
||||
.map((paragraph, idx) => (
|
||||
<Body key={`facilityInfo-${idx}`}>{paragraph}</Body>
|
||||
))}
|
||||
</section>
|
||||
</article>
|
||||
</SidePeek>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/enterDetails/sidePeek"
|
||||
|
||||
export default function ToggleSidePeek() {
|
||||
const intl = useIntl()
|
||||
const openSidePeek = useEnterDetailsStore((state) => state.openSidePeek)
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
openSidePeek(SidePeekEnum.hotelDetails)
|
||||
}}
|
||||
theme="base"
|
||||
size="small"
|
||||
variant="icon"
|
||||
intent="text"
|
||||
wrapping
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}{" "}
|
||||
<ChevronRightSmallIcon
|
||||
color="baseButtonTextOnFillNormal"
|
||||
height={20}
|
||||
width={20}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import { ArrowRightIcon, ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import { ArrowRightIcon } 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 ToggleSidePeek from "./ToggleSidePeek"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
// TEMP
|
||||
@@ -21,7 +22,6 @@ const rooms = [
|
||||
export default async function Summary() {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
|
||||
const fromDate = dt().locale(lang).format("ddd, D MMM")
|
||||
const toDate = dt().add(1, "day").locale(lang).format("ddd, D MMM")
|
||||
const diff = dt(toDate).diff(fromDate, "days")
|
||||
@@ -75,19 +75,8 @@ export default async function Summary() {
|
||||
<ArrowRightIcon color="uiTextMediumContrast" height={15} width={15} />
|
||||
{toDate}
|
||||
</Body>
|
||||
<Link
|
||||
className={styles.link}
|
||||
color="baseButtonTextOnFillNormal"
|
||||
href="#"
|
||||
variant="icon"
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}
|
||||
<ChevronRightSmallIcon
|
||||
color="baseButtonTextOnFillNormal"
|
||||
height={20}
|
||||
width={20}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<ToggleSidePeek />
|
||||
</header>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.addOns}>
|
||||
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import ChevronRightSmallIcon from "@/components/Icons/ChevronRightSmall"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
|
||||
import styles from "./hotelDetailSidePeek.module.css"
|
||||
|
||||
export default function HotelDetailSidePeek() {
|
||||
const intl = useIntl()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
function toggleSidePeek() {
|
||||
setIsOpen(!isOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.buttons}>
|
||||
<Button
|
||||
variant="icon"
|
||||
theme="base"
|
||||
intent="text"
|
||||
wrapping
|
||||
onClick={toggleSidePeek}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: "See hotel details",
|
||||
})}
|
||||
<ChevronRightSmallIcon aria-hidden="true" color="burgundy" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="icon"
|
||||
theme="base"
|
||||
intent="text"
|
||||
wrapping
|
||||
onClick={toggleSidePeek}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: "Show all amenities",
|
||||
})}
|
||||
<ChevronRightSmallIcon aria-hidden="true" color="burgundy" />
|
||||
</Button>
|
||||
</div>
|
||||
<SidePeek
|
||||
contentKey="hotel-detail-side-peek"
|
||||
title="Hotel Details"
|
||||
isOpen={isOpen}
|
||||
handleClose={() => setIsOpen(false)}
|
||||
>
|
||||
<div>TBD</div>
|
||||
</SidePeek>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import HotelDetailSidePeek from "./HotelDetailSidePeek"
|
||||
|
||||
import styles from "./hotelSelectionHeader.module.css"
|
||||
|
||||
import { HotelSelectionHeaderProps } from "@/types/components/hotelReservation/selectRate/hotelSelectionHeader"
|
||||
@@ -46,7 +44,6 @@ export default function HotelSelectionHeader({
|
||||
<Body color="textHighContrast">
|
||||
{hotel.hotelContent.texts.descriptions.short}
|
||||
</Body>
|
||||
<HotelDetailSidePeek />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -11,7 +11,7 @@ import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import Contact from "./Contact"
|
||||
import Contact from "../Contact"
|
||||
|
||||
import styles from "./readMore.module.css"
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import Image from "next/image"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
import { PAYMENT_METHOD_ICONS } from "@/constants/booking"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import { PaymentOptionProps } from "./paymentOption"
|
||||
|
||||
import styles from "./paymentOption.module.css"
|
||||
|
||||
export default function PaymentOption({
|
||||
name,
|
||||
value,
|
||||
label,
|
||||
}: PaymentOptionProps) {
|
||||
const { register } = useFormContext()
|
||||
return (
|
||||
<label key={value} className={styles.paymentOption} htmlFor={value}>
|
||||
<div className={styles.titleContainer}>
|
||||
<input
|
||||
aria-hidden
|
||||
hidden
|
||||
type="radio"
|
||||
id={value}
|
||||
value={value}
|
||||
{...register(name)}
|
||||
/>
|
||||
<span className={styles.radio} />
|
||||
<Body asChild>
|
||||
<label htmlFor={value}>{label}</label>
|
||||
</Body>
|
||||
</div>
|
||||
<Image
|
||||
className={styles.paymentOptionIcon}
|
||||
src={PAYMENT_METHOD_ICONS[value]}
|
||||
alt={label}
|
||||
width={48}
|
||||
height={32}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
.paymentOption {
|
||||
position: relative;
|
||||
background-color: var(--UI-Input-Controls-Surface-Normal);
|
||||
padding: var(--Spacing-x3);
|
||||
border: 1px solid var(--Base-Border-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--Spacing-x2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.paymentOption .radio {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--Base-Border-Normal);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.paymentOption input:checked + .radio {
|
||||
border: 8px solid var(--UI-Input-Controls-Fill-Selected);
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.paymentOptionIcon {
|
||||
position: absolute;
|
||||
right: var(--Spacing-x3);
|
||||
top: calc(50% - 16px);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { RegisterOptions } from "react-hook-form"
|
||||
|
||||
import { PaymentMethodEnum } from "@/constants/booking"
|
||||
|
||||
export interface PaymentOptionProps {
|
||||
name: string
|
||||
value: PaymentMethodEnum
|
||||
label: string
|
||||
registerOptions?: RegisterOptions
|
||||
}
|
||||
@@ -1,21 +1,36 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
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"
|
||||
|
||||
import {
|
||||
BOOKING_CONFIRMATION_NUMBER,
|
||||
BookingStatusEnum,
|
||||
PAYMENT_METHOD_TITLES,
|
||||
PaymentMethodEnum,
|
||||
} from "@/constants/booking"
|
||||
import {
|
||||
bookingTermsAndConditions,
|
||||
privacyPolicy,
|
||||
} from "@/constants/currentWebHrefs"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Checkbox from "@/components/TempDesignSystem/Checkbox"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import PaymentOption from "./PaymentOption"
|
||||
import { PaymentFormData, paymentSchema } from "./schema"
|
||||
|
||||
import styles from "./payment.module.css"
|
||||
|
||||
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
@@ -28,17 +43,21 @@ export default function Payment({ hotel }: PaymentProps) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>("")
|
||||
|
||||
const methods = useForm<PaymentFormData>({
|
||||
defaultValues: {
|
||||
paymentMethod: PaymentMethodEnum.card,
|
||||
smsConfirmation: false,
|
||||
termsAndConditions: false,
|
||||
},
|
||||
mode: "all",
|
||||
reValidateMode: "onChange",
|
||||
resolver: zodResolver(paymentSchema),
|
||||
})
|
||||
|
||||
const initiateBooking = trpc.booking.booking.create.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result?.confirmationNumber) {
|
||||
// Planet doesn't support query params so we have to store values in session storage
|
||||
sessionStorage.setItem(
|
||||
BOOKING_CONFIRMATION_NUMBER,
|
||||
result.confirmationNumber
|
||||
)
|
||||
|
||||
setConfirmationNumber(result.confirmationNumber)
|
||||
} else {
|
||||
// TODO: add proper error message
|
||||
@@ -60,12 +79,14 @@ export default function Payment({ hotel }: PaymentProps) {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingStatus?.data?.paymentUrl) {
|
||||
if (confirmationNumber && bookingStatus?.data?.paymentUrl) {
|
||||
// Planet doesn't support query params so we have to store values in session storage
|
||||
sessionStorage.setItem(BOOKING_CONFIRMATION_NUMBER, confirmationNumber)
|
||||
router.push(bookingStatus.data.paymentUrl)
|
||||
}
|
||||
}, [bookingStatus, router])
|
||||
}, [confirmationNumber, bookingStatus, router])
|
||||
|
||||
function handleSubmit() {
|
||||
function handleSubmit(data: PaymentFormData) {
|
||||
initiateBooking.mutate({
|
||||
hotelId: hotel.operaId,
|
||||
checkInDate: "2024-12-10",
|
||||
@@ -91,11 +112,11 @@ export default function Payment({ hotel }: PaymentProps) {
|
||||
petFriendly: true,
|
||||
accessibility: true,
|
||||
},
|
||||
smsConfirmationRequested: true,
|
||||
smsConfirmationRequested: data.smsConfirmation,
|
||||
},
|
||||
],
|
||||
payment: {
|
||||
paymentMethod: selectedPaymentMethod,
|
||||
paymentMethod: data.paymentMethod,
|
||||
cardHolder: {
|
||||
email: "test.user@scandichotels.com",
|
||||
name: "Test User",
|
||||
@@ -117,45 +138,80 @@ export default function Payment({ hotel }: PaymentProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div className={styles.paymentItemContainer}>
|
||||
<button
|
||||
className={styles.paymentItem}
|
||||
onClick={() => setSelectedPaymentMethod("card")}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="payment-method"
|
||||
id="card"
|
||||
value="card"
|
||||
defaultChecked={selectedPaymentMethod === "card"}
|
||||
/>
|
||||
<label htmlFor="card">card</label>
|
||||
</button>
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.paymentContainer}
|
||||
onSubmit={methods.handleSubmit(handleSubmit)}
|
||||
>
|
||||
<div className={styles.paymentOptionContainer}>
|
||||
<PaymentOption
|
||||
name="paymentMethod"
|
||||
value={PaymentMethodEnum.card}
|
||||
label={intl.formatMessage({ id: "Credit card" })}
|
||||
/>
|
||||
{hotel.merchantInformationData.alternatePaymentOptions.map(
|
||||
(paymentOption) => (
|
||||
<button
|
||||
key={paymentOption}
|
||||
className={styles.paymentItem}
|
||||
onClick={() => setSelectedPaymentMethod(paymentOption)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="payment-method"
|
||||
id={paymentOption}
|
||||
value={paymentOption}
|
||||
defaultChecked={selectedPaymentMethod === paymentOption}
|
||||
/>
|
||||
<label htmlFor={paymentOption}>{paymentOption}</label>
|
||||
</button>
|
||||
(paymentMethod) => (
|
||||
<PaymentOption
|
||||
key={paymentMethod}
|
||||
name="paymentMethod"
|
||||
value={paymentMethod as PaymentMethodEnum}
|
||||
label={
|
||||
PAYMENT_METHOD_TITLES[paymentMethod as PaymentMethodEnum]
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button disabled={!selectedPaymentMethod} onClick={handleSubmit}>
|
||||
{intl.formatMessage({ id: "Complete booking & go to payment" })}
|
||||
</Button>
|
||||
</div>
|
||||
<Checkbox name="smsConfirmation">
|
||||
<Caption>
|
||||
{intl.formatMessage({
|
||||
id: "I would like to get my booking confirmation via sms",
|
||||
})}
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
|
||||
<AriaLabel className={styles.terms}>
|
||||
<Checkbox name="termsAndConditions" />
|
||||
<Caption>
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{
|
||||
id: "booking.terms",
|
||||
},
|
||||
{
|
||||
termsLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
variant="underscored"
|
||||
href={bookingTermsAndConditions[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
privacyLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
variant="underscored"
|
||||
href={privacyPolicy[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</AriaLabel>
|
||||
<Button
|
||||
type="submit"
|
||||
className={styles.submitButton}
|
||||
disabled={
|
||||
!methods.formState.isValid || methods.formState.isSubmitting
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({ id: "Complete booking & go to payment" })}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
.paymentItemContainer {
|
||||
max-width: 480px;
|
||||
.paymentContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
padding-bottom: var(--Spacing-x4);
|
||||
gap: var(--Spacing-x3);
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.paymentItem {
|
||||
background-color: var(--Base-Background-Normal);
|
||||
padding: var(--Spacing-x3);
|
||||
border: 1px solid var(--Base-Border-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
.paymentOptionContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x2);
|
||||
cursor: pointer;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.paymentContainer .link {
|
||||
font-weight: 500;
|
||||
font-size: var(--Typography-Caption-Regular-fontSize);
|
||||
}
|
||||
|
||||
.terms {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { PaymentMethodEnum } from "@/constants/booking"
|
||||
|
||||
export const paymentSchema = z.object({
|
||||
paymentMethod: z.nativeEnum(PaymentMethodEnum),
|
||||
smsConfirmation: z.boolean(),
|
||||
termsAndConditions: z.boolean().refine((value) => value === true, {
|
||||
message: "You must accept the terms and conditions",
|
||||
}),
|
||||
})
|
||||
|
||||
export interface PaymentFormData extends z.output<typeof paymentSchema> {}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { Button, Dialog, OverlayArrow, Popover } from "react-aria-components"
|
||||
|
||||
import { CloseIcon } from "@/components/Icons"
|
||||
|
||||
import styles from "./popover.module.css"
|
||||
|
||||
import { PricePopoverProps } from "@/types/components/hotelReservation/selectRate/pricePopover"
|
||||
|
||||
export default function PricePopover({
|
||||
children,
|
||||
...props
|
||||
}: PricePopoverProps) {
|
||||
return (
|
||||
<Popover {...props}>
|
||||
<OverlayArrow className={styles.arrow}>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
style={{ display: "block", transform: "rotate(180deg)" }}
|
||||
>
|
||||
<path d="M0 0L6 6L12 0" fill="white" />
|
||||
</svg>
|
||||
</OverlayArrow>
|
||||
<Dialog>
|
||||
<Button
|
||||
onPress={() => props.onOpenChange?.(false)}
|
||||
className={styles.closeButton}
|
||||
>
|
||||
<CloseIcon className={styles.closeIcon} />
|
||||
</Button>
|
||||
{children}
|
||||
</Dialog>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
.arrow {
|
||||
top: -6px;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./priceList.module.css"
|
||||
|
||||
import { PriceListProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
||||
|
||||
export default function PriceList({
|
||||
publicPrice = {},
|
||||
memberPrice = {},
|
||||
}: PriceListProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const { localPrice: publicLocalPrice, requestedPrice: publicRequestedPrice } =
|
||||
publicPrice
|
||||
const { localPrice: memberLocalPrice, requestedPrice: memberRequestedPrice } =
|
||||
memberPrice
|
||||
|
||||
const showRequestedPrice = publicRequestedPrice && memberRequestedPrice
|
||||
|
||||
return (
|
||||
<dl className={styles.priceList}>
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
<Caption
|
||||
type="bold"
|
||||
color={publicLocalPrice ? "uiTextHighContrast" : "disabled"}
|
||||
>
|
||||
{intl.formatMessage({ id: "Standard price" })}
|
||||
</Caption>
|
||||
</dt>
|
||||
<dd>
|
||||
{publicLocalPrice ? (
|
||||
<div className={styles.price}>
|
||||
<Subtitle type="two" color="uiTextHighContrast">
|
||||
{publicLocalPrice.pricePerNight}
|
||||
</Subtitle>
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{publicLocalPrice.currency}
|
||||
</Body>
|
||||
</div>
|
||||
) : (
|
||||
<Subtitle type="two" color="disabled">
|
||||
{intl.formatMessage({ id: "n/a" })}
|
||||
</Subtitle>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
<Caption type="bold" color={memberLocalPrice ? "red" : "disabled"}>
|
||||
{intl.formatMessage({ id: "Member price" })}
|
||||
</Caption>
|
||||
</dt>
|
||||
<dd>
|
||||
{memberLocalPrice ? (
|
||||
<div className={styles.price}>
|
||||
<Subtitle type="two" color="red">
|
||||
{memberLocalPrice.pricePerNight}
|
||||
</Subtitle>
|
||||
<Body color="red" textTransform="bold">
|
||||
{memberLocalPrice.currency}
|
||||
</Body>
|
||||
</div>
|
||||
) : (
|
||||
<Body textTransform="bold" color="disabled">
|
||||
- {intl.formatMessage({ id: "Currency Code" })}
|
||||
</Body>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
<Caption
|
||||
color={showRequestedPrice ? "uiTextMediumContrast" : "disabled"}
|
||||
>
|
||||
{intl.formatMessage({ id: "Approx." })}
|
||||
</Caption>
|
||||
</dt>
|
||||
<dd>
|
||||
{showRequestedPrice ? (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{publicRequestedPrice.pricePerNight}/
|
||||
{memberRequestedPrice.pricePerNight}{" "}
|
||||
{publicRequestedPrice.currency}
|
||||
</Caption>
|
||||
) : (
|
||||
<Caption color="disabled">- / - EUR</Caption>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
.priceRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--Spacing-x-quarter) 0;
|
||||
}
|
||||
|
||||
.priceTable {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.price {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
+71
-6
@@ -1,15 +1,80 @@
|
||||
.card {
|
||||
font-size: 14px;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
border: 1px solid var(--Base-Border-Normal);
|
||||
.card,
|
||||
.disabledCard {
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
input[type="radio"]:checked + .card {
|
||||
.disabledCard {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.disabledCard:hover {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
.checkIcon {
|
||||
display: none;
|
||||
}
|
||||
input[type="radio"]:checked + .card {
|
||||
border: 1px solid var(--Primary-Dark-On-Surface-Divider);
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
input[type="radio"]:checked + .card .checkIcon {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.header .infoIcon,
|
||||
.header .infoIcon path {
|
||||
stroke: var(--UI-Text-Medium-contrast);
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
grid-area: chevron;
|
||||
height: 100%;
|
||||
justify-self: flex-end;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.popover {
|
||||
background-color: var(--Main-Grey-White);
|
||||
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
left: 0px;
|
||||
max-height: 400px;
|
||||
padding: var(--Spacing-x2);
|
||||
top: calc(55px + var(--Spacing-x1));
|
||||
width: 100%;
|
||||
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.popover section:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.popover .popoverText {
|
||||
margin-bottom: var(--Spacing-x-half);
|
||||
}
|
||||
.popover .popoverHeading {
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
font-weight: 600; /* TODO: Remove when this is updated in Design system */
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useState } from "react"
|
||||
import { Button, DialogTrigger } from "react-aria-components"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import { CheckCircleIcon, InfoCircleIcon } from "@/components/Icons"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import PricePopover from "./Popover"
|
||||
import PriceTable from "./PriceList"
|
||||
|
||||
import styles from "./flexibilityOption.module.css"
|
||||
|
||||
import { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
||||
@@ -12,59 +16,109 @@ export default function FlexibilityOption({
|
||||
product,
|
||||
name,
|
||||
paymentTerm,
|
||||
priceInformation,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
handleSelectRate,
|
||||
}: FlexibilityOptionProps) {
|
||||
const intl = useIntl()
|
||||
const [rootDiv, setRootDiv] = useState<Element | undefined>(undefined)
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
|
||||
|
||||
if (!product) {
|
||||
// TODO: Implement empty state when this rate can't be booked
|
||||
return <div>TBI: Rate not available</div>
|
||||
function setRef(node: Element | null) {
|
||||
if (node) {
|
||||
setRootDiv(node)
|
||||
}
|
||||
}
|
||||
|
||||
const { productType } = product
|
||||
const { public: publicPrice, member: memberPrice } = productType
|
||||
const { localPrice: publicLocalPrice, requestedPrice: publicRequestedPrice } =
|
||||
publicPrice
|
||||
const { localPrice: memberLocalPrice, requestedPrice: memberRequestedPrice } =
|
||||
memberPrice
|
||||
if (!product) {
|
||||
return (
|
||||
<div className={styles.disabledCard}>
|
||||
<div className={styles.header}>
|
||||
<InfoCircleIcon className={styles.infoIcon} />
|
||||
<Caption color="disabled">{name}</Caption>
|
||||
<Caption color="disabled">({paymentTerm})</Caption>
|
||||
</div>
|
||||
<PriceTable />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { public: publicPrice, member: memberPrice } = product.productType
|
||||
|
||||
function onChange() {
|
||||
const rate = {
|
||||
roomTypeCode,
|
||||
roomType,
|
||||
priceName: name,
|
||||
public: publicPrice,
|
||||
member: memberPrice,
|
||||
}
|
||||
handleSelectRate(rate)
|
||||
}
|
||||
|
||||
return (
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="rateCode"
|
||||
value={product.productType.public.rateCode}
|
||||
value={publicPrice?.rateCode}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<Body>{name}</Body>
|
||||
<Caption>{paymentTerm}</Caption>
|
||||
<div className={styles.header} ref={setRef}>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
aria-label="Help"
|
||||
className={styles.button}
|
||||
onPress={() => setIsPopoverOpen(true)}
|
||||
>
|
||||
<InfoCircleIcon className={styles.infoIcon} />
|
||||
</Button>
|
||||
<PricePopover
|
||||
placement="bottom"
|
||||
className={styles.popover}
|
||||
isNonModal
|
||||
shouldFlip={false}
|
||||
shouldUpdatePosition={false}
|
||||
/**
|
||||
* react-aria uses portals to render Popover in body
|
||||
* unless otherwise specified. We need it to be contained
|
||||
* by this component to both access css variables assigned
|
||||
* on the container as well as to not overflow it at any time.
|
||||
*/
|
||||
UNSTABLE_portalContainer={rootDiv}
|
||||
isOpen={isPopoverOpen}
|
||||
onOpenChange={setIsPopoverOpen}
|
||||
>
|
||||
<Caption
|
||||
color="uiTextHighContrast"
|
||||
type="bold"
|
||||
className={styles.popoverHeading}
|
||||
>
|
||||
{name}
|
||||
</Caption>
|
||||
{priceInformation?.map((info) => (
|
||||
<Caption
|
||||
key={info}
|
||||
color="uiTextHighContrast"
|
||||
className={styles.popoverText}
|
||||
>
|
||||
{info}
|
||||
</Caption>
|
||||
))}
|
||||
</PricePopover>
|
||||
</DialogTrigger>
|
||||
<Caption color="uiTextHighContrast">{name}</Caption>
|
||||
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{intl.formatMessage({ id: "Standard price" })}</dt>
|
||||
<dd>
|
||||
{publicLocalPrice.pricePerNight} {publicLocalPrice.currency}/
|
||||
{intl.formatMessage({ id: "night" })}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{intl.formatMessage({ id: "Member price" })}</dt>
|
||||
<dd>
|
||||
{memberLocalPrice.pricePerNight} {memberLocalPrice.currency}/
|
||||
{intl.formatMessage({ id: "night" })}
|
||||
</dd>
|
||||
</div>
|
||||
{publicRequestedPrice && memberRequestedPrice && (
|
||||
<div>
|
||||
<dt>{intl.formatMessage({ id: "Approx." })}</dt>
|
||||
<dd>
|
||||
{publicRequestedPrice.pricePerNight}/
|
||||
{memberRequestedPrice.pricePerNight}{" "}
|
||||
{publicRequestedPrice.currency}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
<PriceTable publicPrice={publicPrice} memberPrice={memberPrice} />
|
||||
<CheckCircleIcon
|
||||
color="blue"
|
||||
className={styles.checkIcon}
|
||||
width={24}
|
||||
height={24}
|
||||
stroke="white"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./rateSummary.module.css"
|
||||
|
||||
import { RateSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
||||
|
||||
export default function RateSummary({
|
||||
rateSummary,
|
||||
isUserLoggedIn,
|
||||
}: RateSummaryProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const priceToShow = isUserLoggedIn ? rateSummary.member : rateSummary.public
|
||||
|
||||
return (
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.summaryText}>
|
||||
<Subtitle color="uiTextHighContrast">{rateSummary.roomType}</Subtitle>
|
||||
<Body color="uiTextMediumContrast">{rateSummary.priceName}</Body>
|
||||
</div>
|
||||
<div className={styles.summaryPrice}>
|
||||
<div className={styles.summaryPriceText}>
|
||||
<Subtitle color={isUserLoggedIn ? "red" : "uiTextHighContrast"}>
|
||||
{priceToShow?.localPrice.pricePerStay}{" "}
|
||||
{priceToShow?.localPrice.currency}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}{" "}
|
||||
{priceToShow?.requestedPrice?.pricePerStay}{" "}
|
||||
{priceToShow?.requestedPrice?.currency}
|
||||
</Body>
|
||||
</div>
|
||||
<Button type="submit" theme="base">
|
||||
{intl.formatMessage({ id: "Continue" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
.summary {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x7) var(--Spacing-x5);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.summaryPrice {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { RateDefinition } from "@/server/routers/hotels/output"
|
||||
|
||||
import FlexibilityOption from "@/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption"
|
||||
import { ChevronRightSmallIcon, GalleryIcon } from "@/components/Icons"
|
||||
import Image from "@/components/Image"
|
||||
import Lightbox from "@/components/Lightbox"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./roomCard.module.css"
|
||||
@@ -13,6 +21,8 @@ import { RoomCardProps } from "@/types/components/hotelReservation/selectRate/ro
|
||||
export default function RoomCard({
|
||||
rateDefinitions,
|
||||
roomConfiguration,
|
||||
roomCategories,
|
||||
handleSelectRate,
|
||||
}: RoomCardProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
@@ -29,90 +39,143 @@ export default function RoomCard({
|
||||
(rate) => rate.cancellationRule === "CancellableBefore6PM"
|
||||
)
|
||||
|
||||
const saveProduct = saveRate
|
||||
? roomConfiguration.products.find(
|
||||
(product) =>
|
||||
product.productType.public.rateCode === saveRate.rateCode ||
|
||||
product.productType.member.rateCode === saveRate.rateCode
|
||||
)
|
||||
: undefined
|
||||
const changeProduct = changeRate
|
||||
? roomConfiguration.products.find(
|
||||
(product) =>
|
||||
product.productType.public.rateCode === changeRate.rateCode ||
|
||||
product.productType.member.rateCode === changeRate.rateCode
|
||||
)
|
||||
: undefined
|
||||
const flexProduct = flexRate
|
||||
? roomConfiguration.products.find(
|
||||
(product) =>
|
||||
product.productType.public.rateCode === flexRate.rateCode ||
|
||||
product.productType.member.rateCode === flexRate.rateCode
|
||||
)
|
||||
: undefined
|
||||
function findProductForRate(rate: RateDefinition | undefined) {
|
||||
return rate
|
||||
? roomConfiguration.products.find(
|
||||
(product) =>
|
||||
product.productType.public?.rateCode === rate.rateCode ||
|
||||
product.productType.member?.rateCode === rate.rateCode
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function getPriceForRate(
|
||||
rate: typeof saveRate | typeof changeRate | typeof flexRate
|
||||
) {
|
||||
return rateDefinitions.find((def) => def.rateCode === rate?.rateCode)
|
||||
?.generalTerms
|
||||
}
|
||||
const selectedRoom = roomCategories.find(
|
||||
(room) => room.name === roomConfiguration.roomType
|
||||
)
|
||||
|
||||
const roomSize = selectedRoom?.roomSize
|
||||
const occupancy = selectedRoom?.occupancy.total
|
||||
const roomDescription = selectedRoom?.descriptions.short
|
||||
const images = selectedRoom?.images
|
||||
const mainImage = images?.[0]
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.cardBody}>
|
||||
<div className={styles.specification}>
|
||||
<Subtitle className={styles.name} type="two">
|
||||
{roomConfiguration.roomType}
|
||||
</Subtitle>
|
||||
<Caption>Room size TBI</Caption>
|
||||
<Button intent="text" type="button" size="small" theme="base">
|
||||
{intl.formatMessage({ id: "See room details" })}
|
||||
</Button>
|
||||
<Caption>
|
||||
<Caption color="uiTextMediumContrast" className={styles.guests}>
|
||||
{/*TODO: Handle pluralisation*/}
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "Max {nrOfGuests} guests",
|
||||
defaultMessage: "Max {nrOfGuests} guests",
|
||||
id: "booking.guests",
|
||||
},
|
||||
// TODO: Correct number
|
||||
{ nrOfGuests: 2 }
|
||||
{ nrOfGuests: occupancy }
|
||||
)}
|
||||
</Caption>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{roomSize?.min === roomSize?.max
|
||||
? roomSize?.min
|
||||
: `${roomSize?.min}-${roomSize?.max}`}
|
||||
m²
|
||||
</Caption>
|
||||
<Button
|
||||
intent="text"
|
||||
type="button"
|
||||
size="small"
|
||||
theme="base"
|
||||
className={styles.button}
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}
|
||||
<ChevronRightSmallIcon color="burgundy" width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.roomDetails}>
|
||||
<Subtitle className={styles.name} type="two">
|
||||
{roomConfiguration.roomType}
|
||||
</Subtitle>
|
||||
<Body>{roomDescription}</Body>
|
||||
</div>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage({
|
||||
id: "Breakfast included",
|
||||
id: "Breakfast selection in next step.",
|
||||
})}
|
||||
</Caption>
|
||||
<div className={styles.flexibilityOptions}>
|
||||
<FlexibilityOption
|
||||
name={intl.formatMessage({ id: "Non-refundable" })}
|
||||
value="non-refundable"
|
||||
paymentTerm={intl.formatMessage({ id: "Pay now" })}
|
||||
product={findProductForRate(saveRate)}
|
||||
priceInformation={getPriceForRate(saveRate)}
|
||||
handleSelectRate={handleSelectRate}
|
||||
roomType={roomConfiguration.roomType}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
/>
|
||||
<FlexibilityOption
|
||||
name={intl.formatMessage({ id: "Free rebooking" })}
|
||||
value="free-rebooking"
|
||||
paymentTerm={intl.formatMessage({ id: "Pay now" })}
|
||||
product={findProductForRate(changeRate)}
|
||||
priceInformation={getPriceForRate(changeRate)}
|
||||
handleSelectRate={handleSelectRate}
|
||||
roomType={roomConfiguration.roomType}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
/>
|
||||
<FlexibilityOption
|
||||
name={intl.formatMessage({ id: "Free cancellation" })}
|
||||
value="free-cancellation"
|
||||
paymentTerm={intl.formatMessage({ id: "Pay later" })}
|
||||
product={findProductForRate(flexRate)}
|
||||
priceInformation={getPriceForRate(flexRate)}
|
||||
handleSelectRate={handleSelectRate}
|
||||
roomType={roomConfiguration.roomType}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FlexibilityOption
|
||||
name={intl.formatMessage({ id: "Non-refundable" })}
|
||||
value="non-refundable"
|
||||
paymentTerm={intl.formatMessage({ id: "Pay now" })}
|
||||
product={saveProduct}
|
||||
/>
|
||||
<FlexibilityOption
|
||||
name={intl.formatMessage({ id: "Free rebooking" })}
|
||||
value="free-rebooking"
|
||||
paymentTerm={intl.formatMessage({ id: "Pay now" })}
|
||||
product={changeProduct}
|
||||
/>
|
||||
<FlexibilityOption
|
||||
name={intl.formatMessage({ id: "Free cancellation" })}
|
||||
value="free-cancellation"
|
||||
paymentTerm={intl.formatMessage({ id: "Pay later" })}
|
||||
product={flexProduct}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="small"
|
||||
theme="primaryDark"
|
||||
className={styles.button}
|
||||
>
|
||||
{intl.formatMessage({ id: "Choose room" })}
|
||||
</Button>
|
||||
</div>
|
||||
{/* TODO: maybe use the `Image` component instead of the `img` tag. Waiting until we know how to get the image */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
alt={intl.formatMessage({ id: "A photo of the room" })}
|
||||
// TODO: Correct image URL
|
||||
src="https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg"
|
||||
/>
|
||||
{mainImage && (
|
||||
<div className={styles.imageContainer}>
|
||||
{roomConfiguration.roomsLeft < 5 && (
|
||||
<span className={styles.roomsLeft}>
|
||||
<Footnote
|
||||
color="burgundy"
|
||||
textTransform="uppercase"
|
||||
>{`${roomConfiguration.roomsLeft} ${intl.formatMessage({ id: "Left" })}`}</Footnote>
|
||||
</span>
|
||||
)}
|
||||
{/*NOTE: images from the test API are hosted on test3.scandichotels.com,
|
||||
which can't be accessed unless on Scandic's Wifi or using Citrix. */}
|
||||
<Image
|
||||
src={mainImage.imageSizes.small}
|
||||
alt={mainImage.metaData.altText}
|
||||
width={330}
|
||||
height={185}
|
||||
/>
|
||||
{images && (
|
||||
<Lightbox
|
||||
images={images.map((image) => ({
|
||||
url: image.imageSizes.small,
|
||||
alt: image.metaData.altText,
|
||||
title: image.metaData.title,
|
||||
}))}
|
||||
dialogTitle={roomConfiguration.roomType}
|
||||
>
|
||||
<div className={styles.galleryIcon} id="lightboxTrigger">
|
||||
<GalleryIcon color="white" />
|
||||
<Footnote color="white">{images.length}</Footnote>
|
||||
</div>
|
||||
</Lightbox>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user