diff --git a/src/app/globals.css b/src/app/globals.css index d6e1e15..5112f2a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -79,3 +79,35 @@ 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; } +} diff --git a/src/components/App.tsx b/src/components/App.tsx index 166ef65..9233a2a 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,10 +1,12 @@ "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"; type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person }; @@ -46,7 +48,19 @@ 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/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/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"); + }); +});