/* global React */ // discussions.jsx — Discussions module per PRODUCT.md. // Two backends, same shape: // - real: /api/discussions + /api/auth/* (when window.CCT_USE_REAL_API) // - mock: localStorage-backed (offline fallback) // Shape: { id, ticker, quarter, handle, karma, body, score, ts, replies: [{...}] } // Exposes window.Discussions, window.discussionsStore. const { useState, useEffect, useRef } = React; // -------- API helper (cookies for session) -------- const API_BASE = window.CCT_API_BASE || ""; async function apiFetch(path, opts = {}) { const res = await fetch(`${API_BASE}${path}`, { credentials: "include", headers: { "Content-Type": "application/json", ...(opts.headers || {}) }, ...opts, }); if (!res.ok) { const t = await res.text().catch(() => ""); const err = new Error(`api ${res.status} ${path} ${t}`); err.status = res.status; throw err; } return res.json(); } // -------- localStorage store (mock + offline fallback) -------- // Keys bumped to v4. Earlier versions (v1/v2/v3) are swept on every load. // Stored sets that contain smoke-test handles or the placeholder shiv take // are treated as poisoned and re-seeded from CCT_DISCUSSIONS_SEED. const LS_KEY_TAKES = "cct.discussions.v4"; const LS_KEY_USER = "cct.user.v1"; const LS_KEY_VOTES = "cct.discussions.votes.v4"; const SMOKE_HANDLE_RE = /smoke|^smokeweb|smoke1777|^web|^shiv$/i; const SMOKE_BODY_RE = /smoke-take|smoke|verifying live|shiv karma/i; function isSmokeTake(t) { return SMOKE_HANDLE_RE.test(t.handle || "") || SMOKE_BODY_RE.test(t.body || ""); } function loadTakes() { try { [ "cct.discussions.v1", "cct.discussions.v2", "cct.discussions.v3", "cct.discussions.votes.v1", "cct.discussions.votes.v2", "cct.discussions.votes.v3", ].forEach(k => { if (localStorage.getItem(k)) localStorage.removeItem(k); }); } catch {} try { const raw = localStorage.getItem(LS_KEY_TAKES); if (raw) { const parsed = JSON.parse(raw); if (Array.isArray(parsed) && parsed.length > 0) { if (!parsed.some(isSmokeTake)) return parsed; } } } catch {} return JSON.parse(JSON.stringify(window.CCT_DISCUSSIONS_SEED || [])); } function saveTakes(takes) { localStorage.setItem(LS_KEY_TAKES, JSON.stringify(takes)); } function loadUser() { try { const raw = localStorage.getItem(LS_KEY_USER); if (raw) return JSON.parse(raw); } catch {} return null; } function saveUser(u) { localStorage.setItem(LS_KEY_USER, JSON.stringify(u)); } function loadVotes() { try { return JSON.parse(localStorage.getItem(LS_KEY_VOTES) || "{}"); } catch { return {}; } } function saveVotes(v) { localStorage.setItem(LS_KEY_VOTES, JSON.stringify(v)); } const localStore = { async list({ ticker, quarter }) { const all = loadTakes(); const filtered = all.filter(t => t.ticker === ticker && (!quarter || t.quarter === quarter)); return withoutSmoke(filtered); }, async add({ ticker, quarter, body, user }) { const take = { id: "u_" + Math.random().toString(36).slice(2, 10), ticker, quarter, handle: user.handle, karma: user.karma || 0, body, score: 1, ts: new Date().toISOString(), replies: [] }; const all = loadTakes(); all.unshift(take); saveTakes(all); return take; }, async reply({ takeId, body, user }) { const reply = { id: "r_" + Math.random().toString(36).slice(2, 10), handle: user.handle, karma: user.karma || 0, body, score: 1, ts: new Date().toISOString() }; const all = loadTakes(); const t = all.find(x => x.id === takeId); if (!t) throw new Error("take not found"); t.replies = t.replies || []; t.replies.push(reply); saveTakes(all); return reply; }, async vote({ id, dir }) { const all = loadTakes(); let target = null; for (const t of all) { if (t.id === id) { target = t; break; } const r = (t.replies || []).find(rr => rr.id === id); if (r) { target = r; break; } } if (!target) throw new Error("not found"); // Approximate: store edges per-id in LS_KEY_VOTES — caller already does that. target.score = (target.score || 0) + dir; saveTakes(all); return target.score; } }; // Filter the seed by ticker/quarter — used as a defensive fallback if the API // is unreachable (e.g. backend not running, JSDOM has no fetch). function seedFor({ ticker, quarter }) { const seed = window.CCT_DISCUSSIONS_SEED || []; return seed.filter(t => t.ticker === ticker && (!quarter || t.quarter === quarter)); } // Strip stale smoke-test takes from any take list before the UI sees them. // The DB sweeper deletes them at startup, but defence-in-depth — if a stale // row survives (race during deploy, replica lag, manual smoke run), the user // shouldn't see it. function withoutSmoke(takes) { return (takes || []).filter(t => !isSmokeTake(t)).map(t => ({ ...t, replies: (t.replies || []).filter(r => !isSmokeTake(r)), })); } // -------- real-API store -------- const apiStore = { async list({ ticker, quarter }) { try { const r = await apiFetch(`/api/discussions?ticker=${encodeURIComponent(ticker)}&quarter=${encodeURIComponent(quarter || "")}`); return withoutSmoke(r.takes || []); } catch (e) { // Network/auth blip: serve curated seed so the page never reads as dead. // Posting still requires a working API + cookie session. return withoutSmoke(seedFor({ ticker, quarter })); } }, async add({ ticker, quarter, body }) { const r = await apiFetch("/api/discussions", { method: "POST", body: JSON.stringify({ ticker, quarter, body }) }); return r.take; }, async reply({ takeId, body }) { const r = await apiFetch(`/api/discussions/${encodeURIComponent(takeId)}/reply`, { method: "POST", body: JSON.stringify({ body }) }); return r.reply; }, async vote({ id, dir }) { const r = await apiFetch(`/api/discussions/${encodeURIComponent(id)}/vote`, { method: "POST", body: JSON.stringify({ dir }) }); return r.score; } }; const discussionsStore = window.CCT_USE_REAL_API ? apiStore : localStore; window.discussionsStore = discussionsStore; // -------- auth (real API or mock-from-LS) -------- function useAuth() { const [user, setUser] = useState(window.CCT_USE_REAL_API ? null : loadUser()); const [bootstrapped, setBootstrapped] = useState(!window.CCT_USE_REAL_API); // On mount, hydrate from /api/auth/me when real API is on. useEffect(() => { if (!window.CCT_USE_REAL_API) return; let cancelled = false; (async () => { try { const r = await apiFetch("/api/auth/me"); if (!cancelled) setUser(r.user || null); } catch {} finally { if (!cancelled) setBootstrapped(true); } })(); return () => { cancelled = true; }; }, []); async function signIn(email) { if (window.CCT_USE_REAL_API) { const r = await apiFetch("/api/auth/signin", { method: "POST", body: JSON.stringify({ email }) }); setUser(r.user); return r.user; } const handle = email.split("@")[0].replace(/[^a-z0-9_]/gi, "_").slice(0, 20).toLowerCase() || "investor"; const u = { email, handle, karma: 0 }; saveUser(u); setUser(u); return u; } async function signOut() { if (window.CCT_USE_REAL_API) { try { await apiFetch("/api/auth/signout", { method: "POST" }); } catch {} } else { localStorage.removeItem(LS_KEY_USER); } setUser(null); } function bumpKarma(d) { // With the real API karma is computed server-side — no-op. Local mode // updates locally so the chip refreshes immediately. if (window.CCT_USE_REAL_API || !user) return; const u = { ...user, karma: (user.karma || 0) + d }; saveUser(u); setUser(u); } return { user, signIn, signOut, bumpKarma, bootstrapped }; } // -------- helpers -------- function timeAgo(iso) { const d = new Date(iso); const s = Math.max(1, Math.floor((Date.now() - d.getTime()) / 1000)); if (s < 60) return s + "s"; const m = Math.floor(s / 60); if (m < 60) return m + "m"; const h = Math.floor(m / 60); if (h < 24) return h + "h"; const day = Math.floor(h / 24); if (day < 30) return day + "d"; return d.toISOString().slice(0, 10); } function VoteCol({ score, voted, onVote }) { return (
{score}
); } // -------- main component -------- function Discussions({ ticker, quarter }) { const [takes, setTakes] = useState([]); const [sort, setSort] = useState("score"); // "score" | "new" const [body, setBody] = useState(""); const [replyOpen, setReplyOpen] = useState(null); // takeId or null const [replyBody, setReplyBody] = useState(""); const [votes, setVotes] = useState(loadVotes()); const auth = useAuth(); const [signInEmail, setSignInEmail] = useState(""); const [error, setError] = useState(null); async function refresh() { try { const list = await discussionsStore.list({ ticker, quarter }); setTakes(list); } catch (e) { setError(e.message || "failed to load discussions"); } } // Load on mount + whenever ticker/quarter changes useEffect(() => { refresh(); }, [ticker, quarter]); async function vote(takeId, replyId, dir) { if (!auth.user) return alert("Sign in to vote."); const key = replyId ? `${takeId}:${replyId}` : takeId; const prev = votes[key] || 0; if (dir === prev) return; try { // Real API: dir replaces prev (server keeps unique vote per user). // Local: we send the delta so existing scores update correctly. const id = replyId || takeId; if (window.CCT_USE_REAL_API) { await discussionsStore.vote({ id, dir }); } else { await discussionsStore.vote({ id, dir: dir - prev }); } const nextVotes = { ...votes, [key]: dir }; saveVotes(nextVotes); setVotes(nextVotes); refresh(); } catch (e) { setError(e.message || "vote failed"); } } async function submit(e) { e.preventDefault(); const t = body.trim(); if (!t || !auth.user || t.length > 280) return; try { await discussionsStore.add({ ticker, quarter, body: t, user: auth.user }); auth.bumpKarma(1); setBody(""); refresh(); } catch (e) { setError(e.message || "failed to post take"); } } async function submitReply(takeId, e) { e.preventDefault(); const t = replyBody.trim(); if (!t || !auth.user || t.length > 280) return; try { await discussionsStore.reply({ takeId, body: t, user: auth.user }); auth.bumpKarma(1); setReplyBody(""); setReplyOpen(null); refresh(); } catch (e) { setError(e.message || "failed to post reply"); } } const sorted = [...takes].sort((a, b) => sort === "score" ? b.score - a.score : new Date(b.ts) - new Date(a.ts) ); return (
Discussions · {quarter}

What other investors are seeing.

280 chars · upvote / downvote · one level of reply. Sharpen your take.

{/* Compose */} {auth.user ? (
{auth.user.handle} karma {auth.user.karma}