bdf9cupe3: Add expert text search and category browse to the left column
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
// Browser-side reader for the same-origin GET /api/experts/search route.
|
||||
import type { ExpertResult } from "./experts-search";
|
||||
|
||||
export class ExpertSearchError extends Error {
|
||||
constructor() {
|
||||
super("experts_unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchExperts(
|
||||
query: { q?: string; category?: string },
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ExpertResult[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (query.q) params.set("q", query.q);
|
||||
else if (query.category) params.set("category", query.category);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetchImpl(`/api/experts/search?${params}`, { credentials: "same-origin" });
|
||||
} catch {
|
||||
throw new ExpertSearchError();
|
||||
}
|
||||
if (!res.ok) throw new ExpertSearchError();
|
||||
try {
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
return Array.isArray(body.experts) ? (body.experts as ExpertResult[]) : [];
|
||||
} catch {
|
||||
throw new ExpertSearchError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { DISCOVER_BASE_URL } from "./env";
|
||||
|
||||
// Expert-only search and category browse over Minnect's PUBLIC expert list
|
||||
// (GET /api/v3/experts). Never touches /api/v4/users or any user search.
|
||||
//
|
||||
// Upstream findings (probed 2026-10-19, unauthenticated):
|
||||
// - /api/v1/individual/experts/search?query= ignores `query` (returns all
|
||||
// experts) and its payload carries KYC/payment/rate fields. NOT used.
|
||||
// - /api/v3/experts?search=<text>&limit=N filters by name/byline and returns a
|
||||
// lean public payload (id, names, slug, byline, picture, categories). Used.
|
||||
// - /api/v3/experts has no category filter and ignores `page`. Category browse
|
||||
// therefore filters the first CATEGORY_POOL experts of the public list by
|
||||
// category name (documented limitation, cached in memory for a few minutes).
|
||||
|
||||
export interface ExpertCategory {
|
||||
id: string | number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ExpertResult {
|
||||
id: string | number;
|
||||
slug: string | null;
|
||||
displayName: string;
|
||||
pictureUrl: string | null;
|
||||
byline: string | null;
|
||||
rating: number | null;
|
||||
categories: ExpertCategory[];
|
||||
}
|
||||
|
||||
export type ExpertSearchResult =
|
||||
| { ok: true; experts: ExpertResult[] }
|
||||
| { ok: false; status: 502; error: "experts_unavailable" };
|
||||
|
||||
export const SEARCH_LIMIT = 20;
|
||||
export const CATEGORY_POOL = 300;
|
||||
const CACHE_MS = 5 * 60 * 1000;
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
const rec = (v: unknown): Rec =>
|
||||
v && typeof v === "object" && !Array.isArray(v) ? (v as Rec) : {};
|
||||
const str = (v: unknown): string | null =>
|
||||
typeof v === "string" && v.trim() ? v : null;
|
||||
const num = (v: unknown): number | null =>
|
||||
typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
const id = (v: unknown): string | number | null =>
|
||||
typeof v === "string" && v ? v : typeof v === "number" ? v : null;
|
||||
|
||||
/** Explicit allow-list: nothing outside these fields can reach the browser. */
|
||||
export function mapExpertResult(raw: unknown): ExpertResult | null {
|
||||
const r = rec(raw);
|
||||
const eid = id(r.id);
|
||||
if (eid === null) return null;
|
||||
const categories: ExpertCategory[] = [];
|
||||
if (Array.isArray(r.categories)) {
|
||||
for (const c of r.categories) {
|
||||
const cr = rec(c);
|
||||
const cid = id(cr.id);
|
||||
const name = str(cr.name)?.trim();
|
||||
if (cid !== null && name) categories.push({ id: cid, name });
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: eid,
|
||||
slug: str(r.slug),
|
||||
displayName: [str(r.first_name), str(r.last_name)].filter(Boolean).join(" "),
|
||||
pictureUrl: str(r.url_picture),
|
||||
byline: str(r.byline),
|
||||
rating: num(r.rating),
|
||||
categories,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapExpertList(body: unknown): ExpertResult[] {
|
||||
const data = rec(body).data;
|
||||
if (!Array.isArray(data)) return [];
|
||||
const out: ExpertResult[] = [];
|
||||
for (const e of data) {
|
||||
const m = mapExpertResult(e);
|
||||
if (m) out.push(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const UNAVAILABLE: ExpertSearchResult = {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: "experts_unavailable",
|
||||
};
|
||||
|
||||
async function fetchList(
|
||||
params: Record<string, string>,
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<ExpertResult[] | null> {
|
||||
try {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
const res = await fetchImpl(`${DISCOVER_BASE_URL}/api/v3/experts?${qs}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return mapExpertList(await res.json());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Free-text expert search by name/byline. Never throws. */
|
||||
export async function searchExperts(
|
||||
q: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ExpertSearchResult> {
|
||||
const list = await fetchList(
|
||||
{ search: q, limit: String(SEARCH_LIMIT), page: "1" },
|
||||
fetchImpl,
|
||||
);
|
||||
return list ? { ok: true, experts: list.slice(0, SEARCH_LIMIT) } : UNAVAILABLE;
|
||||
}
|
||||
|
||||
let pool: { at: number; experts: ExpertResult[] } | null = null;
|
||||
|
||||
/** Test hook: forget the cached pool. */
|
||||
export function resetCategoryPool(): void {
|
||||
pool = null;
|
||||
}
|
||||
|
||||
const norm = (s: string) => s.trim().toLowerCase();
|
||||
|
||||
/** Experts (from the first CATEGORY_POOL of the public list) in a category, by name. */
|
||||
export async function browseCategory(
|
||||
category: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
now: number = Date.now(),
|
||||
): Promise<ExpertSearchResult> {
|
||||
if (!pool || now - pool.at > CACHE_MS) {
|
||||
const list = await fetchList({ limit: String(CATEGORY_POOL), page: "1" }, fetchImpl);
|
||||
if (!list) return UNAVAILABLE;
|
||||
pool = { at: now, experts: list };
|
||||
}
|
||||
const want = norm(category);
|
||||
const experts = pool.experts
|
||||
.filter((e) => e.categories.some((c) => norm(c.name) === want))
|
||||
.slice(0, SEARCH_LIMIT);
|
||||
return { ok: true, experts };
|
||||
}
|
||||
Reference in New Issue
Block a user