Merged in feat/sw-2879-booking-widget-to-booking-flow-package (pull request #2532)
feat(SW-2879): Move BookingWidget to booking-flow package * Fix lockfile * Fix styling * a tiny little booking widget test * Tiny fixes * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package * Remove unused scripts * lint:fix * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package * Tiny lint fixes * update test * Update Input in booking-flow * Clean up comments etc * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package * Setup tracking context for booking-flow * Add missing use client * Fix temp tracking function * Pass booking to booking-widget * Remove comment * Add use client to booking widget tracking provider * Add use client to tracking functions * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package * Move debug page * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package Approved-by: Bianca Widstam
This commit is contained in:
@@ -1,12 +0,0 @@
|
||||
export function debounce<Params extends any[]>(
|
||||
func: (...args: Params) => any,
|
||||
delay = 300
|
||||
) {
|
||||
let debounceTimer: ReturnType<typeof setTimeout>
|
||||
|
||||
return function <U>(this: U, ...args: Parameters<typeof func>) {
|
||||
const context = this
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => func.apply(context, args), delay)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { logger } from "@scandic-hotels/common/logger"
|
||||
import { phoneErrors } from "@scandic-hotels/common/utils/zod/phoneValidator"
|
||||
import { signupErrors } from "@scandic-hotels/trpc/routers/user/schemas"
|
||||
|
||||
import { bookingWidgetErrors } from "@/components/Forms/BookingWidget/schema"
|
||||
import { editProfileErrors } from "@/components/Forms/Edit/Profile/schema"
|
||||
import { multiroomErrors } from "@/components/HotelReservation/EnterDetails/Details/Multiroom/schema"
|
||||
import { roomOneErrors } from "@/components/HotelReservation/EnterDetails/Details/RoomOne/schema"
|
||||
@@ -20,10 +19,6 @@ export function getErrorMessage(intl: IntlShape, errorCode?: string) {
|
||||
return intl.formatMessage({
|
||||
defaultMessage: "Invalid booking number",
|
||||
})
|
||||
case bookingWidgetErrors.BOOKING_CODE_INVALID:
|
||||
return intl.formatMessage({
|
||||
defaultMessage: "Booking code is invalid",
|
||||
})
|
||||
case findMyBookingErrors.FIRST_NAME_REQUIRED:
|
||||
case signupErrors.FIRST_NAME_REQUIRED:
|
||||
case multiroomErrors.FIRST_NAME_REQUIRED:
|
||||
@@ -146,42 +141,6 @@ export function getErrorMessage(intl: IntlShape, errorCode?: string) {
|
||||
defaultMessage:
|
||||
"Membership number can't be the same for two different rooms",
|
||||
})
|
||||
case bookingWidgetErrors.AGE_REQUIRED:
|
||||
return intl.formatMessage({
|
||||
defaultMessage: "Age is required",
|
||||
})
|
||||
case bookingWidgetErrors.BED_CHOICE_REQUIRED:
|
||||
return intl.formatMessage({
|
||||
defaultMessage: "Bed choice is required",
|
||||
})
|
||||
case bookingWidgetErrors.CHILDREN_EXCEEDS_ADULTS:
|
||||
return intl.formatMessage({
|
||||
defaultMessage:
|
||||
"You cannot have more children in adults bed than adults in the room",
|
||||
})
|
||||
case bookingWidgetErrors.REQUIRED:
|
||||
return intl.formatMessage({
|
||||
defaultMessage: "Required",
|
||||
})
|
||||
case bookingWidgetErrors.DESTINATION_REQUIRED:
|
||||
return intl.formatMessage({
|
||||
defaultMessage: "Destination required",
|
||||
})
|
||||
case bookingWidgetErrors.MULTIROOM_BOOKING_CODE_UNAVAILABLE:
|
||||
return intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Multi-room booking is not available with this booking code.",
|
||||
})
|
||||
case bookingWidgetErrors.MULTIROOM_REWARD_NIGHT_UNAVAILABLE:
|
||||
return intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Multi-room booking is not available with reward night.",
|
||||
})
|
||||
case bookingWidgetErrors.CODE_VOUCHER_REWARD_NIGHT_UNAVAILABLE:
|
||||
return intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Reward nights can't be combined with codes or vouchers.",
|
||||
})
|
||||
default:
|
||||
logger.warn("Error code not supported:", errorCode)
|
||||
return errorCode
|
||||
|
||||
@@ -11,10 +11,9 @@ import {
|
||||
|
||||
import { getLocations } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import type { BookingSearchType } from "@scandic-hotels/booking-flow/searchType"
|
||||
import type { Child } from "@scandic-hotels/trpc/types/child"
|
||||
|
||||
import type { BookingSearchType } from "@/types/components/hotelReservation/booking"
|
||||
|
||||
export type ChildrenInRoom = (Child[] | null)[] | null
|
||||
export type ChildrenInRoomString = (string | null)[] | null
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export default function isValidJson(value: string | null | undefined): boolean {
|
||||
if (!value || value === "undefined") return false
|
||||
try {
|
||||
JSON.parse(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,556 +0,0 @@
|
||||
import { describe, expect, test } from "vitest"
|
||||
import { z } from "zod"
|
||||
|
||||
import { parseSearchParams, serializeSearchParams } from "./searchParams"
|
||||
|
||||
describe("Parse search params", () => {
|
||||
test("with flat values", () => {
|
||||
const searchParams = getSearchParams("city=stockholm&hotel=123")
|
||||
const result = parseSearchParams(searchParams)
|
||||
|
||||
expect(result).toEqual({
|
||||
city: "stockholm",
|
||||
hotel: "123",
|
||||
})
|
||||
})
|
||||
|
||||
test("with comma separated array", () => {
|
||||
const searchParams = getSearchParams(
|
||||
"filter=1831,1383,971,1607&packages=ABC,XYZ"
|
||||
)
|
||||
const result = parseSearchParams(searchParams, {
|
||||
typeHints: {
|
||||
packages: "COMMA_SEPARATED_ARRAY",
|
||||
filter: "COMMA_SEPARATED_ARRAY",
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
filter: ["1831", "1383", "971", "1607"],
|
||||
packages: ["ABC", "XYZ"],
|
||||
})
|
||||
})
|
||||
|
||||
test("with comma separated array with single value", () => {
|
||||
const searchParams = getSearchParams(
|
||||
"details.packages=ABC&filter=1831&rooms[0].packages=XYZ"
|
||||
)
|
||||
const result = parseSearchParams(searchParams, {
|
||||
typeHints: {
|
||||
filter: "COMMA_SEPARATED_ARRAY",
|
||||
packages: "COMMA_SEPARATED_ARRAY",
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
filter: ["1831"],
|
||||
details: {
|
||||
packages: ["ABC"],
|
||||
},
|
||||
rooms: [
|
||||
{
|
||||
packages: ["XYZ"],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("with nested object", () => {
|
||||
const searchParams = getSearchParams(
|
||||
"room.details.adults=1&room.ratecode=ABC&room.details.children=2&room.filters=1,2,3,4"
|
||||
)
|
||||
const result = parseSearchParams(searchParams, {
|
||||
typeHints: {
|
||||
filters: "COMMA_SEPARATED_ARRAY",
|
||||
},
|
||||
})
|
||||
expect(result).toEqual({
|
||||
room: {
|
||||
ratecode: "ABC",
|
||||
filters: ["1", "2", "3", "4"],
|
||||
details: {
|
||||
adults: "1",
|
||||
children: "2",
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("with array of objects", () => {
|
||||
const searchParams = getSearchParams(
|
||||
"room[0].adults=1&room[0].ratecode=ABC&room[1].adults=2&room[1].ratecode=DEF"
|
||||
)
|
||||
|
||||
const result = parseSearchParams(searchParams)
|
||||
expect(result).toEqual({
|
||||
room: [
|
||||
{
|
||||
adults: "1",
|
||||
ratecode: "ABC",
|
||||
},
|
||||
{
|
||||
adults: "2",
|
||||
ratecode: "DEF",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("with array defined out of order", () => {
|
||||
const searchParams = getSearchParams("room[1].adults=1&room[0].adults=2")
|
||||
|
||||
const result = parseSearchParams(searchParams)
|
||||
expect(result).toEqual({
|
||||
room: [
|
||||
{
|
||||
adults: "2",
|
||||
},
|
||||
{
|
||||
adults: "1",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("with nested array of objects", () => {
|
||||
const searchParams = getSearchParams(
|
||||
"room[0].adults=1&room[0].child[0].age=2&room[1].adults=2&room[1].child[0].age=3"
|
||||
)
|
||||
|
||||
const result = parseSearchParams(searchParams)
|
||||
|
||||
expect(result).toEqual({
|
||||
room: [
|
||||
{
|
||||
adults: "1",
|
||||
child: [
|
||||
{
|
||||
age: "2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
adults: "2",
|
||||
child: [
|
||||
{
|
||||
age: "3",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("can handle array syntax with primitive values", () => {
|
||||
const searchParams = getSearchParams("room[1]=1&room[0]=2")
|
||||
const result = parseSearchParams(searchParams)
|
||||
expect(result).toEqual({
|
||||
room: ["2", "1"],
|
||||
})
|
||||
})
|
||||
|
||||
test("can rename search param keys", () => {
|
||||
const searchParams = getSearchParams(
|
||||
"city=stockholm&hotel=123&room[0].adults=1&room[0].child[0].age=2&room[1].adults=2&room[1].child[0].age=3"
|
||||
)
|
||||
const result = parseSearchParams(searchParams, {
|
||||
keyRenameMap: {
|
||||
hotel: "hotelId",
|
||||
room: "rooms",
|
||||
age: "childAge",
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
city: "stockholm",
|
||||
hotelId: "123",
|
||||
rooms: [
|
||||
{
|
||||
adults: "1",
|
||||
child: [
|
||||
{
|
||||
childAge: "2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
adults: "2",
|
||||
child: [
|
||||
{
|
||||
childAge: "3",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("with schema validation", () => {
|
||||
const searchParams = getSearchParams(
|
||||
"room[0].adults=1&room[0].child[0].age=2&room[1].adults=2&room[1].child[0].age=3"
|
||||
)
|
||||
|
||||
const result = parseSearchParams(searchParams, {
|
||||
schema: z.object({
|
||||
room: z.array(
|
||||
z.object({
|
||||
adults: z.string(),
|
||||
child: z.array(
|
||||
z.object({
|
||||
age: z.string(),
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
expect(result).toEqual({
|
||||
room: [
|
||||
{
|
||||
adults: "1",
|
||||
child: [
|
||||
{
|
||||
age: "2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
adults: "2",
|
||||
child: [
|
||||
{
|
||||
age: "3",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("throws when schema validation fails", () => {
|
||||
const searchParams = getSearchParams("city=stockholm")
|
||||
|
||||
expect(() =>
|
||||
parseSearchParams(searchParams, {
|
||||
schema: z.object({
|
||||
city: z.string(),
|
||||
hotel: z.string(),
|
||||
}),
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test("with value coercion", () => {
|
||||
const searchParams = getSearchParams(
|
||||
"room[0].adults=1&room[0].enabled=true"
|
||||
)
|
||||
|
||||
const result = parseSearchParams(searchParams, {
|
||||
schema: z.object({
|
||||
room: z.array(
|
||||
z.object({
|
||||
adults: z.coerce.number(),
|
||||
enabled: z.coerce.boolean(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
expect(result).toEqual({
|
||||
room: [
|
||||
{
|
||||
adults: 1,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Serialize search params", () => {
|
||||
test("with flat values", () => {
|
||||
const obj = {
|
||||
city: "stockholm",
|
||||
hotel: "123",
|
||||
}
|
||||
const result = serializeSearchParams(obj)
|
||||
|
||||
expect(decodeURIComponent(result.toString())).toEqual(
|
||||
"city=stockholm&hotel=123"
|
||||
)
|
||||
})
|
||||
|
||||
test("with comma separated array", () => {
|
||||
const obj = {
|
||||
filter: ["1831", "1383", "971", "1607"],
|
||||
}
|
||||
const result = serializeSearchParams(obj, {
|
||||
typeHints: {
|
||||
filter: "COMMA_SEPARATED_ARRAY",
|
||||
},
|
||||
})
|
||||
|
||||
expect(decodeURIComponent(result.toString())).toEqual(
|
||||
"filter=1831,1383,971,1607"
|
||||
)
|
||||
})
|
||||
|
||||
test("with comma separated array with single value", () => {
|
||||
const obj = {
|
||||
details: {
|
||||
packages: ["ABC"],
|
||||
},
|
||||
filter: ["1831"],
|
||||
rooms: [
|
||||
{
|
||||
packages: ["XYZ"],
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = serializeSearchParams(obj, {
|
||||
typeHints: {
|
||||
filter: "COMMA_SEPARATED_ARRAY",
|
||||
packages: "COMMA_SEPARATED_ARRAY",
|
||||
},
|
||||
})
|
||||
|
||||
expect(decodeURIComponent(result.toString())).toEqual(
|
||||
"details.packages=ABC&filter=1831&rooms[0].packages=XYZ"
|
||||
)
|
||||
})
|
||||
|
||||
test("with nested object", () => {
|
||||
const obj = {
|
||||
room: {
|
||||
ratecode: "ABC",
|
||||
filters: ["1", "2", "3", "4"],
|
||||
details: {
|
||||
adults: "1",
|
||||
children: "2",
|
||||
},
|
||||
},
|
||||
}
|
||||
const result = serializeSearchParams(obj, {
|
||||
typeHints: {
|
||||
filters: "COMMA_SEPARATED_ARRAY",
|
||||
},
|
||||
})
|
||||
|
||||
expect(decodeURIComponent(result.toString())).toEqual(
|
||||
"room.ratecode=ABC&room.filters=1,2,3,4&room.details.adults=1&room.details.children=2"
|
||||
)
|
||||
})
|
||||
|
||||
test("with array of objects", () => {
|
||||
const obj = {
|
||||
room: [
|
||||
{
|
||||
adults: "1",
|
||||
ratecode: "ABC",
|
||||
},
|
||||
{
|
||||
adults: "2",
|
||||
ratecode: "DEF",
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = serializeSearchParams(obj)
|
||||
|
||||
expect(decodeURIComponent(result.toString())).toEqual(
|
||||
"room[0].adults=1&room[0].ratecode=ABC&room[1].adults=2&room[1].ratecode=DEF"
|
||||
)
|
||||
})
|
||||
|
||||
test("with nested array of objects", () => {
|
||||
const obj = {
|
||||
room: [
|
||||
{
|
||||
adults: "1",
|
||||
child: [
|
||||
{
|
||||
age: "2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
adults: "2",
|
||||
child: [
|
||||
{
|
||||
age: "3",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = serializeSearchParams(obj)
|
||||
|
||||
expect(decodeURIComponent(result.toString())).toEqual(
|
||||
"room[0].adults=1&room[0].child[0].age=2&room[1].adults=2&room[1].child[0].age=3"
|
||||
)
|
||||
})
|
||||
|
||||
test("can handle array syntax with primitive values", () => {
|
||||
const obj = {
|
||||
room: ["2", "1"],
|
||||
}
|
||||
const result = serializeSearchParams(obj)
|
||||
|
||||
expect(decodeURIComponent(result.toString())).toEqual("room[0]=2&room[1]=1")
|
||||
})
|
||||
|
||||
test("can rename search param keys", () => {
|
||||
const obj = {
|
||||
city: "stockholm",
|
||||
hotelId: "123",
|
||||
rooms: [
|
||||
{
|
||||
adults: "1",
|
||||
child: [
|
||||
{
|
||||
childAge: "2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
adults: "2",
|
||||
child: [
|
||||
{
|
||||
childAge: "3",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = serializeSearchParams(obj, {
|
||||
keyRenameMap: {
|
||||
hotelId: "hotel",
|
||||
rooms: "room",
|
||||
childAge: "age",
|
||||
},
|
||||
})
|
||||
|
||||
expect(decodeURIComponent(result.toString())).toEqual(
|
||||
"city=stockholm&hotel=123&room[0].adults=1&room[0].child[0].age=2&room[1].adults=2&room[1].child[0].age=3"
|
||||
)
|
||||
})
|
||||
|
||||
test("with initial search params", () => {
|
||||
const initialSearchParams = new URLSearchParams("city=stockholm&hotel=123")
|
||||
const obj = {
|
||||
hotel: "456",
|
||||
filter: ["1831", "1383"],
|
||||
packages: ["ABC"],
|
||||
}
|
||||
const result = serializeSearchParams(obj, {
|
||||
initialSearchParams,
|
||||
typeHints: {
|
||||
packages: "COMMA_SEPARATED_ARRAY",
|
||||
},
|
||||
})
|
||||
|
||||
expect(decodeURIComponent(result.toString())).toEqual(
|
||||
"city=stockholm&hotel=456&filter[0]=1831&filter[1]=1383&packages=ABC"
|
||||
)
|
||||
})
|
||||
|
||||
test("with initial search params and removing existing parameter", () => {
|
||||
const initialSearchParams = new URLSearchParams(
|
||||
"city=stockholm&hotel=123&filters=123,456,789"
|
||||
)
|
||||
const obj = {
|
||||
hotel: null,
|
||||
filters: ["123", "789"],
|
||||
}
|
||||
const result = serializeSearchParams(obj, {
|
||||
initialSearchParams,
|
||||
typeHints: {
|
||||
filters: "COMMA_SEPARATED_ARRAY",
|
||||
},
|
||||
})
|
||||
|
||||
expect(decodeURIComponent(result.toString())).toEqual(
|
||||
"city=stockholm&filters=123,789"
|
||||
)
|
||||
})
|
||||
|
||||
test("with initial search params and removing values in array", () => {
|
||||
const initialSearchParams = new URLSearchParams(
|
||||
"room[0].adults=1&room[0].rateCode=ABC&room[1].adults=2&room[2].adults=3&room[3].adults=4"
|
||||
)
|
||||
const obj = {
|
||||
room: [
|
||||
{
|
||||
adults: 1,
|
||||
rateCode: null,
|
||||
},
|
||||
{
|
||||
adults: 3,
|
||||
},
|
||||
{
|
||||
adults: 4,
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = serializeSearchParams(obj, {
|
||||
initialSearchParams,
|
||||
})
|
||||
|
||||
expect(decodeURIComponent(result.toString())).toEqual(
|
||||
"room[0].adults=1&room[1].adults=3&room[2].adults=4"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Parse serialized search params", () => {
|
||||
test("should return the same object", () => {
|
||||
const obj = {
|
||||
city: "stockholm",
|
||||
hotelId: "123",
|
||||
filter: ["1831", "1383", "971", "1607"],
|
||||
details: {
|
||||
packages: ["ABC"],
|
||||
},
|
||||
rooms: [
|
||||
{
|
||||
packages: ["XYZ"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const searchParams = serializeSearchParams(obj, {
|
||||
keyRenameMap: {
|
||||
hotelId: "hotel",
|
||||
rooms: "room",
|
||||
},
|
||||
typeHints: {
|
||||
filter: "COMMA_SEPARATED_ARRAY",
|
||||
packages: "COMMA_SEPARATED_ARRAY",
|
||||
},
|
||||
})
|
||||
|
||||
const searchParamsObj = searchParamsToObject(searchParams)
|
||||
const result = parseSearchParams(searchParamsObj, {
|
||||
keyRenameMap: {
|
||||
hotel: "hotelId",
|
||||
room: "rooms",
|
||||
},
|
||||
typeHints: {
|
||||
filter: "COMMA_SEPARATED_ARRAY",
|
||||
packages: "COMMA_SEPARATED_ARRAY",
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual(obj)
|
||||
})
|
||||
})
|
||||
|
||||
// Simulates what Next does behind the scenes for search params
|
||||
const getSearchParams = (input: string) => {
|
||||
const searchParams = new URLSearchParams(input)
|
||||
return searchParamsToObject(searchParams)
|
||||
}
|
||||
const searchParamsToObject = (searchParams: URLSearchParams) => {
|
||||
const obj: Record<string, any> = {}
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
obj[key] = value
|
||||
}
|
||||
return obj
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import type { z } from "zod"
|
||||
|
||||
import type { NextSearchParams } from "@/types/params"
|
||||
|
||||
type ParseOptions<T extends z.ZodRawShape> = {
|
||||
keyRenameMap?: Record<string, string>
|
||||
typeHints?: Record<string, "COMMA_SEPARATED_ARRAY">
|
||||
schema?: z.ZodObject<T>
|
||||
}
|
||||
|
||||
type ParseOptionsWithSchema<T extends z.ZodRawShape> = ParseOptions<T> & {
|
||||
schema: z.ZodObject<T>
|
||||
}
|
||||
|
||||
// This ensures that the return type is correct when a schema is provided
|
||||
export function parseSearchParams<T extends z.ZodRawShape>(
|
||||
searchParams: NextSearchParams,
|
||||
options: ParseOptionsWithSchema<T>
|
||||
): z.infer<typeof options.schema>
|
||||
export function parseSearchParams<T extends z.ZodRawShape>(
|
||||
searchParams: NextSearchParams,
|
||||
options?: ParseOptions<T>
|
||||
): Record<string, any>
|
||||
|
||||
/**
|
||||
* Parses URL search parameters into a structured object.
|
||||
* This function can handle nested objects, arrays, and type validation/transformation using Zod schema.
|
||||
*
|
||||
* @param searchParams - The object to parse
|
||||
* @param options.keyRenameMap - Optional mapping of keys to rename, ie { "oldKey": "newKey" }
|
||||
* @param options.typeHints - Optional type hints to force certain keys to be treated as arrays
|
||||
* @param options.schema - Pass a Zod schema to validate and transform the parsed search parameters and get a typed return value
|
||||
*
|
||||
* Supported formats:
|
||||
* - Objects: `user.name=John&user.age=30`
|
||||
* - Arrays: `tags[0]=javascript&tags[1]=typescript`
|
||||
* - Arrays of objects: `tags[0].name=javascript&tags[0].age=30`
|
||||
* - Nested arrays: `tags[0].languages[0]=javascript&tags[0].languages[1]=typescript`
|
||||
* - Comma-separated arrays: `tags=javascript,typescript`
|
||||
*
|
||||
* For comma-separated arrays you must use the `typeHints`
|
||||
* option to inform the parser that the key should be treated as an array.
|
||||
*/
|
||||
export function parseSearchParams<T extends z.ZodRawShape>(
|
||||
searchParams: NextSearchParams,
|
||||
options?: ParseOptions<T>
|
||||
) {
|
||||
const entries = Object.entries(searchParams)
|
||||
|
||||
const buildObject = getBuilder(options || {})
|
||||
|
||||
const resultObject: Record<string, any> = {}
|
||||
for (const [key, value] of entries) {
|
||||
const paths = key.split(".")
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
throw new Error(
|
||||
`Arrays from duplicate keys (?a=1&a=2) are not yet supported.`
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
continue
|
||||
}
|
||||
|
||||
buildObject(resultObject, paths, value)
|
||||
}
|
||||
|
||||
if (options?.schema) {
|
||||
return options.schema.parse(resultObject)
|
||||
}
|
||||
|
||||
return resultObject
|
||||
}
|
||||
|
||||
// Use a higher-order function to avoid passing the options
|
||||
// object every time we recursively call the builder
|
||||
function getBuilder<T extends z.ZodRawShape>(options: ParseOptions<T>) {
|
||||
const keyRenameMap = options.keyRenameMap || {}
|
||||
const typeHints = options.typeHints || {}
|
||||
|
||||
return function buildNestedObject(
|
||||
obj: Record<string, any>,
|
||||
paths: string[],
|
||||
value: string
|
||||
) {
|
||||
if (paths.length === 0) return
|
||||
|
||||
const path = paths[0]
|
||||
const remainingPaths = paths.slice(1)
|
||||
|
||||
// Extract the key name and optional array index
|
||||
const match = path.match(/^([^\[]+)(?:\[(\d+)\])?$/)
|
||||
if (!match) return
|
||||
const key = keyRenameMap[match[1]] || match[1]
|
||||
const index = match[2] ? parseInt(match[2]) : null
|
||||
|
||||
const forceCommaSeparatedArray = typeHints[key] === "COMMA_SEPARATED_ARRAY"
|
||||
const hasIndex = index !== null
|
||||
|
||||
// If we've reached the last path, set the value
|
||||
if (remainingPaths.length === 0) {
|
||||
// This is either an array or a value that is
|
||||
// forced to be an array by the typeHints
|
||||
if (hasIndex || forceCommaSeparatedArray) {
|
||||
if (isNotArray(obj[key])) obj[key] = []
|
||||
|
||||
if (!hasIndex || forceCommaSeparatedArray) {
|
||||
obj[key] = value.split(",")
|
||||
return
|
||||
}
|
||||
|
||||
obj[key][index] = value
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
obj[key] = value
|
||||
return
|
||||
}
|
||||
|
||||
if (hasIndex) {
|
||||
// If the key is an array, ensure array and element at index exists
|
||||
if (isNotArray(obj[key])) obj[key] = []
|
||||
if (!obj[key][index]) obj[key][index] = {}
|
||||
|
||||
buildNestedObject(obj[key][index], remainingPaths, value)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, it should be an object
|
||||
if (!obj[key]) obj[key] = {}
|
||||
buildNestedObject(obj[key], remainingPaths, value)
|
||||
}
|
||||
}
|
||||
|
||||
function isNotArray(value: any) {
|
||||
return !value || typeof value !== "object" || !Array.isArray(value)
|
||||
}
|
||||
|
||||
type SerializeOptions = {
|
||||
keyRenameMap?: Record<string, string>
|
||||
typeHints?: Record<string, "COMMA_SEPARATED_ARRAY">
|
||||
initialSearchParams?: URLSearchParams
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an object into URL search parameters.
|
||||
*
|
||||
* @param obj - The object to serialize
|
||||
* @param options.keyRenameMap - Optional mapping of keys to rename, ie { "oldKey": "newKey" }
|
||||
* @param options.typeHints - Optional type hints to force certain keys to be treated as comma separated arrays
|
||||
* @param options.initialSearchParams - Optional initial URL search parameters to merge with the serialized object
|
||||
* @returns URLSearchParams - The serialized URL search parameters
|
||||
*
|
||||
* To force a key to be removed when merging with initialSearchParams, set its value to `null` in the object.
|
||||
* Arrays are not merged, they will always replace existing values.
|
||||
*/
|
||||
export function serializeSearchParams(
|
||||
obj: Record<string, any>,
|
||||
options?: SerializeOptions
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams(options?.initialSearchParams)
|
||||
|
||||
const keyRenameMap = options?.keyRenameMap || {}
|
||||
const typeHints = options?.typeHints || {}
|
||||
|
||||
function buildParams(obj: unknown, prefix: string) {
|
||||
if (obj === null || obj === undefined) return
|
||||
|
||||
if (!isRecord(obj)) {
|
||||
params.set(prefix, String(obj))
|
||||
return
|
||||
}
|
||||
|
||||
for (const key in obj) {
|
||||
const value = obj[key]
|
||||
|
||||
const renamedKey = keyRenameMap[key] || key
|
||||
const paramKey = prefix ? `${prefix}.${renamedKey}` : renamedKey
|
||||
|
||||
if (value === null) {
|
||||
params.delete(paramKey)
|
||||
continue
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (typeHints[key] === "COMMA_SEPARATED_ARRAY") {
|
||||
params.set(paramKey, value.join(","))
|
||||
continue
|
||||
}
|
||||
|
||||
// If an array value already exists (from initialSearchParams),
|
||||
// we need to first remove it since it can't be merged.
|
||||
deleteAllKeysStartingWith(params, renamedKey)
|
||||
value.forEach((item, index) => {
|
||||
const indexedKey = `${renamedKey}[${index}]`
|
||||
const arrayKey = prefix ? `${prefix}.${indexedKey}` : indexedKey
|
||||
|
||||
buildParams(item, arrayKey)
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof value === "object" && value !== null) {
|
||||
buildParams(value, paramKey)
|
||||
continue
|
||||
}
|
||||
|
||||
params.set(paramKey, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
buildParams(obj, "")
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function deleteAllKeysStartingWith(searchParams: URLSearchParams, key: string) {
|
||||
const keysToDelete = Array.from(searchParams.keys()).filter(
|
||||
(k) => k.startsWith(key) || k === key
|
||||
)
|
||||
for (const k of keysToDelete) {
|
||||
searchParams.delete(k)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
|
||||
import { SESSION_ID_KEY_NAME } from "@/hooks/useSessionId"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { trackEvent } from "./base"
|
||||
|
||||
import type { BreakfastPackages } from "@/types/components/hotelReservation/breakfast"
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import { bookingSearchTypes } from "@/constants/booking"
|
||||
|
||||
import { parseSearchParams, serializeSearchParams } from "./searchParams"
|
||||
|
||||
import type { BookingWidgetSearchData } from "@/types/components/bookingWidget"
|
||||
import type { DetailsBooking } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import type { SelectHotelBooking } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import type {
|
||||
Room,
|
||||
SelectRateBooking,
|
||||
} from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type { NextSearchParams } from "@/types/params"
|
||||
|
||||
type PartialRoom = { rooms?: Partial<Room>[] }
|
||||
|
||||
export type SelectHotelParams<T> = Omit<T, "hotel"> & {
|
||||
hotelId: string
|
||||
} & PartialRoom
|
||||
|
||||
export function searchParamsToRecord(searchParams: URLSearchParams) {
|
||||
return Object.fromEntries(searchParams.entries())
|
||||
}
|
||||
|
||||
const keyRenameMap = {
|
||||
room: "rooms",
|
||||
ratecode: "rateCode",
|
||||
counterratecode: "counterRateCode",
|
||||
roomtype: "roomTypeCode",
|
||||
fromdate: "fromDate",
|
||||
todate: "toDate",
|
||||
hotel: "hotelId",
|
||||
child: "childrenInRoom",
|
||||
searchtype: "searchType",
|
||||
}
|
||||
const typeHints = {
|
||||
filters: "COMMA_SEPARATED_ARRAY",
|
||||
packages: "COMMA_SEPARATED_ARRAY",
|
||||
} as const
|
||||
const adultsSchema = z.coerce.number().min(1).max(6).catch(0)
|
||||
const childAgeSchema = z.coerce.number().catch(-1)
|
||||
const childBedSchema = z.coerce.number().catch(-1)
|
||||
const searchTypeSchema = z.enum(bookingSearchTypes).optional().catch(undefined)
|
||||
|
||||
export function parseBookingWidgetSearchParams(
|
||||
searchParams: NextSearchParams
|
||||
): BookingWidgetSearchData {
|
||||
try {
|
||||
const result = parseSearchParams(searchParams, {
|
||||
keyRenameMap,
|
||||
typeHints,
|
||||
schema: z.object({
|
||||
city: z.string().optional(),
|
||||
hotelId: z.string().optional(),
|
||||
fromDate: z.string().optional(),
|
||||
toDate: z.string().optional(),
|
||||
bookingCode: z.string().optional(),
|
||||
searchType: searchTypeSchema,
|
||||
rooms: z
|
||||
.array(
|
||||
z.object({
|
||||
adults: adultsSchema,
|
||||
childrenInRoom: z
|
||||
.array(
|
||||
z.object({
|
||||
bed: childBedSchema,
|
||||
age: childAgeSchema,
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error("[URL] Error parsing search params for booking widget:", error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSelectHotelSearchParams(
|
||||
searchParams: NextSearchParams
|
||||
): SelectHotelBooking | null {
|
||||
try {
|
||||
const result = parseSearchParams(searchParams, {
|
||||
keyRenameMap,
|
||||
typeHints,
|
||||
schema: z.object({
|
||||
city: z.string().optional(),
|
||||
hotelId: z.string().optional(),
|
||||
fromDate: z.string(),
|
||||
toDate: z.string(),
|
||||
bookingCode: z.string().optional(),
|
||||
searchType: searchTypeSchema,
|
||||
rooms: z.array(
|
||||
z.object({
|
||||
adults: adultsSchema,
|
||||
childrenInRoom: z
|
||||
.array(
|
||||
z.object({
|
||||
bed: childBedSchema,
|
||||
age: childAgeSchema,
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error("[URL] Error parsing search params for select hotel:", error)
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSelectRateSearchParams(
|
||||
searchParams: NextSearchParams
|
||||
): SelectRateBooking | null {
|
||||
try {
|
||||
const result = parseSearchParams(searchParams, {
|
||||
keyRenameMap,
|
||||
typeHints,
|
||||
schema: z.object({
|
||||
city: z.string().optional(),
|
||||
hotelId: z.string(),
|
||||
fromDate: z.string(),
|
||||
toDate: z.string(),
|
||||
searchType: searchTypeSchema,
|
||||
bookingCode: z.string().optional(),
|
||||
rooms: z.array(
|
||||
z.object({
|
||||
adults: adultsSchema,
|
||||
bookingCode: z.string().optional(),
|
||||
counterRateCode: z.string().optional(),
|
||||
rateCode: z.string().optional(),
|
||||
roomTypeCode: z.string().optional(),
|
||||
packages: z
|
||||
.array(
|
||||
z.nativeEnum({
|
||||
...BreakfastPackageEnum,
|
||||
...RoomPackageCodeEnum,
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
childrenInRoom: z
|
||||
.array(
|
||||
z.object({
|
||||
bed: childBedSchema,
|
||||
age: childAgeSchema,
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error("[URL] Error parsing search params for select rate:", error)
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDetailsSearchParams(
|
||||
searchParams: NextSearchParams
|
||||
): DetailsBooking | null {
|
||||
const packageEnum = {
|
||||
...BreakfastPackageEnum,
|
||||
...RoomPackageCodeEnum,
|
||||
} as const
|
||||
|
||||
try {
|
||||
const result = parseSearchParams(searchParams, {
|
||||
keyRenameMap,
|
||||
typeHints,
|
||||
schema: z.object({
|
||||
city: z.string().optional(),
|
||||
hotelId: z.string(),
|
||||
fromDate: z.string(),
|
||||
toDate: z.string(),
|
||||
searchType: searchTypeSchema,
|
||||
bookingCode: z.string().optional(),
|
||||
rooms: z.array(
|
||||
z.object({
|
||||
adults: adultsSchema,
|
||||
bookingCode: z.string().optional(),
|
||||
counterRateCode: z.string().optional(),
|
||||
rateCode: z.string(),
|
||||
roomTypeCode: z.string(),
|
||||
packages: z.array(z.nativeEnum(packageEnum)).optional(),
|
||||
childrenInRoom: z
|
||||
.array(
|
||||
z.object({
|
||||
bed: childBedSchema,
|
||||
age: childAgeSchema,
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error("[URL] Error parsing search params for details:", error)
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const reversedKeyRenameMap = Object.fromEntries(
|
||||
Object.entries(keyRenameMap).map(([key, value]) => [value, key])
|
||||
)
|
||||
export function serializeBookingSearchParams(
|
||||
obj:
|
||||
| BookingWidgetSearchData
|
||||
| SelectHotelBooking
|
||||
| SelectRateBooking
|
||||
| DetailsBooking,
|
||||
{ initialSearchParams }: { initialSearchParams?: URLSearchParams } = {}
|
||||
) {
|
||||
return serializeSearchParams(obj, {
|
||||
keyRenameMap: reversedKeyRenameMap,
|
||||
initialSearchParams,
|
||||
typeHints,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user