/** * /finder URL contract, shared by the page (server + client) and the API helpers. The site URL and the API use * the SAME encoding: repeated `f=slug:op:value` (between → `slug:between:a..b`), `mode=and|or`, `region`, `income`, * `sort=slug:asc|desc`. */ export type FinderOp = 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'between'; export const FINDER_OPS: readonly FinderOp[] = ['gt', 'gte', 'lt', 'lte', 'eq', 'between']; export interface FinderFilterInput { slug: string; op: FinderOp; value: number; value2?: number | null; } export interface FinderState { filters: FinderFilterInput[]; mode: 'and' | 'or'; region: string | null; income: string | null; sort: string | null; } const SLUG = /^[a-z0-9][a-z0-9-]*$/; export function finderFilterParam(f: FinderFilterInput): string { return f.op === 'between' ? `${f.slug}:between:${f.value}..${f.value2 ?? f.value}` : `${f.slug}:${f.op}:${f.value}`; } export function parseFinderFilter(raw: string): FinderFilterInput | null { const m = /^([a-z0-9][a-z0-9-]*):(gt|gte|lt|lte|eq|between):(.+)$/.exec(raw.trim().toLowerCase()); if (!m) return null; const [, slug, op, rest] = m; if (op === 'between') { const [a, b] = rest!.split('..'); const v1 = Number(a); const v2 = Number(b); if (!Number.isFinite(v1) || !Number.isFinite(v2)) return null; return { slug: slug!, op: 'between', value: Math.min(v1, v2), value2: Math.max(v1, v2) }; } const v = Number(rest); if (!Number.isFinite(v)) return null; return { slug: slug!, op: op as FinderOp, value: v }; } export function finderQuery(filters: FinderFilterInput[], q: { mode?: 'and' | 'or'; region?: string | null; income?: string | null; sort?: string | null; limit?: number } = {}): string { const p = new URLSearchParams(); for (const f of filters) p.append('f', finderFilterParam(f)); if (q.mode && q.mode !== 'and') p.set('mode', q.mode); if (q.region) p.set('region', q.region); if (q.income) p.set('income', q.income); if (q.sort) p.set('sort', q.sort); if (q.limit) p.set('limit', String(q.limit)); const s = p.toString(); return s ? `?${s}` : ''; } type ParamsLike = { getAll(name: string): string[]; get(name: string): string | null } | Record; function all(params: ParamsLike, key: string): string[] { if (typeof (params as { getAll?: unknown }).getAll === 'function') return (params as { getAll(name: string): string[] }).getAll(key); const v = (params as Record)[key]; return Array.isArray(v) ? v : v != null ? [v] : []; } function one(params: ParamsLike, key: string): string | null { return all(params, key)[0] ?? null; } export function parseFinderState(params: ParamsLike): FinderState { const filters = all(params, 'f').map(parseFinderFilter).filter((f): f is FinderFilterInput => !!f).slice(0, 8); const mode = one(params, 'mode') === 'or' ? 'or' : 'and'; const clean = (v: string | null) => (v && SLUG.test(v.toLowerCase()) ? v.toLowerCase() : null); const sortRaw = one(params, 'sort'); const sort = sortRaw && /^[a-z0-9][a-z0-9-]*:(asc|desc)$/.test(sortRaw.toLowerCase()) ? sortRaw.toLowerCase() : null; return { filters, mode, region: clean(one(params, 'region')), income: clean(one(params, 'income')), sort }; } export function finderStateQuery(s: FinderState): string { return finderQuery(s.filters, { mode: s.mode, region: s.region, income: s.income, sort: s.sort }); }