Feat/LOY-400 create spend points modal * feat(LOY-400): Added custom button to my pages overview and skeleton file to custom modal for my points. * feat(LOY-400): Added custom button to my pages overview and components for custom modal for my points. * feat(LOY-400): Changed some style and infogridcardover * feat(LOY-400):Removed custom card components and changed in infoCard: Added imagePosition top, added optional height prop. In Card: Changed Text-wrap styling, added min-width styling to buttons, added optional Icon prop, added optional height prop * feat(LOY-400):Added linkList, LinkListItem component and messageBanner component. Added granola illustration. * feat(LOY-400): Removed background in several illustrations. Added component for illustration. Fixed LinkedList and styling for UsePointsButton. * feat(LOY-400): Added modal to PointsToSpendCard and fixed UsePointsButton. * fix(LOY-400):added some styling * feat(LOY-400): Linked Modal to contentstack and fetch the data in cards with UsePointsModal for now * feat(LOY-400): changed link to aria-component, cleaned up a bit * feat(LOY-400): Changed height for larger modals in mobile, fixed zod schema for no illustration input, cleaned up * fix(LOY-400): fixed graphql after rebase * fix(LOY-400): mini fix * fix(LOY-400): fixed pr-comments * fix(LOY-400): fixed some PR-comments * fix(LOY-400): fixed a PR-comment * feat(LOY-400): added size prop to ilustration in LinkListItem to be able to use illustrations in IllustrationByIconName * fix(LOY-400): fixed pr-comments * Merged in feat/LOY-402-pre-ticked-book-reward-night-in-booking-flow (pull request #3210) Feat/LOY-402 pre ticked book reward night in booking flow * feat(LOY-402): Changed UsePointsModal structure to handle button actions in card. * feat(LOY-402): added functionality for book now button * feat(LOY-400): pr comment fix * feat(LOY-402): transformed the contentstack data * fix(LOY-402): fixed pr comments Approved-by: Chuma Mcphoy (We Ahead) Approved-by: Anton Gunnarsson Approved-by: Matilda Landström * Merged in feat/LOY-404-add-tracking-for-spend-points-modal (pull request #3229) Feat/LOY-404 add tracking for spend points modal * feat(LOY-402): Changed UsePointsModal structure to handle button actions in card. * feat(LOY-402): added functionality for book now button * feat(LOY-400): pr comment fix * feat(LOY-402): transformed the contentstack data * feat(LOY-404): added tracking * fix(LOY-404): fix for session storage removal of bookNowFromPointsModal * feat(LOY-404): added consts * fix(LOY-404): moved foxusWidget const * fix(LOY-404): moved BOOKING_WIDGET_STATE const * fix(LOY-404):fix Approved-by: Matilda Landström * fix(LOY-400): some fixes * feat(LOY-400): created linkList storybook Approved-by: Chuma Mcphoy (We Ahead) Approved-by: Matilda Landström
267 lines
7.9 KiB
TypeScript
267 lines
7.9 KiB
TypeScript
"use client"
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod"
|
|
import { useSearchParams } from "next/navigation"
|
|
import { use, useEffect, useRef, useState } from "react"
|
|
import { FormProvider, useForm } from "react-hook-form"
|
|
|
|
import { dt } from "@scandic-hotels/common/dt"
|
|
import { useScrollLock } from "@scandic-hotels/common/hooks/useScrollLock"
|
|
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
|
|
import { StickyElementNameEnum } from "@scandic-hotels/common/stores/sticky-position"
|
|
import { debounce } from "@scandic-hotels/common/utils/debounce"
|
|
import isValidJson from "@scandic-hotels/common/utils/isValidJson"
|
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
|
import { trpc } from "@scandic-hotels/trpc/client"
|
|
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
|
|
|
|
import { useBookingFlowConfig } from "../../bookingFlowConfig/bookingFlowConfigContext"
|
|
import { useBookingWidgetState } from "../../hooks/useBookingWidgetState"
|
|
import useLang from "../../hooks/useLang"
|
|
import {
|
|
type bookingCodeSchema,
|
|
bookingWidgetSchema,
|
|
} from "./BookingWidgetForm/schema"
|
|
import Form from "./BookingWidgetForm"
|
|
import MobileToggleButton from "./MobileToggleButton"
|
|
import { BookingWidgetSkeleton } from "./Skeleton"
|
|
import {
|
|
bookingWidgetContainerVariants,
|
|
formContainerVariants,
|
|
} from "./variant"
|
|
|
|
import styles from "./bookingWidget.module.css"
|
|
|
|
import type { z } from "zod"
|
|
|
|
import type { BookingWidgetSearchData, BookingWidgetType } from "."
|
|
|
|
export type BookingWidgetSchema = z.output<typeof bookingWidgetSchema>
|
|
export type BookingCodeSchema = z.output<typeof bookingCodeSchema>
|
|
|
|
export type BookingWidgetClientProps = {
|
|
type?: BookingWidgetType
|
|
data: BookingWidgetSearchData
|
|
pageSettingsBookingCodePromise: Promise<string> | null
|
|
}
|
|
export const FOCUS_WIDGET = "focusWidget"
|
|
|
|
export default function BookingWidgetClient({
|
|
type,
|
|
data,
|
|
pageSettingsBookingCodePromise,
|
|
}: BookingWidgetClientProps) {
|
|
const searchParams = useSearchParams()
|
|
const focusWidget = searchParams.get(FOCUS_WIDGET) === "true"
|
|
|
|
const [isOpen, setIsOpen] = useState(focusWidget)
|
|
const bookingWidgetRef = useRef(null)
|
|
const lang = useLang()
|
|
const bookingFlowConfig = useBookingFlowConfig()
|
|
const storedBookingWidgetState = useBookingWidgetState()
|
|
const { lockScroll, unlockScroll } = useScrollLock({
|
|
autoLock: false,
|
|
})
|
|
const shouldFetchAutoComplete = !!data.hotelId || !!data.city
|
|
|
|
const { data: destinationsData, isPending } =
|
|
trpc.autocomplete.destinations.useQuery(
|
|
{
|
|
lang,
|
|
query: "",
|
|
includeTypes: ["hotels", "cities"],
|
|
selectedHotelId: data.hotelId ? data.hotelId.toString() : undefined,
|
|
selectedCity: data.city,
|
|
},
|
|
{ enabled: shouldFetchAutoComplete }
|
|
)
|
|
const shouldShowSkeleton = shouldFetchAutoComplete && isPending
|
|
|
|
useStickyPosition({
|
|
ref: bookingWidgetRef,
|
|
name: StickyElementNameEnum.BOOKING_WIDGET,
|
|
})
|
|
|
|
const now = dt()
|
|
// if fromDate or toDate is undefined, dt will return value that represents the same as 'now' above.
|
|
// this is fine as isDateParamValid will catch this and default the values accordingly.
|
|
let fromDate = dt(data.fromDate)
|
|
let toDate = dt(data.toDate)
|
|
|
|
const isDateParamValid =
|
|
fromDate.isValid() &&
|
|
toDate.isValid() &&
|
|
fromDate.isSameOrAfter(now, "day") &&
|
|
toDate.isAfter(fromDate)
|
|
|
|
if (!isDateParamValid) {
|
|
fromDate = now
|
|
toDate = now.add(1, "day")
|
|
}
|
|
|
|
const selectedLocation =
|
|
destinationsData?.currentSelection.hotel ??
|
|
destinationsData?.currentSelection.city
|
|
|
|
// if bookingCode is not provided in the search params,
|
|
// we will fetch it from the page settings stored in Contentstack.
|
|
const selectedBookingCode =
|
|
data.bookingCode ||
|
|
(pageSettingsBookingCodePromise !== null
|
|
? use(pageSettingsBookingCodePromise)
|
|
: "")
|
|
|
|
const defaultRoomsData: BookingWidgetSchema["rooms"] = data.rooms?.map(
|
|
(room) => ({
|
|
adults: room.adults,
|
|
childrenInRoom: room.childrenInRoom || [],
|
|
})
|
|
) ?? [
|
|
{
|
|
adults: 1,
|
|
childrenInRoom: [],
|
|
},
|
|
]
|
|
const hotelId = data.hotelId ? parseInt(data.hotelId) : undefined
|
|
const methods = useForm({
|
|
defaultValues: {
|
|
search: selectedLocation?.name ?? "",
|
|
// Only used for displaying the selected location for mobile, not for actual form input
|
|
selectedSearch: selectedLocation?.name ?? "",
|
|
date: {
|
|
fromDate: fromDate.format("YYYY-MM-DD"),
|
|
toDate: toDate.format("YYYY-MM-DD"),
|
|
},
|
|
bookingCode: {
|
|
value: bookingFlowConfig.bookingCodeEnabled ? selectedBookingCode : "",
|
|
remember: false,
|
|
},
|
|
redemption: data.searchType === SEARCH_TYPE_REDEMPTION,
|
|
rooms: defaultRoomsData,
|
|
city: data.city || undefined,
|
|
hotel: hotelId,
|
|
},
|
|
shouldFocusError: false,
|
|
mode: "onSubmit",
|
|
resolver: zodResolver(bookingWidgetSchema),
|
|
reValidateMode: "onSubmit",
|
|
})
|
|
|
|
const bookingCodeFromSearchParams = searchParams.get("bookingCode") || ""
|
|
const [bookingCode, setBookingCode] = useState(bookingCodeFromSearchParams)
|
|
|
|
if (bookingCode !== bookingCodeFromSearchParams) {
|
|
methods.setValue("bookingCode", {
|
|
value: bookingCodeFromSearchParams,
|
|
})
|
|
setBookingCode(bookingCodeFromSearchParams)
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!selectedLocation) return
|
|
|
|
/*
|
|
If `trpc.hotel.locations.get.useQuery` hasn't been fetched previously and is hence async
|
|
we need to update the default values when data is available
|
|
*/
|
|
methods.setValue("search", selectedLocation.name)
|
|
methods.setValue("selectedSearch", selectedLocation.name)
|
|
}, [selectedLocation, methods])
|
|
|
|
function closeMobileSearch() {
|
|
unlockScroll()
|
|
setIsOpen(false)
|
|
}
|
|
|
|
function openMobileSearch() {
|
|
lockScroll()
|
|
setIsOpen(true)
|
|
}
|
|
|
|
useEffect(() => {
|
|
const observer = new ResizeObserver(
|
|
debounce(([entry]) => {
|
|
if (entry.contentRect.width > 768) {
|
|
setIsOpen(false)
|
|
unlockScroll()
|
|
}
|
|
})
|
|
)
|
|
|
|
observer.observe(document.body)
|
|
|
|
return () => {
|
|
observer.unobserve(document.body)
|
|
}
|
|
}, [unlockScroll])
|
|
|
|
useEffect(() => {
|
|
if (!window?.sessionStorage || !window?.localStorage) return
|
|
|
|
if (!selectedBookingCode) {
|
|
const storedBookingCode = localStorage.getItem("bookingCode")
|
|
const initialBookingCode: BookingCodeSchema | undefined =
|
|
storedBookingCode && isValidJson(storedBookingCode)
|
|
? JSON.parse(storedBookingCode)
|
|
: undefined
|
|
|
|
if (initialBookingCode?.remember) {
|
|
methods.setValue("bookingCode", initialBookingCode)
|
|
}
|
|
}
|
|
}, [methods, selectedBookingCode])
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!data.fromDate &&
|
|
!data.toDate &&
|
|
!data.rooms &&
|
|
storedBookingWidgetState
|
|
) {
|
|
methods.reset({
|
|
...methods.getValues(),
|
|
date: {
|
|
fromDate: storedBookingWidgetState.fromDate,
|
|
toDate: storedBookingWidgetState.toDate,
|
|
},
|
|
rooms: storedBookingWidgetState.rooms,
|
|
})
|
|
}
|
|
}, [data, methods, storedBookingWidgetState])
|
|
|
|
if (shouldShowSkeleton) {
|
|
return <BookingWidgetSkeleton type={type} config={bookingFlowConfig} />
|
|
}
|
|
|
|
const classNames = bookingWidgetContainerVariants({
|
|
type,
|
|
})
|
|
|
|
const formContainerClassNames = formContainerVariants({
|
|
type,
|
|
})
|
|
|
|
return (
|
|
<FormProvider {...methods}>
|
|
<section
|
|
ref={bookingWidgetRef}
|
|
className={classNames}
|
|
data-booking-widget-open={isOpen}
|
|
>
|
|
<MobileToggleButton openMobileSearch={openMobileSearch} />
|
|
<div className={styles.backdrop} onClick={closeMobileSearch} />
|
|
<div className={formContainerClassNames}>
|
|
<button
|
|
className={styles.close}
|
|
onClick={closeMobileSearch}
|
|
type="button"
|
|
>
|
|
<MaterialIcon icon="close" />
|
|
</button>
|
|
<Form type={type} onClose={closeMobileSearch} />
|
|
</div>
|
|
</section>
|
|
</FormProvider>
|
|
)
|
|
}
|