@orangecheck/vote-react 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @orangecheck/vote-react
2
2
 
3
+ > **Full reference:** [ochk.io/docs/sdk/vote-react](https://ochk.io/docs/sdk/vote-react) — auto-generated from the TypeScript source on every release.
4
+ > Hand-written prose below is the high-level overview; the docs site is the source of truth for every export, type, and signature.
5
+
6
+
3
7
  Drop-in React components for [OC Vote](https://github.com/orangecheck/oc-vote-protocol). Embed a live tally anywhere in three lines.
4
8
 
5
9
  ```bash
@@ -30,7 +34,7 @@ import { OcTallyBadge } from '@orangecheck/vote-react';
30
34
 
31
35
  ### `useTally(pollId, opts?)`
32
36
 
33
- The underlying hook, for custom rendering.
37
+ The underlying hook, for custom rendering. When a fetch fails, `data` is `null` until the next good answer, so an error never shows beside an earlier tally.
34
38
 
35
39
  ```tsx
36
40
  import { useTally } from '@orangecheck/vote-react';
package/dist/badge.js CHANGED
@@ -24,40 +24,58 @@ var THEMES = {
24
24
  };
25
25
  var DEFAULT_API_BASE = "https://vote.ochk.io";
26
26
  var POLL_ID_RE = /^[0-9a-f]{64}$/;
27
+ function contentOf(d) {
28
+ if (!d || d.question === void 0 && d.deadline === void 0) return null;
29
+ return {
30
+ ...d.question !== void 0 ? { question: d.question } : {},
31
+ ...d.deadline !== void 0 ? { deadline: d.deadline } : {}
32
+ };
33
+ }
27
34
  function useTally(pollId, opts = {}) {
28
35
  const baseUrl = opts.baseUrl ?? DEFAULT_API_BASE;
29
36
  const refreshMs = opts.refreshMs ?? 6e4;
30
- const [data, setData] = react.useState(opts.initialData ?? null);
31
- const [error, setError] = react.useState(null);
32
- const [loading, setLoading] = react.useState(!opts.initialData);
33
- const pollIdRef = react.useRef(pollId);
34
- pollIdRef.current = pollId;
37
+ const [entry, setEntry] = react.useState(() => ({
38
+ pollId,
39
+ data: opts.initialData ?? null,
40
+ poll: contentOf(opts.initialData),
41
+ error: null,
42
+ loading: !opts.initialData
43
+ }));
44
+ const latest = react.useRef(0);
35
45
  const fetchOnce = react.useCallback(async () => {
36
- const pid = pollIdRef.current;
46
+ const pid = pollId;
47
+ const seq = ++latest.current;
48
+ const kept = (prev) => prev.pollId === pid ? prev.poll : null;
37
49
  if (!POLL_ID_RE.test(pid)) {
38
- setError(new Error("pollId must be 64 hex chars"));
39
- setLoading(false);
50
+ setEntry({ pollId: pid, data: null, poll: null, error: new Error("pollId must be 64 hex chars"), loading: false });
40
51
  return;
41
52
  }
42
53
  try {
43
54
  const res = await fetch(`${baseUrl}/api/tally?poll=${pid}`);
44
55
  const body = await res.json();
45
56
  if (!res.ok) throw new Error(body.error ?? `http ${res.status}`);
46
- setData(body);
47
- setError(null);
57
+ if (seq !== latest.current) return;
58
+ setEntry((prev) => ({ pollId: pid, data: body, poll: contentOf(body) ?? kept(prev), error: null, loading: false }));
48
59
  } catch (err) {
49
- setError(err instanceof Error ? err : new Error(String(err)));
50
- } finally {
51
- setLoading(false);
60
+ if (seq !== latest.current) return;
61
+ const error = err instanceof Error ? err : new Error(String(err));
62
+ setEntry((prev) => ({ pollId: pid, data: null, poll: kept(prev), error, loading: false }));
52
63
  }
53
- }, [baseUrl]);
64
+ }, [baseUrl, pollId]);
54
65
  react.useEffect(() => {
55
66
  void fetchOnce();
56
67
  if (refreshMs <= 0) return;
57
68
  const timer = setInterval(() => void fetchOnce(), refreshMs);
58
69
  return () => clearInterval(timer);
59
70
  }, [fetchOnce, refreshMs]);
60
- return { data, error, loading, refetch: fetchOnce };
71
+ const mine = entry.pollId === pollId;
72
+ return {
73
+ data: mine ? entry.data : null,
74
+ poll: mine ? entry.poll : null,
75
+ error: mine ? entry.error : null,
76
+ loading: mine ? entry.loading : true,
77
+ refetch: fetchOnce
78
+ };
61
79
  }
