feat: add mobile ui to calendar

This commit is contained in:
Simon Emanuelsson
2024-09-27 17:44:36 +02:00
parent 73eddcf4b7
commit 1380dec6e3
32 changed files with 1005 additions and 296 deletions

View File

@@ -11,7 +11,7 @@ export default function Stats({ user }: UserProps) {
return ( return (
<section className={styles.stats}> <section className={styles.stats}>
<Points user={user} /> <Points user={user} />
<Divider variant="default" color="pale" /> <Divider color="pale" />
<ExpiringPoints user={user} /> <ExpiringPoints user={user} />
</section> </section>
) )

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -1,5 +1,28 @@
.container { @media screen and (max-width: 1366px) {
display: none; .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 (min-width: 1367px) { @media screen and (min-width: 1367px) {
@@ -8,4 +31,8 @@
border-top: 1px solid var(--Base-Border-Subtle); border-top: 1px solid var(--Base-Border-Subtle);
display: block; display: block;
} }
.close {
display: none;
}
} }

View File

@@ -1,8 +1,6 @@
import { getLocations } from "@/lib/trpc/memoizedRequests" import { getLocations } from "@/lib/trpc/memoizedRequests"
import Form from "@/components/Forms/BookingWidget" import BookingWidgetClient from "./Client"
import styles from "./bookingWidget.module.css"
export function preload() { export function preload() {
void getLocations() void getLocations()
@@ -15,9 +13,5 @@ export default async function BookingWidget() {
return null return null
} }
return ( return <BookingWidgetClient locations={locations.data} />
<section className={styles.container}>
<Form locations={locations.data} />
</section>
)
} }

View File

@@ -1,49 +1,30 @@
"use client" "use client"
import { da, de, fi, nb, sv } from "date-fns/locale" import { DayPicker } from "react-day-picker"
import { useState } from "react"
import { type DateRange, DayPicker } from "react-day-picker"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages" import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
import { ChevronLeftIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider" import Divider from "@/components/TempDesignSystem/Divider"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import { ChevronLeftIcon } from "../Icons" import styles from "./desktop.module.css"
import styles from "./date-picker.module.css"
import classNames from "react-day-picker/style.module.css" import classNames from "react-day-picker/style.module.css"
import type { DatePickerProps } from "@/types/components/datepicker" import type { DatePickerProps } from "@/types/components/datepicker"
const locales = { export default function DatePickerDesktop({
[Lang.da]: da,
[Lang.de]: de,
[Lang.fi]: fi,
[Lang.no]: nb,
[Lang.sv]: sv,
}
export default function DatePicker({
close, close,
handleOnSelect, handleOnSelect,
initialSelected = { locales,
from: undefined, selectedDate,
to: undefined,
},
}: DatePickerProps) { }: DatePickerProps) {
const lang = useLang() const lang = useLang()
const intl = useIntl() const intl = useIntl()
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 */ /** English is default language and doesn't need to be imported */
const locale = lang === Lang.en ? undefined : locales[lang] const locale = lang === Lang.en ? undefined : locales[lang]
@@ -63,6 +44,7 @@ export default function DatePicker({
range_end: styles.rangeEnd, range_end: styles.rangeEnd,
range_middle: styles.rangeMiddle, range_middle: styles.rangeMiddle,
range_start: styles.rangeStart, range_start: styles.rangeStart,
root: `${classNames.root} ${styles.container}`,
week: styles.week, week: styles.week,
weekday: `${classNames.weekday} ${styles.weekDay}`, weekday: `${classNames.weekday} ${styles.weekDay}`,
}} }}
@@ -78,7 +60,7 @@ export default function DatePicker({
locale={locale} locale={locale}
mode="range" mode="range"
numberOfMonths={2} numberOfMonths={2}
onSelect={handleSelectDate} onSelect={handleOnSelect}
pagedNavigation pagedNavigation
required required
selected={selectedDate} selected={selectedDate}

View File

@@ -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>
)
},
}}
/>
)
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -7,20 +7,6 @@
} }
} }
.hideWrapper {
background-color: var(--Main-Grey-White);
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));
}
.btn { .btn {
background: none; background: none;
border: none; border: none;
@@ -34,117 +20,42 @@
opacity: 0.8; opacity: 0.8;
} }
div.months { .hideWrapper {
flex-wrap: nowrap; background-color: var(--Main-Grey-White);
} }
.monthCaption { @media screen and (max-width: 1366px) {
justify-content: center; .container {
} z-index: 10001;
}
.captionLabel { .hideWrapper {
text-transform: capitalize; bottom: 0;
}
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; 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));
}
} }

