@adia-ai/web-modules 0.8.35 → 0.8.37

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -3
  3. package/agent-admin/admin-roster/admin-roster.js +1 -1
  4. package/agent-admin/admin-settings/admin-settings.js +1 -1
  5. package/agent-admin/agent-admin/agent-admin.js +1 -1
  6. package/billing/billing-overview/billing-overview.a2ui.json +281 -0
  7. package/billing/billing-overview/billing-overview.class.js +838 -0
  8. package/billing/billing-overview/billing-overview.css +223 -0
  9. package/billing/billing-overview/billing-overview.js +16 -0
  10. package/billing/billing-overview/billing-overview.yaml +441 -0
  11. package/billing/index.js +21 -0
  12. package/billing/invoice-detail/invoice-detail.a2ui.json +276 -0
  13. package/billing/invoice-detail/invoice-detail.class.js +711 -0
  14. package/billing/invoice-detail/invoice-detail.css +305 -0
  15. package/billing/invoice-detail/invoice-detail.js +16 -0
  16. package/billing/invoice-detail/invoice-detail.yaml +411 -0
  17. package/billing/invoice-history/invoice-history.a2ui.json +315 -0
  18. package/billing/invoice-history/invoice-history.class.js +672 -0
  19. package/billing/invoice-history/invoice-history.css +130 -0
  20. package/billing/invoice-history/invoice-history.js +16 -0
  21. package/billing/invoice-history/invoice-history.yaml +398 -0
  22. package/billing/payment-method-form/payment-method-form.a2ui.json +310 -0
  23. package/billing/payment-method-form/payment-method-form.class.js +886 -0
  24. package/billing/payment-method-form/payment-method-form.css +149 -0
  25. package/billing/payment-method-form/payment-method-form.js +16 -0
  26. package/billing/payment-method-form/payment-method-form.yaml +430 -0
  27. package/billing/payment-method-list/payment-method-list.a2ui.json +283 -0
  28. package/billing/payment-method-list/payment-method-list.class.js +705 -0
  29. package/billing/payment-method-list/payment-method-list.css +128 -0
  30. package/billing/payment-method-list/payment-method-list.js +16 -0
  31. package/billing/payment-method-list/payment-method-list.yaml +355 -0
  32. package/billing/plan-picker/plan-picker.a2ui.json +349 -0
  33. package/billing/plan-picker/plan-picker.class.js +635 -0
  34. package/billing/plan-picker/plan-picker.css +227 -0
  35. package/billing/plan-picker/plan-picker.js +16 -0
  36. package/billing/plan-picker/plan-picker.yaml +453 -0
  37. package/chat/README.md +1 -1
  38. package/chat/chat-composer/chat-composer.js +2 -2
  39. package/chat/chat-empty/chat-empty.js +2 -2
  40. package/chat/chat-shell/chat-shell.js +4 -4
  41. package/chat/chat-sidebar/chat-sidebar.js +2 -2
  42. package/chat/chat-surfaces/chat-surfaces.js +2 -2
  43. package/chat/chat-thread/chat-thread.js +2 -2
  44. package/dashboard/dashboard-layout/dashboard-layout.a2ui.json +254 -0
  45. package/dashboard/dashboard-layout/dashboard-layout.css +190 -0
  46. package/dashboard/dashboard-layout/dashboard-layout.js +289 -0
  47. package/dashboard/dashboard-layout/dashboard-layout.yaml +374 -0
  48. package/dashboard/date-range-selector/date-range-selector.a2ui.json +236 -0
  49. package/dashboard/date-range-selector/date-range-selector.css +84 -0
  50. package/dashboard/date-range-selector/date-range-selector.js +686 -0
  51. package/dashboard/date-range-selector/date-range-selector.yaml +358 -0
  52. package/dashboard/index.js +14 -0
  53. package/dist/everything.min.js +1 -1
  54. package/editor/editor-canvas/editor-canvas.js +2 -2
  55. package/editor/editor-shell/editor-shell.js +2 -2
  56. package/editor/editor-sidebar/editor-sidebar.js +2 -2
  57. package/editor/editor-toolbar/editor-toolbar.js +2 -2
  58. package/feedback/confirm-dialog/confirm-dialog.a2ui.json +206 -0
  59. package/feedback/confirm-dialog/confirm-dialog.css +157 -0
  60. package/feedback/confirm-dialog/confirm-dialog.js +383 -0
  61. package/feedback/confirm-dialog/confirm-dialog.yaml +297 -0
  62. package/feedback/index.js +10 -0
  63. package/form/form-popover/form-popover.js +1 -1
  64. package/generative/index.d.ts +1 -1
  65. package/generative/index.js +2 -2
  66. package/onboarding/index.js +1 -0
  67. package/onboarding/onboarding-checklist/onboarding-checklist.a2ui.json +263 -0
  68. package/onboarding/onboarding-checklist/onboarding-checklist.css +157 -0
  69. package/onboarding/onboarding-checklist/onboarding-checklist.js +428 -0
  70. package/onboarding/onboarding-checklist/onboarding-checklist.yaml +236 -0
  71. package/package.json +99 -6
  72. package/runtime/a2ui-root/a2ui-root.js +3 -3
  73. package/runtime/gen-root/gen-root.js +5 -5
  74. package/settings/index.js +12 -0
  75. package/settings/integrations-page/integrations-page.a2ui.json +274 -0
  76. package/settings/integrations-page/integrations-page.css +177 -0
  77. package/settings/integrations-page/integrations-page.d.ts +77 -0
  78. package/settings/integrations-page/integrations-page.js +413 -0
  79. package/settings/integrations-page/integrations-page.yaml +317 -0
  80. package/settings/notification-preferences/notification-preferences.a2ui.json +245 -0
  81. package/settings/notification-preferences/notification-preferences.css +204 -0
  82. package/settings/notification-preferences/notification-preferences.js +622 -0
  83. package/settings/notification-preferences/notification-preferences.yaml +358 -0
  84. package/shell/admin-command/admin-command.js +2 -2
  85. package/shell/admin-shell/admin-shell.js +2 -2
  86. package/shell/admin-sidebar/admin-sidebar.js +2 -2
  87. package/shell/embed-shell/embed-shell.js +3 -3
  88. package/simple/simple-shell/simple-shell.js +1 -1
  89. package/theme/theme-panel/theme-panel.js +2 -2
