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 (
<section className={styles.stats}>
<Points user={user} />
<Divider variant="default" color="pale" />
<Divider color="pale" />
<ExpiringPoints user={user} />
</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 {
display: none;
@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 (min-width: 1367px) {
@@ -8,4 +31,8 @@
border-top: 1px solid var(--Base-Border-Subtle);
display: block;
}
.close {
display: none;
}
}

View File

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

View File

@@ -1,49 +1,30 @@
"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 { 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 { ChevronLeftIcon } from "../Icons"
import styles from "./date-picker.module.css"
import styles from "./desktop.module.css"
import classNames from "react-day-picker/style.module.css"
import type { DatePickerProps } from "@/types/components/datepicker"
const locales = {
[Lang.da]: da,
[Lang.de]: de,
[Lang.fi]: fi,
[Lang.no]: nb,
[Lang.sv]: sv,
}
export default function DatePicker({
export default function DatePickerDesktop({
close,
handleOnSelect,
initialSelected = {
from: undefined,
to: undefined,
},
locales,
selectedDate,
}: DatePickerProps) {
const lang = useLang()
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 */
const locale = lang === Lang.en ? undefined : locales[lang]
@@ -63,6 +44,7 @@ export default function DatePicker({
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}`,
}}
@@ -78,7 +60,7 @@ export default function DatePicker({
locale={locale}
mode="range"
numberOfMonths={2}
onSelect={handleSelectDate}
onSelect={handleOnSelect}
pagedNavigation
required
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 {
background: none;
border: none;
@@ -34,117 +20,42 @@
opacity: 0.8;
}
div.months {
flex-wrap: nowrap;
.hideWrapper {
background-color: var(--Main-Grey-White);
}
.monthCaption {
justify-content: center;
@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;
}
}
.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;
@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"
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)
@@ -44,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])
@@ -67,10 +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>

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

View File

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

View File

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

View File

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

View File

@@ -29,7 +29,7 @@ export const bookingWidgetSchema = z.object({
// 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(),

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 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"

View File

@@ -1,40 +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 {
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 {

View File

@@ -5,6 +5,7 @@ import styles from "./divider.module.css"
export const dividerVariants = cva(styles.divider, {
variants: {
color: {
baseSurfaceSubtleNormal: styles.baseSurfaceSubtleNormal,
beige: styles.beige,
burgundy: styles.burgundy,
pale: styles.pale,
@@ -18,13 +19,13 @@ export const dividerVariants = cva(styles.divider, {
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",
},
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,4 +2,14 @@ import { z } from "zod"
import { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema"
import type { Locations } from "@/types/trpc/routers/hotel/locations"
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"
export interface DatePickerFormProps {
name?: string
}
type LangWithoutEn = Lang.da | Lang.de | Lang.fi | Lang.no | Lang.sv
export interface DatePickerProps {
close: () => 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)
}
}