Merged in feature/autocomplete-search (pull request #1725)

Feature/autocomplete search

* wip autocomplete search

* add skeletons to loading

* Using aumlauts/accents when searching will still give results
remove unused reducer
sort autocomplete results

* remove testcode

* Add tests for autocomplete

* cleanup tests

* use node@20

* use node 22

* use node22

* merge
fix: search button outside of viewport

* merge

* remove more unused code

* fix: error message when empty search field in booking widget

* fix: don't display empty white box when search field is empty and no searchHistory is present

* merge

* fix: set height of shimmer for search skeleton

* rename autocomplete trpc -> destinationsAutocomplete

* more accute cache key naming

* fix: able to control wether bookingwidget is visible on startPage
fix: sticky booking widget under alert

* remove unused code

* fix: skeletons
fix: error overlay on search startpage

* remove extra .nvmrc

* merge


Approved-by: Linus Flood
This commit is contained in:
Joakim Jäderberg
2025-04-09 10:43:08 +00:00
parent 7e6abe1f03
commit da07e8a458
40 changed files with 1024 additions and 666 deletions

View File

@@ -0,0 +1,122 @@
import { z } from "zod"
import { Lang } from "@/constants/languages"
import { safeProtectedServiceProcedure } from "@/server/trpc"
import { getCacheClient } from "@/services/dataCache"
import { getCitiesByCountry, getCountries, getLocations } from "../hotels/utils"
import { filterLocationByQuery } from "./util/filterLocationByQuery"
import { mapLocationToAutoCompleteLocation } from "./util/mapLocationToAutoCompleteLocation"
import { sortAutocompleteLocations } from "./util/sortAutocompleteLocations"
import type { AutoCompleteLocation } from "./schema"
const destinationsAutoCompleteInputSchema = z.object({
query: z.string(),
selectedHotelId: z.string().optional(),
selectedCity: z.string().optional(),
lang: z.nativeEnum(Lang),
})
type DestinationsAutoCompleteOutput = {
hits: {
hotels: AutoCompleteLocation[]
cities: 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 cacheClient = await getCacheClient()
const locations: AutoCompleteLocation[] = await cacheClient.cacheOrGet(
`autocomplete:destinations:locations:${input.lang}`,
async () => {
const lang = input.lang || ctx.lang
const countries = await getCountries({
lang: lang,
serviceToken: ctx.serviceToken,
})
if (!countries) {
throw new Error("Unable to fetch countries")
}
const countryNames = countries.data.map((country) => country.name)
const citiesByCountry = await getCitiesByCountry({
countries: countryNames,
serviceToken: ctx.serviceToken,
lang,
})
const locations = await getLocations({
lang: lang,
serviceToken: ctx.serviceToken,
citiesByCountry: citiesByCountry,
})
return locations
.map(mapLocationToAutoCompleteLocation)
.filter(isDefined)
},
"1d"
)
const filteredLocations = locations.filter((location) =>
filterLocationByQuery({ location, query: input.query })
)
const selectedHotel = locations.find(
(location) =>
location.type === "hotels" && location.id === input.selectedHotelId
)
const selectedCity = locations.find(
(location) =>
location.type === "cities" && location.name === input.selectedCity
)
const sortedCities = sortAutocompleteLocations(
filteredLocations.filter(isCity),
input.query
)
const sortedHotels = sortAutocompleteLocations(
filteredLocations.filter(isHotel),
input.query
)
return {
hits: {
cities: sortedCities,
hotels: sortedHotels,
},
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"
}
function isDefined(
value: AutoCompleteLocation | null | undefined
): value is AutoCompleteLocation {
return !!value
}

View File

@@ -0,0 +1,7 @@
import { router } from "@/server/trpc"
import { getDestinationsAutoCompleteRoute } from "./destinations"
export const autocompleteRouter = router({
destinations: getDestinationsAutoCompleteRoute,
})

View File

@@ -0,0 +1,10 @@
import { z } from "zod"
export const autoCompleteLocationSchema = z.object({
id: z.string(),
name: z.string(),
type: z.enum(["cities", "hotels"]),
searchTokens: z.array(z.string()),
destination: z.string(),
})
export type AutoCompleteLocation = z.infer<typeof autoCompleteLocationSchema>

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from "@jest/globals"
import { filterLocationByQuery } from "./filterLocationByQuery"
import type { DeepPartial } from "@/types/DeepPartial"
import type { AutoCompleteLocation } from "../schema"
describe("filterLocationByQuery", () => {
it("should return false if the query is too short", () => {
const location: DeepPartial<AutoCompleteLocation> = {
searchTokens: ["beach", "luxury"],
}
expect(
filterLocationByQuery({
location: location as AutoCompleteLocation,
query: " a ",
})
).toBe(false)
expect(
filterLocationByQuery({
location: location as AutoCompleteLocation,
query: " ",
})
).toBe(false)
})
it("should return true if one of the search tokens includes part of a valid query token", () => {
const location: DeepPartial<AutoCompleteLocation> = {
searchTokens: ["beach", "grand hotel", "stockholm"],
}
expect(
filterLocationByQuery({
location: location as AutoCompleteLocation,
query: "Bea",
})
).toBe(true)
expect(
filterLocationByQuery({
location: location as AutoCompleteLocation,
query: "hotel",
})
).toBe(true)
})
it("should return false if none of the search tokens include a valid query token", () => {
const location: DeepPartial<AutoCompleteLocation> = {
searchTokens: ["beach", "grand hotel", "stockholm"],
}
expect(
filterLocationByQuery({
location: location as AutoCompleteLocation,
query: "xyz",
})
).toBe(false)
expect(
filterLocationByQuery({
location: location as AutoCompleteLocation,
query: "garbage",
})
).toBe(false)
})
it("should correctly handle queries with punctuation and extra spaces", () => {
const location: DeepPartial<AutoCompleteLocation> = {
searchTokens: ["grand hotel", "stockholm"],
}
expect(
filterLocationByQuery({
location: location as AutoCompleteLocation,
query: " Grand Hotel! ",
})
).toBe(true)
})
it("should work with queries containing multiple valid tokens", () => {
const location: DeepPartial<AutoCompleteLocation> = {
searchTokens: ["beach", "luxury", "grand hotel", "stockholm"],
}
expect(
filterLocationByQuery({
location: location as AutoCompleteLocation,
query: "luxury beach",
})
).toBe(true)
})
})

View File

@@ -0,0 +1,23 @@
import type { AutoCompleteLocation } from "../schema"
export function filterLocationByQuery({
location,
query,
}: {
location: AutoCompleteLocation
query: string
}) {
const queryable = query
.trim()
.toLowerCase()
.replace(/[^A-Za-zÀ-ÖØ-öø-ÿ0-9\s]/g, "") // Only keep alphanumeric characters and it's accents
.substring(0, 30)
.split(/\s+/)
.filter((s) => s.length > 2)
if (queryable.length === 0) return false
return location.searchTokens?.some((token) =>
queryable.some((q) => token.toLowerCase().includes(q))
)
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "@jest/globals"
import { getSearchTokens } from "./getSearchTokens"
import type { DeepPartial } from "@/types/DeepPartial"
import type { Location } from "@/types/trpc/routers/hotel/locations"
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).toEqual([
"ångström",
"café",
"münchen",
"frånce",
"angstrom",
"cafe",
"munchen",
"france",
])
})
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"])
})
})

