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:
Anton Gunnarsson
2025-06-26 12:40:45 +00:00
parent 7e4ed93c97
commit 5f8ac8cdeb
29 changed files with 92 additions and 70 deletions

View File

@@ -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",

View 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
View File

View 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" }
)
}

View File

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

View 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>

View File

@@ -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"
}

View File

@@ -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ø",
],
}

View File

@@ -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
}

View File

@@ -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"])
})
})

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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])]
}

View File

@@ -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,

View File

@@ -0,0 +1,5 @@
export type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>
}
: T

View File

@@ -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"
}
}

View File

@@ -0,0 +1,3 @@
import { config } from "dotenv"
config({ path: "./.env.test" })

View File

@@ -0,0 +1,7 @@
export default {
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./vitest-setup.ts"],
},
}