spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
1'use client';2import { Bell, Trash2 } from 'lucide-react';3import Link from 'next/link';4import { useCallback, useEffect, useState } from 'react';5import { EventList } from '@/components/events/event-row';6import { EventTypeBadge } from '@/components/ui/badges';7import { LiveAgo } from '@/components/ui/live';8import { Empty, Note, Section } from '@/components/ui/section';9import { SkeletonRows } from '@/components/ui/skeleton';10import { ownerApi } from '@/lib/client-api';11import { EVENT_TYPES } from '@/lib/event-styles';12import { readWatched, setOwnerToken, useOwnerToken, writeWatched } from '@/lib/owner';13import { routes } from '@/lib/site';14import type { Alert, AlertDelivery, WatchlistPayload } from '@/lib/types';15import { CompanyTable } from './company-table';1617/** /watchlist: watched companies with their latest events, alert rules and recent deliveries — all keyed by the owner token. */18export function WatchlistClient() {19 const token = useOwnerToken(true);20 const [data, setData] = useState<WatchlistPayload | null>(null);21 const [alerts, setAlerts] = useState<Alert[]>([]);22 const [deliveries, setDeliveries] = useState<AlertDelivery[]>([]);23 const [error, setError] = useState<string | null>(null);24 const [loading, setLoading] = useState(true);25 const [showToken, setShowToken] = useState(false);26 const [importDraft, setImportDraft] = useState('');27 const [form, setForm] = useState<{ name: string; company: string; event_types: string[]; min_importance: string; channel: 'web' | 'webhook'; target: string }>({ name: '', company: '', event_types: [], min_importance: '', channel: 'web', target: '' });2829 const load = useCallback(async () => {30 if (!token) return;31 const api = ownerApi(token);32 setLoading(true);33 try {34 const [w, a, d] = await Promise.all([api.watchlist(), api.alerts().catch(() => ({ items: [] })), api.deliveries(30).catch(() => ({ items: [] }))]);35 setData(w);36 setAlerts(a.items ?? []);37 setDeliveries(d.items ?? []);38 setError(null);39 // reconcile the local mirror with the server truth40 writeWatched(w.items.map((c) => c.slug));41 } catch (e) {42 setError((e as Error).message);43 // fall back to the local mirror for names only44 if (!data) setData({ items: [], events: [] });45 } finally {46 setLoading(false);47 }48 // eslint-disable-next-line react-hooks/exhaustive-deps49 }, [token]);50 useEffect(() => {51 load();52 }, [load]);5354 const remove = async (slug: string) => {55 if (!token) return;56 writeWatched(readWatched().filter((s) => s !== slug));57 setData((d) => (d ? { ...d, items: d.items.filter((c) => c.slug !== slug) } : d));58 try {59 await ownerApi(token).unwatch(slug);60 } catch {61 /* reload will reconcile */62 }63 };64 const createAlert = async (e: React.FormEvent) => {65 e.preventDefault();66 if (!token || !form.name.trim()) return;67 try {68 const a = await ownerApi(token).createAlert({ name: form.name.trim(), company: form.company || undefined, condition: { event_types: form.event_types.length ? form.event_types : undefined, min_importance: form.min_importance ? Number(form.min_importance) : undefined }, channel: form.channel, target: form.channel === 'webhook' ? form.target : undefined });69 setAlerts((l) => [...l, a]);70 setForm({ name: '', company: '', event_types: [], min_importance: '', channel: 'web', target: '' });71 } catch (err) {72 setError((err as Error).message);73 }74 };75 const deleteAlert = async (id: string) => {76 if (!token) return;77 setAlerts((l) => l.filter((a) => a.id !== id));78 try {79 await ownerApi(token).deleteAlert(id);80 } catch {81 /* ignore */82 }83 };8485 const localOnly = readWatched();86 return (87 <div data-watchlist>88 {error && (89 <p className="mb-4 border border-warning/40 bg-warning-soft px-3 py-2 text-sm text-warning" role="status">90 Watchlist service: {error}. Your locally saved list ({localOnly.length}) is kept and will sync when the API answers.91 </p>92 )}93 <Section eyebrow="Watched companies" title={data ? `${data.items.length} ${data.items.length === 1 ? 'company' : 'companies'}` : 'Loading…'} hairline={false} action={{ href: routes.companies(), label: 'Find companies' }}>94 {loading && !data ? (95 <SkeletonRows />96 ) : data && data.items.length ? (97 <>98 <CompanyTable items={data.items} />99 <ul className="mt-2 flex flex-wrap gap-1.5">100 {data.items.map((c) => (101 <li key={c.slug}>102 <button type="button" onClick={() => remove(c.slug)} className="chip-btn" aria-label={`Stop watching ${c.display_name}`}>103 <Trash2 className="size-3" aria-hidden /> {c.display_name}104 </button>105 </li>106 ))}107 </ul>108 {data.items.length >= 2 && (109 <p className="mt-3 text-sm">110 <Link href={routes.compare(data.items.slice(0, 6).map((c) => c.slug))} className="link">111 Compare watched companies →112 </Link>113 </p>114 )}115 </>116 ) : (117 <Empty title="Your watchlist is empty.">118 Use the <span className="font-medium text-ink-2">Watch</span> button on any company page. No account is needed — the list is tied to a token stored in this browser.119 </Empty>120 )}121 </Section>122123 <Section eyebrow="Latest events" title="Across watched companies">124 {data ? <EventList events={data.events} variant="table" emptyLabel="No events yet for the companies you watch." /> : <SkeletonRows />}125 </Section>126127 <Section eyebrow="Alerts" title="Rules" lede="Get notified in the web feed or by webhook when watched companies produce matching events. Rules are evaluated on the server against new structured events.">128 <form onSubmit={createAlert} className="grid gap-2 border border-rule p-3 md:grid-cols-[1.5fr_1fr_1fr_1fr_auto]" data-alert-form>129 <input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Rule name (required)" className="field" aria-label="Rule name" required />130 <select value={form.company} onChange={(e) => setForm({ ...form, company: e.target.value })} className="field" aria-label="Company">131 <option value="">Any watched company</option>132 {(data?.items ?? []).map((c) => (133 <option key={c.slug} value={c.slug}>134 {c.display_name}135 </option>136 ))}137 </select>138 <select value={form.min_importance} onChange={(e) => setForm({ ...form, min_importance: e.target.value })} className="field" aria-label="Minimum importance">139 <option value="">Any importance</option>140 <option value="0.5">Importance ≥ 50</option>141 <option value="0.7">Importance ≥ 70</option>142 <option value="0.85">Importance ≥ 85</option>143 </select>144 <select value={form.channel} onChange={(e) => setForm({ ...form, channel: e.target.value as 'web' | 'webhook' })} className="field" aria-label="Channel">145 <option value="web">Web</option>146 <option value="webhook">Webhook</option>147 </select>148 <button type="submit" className="btn btn-primary">149 <Bell className="size-4" aria-hidden /> Add rule150 </button>151 {form.channel === 'webhook' && <input value={form.target} onChange={(e) => setForm({ ...form, target: e.target.value })} placeholder="https://your-endpoint.example/hook" className="field md:col-span-5" aria-label="Webhook URL" type="url" required />}152 <div className="flex flex-wrap gap-1 md:col-span-5">153 {EVENT_TYPES.map((t) => {154 const on = form.event_types.includes(t);155 return (156 <button key={t} type="button" onClick={() => setForm({ ...form, event_types: on ? form.event_types.filter((x) => x !== t) : [...form.event_types, t] })} className="chip-btn" data-on={on} aria-pressed={on}>157 {t}158 </button>159 );160 })}161 <span className="self-center text-[11px] text-ink-3">no selection = all types</span>162 </div>163 </form>164 {alerts.length > 0 ? (165 <ul className="mt-4 divide-y divide-rule border-y border-rule text-sm">166 {alerts.map((a) => (167 <li key={a.id} className="flex flex-wrap items-center gap-2 py-2">168 <span className="font-medium text-ink">{a.name}</span>169 <span className="text-xs text-ink-3">{a.company ? `company ${a.company}` : 'any watched company'}</span>170 {(a.condition.event_types ?? []).map((t) => (171 <EventTypeBadge key={t} type={t} small />172 ))}173 {a.condition.min_importance !== undefined && <span className="tnum text-xs text-ink-3">importance ≥ {Math.round(a.condition.min_importance * 100)}</span>}174 <span className="mono text-[11px] text-ink-3">{a.channel}</span>175 <button type="button" onClick={() => deleteAlert(a.id)} className="ml-auto flex size-9 items-center justify-center text-ink-3 hover:text-danger" aria-label={`Delete rule ${a.name}`}>176 <Trash2 className="size-4" aria-hidden />177 </button>178 </li>179 ))}180 </ul>181 ) : (182 <Note className="mt-3">No rules yet.</Note>183 )}184 {deliveries.length > 0 && (185 <div className="mt-6">186 <p className="eyebrow mb-1">Recent deliveries</p>187 <ul className="divide-y divide-rule border-y border-rule text-sm">188 {deliveries.map((d) => (189 <li key={d.id} className="flex flex-wrap items-center gap-2 py-2">190 <span className="text-xs text-ink-3">{d.alert_name ?? d.alert_id}</span>191 {d.event ? (192 <Link href={routes.event(d.event.id)} className="min-w-0 flex-1 truncate hover:text-accent">193 {d.event.title}194 </Link>195 ) : (196 <span className="mono text-xs">{d.event_id}</span>197 )}198 <span className="mono text-[11px] text-ink-3">199 {d.channel} · {d.status}200 </span>201 <LiveAgo at={d.delivered_at} tick={30000} className="text-xs text-ink-3" />202 </li>203 ))}204 </ul>205 </div>206 )}207 </Section>208209 <Section eyebrow="Your token" title="No account, one token">210 <p className="text-sm text-ink-2">Watchlists and alerts belong to a random token generated by this browser and stored in localStorage; the server keeps only a hash. Clearing site data loses the list — copy the token to move it to another device.</p>211 <div className="mt-3 flex flex-wrap items-center gap-2">212 <button type="button" onClick={() => setShowToken((v) => !v)} className="btn btn-sm">213 {showToken ? 'Hide token' : 'Show token'}214 </button>215 {showToken && token && <code className="mono break-all border border-rule bg-surface-2 px-2 py-1 text-xs">{token}</code>}216 </div>217 <form218 className="mt-3 flex max-w-lg gap-2"219 onSubmit={(e) => {220 e.preventDefault();221 if (setOwnerToken(importDraft)) {222 setImportDraft('');223 load();224 }225 }}226 >227 <input value={importDraft} onChange={(e) => setImportDraft(e.target.value)} placeholder="Paste a token from another device" className="field flex-1 text-xs" aria-label="Import token" />228 <button type="submit" className="btn btn-sm" disabled={importDraft.trim().length < 24}>229 Import230 </button>231 </form>232 </Section>233 </div>234 );235}236