62
80
  var OcTallyBadgeImpl = ({
63
81
  pollId,
package/dist/badge.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts","../src/use-tally.ts","../src/badge.tsx"],"names":["useState","useRef","useCallback","useEffect","useMemo","jsxs","jsx","memo"],"mappings":";;;;;;AAkCO,IAAM,MAAA,GAA0C;AAAA,EACnD,KAAA,EAAO;AAAA,IACH,EAAA,EAAI,SAAA;AAAA,IACJ,IAAA,EAAM,SAAA;AAAA,IACN,KAAA,EAAO,SAAA;AAAA,IACP,MAAA,EAAQ,SAAA;AAAA,IACR,MAAA,EAAQ,SAAA;AAAA,IACR,UAAA,EAAY;AAAA,GAChB;AAAA,EACA,IAAA,EAAM;AAAA,IACF,EAAA,EAAI,SAAA;AAAA,IACJ,IAAA,EAAM,SAAA;AAAA,IACN,KAAA,EAAO,SAAA;AAAA,IACP,MAAA,EAAQ,SAAA;AAAA,IACR,MAAA,EAAQ,SAAA;AAAA,IACR,UAAA,EAAY;AAAA;AAEpB,CAAA;AAEO,IAAM,gBAAA,GAAmB,sBAAA;AAEzB,IAAM,UAAA,GAAa,gBAAA;ACtBnB,SAAS,QAAA,CACZ,MAAA,EACA,IAAA,GAAwB,EAAC,EACZ;AACb,EAAA,MAAM,OAAA,GAAU,KAAK,OAAA,IAAW,gBAAA;AAChC,EAAA,MAAM,SAAA,GAAY,KAAK,SAAA,IAAa,GAAA;AAEpC,EAAA,MAAM,CAAC,IAAA,EAAM,OAAO,IAAIA,cAAA,CAA+B,IAAA,CAAK,eAAe,IAAI,CAAA;AAC/E,EAAA,MAAM,CAAC,KAAA,EAAO,QAAQ,CAAA,GAAIA,eAAuB,IAAI,CAAA;AACrD,EAAA,MAAM,CAAC,OAAA,EAAS,UAAU,IAAIA,cAAA,CAAS,CAAC,KAAK,WAAW,CAAA;AACxD,EAAA,MAAM,SAAA,GAAYC,aAAO,MAAM,CAAA;AAC/B,EAAA,SAAA,CAAU,OAAA,GAAU,MAAA;AAEpB,EAAA,MAAM,SAAA,GAAYC,kBAAY,YAAY;AACtC,IAAA,MAAM,MAAM,SAAA,CAAU,OAAA;AACtB,IAAA,IAAI,CAAC,UAAA,CAAW,IAAA,CAAK,GAAG,CAAA,EAAG;AACvB,MAAA,QAAA,CAAS,IAAI,KAAA,CAAM,6BAA6B,CAAC,CAAA;AACjD,MAAA,UAAA,CAAW,KAAK,CAAA;AAChB,MAAA;AAAA,IACJ;AACA,IAAA,IAAI;AACA,MAAA,MAAM,MAAM,MAAM,KAAA,CAAM,GAAG,OAAO,CAAA,gBAAA,EAAmB,GAAG,CAAA,CAAE,CAAA;AAC1D,MAAA,MAAM,IAAA,GAAQ,MAAM,GAAA,CAAI,IAAA,EAAK;AAC7B,MAAA,IAAI,CAAC,GAAA,CAAI,EAAA,EAAI,MAAM,IAAI,KAAA,CAAM,IAAA,CAAK,KAAA,IAAS,CAAA,KAAA,EAAQ,GAAA,CAAI,MAAM,CAAA,CAAE,CAAA;AAC/D,MAAA,OAAA,CAAQ,IAAI,CAAA;AACZ,MAAA,QAAA,CAAS,IAAI,CAAA;AAAA,IACjB,SAAS,GAAA,EAAK;AACV,MAAA,QAAA,CAAS,GAAA,YAAe,QAAQ,GAAA,GAAM,IAAI,MAAM,MAAA,CAAO,GAAG,CAAC,CAAC,CAAA;AAAA,IAChE,CAAA,SAAE;AACE,MAAA,UAAA,CAAW,KAAK,CAAA;AAAA,IACpB;AAAA,EACJ,CAAA,EAAG,CAAC,OAAO,CAAC,CAAA;AAEZ,EAAAC,eAAA,CAAU,MAAM;AACZ,IAAA,KAAK,SAAA,EAAU;AACf,IAAA,IAAI,aAAa,CAAA,EAAG;AACpB,IAAA,MAAM,QAAQ,WAAA,CAAY,MAAM,KAAK,SAAA,IAAa,SAAS,CAAA;AAC3D,IAAA,OAAO,MAAM,cAAc,KAAK,CAAA;AAAA,EACpC,CAAA,EAAG,CAAC,SAAA,EAAW,SAAS,CAAC,CAAA;AAEzB,EAAA,OAAO,EAAE,IAAA,EAAM,KAAA,EAAO,OAAA,EAAS,SAAS,SAAA,EAAU;AACtD;ACpCA,IAAM,mBAAmB,CAAC;AAAA,EACtB,MAAA;AAAA,EACA,KAAA,GAAQ,OAAA;AAAA,EACR,OAAA;AAAA,EACA,SAAA;AAAA,EACA,WAAA;AAAA,EACA,SAAA;AAAA,EACA,KAAA;AAAA,EACA,MAAA,GAAS;AACb,CAAA,KAAyB;AACrB,EAAA,MAAM,EAAE,IAAA,EAAM,KAAA,EAAO,OAAA,EAAQ,GAAI,SAAS,MAAA,EAAQ;AAAA,IAC9C,GAAI,OAAA,KAAY,MAAA,GAAY,EAAE,OAAA,KAAY,EAAC;AAAA,IAC3C,GAAI,SAAA,KAAc,MAAA,GAAY,EAAE,SAAA,KAAc,EAAC;AAAA,IAC/C,GAAI,WAAA,KAAgB,MAAA,GAAY,EAAE,WAAA,KAAgB;AAAC,GACtD,CAAA;AACD,EAAA,MAAM,CAAA,GAAI,OAAO,KAAK,CAAA;AAEtB,EAAA,MAAM,EAAE,QAAA,EAAU,KAAA,EAAM,GAAIC,aAAA,CAAQ,MAAM,WAAA,CAAY,IAAA,EAAM,KAAA,EAAO,OAAA,EAAS,CAAC,CAAA,EAAG,CAAC,MAAM,KAAA,EAAO,OAAA,EAAS,CAAC,CAAC,CAAA;AAEzG,EAAA,MAAM,IAAA,GAAO,CAAA,EAAG,OAAA,IAAW,sBAAsB,MAAM,MAAM,CAAA,CAAA;AAE7D,EAAA,MAAM,UAAA,GAA4B;AAAA,IAC9B,OAAA,EAAS,aAAA;AAAA,IACT,UAAA,EAAY,QAAA;AAAA,IACZ,GAAA,EAAK,CAAA;AAAA,IACL,OAAA,EAAS,UAAA;AAAA,IACT,MAAA,EAAQ,CAAA,UAAA,EAAa,CAAA,CAAE,MAAM,CAAA,CAAA;AAAA,IAC7B,YAAA,EAAc,CAAA;AAAA,IACd,YAAY,CAAA,CAAE,EAAA;AAAA,IACd,OAAO,CAAA,CAAE,IAAA;AAAA,IACT,UAAA,EAAY,gCAAA;AAAA,IACZ,QAAA,EAAU,EAAA;AAAA,IACV,cAAA,EAAgB,MAAA;AAAA,IAChB,UAAA,EAAY,GAAA;AAAA,IACZ,GAAG;AAAA,GACP;AAEA,EAAA,uBACIC,eAAA;AAAA,IAAC,GAAA;AAAA,IAAA;AAAA,MACG,IAAA;AAAA,MACA,MAAA;AAAA,MACA,GAAA,EAAK,MAAA,KAAW,QAAA,GAAW,qBAAA,GAAwB,MAAA;AAAA,MACnD,SAAA;AAAA,MACA,KAAA,EAAO,UAAA;AAAA,MACP,WAAA,EAAU,gBAAA;AAAA,MAEV,QAAA,EAAA;AAAA,wBAAAC,cAAA;AAAA,UAAC,MAAA;AAAA,UAAA;AAAA,YACG,aAAA,EAAW,IAAA;AAAA,YACX,KAAA,EAAO;AAAA,cACH,KAAA,EAAO,CAAA;AAAA,cACP,MAAA,EAAQ,CAAA;AAAA,cACR,YAAA,EAAc,KAAA;AAAA,cACd,UAAA,EAAY,QAAA;AAAA,cACZ,UAAA,EAAY;AAAA;AAChB;AAAA,SACJ;AAAA,uCACC,MAAA,EAAA,EAAK,KAAA,EAAO,EAAE,UAAA,EAAY,GAAA,IAAO,QAAA,EAAA,SAAA,EAAO,CAAA;AAAA,wBACzCA,cAAA,CAAC,UAAK,KAAA,EAAO,EAAE,OAAO,CAAA,CAAE,KAAA,IAAS,QAAA,EAAA,MAAA,EAAC,CAAA;AAAA,wBAClCA,cAAA,CAAC,UAAM,QAAA,EAAA,KAAA,EAAM;AAAA;AAAA;AAAA,GACjB;AAER,CAAA;AAEA,SAAS,WAAA,CACL,IAAA,EACA,KAAA,EACA,OAAA,EACA,CAAA,EACmC;AACnC,EAAA,IAAI,OAAO,OAAO,EAAE,UAAU,SAAA,EAAW,KAAA,EAAO,MAAM,OAAA,EAAQ;AAC9D,EAAA,IAAI,OAAA,IAAW,CAAC,IAAA,EAAM,OAAO,EAAE,QAAA,EAAU,CAAA,CAAE,KAAA,EAAO,KAAA,EAAO,eAAA,EAAW;AACpE,EAAA,IAAI,CAAC,MAAM,OAAO,EAAE,UAAU,CAAA,CAAE,KAAA,EAAO,OAAO,SAAA,EAAU;AACxD,EAAA,IAAI,KAAK,KAAA,KAAU,iBAAA;AACf,IAAA,OAAO,EAAE,UAAU,CAAA,CAAE,MAAA,EAAQ,OAAO,CAAA,qBAAA,EAAqB,IAAA,CAAK,YAAA,IAAgB,CAAC,CAAA,QAAA,CAAA,EAAW;AAC9F,EAAA,IAAI,IAAA,CAAK,KAAA,KAAU,SAAA,EAAW,OAAO,EAAE,QAAA,EAAU,CAAA,CAAE,KAAA,EAAO,KAAA,EAAO,MAAA,CAAO,IAAA,CAAK,KAAK,CAAA,EAAE;AAEpF,EAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,OAAA,EAAS,MAAA,IAAU,CAAA;AACtC,EAAA,MAAM,OAAA,GAAU,IAAA,CAAK,OAAA,IAAW,EAAC;AACjC,EAAA,MAAM,MAAA,GAAS,MAAA,CAAO,OAAA,CAAQ,OAAO,EAAE,IAAA,CAAK,CAAC,CAAA,EAAG,CAAA,KAAM,CAAA,CAAE,CAAC,CAAA,GAAI,CAAA,CAAE,CAAC,CAAC,CAAA;AACjE,EAAA,IAAI,MAAA,CAAO,MAAA,KAAW,CAAA,IAAK,KAAA,KAAU,CAAA,EAAG,OAAO,EAAE,QAAA,EAAU,CAAA,CAAE,KAAA,EAAO,KAAA,EAAO,gBAAA,EAAiB;AAE5F,EAAA,MAAM,CAAC,KAAA,EAAO,IAAI,CAAA,GAAI,OAAO,CAAC,CAAA;AAC9B,EAAA,MAAM,GAAA,GAAO,OAAO,KAAA,GAAS,GAAA;AAC7B,EAAA,OAAO;AAAA,IACH,QAAA,EAAU,SAAA;AAAA,IACV,KAAA,EAAO,CAAA,EAAG,KAAK,CAAA,MAAA,EAAM,GAAA,CAAI,OAAA,CAAQ,CAAC,CAAC,CAAA,KAAA,EAAQ,GAAA,CAAI,KAAK,CAAC,CAAA;AAAA,GACzD;AACJ;AAEA,SAAS,IAAI,CAAA,EAAmB;AAC5B,EAAA,IAAI,KAAK,GAAA,EAAe,OAAA,CAAQ,IAAI,GAAA,EAAe,OAAA,CAAQ,CAAC,CAAA,GAAI,GAAA;AAChE,EAAA,IAAI,KAAK,GAAA,EAAW,OAAA,CAAQ,IAAI,GAAA,EAAW,OAAA,CAAQ,CAAC,CAAA,GAAI,GAAA;AACxD,EAAA,IAAI,KAAK,GAAA,EAAO,OAAA,CAAQ,IAAI,GAAA,EAAO,OAAA,CAAQ,CAAC,CAAA,GAAI,GAAA;AAChD,EAAA,OAAO,OAAO,CAAC,CAAA;AACnB;AAEO,IAAM,YAAA,GAAeC,WAAK,gBAAgB","file":"badge.js","sourcesContent":["// Wire types for the /api/tally response.\n// Deliberately a subset of what vote.ochk.io returns — we don't re-export\n// every field, just what the components render.\n\nexport interface TallyTurnout {\n voters: number;\n weight: number;\n}\n\nexport interface TallyResponse {\n poll_id: string;\n state: 'tallied' | 'awaiting_reveal';\n question?: string;\n creator?: string;\n deadline?: string;\n weight_mode?: string;\n mode?: 'public' | 'secret';\n snapshot_block?: number;\n turnout?: TallyTurnout;\n tallies?: Record<string, number>;\n ballot_count?: number;\n reveal_published?: boolean;\n error?: string;\n}\n\nexport interface Theme {\n bg: string;\n text: string;\n muted: string;\n border: string;\n accent: string;\n accentSoft: string;\n}\n\nexport const THEMES: Record<'light' | 'dark', Theme> = {\n light: {\n bg: '#ffffff',\n text: '#0a0a0a',\n muted: '#666666',\n border: '#e5e5e5',\n accent: '#f97316',\n accentSoft: '#fff4ec',\n },\n dark: {\n bg: '#0a0a0a',\n text: '#fafafa',\n muted: '#a0a0a0',\n border: '#262626',\n accent: '#fb923c',\n accentSoft: '#2a1a0a',\n },\n};\n\nexport const DEFAULT_API_BASE = 'https://vote.ochk.io';\n\nexport const POLL_ID_RE = /^[0-9a-f]{64}$/;\n","'use client';\n\n// useTally(pollId) — React hook that fetches + polls /api/tally.\n//\n// Defaults: fetch once on mount; refetch every 60s (matches the /api/tally\n// edge cache TTL so we never get stale data from our own server-side cache\n// but don't thrash the relay set either). Pass { refreshMs: 0 } for no\n// auto-refresh, or a custom interval.\n//\n// Consumers that want independent verification should NOT use this hook —\n// use @orangecheck/vote-core + @orangecheck/vote-cli with their own\n// node/bitcoin source. This hook trusts vote.ochk.io (or whatever baseUrl\n// you pass).\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\nimport { DEFAULT_API_BASE, POLL_ID_RE } from './types.js';\nimport type { TallyResponse } from './types.js';\n\nexport interface UseTallyOptions {\n baseUrl?: string;\n refreshMs?: number;\n /** If set, provides initial data (SSR hydration). */\n initialData?: TallyResponse;\n}\n\nexport interface UseTallyState {\n data: TallyResponse | null;\n error: Error | null;\n loading: boolean;\n refetch: () => void;\n}\n\nexport function useTally(\n pollId: string,\n opts: UseTallyOptions = {}\n): UseTallyState {\n const baseUrl = opts.baseUrl ?? DEFAULT_API_BASE;\n const refreshMs = opts.refreshMs ?? 60_000;\n\n const [data, setData] = useState<TallyResponse | null>(opts.initialData ?? null);\n const [error, setError] = useState<Error | null>(null);\n const [loading, setLoading] = useState(!opts.initialData);\n const pollIdRef = useRef(pollId);\n pollIdRef.current = pollId;\n\n const fetchOnce = useCallback(async () => {\n const pid = pollIdRef.current;\n if (!POLL_ID_RE.test(pid)) {\n setError(new Error('pollId must be 64 hex chars'));\n setLoading(false);\n return;\n }\n try {\n const res = await fetch(`${baseUrl}/api/tally?poll=${pid}`);\n const body = (await res.json()) as TallyResponse;\n if (!res.ok) throw new Error(body.error ?? `http ${res.status}`);\n setData(body);\n setError(null);\n } catch (err) {\n setError(err instanceof Error ? err : new Error(String(err)));\n } finally {\n setLoading(false);\n }\n }, [baseUrl]);\n\n useEffect(() => {\n void fetchOnce();\n if (refreshMs <= 0) return;\n const timer = setInterval(() => void fetchOnce(), refreshMs);\n return () => clearInterval(timer);\n }, [fetchOnce, refreshMs]);\n\n return { data, error, loading, refetch: fetchOnce };\n}\n","'use client';\n\n/**\n * <OcTallyBadge pollId=\"…\" />\n *\n * Compact inline pill. Shows:\n * - a colored dot (green=tallied, orange=awaiting_reveal, gray=loading, red=error)\n * - the top option + its percentage (tallied), or the state label (otherwise)\n * - a subtle link to vote.ochk.io/p/<id>\n *\n * Use on a feed row / profile card / sidebar. For the full poll card, use <OcPoll />.\n */\n\nimport type { CSSProperties } from 'react';\nimport { memo, useMemo } from 'react';\n\nimport { THEMES, type Theme, type TallyResponse } from './types.js';\nimport { useTally } from './use-tally.js';\n\nexport interface OcTallyBadgeProps {\n /** 64-hex poll id. */\n pollId: string;\n /** Theme. Default `light`. */\n theme?: 'light' | 'dark';\n /** Override /api/tally origin. Default `https://vote.ochk.io`. */\n baseUrl?: string;\n /** Auto-refresh interval in ms. Default 60000. Set to 0 to disable. */\n refreshMs?: number;\n /** Supply SSR-hydrated data to skip the initial fetch. */\n initialData?: TallyResponse;\n /** Extra class on the outer element. */\n className?: string;\n /** Inline style override. */\n style?: CSSProperties;\n /** Target for the link. Default `_blank`. */\n target?: '_blank' | '_self';\n}\n\nconst OcTallyBadgeImpl = ({\n pollId,\n theme = 'light',\n baseUrl,\n refreshMs,\n initialData,\n className,\n style,\n target = '_blank',\n}: OcTallyBadgeProps) => {\n const { data, error, loading } = useTally(pollId, {\n ...(baseUrl !== undefined ? { baseUrl } : {}),\n ...(refreshMs !== undefined ? { refreshMs } : {}),\n ...(initialData !== undefined ? { initialData } : {}),\n });\n const t = THEMES[theme];\n\n const { dotColor, label } = useMemo(() => renderState(data, error, loading, t), [data, error, loading, t]);\n\n const href = `${baseUrl ?? 'https://vote.ochk.io'}/p/${pollId}`;\n\n const outerStyle: CSSProperties = {\n display: 'inline-flex',\n alignItems: 'center',\n gap: 8,\n padding: '4px 10px',\n border: `1px solid ${t.border}`,\n borderRadius: 6,\n background: t.bg,\n color: t.text,\n fontFamily: 'ui-monospace, Menlo, monospace',\n fontSize: 12,\n textDecoration: 'none',\n lineHeight: 1.4,\n ...style,\n };\n\n return (\n <a\n href={href}\n target={target}\n rel={target === '_blank' ? 'noopener noreferrer' : undefined}\n className={className}\n style={outerStyle}\n data-slot=\"oc-tally-badge\"\n >\n <span\n aria-hidden\n style={{\n width: 8,\n height: 8,\n borderRadius: '50%',\n background: dotColor,\n flexShrink: 0,\n }}\n />\n <span style={{ fontWeight: 600 }}>oc vote</span>\n <span style={{ color: t.muted }}>·</span>\n <span>{label}</span>\n </a>\n );\n};\n\nfunction renderState(\n data: TallyResponse | null,\n error: Error | null,\n loading: boolean,\n t: Theme\n): { dotColor: string; label: string } {\n if (error) return { dotColor: '#ef4444', label: error.message };\n if (loading && !data) return { dotColor: t.muted, label: 'loading…' };\n if (!data) return { dotColor: t.muted, label: 'no data' };\n if (data.state === 'awaiting_reveal')\n return { dotColor: t.accent, label: `awaiting reveal · ${data.ballot_count ?? 0} ballots` };\n if (data.state !== 'tallied') return { dotColor: t.muted, label: String(data.state) };\n\n const total = data.turnout?.weight ?? 0;\n const tallies = data.tallies ?? {};\n const sorted = Object.entries(tallies).sort((a, b) => b[1] - a[1]);\n if (sorted.length === 0 || total === 0) return { dotColor: t.muted, label: 'no ballots yet' };\n\n const [topId, topW] = sorted[0]!;\n const pct = (topW / total) * 100;\n return {\n dotColor: '#10b981',\n label: `${topId} · ${pct.toFixed(0)}% of ${fmt(total)}`,\n };\n}\n\nfunction fmt(n: number): string {\n if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';\n if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';\n if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';\n return String(n);\n}\n\nexport const OcTallyBadge = memo(OcTallyBadgeImpl);\n"]}
1
+ {"version":3,"sources":["../src/types.ts","../src/use-tally.ts","../src/badge.tsx"],"names":["useState","useRef","useCallback","useEffect","useMemo","jsxs","jsx","memo"],"mappings":"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","file":"badge.js","sourcesContent":["// Wire types for the /api/tally response.\n// Deliberately a subset of what vote.ochk.io returns — we don't re-export\n// every field, just what the components render.\n\nexport interface TallyTurnout {\n voters: number;\n weight: number;\n}\n\nexport interface TallyResponse {\n poll_id: string;\n state: 'tallied' | 'awaiting_reveal';\n question?: string;\n creator?: string;\n deadline?: string;\n weight_mode?: string;\n mode?: 'public' | 'secret';\n snapshot_block?: number;\n turnout?: TallyTurnout;\n tallies?: Record<string, number>;\n ballot_count?: number;\n reveal_published?: boolean;\n error?: string;\n}\n\nexport interface Theme {\n bg: string;\n text: string;\n muted: string;\n border: string;\n accent: string;\n accentSoft: string;\n}\n\nexport const THEMES: Record<'light' | 'dark', Theme> = {\n light: {\n bg: '#ffffff',\n text: '#0a0a0a',\n muted: '#666666',\n border: '#e5e5e5',\n accent: '#f97316',\n accentSoft: '#fff4ec',\n },\n dark: {\n bg: '#0a0a0a',\n text: '#fafafa',\n muted: '#a0a0a0',\n border: '#262626',\n accent: '#fb923c',\n accentSoft: '#2a1a0a',\n },\n};\n\nexport const DEFAULT_API_BASE = 'https://vote.ochk.io';\n\nexport const POLL_ID_RE = /^[0-9a-f]{64}$/;\n","'use client';\n\n// useTally(pollId) — React hook that fetches + polls /api/tally.\n//\n// Defaults: fetch once on mount; refetch every 60s (matches the /api/tally\n// edge cache TTL so we never get stale data from our own server-side cache\n// but don't thrash the relay set either). Pass { refreshMs: 0 } for no\n// auto-refresh, or a custom interval.\n//\n// Consumers that want independent verification should NOT use this hook —\n// use @orangecheck/vote-core + @orangecheck/vote-cli with their own\n// node/bitcoin source. This hook trusts vote.ochk.io (or whatever baseUrl\n// you pass).\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\nimport { DEFAULT_API_BASE, POLL_ID_RE } from './types.js';\nimport type { TallyResponse } from './types.js';\n\nexport interface UseTallyOptions {\n baseUrl?: string;\n refreshMs?: number;\n /** If set, provides initial data (SSR hydration). */\n initialData?: TallyResponse;\n}\n\n/** A poll's own content: what it asks and when it closes. Not a count, so it\n * outlives a failed refresh of the same poll. */\nexport interface PollContent {\n question?: string;\n deadline?: string;\n}\n\nexport interface UseTallyState {\n data: TallyResponse | null;\n /** The last question and deadline read for this pollId, kept after a failed refresh. */\n poll: PollContent | null;\n error: Error | null;\n loading: boolean;\n refetch: () => void;\n}\n\n/** What the hook holds, always for one pollId: nothing of another poll is ever returned. */\ninterface Entry {\n pollId: string;\n data: TallyResponse | null;\n poll: PollContent | null;\n error: Error | null;\n loading: boolean;\n}\n\nfunction contentOf(d: TallyResponse | null | undefined): PollContent | null {\n if (!d || (d.question === undefined && d.deadline === undefined)) return null;\n return {\n ...(d.question !== undefined ? { question: d.question } : {}),\n ...(d.deadline !== undefined ? { deadline: d.deadline } : {}),\n };\n}\n\nexport function useTally(\n pollId: string,\n opts: UseTallyOptions = {}\n): UseTallyState {\n const baseUrl = opts.baseUrl ?? DEFAULT_API_BASE;\n const refreshMs = opts.refreshMs ?? 60_000;\n\n const [entry, setEntry] = useState<Entry>(() => ({\n pollId,\n data: opts.initialData ?? null,\n poll: contentOf(opts.initialData),\n error: null,\n loading: !opts.initialData,\n }));\n\n // Only the latest request writes: an answer that lands after a newer request\n // (an earlier refresh, or the poll before a pollId change) is dropped.\n const latest = useRef(0);\n\n const fetchOnce = useCallback(async () => {\n // The poll this fetch is for; a later pollId keeps an answer for this one off screen.\n const pid = pollId;\n const seq = ++latest.current;\n const kept = (prev: Entry) => (prev.pollId === pid ? prev.poll : null);\n if (!POLL_ID_RE.test(pid)) {\n setEntry({ pollId: pid, data: null, poll: null, error: new Error('pollId must be 64 hex chars'), loading: false });\n return;\n }\n try {\n const res = await fetch(`${baseUrl}/api/tally?poll=${pid}`);\n const body = (await res.json()) as TallyResponse;\n if (!res.ok) throw new Error(body.error ?? `http ${res.status}`);\n if (seq !== latest.current) return;\n setEntry((prev) => ({ pollId: pid, data: body, poll: contentOf(body) ?? kept(prev), error: null, loading: false }));\n } catch (err) {\n if (seq !== latest.current) return;\n // The last tally is no longer the answer: never show it beside the error.\n const error = err instanceof Error ? err : new Error(String(err));\n setEntry((prev) => ({ pollId: pid, data: null, poll: kept(prev), error, loading: false }));\n }\n }, [baseUrl, pollId]);\n\n useEffect(() => {\n void fetchOnce();\n if (refreshMs <= 0) return;\n const timer = setInterval(() => void fetchOnce(), refreshMs);\n return () => clearInterval(timer);\n }, [fetchOnce, refreshMs]);\n\n // Until this pollId's first answer, nothing of the last poll shows.\n const mine = entry.pollId === pollId;\n return {\n data: mine ? entry.data : null,\n poll: mine ? entry.poll : null,\n error: mine ? entry.error : null,\n loading: mine ? entry.loading : true,\n refetch: fetchOnce,\n };\n}\n","'use client';\n\n/**\n * <OcTallyBadge pollId=\"…\" />\n *\n * Compact inline pill. Shows:\n * - a colored dot (green=tallied, orange=awaiting_reveal, gray=loading, red=error)\n * - the top option + its percentage (tallied), or the state label (otherwise)\n * - a subtle link to vote.ochk.io/p/<id>\n *\n * Use on a feed row / profile card / sidebar. For the full poll card, use <OcPoll />.\n */\n\nimport type { CSSProperties } from 'react';\nimport { memo, useMemo } from 'react';\n\nimport { THEMES, type Theme, type TallyResponse } from './types.js';\nimport { useTally } from './use-tally.js';\n\nexport interface OcTallyBadgeProps {\n /** 64-hex poll id. */\n pollId: string;\n /** Theme. Default `light`. */\n theme?: 'light' | 'dark';\n /** Override /api/tally origin. Default `https://vote.ochk.io`. */\n baseUrl?: string;\n /** Auto-refresh interval in ms. Default 60000. Set to 0 to disable. */\n refreshMs?: number;\n /** Supply SSR-hydrated data to skip the initial fetch. */\n initialData?: TallyResponse;\n /** Extra class on the outer element. */\n className?: string;\n /** Inline style override. */\n style?: CSSProperties;\n /** Target for the link. Default `_blank`. */\n target?: '_blank' | '_self';\n}\n\nconst OcTallyBadgeImpl = ({\n pollId,\n theme = 'light',\n baseUrl,\n refreshMs,\n initialData,\n className,\n style,\n target = '_blank',\n}: OcTallyBadgeProps) => {\n const { data, error, loading } = useTally(pollId, {\n ...(baseUrl !== undefined ? { baseUrl } : {}),\n ...(refreshMs !== undefined ? { refreshMs } : {}),\n ...(initialData !== undefined ? { initialData } : {}),\n });\n const t = THEMES[theme];\n\n const { dotColor, label } = useMemo(() => renderState(data, error, loading, t), [data, error, loading, t]);\n\n const href = `${baseUrl ?? 'https://vote.ochk.io'}/p/${pollId}`;\n\n const outerStyle: CSSProperties = {\n display: 'inline-flex',\n alignItems: 'center',\n gap: 8,\n padding: '4px 10px',\n border: `1px solid ${t.border}`,\n borderRadius: 6,\n background: t.bg,\n color: t.text,\n fontFamily: 'ui-monospace, Menlo, monospace',\n fontSize: 12,\n textDecoration: 'none',\n lineHeight: 1.4,\n ...style,\n };\n\n return (\n <a\n href={href}\n target={target}\n rel={target === '_blank' ? 'noopener noreferrer' : undefined}\n className={className}\n style={outerStyle}\n data-slot=\"oc-tally-badge\"\n >\n <span\n aria-hidden\n style={{\n width: 8,\n height: 8,\n borderRadius: '50%',\n background: dotColor,\n flexShrink: 0,\n }}\n />\n <span style={{ fontWeight: 600 }}>oc vote</span>\n <span style={{ color: t.muted }}>·</span>\n <span>{label}</span>\n </a>\n );\n};\n\nfunction renderState(\n data: TallyResponse | null,\n error: Error | null,\n loading: boolean,\n t: Theme\n): { dotColor: string; label: string } {\n if (error) return { dotColor: '#ef4444', label: error.message };\n if (loading && !data) return { dotColor: t.muted, label: 'loading…' };\n if (!data) return { dotColor: t.muted, label: 'no data' };\n if (data.state === 'awaiting_reveal')\n return { dotColor: t.accent, label: `awaiting reveal · ${data.ballot_count ?? 0} ballots` };\n if (data.state !== 'tallied') return { dotColor: t.muted, label: String(data.state) };\n\n const total = data.turnout?.weight ?? 0;\n const tallies = data.tallies ?? {};\n const sorted = Object.entries(tallies).sort((a, b) => b[1] - a[1]);\n if (sorted.length === 0 || total === 0) return { dotColor: t.muted, label: 'no ballots yet' };\n\n const [topId, topW] = sorted[0]!;\n const pct = (topW / total) * 100;\n return {\n dotColor: '#10b981',\n label: `${topId} · ${pct.toFixed(0)}% of ${fmt(total)}`,\n };\n}\n\nfunction fmt(n: number): string {\n if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';\n if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';\n if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';\n return String(n);\n}\n\nexport const OcTallyBadge = memo(OcTallyBadgeImpl);\n"]}
package/dist/badge.mjs CHANGED
@@ -22,40 +22,58 @@ var THEMES = {
22
22
  };
23
23
  var DEFAULT_API_BASE = "https://vote.ochk.io";
24
24
  var POLL_ID_RE = /^[0-9a-f]{64}$/;
25
+ function contentOf(d) {
26
+ if (!d || d.question === void 0 && d.deadline === void 0) return null;
27
+ return {
28
+ ...d.question !== void 0 ? { question: d.question } : {},
29
+ ...d.deadline !== void 0 ? { deadline: d.deadline } : {}
30
+ };
31
+ }
25
32
  function useTally(pollId, opts = {}) {
26
33
  const baseUrl = opts.baseUrl ?? DEFAULT_API_BASE;
27
34
  const refreshMs = opts.refreshMs ?? 6e4;
28
- const [data, setData] = useState(opts.initialData ?? null);
29
- const [error, setError] = useState(null);
30
- const [loading, setLoading] = useState(!opts.initialData);
31
- const pollIdRef = useRef(pollId);
32
- pollIdRef.current = pollId;
35
+ const [entry, setEntry] = useState(() => ({
36
+ pollId,
37
+ data: opts.initialData ?? null,
38
+ poll: contentOf(opts.initialData),
39
+ error: null,
40
+ loading: !opts.initialData
41
+ }));
42
+ const latest = useRef(0);
33
43
  const fetchOnce = useCallback(async () => {
34
- const pid = pollIdRef.current;
44
+ const pid = pollId;
45
+ const seq = ++latest.current;
46
+ const kept = (prev) => prev.pollId === pid ? prev.poll : null;
35
47
  if (!POLL_ID_RE.test(pid)) {
36
- setError(new Error("pollId must be 64 hex chars"));
37
- setLoading(false);
48
+ setEntry({ pollId: pid, data: null, poll: null, error: new Error("pollId must be 64 hex chars"), loading: false });
38
49
  return;
39
50
  }
40
51
  try {
41
52
  const res = await fetch(`${baseUrl}/api/tally?poll=${pid}`);
42
53
  const body = await res.json();
43
54
  if (!res.ok) throw new Error(body.error ?? `http ${res.status}`);
44
- setData(body);
45
- setError(null);
55
+ if (seq !== latest.current) return;
56
+ setEntry((prev) => ({ pollId: pid, data: body, poll: contentOf(body) ?? kept(prev), error: null, loading: false }));
46
57
  } catch (err) {
47
- setError(err instanceof Error ? err : new Error(String(err)));
48
- } finally {
49
- setLoading(false);
58
+ if (seq !== latest.current) return;
59
+ const error = err instanceof Error ? err : new Error(String(err));
60
+ setEntry((prev) => ({ pollId: pid, data: null, poll: kept(prev), error, loading: false }));
50
61
  }
51
- }, [baseUrl]);
62
+ }, [baseUrl, pollId]);
52
63
  useEffect(() => {
53
64
  void fetchOnce();
54
65
  if (refreshMs <= 0) return;
55
66
  const timer = setInterval(() => void fetchOnce(), refreshMs);
56
67
  return () => clearInterval(timer);
57
68
  }, [fetchOnce, refreshMs]);
58
- return { data, error, loading, refetch: fetchOnce };
69
+ const mine = entry.pollId === pollId;
70
+ return {
71
+ data: mine ? entry.data : null,
72
+ poll: mine ? entry.poll : null,
73
+ error: mine ? entry.error : null,
74
+ loading: mine ? entry.loading : true,
75
+ refetch: fetchOnce
76
+ };
59
77
  }
