Merged in feat/sw-2861-move-autocomplete-router-to-trpc-package (pull request #2417)
feat(SW-2861): Move autocomplete router to trpc package * Apply lint rules * Use direct imports from trpc package * Add lint-staged config to trpc * Move lang enum to common * Restructure trpc package folder structure * WIP first step * update internal imports in trpc * Fix most errors in scandic-web Just 100 left... * Move Props type out of trpc * Fix CategorizedFilters types * Move more schemas in hotel router * Fix deps * fix getNonContentstackUrls * Fix import error * Fix entry error handling * Fix generateMetadata metrics * Fix alertType enum * Fix duplicated types * lint:fix * Merge branch 'master' into feat/sw-2863-move-contentstack-router-to-trpc-package * Fix broken imports * Move booking router to trpc package * Move partners router to trpc package * Move autocomplete router to trpc package * Merge branch 'master' into feat/sw-2861-move-autocomplete-router-to-trpc-package Approved-by: Linus Flood
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
"./utils/url": "./utils/url.ts",
|
||||
"./utils/languages": "./utils/languages.ts",
|
||||
"./utils/chunk": "./utils/chunk.ts",
|
||||
"./utils/isDefined": "./utils/isDefined.ts",
|
||||
"./utils/zod/stringValidator": "./utils/zod/stringValidator.ts",
|
||||
"./utils/zod/numberValidator": "./utils/zod/numberValidator.ts",
|
||||
"./utils/zod/arrayValidator": "./utils/zod/arrayValidator.ts",
|
||||
|
||||
3
packages/common/utils/isDefined.ts
Normal file
3
packages/common/utils/isDefined.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function isDefined<T>(argument: T | undefined | null): argument is T {
|
||||
return argument !== undefined && argument !== null
|
||||
}
|
||||
0
packages/trpc/.env.test
Normal file
0
packages/trpc/.env.test
Normal file
198
packages/trpc/lib/routers/autocomplete/destinations.ts
Normal file
198
packages/trpc/lib/routers/autocomplete/destinations.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { Lang } from "@scandic-hotels/common/constants/language"
|
||||
import { getCacheClient } from "@scandic-hotels/common/dataCache"
|
||||
import { isDefined } from "@scandic-hotels/common/utils/isDefined"
|
||||
import { safeTry } from "@scandic-hotels/common/utils/safeTry"
|
||||
|
||||
import { safeProtectedServiceProcedure } from "../../procedures"
|
||||
import { getCityPageUrls } from "../../routers/contentstack/destinationCityPage/utils"
|
||||
import { getCountryPageUrls } from "../../routers/contentstack/destinationCountryPage/utils"
|
||||
import { getHotelPageUrls } from "../../routers/contentstack/hotelPage/utils"
|
||||
import {
|
||||
getCitiesByCountry,
|
||||
getCountries,
|
||||
getLocations,
|
||||
} from "../../routers/hotels/utils"
|
||||
import { ApiCountry, type Country } from "../../types/country"
|
||||
import { filterAndCategorizeAutoComplete } from "./util/filterAndCategorizeAutoComplete"
|
||||
import { mapLocationToAutoCompleteLocation } from "./util/mapLocationToAutoCompleteLocation"
|
||||
|
||||
import type { AutoCompleteLocation } from "./schema"
|
||||
|
||||
const destinationsAutoCompleteInputSchema = z.object({
|
||||
query: z.string(),
|
||||
selectedHotelId: z.string().optional(),
|
||||
selectedCity: z.string().optional(),
|
||||
lang: z.nativeEnum(Lang),
|
||||
includeTypes: z.array(z.enum(["hotels", "cities", "countries"])),
|
||||
})
|
||||
|
||||
type DestinationsAutoCompleteOutput = {
|
||||
hits: {
|
||||
hotels: AutoCompleteLocation[]
|
||||
cities: AutoCompleteLocation[]
|
||||
countries: AutoCompleteLocation[]
|
||||
}
|
||||
currentSelection: {
|
||||
hotel: (AutoCompleteLocation & { type: "hotels" }) | null
|
||||
city: (AutoCompleteLocation & { type: "cities" }) | null
|
||||
}
|
||||
}
|
||||
|
||||
export const getDestinationsAutoCompleteRoute = safeProtectedServiceProcedure
|
||||
.input(destinationsAutoCompleteInputSchema)
|
||||
.query(async ({ ctx, input }): Promise<DestinationsAutoCompleteOutput> => {
|
||||
const lang = input.lang || ctx.lang
|
||||
const locations: AutoCompleteLocation[] =
|
||||
await getAutoCompleteDestinationsData({
|
||||
lang,
|
||||
serviceToken: ctx.serviceToken,
|
||||
})
|
||||
|
||||
const hits = filterAndCategorizeAutoComplete({
|
||||
locations: locations,
|
||||
query: input.query,
|
||||
includeTypes: input.includeTypes,
|
||||
})
|
||||
|
||||
const selectedHotel = locations.find(
|
||||
(location) =>
|
||||
location.type === "hotels" && location.id === input.selectedHotelId
|
||||
)
|
||||
|
||||
const selectedCity = locations.find(
|
||||
(location) =>
|
||||
location.type === "cities" &&
|
||||
location.cityIdentifier === input.selectedCity
|
||||
)
|
||||
|
||||
return {
|
||||
hits: hits,
|
||||
currentSelection: {
|
||||
city: isCity(selectedCity) ? selectedCity : null,
|
||||
hotel: isHotel(selectedHotel) ? selectedHotel : null,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function isHotel(
|
||||
location: AutoCompleteLocation | null | undefined
|
||||
): location is AutoCompleteLocation & { type: "hotels" } {
|
||||
return !!location && location.type === "hotels"
|
||||
}
|
||||
|
||||
function isCity(
|
||||
location: AutoCompleteLocation | null | undefined
|
||||
): location is AutoCompleteLocation & { type: "cities" } {
|
||||
return !!location && location.type === "cities"
|
||||
}
|
||||
|
||||
export async function getAutoCompleteDestinationsData({
|
||||
lang,
|
||||
serviceToken,
|
||||
warmup = false,
|
||||
}: {
|
||||
lang: Lang
|
||||
serviceToken: string
|
||||
warmup?: boolean
|
||||
}) {
|
||||
const cacheClient = await getCacheClient()
|
||||
return await cacheClient.cacheOrGet(
|
||||
`autocomplete:destinations:locations:${lang}`,
|
||||
async () => {
|
||||
const hotelUrlsPromise = safeTry(getHotelPageUrls(lang))
|
||||
const cityUrlsPromise = safeTry(getCityPageUrls(lang))
|
||||
const countryUrlsPromise = safeTry(getCountryPageUrls(lang))
|
||||
const countries = await getCountries({
|
||||
lang: lang,
|
||||
serviceToken,
|
||||
})
|
||||
|
||||
if (!countries) {
|
||||
console.error("Unable to fetch countries")
|
||||
throw new Error("Unable to fetch countries")
|
||||
}
|
||||
|
||||
const countryNames = countries.data.map((country) => country.name)
|
||||
const citiesByCountry = await getCitiesByCountry({
|
||||
countries: countryNames,
|
||||
serviceToken: serviceToken,
|
||||
lang,
|
||||
})
|
||||
|
||||
const locations = await getLocations({
|
||||
lang: lang,
|
||||
serviceToken: serviceToken,
|
||||
citiesByCountry: citiesByCountry,
|
||||
})
|
||||
|
||||
const activeLocations = locations.filter((location) => {
|
||||
return (
|
||||
location.type === "cities" ||
|
||||
(location.type === "hotels" && location.isActive)
|
||||
)
|
||||
})
|
||||
|
||||
const [hotelUrls, hotelUrlsError] = await hotelUrlsPromise
|
||||
const [cityUrls, cityUrlsError] = await cityUrlsPromise
|
||||
const [countryUrls, countryUrlsError] = await countryUrlsPromise
|
||||
|
||||
if (
|
||||
hotelUrlsError ||
|
||||
cityUrlsError ||
|
||||
countryUrlsError ||
|
||||
!hotelUrls ||
|
||||
!cityUrls ||
|
||||
!countryUrls
|
||||
) {
|
||||
console.error("Unable to fetch location URLs")
|
||||
throw new Error("Unable to fetch location URLs")
|
||||
}
|
||||
|
||||
const hotelsAndCities = activeLocations
|
||||
.map((location) => {
|
||||
let url: string | undefined
|
||||
|
||||
if (location.type === "cities") {
|
||||
url = cityUrls.find(
|
||||
(c) =>
|
||||
c.city &&
|
||||
location.cityIdentifier &&
|
||||
c.city === location.cityIdentifier
|
||||
)?.url
|
||||
}
|
||||
|
||||
if (location.type === "hotels") {
|
||||
url = hotelUrls.find(
|
||||
(h) => h.hotelId && location.id && h.hotelId === location.id
|
||||
)?.url
|
||||
}
|
||||
|
||||
return { ...location, url }
|
||||
})
|
||||
.map(mapLocationToAutoCompleteLocation)
|
||||
.filter(isDefined)
|
||||
|
||||
const countryAutoCompleteLocations = countries.data.map((country) => {
|
||||
const url = countryUrls.find(
|
||||
(c) =>
|
||||
c.country && ApiCountry[lang][c.country as Country] === country.name
|
||||
)?.url
|
||||
|
||||
return {
|
||||
id: country.id,
|
||||
name: country.name,
|
||||
type: "countries",
|
||||
searchTokens: [country.name],
|
||||
destination: "",
|
||||
url,
|
||||
} satisfies AutoCompleteLocation
|
||||
})
|
||||
|
||||
return [...hotelsAndCities, ...countryAutoCompleteLocations]
|
||||
},
|
||||
"1d",
|
||||
{ cacheStrategy: warmup ? "fetch-then-cache" : "cache-first" }
|
||||
)
|
||||
}
|
||||
6
packages/trpc/lib/routers/autocomplete/index.ts
Normal file
6
packages/trpc/lib/routers/autocomplete/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { router } from "../.."
|
||||
import { getDestinationsAutoCompleteRoute } from "./destinations"
|
||||
|
||||
export const autocompleteRouter = router({
|
||||
destinations: getDestinationsAutoCompleteRoute,
|
||||
})
|
||||
13
packages/trpc/lib/routers/autocomplete/schema.ts
Normal file
13
packages/trpc/lib/routers/autocomplete/schema.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const autoCompleteLocationSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(["cities", "hotels", "countries"]),
|
||||
searchTokens: z.array(z.string()),
|
||||
destination: z.string(),
|
||||
url: z.string().optional(),
|
||||
cityIdentifier: z.string().optional(),
|
||||
})
|
||||
|
||||
export type AutoCompleteLocation = z.infer<typeof autoCompleteLocationSchema>
|
||||
@@ -0,0 +1,68 @@
|
||||
import { filterAutoCompleteLocations } from "./filterAutoCompleteLocations"
|
||||
|
||||
import type { AutoCompleteLocation } from "../schema"
|
||||
|
||||
export type DestinationsAutoCompleteOutput = {
|
||||
hits: {
|
||||
hotels: AutoCompleteLocation[]
|
||||
cities: AutoCompleteLocation[]
|
||||
countries: AutoCompleteLocation[]
|
||||
}
|
||||
currentSelection: {
|
||||
hotel: (AutoCompleteLocation & { type: "hotels" }) | null
|
||||
city: (AutoCompleteLocation & { type: "cities" }) | null
|
||||
}
|
||||
}
|
||||
|
||||
export function filterAndCategorizeAutoComplete({
|
||||
locations,
|
||||
query,
|
||||
includeTypes,
|
||||
}: {
|
||||
locations: AutoCompleteLocation[]
|
||||
query: string
|
||||
includeTypes: ("cities" | "hotels" | "countries")[]
|
||||
}) {
|
||||
const rankedLocations = filterAutoCompleteLocations(locations, query)
|
||||
|
||||
const sortedCities = rankedLocations.filter(
|
||||
(loc) => shouldIncludeType(includeTypes, loc) && isCity(loc)
|
||||
)
|
||||
const sortedHotels = rankedLocations.filter(
|
||||
(loc) => shouldIncludeType(includeTypes, loc) && isHotel(loc)
|
||||
)
|
||||
const sortedCountries = rankedLocations.filter(
|
||||
(loc) => shouldIncludeType(includeTypes, loc) && isCountry(loc)
|
||||
)
|
||||
|
||||
return {
|
||||
cities: sortedCities,
|
||||
hotels: sortedHotels,
|
||||
countries: sortedCountries,
|
||||
}
|
||||
}
|
||||
|
||||
function shouldIncludeType(
|
||||
includedTypes: ("cities" | "hotels" | "countries")[],
|
||||
location: AutoCompleteLocation
|
||||
) {
|
||||
return includedTypes.includes(location.type)
|
||||
}
|
||||
|
||||
function isHotel(
|
||||
location: AutoCompleteLocation | null | undefined
|
||||
): location is AutoCompleteLocation & { type: "hotels" } {
|
||||
return !!location && location.type === "hotels"
|
||||
}
|
||||
|
||||
function isCountry(
|
||||
location: AutoCompleteLocation | null | undefined
|
||||
): location is AutoCompleteLocation & { type: "countries" } {
|
||||
return !!location && location.type === "countries"
|
||||
}
|
||||
|
||||
function isCity(
|
||||
location: AutoCompleteLocation | null | undefined
|
||||
): location is AutoCompleteLocation & { type: "cities" } {
|
||||
return !!location && location.type === "cities"
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { filterAutoCompleteLocations } from "./filterAutoCompleteLocations"
|
||||
|
||||
import type { DeepPartial } from "../../../types/deepPartial"
|
||||
import type { AutoCompleteLocation } from "../schema"
|
||||
|
||||
describe("rankAutoCompleteLocations", () => {
|
||||
it("should give no hits when the query does not match", () => {
|
||||
const locations = [scandicAlborgOst] as DeepPartial<AutoCompleteLocation>[]
|
||||
|
||||
const query = "NonMatchingQuery"
|
||||
const ranked = filterAutoCompleteLocations(
|
||||
locations as AutoCompleteLocation[],
|
||||
query
|
||||
)
|
||||
|
||||
expect(ranked.length).toBe(0)
|
||||
})
|
||||
|
||||
it("should include items when the query matches parts of name", () => {
|
||||
const locations = [scandicAlborgOst] as DeepPartial<AutoCompleteLocation>[]
|
||||
|
||||
const query = "Øst"
|
||||
const ranked = filterAutoCompleteLocations(
|
||||
locations as AutoCompleteLocation[],
|
||||
query
|
||||
)
|
||||
|
||||
expect(ranked.length).toBe(1)
|
||||
expect(ranked.at(0)!.name).toEqual("Scandic Aalborg Øst")
|
||||
})
|
||||
|
||||
it("should allow multiple search terms", () => {
|
||||
const locations = [scandicAlborgOst] as DeepPartial<AutoCompleteLocation>[]
|
||||
|
||||
const query = "Aalborg Øst"
|
||||
const ranked = filterAutoCompleteLocations(
|
||||
locations as AutoCompleteLocation[],
|
||||
query
|
||||
)
|
||||
|
||||
expect(ranked.length).toBe(1)
|
||||
expect(ranked.at(0)!.name).toEqual("Scandic Aalborg Øst")
|
||||
})
|
||||
|
||||
it("should rank full word higher than part of word", () => {
|
||||
const locations = [
|
||||
scandicSyvSostre,
|
||||
scandicAlborgOst,
|
||||
] as DeepPartial<AutoCompleteLocation>[]
|
||||
|
||||
const query = "Øst"
|
||||
const ranked = filterAutoCompleteLocations(
|
||||
locations as AutoCompleteLocation[],
|
||||
query
|
||||
)
|
||||
|
||||
expect(ranked.length).toBe(2)
|
||||
expect(ranked.at(0)!.name).toEqual("Scandic Aalborg Øst")
|
||||
expect(ranked.at(1)!.name).toEqual("Scandic Syv Søstre")
|
||||
})
|
||||
|
||||
it("should ignore items without match", () => {
|
||||
const locations = [
|
||||
scandicSyvSostre,
|
||||
scandicAlborgOst,
|
||||
berlinLodge,
|
||||
scandicBrennemoen,
|
||||
] as DeepPartial<AutoCompleteLocation>[]
|
||||
|
||||
const query = "Øst"
|
||||
const ranked = filterAutoCompleteLocations(
|
||||
locations as AutoCompleteLocation[],
|
||||
query
|
||||
)
|
||||
|
||||
expect(ranked.length).toBe(3)
|
||||
expect(ranked.at(0)!.name).toEqual("Scandic Aalborg Øst")
|
||||
expect(ranked.at(1)!.name).toEqual("Scandic Syv Søstre")
|
||||
expect(ranked.at(2)!.name).toEqual("Scandic Brennemoen")
|
||||
})
|
||||
|
||||
it("should ignore 'scandic' from name and destination when searching", () => {
|
||||
const locations = [scandicAlborgOst, scandicBrennemoen].map((x) => ({
|
||||
...x,
|
||||
searchTokens: [],
|
||||
})) as DeepPartial<AutoCompleteLocation>[]
|
||||
|
||||
const query = "scandic"
|
||||
const ranked = filterAutoCompleteLocations(
|
||||
locations as AutoCompleteLocation[],
|
||||
query
|
||||
)
|
||||
|
||||
expect(ranked.length).toBe(0)
|
||||
})
|
||||
|
||||
it("should get hits for destination", () => {
|
||||
const locations = [
|
||||
scandicAlborgOst,
|
||||
scandicBrennemoen,
|
||||
] as DeepPartial<AutoCompleteLocation>[]
|
||||
|
||||
const query = "Mysen"
|
||||
const ranked = filterAutoCompleteLocations(
|
||||
locations as AutoCompleteLocation[],
|
||||
query
|
||||
)
|
||||
|
||||
expect(ranked.length).toBe(1)
|
||||
expect(ranked.at(0)!.name).toBe("Scandic Brennemoen")
|
||||
})
|
||||
|
||||
it("should get hits for searchTokens", () => {
|
||||
const locations = [
|
||||
scandicAlborgOst,
|
||||
scandicBrennemoen,
|
||||
] as DeepPartial<AutoCompleteLocation>[]
|
||||
|
||||
const query = "tusenfryd"
|
||||
const ranked = filterAutoCompleteLocations(
|
||||
locations as AutoCompleteLocation[],
|
||||
query
|
||||
)
|
||||
|
||||
expect(ranked.length).toBe(1)
|
||||
expect(ranked.at(0)!.name).toBe("Scandic Brennemoen")
|
||||
})
|
||||
|
||||
it("should match when using the wrong aumlaut ö -> ø", () => {
|
||||
const locations = [scandicBodo] as DeepPartial<AutoCompleteLocation>[]
|
||||
|
||||
const query = "bodö"
|
||||
const ranked = filterAutoCompleteLocations(
|
||||
locations as AutoCompleteLocation[],
|
||||
query
|
||||
)
|
||||
|
||||
expect(ranked.length).toBe(1)
|
||||
expect(ranked.at(0)!.name).toBe("Scandic Bodø")
|
||||
})
|
||||
})
|
||||
|
||||
const scandicAlborgOst: DeepPartial<AutoCompleteLocation> = {
|
||||
name: "Scandic Aalborg Øst",
|
||||
destination: "Aalborg",
|
||||
searchTokens: [
|
||||
"aalborg",
|
||||
"aalborg øst",
|
||||
"scandic aalborg øst",
|
||||
"aalborg ost",
|
||||
"scandic aalborg ost",
|
||||
],
|
||||
}
|
||||
|
||||
const scandicBrennemoen: DeepPartial<AutoCompleteLocation> = {
|
||||
name: "Scandic Brennemoen",
|
||||
destination: "Mysen",
|
||||
searchTokens: [
|
||||
"mysen",
|
||||
"askim",
|
||||
"indre østfold",
|
||||
"drøbak",
|
||||
"slitu",
|
||||
"morenen",
|
||||
"østfoldbadet",
|
||||
"tusenfryd",
|
||||
"brennemoen",
|
||||
"scandic brennemoen",
|
||||
"indre ostfold",
|
||||
"drobak",
|
||||
"ostfoldbadet",
|
||||
],
|
||||
}
|
||||
|
||||
const scandicSyvSostre: DeepPartial<AutoCompleteLocation> = {
|
||||
name: "Scandic Syv Søstre",
|
||||
destination: "Sandnessjoen",
|
||||
searchTokens: [
|
||||
"syv sostre",
|
||||
"sandnessjoen",
|
||||
"sandnessjøen",
|
||||
"syv søstre",
|
||||
"scandic syv søstre",
|
||||
"scandic syv sostre",
|
||||
],
|
||||
}
|
||||
|
||||
const berlinLodge: DeepPartial<AutoCompleteLocation> = {
|
||||
name: "Berlin Lodge",
|
||||
searchTokens: [],
|
||||
}
|
||||
|
||||
const scandicBodo: DeepPartial<AutoCompleteLocation> = {
|
||||
name: "Scandic Bodø",
|
||||
destination: "Bodo",
|
||||
searchTokens: [
|
||||
"bodo",
|
||||
"kjerringoy",
|
||||
"bodo i vinden",
|
||||
"visit bodo",
|
||||
"badin",
|
||||
"scandic bodo",
|
||||
"bodø",
|
||||
"stormen",
|
||||
"midnattsol",
|
||||
"hurtigruten",
|
||||
"saltstraumen",
|
||||
"nord universitet",
|
||||
"kjerringøy",
|
||||
"nordlys",
|
||||
"tuvsjyen",
|
||||
"stella polaris",
|
||||
"topptur",
|
||||
"svartisen",
|
||||
"polarsirkelen",
|
||||
"aurora borealis",
|
||||
"bodø i vinden",
|
||||
"visit bodø",
|
||||
"bådin",
|
||||
"norsk luftfartsmuseum",
|
||||
"rib",
|
||||
"scandic bodø",
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Fuse from "fuse.js"
|
||||
|
||||
import type { AutoCompleteLocation } from "../schema"
|
||||
|
||||
type SearchableAutoCompleteLocation = AutoCompleteLocation & {
|
||||
nameTokens: string[]
|
||||
destinationTokens: string[]
|
||||
}
|
||||
type SearchableKey = keyof SearchableAutoCompleteLocation
|
||||
|
||||
const fuseConfig = new Fuse([] as SearchableAutoCompleteLocation[], {
|
||||
minMatchCharLength: 2,
|
||||
isCaseSensitive: false,
|
||||
ignoreDiacritics: true,
|
||||
includeMatches: true,
|
||||
includeScore: true,
|
||||
threshold: 0.2,
|
||||
keys: [
|
||||
{
|
||||
name: "nameTokens" satisfies SearchableKey,
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
name: "destinationTokens" satisfies SearchableKey,
|
||||
weight: 2,
|
||||
},
|
||||
{
|
||||
name: "searchTokens" satisfies SearchableKey,
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export function filterAutoCompleteLocations<T extends AutoCompleteLocation>(
|
||||
locations: T[],
|
||||
query: string
|
||||
) {
|
||||
const searchable = locations.map((x) => ({
|
||||
...x,
|
||||
nameTokens: extractTokens(x.name),
|
||||
destinationTokens: extractTokens(x.destination),
|
||||
}))
|
||||
fuseConfig.setCollection(searchable)
|
||||
|
||||
const searchResults = fuseConfig.search(query, { limit: 50 })
|
||||
|
||||
return searchResults.map(
|
||||
(x) =>
|
||||
({
|
||||
id: x.item.id,
|
||||
name: x.item.name,
|
||||
cityIdentifier: x.item.cityIdentifier,
|
||||
destination: x.item.destination,
|
||||
searchTokens: x.item.searchTokens,
|
||||
type: x.item.type,
|
||||
url: x.item.url,
|
||||
}) satisfies AutoCompleteLocation
|
||||
)
|
||||
}
|
||||
|
||||
function extractTokens(value: string): string[] {
|
||||
const cleaned = value?.toLowerCase().replaceAll("scandic", "").trim() ?? ""
|
||||
const output = [...new Set([cleaned, ...cleaned.split(" ")])]
|
||||
|
||||
return output
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { getSearchTokens } from "./getSearchTokens"
|
||||
|
||||
import type { Location } from "@scandic-hotels/trpc/types/locations"
|
||||
|
||||
import type { DeepPartial } from "../../../types/deepPartial"
|
||||
|
||||
describe("getSearchTokens", () => {
|
||||
it("should return lowercased tokens for a hotel location", () => {
|
||||
const location: DeepPartial<Location> = {
|
||||
keyWords: ["Beach", "Luxury"],
|
||||
name: "Grand Hotel",
|
||||
type: "hotels",
|
||||
relationships: { city: { name: "Stockholm" } },
|
||||
}
|
||||
|
||||
const result = getSearchTokens(location as Location)
|
||||
expect(result).toEqual(["beach", "luxury", "grand hotel", "stockholm"])
|
||||
})
|
||||
|
||||
it("should generate additional tokens for diacritics replacement on a non-hotel location", () => {
|
||||
const location: DeepPartial<Location> = {
|
||||
keyWords: ["Ångström", "Café"],
|
||||
name: "München",
|
||||
country: "Frånce",
|
||||
type: "cities",
|
||||
}
|
||||
|
||||
const result = getSearchTokens(location as Location)
|
||||
expect(result.toSorted()).toEqual(
|
||||
[
|
||||
"ångström",
|
||||
"café",
|
||||
"münchen",
|
||||
"frånce",
|
||||
"angstrom",
|
||||
"cafe",
|
||||
"munchen",
|
||||
"france",
|
||||
].toSorted()
|
||||
)
|
||||
})
|
||||
|
||||
it("should filter out empty or falsey tokens", () => {
|
||||
const location: DeepPartial<Location> = {
|
||||
keyWords: ["", "Valid"],
|
||||
name: "",
|
||||
type: "hotels",
|
||||
relationships: { city: { name: "" } },
|
||||
}
|
||||
|
||||
const result = getSearchTokens(location as Location)
|
||||
expect(result).toEqual(["valid"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { normalizeAumlauts } from "./normalizeAumlauts"
|
||||
|
||||
import type { Location } from "../../../types/locations"
|
||||
|
||||
export function getSearchTokens(location: Location) {
|
||||
const tokens = [
|
||||
...(location.keyWords?.map((x) => x.toLocaleLowerCase()) ?? []),
|
||||
location.name,
|
||||
location.type === "hotels"
|
||||
? location.relationships.city.name
|
||||
: location.country,
|
||||
]
|
||||
.filter(hasValue)
|
||||
.map((x) => x.toLocaleLowerCase())
|
||||
|
||||
const normalizedTokens = normalizeAumlauts(tokens)
|
||||
return normalizedTokens
|
||||
}
|
||||
|
||||
function hasValue(value: string | null | undefined): value is string {
|
||||
return !!value && value.length > 0
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getSearchTokens } from "./getSearchTokens"
|
||||
|
||||
import type { Location } from "../../../types/locations"
|
||||
import type { AutoCompleteLocation } from "../schema"
|
||||
|
||||
export function mapLocationToAutoCompleteLocation(
|
||||
location: (Location & { url?: string }) | null | undefined
|
||||
): AutoCompleteLocation | null {
|
||||
if (!location) return null
|
||||
|
||||
return {
|
||||
id: location.id,
|
||||
name: location.name,
|
||||
type: location.type,
|
||||
url: location.url,
|
||||
searchTokens: getSearchTokens(location),
|
||||
destination:
|
||||
location.type === "hotels"
|
||||
? location.relationships.city.name
|
||||
: location.country,
|
||||
cityIdentifier:
|
||||
location.type === "cities" ? location.cityIdentifier : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function normalizeAumlauts(terms: string[]): string[] {
|
||||
const additionalTerms: string[] = []
|
||||
|
||||
terms.forEach((token) => {
|
||||
if (!token) return
|
||||
|
||||
const replaced = token
|
||||
.replace(/å/g, "a")
|
||||
.replace(/ä/g, "a")
|
||||
.replace(/ö/g, "o")
|
||||
.replace(/ø/g, "o")
|
||||
.replace(/æ/g, "a")
|
||||
.replace(/é/g, "e")
|
||||
.replace(/ü/g, "u")
|
||||
|
||||
if (replaced !== token) {
|
||||
additionalTerms.push(replaced)
|
||||
}
|
||||
})
|
||||
|
||||
return [...new Set([...additionalTerms, ...terms])]
|
||||
}
|
||||
@@ -2,6 +2,24 @@ import { Lang } from "@scandic-hotels/common/constants/language"
|
||||
import { getCacheClient } from "@scandic-hotels/common/dataCache"
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
import { createCounter } from "@scandic-hotels/common/telemetry"
|
||||
|
||||
import { env } from "../../../env/server"
|
||||
import { router } from "../.."
|
||||
import * as api from "../../api"
|
||||
import { REDEMPTION } from "../../constants/booking"
|
||||
import { BreakfastPackageEnum } from "../../enums/breakfast"
|
||||
import { RateEnum } from "../../enums/rate"
|
||||
import { RateTypeEnum } from "../../enums/rateType"
|
||||
import { AvailabilityEnum } from "../../enums/selectHotel"
|
||||
import { badRequestError, unauthorizedError } from "../../errors"
|
||||
import {
|
||||
contentStackBaseWithServiceProcedure,
|
||||
publicProcedure,
|
||||
safeProtectedServiceProcedure,
|
||||
serviceProcedure,
|
||||
} from "../../procedures"
|
||||
import { getCityPageUrls } from "../../routers/contentstack/destinationCityPage/utils"
|
||||
import { getHotelPageUrls } from "../../routers/contentstack/hotelPage/utils"
|
||||
import {
|
||||
ancillaryPackageInputSchema,
|
||||
breakfastPackageInputSchema,
|
||||
@@ -23,46 +41,30 @@ import {
|
||||
roomPackagesInputSchema,
|
||||
selectRateRoomAvailabilityInputSchema,
|
||||
selectRateRoomsAvailabilityInputSchema,
|
||||
} from "@scandic-hotels/trpc/routers/hotels/input"
|
||||
} from "../../routers/hotels/input"
|
||||
import {
|
||||
ancillaryPackagesSchema,
|
||||
breakfastPackagesSchema,
|
||||
getNearbyHotelIdsSchema,
|
||||
} from "@scandic-hotels/trpc/routers/hotels/output"
|
||||
import { additionalDataSchema } from "@scandic-hotels/trpc/routers/hotels/schemas/hotel/include/additionalData"
|
||||
|
||||
import { env } from "../../../env/server"
|
||||
import { router } from "../.."
|
||||
import * as api from "../../api"
|
||||
import { REDEMPTION } from "../../constants/booking"
|
||||
import { BreakfastPackageEnum } from "../../enums/breakfast"
|
||||
import { RateEnum } from "../../enums/rate"
|
||||
import { RateTypeEnum } from "../../enums/rateType"
|
||||
import { AvailabilityEnum } from "../../enums/selectHotel"
|
||||
import { badRequestError, unauthorizedError } from "../../errors"
|
||||
import {
|
||||
contentStackBaseWithServiceProcedure,
|
||||
publicProcedure,
|
||||
safeProtectedServiceProcedure,
|
||||
serviceProcedure,
|
||||
} from "../../procedures"
|
||||
import { getCityPageUrls } from "../../routers/contentstack/destinationCityPage/utils"
|
||||
import { getHotelPageUrls } from "../../routers/contentstack/hotelPage/utils"
|
||||
} from "../../routers/hotels/output"
|
||||
import { additionalDataSchema } from "../../routers/hotels/schemas/hotel/include/additionalData"
|
||||
import { toApiLang } from "../../utils"
|
||||
import { getVerifiedUser } from "../user/utils"
|
||||
import { meetingRoomsSchema } from "./schemas/meetingRoom"
|
||||
import {
|
||||
getBedTypes,
|
||||
getCitiesByCountry,
|
||||
getCountries,
|
||||
getHotel,
|
||||
getHotelIdsByCityId,
|
||||
getHotelIdsByCityIdentifier,
|
||||
getHotelIdsByCountry,
|
||||
getHotelsAvailabilityByCity,
|
||||
getHotelsAvailabilityByHotelIds,
|
||||
getHotelsByHotelIds,
|
||||
getLocations,
|
||||
} from "./utils"
|
||||
import {
|
||||
getBedTypes,
|
||||
getHotelsAvailabilityByCity,
|
||||
getHotelsAvailabilityByHotelIds,
|
||||
getPackages,
|
||||
getRoomsAvailability,
|
||||
getSelectedRoomAvailability,
|
||||
|
||||
5
packages/trpc/lib/types/deepPartial.ts
Normal file
5
packages/trpc/lib/types/deepPartial.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type DeepPartial<T> = T extends object
|
||||
? {
|
||||
[P in keyof T]?: DeepPartial<T[P]>
|
||||
}
|
||||
: T
|
||||
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"check-types": "tsc --noEmit",
|
||||
"lint": "eslint . --max-warnings 0 && tsc --noEmit",
|
||||
"lint:fix": "eslint . --fix && tsc --noEmit"
|
||||
"lint:fix": "eslint . --fix && tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"exports": {
|
||||
".": "./lib/index.ts",
|
||||
@@ -24,6 +26,7 @@
|
||||
"./routers/booking/*": "./lib/routers/booking/*.ts",
|
||||
"./routers/user/*": "./lib/routers/user/*.ts",
|
||||
"./routers/partners/*": "./lib/routers/partners/*.ts",
|
||||
"./routers/autocomplete/*": "./lib/routers/autocomplete/*.ts",
|
||||
"./enums/*": "./lib/enums/*.ts",
|
||||
"./types/*": "./lib/types/*.ts",
|
||||
"./constants/*": "./lib/constants/*.ts",
|
||||
@@ -46,6 +49,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"deepmerge": "^4.3.1",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"graphql": "^16.11.0",
|
||||
"graphql-request": "^7.1.2",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
@@ -68,9 +72,11 @@
|
||||
"@types/react": "19.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.32.0",
|
||||
"@typescript-eslint/parser": "^8.32.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"eslint": "^9",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"typescript": "5.8.3"
|
||||
"typescript": "5.8.3",
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
3
packages/trpc/vitest-setup.ts
Normal file
3
packages/trpc/vitest-setup.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { config } from "dotenv"
|
||||
|
||||
config({ path: "./.env.test" })
|
||||
7
packages/trpc/vitest.config.js
Normal file
7
packages/trpc/vitest.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./vitest-setup.ts"],
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user