Merge branch 'develop' into feat/sw-222-staycard-link-loading

This commit is contained in:
Linus Flood
2024-10-24 13:33:34 +02:00
31 changed files with 283 additions and 158 deletions

View File

@@ -1,5 +1,6 @@
"use server"
import { parsePhoneNumber } from "libphonenumber-js"
import { redirect } from "next/navigation"
import { z } from "zod"
@@ -7,7 +8,7 @@ import { signupVerify } from "@/constants/routes/signup"
import * as api from "@/lib/api"
import { serviceServerActionProcedure } from "@/server/trpc"
import { registerSchema } from "@/components/Forms/Register/schema"
import { signUpSchema } from "@/components/Forms/Signup/schema"
import { passwordValidator } from "@/utils/passwordValidator"
import { phoneValidator } from "@/utils/phoneValidator"
@@ -29,12 +30,14 @@ const registerUserPayload = z.object({
})
export const registerUser = serviceServerActionProcedure
.input(registerSchema)
.input(signUpSchema)
.mutation(async function ({ ctx, input }) {
const payload = {
...input,
language: ctx.lang,
phoneNumber: input.phoneNumber.replace(/\s+/g, ""),
phoneNumber: parsePhoneNumber(input.phoneNumber)
.formatNational()
.replace(/\s+/g, ""),
}
const parsedPayload = registerUserPayload.safeParse(payload)

View File

@@ -17,11 +17,11 @@ export default async function SelectRatePage({
}: PageArgs<LangParams & { section: string }, SelectRateSearchParams>) {
setLang(params.lang)
const selecetRoomParams = new URLSearchParams(searchParams)
const selecetRoomParamsObject =
getHotelReservationQueryParams(selecetRoomParams)
const adults = selecetRoomParamsObject.room[0].adults // TODO: Handle multiple rooms
const children = selecetRoomParamsObject.room[0].child.length // TODO: Handle multiple rooms
const selectRoomParams = new URLSearchParams(searchParams)
const selectRoomParamsObject =
getHotelReservationQueryParams(selectRoomParams)
const adults = selectRoomParamsObject.room[0].adults // TODO: Handle multiple rooms
const children = selectRoomParamsObject.room[0].child?.length // TODO: Handle multiple rooms
const [hotelData, roomConfigurations, user] = await Promise.all([
serverClient().hotel.hotelData.get({
@@ -33,8 +33,8 @@ export default async function SelectRatePage({
hotelId: parseInt(searchParams.hotel, 10),
roomStayStartDate: searchParams.fromDate,
roomStayEndDate: searchParams.toDate,
adults: adults,
children: children,
adults,
children,
}),
getProfileSafely(),
])

View File

@@ -3,7 +3,7 @@ import { redirect } from "next/navigation"
import { overview } from "@/constants/routes/myPages"
import { auth } from "@/auth"
import Form from "@/components/Forms/Register"
import SignupForm from "@/components/Forms/Signup"
import { getLang } from "@/i18n/serverContext"
import { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent"
@@ -16,5 +16,5 @@ export default async function SignupFormWrapper({
// We don't want to allow users to access signup if they are already authenticated.
redirect(overview[getLang()])
}
return <Form {...dynamic_content} />
return <SignupForm {...dynamic_content} />
}

View File

@@ -42,8 +42,8 @@ export default function BookingWidgetClient({
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"),
fromDate: dt().utc().format("YYYY-MM-DD"),
toDate: dt().utc().add(1, "day").format("YYYY-MM-DD"),
},
bookingCode: "",
redemption: false,

View File

@@ -29,6 +29,11 @@
display: none;
}
.overview {
display: grid;
gap: var(--Spacing-x3);
}
.introContainer {
display: flex;
flex-wrap: wrap;
@@ -37,6 +42,11 @@
scroll-margin-top: var(--hotel-page-scroll-margin-top);
}
.alertsContainer {
display: grid;
gap: var(--Spacing-x2);
}
@media screen and (min-width: 1367px) {
.pageContainer {
grid-template-areas:
@@ -76,10 +86,4 @@
padding-left: var(--Spacing-x5);
padding-right: var(--Spacing-x5);
}
.introContainer {
grid-template-columns: 38rem minmax(max-content, 16rem);
justify-content: space-between;
gap: var(--Spacing-x2);
align-items: end;
}
}

View File

@@ -4,6 +4,7 @@ import { serverClient } from "@/lib/trpc/server"
import AccordionSection from "@/components/Blocks/Accordion"
import SidePeekProvider from "@/components/SidePeekProvider"
import Alert from "@/components/TempDesignSystem/Alert"
import SidePeek from "@/components/TempDesignSystem/SidePeek"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
@@ -49,6 +50,7 @@ export default async function HotelPage() {
pointsOfInterest,
facilities,
faq,
alerts,
} = hotelData
const topThreePois = pointsOfInterest.slice(0, 3)
@@ -69,16 +71,30 @@ export default async function HotelPage() {
hasFAQ={!!faq}
/>
<main className={styles.mainSection}>
<div id={HotelHashValues.overview} className={styles.introContainer}>
<IntroSection
hotelName={hotelName}
hotelDescription={hotelDescription}
location={hotelLocation}
address={hotelAddress}
tripAdvisor={hotelRatings?.tripAdvisor}
/>
<div id={HotelHashValues.overview} className={styles.overview}>
<div className={styles.introContainer}>
<IntroSection
hotelName={hotelName}
hotelDescription={hotelDescription}
location={hotelLocation}
address={hotelAddress}
tripAdvisor={hotelRatings?.tripAdvisor}
/>
<AmenitiesList detailedFacilities={hotelDetailedFacilities} />
<AmenitiesList detailedFacilities={hotelDetailedFacilities} />
</div>
{alerts.length ? (
<div className={styles.alertsContainer}>
{alerts.map((alert) => (
<Alert
key={alert.id}
type={alert.type}
heading={alert.heading}
text={alert.text}
/>
))}
</div>
) : null}
</div>
<Rooms rooms={roomCategories} />
<Facilities facilities={facilities} activitiesCard={activitiesCard} />

View File

@@ -44,22 +44,22 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
function handleSelectDate(selected: Date) {
if (isSelectingFrom) {
setValue(name, {
from: dt(selected).format("YYYY-MM-DD"),
to: undefined,
fromDate: dt(selected).format("YYYY-MM-DD"),
toDate: undefined,
})
setIsSelectingFrom(false)
} else {
const fromDate = dt(selectedDate.from)
const fromDate = dt(selectedDate.fromDate)
const toDate = dt(selected)
if (toDate.isAfter(fromDate)) {
setValue(name, {
from: selectedDate.from,
to: toDate.format("YYYY-MM-DD"),
fromDate: selectedDate.fromDate,
toDate: toDate.format("YYYY-MM-DD"),
})
} else {
setValue(name, {
from: toDate.format("YYYY-MM-DD"),
to: selectedDate.from,
fromDate: toDate.format("YYYY-MM-DD"),
toDate: selectedDate.fromDate,
})
}
setIsSelectingFrom(true)
@@ -79,11 +79,11 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
}
}, [setIsOpen])
const selectedFromDate = dt(selectedDate.from)
const selectedFromDate = dt(selectedDate.fromDate)
.locale(lang)
.format("ddd D MMM")
const selectedToDate = !!selectedDate.to
? dt(selectedDate.to).locale(lang).format("ddd D MMM")
const selectedToDate = !!selectedDate.toDate
? dt(selectedDate.toDate).locale(lang).format("ddd D MMM")
: ""
return (
@@ -93,8 +93,8 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
{selectedFromDate} - {selectedToDate}
</Body>
</button>
<input {...register("date.from")} type="hidden" />
<input {...register("date.to")} type="hidden" />
<input {...register("date.fromDate")} type="hidden" />
<input {...register("date.toDate")} type="hidden" />
<div aria-modal className={styles.hideWrapper} role="dialog">
<DatePickerDesktop
close={close}

View File

@@ -27,7 +27,7 @@ export default function FormContent({
const rooms = intl.formatMessage({ id: "Guests & Rooms" })
const nights = dt(selectedDate.to).diff(dt(selectedDate.from), "days")
const nights = dt(selectedDate.toDate).diff(dt(selectedDate.fromDate), "days")
return (
<>

View File

@@ -18,8 +18,8 @@ export const bookingWidgetSchema = z.object({
bookingCode: z.string(), // Update this as required when working with booking codes component
date: z.object({
// Update this as required once started working with Date picker in Nights component
from: z.string(),
to: z.string(),
fromDate: z.string(),
toDate: z.string(),
}),
location: z.string().refine(
(value) => {

View File

@@ -46,4 +46,8 @@
.nameInputs {
grid-template-columns: 1fr 1fr;
}
.signUpButton {
width: fit-content;
}
}

View File

@@ -22,16 +22,21 @@ import Title from "@/components/TempDesignSystem/Text/Title"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import { RegisterSchema, registerSchema } from "./schema"
import { SignUpSchema, signUpSchema } from "./schema"
import styles from "./form.module.css"
import type { RegisterFormProps } from "@/types/components/form/registerForm"
import type { SignUpFormProps } from "@/types/components/form/signupForm"
export default function Form({ link, subtitle, title }: RegisterFormProps) {
export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
const intl = useIntl()
const lang = useLang()
const methods = useForm<RegisterSchema>({
const country = intl.formatMessage({ id: "Country" })
const email = intl.formatMessage({ id: "Email address" })
const phoneNumber = intl.formatMessage({ id: "Phone number" })
const zipCode = intl.formatMessage({ id: "Zip code" })
const methods = useForm<SignUpSchema>({
defaultValues: {
firstName: "",
lastName: "",
@@ -47,15 +52,11 @@ export default function Form({ link, subtitle, title }: RegisterFormProps) {
},
mode: "all",
criteriaMode: "all",
resolver: zodResolver(registerSchema),
resolver: zodResolver(signUpSchema),
reValidateMode: "onChange",
})
const country = intl.formatMessage({ id: "Country" })
const email = `${intl.formatMessage({ id: "Email" })} ${intl.formatMessage({ id: "Address" }).toLowerCase()}`
const phoneNumber = intl.formatMessage({ id: "Phone number" })
const zipCode = intl.formatMessage({ id: "Zip code" })
async function handleSubmit(data: RegisterSchema) {
async function onSubmit(data: SignUpSchema) {
try {
const result = await registerUser(data)
if (result && !result.success) {
@@ -78,12 +79,12 @@ export default function Form({ link, subtitle, title }: RegisterFormProps) {
<form
className={styles.form}
id="register"
onSubmit={methods.handleSubmit(onSubmit)}
/**
* Ignoring since ts doesn't recognize that tRPC
* parses FormData before reaching the route
* @ts-ignore */
action={registerUser}
onSubmit={methods.handleSubmit(handleSubmit)}
>
<section className={styles.userInfo}>
<div className={styles.container}>
@@ -94,12 +95,12 @@ export default function Form({ link, subtitle, title }: RegisterFormProps) {
</header>
<div className={styles.nameInputs}>
<Input
label={"firstName"}
label={intl.formatMessage({ id: "First name" })}
name="firstName"
registerOptions={{ required: true }}
/>
<Input
label={"lastName"}
label={intl.formatMessage({ id: "Last name" })}
name="lastName"
registerOptions={{ required: true }}
/>
@@ -170,14 +171,36 @@ export default function Form({ link, subtitle, title }: RegisterFormProps) {
</Body>
</Checkbox>
</section>
<Button
type="submit"
intent="primary"
disabled={methods.formState.isSubmitting}
data-testid="submit"
>
{intl.formatMessage({ id: "Sign up to Scandic Friends" })}
</Button>
{/*
This is a manual validation trigger workaround:
- The Controller component (which Input uses) doesn't re-render on submit,
which prevents automatic error display.
- Future fix requires Input component refactoring (out of scope for now).
*/}
{!methods.formState.isValid ? (
<Button
className={styles.signUpButton}
type="button"
theme="base"
intent="primary"
onClick={() => methods.trigger()}
data-testid="trigger-validation"
>
{intl.formatMessage({ id: "Sign up to Scandic Friends" })}
</Button>
) : (
<Button
className={styles.signUpButton}
type="submit"
theme="base"
intent="primary"
disabled={methods.formState.isSubmitting}
data-testid="submit"
>
{intl.formatMessage({ id: "Sign up to Scandic Friends" })}
</Button>
)}
</form>
</FormProvider>
</section>

View File

@@ -3,19 +3,14 @@ import { z } from "zod"
import { passwordValidator } from "@/utils/passwordValidator"
import { phoneValidator } from "@/utils/phoneValidator"
export const registerSchema = z.object({
firstName: z
.string()
.max(250)
.refine((value) => value.trim().length > 0, {
message: "First name is required",
}),
lastName: z
.string()
.max(250)
.refine((value) => value.trim().length > 0, {
message: "Last name is required",
}),
const countryRequiredMsg = "Country is required"
export const signUpSchema = z.object({
firstName: z.string().max(250).trim().min(1, {
message: "First name is required",
}),
lastName: z.string().max(250).trim().min(1, {
message: "Last name is required",
}),
email: z.string().max(250).email(),
phoneNumber: phoneValidator(
"Phone is required",
@@ -23,7 +18,12 @@ export const registerSchema = z.object({
),
dateOfBirth: z.string().min(1),
address: z.object({
countryCode: z.string(),
countryCode: z
.string({
required_error: countryRequiredMsg,
invalid_type_error: countryRequiredMsg,
})
.min(1, countryRequiredMsg),
zipCode: z.string().min(1),
}),
password: passwordValidator("Password is required"),
@@ -32,4 +32,4 @@ export const registerSchema = z.object({
}),
})
export type RegisterSchema = z.infer<typeof registerSchema>
export type SignUpSchema = z.infer<typeof signUpSchema>

View File

@@ -14,15 +14,16 @@
.imageContainer {
grid-area: image;
position: relative;
height: 100%;
width: 116px;
}
.tripAdvisor {
display: none;
}
.image {
height: 100%;
width: 116px;
.imageContainer img {
object-fit: cover;
}
@@ -77,6 +78,8 @@
.imageContainer {
position: relative;
min-height: 200px;
width: 518px;
}
.tripAdvisor {
@@ -86,10 +89,6 @@
top: 7px;
}
.image {
width: 518px;
}
.hotelInformation {
padding-top: var(--Spacing-x2);
padding-right: var(--Spacing-x2);

View File

@@ -11,10 +11,11 @@ import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import ReadMore from "../ReadMore"
import ImageGallery from "../SelectRate/ImageGallery"
import styles from "./hotelCard.module.css"
import { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
import type { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
export default async function HotelCard({ hotel }: HotelCardProps) {
const intl = await getIntl()
@@ -27,13 +28,15 @@ export default async function HotelCard({ hotel }: HotelCardProps) {
return (
<article className={styles.card}>
<section className={styles.imageContainer}>
<Image
src={hotelData.hotelContent.images.imageSizes.medium}
alt={hotelData.hotelContent.images.metaData.altText}
width={300}
height={200}
className={styles.image}
/>
{hotelData.gallery && (
<ImageGallery
title={hotelData.name}
images={[
hotelData.hotelContent.images,
...hotelData.gallery.heroImages,
]}
/>
)}
<div className={styles.tripAdvisor}>
<Chip intent="primary" className={styles.tripAdvisor}>
<TripAdvisorIcon color="white" />
@@ -102,7 +105,11 @@ export default async function HotelCard({ hotel }: HotelCardProps) {
className={styles.button}
>
{/* TODO: Localize link and also use correct search params */}
<Link href="/en/hotelreservation/select-rate" color="none">
<Link
href={`/en/hotelreservation/select-rate?hotel=${hotelData.operaId}`}
color="none"
keepSearchParams
>
{intl.formatMessage({ id: "See rooms" })}
</Link>
</Button>

View File

@@ -12,10 +12,6 @@
gap: var(--Spacing-x2);
}
.image {
border-radius: var(--Corner-radius-Medium);
}
.imageWrapper {
position: relative;
overflow: hidden;
@@ -24,6 +20,10 @@
width: 100%;
}
.imageWrapper img {
border-radius: var(--Corner-radius-Medium);
}
.tripAdvisor {
display: flex;
align-items: center;

View File

@@ -10,6 +10,7 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import ReadMore from "../../ReadMore"
import ImageGallery from "../ImageGallery"
import styles from "./hotelInfoCard.module.css"
@@ -28,12 +29,6 @@ export default function HotelInfoCard({ hotelData }: HotelInfoCardProps) {
{hotelAttributes && (
<section className={styles.wrapper}>
<div className={styles.imageWrapper}>
<Image
src={hotelAttributes.hotelContent.images.imageSizes.medium}
alt={hotelAttributes.hotelContent.images.metaData.altText}
className={styles.image}
fill
/>
{hotelAttributes.ratings?.tripAdvisor && (
<div className={styles.tripAdvisor}>
<TripAdvisorIcon color="burgundy" />
@@ -42,7 +37,15 @@ export default function HotelInfoCard({ hotelData }: HotelInfoCardProps) {
</Caption>
</div>
)}
{/* TODO: gallery icon and image carousel */}
{hotelAttributes.gallery && (
<ImageGallery
title={hotelAttributes.name}
images={[
hotelAttributes.hotelContent.images,
...hotelAttributes.gallery.heroImages,
]}
/>
)}
</div>
<div className={styles.hotelContent}>
<div className={styles.hotelInformation}>

View File

@@ -0,0 +1,17 @@
.galleryIcon {
position: absolute;
bottom: 16px;
right: 16px;
max-height: 32px;
width: 48px;
background-color: rgba(0, 0, 0, 0.6);
padding: var(--Spacing-x-quarter) var(--Spacing-x-half);
border-radius: var(--Corner-radius-Small);
display: flex;
align-items: center;
gap: var(--Spacing-x-quarter);
}
.triggerArea {
cursor: pointer;
}

View File

@@ -0,0 +1,36 @@
import { GalleryIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Lightbox from "@/components/Lightbox"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import styles from "./imageGallery.module.css"
import type { ImageGalleryProps } from "@/types/components/hotelReservation/selectRate/imageGallery"
export default function ImageGallery({ images, title }: ImageGalleryProps) {
return (
<Lightbox
images={images.map((image) => ({
url: image.imageSizes.small,
alt: image.metaData.altText,
title: image.metaData.title,
}))}
dialogTitle={title}
>
<div className={styles.triggerArea} id="lightboxTrigger">
<Image
src={images[0].imageSizes.medium}
alt={images[0].metaData.altText}
className={styles.image}
fill
/>
<div className={styles.galleryIcon}>
<GalleryIcon color="white" />
<Footnote color="white" type="label">
{images.length}
</Footnote>
</div>
</div>
</Lightbox>
)
}

View File

@@ -5,18 +5,18 @@ import { useIntl } from "react-intl"
import { RateDefinition } from "@/server/routers/hotels/output"
import FlexibilityOption from "@/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption"
import { ChevronRightSmallIcon, GalleryIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Lightbox from "@/components/Lightbox"
import { ChevronRightSmallIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import ImageGallery from "../../ImageGallery"
import styles from "./roomCard.module.css"
import { RoomCardProps } from "@/types/components/hotelReservation/selectRate/roomCard"
import type { RoomCardProps } from "@/types/components/hotelReservation/selectRate/roomCard"
export default function RoomCard({
rateDefinitions,
@@ -25,7 +25,6 @@ export default function RoomCard({
handleSelectRate,
}: RoomCardProps) {
const intl = useIntl()
const saveRate = rateDefinitions.find(
// TODO: Update string when API has decided
(rate) => rate.cancellationRule === "NonCancellable"
@@ -153,26 +152,8 @@ export default function RoomCard({
)}
{/*NOTE: images from the test API are hosted on test3.scandichotels.com,
which can't be accessed unless on Scandic's Wifi or using Citrix. */}
<Image
src={mainImage.imageSizes.small}
alt={mainImage.metaData.altText}
width={330}
height={185}
/>
{images && (
<Lightbox
images={images.map((image) => ({
url: image.imageSizes.small,
alt: image.metaData.altText,
title: image.metaData.title,
}))}
dialogTitle={roomConfiguration.roomType}
>
<div className={styles.galleryIcon} id="lightboxTrigger">
<GalleryIcon color="white" />
<Footnote color="white">{images.length}</Footnote>
</div>
</Lightbox>
<ImageGallery images={images} title={roomConfiguration.roomType} />
)}
</div>
)}

View File

@@ -77,17 +77,3 @@
min-height: 185px;
position: relative;
}
.galleryIcon {
position: absolute;
bottom: 16px;
right: 16px;
height: 24px;
background-color: rgba(64, 57, 55, 0.9);
padding: 0 var(--Spacing-x-half);
border-radius: var(--Corner-radius-Small);
cursor: pointer;
display: flex;
align-items: center;
gap: var(--Spacing-x-quarter);
}

View File

@@ -8,8 +8,8 @@ import type { SidepeekContent } from "@/types/trpc/routers/contentstack/siteConf
export interface AlertProps extends VariantProps<typeof alertVariants> {
className?: string
type: AlertTypeEnum
heading?: string
text: string
heading?: string | null
text?: string | null
phoneContact?: {
displayText: string
phoneNumber?: string

View File

@@ -27,6 +27,10 @@ export default function Alert({
})
const Icon = getIconByAlertType(type)
if (!text && !heading) {
return null
}
return (
<section className={classNames}>
<div className={styles.content}>

View File

@@ -21,7 +21,6 @@
}
.checkbox {
flex-grow: 1;
width: 24px;
height: 24px;
min-width: 24px;

View File

@@ -1,7 +1,7 @@
.phone {
display: grid;
gap: var(--Spacing-x2);
grid-template-columns: max(164px) 1fr;
grid-template-columns: minmax(124px, 164px) 1fr;
--react-international-phone-background-color: var(--Main-Grey-White);
--react-international-phone-border-color: var(--Scandic-Beige-40);

View File

@@ -51,9 +51,10 @@ export default function Link({
const router = useRouter()
const fullUrl = useMemo(() => {
const search =
keepSearchParams && searchParams.size ? `?${searchParams}` : ""
return `${href}${search}`
if (!keepSearchParams || !searchParams.size) return href
const delimiter = href.includes("?") ? "&" : "?"
return `${href}${delimiter}${searchParams}`
}, [href, searchParams, keepSearchParams])
// TODO: Remove this check (and hook) and only return <Link /> when current web is deleted

View File

@@ -1,11 +1,13 @@
import { z } from "zod"
import { dt } from "@/lib/dt"
import { toLang } from "@/server/utils"
import { imageMetaDataSchema, imageSizesSchema } from "./schemas/image"
import { roomSchema } from "./schemas/room"
import { getPoiGroupByCategoryName } from "./utils"
import { AlertTypeEnum } from "@/types/enums/alert"
import { FacilityEnum } from "@/types/enums/facilities"
import { PointOfInterestCategoryNameEnum } from "@/types/hotel"
@@ -160,6 +162,21 @@ export const facilitySchema = z.object({
),
})
export const gallerySchema = z.object({
heroImages: z.array(
z.object({
metaData: imageMetaDataSchema,
imageSizes: imageSizesSchema,
})
),
smallerImages: z.array(
z.object({
metaData: imageMetaDataSchema,
imageSizes: imageSizesSchema,
})
),
})
const healthFacilitySchema = z.object({
type: z.string(),
content: z.object({
@@ -321,6 +338,7 @@ const socialMediaSchema = z.object({
const metaSpecialAlertSchema = z.object({
type: z.string(),
title: z.string().optional(),
description: z.string().optional(),
displayInBookingFlow: z.boolean(),
startDate: z.string(),
@@ -328,7 +346,23 @@ const metaSpecialAlertSchema = z.object({
})
const metaSchema = z.object({
specialAlerts: z.array(metaSpecialAlertSchema),
specialAlerts: z
.array(metaSpecialAlertSchema)
.transform((data) => {
const now = dt().utc().format("YYYY-MM-DD")
const filteredAlerts = data.filter((alert) => {
const shouldShowNow = alert.startDate <= now && alert.endDate >= now
const hasText = alert.description || alert.title
return shouldShowNow && hasText
})
return filteredAlerts.map((alert, idx) => ({
id: `alert-${alert.type}-${idx}`,
type: AlertTypeEnum.Info,
heading: alert.title || null,
text: alert.description || null,
}))
})
.default([]),
})
const relationshipsSchema = z.object({
@@ -422,6 +456,7 @@ export const getHotelDataSchema = z.object({
conferencesAndMeetings: facilitySchema.optional(),
healthAndWellness: facilitySchema.optional(),
restaurantImages: facilitySchema.optional(),
gallery: gallerySchema.optional(),
}),
relationships: relationshipsSchema,
}),

View File

@@ -219,6 +219,7 @@ export const hotelQueryRouter = router({
const hotelAttributes = validatedHotelData.data.data.attributes
const images = extractHotelImages(hotelAttributes)
const hotelAlerts = hotelAttributes.meta?.specialAlerts || []
const roomCategories = included
? included.filter((item) => item.type === "roomcategories")
@@ -262,6 +263,7 @@ export const hotelQueryRouter = router({
roomCategories,
activitiesCard: activities?.upcoming_activities_card,
facilities,
alerts: hotelAlerts,
faq: contentstackData?.faq,
}
}),

View File

@@ -1,4 +1,4 @@
export type RegisterFormProps = {
export type SignUpFormProps = {
link?: { href: string; text: string }
subtitle?: string
title: string

View File

@@ -0,0 +1,3 @@
import type { GalleryImages } from "@/types/hotel"
export type ImageGalleryProps = { images: GalleryImages; title: string }

View File

@@ -7,9 +7,9 @@ interface Child {
interface Room {
adults: number
roomtypecode: string
ratecode: string
child: Child[]
roomcode?: string
ratecode?: string
child?: Child[]
}
export interface SelectRateSearchParams {

View File

@@ -2,6 +2,7 @@ import { z } from "zod"
import {
facilitySchema,
gallerySchema,
getHotelDataSchema,
parkingSchema,
pointOfInterestSchema,
@@ -13,7 +14,6 @@ export type HotelData = z.infer<typeof getHotelDataSchema>
export type Hotel = HotelData["data"]["attributes"]
export type HotelAddress = HotelData["data"]["attributes"]["address"]
export type HotelLocation = HotelData["data"]["attributes"]["location"]
export type Amenities = HotelData["data"]["attributes"]["detailedFacilities"]
type HotelRatings = HotelData["data"]["attributes"]["ratings"]
@@ -22,6 +22,8 @@ export type HotelTripAdvisor =
| undefined
export type RoomData = z.infer<typeof roomSchema>
export type GallerySchema = z.infer<typeof gallerySchema>
export type GalleryImages = GallerySchema["heroImages"]
export type PointOfInterest = z.output<typeof pointOfInterestSchema>