diff --git a/.env.example b/.env.example index 03ef410..c55a3bd 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,7 @@ PROFILES_BASE_URL=https://profiles.minnect.com # Feed host: home feed, posts, likes, comments (used by later tasks). 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 diff --git a/src/app/api/discover/route.ts b/src/app/api/discover/route.ts new file mode 100644 index 0000000..30da477 --- /dev/null +++ b/src/app/api/discover/route.ts @@ -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); +} diff --git a/src/app/api/experts/search/route.ts b/src/app/api/experts/search/route.ts new file mode 100644 index 0000000..b002665 --- /dev/null +++ b/src/app/api/experts/search/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { browseCategory, searchExperts } from "@/lib/experts-search"; + +// GET /api/experts/search?q= or ?category= +// 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 }); +} diff --git a/src/app/globals.css b/src/app/globals.css index d6e1e15..dc98707 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -79,3 +79,49 @@ button:disabled { opacity: .5; cursor: not-allowed; } .menu button:hover { background: #f4f4f4; } .menu .menu-danger { color: #a8141b; } .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%; } +} diff --git a/src/components/App.tsx b/src/components/App.tsx index 166ef65..804e83a 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,10 +1,13 @@ "use client"; +import Link from "next/link"; import { useEffect, useState } from "react"; import { loadSession, type Person } from "@/lib/auth-client"; import { SignInForm } from "./SignInForm"; import { Logo } from "./Logo"; import { Feed } from "./Feed"; +import { DiscoverRails } from "./DiscoverRails"; +import { ExpertSearch } from "./ExpertSearch"; type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person }; @@ -46,7 +49,20 @@ export function App() { {state.person.email} - setState({ kind: "out" })} /> +
+ +
+ setState({ kind: "out" })} /> +
+ +
); } diff --git a/src/components/DiscoverRails.tsx b/src/components/DiscoverRails.tsx new file mode 100644 index 0000000..b33989b --- /dev/null +++ b/src/components/DiscoverRails.tsx @@ -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 ( + <> + + + + ); +} + +/** Loads /api/discover once. Failure hides the rails; the feed is unaffected. */ +export function DiscoverRails() { + const [data, setData] = useState(null); + useEffect(() => { + let live = true; + fetchDiscover().then((d) => { if (live) setData(d); }, () => {}); + return () => { live = false; }; + }, []); + return ; +} diff --git a/src/components/ExpertSearch.tsx b/src/components/ExpertSearch.tsx new file mode 100644 index 0000000..eb7e30d --- /dev/null +++ b/src/components/ExpertSearch.tsx @@ -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 ( +
+ Find experts +
e.preventDefault()}> + + p.onQuery(e.target.value)} + /> +
+ {p.categories.length > 0 ? ( +
+ {p.categories.map((c) => ( + + ))} +
+ ) : null} +
+ {p.status === "loading" ?

Searching...

: null} + {p.status === "error" ?

Search is unavailable. Try again.

: null} + {p.status === "ready" && p.results.length === 0 ? ( +

No experts found.

+ ) : null} + {p.status === "ready" && p.results.length > 0 ? ( +
    + {p.results.map((e) => ( +
  • + {e.pictureUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} +
    + {e.displayName || "Expert"} + {e.byline ? {e.byline} : null} + {e.rating !== null ? ★ {e.rating.toFixed(1)} : null} +
    +
  • + ))} +
+ ) : null} +
+
+ ); +} + +const DEBOUNCE_MS = 300; + +export function ExpertSearch() { + const [query, setQuery] = useState(""); + const [category, setCategory] = useState(null); + const [categories, setCategories] = useState([]); + const [status, setStatus] = useState("idle"); + const [results, setResults] = useState([]); + 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 ( + + ); +} diff --git a/src/components/FeaturedExpertsRail.tsx b/src/components/FeaturedExpertsRail.tsx new file mode 100644 index 0000000..a057e91 --- /dev/null +++ b/src/components/FeaturedExpertsRail.tsx @@ -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 ( + + {rating !== null ? ★ {rating.toFixed(1)} : null} + {reviews !== null ? ({reviews} {reviews === 1 ? "review" : "reviews"}) : null} + + ); +} + +export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] }) { + if (experts.length === 0) return null; + return ( +
+

Featured experts

+
    + {experts.map((e) => ( +
  • + {e.pictureUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} +
    + {e.displayName || "Expert"} + {e.byline ? {e.byline} : null} + +
    +
  • + ))} +
+
+ ); +} diff --git a/src/components/TrendingMinnectsRail.tsx b/src/components/TrendingMinnectsRail.tsx new file mode 100644 index 0000000..16de916 --- /dev/null +++ b/src/components/TrendingMinnectsRail.tsx @@ -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 ( +
+ +
    + {minnects.map((m) => { + const who = m.expert.displayName || "Expert"; + return ( +
  • +
    + {m.expert.pictureUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} + {who} +
    + {m.thumbnailUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {m.question + ) : null} + {m.question ?

    {m.question}

    : null} + {m.views !== null ? ( + {m.views.toLocaleString("en-US")} {m.views === 1 ? "view" : "views"} + ) : null} + {m.audio.url ? ( +
  • + ); + })} +
+
+ ); +} diff --git a/src/lib/discover-client.ts b/src/lib/discover-client.ts new file mode 100644 index 0000000..7fa585f --- /dev/null +++ b/src/lib/discover-client.ts @@ -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 = (v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []); + +export async function fetchDiscover( + fetchImpl: typeof fetch = fetch, +): Promise { + 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; + return { + featuredExperts: arr(body.featuredExperts), + trendingMinnects: arr(body.trendingMinnects), + categories: arr(body.categories), + }; + } catch { + throw new DiscoverLoadError(); + } +} diff --git a/src/lib/discover.ts b/src/lib/discover.ts new file mode 100644 index 0000000..e20f5dc --- /dev/null +++ b/src/lib/discover.ts @@ -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; + +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 = (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 { + 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; + } +} diff --git a/src/lib/env.ts b/src/lib/env.ts index a632ef1..68e67ff 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -15,3 +15,10 @@ export const FEED_BASE_URL = baseUrl( process.env.FEED_BASE_URL, "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", +); diff --git a/src/lib/experts-search-client.ts b/src/lib/experts-search-client.ts new file mode 100644 index 0000000..6bc88ce --- /dev/null +++ b/src/lib/experts-search-client.ts @@ -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 { + 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; + return Array.isArray(body.experts) ? (body.experts as ExpertResult[]) : []; + } catch { + throw new ExpertSearchError(); + } +} diff --git a/src/lib/experts-search.ts b/src/lib/experts-search.ts new file mode 100644 index 0000000..3eac8ad --- /dev/null +++ b/src/lib/experts-search.ts @@ -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=&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; +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, + fetchImpl: typeof fetch, +): Promise { + 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 { + 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 { + 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 }; +} diff --git a/tests/discover-rails.test.ts b/tests/discover-rails.test.ts new file mode 100644 index 0000000..d54cc1a --- /dev/null +++ b/tests/discover-rails.test.ts @@ -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 => ({ + 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 => ({ + 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) => renderToStaticMarkup(el); + +describe("FeaturedExpertsRail", () => { + it("renders a card with name, byline, rating and reviews", () => { + const out = html(createElement(FeaturedExpertsRail, { experts: [expert()] })); + expect(out).toContain(" { + const out = html(createElement(FeaturedExpertsRail, { + experts: [expert({ pictureUrl: null, byline: null, rating: null, reviewsCount: null })], + })); + expect(out).not.toContain(" { + 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(" { + const out = html(createElement(TrendingMinnectsRail, { + minnects: [minnect({ audio: { url: null, duration: null, waveform: null }, thumbnailUrl: null, question: null, views: null })], + })); + expect(out).not.toContain(" { + 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"); + }); +}); diff --git a/tests/discover.test.ts b/tests/discover.test.ts new file mode 100644 index 0000000..926c2c7 --- /dev/null +++ b/tests/discover.test.ts @@ -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) { + 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("")); + expect(b.res.status).toBe(502); + }); +}); diff --git a/tests/expert-search.test.ts b/tests/expert-search.test.ts new file mode 100644 index 0000000..5b64beb --- /dev/null +++ b/tests/expert-search.test.ts @@ -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 = {}) => ({ + 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 = {}) => + 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(''); + expect(h).toContain('id="expert-q"'); + expect(h).toMatch(/]*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\""); + }); +});