Merge Feed browser: Experts & trending into develop

3-column desktop with right rails (Featured experts + Trending minnects) from a new public /api/discover route, plus expert search (v3 public list) and category browse. Public fields only. Tasks 2pz7hheja, gm4ebnr3j, bdf9cupe3.
This commit is contained in:
2026-10-19 17:34:18 -06:00
17 changed files with 1030 additions and 1 deletions
+4
View File
@@ -6,3 +6,7 @@ PROFILES_BASE_URL=https://profiles.minnect.com
# Feed host: home feed, posts, likes, comments (used by later tasks). # Feed host: home feed, posts, likes, comments (used by later tasks).
FEED_BASE_URL=https://feed.sharedservices.vt.com FEED_BASE_URL=https://feed.sharedservices.vt.com
# Discovery host: public, read-only, unauthenticated. Featured experts,
# trending minnects and categories. No token is ever sent to it.
DISCOVER_BASE_URL=https://api.minnect.com
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { getDiscover } from "@/lib/discover";
// GET /api/discover
// Same-origin proxy for Minnect's PUBLIC discovery data. No session needed.
// Returns { featuredExperts, trendingMinnects, categories }, public fields only.
// Upstream failure -> 502 { error: "discover_unavailable" } (never empty arrays,
// so the UI can tell "nothing to show" from "could not load").
export const runtime = "nodejs";
export async function GET() {
const result = await getDiscover();
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status });
}
return NextResponse.json(result.data);
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { browseCategory, searchExperts } from "@/lib/experts-search";
// GET /api/experts/search?q=<text> or ?category=<name>
// Same-origin proxy for Minnect's PUBLIC expert list. EXPERTS ONLY: it never
// calls a user-search endpoint. Public fields only. No session needed.
// Upstream failure -> 502 { error: "experts_unavailable" }.
export const runtime = "nodejs";
export async function GET(req: Request) {
const url = new URL(req.url);
const q = (url.searchParams.get("q") ?? "").trim().slice(0, 80);
const category = (url.searchParams.get("category") ?? "").trim().slice(0, 80);
if (!q && !category) return NextResponse.json({ experts: [] });
const result = q ? await searchExperts(q) : await browseCategory(category);
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status });
}
return NextResponse.json({ experts: result.experts });
}
+46
View File
@@ -79,3 +79,49 @@ button:disabled { opacity: .5; cursor: not-allowed; }
.menu button:hover { background: #f4f4f4; } .menu button:hover { background: #f4f4f4; }
.menu .menu-danger { color: #a8141b; } .menu .menu-danger { color: #a8141b; }
.menu-ask { font-size: 13px; color: var(--grey); padding: 4px 8px; } .menu-ask { font-size: 13px; color: var(--grey); padding: 4px 8px; }
/* 3-column signed-in layout: nav | feed | rails. Wraps on narrow widths. */
.columns { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; max-width: 1240px; margin: 0 auto; padding: 0 16px; }
.col-left { flex: 0 0 180px; position: sticky; top: 72px; padding-top: 16px; }
.col-left > ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.col-left a { display: block; padding: 10px 12px; border-radius: 8px; color: var(--ink); text-decoration: none; font-weight: 500; }
.col-left a[aria-current="page"] { background: var(--ink); color: #fff; }
.col-center { flex: 1 1 400px; min-width: 0; }
.col-center .feed { padding-left: 0; padding-right: 0; }
.col-right { flex: 0 0 300px; position: sticky; top: 72px; max-height: calc(100vh - 88px); overflow-y: auto; padding-top: 16px; display: flex; flex-direction: column; gap: 20px; }
.rail h2 { margin: 0 0 8px; font-size: 16px; font-weight: 700; }
.rail-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.rail-card { background: #fff; border: 1px solid var(--line); border-radius: 8px; padding: 12px; display: flex; gap: 10px; }
.expert-card { align-items: center; }
.minnect-card { flex-direction: column; }
.rail-text { display: flex; flex-direction: column; min-width: 0; font-size: 14px; }
.rail-name { font-size: 14px; }
.rail-byline { color: var(--grey); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rail-meta { color: var(--grey); font-size: 13px; }
.minnect-who { display: flex; align-items: center; gap: 8px; }
.avatar-sm { width: 28px; height: 28px; font-size: 11px; }
.minnect-thumb { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 8px; background: var(--line); }
.minnect-question { margin: 0; font-size: 14px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.minnect-audio { width: 100%; height: 36px; }
@media (max-width: 959px) {
.col-left { flex: 1 1 100%; position: static; padding-top: 8px; }
.col-left > ul { flex-direction: row; }
.col-center { flex: 1 1 100%; order: 3; }
.col-right { flex: 1 1 100%; order: 2; position: static; max-height: none; overflow: visible; padding-top: 0; }
.col-right .rail-list { flex-direction: row; overflow-x: auto; padding-bottom: 8px; }
.col-right .rail-card { flex: 0 0 260px; }
}
/* Expert search + category chips (left column). Squared corners, monochrome + red. */
.expert-search { margin-top: 16px; }
.expert-search summary { font-weight: 700; font-size: 16px; cursor: pointer; padding: 4px 0; }
.expert-search form { display: flex; flex-direction: column; }
.expert-search input { border-radius: 0; width: 100%; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.chip { margin: 0; padding: 6px 10px; border-radius: 0; background: #fff; color: var(--ink); border: 1px solid var(--ink); font-size: 13px; }
.chip[aria-pressed="true"] { background: var(--red); border-color: var(--red); color: #fff; }
.search-results { margin-top: 12px; }
.search-note { margin: 0; font-size: 14px; color: var(--grey); }
@media (max-width: 959px) {
.col-left .expert-search { flex-basis: 100%; }
}
+16
View File
@@ -1,10 +1,13 @@
"use client"; "use client";
import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { loadSession, type Person } from "@/lib/auth-client"; import { loadSession, type Person } from "@/lib/auth-client";
import { SignInForm } from "./SignInForm"; import { SignInForm } from "./SignInForm";
import { Logo } from "./Logo"; import { Logo } from "./Logo";
import { Feed } from "./Feed"; import { Feed } from "./Feed";
import { DiscoverRails } from "./DiscoverRails";
import { ExpertSearch } from "./ExpertSearch";
type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person }; type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
@@ -46,7 +49,20 @@ export function App() {
<span>{state.person.email}</span> <span>{state.person.email}</span>
</div> </div>
</header> </header>
<div className="columns">
<nav className="col-left" aria-label="Main">
<ul>
<li><Link href="/" aria-current="page">Feed</Link></li>
</ul>
<ExpertSearch />
</nav>
<div className="col-center">
<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} /> <Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />
</div>
<aside className="col-right" aria-label="Discover">
<DiscoverRails />
</aside>
</div>
</main> </main>
); );
} }
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useEffect, useState } from "react";
import type { DiscoverData } from "@/lib/discover";
import { fetchDiscover } from "@/lib/discover-client";
import { FeaturedExpertsRail } from "./FeaturedExpertsRail";
import { TrendingMinnectsRail } from "./TrendingMinnectsRail";
export function DiscoverRailsView({ data }: { data: DiscoverData | null }) {
if (!data) return null;
return (
<>
<FeaturedExpertsRail experts={data.featuredExperts} />
<TrendingMinnectsRail minnects={data.trendingMinnects} />
</>
);
}
/** Loads /api/discover once. Failure hides the rails; the feed is unaffected. */
export function DiscoverRails() {
const [data, setData] = useState<DiscoverData | null>(null);
useEffect(() => {
let live = true;
fetchDiscover().then((d) => { if (live) setData(d); }, () => {});
return () => { live = false; };
}, []);
return <DiscoverRailsView data={data} />;
}
+136
View File
@@ -0,0 +1,136 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { DiscoverCategory } from "@/lib/discover";
import { fetchDiscover } from "@/lib/discover-client";
import type { ExpertResult } from "@/lib/experts-search";
import { fetchExperts } from "@/lib/experts-search-client";
import { initials } from "@/lib/feed-model";
export type SearchStatus = "idle" | "loading" | "ready" | "error";
export interface ExpertSearchViewProps {
query: string;
onQuery: (q: string) => void;
categories: DiscoverCategory[];
activeCategory: string | null;
onCategory: (name: string | null) => void;
status: SearchStatus;
results: ExpertResult[];
defaultOpen?: boolean;
}
export function ExpertSearchView(p: ExpertSearchViewProps) {
return (
<details className="expert-search" open={p.defaultOpen}>
<summary>Find experts</summary>
<form role="search" onSubmit={(e) => e.preventDefault()}>
<label htmlFor="expert-q">Search experts</label>
<input
id="expert-q"
type="search"
value={p.query}
placeholder="Name or topic"
autoComplete="off"
maxLength={80}
onChange={(e) => p.onQuery(e.target.value)}
/>
</form>
{p.categories.length > 0 ? (
<div role="group" aria-label="Browse experts by category" className="chips">
{p.categories.map((c) => (
<button
key={String(c.id)}
type="button"
className="chip"
aria-pressed={p.activeCategory === c.name}
onClick={() => p.onCategory(p.activeCategory === c.name ? null : c.name)}
>
{c.name}
</button>
))}
</div>
) : null}
<div aria-live="polite" className="search-results">
{p.status === "loading" ? <p className="search-note">Searching...</p> : null}
{p.status === "error" ? <p className="search-note" role="alert">Search is unavailable. Try again.</p> : null}
{p.status === "ready" && p.results.length === 0 ? (
<p className="search-note">No experts found.</p>
) : null}
{p.status === "ready" && p.results.length > 0 ? (
<ul className="rail-list">
{p.results.map((e) => (
<li key={String(e.id)} className="rail-card expert-card" data-testid="search-result">
{e.pictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar" src={e.pictureUrl} alt="" width={40} height={40} />
) : (
<span className="avatar avatar-initials" aria-hidden="true">{initials(e.displayName)}</span>
)}
<div className="rail-text">
<strong className="rail-name">{e.displayName || "Expert"}</strong>
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
{e.rating !== null ? <span className="rail-meta">★ {e.rating.toFixed(1)}</span> : null}
</div>
</li>
))}
</ul>
) : null}
</div>
</details>
);
}
const DEBOUNCE_MS = 300;
export function ExpertSearch() {
const [query, setQuery] = useState("");
const [category, setCategory] = useState<string | null>(null);
const [categories, setCategories] = useState<DiscoverCategory[]>([]);
const [status, setStatus] = useState<SearchStatus>("idle");
const [results, setResults] = useState<ExpertResult[]>([]);
const [open, setOpen] = useState(true);
const seq = useRef(0);
useEffect(() => {
setOpen(window.matchMedia("(min-width: 960px)").matches);
let live = true;
fetchDiscover().then(
(d) => { if (live) setCategories(d.categories); },
() => {},
);
return () => { live = false; };
}, []);
useEffect(() => {
const q = query.trim();
const mine = ++seq.current;
if (!q && !category) {
setStatus("idle");
setResults([]);
return;
}
setStatus("loading");
const t = setTimeout(() => {
fetchExperts(q ? { q } : { category: category ?? undefined }).then(
(r) => { if (seq.current === mine) { setResults(r); setStatus("ready"); } },
() => { if (seq.current === mine) { setResults([]); setStatus("error"); } },
);
}, q ? DEBOUNCE_MS : 0);
return () => clearTimeout(t);
}, [query, category]);
return (
<ExpertSearchView
key={open ? "open" : "closed"}
defaultOpen={open}
query={query}
onQuery={setQuery}
categories={categories}
activeCategory={category}
onCategory={setCategory}
status={status}
results={results}
/>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { DiscoverExpert } from "@/lib/discover";
import { initials } from "@/lib/feed-model";
function Rating({ rating, reviews }: { rating: number | null; reviews: number | null }) {
if (rating === null && reviews === null) return null;
return (
<span className="rail-meta">
{rating !== null ? <span aria-label={`Rated ${rating.toFixed(1)} out of 5`}>★ {rating.toFixed(1)}</span> : null}
{reviews !== null ? <span> ({reviews} {reviews === 1 ? "review" : "reviews"})</span> : null}
</span>
);
}
export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] }) {
if (experts.length === 0) return null;
return (
<section className="rail" aria-labelledby="rail-experts">
<h2 id="rail-experts">Featured experts</h2>
<ul className="rail-list">
{experts.map((e) => (
<li key={String(e.id)} className="rail-card expert-card" data-testid="expert-card">
{e.pictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar" src={e.pictureUrl} alt="" width={40} height={40} />
) : (
<span className="avatar avatar-initials" aria-hidden="true">{initials(e.displayName)}</span>
)}
<div className="rail-text">
<strong className="rail-name">{e.displayName || "Expert"}</strong>
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
<Rating rating={e.rating} reviews={e.reviewsCount} />
</div>
</li>
))}
</ul>
</section>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { DiscoverMinnect } from "@/lib/discover";
import { initials } from "@/lib/feed-model";
export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[] }) {
if (minnects.length === 0) return null;
return (
<section className="rail" aria-labelledby="rail-trending">
<h2 id="rail-trending">Trending minnects</h2>
<ul className="rail-list">
{minnects.map((m) => {
const who = m.expert.displayName || "Expert";
return (
<li key={String(m.id)} className="rail-card minnect-card" data-testid="minnect-card">
<div className="minnect-who">
{m.expert.pictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar avatar-sm" src={m.expert.pictureUrl} alt="" width={28} height={28} />
) : (
<span className="avatar avatar-sm avatar-initials" aria-hidden="true">{initials(who)}</span>
)}
<strong className="rail-name">{who}</strong>
</div>
{m.thumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="minnect-thumb" src={m.thumbnailUrl} alt={m.question ? `Thumbnail for: ${m.question}` : "Minnect thumbnail"} />
) : null}
{m.question ? <p className="minnect-question">{m.question}</p> : null}
{m.views !== null ? (
<span className="rail-meta">{m.views.toLocaleString("en-US")} {m.views === 1 ? "view" : "views"}</span>
) : null}
{m.audio.url ? (
<audio className="minnect-audio" controls preload="none" src={m.audio.url} aria-label={`Play answer from ${who}`} />
) : null}
</li>
);
})}
</ul>
</section>
);
}
+32
View File
@@ -0,0 +1,32 @@
// Browser-side reader for the same-origin GET /api/discover route.
import type { DiscoverData } from "./discover";
export class DiscoverLoadError extends Error {
constructor() {
super("discover_unavailable");
}
}
const arr = <T>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []);
export async function fetchDiscover(
fetchImpl: typeof fetch = fetch,
): Promise<DiscoverData> {
let res: Response;
try {
res = await fetchImpl("/api/discover", { credentials: "same-origin" });
} catch {
throw new DiscoverLoadError();
}
if (!res.ok) throw new DiscoverLoadError();
try {
const body = (await res.json()) as Record<string, unknown>;
return {
featuredExperts: arr(body.featuredExperts),
trendingMinnects: arr(body.trendingMinnects),
categories: arr(body.categories),
};
} catch {
throw new DiscoverLoadError();
}
}
+163
View File
@@ -0,0 +1,163 @@
import { DISCOVER_BASE_URL } from "./env";
// Maps Minnect's public discovery payload (GET /api/v1/individual/home) to
// PUBLIC FIELDS ONLY. The mappers build each object from an explicit
// allow-list, so phone, DOB, zip, email, gender, KYC/Stripe fields,
// is_blocking, scores and rates can never leak through. No token is sent or logged.
export interface DiscoverExpert {
id: string | number;
slug: string | null;
displayName: string;
byline: string | null;
pictureUrl: string | null;
rating: number | null;
reviewsCount: number | null;
available: boolean | null;
location: { city: string | null; country: string | null };
}
export interface DiscoverMinnect {
id: string | number;
question: string | null;
views: number | null;
thumbnailUrl: string | null;
audio: {
url: string | null;
duration: number | null;
waveform: number[] | string | null;
};
expert: {
slug: string | null;
displayName: string;
pictureUrl: string | null;
byline: string | null;
rating: number | null;
};
}
export interface DiscoverCategory {
id: string | number;
name: string;
icon: string | null;
sortOrder: number;
}
export interface DiscoverData {
featuredExperts: DiscoverExpert[];
trendingMinnects: DiscoverMinnect[];
categories: DiscoverCategory[];
}
export type DiscoverResult =
| { ok: true; data: DiscoverData }
| { ok: false; status: 502; error: "discover_unavailable" };
type Rec = Record<string, unknown>;
const rec = (v: unknown): Rec =>
v && typeof v === "object" && !Array.isArray(v) ? (v as Rec) : {};
const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
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 bool = (v: unknown): boolean | null => (typeof v === "boolean" ? v : null);
const id = (v: unknown): string | number | null =>
typeof v === "string" && v ? v : typeof v === "number" ? v : null;
function displayName(r: Rec): string {
return [str(r.first_name), str(r.last_name)].filter(Boolean).join(" ");
}
export function mapExpert(raw: unknown): DiscoverExpert | null {
const r = rec(raw);
const eid = id(r.id);
if (eid === null) return null;
return {
id: eid,
slug: str(r.slug),
displayName: displayName(r),
byline: str(r.byline),
pictureUrl: str(r.url_picture),
rating: num(r.rating),
reviewsCount: num(r.reviews_count),
available: bool(r.available),
location: { city: str(r.city), country: str(r.country) },
};
}
function waveform(v: unknown): number[] | string | null {
if (Array.isArray(v)) return v.filter((n): n is number => typeof n === "number");
return str(v);
}
export function mapMinnect(raw: unknown): DiscoverMinnect | null {
const r = rec(raw);
const mid = id(r.id);
if (mid === null) return null;
const answer = rec(r.answer);
const expert = rec(r.expert);
return {
id: mid,
question: str(r.question_text),
views: num(r.views),
thumbnailUrl: str(rec(r.thumbnail).url),
audio: {
url: str(answer.url),
duration: num(answer.duration),
waveform: waveform(answer.waveform_data),
},
expert: {
slug: str(expert.slug),
displayName: displayName(expert),
pictureUrl: str(expert.url_picture),
byline: str(expert.byline),
rating: num(expert.rating),
},
};
}
export function mapCategory(raw: unknown): DiscoverCategory | null {
const r = rec(raw);
const cid = id(r.id);
const name = str(r.name);
if (cid === null || !name || r.active === false) return null;
return { id: cid, name, icon: str(r.icon), sortOrder: num(r.sort_order) ?? 0 };
}
export function mapDiscover(body: unknown): DiscoverData {
const data = rec(rec(body).data);
const keep = <T>(x: T | null): x is T => x !== null;
return {
featuredExperts: arr(data.featured_experts).map(mapExpert).filter(keep),
trendingMinnects: arr(data.trending_conversations).map(mapMinnect).filter(keep),
categories: arr(data.trending_categories)
.map(mapCategory)
.filter(keep)
.sort((a, b) => a.sortOrder - b.sortOrder),
};
}
const UNAVAILABLE: DiscoverResult = {
ok: false,
status: 502,
error: "discover_unavailable",
};
/** Fetch and map the public discovery data. Never throws; upstream failure -> 502. */
export async function getDiscover(
fetchImpl: typeof fetch = fetch,
): Promise<DiscoverResult> {
try {
const res = await fetchImpl(`${DISCOVER_BASE_URL}/api/v1/individual/home`, {
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!res.ok) return UNAVAILABLE;
const body: unknown = await res.json();
return { ok: true, data: mapDiscover(body) };
} catch {
return UNAVAILABLE;
}
}
+7
View File
@@ -15,3 +15,10 @@ export const FEED_BASE_URL = baseUrl(
process.env.FEED_BASE_URL, process.env.FEED_BASE_URL,
"https://feed.sharedservices.vt.com", "https://feed.sharedservices.vt.com",
); );
// Public, read-only, UNAUTHENTICATED discovery host (featured experts,
// trending minnects, categories). Public fields only; no user token is sent.
export const DISCOVER_BASE_URL = baseUrl(
process.env.DISCOVER_BASE_URL,
"https://api.minnect.com",
);
+30
View File
@@ -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();
}
}
+144
View File
@@ -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 };
}
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { FeaturedExpertsRail } from "@/components/FeaturedExpertsRail";
import { TrendingMinnectsRail } from "@/components/TrendingMinnectsRail";
import { DiscoverRailsView } from "@/components/DiscoverRails";
import { fetchDiscover } from "@/lib/discover-client";
import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
const expert = (o: Partial<DiscoverExpert> = {}): DiscoverExpert => ({
id: 1, slug: "ann", displayName: "Ann Lee", byline: "Growth coach", pictureUrl: "https://x/a.jpg",
rating: 4.8, reviewsCount: 12, available: true, location: { city: null, country: null }, ...o,
});
const minnect = (o: Partial<DiscoverMinnect> = {}): DiscoverMinnect => ({
id: 9, question: "How do I price?", views: 1234, thumbnailUrl: "https://x/t.jpg",
audio: { url: "https://x/a.mp3", duration: 30, waveform: null },
expert: { slug: "ann", displayName: "Ann Lee", pictureUrl: null, byline: null, rating: null }, ...o,
});
const html = (el: ReturnType<typeof createElement>) => renderToStaticMarkup(el);
describe("FeaturedExpertsRail", () => {
it("renders a card with name, byline, rating and reviews", () => {
const out = html(createElement(FeaturedExpertsRail, { experts: [expert()] }));
expect(out).toContain("<h2");
expect(out).toContain("Ann Lee");
expect(out).toContain("Growth coach");
expect(out).toContain("4.8");
expect(out).toContain("12 reviews");
expect(out).toContain("<img");
});
it("falls back to initials and omits missing fields", () => {
const out = html(createElement(FeaturedExpertsRail, {
experts: [expert({ pictureUrl: null, byline: null, rating: null, reviewsCount: null })],
}));
expect(out).not.toContain("<img");
expect(out).toContain("AL");
expect(out).not.toContain("review");
});
it("renders nothing when empty", () => {
expect(html(createElement(FeaturedExpertsRail, { experts: [] }))).toBe("");
});
});
describe("TrendingMinnectsRail", () => {
it("renders question, views, thumbnail and an audio control", () => {
const out = html(createElement(TrendingMinnectsRail, { minnects: [minnect()] }));
expect(out).toContain("How do I price?");
expect(out).toContain("1,234 views");
expect(out).toContain("<audio");
expect(out).toContain("controls");
expect(out).toContain('src="https://x/a.mp3"');
expect(out).toContain("t.jpg");
});
it("has no audio element or thumbnail when those are missing", () => {
const out = html(createElement(TrendingMinnectsRail, {
minnects: [minnect({ audio: { url: null, duration: null, waveform: null }, thumbnailUrl: null, question: null, views: null })],
}));
expect(out).not.toContain("<audio");
expect(out).not.toContain("minnect-thumb");
expect(out).toContain("Ann Lee");
});
});
describe("DiscoverRailsView / fetchDiscover", () => {
it("renders both rails from data and nothing when null", () => {
const data = { featuredExperts: [expert()], trendingMinnects: [minnect()], categories: [] };
const out = html(createElement(DiscoverRailsView, { data }));
expect(out).toContain("Featured experts");
expect(out).toContain("Trending minnects");
expect(html(createElement(DiscoverRailsView, { data: null }))).toBe("");
});
it("fetches /api/discover and tolerates missing arrays", async () => {
const f = (async () => new Response(JSON.stringify({ featuredExperts: [expert()] }), { status: 200 })) as typeof fetch;
const d = await fetchDiscover(f);
expect(d.featuredExperts).toHaveLength(1);
expect(d.trendingMinnects).toEqual([]);
});
it("throws on a 502", async () => {
const f = (async () => new Response("{}", { status: 502 })) as typeof fetch;
await expect(fetchDiscover(f)).rejects.toThrow("discover_unavailable");
});
});
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it, vi } from "vitest";
import { GET } from "@/app/api/discover/route";
import { mapDiscover } from "@/lib/discover";
const expert = {
id: 7, slug: "jane-doe", first_name: "Jane", last_name: "Doe", byline: "Coach",
url_picture: "https://img/jane.jpg", rating: 4.9, reviews_count: 12, available: true,
city: "Austin", country: "US",
quick_question_rate: 25, video_call_rate: 100, is_blocking: false, score: 99,
leaderboard: 3, profile_views: 500, status: "active",
phone: "555-1234", dob: "1990-01-01", zip: "78701", email: "jane@x.com",
gender: "f", stripe_account_id: "acct_1", kyc_status: "verified",
};
const body = {
data: {
featured_experts: [expert],
trending_conversations: [{
id: "c1", question_text: "How?", answer_text: "secret text", views: 10,
made_public_on: "2026-01-01", thumbnail: { url: "https://img/t.jpg" },
answer: { url: "https://a/1.mp3", duration: 42, waveform_data: [1, 2, 3], file_type: "audio" },
expert,
}],
trending_categories: [
{ id: 2, name: "Money", icon: "m", sort_order: 2, active: true, search: "x" },
{ id: 1, name: "Health", icon: "h", sort_order: 1, active: true },
{ id: 3, name: "Hidden", icon: "x", sort_order: 0, active: false },
],
popular_topics: [], wallet: { balance: 5 },
},
};
async function withUpstream(impl: () => Promise<Response>) {
const orig = globalThis.fetch;
const spy = vi.fn(impl);
globalThis.fetch = spy as unknown as typeof fetch;
try {
return { res: await GET(), spy };
} finally {
globalThis.fetch = orig;
}
}
describe("GET /api/discover", () => {
it("maps public fields, sorts and filters categories, sends no auth", async () => {
const { res, spy } = await withUpstream(async () => new Response(JSON.stringify(body)));
expect(res.status).toBe(200);
const json = await res.json();
expect(json.featuredExperts[0]).toEqual({
id: 7, slug: "jane-doe", displayName: "Jane Doe", byline: "Coach",
pictureUrl: "https://img/jane.jpg", rating: 4.9, reviewsCount: 12, available: true,
location: { city: "Austin", country: "US" },
});
expect(json.trendingMinnects[0]).toEqual({
id: "c1", question: "How?", views: 10, thumbnailUrl: "https://img/t.jpg",
audio: { url: "https://a/1.mp3", duration: 42, waveform: [1, 2, 3] },
expert: { slug: "jane-doe", displayName: "Jane Doe", pictureUrl: "https://img/jane.jpg", byline: "Coach", rating: 4.9 },
});
expect(json.categories).toEqual([
{ id: 1, name: "Health", icon: "h", sortOrder: 1 },
{ id: 2, name: "Money", icon: "m", sortOrder: 2 },
]);
const [url, init] = spy.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe("https://api.minnect.com/api/v1/individual/home");
expect(JSON.stringify(init.headers)).not.toMatch(/authorization/i);
});
it("never leaks PII, KYC, payment or internal fields", async () => {
const { res } = await withUpstream(async () => new Response(JSON.stringify(body)));
const text = JSON.stringify(await res.json());
for (const bad of [
"555-1234", "1990-01-01", "78701", "jane@x.com", "acct_1", "kyc", "stripe",
"is_blocking", "isBlocking", "score", "leaderboard", "profile_views",
"quick_question_rate", "video_call_rate", "secret text", "wallet", "balance",
"gender", "phone", "dob", "zip", "email",
]) {
expect(text.toLowerCase()).not.toContain(bad.toLowerCase());
}
});
it("tolerates missing fields", () => {
const out = mapDiscover({
data: {
featured_experts: [{ id: 1 }, {}, null, "x"],
trending_conversations: [{ id: 2 }, { question_text: "no id" }],
trending_categories: [{ id: 3, name: "A" }, { id: 4 }],
},
});
expect(out.featuredExperts).toEqual([{
id: 1, slug: null, displayName: "", byline: null, pictureUrl: null, rating: null,
reviewsCount: null, available: null, location: { city: null, country: null },
}]);
expect(out.trendingMinnects).toHaveLength(1);
expect(out.trendingMinnects[0].audio).toEqual({ url: null, duration: null, waveform: null });
expect(out.categories).toEqual([{ id: 3, name: "A", icon: null, sortOrder: 0 }]);
expect(mapDiscover(null)).toEqual({ featuredExperts: [], trendingMinnects: [], categories: [] });
});
it("upstream non-2xx -> 502", async () => {
const { res } = await withUpstream(async () => new Response("boom", { status: 500 }));
expect(res.status).toBe(502);
expect(await res.json()).toEqual({ error: "discover_unavailable" });
});
it("network error or bad JSON -> 502", async () => {
const a = await withUpstream(async () => { throw new Error("down"); });
expect(a.res.status).toBe(502);
const b = await withUpstream(async () => new Response("<html>"));
expect(b.res.status).toBe(502);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { GET } from "@/app/api/experts/search/route";
import { mapExpertResult, resetCategoryPool } from "@/lib/experts-search";
import { fetchExperts } from "@/lib/experts-search-client";
import { ExpertSearchView, type ExpertSearchViewProps } from "@/components/ExpertSearch";
const raw = (o: Record<string, unknown> = {}) => ({
id: "e1", first_name: "Jane", last_name: "Doe", slug: "JaneDoe", byline: "Coach",
url_picture: "https://img/j.jpg", rating: 4.5, transfer_method_setup: true,
categories: [{ id: 42, name: "Business " }, { id: 44, name: "Coaching" }],
email: "j@x.com", phone: "555", dob: "1990", kyc_status: "ok", stripe_account_set: true,
quick_question_rate: 5, is_blocking: false, user_id: "u1", ...o,
});
async function call(url: string, impl: (u: string) => Response) {
const orig = globalThis.fetch;
const spy = vi.fn(async (u: unknown, _init?: unknown) => impl(String(u)));
globalThis.fetch = spy as unknown as typeof fetch;
try {
return { res: await GET(new Request(url)), spy };
} finally {
globalThis.fetch = orig;
}
}
const ok = (data: unknown[]) => new Response(JSON.stringify({ status: "success", data }));
beforeEach(() => resetCategoryPool());
describe("mapExpertResult", () => {
it("keeps only public fields", () => {
const m = mapExpertResult(raw())!;
expect(Object.keys(m).sort()).toEqual(
["byline", "categories", "displayName", "id", "pictureUrl", "rating", "slug"],
);
expect(m.displayName).toBe("Jane Doe");
expect(m.categories[0].name).toBe("Business");
const s = JSON.stringify(m);
for (const bad of ["j@x.com", "555", "1990", "kyc", "stripe", "is_blocking", "u1"]) {
expect(s).not.toContain(bad);
}
});
it("drops items without an id", () => expect(mapExpertResult({ first_name: "x" })).toBeNull());
});
describe("GET /api/experts/search", () => {
it("returns empty for no query without calling upstream", async () => {
const { res, spy } = await call("http://x/api/experts/search", () => ok([]));
expect(await res.json()).toEqual({ experts: [] });
expect(spy).not.toHaveBeenCalled();
});
it("text search hits the v3 experts search, never a user endpoint, with no auth", async () => {
const { res, spy } = await call("http://x/api/experts/search?q=pat", () => ok([raw()]));
const json = await res.json();
expect(json.experts).toHaveLength(1);
expect(JSON.stringify(json)).not.toContain("j@x.com");
const url = String(spy.mock.calls[0][0]);
expect(url).toContain("/api/v3/experts?");
expect(url).toContain("search=pat");
expect(url).not.toMatch(/\/api\/v4\/users|\/admin\//);
expect(JSON.stringify(spy.mock.calls[0][1])).not.toMatch(/authorization/i);
});
it("category browse filters the pool by category name and caches it", async () => {
const list = [raw(), raw({ id: "e2", categories: [{ id: 9, name: "Media" }] })];
const { res, spy } = await call("http://x/api/experts/search?category=business", () => ok(list));
expect((await res.json()).experts.map((e: { id: string }) => e.id)).toEqual(["e1"]);
const again = await call("http://x/api/experts/search?category=Media", () => ok(list));
expect((await again.res.json()).experts.map((e: { id: string }) => e.id)).toEqual(["e2"]);
expect(spy).toHaveBeenCalledTimes(1);
expect(again.spy).not.toHaveBeenCalled();
});
it("upstream failure -> 502", async () => {
const { res } = await call("http://x/api/experts/search?q=a", () => new Response("no", { status: 500 }));
expect(res.status).toBe(502);
expect(await res.json()).toEqual({ error: "experts_unavailable" });
});
});
describe("fetchExperts", () => {
it("calls the same-origin route and rejects on failure", async () => {
const f = vi.fn(async (_u: string) => new Response(JSON.stringify({ experts: [mapExpertResult(raw())] })));
expect(await fetchExperts({ q: "a b" }, f as unknown as typeof fetch)).toHaveLength(1);
expect(f.mock.calls[0][0]).toBe("/api/experts/search?q=a+b");
const bad = vi.fn(async (_u: string) => new Response("", { status: 502 }));
await expect(fetchExperts({ category: "Media" }, bad as unknown as typeof fetch)).rejects.toThrow();
expect(bad.mock.calls[0][0]).toBe("/api/experts/search?category=Media");
});
});
const view = (o: Partial<ExpertSearchViewProps> = {}) =>
renderToStaticMarkup(createElement(ExpertSearchView, {
query: "", onQuery: () => {}, categories: [{ id: 1, name: "Business", icon: null, sortOrder: 1 }],
activeCategory: null, onCategory: () => {}, status: "idle", results: [], defaultOpen: true, ...o,
}));
describe("ExpertSearchView", () => {
it("labels the input and renders category buttons", () => {
const h = view();
expect(h).toContain('<label for="expert-q">Search experts</label>');
expect(h).toContain('id="expert-q"');
expect(h).toMatch(/<button[^>]*type="button"[^>]*aria-pressed="false"[^>]*>Business<\/button>/);
});
it("marks the active category pressed", () => {
expect(view({ activeCategory: "Business" })).toContain('aria-pressed="true"');
});
it("renders results, empty and error states", () => {
const r = mapExpertResult(raw())!;
expect(view({ status: "ready", results: [r] })).toContain("Jane Doe");
expect(view({ status: "ready", results: [] })).toContain("No experts found.");
expect(view({ status: "error" })).toContain('role="alert"');
expect(view({ status: "idle" })).not.toContain("data-testid=\"search-result\"");
});
});