@volter/twin-stripe 3.0.1 → 3.0.3

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@volter/twin-stripe",
3
- "version": "3.0.1",
4
- "description": "Local Stripe twin — a faithful, stateful local Stripe API your real `stripe` SDK talks to unmodified, its hosted pages and Stripe.js. Built on @volter/world-core.",
3
+ "version": "3.0.3",
4
+ "description": "Local Stripe customer, checkout and billing workflows with synthetic state and the real Stripe SDK. Supported operations and hosted-page limits are documented in the README.",
5
5
  "keywords": [
6
6
  "twin",
7
7
  "local",
@@ -34,27 +34,24 @@
34
34
  ],
35
35
  "repository": {
36
36
  "type": "git",
37
- "url": "git+https://github.com/volter-ai/twin-packs-p3.git",
37
+ "url": "git+https://github.com/volter-ai/twin-packs-open.git",
38
38
  "directory": "stripe"
39
39
  },
40
- "homepage": "https://github.com/volter-ai/twin-packs-p3/tree/main/stripe#readme",
40
+ "homepage": "https://github.com/volter-ai/twin-packs-open/tree/main/stripe#readme",
41
41
  "type": "module",
42
42
  "exports": {
43
- ".": {
44
- "types": "./dist/src/index.d.ts",
45
- "default": "./dist/src/index.js"
46
- }
43
+ ".": "./dist/src/index.js"
47
44
  },
48
45
  "bin": {
49
46
  "world-stripe": "dist/src/cli.js"
50
47
  },
51
48
  "dependencies": {
52
- "@volter/world-ui": "^1.0.38",
49
+ "@volter/world-ui": "^1.0.65",
53
50
  "react": "^19.2.7",
54
51
  "react-dom": "^19.2.7"
55
52
  },
56
53
  "peerDependencies": {
57
- "@volter/world-core": "^3.0.38"
54
+ "@volter/world-core": "^3.0.68"
58
55
  },
59
56
  "devDependencies": {
60
57
  "@types/bun": "^1.2.20",
@@ -66,5 +63,8 @@
66
63
  },
67
64
  "engines": {
68
65
  "node": ">=22.3"
66
+ },
67
+ "bugs": {
68
+ "url": "https://github.com/volter-ai/twin-packs-open/issues"
69
69
  }
70
70
  }
