@proveanything/smartlinks 2.0.60 → 2.0.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,7 @@ If you're new to the SDK, this is the easiest path:
28
28
  - [docs/translations.md](docs/translations.md) — runtime translation lookup, browser-side caching, and translation admin flows
29
29
  - [docs/widgets.md](docs/widgets.md) — embeddable React components
30
30
  - [docs/realtime.md](docs/realtime.md) — subscriptions and live updates
31
+ - [docs/admin-cards.md](docs/admin-cards.md) — cards on the collection's Studio dashboard
31
32
  - [docs/iframe-responder.md](docs/iframe-responder.md) — iframe integration and parent/child messaging
32
33
  - [docs/utils.md](docs/utils.md) — helper utilities and portal path generation
33
34
 
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Trim a card function's return value to what a card shows — the same rules SmartLinks applies
3
+ * before caching it (prove services/adminCards normalisePayload), so a preview shows exactly what the
4
+ * dashboard will. Unknown fields are dropped, text is shortened, lists are capped, and only `/paths`
5
+ * and `https:` links survive. Throws when the input isn't an object or the result is over 8KB.
6
+ */
7
+ import type { AdminCardPayload } from './types/adminCards.js';
8
+ export declare const ADMIN_CARD_MAX_BYTES: number;
9
+ export declare function normaliseAdminCardPayload(raw: unknown): AdminCardPayload;
@@ -0,0 +1,61 @@
1
+ export const ADMIN_CARD_MAX_BYTES = 8 * 1024;
2
+ const TONES = ['ok', 'attention', 'neutral', 'error'];
3
+ const DIRECTIONS = ['up', 'down', 'flat'];
4
+ const str = (v, max) => String(v).slice(0, max);
5
+ function action(a) {
6
+ if (!a || typeof a !== 'object' || !a.label)
7
+ return undefined;
8
+ const out = { label: str(a.label, 40) };
9
+ if (typeof a.path === 'string' && a.path.startsWith('/'))
10
+ out.path = a.path.slice(0, 300);
11
+ else if (typeof a.url === 'string' && /^https:\/\//i.test(a.url))
12
+ out.url = a.url.slice(0, 500);
13
+ else
14
+ return undefined;
15
+ return out;
16
+ }
17
+ function byteLength(text) {
18
+ return typeof TextEncoder !== 'undefined' ? new TextEncoder().encode(text).length : text.length;
19
+ }
20
+ export function normaliseAdminCardPayload(raw) {
21
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
22
+ throw new Error('The card function must return an object (an AdminCardPayload).');
23
+ const r = raw;
24
+ const p = {};
25
+ if (r.title != null)
26
+ p.title = str(r.title, 80);
27
+ if (typeof r.value === 'number' && Number.isFinite(r.value))
28
+ p.value = r.value;
29
+ else if (r.value != null)
30
+ p.value = str(r.value, 40);
31
+ if (r.label != null)
32
+ p.label = str(r.label, 80);
33
+ if (r.unit != null)
34
+ p.unit = str(r.unit, 12);
35
+ if (r.trend && DIRECTIONS.includes(r.trend.direction)) {
36
+ p.trend = Object.assign(Object.assign({ direction: r.trend.direction }, (r.trend.label != null ? { label: str(r.trend.label, 60) } : {})), (typeof r.trend.good === 'boolean' ? { good: r.trend.good } : {}));
37
+ }
38
+ if (r.status && TONES.includes(r.status.tone) && r.status.text)
39
+ p.status = { tone: r.status.tone, text: str(r.status.text, 140) };
40
+ if (r.progress && Number.isFinite(r.progress.done) && Number.isFinite(r.progress.total) && r.progress.total > 0) {
41
+ p.progress = Object.assign({ done: r.progress.done, total: r.progress.total }, (r.progress.label != null ? { label: str(r.progress.label, 60) } : {}));
42
+ }
43
+ if (Array.isArray(r.items)) {
44
+ p.items = r.items.filter((i) => i && i.title != null).slice(0, 5).map((i) => (Object.assign(Object.assign(Object.assign({ title: str(i.title, 120) }, (i.subtitle != null ? { subtitle: str(i.subtitle, 120) } : {})), (i.meta != null ? { meta: str(i.meta, 60) } : {})), (typeof i.path === 'string' && i.path.startsWith('/') ? { path: i.path.slice(0, 300) } : {}))));
45
+ }
46
+ if (Array.isArray(r.todo)) {
47
+ p.todo = r.todo.filter((t) => t && t.text).slice(0, 5).map((t) => {
48
+ const a = action(t.action);
49
+ return Object.assign({ text: str(t.text, 140) }, (a ? { action: a } : {}));
50
+ });
51
+ }
52
+ if (Array.isArray(r.actions))
53
+ p.actions = r.actions.map(action).filter(Boolean).slice(0, 2);
54
+ if (r.empty && r.empty.text) {
55
+ const a = action(r.empty.action);
56
+ p.empty = Object.assign({ text: str(r.empty.text, 140) }, (a ? { action: a } : {}));
57
+ }
58
+ if (byteLength(JSON.stringify(p)) > ADMIN_CARD_MAX_BYTES)
59
+ throw new Error(`The card's payload is over ${ADMIN_CARD_MAX_BYTES / 1024}KB — return fewer or shorter items.`);
60
+ return p;
61
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The admin card RENDERER — one look for a card everywhere it's drawn (Studio's dashboard, Forge's
3
+ * preview), so what a developer previews is what the business sees. docs/admin-cards.md.
4
+ *
5
+ * Framework-light on purpose: the host passes its own React, so the SDK has no React dependency and
6
+ * a page never gets a second copy of React.
7
+ *
8
+ * ```tsx
9
+ * import React from 'react'
10
+ * import { createAdminCardViews } from '@proveanything/smartlinks'
11
+ * const { AdminCardView, AdminCardGrid, AdminCardTodo } = createAdminCardViews(React)
12
+ *
13
+ * <AdminCardTodo cards={cards} onAction={go} />
14
+ * <AdminCardGrid>
15
+ * {cards.map((c) => <AdminCardView key={c.key} card={c} onAction={go} onRefresh={refresh} />)}
16
+ * </AdminCardGrid>
17
+ * ```
18
+ *
19
+ * Styling: plain classes (`slc-*`) injected once, coloured by CSS variables that default to the SDK
20
+ * theme tokens (`--sl-color-*`) — set `--slc-*` on a parent to fit any host.
21
+ */
22
+ import type { AdminCard, AdminCardAction } from './types/adminCards.js';
23
+ type Node = any;
24
+ interface ReactLike {
25
+ createElement: (type: any, props?: any, ...children: any[]) => Node;
26
+ Fragment: any;
27
+ useEffect: (fn: () => void | (() => void), deps?: unknown[]) => void;
28
+ }
29
+ export interface AdminCardViewProps {
30
+ card: Pick<AdminCard, 'title' | 'size' | 'state' | 'payload' | 'computedAt'> & Partial<AdminCard>;
31
+ /** A card button or list row was clicked: `path` = open the app's admin there; `url` = open it. */
32
+ onAction?: (action: AdminCardAction, card: AdminCardViewProps['card']) => void;
33
+ /** Show a refresh button that calls this. */
34
+ onRefresh?: (card: AdminCardViewProps['card']) => void;
35
+ /** The refresh is running (spins the button). */
36
+ refreshing?: boolean;
37
+ /** Component cards: mount the app's component (the host loads its bundle). */
38
+ renderComponent?: (card: AdminCardViewProps['card']) => Node;
39
+ /** For number formatting. Defaults to the browser's. */
40
+ lang?: string;
41
+ /** "Now", for the "updated 5 min ago" line (tests, server render). */
42
+ now?: number;
43
+ className?: string;
44
+ }
45
+ export interface AdminCardTodoItem {
46
+ card: AdminCardViewProps['card'];
47
+ text: string;
48
+ tone: 'attention' | 'error';
49
+ action?: AdminCardAction;
50
+ }
51
+ /** The To do strip's items: every card's `todo`, plus attention/error statuses without one. */
52
+ export declare function collectAdminCardTodo(cards: Array<AdminCardViewProps['card']>): AdminCardTodoItem[];
53
+ /** "just now" / "5 min ago" / "3 h ago" / "2 d ago". */
54
+ export declare function adminCardAge(at: number | null | undefined, now?: number): string;
55
+ /** Add the card styles to the page once (the views call it; hosts rendering on the server can too). */
56
+ export declare function injectAdminCardStyles(doc?: Document | undefined): void;
57
+ /** The card stylesheet, for hosts that prefer to ship it themselves. */
58
+ export declare const ADMIN_CARD_CSS = "\n.slc-grid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:var(--slc-gap,16px)}\n.slc-grid>*{grid-column:span 12}\n@media (min-width:768px){.slc-grid>.slc-size-small{grid-column:span 6}.slc-grid>.slc-size-medium,.slc-grid>.slc-size-wide{grid-column:span 12}}\n@media (min-width:1100px){.slc-grid>.slc-size-small{grid-column:span 3}.slc-grid>.slc-size-medium{grid-column:span 6}.slc-grid>.slc-size-wide{grid-column:span 8}}\n.slc-card{--_bg:var(--slc-bg,var(--sl-color-surface,#fff));--_fg:var(--slc-fg,var(--sl-color-fg,#0f172a));--_muted:var(--slc-muted,var(--sl-color-muted,#64748b));--_border:var(--slc-border,var(--sl-color-border,#e2e8f0));--_accent:var(--slc-accent,var(--sl-color-accent,#2563eb));--_ok:var(--slc-ok,var(--sl-color-success,#059669));--_warn:var(--slc-attention,#b45309);--_err:var(--slc-error,var(--sl-color-danger,#dc2626));\n box-sizing:border-box;display:flex;flex-direction:column;gap:12px;min-height:148px;padding:16px 18px;border:1px solid var(--_border);border-radius:var(--slc-radius,var(--sl-radius-lg,14px));background:var(--_bg);color:var(--_fg);font:inherit;position:relative}\n.slc-head{display:flex;align-items:center;gap:8px;min-width:0}\n.slc-title{margin:0;font-size:.95rem;font-weight:600;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.slc-age{font-size:.75rem;color:var(--_muted);white-space:nowrap}\n.slc-refresh{all:unset;cursor:pointer;width:24px;height:24px;display:grid;place-items:center;border-radius:6px;color:var(--_muted)}\n.slc-refresh:hover{background:color-mix(in srgb,var(--_muted) 12%,transparent);color:var(--_fg)}\n.slc-refresh:focus-visible{outline:2px solid var(--_accent)}\n.slc-spin{animation:slc-spin 1s linear infinite}\n@keyframes slc-spin{to{transform:rotate(360deg)}}\n.slc-valuerow{display:flex;align-items:baseline;flex-wrap:wrap;gap:6px 10px}\n.slc-value{font-size:2rem;line-height:1.1;font-weight:700;letter-spacing:-.02em;min-width:0;overflow-wrap:anywhere}\n.slc-value.slc-text{font-size:1.375rem;letter-spacing:-.01em}\n.slc-unit{font-size:1rem;font-weight:600;margin-left:2px;color:var(--_muted)}\n.slc-label{font-size:.875rem;color:var(--_muted)}\n.slc-trend{font-size:.75rem;font-weight:600;padding:2px 8px;border-radius:999px;background:color-mix(in srgb,var(--_muted) 12%,transparent);color:var(--_muted)}\n.slc-trend.slc-good{background:color-mix(in srgb,var(--_ok) 14%,transparent);color:var(--_ok)}\n.slc-trend.slc-bad{background:color-mix(in srgb,var(--_err) 12%,transparent);color:var(--_err)}\n.slc-status{display:flex;align-items:flex-start;gap:8px;font-size:.875rem}\n.slc-dot{flex:none;width:8px;height:8px;border-radius:50%;margin-top:6px;background:var(--_muted)}\n.slc-tone-ok .slc-dot{background:var(--_ok)}.slc-tone-attention .slc-dot{background:var(--_warn)}.slc-tone-error .slc-dot{background:var(--_err)}\n.slc-progress{display:flex;flex-direction:column;gap:4px;font-size:.75rem;color:var(--_muted)}\n.slc-bar{height:6px;border-radius:999px;background:color-mix(in srgb,var(--_muted) 18%,transparent);overflow:hidden}\n.slc-bar>span{display:block;height:100%;background:var(--_accent);border-radius:inherit}\n.slc-items{list-style:none;margin:0;padding:0;display:flex;flex-direction:column}\n.slc-item{display:flex;align-items:center;gap:8px;padding:7px 0;border-top:1px solid var(--_border);font-size:.875rem;min-width:0}\n.slc-item:first-child{border-top:0}\n.slc-item-main{flex:1;min-width:0}\n.slc-item-title,.slc-item-sub{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.slc-item-sub,.slc-item-meta{color:var(--_muted);font-size:.75rem}\n.slc-link{all:unset;cursor:pointer}\n.slc-link:hover .slc-item-title{text-decoration:underline}\n.slc-link:focus-visible{outline:2px solid var(--_accent);border-radius:4px}\n.slc-empty{font-size:.875rem;color:var(--_muted)}\n.slc-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:auto}\n.slc-btn{all:unset;cursor:pointer;font-size:.8125rem;font-weight:600;padding:6px 12px;border-radius:8px;border:1px solid var(--_border);color:var(--_fg)}\n.slc-btn:hover{background:color-mix(in srgb,var(--_muted) 10%,transparent)}\n.slc-btn.slc-primary{background:var(--_accent);border-color:var(--_accent);color:var(--slc-on-accent,var(--sl-color-on-accent,#fff))}\n.slc-btn:focus-visible{outline:2px solid var(--_accent);outline-offset:2px}\n.slc-note{font-size:.75rem;color:var(--_muted)}\n.slc-note.slc-err{color:var(--_err)}\n.slc-skel{height:12px;border-radius:6px;background:color-mix(in srgb,var(--_muted) 16%,transparent);animation:slc-pulse 1.4s ease-in-out infinite}\n@keyframes slc-pulse{50%{opacity:.5}}\n.slc-todo{display:flex;flex-direction:column;gap:6px;padding:12px 16px;border-radius:var(--slc-radius,var(--sl-radius-lg,14px));border:1px solid color-mix(in srgb,var(--slc-attention,#b45309) 35%,transparent);background:color-mix(in srgb,var(--slc-attention,#b45309) 7%,var(--slc-bg,var(--sl-color-surface,#fff)));color:var(--slc-fg,var(--sl-color-fg,#0f172a))}\n.slc-todo-title{margin:0 0 2px;font-size:.875rem;font-weight:600}\n.slc-todo-row{display:flex;align-items:center;gap:10px;font-size:.875rem}\n.slc-todo-row .slc-dot{margin-top:0;background:var(--slc-attention,#b45309)}\n.slc-todo-row.slc-tone-error .slc-dot{background:var(--slc-error,var(--sl-color-danger,#dc2626))}\n.slc-todo-text{flex:1;min-width:0}\n";
59
+ /** The card views, bound to the host's React. Call once per app (module scope). */
60
+ export declare function createAdminCardViews(React: ReactLike): {
61
+ AdminCardView: (props: AdminCardViewProps) => any;
62
+ AdminCardGrid: ({ children, className }: {
63
+ children?: any;
64
+ className?: string | undefined;
65
+ }) => any;
66
+ AdminCardTodo: ({ cards, onAction, title }: {
67
+ cards: Array<AdminCardViewProps['card']>;
68
+ onAction?: AdminCardViewProps['onAction'];
69
+ title?: string | undefined;
70
+ }) => any;
71
+ };
72
+ export {};
@@ -0,0 +1,187 @@
1
+ /** The To do strip's items: every card's `todo`, plus attention/error statuses without one. */
2
+ export function collectAdminCardTodo(cards) {
3
+ var _a;
4
+ const out = [];
5
+ for (const card of cards) {
6
+ const p = card.payload;
7
+ if (!p)
8
+ continue;
9
+ for (const t of p.todo || [])
10
+ out.push(Object.assign({ card, text: t.text, tone: ((_a = p.status) === null || _a === void 0 ? void 0 : _a.tone) === 'error' ? 'error' : 'attention' }, (t.action ? { action: t.action } : {})));
11
+ if (!(p.todo || []).length && p.status && (p.status.tone === 'attention' || p.status.tone === 'error')) {
12
+ const action = (p.actions || [])[0];
13
+ out.push(Object.assign({ card, text: `${card.title}: ${p.status.text}`, tone: p.status.tone }, (action ? { action } : {})));
14
+ }
15
+ }
16
+ return out.sort((a, b) => (a.tone === b.tone ? 0 : a.tone === 'error' ? -1 : 1));
17
+ }
18
+ /** "just now" / "5 min ago" / "3 h ago" / "2 d ago". */
19
+ export function adminCardAge(at, now = Date.now()) {
20
+ if (!at)
21
+ return '';
22
+ const s = Math.max(0, Math.round((now - at) / 1000));
23
+ if (s < 60)
24
+ return 'just now';
25
+ if (s < 3600)
26
+ return `${Math.round(s / 60)} min ago`;
27
+ if (s < 86400)
28
+ return `${Math.round(s / 3600)} h ago`;
29
+ return `${Math.round(s / 86400)} d ago`;
30
+ }
31
+ const CSS = `
32
+ .slc-grid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:var(--slc-gap,16px)}
33
+ .slc-grid>*{grid-column:span 12}
34
+ @media (min-width:768px){.slc-grid>.slc-size-small{grid-column:span 6}.slc-grid>.slc-size-medium,.slc-grid>.slc-size-wide{grid-column:span 12}}
35
+ @media (min-width:1100px){.slc-grid>.slc-size-small{grid-column:span 3}.slc-grid>.slc-size-medium{grid-column:span 6}.slc-grid>.slc-size-wide{grid-column:span 8}}
36
+ .slc-card{--_bg:var(--slc-bg,var(--sl-color-surface,#fff));--_fg:var(--slc-fg,var(--sl-color-fg,#0f172a));--_muted:var(--slc-muted,var(--sl-color-muted,#64748b));--_border:var(--slc-border,var(--sl-color-border,#e2e8f0));--_accent:var(--slc-accent,var(--sl-color-accent,#2563eb));--_ok:var(--slc-ok,var(--sl-color-success,#059669));--_warn:var(--slc-attention,#b45309);--_err:var(--slc-error,var(--sl-color-danger,#dc2626));
37
+ box-sizing:border-box;display:flex;flex-direction:column;gap:12px;min-height:148px;padding:16px 18px;border:1px solid var(--_border);border-radius:var(--slc-radius,var(--sl-radius-lg,14px));background:var(--_bg);color:var(--_fg);font:inherit;position:relative}
38
+ .slc-head{display:flex;align-items:center;gap:8px;min-width:0}
39
+ .slc-title{margin:0;font-size:.95rem;font-weight:600;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
40
+ .slc-age{font-size:.75rem;color:var(--_muted);white-space:nowrap}
41
+ .slc-refresh{all:unset;cursor:pointer;width:24px;height:24px;display:grid;place-items:center;border-radius:6px;color:var(--_muted)}
42
+ .slc-refresh:hover{background:color-mix(in srgb,var(--_muted) 12%,transparent);color:var(--_fg)}
43
+ .slc-refresh:focus-visible{outline:2px solid var(--_accent)}
44
+ .slc-spin{animation:slc-spin 1s linear infinite}
45
+ @keyframes slc-spin{to{transform:rotate(360deg)}}
46
+ .slc-valuerow{display:flex;align-items:baseline;flex-wrap:wrap;gap:6px 10px}
47
+ .slc-value{font-size:2rem;line-height:1.1;font-weight:700;letter-spacing:-.02em;min-width:0;overflow-wrap:anywhere}
48
+ .slc-value.slc-text{font-size:1.375rem;letter-spacing:-.01em}
49
+ .slc-unit{font-size:1rem;font-weight:600;margin-left:2px;color:var(--_muted)}
50
+ .slc-label{font-size:.875rem;color:var(--_muted)}
51
+ .slc-trend{font-size:.75rem;font-weight:600;padding:2px 8px;border-radius:999px;background:color-mix(in srgb,var(--_muted) 12%,transparent);color:var(--_muted)}
52
+ .slc-trend.slc-good{background:color-mix(in srgb,var(--_ok) 14%,transparent);color:var(--_ok)}
53
+ .slc-trend.slc-bad{background:color-mix(in srgb,var(--_err) 12%,transparent);color:var(--_err)}
54
+ .slc-status{display:flex;align-items:flex-start;gap:8px;font-size:.875rem}
55
+ .slc-dot{flex:none;width:8px;height:8px;border-radius:50%;margin-top:6px;background:var(--_muted)}
56
+ .slc-tone-ok .slc-dot{background:var(--_ok)}.slc-tone-attention .slc-dot{background:var(--_warn)}.slc-tone-error .slc-dot{background:var(--_err)}
57
+ .slc-progress{display:flex;flex-direction:column;gap:4px;font-size:.75rem;color:var(--_muted)}
58
+ .slc-bar{height:6px;border-radius:999px;background:color-mix(in srgb,var(--_muted) 18%,transparent);overflow:hidden}
59
+ .slc-bar>span{display:block;height:100%;background:var(--_accent);border-radius:inherit}
60
+ .slc-items{list-style:none;margin:0;padding:0;display:flex;flex-direction:column}
61
+ .slc-item{display:flex;align-items:center;gap:8px;padding:7px 0;border-top:1px solid var(--_border);font-size:.875rem;min-width:0}
62
+ .slc-item:first-child{border-top:0}
63
+ .slc-item-main{flex:1;min-width:0}
64
+ .slc-item-title,.slc-item-sub{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
65
+ .slc-item-sub,.slc-item-meta{color:var(--_muted);font-size:.75rem}
66
+ .slc-link{all:unset;cursor:pointer}
67
+ .slc-link:hover .slc-item-title{text-decoration:underline}
68
+ .slc-link:focus-visible{outline:2px solid var(--_accent);border-radius:4px}
69
+ .slc-empty{font-size:.875rem;color:var(--_muted)}
70
+ .slc-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:auto}
71
+ .slc-btn{all:unset;cursor:pointer;font-size:.8125rem;font-weight:600;padding:6px 12px;border-radius:8px;border:1px solid var(--_border);color:var(--_fg)}
72
+ .slc-btn:hover{background:color-mix(in srgb,var(--_muted) 10%,transparent)}
73
+ .slc-btn.slc-primary{background:var(--_accent);border-color:var(--_accent);color:var(--slc-on-accent,var(--sl-color-on-accent,#fff))}
74
+ .slc-btn:focus-visible{outline:2px solid var(--_accent);outline-offset:2px}
75
+ .slc-note{font-size:.75rem;color:var(--_muted)}
76
+ .slc-note.slc-err{color:var(--_err)}
77
+ .slc-skel{height:12px;border-radius:6px;background:color-mix(in srgb,var(--_muted) 16%,transparent);animation:slc-pulse 1.4s ease-in-out infinite}
78
+ @keyframes slc-pulse{50%{opacity:.5}}
79
+ .slc-todo{display:flex;flex-direction:column;gap:6px;padding:12px 16px;border-radius:var(--slc-radius,var(--sl-radius-lg,14px));border:1px solid color-mix(in srgb,var(--slc-attention,#b45309) 35%,transparent);background:color-mix(in srgb,var(--slc-attention,#b45309) 7%,var(--slc-bg,var(--sl-color-surface,#fff)));color:var(--slc-fg,var(--sl-color-fg,#0f172a))}
80
+ .slc-todo-title{margin:0 0 2px;font-size:.875rem;font-weight:600}
81
+ .slc-todo-row{display:flex;align-items:center;gap:10px;font-size:.875rem}
82
+ .slc-todo-row .slc-dot{margin-top:0;background:var(--slc-attention,#b45309)}
83
+ .slc-todo-row.slc-tone-error .slc-dot{background:var(--slc-error,var(--sl-color-danger,#dc2626))}
84
+ .slc-todo-text{flex:1;min-width:0}
85
+ `;
86
+ let injected = false;
87
+ /** Add the card styles to the page once (the views call it; hosts rendering on the server can too). */
88
+ export function injectAdminCardStyles(doc = typeof document !== 'undefined' ? document : undefined) {
89
+ if (injected || !doc)
90
+ return;
91
+ injected = true;
92
+ const style = doc.createElement('style');
93
+ style.setAttribute('data-smartlinks', 'admin-cards');
94
+ style.textContent = CSS;
95
+ doc.head.appendChild(style);
96
+ }
97
+ /** The card stylesheet, for hosts that prefer to ship it themselves. */
98
+ export const ADMIN_CARD_CSS = CSS;
99
+ const ITEMS_FOR_SIZE = { small: 0, medium: 3, wide: 5, full: 5 };
100
+ /** The card views, bound to the host's React. Call once per app (module scope). */
101
+ export function createAdminCardViews(React) {
102
+ const h = React.createElement;
103
+ function useStyles() {
104
+ React.useEffect(() => injectAdminCardStyles(), []);
105
+ }
106
+ function formatValue(v, lang) {
107
+ if (typeof v === 'number') {
108
+ try {
109
+ return new Intl.NumberFormat(lang, { maximumFractionDigits: 1 }).format(v);
110
+ }
111
+ catch (_a) {
112
+ return String(v);
113
+ }
114
+ }
115
+ return v == null ? '' : String(v);
116
+ }
117
+ function ActionButton({ action, primary, onClick }) {
118
+ return h('button', { type: 'button', className: `slc-btn${primary ? ' slc-primary' : ''}`, onClick }, action.label);
119
+ }
120
+ function RefreshIcon({ spinning }) {
121
+ return h('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', className: spinning ? 'slc-spin' : undefined, 'aria-hidden': true }, h('path', { d: 'M21 12a9 9 0 1 1-2.64-6.36' }), h('path', { d: 'M21 3v6h-6' }));
122
+ }
123
+ /** One card: header (title, age, refresh) and a body drawn from its payload. */
124
+ function AdminCardView(props) {
125
+ var _a;
126
+ useStyles();
127
+ const { card, onAction, onRefresh, refreshing = false, renderComponent, lang, now } = props;
128
+ const p = card.payload;
129
+ const size = card.size || 'medium';
130
+ const act = (a) => onAction && onAction(a, card);
131
+ const title = (p && p.title) || card.title;
132
+ const age = card.state === 'pending' ? 'loading…' : card.state === 'stale' ? 'updating…' : adminCardAge(card.computedAt, now);
133
+ const header = h('header', { className: 'slc-head' }, h('h3', { className: 'slc-title', title }, title), age ? h('span', { className: 'slc-age' }, age) : null, onRefresh ? h('button', { type: 'button', className: 'slc-refresh', 'aria-label': `Refresh ${title}`, title: 'Refresh', disabled: refreshing, onClick: () => onRefresh(card) }, h(RefreshIcon, { spinning: refreshing || card.state === 'stale' })) : null);
134
+ let body;
135
+ if (card.kind === 'component' && renderComponent) {
136
+ body = renderComponent(card);
137
+ }
138
+ else if (!p) {
139
+ body = card.state === 'error'
140
+ ? h('p', { className: 'slc-note slc-err' }, card.error || "This card couldn't load.")
141
+ : h('div', { 'aria-busy': true, style: { display: 'flex', flexDirection: 'column', gap: 8 } }, h('div', { className: 'slc-skel', style: { width: '40%', height: 28 } }), h('div', { className: 'slc-skel', style: { width: '70%' } }));
142
+ }
143
+ else {
144
+ const showEmpty = !!p.empty && (p.value == null || p.value === 0) && !(p.items || []).length;
145
+ const maxItems = (_a = ITEMS_FOR_SIZE[size]) !== null && _a !== void 0 ? _a : 3;
146
+ const items = (p.items || []).slice(0, maxItems);
147
+ const actions = (size === 'small' ? (p.actions || []).slice(0, 1) : p.actions || []);
148
+ const trendClass = p.trend && p.trend.good != null ? (p.trend.good ? ' slc-good' : ' slc-bad') : '';
149
+ const arrow = p.trend ? (p.trend.direction === 'up' ? '▲ ' : p.trend.direction === 'down' ? '▼ ' : '') : '';
150
+ body = h(React.Fragment, null, p.value != null && !showEmpty
151
+ ? h('div', { className: 'slc-valuerow' }, h('span', { className: `slc-value${typeof p.value === 'string' && p.value.length > 8 ? ' slc-text' : ''}` }, formatValue(p.value, lang), p.unit ? h('span', { className: 'slc-unit' }, p.unit) : null), p.label ? h('span', { className: 'slc-label' }, p.label) : null, p.trend ? h('span', { className: `slc-trend${trendClass}` }, arrow + (p.trend.label || '')) : null)
152
+ : null, showEmpty
153
+ ? h('div', { className: 'slc-empty' }, p.empty.text, p.empty.action ? h('div', { className: 'slc-actions', style: { marginTop: 10 } }, h(ActionButton, { action: p.empty.action, primary: true, onClick: () => act(p.empty.action) })) : null)
154
+ : null, p.status ? h('div', { className: `slc-status slc-tone-${p.status.tone}` }, h('span', { className: 'slc-dot', 'aria-hidden': true }), h('span', null, p.status.text)) : null, p.progress
155
+ ? h('div', { className: 'slc-progress' }, h('div', { className: 'slc-bar', role: 'progressbar', 'aria-valuemin': 0, 'aria-valuemax': p.progress.total, 'aria-valuenow': p.progress.done }, h('span', { style: { width: `${Math.min(100, Math.round((p.progress.done / p.progress.total) * 100))}%` } })), h('span', null, p.progress.label || `${p.progress.done} of ${p.progress.total}`))
156
+ : null, items.length && !showEmpty
157
+ ? h('ul', { className: 'slc-items' }, ...items.map((it, i) => {
158
+ const inner = h(React.Fragment, null, h('span', { className: 'slc-item-main' }, h('span', { className: 'slc-item-title' }, it.title), it.subtitle ? h('span', { className: 'slc-item-sub' }, it.subtitle) : null), it.meta ? h('span', { className: 'slc-item-meta' }, it.meta) : null);
159
+ return h('li', { key: i, className: 'slc-item' }, it.path && onAction
160
+ ? h('button', { type: 'button', className: 'slc-link', style: { display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }, onClick: () => act({ label: it.title, path: it.path }) }, inner)
161
+ : inner);
162
+ }))
163
+ : null, actions.length && !showEmpty
164
+ ? h('div', { className: 'slc-actions' }, ...actions.map((a, i) => h(ActionButton, { key: i, action: a, primary: i === 0, onClick: () => act(a) })))
165
+ : null, card.state === 'error' && card.error ? h('p', { className: 'slc-note slc-err' }, `Couldn't refresh: ${card.error}`) : null);
166
+ }
167
+ return h('article', {
168
+ className: `slc-card slc-size-${size} slc-state-${card.state}${props.className ? ` ${props.className}` : ''}`,
169
+ 'aria-busy': card.state === 'pending' || card.state === 'stale' || refreshing ? true : undefined,
170
+ 'data-card': card.key,
171
+ }, header, body);
172
+ }
173
+ /** The 12-column dashboard grid (one column on phones); each card spans by its size. */
174
+ function AdminCardGrid({ children, className }) {
175
+ useStyles();
176
+ return h('div', { className: `slc-grid${className ? ` ${className}` : ''}` }, children);
177
+ }
178
+ /** The To do strip: what the cards say needs doing. Renders nothing when there's nothing to do. */
179
+ function AdminCardTodo({ cards, onAction, title = 'To do' }) {
180
+ useStyles();
181
+ const todo = collectAdminCardTodo(cards);
182
+ if (!todo.length)
183
+ return null;
184
+ return h('section', { className: 'slc-todo', 'aria-label': title }, h('h2', { className: 'slc-todo-title' }, title), ...todo.slice(0, 6).map((t, i) => h('div', { key: i, className: `slc-todo-row slc-tone-${t.tone}` }, h('span', { className: 'slc-dot', 'aria-hidden': true }), h('span', { className: 'slc-todo-text' }, t.text), t.action && onAction ? h('button', { type: 'button', className: 'slc-btn', onClick: () => onAction(t.action, t.card) }, t.action.label) : null)));
185
+ }
186
+ return { AdminCardView, AdminCardGrid, AdminCardTodo };
187
+ }
@@ -0,0 +1,13 @@
1
+ import type { AdminCard, AdminCardsResponse, StudioHomeLayout } from "../types/adminCards.js";
2
+ export declare namespace adminCards {
3
+ /**
4
+ * The collection's dashboard: every card, in display order, with its cached payload and how fresh
5
+ * it is (`state`). Never waits on slow cards — those come back `stale` / `pending` and arrive later
6
+ * as a realtime `cards` change.
7
+ */
8
+ function list(collectionId: string): Promise<AdminCardsResponse>;
9
+ /** Recompute one card now (the card's "refresh" button). Rate-limited per card. */
10
+ function refresh(collectionId: string, appId: string, cardId: string): Promise<AdminCard>;
11
+ /** Save the collection's dashboard layout (order, sizes, hidden cards) — every admin sees it. */
12
+ function saveLayout(collectionId: string, layout: StudioHomeLayout): Promise<StudioHomeLayout>;
13
+ }
@@ -0,0 +1,36 @@
1
+ // src/api/adminCards.ts
2
+ //
3
+ // Admin cards — a collection's dashboard, built from cards its installed apps contribute (plus the
4
+ // platform's own: products, contacts, visitors). See docs/admin-cards.md.
5
+ //
6
+ // One call loads the whole dashboard, served from a cache; stale cards are recomputed in the
7
+ // background and announced over SmartLinks Realtime as a `cards` change event:
8
+ //
9
+ // const { cards } = await adminCards.list(collectionId)
10
+ // realtime.onChange({ collectionId }, { type: 'cards' }, () => reload())
11
+ //
12
+ // Endpoints: /admin/collection/:collectionId/cards
13
+ import { request, post, put } from "../http.js";
14
+ const base = (collectionId) => `/admin/collection/${encodeURIComponent(collectionId)}/cards`;
15
+ export var adminCards;
16
+ (function (adminCards) {
17
+ /**
18
+ * The collection's dashboard: every card, in display order, with its cached payload and how fresh
19
+ * it is (`state`). Never waits on slow cards — those come back `stale` / `pending` and arrive later
20
+ * as a realtime `cards` change.
21
+ */
22
+ async function list(collectionId) {
23
+ return request(base(collectionId));
24
+ }
25
+ adminCards.list = list;
26
+ /** Recompute one card now (the card's "refresh" button). Rate-limited per card. */
27
+ async function refresh(collectionId, appId, cardId) {
28
+ return post(`${base(collectionId)}/${encodeURIComponent(appId)}/${encodeURIComponent(cardId)}/refresh`, {});
29
+ }
30
+ adminCards.refresh = refresh;
31
+ /** Save the collection's dashboard layout (order, sizes, hidden cards) — every admin sees it. */
32
+ async function saveLayout(collectionId, layout) {
33
+ return put(`${base(collectionId)}/layout`, layout);
34
+ }
35
+ adminCards.saveLayout = saveLayout;
36
+ })(adminCards || (adminCards = {}));
@@ -30,6 +30,7 @@ export { analytics } from "./analytics.js";
30
30
  export { functions } from "./functions.js";