View File

@@ -0,0 +1,37 @@
import type { Location } from "@/types/trpc/routers/hotel/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 additionalTokens: string[] = []
tokens.forEach((token) => {
const replaced = token
.replace(/å/g, "a")
.replace(/ä/g, "a")
.replace(/ö/g, "o")
.replace(/æ/g, "a")
.replace(/ø/g, "o")
.replace(/é/g, "e")
.replace(/ü/g, "u")
if (replaced !== token) {
additionalTokens.push(replaced)
}
})
const allTokens = [...new Set([...tokens, ...additionalTokens])]
return allTokens
}
function hasValue(value: string | null | undefined): value is string {
return !!value && value.length > 0
}

View File

@@ -0,0 +1,21 @@
import { getSearchTokens } from "./getSearchTokens"
import type { Location } from "@/types/trpc/routers/hotel/locations"
import type { AutoCompleteLocation } from "../schema"
export function mapLocationToAutoCompleteLocation(
location: Location | null | undefined
): AutoCompleteLocation | null {
if (!location) return null
return {
id: location.id,
name: location.name,
type: location.type,
searchTokens: getSearchTokens(location),
destination:
location.type === "hotels"
? location.relationships.city.name
: location.country,
}
}

View File

@@ -0,0 +1,106 @@
import { describe, expect, it } from "@jest/globals"
import { sortAutocompleteLocations } from "./sortAutocompleteLocations"
import type { DeepPartial } from "@/types/DeepPartial"
import type { AutoCompleteLocation } from "../schema"
describe("sortAutocompleteLocations", () => {
it("should put locations with names starting with the query at the top", () => {
const locations: DeepPartial<AutoCompleteLocation>[] = [
{ name: "Paris Hotel" },
{ name: "London Inn" },
{ name: "paradise Resort" },
{ name: "Berlin Lodge" },
]
const query = "par"
const sorted = sortAutocompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(sorted.map((loc) => loc.name)).toEqual([
"paradise Resort",
"Paris Hotel",
"Berlin Lodge",
"London Inn",
])
})
it("should sort locations alphabetically if both start with the query", () => {
const locations: DeepPartial<AutoCompleteLocation>[] = [
{ name: "Alpha Place" },
{ name: "alphabet City" },
]
const query = "al"
const sorted = sortAutocompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(sorted.map((loc) => loc.name)).toEqual([
"Alpha Place",
"alphabet City",
])
})
it("should sort locations alphabetically if neither name starts with the query", () => {
const locations: DeepPartial<AutoCompleteLocation>[] = [
{ name: "Zenith" },
{ name: "apple orchard" },
{ name: "Mountain Retreat" },
]
const query = "xyz"
const sorted = sortAutocompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(sorted.map((loc) => loc.name)).toEqual([
"apple orchard",
"Mountain Retreat",
"Zenith",
])
})
it("should handle an empty query by sorting alphabetically", () => {
const locations: DeepPartial<AutoCompleteLocation>[] = [
{ name: "Delta" },
{ name: "Alpha" },
{ name: "Charlie" },
{ name: "Bravo" },
]
const query = ""
const sorted = sortAutocompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(sorted.map((loc) => loc.name)).toEqual([
"Alpha",
"Bravo",
"Charlie",
"Delta",
])
})
it("should be case-insensitive when sorting names", () => {
const locations: DeepPartial<AutoCompleteLocation>[] = [
{ name: "Mountain Cabin" },
{ name: "Beachside Villa" },
{ name: "beach House" },
]
const query = "beach"
const sorted = sortAutocompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(sorted.map((x) => x.name)).toEqual([
"beach House",
"Beachside Villa",
"Mountain Cabin",
])
})
})

View File

@@ -0,0 +1,17 @@
import type { AutoCompleteLocation } from "../schema"
export function sortAutocompleteLocations<T extends AutoCompleteLocation>(
locations: T[],
query: string
) {
return locations.toSorted((a, b) => {
const queryLower = query.toLowerCase()
const aStarts = a.name.toLowerCase().startsWith(queryLower)
const bStarts = b.name.toLowerCase().startsWith(queryLower)
if (aStarts && !bStarts) return -1
if (!aStarts && bStarts) return 1
return a.name.localeCompare(b.name)
})
}