@volter/twin-stripe 0.1.0

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.
@@ -0,0 +1,691 @@
1
+ // Stripe UI mirror — React/TSX client (Bun-bundled). A Stripe-dashboard-style view
2
+ // in the shared world UI-mirror design language: top bar + left nav + list/detail
3
+ // split. Renders by consuming the twin's own REST API (/v1/<collection>).
4
+ //
5
+ // The pure format/resolution helpers live in ../src/stripe-mirror-ui.ts so they can
6
+ // be unit-tested AND shared here (Bun tree-shakes the server-only exports out of
7
+ // this browser bundle).
8
+ import React, { useEffect, useMemo, useState } from 'react';
9
+ import { createRoot } from 'react-dom/client';
10
+ import {
11
+ formatStripeAmount, formatRecurring, statusTone, flattenStripeValue,
12
+ resolveCrossRefs, referenceCollection, formatPaymentMethod, productImageUrls,
13
+ formatPaymentError, formatBalanceSummary, formatAccountFlags, accountCurrentlyDue,
14
+ type StripeRow, type FlatLine, type CrossRefs,
15
+ } from '../src/stripe-mirror-ui.ts';
16
+
17
+ export type Section = { key: string; label: string; title: (r: StripeRow) => string; subtitle: (r: StripeRow) => string };
18
+
19
+ export const SECTIONS: Section[] = [
20
+ { key: 'customers', label: 'Customers', title: (r) => r.name || r.email || r.id, subtitle: (r) => r.email || r.id },
21
+ { key: 'payment_intents', label: 'Payments', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `${r.status} · ${r.id}` },
22
+ { key: 'setup_intents', label: 'Setup Intents', title: (r) => r.id, subtitle: (r) => `${r.status ?? ''} · ${r.usage ?? ''}` },
23
+ { key: 'subscriptions', label: 'Subscriptions', title: (r) => r.id, subtitle: (r) => `${r.status} · ${r.customer ?? ''}` },
24
+ { key: 'invoices', label: 'Invoices', title: (r) => `${formatStripeAmount(r.total ?? r.amount_due, r.currency)}`, subtitle: (r) => `${r.status} · ${r.id}` },
25
+ { key: 'invoiceitems', label: 'Invoice Items', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `${r.customer ?? ''} · ${r.id}` },
26
+ { key: 'credit_notes', label: 'Credit Notes', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `${r.status ?? ''} · ${r.invoice ?? r.id}` },
27
+ { key: 'products', label: 'Products', title: (r) => r.name || r.id, subtitle: (r) => r.id },
28
+ { key: 'prices', label: 'Prices', title: (r) => `${formatStripeAmount(r.unit_amount, r.currency)}${formatRecurring(r.recurring) ? ` ${formatRecurring(r.recurring)}` : ''}`, subtitle: (r) => r.product ?? r.id },
29
+ // --- billing & commerce (coupons, promotion codes, payment links, quotes) ---
30
+ { key: 'coupons', label: 'Coupons', title: (r) => r.name || (r.percent_off != null ? `${r.percent_off}% off` : `${formatStripeAmount(r.amount_off, r.currency)} off`), subtitle: (r) => `${r.duration ?? ''} · ${r.id}` },
31
+ { key: 'promotion_codes', label: 'Promotion Codes', title: (r) => r.code || r.id, subtitle: (r) => `${r.active ? 'active' : 'inactive'} · ${r.coupon ?? ''}` },
32
+ { key: 'payment_links', label: 'Payment Links', title: (r) => r.url || r.id, subtitle: (r) => `${r.active ? 'active' : 'inactive'} · ${r.id}` },
33
+ { key: 'quotes', label: 'Quotes', title: (r) => formatStripeAmount(r.amount_total, r.currency), subtitle: (r) => `${r.status ?? ''} · ${r.customer ?? r.id}` },
34
+ { key: 'charges', label: 'Charges', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `${r.status} · ${r.id}` },
35
+ { key: 'refunds', label: 'Refunds', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `${r.status ?? ''} · ${r.charge ?? r.id}` },
36
+ { key: 'payment_methods', label: 'Payment Methods', title: (r) => formatPaymentMethod(r), subtitle: (r) => `${r.type ?? ''} · ${r.id}` },
37
+ { key: 'disputes', label: 'Disputes', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `${r.reason ?? ''} · ${r.status ?? ''}` },
38
+ { key: 'payouts', label: 'Payouts', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `${r.status ?? ''} · ${r.id}` },
39
+ { key: 'balance_transactions', label: 'Balance Transactions', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `${r.type ?? ''} · ${r.id}` },
40
+ { key: 'balance', label: 'Balance', title: (r) => r.id, subtitle: (r) => 'account balance summary' },
41
+ // --- Connect (connected accounts + transfers) ---
42
+ { key: 'accounts', label: 'Connected Accounts', title: (r) => r.email || r.id, subtitle: (r) => `${r.type ?? 'account'} · ${r.country ?? ''}` },
43
+ { key: 'transfers', label: 'Transfers', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `→ ${r.destination ?? '?'}` },
44
+ // --- customer billing (tax IDs + credit-balance ledger) ---
45
+ { key: 'tax_ids', label: 'Customer Tax IDs', title: (r) => `${r.value ?? r.id}`, subtitle: (r) => `${r.type ?? ''} · ${r.customer ?? ''}` },
46
+ { key: 'customer_balance_transactions', label: 'Customer Balance', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `bal ${formatStripeAmount(r.ending_balance, r.currency)} · ${r.customer ?? ''}` },
47
+ // --- Stripe Tax (tax rates + calculations) ---
48
+ { key: 'tax_rates', label: 'Tax Rates', title: (r) => `${r.display_name ?? 'Tax'} · ${r.percentage ?? 0}%`, subtitle: (r) => `${r.inclusive ? 'inclusive' : 'exclusive'} · ${r.jurisdiction ?? r.id}` },
49
+ { key: 'tax/calculations', label: 'Tax Calculations', title: (r) => formatStripeAmount(r.amount_total, r.currency), subtitle: (r) => `tax ${formatStripeAmount(r.tax_amount_exclusive, r.currency)} · ${r.id}` },
50
+ { key: 'events', label: 'Events', title: (r) => r.type || r.id, subtitle: (r) => r.id },
51
+ { key: 'webhook_endpoints', label: 'Webhook Endpoints', title: (r) => r.url || r.id, subtitle: (r) => `${r.status ?? ''} · ${r.id}` },
52
+ // --- Radar (fraud prevention): reviews, value lists, custom rules ---
53
+ { key: 'reviews', label: 'Radar Reviews', title: (r) => r.charge || r.payment_intent || r.id, subtitle: (r) => `${r.open ? 'open' : (r.closed_reason ?? 'closed')} · ${r.id}` },
54
+ { key: 'radar/value_lists', label: 'Radar Value Lists', title: (r) => r.name || r.alias || r.id, subtitle: (r) => `${r.item_type ?? ''} · ${r.alias ?? r.id}` },
55
+ { key: 'radar/rules', label: 'Radar Rules', title: (r) => r.predicate || r.id, subtitle: (r) => `${r.action ?? ''} · ${r.id}` },
56
+ // --- Terminal (in-person) + Issuing (card issuing) screens ---
57
+ { key: 'terminal/readers', label: 'Terminal Readers', title: (r) => r.label || r.id, subtitle: (r) => `${r.status ?? ''} · ${r.device_type ?? ''}` },
58
+ { key: 'terminal/locations', label: 'Terminal Locations', title: (r) => r.display_name || r.id, subtitle: (r) => `${(r.address as any)?.city ?? ''} · ${r.id}` },
59
+ { key: 'issuing/cards', label: 'Issuing Cards', title: (r) => `•••• ${r.last4 ?? ''}`, subtitle: (r) => `${r.status ?? ''} · ${r.type ?? ''}` },
60
+ { key: 'issuing/cardholders', label: 'Issuing Cardholders', title: (r) => r.name || r.id, subtitle: (r) => `${r.type ?? ''} · ${r.status ?? ''}` },
61
+ // --- Reporting (financial reports / Sigma report runs) ---
62
+ { key: 'reporting/report_runs', label: 'Reports', title: (r) => r.report_type || r.id, subtitle: (r) => `${r.status ?? ''} · ${r.id}` },
63
+ ];
64
+
65
+ // `balance` is the one section backed by a synthesized summary (GET /v1/balance), not a
66
+ // list collection; the fetch loop special-cases it (no `data` array).
67
+ export const BALANCE_KEY = 'balance';
68
+ const SECTION_BY_KEY: Record<string, Section> = Object.fromEntries(SECTIONS.map((s) => [s.key, s]));
69
+
70
+ export function StatusPill({ value }: { value: any }) {
71
+ const v = String(value ?? '');
72
+ return <span className={`pill ${statusTone(v)}`}>{v.replace(/_/g, ' ') || '—'}</span>;
73
+ }
74
+
75
+ /** A clickable id reference that jumps to its collection + selects the row. */
76
+ export function RefChip({ collection, id, label, onJump }: { collection: string; id: string; label: string; onJump: (c: string, id: string) => void }) {
77
+ return (
78
+ <button className="ref-chip" title={id} onClick={() => onJump(collection, id)}>
79
+ <span className="ref-label">{label}</span>
80
+ <span className="ref-id mono">{id}</span>
81
+ </button>
82
+ );
83
+ }
84
+
85
+ /** Render the flattened nested-field lines (replaces the old "[object Object]"). */
86
+ export function NestedLines({ lines, onJump }: { lines: FlatLine[]; onJump: (c: string, id: string) => void }) {
87
+ return (
88
+ <div className="nested">
89
+ {lines.map((ln, i) => (
90
+ <div className="nested-line" key={i} style={{ paddingLeft: `${ln.depth * 14}px` }}>
91
+ {ln.label ? <span className="nested-label">{ln.label}</span> : null}
92
+ {ln.ref ? (
93
+ <button className="ref-inline mono" title={ln.value} onClick={() => onJump(referenceCollection(ln.label) ?? '', ln.value)}>{ln.value}</button>
94
+ ) : ln.value ? (
95
+ <span className="nested-value">{ln.value}</span>
96
+ ) : null}
97
+ </div>
98
+ ))}
99
+ </div>
100
+ );
101
+ }
102
+
103
+ /**
104
+ * The test-card decline state (payment_intent / charge `last_payment_error`) rendered as
105
+ * a prominent error banner — this is the vendor-faithful decline reason, not just another
106
+ * nested field, so the mirror surfaces it like a real Stripe payment-failed screen.
107
+ */
108
+ export function PaymentErrorBanner({ error }: { error: any }) {
109
+ const text = formatPaymentError(error);
110
+ if (!text) return null;
111
+ return (
112
+ <div className="pay-error" role="alert">
113
+ <span className="pay-error-label">Payment error</span>
114
+ <span className="pay-error-text">{text}</span>
115
+ </div>
116
+ );
117
+ }
118
+
119
+ /** The synthesized account balance summary (available / pending per currency). */
120
+ export function BalanceSummary({ row }: { row: StripeRow }) {
121
+ const buckets = formatBalanceSummary(row);
122
+ if (buckets.length === 0) return null;
123
+ return (
124
+ <div className="balance-summary">
125
+ {buckets.map((b) => (
126
+ <div className={`balance-bucket ${b.bucket}`} key={b.bucket}>
127
+ <span className="balance-bucket-label">{b.bucket}</span>
128
+ <span className="balance-bucket-amount">{b.text}</span>
129
+ </div>
130
+ ))}
131
+ </div>
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Connect connected-account panel: the three enablement flags (charges/payouts/details)
137
+ * a real Stripe Connect dashboard shows up front, plus the outstanding onboarding
138
+ * requirements (`currently_due`). Surfaces the account's readiness like Stripe does,
139
+ * instead of leaving these buried in the generic field grid.
140
+ */
141
+ export function ConnectAccountPanel({ row }: { row: StripeRow }) {
142
+ const flags = formatAccountFlags(row);
143
+ const due = accountCurrentlyDue(row);
144
+ if (flags.length === 0) return null;
145
+ return (
146
+ <div className="connect-panel">
147
+ <div className="connect-flags">
148
+ {flags.map((f) => (
149
+ <div className={`connect-flag ${f.enabled ? 'ok' : 'warn'}`} key={f.key}>
150
+ <span className="connect-flag-label">{f.label}</span>
151
+ <span className="connect-flag-state">{f.enabled ? 'enabled' : 'disabled'}</span>
152
+ </div>
153
+ ))}
154
+ </div>
155
+ {due.length > 0 && (
156
+ <div className="connect-requirements" role="status">
157
+ <span className="connect-req-label">Requirements currently due</span>
158
+ <ul className="connect-req-list">
159
+ {due.map((d) => <li className="connect-req-item mono" key={d}>{d}</li>)}
160
+ </ul>
161
+ </div>
162
+ )}
163
+ </div>
164
+ );
165
+ }
166
+
167
+ /**
168
+ * Resource action bar — the lifecycle verbs a real Stripe dashboard exposes on a detail
169
+ * (Capture / Cancel an authorized PaymentIntent, etc.). Each button POSTs the real twin
170
+ * sub-action endpoint (e.g. /v1/payment_intents/:id/capture); the polling loop then
171
+ * reflects the new status. Kept faithful: a Capture only shows for a requires_capture PI,
172
+ * a Cancel only for a still-cancelable one — matching when Stripe actually allows them.
173
+ */
174
+ export function detailActions(collection: string, row: StripeRow): Array<{ verb: string; label: string }> {
175
+ const actions: Array<{ verb: string; label: string }> = [];
176
+ if (collection === 'payment_intents') {
177
+ if (row.status === 'requires_capture') actions.push({ verb: 'capture', label: 'Capture' });
178
+ if (row.status && !['succeeded', 'canceled'].includes(String(row.status))) actions.push({ verb: 'cancel', label: 'Cancel' });
179
+ }
180
+ return actions;
181
+ }
182
+
183
+ export function DetailActions({ collection, row, onDone }: { collection: string; row: StripeRow; onDone: () => void }) {
184
+ const [busy, setBusy] = useState<string | null>(null);
185
+ const [err, setErr] = useState<string | null>(null);
186
+ const actions = detailActions(collection, row);
187
+ if (actions.length === 0) return null;
188
+ const run = async (verb: string) => {
189
+ setBusy(verb); setErr(null);
190
+ try {
191
+ const res = await fetch(`/v1/${collection}/${row.id}/${verb}`, { method: 'POST' }).then((r) => r.json());
192
+ if (res.error) throw new Error(res.error.message);
193
+ onDone();
194
+ } catch (e: any) {
195
+ setErr(String(e.message ?? e));
196
+ } finally {
197
+ setBusy(null);
198
+ }
199
+ };
200
+ return (
201
+ <div className="detail-actions">
202
+ {actions.map((a) => (
203
+ <button key={a.verb} className={`action-btn action-${a.verb}`} disabled={busy !== null} onClick={() => run(a.verb)}>
204
+ {busy === a.verb ? `${a.label}…` : a.label}
205
+ </button>
206
+ ))}
207
+ {err && <span className="action-error" role="alert">{err}</span>}
208
+ </div>
209
+ );
210
+ }
211
+
212
+ export function Detail({ collection, row, refs, onJump, onAction }: { collection: string; row?: StripeRow; refs: CrossRefs; onJump: (c: string, id: string) => void; onAction?: () => void }) {
213
+ if (!row) return <aside className="detail empty">Select a row to inspect it.</aside>;
214
+ const ccy = typeof row.currency === 'string' ? row.currency : undefined;
215
+ // Split scalar fields (a plain key/value grid) from nested objects/arrays (recursive).
216
+ // `last_payment_error` is lifted out into a prominent banner (below) rather than buried
217
+ // in the generic nested list, so the decline reason reads like a real payment screen.
218
+ const scalars: Array<[string, any]> = [];
219
+ const nested: Array<[string, any]> = [];
220
+ for (const [k, v] of Object.entries(row)) {
221
+ if (v === null || v === undefined || k === 'object' || k === 'last_payment_error') continue;
222
+ (typeof v === 'object' ? nested : scalars).push([k, v]);
223
+ }
224
+ return (
225
+ <aside className="detail">
226
+ <div className="detail-head">
227
+ <span className="mono">{row.id}</span>
228
+ {row.status ? <StatusPill value={row.status} /> : row.active !== undefined ? <StatusPill value={String(row.active)} /> : null}
229
+ </div>
230
+
231
+ <DetailActions collection={collection} row={row} onDone={() => onAction?.()} />
232
+ <PaymentErrorBanner error={row.last_payment_error} />
233
+ {collection === BALANCE_KEY ? <BalanceSummary row={row} /> : null}
234
+ {collection === 'accounts' ? <ConnectAccountPanel row={row} /> : null}
235
+
236
+ <dl>
237
+ {scalars.map(([k, v]) => {
238
+ const ref = typeof v === 'string' && referenceCollection(k);
239
+ const isMoney = k === 'amount' || k === 'unit_amount' || /amount|total|subtotal|balance/.test(k);
240
+ return (
241
+ <div className="kv" key={k}>
242
+ <dt>{k}</dt>
243
+ <dd className={k === 'id' || String(k).endsWith('_id') ? 'mono' : ''}>
244
+ {ref ? (
245
+ <button className="ref-inline mono" onClick={() => onJump(ref, v)}>{v}</button>
246
+ ) : isMoney && typeof v === 'number' ? formatStripeAmount(v, ccy) : String(v)}
247
+ </dd>
248
+ </div>
249
+ );
250
+ })}
251
+ </dl>
252
+
253
+ {nested.map(([k, v]) => {
254
+ const thumbs = collection === 'products' && k === 'images' ? productImageUrls(v) : [];
255
+ return (
256
+ <div className="detail-block" key={k}>
257
+ <h4>{k}</h4>
258
+ {thumbs.length > 0 ? (
259
+ <div className="thumbs">
260
+ {thumbs.map((src, i) => (
261
+ <a className="thumb" key={i} href={src} target="_blank" rel="noreferrer" title={src}>
262
+ <img src={src} alt={`${row.name ?? row.id} image ${i + 1}`} loading="lazy" />
263
+ </a>
264
+ ))}
265
+ </div>
266
+ ) : (
267
+ <NestedLines lines={flattenStripeValue(v, { currency: ccy })} onJump={onJump} />
268
+ )}
269
+ </div>
270
+ );
271
+ })}
272
+
273
+ {(refs.outgoing.length > 0 || refs.incoming.length > 0) && (
274
+ <div className="detail-block refs">
275
+ <h4>Related</h4>
276
+ {refs.outgoing.length > 0 && (
277
+ <div className="ref-group">
278
+ <span className="ref-group-label">References</span>
279
+ {refs.outgoing.map((r, i) => <RefChip key={`o${i}`} {...r} onJump={onJump} />)}
280
+ </div>
281
+ )}
282
+ {refs.incoming.length > 0 && (
283
+ <div className="ref-group">
284
+ <span className="ref-group-label">Referenced by</span>
285
+ {refs.incoming.map((r, i) => <RefChip key={`i${i}`} {...r} onJump={onJump} />)}
286
+ </div>
287
+ )}
288
+ </div>
289
+ )}
290
+ </aside>
291
+ );
292
+ }
293
+
294
+ /** The left navigation: one item per collection (section), each with a live count. */
295
+ export function SideNav({ sections, counts, activeKey, onSelect, onHome, onSettings }: {
296
+ sections: Section[];
297
+ counts: Record<string, number>;
298
+ activeKey: string;
299
+ onSelect: (key: string) => void;
300
+ onHome?: () => void;
301
+ onSettings?: () => void;
302
+ }) {
303
+ return (
304
+ <nav className="side">
305
+ <button className={`nav-home ${activeKey === '__home' ? 'active' : ''}`} onClick={onHome}>
306
+ <span>Home</span>
307
+ </button>
308
+ {sections.map((s) => (
309
+ <button key={s.key} className={s.key === activeKey ? 'active' : ''} onClick={() => onSelect(s.key)}>
310
+ <span>{s.label}</span>
311
+ <span className="nav-count">{counts[s.key] ?? 0}</span>
312
+ </button>
313
+ ))}
314
+ <button className={`nav-settings ${activeKey === '__settings' ? 'active' : ''}`} onClick={onSettings}>
315
+ <span>Settings</span>
316
+ </button>
317
+ </nav>
318
+ );
319
+ }
320
+
321
+ // ── Home / overview screen ──────────────────────────────────────────────────
322
+ // The real Stripe Dashboard Home leads with top-line metrics. We compute them from the
323
+ // SAME twin state the rest of the dashboard renders (no separate data source): gross
324
+ // volume from succeeded payments, the available balance, and live counts. Data-coupled:
325
+ // every number here is derived from the fetched /v1/<collection> projections.
326
+ export function computeHomeMetrics(data: Record<string, StripeRow[]>): {
327
+ grossVolume: number; currency: string; succeededPayments: number;
328
+ activeSubscriptions: number; customers: number; openInvoices: number; availableText: string;
329
+ } {
330
+ const pis = data.payment_intents ?? [];
331
+ const succeeded = pis.filter((p) => p.status === 'succeeded');
332
+ const currency = (succeeded[0]?.currency as string) || (pis[0]?.currency as string) || 'usd';
333
+ const grossVolume = succeeded.reduce((s, p) => s + (Number(p.amount_received ?? p.amount) || 0), 0);
334
+ const subs = data.subscriptions ?? [];
335
+ const invoices = data.invoices ?? [];
336
+ const balanceRow = (data.balance ?? [])[0];
337
+ const buckets = formatBalanceSummary(balanceRow);
338
+ const availableText = buckets.find((b) => b.bucket === 'available')?.text ?? formatStripeAmount(0, currency);
339
+ return {
340
+ grossVolume, currency,
341
+ succeededPayments: succeeded.length,
342
+ activeSubscriptions: subs.filter((s) => s.status === 'active' || s.status === 'trialing').length,
343
+ customers: (data.customers ?? []).length,
344
+ openInvoices: invoices.filter((i) => i.status === 'open').length,
345
+ availableText,
346
+ };
347
+ }
348
+
349
+ export function HomeScreen({ data, onJump }: { data: Record<string, StripeRow[]>; onJump: (c: string, id: string) => void }) {
350
+ const m = computeHomeMetrics(data);
351
+ const cards = [
352
+ { key: 'gross', label: 'Gross volume', value: formatStripeAmount(m.grossVolume, m.currency) },
353
+ { key: 'available', label: 'Available balance', value: m.availableText },
354
+ { key: 'payments', label: 'Successful payments', value: String(m.succeededPayments) },
355
+ { key: 'subs', label: 'Active subscriptions', value: String(m.activeSubscriptions) },
356
+ { key: 'customers', label: 'Customers', value: String(m.customers) },
357
+ { key: 'open_invoices', label: 'Open invoices', value: String(m.openInvoices) },
358
+ ];
359
+ const recent = (data.payment_intents ?? []).slice(0, 5);
360
+ return (
361
+ <div className="home-screen">
362
+ <div className="home-metrics">
363
+ {cards.map((c) => (
364
+ <div className="metric-card" key={c.key} data-metric={c.key}>
365
+ <span className="metric-label">{c.label}</span>
366
+ <span className="metric-value">{c.value}</span>
367
+ </div>
368
+ ))}
369
+ </div>
370
+ <div className="home-recent">
371
+ <h3>Recent payments</h3>
372
+ {recent.length === 0 && <div className="empty">No payments yet.</div>}
373
+ {recent.map((p) => (
374
+ <button className="home-recent-row" key={p.id} onClick={() => onJump('payment_intents', p.id)}>
375
+ <span className="row-title">{formatStripeAmount(p.amount, p.currency)}</span>
376
+ <StatusPill value={p.status} />
377
+ <span className="row-sub mono">{p.id}</span>
378
+ </button>
379
+ ))}
380
+ </div>
381
+ </div>
382
+ );
383
+ }
384
+
385
+ // ── Global search / command bar ─────────────────────────────────────────────
386
+ // Stripe's Cmd-K command bar searches ACROSS every object type. We search the SAME cached
387
+ // collection data (id / email / name / status / amount substring) and return ranked,
388
+ // cross-collection hits — data-coupled to the twin's real state.
389
+ export type SearchHit = { collection: string; id: string; label: string; sub: string };
390
+ export function globalSearch(data: Record<string, StripeRow[]>, query: string, limit = 20): SearchHit[] {
391
+ const q = query.trim().toLowerCase();
392
+ if (!q) return [];
393
+ const hits: SearchHit[] = [];
394
+ for (const section of SECTIONS) {
395
+ for (const row of data[section.key] ?? []) {
396
+ const haystack = [row.id, row.email, row.name, row.status, section.title(row), section.subtitle(row)]
397
+ .map((f) => String(f ?? '').toLowerCase());
398
+ if (haystack.some((f) => f.includes(q))) {
399
+ hits.push({ collection: section.key, id: row.id, label: section.title(row), sub: `${section.label} · ${row.id}` });
400
+ if (hits.length >= limit) return hits;
401
+ }
402
+ }
403
+ }
404
+ return hits;
405
+ }
406
+
407
+ export function CommandBar({ data, onJump, onClose }: { data: Record<string, StripeRow[]>; onJump: (c: string, id: string) => void; onClose: () => void }) {
408
+ const [q, setQ] = useState('');
409
+ const hits = useMemo(() => globalSearch(data, q), [data, q]);
410
+ return (
411
+ <div className="cmdk-overlay" role="dialog" aria-label="Search" onClick={onClose}>
412
+ <div className="cmdk" onClick={(e) => e.stopPropagation()}>
413
+ <input className="cmdk-input" autoFocus placeholder="Search customers, payments, invoices…" value={q} onChange={(e) => setQ(e.target.value)} />
414
+ <div className="cmdk-results">
415
+ {q && hits.length === 0 && <div className="cmdk-empty">No results for “{q}”.</div>}
416
+ {hits.map((h) => (
417
+ <button className="cmdk-hit" key={`${h.collection}:${h.id}`} onClick={() => { onJump(h.collection, h.id); onClose(); }}>
418
+ <span className="cmdk-hit-label">{h.label}</span>
419
+ <span className="cmdk-hit-sub mono">{h.sub}</span>
420
+ </button>
421
+ ))}
422
+ </div>
423
+ </div>
424
+ </div>
425
+ );
426
+ }
427
+
428
+ // ── Settings screen (account / API keys / webhooks) ─────────────────────────
429
+ // Backed by real twin state: the account (GET /v1/account, incl. the payout schedule) and
430
+ // the registered webhook endpoints (GET /v1/webhook_endpoints). The API keys block shows
431
+ // the twin's test-mode keys (it is a test-mode twin) — labeled as such, not fabricated live keys.
432
+ export function payoutScheduleText(account: StripeRow | undefined): string {
433
+ const sched = (account?.settings as any)?.payouts?.schedule;
434
+ if (!sched || typeof sched !== 'object') return 'default';
435
+ const interval = String(sched.interval ?? 'daily');
436
+ if (interval === 'weekly' && sched.weekly_anchor) return `weekly (on ${sched.weekly_anchor})`;
437
+ if (interval === 'monthly' && sched.monthly_anchor != null) return `monthly (on day ${sched.monthly_anchor})`;
438
+ return interval;
439
+ }
440
+
441
+ export function SettingsScreen({ account, webhooks }: { account?: StripeRow; webhooks: StripeRow[] }) {
442
+ return (
443
+ <div className="settings-screen">
444
+ <section className="settings-block" data-settings="account">
445
+ <h3>Account</h3>
446
+ <dl>
447
+ <div className="kv"><dt>Account ID</dt><dd className="mono">{account?.id ?? '—'}</dd></div>
448
+ <div className="kv"><dt>Country</dt><dd>{(account?.country as string) ?? '—'}</dd></div>
449
+ <div className="kv"><dt>Default currency</dt><dd>{String((account?.default_currency as string) ?? '—').toUpperCase()}</dd></div>
450
+ <div className="kv"><dt>Payout schedule</dt><dd className="settings-payout-schedule">{payoutScheduleText(account)}</dd></div>
451
+ <div className="kv"><dt>Charges enabled</dt><dd>{String(account?.charges_enabled ?? false)}</dd></div>
452
+ </dl>
453
+ </section>
454
+ <section className="settings-block" data-settings="api-keys">
455
+ <h3>API keys</h3>
456
+ <div className="api-key-row">
457
+ <span className="api-key-label">Publishable key (test)</span>
458
+ <span className="api-key-value mono">pk_test_twin</span>
459
+ </div>
460
+ <div className="api-key-row">
461
+ <span className="api-key-label">Secret key (test)</span>
462
+ <span className="api-key-value mono">sk_test_twin••••</span>
463
+ </div>
464
+ </section>
465
+ <section className="settings-block" data-settings="webhooks">
466
+ <h3>Webhooks</h3>
467
+ {webhooks.length === 0 && <div className="empty">No webhook endpoints registered.</div>}
468
+ {webhooks.map((w) => (
469
+ <div className="webhook-row" key={w.id}>
470
+ <span className="webhook-url mono">{w.url ?? w.id}</span>
471
+ <StatusPill value={w.status ?? 'enabled'} />
472
+ </div>
473
+ ))}
474
+ </section>
475
+ </div>
476
+ );
477
+ }
478
+
479
+ // ── Create / edit modal (write forms) ───────────────────────────────────────
480
+ // The real Dashboard creates objects via a modal write form that POSTs the create endpoint.
481
+ // We model a faithful create form per collection (the few required fields), POSTing the SAME
482
+ // /v1/<collection> the SDK uses; on success the polling loop reflects the new row.
483
+ export const CREATE_FORMS: Record<string, { label: string; fields: Array<{ name: string; label: string; placeholder?: string; required?: boolean }> }> = {
484
+ customers: { label: 'customer', fields: [{ name: 'email', label: 'Email', placeholder: 'jenny@example.com' }, { name: 'name', label: 'Name' }] },
485
+ products: { label: 'product', fields: [{ name: 'name', label: 'Name', required: true }, { name: 'description', label: 'Description' }] },
486
+ coupons: { label: 'coupon', fields: [{ name: 'percent_off', label: 'Percent off', placeholder: '20' }, { name: 'duration', label: 'Duration', placeholder: 'once' }] },
487
+ payment_intents: { label: 'payment', fields: [{ name: 'amount', label: 'Amount (cents)', required: true, placeholder: '1000' }, { name: 'currency', label: 'Currency', required: true, placeholder: 'usd' }] },
488
+ };
489
+
490
+ export function CreateModal({ collection, onClose, onCreated }: { collection: string; onClose: () => void; onCreated: () => void }) {
491
+ const form = CREATE_FORMS[collection];
492
+ const [values, setValues] = useState<Record<string, string>>({});
493
+ const [busy, setBusy] = useState(false);
494
+ const [err, setErr] = useState<string | null>(null);
495
+ if (!form) return null;
496
+ const submit = async () => {
497
+ setBusy(true); setErr(null);
498
+ try {
499
+ const body = Object.entries(values).filter(([, v]) => v !== '').map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
500
+ const res = await fetch(`/v1/${collection}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body }).then((r) => r.json());
501
+ if (res.error) throw new Error(res.error.message);
502
+ onCreated(); onClose();
503
+ } catch (e: any) {
504
+ setErr(String(e.message ?? e));
505
+ } finally {
506
+ setBusy(false);
507
+ }
508
+ };
509
+ return (
510
+ <div className="create-overlay" role="dialog" aria-label={`Create ${form.label}`} onClick={onClose}>
511
+ <div className="create-modal" onClick={(e) => e.stopPropagation()}>
512
+ <h3 className="create-title">New {form.label}</h3>
513
+ {form.fields.map((f) => (
514
+ <label className="create-field" key={f.name}>
515
+ <span className="create-field-label">{f.label}{f.required ? ' *' : ''}</span>
516
+ <input className="create-field-input" placeholder={f.placeholder ?? ''} value={values[f.name] ?? ''} onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))} />
517
+ </label>
518
+ ))}
519
+ {err && <div className="create-error" role="alert">{err}</div>}
520
+ <div className="create-actions">
521
+ <button className="create-cancel" onClick={onClose}>Cancel</button>
522
+ <button className="create-submit" disabled={busy} onClick={submit}>{busy ? 'Creating…' : 'Create'}</button>
523
+ </div>
524
+ </div>
525
+ </div>
526
+ );
527
+ }
528
+
529
+ /** The list pane: one row per object in the active section (or an empty state). */
530
+ export function ListPane({ section, rows, query, currentId, onSelect }: {
531
+ section: Section;
532
+ rows: StripeRow[];
533
+ query?: string;
534
+ currentId?: string;
535
+ onSelect: (id: string) => void;
536
+ }) {
537
+ return (
538
+ <section className="list">
539
+ {rows.length === 0 && <div className="empty">{query ? 'No matches.' : `No ${section.label.toLowerCase()} in the twin yet.`}</div>}
540
+ {rows.map((row, i) => (
541
+ <button key={row.id ?? i} className={`list-row ${currentId === row.id ? 'active' : ''}`} onClick={() => onSelect(row.id)}>
542
+ <span className="row-main">
543
+ <span className="row-title">{section.title(row)}</span>
544
+ <span className="row-sub mono">{section.subtitle(row)}</span>
545
+ </span>
546
+ {row.status ? <StatusPill value={row.status} /> : row.active !== undefined ? <StatusPill value={String(row.active)} /> : null}
547
+ </button>
548
+ ))}
549
+ </section>
550
+ );
551
+ }
552
+
553
+ export function App() {
554
+ const [activeKey, setActiveKey] = useState<string>('__home');
555
+ // All collections cached so cross-references resolve without extra round-trips.
556
+ const [data, setData] = useState<Record<string, StripeRow[]>>({});
557
+ const [account, setAccount] = useState<StripeRow | undefined>(undefined);
558
+ const [error, setError] = useState<string | null>(null);
559
+ const [selected, setSelected] = useState<string | null>(null);
560
+ const [query, setQuery] = useState('');
561
+ const [cmdOpen, setCmdOpen] = useState(false);
562
+ const [createFor, setCreateFor] = useState<string | null>(null);
563
+ // Bumped after a detail action (capture/cancel) to re-fetch immediately rather than
564
+ // waiting for the next poll tick.
565
+ const [refreshKey, setRefreshKey] = useState(0);
566
+
567
+ const active = SECTION_BY_KEY[activeKey];
568
+
569
+ useEffect(() => {
570
+ let live = true;
571
+ const tick = async () => {
572
+ try {
573
+ const results = await Promise.all(SECTIONS.map((s) =>
574
+ fetch(`/v1/${s.key}`).then((r) => r.json()).then((j) => {
575
+ if (j.error) throw new Error(j.error.message);
576
+ // `balance` returns a single summary object (no `data` array); present it as a
577
+ // one-row list so it flows through the same list/detail machinery. Give it a
578
+ // stable id so selection/cross-ref code (keyed on `id`) works.
579
+ if (s.key === BALANCE_KEY) return [s.key, [{ id: 'balance', ...j } as StripeRow]] as const;
580
+ return [s.key, (j.data ?? []) as StripeRow[]] as const;
581
+ }),
582
+ ));
583
+ // the platform account (GET /v1/account) backs the Settings screen.
584
+ const acct = await fetch('/v1/account').then((r) => r.json()).catch(() => undefined);
585
+ if (!live) return;
586
+ setData(Object.fromEntries(results));
587
+ if (acct && !acct.error) setAccount(acct as StripeRow);
588
+ setError(null);
589
+ } catch (e: any) {
590
+ if (live) setError(String(e.message ?? e));
591
+ }
592
+ };
593
+ void tick();
594
+ const timer = window.setInterval(tick, 2000);
595
+ return () => { live = false; window.clearInterval(timer); };
596
+ }, [refreshKey]);
597
+
598
+ // Cmd/Ctrl-K opens the global command bar (Stripe's search shortcut).
599
+ useEffect(() => {
600
+ const onKey = (e: KeyboardEvent) => {
601
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); setCmdOpen((o) => !o); }
602
+ if (e.key === 'Escape') { setCmdOpen(false); setCreateFor(null); }
603
+ };
604
+ window.addEventListener('keydown', onKey);
605
+ return () => window.removeEventListener('keydown', onKey);
606
+ }, []);
607
+
608
+ const rows = data[activeKey];
609
+ const filtered = useMemo(() => {
610
+ const q = query.trim().toLowerCase();
611
+ if (!active) return [];
612
+ if (!q) return rows ?? [];
613
+ return (rows ?? []).filter((r) =>
614
+ [active.title(r), active.subtitle(r), r.id, r.email, r.name].some((f) => String(f ?? '').toLowerCase().includes(q)),
615
+ );
616
+ }, [rows, query, active]);
617
+
618
+ const current = useMemo(() => (rows ?? []).find((r) => r.id === selected) ?? filtered[0], [rows, selected, filtered]);
619
+ const refs = useMemo(() => current ? resolveCrossRefs(activeKey, current, data) : { outgoing: [], incoming: [] }, [current, activeKey, data]);
620
+
621
+ const jump = (collection: string, id: string) => {
622
+ if (!collection) return;
623
+ setActiveKey(collection);
624
+ setSelected(id);
625
+ setQuery('');
626
+ };
627
+
628
+ const canCreate = active && CREATE_FORMS[activeKey] !== undefined;
629
+
630
+ return (
631
+ <div className="dash">
632
+ <header className="topbar">
633
+ <div className="brand"><span className="dot" /> Stripe <span className="tag">twin</span></div>
634
+ <button className="search search-trigger" onClick={() => setCmdOpen(true)}>Search… <span className="kbd">⌘K</span></button>
635
+ {active ? <input className="search section-filter" placeholder="Filter this section…" value={query} onChange={(e) => setQuery(e.target.value)} /> : null}
636
+ <span className="mode">Test mode</span>
637
+ </header>
638
+ <div className="body">
639
+ <SideNav
640
+ sections={SECTIONS}
641
+ counts={Object.fromEntries(SECTIONS.map((s) => [s.key, data[s.key]?.length ?? 0]))}
642
+ activeKey={activeKey}
643
+ onSelect={(key) => { setActiveKey(key); setSelected(null); setQuery(''); }}
644
+ onHome={() => { setActiveKey('__home'); setSelected(null); setQuery(''); }}
645
+ onSettings={() => { setActiveKey('__settings'); setSelected(null); setQuery(''); }}
646
+ />
647
+ <main className="content">
648
+ {activeKey === '__home' ? (
649
+ <>
650
+ <div className="content-head"><h2>Home</h2></div>
651
+ <HomeScreen data={data} onJump={jump} />
652
+ </>
653
+ ) : activeKey === '__settings' ? (
654
+ <>
655
+ <div className="content-head"><h2>Settings</h2></div>
656
+ <SettingsScreen account={account} webhooks={data.webhook_endpoints ?? []} />
657
+ </>
658
+ ) : active ? (
659
+ <>
660
+ <div className="content-head">
661
+ <h2>{active.label}</h2>
662
+ <span className="count">{filtered.length}{query ? ` of ${rows?.length ?? 0}` : ''}</span>
663
+ {canCreate ? <button className="create-trigger" onClick={() => setCreateFor(activeKey)}>+ New</button> : null}
664
+ </div>
665
+ {error && <div className="error">Failed to load /v1/{activeKey}: {error}</div>}
666
+ <div className="split">
667
+ <ListPane
668
+ section={active}
669
+ rows={rows ? filtered : []}
670
+ query={query}
671
+ currentId={current?.id}
672
+ onSelect={(id) => setSelected(id)}
673
+ />
674
+ <Detail collection={activeKey} row={current} refs={refs} onJump={jump} onAction={() => setRefreshKey((k) => k + 1)} />
675
+ </div>
676
+ </>
677
+ ) : null}
678
+ </main>
679
+ </div>
680
+ {cmdOpen ? <CommandBar data={data} onJump={jump} onClose={() => setCmdOpen(false)} /> : null}
681
+ {createFor ? <CreateModal collection={createFor} onClose={() => setCreateFor(null)} onCreated={() => setRefreshKey((k) => k + 1)} /> : null}
682
+ </div>
683
+ );
684
+ }
685
+
686
+ // Browser-only mount. Guarded so the presentational components above can be imported
687
+ // (and renderToStaticMarkup'd) in a DOM-less test/SSR environment without this top-level
688
+ // `document` access throwing. The browser bundle still mounts exactly as before.
689
+ if (typeof document !== 'undefined') {
690
+ createRoot(document.getElementById('root')!).render(<App />);
691
+ }