60
78
  var OcTallyBadgeImpl = ({
61
79
  pollId,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts","../src/use-tally.ts","../src/badge.tsx"],"names":[],"mappings":"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","file":"badge.mjs","sourcesContent":["// Wire types for the /api/tally response.\n// Deliberately a subset of what vote.ochk.io returns — we don't re-export\n// every field, just what the components render.\n\nexport interface TallyTurnout {\n voters: number;\n weight: number;\n}\n\nexport interface TallyResponse {\n poll_id: string;\n state: 'tallied' | 'awaiting_reveal';\n question?: string;\n creator?: string;\n deadline?: string;\n weight_mode?: string;\n mode?: 'public' | 'secret';\n snapshot_block?: number;\n turnout?: TallyTurnout;\n tallies?: Record<string, number>;\n ballot_count?: number;\n reveal_published?: boolean;\n error?: string;\n}\n\nexport interface Theme {\n bg: string;\n text: string;\n muted: string;\n border: string;\n accent: string;\n accentSoft: string;\n}\n\nexport const THEMES: Record<'light' | 'dark', Theme> = {\n light: {\n bg: '#ffffff',\n text: '#0a0a0a',\n muted: '#666666',\n border: '#e5e5e5',\n accent: '#f97316',\n accentSoft: '#fff4ec',\n },\n dark: {\n bg: '#0a0a0a',\n text: '#fafafa',\n muted: '#a0a0a0',\n border: '#262626',\n accent: '#fb923c',\n accentSoft: '#2a1a0a',\n },\n};\n\nexport const DEFAULT_API_BASE = 'https://vote.ochk.io';\n\nexport const POLL_ID_RE = /^[0-9a-f]{64}$/;\n","'use client';\n\n// useTally(pollId) — React hook that fetches + polls /api/tally.\n//\n// Defaults: fetch once on mount; refetch every 60s (matches the /api/tally\n// edge cache TTL so we never get stale data from our own server-side cache\n// but don't thrash the relay set either). Pass { refreshMs: 0 } for no\n// auto-refresh, or a custom interval.\n//\n// Consumers that want independent verification should NOT use this hook —\n// use @orangecheck/vote-core + @orangecheck/vote-cli with their own\n// node/bitcoin source. This hook trusts vote.ochk.io (or whatever baseUrl\n// you pass).\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\nimport { DEFAULT_API_BASE, POLL_ID_RE } from './types.js';\nimport type { TallyResponse } from './types.js';\n\nexport interface UseTallyOptions {\n baseUrl?: string;\n refreshMs?: number;\n /** If set, provides initial data (SSR hydration). */\n initialData?: TallyResponse;\n}\n\nexport interface UseTallyState {\n data: TallyResponse | null;\n error: Error | null;\n loading: boolean;\n refetch: () => void;\n}\n\nexport function useTally(\n pollId: string,\n opts: UseTallyOptions = {}\n): UseTallyState {\n const baseUrl = opts.baseUrl ?? DEFAULT_API_BASE;\n const refreshMs = opts.refreshMs ?? 60_000;\n\n const [data, setData] = useState<TallyResponse | null>(opts.initialData ?? null);\n const [error, setError] = useState<Error | null>(null);\n const [loading, setLoading] = useState(!opts.initialData);\n const pollIdRef = useRef(pollId);\n pollIdRef.current = pollId;\n\n const fetchOnce = useCallback(async () => {\n const pid = pollIdRef.current;\n if (!POLL_ID_RE.test(pid)) {\n setError(new Error('pollId must be 64 hex chars'));\n setLoading(false);\n return;\n }\n try {\n const res = await fetch(`${baseUrl}/api/tally?poll=${pid}`);\n const body = (await res.json()) as TallyResponse;\n if (!res.ok) throw new Error(body.error ?? `http ${res.status}`);\n setData(body);\n setError(null);\n } catch (err) {\n setError(err instanceof Error ? err : new Error(String(err)));\n } finally {\n setLoading(false);\n }\n }, [baseUrl]);\n\n useEffect(() => {\n void fetchOnce();\n if (refreshMs <= 0) return;\n const timer = setInterval(() => void fetchOnce(), refreshMs);\n return () => clearInterval(timer);\n }, [fetchOnce, refreshMs]);\n\n return { data, error, loading, refetch: fetchOnce };\n}\n","'use client';\n\n/**\n * <OcTallyBadge pollId=\"…\" />\n *\n * Compact inline pill. Shows:\n * - a colored dot (green=tallied, orange=awaiting_reveal, gray=loading, red=error)\n * - the top option + its percentage (tallied), or the state label (otherwise)\n * - a subtle link to vote.ochk.io/p/<id>\n *\n * Use on a feed row / profile card / sidebar. For the full poll card, use <OcPoll />.\n */\n\nimport type { CSSProperties } from 'react';\nimport { memo, useMemo } from 'react';\n\nimport { THEMES, type Theme, type TallyResponse } from './types.js';\nimport { useTally } from './use-tally.js';\n\nexport interface OcTallyBadgeProps {\n /** 64-hex poll id. */\n pollId: string;\n /** Theme. Default `light`. */\n theme?: 'light' | 'dark';\n /** Override /api/tally origin. Default `https://vote.ochk.io`. */\n baseUrl?: string;\n /** Auto-refresh interval in ms. Default 60000. Set to 0 to disable. */\n refreshMs?: number;\n /** Supply SSR-hydrated data to skip the initial fetch. */\n initialData?: TallyResponse;\n /** Extra class on the outer element. */\n className?: string;\n /** Inline style override. */\n style?: CSSProperties;\n /** Target for the link. Default `_blank`. */\n target?: '_blank' | '_self';\n}\n\nconst OcTallyBadgeImpl = ({\n pollId,\n theme = 'light',\n baseUrl,\n refreshMs,\n initialData,\n className,\n style,\n target = '_blank',\n}: OcTallyBadgeProps) => {\n const { data, error, loading } = useTally(pollId, {\n ...(baseUrl !== undefined ? { baseUrl } : {}),\n ...(refreshMs !== undefined ? { refreshMs } : {}),\n ...(initialData !== undefined ? { initialData } : {}),\n });\n const t = THEMES[theme];\n\n const { dotColor, label } = useMemo(() => renderState(data, error, loading, t), [data, error, loading, t]);\n\n const href = `${baseUrl ?? 'https://vote.ochk.io'}/p/${pollId}`;\n\n const outerStyle: CSSProperties = {\n display: 'inline-flex',\n alignItems: 'center',\n gap: 8,\n padding: '4px 10px',\n border: `1px solid ${t.border}`,\n borderRadius: 6,\n background: t.bg,\n color: t.text,\n fontFamily: 'ui-monospace, Menlo, monospace',\n fontSize: 12,\n textDecoration: 'none',\n lineHeight: 1.4,\n ...style,\n };\n\n return (\n <a\n href={href}\n target={target}\n rel={target === '_blank' ? 'noopener noreferrer' : undefined}\n className={className}\n style={outerStyle}\n data-slot=\"oc-tally-badge\"\n >\n <span\n aria-hidden\n style={{\n width: 8,\n height: 8,\n borderRadius: '50%',\n background: dotColor,\n flexShrink: 0,\n }}\n />\n <span style={{ fontWeight: 600 }}>oc vote</span>\n <span style={{ color: t.muted }}>·</span>\n <span>{label}</span>\n </a>\n );\n};\n\nfunction renderState(\n data: TallyResponse | null,\n error: Error | null,\n loading: boolean,\n t: Theme\n): { dotColor: string; label: string } {\n if (error) return { dotColor: '#ef4444', label: error.message };\n if (loading && !data) return { dotColor: t.muted, label: 'loading…' };\n if (!data) return { dotColor: t.muted, label: 'no data' };\n if (data.state === 'awaiting_reveal')\n return { dotColor: t.accent, label: `awaiting reveal · ${data.ballot_count ?? 0} ballots` };\n if (data.state !== 'tallied') return { dotColor: t.muted, label: String(data.state) };\n\n const total = data.turnout?.weight ?? 0;\n const tallies = data.tallies ?? {};\n const sorted = Object.entries(tallies).sort((a, b) => b[1] - a[1]);\n if (sorted.length === 0 || total === 0) return { dotColor: t.muted, label: 'no ballots yet' };\n\n const [topId, topW] = sorted[0]!;\n const pct = (topW / total) * 100;\n return {\n dotColor: '#10b981',\n label: `${topId} · ${pct.toFixed(0)}% of ${fmt(total)}`,\n };\n}\n\nfunction fmt(n: number): string {\n if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';\n if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';\n if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';\n return String(n);\n}\n\nexport const OcTallyBadge = memo(OcTallyBadgeImpl);\n"]}
1
+ {"version":3,"sources":["../src/types.ts","../src/use-tally.ts","../src/badge.tsx"],"names":[],"mappings":"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","file":"badge.mjs","sourcesContent":["// Wire types for the /api/tally response.\n// Deliberately a subset of what vote.ochk.io returns — we don't re-export\n// every field, just what the components render.\n\nexport interface TallyTurnout {\n voters: number;\n weight: number;\n}\n\nexport interface TallyResponse {\n poll_id: string;\n state: 'tallied' | 'awaiting_reveal';\n question?: string;\n creator?: string;\n deadline?: string;\n weight_mode?: string;\n mode?: 'public' | 'secret';\n snapshot_block?: number;\n turnout?: TallyTurnout;\n tallies?: Record<string, number>;\n ballot_count?: number;\n reveal_published?: boolean;\n error?: string;\n}\n\nexport interface Theme {\n bg: string;\n text: string;\n muted: string;\n border: string;\n accent: string;\n accentSoft: string;\n}\n\nexport const THEMES: Record<'light' | 'dark', Theme> = {\n light: {\n bg: '#ffffff',\n text: '#0a0a0a',\n muted: '#666666',\n border: '#e5e5e5',\n accent: '#f97316',\n accentSoft: '#fff4ec',\n },\n dark: {\n bg: '#0a0a0a',\n text: '#fafafa',\n muted: '#a0a0a0',\n border: '#262626',\n accent: '#fb923c',\n accentSoft: '#2a1a0a',\n },\n};\n\nexport const DEFAULT_API_BASE = 'https://vote.ochk.io';\n\nexport const POLL_ID_RE = /^[0-9a-f]{64}$/;\n","'use client';\n\n// useTally(pollId) — React hook that fetches + polls /api/tally.\n//\n// Defaults: fetch once on mount; refetch every 60s (matches the /api/tally\n// edge cache TTL so we never get stale data from our own server-side cache\n// but don't thrash the relay set either). Pass { refreshMs: 0 } for no\n// auto-refresh, or a custom interval.\n//\n// Consumers that want independent verification should NOT use this hook —\n// use @orangecheck/vote-core + @orangecheck/vote-cli with their own\n// node/bitcoin source. This hook trusts vote.ochk.io (or whatever baseUrl\n// you pass).\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\nimport { DEFAULT_API_BASE, POLL_ID_RE } from './types.js';\nimport type { TallyResponse } from './types.js';\n\nexport interface UseTallyOptions {\n baseUrl?: string;\n refreshMs?: number;\n /** If set, provides initial data (SSR hydration). */\n initialData?: TallyResponse;\n}\n\n/** A poll's own content: what it asks and when it closes. Not a count, so it\n * outlives a failed refresh of the same poll. */\nexport interface PollContent {\n question?: string;\n deadline?: string;\n}\n\nexport interface UseTallyState {\n data: TallyResponse | null;\n /** The last question and deadline read for this pollId, kept after a failed refresh. */\n poll: PollContent | null;\n error: Error | null;\n loading: boolean;\n refetch: () => void;\n}\n\n/** What the hook holds, always for one pollId: nothing of another poll is ever returned. */\ninterface Entry {\n pollId: string;\n data: TallyResponse | null;\n poll: PollContent | null;\n error: Error | null;\n loading: boolean;\n}\n\nfunction contentOf(d: TallyResponse | null | undefined): PollContent | null {\n if (!d || (d.question === undefined && d.deadline === undefined)) return null;\n return {\n ...(d.question !== undefined ? { question: d.question } : {}),\n ...(d.deadline !== undefined ? { deadline: d.deadline } : {}),\n };\n}\n\nexport function useTally(\n pollId: string,\n opts: UseTallyOptions = {}\n): UseTallyState {\n const baseUrl = opts.baseUrl ?? DEFAULT_API_BASE;\n const refreshMs = opts.refreshMs ?? 60_000;\n\n const [entry, setEntry] = useState<Entry>(() => ({\n pollId,\n data: opts.initialData ?? null,\n poll: contentOf(opts.initialData),\n error: null,\n loading: !opts.initialData,\n }));\n\n // Only the latest request writes: an answer that lands after a newer request\n // (an earlier refresh, or the poll before a pollId change) is dropped.\n const latest = useRef(0);\n\n const fetchOnce = useCallback(async () => {\n // The poll this fetch is for; a later pollId keeps an answer for this one off screen.\n const pid = pollId;\n const seq = ++latest.current;\n const kept = (prev: Entry) => (prev.pollId === pid ? prev.poll : null);\n if (!POLL_ID_RE.test(pid)) {\n setEntry({ pollId: pid, data: null, poll: null, error: new Error('pollId must be 64 hex chars'), loading: false });\n return;\n }\n try {\n const res = await fetch(`${baseUrl}/api/tally?poll=${pid}`);\n const body = (await res.json()) as TallyResponse;\n if (!res.ok) throw new Error(body.error ?? `http ${res.status}`);\n if (seq !== latest.current) return;\n setEntry((prev) => ({ pollId: pid, data: body, poll: contentOf(body) ?? kept(prev), error: null, loading: false }));\n } catch (err) {\n if (seq !== latest.current) return;\n // The last tally is no longer the answer: never show it beside the error.\n const error = err instanceof Error ? err : new Error(String(err));\n setEntry((prev) => ({ pollId: pid, data: null, poll: kept(prev), error, loading: false }));\n }\n }, [baseUrl, pollId]);\n\n useEffect(() => {\n void fetchOnce();\n if (refreshMs <= 0) return;\n const timer = setInterval(() => void fetchOnce(), refreshMs);\n return () => clearInterval(timer);\n }, [fetchOnce, refreshMs]);\n\n // Until this pollId's first answer, nothing of the last poll shows.\n const mine = entry.pollId === pollId;\n return {\n data: mine ? entry.data : null,\n poll: mine ? entry.poll : null,\n error: mine ? entry.error : null,\n loading: mine ? entry.loading : true,\n refetch: fetchOnce,\n };\n}\n","'use client';\n\n/**\n * <OcTallyBadge pollId=\"…\" />\n *\n * Compact inline pill. Shows:\n * - a colored dot (green=tallied, orange=awaiting_reveal, gray=loading, red=error)\n * - the top option + its percentage (tallied), or the state label (otherwise)\n * - a subtle link to vote.ochk.io/p/<id>\n *\n * Use on a feed row / profile card / sidebar. For the full poll card, use <OcPoll />.\n */\n\nimport type { CSSProperties } from 'react';\nimport { memo, useMemo } from 'react';\n\nimport { THEMES, type Theme, type TallyResponse } from './types.js';\nimport { useTally } from './use-tally.js';\n\nexport interface OcTallyBadgeProps {\n /** 64-hex poll id. */\n pollId: string;\n /** Theme. Default `light`. */\n theme?: 'light' | 'dark';\n /** Override /api/tally origin. Default `https://vote.ochk.io`. */\n baseUrl?: string;\n /** Auto-refresh interval in ms. Default 60000. Set to 0 to disable. */\n refreshMs?: number;\n /** Supply SSR-hydrated data to skip the initial fetch. */\n initialData?: TallyResponse;\n /** Extra class on the outer element. */\n className?: string;\n /** Inline style override. */\n style?: CSSProperties;\n /** Target for the link. Default `_blank`. */\n target?: '_blank' | '_self';\n}\n\nconst OcTallyBadgeImpl = ({\n pollId,\n theme = 'light',\n baseUrl,\n refreshMs,\n initialData,\n className,\n style,\n target = '_blank',\n}: OcTallyBadgeProps) => {\n const { data, error, loading } = useTally(pollId, {\n ...(baseUrl !== undefined ? { baseUrl } : {}),\n ...(refreshMs !== undefined ? { refreshMs } : {}),\n ...(initialData !== undefined ? { initialData } : {}),\n });\n const t = THEMES[theme];\n\n const { dotColor, label } = useMemo(() => renderState(data, error, loading, t), [data, error, loading, t]);\n\n const href = `${baseUrl ?? 'https://vote.ochk.io'}/p/${pollId}`;\n\n const outerStyle: CSSProperties = {\n display: 'inline-flex',\n alignItems: 'center',\n gap: 8,\n padding: '4px 10px',\n border: `1px solid ${t.border}`,\n borderRadius: 6,\n background: t.bg,\n color: t.text,\n fontFamily: 'ui-monospace, Menlo, monospace',\n fontSize: 12,\n textDecoration: 'none',\n lineHeight: 1.4,\n ...style,\n };\n\n return (\n <a\n href={href}\n target={target}\n rel={target === '_blank' ? 'noopener noreferrer' : undefined}\n className={className}\n style={outerStyle}\n data-slot=\"oc-tally-badge\"\n >\n <span\n aria-hidden\n style={{\n width: 8,\n height: 8,\n borderRadius: '50%',\n background: dotColor,\n flexShrink: 0,\n }}\n />\n <span style={{ fontWeight: 600 }}>oc vote</span>\n <span style={{ color: t.muted }}>·</span>\n <span>{label}</span>\n </a>\n );\n};\n\nfunction renderState(\n data: TallyResponse | null,\n error: Error | null,\n loading: boolean,\n t: Theme\n): { dotColor: string; label: string } {\n if (error) return { dotColor: '#ef4444', label: error.message };\n if (loading && !data) return { dotColor: t.muted, label: 'loading…' };\n if (!data) return { dotColor: t.muted, label: 'no data' };\n if (data.state === 'awaiting_reveal')\n return { dotColor: t.accent, label: `awaiting reveal · ${data.ballot_count ?? 0} ballots` };\n if (data.state !== 'tallied') return { dotColor: t.muted, label: String(data.state) };\n\n const total = data.turnout?.weight ?? 0;\n const tallies = data.tallies ?? {};\n const sorted = Object.entries(tallies).sort((a, b) => b[1] - a[1]);\n if (sorted.length === 0 || total === 0) return { dotColor: t.muted, label: 'no ballots yet' };\n\n const [topId, topW] = sorted[0]!;\n const pct = (topW / total) * 100;\n return {\n dotColor: '#10b981',\n label: `${topId} · ${pct.toFixed(0)}% of ${fmt(total)}`,\n };\n}\n\nfunction fmt(n: number): string {\n if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';\n if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';\n if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';\n return String(n);\n}\n\nexport const OcTallyBadge = memo(OcTallyBadgeImpl);\n"]}
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { OcPoll, OcPollProps } from './poll.mjs';
2
2
  export { OcTallyBadge, OcTallyBadgeProps } from './badge.mjs';
3
- export { UseTallyOptions, UseTallyState, useTally } from './use-tally.mjs';
3
+ export { PollContent, UseTallyOptions, UseTallyState, useTally } from './use-tally.mjs';
4
4
  export { D as DEFAULT_API_BASE, P as POLL_ID_RE, T as THEMES, a as TallyResponse, b as TallyTurnout, c as Theme } from './types-Czxkdz7H.mjs';
5
5
  import 'react';
6
6
  import 'react/jsx-runtime';
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { OcPoll, OcPollProps } from './poll.js';
2
2
  export { OcTallyBadge, OcTallyBadgeProps } from './badge.js';
3
- export { UseTallyOptions, UseTallyState, useTally } from './use-tally.js';
3
+ export { PollContent, UseTallyOptions, UseTallyState, useTally } from './use-tally.js';
4
4
  export { D as DEFAULT_API_BASE, P as POLL_ID_RE, T as THEMES, a as TallyResponse, b as TallyTurnout, c as Theme } from './types-Czxkdz7H.js';
5
5
  import 'react';
6
6
  import 'react/jsx-runtime';
package/dist/index.js CHANGED
@@ -26,40 +26,58 @@ var THEMES = {
26
26
  };
27
27
  var DEFAULT_API_BASE = "https://vote.ochk.io";
28
28
  var POLL_ID_RE = /^[0-9a-f]{64}$/;
29
+ function contentOf(d) {
30
+ if (!d || d.question === void 0 && d.deadline === void 0) return null;
31
+ return {
32
+ ...d.question !== void 0 ? { question: d.question } : {},
33
+ ...d.deadline !== void 0 ? { deadline: d.deadline } : {}
34
+ };
35
+ }
29
36
  function useTally(pollId, opts = {}) {
30
37
  const baseUrl = opts.baseUrl ?? DEFAULT_API_BASE;
31
38
  const refreshMs = opts.refreshMs ?? 6e4;
32
- const [data, setData] = react.useState(opts.initialData ?? null);
33
- const [error, setError] = react.useState(null);
34
- const [loading, setLoading] = react.useState(!opts.initialData);
35
- const pollIdRef = react.useRef(pollId);
36
- pollIdRef.current = pollId;
39
+ const [entry, setEntry] = react.useState(() => ({
40
+ pollId,
41
+ data: opts.initialData ?? null,
42
+ poll: contentOf(opts.initialData),
43
+ error: null,
44
+ loading: !opts.initialData
45
+ }));
46
+ const latest = react.useRef(0);
37
47
  const fetchOnce = react.useCallback(async () => {
38
- const pid = pollIdRef.current;
48
+ const pid = pollId;
49
+ const seq = ++latest.current;
50
+ const kept = (prev) => prev.pollId === pid ? prev.poll : null;
39
51
  if (!POLL_ID_RE.test(pid)) {
40
- setError(new Error("pollId must be 64 hex chars"));
41
- setLoading(false);
52
+ setEntry({ pollId: pid, data: null, poll: null, error: new Error("pollId must be 64 hex chars"), loading: false });
42
53
  return;
43
54
  }
44
55
  try {
45
56
  const res = await fetch(`${baseUrl}/api/tally?poll=${pid}`);
46
57
  const body = await res.json();
47
58
  if (!res.ok) throw new Error(body.error ?? `http ${res.status}`);
48
- setData(body);
49
- setError(null);
59
+ if (seq !== latest.current) return;
60
+ setEntry((prev) => ({ pollId: pid, data: body, poll: contentOf(body) ?? kept(prev), error: null, loading: false }));
50
61
  } catch (err) {
51
- setError(err instanceof Error ? err : new Error(String(err)));
52
- } finally {
53
- setLoading(false);
62
+ if (seq !== latest.current) return;
63
+ const error = err instanceof Error ? err : new Error(String(err));
64
+ setEntry((prev) => ({ pollId: pid, data: null, poll: kept(prev), error, loading: false }));
54
65
  }
55
- }, [baseUrl]);
66
+ }, [baseUrl, pollId]);
56
67
  react.useEffect(() => {
57
68
  void fetchOnce();
58
69
  if (refreshMs <= 0) return;
59
70
  const timer = setInterval(() => void fetchOnce(), refreshMs);
60
71
  return () => clearInterval(timer);
61
72
  }, [fetchOnce, refreshMs]);
62
- return { data, error, loading, refetch: fetchOnce };
73
+ const mine = entry.pollId === pollId;
74
+ return {
75
+ data: mine ? entry.data : null,
76
+ poll: mine ? entry.poll : null,
77
+ error: mine ? entry.error : null,
78
+ loading: mine ? entry.loading : true,
79
+ refetch: fetchOnce
80
+ };
63
81
  }
