Merge branch 'develop' into feat/sw-222-staycard-link-loading
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
])
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -46,4 +46,8 @@
|
||||
.nameInputs {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.signUpButton {
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
flex-grow: 1;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type RegisterFormProps = {
|
||||
export type SignUpFormProps = {
|
||||
link?: { href: string; text: string }
|
||||
subtitle?: string
|
||||
title: string
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { GalleryImages } from "@/types/hotel"
|
||||
|
||||
export type ImageGalleryProps = { images: GalleryImages; title: string }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user