Files
juwan-frontend/lib/search/search-catalog.ts
T

190 lines
5.5 KiB
TypeScript

import type { SearchResponse, SearchResultItem, SearchSort } from "@/lib/search/types"
import type { Player, PlayerService, Shop } from "@/lib/types"
export interface SearchCatalogParams {
q?: string
selectedGames?: string[]
min?: string
max?: string
onlyOnline?: boolean
minRating?: string
sort?: SearchSort
limit?: number
offset?: number
}
export interface SearchCatalogData {
players: Player[]
shops: Shop[]
services: PlayerService[]
}
type InternalResultItem = SearchResultItem & { __index: number }
function minPriceFromServices(services: PlayerService[]) {
return services.length === 0 ? 0 : Math.min(...services.map((s) => s.price))
}
function unitFromCheapestService(services: PlayerService[]): PlayerService["unit"] {
if (services.length === 0) return "局"
return services.reduce((prev, curr) => (prev.price < curr.price ? prev : curr)).unit
}
export function searchCatalog(
params: SearchCatalogParams,
data: SearchCatalogData,
): SearchResponse {
const q = params.q ?? ""
const query = q ? q.toLowerCase() : ""
const selectedGames = params.selectedGames ?? []
const min = params.min ?? ""
const max = params.max ?? ""
const minP = min ? Number(min) : 0
const maxP = max ? Number(max) : Infinity
const onlyOnline = params.onlyOnline ?? false
const minRating = Number(params.minRating ?? "0")
const sort = params.sort ?? "composite"
const offset = Math.max(0, params.offset ?? 0)
const limit = Math.max(0, params.limit ?? 12)
const shopDerivedById = new Map<
string,
{
minPrice: number
unit: PlayerService["unit"]
games: string[]
hasAvailable: boolean
}
>()
for (const shop of data.shops) {
const shopPlayers = data.players.filter((player) => player.shopId === shop.id)
const playerIds = new Set(shopPlayers.map((player) => player.id))
const shopServices = data.services.filter((service) => playerIds.has(service.playerId))
const shopMinPrice = shopServices.length > 0 ? Math.min(...shopServices.map((s) => s.price)) : 0
const shopUnit = shopServices.length > 0 ? unitFromCheapestService(shopServices) : "局"
const shopGames = [...new Set(shopServices.map((service) => service.gameName))]
const hasAvailable = shopPlayers.some((player) => player.status === "available")
shopDerivedById.set(shop.id, {
minPrice: shopMinPrice,
unit: shopUnit,
games: shopGames,
hasAvailable,
})
}
let nextIndex = 0
const results: InternalResultItem[] = []
for (const player of data.players) {
results.push({
__index: nextIndex++,
type: "player",
player,
rating: player.rating,
orders: player.totalOrders,
minPrice: minPriceFromServices(player.services),
unit: unitFromCheapestService(player.services),
})
}
for (const shop of data.shops) {
const derived = shopDerivedById.get(shop.id)
if (!derived) continue
results.push({
__index: nextIndex++,
type: "shop",
shop,
rating: shop.rating,
orders: shop.totalOrders,
minPrice: derived.minPrice,
unit: derived.unit,
games: derived.games,
hasAvailable: derived.hasAvailable,
})
}
const filtered = results.filter((item) => {
if (query) {
if (item.type === "player") {
const matchName = item.player.user.nickname.toLowerCase().includes(query)
const matchTags = item.player.tags.some((tag) => tag.toLowerCase().includes(query))
const matchGames = item.player.games.some((game) => game.toLowerCase().includes(query))
if (!matchName && !matchTags && !matchGames) return false
} else {
const matchName = item.shop.name.toLowerCase().includes(query)
const matchDescription = item.shop.description.toLowerCase().includes(query)
const matchGames = item.games.some((game) => game.toLowerCase().includes(query))
if (!matchName && !matchDescription && !matchGames) return false
}
}
if (selectedGames.length > 0) {
if (item.type === "player") {
const hasGame = item.player.games.some((game) => selectedGames.includes(game))
if (!hasGame) return false
} else {
const hasGame = item.games.some((game) => selectedGames.includes(game))
if (!hasGame) return false
}
}
if (item.minPrice < minP) return false
if (max && item.minPrice > maxP) return false
if (onlyOnline) {
if (item.type === "player") {
if (item.player.status !== "available") return false
} else {
if (!item.hasAvailable) return false
}
}
if (item.type === "player") {
if (item.player.rating < minRating) return false
} else {
if (item.shop.rating < minRating) return false
}
return true
})
filtered.sort((a, b) => {
let diff = 0
switch (sort) {
case "rating":
diff = b.rating - a.rating
break
case "price_asc":
diff = a.minPrice - b.minPrice
break
case "price_desc":
diff = b.minPrice - a.minPrice
break
case "orders":
diff = b.orders - a.orders
break
case "composite": {
const scoreA = a.rating * Math.log10(a.orders + 1)
const scoreB = b.rating * Math.log10(b.orders + 1)
diff = scoreB - scoreA
break
}
default:
diff = 0
}
if (diff !== 0) return diff
return a.__index - b.__index
})
const total = filtered.length
const items = filtered.slice(offset, offset + limit).map(({ __index, ...item }) => item)
return { items, meta: { total, offset, limit } }
}