31
31
  export { location } from "./location.js";
32
32
  export * as realtime from "./realtime.js";
33
+ export { adminCards } from "./adminCards.js";
33
34
  export { tags } from "./tags.js";
34
35
  export { order } from "./order.js";
35
36
  export { app } from "./appObjects.js";
package/dist/api/index.js CHANGED
@@ -33,6 +33,7 @@ export { functions } from "./functions.js";
33
33
  export { location } from "./location.js";
34
34
  import * as realtime_1 from "./realtime.js";
35
35
  export { realtime_1 as realtime };
36
+ export { adminCards } from "./adminCards.js";
36
37
  export { tags } from "./tags.js";
37
38
  export { order } from "./order.js";
38
39
  export { app } from "./appObjects.js";
@@ -1,4 +1,4 @@
1
- import type { IntegrationFlow, CreateFlowInput, UpdateFlowInput, ListFlowsQuery, FlowList, RunFlowInput, RunFlowResult, RunFlowSummary, RunFlowEnqueued, RecordTypesResponse, FlowRun, ListRunsQuery, RunList, ListRunItemsQuery, RunItemList, LoggingWindow, SetLoggingInput, InstalledConnector, SaveConnectorInstallationInput, ConnectorConsoleStep, FailedRecordList } from "../types/integrations.js";
1
+ import type { IntegrationFlow, CreateFlowInput, UpdateFlowInput, ListFlowsQuery, FlowList, RunFlowInput, RunFlowResult, RunFlowSummary, RunFlowEnqueued, RecordTypesResponse, FlowRun, ListRunsQuery, RunList, ListRunItemsQuery, RunItemList, LoggingWindow, SetLoggingInput, InstalledConnector, SaveConnectorInstallationInput, ConnectorConsoleStep, FailedRecordList, FlowHealth, IntegrationHealth, ProductLineage } from "../types/integrations.js";
2
2
  export declare namespace integrations {
3
3
  /** List flows in a collection. GET /integrations/flows */
4
4
  function listFlows(collectionId: string, query?: ListFlowsQuery): Promise<FlowList>;
@@ -63,6 +63,19 @@ export declare namespace integrations {
63
63
  function retryFailed(collectionId: string, flowId: string, ids?: string[]): Promise<{
64
64
  retrying: number;
65
65
  }>;
66
+ /** Every flow at a glance: paused?, running?, last 24h, backlog. GET /integrations/health */
67
+ function getHealth(collectionId: string): Promise<IntegrationHealth>;
68
+ /** One flow's health. GET /integrations/flows/:id/health */
69
+ function getFlowHealth(collectionId: string, flowId: string): Promise<FlowHealth>;
70
+ /**
71
+ * Where a product's data came from (the provider record, when it was fetched and imported, by which
72
+ * run, with the current mapping or an older one) and where it went (each outbound flow's last
73
+ * delivery). `payload: true` includes the provider records as received.
74
+ * GET /integrations/lineage/product/:productId
75
+ */
76
+ function getProductLineage(collectionId: string, productId: string, opts?: {
77
+ payload?: boolean;
78
+ }): Promise<ProductLineage>;
66
79
  /** Resume a flow that paused itself (circuit open): held records go out now. POST /integrations/flows/:id/resume */
67
80
  function resumeFlow(collectionId: string, flowId: string): Promise<{
68
81
  resumed: boolean;
@@ -143,6 +143,27 @@ export var integrations;
143
143
  return post(`${base(collectionId)}/${enc(flowId)}/failed/retry`, ids ? { ids } : {});
144
144
  }
145
145
  integrations.retryFailed = retryFailed;
146
+ // --- Health + lineage ---------------------------------------------------------
147
+ /** Every flow at a glance: paused?, running?, last 24h, backlog. GET /integrations/health */
148
+ async function getHealth(collectionId) {
149
+ return request(`/admin/collection/${enc(collectionId)}/integrations/health`);
150
+ }
151
+ integrations.getHealth = getHealth;
152
+ /** One flow's health. GET /integrations/flows/:id/health */
153
+ async function getFlowHealth(collectionId, flowId) {
154
+ return request(`${base(collectionId)}/${enc(flowId)}/health`);
155
+ }
156
+ integrations.getFlowHealth = getFlowHealth;
157
+ /**
158
+ * Where a product's data came from (the provider record, when it was fetched and imported, by which
159
+ * run, with the current mapping or an older one) and where it went (each outbound flow's last
160
+ * delivery). `payload: true` includes the provider records as received.
161
+ * GET /integrations/lineage/product/:productId
162
+ */
163
+ async function getProductLineage(collectionId, productId, opts = {}) {
164
+ return request(`/admin/collection/${enc(collectionId)}/integrations/lineage/product/${enc(productId)}${opts.payload ? '?payload=1' : ''}`);
165
+ }
166
+ integrations.getProductLineage = getProductLineage;
146
167
  /** Resume a flow that paused itself (circuit open): held records go out now. POST /integrations/flows/:id/resume */
147
168
  async function resumeFlow(collectionId, flowId) {
148
169
  return post(`${base(collectionId)}/${enc(flowId)}/resume`, {});