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

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