Promote to main: Fixes (sign-out removal, video playback) and Experts & trending
Validated in dev by Cesar. Clean build verified. Includes sign-out removal, feed video mp4 playback, 3-column layout with Featured experts + Trending minnects rails, expert search, category browse, and trending video-answer playback.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { loginToMinnect, LoginError } from "@/lib/minnect";
|
import { loginToMinnect, LoginError } from "@/lib/minnect";
|
||||||
import { createSession, getSession } from "@/lib/session-store";
|
import { createSession, getSession } from "@/lib/session-store";
|
||||||
import { endSession } from "@/lib/session-refresh";
|
|
||||||
import { SESSION_COOKIE, sessionCookieOptions } from "@/lib/cookie";
|
import { SESSION_COOKIE, sessionCookieOptions } from "@/lib/cookie";
|
||||||
|
|
||||||
// This route holds the session on the server. The browser talks only to this
|
// This route holds the session on the server. The browser talks only to this
|
||||||
// app; Minnect tokens never leave the server (ADR-001).
|
// app; Minnect tokens never leave the server (ADR-001).
|
||||||
//
|
//
|
||||||
// POST signs in, GET says who the person is, DELETE signs out (revoke + clear).
|
// POST signs in, GET says who the person is. There is no sign-out: the session ends
|
||||||
|
// when the server restarts or the cookie is cleared, and Minnect's revoke endpoint
|
||||||
|
// is never called (the refresh token is shared across the person's devices).
|
||||||
// The reusable token-refresh logic lives in session-refresh.ts for the feed
|
// The reusable token-refresh logic lives in session-refresh.ts for the feed
|
||||||
// route (a later task) to call before it uses a token.
|
// route (a later task) to call before it uses a token.
|
||||||
|
|
||||||
@@ -91,21 +92,3 @@ export async function GET(req: NextRequest) {
|
|||||||
feedUserId: session.person.feedUserId ?? null,
|
feedUserId: session.person.feedUserId ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/session
|
|
||||||
* Sign out: revoke the refresh token at Minnect (best-effort), delete the
|
|
||||||
* server session, and clear the cookie. Always returns 200: sign-out succeeds
|
|
||||||
* even when revoke errors. After this, the session is gone, so every
|
|
||||||
* token-requiring route (and GET /api/session) returns 401 { error: "sign_in" }.
|
|
||||||
*/
|
|
||||||
export async function DELETE(req: NextRequest) {
|
|
||||||
const sessionId = req.cookies.get(SESSION_COOKIE)?.value;
|
|
||||||
|
|
||||||
await endSession(sessionId);
|
|
||||||
|
|
||||||
const res = NextResponse.json({ ok: true });
|
|
||||||
// Expire the cookie with the same attributes it was set with.
|
|
||||||
res.cookies.set(SESSION_COOKIE, "", { ...sessionCookieOptions(), maxAge: 0 });
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -79,3 +79,50 @@ 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-video { background: #000; border-radius: 0; }
|
||||||
|
.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%; }
|
||||||
|
}
|
||||||
|
|||||||
+18
-14
@@ -1,16 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { loadSession, signOut, 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 };
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [state, setState] = useState<State>({ kind: "loading" });
|
const [state, setState] = useState<State>({ kind: "loading" });
|
||||||
const [signingOut, setSigningOut] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let live = true;
|
let live = true;
|
||||||
@@ -22,14 +24,6 @@ export function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function handleSignOut() {
|
|
||||||
if (signingOut) return;
|
|
||||||
setSigningOut(true);
|
|
||||||
await signOut();
|
|
||||||
setSigningOut(false);
|
|
||||||
setState({ kind: "out" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.kind === "loading") {
|
if (state.kind === "loading") {
|
||||||
return (
|
return (
|
||||||
<main className="center">
|
<main className="center">
|
||||||
@@ -54,11 +48,21 @@ export function App() {
|
|||||||
<strong>{state.person.name}</strong>
|
<strong>{state.person.name}</strong>
|
||||||
<span>{state.person.email}</span>
|
<span>{state.person.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={handleSignOut} disabled={signingOut}>
|
|
||||||
{signingOut ? "Signing out..." : "Sign out"}
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />
|
<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" })} />
|
||||||
|
</div>
|
||||||
|
<aside className="col-right" aria-label="Discover">
|
||||||
|
<DiscoverRails />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { DiscoverMedia } from "@/lib/discover";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
media: DiscoverMedia | null;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
question: string | null;
|
||||||
|
who: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Video answers play inline; audio keeps the audio control; no url shows the thumbnail only. */
|
||||||
|
export function MinnectMedia({ media, thumbnailUrl, question, who }: Props) {
|
||||||
|
const [videoFailed, setVideoFailed] = useState(false);
|
||||||
|
|
||||||
|
const thumb = thumbnailUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img className="minnect-thumb" src={thumbnailUrl} alt={question ? `Thumbnail for: ${question}` : "Minnect thumbnail"} />
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (media?.kind === "video" && !videoFailed) {
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
className="minnect-thumb minnect-video"
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload="none"
|
||||||
|
src={media.url}
|
||||||
|
poster={thumbnailUrl ?? undefined}
|
||||||
|
aria-label={`Play answer from ${who}`}
|
||||||
|
onError={() => setVideoFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{thumb}
|
||||||
|
{media?.kind === "audio" ? (
|
||||||
|
<audio className="minnect-audio" controls preload="none" src={media.url} aria-label={`Play answer from ${who}`} />
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -87,9 +87,9 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
|
|||||||
{deleteError ? <p className="like-error" role="alert">Could not delete the post.</p> : null}
|
{deleteError ? <p className="like-error" role="alert">Could not delete the post.</p> : null}
|
||||||
{post.text ? <p className="post-text">{post.text}</p> : null}
|
{post.text ? <p className="post-text">{post.text}</p> : null}
|
||||||
{post.media.map((m, i) =>
|
{post.media.map((m, i) =>
|
||||||
m.kind === "video" && m.hlsUrl ? (
|
m.kind === "video" && (m.mp4Url || m.hlsUrl) ? (
|
||||||
<div className="post-media post-video" key={i}>
|
<div className="post-media post-video" key={i}>
|
||||||
<VideoPlayer hlsUrl={m.hlsUrl} poster={m.src} label={`Video from ${post.authorName}`} />
|
<VideoPlayer mp4Url={m.mp4Url} hlsUrl={m.hlsUrl} poster={m.src} label={`Video from ${post.authorName}`} />
|
||||||
</div>
|
</div>
|
||||||
) : m.src ? (
|
) : m.src ? (
|
||||||
<div className={m.kind === "video" ? "post-media post-video" : "post-media"} key={i}>
|
<div className={m.kind === "video" ? "post-media post-video" : "post-media"} key={i}>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { DiscoverMinnect } from "@/lib/discover";
|
||||||
|
import { MinnectMedia } from "./MinnectMedia";
|
||||||
|
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>
|
||||||
|
<MinnectMedia media={m.media} thumbnailUrl={m.thumbnailUrl} question={m.question} who={who} />
|
||||||
|
{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}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,66 +4,85 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { choosePlayback, supportsNativeHls } from "@/lib/video-playback";
|
import { choosePlayback, supportsNativeHls } from "@/lib/video-playback";
|
||||||
|
|
||||||
export interface VideoPlayerProps {
|
export interface VideoPlayerProps {
|
||||||
hlsUrl: string;
|
mp4Url?: string | null;
|
||||||
|
hlsUrl?: string | null;
|
||||||
poster: string | null;
|
poster: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UNPLAYABLE = "Video can't be played here.";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real <video controls>. Native HLS where the browser has it, otherwise hls.js,
|
* Real <video controls>. Order: direct mp4Url (plain progressive source), else
|
||||||
* loaded only when a video card mounts. Never autoplays.
|
* hls.js (lazy-loaded, only for the HLS path; native HLS only where hls.js is
|
||||||
|
* unsupported), else thumbnail only. Never
|
||||||
|
* autoplays. Any failure falls back to the thumbnail with a short note.
|
||||||
*/
|
*/
|
||||||
export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
|
export function VideoPlayer({ mp4Url, hlsUrl, poster, label }: VideoPlayerProps) {
|
||||||
const ref = useRef<HTMLVideoElement>(null);
|
const ref = useRef<HTMLVideoElement>(null);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = ref.current;
|
const video = ref.current;
|
||||||
if (!video) return;
|
// A progressive MP4 needs no hls.js; only the HLS fallback loads it.
|
||||||
|
if (!video || mp4Url || !hlsUrl) return;
|
||||||
let destroy: (() => void) | undefined;
|
let destroy: (() => void) | undefined;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const native = supportsNativeHls(video);
|
|
||||||
if (native) {
|
|
||||||
if (choosePlayback(hlsUrl, { nativeHls: true, hlsJsSupported: false }) === "native") {
|
|
||||||
video.src = hlsUrl;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
import("hls.js")
|
import("hls.js")
|
||||||
.then(({ default: Hls }) => {
|
.then(({ default: Hls }) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const mode = choosePlayback(hlsUrl, { nativeHls: false, hlsJsSupported: Hls.isSupported() });
|
const mode = choosePlayback(hlsUrl, {
|
||||||
if (mode !== "hlsjs") {
|
nativeHls: supportsNativeHls(video),
|
||||||
setFailed(true);
|
hlsJsSupported: Hls.isSupported(),
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hls = new Hls();
|
|
||||||
hls.on(Hls.Events.ERROR, (_e, data) => {
|
|
||||||
if (data.fatal) setFailed(true);
|
|
||||||
});
|
});
|
||||||
hls.loadSource(hlsUrl);
|
if (mode === "hlsjs") {
|
||||||
hls.attachMedia(video);
|
const hls = new Hls();
|
||||||
destroy = () => hls.destroy();
|
hls.on(Hls.Events.ERROR, (_e, data) => {
|
||||||
|
if (data.fatal) setFailed(true);
|
||||||
|
});
|
||||||
|
hls.loadSource(hlsUrl);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
destroy = () => hls.destroy();
|
||||||
|
} else if (mode === "native") {
|
||||||
|
video.src = hlsUrl;
|
||||||
|
} else {
|
||||||
|
setFailed(true);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => setFailed(true));
|
.catch(() => setFailed(true));
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
destroy?.();
|
destroy?.();
|
||||||
};
|
};
|
||||||
}, [hlsUrl]);
|
}, [mp4Url, hlsUrl]);
|
||||||
|
|
||||||
if (failed && poster) {
|
if (failed) {
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
return (
|
||||||
return <img src={poster} alt="Video thumbnail" loading="lazy" />;
|
<div>
|
||||||
|
{poster ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={poster} alt="Video thumbnail" loading="lazy" />
|
||||||
|
) : null}
|
||||||
|
<p role="status">{UNPLAYABLE}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!mp4Url && !hlsUrl) {
|
||||||
|
return poster ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={poster} alt="Video thumbnail" loading="lazy" />
|
||||||
|
) : null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<video
|
<video
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
src={mp4Url ?? undefined}
|
||||||
controls
|
controls
|
||||||
playsInline
|
playsInline
|
||||||
preload="none"
|
preload="none"
|
||||||
poster={poster ?? undefined}
|
poster={poster ?? undefined}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,19 +31,6 @@ export async function loadSession(f: Fetch = fetch): Promise<Person | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/session: sign out. Resolves once the server has cleared the
|
|
||||||
* session and cookie. A failed request still returns the UI to signed-out,
|
|
||||||
* because the local session view is no longer trustworthy.
|
|
||||||
*/
|
|
||||||
export async function signOut(f: Fetch = fetch): Promise<void> {
|
|
||||||
try {
|
|
||||||
await f("/api/session", { method: "DELETE", credentials: "same-origin", cache: "no-store" });
|
|
||||||
} catch {
|
|
||||||
// Ignore: the caller returns to the sign-in screen regardless.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SignInResult = { ok: true; person: Person } | { ok: false; message: string };
|
export type SignInResult = { ok: true; person: Person } | { ok: false; message: string };
|
||||||
|
|
||||||
/** POST /api/session. A 401 is always the one wrong-credentials message. */
|
/** POST /api/session. A 401 is always the one wrong-credentials message. */
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
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 type DiscoverMediaKind = "video" | "audio";
|
||||||
|
|
||||||
|
export interface DiscoverMedia {
|
||||||
|
kind: DiscoverMediaKind;
|
||||||
|
url: string;
|
||||||
|
duration: number | null;
|
||||||
|
waveform: number[] | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoverMinnect {
|
||||||
|
id: string | number;
|
||||||
|
question: string | null;
|
||||||
|
views: number | null;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
/** null when the answer has no playable url. */
|
||||||
|
media: DiscoverMedia | 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIDEO_TYPES = new Set(["MOV", "MP4", "M4V", "QUICKTIME", "MO4", "WEBM"]);
|
||||||
|
|
||||||
|
function mapMedia(answer: Rec): DiscoverMedia | null {
|
||||||
|
const url = str(answer.url);
|
||||||
|
if (!url) return null;
|
||||||
|
const type = (str(answer.file_type) ?? "").trim().toUpperCase();
|
||||||
|
return {
|
||||||
|
kind: VIDEO_TYPES.has(type) ? "video" : "audio",
|
||||||
|
url,
|
||||||
|
duration: num(answer.duration),
|
||||||
|
waveform: waveform(answer.waveform_data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
media: mapMedia(answer),
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
+22
-5
@@ -7,6 +7,8 @@ export interface CardMedia {
|
|||||||
src: string | null;
|
src: string | null;
|
||||||
/** Mux HLS playlist (videos only). Null when the video is not ready yet. */
|
/** Mux HLS playlist (videos only). Null when the video is not ready yet. */
|
||||||
hlsUrl?: string | null;
|
hlsUrl?: string | null;
|
||||||
|
/** Direct progressive MP4 on the feed CDN (videos only). Preferred for playback. */
|
||||||
|
mp4Url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardPost {
|
export interface CardPost {
|
||||||
@@ -65,6 +67,18 @@ export function muxHlsUrl(v: unknown): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The MP4 URL only when it is https on the feed CDN. */
|
||||||
|
export function feedMp4Url(v: unknown): string | null {
|
||||||
|
const s = str(v);
|
||||||
|
if (!s) return null;
|
||||||
|
try {
|
||||||
|
const u = new URL(s);
|
||||||
|
return u.protocol === "https:" && u.hostname === "cdn.feed.sharedservices.vt.com" ? s : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toMedia(raw: unknown): CardMedia[] {
|
function toMedia(raw: unknown): CardMedia[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
const items = raw
|
const items = raw
|
||||||
@@ -76,8 +90,9 @@ function toMedia(raw: unknown): CardMedia[] {
|
|||||||
if (m.mediaType === "video") {
|
if (m.mediaType === "video") {
|
||||||
const thumb = str(m.thumbnailUrl);
|
const thumb = str(m.thumbnailUrl);
|
||||||
const hls = muxHlsUrl(m.hlsUrl);
|
const hls = muxHlsUrl(m.hlsUrl);
|
||||||
// Keep a video even with no thumbnail or hlsUrl yet (just published).
|
const mp4 = feedMp4Url(m.mp4Url);
|
||||||
out.push({ kind: "video", src: thumb, hlsUrl: hls });
|
// Keep a video even with no thumbnail or playable URL yet (just published).
|
||||||
|
out.push({ kind: "video", src: thumb, hlsUrl: hls, mp4Url: mp4 });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const src = str(m.imageMediumWebpUrl) ?? str(m.imageMediumUrl) ?? str(m.imageUrl);
|
const src = str(m.imageMediumWebpUrl) ?? str(m.imageMediumUrl) ?? str(m.imageUrl);
|
||||||
@@ -143,10 +158,12 @@ export function mergePosts(...lists: CardPost[][]): CardPost[] {
|
|||||||
return sortByPublishedDesc(all);
|
return sortByPublishedDesc(all);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasHls = (p: CardPost) => p.media.some((m) => m.kind === "video" && m.hlsUrl);
|
/** A video is playable once it has an mp4Url or an hlsUrl. */
|
||||||
|
export const isPlayableVideo = (m: CardMedia) => m.kind === "video" && !!(m.mp4Url || m.hlsUrl);
|
||||||
|
const hasPlayable = (p: CardPost) => p.media.some(isPlayableVideo);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Swap in the media of shown posts whose video gained an hlsUrl on a refresh
|
* Swap in the media of shown posts whose video gained an mp4Url or hlsUrl on a refresh
|
||||||
* (transcoding finished), so the thumbnail upgrades to the player in place.
|
* (transcoding finished), so the thumbnail upgrades to the player in place.
|
||||||
* Returns the same array when nothing changed.
|
* Returns the same array when nothing changed.
|
||||||
*/
|
*/
|
||||||
@@ -155,7 +172,7 @@ export function upgradeVideos(current: CardPost[], fresh: CardPost[]): CardPost[
|
|||||||
let changed = false;
|
let changed = false;
|
||||||
const next = current.map((p) => {
|
const next = current.map((p) => {
|
||||||
const f = byKey.get(p.key);
|
const f = byKey.get(p.key);
|
||||||
if (f && !hasHls(p) && hasHls(f)) {
|
if (f && !hasPlayable(p) && hasPlayable(f)) {
|
||||||
changed = true;
|
changed = true;
|
||||||
return { ...p, media: f.media };
|
return { ...p, media: f.media };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,27 +196,6 @@ export async function refreshProfileTokens(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Revoke a profile refresh token on sign-out.
|
|
||||||
* `POST /api/v4/auth/revoke-token` with `{ refreshToken }`. Best-effort: any
|
|
||||||
* error (network or non-200) is swallowed so that sign-out still succeeds.
|
|
||||||
*/
|
|
||||||
export async function revokeToken(
|
|
||||||
refreshToken: string,
|
|
||||||
options: LoginOptions = {},
|
|
||||||
): Promise<void> {
|
|
||||||
const doFetch = options.fetchImpl ?? fetch;
|
|
||||||
try {
|
|
||||||
await doFetch(`${PROFILES_BASE_URL}/api/v4/auth/revoke-token`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: jsonHeaders(),
|
|
||||||
body: JSON.stringify({ refreshToken }),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Sign-out still succeeds if revoke fails (spec, "Sign out").
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `GET /api/v1/auth/me` on the feed host. Returns data.id, or null on any failure. */
|
/** `GET /api/v1/auth/me` on the feed host. Returns data.id, or null on any failure. */
|
||||||
async function fetchFeedUserId(feedToken: string, doFetch: typeof fetch): Promise<string | null> {
|
async function fetchFeedUserId(feedToken: string, doFetch: typeof fetch): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ import {
|
|||||||
updateSession,
|
updateSession,
|
||||||
type SessionData,
|
type SessionData,
|
||||||
} from "./session-store";
|
} from "./session-store";
|
||||||
import { exchangeFeedToken, refreshProfileTokens, revokeToken } from "./minnect";
|
import { exchangeFeedToken, refreshProfileTokens } from "./minnect";
|
||||||
|
|
||||||
// Reusable server-side session logic the route handlers share. The feed route
|
// Reusable server-side session logic the route handlers share. The feed route
|
||||||
// (a later task) calls `ensureFreshSession` before using a token, and
|
// (a later task) calls `ensureFreshSession` before using a token, and
|
||||||
// `refreshSession` once after a feed call returns 401. Sign-out uses
|
// `refreshSession` once after a feed call returns 401. Minnect tokens never leave the server; nothing here is logged.
|
||||||
// `endSession`. Minnect tokens never leave the server; nothing here is logged.
|
|
||||||
|
|
||||||
/** Refresh when a token expires within this window (spec: two minutes). */
|
/** Refresh when a token expires within this window (spec: two minutes). */
|
||||||
export const REFRESH_WINDOW_MS = 2 * 60 * 1000;
|
export const REFRESH_WINDOW_MS = 2 * 60 * 1000;
|
||||||
@@ -85,20 +84,3 @@ export async function ensureFreshSession(
|
|||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign-out. Revoke the refresh token (best-effort) and delete the server
|
|
||||||
* session. Always resolves: a revoke error does not stop sign-out. The caller
|
|
||||||
* clears the cookie.
|
|
||||||
*/
|
|
||||||
export async function endSession(
|
|
||||||
sessionId: string | undefined,
|
|
||||||
options: Pick<RefreshOptions, "fetchImpl"> = {},
|
|
||||||
): Promise<void> {
|
|
||||||
if (!sessionId) return;
|
|
||||||
const session = getSession(sessionId);
|
|
||||||
if (session) {
|
|
||||||
await revokeToken(session.profileRefreshToken, options);
|
|
||||||
}
|
|
||||||
deleteSession(sessionId);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ export interface PlaybackEnv {
|
|||||||
hlsJsSupported: boolean;
|
hlsJsSupported: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** No hlsUrl: thumbnail only. Native HLS wins; hls.js is the fallback. */
|
/**
|
||||||
|
* No hlsUrl: thumbnail only. hls.js (Media Source Extensions) is preferred
|
||||||
|
* because Chromium reports partial native HLS support it cannot honour; native
|
||||||
|
* HLS is only chosen where hls.js is unsupported (Safari on iOS).
|
||||||
|
*/
|
||||||
export function choosePlayback(hlsUrl: string | null | undefined, env: PlaybackEnv): Playback {
|
export function choosePlayback(hlsUrl: string | null | undefined, env: PlaybackEnv): Playback {
|
||||||
if (!hlsUrl) return "none";
|
if (!hlsUrl) return "none";
|
||||||
if (env.nativeHls) return "native";
|
if (env.hlsJsSupported) return "hlsjs";
|
||||||
return env.hlsJsSupported ? "hlsjs" : "none";
|
return env.nativeHls ? "native" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function supportsNativeHls(video: Pick<HTMLVideoElement, "canPlayType">): boolean {
|
export function supportsNativeHls(video: Pick<HTMLVideoElement, "canPlayType">): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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",
|
||||||
|
media: { kind: "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("renders a video with poster for video answers, no audio element", () => {
|
||||||
|
const url = "https://valuentainment-quick-questions-prod.s3.amazonaws.com/a.mov?X-Amz-Signature=abc";
|
||||||
|
const out = html(createElement(TrendingMinnectsRail, {
|
||||||
|
minnects: [minnect({ media: { kind: "video", url, duration: 9, waveform: null } })],
|
||||||
|
}));
|
||||||
|
expect(out).toContain("<video");
|
||||||
|
expect(out).toContain("controls");
|
||||||
|
expect(out).toContain("preload=\"none\"");
|
||||||
|
expect(out).toContain("X-Amz-Signature=abc");
|
||||||
|
expect(out).toContain('poster="https://x/t.jpg"');
|
||||||
|
expect(out).not.toContain("<audio");
|
||||||
|
expect(out).not.toContain("autoPlay");
|
||||||
|
});
|
||||||
|
it("has no media element for a missing url but keeps the thumbnail", () => {
|
||||||
|
const out = html(createElement(TrendingMinnectsRail, { minnects: [minnect({ media: null })] }));
|
||||||
|
expect(out).not.toContain("<audio");
|
||||||
|
expect(out).not.toContain("<video");
|
||||||
|
expect(out).toContain("minnect-thumb");
|
||||||
|
});
|
||||||
|
it("has no audio element or thumbnail when those are missing", () => {
|
||||||
|
const out = html(createElement(TrendingMinnectsRail, {
|
||||||
|
minnects: [minnect({ media: 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
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",
|
||||||
|
media: { kind: "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].media).toBeNull();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("trending media kind", () => {
|
||||||
|
const conv = (answer: unknown) => mapDiscover({ data: { trending_conversations: [{ id: 1, answer }] } }).trendingMinnects[0].media;
|
||||||
|
it.each(["MOV", "mp4", "M4V", "QuickTime", "MO4", "webm"])("maps %s to video and keeps the signed url", (t) => {
|
||||||
|
const url = "https://valuentainment-quick-questions-prod.s3.amazonaws.com/a.mov?X-Amz-Signature=abc";
|
||||||
|
expect(conv({ url, file_type: t, duration: 5 })).toEqual({ kind: "video", url, duration: 5, waveform: null });
|
||||||
|
});
|
||||||
|
it("maps other or missing file types to audio", () => {
|
||||||
|
expect(conv({ url: "https://a/x", file_type: "aac" })?.kind).toBe("audio");
|
||||||
|
expect(conv({ url: "https://a/x" })?.kind).toBe("audio");
|
||||||
|
});
|
||||||
|
it("has no media without a url", () => {
|
||||||
|
expect(conv({ file_type: "MOV" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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\"");
|
||||||
|
});
|
||||||
|
});
|
||||||
+17
-1
@@ -144,7 +144,7 @@ describe("video transcoding", () => {
|
|||||||
|
|
||||||
it("keeps a just-published video as a thumbnail with no hlsUrl", () => {
|
it("keeps a just-published video as a thumbnail with no hlsUrl", () => {
|
||||||
const [p] = vid(null);
|
const [p] = vid(null);
|
||||||
expect(p.media).toEqual([{ kind: "video", src: "https://i/t.jpg", hlsUrl: null }]);
|
expect(p.media).toEqual([{ kind: "video", src: "https://i/t.jpg", hlsUrl: null, mp4Url: null }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a video with neither thumbnail nor hlsUrl as a pending card", () => {
|
it("keeps a video with neither thumbnail nor hlsUrl as a pending card", () => {
|
||||||
@@ -161,6 +161,22 @@ describe("video transcoding", () => {
|
|||||||
expect(upgradeVideos(shown, vid(null))).toBe(shown);
|
expect(upgradeVideos(shown, vid(null))).toBe(shown);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("captures mp4Url on the feed CDN and rejects other hosts", () => {
|
||||||
|
const mk = (u: unknown) =>
|
||||||
|
toCardPosts([post("9", "2026-10-19T10:00:00Z", { mediaItems: [{ mediaType: "video", thumbnailUrl: "https://i/t.jpg", mp4Url: u }] })])[0].media[0];
|
||||||
|
expect(mk("https://cdn.feed.sharedservices.vt.com/v.mp4").mp4Url).toBe("https://cdn.feed.sharedservices.vt.com/v.mp4");
|
||||||
|
expect(mk("https://evil.example/v.mp4").mp4Url).toBeNull();
|
||||||
|
expect(mk("http://cdn.feed.sharedservices.vt.com/v.mp4").mp4Url).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upgrades the thumbnail in place when only mp4Url appears", () => {
|
||||||
|
const withMp4 = toCardPosts([post("7", "2026-10-19T10:00:00Z", { mediaItems: [{ mediaType: "video", thumbnailUrl: "https://i/t.jpg", mp4Url: "https://cdn.feed.sharedservices.vt.com/v.mp4" }] })]);
|
||||||
|
const shown = vid(null);
|
||||||
|
const next = upgradeVideos(shown, withMp4);
|
||||||
|
expect(next[0].media[0].mp4Url).toContain("cdn.feed");
|
||||||
|
expect(upgradeVideos(next, vid(null))).toBe(next);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the thumbnail while transcoding and the player once hlsUrl is present", () => {
|
it("renders the thumbnail while transcoding and the player once hlsUrl is present", () => {
|
||||||
const html = render({ posts: vid(null) });
|
const html = render({ posts: vid(null) });
|
||||||
expect(html).toContain("https://i/t.jpg");
|
expect(html).toContain("https://i/t.jpg");
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
endSession,
|
|
||||||
ensureFreshSession,
|
ensureFreshSession,
|
||||||
refreshSession,
|
refreshSession,
|
||||||
} from "@/lib/session-refresh";
|
} from "@/lib/session-refresh";
|
||||||
@@ -113,27 +112,3 @@ describe("refreshSession failure", () => {
|
|||||||
expect(getSession(id)).toBeNull();
|
expect(getSession(id)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("endSession", () => {
|
|
||||||
it("revokes the refresh token and deletes the session", async () => {
|
|
||||||
const id = seedSession(Date.now() + 60 * 60_000);
|
|
||||||
const fetchImpl = vi.fn(async (_input: RequestInfo | URL) => json({ data: { success: true } }));
|
|
||||||
|
|
||||||
await endSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
|
||||||
|
|
||||||
expect(fetchImpl.mock.calls.some(([u]) => String(u).endsWith("/api/v4/auth/revoke-token"))).toBe(true);
|
|
||||||
expect(getSession(id)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still deletes the session when revoke throws", async () => {
|
|
||||||
const id = seedSession(Date.now() + 60 * 60_000);
|
|
||||||
const fetchImpl = vi.fn(async (_input: RequestInfo | URL): Promise<Response> => {
|
|
||||||
throw new Error("revoke down");
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
endSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch }),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
expect(getSession(id)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
+3
-79
@@ -1,26 +1,17 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { DELETE, GET, POST } from "@/app/api/session/route";
|
import { GET, POST } from "@/app/api/session/route";
|
||||||
import { SESSION_COOKIE } from "@/lib/cookie";
|
import { SESSION_COOKIE } from "@/lib/cookie";
|
||||||
import { getSession } from "@/lib/session-store";
|
import { getSession } from "@/lib/session-store";
|
||||||
|
|
||||||
// A fetch stub that walks the Minnect calls. `overrides` lets a test force a
|
// A fetch stub that walks the Minnect calls. `overrides` lets a test force a
|
||||||
// non-200 or a weird body at any step. `revoke` covers the sign-out call.
|
// non-200 or a weird body at any step.
|
||||||
function makeFetchStub(
|
function makeFetchStub(
|
||||||
overrides: Partial<Record<"login" | "me" | "feed" | "revoke", Response>> = {},
|
overrides: Partial<Record<"login" | "me" | "feed", Response>> = {},
|
||||||
) {
|
) {
|
||||||
return vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
|
return vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
|
||||||
const url = typeof input === "string" ? input : input.toString();
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
|
||||||
if (url.endsWith("/api/v4/auth/revoke-token")) {
|
|
||||||
return (
|
|
||||||
overrides.revoke ??
|
|
||||||
new Response(JSON.stringify({ data: { success: true } }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (url.endsWith("/api/v4/auth/login")) {
|
if (url.endsWith("/api/v4/auth/login")) {
|
||||||
return (
|
return (
|
||||||
overrides.login ??
|
overrides.login ??
|
||||||
@@ -193,70 +184,3 @@ describe("GET /api/session", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function requestWithCookie(method: string, id: string): NextRequest {
|
|
||||||
return new NextRequest("http://localhost/api/session", {
|
|
||||||
method,
|
|
||||||
headers: { cookie: `${SESSION_COOKIE}=${id}` },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("DELETE /api/session", () => {
|
|
||||||
it("revokes, deletes the session, clears the cookie; next GET is 401", async () => {
|
|
||||||
const fetchStub = makeFetchStub();
|
|
||||||
vi.stubGlobal("fetch", fetchStub);
|
|
||||||
|
|
||||||
const postRes = await POST(postRequest({ email: "person@example.com", password: "right" }));
|
|
||||||
const id = postRes.cookies.get(SESSION_COOKIE)!.value;
|
|
||||||
expect(getSession(id)).not.toBeNull();
|
|
||||||
|
|
||||||
const delRes = await DELETE(requestWithCookie("DELETE", id));
|
|
||||||
expect(delRes.status).toBe(200);
|
|
||||||
|
|
||||||
// The revoke-token call was made.
|
|
||||||
expect(
|
|
||||||
fetchStub.mock.calls.some(([u]) => String(u).endsWith("/api/v4/auth/revoke-token")),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
// Cookie is cleared (empty value, immediate expiry).
|
|
||||||
const cookie = delRes.cookies.get(SESSION_COOKIE);
|
|
||||||
expect(cookie?.value).toBe("");
|
|
||||||
expect(cookie?.maxAge).toBe(0);
|
|
||||||
|
|
||||||
// Session is gone.
|
|
||||||
expect(getSession(id)).toBeNull();
|
|
||||||
|
|
||||||
// The next token-requiring call (GET) returns 401 sign_in.
|
|
||||||
const getRes = await GET(requestWithCookie("GET", id));
|
|
||||||
expect(getRes.status).toBe(401);
|
|
||||||
expect(await getRes.json()).toEqual({ error: "sign_in" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still succeeds when revoke returns an error", async () => {
|
|
||||||
vi.stubGlobal("fetch", makeFetchStub());
|
|
||||||
const postRes = await POST(postRequest({ email: "person@example.com", password: "right" }));
|
|
||||||
const id = postRes.cookies.get(SESSION_COOKIE)!.value;
|
|
||||||
|
|
||||||
// Now make revoke fail.
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
makeFetchStub({
|
|
||||||
revoke: new Response(JSON.stringify({ data: null }), {
|
|
||||||
status: 500,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const delRes = await DELETE(requestWithCookie("DELETE", id));
|
|
||||||
expect(delRes.status).toBe(200);
|
|
||||||
expect(delRes.cookies.get(SESSION_COOKIE)?.value).toBe("");
|
|
||||||
expect(getSession(id)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("no cookie: still 200 and clears the cookie", async () => {
|
|
||||||
vi.stubGlobal("fetch", makeFetchStub());
|
|
||||||
const delRes = await DELETE(new NextRequest("http://localhost/api/session", { method: "DELETE" }));
|
|
||||||
expect(delRes.status).toBe(200);
|
|
||||||
expect(delRes.cookies.get(SESSION_COOKIE)?.value).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PostCard } from "@/components/PostCard";
|
|||||||
import { toCardPosts } from "@/lib/feed-model";
|
import { toCardPosts } from "@/lib/feed-model";
|
||||||
import { choosePlayback, supportsNativeHls } from "@/lib/video-playback";
|
import { choosePlayback, supportsNativeHls } from "@/lib/video-playback";
|
||||||
|
|
||||||
|
const MP4 = "https://cdn.feed.sharedservices.vt.com/v.mp4";
|
||||||
const HLS = "https://stream.mux.com/abc.m3u8";
|
const HLS = "https://stream.mux.com/abc.m3u8";
|
||||||
const item = (media: Record<string, unknown>) => ({
|
const item = (media: Record<string, unknown>) => ({
|
||||||
type: "post",
|
type: "post",
|
||||||
@@ -19,10 +20,13 @@ describe("choosePlayback", () => {
|
|||||||
expect(choosePlayback(null, { nativeHls: true, hlsJsSupported: true })).toBe("none");
|
expect(choosePlayback(null, { nativeHls: true, hlsJsSupported: true })).toBe("none");
|
||||||
expect(choosePlayback(undefined, { nativeHls: false, hlsJsSupported: true })).toBe("none");
|
expect(choosePlayback(undefined, { nativeHls: false, hlsJsSupported: true })).toBe("none");
|
||||||
});
|
});
|
||||||
it("uses native HLS where supported, hls.js otherwise", () => {
|
it("prefers hls.js whenever it is supported, even if native HLS is reported", () => {
|
||||||
expect(choosePlayback(HLS, { nativeHls: true, hlsJsSupported: true })).toBe("native");
|
expect(choosePlayback(HLS, { nativeHls: true, hlsJsSupported: true })).toBe("hlsjs");
|
||||||
expect(choosePlayback(HLS, { nativeHls: false, hlsJsSupported: true })).toBe("hlsjs");
|
expect(choosePlayback(HLS, { nativeHls: false, hlsJsSupported: true })).toBe("hlsjs");
|
||||||
});
|
});
|
||||||
|
it("uses native HLS only when hls.js is unsupported", () => {
|
||||||
|
expect(choosePlayback(HLS, { nativeHls: true, hlsJsSupported: false })).toBe("native");
|
||||||
|
});
|
||||||
it("falls back to none when neither is available", () => {
|
it("falls back to none when neither is available", () => {
|
||||||
expect(choosePlayback(HLS, { nativeHls: false, hlsJsSupported: false })).toBe("none");
|
expect(choosePlayback(HLS, { nativeHls: false, hlsJsSupported: false })).toBe("none");
|
||||||
});
|
});
|
||||||
@@ -52,4 +56,24 @@ describe("video media in the card", () => {
|
|||||||
expect(out).not.toContain("<video");
|
expect(out).not.toContain("<video");
|
||||||
expect(out).toContain('src="https://i/v.jpg"');
|
expect(out).toContain('src="https://i/v.jpg"');
|
||||||
});
|
});
|
||||||
|
it("prefers mp4Url: plain video source, no hls.js needed", () => {
|
||||||
|
const out = html({ ...video, hlsUrl: HLS, mp4Url: MP4 });
|
||||||
|
expect(out).toContain("<video");
|
||||||
|
expect(out).toContain(`src="${MP4}"`);
|
||||||
|
expect(out).toContain('poster="https://i/v.jpg"');
|
||||||
|
expect(out).not.toContain("m3u8");
|
||||||
|
expect(out).not.toContain("autoplay");
|
||||||
|
});
|
||||||
|
it("mp4Url alone renders the player", () => {
|
||||||
|
expect(html({ ...video, mp4Url: MP4 })).toContain(`src="${MP4}"`);
|
||||||
|
});
|
||||||
|
it("hls-only video renders a video without a src (hls.js attaches it)", () => {
|
||||||
|
const out = html({ ...video, hlsUrl: HLS });
|
||||||
|
expect(out).toContain("<video");
|
||||||
|
expect(out).not.toContain(`src="${HLS}"`);
|
||||||
|
});
|
||||||
|
it("rejects an mp4Url off the feed CDN", () => {
|
||||||
|
const out = html({ ...video, mp4Url: "https://evil.example/v.mp4" });
|
||||||
|
expect(out).not.toContain("<video");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user