@@ -0,0 +1,838 @@
1
+ /**
2
+ * <billing-overview-ui> — Module-tier billing dashboard composite.
3
+ *
4
+ * Stitches the Wave A+B billing primitives into a single dashboard
5
+ * surface — header (current plan + status badge) → KPI strip
6
+ * (current spend · seats · next invoice) → optional dunning banner
7
+ * (when past-due) → plan-picker section → payment-method-list section
8
+ * → invoice-history section. The composite owns the page-level
9
+ * layout, status-badge variant mapping, KPI formatting, and the
10
+ * unified `account-action` event surface. Each child primitive owns
11
+ * its own row chrome.
12
+ *
13
+ * SPEC: .claude/docs/specs/implementation-ready/SPEC-004-billing-overview.md
14
+ * Wave A+B siblings composed:
15
+ * - SPEC-006 — alert-ui[pattern=dunning] for past-due state
16
+ * - SPEC-008 — invoice-history-ui for the invoice list section
17
+ * - SPEC-010 — payment-method-list-ui for the cards/wallets section
18
+ * - SPEC-011 — plan-picker-ui for the in-app plan chooser
19
+ * - stat-ui for the KPI strip
20
+ *
21
+ * Architecture (light-DOM, ADR-0033):
22
+ * Stamps a six-region skeleton once on first connect:
23
+ * [data-header] — plan title + status tag + slotted actions
24
+ * [data-kpis] — 3-stat KPI strip
25
+ * [data-dunning] — alert-ui[pattern="dunning"], conditional
26
+ * [data-plan-picker] — plan-picker-ui section, conditional
27
+ * [data-payment-methods] — payment-method-list-ui section
28
+ * [data-invoice-history] — invoice-history-ui section
29
+ * [data-empty] — default empty-state branch
30
+ * Then mutates per-region nodes via property assignment on every
31
+ * `account` / `variant` / `currency` / `loading` change. Children
32
+ * are real custom-element instances; the composite forwards data
33
+ * into their `.plans` / `.methods` / `.invoices` properties.
34
+ *
35
+ * Form participation: none. Extends UIElement, not UIFormElement —
36
+ * the dashboard is a display surface. Child primitives (plan-picker,
37
+ * payment-method-list) are individually form-bearing.
38
+ *
39
+ * Per ADR-0027 composed primitives are NOT auto-imported. The
40
+ * consumer (or this composite's .html demo shell) must side-effect
41
+ * import each one explicitly.
42
+ */
43
+
44
+ import { UIElement } from '@adia-ai/web-components/core/element';
45
+ import { untracked } from '@adia-ai/web-components/core/signals';
46
+
47
+ const KNOWN_STATUSES = new Set([
48
+ 'active', 'trialing', 'past_due', 'canceled', 'paused',
49
+ ]);
50
+
51
+ /** Map account.status → tag-ui variant (status badge in the header).
52
+ * `canceled` carries `tone="muted"` (family-less quiet chrome) instead —
53
+ * `variant="muted"` was never a valid tag-ui value (tag's `variant` enum is
54
+ * the semantic-family axis; `muted` only ever existed on `tone`, the fill-
55
+ * style axis) — a pre-existing bug, fixed while migrating the adjacent
56
+ * accent/muted vocabulary (variant/color/tone convention cut, gh#873/874). */
57
+ const STATUS_TO_VARIANT = {
58
+ active: 'success',
59
+ trialing: 'info',
60
+ past_due: 'danger',
61
+ paused: 'warning',
62
+ };
63
+
64
+ const STATUS_LABEL = {
65
+ active: 'Active',
66
+ trialing: 'Trialing',
67
+ past_due: 'Past due',
68
+ canceled: 'Canceled',
69
+ paused: 'Paused',
70
+ };
71
+
72
+ /** Per-child event → unified `account-action.detail.action` mapping. */
73
+ const DUNNING_INTENT = {
74
+ update: 'update-card',
75
+ retry: 'retry',
76
+ };
77
+
78
+ /** Format an amount via Intl.NumberFormat. */
79
+ function formatAmount(amount, currency, locale) {
80
+ const value = Number.isFinite(amount) ? amount : 0;
81
+ try {
82
+ return new Intl.NumberFormat(locale || undefined, {
83
+ style: 'currency',
84
+ currency: currency || 'USD',
85
+ maximumFractionDigits: Number.isInteger(value) ? 0 : 2,
86
+ }).format(value);
87
+ } catch {
88
+ return `${currency || '$'}${value}`;
89
+ }
90
+ }
91
+
92
+ /** Format an ISO date via Intl.DateTimeFormat (medium style). */
93
+ function formatDate(iso, locale) {
94
+ if (!iso) return '';
95
+ try {
96
+ const d = iso instanceof Date ? iso : new Date(iso);
97
+ if (Number.isNaN(d.getTime())) return String(iso);
98
+ return new Intl.DateTimeFormat(locale || undefined, {
99
+ year: 'numeric',
100
+ month: 'short',
101
+ day: '2-digit',
102
+ }).format(d);
103
+ } catch {
104
+ return String(iso);
105
+ }
106
+ }
107
+
108
+ export class UIBillingOverview extends UIElement {
109
+ static get properties() {
110
+ return {
111
+ account: { type: Object, default: null },
112
+ variant: { type: String, default: 'full', reflect: true },
113
+ currency: { type: String, default: 'USD', reflect: true },
114
+ locale: { type: String, default: '' },
115
+ loading: { type: Boolean, default: false, reflect: true },
116
+ };
117
+ }
118
+
119
+ // No template — composite stamps a fixed region skeleton once
120
+ // and mutates per-region nodes across renders. Returning html``
121
+ // would trigger stamp() → replaceChildren() and wipe authored
122
+ // slotted children + lose internal state inside the child
123
+ // composites (plan-picker, payment-method-list, invoice-history).
124
+ static template = () => null;
125
+
126
+ // ── Field refs ────────────────────────────────────────────────────
127
+ #stamped = false;
128
+ #cleanups = [];
129
+
130
+ // Header region
131
+ #headerHost = null;
132
+ #headerTitle = null;
133
+ #headerSubtitle = null;
134
+ #headerStatus = null;
135
+ #headerActions = null;
136
+
137
+ // KPI strip
138
+ #kpisHost = null;
139
+ #kpiSpend = null;
140
+ #kpiSeats = null;
141
+ #kpiInvoice = null;
142
+
143
+ // Dunning banner
144
+ #dunningHost = null;
145
+ #dunningAlert = null;
146
+
147
+ // Plan-picker section
148
+ #planPickerHost = null;
149
+ #planPickerHeading = null;
150
+ #planPicker = null;
151
+
152
+ // Payment-methods section
153
+ #paymentMethodsHost = null;
154
+ #paymentMethodsHeading = null;
155
+ #paymentMethods = null;
156
+
157
+ // Invoice-history section
158
+ #invoiceHistoryHost = null;
159
+ #invoiceHistoryHeading = null;
160
+ #invoiceHistory = null;
161
+
162
+ // Empty state
163
+ #emptyHost = null;
164
+
165
+ // Bookkeeping
166
+ #headingId = '';
167
+ #warnedStatus = false;
168
+ #loadCount = 0;
169
+
170
+ // ── Public imperative API ─────────────────────────────────────────
171
+
172
+ /** Re-fetch from `data-stream-src` when configured. */
173
+ async refresh() {
174
+ const src = this.getAttribute('data-stream-src');
175
+ if (!src) return;
176
+ const tag = ++this.#loadCount;
177
+ this.loading = true;
178
+ this.removeAttribute('error');
179
+ try {
180
+ const res = await fetch(src);
181
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
182
+ const data = await res.json();
183
+ if (tag !== this.#loadCount) return;
184
+ this.loading = false;
185
+ const record = data && typeof data === 'object' && data.account
186
+ ? data.account
187
+ : data;
188
+ this.account = record;
189
+ this.dispatchEvent(new CustomEvent('load', {
190
+ bubbles: true,
191
+ detail: { account: record },
192
+ }));
193
+ } catch (err) {
194
+ if (tag !== this.#loadCount) return;
195
+ this.loading = false;
196
+ this.setAttribute('error', '');
197
+ this.dispatchEvent(new CustomEvent('error', {
198
+ bubbles: true,
199
+ detail: { reason: 'fetch-failed', message: String(err?.message || err) },
200
+ }));
201
+ }
202
+ }
203
+
204
+ // ── Lifecycle ─────────────────────────────────────────────────────
205
+
206
+ connected() {
207
+ this.setAttribute('role', 'region');
208
+
209
+ if (!this.#stamped) {
210
+ this.#absorbInlineAccountScript();
211
+ this.#stamp();
212
+ this.#wireListeners();
213
+ this.#stamped = true;
214
+ }
215
+
216
+ untracked(() => this.render());
217
+
218
+ if (this.getAttribute('data-stream-src')) {
219
+ this.refresh();
220
+ }
221
+ }
222
+
223
+ disconnected() {
224
+ for (const fn of this.#cleanups) {
225
+ try { fn(); } catch { /* ignore stale */ }
226
+ }
227
+ this.#cleanups.length = 0;
228
+ }
229
+
230
+ attributeChangedCallback(name, oldVal, newVal) {
231
+ super.attributeChangedCallback?.(name, oldVal, newVal);
232
+ if (!this.#stamped) return;
233
+ if (name === 'data-stream-src' && oldVal !== newVal && newVal) {
234
+ this.refresh();
235
+ }
236
+ }
237
+
238
+ // ── Render — mutates per-region nodes only ────────────────────────
239
+
240
+ render() {
241
+ if (!this.#stamped) return;
242
+
243
+ const account = this.account;
244
+ const isEmpty = !account && !this.getAttribute('data-stream-src');
245
+
246
+ if (isEmpty) this.setAttribute('empty', '');
247
+ else this.removeAttribute('empty');
248
+
249
+ if (this.loading) this.setAttribute('aria-busy', 'true');
250
+ else this.removeAttribute('aria-busy');
251
+
252
+ // State attribute reflection (past-due / trial).
253
+ const status = account?.status || '';
254
+ if (status === 'past_due') this.setAttribute('past-due', '');
255
+ else this.removeAttribute('past-due');
256
+ if (status === 'trialing') this.setAttribute('trial', '');
257
+ else this.removeAttribute('trial');
258
+
259
+ if (status && !KNOWN_STATUSES.has(status) && !this.#warnedStatus) {
260
+ this.#warnedStatus = true;
261
+ // eslint-disable-next-line no-console
262
+ console.warn(
263
+ `[billing-overview-ui] unknown account.status "${status}" — rendering default chrome.`,
264
+ );
265
+ }
266
+
267
+ if (isEmpty) {
268
+ this.#renderHeader(null);
269
+ this.#renderAccessibleLabel(null);
270
+ return;
271
+ }
272
+
273
+ if (!account) {
274
+ // loading + no account yet — render header chrome only.
275
+ this.#renderHeader(null);
276
+ return;
277
+ }
278
+
279
+ this.#renderHeader(account);
280
+ this.#renderKpis(account);
281
+ this.#renderDunning(account);
282
+ this.#renderPlanPicker(account);
283
+ this.#renderPaymentMethods(account);
284
+ this.#renderInvoiceHistory(account);
285
+ this.#renderAccessibleLabel(account);
286
+ }
287
+
288
+ // ── Stamp — six-region skeleton ───────────────────────────────────
289
+
290
+ #absorbInlineAccountScript() {
291
+ const script = this.querySelector(
292
+ ':scope > script[type="application/json"][data-account]',
293
+ );
294
+ if (!script) return;
295
+ try {
296
+ const parsed = JSON.parse(script.textContent || '{}');
297
+ if (parsed && typeof parsed === 'object') {
298
+ const record = parsed.account && typeof parsed.account === 'object'
299
+ ? parsed.account
300
+ : parsed;
301
+ this.account = record;
302
+ }
303
+ } catch (err) {
304
+ // eslint-disable-next-line no-console
305
+ console.warn(
306
+ '[billing-overview-ui] failed to parse <script data-account> JSON:',
307
+ err?.message,
308
+ );
309
+ } finally {
310
+ script.remove();
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Move consumer-authored `[slot=...]` direct children into their
316
+ * target regions. Light-DOM substrate (ADR-0033) — `slot=` is
317
+ * decorative metadata; <slot> elements do NOT reparent children
318
+ * the way they do in shadow DOM.
319
+ */
320
+ #absorbSlottedChildren(slotName, target) {
321
+ const nodes = this.querySelectorAll(`:scope > [slot="${slotName}"]`);
322
+ for (const n of nodes) target.appendChild(n);
323
+ }
324
+
325
+ #stamp() {
326
+ this.#headingId = `bo-h-${Math.random().toString(36).slice(2, 8)}`;
327
+
328
+ // ── Header region ── card-ui wrapping plan title + status tag + actions.
329
+ // Card chrome matches canonical billing.contents.html plan card pattern
330
+ // (apps/saas/app/billing/billing.contents.html lines 13-29). See
331
+ // .brain/findings/demos/billing-overview.wireframe.md §Level 5 tree.
332
+ const headerCard = document.createElement('card-ui');
333
+ headerCard.setAttribute('data-header', '');
334
+
335
+ const header = document.createElement('header');
336
+
337
+ // Icon slot — avatar-ui per canonical convention (star/credit-card iconography).
338
+ const headerIconSpan = document.createElement('span');
339
+ headerIconSpan.setAttribute('slot', 'icon');
340
+ const headerAvatar = document.createElement('avatar-ui');
341
+ headerAvatar.setAttribute('icon', 'credit-card');
342
+ headerIconSpan.appendChild(headerAvatar);
343
+
344
+ // Heading slot — plan name + status badge.
345
+ const headerHeadingSpan = document.createElement('span');
346
+ headerHeadingSpan.setAttribute('slot', 'heading');
347
+
348
+ const title = document.createElement('h2');
349
+ title.id = this.#headingId;
350
+ title.setAttribute('data-billing-plan-name', '');
351
+ headerHeadingSpan.appendChild(title);
352
+
353
+ const statusBadge = document.createElement('tag-ui');
354
+ statusBadge.setAttribute('data-billing-status', '');
355
+ statusBadge.setAttribute('size', 'md');
356
+ headerHeadingSpan.appendChild(statusBadge);
357
+
358
+ // Description slot — plan price/cycle/next-invoice meta.
359
+ const headerDescriptionSpan = document.createElement('span');
360
+ headerDescriptionSpan.setAttribute('slot', 'description');
361
+ const subtitle = document.createElement('div');
362
+ subtitle.setAttribute('data-billing-plan-meta', '');
363
+ headerDescriptionSpan.appendChild(subtitle);
364
+
365
+ // Action slot — change-plan + consumer-supplied actions.
366
+ const headerActionSpan = document.createElement('span');
367
+ headerActionSpan.setAttribute('slot', 'action');
368
+ const headerActions = document.createElement('div');
369
+ headerActions.setAttribute('data-header-actions', '');
370
+ headerActionSpan.appendChild(headerActions);
371
+
372
+ header.append(headerIconSpan, headerHeadingSpan, headerDescriptionSpan, headerActionSpan);
373
+ headerCard.appendChild(header);
374
+ this.appendChild(headerCard);
375
+
376
+ this.#headerHost = headerCard;
377
+ this.#headerTitle = title;
378
+ this.#headerSubtitle = subtitle;
379
+ this.#headerStatus = statusBadge;
380
+ this.#headerActions = headerActions;
381
+
382
+ // Absorb consumer [slot="header-actions"] children.
383
+ this.#absorbSlottedChildren('header-actions', headerActions);
384
+
385
+ // ── KPI strip ── card-ui × 3 wrapping stat-ui (currentSpend · seats · nextInvoice).
386
+ // Card chrome matches canonical KPI strip pattern from
387
+ // apps/saas/app/admin-dashboard/admin-dashboard.contents.html (card-ui +
388
+ // section + stat-ui). See wireframe Level 5 tree.
389
+ const kpis = document.createElement('section');
390
+ kpis.setAttribute('data-kpis', '');
391
+
392
+ /**
393
+ * Helper: wrap a stat-ui in <card-ui><section> per canonical KPI pattern.
394
+ * @returns {[HTMLElement, HTMLElement]} [cardWrapper, statElement]
395
+ */
396
+ const makeKpiCard = (kpiId, label, icon) => {
397
+ const card = document.createElement('card-ui');
398
+ card.setAttribute('data-kpi-card', kpiId);
399
+ const section = document.createElement('section');
400
+ const stat = document.createElement('stat-ui');
401
+ stat.setAttribute('data-kpi', kpiId);
402
+ stat.setAttribute('label', label);
403
+ stat.setAttribute('value', '—');
404
+ stat.setAttribute('icon', icon);
405
+ section.appendChild(stat);
406
+ card.appendChild(section);
407
+ return [card, stat];
408
+ };
409
+
410
+ const [kpiSpendCard, kpiSpend] = makeKpiCard('spend', 'Current month', 'credit-card');
411
+ const [kpiSeatsCard, kpiSeats] = makeKpiCard('seats', 'Seats', 'users-three');
412
+ const [kpiInvoiceCard, kpiInvoice] = makeKpiCard('next-invoice', 'Next invoice', 'calendar');
413
+
414
+ kpis.append(kpiSpendCard, kpiSeatsCard, kpiInvoiceCard);
415
+ this.appendChild(kpis);
416
+ this.#kpisHost = kpis;
417
+ this.#kpiSpend = kpiSpend;
418
+ this.#kpiSeats = kpiSeats;
419
+ this.#kpiInvoice = kpiInvoice;
420
+
421
+ // ── Dunning banner ── alert-ui[pattern="dunning"], conditional.
422
+ const dunningHost = document.createElement('section');
423
+ dunningHost.setAttribute('data-dunning', '');
424
+ dunningHost.setAttribute('hidden', '');
425
+ const dunningAlert = document.createElement('alert-ui');
426
+ dunningAlert.setAttribute('pattern', 'dunning');
427
+ dunningAlert.setAttribute('variant', 'danger');
428
+ dunningHost.appendChild(dunningAlert);
429
+ this.appendChild(dunningHost);
430
+ this.#dunningHost = dunningHost;
431
+ this.#dunningAlert = dunningAlert;
432
+
433
+ // ── Plan-picker section ── conditional on [variant=full].
434
+ const planPickerHost = document.createElement('section');
435
+ planPickerHost.setAttribute('data-plan-picker', '');
436
+ const planPickerHeading = document.createElement('h3');
437
+ planPickerHeading.setAttribute('data-section-heading', '');
438
+ planPickerHeading.textContent = 'Plan';
439
+ const planPicker = document.createElement('plan-picker-ui');
440
+ planPicker.setAttribute('data-billing-plan-picker', '');
441
+ planPicker.setAttribute('name', 'billing-overview-plan');
442
+ planPickerHost.append(planPickerHeading, planPicker);
443
+
444
+ // Optional before-plan-picker slot anchor sits before the plan picker
445
+ // section card. We absorb consumer-authored content into planPickerHost
446
+ // ahead of the heading.
447
+ this.appendChild(planPickerHost);
448
+ this.#planPickerHost = planPickerHost;
449
+ this.#planPickerHeading = planPickerHeading;
450
+ this.#planPicker = planPicker;
451
+
452
+ // Absorb before-plan-picker slot content into the section
453
+ // ahead of the plan picker.
454
+ const beforePlanPicker = document.createElement('div');
455
+ beforePlanPicker.setAttribute('data-before-plan-picker', '');
456
+ planPickerHost.insertBefore(beforePlanPicker, planPickerHeading);
457
+ this.#absorbSlottedChildren('before-plan-picker', beforePlanPicker);
458
+
459
+ // ── Payment-methods section ──
460
+ const paymentMethodsHost = document.createElement('section');
461
+ paymentMethodsHost.setAttribute('data-payment-methods', '');
462
+ const paymentMethodsHeading = document.createElement('h3');
463
+ paymentMethodsHeading.setAttribute('data-section-heading', '');
464
+ paymentMethodsHeading.textContent = 'Payment methods';
465
+ const paymentMethods = document.createElement('payment-method-list-ui');
466
+ paymentMethods.setAttribute('data-billing-payment-methods', '');
467
+ paymentMethods.setAttribute('name', 'billing-overview-payment-method');
468
+ paymentMethodsHost.append(paymentMethodsHeading, paymentMethods);
469
+ this.appendChild(paymentMethodsHost);
470
+ this.#paymentMethodsHost = paymentMethodsHost;
471
+ this.#paymentMethodsHeading = paymentMethodsHeading;
472
+ this.#paymentMethods = paymentMethods;
473
+
474
+ // ── Invoice-history section ──
475
+ const invoiceHistoryHost = document.createElement('section');
476
+ invoiceHistoryHost.setAttribute('data-invoice-history', '');
477
+ const invoiceHistoryHeading = document.createElement('h3');
478
+ invoiceHistoryHeading.setAttribute('data-section-heading', '');
479
+ invoiceHistoryHeading.textContent = 'Invoice history';
480
+ const invoiceHistory = document.createElement('invoice-history-ui');
481
+ invoiceHistory.setAttribute('data-billing-invoice-history', '');
482
+ invoiceHistoryHost.append(invoiceHistoryHeading, invoiceHistory);
483
+ this.appendChild(invoiceHistoryHost);
484
+ this.#invoiceHistoryHost = invoiceHistoryHost;
485
+ this.#invoiceHistoryHeading = invoiceHistoryHeading;
486
+ this.#invoiceHistory = invoiceHistory;
487
+
488
+ // After-invoice-history slot anchor.
489
+ const afterInvoiceHistory = document.createElement('div');
490
+ afterInvoiceHistory.setAttribute('data-after-invoice-history', '');
491
+ invoiceHistoryHost.appendChild(afterInvoiceHistory);
492
+ this.#absorbSlottedChildren('after-invoice-history', afterInvoiceHistory);
493
+
494
+ // ── Empty state ──
495
+ const empty = document.createElement('div');
496
+ empty.setAttribute('data-empty', '');
497
+ const emptyHeading = document.createElement('p');
498
+ emptyHeading.setAttribute('data-empty-heading', '');
499
+ emptyHeading.textContent = 'No billing account loaded';
500
+ const emptyDescription = document.createElement('p');
501
+ emptyDescription.setAttribute('data-empty-description', '');
502
+ emptyDescription.textContent = 'Pass an `account` snapshot or `data-stream-src` to render the dashboard.';
503
+ empty.append(emptyHeading, emptyDescription);
504
+ this.appendChild(empty);
505
+ this.#emptyHost = empty;
506
+
507
+ // Absorb consumer-authored [slot="empty"] override.
508
+ this.#absorbSlottedChildren('empty', this);
509
+ }
510
+
511
+ // ── Region renderers ──────────────────────────────────────────────
512
+
513
+ #renderHeader(account) {
514
+ if (!this.#headerTitle || !this.#headerStatus || !this.#headerSubtitle) {
515
+ return;
516
+ }
517
+
518
+ const plan = account?.plan || null;
519
+ const name = plan?.name ? String(plan.name) : 'Billing overview';
520
+ if (this.#headerTitle.textContent !== name) {
521
+ this.#headerTitle.textContent = name;
522
+ }
523
+
524
+ // Subtitle — formatted price · cycle.
525
+ const subtitle = this.#headerSubtitle;
526
+ subtitle.replaceChildren();
527
+ if (plan && Number.isFinite(plan.price)) {
528
+ const currency = plan.currency || this.currency || 'USD';
529
+ const locale = this.#resolveLocale();
530
+ const cycle = plan.cycle === 'year' ? '/yr' : '/mo';
531
+ const priceEl = document.createElement('span');
532
+ priceEl.setAttribute('data-plan-price', '');
533
+ priceEl.textContent = `${formatAmount(plan.price, currency, locale)} ${cycle}`;
534
+ subtitle.appendChild(priceEl);
535
+ }
536
+ if (account?.billingCycle && account.billingCycle !== plan?.cycle) {
537
+ const cycleEl = document.createElement('span');
538
+ cycleEl.setAttribute('data-plan-billing-cycle', '');
539
+ cycleEl.textContent = `Billed ${account.billingCycle}ly`;
540
+ subtitle.appendChild(cycleEl);
541
+ }
542
+
543
+ // Status badge — variant + label from STATUS_TO_VARIANT.
544
+ const status = account?.status || '';
545
+ if (!status) {
546
+ this.#headerStatus.setAttribute('hidden', '');
547
+ } else {
548
+ this.#headerStatus.removeAttribute('hidden');
549
+ const variant = STATUS_TO_VARIANT[status];
550
+ const label = STATUS_LABEL[status] || status;
551
+ if (variant) {
552
+ if (this.#headerStatus.getAttribute('variant') !== variant) {
553
+ this.#headerStatus.setAttribute('variant', variant);
554
+ }
555
+ this.#headerStatus.removeAttribute('tone');
556
+ } else {
557
+ this.#headerStatus.removeAttribute('variant');
558
+ if (this.#headerStatus.getAttribute('tone') !== 'muted') {
559
+ this.#headerStatus.setAttribute('tone', 'muted');
560
+ }
561
+ }
562
+ if (this.#headerStatus.getAttribute('text') !== label) {
563
+ this.#headerStatus.setAttribute('text', label);
564
+ }
565
+ this.#headerStatus.setAttribute('aria-label', `Account status: ${label}`);
566
+ }
567
+ }
568
+
569
+ #renderKpis(account) {
570
+ if (!this.#kpiSpend || !this.#kpiSeats || !this.#kpiInvoice) return;
571
+
572
+ const currency = account?.plan?.currency || this.currency || 'USD';
573
+ const locale = this.#resolveLocale();
574
+
575
+ // KPI 1 — current month spend.
576
+ if (Number.isFinite(account?.currentSpend)) {
577
+ this.#kpiSpend.setAttribute(
578
+ 'value',
579
+ formatAmount(account.currentSpend, currency, locale),
580
+ );
581
+ } else {
582
+ this.#kpiSpend.setAttribute('value', '—');
583
+ }
584
+
585
+ // KPI 2 — seats (used / total).
586
+ if (account?.seats && Number.isFinite(account.seats.used)) {
587
+ const used = account.seats.used;
588
+ const total = Number.isFinite(account.seats.total) ? account.seats.total : null;
589
+ this.#kpiSeats.setAttribute(
590
+ 'value',
591
+ total != null ? `${used} / ${total}` : String(used),
592
+ );
593
+ } else {
594
+ this.#kpiSeats.setAttribute('value', '—');
595
+ }
596
+
597
+ // KPI 3 — next invoice date.
598
+ if (account?.nextInvoiceDate) {
599
+ this.#kpiInvoice.setAttribute(
600
+ 'value',
601
+ formatDate(account.nextInvoiceDate, locale),
602
+ );
603
+ } else {
604
+ this.#kpiInvoice.setAttribute('value', '—');
605
+ }
606
+
607
+ // Forward [loading] to each stat.
608
+ for (const stat of [this.#kpiSpend, this.#kpiSeats, this.#kpiInvoice]) {
609
+ if (this.loading) stat.setAttribute('loading', '');
610
+ else stat.removeAttribute('loading');
611
+ }
612
+ }
613
+
614
+ #renderDunning(account) {
615
+ if (!this.#dunningHost || !this.#dunningAlert) return;
616
+
617
+ const dunning = account?.dunning || null;
618
+ const isPastDue = account?.status === 'past_due';
619
+
620
+ if (!isPastDue || !dunning) {
621
+ this.#dunningHost.setAttribute('hidden', '');
622
+ return;
623
+ }
624
+
625
+ this.#dunningHost.removeAttribute('hidden');
626
+
627
+ const amount = Number.isFinite(dunning.amount) ? dunning.amount : null;
628
+ const currency = dunning.currency || account?.plan?.currency || this.currency || 'USD';
629
+
630
+ if (amount != null) {
631
+ this.#dunningAlert.setAttribute('amount', String(amount));
632
+ } else {
633
+ this.#dunningAlert.removeAttribute('amount');
634
+ }
635
+ this.#dunningAlert.setAttribute('currency', currency);
636
+ if (dunning.dueAt) {
637
+ this.#dunningAlert.setAttribute('due-at', String(dunning.dueAt));
638
+ }
639
+ if (dunning.cardLast4) {
640
+ this.#dunningAlert.setAttribute('card-last4', String(dunning.cardLast4));
641
+ }
642
+ if (dunning.reason) {
643
+ this.#dunningAlert.setAttribute('reason', String(dunning.reason));
644
+ }
645
+
646
+ // Stamp dunning action buttons inside the alert's actions slot when
647
+ // none exist (consumer-overridable: any [slot=actions] descendant
648
+ // in the alert is preserved).
649
+ if (!this.#dunningAlert.querySelector(':scope > [slot="actions"]')) {
650
+ const updateBtn = document.createElement('button-ui');
651
+ updateBtn.setAttribute('slot', 'actions');
652
+ updateBtn.setAttribute('data-dunning-action', 'update');
653
+ updateBtn.setAttribute('variant', 'primary');
654
+ updateBtn.setAttribute('text', 'Update payment method');
655
+ this.#dunningAlert.appendChild(updateBtn);
656
+
657
+ const retryBtn = document.createElement('button-ui');
658
+ retryBtn.setAttribute('slot', 'actions');
659
+ retryBtn.setAttribute('data-dunning-action', 'retry');
660
+ retryBtn.setAttribute('variant', 'outline');
661
+ retryBtn.setAttribute('text', 'Retry charge');
662
+ this.#dunningAlert.appendChild(retryBtn);
663
+ }
664
+ }
665
+
666
+ #renderPlanPicker(account) {
667
+ if (!this.#planPickerHost || !this.#planPicker) return;
668
+
669
+ // Hide the section when variant != full OR when no plans were supplied.
670
+ const wants = this.variant === 'full';
671
+ const hasPlans = Array.isArray(account?.plans) && account.plans.length > 0;
672
+ const visible = wants && hasPlans;
673
+
674
+ if (!visible) {
675
+ this.#planPickerHost.setAttribute('hidden', '');
676
+ return;
677
+ }
678
+ this.#planPickerHost.removeAttribute('hidden');
679
+
680
+ // Forward plans + current plan id.
681
+ this.#planPicker.plans = account.plans;
682
+ if (account?.plan?.id) {
683
+ this.#planPicker.setAttribute('current', String(account.plan.id));
684
+ }
685
+
686
+ if (this.loading) this.#planPicker.setAttribute('loading', '');
687
+ else this.#planPicker.removeAttribute('loading');
688
+ }
689
+
690
+ #renderPaymentMethods(account) {
691
+ if (!this.#paymentMethods) return;
692
+
693
+ const methods = Array.isArray(account?.paymentMethods)
694
+ ? account.paymentMethods
695
+ : [];
696
+ this.#paymentMethods.methods = methods;
697
+
698
+ if (this.loading) this.#paymentMethods.setAttribute('loading', '');
699
+ else this.#paymentMethods.removeAttribute('loading');
700
+ }
701
+
702
+ #renderInvoiceHistory(account) {
703
+ if (!this.#invoiceHistory) return;
704
+
705
+ const invoices = Array.isArray(account?.invoices)
706
+ ? account.invoices
707
+ : [];
708
+ this.#invoiceHistory.invoices = invoices;
709
+ const currency = account?.plan?.currency || this.currency || 'USD';
710
+ this.#invoiceHistory.setAttribute('currency', currency);
711
+
712
+ if (this.loading) this.#invoiceHistory.setAttribute('loading', '');
713
+ else this.#invoiceHistory.removeAttribute('loading');
714
+ }
715
+
716
+ #renderAccessibleLabel(account) {
717
+ if (this.#headingId) {
718
+ this.setAttribute('aria-labelledby', this.#headingId);
719
+ }
720
+ if (!account && !this.hasAttribute('aria-label')) {
721
+ this.setAttribute('aria-label', 'No billing account loaded');
722
+ } else if (account) {
723
+ this.removeAttribute('aria-label');
724
+ }
725
+ }
726
+
727
+ #resolveLocale() {
728
+ if (this.locale) return this.locale;
729
+ if (typeof document !== 'undefined') {
730
+ return document.documentElement.lang || '';
731
+ }
732
+ return '';
733
+ }
734
+
735
+ // ── Event delegation — unified `account-action` ──────────────────
736
+
737
+ #wireListeners() {
738
+ // Generic [data-account-action] click → emit account-action.
739
+ const onActionClick = (e) => {
740
+ const target = e.target?.closest?.('[data-account-action]');
741
+ if (!target || !this.contains(target)) return;
742
+ const action = target.getAttribute('data-account-action') || '';
743
+ if (!action) return;
744
+ e.stopPropagation();
745
+ this.#emitAccountAction(action, 'header', { target });
746
+ };
747
+ this.addEventListener('press', onActionClick);
748
+ this.addEventListener('click', onActionClick);
749
+ this.#cleanups.push(() => this.removeEventListener('press', onActionClick));
750
+ this.#cleanups.push(() => this.removeEventListener('click', onActionClick));
751
+
752
+ // Dunning alert action → emit account-action.
753
+ const onDunning = (e) => {
754
+ const intent = e.detail?.action || '';
755
+ const action = DUNNING_INTENT[intent] || intent || 'dunning';
756
+ this.#emitAccountAction(action, 'alert-dunning', e.detail);
757
+ };
758
+ this.addEventListener('dunning-action', onDunning);
759
+ this.#cleanups.push(
760
+ () => this.removeEventListener('dunning-action', onDunning),
761
+ );
762
+
763
+ // Plan-picker child events → emit account-action.
764
+ const onPlanChange = (e) => {
765
+ const planId = e.detail?.value ?? e.detail?.plan?.id ?? '';
766
+ this.#emitAccountAction('select-plan', 'plan-picker', { planId, ...e.detail });
767
+ };
768
+ this.addEventListener('plan-change', onPlanChange);
769
+ this.#cleanups.push(
770
+ () => this.removeEventListener('plan-change', onPlanChange),
771
+ );
772
+ // plan-picker-ui fires `change` for value changes too — bridge it
773
+ // when the event originates from the embedded picker.
774
+ const onPlanPickerChange = (e) => {
775
+ if (!this.#planPicker || !this.#planPicker.contains(e.target)
776
+ && e.target !== this.#planPicker) {
777
+ return;
778
+ }
779
+ this.#emitAccountAction('select-plan', 'plan-picker', e.detail);
780
+ };
781
+ this.addEventListener('change', onPlanPickerChange);
782
+ this.#cleanups.push(
783
+ () => this.removeEventListener('change', onPlanPickerChange),
784
+ );
785
+
786
+ // Payment-method-list child events.
787
+ const onPaymentAdd = () => {
788
+ this.#emitAccountAction('add-payment-method', 'payment-method-list', null);
789
+ };
790
+ this.addEventListener('add', onPaymentAdd);
791
+ this.#cleanups.push(() => this.removeEventListener('add', onPaymentAdd));
792
+
793
+ const onPaymentRemove = (e) => {
794
+ this.#emitAccountAction('remove-payment-method', 'payment-method-list', e.detail);
795
+ };
796
+ this.addEventListener('remove', onPaymentRemove);
797
+ this.#cleanups.push(() => this.removeEventListener('remove', onPaymentRemove));
798
+
799
+ const onPaymentSelect = (e) => {
800
+ this.#emitAccountAction('set-default-payment-method', 'payment-method-list', e.detail);
801
+ };
802
+ this.addEventListener('select', onPaymentSelect);
803
+ this.#cleanups.push(() => this.removeEventListener('select', onPaymentSelect));
804
+
805
+ // Invoice-history child events.
806
+ const onInvoiceRowClick = (e) => {
807
+ this.#emitAccountAction('view-invoice', 'invoice-history', e.detail);
808
+ };
809
+ this.addEventListener('invoice-row-click', onInvoiceRowClick);
810
+ this.#cleanups.push(
811
+ () => this.removeEventListener('invoice-row-click', onInvoiceRowClick),
812
+ );
813
+
814
+ const onInvoiceExport = (e) => {
815
+ this.#emitAccountAction('download-invoice', 'invoice-history', e.detail);
816
+ };
817
+ this.addEventListener('invoice-export', onInvoiceExport);
818
+ this.#cleanups.push(
819
+ () => this.removeEventListener('invoice-export', onInvoiceExport),
820
+ );
821
+
822
+ const onInvoiceAction = (e) => {
823
+ const action = e.detail?.action || 'invoice';
824
+ this.#emitAccountAction(action, 'invoice-history', e.detail);
825
+ };
826
+ this.addEventListener('invoice-action', onInvoiceAction);
827
+ this.#cleanups.push(
828
+ () => this.removeEventListener('invoice-action', onInvoiceAction),
829
+ );
830
+ }
831
+
832
+ #emitAccountAction(action, source, payload) {
833
+ this.dispatchEvent(new CustomEvent('account-action', {
834
+ bubbles: true,
835
+ detail: { action, source, payload: payload || null },
836
+ }));
837
+ }
838
+ }