@adia-ai/web-modules 0.8.36 → 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.
- package/CHANGELOG.md +17 -0
- package/README.md +3 -3
- package/agent-admin/admin-roster/admin-roster.js +1 -1
- package/agent-admin/admin-settings/admin-settings.js +1 -1
- package/agent-admin/agent-admin/agent-admin.js +1 -1
- package/billing/billing-overview/billing-overview.a2ui.json +281 -0
- package/billing/billing-overview/billing-overview.class.js +838 -0
- package/billing/billing-overview/billing-overview.css +223 -0
- package/billing/billing-overview/billing-overview.js +16 -0
- package/billing/billing-overview/billing-overview.yaml +441 -0
- package/billing/index.js +21 -0
- package/billing/invoice-detail/invoice-detail.a2ui.json +276 -0
- package/billing/invoice-detail/invoice-detail.class.js +711 -0
- package/billing/invoice-detail/invoice-detail.css +305 -0
- package/billing/invoice-detail/invoice-detail.js +16 -0
- package/billing/invoice-detail/invoice-detail.yaml +411 -0
- package/billing/invoice-history/invoice-history.a2ui.json +315 -0
- package/billing/invoice-history/invoice-history.class.js +672 -0
- package/billing/invoice-history/invoice-history.css +130 -0
- package/billing/invoice-history/invoice-history.js +16 -0
- package/billing/invoice-history/invoice-history.yaml +398 -0
- package/billing/payment-method-form/payment-method-form.a2ui.json +310 -0
- package/billing/payment-method-form/payment-method-form.class.js +886 -0
- package/billing/payment-method-form/payment-method-form.css +149 -0
- package/billing/payment-method-form/payment-method-form.js +16 -0
- package/billing/payment-method-form/payment-method-form.yaml +430 -0
- package/billing/payment-method-list/payment-method-list.a2ui.json +283 -0
- package/billing/payment-method-list/payment-method-list.class.js +705 -0
- package/billing/payment-method-list/payment-method-list.css +128 -0
- package/billing/payment-method-list/payment-method-list.js +16 -0
- package/billing/payment-method-list/payment-method-list.yaml +355 -0
- package/billing/plan-picker/plan-picker.a2ui.json +349 -0
- package/billing/plan-picker/plan-picker.class.js +635 -0
- package/billing/plan-picker/plan-picker.css +227 -0
- package/billing/plan-picker/plan-picker.js +16 -0
- package/billing/plan-picker/plan-picker.yaml +453 -0
- package/chat/README.md +1 -1
- package/chat/chat-composer/chat-composer.js +2 -2
- package/chat/chat-empty/chat-empty.js +2 -2
- package/chat/chat-shell/chat-shell.js +4 -4
- package/chat/chat-sidebar/chat-sidebar.js +2 -2
- package/chat/chat-surfaces/chat-surfaces.js +2 -2
- package/chat/chat-thread/chat-thread.js +2 -2
- package/dashboard/dashboard-layout/dashboard-layout.a2ui.json +254 -0
- package/dashboard/dashboard-layout/dashboard-layout.css +190 -0
- package/dashboard/dashboard-layout/dashboard-layout.js +289 -0
- package/dashboard/dashboard-layout/dashboard-layout.yaml +374 -0
- package/dashboard/date-range-selector/date-range-selector.a2ui.json +236 -0
- package/dashboard/date-range-selector/date-range-selector.css +84 -0
- package/dashboard/date-range-selector/date-range-selector.js +686 -0
- package/dashboard/date-range-selector/date-range-selector.yaml +358 -0
- package/dashboard/index.js +14 -0
- package/dist/everything.min.js +1 -1
- package/editor/editor-canvas/editor-canvas.js +2 -2
- package/editor/editor-shell/editor-shell.js +2 -2
- package/editor/editor-sidebar/editor-sidebar.js +2 -2
- package/editor/editor-toolbar/editor-toolbar.js +2 -2
- package/feedback/confirm-dialog/confirm-dialog.a2ui.json +206 -0
- package/feedback/confirm-dialog/confirm-dialog.css +157 -0
- package/feedback/confirm-dialog/confirm-dialog.js +383 -0
- package/feedback/confirm-dialog/confirm-dialog.yaml +297 -0
- package/feedback/index.js +10 -0
- package/form/form-popover/form-popover.js +1 -1
- package/generative/index.d.ts +1 -1
- package/generative/index.js +2 -2
- package/onboarding/index.js +1 -0
- package/onboarding/onboarding-checklist/onboarding-checklist.a2ui.json +263 -0
- package/onboarding/onboarding-checklist/onboarding-checklist.css +157 -0
- package/onboarding/onboarding-checklist/onboarding-checklist.js +428 -0
- package/onboarding/onboarding-checklist/onboarding-checklist.yaml +236 -0
- package/package.json +99 -6
- package/runtime/a2ui-root/a2ui-root.js +3 -3
- package/runtime/gen-root/gen-root.js +5 -5
- package/settings/index.js +12 -0
- package/settings/integrations-page/integrations-page.a2ui.json +274 -0
- package/settings/integrations-page/integrations-page.css +177 -0
- package/settings/integrations-page/integrations-page.d.ts +77 -0
- package/settings/integrations-page/integrations-page.js +413 -0
- package/settings/integrations-page/integrations-page.yaml +317 -0
- package/settings/notification-preferences/notification-preferences.a2ui.json +245 -0
- package/settings/notification-preferences/notification-preferences.css +204 -0
- package/settings/notification-preferences/notification-preferences.js +622 -0
- package/settings/notification-preferences/notification-preferences.yaml +358 -0
- package/shell/admin-command/admin-command.js +2 -2
- package/shell/admin-shell/admin-shell.js +2 -2
- package/shell/admin-sidebar/admin-sidebar.js +2 -2
- package/shell/embed-shell/embed-shell.js +3 -3
- package/simple/simple-shell/simple-shell.js +1 -1
- package/theme/theme-panel/theme-panel.js +2 -2
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <plan-picker-ui> — Form-participating chooser of one of N billing plans.
|
|
3
|
+
*
|
|
4
|
+
* The canonical plan-card grid used by both in-app upgrade flows AND the
|
|
5
|
+
* marketing pricing page (SPEC-012). Each plan card is composed from
|
|
6
|
+
* <option-card-ui> (radio semantics) decorated with a price block,
|
|
7
|
+
* feature list, and CTA button derived from the plan record. The
|
|
8
|
+
* optional cycle toggle is a <segmented-ui> that swaps monthly /
|
|
9
|
+
* annual prices in place across every card.
|
|
10
|
+
*
|
|
11
|
+
* SPEC: .claude/docs/specs/implementation-ready/SPEC-011-plan-picker-in-app-upgrade.md
|
|
12
|
+
* WAI-APG: https://www.w3.org/WAI/ARIA/apg/patterns/radio/
|
|
13
|
+
*
|
|
14
|
+
* Architecture (light-DOM, ADR-0033):
|
|
15
|
+
* This composite stamps a four-region skeleton once on first connect:
|
|
16
|
+
* [data-toggle] · [data-plans] · [data-skeleton] · [data-empty]
|
|
17
|
+
* Then mutates per-card DOM by id-keyed in-place diff on every
|
|
18
|
+
* `plans` / `value` / `current` / `cycle` change. The diff preserves
|
|
19
|
+
* focus-within during re-render (replacing children would break the
|
|
20
|
+
* radiogroup keyboard model — see the FLIP-via-in-place-diff memory).
|
|
21
|
+
*
|
|
22
|
+
* Form participation:
|
|
23
|
+
* Extends UIFormElement. `value` (selected plan id) serializes under
|
|
24
|
+
* `name` via ElementInternals. Per ADR-0025 no native radio is
|
|
25
|
+
* rendered — the selected card's [checked] state is the sole source
|
|
26
|
+
* of truth.
|
|
27
|
+
*
|
|
28
|
+
* Public API:
|
|
29
|
+
* .setValue(id) — select a plan by id; returns true on hit.
|
|
30
|
+
* .setCycle(cycle) — switch the billing cycle; returns true on hit.
|
|
31
|
+
* .refresh() — re-fetch from data-stream-src when configured.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { UIFormElement } from '@adia-ai/web-components/core/form';
|
|
35
|
+
import { untracked } from '@adia-ai/web-components/core/signals';
|
|
36
|
+
|
|
37
|
+
const KNOWN_CYCLES = new Set(['monthly', 'annual']);
|
|
38
|
+
|
|
39
|
+
const CYCLE_LABELS = {
|
|
40
|
+
monthly: 'Monthly',
|
|
41
|
+
annual: 'Annual',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const CYCLE_SUFFIX = {
|
|
45
|
+
monthly: '/month',
|
|
46
|
+
annual: '/year',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Format an amount with Intl.NumberFormat for the given currency + locale.
|
|
51
|
+
* Returns a string like "$25" or "€20"; falls back to "$N" on failure.
|
|
52
|
+
*/
|
|
53
|
+
function formatAmount(amount, currency, locale) {
|
|
54
|
+
const value = Number.isFinite(amount) ? amount : 0;
|
|
55
|
+
try {
|
|
56
|
+
return new Intl.NumberFormat(locale || undefined, {
|
|
57
|
+
style: 'currency',
|
|
58
|
+
currency: currency || 'USD',
|
|
59
|
+
maximumFractionDigits: Number.isInteger(value) ? 0 : 2,
|
|
60
|
+
}).format(value);
|
|
61
|
+
} catch {
|
|
62
|
+
return `${currency || '$'}${value}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Derive the CTA label for a plan relative to the host's `current`.
|
|
68
|
+
* - Same id as current → "Manage"
|
|
69
|
+
* - rank > currentRank → "Upgrade"
|
|
70
|
+
* - rank < currentRank → "Downgrade"
|
|
71
|
+
* - otherwise → "Choose {name}"
|
|
72
|
+
* Consumer can override per-record via plans[].cta.
|
|
73
|
+
*/
|
|
74
|
+
function deriveCtaLabel(plan, plans, current) {
|
|
75
|
+
if (plan.cta) return plan.cta;
|
|
76
|
+
if (!current) return `Choose ${plan.name || ''}`.trim();
|
|
77
|
+
if (plan.id === current) return 'Manage';
|
|
78
|
+
const cur = plans.find((p) => p.id === current);
|
|
79
|
+
if (cur && Number.isFinite(cur.rank) && Number.isFinite(plan.rank)) {
|
|
80
|
+
if (plan.rank > cur.rank) return 'Upgrade';
|
|
81
|
+
if (plan.rank < cur.rank) return 'Downgrade';
|
|
82
|
+
}
|
|
83
|
+
return `Switch to ${plan.name || ''}`.trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class PlanPicker extends UIFormElement {
|
|
87
|
+
// `label` is NOT first-class here — host carries aria-label directly.
|
|
88
|
+
static labelDeprecated = true;
|
|
89
|
+
|
|
90
|
+
static get properties() {
|
|
91
|
+
return {
|
|
92
|
+
...UIFormElement.properties,
|
|
93
|
+
// `plans` is the source of truth — array via prop OR JSON attribute.
|
|
94
|
+
plans: { type: Array, default: [] },
|
|
95
|
+
// Currently selected plan id. Mirrors UIFormElement.value.
|
|
96
|
+
value: { type: String, default: '', reflect: true },
|
|
97
|
+
// Anchor id — the user's current plan.
|
|
98
|
+
current: { type: String, default: '', reflect: true },
|
|
99
|
+
cycle: { type: String, default: 'monthly', reflect: true },
|
|
100
|
+
cycleToggle: { type: Boolean, default: false, reflect: true, attribute: 'cycle-toggle' },
|
|
101
|
+
currency: { type: String, default: 'USD' },
|
|
102
|
+
locale: { type: String, default: '' },
|
|
103
|
+
layout: { type: String, default: 'grid', reflect: true },
|
|
104
|
+
disabled: { type: Boolean, default: false, reflect: true },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// No template — the composite stamps a fixed region skeleton once
|
|
109
|
+
// (toggle · plans · skeleton · empty) and mutates per-region nodes
|
|
110
|
+
// across renders. Returning html`` would trigger stamp() →
|
|
111
|
+
// replaceChildren() and wipe authored slot children + zap the
|
|
112
|
+
// <option-card-ui> radiogroup state.
|
|
113
|
+
static template = () => null;
|
|
114
|
+
|
|
115
|
+
// ── Field refs ────────────────────────────────────────────────────
|
|
116
|
+
#stamped = false;
|
|
117
|
+
#cleanups = [];
|
|
118
|
+
#toggleHost = null; // <div data-toggle> wrapper
|
|
119
|
+
#toggleSegmented = null; // <segmented-ui> inside toggle host
|
|
120
|
+
#plansHost = null; // <div data-plans role=radiogroup>
|
|
121
|
+
#skeletonHost = null; // <div data-skeleton>
|
|
122
|
+
#emptyHost = null; // <empty-state-ui data-empty>
|
|
123
|
+
#cardsById = new Map(); // id → <option-card-ui>
|
|
124
|
+
#internalUpdate = false;
|
|
125
|
+
#warnedCycle = false;
|
|
126
|
+
#warnedRecommend = false;
|
|
127
|
+
#loadCount = 0; // monotonic — detects out-of-order fetches
|
|
128
|
+
|
|
129
|
+
// ── Public imperative API (SPEC §4 Methods) ───────────────────────
|
|
130
|
+
|
|
131
|
+
/** Select a plan by id. Returns true on hit. */
|
|
132
|
+
setValue(id) {
|
|
133
|
+
if (!id) return false;
|
|
134
|
+
return untracked(() => {
|
|
135
|
+
const found = (this.plans || []).find((p) => p.id === id);
|
|
136
|
+
if (!found) return false;
|
|
137
|
+
if (found.disabled) {
|
|
138
|
+
this.dispatchEvent(new CustomEvent('error', {
|
|
139
|
+
bubbles: true,
|
|
140
|
+
detail: { reason: 'plan-disabled' },
|
|
141
|
+
}));
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
if (this.value === id) return true;
|
|
145
|
+
const previous = this.value || '';
|
|
146
|
+
this.value = id;
|
|
147
|
+
this.syncValue();
|
|
148
|
+
this.#emitChange(id, previous);
|
|
149
|
+
return true;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Switch the billing cycle. Returns true on hit. */
|
|
154
|
+
setCycle(cycle) {
|
|
155
|
+
if (!KNOWN_CYCLES.has(cycle)) return false;
|
|
156
|
+
return untracked(() => {
|
|
157
|
+
if (this.cycle === cycle) return true;
|
|
158
|
+
const previous = this.cycle;
|
|
159
|
+
this.cycle = cycle;
|
|
160
|
+
this.dispatchEvent(new CustomEvent('cycle-change', {
|
|
161
|
+
bubbles: true,
|
|
162
|
+
detail: { cycle, previous },
|
|
163
|
+
}));
|
|
164
|
+
return true;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Re-fetch plans from `data-stream-src`. */
|
|
169
|
+
async refresh() {
|
|
170
|
+
const src = this.getAttribute('data-stream-src');
|
|
171
|
+
if (!src) return;
|
|
172
|
+
const tag = ++this.#loadCount;
|
|
173
|
+
this.setAttribute('loading', '');
|
|
174
|
+
this.removeAttribute('error');
|
|
175
|
+
try {
|
|
176
|
+
const res = await fetch(src);
|
|
177
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
178
|
+
const data = await res.json();
|
|
179
|
+
if (tag !== this.#loadCount) return; // stale
|
|
180
|
+
this.removeAttribute('loading');
|
|
181
|
+
const list = Array.isArray(data) ? data : (Array.isArray(data?.plans) ? data.plans : []);
|
|
182
|
+
this.plans = list;
|
|
183
|
+
this.dispatchEvent(new CustomEvent('load', {
|
|
184
|
+
bubbles: true,
|
|
185
|
+
detail: { count: list.length },
|
|
186
|
+
}));
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (tag !== this.#loadCount) return;
|
|
189
|
+
this.removeAttribute('loading');
|
|
190
|
+
this.setAttribute('error', '');
|
|
191
|
+
this.dispatchEvent(new CustomEvent('error', {
|
|
192
|
+
bubbles: true,
|
|
193
|
+
detail: { reason: 'fetch-failed', message: String(err?.message || err) },
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Lifecycle ─────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
connected() {
|
|
201
|
+
super.connected();
|
|
202
|
+
this.setAttribute('role', 'radiogroup');
|
|
203
|
+
if (!this.hasAttribute('aria-label') && !this.hasAttribute('aria-labelledby')) {
|
|
204
|
+
this.setAttribute('aria-label', 'Choose a plan');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!this.#stamped) {
|
|
208
|
+
// Attempt to absorb a <script type="application/json" data-plans>
|
|
209
|
+
// child before stamping wipes the host's children. Authors can
|
|
210
|
+
// write the plans array declaratively this way (the in-band JSON
|
|
211
|
+
// pattern shipped by chart-ui + table-ui).
|
|
212
|
+
this.#absorbInlinePlansScript();
|
|
213
|
+
this.#stamp();
|
|
214
|
+
this.#wireListeners();
|
|
215
|
+
this.#stamped = true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Trigger first render after the registry has had a chance to
|
|
219
|
+
// upgrade the composed primitives.
|
|
220
|
+
untracked(() => this.render());
|
|
221
|
+
|
|
222
|
+
// Fetch on connect when configured.
|
|
223
|
+
if (this.getAttribute('data-stream-src')) {
|
|
224
|
+
this.refresh();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
disconnected() {
|
|
229
|
+
super.disconnected();
|
|
230
|
+
for (const fn of this.#cleanups) {
|
|
231
|
+
try { fn(); } catch { /* ignore stale */ }
|
|
232
|
+
}
|
|
233
|
+
this.#cleanups.length = 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
attributeChangedCallback(name, oldVal, newVal) {
|
|
237
|
+
super.attributeChangedCallback?.(name, oldVal, newVal);
|
|
238
|
+
if (!this.#stamped || this.#internalUpdate) return;
|
|
239
|
+
if (name === 'data-stream-src' && oldVal !== newVal && newVal) {
|
|
240
|
+
this.refresh();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Form participation ────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
/** Selected plan id is the form value. */
|
|
247
|
+
syncValue(_val) {
|
|
248
|
+
this.internals.setFormValue(this.value || '');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Render — narrow; mutates per-region nodes only ────────────────
|
|
252
|
+
|
|
253
|
+
render() {
|
|
254
|
+
if (!this.#stamped) return;
|
|
255
|
+
|
|
256
|
+
const plans = Array.isArray(this.plans) ? this.plans : [];
|
|
257
|
+
|
|
258
|
+
// Coerce unknown cycle to monthly (one-shot warn).
|
|
259
|
+
if (!KNOWN_CYCLES.has(this.cycle)) {
|
|
260
|
+
if (!this.#warnedCycle) {
|
|
261
|
+
this.#warnedCycle = true;
|
|
262
|
+
// eslint-disable-next-line no-console
|
|
263
|
+
console.warn(`[plan-picker-ui] unknown cycle "${this.cycle}" — coercing to "monthly".`);
|
|
264
|
+
}
|
|
265
|
+
this.#internalUpdate = true;
|
|
266
|
+
try { this.cycle = 'monthly'; } finally { this.#internalUpdate = false; }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Warn on multiple recommended (one-shot).
|
|
270
|
+
const recommended = plans.filter((p) => p.recommended);
|
|
271
|
+
if (recommended.length > 1 && !this.#warnedRecommend) {
|
|
272
|
+
this.#warnedRecommend = true;
|
|
273
|
+
// eslint-disable-next-line no-console
|
|
274
|
+
console.warn(
|
|
275
|
+
`[plan-picker-ui] ${recommended.length} plans carry recommended:true — ` +
|
|
276
|
+
`using the first ("${recommended[0].id}"); ignoring the rest.`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
const recommendedId = recommended[0]?.id || '';
|
|
280
|
+
|
|
281
|
+
// Empty / non-empty state attribute (CSS-gated reveal).
|
|
282
|
+
if (plans.length === 0) {
|
|
283
|
+
this.setAttribute('empty', '');
|
|
284
|
+
} else {
|
|
285
|
+
this.removeAttribute('empty');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Toggle visibility / contents.
|
|
289
|
+
if (this.#toggleHost) {
|
|
290
|
+
const show = this.cycleToggle && plans.length > 0;
|
|
291
|
+
this.#toggleHost.hidden = !show;
|
|
292
|
+
if (show) this.#renderToggle();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Diff plan cards by id.
|
|
296
|
+
this.#renderCards(plans, recommendedId);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── Internal — DOM stamping ───────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
#absorbInlinePlansScript() {
|
|
302
|
+
const script = this.querySelector(':scope > script[type="application/json"][data-plans]');
|
|
303
|
+
if (!script) return;
|
|
304
|
+
try {
|
|
305
|
+
const parsed = JSON.parse(script.textContent || '[]');
|
|
306
|
+
if (Array.isArray(parsed)) this.plans = parsed;
|
|
307
|
+
} catch (err) {
|
|
308
|
+
// eslint-disable-next-line no-console
|
|
309
|
+
console.warn('[plan-picker-ui] failed to parse <script data-plans> JSON:', err?.message);
|
|
310
|
+
} finally {
|
|
311
|
+
script.remove();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#stamp() {
|
|
316
|
+
// Toggle host — initially hidden; #renderToggle stamps its content
|
|
317
|
+
// when cycle-toggle is set.
|
|
318
|
+
const toggle = document.createElement('div');
|
|
319
|
+
toggle.setAttribute('data-toggle', '');
|
|
320
|
+
toggle.hidden = true;
|
|
321
|
+
this.appendChild(toggle);
|
|
322
|
+
this.#toggleHost = toggle;
|
|
323
|
+
|
|
324
|
+
// Plans host — the radiogroup container the option-card-ui kids
|
|
325
|
+
// live in. We give it role="presentation" because the OUTER host
|
|
326
|
+
// already carries role="radiogroup" and nesting two radiogroups
|
|
327
|
+
// would confuse AT.
|
|
328
|
+
const plansHost = document.createElement('div');
|
|
329
|
+
plansHost.setAttribute('data-plans', '');
|
|
330
|
+
plansHost.setAttribute('role', 'presentation');
|
|
331
|
+
this.appendChild(plansHost);
|
|
332
|
+
this.#plansHost = plansHost;
|
|
333
|
+
|
|
334
|
+
// Skeleton host — placeholder during data-stream loading.
|
|
335
|
+
const skeleton = document.createElement('div');
|
|
336
|
+
skeleton.setAttribute('data-skeleton', '');
|
|
337
|
+
skeleton.hidden = true;
|
|
338
|
+
this.appendChild(skeleton);
|
|
339
|
+
this.#skeletonHost = skeleton;
|
|
340
|
+
|
|
341
|
+
// Default empty-state — overridable via slot="empty".
|
|
342
|
+
if (!this.querySelector(':scope > [slot="empty"]')) {
|
|
343
|
+
const empty = document.createElement('empty-state-ui');
|
|
344
|
+
empty.setAttribute('data-empty', '');
|
|
345
|
+
empty.setAttribute('icon', 'package');
|
|
346
|
+
empty.setAttribute('heading', 'No plans available');
|
|
347
|
+
empty.setAttribute('description', 'Plans will appear here once configured.');
|
|
348
|
+
this.appendChild(empty);
|
|
349
|
+
this.#emptyHost = empty;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
#renderToggle() {
|
|
354
|
+
if (!this.#toggleHost) return;
|
|
355
|
+
let seg = this.#toggleSegmented;
|
|
356
|
+
if (!seg) {
|
|
357
|
+
seg = document.createElement('segmented-ui');
|
|
358
|
+
seg.setAttribute('aria-label', 'Billing cycle');
|
|
359
|
+
this.#toggleHost.appendChild(seg);
|
|
360
|
+
this.#toggleSegmented = seg;
|
|
361
|
+
// Stamp segments — known cycles in display order.
|
|
362
|
+
for (const cycleKey of ['monthly', 'annual']) {
|
|
363
|
+
const s = document.createElement('segment-ui');
|
|
364
|
+
s.setAttribute('value', cycleKey);
|
|
365
|
+
s.setAttribute('text', CYCLE_LABELS[cycleKey]);
|
|
366
|
+
seg.appendChild(s);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (seg.value !== this.cycle) {
|
|
370
|
+
seg.value = this.cycle;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
#renderCards(plans, recommendedId) {
|
|
375
|
+
if (!this.#plansHost) return;
|
|
376
|
+
|
|
377
|
+
const next = new Map();
|
|
378
|
+
|
|
379
|
+
for (const plan of plans) {
|
|
380
|
+
if (!plan?.id) continue;
|
|
381
|
+
let card = this.#cardsById.get(plan.id);
|
|
382
|
+
if (!card) {
|
|
383
|
+
card = document.createElement('option-card-ui');
|
|
384
|
+
card.setAttribute('layout', 'tile');
|
|
385
|
+
this.#plansHost.appendChild(card);
|
|
386
|
+
}
|
|
387
|
+
this.#decorateCard(card, plan, recommendedId);
|
|
388
|
+
next.set(plan.id, card);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Remove dropped cards (was in #cardsById but not in next).
|
|
392
|
+
for (const [id, card] of this.#cardsById) {
|
|
393
|
+
if (!next.has(id)) {
|
|
394
|
+
try { card.remove(); } catch { /* ignore */ }
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Reorder DOM to match the input array order (in-place move via
|
|
399
|
+
// appendChild semantics — preserves identity + focus).
|
|
400
|
+
for (const plan of plans) {
|
|
401
|
+
const card = next.get(plan.id);
|
|
402
|
+
if (card) this.#plansHost.appendChild(card);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
this.#cardsById = next;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
#decorateCard(card, plan, recommendedId) {
|
|
409
|
+
// Identity + form wiring.
|
|
410
|
+
card.setAttribute('name', `${this.name || 'plan'}__${this.id || 'pp'}`);
|
|
411
|
+
card.setAttribute('value', plan.id);
|
|
412
|
+
|
|
413
|
+
// Heading + tagline (the first becomes the option-card heading
|
|
414
|
+
// attribute, the second its description).
|
|
415
|
+
card.setAttribute('heading', plan.name || '');
|
|
416
|
+
card.setAttribute('description', plan.tagline || '');
|
|
417
|
+
if (plan.icon) card.setAttribute('icon', plan.icon);
|
|
418
|
+
else card.removeAttribute('icon');
|
|
419
|
+
|
|
420
|
+
// State attributes the CSS scope keys off.
|
|
421
|
+
const isCurrent = !!(this.current && plan.id === this.current);
|
|
422
|
+
const isRecommended = plan.id === recommendedId;
|
|
423
|
+
if (isCurrent) card.setAttribute('data-current', '');
|
|
424
|
+
else card.removeAttribute('data-current');
|
|
425
|
+
if (isRecommended) card.setAttribute('data-recommended', '');
|
|
426
|
+
else card.removeAttribute('data-recommended');
|
|
427
|
+
|
|
428
|
+
// Plan-disabled state (per-plan); rendered alongside host disabled.
|
|
429
|
+
if (plan.disabled || this.disabled) card.setAttribute('disabled', '');
|
|
430
|
+
else card.removeAttribute('disabled');
|
|
431
|
+
|
|
432
|
+
// Selection: current plan defaults to checked unless host value
|
|
433
|
+
// overrides; otherwise the host value chooses.
|
|
434
|
+
const isChecked =
|
|
435
|
+
(this.value && this.value === plan.id) ||
|
|
436
|
+
(!this.value && isCurrent);
|
|
437
|
+
if (isChecked) card.setAttribute('checked', '');
|
|
438
|
+
else card.removeAttribute('checked');
|
|
439
|
+
|
|
440
|
+
// Stamp the price + features + CTA block as the spillover (default
|
|
441
|
+
// slot) — option-card-ui shows un-slotted children only when the
|
|
442
|
+
// card is [checked]; we override that via our own CSS so plan
|
|
443
|
+
// bodies show always.
|
|
444
|
+
let body = card.querySelector(':scope > [data-plan-body]');
|
|
445
|
+
if (!body) {
|
|
446
|
+
body = document.createElement('div');
|
|
447
|
+
body.setAttribute('data-plan-body', '');
|
|
448
|
+
card.appendChild(body);
|
|
449
|
+
}
|
|
450
|
+
this.#decorateBody(body, plan);
|
|
451
|
+
|
|
452
|
+
// Tags region (recommended / current pill).
|
|
453
|
+
let tags = card.querySelector(':scope > [data-plan-tags]');
|
|
454
|
+
if (!tags) {
|
|
455
|
+
tags = document.createElement('div');
|
|
456
|
+
tags.setAttribute('data-plan-tags', '');
|
|
457
|
+
tags.setAttribute('slot', 'heading-suffix');
|
|
458
|
+
card.appendChild(tags);
|
|
459
|
+
}
|
|
460
|
+
tags.replaceChildren();
|
|
461
|
+
if (isCurrent) {
|
|
462
|
+
const t = document.createElement('tag-ui');
|
|
463
|
+
t.setAttribute('text', 'Current plan');
|
|
464
|
+
// `muted` is not in tag's `variant` enum and matches no rule — the
|
|
465
|
+
// pill already renders the solid-neutral base pair (gh#794), and a
|
|
466
|
+
// bare tag renders the same pair, so dropping the call is a
|
|
467
|
+
// zero-change edit; NB the pill is a solid stamp, not quiet chrome.
|
|
468
|
+
tags.appendChild(t);
|
|
469
|
+
} else if (isRecommended) {
|
|
470
|
+
const t = document.createElement('tag-ui');
|
|
471
|
+
t.setAttribute('text', 'Recommended');
|
|
472
|
+
t.setAttribute('variant', 'primary');
|
|
473
|
+
tags.appendChild(t);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
#decorateBody(body, plan) {
|
|
478
|
+
// Wipe + restamp body (small DOM; cycle / feature-list / cta).
|
|
479
|
+
body.replaceChildren();
|
|
480
|
+
|
|
481
|
+
// Price row.
|
|
482
|
+
const priceRow = document.createElement('div');
|
|
483
|
+
priceRow.setAttribute('data-price-row', '');
|
|
484
|
+
const cycle = KNOWN_CYCLES.has(this.cycle) ? this.cycle : 'monthly';
|
|
485
|
+
const prices = plan.prices || {};
|
|
486
|
+
const priceRec = prices[cycle] || prices.monthly || null;
|
|
487
|
+
|
|
488
|
+
if (priceRec) {
|
|
489
|
+
const amountEl = document.createElement('span');
|
|
490
|
+
amountEl.setAttribute('data-price', '');
|
|
491
|
+
const currency = priceRec.currency || this.currency || 'USD';
|
|
492
|
+
amountEl.textContent = formatAmount(priceRec.amount, currency, this.locale);
|
|
493
|
+
priceRow.appendChild(amountEl);
|
|
494
|
+
|
|
495
|
+
const suffix = document.createElement('span');
|
|
496
|
+
suffix.setAttribute('data-cycle', '');
|
|
497
|
+
const unit = priceRec.unit ? `/${priceRec.unit}` : CYCLE_SUFFIX[cycle];
|
|
498
|
+
suffix.textContent = unit;
|
|
499
|
+
priceRow.appendChild(suffix);
|
|
500
|
+
} else {
|
|
501
|
+
const fallback = document.createElement('span');
|
|
502
|
+
fallback.setAttribute('data-price', '');
|
|
503
|
+
fallback.textContent = 'Contact sales';
|
|
504
|
+
priceRow.appendChild(fallback);
|
|
505
|
+
}
|
|
506
|
+
body.appendChild(priceRow);
|
|
507
|
+
|
|
508
|
+
// Price note (e.g. "billed annually").
|
|
509
|
+
if (priceRec?.note) {
|
|
510
|
+
const note = document.createElement('span');
|
|
511
|
+
note.setAttribute('data-price-note', '');
|
|
512
|
+
note.textContent = priceRec.note;
|
|
513
|
+
body.appendChild(note);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Feature list.
|
|
517
|
+
if (Array.isArray(plan.features) && plan.features.length) {
|
|
518
|
+
const list = document.createElement('ul');
|
|
519
|
+
list.setAttribute('data-features', '');
|
|
520
|
+
for (const feature of plan.features) {
|
|
521
|
+
const li = document.createElement('li');
|
|
522
|
+
const icon = document.createElement('icon-ui');
|
|
523
|
+
icon.setAttribute('name', 'check');
|
|
524
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
525
|
+
li.appendChild(icon);
|
|
526
|
+
const label = document.createElement('span');
|
|
527
|
+
label.textContent = String(feature);
|
|
528
|
+
li.appendChild(label);
|
|
529
|
+
list.appendChild(li);
|
|
530
|
+
}
|
|
531
|
+
body.appendChild(list);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// CTA button (skipped when this IS the current plan and the plan
|
|
535
|
+
// record didn't override `cta`).
|
|
536
|
+
const isCurrent = !!(this.current && plan.id === this.current);
|
|
537
|
+
if (!isCurrent || plan.cta) {
|
|
538
|
+
const cta = document.createElement('button-ui');
|
|
539
|
+
cta.setAttribute('data-cta', '');
|
|
540
|
+
cta.setAttribute(
|
|
541
|
+
'variant',
|
|
542
|
+
plan.recommended ? 'primary' : 'outline',
|
|
543
|
+
);
|
|
544
|
+
cta.setAttribute('text', deriveCtaLabel(plan, Array.isArray(this.plans) ? this.plans : [], this.current));
|
|
545
|
+
if (plan.disabled || this.disabled) cta.setAttribute('disabled', '');
|
|
546
|
+
body.appendChild(cta);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
#wireListeners() {
|
|
551
|
+
// ── Cycle toggle ──
|
|
552
|
+
const onCycleChange = (e) => {
|
|
553
|
+
const cycle = e?.detail?.value;
|
|
554
|
+
if (!KNOWN_CYCLES.has(cycle)) return;
|
|
555
|
+
if (this.cycle === cycle) return;
|
|
556
|
+
e.stopPropagation();
|
|
557
|
+
const previous = this.cycle;
|
|
558
|
+
this.cycle = cycle;
|
|
559
|
+
this.dispatchEvent(new CustomEvent('cycle-change', {
|
|
560
|
+
bubbles: true,
|
|
561
|
+
detail: { cycle, previous },
|
|
562
|
+
}));
|
|
563
|
+
};
|
|
564
|
+
this.#toggleHost.addEventListener('change', onCycleChange);
|
|
565
|
+
this.#cleanups.push(() => this.#toggleHost.removeEventListener('change', onCycleChange));
|
|
566
|
+
|
|
567
|
+
// ── Card selection (option-card-ui `change`) ──
|
|
568
|
+
const onCardChange = (e) => {
|
|
569
|
+
const target = e.target?.closest?.('option-card-ui');
|
|
570
|
+
if (!target || !this.#plansHost.contains(target)) return;
|
|
571
|
+
const planId = target.getAttribute('value') || '';
|
|
572
|
+
if (!planId) return;
|
|
573
|
+
const plan = (this.plans || []).find((p) => p.id === planId);
|
|
574
|
+
if (!plan) return;
|
|
575
|
+
if (plan.disabled || this.disabled) {
|
|
576
|
+
// Revert the underlying card and notify.
|
|
577
|
+
target.removeAttribute('checked');
|
|
578
|
+
this.dispatchEvent(new CustomEvent('error', {
|
|
579
|
+
bubbles: true,
|
|
580
|
+
detail: { reason: 'plan-disabled' },
|
|
581
|
+
}));
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
e.stopPropagation();
|
|
585
|
+
const previous = this.value || '';
|
|
586
|
+
if (previous === planId) return;
|
|
587
|
+
this.#internalUpdate = true;
|
|
588
|
+
try { this.value = planId; } finally { this.#internalUpdate = false; }
|
|
589
|
+
this.syncValue();
|
|
590
|
+
this.#emitChange(planId, previous);
|
|
591
|
+
};
|
|
592
|
+
this.#plansHost.addEventListener('change', onCardChange);
|
|
593
|
+
this.#cleanups.push(() => this.#plansHost.removeEventListener('change', onCardChange));
|
|
594
|
+
|
|
595
|
+
// ── CTA click / press → `select` ──
|
|
596
|
+
const onCtaPress = (e) => {
|
|
597
|
+
const cta = e.target?.closest?.('button-ui[data-cta]');
|
|
598
|
+
if (!cta || !this.#plansHost.contains(cta)) return;
|
|
599
|
+
const card = cta.closest('option-card-ui');
|
|
600
|
+
if (!card) return;
|
|
601
|
+
const planId = card.getAttribute('value') || '';
|
|
602
|
+
if (!planId) return;
|
|
603
|
+
const plan = (this.plans || []).find((p) => p.id === planId);
|
|
604
|
+
if (!plan || plan.disabled || this.disabled) return;
|
|
605
|
+
e.stopPropagation();
|
|
606
|
+
// Selecting the plan on CTA press is part of the commit
|
|
607
|
+
// (otherwise users can click an unselected card's CTA and the
|
|
608
|
+
// value doesn't update). Mirror option-card-ui's keyboard model.
|
|
609
|
+
const previous = this.value || '';
|
|
610
|
+
if (previous !== planId) {
|
|
611
|
+
this.#internalUpdate = true;
|
|
612
|
+
try { this.value = planId; } finally { this.#internalUpdate = false; }
|
|
613
|
+
this.syncValue();
|
|
614
|
+
this.#emitChange(planId, previous);
|
|
615
|
+
}
|
|
616
|
+
this.dispatchEvent(new CustomEvent('select', {
|
|
617
|
+
bubbles: true,
|
|
618
|
+
detail: { value: planId, cycle: this.cycle },
|
|
619
|
+
}));
|
|
620
|
+
};
|
|
621
|
+
// Listen for both `press` (button-ui's custom event) and `click`
|
|
622
|
+
// (covers default-button activation paths in happy-dom).
|
|
623
|
+
this.#plansHost.addEventListener('press', onCtaPress);
|
|
624
|
+
this.#plansHost.addEventListener('click', onCtaPress);
|
|
625
|
+
this.#cleanups.push(() => this.#plansHost.removeEventListener('press', onCtaPress));
|
|
626
|
+
this.#cleanups.push(() => this.#plansHost.removeEventListener('click', onCtaPress));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
#emitChange(value, previous) {
|
|
630
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
631
|
+
bubbles: true,
|
|
632
|
+
detail: { value, previous, cycle: this.cycle },
|
|
633
|
+
}));
|
|
634
|
+
}
|
|
635
|
+
}
|