| 1 | "use client"; |
| 2 | |
| 3 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 4 | import Link from "next/link"; |
| 5 | import { SourceLink } from "@/components/source-link"; |
| 6 | import { clsx } from "@/lib/cx"; |
| 7 | import { formatEt, formatDateShort } from "@/lib/format"; |
| 8 | import type { FeedItem } from "@/lib/feed"; |
| 9 | |
| 10 | |
| 11 | export function RegisterHero({ seed }: { seed: FeedItem[] }) { |
| 12 | const [rows, setRows] = useState<FeedItem[]>(seed); |
| 13 | const [flashId, setFlashId] = useState<string | null>(null); |
| 14 | const prevIds = useRef<Set<string>>(new Set(seed.map((r) => r.id))); |
| 15 | |
| 16 | const refresh = useCallback(async () => { |
| 17 | try { |
| 18 | const res = await fetch("/api/feed?limit=12", { cache: "no-store" }); |
| 19 | if (!res.ok) return; |
| 20 | const data = (await res.json()) as { items: FeedItem[] }; |
| 21 | if (!data.items?.length) return; |
| 22 | setRows((prev) => { |
| 23 | const next = data.items.slice(0, 12); |
| 24 | const newTop = next.find((it) => !prevIds.current.has(it.id)); |
| 25 | next.forEach((it) => prevIds.current.add(it.id)); |
| 26 | if (newTop) setFlashId(newTop.id); |
| 27 | return next; |
| 28 | }); |
| 29 | } catch { |
| 30 | } |
| 31 | }, []); |
| 32 | |
| 33 | useEffect(() => { |
| 34 | const iv = setInterval(refresh, 15000); |
| 35 | return () => clearInterval(iv); |
| 36 | }, [refresh]); |
| 37 | |
| 38 | return ( |
| 39 | <section |
| 40 | className="relative overflow-hidden border-y border-line" |
| 41 | aria-label="The Register - live feed of official actions" |
| 42 | > |
| 43 | {} |
| 44 | <div |
| 45 | aria-hidden="true" |
| 46 | className="pointer-events-none absolute inset-0" |
| 47 | style={{ background: "var(--dk-hero-glow)" }} |
| 48 | /> |
| 49 | |
| 50 | <div className="relative mx-auto w-full max-w-register px-4 py-8 sm:px-6 sm:py-14"> |
| 51 | {} |
| 52 | <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> |
| 53 | <div> |
| 54 | <div className="mb-3 flex items-center gap-2.5"> |
| 55 | {} |
| 56 | <span className="live-dot animate-pulseDot" aria-hidden="true" /> |
| 57 | <span className="label text-paper">Live · The Register</span> |
| 58 | </div> |
| 59 | <h1 |
| 60 | className="display max-w-2xl text-paper" |
| 61 | style={{ fontSize: "clamp(1.875rem, 7vw, 3rem)", lineHeight: 1 }} |
| 62 | > |
| 63 | What the government did this week |
| 64 | </h1> |
| 65 | </div> |
| 66 | <p className="prose-lead max-w-sm text-ash"> |
| 67 | Each line is an official action, sealed to its source. No |
| 68 | commentary. Order is the order of action. |
| 69 | </p> |
| 70 | </div> |
| 71 | |
| 72 | {rows.length === 0 ? ( |
| 73 | <EmptyRegister /> |
| 74 | ) : ( |
| 75 | <div |
| 76 | className="mt-8 overflow-hidden rounded-[3px] border border-line bg-surface/60 backdrop-blur-sm" |
| 77 | aria-label="Stamped ledger of official actions" |
| 78 | > |
| 79 | {} |
| 80 | <div className="reg-row hidden grid-cols-[5.5rem_7.5rem_minmax(0,1fr)_6.5rem_minmax(0,5rem)] gap-x-2 px-4 py-2 sm:grid"> |
| 81 | <span className="label">Date</span> |
| 82 | <span className="label">Body</span> |
| 83 | <span className="label">Item</span> |
| 84 | <span className="label">Action</span> |
| 85 | <span className="label text-right">Result</span> |
| 86 | </div> |
| 87 | |
| 88 | {rows.map((r) => { |
| 89 | const isNew = r.id === flashId; |
| 90 | return ( |
| 91 | <div key={r.id} className="border-t border-line first:border-t-0"> |
| 92 | { |
| 93 | } |
| 94 | <RegisterRowInner row={r} flash={isNew} /> |
| 95 | </div> |
| 96 | ); |
| 97 | })} |
| 98 | </div> |
| 99 | )} |
| 100 | |
| 101 | <div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> |
| 102 | <p className="label text-ash-2"> |
| 103 | Ranked by action date, dollar magnitude, and people affected - never by coverage. |
| 104 | </p> |
| 105 | <Link |
| 106 | href="/how-we-pick" |
| 107 | className="focus-ring label tap w-fit rounded-[3px] px-1 text-ash underline underline-offset-4 hover:text-paper" |
| 108 | > |
| 109 | How we pick → |
| 110 | </Link> |
| 111 | </div> |
| 112 | </div> |
| 113 | </section> |
| 114 | ); |
| 115 | } |
| 116 | |
| 117 | function EmptyRegister() { |
| 118 | return ( |
| 119 | <div className="mt-8 rounded-[3px] border border-line bg-surface/60 p-8 text-center"> |
| 120 | <p className="display text-2xl text-paper">The register is quiet.</p> |
| 121 | <p className="prose-lead mx-auto mt-3 max-w-md text-ash"> |
| 122 | No official actions are on record yet. The ledger fills as bills are |
| 123 | filed, votes are taken, and awards are made - each line sealed to its |
| 124 | government source. |
| 125 | </p> |
| 126 | </div> |
| 127 | ); |
| 128 | } |
| 129 | |
| 130 | function RegisterRowInner({ |
| 131 | row, |
| 132 | flash, |
| 133 | }: { |
| 134 | row: FeedItem; |
| 135 | flash?: boolean; |
| 136 | }) { |
| 137 | const isPass = row.actionType === "PASSED"; |
| 138 | const isFail = row.actionType === "FAILED"; |
| 139 | return ( |
| 140 | <div className="reg-row grid grid-cols-1 gap-1 px-3 py-2.5 sm:grid-cols-[5.5rem_7.5rem_minmax(0,1fr)_6.5rem_minmax(0,5rem)] sm:gap-x-2 sm:items-center sm:px-4"> |
| 141 | <span |
| 142 | className={clsx( |
| 143 | "font-mono text-[12px] tabular-nums text-ash", |
| 144 | flash && "animate-fadeUp" |
| 145 | )} |
| 146 | > |
| 147 | {formatEt(row.actionDate) || formatDateShort(row.actionDate)} |
| 148 | </span> |
| 149 | <span className="font-mono text-[12px] uppercase tracking-wide text-ash-2"> |
| 150 | {row.chamber} |
| 151 | </span> |
| 152 | <span className="min-w-0 font-mono text-[13px] text-paper"> |
| 153 | {} |
| 154 | {row.href ? ( |
| 155 | <Link |
| 156 | href={row.href} |
| 157 | className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper" |
| 158 | > |
| 159 | <span className="block break-words">{row.title}</span> |
| 160 | </Link> |
| 161 | ) : ( |
| 162 | <span className="block break-words">{row.title}</span> |
| 163 | )} |
| 164 | {} |
| 165 | <SourceLink |
| 166 | url={row.sourceUrl} |
| 167 | label="Source" |
| 168 | className="mt-1" |
| 169 | /> |
| 170 | </span> |
| 171 | <span |
| 172 | className="font-mono text-[12px] uppercase tracking-wider" |
| 173 | style={{ |
| 174 | color: isPass |
| 175 | ? "var(--dk-yea)" |
| 176 | : isFail |
| 177 | ? "var(--dk-nay)" |
| 178 | : "var(--dk-ash)", |
| 179 | }} |
| 180 | > |
| 181 | {row.actionType} |
| 182 | </span> |
| 183 | <span className="font-mono text-[12px] tabular-nums text-paper sm:text-right"> |
| 184 | { |
| 185 | } |
| 186 | {row.amountText ? row.amountText : <span className="hidden sm:inline">{"-"}</span>} |
| 187 | </span> |
| 188 | </div> |
| 189 | ); |
| 190 | } |