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

View File

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

View File

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

View File

@@ -42,8 +42,8 @@ export default function BookingWidgetClient({
date: { date: {
// UTC is required to handle requests from far away timezones https://scandichotels.atlassian.net/browse/SWAP-6375 & PET-507 // 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. // This is specifically to handle timezones falling in different dates.
from: dt().utc().format("YYYY-MM-DD"), fromDate: dt().utc().format("YYYY-MM-DD"),
to: dt().utc().add(1, "day").format("YYYY-MM-DD"), toDate: dt().utc().add(1, "day").format("YYYY-MM-DD"),
}, },
bookingCode: "", bookingCode: "",
redemption: false, redemption: false,

View File

@@ -29,6 +29,11 @@
display: none; display: none;
} }
.overview {
display: grid;
gap: var(--Spacing-x3);
}
.introContainer { .introContainer {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -37,6 +42,11 @@
scroll-margin-top: var(--hotel-page-scroll-margin-top); scroll-margin-top: var(--hotel-page-scroll-margin-top);
} }
.alertsContainer {
display: grid;
gap: var(--Spacing-x2);
}
@media screen and (min-width: 1367px) { @media screen and (min-width: 1367px) {
.pageContainer { .pageContainer {
grid-template-areas: grid-template-areas:
@@ -76,10 +86,4 @@
padding-left: var(--Spacing-x5); padding-left: var(--Spacing-x5);
padding-right: 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 AccordionSection from "@/components/Blocks/Accordion"
import SidePeekProvider from "@/components/SidePeekProvider" import SidePeekProvider from "@/components/SidePeekProvider"
import Alert from "@/components/TempDesignSystem/Alert"
import SidePeek from "@/components/TempDesignSystem/SidePeek" import SidePeek from "@/components/TempDesignSystem/SidePeek"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext" import { getLang } from "@/i18n/serverContext"
@@ -49,6 +50,7 @@ export default async function HotelPage() {
pointsOfInterest, pointsOfInterest,
facilities, facilities,
faq, faq,
alerts,
} = hotelData } = hotelData
const topThreePois = pointsOfInterest.slice(0, 3) const topThreePois = pointsOfInterest.slice(0, 3)
@@ -69,16 +71,30 @@ export default async function HotelPage() {
hasFAQ={!!faq} hasFAQ={!!faq}
/> />
<main className={styles.mainSection}> <main className={styles.mainSection}>
<div id={HotelHashValues.overview} className={styles.introContainer}> <div id={HotelHashValues.overview} className={styles.overview}>
<IntroSection <div className={styles.introContainer}>
hotelName={hotelName} <IntroSection
hotelDescription={hotelDescription} hotelName={hotelName}
location={hotelLocation} hotelDescription={hotelDescription}
address={hotelAddress} location={hotelLocation}
tripAdvisor={hotelRatings?.tripAdvisor} 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> </div>
<Rooms rooms={roomCategories} /> <Rooms rooms={roomCategories} />
<Facilities facilities={facilities} activitiesCard={activitiesCard} /> <Facilities facilities={facilities} activitiesCard={activitiesCard} />

View File

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

View File

@@ -27,7 +27,7 @@ export default function FormContent({
const rooms = intl.formatMessage({ id: "Guests & Rooms" }) 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 ( 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 bookingCode: z.string(), // Update this as required when working with booking codes component
date: z.object({ date: z.object({
// Update this as required once started working with Date picker in Nights component // Update this as required once started working with Date picker in Nights component
from: z.string(), fromDate: z.string(),
to: z.string(), toDate: z.string(),
}), }),
location: z.string().refine( location: z.string().refine(
(value) => { (value) => {

View File

@@ -46,4 +46,8 @@
.nameInputs { .nameInputs {
grid-template-columns: 1fr 1fr; 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 { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import { RegisterSchema, registerSchema } from "./schema" import { SignUpSchema, signUpSchema } from "./schema"
import styles from "./form.module.css" 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 intl = useIntl()
const lang = useLang() 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: { defaultValues: {
firstName: "", firstName: "",
lastName: "", lastName: "",
@@ -47,15 +52,11 @@ export default function Form({ link, subtitle, title }: RegisterFormProps) {
}, },
mode: "all", mode: "all",
criteriaMode: "all", criteriaMode: "all",
resolver: zodResolver(registerSchema), resolver: zodResolver(signUpSchema),
reValidateMode: "onChange", 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 { try {
const result = await registerUser(data) const result = await registerUser(data)
if (result && !result.success) { if (result && !result.success) {
@@ -78,12 +79,12 @@ export default function Form({ link, subtitle, title }: RegisterFormProps) {
<form <form
className={styles.form} className={styles.form}
id="register" id="register"
onSubmit={methods.handleSubmit(onSubmit)}
/** /**
* Ignoring since ts doesn't recognize that tRPC * Ignoring since ts doesn't recognize that tRPC
* parses FormData before reaching the route * parses FormData before reaching the route
* @ts-ignore */ * @ts-ignore */
action={registerUser} action={registerUser}
onSubmit={methods.handleSubmit(handleSubmit)}
> >
<section className={styles.userInfo}> <section className={styles.userInfo}>
<div className={styles.container}> <div className={styles.container}>
@@ -94,12 +95,12 @@ export default function Form({ link, subtitle, title }: RegisterFormProps) {
</header> </header>
<div className={styles.nameInputs}> <div className={styles.nameInputs}>
<Input <Input
label={"firstName"} label={intl.formatMessage({ id: "First name" })}
name="firstName" name="firstName"
registerOptions={{ required: true }} registerOptions={{ required: true }}
/> />
<Input <Input
label={"lastName"} label={intl.formatMessage({ id: "Last name" })}
name="lastName" name="lastName"
registerOptions={{ required: true }} registerOptions={{ required: true }}
/> />
@@ -170,14 +171,36 @@ export default function Form({ link, subtitle, title }: RegisterFormProps) {
</Body> </Body>
</Checkbox> </Checkbox>
</section> </section>
<Button
type="submit" {/*
intent="primary" This is a manual validation trigger workaround:
disabled={methods.formState.isSubmitting} - The Controller component (which Input uses) doesn't re-render on submit,
data-testid="submit" which prevents automatic error display.
> - Future fix requires Input component refactoring (out of scope for now).
{intl.formatMessage({ id: "Sign up to Scandic Friends" })} */}
</Button> {!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> </form>
</FormProvider> </FormProvider>
</section> </section>

View File

@@ -3,19 +3,14 @@ import { z } from "zod"
import { passwordValidator } from "@/utils/passwordValidator" import { passwordValidator } from "@/utils/passwordValidator"
import { phoneValidator } from "@/utils/phoneValidator" import { phoneValidator } from "@/utils/phoneValidator"
export const registerSchema = z.object({ const countryRequiredMsg = "Country is required"
firstName: z export const signUpSchema = z.object({
.string() firstName: z.string().max(250).trim().min(1, {
.max(250) message: "First name is required",
.refine((value) => value.trim().length > 0, { }),
message: "First name is required", lastName: z.string().max(250).trim().min(1, {
}), message: "Last name is required",
lastName: z }),
.string()
.max(250)
.refine((value) => value.trim().length > 0, {
message: "Last name is required",
}),
email: z.string().max(250).email(), email: z.string().max(250).email(),
phoneNumber: phoneValidator( phoneNumber: phoneValidator(
"Phone is required", "Phone is required",
@@ -23,7 +18,12 @@ export const registerSchema = z.object({
), ),
dateOfBirth: z.string().min(1), dateOfBirth: z.string().min(1),
address: z.object({ address: z.object({
countryCode: z.string(), countryCode: z
.string({
required_error: countryRequiredMsg,
invalid_type_error: countryRequiredMsg,
})
.min(1, countryRequiredMsg),
zipCode: z.string().min(1), zipCode: z.string().min(1),
}), }),
password: passwordValidator("Password is required"), 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 { .imageContainer {
grid-area: image; grid-area: image;
position: relative;
height: 100%;
width: 116px;
} }
.tripAdvisor { .tripAdvisor {
display: none; display: none;
} }
.image { .imageContainer img {
height: 100%;
width: 116px;
object-fit: cover; object-fit: cover;
} }
@@ -77,6 +78,8 @@
.imageContainer { .imageContainer {
position: relative; position: relative;
min-height: 200px;
width: 518px;
} }
.tripAdvisor { .tripAdvisor {
@@ -86,10 +89,6 @@
top: 7px; top: 7px;
} }
.image {
width: 518px;
}
.hotelInformation { .hotelInformation {
padding-top: var(--Spacing-x2); padding-top: var(--Spacing-x2);
padding-right: 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 { getIntl } from "@/i18n"
import ReadMore from "../ReadMore" import ReadMore from "../ReadMore"
import ImageGallery from "../SelectRate/ImageGallery"
import styles from "./hotelCard.module.css" 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) { export default async function HotelCard({ hotel }: HotelCardProps) {
const intl = await getIntl() const intl = await getIntl()
@@ -27,13 +28,15 @@ export default async function HotelCard({ hotel }: HotelCardProps) {
return ( return (
<article className={styles.card}> <article className={styles.card}>
<section className={styles.imageContainer}> <section className={styles.imageContainer}>
<Image {hotelData.gallery && (
src={hotelData.hotelContent.images.imageSizes.medium} <ImageGallery
alt={hotelData.hotelContent.images.metaData.altText} title={hotelData.name}
width={300} images={[
height={200} hotelData.hotelContent.images,
className={styles.image} ...hotelData.gallery.heroImages,
/> ]}
/>
)}
<div className={styles.tripAdvisor}> <div className={styles.tripAdvisor}>
<Chip intent="primary" className={styles.tripAdvisor}> <Chip intent="primary" className={styles.tripAdvisor}>
<TripAdvisorIcon color="white" /> <TripAdvisorIcon color="white" />
@@ -102,7 +105,11 @@ export default async function HotelCard({ hotel }: HotelCardProps) {
className={styles.button} className={styles.button}
> >
{/* TODO: Localize link and also use correct search params */} {/* 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" })} {intl.formatMessage({ id: "See rooms" })}
</Link> </Link>
</Button> </Button>

View File

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

View File

@@ -10,6 +10,7 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import ReadMore from "../../ReadMore" import ReadMore from "../../ReadMore"
import ImageGallery from "../ImageGallery"
import styles from "./hotelInfoCard.module.css" import styles from "./hotelInfoCard.module.css"
@@ -28,12 +29,6 @@ export default function HotelInfoCard({ hotelData }: HotelInfoCardProps) {
{hotelAttributes && ( {hotelAttributes && (
<section className={styles.wrapper}> <section className={styles.wrapper}>
<div className={styles.imageWrapper}> <div className={styles.imageWrapper}>
<Image
src={hotelAttributes.hotelContent.images.imageSizes.medium}
alt={hotelAttributes.hotelContent.images.metaData.altText}
className={styles.image}
fill
/>
{hotelAttributes.ratings?.tripAdvisor && ( {hotelAttributes.ratings?.tripAdvisor && (
<div className={styles.tripAdvisor}> <div className={styles.tripAdvisor}>
<TripAdvisorIcon color="burgundy" /> <TripAdvisorIcon color="burgundy" />
@@ -42,7 +37,15 @@ export default function HotelInfoCard({ hotelData }: HotelInfoCardProps) {
</Caption> </Caption>
</div> </div>
)} )}
{/* TODO: gallery icon and image carousel */} {hotelAttributes.gallery && (
<ImageGallery
title={hotelAttributes.name}
images={[
hotelAttributes.hotelContent.images,
...hotelAttributes.gallery.heroImages,
]}
/>
)}
</div> </div>
<div className={styles.hotelContent}> <div className={styles.hotelContent}>
<div className={styles.hotelInformation}> <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 { RateDefinition } from "@/server/routers/hotels/output"
import FlexibilityOption from "@/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption" import FlexibilityOption from "@/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption"
import { ChevronRightSmallIcon, GalleryIcon } from "@/components/Icons" import { ChevronRightSmallIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Lightbox from "@/components/Lightbox"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote" import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import ImageGallery from "../../ImageGallery"
import styles from "./roomCard.module.css" 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({ export default function RoomCard({
rateDefinitions, rateDefinitions,
@@ -25,7 +25,6 @@ export default function RoomCard({
handleSelectRate, handleSelectRate,
}: RoomCardProps) { }: RoomCardProps) {
const intl = useIntl() const intl = useIntl()
const saveRate = rateDefinitions.find( const saveRate = rateDefinitions.find(
// TODO: Update string when API has decided // TODO: Update string when API has decided
(rate) => rate.cancellationRule === "NonCancellable" (rate) => rate.cancellationRule === "NonCancellable"
@@ -153,26 +152,8 @@ export default function RoomCard({
)} )}
{/*NOTE: images from the test API are hosted on test3.scandichotels.com, {/*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. */} 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 && ( {images && (
<Lightbox <ImageGallery images={images} title={roomConfiguration.roomType} />
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>
)} )}
</div> </div>
)} )}

View File

@@ -77,17 +77,3 @@
min-height: 185px; min-height: 185px;
position: relative; 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> { export interface AlertProps extends VariantProps<typeof alertVariants> {
className?: string className?: string
type: AlertTypeEnum type: AlertTypeEnum
heading?: string heading?: string | null
text: string text?: string | null
phoneContact?: { phoneContact?: {
displayText: string displayText: string
phoneNumber?: string phoneNumber?: string

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
.phone { .phone {
display: grid; display: grid;
gap: var(--Spacing-x2); 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-background-color: var(--Main-Grey-White);
--react-international-phone-border-color: var(--Scandic-Beige-40); --react-international-phone-border-color: var(--Scandic-Beige-40);

View File

@@ -51,9 +51,10 @@ export default function Link({
const router = useRouter() const router = useRouter()
const fullUrl = useMemo(() => { const fullUrl = useMemo(() => {
const search = if (!keepSearchParams || !searchParams.size) return href
keepSearchParams && searchParams.size ? `?${searchParams}` : ""
return `${href}${search}` const delimiter = href.includes("?") ? "&" : "?"
return `${href}${delimiter}${searchParams}`
}, [href, searchParams, keepSearchParams]) }, [href, searchParams, keepSearchParams])
// TODO: Remove this check (and hook) and only return <Link /> when current web is deleted // 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 { z } from "zod"
import { dt } from "@/lib/dt"
import { toLang } from "@/server/utils" import { toLang } from "@/server/utils"
import { imageMetaDataSchema, imageSizesSchema } from "./schemas/image" import { imageMetaDataSchema, imageSizesSchema } from "./schemas/image"
import { roomSchema } from "./schemas/room" import { roomSchema } from "./schemas/room"
import { getPoiGroupByCategoryName } from "./utils" import { getPoiGroupByCategoryName } from "./utils"
import { AlertTypeEnum } from "@/types/enums/alert"
import { FacilityEnum } from "@/types/enums/facilities" import { FacilityEnum } from "@/types/enums/facilities"
import { PointOfInterestCategoryNameEnum } from "@/types/hotel" 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({ const healthFacilitySchema = z.object({
type: z.string(), type: z.string(),
content: z.object({ content: z.object({
@@ -321,6 +338,7 @@ const socialMediaSchema = z.object({
const metaSpecialAlertSchema = z.object({ const metaSpecialAlertSchema = z.object({
type: z.string(), type: z.string(),
title: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
displayInBookingFlow: z.boolean(), displayInBookingFlow: z.boolean(),
startDate: z.string(), startDate: z.string(),
@@ -328,7 +346,23 @@ const metaSpecialAlertSchema = z.object({
}) })
const metaSchema = 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({ const relationshipsSchema = z.object({
@@ -422,6 +456,7 @@ export const getHotelDataSchema = z.object({
conferencesAndMeetings: facilitySchema.optional(), conferencesAndMeetings: facilitySchema.optional(),
healthAndWellness: facilitySchema.optional(), healthAndWellness: facilitySchema.optional(),
restaurantImages: facilitySchema.optional(), restaurantImages: facilitySchema.optional(),
gallery: gallerySchema.optional(),
}), }),
relationships: relationshipsSchema, relationships: relationshipsSchema,
}), }),

View File

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

View File

@@ -1,4 +1,4 @@
export type RegisterFormProps = { export type SignUpFormProps = {
link?: { href: string; text: string } link?: { href: string; text: string }
subtitle?: string subtitle?: string
title: 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 { interface Room {
adults: number adults: number
roomtypecode: string roomcode?: string
ratecode: string ratecode?: string
child: Child[] child?: Child[]
} }
export interface SelectRateSearchParams { export interface SelectRateSearchParams {

View File

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