Merge branch 'develop' of bitbucket.org:scandic-swap/web into feature/tracking
This commit is contained in:
@@ -63,7 +63,7 @@ async function PointsColumn({
|
||||
</Title>
|
||||
{subtitle ? (
|
||||
<Body color="white" textAlign="center">
|
||||
{subtitle}
|
||||
{formatMessage({ id: subtitle })}
|
||||
</Body>
|
||||
) : null}
|
||||
</article>
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function Stats({ user }: UserProps) {
|
||||
return (
|
||||
<section className={styles.stats}>
|
||||
<Points user={user} />
|
||||
<Divider variant="default" color="pale" />
|
||||
<Divider color="pale" />
|
||||
<ExpiringPoints user={user} />
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useEffect, useState } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import Form from "@/components/Forms/BookingWidget"
|
||||
import { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema"
|
||||
import { CloseLarge } from "@/components/Icons"
|
||||
import { debounce } from "@/utils/debounce"
|
||||
|
||||
import MobileToggleButton from "./MobileToggleButton"
|
||||
|
||||
import styles from "./bookingWidget.module.css"
|
||||
|
||||
import type {
|
||||
BookingWidgetClientProps,
|
||||
BookingWidgetSchema,
|
||||
} from "@/types/components/bookingWidget"
|
||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
export default function BookingWidgetClient({
|
||||
locations,
|
||||
}: BookingWidgetClientProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const sessionStorageSearchData =
|
||||
typeof window !== "undefined"
|
||||
? sessionStorage.getItem("searchData")
|
||||
: undefined
|
||||
const initialSelectedLocation: Location | undefined = sessionStorageSearchData
|
||||
? JSON.parse(sessionStorageSearchData)
|
||||
: undefined
|
||||
const methods = useForm<BookingWidgetSchema>({
|
||||
defaultValues: {
|
||||
search: initialSelectedLocation?.name ?? "",
|
||||
location: sessionStorageSearchData
|
||||
? encodeURIComponent(sessionStorageSearchData)
|
||||
: undefined,
|
||||
date: {
|
||||
// UTC is required to handle requests from far away timezones https://scandichotels.atlassian.net/browse/SWAP-6375 & PET-507
|
||||
// This is specifically to handle timezones falling in different dates.
|
||||
from: dt().utc().format("YYYY-MM-DD"),
|
||||
to: dt().utc().add(1, "day").format("YYYY-MM-DD"),
|
||||
},
|
||||
bookingCode: "",
|
||||
redemption: false,
|
||||
voucher: false,
|
||||
rooms: [
|
||||
{
|
||||
adults: 1,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
shouldFocusError: false,
|
||||
mode: "all",
|
||||
resolver: zodResolver(bookingWidgetSchema),
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
function closeMobileSearch() {
|
||||
setIsOpen(false)
|
||||
document.body.style.overflowY = "visible"
|
||||
}
|
||||
|
||||
function openMobileSearch() {
|
||||
setIsOpen(true)
|
||||
document.body.style.overflowY = "hidden"
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const debouncedResizeHandler = debounce(function ([
|
||||
entry,
|
||||
]: ResizeObserverEntry[]) {
|
||||
if (entry.contentRect.width > 1366) {
|
||||
closeMobileSearch()
|
||||
}
|
||||
})
|
||||
const observer = new ResizeObserver(debouncedResizeHandler)
|
||||
|
||||
observer.observe(document.body)
|
||||
|
||||
return () => {
|
||||
if (observer) {
|
||||
observer.unobserve(document.body)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<section className={styles.container} data-open={isOpen}>
|
||||
<button
|
||||
className={styles.close}
|
||||
onClick={closeMobileSearch}
|
||||
type="button"
|
||||
>
|
||||
<CloseLarge />
|
||||
</button>
|
||||
<Form locations={locations} />
|
||||
</section>
|
||||
<MobileToggleButton openMobileSearch={openMobileSearch} />
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.complete,
|
||||
.partial {
|
||||
align-items: center;
|
||||
box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.16);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.complete {
|
||||
grid-template-columns: 1fr 36px;
|
||||
}
|
||||
|
||||
.partial {
|
||||
grid-template-columns: min(1fr, 150px) min-content min(1fr, 150px) 1fr;
|
||||
}
|
||||
|
||||
.icon {
|
||||
align-items: center;
|
||||
background-color: var(--Base-Button-Primary-Fill-Normal);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
height: 36px;
|
||||
justify-content: center;
|
||||
justify-self: flex-end;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.complete,
|
||||
.partial {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import { EditIcon, SearchIcon } from "@/components/Icons"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./button.module.css"
|
||||
|
||||
import type {
|
||||
BookingWidgetSchema,
|
||||
BookingWidgetToggleButtonProps,
|
||||
} from "@/types/components/bookingWidget"
|
||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
export default function MobileToggleButton({
|
||||
openMobileSearch,
|
||||
}: BookingWidgetToggleButtonProps) {
|
||||
const [hasMounted, setHasMounted] = useState(false)
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const d = useWatch({ name: "date" })
|
||||
const location = useWatch({ name: "location" })
|
||||
const rooms: BookingWidgetSchema["rooms"] = useWatch({ name: "rooms" })
|
||||
|
||||
const parsedLocation: Location | null = location
|
||||
? JSON.parse(decodeURIComponent(location))
|
||||
: null
|
||||
|
||||
const nights = dt(d.to).diff(dt(d.from), "days")
|
||||
|
||||
const selectedFromDate = dt(d.from).locale(lang).format("D MMM")
|
||||
const selectedToDate = dt(d.to).locale(lang).format("D MMM")
|
||||
|
||||
useEffect(() => {
|
||||
setHasMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!hasMounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (parsedLocation && d) {
|
||||
const totalRooms = rooms.length
|
||||
const totalAdults = rooms.reduce((acc, room) => {
|
||||
if (room.adults) {
|
||||
acc = acc + room.adults
|
||||
}
|
||||
return acc
|
||||
}, 0)
|
||||
return (
|
||||
<div className={styles.complete} onClick={openMobileSearch} role="button">
|
||||
<div>
|
||||
<Caption color="red">{parsedLocation.name}</Caption>
|
||||
<Caption>
|
||||
{`${selectedFromDate} - ${selectedToDate} (${intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: nights }
|
||||
)}) ${intl.formatMessage({ id: "booking.adults" }, { totalAdults })}, ${intl.formatMessage({ id: "booking.rooms" }, { totalRooms })}`}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.icon}>
|
||||
<EditIcon color="white" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.partial} onClick={openMobileSearch} role="button">
|
||||
<div>
|
||||
<Caption color="red">{intl.formatMessage({ id: "Where to" })}</Caption>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{parsedLocation
|
||||
? parsedLocation.name
|
||||
: intl.formatMessage({ id: "Destination" })}
|
||||
</Body>
|
||||
</div>
|
||||
<Divider color="baseSurfaceSubtleNormal" variant="vertical" />
|
||||
<div>
|
||||
<Caption color="red">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: nights }
|
||||
)}
|
||||
</Caption>
|
||||
<Body>
|
||||
{selectedFromDate} - {selectedToDate}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.icon}>
|
||||
<SearchIcon color="white" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,38 @@
|
||||
.container {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x5);
|
||||
@media screen and (max-width: 1366px) {
|
||||
.container {
|
||||
background-color: var(--UI-Input-Controls-Surface-Normal);
|
||||
bottom: -100%;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
grid-template-rows: 36px 1fr;
|
||||
height: 100dvh;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x7);
|
||||
position: fixed;
|
||||
transition: bottom 300ms ease;
|
||||
width: 100%;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.container[data-open="true"] {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
justify-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1367px) {
|
||||
@media screen and (min-width: 1367px) {
|
||||
.container {
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.close {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { getLocations } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Form from "@/components/Forms/BookingWidget"
|
||||
|
||||
import styles from "./bookingWidget.module.css"
|
||||
import BookingWidgetClient from "./Client"
|
||||
|
||||
export function preload() {
|
||||
void getLocations()
|
||||
@@ -15,9 +13,5 @@ export default async function BookingWidget() {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<Form locations={locations.data} />
|
||||
</section>
|
||||
)
|
||||
return <BookingWidgetClient locations={locations.data} />
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/*nav:has(+ .contentPage) {*/
|
||||
/* background-color: var(--Base-Surface-Subtle-Normal);*/
|
||||
/*}*/
|
||||
|
||||
.contentPage {
|
||||
padding-bottom: var(--Spacing-x9);
|
||||
container-name: content-page;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -17,21 +15,32 @@
|
||||
max-width: var(--max-width-content);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.headerIntro {
|
||||
display: grid;
|
||||
max-width: var(--max-width-text-block);
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.content {
|
||||
.heroContainer {
|
||||
width: 100%;
|
||||
padding: var(--Spacing-x4) var(--Spacing-x2);
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.innerContent {
|
||||
width: 100%;
|
||||
.heroContainer img {
|
||||
max-width: var(--max-width-content);
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.contentContainer {
|
||||
display: grid;
|
||||
padding: var(--Spacing-x4) var(--Spacing-x2) 0;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@@ -39,3 +48,16 @@
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1367px) {
|
||||
.heroContainer {
|
||||
padding: var(--Spacing-x4) 0;
|
||||
}
|
||||
.contentContainer {
|
||||
grid-template-columns: var(--max-width-text-block) 1fr;
|
||||
gap: var(--Spacing-x9);
|
||||
max-width: var(--max-width-content);
|
||||
margin: 0 auto;
|
||||
padding: var(--Spacing-x4) 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,31 +23,38 @@ export default async function ContentPage() {
|
||||
return (
|
||||
<>
|
||||
<section className={styles.contentPage}>
|
||||
{sidebar?.length ? <Sidebar blocks={sidebar} /> : null}
|
||||
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerContent}>
|
||||
<div className={styles.headerIntro}>
|
||||
<Title as="h2">{header.heading}</Title>
|
||||
<Preamble>{header.preamble}</Preamble>
|
||||
</div>
|
||||
{header.navigation_links ? (
|
||||
<LinkChips chips={header.navigation_links} />
|
||||
{header ? (
|
||||
<>
|
||||
<div className={styles.headerIntro}>
|
||||
<Title as="h2">{header.heading}</Title>
|
||||
<Preamble>{header.preamble}</Preamble>
|
||||
</div>
|
||||
{header.navigation_links ? (
|
||||
<LinkChips chips={header.navigation_links} />
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={styles.content}>
|
||||
<div className={styles.innerContent}>
|
||||
{hero_image ? (
|
||||
<Hero
|
||||
alt={hero_image.meta.alt || hero_image.meta.caption || ""}
|
||||
src={hero_image.url}
|
||||
/>
|
||||
) : null}
|
||||
{blocks ? <Blocks blocks={blocks} /> : null}
|
||||
{hero_image ? (
|
||||
<div className={styles.heroContainer}>
|
||||
<Hero
|
||||
alt={hero_image.meta.alt || hero_image.meta.caption || ""}
|
||||
src={hero_image.url}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
) : null}
|
||||
|
||||
<div className={styles.contentContainer}>
|
||||
<main className={styles.mainContent}>
|
||||
{blocks ? <Blocks blocks={blocks} /> : null}
|
||||
</main>
|
||||
|
||||
{sidebar?.length ? <Sidebar blocks={sidebar} /> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TrackingSDK pageData={tracking} />
|
||||
|
||||
@@ -14,3 +14,9 @@
|
||||
top: var(--main-menu-desktop-height);
|
||||
}
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
pointer-events: initial;
|
||||
box-shadow: var(--button-box-shadow);
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import useHotelPageStore from "@/stores/hotel-page"
|
||||
|
||||
import CloseLargeIcon from "@/components/Icons/CloseLarge"
|
||||
import InteractiveMap from "@/components/Maps/InteractiveMap"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
||||
|
||||
import MapContent from "./Map"
|
||||
import Sidebar from "./Sidebar"
|
||||
|
||||
import styles from "./dynamicMap.module.css"
|
||||
@@ -52,6 +54,20 @@ export default function DynamicMap({
|
||||
}
|
||||
}, [isDynamicMapOpen, scrollHeightWhenOpened])
|
||||
|
||||
const closeButton = (
|
||||
<Button
|
||||
theme="base"
|
||||
intent="inverted"
|
||||
variant="icon"
|
||||
size="small"
|
||||
className={styles.closeButton}
|
||||
onClick={closeDynamicMap}
|
||||
>
|
||||
<CloseLargeIcon color="burgundy" />
|
||||
<span>{intl.formatMessage({ id: "Close the map" })}</span>
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<APIProvider apiKey={apiKey}>
|
||||
<Modal isOpen={isDynamicMapOpen}>
|
||||
@@ -68,7 +84,8 @@ export default function DynamicMap({
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
onActivePoiChange={setActivePoi}
|
||||
/>
|
||||
<MapContent
|
||||
<InteractiveMap
|
||||
closeButton={closeButton}
|
||||
coordinates={coordinates}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
activePoi={activePoi}
|
||||
|
||||
@@ -55,6 +55,7 @@ export default function MapCard({ hotelName, pois }: MapCardProps) {
|
||||
theme="base"
|
||||
intent="secondary"
|
||||
size="small"
|
||||
fullWidth
|
||||
className={styles.ctaButton}
|
||||
onClick={openDynamicMap}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
container-name: loyalty-page;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.blocks {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
"use client"
|
||||
import { da, de, fi, nb, sv } from "date-fns/locale"
|
||||
import { useState } from "react"
|
||||
import { type DateRange, DayPicker } from "react-day-picker"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import classNames from "react-day-picker/style.module.css"
|
||||
|
||||
const locales = {
|
||||
[Lang.da]: da,
|
||||
[Lang.de]: de,
|
||||
[Lang.fi]: fi,
|
||||
[Lang.no]: nb,
|
||||
[Lang.sv]: sv,
|
||||
}
|
||||
|
||||
export interface DatePickerProps {
|
||||
handleOnSelect: (selected: DateRange) => void
|
||||
initialSelected?: DateRange
|
||||
}
|
||||
|
||||
export default function DatePicker({
|
||||
handleOnSelect,
|
||||
initialSelected = {
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
},
|
||||
}: DatePickerProps) {
|
||||
const lang = useLang()
|
||||
const [selectedDate, setSelectedDate] = useState<DateRange>(initialSelected)
|
||||
|
||||
function handleSelectDate(selected: DateRange) {
|
||||
handleOnSelect(selected)
|
||||
setSelectedDate(selected)
|
||||
}
|
||||
|
||||
/** English is default language and doesn't need to be imported */
|
||||
const locale = lang === Lang.en ? undefined : locales[lang]
|
||||
|
||||
const currentDate = dt().toDate()
|
||||
const startOfMonth = dt(currentDate).set("date", 1).toDate()
|
||||
const yesterday = dt(currentDate).subtract(1, "day").toDate()
|
||||
return (
|
||||
<DayPicker
|
||||
classNames={classNames}
|
||||
disabled={{ from: startOfMonth, to: yesterday }}
|
||||
excludeDisabled
|
||||
locale={locale}
|
||||
mode="range"
|
||||
onSelect={handleSelectDate}
|
||||
pagedNavigation
|
||||
required
|
||||
selected={selectedDate}
|
||||
showWeekNumber
|
||||
startMonth={currentDate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import { ChevronLeftIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./desktop.module.css"
|
||||
import classNames from "react-day-picker/style.module.css"
|
||||
|
||||
import type { DatePickerProps } from "@/types/components/datepicker"
|
||||
|
||||
export default function DatePickerDesktop({
|
||||
close,
|
||||
handleOnSelect,
|
||||
locales,
|
||||
selectedDate,
|
||||
}: DatePickerProps) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
|
||||
/** English is default language and doesn't need to be imported */
|
||||
const locale = lang === Lang.en ? undefined : locales[lang]
|
||||
const currentDate = dt().toDate()
|
||||
const startOfMonth = dt(currentDate).set("date", 1).toDate()
|
||||
const yesterday = dt(currentDate).subtract(1, "day").toDate()
|
||||
return (
|
||||
<DayPicker
|
||||
classNames={{
|
||||
...classNames,
|
||||
caption_label: `${classNames.caption_label} ${styles.captionLabel}`,
|
||||
day: `${classNames.day} ${styles.day}`,
|
||||
day_button: `${classNames.day_button} ${styles.dayButton}`,
|
||||
footer: styles.footer,
|
||||
month_caption: `${classNames.month_caption} ${styles.monthCaption}`,
|
||||
months: `${classNames.months} ${styles.months}`,
|
||||
range_end: styles.rangeEnd,
|
||||
range_middle: styles.rangeMiddle,
|
||||
range_start: styles.rangeStart,
|
||||
root: `${classNames.root} ${styles.container}`,
|
||||
week: styles.week,
|
||||
weekday: `${classNames.weekday} ${styles.weekDay}`,
|
||||
}}
|
||||
disabled={{ from: startOfMonth, to: yesterday }}
|
||||
excludeDisabled
|
||||
footer
|
||||
formatters={{
|
||||
formatWeekdayName(weekday) {
|
||||
return dt(weekday).locale(lang).format("ddd")
|
||||
},
|
||||
}}
|
||||
lang={lang}
|
||||
locale={locale}
|
||||
mode="range"
|
||||
numberOfMonths={2}
|
||||
onSelect={handleOnSelect}
|
||||
pagedNavigation
|
||||
required
|
||||
selected={selectedDate}
|
||||
startMonth={currentDate}
|
||||
weekStartsOn={1}
|
||||
components={{
|
||||
Chevron(props) {
|
||||
return <ChevronLeftIcon {...props} height={20} width={20} />
|
||||
},
|
||||
Footer(props) {
|
||||
return (
|
||||
<>
|
||||
<Divider className={styles.divider} color="primaryLightSubtle" />
|
||||
<footer className={props.className}>
|
||||
<Button
|
||||
intent="tertiary"
|
||||
onPress={close}
|
||||
size="small"
|
||||
theme="base"
|
||||
>
|
||||
<Caption color="white" textTransform="bold">
|
||||
{intl.formatMessage({ id: "Select dates" })}
|
||||
</Caption>
|
||||
</Button>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
},
|
||||
MonthCaption(props) {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<Subtitle asChild type="two">
|
||||
{props.children}
|
||||
</Subtitle>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
Nav(props) {
|
||||
if (Array.isArray(props.children)) {
|
||||
const prevButton = props.children?.[0]
|
||||
const nextButton = props.children?.[1]
|
||||
return (
|
||||
<>
|
||||
{prevButton ? (
|
||||
<nav
|
||||
className={`${props.className} ${styles.previousButton}`}
|
||||
>
|
||||
{prevButton}
|
||||
</nav>
|
||||
) : null}
|
||||
{nextButton ? (
|
||||
<nav className={`${props.className} ${styles.nextButton}`}>
|
||||
{nextButton}
|
||||
</nav>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return <></>
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
import { type ChangeEvent, useState } from "react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import { CloseLarge } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./mobile.module.css"
|
||||
import classNames from "react-day-picker/style.module.css"
|
||||
|
||||
import type { DatePickerProps } from "@/types/components/datepicker"
|
||||
|
||||
function addOneYear(_: undefined, i: number) {
|
||||
return new Date().getFullYear() + i
|
||||
}
|
||||
|
||||
const fiftyYearsAhead = Array.from({ length: 50 }, addOneYear)
|
||||
|
||||
export default function DatePickerMobile({
|
||||
close,
|
||||
handleOnSelect,
|
||||
locales,
|
||||
selectedDate,
|
||||
}: DatePickerProps) {
|
||||
const [selectedYear, setSelectedYear] = useState(() => dt().year())
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
|
||||
function handleSelectYear(evt: ChangeEvent<HTMLSelectElement>) {
|
||||
setSelectedYear(Number(evt.currentTarget.value))
|
||||
}
|
||||
|
||||
/** English is default language and doesn't need to be imported */
|
||||
const locale = lang === Lang.en ? undefined : locales[lang]
|
||||
const currentDate = dt().toDate()
|
||||
const startOfCurrentMonth = dt(currentDate).set("date", 1).toDate()
|
||||
const yesterday = dt(currentDate).subtract(1, "day").toDate()
|
||||
|
||||
const startMonth = dt().set("year", selectedYear).startOf("year").toDate()
|
||||
const decemberOfYear = dt().set("year", selectedYear).endOf("year").toDate()
|
||||
return (
|
||||
<DayPicker
|
||||
classNames={{
|
||||
...classNames,
|
||||
caption_label: `${classNames.caption_label} ${styles.captionLabel}`,
|
||||
day: `${classNames.day} ${styles.day}`,
|
||||
day_button: `${classNames.day_button} ${styles.dayButton}`,
|
||||
footer: styles.footer,
|
||||
month: styles.month,
|
||||
month_caption: `${classNames.month_caption} ${styles.monthCaption}`,
|
||||
months: styles.months,
|
||||
range_end: styles.rangeEnd,
|
||||
range_middle: styles.rangeMiddle,
|
||||
range_start: styles.rangeStart,
|
||||
root: `${classNames.root} ${styles.container}`,
|
||||
week: styles.week,
|
||||
weekday: `${classNames.weekday} ${styles.weekDay}`,
|
||||
}}
|
||||
disabled={{ from: startOfCurrentMonth, to: yesterday }}
|
||||
endMonth={decemberOfYear}
|
||||
excludeDisabled
|
||||
footer
|
||||
formatters={{
|
||||
formatWeekdayName(weekday) {
|
||||
return dt(weekday).locale(lang).format("ddd")
|
||||
},
|
||||
}}
|
||||
hideNavigation
|
||||
lang={lang}
|
||||
locale={locale}
|
||||
mode="range"
|
||||
/** Showing full year or what's left of it */
|
||||
numberOfMonths={12}
|
||||
onSelect={handleOnSelect}
|
||||
required
|
||||
selected={selectedDate}
|
||||
startMonth={startMonth}
|
||||
weekStartsOn={1}
|
||||
components={{
|
||||
Footer(props) {
|
||||
return (
|
||||
<footer className={props.className}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
intent="tertiary"
|
||||
onPress={close}
|
||||
size="large"
|
||||
theme="base"
|
||||
>
|
||||
<Body color="white" textTransform="bold">
|
||||
{intl.formatMessage({ id: "Select dates" })}
|
||||
</Body>
|
||||
</Button>
|
||||
<div className={styles.backdrop} />
|
||||
</footer>
|
||||
)
|
||||
},
|
||||
MonthCaption(props) {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<Subtitle asChild type="two">
|
||||
{props.children}
|
||||
</Subtitle>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
Root({ children, ...props }) {
|
||||
return (
|
||||
<div {...props}>
|
||||
<header className={styles.header}>
|
||||
<select
|
||||
className={styles.select}
|
||||
defaultValue={selectedYear}
|
||||
onChange={handleSelectYear}
|
||||
>
|
||||
{fiftyYearsAhead.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className={styles.close} onClick={close} type="button">
|
||||
<CloseLarge />
|
||||
</button>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
@media screen and (max-width: 1366px) {
|
||||
.container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
div.months {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.monthCaption {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.captionLabel {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
td.day,
|
||||
td.rangeEnd,
|
||||
td.rangeStart {
|
||||
font-family: var(--typography-Body-Bold-fontFamily);
|
||||
font-size: var(--typography-Body-Bold-fontSize);
|
||||
font-weight: 500;
|
||||
letter-spacing: var(--typography-Body-Bold-letterSpacing);
|
||||
line-height: var(--typography-Body-Bold-lineHeight);
|
||||
text-decoration: var(--typography-Body-Bold-textDecoration);
|
||||
}
|
||||
|
||||
td.rangeEnd,
|
||||
td.rangeStart {
|
||||
background: var(--Base-Background-Primary-Normal);
|
||||
}
|
||||
|
||||
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) {
|
||||
border-radius: 0 50% 50% 0;
|
||||
}
|
||||
|
||||
td.rangeStart[aria-selected="true"] {
|
||||
border-radius: 50% 0 0 50%;
|
||||
}
|
||||
|
||||
td.rangeEnd[aria-selected="true"] button.dayButton:hover,
|
||||
td.rangeStart[aria-selected="true"] button.dayButton:hover {
|
||||
background: var(--Primary-Light-On-Surface-Accent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
|
||||
td.rangeStart[aria-selected="true"]:not([data-outside="true"])
|
||||
button.dayButton {
|
||||
background: var(--Primary-Light-On-Surface-Accent);
|
||||
border: none;
|
||||
color: var(--Base-Button-Inverted-Fill-Normal);
|
||||
}
|
||||
|
||||
td.day,
|
||||
td.day[data-today="true"] {
|
||||
color: var(--UI-Text-High-contrast);
|
||||
height: 40px;
|
||||
padding: var(--Spacing-x-half);
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
td.day button.dayButton:hover {
|
||||
background: var(--Base-Surface-Secondary-light-Hover);
|
||||
}
|
||||
|
||||
td.day[data-outside="true"] button.dayButton {
|
||||
border: none;
|
||||
}
|
||||
|
||||
td.day:not(td.rangeEnd, td.rangeStart)[aria-selected="true"],
|
||||
td.rangeMiddle[aria-selected="true"] button.dayButton {
|
||||
background: var(--Base-Background-Primary-Normal);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
td.day[data-disabled="true"],
|
||||
td.day[data-disabled="true"] button.dayButton,
|
||||
td.day[data-outside="true"] ~ td.day[data-disabled="true"],
|
||||
td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
|
||||
.week:has(td.day[data-outside="true"] ~ td.day[data-disabled="true"])
|
||||
td.day[data-outside="true"]
|
||||
button.dayButton {
|
||||
background: none;
|
||||
color: var(--Base-Text-Disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.weekDay {
|
||||
color: var(--UI-Text-Placeholder);
|
||||
font-family: var(--typography-Footnote-Labels-fontFamily);
|
||||
font-size: var(--typography-Footnote-Labels-fontSize);
|
||||
font-weight: var(--typography-Footnote-Labels-fontWeight);
|
||||
letter-spacing: var(--typography-Footnote-Labels-letterSpacing);
|
||||
line-height: var(--typography-Footnote-Labels-lineHeight);
|
||||
text-decoration: var(--typography-Footnote-Labels-textDecoration);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.nextButton {
|
||||
transform: rotate(180deg);
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.previousButton {
|
||||
left: 0;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
.container {
|
||||
--header-height: 68px;
|
||||
--sticky-button-height: 120px;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"content";
|
||||
grid-template-rows: var(--header-height) calc(100dvh - var(--header-height));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header {
|
||||
align-self: flex-start;
|
||||
background-color: var(--Main-Grey-White);
|
||||
display: grid;
|
||||
grid-area: header;
|
||||
grid-template-columns: 1fr 24px;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x2);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.select {
|
||||
justify-self: center;
|
||||
min-width: 100px;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.close {
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-self: flex-end;
|
||||
}
|
||||
|
||||
div.months {
|
||||
display: grid;
|
||||
grid-area: content;
|
||||
overflow-y: scroll;
|
||||
scroll-snap-type: y mandatory;
|
||||
}
|
||||
|
||||
.month {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.month:last-of-type {
|
||||
padding-bottom: var(--sticky-button-height);
|
||||
}
|
||||
|
||||
.monthCaption {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.captionLabel {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.footer {
|
||||
align-self: flex-start;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0) 7.5%,
|
||||
#ffffff 82.5%
|
||||
);
|
||||
display: flex;
|
||||
grid-area: content;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x2) var(--Spacing-x7);
|
||||
position: sticky;
|
||||
top: calc(100vh - var(--sticky-button-height));
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.footer .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td.day,
|
||||
td.rangeEnd,
|
||||
td.rangeStart {
|
||||
font-family: var(--typography-Body-Bold-fontFamily);
|
||||
font-size: var(--typography-Body-Bold-fontSize);
|
||||
font-weight: 500;
|
||||
letter-spacing: var(--typography-Body-Bold-letterSpacing);
|
||||
line-height: var(--typography-Body-Bold-lineHeight);
|
||||
text-decoration: var(--typography-Body-Bold-textDecoration);
|
||||
}
|
||||
|
||||
td.rangeEnd,
|
||||
td.rangeStart {
|
||||
background: var(--Base-Background-Primary-Normal);
|
||||
}
|
||||
|
||||
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) {
|
||||
border-radius: 0 50% 50% 0;
|
||||
}
|
||||
|
||||
td.rangeStart[aria-selected="true"] {
|
||||
border-radius: 50% 0 0 50%;
|
||||
}
|
||||
|
||||
td.rangeEnd[aria-selected="true"] button.dayButton:hover,
|
||||
td.rangeStart[aria-selected="true"] button.dayButton:hover {
|
||||
background: var(--Primary-Light-On-Surface-Accent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
|
||||
td.rangeStart[aria-selected="true"]:not([data-outside="true"])
|
||||
button.dayButton {
|
||||
background: var(--Primary-Light-On-Surface-Accent);
|
||||
border: none;
|
||||
color: var(--Base-Button-Inverted-Fill-Normal);
|
||||
}
|
||||
|
||||
td.day,
|
||||
td.day[data-today="true"] {
|
||||
color: var(--UI-Text-High-contrast);
|
||||
height: 40px;
|
||||
padding: var(--Spacing-x-half);
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
td.day button.dayButton:hover {
|
||||
background: var(--Base-Surface-Secondary-light-Hover);
|
||||
}
|
||||
|
||||
td.day[data-outside="true"] button.dayButton {
|
||||
border: none;
|
||||
}
|
||||
|
||||
td.day:not(td.rangeEnd, td.rangeStart)[aria-selected="true"],
|
||||
td.rangeMiddle[aria-selected="true"] button.dayButton {
|
||||
background: var(--Base-Background-Primary-Normal);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
td.day[data-disabled="true"],
|
||||
td.day[data-disabled="true"] button.dayButton,
|
||||
td.day[data-outside="true"] ~ td.day[data-disabled="true"],
|
||||
td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
|
||||
.week:has(td.day[data-outside="true"] ~ td.day[data-disabled="true"])
|
||||
td.day[data-outside="true"]
|
||||
button.dayButton {
|
||||
background: none;
|
||||
color: var(--Base-Text-Disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.weekDay {
|
||||
color: var(--Base-Text-Medium-contrast);
|
||||
font-family: var(--typography-Footnote-Labels-fontFamily);
|
||||
font-size: var(--typography-Footnote-Labels-fontSize);
|
||||
font-weight: var(--typography-Footnote-Labels-fontWeight);
|
||||
letter-spacing: var(--typography-Footnote-Labels-letterSpacing);
|
||||
line-height: var(--typography-Footnote-Labels-lineHeight);
|
||||
text-decoration: var(--typography-Footnote-Labels-textDecoration);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.hideWrapper {
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
box-shadow: 0px 16px 24px 0px rgba(0, 0, 0, 0.08);
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
position: absolute;
|
||||
/** BookingWidget padding + border-width */
|
||||
top: calc(100% + var(--Spacing-x2) + 1px);
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -29,3 +19,43 @@
|
||||
.body {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.hideWrapper {
|
||||
background-color: var(--Main-Grey-White);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
.container {
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.hideWrapper {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
overflow: auto;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
transition: top 300ms ease;
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.container[data-isopen="true"] .hideWrapper {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
position: absolute;
|
||||
/**
|
||||
BookingWidget padding +
|
||||
border-width +
|
||||
wanted space below booking widget
|
||||
*/
|
||||
top: calc(100% + var(--Spacing-x2) + 1px + var(--Spacing-x4));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client"
|
||||
import { da, de, fi, nb, sv } from "date-fns/locale"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import DatePicker from "./DatePicker"
|
||||
import DatePickerDesktop from "./Screen/Desktop"
|
||||
import DatePickerMobile from "./Screen/Mobile"
|
||||
|
||||
import styles from "./date-picker.module.css"
|
||||
|
||||
@@ -15,6 +18,14 @@ import type { DateRange } from "react-day-picker"
|
||||
|
||||
import type { DatePickerFormProps } from "@/types/components/datepicker"
|
||||
|
||||
const locales = {
|
||||
[Lang.da]: da,
|
||||
[Lang.de]: de,
|
||||
[Lang.fi]: fi,
|
||||
[Lang.no]: nb,
|
||||
[Lang.sv]: sv,
|
||||
}
|
||||
|
||||
export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
const lang = useLang()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
@@ -22,6 +33,10 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
const { register, setValue } = useFormContext()
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
function close() {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
function handleOnClick() {
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen)
|
||||
}
|
||||
@@ -40,11 +55,9 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", handleClickOutside)
|
||||
|
||||
document.body.addEventListener("click", handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickOutside)
|
||||
document.body.removeEventListener("click", handleClickOutside)
|
||||
}
|
||||
}, [setIsOpen])
|
||||
|
||||
@@ -63,9 +76,17 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
<input {...register("date.from")} type="hidden" />
|
||||
<input {...register("date.to")} type="hidden" />
|
||||
<div aria-modal className={styles.hideWrapper} role="dialog">
|
||||
<DatePicker
|
||||
<DatePickerDesktop
|
||||
close={close}
|
||||
handleOnSelect={handleSelectDate}
|
||||
initialSelected={selectedDate}
|
||||
locales={locales}
|
||||
selectedDate={selectedDate}
|
||||
/>
|
||||
<DatePickerMobile
|
||||
close={close}
|
||||
handleOnSelect={handleSelectDate}
|
||||
locales={locales}
|
||||
selectedDate={selectedDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
height: 24px;
|
||||
outline: none;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +1,80 @@
|
||||
.input {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.input input[type="text"] {
|
||||
border: none;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.vouchers,
|
||||
.when,
|
||||
.where {
|
||||
border-right: 1px solid var(--Base-Surface-Subtle-Normal);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.when {
|
||||
max-width: 240px;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.vouchers {
|
||||
max-width: 200px;
|
||||
padding: var(--Spacing-x1) 0;
|
||||
}
|
||||
|
||||
.where {
|
||||
max-width: 280px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 158px;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
.input {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.vouchers,
|
||||
.when,
|
||||
.where {
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.vouchers,
|
||||
.when {
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.options {
|
||||
gap: var(--Spacing-x2);
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.option {
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.input {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.vouchers,
|
||||
.when,
|
||||
.where {
|
||||
border-right: 1px solid var(--Base-Surface-Subtle-Normal);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input input[type="text"] {
|
||||
border: none;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.when {
|
||||
max-width: 240px;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.vouchers {
|
||||
max-width: 200px;
|
||||
padding: var(--Spacing-x1) 0;
|
||||
}
|
||||
|
||||
.where {
|
||||
max-width: 280px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.options {
|
||||
max-width: 158px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ export default function FormContent({
|
||||
const intl = useIntl()
|
||||
const selectedDate = useWatch({ name: "date" })
|
||||
|
||||
const rooms = intl.formatMessage({ id: "Rooms & Guests" })
|
||||
const vouchers = intl.formatMessage({ id: "Booking codes and vouchers" })
|
||||
const rooms = intl.formatMessage({ id: "Guests & Rooms" })
|
||||
const vouchers = intl.formatMessage({ id: "Code / Voucher" })
|
||||
const bonus = intl.formatMessage({ id: "Use bonus cheque" })
|
||||
const reward = intl.formatMessage({ id: "Book reward night" })
|
||||
|
||||
@@ -33,10 +33,10 @@ export default function FormContent({
|
||||
</div>
|
||||
<div className={styles.when}>
|
||||
<Caption color="red" textTransform="bold">
|
||||
{nights}{" "}
|
||||
{nights > 1
|
||||
? intl.formatMessage({ id: "nights" })
|
||||
: intl.formatMessage({ id: "night" })}
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: nights }
|
||||
)}
|
||||
</Caption>
|
||||
<DatePicker />
|
||||
</div>
|
||||
@@ -47,20 +47,20 @@ export default function FormContent({
|
||||
<input type="text" placeholder={rooms} />
|
||||
</div>
|
||||
<div className={styles.vouchers}>
|
||||
<Caption color="textMediumContrast" textTransform="bold">
|
||||
<Caption color="uiTextMediumContrast" textTransform="bold">
|
||||
{vouchers}
|
||||
</Caption>
|
||||
<input type="text" placeholder={vouchers} />
|
||||
</div>
|
||||
<div className={styles.options}>
|
||||
<div className={styles.option}>
|
||||
<label className={styles.option}>
|
||||
<input type="checkbox" />
|
||||
<Caption color="textMediumContrast">{bonus}</Caption>
|
||||
</div>
|
||||
<div className={styles.option}>
|
||||
</label>
|
||||
<label className={styles.option}>
|
||||
<input type="checkbox" />
|
||||
<Caption color="textMediumContrast">{reward}</Caption>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,36 @@
|
||||
.section {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
display: grid;
|
||||
margin: 0 auto;
|
||||
max-width: var(--max-width-navigation);
|
||||
}
|
||||
|
||||
.form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 118px;
|
||||
justify-content: center;
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
.form {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.button {
|
||||
align-self: flex-end;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.section {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.button {
|
||||
justify-content: center;
|
||||
width: 118px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import FormContent from "./FormContent"
|
||||
import { bookingWidgetSchema } from "./schema"
|
||||
|
||||
import styles from "./form.module.css"
|
||||
|
||||
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
|
||||
import type { BookingWidgetFormProps } from "@/types/components/form/bookingwidget"
|
||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
const formId = "booking-widget"
|
||||
|
||||
@@ -24,40 +19,8 @@ export default function Form({ locations }: BookingWidgetFormProps) {
|
||||
const intl = useIntl()
|
||||
const router = useRouter()
|
||||
|
||||
const sessionStorageSearchData =
|
||||
typeof window !== "undefined"
|
||||
? sessionStorage.getItem("searchData")
|
||||
: undefined
|
||||
const initialSelectedLocation: Location | undefined = sessionStorageSearchData
|
||||
? JSON.parse(sessionStorageSearchData)
|
||||
: undefined
|
||||
const methods = useForm<BookingWidgetSchema>({
|
||||
defaultValues: {
|
||||
search: initialSelectedLocation?.name ?? "",
|
||||
location: sessionStorageSearchData
|
||||
? encodeURIComponent(sessionStorageSearchData)
|
||||
: undefined,
|
||||
date: {
|
||||
// UTC is required to handle requests from far away timezones https://scandichotels.atlassian.net/browse/SWAP-6375 & PET-507
|
||||
// This is specifically to handle timezones falling in different dates.
|
||||
from: dt().utc().format("YYYY-MM-DD"),
|
||||
to: dt().utc().add(1, "day").format("YYYY-MM-DD"),
|
||||
},
|
||||
bookingCode: "",
|
||||
redemption: false,
|
||||
voucher: false,
|
||||
rooms: [
|
||||
{
|
||||
adults: 1,
|
||||
childs: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
shouldFocusError: false,
|
||||
mode: "all",
|
||||
resolver: zodResolver(bookingWidgetSchema),
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
const { formState, handleSubmit, register } =
|
||||
useFormContext<BookingWidgetSchema>()
|
||||
|
||||
function onSubmit(data: BookingWidgetSchema) {
|
||||
data.location = JSON.parse(decodeURIComponent(data.location))
|
||||
@@ -70,25 +33,24 @@ export default function Form({ locations }: BookingWidgetFormProps) {
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<form
|
||||
onSubmit={methods.handleSubmit(onSubmit)}
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className={styles.form}
|
||||
id={formId}
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<input {...methods.register("location")} type="hidden" />
|
||||
<FormContent locations={locations} />
|
||||
</FormProvider>
|
||||
<input {...register("location")} type="hidden" />
|
||||
<FormContent locations={locations} />
|
||||
</form>
|
||||
<Button
|
||||
type="submit"
|
||||
className={styles.button}
|
||||
disabled={!formState.isValid}
|
||||
form={formId}
|
||||
intent="primary"
|
||||
size="small"
|
||||
theme="base"
|
||||
intent="primary"
|
||||
className={styles.button}
|
||||
type="submit"
|
||||
>
|
||||
<Caption color="white" textTransform="bold">
|
||||
{intl.formatMessage({ id: "Find hotels" })}
|
||||
{intl.formatMessage({ id: "Search" })}
|
||||
</Caption>
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
@@ -3,8 +3,9 @@ import { z } from "zod"
|
||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
export const bookingWidgetSchema = z.object({
|
||||
search: z.string({ coerce: true }).min(1, "Required"),
|
||||
bookingCode: z.string(), // Update this as required when working with booking codes component
|
||||
date: z.object({
|
||||
// Update this as required once started working with Date picker in Nights component
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
}),
|
||||
@@ -23,14 +24,12 @@ export const bookingWidgetSchema = z.object({
|
||||
},
|
||||
{ message: "Required" }
|
||||
),
|
||||
bookingCode: z.string(), // Update this as required when working with booking codes component
|
||||
redemption: z.boolean().default(false),
|
||||
voucher: z.boolean().default(false),
|
||||
rooms: z.array(
|
||||
// This will be updated when working in guests component
|
||||
z.object({
|
||||
adults: z.number().default(1),
|
||||
childs: z.array(
|
||||
children: z.array(
|
||||
z.object({
|
||||
age: z.number(),
|
||||
bed: z.number(),
|
||||
@@ -38,4 +37,6 @@ export const bookingWidgetSchema = z.object({
|
||||
),
|
||||
})
|
||||
),
|
||||
search: z.string({ coerce: true }).min(1, "Required"),
|
||||
voucher: z.boolean().default(false),
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Link } from "react-feather"
|
||||
|
||||
import { myPages } from "@/constants/routes/myPages"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
|
||||
@@ -48,12 +48,6 @@
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
padding: var(--Spacing-x2) var(--Spacing-x0);
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
|
||||
.prices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -115,7 +109,7 @@
|
||||
padding-bottom: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.link {
|
||||
.detailsButton {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
PriceTagIcon,
|
||||
ScandicLogoIcon,
|
||||
} from "@/components/Icons"
|
||||
import { PriceTagIcon, ScandicLogoIcon } from "@/components/Icons"
|
||||
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
||||
import Image from "@/components/Image"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
@@ -14,13 +8,16 @@ import Link from "@/components/TempDesignSystem/Link"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import ReadMore from "../ReadMore"
|
||||
|
||||
import styles from "./hotelCard.module.css"
|
||||
|
||||
import { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
|
||||
|
||||
export default function HotelCard({ hotel }: HotelCardProps) {
|
||||
const intl = useIntl()
|
||||
export default async function HotelCard({ hotel }: HotelCardProps) {
|
||||
const intl = await getIntl()
|
||||
|
||||
const { hotelData } = hotel
|
||||
const { price } = hotel
|
||||
@@ -51,7 +48,7 @@ export default function HotelCard({ hotel }: HotelCardProps) {
|
||||
<Title as="h4" textTransform="capitalize">
|
||||
{hotelData.name}
|
||||
</Title>
|
||||
<Footnote color="textMediumContrast" className={styles.adress}>
|
||||
<Footnote color="textMediumContrast">
|
||||
{`${hotelData.address.streetAddress}, ${hotelData.address.city}`}
|
||||
</Footnote>
|
||||
<Footnote color="textMediumContrast">
|
||||
@@ -70,10 +67,7 @@ export default function HotelCard({ hotel }: HotelCardProps) {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Link href="#" color="burgundy" className={styles.link}>
|
||||
{intl.formatMessage({ id: "See hotel details" })}
|
||||
<ChevronRightIcon color="burgundy" />
|
||||
</Link>
|
||||
<ReadMore hotelId={hotelData.operaId} hotel={hotelData} />
|
||||
</section>
|
||||
<section className={styles.prices}>
|
||||
<div>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import HotelCard from "../HotelCard"
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
.hotelSelectionHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||
justify-content: center;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.descriptionContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.address {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.dividerContainer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.hotelSelectionHeader {
|
||||
flex-direction: row;
|
||||
padding: var(--Spacing-x4) var(--Spacing-x5);
|
||||
gap: var(--Spacing-x6);
|
||||
}
|
||||
|
||||
.titleContainer > h1 {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dividerContainer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.address {
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import HotelDetailSidePeek from "./HotelDetailSidePeek"
|
||||
|
||||
import styles from "./hotelSelectionHeader.module.css"
|
||||
|
||||
import { HotelSelectionHeaderProps } from "@/types/components/hotelReservation/selectRate/hotelSelectionHeader"
|
||||
|
||||
export default async function HotelSelectionHeader({
|
||||
hotel,
|
||||
}: HotelSelectionHeaderProps) {
|
||||
const intl = await getIntl()
|
||||
|
||||
return (
|
||||
<header className={styles.hotelSelectionHeader}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Title as="h3" level="h1">
|
||||
{hotel.name}
|
||||
</Title>
|
||||
<address className={styles.address}>
|
||||
<Caption color="textMediumContrast">
|
||||
{hotel.address.streetAddress}, {hotel.address.city}
|
||||
</Caption>
|
||||
<div>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
</div>
|
||||
<Caption color="textMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "Distance to city centre",
|
||||
},
|
||||
{ number: hotel.location.distanceToCentre }
|
||||
)}
|
||||
</Caption>
|
||||
</address>
|
||||
</div>
|
||||
<div className={styles.dividerContainer}>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
</div>
|
||||
<div className={styles.descriptionContainer}>
|
||||
<Body color="textHighContrast">
|
||||
{hotel.hotelContent.texts.descriptions.short}
|
||||
</Body>
|
||||
<HotelDetailSidePeek />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto;
|
||||
gap: var(--Spacing-x2);
|
||||
font-family: var(--typography-Body-Regular-fontFamily);
|
||||
}
|
||||
|
||||
.address,
|
||||
.contactInfo {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-template-rows: subgrid;
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 1 / 4;
|
||||
}
|
||||
|
||||
.contactInfo > li {
|
||||
font-style: normal;
|
||||
list-style-type: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.soMeIcons {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.ecoLabel {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
column-gap: var(--Spacing-x-one-and-half);
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 3 / 4;
|
||||
font-size: var(--typography-Footnote-Regular-fontSize);
|
||||
line-height: ();
|
||||
}
|
||||
|
||||
.ecoLabelText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import FacebookIcon from "@/components/Icons/Facebook"
|
||||
import InstagramIcon from "@/components/Icons/Instagram"
|
||||
import Image from "@/components/Image"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./contact.module.css"
|
||||
|
||||
import { ContactProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
|
||||
export default function Contact({ hotel }: ContactProps) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<section className={styles.wrapper}>
|
||||
<address className={styles.address}>
|
||||
<ul className={styles.contactInfo}>
|
||||
<li>
|
||||
<span className={styles.heading}>
|
||||
{intl.formatMessage({ id: "Address" })}
|
||||
</span>
|
||||
<span>{hotel.address.streetAddress}</span>
|
||||
<span>{hotel.address.city}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.heading}>
|
||||
{intl.formatMessage({ id: "Driving directions" })}
|
||||
</span>
|
||||
<Link href="#">{intl.formatMessage({ id: "Google Maps" })}</Link>
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.heading}>
|
||||
{intl.formatMessage({ id: "Email" })}
|
||||
</span>
|
||||
<Link href={`mailto:${hotel.contactInformation.email}`}>
|
||||
{hotel.contactInformation.email}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.heading}>
|
||||
{intl.formatMessage({ id: "Contact us" })}
|
||||
</span>
|
||||
<Link href={`tel:${hotel.contactInformation.phoneNumber}`}>
|
||||
{hotel.contactInformation.phoneNumber}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.heading}>
|
||||
{intl.formatMessage({ id: "Follow us" })}
|
||||
</span>
|
||||
<div className={styles.soMeIcons}>
|
||||
<Link href="#" target="_blank">
|
||||
<InstagramIcon color="burgundy" />
|
||||
</Link>
|
||||
<Link href="#" target="_blank">
|
||||
<FacebookIcon color="burgundy" />
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</address>
|
||||
{hotel.hotelFacts.ecoLabels.nordicEcoLabel ? (
|
||||
<div className={styles.ecoLabel}>
|
||||
<Image
|
||||
height={38}
|
||||
width={38}
|
||||
alt={intl.formatMessage({ id: "Nordic Swan Ecolabel" })}
|
||||
src={`/_static/img/icons/swan-eco/swan_eco_dark_${lang}.png`}
|
||||
/>
|
||||
<div className={styles.ecoLabelText}>
|
||||
<span>{intl.formatMessage({ id: "Nordic Swan Ecolabel" })}</span>
|
||||
<span>
|
||||
{hotel.hotelFacts.ecoLabels.svanenEcoLabelCertificateNumber}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { ChevronRightIcon } from "@/components/Icons"
|
||||
import Accordion from "@/components/TempDesignSystem/Accordion"
|
||||
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
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 styles from "./readMore.module.css"
|
||||
|
||||
import {
|
||||
DetailedAmenity,
|
||||
ParkingProps,
|
||||
ReadMoreProps,
|
||||
} from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import { Hotel } from "@/types/hotel"
|
||||
|
||||
function getAmenitiesList(hotel: Hotel) {
|
||||
const detailedAmenities: DetailedAmenity[] = Object.entries(
|
||||
hotel.hotelFacts.hotelFacilityDetail
|
||||
).map(([key, value]) => ({ name: key, ...value }))
|
||||
|
||||
// Remove Parking facilities since parking accordion is based on hotel.parking
|
||||
const simpleAmenities = hotel.detailedFacilities.filter(
|
||||
(facility) => !facility.name.startsWith("Parking")
|
||||
)
|
||||
return [...detailedAmenities, ...simpleAmenities]
|
||||
}
|
||||
|
||||
export default function ReadMore({ hotel, hotelId }: ReadMoreProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const [sidePeekOpen, setSidePeekOpen] = useState(false)
|
||||
|
||||
const amenitiesList = getAmenitiesList(hotel)
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onPress={() => {
|
||||
setSidePeekOpen(true)
|
||||
}}
|
||||
intent={"text"}
|
||||
color="burgundy"
|
||||
className={styles.detailsButton}
|
||||
>
|
||||
{intl.formatMessage({ id: "See hotel details" })}
|
||||
<ChevronRightIcon color="burgundy" />
|
||||
</Button>
|
||||
<SidePeek
|
||||
title={hotel.name}
|
||||
isOpen={sidePeekOpen}
|
||||
contentKey={`${hotelId}`}
|
||||
handleClose={() => {
|
||||
setSidePeekOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<Subtitle>
|
||||
{intl.formatMessage({ id: "Practical information" })}
|
||||
</Subtitle>
|
||||
<Contact hotel={hotel} />
|
||||
<Accordion>
|
||||
{/* parking */}
|
||||
{hotel.parking.length ? (
|
||||
<AccordionItem title={intl.formatMessage({ id: "Parking" })}>
|
||||
{hotel.parking.map((p) => (
|
||||
<Parking key={p.name} parking={p} />
|
||||
))}
|
||||
</AccordionItem>
|
||||
) : null}
|
||||
<AccordionItem title={intl.formatMessage({ id: "Accessibility" })}>
|
||||
TODO: What content should be in the accessibility section?
|
||||
</AccordionItem>
|
||||
{amenitiesList.map((amenity) => {
|
||||
return "description" in amenity ? (
|
||||
<AccordionItem key={amenity.name} title={amenity.heading}>
|
||||
{amenity.description}
|
||||
</AccordionItem>
|
||||
) : (
|
||||
<div key={amenity.id} className={styles.amenity}>
|
||||
{amenity.name}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
{/* TODO: handle linking to Hotel Page */}
|
||||
<Button theme={"base"}>To the hotel</Button>
|
||||
</div>
|
||||
</SidePeek>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Parking({ parking }: ParkingProps) {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<div>
|
||||
<Body>{`${intl.formatMessage({ id: parking.type })} (${parking.name})`}</Body>
|
||||
<ul className={styles.list}>
|
||||
<li>
|
||||
{`${intl.formatMessage({
|
||||
id: "Number of charging points for electric cars",
|
||||
})}: ${parking.numberOfChargingSpaces}`}
|
||||
</li>
|
||||
<li>{`${intl.formatMessage({ id: "Parking can be reserved in advance" })}: ${parking.canMakeReservation ? intl.formatMessage({ id: "Yes" }) : intl.formatMessage({ id: "No" })}`}</li>
|
||||
<li>{`${intl.formatMessage({ id: "Number of parking spots" })}: ${parking.numberOfParkingSpots}`}</li>
|
||||
<li>{`${intl.formatMessage({ id: "Distance to hotel" })}: ${parking.distanceToHotel}`}</li>
|
||||
<li>{`${intl.formatMessage({ id: "Address" })}: ${parking.address}`}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
.detailsButton {
|
||||
align-self: start;
|
||||
border-radius: 0;
|
||||
height: auto;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.amenity {
|
||||
font-family: var(--typography-Body-Regular-fontFamily);
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
/* padding set to align with AccordionItem which has a different composition */
|
||||
padding: var(--Spacing-x2)
|
||||
calc(var(--Spacing-x1) + var(--Spacing-x-one-and-half));
|
||||
}
|
||||
|
||||
.list {
|
||||
font-family: var(--typography-Body-Regular-fontFamily);
|
||||
list-style: inside;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import styles from "./hotelFilter.module.css"
|
||||
|
||||
import { HotelFiltersProps } from "@/types/components/hotelReservation/selectHotel/hotelFiltersProps"
|
||||
import { HotelFiltersProps } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
||||
|
||||
export default function HotelFilter({ filters }: HotelFiltersProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
// TODO: This component is copied from
|
||||
// components/ContentType/HotelPage/Map/DynamicMap/Sidebar.
|
||||
// Look at that for inspiration on how to do the interaction with the map.
|
||||
|
||||
export default function HotelListing({}: HotelListingProps) {
|
||||
return <section>Hotel listing TBI</section>
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
import { APIProvider } from "@vis.gl/react-google-maps"
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { selectHotel } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import { CloseIcon } from "@/components/Icons"
|
||||
import InteractiveMap from "@/components/Maps/InteractiveMap"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import HotelListing from "./HotelListing"
|
||||
|
||||
import styles from "./selectHotelMap.module.css"
|
||||
|
||||
import { SelectHotelMapProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export default function SelectHotelMap({
|
||||
apiKey,
|
||||
coordinates,
|
||||
pointsOfInterest,
|
||||
mapId,
|
||||
}: SelectHotelMapProps) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const [activePoi, setActivePoi] = useState<string | null>(null)
|
||||
|
||||
const closeButton = (
|
||||
<Button
|
||||
asChild
|
||||
intent="inverted"
|
||||
size="small"
|
||||
theme="base"
|
||||
className={styles.closeButton}
|
||||
>
|
||||
<Link href={selectHotel[lang]} keepSearchParams color="burgundy">
|
||||
<CloseIcon color="burgundy" />
|
||||
{intl.formatMessage({ id: "Close the map" })}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
return (
|
||||
<APIProvider apiKey={apiKey}>
|
||||
<HotelListing />
|
||||
<InteractiveMap
|
||||
closeButton={closeButton}
|
||||
coordinates={coordinates}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
activePoi={activePoi}
|
||||
onActivePoiChange={setActivePoi}
|
||||
mapId={mapId}
|
||||
/>
|
||||
</APIProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.closeButton {
|
||||
pointer-events: initial;
|
||||
box-shadow: var(--button-box-shadow);
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function EditIcon({ className, color, ...props }: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
fill="none"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<mask
|
||||
height="20"
|
||||
id="mask0_162_2666"
|
||||
maskUnits="userSpaceOnUse"
|
||||
style={{ maskType: "alpha" }}
|
||||
width="20"
|
||||
x="0"
|
||||
y="0"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_162_2666)">
|
||||
<path
|
||||
d="M4.58333 16.8125C4.19949 16.8125 3.87088 16.6758 3.59752 16.4025C3.32417 16.1291 3.1875 15.8005 3.1875 15.4167V4.58333C3.1875 4.19375 3.32313 3.86024 3.5944 3.58281C3.86566 3.30538 4.19531 3.17361 4.58333 3.1875H11.7292L10.3333 4.58333H4.58333V15.4167H15.4167V9.65625L16.8125 8.26042V15.4167C16.8125 15.8005 16.6758 16.1291 16.4025 16.4025C16.1291 16.6758 15.8005 16.8125 15.4167 16.8125H4.58333ZM8.05208 11.9479V8.83333L15.4583 1.42708C15.6042 1.28125 15.7569 1.17882 15.9167 1.11979C16.0764 1.06076 16.245 1.03125 16.4224 1.03125C16.6116 1.03125 16.7899 1.06076 16.9573 1.11979C17.1247 1.17882 17.2808 1.27974 17.4259 1.42256L18.5625 2.5625C18.7083 2.70833 18.8125 2.86585 18.875 3.03504C18.9375 3.20424 18.9688 3.38006 18.9688 3.5625C18.9688 3.74529 18.9373 3.91971 18.8745 4.08575C18.8118 4.25179 18.7077 4.40724 18.5625 4.55208L11.1667 11.9479H8.05208ZM9.44792 10.5521H10.5938L15.4583 5.67708L14.8958 5.09375L14.3229 4.54167L9.44792 9.40625V10.5521Z"
|
||||
fill="#26201E"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export { default as CrossCircle } from "./CrossCircle"
|
||||
export { default as CulturalIcon } from "./Cultural"
|
||||
export { default as DeleteIcon } from "./Delete"
|
||||
export { default as DoorOpenIcon } from "./DoorOpen"
|
||||
export { default as EditIcon } from "./Edit"
|
||||
export { default as ElectricBikeIcon } from "./ElectricBike"
|
||||
export { default as EmailIcon } from "./Email"
|
||||
export { default as ErrorCircleIcon } from "./ErrorCircle"
|
||||
|
||||
@@ -18,16 +18,12 @@
|
||||
color: var(--Primary-Light-On-Surface-Accent);
|
||||
}
|
||||
|
||||
.li:has(.heart),
|
||||
.li:has(.check) {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.li {
|
||||
margin-left: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.li:has(.heart):before {
|
||||
.heart > .li::before,
|
||||
.li:has(.heart)::before {
|
||||
content: url("/_static/icons/heart.svg");
|
||||
position: relative;
|
||||
height: 8px;
|
||||
@@ -36,6 +32,14 @@
|
||||
margin-left: calc(var(--Spacing-x3) * -1);
|
||||
}
|
||||
|
||||
.heart > .li,
|
||||
.check > .li,
|
||||
.li:has(.check),
|
||||
.li:has(.heart) {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.check > .li::before,
|
||||
.li:has(.check)::before {
|
||||
content: url("/_static/icons/check-ring.svg");
|
||||
position: relative;
|
||||
@@ -59,6 +63,7 @@
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.ol:has(li:nth-last-child(n + 5)),
|
||||
.ul:has(li:nth-last-child(n + 5)) {
|
||||
@@ -66,3 +71,11 @@
|
||||
grid-auto-flow: column;
|
||||
}
|
||||
}
|
||||
|
||||
@container sidebar (max-width: 360px) {
|
||||
.ol,
|
||||
.ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ import Caption from "../TempDesignSystem/Text/Caption"
|
||||
import Footnote from "../TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "../TempDesignSystem/Text/Subtitle"
|
||||
import Title from "../TempDesignSystem/Text/Title"
|
||||
import { hasAvailableParagraphFormat, hasAvailableULFormat } from "./utils"
|
||||
import {
|
||||
hasAvailableParagraphFormat,
|
||||
hasAvailableULFormat,
|
||||
makeCssModuleCompatibleClassName,
|
||||
} from "./utils"
|
||||
|
||||
import styles from "./jsontohtml.module.css"
|
||||
|
||||
@@ -217,8 +221,16 @@ export const renderOptions: RenderOptions = {
|
||||
fullRenderOptions: RenderOptions
|
||||
) => {
|
||||
const props = extractPossibleAttributes(node.attrs)
|
||||
const compatibleClassName = makeCssModuleCompatibleClassName(
|
||||
props.className,
|
||||
"ul"
|
||||
)
|
||||
return (
|
||||
<li key={node.uid} {...props} className={styles.li}>
|
||||
<li
|
||||
key={node.uid}
|
||||
{...props}
|
||||
className={`${styles.li} ${compatibleClassName}`}
|
||||
>
|
||||
{next(node.children, embeds, fullRenderOptions)}
|
||||
</li>
|
||||
)
|
||||
@@ -525,6 +537,10 @@ export const renderOptions: RenderOptions = {
|
||||
fullRenderOptions: RenderOptions
|
||||
) => {
|
||||
const props = extractPossibleAttributes(node.attrs)
|
||||
const compatibleClassName = makeCssModuleCompatibleClassName(
|
||||
props.className,
|
||||
"ul"
|
||||
)
|
||||
|
||||
// Set the number of rows dynamically to create even rows for each column. We want the li:s
|
||||
// to flow with the column, so therefore this is needed.
|
||||
@@ -538,7 +554,7 @@ export const renderOptions: RenderOptions = {
|
||||
<ul
|
||||
key={node.uid}
|
||||
{...props}
|
||||
className={styles.ul}
|
||||
className={`${styles.ul} ${compatibleClassName}`}
|
||||
style={
|
||||
numberOfRows
|
||||
? {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { renderOptions } from "./renderOptions"
|
||||
|
||||
import styles from "./jsontohtml.module.css"
|
||||
|
||||
import type { Node } from "@/types/requests/utils/edges"
|
||||
import type { EmbedByUid } from "@/types/transitionTypes/jsontohtml"
|
||||
import {
|
||||
@@ -136,3 +138,19 @@ export function nodesToHtml(
|
||||
const fullRenderOptions = { ...renderOptions, ...overrideRenderOptions }
|
||||
return nodes.map((node) => nodeToHtml(node, embeds, fullRenderOptions))
|
||||
}
|
||||
|
||||
export function makeCssModuleCompatibleClassName(
|
||||
className: string | undefined,
|
||||
formatType: "ul"
|
||||
): string {
|
||||
if (!className) return ""
|
||||
|
||||
if (formatType === "ul" && hasAvailableULFormat(className)) {
|
||||
// @ts-ignore: We want to set css modules classNames even if it does not correspond
|
||||
// to an existing class in the module style sheet. Due to our css modules plugin for
|
||||
// typescript, we cannot do this without the ts-ignore
|
||||
return styles[className] || className
|
||||
}
|
||||
|
||||
return className
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { LanguageSwitcherContentProps } from "@/types/components/languageSw
|
||||
|
||||
export default function LanguageSwitcherContent({
|
||||
urls,
|
||||
onLanguageSwitch,
|
||||
}: LanguageSwitcherContentProps) {
|
||||
const intl = useIntl()
|
||||
const currentLanguage = useLang()
|
||||
@@ -39,6 +40,7 @@ export default function LanguageSwitcherContent({
|
||||
<Link
|
||||
className={`${styles.link} ${isActive ? styles.active : ""}`}
|
||||
href={url}
|
||||
onClick={onLanguageSwitch}
|
||||
>
|
||||
{languages[key]}
|
||||
{isActive ? <CheckIcon color="burgundy" /> : null}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { languages } from "@/constants/languages"
|
||||
@@ -28,6 +29,7 @@ export default function LanguageSwitcher({
|
||||
const intl = useIntl()
|
||||
const currentLanguage = useLang()
|
||||
const toggleDropdown = useDropdownStore((state) => state.toggleDropdown)
|
||||
const languageSwitcherRef = useRef<HTMLDivElement>(null)
|
||||
const isFooterLanguageSwitcherOpen = useDropdownStore(
|
||||
(state) => state.isFooterLanguageSwitcherOpen
|
||||
)
|
||||
@@ -70,10 +72,37 @@ export default function LanguageSwitcher({
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(evt: Event) {
|
||||
const target = evt.target as HTMLElement
|
||||
if (
|
||||
languageSwitcherRef.current &&
|
||||
target &&
|
||||
!languageSwitcherRef.current.contains(target) &&
|
||||
isLanguageSwitcherOpen &&
|
||||
!isHeaderLanguageSwitcherMobileOpen
|
||||
) {
|
||||
toggleDropdown(dropdownType)
|
||||
}
|
||||
}
|
||||
|
||||
if (languageSwitcherRef.current) {
|
||||
document.addEventListener("click", handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickOutside)
|
||||
}
|
||||
}, [
|
||||
dropdownType,
|
||||
toggleDropdown,
|
||||
isLanguageSwitcherOpen,
|
||||
isHeaderLanguageSwitcherMobileOpen,
|
||||
])
|
||||
|
||||
const classNames = languageSwitcherVariants({ color, position })
|
||||
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<div className={classNames} ref={languageSwitcherRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.button}
|
||||
@@ -99,7 +128,10 @@ export default function LanguageSwitcher({
|
||||
>
|
||||
{isLanguageSwitcherOpen ? (
|
||||
<LanguageSwitcherContainer type={type}>
|
||||
<LanguageSwitcherContent urls={urls} />
|
||||
<LanguageSwitcherContent
|
||||
urls={urls}
|
||||
onLanguageSwitch={() => toggleDropdown(dropdownType)}
|
||||
/>
|
||||
</LanguageSwitcherContainer>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
+6
-19
@@ -8,29 +8,26 @@ import {
|
||||
} from "@vis.gl/react-google-maps"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import useHotelPageStore from "@/stores/hotel-page"
|
||||
|
||||
import { MinusIcon, PlusIcon } from "@/components/Icons"
|
||||
import CloseLargeIcon from "@/components/Icons/CloseLarge"
|
||||
import PoiMarker from "@/components/Maps/Markers/Poi"
|
||||
import ScandicMarker from "@/components/Maps/Markers/Scandic"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import styles from "./map.module.css"
|
||||
import styles from "./interactiveMap.module.css"
|
||||
|
||||
import type { MapContentProps } from "@/types/components/hotelPage/map/mapContent"
|
||||
import type { InteractiveMapProps } from "@/types/components/hotelPage/map/interactiveMap"
|
||||
|
||||
export default function MapContent({
|
||||
export default function InteractiveMap({
|
||||
coordinates,
|
||||
pointsOfInterest,
|
||||
activePoi,
|
||||
mapId,
|
||||
onActivePoiChange,
|
||||
}: MapContentProps) {
|
||||
closeButton,
|
||||
}: InteractiveMapProps) {
|
||||
const intl = useIntl()
|
||||
const { closeDynamicMap } = useHotelPageStore()
|
||||
const map = useMap()
|
||||
|
||||
const mapOptions: MapProps = {
|
||||
@@ -98,17 +95,7 @@ export default function MapContent({
|
||||
))}
|
||||
</Map>
|
||||
<div className={styles.ctaButtons}>
|
||||
<Button
|
||||
theme="base"
|
||||
intent="inverted"
|
||||
variant="icon"
|
||||
size="small"
|
||||
className={styles.closeButton}
|
||||
onClick={closeDynamicMap}
|
||||
>
|
||||
<CloseLargeIcon color="burgundy" />
|
||||
<span>{intl.formatMessage({ id: "Close the map" })}</span>
|
||||
</Button>
|
||||
{closeButton}
|
||||
<div className={styles.zoomButtons}>
|
||||
<Button
|
||||
theme="base"
|
||||
@@ -1,15 +1,23 @@
|
||||
.aside {
|
||||
display: none;
|
||||
display: grid;
|
||||
container-name: sidebar;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--Spacing-x0) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1366px) {
|
||||
@media screen and (min-width: 1367px) {
|
||||
.aside {
|
||||
align-content: flex-start;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
}
|
||||
|
||||
@container loyalty-page (max-width: 1366px) {
|
||||
.aside {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
.btn {
|
||||
background: none;
|
||||
/* No variable yet for radius 50px */
|
||||
border-radius: 50px;
|
||||
border-radius: var(--Corner-radius-Rounded);
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -10,12 +9,12 @@
|
||||
background-color 300ms ease,
|
||||
color 300ms ease;
|
||||
|
||||
/* TODO: Waiting for variables for buttons from Design team */
|
||||
font-family: var(--typography-Body-Bold-fontFamily);
|
||||
font-size: var(--typography-Body-Bold-fontSize);
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
line-height: var(--typography-Body-Bold-lineHeight);
|
||||
letter-spacing: 0.6%;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wrapping {
|
||||
@@ -23,6 +22,10 @@
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* INTENT */
|
||||
.primary,
|
||||
a.primary {
|
||||
@@ -69,21 +72,33 @@ a.default {
|
||||
}
|
||||
|
||||
/* SIZES */
|
||||
.small {
|
||||
.btn.small {
|
||||
font-size: var(--typography-Caption-Bold-fontSize);
|
||||
line-height: var(--typography-Caption-Bold-lineHeight);
|
||||
gap: var(--Spacing-x-quarter);
|
||||
height: 40px;
|
||||
padding: calc(var(--Spacing-x1) + 2px) var(--Spacing-x2); /* Special case padding to adjust the missing border */
|
||||
}
|
||||
|
||||
.btn.small.secondary {
|
||||
padding: var(--Spacing-x1) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.medium {
|
||||
.btn.medium {
|
||||
gap: var(--Spacing-x-half);
|
||||
height: 48px;
|
||||
padding: calc(var(--Spacing-x-one-and-half) + 2px) var(--Spacing-x2); /* Special case padding to adjust the missing border */
|
||||
}
|
||||
|
||||
.medium.secondary {
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.large {
|
||||
.btn.large {
|
||||
gap: var(--Spacing-x-half);
|
||||
padding: calc(var(--Spacing-x2) + 2px) var(--Spacing-x3); /* Special case padding to adjust the missing border */
|
||||
}
|
||||
|
||||
.large.secondary {
|
||||
gap: var(--Spacing-x-half);
|
||||
height: 56px;
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
}
|
||||
|
||||
@@ -170,19 +185,19 @@ a.default {
|
||||
fill: var(--Base-Button-Secondary-On-Fill-Disabled);
|
||||
}
|
||||
|
||||
.baseTertiary {
|
||||
.btn.baseTertiary {
|
||||
background-color: var(--Base-Button-Tertiary-Fill-Normal);
|
||||
color: var(--Base-Button-Tertiary-On-Fill-Normal);
|
||||
}
|
||||
|
||||
.baseTertiary:active,
|
||||
.baseTertiary:focus,
|
||||
.baseTertiary:hover {
|
||||
.btn.baseTertiary:active,
|
||||
.btn.baseTertiary:focus,
|
||||
.btn.baseTertiary:hover {
|
||||
background-color: var(--Base-Button-Tertiary-Fill-Hover);
|
||||
color: var(--Base-Button-Tertiary-On-Fill-Hover);
|
||||
}
|
||||
|
||||
.baseTertiary:disabled {
|
||||
.btn.baseTertiary:disabled {
|
||||
background-color: var(--Base-Button-Tertiary-Fill-Disabled);
|
||||
color: var(--Base-Button-Tertiary-On-Fill-Disabled);
|
||||
}
|
||||
@@ -800,4 +815,4 @@ a.default {
|
||||
.icon.tertiaryLightSecondary:disabled svg,
|
||||
.icon.tertiaryLightSecondary:disabled svg * {
|
||||
fill: var(--Tertiary-Light-Button-Secondary-On-Fill-Disabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,23 @@ import { buttonVariants } from "./variants"
|
||||
import type { ButtonProps } from "./button"
|
||||
|
||||
export default function Button(props: ButtonProps) {
|
||||
const { className, intent, size, theme, wrapping, variant, ...restProps } =
|
||||
props
|
||||
const {
|
||||
className,
|
||||
intent,
|
||||
size,
|
||||
theme,
|
||||
fullWidth,
|
||||
wrapping,
|
||||
variant,
|
||||
...restProps
|
||||
} = props
|
||||
|
||||
const classNames = buttonVariants({
|
||||
className,
|
||||
intent,
|
||||
size,
|
||||
theme,
|
||||
fullWidth,
|
||||
wrapping,
|
||||
variant,
|
||||
})
|
||||
|
||||
@@ -33,6 +33,9 @@ export const buttonVariants = cva(styles.btn, {
|
||||
wrapping: {
|
||||
true: styles.wrapping,
|
||||
},
|
||||
fullWidth: {
|
||||
true: styles.fullWidth,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
intent: "primary",
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface CardProps
|
||||
heading?: string | null
|
||||
bodyText?: string | null
|
||||
backgroundImage?: ImageVaultAsset
|
||||
imageHeight?: number
|
||||
imageWidth?: number
|
||||
onPrimaryButtonClick?: () => void
|
||||
onSecondaryButtonClick?: () => void
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link"
|
||||
|
||||
import Image from "@/components/Image"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
@@ -21,10 +22,19 @@ export default function Card({
|
||||
className,
|
||||
theme,
|
||||
backgroundImage,
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
onPrimaryButtonClick,
|
||||
onSecondaryButtonClick,
|
||||
}: CardProps) {
|
||||
const { buttonTheme, primaryLinkColor, secondaryLinkColor } = getTheme(theme)
|
||||
const buttonTheme = getTheme(theme)
|
||||
|
||||
imageHeight = imageHeight || 320
|
||||
imageWidth =
|
||||
imageWidth ||
|
||||
(backgroundImage
|
||||
? backgroundImage.dimensions.aspectRatio * imageHeight
|
||||
: 420)
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -39,8 +49,8 @@ export default function Card({
|
||||
src={backgroundImage.url}
|
||||
className={styles.image}
|
||||
alt={backgroundImage.meta.alt || backgroundImage.title}
|
||||
width={backgroundImage.dimensions.width || 420}
|
||||
height={backgroundImage.dimensions.height || 320}
|
||||
width={imageWidth}
|
||||
height={imageHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -76,7 +86,6 @@ export default function Card({
|
||||
<Link
|
||||
href={primaryButton.href}
|
||||
target={primaryButton.openInNewTab ? "_blank" : undefined}
|
||||
color={primaryLinkColor}
|
||||
onClick={onPrimaryButtonClick}
|
||||
>
|
||||
{primaryButton.title}
|
||||
@@ -94,7 +103,6 @@ export default function Card({
|
||||
<Link
|
||||
href={secondaryButton.href}
|
||||
target={secondaryButton.openInNewTab ? "_blank" : undefined}
|
||||
color={secondaryLinkColor}
|
||||
onClick={onSecondaryButtonClick}
|
||||
>
|
||||
{secondaryButton.title}
|
||||
|
||||
@@ -1,36 +1,47 @@
|
||||
.divider {
|
||||
border-bottom-style: solid;
|
||||
border-bottom-width: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.horizontal {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dotted {
|
||||
border-bottom-style: dotted;
|
||||
.vertical {
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.burgundy {
|
||||
border-bottom-color: var(--Scandic-Brand-Burgundy);
|
||||
background-color: var(--Scandic-Brand-Burgundy);
|
||||
}
|
||||
|
||||
.pale {
|
||||
border-bottom-color: var(--Primary-Dark-On-Surface-Text);
|
||||
background-color: var(--Primary-Dark-On-Surface-Text);
|
||||
}
|
||||
|
||||
.peach {
|
||||
border-bottom-color: var(--Primary-Light-On-Surface-Divider);
|
||||
background-color: var(--Primary-Light-On-Surface-Divider);
|
||||
}
|
||||
|
||||
.beige {
|
||||
border-bottom-color: var(--Scandic-Beige-20);
|
||||
background-color: var(--Scandic-Beige-20);
|
||||
}
|
||||
|
||||
.white {
|
||||
border-bottom-color: var(--UI-Opacity-White-100);
|
||||
background-color: var(--UI-Opacity-White-100);
|
||||
}
|
||||
|
||||
.subtle {
|
||||
border-bottom-color: var(--Base-Border-Subtle);
|
||||
background-color: var(--Base-Border-Subtle);
|
||||
}
|
||||
|
||||
.primaryLightSubtle {
|
||||
background-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
}
|
||||
|
||||
.baseSurfaceSubtleNormal {
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
}
|
||||
|
||||
.opacity100 {
|
||||
|
||||
@@ -5,25 +5,27 @@ import styles from "./divider.module.css"
|
||||
export const dividerVariants = cva(styles.divider, {
|
||||
variants: {
|
||||
color: {
|
||||
burgundy: styles.burgundy,
|
||||
peach: styles.peach,
|
||||
baseSurfaceSubtleNormal: styles.baseSurfaceSubtleNormal,
|
||||
beige: styles.beige,
|
||||
white: styles.white,
|
||||
subtle: styles.subtle,
|
||||
burgundy: styles.burgundy,
|
||||
pale: styles.pale,
|
||||
peach: styles.peach,
|
||||
primaryLightSubtle: styles.primaryLightSubtle,
|
||||
subtle: styles.subtle,
|
||||
white: styles.white,
|
||||
},
|
||||
opacity: {
|
||||
100: styles.opacity100,
|
||||
8: styles.opacity8,
|
||||
},
|
||||
variant: {
|
||||
default: styles.default,
|
||||
dotted: styles.dotted,
|
||||
horizontal: styles.horizontal,
|
||||
vertical: styles.vertical,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
color: "burgundy",
|
||||
opacity: 100,
|
||||
variant: "default",
|
||||
variant: "horizontal",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
import NextLink from "next/link"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { startTransition, useCallback } from "react"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { startTransition, useCallback, useMemo } from "react"
|
||||
|
||||
import { trackClick } from "@/utils/tracking"
|
||||
|
||||
@@ -22,9 +22,14 @@ export default function Link({
|
||||
variant,
|
||||
trackingId,
|
||||
onClick,
|
||||
/**
|
||||
* Decides if the link should include the current search params in the URL
|
||||
*/
|
||||
keepSearchParams,
|
||||
...props
|
||||
}: LinkProps) {
|
||||
const currentPageSlug = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
let isActive = active || currentPageSlug === href
|
||||
|
||||
if (partialMatch && !isActive) {
|
||||
@@ -42,6 +47,12 @@ export default function Link({
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const fullUrl = useMemo(() => {
|
||||
const search =
|
||||
keepSearchParams && searchParams.size ? `?${searchParams}` : ""
|
||||
return `${href}${search}`
|
||||
}, [href, searchParams, keepSearchParams])
|
||||
|
||||
const trackClickById = useCallback(() => {
|
||||
if (trackingId) {
|
||||
trackClick(trackingId)
|
||||
@@ -65,12 +76,17 @@ export default function Link({
|
||||
// track navigation nor start a router transition.
|
||||
return
|
||||
}
|
||||
if (href.startsWith("tel:") || href.startsWith("mailto:")) {
|
||||
// If href contains tel or mailto protocols we don't want to
|
||||
// track navigation nor start a router transition.
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
startTransition(() => {
|
||||
router.push(href, { scroll })
|
||||
router.push(fullUrl, { scroll })
|
||||
})
|
||||
}}
|
||||
href={href}
|
||||
href={fullUrl}
|
||||
id={trackingId}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -10,4 +10,5 @@ export interface LinkProps
|
||||
partialMatch?: boolean
|
||||
prefetch?: boolean
|
||||
trackingId?: string
|
||||
keepSearchParams?: boolean
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useIsSSR } from "@react-aria/ssr"
|
||||
import { useContext } from "react"
|
||||
import { useContext, useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
@@ -29,6 +29,15 @@ function SidePeek({
|
||||
}: React.PropsWithChildren<SidePeekProps>) {
|
||||
const isSSR = useIsSSR()
|
||||
const intl = useIntl()
|
||||
|
||||
const [rootDiv, setRootDiv] = useState<HTMLDivElement | undefined>(undefined)
|
||||
|
||||
function setRef(node: HTMLDivElement | null) {
|
||||
if (node) {
|
||||
setRootDiv(node)
|
||||
}
|
||||
}
|
||||
|
||||
const context = useContext(SidePeekContext)
|
||||
function onClose() {
|
||||
const closeHandler = handleClose || context?.handleClose
|
||||
@@ -44,42 +53,45 @@ function SidePeek({
|
||||
)
|
||||
}
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<ModalOverlay
|
||||
className={styles.overlay}
|
||||
isOpen={isOpen || contentKey === context?.activeSidePeek}
|
||||
onOpenChange={onClose}
|
||||
isDismissable
|
||||
>
|
||||
<Modal className={styles.modal}>
|
||||
<Dialog className={styles.dialog}>
|
||||
<aside className={styles.sidePeek}>
|
||||
<header className={styles.header}>
|
||||
{title ? (
|
||||
<Title
|
||||
color="burgundy"
|
||||
textTransform="uppercase"
|
||||
level="h2"
|
||||
as="h3"
|
||||
<div ref={setRef}>
|
||||
<DialogTrigger>
|
||||
<ModalOverlay
|
||||
UNSTABLE_portalContainer={rootDiv}
|
||||
className={styles.overlay}
|
||||
isOpen={isOpen || contentKey === context?.activeSidePeek}
|
||||
onOpenChange={onClose}
|
||||
isDismissable
|
||||
>
|
||||
<Modal className={styles.modal}>
|
||||
<Dialog className={styles.dialog}>
|
||||
<aside className={styles.sidePeek}>
|
||||
<header className={styles.header}>
|
||||
{title ? (
|
||||
<Title
|
||||
color="burgundy"
|
||||
textTransform="uppercase"
|
||||
level="h2"
|
||||
as="h3"
|
||||
>
|
||||
{title}
|
||||
</Title>
|
||||
) : null}
|
||||
<Button
|
||||
aria-label={intl.formatMessage({ id: "Close" })}
|
||||
className={styles.closeButton}
|
||||
intent="text"
|
||||
onPress={onClose}
|
||||
>
|
||||
{title}
|
||||
</Title>
|
||||
) : null}
|
||||
<Button
|
||||
aria-label={intl.formatMessage({ id: "Close" })}
|
||||
className={styles.closeButton}
|
||||
intent="text"
|
||||
onPress={onClose}
|
||||
>
|
||||
<CloseIcon color="burgundy" height={32} width={32} />
|
||||
</Button>
|
||||
</header>
|
||||
<div className={styles.sidePeekContent}>{children}</div>
|
||||
</aside>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
</DialogTrigger>
|
||||
<CloseIcon color="burgundy" height={32} width={32} />
|
||||
</Button>
|
||||
</header>
|
||||
<div className={styles.sidePeekContent}>{children}</div>
|
||||
</aside>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
|
||||
.sidePeekContent {
|
||||
padding: var(--Spacing-x4);
|
||||
overflow-y: auto;
|
||||
}
|
||||
@media screen and (min-width: 1367px) {
|
||||
.modal {
|
||||
@@ -94,8 +95,4 @@
|
||||
.modal[data-exiting] {
|
||||
animation: slide-in 250ms reverse;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@
|
||||
color: var(--UI-Text-Medium-contrast);
|
||||
}
|
||||
|
||||
.textHighContrast {
|
||||
color: var(--UI-Text-High-contrast);
|
||||
}
|
||||
|
||||
.white {
|
||||
color: var(--UI-Opacity-White-100);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const config = {
|
||||
pale: styles.pale,
|
||||
red: styles.red,
|
||||
textMediumContrast: styles.textMediumContrast,
|
||||
textHighContrast: styles.textHighContrast,
|
||||
white: styles.white,
|
||||
peach50: styles.peach50,
|
||||
peach80: styles.peach80,
|
||||
|
||||
@@ -63,6 +63,10 @@ p.caption {
|
||||
color: var(--UI-Text-Active);
|
||||
}
|
||||
|
||||
.uiTextMediumContrast {
|
||||
color: var(--UI-Text-Medium-contrast);
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const config = {
|
||||
red: styles.red,
|
||||
white: styles.white,
|
||||
uiTextActive: styles.uiTextActive,
|
||||
uiTextMediumContrast: styles.uiTextMediumContrast,
|
||||
},
|
||||
textTransform: {
|
||||
bold: styles.bold,
|
||||
|
||||
Reference in New Issue
Block a user