64
82
  var OcPollImpl = ({
65
83
  pollId,
@@ -71,14 +89,15 @@ var OcPollImpl = ({
71
89
  style,
72
90
  hideCta = false
73
91
  }) => {
74
- const { data, error, loading } = useTally(pollId, {
92
+ const { data, poll, error, loading } = useTally(pollId, {
75
93
  ...baseUrl !== void 0 ? { baseUrl } : {},
76
94
  ...refreshMs !== void 0 ? { refreshMs } : {},
77
95
  ...initialData !== void 0 ? { initialData } : {}
78
96
  });
79
97
  const t = THEMES[theme];
80
98
  const resolvedBase = baseUrl ?? "https://vote.ochk.io";
81
- const isPast = data?.deadline ? Date.parse(data.deadline) < Date.now() : false;
99
+ const deadline = data?.deadline ?? poll?.deadline;
100
+ const isPast = deadline ? Date.parse(deadline) < Date.now() : false;
82
101
  const total = data?.turnout?.weight ?? 0;
83
102
  const sorted = react.useMemo(() => {
84
103
  if (!data?.tallies) return [];
@@ -111,7 +130,7 @@ var OcPollImpl = ({
111
130
  ] }),
112
131
  /* @__PURE__ */ jsxRuntime.jsx(StatusPill, { data, error, loading, theme: t })
113
132
  ] }),
114
- /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 18, fontWeight: 700, lineHeight: 1.25, marginBottom: 10 }, children: data?.question ?? (loading ? "loading poll\u2026" : error ? error.message : pollId.slice(0, 16) + "\u2026") }),
133
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 18, fontWeight: 700, lineHeight: 1.25, marginBottom: 10 }, children: data?.question ?? poll?.question ?? (loading ? "loading poll\u2026" : pollId.slice(0, 16) + "\u2026") }),
115
134
  data?.state === "awaiting_reveal" ? /* @__PURE__ */ jsxRuntime.jsxs(
116
135
  "div",
117
136
  {
@@ -198,13 +217,12 @@ var OcPollImpl = ({
198
217
  },
199
218
  children: [
200
219
  /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
201
- data?.turnout?.voters ?? 0,
202
- " voters",
203
- data?.deadline && " \xB7 ",
204
- data?.deadline && (isPast ? "closed" : "closes"),
205
- data?.deadline && " " + data.deadline.slice(0, 16).replace("T", " ")
220
+ data && `${data.turnout?.voters ?? 0} voters`,
221
+ data && deadline && " \xB7 ",
222
+ deadline && (isPast ? "closed" : "closes"),
223
+ deadline && " " + deadline.slice(0, 16).replace("T", " ")
206
224
  ] }),
207
- !hideCta && !isPast && /* @__PURE__ */ jsxRuntime.jsx(
225
+ !hideCta && (data || deadline) && !isPast && /* @__PURE__ */ jsxRuntime.jsx(
208
226
  "a",
209
227
  {
210
228
  href: `${resolvedBase}/p/${pollId}`,
@@ -253,6 +271,7 @@ function StatusPill({
253
271
  return /* @__PURE__ */ jsxRuntime.jsx(
254
272
  "span",
255
273
  {
274
+ ...error ? { title: error.message, "aria-label": `error: ${error.message}` } : {},
256
275
  style: {
257
276
  padding: "2px 8px",
258
277
  border: `1px solid ${color}`,