@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.
- package/CHANGELOG.md +22 -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,886 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <payment-method-form-ui> — Form-participating payment-method composite.
|
|
3
|
+
*
|
|
4
|
+
* Captures card number, expiry, CVC, name on card, billing address, and
|
|
5
|
+
* country through a grid of <field-ui> + <input-ui> + <select-ui>
|
|
6
|
+
* sub-controls. Per ADR-0025 the host IS the form-associated element —
|
|
7
|
+
* no native <input type=text> is wrapped at the composite tier; the
|
|
8
|
+
* leaf primitives carry their own ElementInternals.
|
|
9
|
+
*
|
|
10
|
+
* Card-number Luhn validation, MM/YY expiry parsing, CVC 3-4 digit
|
|
11
|
+
* length check, brand detection via small regex map (visa /
|
|
12
|
+
* mastercard / amex / discover / unknown). The form chrome + structured
|
|
13
|
+
* validation lives here; tokenization against a real processor is the
|
|
14
|
+
* consumer's responsibility (Stripe Elements, Braintree, Adyen).
|
|
15
|
+
*
|
|
16
|
+
* Form value is a synthetic opaque local-mode token written ONLY after
|
|
17
|
+
* tokenize() resolves; raw card digits never leak through `value`.
|
|
18
|
+
*
|
|
19
|
+
* SPEC: .claude/docs/specs/implementation-ready/SPEC-009-payment-method-form.md
|
|
20
|
+
* ADR: .brain/adrs/0025-no-native-form-controls.md
|
|
21
|
+
* ADR: .brain/adrs/0033-light-dom-substrate.md
|
|
22
|
+
*
|
|
23
|
+
* Architecture (light-DOM, ADR-0033):
|
|
24
|
+
* Stamps a fixed skeleton once on first connect:
|
|
25
|
+
* [data-alert] · [data-summary] · [data-grid > field rows] · [data-legal]
|
|
26
|
+
* Mutates per-field state via reflected attributes on each sub-control.
|
|
27
|
+
* No template rebuilds across renders — field nodes keep identity so
|
|
28
|
+
* focus + IME composition state survive.
|
|
29
|
+
*
|
|
30
|
+
* Public API:
|
|
31
|
+
* .tokenize() — Promise resolving the normalized payload (or rejects).
|
|
32
|
+
* .reset() — Clear every field + the resolved token.
|
|
33
|
+
* .checkValidity() / .reportValidity() — inherited from UIFormElement.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { UIFormElement } from '@adia-ai/web-components/core/form';
|
|
37
|
+
import { untracked } from '@adia-ai/web-components/core/signals';
|
|
38
|
+
|
|
39
|
+
// ── Brand detection ────────────────────────────────────────────────
|
|
40
|
+
//
|
|
41
|
+
// Order matters: Amex regex tested first (its prefix overlaps with
|
|
42
|
+
// Mastercard's older range). Conservative regex — production
|
|
43
|
+
// detection uses BIN tables, but a small map covers the brand
|
|
44
|
+
// recognition the LLM corpus needs (visa / mastercard / amex /
|
|
45
|
+
// discover).
|
|
46
|
+
const BRAND_PATTERNS = [
|
|
47
|
+
{ brand: 'amex', re: /^3[47]/ },
|
|
48
|
+
{ brand: 'visa', re: /^4/ },
|
|
49
|
+
{ brand: 'mastercard', re: /^(5[1-5]|2[2-7])/ },
|
|
50
|
+
{ brand: 'discover', re: /^6(011|5)/ },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** Detect the card brand from a normalized digits-only PAN. */
|
|
54
|
+
function detectBrand(digits) {
|
|
55
|
+
if (!digits) return 'unknown';
|
|
56
|
+
for (const { brand, re } of BRAND_PATTERNS) {
|
|
57
|
+
if (re.test(digits)) return brand;
|
|
58
|
+
}
|
|
59
|
+
return 'unknown';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Strip all non-digit characters; keep at most 19 digits (Maestro). */
|
|
63
|
+
function digitsOnly(s) {
|
|
64
|
+
return String(s || '').replace(/\D+/g, '').slice(0, 19);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Format a PAN string with spaces every 4 digits (Amex uses 4-6-5). */
|
|
68
|
+
function formatPan(digits, brand) {
|
|
69
|
+
if (!digits) return '';
|
|
70
|
+
if (brand === 'amex') {
|
|
71
|
+
return digits.replace(/^(\d{0,4})(\d{0,6})(\d{0,5}).*$/, (_, a, b, c) =>
|
|
72
|
+
[a, b, c].filter(Boolean).join(' '),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return digits.match(/.{1,4}/g)?.join(' ') ?? '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Luhn check on a digits-only string. */
|
|
79
|
+
function luhn(digits) {
|
|
80
|
+
if (!digits || digits.length < 12) return false;
|
|
81
|
+
let sum = 0;
|
|
82
|
+
let alt = false;
|
|
83
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
84
|
+
let n = digits.charCodeAt(i) - 48;
|
|
85
|
+
if (n < 0 || n > 9) return false;
|
|
86
|
+
if (alt) {
|
|
87
|
+
n *= 2;
|
|
88
|
+
if (n > 9) n -= 9;
|
|
89
|
+
}
|
|
90
|
+
sum += n;
|
|
91
|
+
alt = !alt;
|
|
92
|
+
}
|
|
93
|
+
return sum % 10 === 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Parse an MM/YY (or MM/YYYY) string into { month, year } where year
|
|
98
|
+
* is the 4-digit form. Returns null on parse failure. Accepts "12/27",
|
|
99
|
+
* "12 / 27", "12/2027". Does NOT bounds-check month/year sanity beyond
|
|
100
|
+
* the parse — that's the validation step's job.
|
|
101
|
+
*/
|
|
102
|
+
function parseExpiry(s) {
|
|
103
|
+
if (!s) return null;
|
|
104
|
+
const m = String(s).replace(/\s+/g, '').match(/^(\d{1,2})\/(\d{2}|\d{4})$/);
|
|
105
|
+
if (!m) return null;
|
|
106
|
+
const month = Number(m[1]);
|
|
107
|
+
let year = Number(m[2]);
|
|
108
|
+
if (year < 100) year += 2000;
|
|
109
|
+
if (!Number.isFinite(month) || !Number.isFinite(year)) return null;
|
|
110
|
+
return { month, year };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Validate an expiry. Returns null on OK, an error code otherwise. */
|
|
114
|
+
function validateExpiry(s) {
|
|
115
|
+
const parsed = parseExpiry(s);
|
|
116
|
+
if (!parsed) return 'expiry-format';
|
|
117
|
+
const { month, year } = parsed;
|
|
118
|
+
if (month < 1 || month > 12) return 'expiry-month';
|
|
119
|
+
const now = new Date();
|
|
120
|
+
const nowYear = now.getFullYear();
|
|
121
|
+
const nowMonth = now.getMonth() + 1;
|
|
122
|
+
if (year < nowYear || (year === nowYear && month < nowMonth)) {
|
|
123
|
+
return 'expiry-past';
|
|
124
|
+
}
|
|
125
|
+
if (year > nowYear + 50) return 'expiry-far';
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** CVC validation — 3 digits for most brands, 4 for Amex. */
|
|
130
|
+
function validateCvc(cvc, brand) {
|
|
131
|
+
const digits = String(cvc || '').replace(/\D+/g, '');
|
|
132
|
+
const wantLen = brand === 'amex' ? 4 : 3;
|
|
133
|
+
if (digits.length !== wantLen) return 'cvc-length';
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Generate a synthetic opaque local-mode token. NOT a real PSP token. */
|
|
138
|
+
function makeLocalToken(seed) {
|
|
139
|
+
// 32-char hex-ish opaque; sourced from Math.random + timestamp. Real
|
|
140
|
+
// processors mint real tokens; this is the local-mode placeholder so
|
|
141
|
+
// consumers can wire the event without a network round-trip.
|
|
142
|
+
const rnd = Math.random().toString(36).slice(2);
|
|
143
|
+
const ts = Date.now().toString(36);
|
|
144
|
+
return `pm_local_${ts}_${rnd}_${String(seed).slice(0, 4)}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const DEFAULT_COUNTRIES = ['US', 'CA', 'GB', 'DE', 'FR', 'AU'];
|
|
148
|
+
|
|
149
|
+
/** Map alpha-2 to a human-readable country name. Small built-in set. */
|
|
150
|
+
const COUNTRY_NAMES = {
|
|
151
|
+
US: 'United States',
|
|
152
|
+
CA: 'Canada',
|
|
153
|
+
GB: 'United Kingdom',
|
|
154
|
+
DE: 'Germany',
|
|
155
|
+
FR: 'France',
|
|
156
|
+
AU: 'Australia',
|
|
157
|
+
JP: 'Japan',
|
|
158
|
+
ES: 'Spain',
|
|
159
|
+
IT: 'Italy',
|
|
160
|
+
NL: 'Netherlands',
|
|
161
|
+
SE: 'Sweden',
|
|
162
|
+
IE: 'Ireland',
|
|
163
|
+
SG: 'Singapore',
|
|
164
|
+
IN: 'India',
|
|
165
|
+
BR: 'Brazil',
|
|
166
|
+
MX: 'Mexico',
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
function countryName(code) {
|
|
170
|
+
return COUNTRY_NAMES[code] || code;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export class UIPaymentMethodForm extends UIFormElement {
|
|
174
|
+
// The host carries aria-label directly; per-control `label` deprecation
|
|
175
|
+
// doesn't apply at this tier (we don't expose a `label` prop).
|
|
176
|
+
static labelDeprecated = false;
|
|
177
|
+
|
|
178
|
+
static get properties() {
|
|
179
|
+
return {
|
|
180
|
+
...UIFormElement.properties,
|
|
181
|
+
name: { type: String, default: 'paymentMethod', reflect: true },
|
|
182
|
+
value: { type: String, default: '', reflect: true },
|
|
183
|
+
prefill: { type: String, default: '', attribute: 'prefill' },
|
|
184
|
+
countries: { type: Array, default: [] },
|
|
185
|
+
defaultCountry: { type: String, default: 'US', reflect: true, attribute: 'default-country' },
|
|
186
|
+
requireBillingAddress: { type: Boolean, default: false, reflect: true, attribute: 'require-billing-address' },
|
|
187
|
+
disabled: { type: Boolean, default: false, reflect: true },
|
|
188
|
+
required: { type: Boolean, default: false, reflect: true },
|
|
189
|
+
error: { type: String, default: '', reflect: true },
|
|
190
|
+
autofocus: { type: Boolean, default: false, reflect: true },
|
|
191
|
+
// gh#853: tokenize() below already reflects this via raw
|
|
192
|
+
// setAttribute/removeAttribute; declared here too so `el.tokenizing`
|
|
193
|
+
// (JS property reads/writes) stays in sync per audit:static-properties-vs-yaml
|
|
194
|
+
// (yaml `reflect: true` requires a matching static-properties entry).
|
|
195
|
+
tokenizing: { type: Boolean, default: false, reflect: true },
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Stamp-nothing per plan-picker pattern — we mutate a fixed
|
|
200
|
+
// skeleton rather than re-running the template engine.
|
|
201
|
+
static template = () => null;
|
|
202
|
+
|
|
203
|
+
// ── Field refs ────────────────────────────────────────────────────
|
|
204
|
+
#stamped = false;
|
|
205
|
+
#cleanups = [];
|
|
206
|
+
|
|
207
|
+
#alertHost = null; // <alert-ui data-alert>
|
|
208
|
+
#summaryHost = null; // <div data-summary>
|
|
209
|
+
#editButton = null; // <button-ui> inside summary
|
|
210
|
+
#gridHost = null; // <div data-grid>
|
|
211
|
+
|
|
212
|
+
// Per-row <field-ui> hosts (keyed by field name).
|
|
213
|
+
#fields = {
|
|
214
|
+
name: null,
|
|
215
|
+
cardNumber: null,
|
|
216
|
+
expiry: null,
|
|
217
|
+
cvc: null,
|
|
218
|
+
country: null,
|
|
219
|
+
postalCode: null,
|
|
220
|
+
addressLine1: null,
|
|
221
|
+
addressLine2: null,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Per-row <input-ui> / <select-ui> controls (keyed by field name).
|
|
225
|
+
#controls = {
|
|
226
|
+
name: null,
|
|
227
|
+
cardNumber: null,
|
|
228
|
+
expiry: null,
|
|
229
|
+
cvc: null,
|
|
230
|
+
country: null,
|
|
231
|
+
postalCode: null,
|
|
232
|
+
addressLine1: null,
|
|
233
|
+
addressLine2: null,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
#brand = 'unknown';
|
|
237
|
+
#internalUpdate = false;
|
|
238
|
+
#legalHost = null;
|
|
239
|
+
#legalSlotProbe = null;
|
|
240
|
+
#tokenizing = false;
|
|
241
|
+
|
|
242
|
+
// ── Public imperative API ─────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Validate every field + emit a resolved token. Resolves with the
|
|
246
|
+
* normalized payload on success; rejects with an Error carrying
|
|
247
|
+
* `code` + `field` on failure.
|
|
248
|
+
*/
|
|
249
|
+
async tokenize() {
|
|
250
|
+
if (this.#tokenizing) return Promise.reject(new Error('tokenize-inflight'));
|
|
251
|
+
this.#tokenizing = true;
|
|
252
|
+
this.setAttribute('tokenizing', '');
|
|
253
|
+
try {
|
|
254
|
+
const result = this.#validateAll();
|
|
255
|
+
if (!result.ok) {
|
|
256
|
+
const err = new Error(result.message);
|
|
257
|
+
err.code = result.code;
|
|
258
|
+
err.field = result.field;
|
|
259
|
+
this.dispatchEvent(new CustomEvent('payment-method-error', {
|
|
260
|
+
bubbles: true,
|
|
261
|
+
detail: { message: result.message, code: result.code, field: result.field },
|
|
262
|
+
}));
|
|
263
|
+
// Set host-level error attr so the alert renders.
|
|
264
|
+
this.setAttribute('aria-invalid', 'true');
|
|
265
|
+
throw err;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const detail = result.detail;
|
|
269
|
+
// Resolve `value` to a synthetic local-mode token AFTER validation
|
|
270
|
+
// succeeds — raw digits never leak through.
|
|
271
|
+
this.#internalUpdate = true;
|
|
272
|
+
try {
|
|
273
|
+
this.value = makeLocalToken(detail.last4);
|
|
274
|
+
} finally {
|
|
275
|
+
this.#internalUpdate = false;
|
|
276
|
+
}
|
|
277
|
+
this.syncValue();
|
|
278
|
+
this.removeAttribute('aria-invalid');
|
|
279
|
+
|
|
280
|
+
const payload = { token: this.value, ...detail };
|
|
281
|
+
this.dispatchEvent(new CustomEvent('submit-payment-method', {
|
|
282
|
+
bubbles: true,
|
|
283
|
+
detail: payload,
|
|
284
|
+
}));
|
|
285
|
+
return payload;
|
|
286
|
+
} finally {
|
|
287
|
+
this.removeAttribute('tokenizing');
|
|
288
|
+
this.#tokenizing = false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Clear every sub-field + resolved token. */
|
|
293
|
+
reset() {
|
|
294
|
+
untracked(() => {
|
|
295
|
+
for (const key of Object.keys(this.#controls)) {
|
|
296
|
+
const ctrl = this.#controls[key];
|
|
297
|
+
if (!ctrl) continue;
|
|
298
|
+
ctrl.value = '';
|
|
299
|
+
ctrl.removeAttribute('error');
|
|
300
|
+
}
|
|
301
|
+
if (this.#controls.country) {
|
|
302
|
+
this.#controls.country.value = this.defaultCountry || 'US';
|
|
303
|
+
}
|
|
304
|
+
this.#internalUpdate = true;
|
|
305
|
+
try {
|
|
306
|
+
this.value = '';
|
|
307
|
+
this.error = '';
|
|
308
|
+
} finally {
|
|
309
|
+
this.#internalUpdate = false;
|
|
310
|
+
}
|
|
311
|
+
this.syncValue();
|
|
312
|
+
this.#brand = 'unknown';
|
|
313
|
+
this.removeAttribute('aria-invalid');
|
|
314
|
+
this.removeAttribute('error');
|
|
315
|
+
this.#syncBrandSuffix();
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── Form-bearing override ─────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
/** The form value is the resolved token (or empty). */
|
|
322
|
+
syncValue(_val) {
|
|
323
|
+
this.internals.setFormValue(this.value || '');
|
|
324
|
+
if (this.required && !this.value) {
|
|
325
|
+
this.internals.setValidity(
|
|
326
|
+
{ valueMissing: true },
|
|
327
|
+
this.getAttribute('data-msg-required') || 'Payment method required.',
|
|
328
|
+
this,
|
|
329
|
+
);
|
|
330
|
+
} else {
|
|
331
|
+
this.internals.setValidity({});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── Lifecycle ─────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
connected() {
|
|
338
|
+
super.connected();
|
|
339
|
+
this.setAttribute('role', 'group');
|
|
340
|
+
if (!this.hasAttribute('aria-label') && !this.hasAttribute('aria-labelledby')) {
|
|
341
|
+
this.setAttribute('aria-label', 'Payment method form');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!this.#stamped) {
|
|
345
|
+
this.#stamp();
|
|
346
|
+
this.#wireListeners();
|
|
347
|
+
this.#stamped = true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
untracked(() => this.render());
|
|
351
|
+
|
|
352
|
+
// Autofocus the first sub-field after the registry has had a
|
|
353
|
+
// chance to upgrade child primitives.
|
|
354
|
+
if (this.autofocus) {
|
|
355
|
+
requestAnimationFrame(() => {
|
|
356
|
+
const nameCtrl = this.#controls.name;
|
|
357
|
+
nameCtrl?.focus?.();
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
disconnected() {
|
|
363
|
+
super.disconnected();
|
|
364
|
+
for (const fn of this.#cleanups) {
|
|
365
|
+
try { fn(); } catch { /* ignore stale */ }
|
|
366
|
+
}
|
|
367
|
+
this.#cleanups.length = 0;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
attributeChangedCallback(name, oldVal, newVal) {
|
|
371
|
+
super.attributeChangedCallback?.(name, oldVal, newVal);
|
|
372
|
+
if (!this.#stamped || this.#internalUpdate) return;
|
|
373
|
+
if (name === 'prefill' && oldVal !== newVal) {
|
|
374
|
+
// re-render summary
|
|
375
|
+
this.render();
|
|
376
|
+
} else if (name === 'disabled' && oldVal !== newVal) {
|
|
377
|
+
this.#syncDisabled();
|
|
378
|
+
} else if (name === 'error' && oldVal !== newVal) {
|
|
379
|
+
this.#syncAlert();
|
|
380
|
+
} else if (name === 'require-billing-address' && oldVal !== newVal) {
|
|
381
|
+
this.#syncBillingRows();
|
|
382
|
+
} else if (name === 'default-country' && oldVal !== newVal) {
|
|
383
|
+
const sel = this.#controls.country;
|
|
384
|
+
if (sel && !sel.value) sel.value = newVal || 'US';
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── Render — narrow; mutates per-region nodes only ────────────────
|
|
389
|
+
|
|
390
|
+
render() {
|
|
391
|
+
if (!this.#stamped) return;
|
|
392
|
+
|
|
393
|
+
this.#syncCountries();
|
|
394
|
+
this.#syncBillingRows();
|
|
395
|
+
this.#syncDisabled();
|
|
396
|
+
this.#syncAlert();
|
|
397
|
+
this.#syncSummary();
|
|
398
|
+
this.#syncBrandSuffix();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── Internal — DOM stamping ───────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
#stamp() {
|
|
404
|
+
// Host-level alert region.
|
|
405
|
+
const alertHost = document.createElement('alert-ui');
|
|
406
|
+
alertHost.setAttribute('data-alert', '');
|
|
407
|
+
alertHost.setAttribute('variant', 'danger');
|
|
408
|
+
alertHost.setAttribute('icon', 'warning-circle');
|
|
409
|
+
alertHost.hidden = true;
|
|
410
|
+
this.appendChild(alertHost);
|
|
411
|
+
this.#alertHost = alertHost;
|
|
412
|
+
|
|
413
|
+
// Saved-card summary region.
|
|
414
|
+
const summary = document.createElement('div');
|
|
415
|
+
summary.setAttribute('data-summary', '');
|
|
416
|
+
summary.hidden = true;
|
|
417
|
+
const cardIcon = document.createElement('icon-ui');
|
|
418
|
+
cardIcon.setAttribute('name', 'credit-card');
|
|
419
|
+
cardIcon.setAttribute('aria-hidden', 'true');
|
|
420
|
+
cardIcon.setAttribute('data-summary-icon', '');
|
|
421
|
+
summary.appendChild(cardIcon);
|
|
422
|
+
const summaryText = document.createElement('div');
|
|
423
|
+
summaryText.setAttribute('data-summary-text', '');
|
|
424
|
+
summary.appendChild(summaryText);
|
|
425
|
+
const editBtn = document.createElement('button-ui');
|
|
426
|
+
editBtn.setAttribute('data-summary-edit', '');
|
|
427
|
+
editBtn.setAttribute('variant', 'ghost');
|
|
428
|
+
editBtn.setAttribute('size', 'sm');
|
|
429
|
+
editBtn.setAttribute('text', 'Edit card');
|
|
430
|
+
summary.appendChild(editBtn);
|
|
431
|
+
this.appendChild(summary);
|
|
432
|
+
this.#summaryHost = summary;
|
|
433
|
+
this.#editButton = editBtn;
|
|
434
|
+
|
|
435
|
+
// Grid host.
|
|
436
|
+
const grid = document.createElement('div');
|
|
437
|
+
grid.setAttribute('data-grid', '');
|
|
438
|
+
this.appendChild(grid);
|
|
439
|
+
this.#gridHost = grid;
|
|
440
|
+
|
|
441
|
+
// Stamp every row up-front. Rows are hidden via `[hidden]` when
|
|
442
|
+
// `require-billing-address` is unset so toggling that attribute is
|
|
443
|
+
// a no-DOM-mutation render.
|
|
444
|
+
|
|
445
|
+
this.#fields.name = this.#stampRow('name', {
|
|
446
|
+
label: 'Name on card',
|
|
447
|
+
required: true,
|
|
448
|
+
area: 'name',
|
|
449
|
+
input: {
|
|
450
|
+
autocomplete: 'cc-name',
|
|
451
|
+
placeholder: 'Pat Doe',
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
this.#controls.name = this.#fields.name.querySelector('input-ui');
|
|
455
|
+
|
|
456
|
+
this.#fields.cardNumber = this.#stampRow('cardNumber', {
|
|
457
|
+
label: 'Card number',
|
|
458
|
+
required: true,
|
|
459
|
+
area: 'card',
|
|
460
|
+
input: {
|
|
461
|
+
type: 'text',
|
|
462
|
+
inputmode: 'numeric',
|
|
463
|
+
autocomplete: 'cc-number',
|
|
464
|
+
placeholder: '1234 1234 1234 1234',
|
|
465
|
+
prefix: 'credit-card',
|
|
466
|
+
maxlength: 23, // 19 digits + 4 spaces (Maestro upper bound)
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
this.#controls.cardNumber = this.#fields.cardNumber.querySelector('input-ui');
|
|
470
|
+
// Suffix slot — brand badge stamped lazily by #syncBrandSuffix.
|
|
471
|
+
const brandSlot = document.createElement('span');
|
|
472
|
+
brandSlot.setAttribute('slot', 'trailing');
|
|
473
|
+
brandSlot.setAttribute('data-brand-badge', '');
|
|
474
|
+
this.#controls.cardNumber.appendChild(brandSlot);
|
|
475
|
+
|
|
476
|
+
this.#fields.expiry = this.#stampRow('expiry', {
|
|
477
|
+
label: 'Expiry',
|
|
478
|
+
required: true,
|
|
479
|
+
area: 'expiry',
|
|
480
|
+
input: {
|
|
481
|
+
type: 'text',
|
|
482
|
+
inputmode: 'numeric',
|
|
483
|
+
autocomplete: 'cc-exp',
|
|
484
|
+
placeholder: 'MM/YY',
|
|
485
|
+
maxlength: 7,
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
this.#controls.expiry = this.#fields.expiry.querySelector('input-ui');
|
|
489
|
+
|
|
490
|
+
this.#fields.cvc = this.#stampRow('cvc', {
|
|
491
|
+
label: 'CVC',
|
|
492
|
+
required: true,
|
|
493
|
+
area: 'cvc',
|
|
494
|
+
input: {
|
|
495
|
+
type: 'text',
|
|
496
|
+
inputmode: 'numeric',
|
|
497
|
+
autocomplete: 'cc-csc',
|
|
498
|
+
placeholder: '123',
|
|
499
|
+
maxlength: 4,
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
this.#controls.cvc = this.#fields.cvc.querySelector('input-ui');
|
|
503
|
+
|
|
504
|
+
// Country select — composed via <select-ui>. Options must be
|
|
505
|
+
// appended BEFORE the select-ui is added to the document, because
|
|
506
|
+
// <select-ui>.connected() parses declarative <option> children
|
|
507
|
+
// once on attach and discards them. After that we use the
|
|
508
|
+
// programmatic .options property to mutate the list.
|
|
509
|
+
this.#fields.country = this.#stampRow('country', {
|
|
510
|
+
label: 'Country',
|
|
511
|
+
required: true,
|
|
512
|
+
area: 'country',
|
|
513
|
+
kind: 'select',
|
|
514
|
+
input: {
|
|
515
|
+
autocomplete: 'country',
|
|
516
|
+
},
|
|
517
|
+
// Seed options from the resolved countries list at stamp time so
|
|
518
|
+
// select-ui's first parse sees them.
|
|
519
|
+
seedOptions: this.#resolveCountries(),
|
|
520
|
+
seedValue: this.defaultCountry || 'US',
|
|
521
|
+
});
|
|
522
|
+
this.#controls.country = this.#fields.country.querySelector('select-ui');
|
|
523
|
+
|
|
524
|
+
this.#fields.postalCode = this.#stampRow('postalCode', {
|
|
525
|
+
label: 'Postal code',
|
|
526
|
+
required: true,
|
|
527
|
+
area: 'postal',
|
|
528
|
+
input: {
|
|
529
|
+
type: 'text',
|
|
530
|
+
inputmode: 'text',
|
|
531
|
+
autocomplete: 'postal-code',
|
|
532
|
+
placeholder: '94105',
|
|
533
|
+
maxlength: 12,
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
this.#controls.postalCode = this.#fields.postalCode.querySelector('input-ui');
|
|
537
|
+
|
|
538
|
+
// Billing-address lines — stamped now, hidden until needed.
|
|
539
|
+
this.#fields.addressLine1 = this.#stampRow('addressLine1', {
|
|
540
|
+
label: 'Address line 1',
|
|
541
|
+
required: false,
|
|
542
|
+
area: 'address1',
|
|
543
|
+
input: {
|
|
544
|
+
type: 'text',
|
|
545
|
+
autocomplete: 'address-line1',
|
|
546
|
+
placeholder: '123 Main St',
|
|
547
|
+
maxlength: 100,
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
this.#controls.addressLine1 = this.#fields.addressLine1.querySelector('input-ui');
|
|
551
|
+
this.#fields.addressLine1.hidden = true;
|
|
552
|
+
|
|
553
|
+
this.#fields.addressLine2 = this.#stampRow('addressLine2', {
|
|
554
|
+
label: 'Address line 2 (optional)',
|
|
555
|
+
required: false,
|
|
556
|
+
area: 'address2',
|
|
557
|
+
input: {
|
|
558
|
+
type: 'text',
|
|
559
|
+
autocomplete: 'address-line2',
|
|
560
|
+
placeholder: 'Apt 4B',
|
|
561
|
+
maxlength: 100,
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
this.#controls.addressLine2 = this.#fields.addressLine2.querySelector('input-ui');
|
|
565
|
+
this.#fields.addressLine2.hidden = true;
|
|
566
|
+
|
|
567
|
+
// Legal slot probe — we don't stamp legal content; consumers slot
|
|
568
|
+
// their own. Stamp a wrapper so :scope > [data-legal] gates the
|
|
569
|
+
// CSS-controlled styling (color + size).
|
|
570
|
+
const legalHost = document.createElement('div');
|
|
571
|
+
legalHost.setAttribute('data-legal', '');
|
|
572
|
+
this.appendChild(legalHost);
|
|
573
|
+
this.#legalHost = legalHost;
|
|
574
|
+
|
|
575
|
+
// Promote any author-provided slot="legal" children into the
|
|
576
|
+
// [data-legal] region so the slot mechanism stays decorative.
|
|
577
|
+
for (const node of [...this.children]) {
|
|
578
|
+
if (node === legalHost) continue;
|
|
579
|
+
if (node.getAttribute && node.getAttribute('slot') === 'legal') {
|
|
580
|
+
legalHost.appendChild(node);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
#stampRow(key, opts) {
|
|
586
|
+
const field = document.createElement('field-ui');
|
|
587
|
+
field.setAttribute('label', opts.label);
|
|
588
|
+
if (opts.required) field.setAttribute('required', '');
|
|
589
|
+
field.setAttribute('data-field', key);
|
|
590
|
+
field.style.gridArea = opts.area;
|
|
591
|
+
|
|
592
|
+
let ctrl;
|
|
593
|
+
if (opts.kind === 'select') {
|
|
594
|
+
ctrl = document.createElement('select-ui');
|
|
595
|
+
// Seed declarative <option> children BEFORE the element is added
|
|
596
|
+
// to the DOM so select-ui's connected() parse picks them up.
|
|
597
|
+
if (Array.isArray(opts.seedOptions)) {
|
|
598
|
+
for (const o of opts.seedOptions) {
|
|
599
|
+
const optEl = document.createElement('option');
|
|
600
|
+
optEl.value = o.value;
|
|
601
|
+
optEl.textContent = o.label;
|
|
602
|
+
if (opts.seedValue && o.value === opts.seedValue) optEl.setAttribute('selected', '');
|
|
603
|
+
ctrl.appendChild(optEl);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
} else {
|
|
607
|
+
ctrl = document.createElement('input-ui');
|
|
608
|
+
}
|
|
609
|
+
ctrl.setAttribute('name', key);
|
|
610
|
+
ctrl.setAttribute('data-control', key);
|
|
611
|
+
if (opts.required) ctrl.setAttribute('required', '');
|
|
612
|
+
for (const [attr, val] of Object.entries(opts.input || {})) {
|
|
613
|
+
if (val == null || val === '') continue;
|
|
614
|
+
ctrl.setAttribute(attr, String(val));
|
|
615
|
+
}
|
|
616
|
+
field.appendChild(ctrl);
|
|
617
|
+
|
|
618
|
+
this.#gridHost.appendChild(field);
|
|
619
|
+
return field;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Resolve the active countries list (props || defaults). */
|
|
623
|
+
#resolveCountries() {
|
|
624
|
+
const list = Array.isArray(this.countries) && this.countries.length > 0
|
|
625
|
+
? this.countries
|
|
626
|
+
: DEFAULT_COUNTRIES;
|
|
627
|
+
return list.map((code) => ({ value: code, label: countryName(code) }));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ── Internal — wiring ─────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
#wireListeners() {
|
|
633
|
+
// Card-number input → digit-only normalization + brand detect.
|
|
634
|
+
const onCardInput = (e) => {
|
|
635
|
+
if (!this.#controls.cardNumber || e.target !== this.#controls.cardNumber) return;
|
|
636
|
+
const raw = String(this.#controls.cardNumber.value || '');
|
|
637
|
+
const digits = digitsOnly(raw);
|
|
638
|
+
const brand = detectBrand(digits);
|
|
639
|
+
if (brand !== this.#brand) {
|
|
640
|
+
this.#brand = brand;
|
|
641
|
+
this.#syncBrandSuffix();
|
|
642
|
+
}
|
|
643
|
+
this.#emitChange();
|
|
644
|
+
};
|
|
645
|
+
this.addEventListener('input', onCardInput);
|
|
646
|
+
this.#cleanups.push(() => this.removeEventListener('input', onCardInput));
|
|
647
|
+
|
|
648
|
+
// Any field change → re-emit composite change. Guard against the
|
|
649
|
+
// self-re-fire: `#emitChange` dispatches a bubbling `change` on the
|
|
650
|
+
// host (so consumer `<form>` listeners fire); the host itself is
|
|
651
|
+
// also listening, which without this guard would recurse to stack
|
|
652
|
+
// overflow. Only react to events from DESCENDANT fields, never to
|
|
653
|
+
// our own re-dispatch. Caught 2026-05-24 by the test agent when
|
|
654
|
+
// simulated card-input recursed via #emitChange ↔ change listener.
|
|
655
|
+
const onAnyChange = (e) => {
|
|
656
|
+
if (e.target === this) return;
|
|
657
|
+
this.#emitChange();
|
|
658
|
+
};
|
|
659
|
+
this.addEventListener('change', onAnyChange);
|
|
660
|
+
this.#cleanups.push(() => this.removeEventListener('change', onAnyChange));
|
|
661
|
+
|
|
662
|
+
// Edit-card → return to full-form mode.
|
|
663
|
+
const onEditClick = () => {
|
|
664
|
+
if (this.disabled) return;
|
|
665
|
+
this.removeAttribute('prefilled');
|
|
666
|
+
// Don't clear `prefill` attr — consumer may still want it as a
|
|
667
|
+
// hydrator for the next render. We hide the summary via state.
|
|
668
|
+
if (this.#summaryHost) this.#summaryHost.hidden = true;
|
|
669
|
+
if (this.#gridHost) this.#gridHost.hidden = false;
|
|
670
|
+
};
|
|
671
|
+
this.#editButton.addEventListener('click', onEditClick);
|
|
672
|
+
this.#editButton.addEventListener('press', onEditClick);
|
|
673
|
+
this.#cleanups.push(() => {
|
|
674
|
+
this.#editButton.removeEventListener('click', onEditClick);
|
|
675
|
+
this.#editButton.removeEventListener('press', onEditClick);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
// Outer-form submit → tokenize (if inside a <form>).
|
|
679
|
+
// We listen on the host for 'invalid' so we can short-circuit
|
|
680
|
+
// the browser's default-prevented submit path.
|
|
681
|
+
// tokenize() is the canonical commit; consumers wire their own
|
|
682
|
+
// submit handler that calls tokenize() (see anti_patterns example).
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
#emitChange() {
|
|
686
|
+
const cardDigits = digitsOnly(this.#controls.cardNumber?.value);
|
|
687
|
+
const brand = detectBrand(cardDigits);
|
|
688
|
+
const validity = this.#peekValidity();
|
|
689
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
690
|
+
bubbles: true,
|
|
691
|
+
detail: { valid: validity.ok, brand },
|
|
692
|
+
}));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ── Internal — render-side syncs ──────────────────────────────────
|
|
696
|
+
|
|
697
|
+
#syncCountries() {
|
|
698
|
+
const sel = this.#controls.country;
|
|
699
|
+
if (!sel) return;
|
|
700
|
+
|
|
701
|
+
// <select-ui> parses native <option> children on its own connect.
|
|
702
|
+
// We seeded them at stamp time so the first render is a no-op here.
|
|
703
|
+
// On subsequent renders (when `countries` prop changes), bypass the
|
|
704
|
+
// children path entirely and set `.options` via the programmatic
|
|
705
|
+
// API — the property setter performs the rebuild + uses `untracked`
|
|
706
|
+
// to avoid reactivity feedback.
|
|
707
|
+
const next = this.#resolveCountries();
|
|
708
|
+
const same = Array.isArray(sel.options)
|
|
709
|
+
&& sel.options.length === next.length
|
|
710
|
+
&& sel.options.every((o, i) => o?.value === next[i].value && o?.label === next[i].label);
|
|
711
|
+
if (!same) sel.options = next;
|
|
712
|
+
|
|
713
|
+
if (!sel.value) {
|
|
714
|
+
sel.value = this.defaultCountry || next[0]?.value || 'US';
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
#syncBillingRows() {
|
|
719
|
+
const show = !!this.requireBillingAddress;
|
|
720
|
+
if (this.#fields.addressLine1) this.#fields.addressLine1.hidden = !show;
|
|
721
|
+
if (this.#fields.addressLine2) this.#fields.addressLine2.hidden = !show;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
#syncDisabled() {
|
|
725
|
+
const dis = !!this.disabled;
|
|
726
|
+
for (const ctrl of Object.values(this.#controls)) {
|
|
727
|
+
if (!ctrl) continue;
|
|
728
|
+
if (dis) ctrl.setAttribute('disabled', '');
|
|
729
|
+
else ctrl.removeAttribute('disabled');
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
#syncAlert() {
|
|
734
|
+
if (!this.#alertHost) return;
|
|
735
|
+
const msg = this.error || '';
|
|
736
|
+
if (msg) {
|
|
737
|
+
this.#alertHost.setAttribute('text', msg);
|
|
738
|
+
this.#alertHost.hidden = false;
|
|
739
|
+
this.setAttribute('aria-invalid', 'true');
|
|
740
|
+
} else {
|
|
741
|
+
this.#alertHost.removeAttribute('text');
|
|
742
|
+
this.#alertHost.hidden = true;
|
|
743
|
+
this.removeAttribute('aria-invalid');
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
#syncSummary() {
|
|
748
|
+
if (!this.#summaryHost) return;
|
|
749
|
+
const prefill = this.#parsePrefill();
|
|
750
|
+
if (!prefill || this.hasAttribute('prefilled-dismissed')) {
|
|
751
|
+
this.#summaryHost.hidden = true;
|
|
752
|
+
if (this.#gridHost) this.#gridHost.hidden = false;
|
|
753
|
+
this.removeAttribute('prefilled');
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
const text = this.#summaryHost.querySelector(':scope > [data-summary-text]');
|
|
757
|
+
if (text) {
|
|
758
|
+
const brand = (prefill.brand || 'Card').toUpperCase();
|
|
759
|
+
const last4 = prefill.last4 || '••••';
|
|
760
|
+
const exp = prefill.expMonth && prefill.expYear
|
|
761
|
+
? ` · expires ${String(prefill.expMonth).padStart(2, '0')}/${String(prefill.expYear).slice(-2)}`
|
|
762
|
+
: '';
|
|
763
|
+
text.textContent = `${brand} ending in ${last4}${exp}`;
|
|
764
|
+
}
|
|
765
|
+
this.#summaryHost.hidden = false;
|
|
766
|
+
if (this.#gridHost) this.#gridHost.hidden = true;
|
|
767
|
+
this.setAttribute('prefilled', '');
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
#syncBrandSuffix() {
|
|
771
|
+
const ctrl = this.#controls.cardNumber;
|
|
772
|
+
if (!ctrl) return;
|
|
773
|
+
const slot = ctrl.querySelector(':scope > [data-brand-badge]');
|
|
774
|
+
if (!slot) return;
|
|
775
|
+
if (this.#brand === 'unknown' || !this.#brand) {
|
|
776
|
+
slot.replaceChildren();
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const existing = slot.querySelector(':scope > [data-brand]');
|
|
780
|
+
if (existing && existing.getAttribute('data-brand') === this.#brand) return;
|
|
781
|
+
slot.replaceChildren();
|
|
782
|
+
const badge = document.createElement('span');
|
|
783
|
+
badge.setAttribute('data-brand', this.#brand);
|
|
784
|
+
badge.textContent = this.#brand.toUpperCase();
|
|
785
|
+
slot.appendChild(badge);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ── Internal — validation ─────────────────────────────────────────
|
|
789
|
+
|
|
790
|
+
/** Peek current validity for `change` events — no side-effects on fields. */
|
|
791
|
+
#peekValidity() {
|
|
792
|
+
const digits = digitsOnly(this.#controls.cardNumber?.value);
|
|
793
|
+
if (!digits) return { ok: false };
|
|
794
|
+
if (!luhn(digits)) return { ok: false };
|
|
795
|
+
if (validateExpiry(this.#controls.expiry?.value)) return { ok: false };
|
|
796
|
+
if (validateCvc(this.#controls.cvc?.value, detectBrand(digits))) return { ok: false };
|
|
797
|
+
if (!(this.#controls.name?.value || '').trim()) return { ok: false };
|
|
798
|
+
if (!(this.#controls.postalCode?.value || '').trim()) return { ok: false };
|
|
799
|
+
return { ok: true };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Validate every field, surface per-field error attrs, return result. */
|
|
803
|
+
#validateAll() {
|
|
804
|
+
// Clear previous per-field errors.
|
|
805
|
+
for (const ctrl of Object.values(this.#controls)) ctrl?.removeAttribute?.('error');
|
|
806
|
+
|
|
807
|
+
const name = String(this.#controls.name?.value || '').trim();
|
|
808
|
+
if (!name) {
|
|
809
|
+
this.#controls.name?.setAttribute('error', 'Name on card is required.');
|
|
810
|
+
return { ok: false, message: 'Name on card is required.', code: 'validation-failed', field: 'name' };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const rawCard = this.#controls.cardNumber?.value || '';
|
|
814
|
+
const digits = digitsOnly(rawCard);
|
|
815
|
+
if (!digits) {
|
|
816
|
+
this.#controls.cardNumber?.setAttribute('error', 'Card number is required.');
|
|
817
|
+
return { ok: false, message: 'Card number is required.', code: 'validation-failed', field: 'cardNumber' };
|
|
818
|
+
}
|
|
819
|
+
if (!luhn(digits)) {
|
|
820
|
+
this.#controls.cardNumber?.setAttribute('error', 'Card number is invalid.');
|
|
821
|
+
return { ok: false, message: 'Card number is invalid.', code: 'card-invalid', field: 'cardNumber' };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const expiryErr = validateExpiry(this.#controls.expiry?.value || '');
|
|
825
|
+
if (expiryErr) {
|
|
826
|
+
const msg =
|
|
827
|
+
expiryErr === 'expiry-past' ? 'Card has expired.' :
|
|
828
|
+
expiryErr === 'expiry-month' ? 'Expiry month must be 01-12.' :
|
|
829
|
+
expiryErr === 'expiry-far' ? 'Expiry year is too far in the future.' :
|
|
830
|
+
'Expiry must be MM/YY.';
|
|
831
|
+
this.#controls.expiry?.setAttribute('error', msg);
|
|
832
|
+
return { ok: false, message: msg, code: expiryErr === 'expiry-past' ? 'expired' : 'validation-failed', field: 'expiry' };
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const brand = detectBrand(digits);
|
|
836
|
+
const cvcErr = validateCvc(this.#controls.cvc?.value || '', brand);
|
|
837
|
+
if (cvcErr) {
|
|
838
|
+
const wantLen = brand === 'amex' ? 4 : 3;
|
|
839
|
+
const msg = `CVC must be ${wantLen} digits.`;
|
|
840
|
+
this.#controls.cvc?.setAttribute('error', msg);
|
|
841
|
+
return { ok: false, message: msg, code: 'cvc-invalid', field: 'cvc' };
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const country = String(this.#controls.country?.value || this.defaultCountry || 'US');
|
|
845
|
+
const postal = String(this.#controls.postalCode?.value || '').trim();
|
|
846
|
+
if (!postal) {
|
|
847
|
+
this.#controls.postalCode?.setAttribute('error', 'Postal code is required.');
|
|
848
|
+
return { ok: false, message: 'Postal code is required.', code: 'validation-failed', field: 'postalCode' };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (this.requireBillingAddress) {
|
|
852
|
+
const line1 = String(this.#controls.addressLine1?.value || '').trim();
|
|
853
|
+
if (!line1) {
|
|
854
|
+
this.#controls.addressLine1?.setAttribute('error', 'Address is required.');
|
|
855
|
+
return { ok: false, message: 'Address is required.', code: 'validation-failed', field: 'addressLine1' };
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const parsed = parseExpiry(this.#controls.expiry.value);
|
|
860
|
+
const last4 = digits.slice(-4);
|
|
861
|
+
const detail = {
|
|
862
|
+
last4,
|
|
863
|
+
brand,
|
|
864
|
+
expMonth: parsed.month,
|
|
865
|
+
expYear: parsed.year,
|
|
866
|
+
name,
|
|
867
|
+
country,
|
|
868
|
+
};
|
|
869
|
+
return { ok: true, detail };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ── Internal — helpers ────────────────────────────────────────────
|
|
873
|
+
|
|
874
|
+
/** Parse the `prefill` JSON-encoded string, return null on failure. */
|
|
875
|
+
#parsePrefill() {
|
|
876
|
+
const raw = this.prefill || this.getAttribute('prefill') || '';
|
|
877
|
+
if (!raw) return null;
|
|
878
|
+
try {
|
|
879
|
+
const parsed = JSON.parse(raw);
|
|
880
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
881
|
+
return parsed;
|
|
882
|
+
} catch {
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|