package/spec/SOURCE.md CHANGED
@@ -16,3 +16,7 @@ The pack serves this document's version (`SERVED_VERSION` in `src/engine/version
16
16
  publish (its testing page's cards, the payment lifecycle's states, each reference page's stated rule), each with its
17
17
  page's URL and the page's words for what it does, read with twin-standard's `read-page`. **Read by:**
18
18
  `bun scripts/vendor-examples.ts stripe`.
19
+
20
+ Screen references, read 2026-10-08: [Stripe's hosted-onboarding guide](https://docs.stripe.com/connect/hosted-onboarding), its hosted_onboarding_form.e59ba8300f563e43489953f06127f52c.png image, supplies the two-column layout, progress and sequential form. The image's example platform branding is configurable; this screen retains the Stripe skin. Back/Continue keep local form answers; only the existing final submit changes the connected account. The existing US requirements and refusal behavior remain the contract. No upstream page, image or script is shipped.
21
+
22
+ Dashboard references, read 2026-10-08: [the Web Dashboard guide](https://docs.stripe.com/dashboard/basics) supplies the primary sidebar categories, Shortcuts and Settings. The public [Stripe Apps testing guide](https://docs.stripe.com/stripe-apps/test-app), image `test-app-uninstall.710b89177767cff3a7f53de749558d88.png`, illustrates the earlier horizontal navigation, settings breadcrumbs, form density and type; its publication date is unspecified, and it is not a current Public details screenshot. The authored shell follows the current documented sidebar, while the controls remain the existing public business name, Radar list creation and test Issuing funding. The declared root opens Public details; other Dashboard products are unavailable, and there are no invented analytics. [Radar Lists](https://docs.stripe.com/radar/lists) supplies New, name, alias, type and Add. No upstream image, DOM, script or account sign-in is shipped.
@@ -712,6 +712,28 @@ export function disputeEvidence(): Record<string, unknown> {
712
712
  type Params = Record<string, unknown>;
713
713
  const subParams = (params: Params, k: string): Params => (params[k] && typeof params[k] === 'object' ? (params[k] as Params) : {});
714
714
 
715
+ // source: https://docs.stripe.com/testing
716
+ // The documented brand cards keep their brand and funding type when Checkout or the portal saves them.
717
+ // Where the documentation stops and the twin decides: other numbers retain the existing synthetic Visa default.
718
+ const TEST_CARD_PROFILES: Record<string, { brand: string; funding: string }> = {
719
+ '4000056655665556': { brand: 'visa', funding: 'debit' },
720
+ '5555555555554444': { brand: 'mastercard', funding: 'credit' },
721
+ '2223003122003222': { brand: 'mastercard', funding: 'credit' },
722
+ '5200828282828210': { brand: 'mastercard', funding: 'debit' },
723
+ '5105105105105100': { brand: 'mastercard', funding: 'prepaid' },
724
+ '378282246310005': { brand: 'amex', funding: 'credit' },
725
+ '371449635398431': { brand: 'amex', funding: 'credit' },
726
+ '6011111111111117': { brand: 'discover', funding: 'credit' },
727
+ '6011000990139424': { brand: 'discover', funding: 'credit' },
728
+ '6011981111111113': { brand: 'discover', funding: 'debit' },
729
+ '3056930009020004': { brand: 'diners', funding: 'credit' },
730
+ '36227206271667': { brand: 'diners', funding: 'credit' },
731
+ '3566002020360505': { brand: 'jcb', funding: 'credit' },
732
+ '6200000000000005': { brand: 'unionpay', funding: 'credit' },
733
+ '6200000000000047': { brand: 'unionpay', funding: 'debit' },
734
+ '6205500000000000004': { brand: 'unionpay', funding: 'credit' },
735
+ };
736
+
715
737
  /** A card's own sub-object, from the number and expiry given. */
716
738
  function cardSubObject(params: Params): Params {
717
739
  const c = subParams(params, 'card');
@@ -720,15 +742,16 @@ function cardSubObject(params: Params): Params {
720
742
  // produces its real last4 instead of silently falling back to the '4242' default.
721
743
  const number = typeof c.number === 'string' ? c.number.replace(/\D/g, '')
722
744
  : typeof c.number === 'number' ? String(c.number).replace(/\D/g, '') : '';
745
+ const profile = TEST_CARD_PROFILES[number] ?? { brand: 'visa', funding: 'credit' };
723
746
  return { card: {
724
- brand: 'visa', last4: number ? number.slice(-4) : '4242',
747
+ brand: profile.brand, last4: number ? number.slice(-4) : '4242',
725
748
  exp_month: Number(c.exp_month) || 12, exp_year: Number(c.exp_year) || 2034,
726
- funding: 'credit', country: 'US',
749
+ funding: profile.funding, country: 'US',
727
750
  // source: spec:/components/schemas/payment_method_card/properties/fingerprint/description "Uniquely identifies this particular card number."
728
751
  // The synthetic fingerprint is stable for a number; Stripe's opaque fingerprint algorithm is not published.
729
752
  fingerprint: digest('sha256', number || '4242424242424242').slice(0, 16),
730
753
  checks: { address_line1_check: null, address_postal_code_check: null, cvc_check: 'pass' },
731
- networks: { available: ['visa'], preferred: null },
754
+ networks: { available: [profile.brand], preferred: null },
732
755
  three_d_secure_usage: { supported: true }, wallet: null,
733
756
  } };
734
757
  }
@@ -81,8 +81,8 @@ function page(ctx: HandlerContext, id: string, session: Row, problem?: { error:
81
81
  badge: s.status === 'trialing' ? 'Trial' : s.status === 'past_due' ? 'Past due' : ending ? 'Cancels' : undefined,
82
82
  note: ending ? `Your plan will be canceled on ${day(s.current_period_end)}.` : s.status === 'trialing' ? `Trial ends ${day(s.trial_end)}.` : `Renews on ${day(s.current_period_end)}.`,
83
83
  actions: ending
84
- ? [{ label: 'Renew plan', action: `/p/session/${id}/renew`, fields: { subscription: String(s.id) }, tone: 'primary' as const }]
85
- : features.cancel.enabled ? [{ label: 'Cancel plan', action: `/p/session/${id}/cancel`, fields: { subscription: String(s.id) }, tone: 'danger' as const }] : [],
84
+ ? [{ label: 'Renew plan', action: `${ctx.publicBase}/p/session/${id}/renew`, fields: { subscription: String(s.id) }, tone: 'primary' as const }]
85
+ : features.cancel.enabled ? [{ label: 'Cancel plan', action: `${ctx.publicBase}/p/session/${id}/cancel`, fields: { subscription: String(s.id) }, tone: 'danger' as const }] : [],
86
86
  };
87
87
  });
88
88
  const defaultPm = ((customer.invoice_settings as Row | undefined)?.default_payment_method as string | undefined) ?? undefined;
@@ -97,7 +97,7 @@ function page(ctx: HandlerContext, id: string, session: Row, problem?: { error:
97
97
  ? ctx.rows('invoice').filter((i) => i.customer === session.customer && i.status !== 'draft').sort((a, b) => Number(b.created) - Number(a.created))
98
98
  .map((i) => ({
99
99
  title: day(i.created), detail: money(i.amount_due ?? i.total, i.currency), badge: String(i.status ?? '').replace(/^./, (c) => c.toUpperCase()),
100
- ...(i.status === 'open' && i.collection_method !== 'send_invoice' ? { actions: [{ label: 'Pay', action: `/p/session/${id}/pay`, fields: { invoice: String(i.id) }, tone: 'primary' as const }] } : {}),
100
+ ...(i.status === 'open' && i.collection_method !== 'send_invoice' ? { actions: [{ label: 'Pay', action: `${ctx.publicBase}/p/session/${id}/pay`, fields: { invoice: String(i.id) }, tone: 'primary' as const }] } : {}),
101
101
  }))
102
102
  : [];
103
103
  const back = typeof session.return_url === 'string' ? session.return_url : undefined;
@@ -124,7 +124,7 @@ function page(ctx: HandlerContext, id: string, session: Row, problem?: { error:
124
124
  <Portal
125
125
  merchant={merchant}
126
126
  {...(problem ? { notice: problem.error } : {})}
127
- {...(features.paymentMethod ? { forms: [{ heading: pm ? 'Update payment method' : 'Add payment method', action: `/p/session/${id}/payment_method`, fields: cardFields, submit: { label: pm ? 'Update' : 'Add' } }] } : {})}
127
+ {...(features.paymentMethod ? { forms: [{ heading: pm ? 'Update payment method' : 'Add payment method', action: `${ctx.publicBase}/p/session/${id}/payment_method`, fields: cardFields, submit: { label: pm ? 'Update' : 'Add' } }] } : {})}
128
128
  {...(back ? { back: { href: back, label: `← Return to ${merchant}` } } : {})}
129
129
  sections={[
130
130
  { heading: 'Current plan', items: plans, empty: 'You have no active plans.' },
@@ -141,7 +141,7 @@ function page(ctx: HandlerContext, id: string, session: Row, problem?: { error:
141
141
  async function move(ctx: HandlerContext, id: string, session: Row, subscription: string, kind: 'cancel' | 'renew'): Promise<Response> {
142
142
  const sub = resourceView(ctx, SUB, subscription);
143
143
  if (!sub || sub.customer !== session.customer || sub.status === 'canceled') return gone();
144
- const back = new Response(null, { status: 303, headers: { location: `/p/session/${id}` } });
144
+ const back = new Response(null, { status: 303, headers: { location: `${ctx.publicBase}/p/session/${id}` } });
145
145
  if (kind === 'renew') {
146
146
  await ctx.write(SUB, subscription, { cancel_at_period_end: false, cancel_at: null, canceled_at: null, cancellation_details: { comment: null, feedback: null, reason: null } }, 'subscription.update');
147
147
  return back;
@@ -179,7 +179,7 @@ async function updatePaymentMethod(ctx: HandlerContext, id: string, session: Row
179
179
  for (const sub of ctx.rows(SUB).map((s) => ctx.expand(SUB, s)).filter((s) => s.customer === customer && s.status !== 'canceled' && s.status !== 'incomplete_expired')) {
180
180
  await ctx.write(SUB, String(sub.id), { default_payment_method: pm.id }, 'subscription.update');
181
181
  }
182
- return new Response(null, { status: 303, headers: { location: `/p/session/${id}` } });
182
+ return new Response(null, { status: 303, headers: { location: `${ctx.publicBase}/p/session/${id}` } });
183
183
  }
184
184
 
185
185
  /** The customer pays one of their open invoices with the card on file. */
@@ -192,7 +192,7 @@ async function payInvoice(ctx: HandlerContext, id: string, session: Row, invoice
192
192
  if (!pm) return page(ctx, id, session, { error: 'Add a payment method to pay this invoice.', values: {} });
193
193
  const declined = await payOpenInvoice(ctx, invoiceId, pm);
194
194
  if (declined) return page(ctx, id, session, { error: declined.message, values: {} });
195
- return new Response(null, { status: 303, headers: { location: `/p/session/${id}` } });
195
+ return new Response(null, { status: 303, headers: { location: `${ctx.publicBase}/p/session/${id}` } });
196
196
  }
197
197
 
198
198
  /** billing.stripe.com's portal pages, or undefined for any other request. */
@@ -94,7 +94,7 @@ function customerEmailOf(session: Row): string | undefined {
94
94
  return typeof email === 'string' && EMAIL.test(email) ? email : undefined;
95
95
  }
96
96
 
97
- function page(session: Row, id: string, now: number, values: Record<string, string> = {}, error?: string): Response {
97
+ function page(publicBase: string, session: Row, id: string, now: number, values: Record<string, string> = {}, error?: string): Response {
98
98
  const lines = (((session.line_items as { data?: Row[] } | undefined)?.data) ?? []).map((item) => ({
99
99
  name: String(item.description ?? 'Item'),
100
100
  amount: money(item.amount_total, item.currency ?? session.currency),
@@ -128,7 +128,7 @@ function page(session: Row, id: string, now: number, values: Record<string, stri
128
128
  merchant={merchant}
129
129
  lines={lines}
130
130
  {...(trialText ? { total: trialText.lead, note: trialText.note } : session.amount_total !== null && session.amount_total !== undefined ? { total: { label: mode === 'subscription' ? 'Subscribe' : `Pay ${merchant}`, amount: money(session.amount_total, session.currency) } } : {})}
131
- action={`/c/pay/${id}`}
131
+ action={`${publicBase}/c/pay/${id}`}
132
132
  sections={[{ heading: 'Contact information', fields: contact }, { heading: 'Payment method', fields: card }]}
133
133
  submit={{ label: submit, testId: 'hosted-payment-submit-button' }}
134
134
  {...(typeof session.cancel_url === 'string' ? { back: { href: session.cancel_url, label: '← Back' } } : {})}
@@ -145,13 +145,13 @@ async function pay(ctx: HandlerContext, id: string, session: Row, v: Record<stri
145
145
  const onFile = customerEmailOf(session);
146
146
  const email = onFile ?? (typeof session.customer_email === 'string' ? session.customer_email : (v.email ?? '').trim());
147
147
  if (session.mode !== 'setup' || !session.customer) {
148
- if (!EMAIL.test(email)) return page(session, id, nowUnix(ctx.occurredAt), v, 'Your email address is incomplete.');
148
+ if (!EMAIL.test(email)) return page(ctx.publicBase, session, id, nowUnix(ctx.occurredAt), v, 'Your email address is incomplete.');
149
149
  }
150
150
  // a trial charges nothing now unless a one-time price is on the first invoice
151
151
  const oneTimeDue = (((session.line_items as { data?: Row[] } | undefined)?.data) ?? []).some((it, i) => isOneTime(it, inlineOf(session, i)));
152
152
  const chargesNow = session.mode === 'payment' || (session.mode === 'subscription' && (!trialOf(session, nowUnix(ctx.occurredAt)) || oneTimeDue));
153
153
  const refused = cardAnswer(v, nowUnix(ctx.occurredAt), chargesNow);
154
- if (refused) return page(session, id, nowUnix(ctx.occurredAt), v, refused);
154
+ if (refused) return page(ctx.publicBase, session, id, nowUnix(ctx.occurredAt), v, refused);
155
155
  let existing = session;
156
156
  // Checkout always makes a customer for a subscription; a payment makes one only when the session asks
157
157
  if (!session.customer && (session.mode === 'subscription' || session.customer_creation === 'always')) {
@@ -199,5 +199,5 @@ export async function screen(ctx: HandlerContext): Promise<Response> {
199
199
  const stored = ctx.row(CS, id) ?? {};
200
200
  const coupon = (stored._discount as Row | undefined)?.coupon;
201
201
  const full = { ...session, _subscription_data: stored._subscription_data, _price_data: stored._price_data, _merchant: merchantOf(ctx, stored), _coupon: typeof coupon === 'string' ? ctx.get('coupon', coupon) : undefined, _customer: typeof session.customer === 'string' ? ctx.get('customer', session.customer) : undefined };
202
- return request.method === 'GET' ? page(full, id, nowUnix(ctx.occurredAt)) : pay(ctx, id, full, values);
202
+ return request.method === 'GET' ? page(ctx.publicBase, full, id, nowUnix(ctx.occurredAt)) : pay(ctx, id, full, values);
203
203
  }
@@ -3,7 +3,8 @@
3
3
  // single-use entry point: visiting it takes the owner to their onboarding session, and visiting it again, or after it
4
4
  // expires, sends them to refresh_url, where the platform makes a new one (docs.stripe.com/connect/hosted-onboarding).
5
5
  // At the session's own address they give Stripe what it requires, and Stripe sends them to return_url. Submitting is
6
- // the owner's move, not an API call. Authored from @volter/world-ui's payment piece; nothing of Stripe's page is copied.
6
+ // the owner's move, not an API call. Authored from @volter/world-ui's payment piece and Stripe's published visual
7
+ // example; the form moves through one section at a time, retaining answers until the final submit.
7
8
  //
8
9
  // The page asks for exactly what Stripe requires before a US Express account with card_payments and transfers can
9
10
  // take card payments (docs.stripe.com/connect/required-verification-information; the set is the one Stripe's
@@ -23,8 +24,8 @@
23
24
  // merchant category codes as its list gives them (./industries.ts), and an industry the page does not offer leaves
24
25
  // business_profile.mcc due; the states are the fifty and the District of Columbia; a request with no forwarded address
25
26
  // records 127.0.0.1. An update link (account_update) shows the same page.
26
- import type { HandlerContext } from '@volter/world-core';
27
- import { CONSENT_CSS, flowPage, Payment, PAYMENT_CSS, type PaymentField } from '@volter/world-ui';
27
+ import { twinVendorUrl, type HandlerContext } from '@volter/world-core';
28
+ import { CONSENT_CSS, flowPage, Payment, PAYMENT_CSS, type PaymentProps, type PaymentField } from '@volter/world-ui';
28
29
  import type { Row } from '../engine/common.ts';
29
30
  import { nowUnix } from '../engine/stripe.ts';
30
31
  import { formOf, notFound, seeOther, STRIPE_CONSENT_SKIN } from './shared.tsx';
@@ -90,12 +91,14 @@ function page(ctx: HandlerContext, sessionUrl: string, link: Row, kind: Kind, va
90
91
  : [{ heading: 'Business details', fields: business }, { heading: 'About you', fields: person }, { heading: 'Payout account', fields: payout }, { heading: 'Agreement', fields: agreement }];
91
92
  return flowPage({
92
93
  title: 'Stripe · Account onboarding',
93
- css: [PAYMENT_CSS],
94
+ css: [ONBOARDING_CSS],
94
95
  ...(error ? { status: 400 } : {}),
95
96
  body: (
96
- <Payment
97
- merchant={`Verify your ${kind === 'company' ? 'company' : 'business'} with Stripe · ${String(link.account)} · test mode`}
98
- lines={[]}
97
+ <ConnectOnboarding
98
+ merchant="Stripe"
99
+ account={String(link.account)}
100
+ checked={['owners_provided', 'tos'].filter(name => v[name] === (name === 'tos' ? 'accepted' : 'provided'))}
101
+ lines={sections.map(section => ({ name: section.heading, amount: '' }))}
99
102
  action={sessionUrl}
100
103
  sections={sections}
101
104
  submit={{ label: 'Submit' }}
@@ -167,7 +170,7 @@ async function visit(ctx: HandlerContext, id: string): Promise<Response> {
167
170
  if (!usable(ctx, link)) return seeOther(String(link.refresh_url));
168
171
  const session = `onbs_${id.replace(/^acctlink_/, '')}`;
169
172
  await ctx.write(LINK, id, { used: true, _session: session }, 'account_link.used');
170
- return seeOther(`${SESSIONS}${session}`);
173
+ return seeOther(twinVendorUrl(ctx.call.request, `${SESSIONS}${session}`));
171
174
  }
172
175
 
173
176
  /** connect.stripe.com's onboarding pages, or undefined for any other request. */
@@ -184,7 +187,7 @@ export async function screen(ctx: HandlerContext): Promise<Response> {
184
187
  const stored = ctx.rowsRaw(LINK).find((l) => l._session === sid && l._submitted !== true);
185
188
  const link = stored ? ctx.get(LINK, String(stored.id)) : undefined;
186
189
  if (!stored || !link) return gone();
187
- const sessionUrl = `${SESSIONS}${sid}`;
190
+ const sessionUrl = twinVendorUrl(request, `${SESSIONS}${sid}`);
188
191
  const account = ctx.get('account', String(link.account)) ?? {};
189
192
  const kind = kindOf(account);
190
193
  if (!kind) return unmodelled(account);
@@ -195,3 +198,58 @@ export async function screen(ctx: HandlerContext): Promise<Response> {
195
198
  await submit(ctx, link, kind, form, request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || '127.0.0.1');
196
199
  return seeOther(String(link.return_url));
197
200
  }
201
+
202
+ // Visual reference: Stripe's own hosted onboarding example, read 2026-10-08:
203
+ // https://docs.stripe.com/connect/hosted-onboarding
204
+ // hosted_onboarding_form.e59ba8300f563e43489953f06127f52c.png. Platform branding
205
+ // in that example is configurable; this twin uses its existing Stripe skin.
206
+
207
+ const ONBOARDING_CSS = PAYMENT_CSS + `
208
+ *{box-sizing:border-box}body{color:#30313d;background:#fff;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
209
+ .pay{grid-template-columns:minmax(280px,40%) minmax(0,60%)}.pay-summary{background:#f7f7f9;padding:48px max(32px,calc((100vw - 1000px)/2));padding-right:40px;position:relative}
210
+ .pay-back{color:#596171;font-size:13px;margin-bottom:40px}.pay-merchant{color:#635bff;font-size:30px;font-weight:700;letter-spacing:-1px;margin-bottom:32px}
211
+ .pay-lines{counter-reset:step;max-width:280px}.pay-line{counter-increment:step;display:block;position:relative;padding:12px 0 12px 38px;color:#687385;font-size:14px}
212
+ .pay-line::before{content:counter(step);position:absolute;left:0;top:11px;width:24px;height:24px;line-height:22px;text-align:center;border:1px solid #d8dee4;border-radius:50%;background:#fff;font-size:12px}
213
+ .pay-line[aria-current=step]{color:#30313d;font-weight:600}.pay-line[aria-current=step]::before{border-color:#635bff;color:#635bff}.pay-line.is-complete::before{background:#635bff;border-color:#635bff;color:#fff}
214
+ .pay-form{padding:56px 48px;max-width:620px;width:100%}.pay-section legend{font-size:26px;letter-spacing:-.6px;font-weight:600;margin-bottom:24px}.pay-field{margin-bottom:22px}
215
+ .pay-field span{font-size:14px;margin-bottom:8px;font-weight:500}.pay-field input,.pay-field select{background:#fff;border-color:#d8dee4;border-radius:6px;padding:10px 12px;box-shadow:0 1px 2px #00000005;color:#30313d}
216
+ .pay-field input:focus,.pay-field select:focus{outline:2px solid #a5a0ff;outline-offset:1px}
217
+ .pay-field:has(input[type=checkbox]){display:flex;align-items:flex-start;gap:10px}.pay-field:has(input[type=checkbox]) span{order:2;font-weight:400}.pay-field input[type=checkbox]{width:16px;height:16px;flex:none;margin-top:3px;accent-color:#635bff}
218
+ .pay-submit,.connect-next{background:#635bff;color:#fff;border:0;border-radius:6px;font-size:14px;font-weight:600;cursor:pointer;padding:12px 16px}
219
+ .connect-actions{display:flex;justify-content:space-between;gap:16px;margin-top:32px}.connect-back{color:#596171;background:transparent;border:0;cursor:pointer;padding:12px 0;font:inherit}.connect-next{min-width:140px;flex:1}
220
+ .pay-error{border:1px solid #f1c2c2;padding:12px;border-radius:6px;color:#b52e2e;background:#fff7f7;font-size:13px;white-space:normal;overflow-wrap:anywhere}
221
+ .connect-progress{height:3px;background:#ececf1;margin:0 0 36px;border-radius:2px;overflow:hidden}.connect-progress div{height:100%;background:#635bff;transition:width .15s}
222
+ .connect-context{position:absolute;bottom:40px;left:32px;color:#687385;font-size:12px}.connect-context strong{display:block;font-weight:500;color:#30313d}
223
+ .connect-ready .pay-section[hidden],.connect-ready .pay-submit[hidden],.connect-actions [hidden]{display:none}
224
+ @media(max-width:800px){.pay{grid-template-columns:1fr}.pay-summary{padding:24px}.pay-back{margin-bottom:20px}.pay-merchant{font-size:24px;margin-bottom:0}.pay-lines{display:none}.connect-context{position:static;padding:0 24px;background:#f7f7f9}.pay-form{padding:32px 24px;max-width:100%}.pay-section legend{font-size:24px}}
225
+ `;
226
+
227
+ // Progressive enhancement only: Back/Continue move through the existing form.
228
+ // No account state is written until its existing Submit operation succeeds.
229
+ const STEPS = `(function(){
230
+ var root=document.querySelector('.connect-onboarding'),form=root.querySelector('form'),groups=Array.from(form.querySelectorAll('.pay-section')),items=Array.from(root.querySelectorAll('.pay-line')),submit=form.querySelector('.pay-submit');
231
+ var progress=document.createElement('div');progress.className='connect-progress';progress.setAttribute('role','progressbar');progress.setAttribute('aria-label','Onboarding progress');progress.setAttribute('aria-valuemin','0');progress.setAttribute('aria-valuemax',String(groups.length));
232
+ var bar=document.createElement('div');progress.append(bar);form.before(progress);
233
+ var controls=document.createElement('div');controls.className='connect-actions';
234
+ var back=document.createElement('button');back.type='button';back.className='connect-back';back.textContent='Back';
235
+ var next=document.createElement('button');next.type='button';next.className='connect-next';next.textContent='Continue';
236
+ controls.append(back,next);form.append(controls);
237
+ var checked=JSON.parse(root.getAttribute('data-checked-fields')||'[]');
238
+ groups.forEach(function(group){group.querySelectorAll('input,select').forEach(function(input){input.required=true;if(input.type==='checkbox')input.checked=checked.includes(input.name);if(input.name==='ssn_last_4'){input.pattern='[0-9]{4}';input.inputMode='numeric';input.maxLength=4;}});});
239
+ var index=0;
240
+ function show(i){index=i;groups.forEach(function(group,n){group.hidden=n!==i;});items.forEach(function(item,n){if(n===i)item.setAttribute('aria-current','step');else item.removeAttribute('aria-current');item.classList.toggle('is-complete',n<i);});back.hidden=i===0;next.hidden=i===groups.length-1;submit.hidden=i!==groups.length-1;bar.style.width=((i+1)/groups.length*100)+'%';progress.setAttribute('aria-valuenow',String(i+1));progress.setAttribute('aria-valuetext',groups[i].querySelector('legend').textContent);root.classList.add('connect-ready');}
241
+ function invalid(group){return Array.from(group.querySelectorAll('input,select')).find(function(input){return !input.checkValidity();});}
242
+ back.addEventListener('click',function(){show(index-1);});
243
+ next.addEventListener('click',function(){var field=invalid(groups[index]);if(field){field.reportValidity();return;}show(index+1);groups[index].querySelector('input,select')?.focus();});
244
+ form.noValidate=true;
245
+ form.addEventListener('submit',function(event){for(var i=0;i<groups.length;i++){var field=invalid(groups[i]);if(field){event.preventDefault();show(i);field.reportValidity();return;}}});
246
+ if(form.querySelector('.pay-error')){var first=groups.findIndex(function(group){return invalid(group);});show(first<0?groups.length-1:first);}else show(0);
247
+ })();`;
248
+
249
+ function ConnectOnboarding(props: PaymentProps & { account: string; checked: string[] }) {
250
+ return <div className="connect-onboarding" data-checked-fields={JSON.stringify(props.checked)}>
251
+ <Payment {...props} />
252
+ <div className="connect-context"><strong>Test mode</strong>{props.account}</div>
253
+ <script dangerouslySetInnerHTML={{ __html: STEPS }} />
254
+ </div>;
255
+ }
@@ -1,6 +1,5 @@
1
1
  // THE DASHBOARD — dashboard.stripe.com's pages an operator sets what Stripe's API cannot (docs/contributing/architecture.md,
2
- // "Screens": a workspace): Public details (/settings/public) and the Connect OAuth settings
3
- // (/settings/connect/onboarding-options/oauth), Radar lists and test Issuing funding.
2
+ // "Screens": a workspace): Public details (/settings/public), Radar lists and test Issuing funding.
4
3
  // The operator controls prepare the cited real application calls; other Dashboard pages answer Stripe's 404.
5
4
  //
6
5
  // THE DASHBOARD'S PUBLIC DETAILS — a workspace page (docs/contributing/architecture.md, "Screens") of the Dashboard
@@ -23,26 +22,6 @@
23
22
  // and the save redirects to its own path. Saving is an update of an account that exists (the first save included, though
24
23
  // the twin stores the platform's record only then), so every save is sent as account.updated.
25
24
  //
26
- // THE DASHBOARD'S CONNECT OAUTH SETTINGS — a workspace page (docs/contributing/architecture.md, "Screens") of the
27
- // Dashboard (manifest screen `dashboard`), at dashboard.stripe.com/settings/connect/onboarding-options/oauth, where a
28
- // platform starts an OAuth integration: "Enable onboarding accounts with OAuth", "Copy your client_id, a unique
29
- // identifier for your platform that's generated by Stripe", and "Set your redirect_uri ... You must specify all redirect
30
- // URLs in your platform settings. If you don't include the redirect_uri parameter in your request, Stripe defaults to
31
- // using the first address you've configured" (docs.stripe.com/connect/oauth-standard-accounts). The test client_id
32
- // "allows you to: Set your redirect_uri to a non-HTTPS URL; Set your redirect_uri to localhost"
33
- // (docs.stripe.com/connect/testing#using-oauth). Stripe's API has no call that reads or changes these settings (GET
34
- // /v1/account does not carry the client_id), so this page is the only way to them, and connect.stripe.com/oauth/authorize
35
- // (./connect-oauth.tsx) reads what it saves. Authored from plain markup under Stripe's type; nothing of Stripe's page is
36
- // copied.
37
- //
38
- // Where the documentation stops and the twin decides: the World has one platform, and its test client_id is
39
- // `ca_twin_self` (PLATFORM_CLIENT_ID; the platform account is acct_twin_self), the same in every World, shown on the
40
- // page in an element a runner reads (data-testid="connect-client-id"). OAuth starts disabled. The page is served at
41
- // the docs' address, at /test/… (the docs' test-mode link) and at /settings/connect (the address Cal.com's setup
42
- // guide gives). The redirect URIs are one form field, one per line (or comma-separated, as the reference describes
43
- // them), saved whole: each an absolute http or https URL without a fragment; http and localhost are allowed, as the
44
- // test client_id allows them. No sign-in guards the page. The form posts to the
45
- // page's own address and the save redirects to it.
46
25
  import type { HandlerContext } from '@volter/world-core';
47
26
  import { flowPage } from '@volter/world-ui';
48
27
  import { VL } from '../engine/radar.ts';
@@ -61,8 +40,32 @@ const MAX_NAME = 5000;
61
40
 
62
41
  const DETAILS_CSS = `
63
42
  * { box-sizing: border-box; }
64
- body { margin: 0; background: #f6f8fa; color: #1a1f36; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif; font-size: 14px; }
65
- .pd { max-width: 640px; margin: 48px auto; padding: 0 16px; }
43
+ body { margin: 0; background: #ffffff; color: #1a1f36; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif; font-size: 14px; line-height: 1.5; }
44
+ button,input,select { font: inherit; }
45
+ button,a,input,select { outline-offset: 3px; }
46
+ button { cursor: pointer; }
47
+ button:disabled { cursor: default; color: #697386; }
48
+ a { color: #635bff; text-decoration: none; }
49
+ .sd { min-height: 100vh; display: grid; grid-template-columns: 224px minmax(0,1fr); }
50
+ .sd-side { background: #f6f8fa; border-right: 1px solid #e3e8ee; padding: 20px 14px; }
51
+ .sd-account { font-weight: 600; padding: 6px 10px 20px; overflow-wrap: anywhere; }
52
+ .sd-mode { display: block; width: fit-content; font-size: 11px; font-weight: 500; background: #e3e8ee; border-radius: 4px; margin-top: 8px; padding: 2px 7px; }
53
+ .sd-nav { display: grid; gap: 3px; }
54
+ .sd-nav a,.sd-nav button { display: block; text-align: left; padding: 7px 10px; font: inherit; border: 0; border-radius: 6px; background: none; color: #697386; }
55
+ .sd-nav a { color: #1a1f36; }
56
+ .sd-nav a:hover { background: #e3e8ee; }
57
+ .sd-nav [aria-current=page] { color: #635bff; background: #ffffff; font-weight: 600; }
58
+ .sd-nav-title { margin: 23px 10px 7px; font-size: 12px; font-weight: 600; color: #697386; }
59
+ .sd-body { min-width: 0; }
60
+ .sd-top { min-height: 64px; border-bottom: 1px solid #e3e8ee; padding: 14px 32px; display: flex; align-items: center; gap: 16px; }
61
+ .sd-search { width: min(520px,100%); border: 1px solid #e3e8ee; border-radius: 7px; padding: 7px 12px; background: #f6f8fa; }
62
+ .sd-top a { margin-left: auto; }
63
+ .sd-top button { border: 0; background: none; }
64
+ .sd-content { padding: 28px 40px 60px; max-width: 1280px; }
65
+ .sd-crumbs { color: #697386; font-size: 13px; display: flex; gap: 10px; margin-bottom: 18px; }
66
+ .sd-title { display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid #e3e8ee; padding-bottom: 20px; margin-bottom: 28px; }
67
+ .sd-title h1 { font-size: 28px; margin: 0; letter-spacing: -.5px; }
68
+ .pd { max-width: 860px; }
66
69
  .pd h1 { font-size: 28px; margin: 16px 0 4px; }
67
70
  .pd-lead { color: #697386; margin: 0 0 24px; }
68
71
  .pd-card { background: #ffffff; border: 1px solid #e3e8ee; border-radius: 8px; padding: 24px; }
@@ -70,22 +73,50 @@ body { margin: 0; background: #f6f8fa; color: #1a1f36; font-family: -apple-syste
70
73
  .pd-card small { display: block; color: #697386; margin-bottom: 8px; }
71
74
  .pd-card input { width: 100%; padding: 8px 10px; border: 1px solid #c1c9d2; border-radius: 6px; font: inherit; }
72
75
  .pd-actions { margin-top: 20px; display: flex; justify-content: flex-end; }
73
- .pd-actions button { background: #635bff; color: #ffffff; border: 0; border-radius: 6px; padding: 8px 16px; font: inherit; font-weight: 600; cursor: pointer; }
76
+ .sd-primary,.pd-actions button { background: #635bff; color: #ffffff; border: 0; border-radius: 6px; padding: 8px 16px; font: inherit; font-weight: 600; cursor: pointer; }
74
77
  .pd-notice { padding: 10px 12px; border-radius: 6px; margin-bottom: 16px; }
75
78
  .pd-error { background: #fff0f3; color: #df1b41; }
76
79
  .pd-saved { background: #e7f8ef; color: #0e6245; }
80
+ .sd-table { border-collapse: collapse; width: 100%; }
81
+ .sd-table th,.sd-table td { padding: 12px 14px; text-align: left; border-bottom: 1px solid #e3e8ee; }
82
+ .sd-table th { font-size: 12px; font-weight: 600; color: #697386; background: #f6f8fa; }
83
+ .sd-empty { color: #697386; padding: 40px; text-align: center; }
84
+ .sd-code { font-family: ui-monospace,SFMono-Regular,monospace; font-size: 12px; }
85
+ .sd-dialog { width: min(480px,calc(100vw - 32px)); max-height: calc(100vh - 48px); overflow: auto; border: 1px solid #e3e8ee; border-radius: 8px; padding: 24px; color: #1a1f36; }
86
+ .sd-dialog::backdrop { background: rgba(26,31,54,.4); }
87
+ .sd-dialog h2 { font-size: 20px; margin: 0 0 20px; }
88
+ .sd-dialog label { display: block; font-weight: 600; margin: 14px 0; }
89
+ .sd-dialog input,.sd-dialog select { display: block; width: 100%; margin-top: 6px; border: 1px solid #c1c9d2; border-radius: 6px; padding: 8px 10px; background: #ffffff; }
90
+ .sd-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; }
91
+ .sd-cancel { border: 1px solid #c1c9d2; border-radius: 6px; padding: 8px 16px; background: #ffffff; color: #1a1f36; }
92
+ @media(max-width:760px) { .sd { grid-template-columns: 160px minmax(0,1fr); } .sd-content { padding: 24px 20px; } .sd-top { padding: 14px 20px; } .sd-top button { display: none; } .sd-table { font-size: 12px; } }
93
+ @media(max-width:520px) { .sd { display: block; } .sd-side { border-right: 0; border-bottom: 1px solid #e3e8ee; padding: 10px 16px; } .sd-account { padding: 0 0 8px; } .sd-mode { display: inline; margin-left: 8px; } .sd-nav { display: flex; flex-wrap: wrap; } .sd-nav button,.sd-nav-title { display: none; } .sd-content { padding: 20px 16px; } .sd-title h1 { font-size: 24px; } .sd-top { padding: 12px 16px; } }
77
94
  `;
78
95
 
79
- function detailsPage(account: Row | undefined, opts: { value?: string; error?: string; saved?: boolean } = {}): Response {
96
+ type DashboardPage = { title: string; section: string; path: string; body: Parameters<typeof flowPage>[0]['body']; action?: Parameters<typeof flowPage>[0]['body']; status?: number };
97
+ /** The documented dashboard navigation around only the operator pages this pack already serves. */
98
+ function dashboardPage(ctx: HandlerContext, opts: DashboardPage): Response {
99
+ const account = ctx.get('account', PLATFORM_ACCOUNT_ID);
100
+ const links = [['Public details', PUBLIC_DETAILS_PATH], ['Radar lists', '/radar/lists'], ['Issuing balance', '/test/issuing/balance']];
101
+ return flowPage({ title: `${opts.title} – Stripe`, css: [DETAILS_CSS], status: opts.status ?? 200, body: <div className="sd">
102
+ <aside className="sd-side"><div className="sd-account">{publicBusinessName(account)}<span className="sd-mode">Test mode</span></div><nav className="sd-nav" aria-label="Dashboard">
103
+ {['Home', 'Balances', 'Transactions', 'Customers', 'Product catalog'].map(label => <button key={label} type="button" disabled title="Not available in this twin's Dashboard">{label}</button>)}
104
+ <p className="sd-nav-title">Shortcuts</p>{links.map(([label,path]) => <a key={path} href={`${ctx.publicBase}${path}`} aria-current={opts.path === path ? 'page' : undefined}>{label}</a>)}
105
+ <p className="sd-nav-title">Products</p>{['Payments', 'Billing', 'Reporting', 'Connect', 'More'].map(label => <button key={label} type="button" disabled title="Not available in this twin's Dashboard">{label}</button>)}
106
+ </nav></aside><div className="sd-body"><header className="sd-top"><input className="sd-search" aria-label="Search Dashboard" placeholder="Search" disabled title="Dashboard search is not available in this twin"/><button type="button" disabled title="Not available in this twin's Dashboard">Create</button><button type="button" disabled>Help</button><a href={`${ctx.publicBase}${PUBLIC_DETAILS_PATH}`}>Settings</a></header>
107
+ <main className="sd-content"><nav className="sd-crumbs" aria-label="Breadcrumb"><span>{opts.section}</span><span aria-hidden="true">/</span><span>{opts.title}</span></nav><div className="sd-title"><h1>{opts.title}</h1>{opts.action}</div>{opts.body}</main></div>
108
+ <script dangerouslySetInnerHTML={{ __html: "document.querySelectorAll('[data-open-dialog]').forEach(b=>b.addEventListener('click',()=>document.getElementById(b.dataset.openDialog).showModal()));document.querySelectorAll('[data-close-dialog]').forEach(b=>b.addEventListener('click',()=>b.closest('dialog').close()));" }}/>
109
+ </div> });
110
+ }
111
+
112
+ function detailsPage(ctx: HandlerContext, account: Row | undefined, opts: { value?: string; error?: string; saved?: boolean } = {}): Response {
80
113
  const named = (account?.business_profile as Row | undefined)?.name;
81
114
  const value = opts.value ?? (typeof named === 'string' ? named : '');
82
- return flowPage({
83
- title: 'Public details – Stripe',
84
- css: [DETAILS_CSS],
115
+ return dashboardPage(ctx, {
116
+ title: 'Public details', section: 'Settings / Business', path: PUBLIC_DETAILS_PATH,
85
117
  ...(opts.error ? { status: 400 } : {}),
86
118
  body: (
87
- <main className="pd">
88
- <h1>Public details</h1>
119
+ <div className="pd">
89
120
  <p className="pd-lead">What your customers see on Checkout, the customer portal, receipts and invoices. Currently shown: <strong data-testid="public-business-name">{publicBusinessName(account)}</strong></p>
90
121
  {opts.error ? <p className="pd-notice pd-error" role="alert">{opts.error}</p> : null}
91
122
  {opts.saved ? <p className="pd-notice pd-saved" role="status">Your public details were saved.</p> : null}
@@ -95,7 +126,7 @@ function detailsPage(account: Row | undefined, opts: { value?: string; error?: s
95
126
  <input id="business_name" name="business_name" autoComplete="organization" defaultValue={value} required maxLength={MAX_NAME} />
96
127
  <div className="pd-actions"><button type="submit">Save</button></div>
97
128
  </form>
98
- </main>
129
+ </div>
99
130
  ),
100
131
  });
101
132
  }
@@ -118,10 +149,10 @@ async function publicDetails(ctx: HandlerContext): Promise<Response> {
118
149
  const url = new URL(request.url);
119
150
  if (request.method !== 'GET' && request.method !== 'POST') return notFound();
120
151
  const form = request.method === 'POST' ? await formOf(request.clone()) : {};
121
- if (request.method === 'GET') return detailsPage(ctx.get('account', PLATFORM_ACCOUNT_ID), { saved: url.searchParams.has('saved') });
152
+ if (request.method === 'GET') return detailsPage(ctx, ctx.get('account', PLATFORM_ACCOUNT_ID), { saved: url.searchParams.has('saved') });
122
153
  const name = (form.business_name ?? '').trim();
123
154
  const refused = nameRefused(name);
124
- if (refused) return detailsPage(ctx.get('account', PLATFORM_ACCOUNT_ID), { value: form.business_name ?? '', error: refused });
155
+ if (refused) return detailsPage(ctx, ctx.get('account', PLATFORM_ACCOUNT_ID), { value: form.business_name ?? '', error: refused });
125
156
  await save(ctx, name);
126
157
  // a query-only reference: the page's own path, wherever it is mounted and with or without its trailing slash
127
158
  return new Response(null, { status: 303, headers: { location: '?saved=1' } });
@@ -130,6 +161,8 @@ async function publicDetails(ctx: HandlerContext): Promise<Response> {
130
161
  /** Dashboard settings, Radar list creation and test Issuing funding; other pages answer Stripe's 404. */
131
162
  export async function screen(ctx: HandlerContext): Promise<Response> {
132
163
  const path = new URL(ctx.call.request.url).pathname.replace(/\/+$/, '');
164
+ // The declared workspace opens a served setting, not a fabricated analytics home.
165
+ if (!path && ctx.call.request.method === 'GET') return redirect(`${ctx.publicBase}${PUBLIC_DETAILS_PATH}`);
133
166
  // source: https://docs.stripe.com/radar/lists "Use the Dashboard or the API to create lists."
134
167
  // source: https://docs.stripe.com/radar/lists "Enter a name for the list"
135
168
  // source: https://docs.stripe.com/radar/lists "Select the type of list to create."
@@ -151,9 +184,22 @@ export async function screen(ctx: HandlerContext): Promise<Response> {
151
184
  list_items: { object: 'list', data: [], has_more: false, url: '/v1/radar/value_list_items' },
152
185
  });
153
186
  await ctx.write(VL, String(list.id), { list_items: { object: 'list', data: [], has_more: false, url: `/v1/radar/value_list_items?value_list=${list.id}` } }, 'radar.value_list.updated');
154
- return redirect(`/radar/lists/${list.id}`);
187
+ return redirect(`${ctx.publicBase}/radar/lists/${list.id}`);
155
188
  }
156
- return flowPage({ css: [], title: 'Lists – Radar – Stripe', body: <main><h1>Radar Lists</h1><form method="post" action="/radar/lists"><h2>New list</h2><label>Name<input name="name" required /></label><label>Alias<input name="alias" /></label><label>List type<select name="item_type"><option value="customer_id">Customer ID</option><option value="email">Email</option><option value="card_fingerprint">Card fingerprint</option><option value="ip_address">IP address</option><option value="string">String</option></select></label><button>Add</button></form><ul>{ctx.rows(VL).map(row => <li key={String(row.id)}>{String(row.name)} ({String(row.alias)})</li>)}</ul></main> });
189
+ const lists = ctx.rows(VL);
190
+ const types = [['customer_id', 'Customer ID'], ['email', 'Email'], ['card_fingerprint', 'Card fingerprint'], ['card_bin', 'Card BIN'], ['ip_address', 'IP address'], ['string', 'String'], ['case_sensitive_string', 'Case-sensitive string'], ['country', 'Country'], ['sepa_debit_fingerprint', 'SEPA Direct Debit fingerprint'], ['us_bank_account_fingerprint', 'ACH Direct Debit fingerprint']];
191
+ const selected = lists.find(row => path === `/radar/lists/${row.id}`);
192
+ return dashboardPage(ctx, { title: selected ? String(selected.name) : 'Lists', section: 'Payments / Radar', path: '/radar/lists',
193
+ action: <button className="sd-primary" type="button" data-open-dialog="radar-new-list">New list</button>,
194
+ body: <>{selected ? <div className="pd-card"><a href={`${ctx.publicBase}/radar/lists`}>All lists</a><h2>{String(selected.name)}</h2><p>Alias: <code>{String(selected.alias)}</code></p><p>List type: {types.find(([key]) => key === selected.item_type)?.[1] ?? String(selected.item_type)}</p></div> : null}<p className="pd-lead">Lists of information to block, allow or review matching payments.</p>
195
+ <table className="sd-table"><thead><tr><th scope="col">Name</th><th scope="col">Alias</th><th scope="col">List type</th></tr></thead><tbody>{lists.map(row => <tr key={String(row.id)}><td><a href={`${ctx.publicBase}/radar/lists/${encodeURIComponent(String(row.id))}`}>{String(row.name)}</a></td><td className="sd-code">{String(row.alias)}</td><td>{types.find(([key]) => key === row.item_type)?.[1] ?? String(row.item_type)}</td></tr>)}</tbody></table>
196
+ {!lists.length ? <p className="sd-empty">No lists yet. Create a list to group related values.</p> : null}
197
+ <dialog className="sd-dialog" id="radar-new-list" aria-labelledby="radar-new-list-title"><h2 id="radar-new-list-title">New list</h2><form method="post" action={`${ctx.publicBase}/radar/lists`}>
198
+ <label htmlFor="radar-list-name">Name</label><input id="radar-list-name" name="name" required/>
199
+ <label htmlFor="radar-list-alias">Alias</label><input id="radar-list-alias" name="alias"/><p className="pd-lead">Leave blank to generate an alias from the name.</p>
200
+ <label htmlFor="radar-list-type">List type</label><select id="radar-list-type" name="item_type">{types.map(([value,label]) => <option key={value} value={value}>{label}</option>)}</select>
201
+ <div className="sd-dialog-actions"><button className="sd-cancel" type="button" data-close-dialog>Cancel</button><button className="sd-primary" type="submit">Add</button></div>
202
+ </form></dialog></> });
157
203
  }
158
204
  // source: https://docs.stripe.com/issuing/testing "Before you create test transactions, you must add test funds to the Issuing balance on your account."
159
205
  // source: https://docs.stripe.com/issuing/testing "You can create test top-ups in the Dashboard, or with the Top-ups API"
@@ -169,10 +215,9 @@ export async function screen(ctx: HandlerContext): Promise<Response> {
169
215
  const amount = Number(form.amount);
170
216
  if (!Number.isInteger(amount) || amount <= 0) return invalid('Enter a positive amount in cents.');
171
217
  await created(ctx, 'balance_transaction', {}, { amount, currency: 'usd', fee: 0, net: amount, type: 'topup', reporting_category: 'topup', source: null, status: 'available', balance_type: 'issuing', available_on: Number(nowUnix(ctx.occurredAt)), fee_details: [] });
172
- return redirect('/test/issuing/balance');
218
+ return redirect(`${ctx.publicBase}/test/issuing/balance?funded=1`);
173
219
  }
174
- return flowPage({ css: [], title: 'Test Issuing balance – Stripe', body: <main><h1>Test Issuing balance</h1><form method="post"><label>Amount<input name="amount" type="number" min="1" required /></label><button>Add test funds</button></form></main> });
220
+ return dashboardPage(ctx, { title: 'Issuing balance', section: 'Issuing', path: '/test/issuing/balance', body: <div className="pd"><p className="pd-lead">Add test funds before creating test Issuing transactions.</p>{new URL(ctx.call.request.url).searchParams.has('funded') ? <p className="pd-notice pd-saved" role="status">Test funds were added to your Issuing balance.</p> : null}<form className="pd-card" method="post" action={`${ctx.publicBase}/test/issuing/balance`}><label htmlFor="issuing-amount">Amount</label><small>USD cents. These funds belong to this test account.</small><input id="issuing-amount" name="amount" type="number" min="1" step="1" required/><div className="pd-actions"><button type="submit">Add test funds</button></div></form></div> });
175
221
  }
176
222
  return path === PUBLIC_DETAILS_PATH ? publicDetails(ctx) : notFound();
177
223
  }
178
-