View File

@@ -1,13 +1,16 @@
"use client" "use client"
import { da, de, fi, nb, sv } from "date-fns/locale"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useFormContext, useWatch } from "react-hook-form" import { useFormContext, useWatch } from "react-hook-form"
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import useLang from "@/hooks/useLang" 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" 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" 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) { export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
const lang = useLang() const lang = useLang()
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
@@ -44,11 +55,9 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
setIsOpen(false) setIsOpen(false)
} }
} }
document.body.addEventListener("click", handleClickOutside)
document.addEventListener("click", handleClickOutside)
return () => { return () => {
document.removeEventListener("click", handleClickOutside) document.body.removeEventListener("click", handleClickOutside)
} }
}, [setIsOpen]) }, [setIsOpen])
@@ -67,10 +76,17 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
<input {...register("date.from")} type="hidden" /> <input {...register("date.from")} type="hidden" />
<input {...register("date.to")} type="hidden" /> <input {...register("date.to")} type="hidden" />
<div aria-modal className={styles.hideWrapper} role="dialog"> <div aria-modal className={styles.hideWrapper} role="dialog">
<DatePicker <DatePickerDesktop
close={close} close={close}
handleOnSelect={handleSelectDate} handleOnSelect={handleSelectDate}
initialSelected={selectedDate} locales={locales}
selectedDate={selectedDate}
/>
<DatePickerMobile
close={close}
handleOnSelect={handleSelectDate}
locales={locales}
selectedDate={selectedDate}
/> />
</div> </div>
</div> </div>

View File

@@ -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 { .options {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
max-width: 158px;
} }
.option { .option {
display: flex; 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;
}
}

View File

