feat(sw-453): implemented filter from packages

This commit is contained in:
Pontus Dreij
2024-10-25 12:55:13 +02:00
parent 260c9096f6
commit 7b36139684
20 changed files with 330 additions and 133 deletions

View File

@@ -1,24 +0,0 @@
.page {
min-height: 100dvh;
padding-top: var(--Spacing-x6);
padding-left: var(--Spacing-x2);
padding-right: var(--Spacing-x2);
background-color: var(--Scandic-Brand-Warm-White);
}
.content {
max-width: var(--max-width);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--Spacing-x7);
padding: var(--Spacing-x2);
}
.main {
flex-grow: 1;
}
.summary {
max-width: 340px;
}

View File

@@ -1,14 +1,12 @@
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import { RoomPackageCode } from "@/server/routers/hotels/schemas/packages"
import HotelInfoCard from "@/components/HotelReservation/SelectRate/HotelInfoCard"
import RoomFilter from "@/components/HotelReservation/SelectRate/RoomFilter"
import RoomSelection from "@/components/HotelReservation/SelectRate/RoomSelection"
import Rooms from "@/components/HotelReservation/SelectRate/Rooms"
import getHotelReservationQueryParams from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { setLang } from "@/i18n/serverContext"
import styles from "./page.module.css"
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { LangParams, PageArgs } from "@/types/params"
@@ -24,7 +22,7 @@ export default async function SelectRatePage({
const adults = selectRoomParamsObject.room?.[0].adults // TODO: Handle multiple rooms
const children = selectRoomParamsObject.room?.[0].child?.length // TODO: Handle multiple rooms
const [hotelData, roomsAvailability, user] = await Promise.all([
const [hotelData, roomsAvailability, packages, user] = await Promise.all([
serverClient().hotel.hotelData.get({
hotelId: searchParams.hotel,
language: params.lang,
@@ -37,6 +35,18 @@ export default async function SelectRatePage({
adults,
children,
}),
serverClient().hotel.packages.get({
hotelId: searchParams.hotel,
startDate: searchParams.fromDate,
endDate: searchParams.toDate,
adults: adults,
children: children,
packageCodes: [
RoomPackageCode.ACCE,
RoomPackageCode.PETR,
RoomPackageCode.ALLG,
],
}),
getProfileSafely(),
])
@@ -51,20 +61,14 @@ export default async function SelectRatePage({
const roomCategories = hotelData?.included
return (
<div>
<>
<HotelInfoCard hotelData={hotelData} />
<div className={styles.content}>
<div className={styles.main}>
<RoomFilter
numberOfRooms={roomsAvailability.roomConfigurations.length}
/>
<RoomSelection
roomsAvailability={roomsAvailability}
roomCategories={roomCategories ?? []}
user={user}
/>
</div>
</div>
</div>
<Rooms
roomsAvailability={roomsAvailability}
roomCategories={roomCategories ?? []}
user={user}
packages={packages ?? []}
/>
</>
)
}

View File

@@ -22,7 +22,7 @@ import { useEnterDetailsStore } from "@/stores/enter-details"
import LoadingSpinner from "@/components/LoadingSpinner"
import Button from "@/components/TempDesignSystem/Button"
import Checkbox from "@/components/TempDesignSystem/Checkbox"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"

View File

@@ -1,49 +1,60 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRef } from "react"
import { useCallback, useEffect, useMemo } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { z } from "zod"
import { roomFilterSchema } from "@/server/routers/hotels/schemas/room"
import { RoomPackageCode } from "@/server/routers/hotels/schemas/packages"
import Chip from "@/components/TempDesignSystem/Chip"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./roomFilter.module.css"
import {
RoomFilterFormData,
RoomFilterProps,
} from "@/types/components/hotelReservation/selectRate/roomFilter"
import { RoomFilterProps } from "@/types/components/hotelReservation/selectRate/roomFilter"
export default function RoomFilter({
numberOfRooms,
onFilter,
filterOptions,
}: RoomFilterProps) {
const initialFilterValues = useMemo(
() =>
filterOptions.reduce(
(acc, option) => {
acc[option.code] = false
return acc
},
{} as Record<string, boolean | undefined>
),
[filterOptions]
)
function RoomFilter({ numberOfRooms }: RoomFilterProps) {
const intl = useIntl()
const methods = useForm<RoomFilterFormData>({
defaultValues: {
allergyFriendly: false,
petFriendly: false,
accessibility: false,
},
const methods = useForm<Record<string, boolean | undefined>>({
defaultValues: initialFilterValues,
mode: "all",
reValidateMode: "onChange",
resolver: zodResolver(roomFilterSchema),
resolver: zodResolver(z.object({})),
})
const formRef = useRef<HTMLFormElement | null>(null)
const { watch, setValue } = methods
const petFriendly = watch("petFriendly")
const allergyFriendly = watch("allergyFriendly")
const { watch, getValues, handleSubmit } = methods
const petFriendly = watch(RoomPackageCode.PETR)
const allergyFriendly = watch(RoomPackageCode.ALLG)
const onSubmit = (data: RoomFilterFormData) => {
if (data.petFriendly) {
setValue("allergyFriendly", false)
} else if (data.allergyFriendly) {
setValue("petFriendly", false)
}
console.log("Form submitted with data:", data)
}
const submitFilter = useCallback(() => {
const data = getValues()
onFilter(data)
}, [onFilter, getValues])
useEffect(() => {
const subscription = watch(() => handleSubmit(submitFilter)())
return () => subscription.unsubscribe()
}, [handleSubmit, watch, submitFilter])
return (
<div className={styles.container}>
@@ -51,45 +62,27 @@ function RoomFilter({ numberOfRooms }: RoomFilterProps) {
{intl.formatMessage({ id: "Room types available" }, { numberOfRooms })}
</Body>
<FormProvider {...methods}>
<form ref={formRef} onSubmit={methods.handleSubmit(onSubmit)}>
<form onSubmit={handleSubmit(submitFilter)}>
<div className={styles.roomsFilter}>
<Checkbox
name="accessibility"
onChange={() => formRef.current?.requestSubmit()}
>
<Caption color="uiTextHighContrast">
{intl.formatMessage({ id: "Accessibility room" })}
</Caption>
</Checkbox>
<Checkbox
name="petFriendly"
onChange={() => {
setValue("petFriendly", !petFriendly)
formRef.current?.requestSubmit()
}}
registerOptions={{ disabled: allergyFriendly }}
>
<Caption color="uiTextHighContrast">
{intl.formatMessage({ id: "Pet room" })}
</Caption>
</Checkbox>
<Checkbox
name="allergyFriendly"
onChange={() => {
setValue("allergyFriendly", !allergyFriendly)
formRef.current?.requestSubmit()
}}
registerOptions={{ disabled: petFriendly }}
>
<Caption color="uiTextHighContrast">
{intl.formatMessage({ id: "Allergy room" })}
</Caption>
</Checkbox>
{filterOptions.map((option) => (
<Checkbox
name={option.code}
key={option.code}
registerOptions={{
required: false,
disabled:
(option.code === RoomPackageCode.PETR && allergyFriendly) ||
(option.code === RoomPackageCode.ALLG && petFriendly),
}}
>
<Caption color="uiTextHighContrast">
{intl.formatMessage({ id: option.description })}
</Caption>
</Checkbox>
))}
</div>
</form>
</FormProvider>
</div>
)
}
export default RoomFilter

View File

@@ -0,0 +1,53 @@
"use client"
import { useState } from "react"
import { RoomsAvailability } from "@/server/routers/hotels/output"
import RoomFilter from "../RoomFilter"
import RoomSelection from "../RoomSelection"
import styles from "./rooms.module.css"
import { RoomProps } from "@/types/components/hotelReservation/selectRate/room"
import { RoomPackageCodes } from "@/types/components/hotelReservation/selectRate/roomFilter"
export default function Rooms({
roomsAvailability,
roomCategories = [],
user,
packages,
}: RoomProps) {
const [rooms, setRooms] = useState<RoomsAvailability>(roomsAvailability)
function handleFilter(filter: Record<string, boolean | undefined>) {
const selectedCodes = Object.keys(filter).filter((key) => filter[key])
if (selectedCodes.length === 0) {
setRooms(roomsAvailability)
return
}
const filteredRooms = roomsAvailability.roomConfigurations.filter((room) =>
room.features.some((feature) =>
selectedCodes.includes(feature.code as RoomPackageCodes)
)
)
setRooms({ ...roomsAvailability, roomConfigurations: filteredRooms })
}
return (
<div className={styles.content}>
<RoomFilter
numberOfRooms={rooms.roomConfigurations.length}
onFilter={handleFilter}
filterOptions={packages}
/>
<RoomSelection
roomsAvailability={rooms}
roomCategories={roomCategories}
user={user}
/>
</div>
)
}

View File

@@ -0,0 +1,8 @@
.content {
max-width: var(--max-width);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--Spacing-x7);
padding: var(--Spacing-x2);
}

View File

@@ -5,7 +5,7 @@
"A photo of the room": "Et foto af værelset",
"About meetings & conferences": "About meetings & conferences",
"About the hotel": "About the hotel",
"Accessibility room": "Handicapvenligt værelse",
"Accessible Room": "Tilgængelighedsrum",
"Activities": "Aktiviteter",
"Add code": "Tilføj kode",
"Add new card": "Tilføj nyt kort",
@@ -14,7 +14,7 @@
"Adults": "voksne",
"Airport": "Lufthavn",
"All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Alle vores morgenmadsbuffeter tilbyder glutenfrie, veganske og allergivenlige muligheder.",
"Allergy room": "Allergivenligt værelse",
"Allergy Room": "Allergirum",
"Already a friend?": "Allerede en ven?",
"Amenities": "Faciliteter",
"Amusement park": "Forlystelsespark",
@@ -217,7 +217,7 @@
"Pay later": "Betal senere",
"Pay now": "Betal nu",
"Payment info": "Betalingsoplysninger",
"Pet room": "Kæledyrsrum",
"Pet Room": "Kæledyrsrum",
"Phone": "Telefon",
"Phone is required": "Telefonnummer er påkrævet",
"Phone number": "Telefonnummer",

View File

@@ -5,7 +5,7 @@
"A photo of the room": "Ein Foto des Zimmers",
"About meetings & conferences": "About meetings & conferences",
"About the hotel": "Über das Hotel",
"Accessibility room": "Barrierefreies Zimmer",
"Accessible Room": "Barrierefreies Zimmer",
"Activities": "Aktivitäten",
"Add code": "Code hinzufügen",
"Add new card": "Neue Karte hinzufügen",
@@ -14,7 +14,7 @@
"Adults": "Erwachsene",
"Airport": "Flughafen",
"All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Alle unsere Frühstücksbuffets bieten glutenfreie, vegane und allergikerfreundliche Speisen.",
"Allergy room": "Allergiefreundliches Zimmer",
"Allergy Room": "Allergikerzimmer",
"Already a friend?": "Sind wir schon Freunde?",
"Amenities": "Annehmlichkeiten",
"Amusement park": "Vergnügungspark",
@@ -217,7 +217,7 @@
"Pay later": "Später bezahlen",
"Pay now": "Jetzt bezahlen",
"Payment info": "Zahlungsinformationen",
"Pet room": "Haustierfreundliches Zimmer",
"Pet Room": "Haustierzimmer",
"Phone": "Telefon",
"Phone is required": "Telefon ist erforderlich",
"Phone number": "Telefonnummer",

View File

@@ -5,7 +5,7 @@
"A photo of the room": "A photo of the room",
"About meetings & conferences": "About meetings & conferences",
"About the hotel": "About the hotel",
"Accessibility room": "Accessibility room",
"Accessible Room": "Accessibility room",
"Activities": "Activities",
"Add Room": "Add room",
"Add code": "Add code",
@@ -17,7 +17,7 @@
"Age": "Age",
"Airport": "Airport",
"All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.",
"Allergy room": "Allergy room",
"Allergy Room": "Allergy room",
"Already a friend?": "Already a friend?",
"Amenities": "Amenities",
"Amusement park": "Amusement park",
@@ -227,7 +227,7 @@
"Pay now": "Pay now",
"Payment info": "Payment info",
"Payment received": "Payment received",
"Pet room": "Pet room",
"Pet Room": "Pet room",
"Phone": "Phone",
"Phone is required": "Phone is required",
"Phone number": "Phone number",
@@ -258,6 +258,7 @@
"Restaurant & Bar": "Restaurant & Bar",
"Restaurants & Bars": "Restaurants & Bars",
"Retype new password": "Retype new password",
"Room": "Room",
"Room & Terms": "Room & Terms",
"Room facilities": "Room facilities",
"Room types available": "{numberOfRooms, plural, one {# room type} other {# room types}} available",

View File

@@ -5,7 +5,7 @@
"A photo of the room": "Kuva huoneesta",
"About meetings & conferences": "About meetings & conferences",
"About the hotel": "Tietoja hotellista",
"Accessibility room": "Esteettömyyshuone",
"Accessible Room": "Esteetön huone",
"Activities": "Aktiviteetit",
"Add code": "Lisää koodi",
"Add new card": "Lisää uusi kortti",
@@ -14,7 +14,7 @@
"Adults": "Aikuista",
"Airport": "Lentokenttä",
"All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Kaikki aamiaisbuffettimme tarjoavat gluteenittomia, vegaanisia ja allergiaystävällisiä vaihtoehtoja.",
"Allergy room": "Allergiahuone",
"Allergy Room": "Allergiahuone",
"Already a friend?": "Oletko jo ystävä?",
"Amenities": "Mukavuudet",
"Amusement park": "Huvipuisto",
@@ -217,7 +217,7 @@
"Pay later": "Maksa myöhemmin",
"Pay now": "Maksa nyt",
"Payment info": "Maksutiedot",
"Pet room": "Lemmikkihuone",
"Pet Room": "Lemmikkihuone",
"Phone": "Puhelin",
"Phone is required": "Puhelin vaaditaan",
"Phone number": "Puhelinnumero",

View File

@@ -5,7 +5,7 @@
"A photo of the room": "Et bilde av rommet",
"About meetings & conferences": "About meetings & conferences",
"About the hotel": "Om hotellet",
"Accessibility room": "Tilgjengelighetsrom",
"Accessible Room": "Tilgjengelighetsrom",
"Activities": "Aktiviteter",
"Add code": "Legg til kode",
"Add new card": "Legg til nytt kort",
@@ -14,7 +14,7 @@
"Adults": "Voksne",
"Airport": "Flyplass",
"All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Alle våre frokostbufféer tilbyr glutenfrie, veganske og allergivennlige alternativer.",
"Allergy room": "Allergihus",
"Allergy Room": "Allergirom",
"Already a friend?": "Allerede Friend?",
"Amenities": "Fasiliteter",
"Amusement park": "Tivoli",
@@ -215,7 +215,7 @@
"Pay later": "Betal senere",
"Pay now": "Betal nå",
"Payment info": "Betalingsinformasjon",
"Pet room": "Kjæledyrrom",
"Pet Room": "Kjæledyrsrom",
"Phone": "Telefon",
"Phone is required": "Telefon kreves",
"Phone number": "Telefonnummer",

View File

@@ -5,7 +5,7 @@
"A photo of the room": "Ett foto av rummet",
"About meetings & conferences": "About meetings & conferences",
"About the hotel": "Om hotellet",
"Accessibility room": "Tillgänglighetsrum",
"Accessible Room": "Tillgänglighetsrum",
"Activities": "Aktiviteter",
"Add code": "Lägg till kod",
"Add new card": "Lägg till nytt kort",
@@ -14,7 +14,7 @@
"Adults": "Vuxna",
"Airport": "Flygplats",
"All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Alla våra frukostbufféer erbjuder glutenfria, veganska och allergivänliga alternativ.",
"Allergy room": "Allergirum",
"Allergy Room": "Allergirum",
"Already a friend?": "Är du redan en vän?",
"Amenities": "Bekvämligheter",
"Amusement park": "Nöjespark",
@@ -215,7 +215,7 @@
"Pay later": "Betala senare",
"Pay now": "Betala nu",
"Payment info": "Betalningsinformation",
"Pet room": "Husdjursrum",
"Pet Room": "Husdjursrum",
"Phone": "Telefon",
"Phone is required": "Telefonnummer är obligatorisk",
"Phone number": "Telefonnummer",

View File

@@ -23,6 +23,7 @@ export namespace endpoints {
rewards = `${profile}/reward`,
tierRewards = `${profile}/TierRewards`,
subscriberId = `${profile}/SubscriberId`,
packages = "package/v1/packages/hotel",
}
}

View File

@@ -37,7 +37,7 @@ export async function get(
const searchParams = new URLSearchParams(params)
if (searchParams.size) {
searchParams.forEach((value, key) => {
url.searchParams.set(key, value)
url.searchParams.append(key, value)
})
url.searchParams.sort()
}

View File

@@ -25,6 +25,10 @@ import {
getHotelPageCounter,
validateHotelPageRefs,
} from "../contentstack/hotelPage/utils"
import {
getRoomPackagesInputSchema,
getRoomPackagesSchema,
} from "./schemas/packages"
import {
getHotelInputSchema,
getHotelsAvailabilityInputSchema,
@@ -57,6 +61,14 @@ const getHotelCounter = meter.createCounter("trpc.hotel.get")
const getHotelSuccessCounter = meter.createCounter("trpc.hotel.get-success")
const getHotelFailCounter = meter.createCounter("trpc.hotel.get-fail")
const getPackagesCounter = meter.createCounter("trpc.hotel.packages.get")
const getPackagesSuccessCounter = meter.createCounter(
"trpc.hotel.packages.get-success"
)
const getPackagesFailCounter = meter.createCounter(
"trpc.hotel.packages.get-fail"
)
const hotelsAvailabilityCounter = meter.createCounter(
"trpc.hotel.availability.hotels"
)
@@ -694,4 +706,89 @@ export const hotelQueryRouter = router({
return locations
}),
}),
packages: router({
get: serviceProcedure
.input(getRoomPackagesInputSchema)
.query(async ({ input, ctx }) => {
const { hotelId, startDate, endDate, adults, children, packageCodes } =
input
const searchParams = new URLSearchParams({
startDate,
endDate,
adults: adults.toString(),
children: children.toString(),
})
packageCodes.forEach((code) => {
searchParams.append("packageCodes", code)
})
const params = searchParams.toString()
getPackagesCounter.add(1, {
hotelId,
})
console.info(
"api.hotels.packages start",
JSON.stringify({ query: { hotelId, params } })
)
const apiResponse = await api.get(
`${api.endpoints.v1.packages}/${hotelId}`,
{
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
},
params
)
if (!apiResponse.ok) {
getPackagesFailCounter.add(1, {
hotelId,
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
}),
})
console.error(
"api.hotels.packages error",
JSON.stringify({ query: { hotelId, params } })
)
throw serverErrorByStatus(apiResponse.status, apiResponse)
}
const apiJson = await apiResponse.json()
const validatedPackagesData = getRoomPackagesSchema.safeParse(apiJson)
if (!validatedPackagesData.success) {
getHotelFailCounter.add(1, {
hotelId,
error_type: "validation_error",
error: JSON.stringify(validatedPackagesData.error),
})
console.error(
"api.hotels.packages validation error",
JSON.stringify({
query: { hotelId, params },
error: validatedPackagesData.error,
})
)
throw badRequestError()
}
getPackagesSuccessCounter.add(1, {
hotelId,
})
console.info(
"api.hotels.packages success",
JSON.stringify({ query: { hotelId, params: params } })
)
return validatedPackagesData.data
}),
}),
})

View File

@@ -0,0 +1,59 @@
import { z } from "zod"
export enum RoomPackageCode {
PETR = "PETR",
ALLG = "ALLG",
ACCE = "ACCE",
}
export const getRoomPackagesInputSchema = z.object({
hotelId: z.string(),
startDate: z.string(),
endDate: z.string(),
adults: z.number(),
children: z.number().optional().default(0),
packageCodes: z.array(z.string()).optional().default([]),
})
const packagesSchema = z.array(
z.object({
code: z.enum([
RoomPackageCode.PETR,
RoomPackageCode.ALLG,
RoomPackageCode.ACCE,
]),
itemCode: z.string(),
description: z.string(),
currency: z.string(),
calculatedPrice: z.number(),
inventories: z.array(
z.object({
date: z.string(),
total: z.number(),
available: z.number(),
})
),
})
)
export const getRoomPackagesSchema = z
.object({
data: z.object({
attributes: z.object({
hotelId: z.number(),
packages: packagesSchema,
}),
relationships: z
.object({
links: z.array(
z.object({
url: z.string(),
type: z.string(),
})
),
})
.optional(),
type: z.string(),
}),
})
.transform((data) => data.data.attributes.packages)

View File

@@ -92,9 +92,3 @@ export const roomSchema = z
roomFacilities: data.attributes.roomFacilities,
}
})
export const roomFilterSchema = z.object({
accessibility: z.boolean(),
petFriendly: z.boolean(),
allergyFriendly: z.boolean(),
})

View File

@@ -74,7 +74,7 @@ export async function getServiceToken() {
if (env.HIDE_FOR_NEXT_RELEASE) {
scopes = ["profile"]
} else {
scopes = ["profile", "hotel", "booking"]
scopes = ["profile", "hotel", "booking", "package"]
}
const tag = generateServiceTokenTag(scopes)
const getCachedJwt = unstable_cache(

View File

@@ -0,0 +1,6 @@
import { RoomPackageData } from "./roomFilter"
import { RoomSelectionProps } from "./roomSelection"
export interface RoomProps extends RoomSelectionProps {
packages: RoomPackageData
}

View File

@@ -1,9 +1,14 @@
import { z } from "zod"
import { roomFilterSchema } from "@/server/routers/hotels/schemas/room"
import { getRoomPackagesSchema } from "@/server/routers/hotels/schemas/packages"
export interface RoomFilterProps {
numberOfRooms: number
onFilter: (filter: Record<string, boolean | undefined>) => void
filterOptions: RoomPackageData
}
export interface RoomFilterFormData extends z.output<typeof roomFilterSchema> {}
export interface RoomPackageData
extends z.output<typeof getRoomPackagesSchema> {}
export type RoomPackageCodes = RoomPackageData[number]["code"]