@volter/world-ui 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.
- package/LICENSE +202 -0
- package/client/brand/fonts/Geist-Variable.woff2 +0 -0
- package/client/brand/fonts/GeistMono-Variable.woff2 +0 -0
- package/client/brand/fonts/InstrumentSerif-Italic.woff2 +0 -0
- package/client/brand/fonts/InstrumentSerif-Regular.woff2 +0 -0
- package/client/brand/fonts/LICENSES.txt +191 -0
- package/client/brand/tokens.css +212 -0
- package/client/consent.tsx +96 -0
- package/client/kit.css +55 -0
- package/client/kit.tsx +312 -0
- package/client/payment.tsx +121 -0
- package/client/portal.tsx +102 -0
- package/client/sign-in.tsx +90 -0
- package/dist/client/brand/fonts/Geist-Variable.woff2 +0 -0
- package/dist/client/brand/fonts/GeistMono-Variable.woff2 +0 -0
- package/dist/client/brand/fonts/InstrumentSerif-Italic.woff2 +0 -0
- package/dist/client/brand/fonts/InstrumentSerif-Regular.woff2 +0 -0
- package/dist/client/brand/fonts/LICENSES.txt +191 -0
- package/dist/client/brand/tokens.css +212 -0
- package/dist/client/consent.bundle.js +36 -0
- package/dist/client/consent.d.ts +47 -0
- package/dist/client/consent.js +24 -0
- package/dist/client/consent.tsx +96 -0
- package/dist/client/kit.bundle.js +235 -0
- package/dist/client/kit.css +55 -0
- package/dist/client/kit.d.ts +101 -0
- package/dist/client/kit.js +160 -0
- package/dist/client/kit.tsx +312 -0
- package/dist/client/payment.bundle.js +77 -0
- package/dist/client/payment.d.ts +57 -0
- package/dist/client/payment.js +55 -0
- package/dist/client/payment.tsx +121 -0
- package/dist/client/portal.bundle.js +39 -0
- package/dist/client/portal.d.ts +44 -0
- package/dist/client/portal.js +29 -0
- package/dist/client/portal.tsx +102 -0
- package/dist/client/sign-in.bundle.js +28 -0
- package/dist/client/sign-in.d.ts +43 -0
- package/dist/client/sign-in.js +28 -0
- package/dist/client/sign-in.tsx +90 -0
- package/dist/src/index.d.ts +33 -0
- package/dist/src/index.js +56 -0
- package/package.json +45 -0
- package/src/index.ts +72 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// AN OAUTH CONSENT — the hosted flow every OAuth vendor puts between an application and its user
|
|
2
|
+
// (docs/contributing/architecture.md, "Screens"): who asks, for which account, for what, and the two
|
|
3
|
+
// answers. The page is a plain form rendered on the server, so a click posts and the vendor's redirect
|
|
4
|
+
// follows with no script, as on the vendor's own page. A pack gives the words and the fields its
|
|
5
|
+
// documented round trip carries, and its skin gives the look; the controls carry the vendor's names so
|
|
6
|
+
// a test that finds them by role and name finds them.
|
|
7
|
+
export type ConsentPermission = { title: string; detail: string; help?: string };
|
|
8
|
+
|
|
9
|
+
export type ConsentProps = {
|
|
10
|
+
/** the page's heading, the vendor's words ("Authorize Acme") */
|
|
11
|
+
heading: string;
|
|
12
|
+
app: { name: string; by?: string };
|
|
13
|
+
/** the vendor's sentence under the app's name ("wants to access your octocat account") */
|
|
14
|
+
request: string;
|
|
15
|
+
permissions: ConsentPermission[];
|
|
16
|
+
/** where the form posts, and the fields the vendor's form carries */
|
|
17
|
+
action: string;
|
|
18
|
+
fields: Record<string, string>;
|
|
19
|
+
/** the two submit buttons: their field, value and label; without `allow` the person can only decline (a consent with
|
|
20
|
+
* nothing to grant, as Slack's with no channel for an incoming webhook) */
|
|
21
|
+
deny: { name: string; value: string; label: string };
|
|
22
|
+
allow?: { name: string; value: string; label: string };
|
|
23
|
+
/** under the buttons: where authorizing sends the person */
|
|
24
|
+
note?: string;
|
|
25
|
+
/** what the person picks before answering (Slack's channel an incoming webhook posts to): each a labelled select the
|
|
26
|
+
* form carries, as the payment and portal pieces carry theirs */
|
|
27
|
+
choices?: Array<{ id: string; label: string; value?: string; options: Array<{ value: string; label: string }> }>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function Consent(props: ConsentProps) {
|
|
31
|
+
return (
|
|
32
|
+
<main className="consent">
|
|
33
|
+
<div className="consent-badges" aria-hidden="true">
|
|
34
|
+
<span className="consent-badge">{props.app.name.slice(0, 1).toUpperCase()}</span>
|
|
35
|
+
<span className="consent-link" />
|
|
36
|
+
<span className="consent-badge consent-vendor" />
|
|
37
|
+
</div>
|
|
38
|
+
<h1 className="consent-heading">{props.heading}</h1>
|
|
39
|
+
<div className="consent-box">
|
|
40
|
+
<div className="consent-who">
|
|
41
|
+
<strong>{props.app.name}</strong>
|
|
42
|
+
{props.app.by ? <> by <strong>{props.app.by}</strong></> : null}
|
|
43
|
+
<small>{props.request}</small>
|
|
44
|
+
</div>
|
|
45
|
+
<ul className="consent-permissions">
|
|
46
|
+
{props.permissions.map((p) => (
|
|
47
|
+
<li key={p.title} className="consent-permission">
|
|
48
|
+
<details>
|
|
49
|
+
<summary>
|
|
50
|
+
<strong className="consent-permission-title">{p.title}</strong>
|
|
51
|
+
<small>{p.detail}</small>
|
|
52
|
+
</summary>
|
|
53
|
+
{p.help ? <p>{p.help}</p> : null}
|
|
54
|
+
</details>
|
|
55
|
+
</li>
|
|
56
|
+
))}
|
|
57
|
+
</ul>
|
|
58
|
+
<form method="post" action={props.action} className="consent-form">
|
|
59
|
+
{Object.entries(props.fields).map(([name, value]) => <input key={name} type="hidden" name={name} id={name} value={value} />)}
|
|
60
|
+
{(props.choices ?? []).map((c) => (
|
|
61
|
+
<label key={c.id} className="consent-choice" htmlFor={c.id}>
|
|
62
|
+
<span>{c.label}</span>
|
|
63
|
+
<select id={c.id} name={c.id} defaultValue={c.value}>{c.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}</select>
|
|
64
|
+
</label>
|
|
65
|
+
))}
|
|
66
|
+
<div className="consent-actions">
|
|
67
|
+
<button type="submit" name={props.deny.name} value={props.deny.value} className="consent-deny">{props.deny.label}</button>
|
|
68
|
+
{props.allow ? <button type="submit" name={props.allow.name} value={props.allow.value} className="consent-allow">{props.allow.label}</button> : null}
|
|
69
|
+
</div>
|
|
70
|
+
{props.note ? <p className="consent-note">{props.note}</p> : null}
|
|
71
|
+
</form>
|
|
72
|
+
</div>
|
|
73
|
+
</main>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The consent's layout; a pack's skin sets the colours and type over it. */
|
|
78
|
+
export const CONSENT_CSS = `
|
|
79
|
+
body { margin: 0; font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
80
|
+
.consent { max-width: 530px; margin: 0 auto; padding: 24px 16px; }
|
|
81
|
+
.consent-badges { display: flex; align-items: center; justify-content: space-between; width: 240px; margin: 16px auto; }
|
|
82
|
+
.consent-badge { display: grid; place-items: center; width: 64px; height: 64px; border-radius: 50%; font-size: 28px; font-weight: 600; }
|
|
83
|
+
.consent-link { flex: 1; margin: 0 12px; border-top: 2px dashed; }
|
|
84
|
+
.consent-heading { text-align: center; font-weight: 400; font-size: 24px; margin: 16px 0 24px; }
|
|
85
|
+
.consent-box { border: 1px solid; border-radius: 6px; padding: 16px 24px; }
|
|
86
|
+
.consent-who small { display: block; }
|
|
87
|
+
.consent-permissions { list-style: none; padding: 0; margin: 16px 0; }
|
|
88
|
+
.consent-permission { padding: 8px 0; }
|
|
89
|
+
.consent-permission summary { cursor: pointer; list-style: none; }
|
|
90
|
+
.consent-permission summary small { display: block; }
|
|
91
|
+
.consent-choice { display: flex; flex-direction: column; gap: 4px; margin: 8px 0 16px; font-weight: 600; }
|
|
92
|
+
.consent-choice select { font: inherit; font-weight: 400; padding: 6px; }
|
|
93
|
+
.consent-actions { display: flex; gap: 8px; padding-top: 16px; border-top: 1px solid; }
|
|
94
|
+
.consent-actions button { flex: 1; padding: 6px 16px; border-radius: 6px; font: inherit; font-weight: 500; cursor: pointer; border: 1px solid; }
|
|
95
|
+
.consent-note { text-align: center; font-size: 12px; margin: 12px 0 0; }
|
|
96
|
+
`;
|
package/client/kit.css
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/* The twin UI kit's base: layout and pieces. A pack's own stylesheet is its skin: it sets the
|
|
2
|
+
variables below (and anything else of the vendor's look) and is loaded after this one. Unskinned,
|
|
3
|
+
the kit wears the Volter brand's roles (company decision 0018): the shell links the brand's tokens
|
|
4
|
+
(assets/brand/tokens.css, fetched at build) before this sheet, and the pre-commit hook refuses a
|
|
5
|
+
literal colour here. */
|
|
6
|
+
:root {
|
|
7
|
+
--tw-accent: var(--volter-action-default);
|
|
8
|
+
--tw-bg: var(--volter-surface-page);
|
|
9
|
+
--tw-nav-bg: var(--volter-surface-subtle);
|
|
10
|
+
--tw-text: var(--volter-text-primary);
|
|
11
|
+
--tw-muted: var(--volter-text-muted);
|
|
12
|
+
--tw-border: var(--volter-border-default);
|
|
13
|
+
--tw-ok: var(--volter-status-healthy-text);
|
|
14
|
+
--tw-warn: var(--volter-status-attention-text);
|
|
15
|
+
--tw-bad: var(--volter-status-danger-text);
|
|
16
|
+
--tw-font: var(--volter-font-ui);
|
|
17
|
+
}
|
|
18
|
+
* { box-sizing: border-box; }
|
|
19
|
+
body { margin: 0; font-family: var(--tw-font); color: var(--tw-text); background: var(--tw-bg); }
|
|
20
|
+
button { font: inherit; }
|
|
21
|
+
.tw-app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
|
|
22
|
+
.tw-nav { background: var(--tw-nav-bg); border-right: 1px solid var(--tw-border); padding: 12px 8px; display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
|
|
23
|
+
.tw-nav button { text-align: left; background: none; border: 0; padding: 6px 10px; border-radius: 6px; color: var(--tw-text); cursor: pointer; display: flex; justify-content: space-between; }
|
|
24
|
+
.tw-nav button:hover { background: var(--volter-overlay-hover); }
|
|
25
|
+
.tw-nav .tw-on, .tw-nav .tw-on:hover { background: var(--tw-accent); color: var(--volter-text-on-strong); }
|
|
26
|
+
.tw-brand { font-weight: 700; font-size: 16px; margin-bottom: 8px; flex-direction: column; }
|
|
27
|
+
.tw-brand small { font-weight: 400; color: var(--tw-muted); font-size: 12px; }
|
|
28
|
+
.tw-count { color: var(--tw-muted); font-size: 12px; margin-left: 8px; }
|
|
29
|
+
.tw-main { padding: 20px 28px; overflow: auto; }
|
|
30
|
+
.tw-main h1 { font-size: 20px; display: flex; align-items: center; gap: 8px; }
|
|
31
|
+
.tw-list { list-style: none; padding: 0; margin: 0; border: 1px solid var(--tw-border); border-radius: 6px; }
|
|
32
|
+
.tw-list li + li { border-top: 1px solid var(--tw-border); }
|
|
33
|
+
.tw-list button { width: 100%; background: none; border: 0; padding: 10px 14px; display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; text-align: left; cursor: pointer; }
|
|
34
|
+
.tw-list button:hover { background: var(--tw-nav-bg); }
|
|
35
|
+
.tw-list span { color: var(--tw-muted); }
|
|
36
|
+
.tw-empty { padding: 12px 14px; color: var(--tw-muted); }
|
|
37
|
+
.tw-pill { font-size: 12px; padding: 1px 8px; border-radius: 10px; border: 1px solid currentColor; white-space: nowrap; }
|
|
38
|
+
.tw-pill-ok { color: var(--tw-ok); }
|
|
39
|
+
.tw-pill-warn { color: var(--tw-warn); }
|
|
40
|
+
.tw-pill-bad { color: var(--tw-bad); }
|
|
41
|
+
.tw-pill-muted { color: var(--tw-muted); }
|
|
42
|
+
.tw-ref { font: inherit; font-size: 13px; background: none; border: 1px solid var(--tw-border); border-radius: 4px; padding: 0 6px; color: var(--tw-accent); cursor: pointer; }
|
|
43
|
+
.tw-fields { margin: 12px 0; }
|
|
44
|
+
.tw-field { display: grid; grid-template-columns: 220px 1fr; gap: 12px; padding: 3px 0; border-bottom: 1px dashed var(--tw-border); }
|
|
45
|
+
.tw-field dt { color: var(--tw-muted); }
|
|
46
|
+
.tw-field dd { margin: 0; word-break: break-all; }
|
|
47
|
+
.tw-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
|
|
48
|
+
.tw-actions button, .tw-create button { background: var(--tw-accent); color: var(--volter-text-on-strong); border: 0; border-radius: 6px; padding: 6px 12px; cursor: pointer; text-transform: capitalize; }
|
|
49
|
+
.tw-create { display: flex; gap: 8px; flex-wrap: wrap; align-items: end; margin-bottom: 12px; }
|
|
50
|
+
.tw-create label { display: flex; flex-direction: column; font-size: 12px; color: var(--tw-muted); }
|
|
51
|
+
.tw-create input { border: 1px solid var(--tw-border); border-radius: 6px; padding: 5px 8px; font: inherit; }
|
|
52
|
+
.tw-back { background: none; border: 0; color: var(--tw-accent); cursor: pointer; padding: 0; }
|
|
53
|
+
.tw-error { color: var(--tw-bad); width: 100%; }
|
|
54
|
+
.tw-home ul { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; }
|
|
55
|
+
.tw-home button { width: 100%; text-align: left; background: var(--tw-nav-bg); border: 1px solid var(--tw-border); border-radius: 6px; padding: 12px; cursor: pointer; }
|
package/client/kit.tsx
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// THE TWIN UI KIT — shared pieces for a derived pack's screens (docs/contributing/architecture.md,
|
|
2
|
+
// "Screens"): the shell and navigation, lists and details, references, state pills, the moves a machine
|
|
3
|
+
// allows from a row's state, and create forms, all through the vendor's own API. A pack builds the
|
|
4
|
+
// screens people work in from these under its skin. It is not a vendor's UI on its own: TwinUi's
|
|
5
|
+
// generic resource browser renders any declared resource and so answers what a World holds, which a
|
|
6
|
+
// vendor's screens never pose as.
|
|
7
|
+
import React, { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
8
|
+
import { createRoot } from 'react-dom/client';
|
|
9
|
+
|
|
10
|
+
export type Row = Record<string, any>;
|
|
11
|
+
|
|
12
|
+
export type UiAction = { operation: string; method: string; path: string; field: string; from: string[] | '*'; to?: string };
|
|
13
|
+
export type UiResource = {
|
|
14
|
+
list?: string;
|
|
15
|
+
retrieve?: string;
|
|
16
|
+
create?: { path: string; encoding: string; body: Array<{ name: string; type: string; required: boolean }> };
|
|
17
|
+
actions: UiAction[];
|
|
18
|
+
states: Record<string, string[]>;
|
|
19
|
+
embeds: Record<string, string>;
|
|
20
|
+
};
|
|
21
|
+
export type UiData = { resources: Record<string, UiResource> };
|
|
22
|
+
|
|
23
|
+
/** One entry of the navigation: a resource's list, titled the vendor's way. */
|
|
24
|
+
export type Section = {
|
|
25
|
+
resource: string;
|
|
26
|
+
label: string;
|
|
27
|
+
/** where it is listed, when not the resource's own list (a sub-collection) */
|
|
28
|
+
list?: string;
|
|
29
|
+
title?: (row: Row) => string;
|
|
30
|
+
subtitle?: (row: Row) => string;
|
|
31
|
+
/** what the vendor's detail page shows beyond the fields (a balance panel, an error banner) */
|
|
32
|
+
extras?: (row: Row) => ReactNode;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** A screen of the vendor's UI that is not a resource view (a chat, a board, a canvas). */
|
|
36
|
+
export type Screen = { key: string; label: string; render: (api: Api) => ReactNode };
|
|
37
|
+
|
|
38
|
+
export type TwinUiConfig = {
|
|
39
|
+
skin: { name: string; product?: string };
|
|
40
|
+
ui: UiData;
|
|
41
|
+
sections: Section[];
|
|
42
|
+
screens?: Screen[];
|
|
43
|
+
/** headers every call carries (the fake credential the vendor's API asks for) */
|
|
44
|
+
headers?: Record<string, string>;
|
|
45
|
+
/** how the vendor's lists hold their rows: `data` (Stripe, OpenAI), the whole body (GitHub), or a key */
|
|
46
|
+
rowsAt?: (body: any, resource: string) => Row[];
|
|
47
|
+
/** a value's tone for its status pill */
|
|
48
|
+
tone?: (value: string) => 'ok' | 'warn' | 'bad' | 'muted';
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ── talking to the vendor's API ────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
const BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
|
|
54
|
+
|
|
55
|
+
export type Api = {
|
|
56
|
+
get: (path: string) => Promise<any>;
|
|
57
|
+
send: (method: string, path: string, body?: Record<string, unknown>, encoding?: string) => Promise<{ status: number; body: any }>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function formEncode(value: unknown, prefix: string, out: URLSearchParams): void {
|
|
61
|
+
if (value === undefined || value === '') return;
|
|
62
|
+
if (value && typeof value === 'object') {
|
|
63
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) formEncode(v, prefix ? `${prefix}[${k}]` : k, out);
|
|
64
|
+
} else out.append(prefix, String(value));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function makeApi(headers: Record<string, string> = {}): Api {
|
|
68
|
+
return {
|
|
69
|
+
get: (path) => fetch(`${BASE}${path}`, { headers }).then((r) => r.json()),
|
|
70
|
+
send: async (method, path, body, encoding = 'json') => {
|
|
71
|
+
const init: RequestInit = { method, headers: { ...headers } };
|
|
72
|
+
if (body && Object.keys(body).length) {
|
|
73
|
+
if (encoding === 'form') {
|
|
74
|
+
const p = new URLSearchParams();
|
|
75
|
+
formEncode(body, '', p);
|
|
76
|
+
init.body = p.toString();
|
|
77
|
+
(init.headers as Record<string, string>)['content-type'] = 'application/x-www-form-urlencoded';
|
|
78
|
+
} else {
|
|
79
|
+
init.body = JSON.stringify(body);
|
|
80
|
+
(init.headers as Record<string, string>)['content-type'] = 'application/json';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const r = await fetch(`${BASE}${path}`, init);
|
|
84
|
+
const text = await r.text();
|
|
85
|
+
let parsed: any = text;
|
|
86
|
+
try { parsed = text ? JSON.parse(text) : null; } catch { /* a non-JSON answer stays text */ }
|
|
87
|
+
return { status: r.status, body: parsed };
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const fill = (path: string, id: string): string => path.replace(/\{[^}]+\}/, encodeURIComponent(id));
|
|
93
|
+
const defaultRows = (body: any): Row[] => (Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : []);
|
|
94
|
+
|
|
95
|
+
// ── pieces ───────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export function StatusPill({ value, tone }: { value: unknown; tone?: TwinUiConfig['tone'] }) {
|
|
98
|
+
if (value === undefined || value === null || value === '') return null;
|
|
99
|
+
const t = tone?.(String(value)) ?? (/(succeeded|paid|active|open|completed|ok|true)/.test(String(value)) ? 'ok' : /(fail|canceled|cancelled|void|closed|expired|error|false)/.test(String(value)) ? 'bad' : 'muted');
|
|
100
|
+
return <span className={`tw-pill tw-pill-${t}`}>{String(value)}</span>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function RefChip({ label, onJump }: { label: string; onJump: () => void }) {
|
|
104
|
+
return (
|
|
105
|
+
<button type="button" className="tw-ref" onClick={onJump}>
|
|
106
|
+
{label}
|
|
107
|
+
</button>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** An object as the vendor's detail page lists it: one line per field, nested objects indented, references as chips. */
|
|
112
|
+
export function Fields({ row, embeds, onJump }: { row: Row; embeds: Record<string, string>; onJump: (resource: string, id: string) => void }) {
|
|
113
|
+
const lines: Array<{ key: string; depth: number; value: ReactNode }> = [];
|
|
114
|
+
const walk = (obj: Row, depth: number, prefix: string) => {
|
|
115
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
116
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
117
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
118
|
+
lines.push({ key, depth, value: '' });
|
|
119
|
+
walk(v, depth + 1, key);
|
|
120
|
+
} else if (depth === 0 && embeds[k] && typeof v === 'string') {
|
|
121
|
+
lines.push({ key, depth, value: <RefChip label={v} onJump={() => onJump(embeds[k]!, v)} /> });
|
|
122
|
+
} else lines.push({ key, depth, value: Array.isArray(v) ? JSON.stringify(v) : String(v) });
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
walk(row, 0, '');
|
|
126
|
+
return (
|
|
127
|
+
<dl className="tw-fields">
|
|
128
|
+
{lines.map((l) => (
|
|
129
|
+
<div key={l.key} className="tw-field" style={{ paddingLeft: `${l.depth * 16}px` }}>
|
|
130
|
+
<dt>{l.key.split('.').at(-1)}</dt>
|
|
131
|
+
<dd>{l.value}</dd>
|
|
132
|
+
</div>
|
|
133
|
+
))}
|
|
134
|
+
</dl>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The moves the resource's machine allows from where this row stands, as buttons. */
|
|
139
|
+
export function Actions({ resource, row, api, onDone }: { resource: UiResource; row: Row; api: Api; onDone: () => void }) {
|
|
140
|
+
const [error, setError] = useState<string | null>(null);
|
|
141
|
+
const legal = resource.actions.filter((a) => a.from === '*' || a.from.includes(String(row[a.field])));
|
|
142
|
+
const seen = new Set<string>();
|
|
143
|
+
const unique = legal.filter((a) => (seen.has(a.operation) ? false : (seen.add(a.operation), true)));
|
|
144
|
+
if (!unique.length) return null;
|
|
145
|
+
return (
|
|
146
|
+
<div className="tw-actions">
|
|
147
|
+
{unique.map((a) => (
|
|
148
|
+
<button
|
|
149
|
+
key={a.operation}
|
|
150
|
+
type="button"
|
|
151
|
+
onClick={async () => {
|
|
152
|
+
setError(null);
|
|
153
|
+
const r = await api.send(a.method, fill(a.path, String(row.id)), undefined, resource.create?.encoding);
|
|
154
|
+
if (r.status >= 400) setError(r.body?.error?.message ?? r.body?.message ?? r.body?.error ?? `HTTP ${r.status}`);
|
|
155
|
+
onDone();
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
{a.path.split('/').at(-1)!.replace(/[_-]/g, ' ')}
|
|
159
|
+
</button>
|
|
160
|
+
))}
|
|
161
|
+
{error ? <p className="tw-error">{String(error)}</p> : null}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A form for the resource's create operation: its required parameters, and any others the caller fills. */
|
|
167
|
+
export function CreateForm({ resource, api, onCreated }: { resource: UiResource; api: Api; onCreated: (row: Row) => void }) {
|
|
168
|
+
const create = resource.create;
|
|
169
|
+
const [values, setValues] = useState<Record<string, string>>({});
|
|
170
|
+
const [error, setError] = useState<string | null>(null);
|
|
171
|
+
if (!create) return null;
|
|
172
|
+
const fields = create.body.filter((b) => b.required || ['name', 'email', 'description', 'title', 'amount', 'currency'].includes(b.name));
|
|
173
|
+
return (
|
|
174
|
+
<form
|
|
175
|
+
className="tw-create"
|
|
176
|
+
onSubmit={async (e) => {
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
setError(null);
|
|
179
|
+
const r = await api.send('POST', create.path, values, create.encoding);
|
|
180
|
+
if (r.status >= 400) setError(r.body?.error?.message ?? r.body?.message ?? `HTTP ${r.status}`);
|
|
181
|
+
else onCreated(r.body);
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
{fields.map((f) => (
|
|
185
|
+
<label key={f.name}>
|
|
186
|
+
{f.name}
|
|
187
|
+
{f.required ? ' *' : ''}
|
|
188
|
+
<input value={values[f.name] ?? ''} onChange={(e) => setValues({ ...values, [f.name]: e.target.value })} />
|
|
189
|
+
</label>
|
|
190
|
+
))}
|
|
191
|
+
<button type="submit">Create</button>
|
|
192
|
+
{error ? <p className="tw-error">{String(error)}</p> : null}
|
|
193
|
+
</form>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── the app ──────────────────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
type Route = { kind: 'home' } | { kind: 'list'; section: Section } | { kind: 'detail'; section: Section; id: string } | { kind: 'screen'; screen: Screen };
|
|
200
|
+
|
|
201
|
+
export function TwinUi({ config }: { config: TwinUiConfig }) {
|
|
202
|
+
const api = useMemo(() => makeApi(config.headers), [config.headers]);
|
|
203
|
+
const [route, setRoute] = useState<Route>({ kind: 'home' });
|
|
204
|
+
const [rows, setRows] = useState<Row[]>([]);
|
|
205
|
+
const [detail, setDetail] = useState<Row | null>(null);
|
|
206
|
+
const [counts, setCounts] = useState<Record<string, number>>({});
|
|
207
|
+
const [tick, setTick] = useState(0);
|
|
208
|
+
const rowsOf = config.rowsAt ?? defaultRows;
|
|
209
|
+
const listPath = (s: Section) => s.list ?? config.ui.resources[s.resource]?.list;
|
|
210
|
+
const bySection = (resource: string) => config.sections.find((s) => s.resource === resource);
|
|
211
|
+
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
for (const s of config.sections) {
|
|
214
|
+
const p = listPath(s);
|
|
215
|
+
if (p && !p.includes('{')) api.get(p).then((b) => setCounts((c) => ({ ...c, [s.label]: rowsOf(b, s.resource).length }))).catch(() => undefined);
|
|
216
|
+
}
|
|
217
|
+
}, [tick]);
|
|
218
|
+
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (route.kind === 'list') {
|
|
221
|
+
const p = listPath(route.section);
|
|
222
|
+
if (p) api.get(p).then((b) => setRows(rowsOf(b, route.section.resource))).catch(() => setRows([]));
|
|
223
|
+
}
|
|
224
|
+
if (route.kind === 'detail') {
|
|
225
|
+
const r = config.ui.resources[route.section.resource];
|
|
226
|
+
const fromList = rows.find((x) => String(x.id) === route.id) ?? null;
|
|
227
|
+
if (r?.retrieve && (r.retrieve.match(/\{/g) ?? []).length === 1) api.get(fill(r.retrieve, route.id)).then(setDetail).catch(() => setDetail(fromList));
|
|
228
|
+
else setDetail(fromList);
|
|
229
|
+
}
|
|
230
|
+
}, [route, tick]);
|
|
231
|
+
|
|
232
|
+
const jump = useCallback((resource: string, id: string) => {
|
|
233
|
+
const s = bySection(resource);
|
|
234
|
+
if (s) setRoute({ kind: 'detail', section: s, id });
|
|
235
|
+
}, []);
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<div className="tw-app">
|
|
239
|
+
<nav className="tw-nav">
|
|
240
|
+
<button type="button" className="tw-brand" onClick={() => setRoute({ kind: 'home' })}>
|
|
241
|
+
{config.skin.name}
|
|
242
|
+
{config.skin.product ? <small>{config.skin.product}</small> : null}
|
|
243
|
+
</button>
|
|
244
|
+
{(config.screens ?? []).map((s) => (
|
|
245
|
+
<button key={s.key} type="button" className={route.kind === 'screen' && route.screen.key === s.key ? 'tw-on' : ''} onClick={() => setRoute({ kind: 'screen', screen: s })}>
|
|
246
|
+
{s.label}
|
|
247
|
+
</button>
|
|
248
|
+
))}
|
|
249
|
+
{config.sections.map((s) => (
|
|
250
|
+
<button key={s.label} type="button" className={route.kind !== 'home' && route.kind !== 'screen' && route.section.label === s.label ? 'tw-on' : ''} onClick={() => setRoute({ kind: 'list', section: s })}>
|
|
251
|
+
{s.label}
|
|
252
|
+
{counts[s.label] !== undefined ? <span className="tw-count">{counts[s.label]}</span> : null}
|
|
253
|
+
</button>
|
|
254
|
+
))}
|
|
255
|
+
</nav>
|
|
256
|
+
<main className="tw-main">
|
|
257
|
+
{route.kind === 'home' ? (
|
|
258
|
+
<section className="tw-home">
|
|
259
|
+
<h1>{config.skin.name}</h1>
|
|
260
|
+
<ul>
|
|
261
|
+
{config.sections.map((s) => (
|
|
262
|
+
<li key={s.label}>
|
|
263
|
+
<button type="button" onClick={() => setRoute({ kind: 'list', section: s })}>
|
|
264
|
+
{s.label} <span className="tw-count">{counts[s.label] ?? '…'}</span>
|
|
265
|
+
</button>
|
|
266
|
+
</li>
|
|
267
|
+
))}
|
|
268
|
+
</ul>
|
|
269
|
+
</section>
|
|
270
|
+
) : null}
|
|
271
|
+
{route.kind === 'screen' ? route.screen.render(api) : null}
|
|
272
|
+
{route.kind === 'list' ? (
|
|
273
|
+
<section>
|
|
274
|
+
<h1>{route.section.label}</h1>
|
|
275
|
+
{config.ui.resources[route.section.resource] ? <CreateForm resource={config.ui.resources[route.section.resource]!} api={api} onCreated={() => setTick((t) => t + 1)} /> : null}
|
|
276
|
+
<ul className="tw-list">
|
|
277
|
+
{rows.map((r) => (
|
|
278
|
+
<li key={String(r.id)}>
|
|
279
|
+
<button type="button" onClick={() => setRoute({ kind: 'detail', section: route.section, id: String(r.id) })}>
|
|
280
|
+
<strong>{route.section.title?.(r) ?? String(r.name ?? r.title ?? r.id)}</strong>
|
|
281
|
+
<span>{route.section.subtitle?.(r) ?? String(r.id)}</span>
|
|
282
|
+
<StatusPill value={r.status ?? r.state} tone={config.tone} />
|
|
283
|
+
</button>
|
|
284
|
+
</li>
|
|
285
|
+
))}
|
|
286
|
+
{rows.length === 0 ? <li className="tw-empty">None yet.</li> : null}
|
|
287
|
+
</ul>
|
|
288
|
+
</section>
|
|
289
|
+
) : null}
|
|
290
|
+
{route.kind === 'detail' && detail ? (
|
|
291
|
+
<section>
|
|
292
|
+
<button type="button" className="tw-back" onClick={() => setRoute({ kind: 'list', section: route.section })}>
|
|
293
|
+
← {route.section.label}
|
|
294
|
+
</button>
|
|
295
|
+
<h1>
|
|
296
|
+
{route.section.title?.(detail) ?? String(detail.name ?? detail.title ?? detail.id)} <StatusPill value={detail.status ?? detail.state} tone={config.tone} />
|
|
297
|
+
</h1>
|
|
298
|
+
{route.section.extras?.(detail)}
|
|
299
|
+
{config.ui.resources[route.section.resource] ? <Actions resource={config.ui.resources[route.section.resource]!} row={detail} api={api} onDone={() => setTick((t) => t + 1)} /> : null}
|
|
300
|
+
<Fields row={detail} embeds={config.ui.resources[route.section.resource]?.embeds ?? {}} onJump={jump} />
|
|
301
|
+
</section>
|
|
302
|
+
) : null}
|
|
303
|
+
</main>
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Mount a pack's UI into the shell's #root. */
|
|
309
|
+
export function mountTwinUi(config: TwinUiConfig): void {
|
|
310
|
+
if (typeof document === 'undefined') return;
|
|
311
|
+
createRoot(document.getElementById('root')!).render(<TwinUi config={config} />);
|
|
312
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// A HOSTED PAYMENT PAGE — the hosted flow a payments vendor puts between an application's "Buy" and its
|
|
2
|
+
// success page (docs/contributing/architecture.md, "Screens"): what is being paid for, the fields the
|
|
3
|
+
// customer fills, one submit, and the way back. A plain form rendered on the server: the submit posts and
|
|
4
|
+
// the vendor's redirect follows with no script. A pack names its fields and controls the vendor's way
|
|
5
|
+
// (ids, names, labels), since applications' tests fill a vendor's payment fields by those names; its skin
|
|
6
|
+
// gives the look.
|
|
7
|
+
export type PaymentField = {
|
|
8
|
+
id: string; label: string; type?: string; placeholder?: string; autoComplete?: string; value?: string; options?: Array<{ value: string; label: string }>;
|
|
9
|
+
/** a field that formats itself as it is typed, as payment vendors' card fields do: `card-expiry` reads the digits
|
|
10
|
+
* `1230` as `12 / 30` (FORMAT_SCRIPT). The page still posts without a script; the pack reads what is posted. */
|
|
11
|
+
format?: 'card-expiry';
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type PaymentProps = {
|
|
15
|
+
merchant: string;
|
|
16
|
+
/** what is paid for, formatted the vendor's way */
|
|
17
|
+
lines: Array<{ name: string; amount: string; detail?: string }>;
|
|
18
|
+
/** the lead of the summary: a label over the amount, and a line under it ("Then $12.00 per month") */
|
|
19
|
+
total?: { label: string; amount: string; detail?: string };
|
|
20
|
+
action: string;
|
|
21
|
+
/** sections of fields, each under its heading ("Contact information", "Payment method") */
|
|
22
|
+
sections: Array<{ heading: string; fields: PaymentField[] }>;
|
|
23
|
+
submit: { label: string; testId?: string };
|
|
24
|
+
back?: { href: string; label: string };
|
|
25
|
+
/** the vendor's answer to the last submit, shown above the button */
|
|
26
|
+
error?: string;
|
|
27
|
+
/** terms shown under the button (what a trial's end will charge) */
|
|
28
|
+
note?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Formats a `format`ted field as it is typed, a progressive enhancement over the plain form. `card-expiry`, as a card
|
|
32
|
+
* form's expiry reads: four digits at most, ` / ` after the month; a first digit above 1 is the month on its own
|
|
33
|
+
* (`5` → `05 / `), as is a single digit followed by a slash (`1/` → `01 / `); a pasted or filled `12/2030` or
|
|
34
|
+
* `122030` keeps the year's last two digits; deleting the year's last digit leaves `12 / `, and deleting back into
|
|
35
|
+
* the separator removes it. */
|
|
36
|
+
export const FORMAT_SCRIPT = `document.querySelectorAll('input[data-format="card-expiry"]').forEach(function (el) {
|
|
37
|
+
el.addEventListener('input', function (e) {
|
|
38
|
+
var raw = el.value, deleting = typeof e.inputType === 'string' && e.inputType.indexOf('delete') === 0, mm, yy;
|
|
39
|
+
var cut = raw.indexOf('/');
|
|
40
|
+
if (cut >= 0) {
|
|
41
|
+
mm = raw.slice(0, cut).replace(/\\D/g, '').slice(0, 2);
|
|
42
|
+
yy = raw.slice(cut + 1).replace(/\\D/g, '');
|
|
43
|
+
if (mm.length === 1 && mm !== '0') mm = '0' + mm;
|
|
44
|
+
} else {
|
|
45
|
+
var d = raw.replace(/\\D/g, '');
|
|
46
|
+
if (d.length === 6 && d.slice(2, 4) === '20') d = d.slice(0, 2) + d.slice(4);
|
|
47
|
+
if (d.length >= 1 && d.charAt(0) > '1') d = '0' + d;
|
|
48
|
+
mm = d.slice(0, 2);
|
|
49
|
+
yy = d.slice(2);
|
|
50
|
+
}
|
|
51
|
+
yy = yy.length === 4 ? yy.slice(2) : yy.slice(0, 2);
|
|
52
|
+
el.value = mm.length < 2 || (deleting && !yy && raw.slice(-2) !== '/ ') ? mm : mm + ' / ' + yy;
|
|
53
|
+
});
|
|
54
|
+
});`;
|
|
55
|
+
|
|
56
|
+
export function Payment(props: PaymentProps) {
|
|
57
|
+
return (
|
|
58
|
+
<div className="pay">
|
|
59
|
+
<section className="pay-summary">
|
|
60
|
+
{props.back ? <a className="pay-back" href={props.back.href}>{props.back.label}</a> : null}
|
|
61
|
+
<div className="pay-merchant">{props.merchant}</div>
|
|
62
|
+
{props.total ? <div className="pay-total-lead"><span>{props.total.label}</span><strong>{props.total.amount}</strong>{props.total.detail ? <small className="pay-total-detail">{props.total.detail}</small> : null}</div> : null}
|
|
63
|
+
<ul className="pay-lines">
|
|
64
|
+
{props.lines.map((l, i) => (
|
|
65
|
+
<li key={`${l.name}:${i}`} className="pay-line">
|
|
66
|
+
<span className="pay-line-name">{l.name}{l.detail ? <small>{l.detail}</small> : null}</span>
|
|
67
|
+
<span className="pay-line-amount">{l.amount}</span>
|
|
68
|
+
</li>
|
|
69
|
+
))}
|
|
70
|
+
</ul>
|
|
71
|
+
</section>
|
|
72
|
+
<main className="pay-form">
|
|
73
|
+
<form method="post" action={props.action}>
|
|
74
|
+
{props.sections.map((s) => (
|
|
75
|
+
<fieldset key={s.heading} className="pay-section">
|
|
76
|
+
<legend>{s.heading}</legend>
|
|
77
|
+
{s.fields.map((f) => (
|
|
78
|
+
<label key={f.id} className="pay-field" htmlFor={f.id}>
|
|
79
|
+
<span>{f.label}</span>
|
|
80
|
+
{f.options
|
|
81
|
+
? <select id={f.id} name={f.id} defaultValue={f.value}>{f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}</select>
|
|
82
|
+
: <input id={f.id} name={f.id} type={f.type ?? 'text'} placeholder={f.placeholder} autoComplete={f.autoComplete} defaultValue={f.value} {...(f.format ? { 'data-format': f.format, inputMode: 'numeric' as const } : {})} />}
|
|
83
|
+
</label>
|
|
84
|
+
))}
|
|
85
|
+
</fieldset>
|
|
86
|
+
))}
|
|
87
|
+
{props.error ? <p className="pay-error" role="alert">{props.error}</p> : null}
|
|
88
|
+
<button type="submit" className="pay-submit SubmitButton" data-testid={props.submit.testId}>{props.submit.label}</button>
|
|
89
|
+
{props.note ? <p className="pay-note">{props.note}</p> : null}
|
|
90
|
+
</form>
|
|
91
|
+
{props.sections.some((s) => s.fields.some((f) => f.format)) ? <script dangerouslySetInnerHTML={{ __html: FORMAT_SCRIPT }} /> : null}
|
|
92
|
+
</main>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The payment page's layout; a pack's skin sets the colours and type over it. */
|
|
98
|
+
export const PAYMENT_CSS = `
|
|
99
|
+
body { margin: 0; font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
100
|
+
.pay { display: grid; grid-template-columns: 1fr 1fr; min-height: 100vh; }
|
|
101
|
+
@media (max-width: 800px) { .pay { grid-template-columns: 1fr; } }
|
|
102
|
+
.pay-summary { padding: 48px 48px 24px; }
|
|
103
|
+
.pay-back { display: inline-block; margin-bottom: 24px; text-decoration: none; }
|
|
104
|
+
.pay-merchant { font-weight: 500; margin-bottom: 16px; }
|
|
105
|
+
.pay-total-lead span { display: block; }
|
|
106
|
+
.pay-total-lead strong { display: block; font-size: 36px; font-weight: 600; }
|
|
107
|
+
.pay-total-lead { margin-bottom: 24px; }
|
|
108
|
+
.pay-total-detail { display: block; }
|
|
109
|
+
.pay-note { margin: 12px 0 0; font-size: 13px; text-align: center; }
|
|
110
|
+
.pay-lines { list-style: none; padding: 0; margin: 0; }
|
|
111
|
+
.pay-line { display: flex; justify-content: space-between; padding: 8px 0; }
|
|
112
|
+
.pay-line small { display: block; }
|
|
113
|
+
.pay-form { padding: 48px; }
|
|
114
|
+
.pay-section { border: 0; padding: 0; margin: 0 0 24px; }
|
|
115
|
+
.pay-section legend { font-weight: 500; margin-bottom: 8px; }
|
|
116
|
+
.pay-field { display: block; margin-bottom: 12px; }
|
|
117
|
+
.pay-field span { display: block; font-size: 13px; margin-bottom: 4px; }
|
|
118
|
+
.pay-field input, .pay-field select { box-sizing: border-box; width: 100%; padding: 8px 12px; border-radius: 6px; border: 1px solid; font: inherit; }
|
|
119
|
+
.pay-error { margin: 0 0 12px; }
|
|
120
|
+
.pay-submit { width: 100%; padding: 12px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer; }
|
|
121
|
+
`;
|