@@ -19,8 +19,8 @@ export default function FormContent({
const intl = useIntl() const intl = useIntl()
const selectedDate = useWatch({ name: "date" }) const selectedDate = useWatch({ name: "date" })
const rooms = intl.formatMessage({ id: "Rooms & Guests" }) const rooms = intl.formatMessage({ id: "Guests & Rooms" })
const vouchers = intl.formatMessage({ id: "Booking codes and vouchers" }) const vouchers = intl.formatMessage({ id: "Code / Voucher" })
const bonus = intl.formatMessage({ id: "Use bonus cheque" }) const bonus = intl.formatMessage({ id: "Use bonus cheque" })
const reward = intl.formatMessage({ id: "Book reward night" }) const reward = intl.formatMessage({ id: "Book reward night" })
@@ -47,20 +47,20 @@ export default function FormContent({
<input type="text" placeholder={rooms} /> <input type="text" placeholder={rooms} />
</div> </div>
<div className={styles.vouchers}> <div className={styles.vouchers}>
<Caption color="textMediumContrast" textTransform="bold"> <Caption color="uiTextMediumContrast" textTransform="bold">
{vouchers} {vouchers}
</Caption> </Caption>
<input type="text" placeholder={vouchers} /> <input type="text" placeholder={vouchers} />
</div> </div>
<div className={styles.options}> <div className={styles.options}>
<div className={styles.option}> <label className={styles.option}>
<input type="checkbox" /> <input type="checkbox" />
<Caption color="textMediumContrast">{bonus}</Caption> <Caption color="textMediumContrast">{bonus}</Caption>
</div> </label>
<div className={styles.option}> <label className={styles.option}>
<input type="checkbox" /> <input type="checkbox" />
<Caption color="textMediumContrast">{reward}</Caption> <Caption color="textMediumContrast">{reward}</Caption>
</div> </label>
</div> </div>
</div> </div>
) )

View File

@@ -1,15 +1,36 @@
.section { .section {
align-items: center; align-items: center;
display: flex; display: grid;
margin: 0 auto; margin: 0 auto;
max-width: var(--max-width-navigation); max-width: var(--max-width-navigation);
}
.form {
width: 100%; width: 100%;
} }
.button { .form {
width: 118px; display: grid;
justify-content: center; 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;
}
} }

View File

@@ -1,22 +1,17 @@
"use client" "use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { FormProvider, useForm } from "react-hook-form" import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
import FormContent from "./FormContent" import FormContent from "./FormContent"
import { bookingWidgetSchema } from "./schema"
import styles from "./form.module.css" import styles from "./form.module.css"
import type { BookingWidgetSchema } from "@/types/components/bookingWidget" import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
import type { BookingWidgetFormProps } from "@/types/components/form/bookingwidget" import type { BookingWidgetFormProps } from "@/types/components/form/bookingwidget"
import type { Location } from "@/types/trpc/routers/hotel/locations"
const formId = "booking-widget" const formId = "booking-widget"
@@ -24,40 +19,8 @@ export default function Form({ locations }: BookingWidgetFormProps) {
const intl = useIntl() const intl = useIntl()
const router = useRouter() const router = useRouter()
const sessionStorageSearchData = const { formState, handleSubmit, register } =
typeof window !== "undefined" useFormContext<BookingWidgetSchema>()
? 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",
})
function onSubmit(data: BookingWidgetSchema) { function onSubmit(data: BookingWidgetSchema) {
data.location = JSON.parse(decodeURIComponent(data.location)) data.location = JSON.parse(decodeURIComponent(data.location))
@@ -70,25 +33,24 @@ export default function Form({ locations }: BookingWidgetFormProps) {
return ( return (
<section className={styles.section}> <section className={styles.section}>
<form <form
onSubmit={methods.handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className={styles.form} className={styles.form}
id={formId} id={formId}
> >
<FormProvider {...methods}> <input {...register("location")} type="hidden" />
<input {...methods.register("location")} type="hidden" />
<FormContent locations={locations} /> <FormContent locations={locations} />
</FormProvider>
</form> </form>
<Button <Button
type="submit" className={styles.button}
disabled={!formState.isValid}
form={formId} form={formId}
intent="primary"
size="small" size="small"
theme="base" theme="base"
intent="primary" type="submit"
className={styles.button}
> >
<Caption color="white" textTransform="bold"> <Caption color="white" textTransform="bold">
{intl.formatMessage({ id: "Find hotels" })} {intl.formatMessage({ id: "Search" })}
</Caption> </Caption>
</Button> </Button>
</section> </section>

View File

@@ -29,7 +29,7 @@ export const bookingWidgetSchema = z.object({
// This will be updated when working in guests component // This will be updated when working in guests component
z.object({ z.object({
adults: z.number().default(1), adults: z.number().default(1),
childs: z.array( children: z.array(
z.object({ z.object({
age: z.number(), age: z.number(),
bed: z.number(), bed: z.number(),

36
components/Icons/Edit.tsx Normal file
View File

@@ -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>
)
}

View File

@@ -23,6 +23,7 @@ export { default as CrossCircle } from "./CrossCircle"
export { default as CulturalIcon } from "./Cultural" export { default as CulturalIcon } from "./Cultural"
export { default as DeleteIcon } from "./Delete" export { default as DeleteIcon } from "./Delete"
export { default as DoorOpenIcon } from "./DoorOpen" export { default as DoorOpenIcon } from "./DoorOpen"
export { default as EditIcon } from "./Edit"
export { default as ElectricBikeIcon } from "./ElectricBike" export { default as ElectricBikeIcon } from "./ElectricBike"
export { default as EmailIcon } from "./Email" export { default as EmailIcon } from "./Email"
export { default as ErrorCircleIcon } from "./ErrorCircle" export { default as ErrorCircleIcon } from "./ErrorCircle"

View File

@@ -1,40 +1,47 @@
.divider { .divider {
border-bottom-style: solid; pointer-events: none;
border-bottom-width: 1px; }
.horizontal {
height: 1px; height: 1px;
width: 100%; width: 100%;
} }
.dotted { .vertical {
border-bottom-style: dotted; height: 100%;
width: 1px;
} }
.burgundy { .burgundy {
border-bottom-color: var(--Scandic-Brand-Burgundy); background-color: var(--Scandic-Brand-Burgundy);
} }
.pale { .pale {
border-bottom-color: var(--Primary-Dark-On-Surface-Text); background-color: var(--Primary-Dark-On-Surface-Text);
} }
.peach { .peach {
border-bottom-color: var(--Primary-Light-On-Surface-Divider); background-color: var(--Primary-Light-On-Surface-Divider);
} }
.beige { .beige {
border-bottom-color: var(--Scandic-Beige-20); background-color: var(--Scandic-Beige-20);
} }
.white { .white {
border-bottom-color: var(--UI-Opacity-White-100); background-color: var(--UI-Opacity-White-100);
} }
.subtle { .subtle {
border-bottom-color: var(--Base-Border-Subtle); background-color: var(--Base-Border-Subtle);
} }
.primaryLightSubtle { .primaryLightSubtle {
border-bottom-color: var(--Primary-Light-On-Surface-Divider-subtle); background-color: var(--Primary-Light-On-Surface-Divider-subtle);
}
.baseSurfaceSubtleNormal {
background-color: var(--Base-Surface-Subtle-Normal);
} }
.opacity100 { .opacity100 {

View File

@@ -5,6 +5,7 @@ import styles from "./divider.module.css"
export const dividerVariants = cva(styles.divider, { export const dividerVariants = cva(styles.divider, {
variants: { variants: {
color: { color: {
baseSurfaceSubtleNormal: styles.baseSurfaceSubtleNormal,
beige: styles.beige, beige: styles.beige,
burgundy: styles.burgundy, burgundy: styles.burgundy,
pale: styles.pale, pale: styles.pale,
@@ -18,13 +19,13 @@ export const dividerVariants = cva(styles.divider, {
8: styles.opacity8, 8: styles.opacity8,
}, },
variant: { variant: {
default: styles.default, horizontal: styles.horizontal,
dotted: styles.dotted, vertical: styles.vertical,
}, },
}, },
defaultVariants: { defaultVariants: {
color: "burgundy", color: "burgundy",
opacity: 100, opacity: 100,
variant: "default", variant: "horizontal",
}, },
}) })

View File

@@ -63,6 +63,10 @@ p.caption {
color: var(--UI-Text-Active); color: var(--UI-Text-Active);
} }
.uiTextMediumContrast {
color: var(--UI-Text-Medium-contrast);
}
.center { .center {
text-align: center; text-align: center;
} }

View File

@@ -12,6 +12,7 @@ const config = {
red: styles.red, red: styles.red,
white: styles.white, white: styles.white,
uiTextActive: styles.uiTextActive, uiTextActive: styles.uiTextActive,
uiTextMediumContrast: styles.uiTextMediumContrast,
}, },
textTransform: { textTransform: {
bold: styles.bold, bold: styles.bold,

View File

@@ -23,7 +23,7 @@
"Bed type": "Seng type", "Bed type": "Seng type",
"Book": "Book", "Book": "Book",
"Book reward night": "Book bonusnat", "Book reward night": "Book bonusnat",
"Booking codes and vouchers": "Bookingkoder og vouchers", "Code / Voucher": "Bookingkoder / voucher",
"Booking number": "Bookingnummer", "Booking number": "Bookingnummer",
"Breakfast": "Morgenmad", "Breakfast": "Morgenmad",
"Breakfast excluded": "Morgenmad ikke inkluderet", "Breakfast excluded": "Morgenmad ikke inkluderet",
@@ -183,10 +183,11 @@
"Room & Terms": "Værelse & Vilkår", "Room & Terms": "Værelse & Vilkår",
"Room facilities": "Værelsesfaciliteter", "Room facilities": "Værelsesfaciliteter",
"Rooms": "Værelser", "Rooms": "Værelser",
"Rooms & Guests": "Værelser & gæster", "Guests & Rooms": "Gæster & værelser",
"Save": "Gemme", "Save": "Gemme",
"Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop", "Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Search": "Søge",
"See all photos": "Se alle billeder", "See all photos": "Se alle billeder",
"See hotel details": "Se hoteloplysninger", "See hotel details": "Se hoteloplysninger",
"See room details": "Se værelsesdetaljer", "See room details": "Se værelsesdetaljer",

View File

@@ -23,7 +23,7 @@
"Bed type": "Bettentyp", "Bed type": "Bettentyp",
"Book": "Buchen", "Book": "Buchen",
"Book reward night": "Bonusnacht buchen", "Book reward night": "Bonusnacht buchen",
"Booking codes and vouchers": "Buchungscodes und Gutscheine", "Code / Voucher": "Buchungscodes / Gutscheine",
"Booking number": "Buchungsnummer", "Booking number": "Buchungsnummer",
"Breakfast": "Frühstück", "Breakfast": "Frühstück",
"Breakfast excluded": "Frühstück nicht inbegriffen", "Breakfast excluded": "Frühstück nicht inbegriffen",
@@ -182,10 +182,11 @@
"Room & Terms": "Zimmer & Bedingungen", "Room & Terms": "Zimmer & Bedingungen",
"Room facilities": "Zimmerausstattung", "Room facilities": "Zimmerausstattung",
"Rooms": "Räume", "Rooms": "Räume",
"Rooms & Guests": "Zimmer & Gäste", "Guests & Rooms": "Gäste & Zimmer",
"Save": "Speichern", "Save": "Speichern",
"Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop", "Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Search": "Suchen",
"See all photos": "Alle Fotos ansehen", "See all photos": "Alle Fotos ansehen",
"See hotel details": "Hotelinformationen ansehen", "See hotel details": "Hotelinformationen ansehen",
"See room details": "Zimmerdetails ansehen", "See room details": "Zimmerdetails ansehen",

View File

@@ -23,7 +23,7 @@
"Bed type": "Bed type", "Bed type": "Bed type",
"Book": "Book", "Book": "Book",
"Book reward night": "Book reward night", "Book reward night": "Book reward night",
"Booking codes and vouchers": "Booking codes and vouchers", "Code / Voucher": "Code / Voucher",
"Booking number": "Booking number", "Booking number": "Booking number",
"Breakfast": "Breakfast", "Breakfast": "Breakfast",
"Breakfast excluded": "Breakfast excluded", "Breakfast excluded": "Breakfast excluded",
@@ -60,6 +60,7 @@
"Day": "Day", "Day": "Day",
"Description": "Description", "Description": "Description",
"Destinations & hotels": "Destinations & hotels", "Destinations & hotels": "Destinations & hotels",
"Destination": "Destination",
"Discard changes": "Discard changes", "Discard changes": "Discard changes",
"Discard unsaved changes?": "Discard unsaved changes?", "Discard unsaved changes?": "Discard unsaved changes?",
"Distance to city centre": "{number}km to city centre", "Distance to city centre": "{number}km to city centre",
@@ -183,11 +184,12 @@
"Room & Terms": "Room & Terms", "Room & Terms": "Room & Terms",
"Room facilities": "Room facilities", "Room facilities": "Room facilities",
"Rooms": "Rooms", "Rooms": "Rooms",
"Rooms & Guests": "Rooms & Guests", "Guests & Rooms": "Guests & Rooms",
"Save": "Save", "Save": "Save",
"Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop", "Scandic Friends Point Shop": "Scandic Friends Point Shop",
"See all photos": "See all photos", "See all photos": "See all photos",
"Search": "Search",
"See hotel details": "See hotel details", "See hotel details": "See hotel details",
"See room details": "See room details", "See room details": "See room details",
"See rooms": "See rooms", "See rooms": "See rooms",
@@ -264,7 +266,9 @@
"Zoom in": "Zoom in", "Zoom in": "Zoom in",
"Zoom out": "Zoom out", "Zoom out": "Zoom out",
"as of today": "as of today", "as of today": "as of today",
"booking.adults": "{totalAdults, plural, one {# adult} other {# adults}}",
"booking.nights": "{totalNights, plural, one {# night} other {# nights}}", "booking.nights": "{totalNights, plural, one {# night} other {# nights}}",
"booking.rooms": "{totalRooms, plural, one {# room} other {# rooms}}",
"by": "by", "by": "by",
"characters": "characters", "characters": "characters",
"hotelPages.rooms.roomCard.person": "person", "hotelPages.rooms.roomCard.person": "person",

View File

@@ -23,7 +23,6 @@
"Bed type": "Vuodetyyppi", "Bed type": "Vuodetyyppi",
"Book": "Varaa", "Book": "Varaa",
"Book reward night": "Kirjapalkinto-ilta", "Book reward night": "Kirjapalkinto-ilta",
"Booking codes and vouchers": "Varauskoodit ja kupongit",
"Booking number": "Varausnumero", "Booking number": "Varausnumero",
"Breakfast": "Aamiainen", "Breakfast": "Aamiainen",
"Breakfast excluded": "Aamiainen ei sisälly", "Breakfast excluded": "Aamiainen ei sisälly",
@@ -45,6 +44,7 @@
"Close menu": "Sulje valikko", "Close menu": "Sulje valikko",
"Close my pages menu": "Sulje omat sivut -valikko", "Close my pages menu": "Sulje omat sivut -valikko",
"Close the map": "Sulje kartta", "Close the map": "Sulje kartta",
"Code / Voucher": "Varauskoodit / kupongit",
"Coming up": "Tulossa", "Coming up": "Tulossa",
"Compare all levels": "Vertaa kaikkia tasoja", "Compare all levels": "Vertaa kaikkia tasoja",
"Contact us": "Ota meihin yhteyttä", "Contact us": "Ota meihin yhteyttä",
@@ -85,6 +85,7 @@
"Get inspired": "Inspiroidu", "Get inspired": "Inspiroidu",
"Go back to edit": "Palaa muokkaamaan", "Go back to edit": "Palaa muokkaamaan",
"Go back to overview": "Palaa yleiskatsaukseen", "Go back to overview": "Palaa yleiskatsaukseen",
"Guests & Rooms": "Vieraat & Huoneet",
"Hi": "Hi", "Hi": "Hi",
"Highest level": "Korkein taso", "Highest level": "Korkein taso",
"Hospital": "Sairaala", "Hospital": "Sairaala",
@@ -183,11 +184,10 @@
"Room & Terms": "Huone & Ehdot", "Room & Terms": "Huone & Ehdot",
"Room facilities": "Huoneen varustelu", "Room facilities": "Huoneen varustelu",
"Rooms": "Huoneet", "Rooms": "Huoneet",
"Rooms & Guests": "Huoneet & Vieraat",
"Rooms & Guestss": "Huoneet & Vieraat",
"Save": "Tallenna", "Save": "Tallenna",
"Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop", "Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Search": "Haku",
"See all photos": "Katso kaikki kuvat", "See all photos": "Katso kaikki kuvat",
"See hotel details": "Katso hotellin tiedot", "See hotel details": "Katso hotellin tiedot",
"See room details": "Katso huoneen tiedot", "See room details": "Katso huoneen tiedot",

View File

@@ -23,7 +23,7 @@
"Bed type": "Seng type", "Bed type": "Seng type",
"Book": "Bestill", "Book": "Bestill",
"Book reward night": "Bestill belønningskveld", "Book reward night": "Bestill belønningskveld",
"Booking codes and vouchers": "Bestillingskoder og kuponger", "Code / Voucher": "Bestillingskoder / kuponger",
"Booking number": "Bestillingsnummer", "Booking number": "Bestillingsnummer",
"Breakfast": "Frokost", "Breakfast": "Frokost",
"Breakfast excluded": "Frokost ekskludert", "Breakfast excluded": "Frokost ekskludert",
@@ -183,10 +183,11 @@
"Room & Terms": "Rom & Vilkår", "Room & Terms": "Rom & Vilkår",
"Room facilities": "Romfasiliteter", "Room facilities": "Romfasiliteter",
"Rooms": "Rom", "Rooms": "Rom",
"Rooms & Guests": "Rom og gjester", "Guests & Rooms": "Gjester & rom",
"Save": "Lagre", "Save": "Lagre",
"Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop", "Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Search": "Søk",
"See all photos": "Se alle bilder", "See all photos": "Se alle bilder",
"See hotel details": "Se hotellinformasjon", "See hotel details": "Se hotellinformasjon",
"See room details": "Se detaljer om rommet", "See room details": "Se detaljer om rommet",

View File

@@ -23,7 +23,7 @@
"Bed type": "Sängtyp", "Bed type": "Sängtyp",
"Book": "Boka", "Book": "Boka",
"Book reward night": "Boka frinatt", "Book reward night": "Boka frinatt",
"Booking codes and vouchers": "Bokningskoder och kuponger", "Code / Voucher": "Bokningskoder / kuponger",
"Booking number": "Bokningsnummer", "Booking number": "Bokningsnummer",
"Breakfast": "Frukost", "Breakfast": "Frukost",
"Breakfast excluded": "Frukost ingår ej", "Breakfast excluded": "Frukost ingår ej",
@@ -183,10 +183,11 @@
"Room & Terms": "Rum & Villkor", "Room & Terms": "Rum & Villkor",
"Room facilities": "Rumfaciliteter", "Room facilities": "Rumfaciliteter",
"Rooms": "Rum", "Rooms": "Rum",
"Rooms & Guests": "Rum och gäster", "Guests & Rooms": "Gäster & rum",
"Save": "Spara", "Save": "Spara",
"Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop", "Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Search": "Sök",
"See all photos": "Se alla foton", "See all photos": "Se alla foton",
"See hotel details": "Se hotellinformation", "See hotel details": "Se hotellinformation",
"See room details": "Se rumsdetaljer", "See room details": "Se rumsdetaljer",

View File

@@ -2,4 +2,14 @@ import { z } from "zod"
import { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema" import { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema"
import type { Locations } from "@/types/trpc/routers/hotel/locations"
export type BookingWidgetSchema = z.output<typeof bookingWidgetSchema> export type BookingWidgetSchema = z.output<typeof bookingWidgetSchema>
export interface BookingWidgetClientProps {
locations: Locations
}
export interface BookingWidgetToggleButtonProps {
openMobileSearch: () => void
}

View File

@@ -1,11 +1,17 @@
import { Lang } from "@/constants/languages"
import type { Locale } from "date-fns"
import type { DateRange } from "react-day-picker" import type { DateRange } from "react-day-picker"
export interface DatePickerFormProps { export interface DatePickerFormProps {
name?: string name?: string
} }
type LangWithoutEn = Lang.da | Lang.de | Lang.fi | Lang.no | Lang.sv
export interface DatePickerProps { export interface DatePickerProps {
close: () => void close: () => void
handleOnSelect: (selected: DateRange) => void handleOnSelect: (selected: DateRange) => void
initialSelected?: DateRange locales: Record<LangWithoutEn, Locale>
selectedDate: DateRange
} }

10
utils/debounce.ts Normal file
View File

@@ -0,0 +1,10 @@
export function debounce(func: Function, delay = 300) {
let debounceTimer: ReturnType<typeof setTimeout>
return function () {
// @ts-expect-error this in TypeScript
const context = this
const args = arguments
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => func.apply(context, args), delay)
}
}