secure-ui-components 0.2.2 → 0.2.4
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/dist/components/secure-card/secure-card.js +1 -766
- package/dist/components/secure-datetime/secure-datetime.js +1 -570
- package/dist/components/secure-file-upload/secure-file-upload.js +1 -868
- package/dist/components/secure-form/secure-form.js +1 -797
- package/dist/components/secure-input/secure-input.css +67 -1
- package/dist/components/secure-input/secure-input.d.ts +14 -0
- package/dist/components/secure-input/secure-input.d.ts.map +1 -1
- package/dist/components/secure-input/secure-input.js +1 -805
- package/dist/components/secure-input/secure-input.js.map +1 -1
- package/dist/components/secure-password-confirm/secure-password-confirm.js +1 -329
- package/dist/components/secure-select/secure-select.js +1 -589
- package/dist/components/secure-submit-button/secure-submit-button.js +1 -378
- package/dist/components/secure-table/secure-table.js +33 -528
- package/dist/components/secure-telemetry-provider/secure-telemetry-provider.js +1 -201
- package/dist/components/secure-textarea/secure-textarea.css +66 -1
- package/dist/components/secure-textarea/secure-textarea.d.ts +11 -0
- package/dist/components/secure-textarea/secure-textarea.d.ts.map +1 -1
- package/dist/components/secure-textarea/secure-textarea.js +1 -436
- package/dist/components/secure-textarea/secure-textarea.js.map +1 -1
- package/dist/core/base-component.d.ts +18 -0
- package/dist/core/base-component.d.ts.map +1 -1
- package/dist/core/base-component.js +1 -455
- package/dist/core/base-component.js.map +1 -1
- package/dist/core/security-config.js +1 -242
- package/dist/core/types.js +0 -2
- package/dist/index.js +1 -17
- package/dist/package.json +4 -2
- package/package.json +4 -2
|
@@ -19,769 +19,4 @@
|
|
|
19
19
|
*
|
|
20
20
|
* @module secure-card
|
|
21
21
|
* @license MIT
|
|
22
|
-
*/
|
|
23
|
-
import { SecureBaseComponent } from '../../core/base-component.js';
|
|
24
|
-
// Ordered most-specific first to avoid false positives (e.g. Diners before Visa)
|
|
25
|
-
const CARD_TYPES = Object.freeze([
|
|
26
|
-
{
|
|
27
|
-
type: 'amex',
|
|
28
|
-
pattern: /^3[47]/,
|
|
29
|
-
format: [4, 6, 5],
|
|
30
|
-
cvcLength: 4,
|
|
31
|
-
lengths: [15],
|
|
32
|
-
label: 'Amex',
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
type: 'diners',
|
|
36
|
-
pattern: /^3(?:0[0-5]|[68])/,
|
|
37
|
-
format: [4, 6, 4],
|
|
38
|
-
cvcLength: 3,
|
|
39
|
-
lengths: [14],
|
|
40
|
-
label: 'Diners',
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
type: 'discover',
|
|
44
|
-
pattern: /^6(?:011|5[0-9]{2})/,
|
|
45
|
-
format: [4, 4, 4, 4],
|
|
46
|
-
cvcLength: 3,
|
|
47
|
-
lengths: [16],
|
|
48
|
-
label: 'Discover',
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
type: 'jcb',
|
|
52
|
-
pattern: /^(?:2131|1800|35\d{3})/,
|
|
53
|
-
format: [4, 4, 4, 4],
|
|
54
|
-
cvcLength: 3,
|
|
55
|
-
lengths: [16],
|
|
56
|
-
label: 'JCB',
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
type: 'mastercard',
|
|
60
|
-
pattern: /^(?:5[1-5]|2[2-7])/,
|
|
61
|
-
format: [4, 4, 4, 4],
|
|
62
|
-
cvcLength: 3,
|
|
63
|
-
lengths: [16],
|
|
64
|
-
label: 'Mastercard',
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
type: 'visa',
|
|
68
|
-
pattern: /^4/,
|
|
69
|
-
format: [4, 4, 4, 4],
|
|
70
|
-
cvcLength: 3,
|
|
71
|
-
lengths: [13, 16, 19],
|
|
72
|
-
label: 'Visa',
|
|
73
|
-
},
|
|
74
|
-
]);
|
|
75
|
-
// ── Utilities ──────────────────────────────────────────────────────────────
|
|
76
|
-
function detectCardType(digits) {
|
|
77
|
-
if (!digits)
|
|
78
|
-
return null;
|
|
79
|
-
return CARD_TYPES.find(ct => ct.pattern.test(digits)) ?? null;
|
|
80
|
-
}
|
|
81
|
-
function formatDigits(digits, format) {
|
|
82
|
-
let result = '';
|
|
83
|
-
let pos = 0;
|
|
84
|
-
for (let i = 0; i < format.length; i++) {
|
|
85
|
-
const chunk = digits.slice(pos, pos + format[i]);
|
|
86
|
-
if (!chunk)
|
|
87
|
-
break;
|
|
88
|
-
if (i > 0)
|
|
89
|
-
result += ' ';
|
|
90
|
-
result += chunk;
|
|
91
|
-
pos += format[i];
|
|
92
|
-
}
|
|
93
|
-
return result;
|
|
94
|
-
}
|
|
95
|
-
function maskCardForDisplay(digits, format) {
|
|
96
|
-
const totalLen = format.reduce((a, b) => a + b, 0);
|
|
97
|
-
if (!digits) {
|
|
98
|
-
return formatDigits('•'.repeat(totalLen), format);
|
|
99
|
-
}
|
|
100
|
-
const padded = digits.padEnd(totalLen, '•').slice(0, totalLen);
|
|
101
|
-
const last4Start = Math.max(0, padded.length - 4);
|
|
102
|
-
const masked = '•'.repeat(last4Start) + padded.slice(last4Start);
|
|
103
|
-
return formatDigits(masked, format);
|
|
104
|
-
}
|
|
105
|
-
function luhnValid(digits) {
|
|
106
|
-
if (!digits || digits.length < 12)
|
|
107
|
-
return false;
|
|
108
|
-
let sum = 0;
|
|
109
|
-
let alternate = false;
|
|
110
|
-
for (let i = digits.length - 1; i >= 0; i--) {
|
|
111
|
-
const ch = digits[i];
|
|
112
|
-
if (!ch)
|
|
113
|
-
return false;
|
|
114
|
-
const n0 = parseInt(ch, 10);
|
|
115
|
-
if (isNaN(n0))
|
|
116
|
-
return false;
|
|
117
|
-
let n = n0;
|
|
118
|
-
if (alternate) {
|
|
119
|
-
n *= 2;
|
|
120
|
-
if (n > 9)
|
|
121
|
-
n -= 9;
|
|
122
|
-
}
|
|
123
|
-
sum += n;
|
|
124
|
-
alternate = !alternate;
|
|
125
|
-
}
|
|
126
|
-
return sum % 10 === 0;
|
|
127
|
-
}
|
|
128
|
-
function isExpiryValid(month, year) {
|
|
129
|
-
const now = new Date();
|
|
130
|
-
const currentYear = now.getFullYear() % 100;
|
|
131
|
-
const currentMonth = now.getMonth() + 1;
|
|
132
|
-
if (year < currentYear)
|
|
133
|
-
return false;
|
|
134
|
-
if (year === currentYear && month < currentMonth)
|
|
135
|
-
return false;
|
|
136
|
-
return true;
|
|
137
|
-
}
|
|
138
|
-
let instanceCounter = 0;
|
|
139
|
-
// ── Component ──────────────────────────────────────────────────────────────
|
|
140
|
-
export class SecureCard extends SecureBaseComponent {
|
|
141
|
-
// Shadow DOM input elements
|
|
142
|
-
#numberInput = null;
|
|
143
|
-
#expiryInput = null;
|
|
144
|
-
#cvcInput = null;
|
|
145
|
-
#nameInput = null;
|
|
146
|
-
// Card visual elements
|
|
147
|
-
#cardEl = null;
|
|
148
|
-
#cardNumberDisplay = null;
|
|
149
|
-
#cardExpiryDisplay = null;
|
|
150
|
-
#cardNameDisplay = null;
|
|
151
|
-
#cardTypeLabel = null;
|
|
152
|
-
#cardCvcDisplay = null;
|
|
153
|
-
// Error containers
|
|
154
|
-
#numberError = null;
|
|
155
|
-
#expiryError = null;
|
|
156
|
-
#cvcError = null;
|
|
157
|
-
#nameError = null;
|
|
158
|
-
// Light DOM hidden inputs for form participation
|
|
159
|
-
#hiddenNumber = null;
|
|
160
|
-
#hiddenExpiry = null;
|
|
161
|
-
#hiddenName = null;
|
|
162
|
-
// Sensitive state — cleared on disconnect
|
|
163
|
-
#cardDigits = '';
|
|
164
|
-
#cvcDigits = '';
|
|
165
|
-
#expiryValue = '';
|
|
166
|
-
#cardholderName = '';
|
|
167
|
-
#cardTypeConfig = null;
|
|
168
|
-
#instanceId;
|
|
169
|
-
constructor() {
|
|
170
|
-
super();
|
|
171
|
-
this.#instanceId = `secure-card-${++instanceCounter}`;
|
|
172
|
-
}
|
|
173
|
-
static get observedAttributes() {
|
|
174
|
-
return [...super.observedAttributes, 'name', 'label', 'show-name'];
|
|
175
|
-
}
|
|
176
|
-
// ── Render ─────────────────────────────────────────────────────────────────
|
|
177
|
-
render() {
|
|
178
|
-
const fragment = document.createDocumentFragment();
|
|
179
|
-
const disabled = this.hasAttribute('disabled');
|
|
180
|
-
const showName = this.hasAttribute('show-name');
|
|
181
|
-
const label = this.getAttribute('label') ?? '';
|
|
182
|
-
// ── Outer container ─────────────────────────────────────────────────────
|
|
183
|
-
const container = document.createElement('div');
|
|
184
|
-
container.className = 'card-container';
|
|
185
|
-
container.setAttribute('part', 'container');
|
|
186
|
-
// ── Card visual (aria-hidden — purely decorative) ────────────────────────
|
|
187
|
-
const scene = document.createElement('div');
|
|
188
|
-
scene.className = 'card-scene';
|
|
189
|
-
scene.setAttribute('aria-hidden', 'true');
|
|
190
|
-
this.#cardEl = document.createElement('div');
|
|
191
|
-
this.#cardEl.className = 'card';
|
|
192
|
-
// Front face
|
|
193
|
-
const front = document.createElement('div');
|
|
194
|
-
front.className = 'card-face card-front';
|
|
195
|
-
const topRow = document.createElement('div');
|
|
196
|
-
topRow.className = 'card-top-row';
|
|
197
|
-
const chip = document.createElement('div');
|
|
198
|
-
chip.className = 'card-chip';
|
|
199
|
-
this.#cardTypeLabel = document.createElement('div');
|
|
200
|
-
this.#cardTypeLabel.className = 'card-type-label';
|
|
201
|
-
topRow.appendChild(chip);
|
|
202
|
-
topRow.appendChild(this.#cardTypeLabel);
|
|
203
|
-
front.appendChild(topRow);
|
|
204
|
-
this.#cardNumberDisplay = document.createElement('div');
|
|
205
|
-
this.#cardNumberDisplay.className = 'card-number-display';
|
|
206
|
-
this.#cardNumberDisplay.textContent = '•••• •••• •••• ••••';
|
|
207
|
-
front.appendChild(this.#cardNumberDisplay);
|
|
208
|
-
const bottomRow = document.createElement('div');
|
|
209
|
-
bottomRow.className = 'card-bottom-row';
|
|
210
|
-
const nameSection = document.createElement('div');
|
|
211
|
-
nameSection.className = 'card-name-section';
|
|
212
|
-
const nameFieldLabel = document.createElement('div');
|
|
213
|
-
nameFieldLabel.className = 'card-field-label';
|
|
214
|
-
nameFieldLabel.textContent = 'Card holder';
|
|
215
|
-
this.#cardNameDisplay = document.createElement('div');
|
|
216
|
-
this.#cardNameDisplay.className = 'card-name-display';
|
|
217
|
-
this.#cardNameDisplay.textContent = 'FULL NAME';
|
|
218
|
-
nameSection.appendChild(nameFieldLabel);
|
|
219
|
-
nameSection.appendChild(this.#cardNameDisplay);
|
|
220
|
-
const expirySection = document.createElement('div');
|
|
221
|
-
expirySection.className = 'card-expiry-section';
|
|
222
|
-
const expiryFieldLabel = document.createElement('div');
|
|
223
|
-
expiryFieldLabel.className = 'card-field-label';
|
|
224
|
-
expiryFieldLabel.textContent = 'Expires';
|
|
225
|
-
this.#cardExpiryDisplay = document.createElement('div');
|
|
226
|
-
this.#cardExpiryDisplay.className = 'card-expiry-display';
|
|
227
|
-
this.#cardExpiryDisplay.textContent = 'MM/YY';
|
|
228
|
-
expirySection.appendChild(expiryFieldLabel);
|
|
229
|
-
expirySection.appendChild(this.#cardExpiryDisplay);
|
|
230
|
-
bottomRow.appendChild(nameSection);
|
|
231
|
-
bottomRow.appendChild(expirySection);
|
|
232
|
-
front.appendChild(bottomRow);
|
|
233
|
-
// Back face
|
|
234
|
-
const back = document.createElement('div');
|
|
235
|
-
back.className = 'card-face card-back';
|
|
236
|
-
const strip = document.createElement('div');
|
|
237
|
-
strip.className = 'card-strip';
|
|
238
|
-
const cvcArea = document.createElement('div');
|
|
239
|
-
cvcArea.className = 'card-cvc-area';
|
|
240
|
-
const cvcAreaLabel = document.createElement('div');
|
|
241
|
-
cvcAreaLabel.className = 'card-cvc-label';
|
|
242
|
-
cvcAreaLabel.textContent = 'CVC';
|
|
243
|
-
this.#cardCvcDisplay = document.createElement('div');
|
|
244
|
-
this.#cardCvcDisplay.className = 'card-cvc-display';
|
|
245
|
-
this.#cardCvcDisplay.textContent = '•••';
|
|
246
|
-
cvcArea.appendChild(cvcAreaLabel);
|
|
247
|
-
cvcArea.appendChild(this.#cardCvcDisplay);
|
|
248
|
-
back.appendChild(strip);
|
|
249
|
-
back.appendChild(cvcArea);
|
|
250
|
-
this.#cardEl.appendChild(front);
|
|
251
|
-
this.#cardEl.appendChild(back);
|
|
252
|
-
scene.appendChild(this.#cardEl);
|
|
253
|
-
// ── Input fields ──────────────────────────────────────────────────────────
|
|
254
|
-
const fields = document.createElement('div');
|
|
255
|
-
fields.className = 'card-fields';
|
|
256
|
-
if (label) {
|
|
257
|
-
const legend = document.createElement('div');
|
|
258
|
-
legend.className = 'card-legend';
|
|
259
|
-
legend.setAttribute('part', 'label');
|
|
260
|
-
legend.textContent = this.sanitizeValue(label);
|
|
261
|
-
fields.appendChild(legend);
|
|
262
|
-
}
|
|
263
|
-
// Card number
|
|
264
|
-
const numberGroup = this.#makeFieldGroup(`${this.#instanceId}-number`);
|
|
265
|
-
const numberLabel = numberGroup.querySelector('label');
|
|
266
|
-
numberLabel.textContent = 'Card number';
|
|
267
|
-
this.#numberInput = document.createElement('input');
|
|
268
|
-
this.#numberInput.id = `${this.#instanceId}-number`;
|
|
269
|
-
this.#numberInput.className = 'card-input card-number-input';
|
|
270
|
-
this.#numberInput.setAttribute('type', 'text');
|
|
271
|
-
this.#numberInput.setAttribute('inputmode', 'numeric');
|
|
272
|
-
this.#numberInput.setAttribute('autocomplete', 'cc-number');
|
|
273
|
-
this.#numberInput.setAttribute('placeholder', '0000 0000 0000 0000');
|
|
274
|
-
this.#numberInput.setAttribute('maxlength', '23');
|
|
275
|
-
this.#numberInput.setAttribute('aria-required', 'true');
|
|
276
|
-
this.#numberInput.setAttribute('aria-describedby', `${this.#instanceId}-number-error`);
|
|
277
|
-
this.#numberInput.setAttribute('part', 'number-input');
|
|
278
|
-
if (disabled)
|
|
279
|
-
this.#numberInput.disabled = true;
|
|
280
|
-
this.#numberError = numberGroup.querySelector('.error-container');
|
|
281
|
-
this.#numberError.id = `${this.#instanceId}-number-error`;
|
|
282
|
-
numberGroup.querySelector('.input-wrapper').appendChild(this.#numberInput);
|
|
283
|
-
fields.appendChild(numberGroup);
|
|
284
|
-
// Expiry + CVC side by side
|
|
285
|
-
const fieldRow = document.createElement('div');
|
|
286
|
-
fieldRow.className = 'field-row';
|
|
287
|
-
const expiryGroup = this.#makeFieldGroup(`${this.#instanceId}-expiry`);
|
|
288
|
-
expiryGroup.querySelector('label').textContent = 'Expiry date';
|
|
289
|
-
this.#expiryInput = document.createElement('input');
|
|
290
|
-
this.#expiryInput.id = `${this.#instanceId}-expiry`;
|
|
291
|
-
this.#expiryInput.className = 'card-input';
|
|
292
|
-
this.#expiryInput.setAttribute('type', 'text');
|
|
293
|
-
this.#expiryInput.setAttribute('inputmode', 'numeric');
|
|
294
|
-
this.#expiryInput.setAttribute('autocomplete', 'cc-exp');
|
|
295
|
-
this.#expiryInput.setAttribute('placeholder', 'MM/YY');
|
|
296
|
-
this.#expiryInput.setAttribute('maxlength', '5');
|
|
297
|
-
this.#expiryInput.setAttribute('aria-required', 'true');
|
|
298
|
-
this.#expiryInput.setAttribute('aria-describedby', `${this.#instanceId}-expiry-error`);
|
|
299
|
-
this.#expiryInput.setAttribute('part', 'expiry-input');
|
|
300
|
-
if (disabled)
|
|
301
|
-
this.#expiryInput.disabled = true;
|
|
302
|
-
this.#expiryError = expiryGroup.querySelector('.error-container');
|
|
303
|
-
this.#expiryError.id = `${this.#instanceId}-expiry-error`;
|
|
304
|
-
expiryGroup.querySelector('.input-wrapper').appendChild(this.#expiryInput);
|
|
305
|
-
const cvcGroup = this.#makeFieldGroup(`${this.#instanceId}-cvc`);
|
|
306
|
-
cvcGroup.querySelector('label').textContent = 'Security code';
|
|
307
|
-
this.#cvcInput = document.createElement('input');
|
|
308
|
-
this.#cvcInput.id = `${this.#instanceId}-cvc`;
|
|
309
|
-
this.#cvcInput.className = 'card-input cvc-input';
|
|
310
|
-
// type=password: browser masks natively, avoids screen capture of CVC
|
|
311
|
-
this.#cvcInput.setAttribute('type', 'password');
|
|
312
|
-
this.#cvcInput.setAttribute('inputmode', 'numeric');
|
|
313
|
-
this.#cvcInput.setAttribute('autocomplete', 'cc-csc');
|
|
314
|
-
this.#cvcInput.setAttribute('placeholder', '•••');
|
|
315
|
-
this.#cvcInput.setAttribute('maxlength', '4');
|
|
316
|
-
this.#cvcInput.setAttribute('aria-required', 'true');
|
|
317
|
-
this.#cvcInput.setAttribute('aria-describedby', `${this.#instanceId}-cvc-error`);
|
|
318
|
-
this.#cvcInput.setAttribute('part', 'cvc-input');
|
|
319
|
-
if (disabled)
|
|
320
|
-
this.#cvcInput.disabled = true;
|
|
321
|
-
this.#cvcError = cvcGroup.querySelector('.error-container');
|
|
322
|
-
this.#cvcError.id = `${this.#instanceId}-cvc-error`;
|
|
323
|
-
cvcGroup.querySelector('.input-wrapper').appendChild(this.#cvcInput);
|
|
324
|
-
fieldRow.appendChild(expiryGroup);
|
|
325
|
-
fieldRow.appendChild(cvcGroup);
|
|
326
|
-
fields.appendChild(fieldRow);
|
|
327
|
-
// Cardholder name (optional)
|
|
328
|
-
const nameGroup = this.#makeFieldGroup(`${this.#instanceId}-name`);
|
|
329
|
-
nameGroup.id = `${this.#instanceId}-name-group`;
|
|
330
|
-
nameGroup.querySelector('label').textContent = 'Cardholder name';
|
|
331
|
-
if (!showName)
|
|
332
|
-
nameGroup.hidden = true;
|
|
333
|
-
this.#nameInput = document.createElement('input');
|
|
334
|
-
this.#nameInput.id = `${this.#instanceId}-name`;
|
|
335
|
-
this.#nameInput.className = 'card-input';
|
|
336
|
-
this.#nameInput.setAttribute('type', 'text');
|
|
337
|
-
this.#nameInput.setAttribute('autocomplete', 'cc-name');
|
|
338
|
-
this.#nameInput.setAttribute('placeholder', 'Name as it appears on card');
|
|
339
|
-
this.#nameInput.setAttribute('spellcheck', 'false');
|
|
340
|
-
this.#nameInput.setAttribute('aria-describedby', `${this.#instanceId}-name-error`);
|
|
341
|
-
this.#nameInput.setAttribute('part', 'name-input');
|
|
342
|
-
if (disabled)
|
|
343
|
-
this.#nameInput.disabled = true;
|
|
344
|
-
this.#nameError = nameGroup.querySelector('.error-container');
|
|
345
|
-
this.#nameError.id = `${this.#instanceId}-name-error`;
|
|
346
|
-
nameGroup.querySelector('.input-wrapper').appendChild(this.#nameInput);
|
|
347
|
-
fields.appendChild(nameGroup);
|
|
348
|
-
container.appendChild(scene);
|
|
349
|
-
container.appendChild(fields);
|
|
350
|
-
// ── Light DOM hidden inputs ───────────────────────────────────────────────
|
|
351
|
-
this.#createHiddenInputs();
|
|
352
|
-
// ── Component styles ──────────────────────────────────────────────────────
|
|
353
|
-
this.addComponentStyles(new URL('./secure-card.css', import.meta.url).href);
|
|
354
|
-
// ── Event listeners ───────────────────────────────────────────────────────
|
|
355
|
-
// Telemetry hooks aggregate signals across all card inputs into one
|
|
356
|
-
// composite behavioral fingerprint for the overall card interaction.
|
|
357
|
-
this.#numberInput.addEventListener('input', (e) => { this.recordTelemetryInput(e); this.#handleNumberInput(e); });
|
|
358
|
-
this.#numberInput.addEventListener('focus', () => { this.recordTelemetryFocus(); this.#handleNumberFocus(); });
|
|
359
|
-
this.#numberInput.addEventListener('blur', () => { this.recordTelemetryBlur(); this.#handleNumberBlur(); });
|
|
360
|
-
this.#expiryInput.addEventListener('input', (e) => { this.recordTelemetryInput(e); this.#handleExpiryInput(e); });
|
|
361
|
-
this.#expiryInput.addEventListener('focus', () => { this.recordTelemetryFocus(); this.#flipCard(false); });
|
|
362
|
-
this.#expiryInput.addEventListener('blur', () => { this.recordTelemetryBlur(); this.#handleExpiryBlur(); });
|
|
363
|
-
this.#cvcInput.addEventListener('input', (e) => { this.recordTelemetryInput(e); this.#handleCvcInput(e); });
|
|
364
|
-
this.#cvcInput.addEventListener('focus', () => { this.recordTelemetryFocus(); this.#flipCard(true); });
|
|
365
|
-
this.#cvcInput.addEventListener('blur', () => { this.recordTelemetryBlur(); this.#handleCvcBlur(); });
|
|
366
|
-
this.#nameInput.addEventListener('input', (e) => { this.recordTelemetryInput(e); this.#handleNameInput(e); });
|
|
367
|
-
this.#nameInput.addEventListener('focus', () => { this.recordTelemetryFocus(); this.#flipCard(false); });
|
|
368
|
-
this.#nameInput.addEventListener('blur', () => { this.recordTelemetryBlur(); this.#handleNameBlur(); });
|
|
369
|
-
fragment.appendChild(container);
|
|
370
|
-
return fragment;
|
|
371
|
-
}
|
|
372
|
-
// ── Field group builder ────────────────────────────────────────────────────
|
|
373
|
-
#makeFieldGroup(id) {
|
|
374
|
-
const group = document.createElement('div');
|
|
375
|
-
group.className = 'field-group';
|
|
376
|
-
const lbl = document.createElement('label');
|
|
377
|
-
lbl.htmlFor = id;
|
|
378
|
-
lbl.className = 'field-label';
|
|
379
|
-
const wrapper = document.createElement('div');
|
|
380
|
-
wrapper.className = 'input-wrapper';
|
|
381
|
-
wrapper.setAttribute('part', 'wrapper');
|
|
382
|
-
const err = document.createElement('div');
|
|
383
|
-
err.className = 'error-container hidden';
|
|
384
|
-
err.setAttribute('role', 'alert');
|
|
385
|
-
err.setAttribute('part', 'error');
|
|
386
|
-
group.appendChild(lbl);
|
|
387
|
-
group.appendChild(wrapper);
|
|
388
|
-
group.appendChild(err);
|
|
389
|
-
return group;
|
|
390
|
-
}
|
|
391
|
-
// ── Attribute change handler ───────────────────────────────────────────────
|
|
392
|
-
handleAttributeChange(name, _oldValue, newValue) {
|
|
393
|
-
if (!this.shadowRoot)
|
|
394
|
-
return;
|
|
395
|
-
switch (name) {
|
|
396
|
-
case 'disabled': {
|
|
397
|
-
const d = newValue !== null;
|
|
398
|
-
if (this.#numberInput)
|
|
399
|
-
this.#numberInput.disabled = d;
|
|
400
|
-
if (this.#expiryInput)
|
|
401
|
-
this.#expiryInput.disabled = d;
|
|
402
|
-
if (this.#cvcInput)
|
|
403
|
-
this.#cvcInput.disabled = d;
|
|
404
|
-
if (this.#nameInput)
|
|
405
|
-
this.#nameInput.disabled = d;
|
|
406
|
-
break;
|
|
407
|
-
}
|
|
408
|
-
case 'show-name': {
|
|
409
|
-
const group = this.shadowRoot.querySelector(`#${this.#instanceId}-name-group`);
|
|
410
|
-
if (group)
|
|
411
|
-
group.hidden = newValue === null;
|
|
412
|
-
break;
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
// ── Card number handlers ───────────────────────────────────────────────────
|
|
417
|
-
#handleNumberInput(e) {
|
|
418
|
-
const input = e.target;
|
|
419
|
-
const digits = input.value.replace(/\D/g, '');
|
|
420
|
-
const prevType = this.#cardTypeConfig?.type;
|
|
421
|
-
this.#cardTypeConfig = detectCardType(digits);
|
|
422
|
-
const format = this.#cardTypeConfig?.format ?? [4, 4, 4, 4];
|
|
423
|
-
const maxLen = format.reduce((a, b) => a + b, 0);
|
|
424
|
-
this.#cardDigits = digits.slice(0, maxLen);
|
|
425
|
-
input.value = formatDigits(this.#cardDigits, format);
|
|
426
|
-
// Update CVC length when card type changes
|
|
427
|
-
if (this.#cardTypeConfig?.type !== prevType) {
|
|
428
|
-
const maxWithSpaces = maxLen + format.length - 1;
|
|
429
|
-
input.setAttribute('maxlength', String(maxWithSpaces));
|
|
430
|
-
const cvcLen = this.#cardTypeConfig?.cvcLength ?? 3;
|
|
431
|
-
if (this.#cvcInput) {
|
|
432
|
-
this.#cvcInput.setAttribute('maxlength', String(cvcLen));
|
|
433
|
-
}
|
|
434
|
-
this.#updateCardType();
|
|
435
|
-
}
|
|
436
|
-
// Update card visual front face
|
|
437
|
-
if (this.#cardNumberDisplay) {
|
|
438
|
-
this.#cardNumberDisplay.textContent =
|
|
439
|
-
input.value || formatDigits('•'.repeat(maxLen), format);
|
|
440
|
-
}
|
|
441
|
-
this.#clearError(this.#numberError);
|
|
442
|
-
this.#syncHiddenInputs();
|
|
443
|
-
this.#dispatchChangeEvent();
|
|
444
|
-
}
|
|
445
|
-
#handleNumberFocus() {
|
|
446
|
-
// Restore formatted digits on focus (undo blur masking)
|
|
447
|
-
if (this.#numberInput && this.#cardDigits) {
|
|
448
|
-
const format = this.#cardTypeConfig?.format ?? [4, 4, 4, 4];
|
|
449
|
-
const formatted = formatDigits(this.#cardDigits, format);
|
|
450
|
-
this.#numberInput.value = formatted;
|
|
451
|
-
if (this.#cardNumberDisplay)
|
|
452
|
-
this.#cardNumberDisplay.textContent = formatted;
|
|
453
|
-
}
|
|
454
|
-
this.#flipCard(false);
|
|
455
|
-
this.#clearError(this.#numberError);
|
|
456
|
-
}
|
|
457
|
-
#handleNumberBlur() {
|
|
458
|
-
const rl = this.checkRateLimit();
|
|
459
|
-
if (!rl.allowed) {
|
|
460
|
-
this.#showError(this.#numberError, `Too many attempts. Try again in ${Math.ceil(rl.retryAfter / 1000)}s.`);
|
|
461
|
-
return;
|
|
462
|
-
}
|
|
463
|
-
if (!this.#cardDigits) {
|
|
464
|
-
this.#showError(this.#numberError, 'Card number is required');
|
|
465
|
-
this.#numberInput?.setAttribute('aria-invalid', 'true');
|
|
466
|
-
}
|
|
467
|
-
else if (!luhnValid(this.#cardDigits)) {
|
|
468
|
-
this.#showError(this.#numberError, 'Invalid card number');
|
|
469
|
-
this.#numberInput?.setAttribute('aria-invalid', 'true');
|
|
470
|
-
}
|
|
471
|
-
else {
|
|
472
|
-
this.#clearError(this.#numberError);
|
|
473
|
-
this.#numberInput?.removeAttribute('aria-invalid');
|
|
474
|
-
}
|
|
475
|
-
// Mask middle digits on blur — last 4 remain visible
|
|
476
|
-
this.#applyNumberMask();
|
|
477
|
-
this.audit('card-number-blur', { cardType: this.#cardTypeConfig?.type ?? 'unknown' });
|
|
478
|
-
}
|
|
479
|
-
#applyNumberMask() {
|
|
480
|
-
const format = this.#cardTypeConfig?.format ?? [4, 4, 4, 4];
|
|
481
|
-
const masked = maskCardForDisplay(this.#cardDigits, format);
|
|
482
|
-
if (this.#numberInput)
|
|
483
|
-
this.#numberInput.value = masked;
|
|
484
|
-
if (this.#cardNumberDisplay)
|
|
485
|
-
this.#cardNumberDisplay.textContent = masked;
|
|
486
|
-
}
|
|
487
|
-
// ── Expiry handlers ───────────────────────────────────────────────────────
|
|
488
|
-
#handleExpiryInput(e) {
|
|
489
|
-
const input = e.target;
|
|
490
|
-
const digits = input.value.replace(/\D/g, '').slice(0, 4);
|
|
491
|
-
const formatted = digits.length > 2 ? `${digits.slice(0, 2)}/${digits.slice(2)}` : digits;
|
|
492
|
-
input.value = formatted;
|
|
493
|
-
this.#expiryValue = formatted;
|
|
494
|
-
if (this.#cardExpiryDisplay) {
|
|
495
|
-
this.#cardExpiryDisplay.textContent = formatted || 'MM/YY';
|
|
496
|
-
}
|
|
497
|
-
this.#clearError(this.#expiryError);
|
|
498
|
-
this.#syncHiddenInputs();
|
|
499
|
-
this.#dispatchChangeEvent();
|
|
500
|
-
}
|
|
501
|
-
#handleExpiryBlur() {
|
|
502
|
-
const parts = this.#expiryValue.split('/');
|
|
503
|
-
if (!this.#expiryValue) {
|
|
504
|
-
this.#showError(this.#expiryError, 'Expiry date is required');
|
|
505
|
-
this.#expiryInput?.setAttribute('aria-invalid', 'true');
|
|
506
|
-
return;
|
|
507
|
-
}
|
|
508
|
-
const mm = parseInt(parts[0] ?? '', 10);
|
|
509
|
-
const yy = parseInt(parts[1] ?? '', 10);
|
|
510
|
-
if (isNaN(mm) || isNaN(yy) || mm < 1 || mm > 12 || (parts[1] ?? '').length < 2) {
|
|
511
|
-
this.#showError(this.#expiryError, 'Enter a valid expiry date (MM/YY)');
|
|
512
|
-
this.#expiryInput?.setAttribute('aria-invalid', 'true');
|
|
513
|
-
}
|
|
514
|
-
else if (!isExpiryValid(mm, yy)) {
|
|
515
|
-
this.#showError(this.#expiryError, 'This card has expired');
|
|
516
|
-
this.#expiryInput?.setAttribute('aria-invalid', 'true');
|
|
517
|
-
}
|
|
518
|
-
else {
|
|
519
|
-
this.#clearError(this.#expiryError);
|
|
520
|
-
this.#expiryInput?.removeAttribute('aria-invalid');
|
|
521
|
-
}
|
|
522
|
-
this.audit('card-expiry-blur', {});
|
|
523
|
-
}
|
|
524
|
-
// ── CVC handlers ──────────────────────────────────────────────────────────
|
|
525
|
-
#handleCvcInput(e) {
|
|
526
|
-
const input = e.target;
|
|
527
|
-
const digits = input.value.replace(/\D/g, '');
|
|
528
|
-
const maxLen = this.#cardTypeConfig?.cvcLength ?? 3;
|
|
529
|
-
this.#cvcDigits = digits.slice(0, maxLen);
|
|
530
|
-
input.value = this.#cvcDigits;
|
|
531
|
-
// Card back shows bullet count matching typed length — value never shown
|
|
532
|
-
if (this.#cardCvcDisplay) {
|
|
533
|
-
this.#cardCvcDisplay.textContent =
|
|
534
|
-
this.#cvcDigits.length > 0 ? '•'.repeat(this.#cvcDigits.length) : '•••';
|
|
535
|
-
}
|
|
536
|
-
this.#clearError(this.#cvcError);
|
|
537
|
-
this.#dispatchChangeEvent();
|
|
538
|
-
}
|
|
539
|
-
#handleCvcBlur() {
|
|
540
|
-
const maxLen = this.#cardTypeConfig?.cvcLength ?? 3;
|
|
541
|
-
if (!this.#cvcDigits) {
|
|
542
|
-
this.#showError(this.#cvcError, 'Security code is required');
|
|
543
|
-
this.#cvcInput?.setAttribute('aria-invalid', 'true');
|
|
544
|
-
}
|
|
545
|
-
else if (this.#cvcDigits.length < maxLen) {
|
|
546
|
-
this.#showError(this.#cvcError, `Security code must be ${maxLen} digits`);
|
|
547
|
-
this.#cvcInput?.setAttribute('aria-invalid', 'true');
|
|
548
|
-
}
|
|
549
|
-
else {
|
|
550
|
-
this.#clearError(this.#cvcError);
|
|
551
|
-
this.#cvcInput?.removeAttribute('aria-invalid');
|
|
552
|
-
}
|
|
553
|
-
this.#flipCard(false);
|
|
554
|
-
// CVC value is never audited
|
|
555
|
-
this.audit('card-cvc-blur', {});
|
|
556
|
-
}
|
|
557
|
-
// ── Name handlers ─────────────────────────────────────────────────────────
|
|
558
|
-
#handleNameInput(e) {
|
|
559
|
-
const input = e.target;
|
|
560
|
-
// Letters, spaces, hyphens, apostrophes only
|
|
561
|
-
const sanitized = input.value.replace(/[^a-zA-Z\s\-']/g, '').slice(0, 50);
|
|
562
|
-
input.value = sanitized;
|
|
563
|
-
this.#cardholderName = sanitized;
|
|
564
|
-
if (this.#cardNameDisplay) {
|
|
565
|
-
this.#cardNameDisplay.textContent = sanitized.toUpperCase() || 'FULL NAME';
|
|
566
|
-
}
|
|
567
|
-
this.#clearError(this.#nameError);
|
|
568
|
-
this.#syncHiddenInputs();
|
|
569
|
-
this.#dispatchChangeEvent();
|
|
570
|
-
}
|
|
571
|
-
#handleNameBlur() {
|
|
572
|
-
if (!this.hasAttribute('show-name'))
|
|
573
|
-
return;
|
|
574
|
-
if (!this.#cardholderName.trim()) {
|
|
575
|
-
this.#showError(this.#nameError, 'Cardholder name is required');
|
|
576
|
-
this.#nameInput?.setAttribute('aria-invalid', 'true');
|
|
577
|
-
}
|
|
578
|
-
else {
|
|
579
|
-
this.#clearError(this.#nameError);
|
|
580
|
-
this.#nameInput?.removeAttribute('aria-invalid');
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
// ── Card visual ───────────────────────────────────────────────────────────
|
|
584
|
-
#flipCard(toBack) {
|
|
585
|
-
this.#cardEl?.classList.toggle('is-flipped', toBack);
|
|
586
|
-
}
|
|
587
|
-
#updateCardType() {
|
|
588
|
-
if (!this.#cardEl)
|
|
589
|
-
return;
|
|
590
|
-
for (const ct of CARD_TYPES) {
|
|
591
|
-
this.#cardEl.classList.remove(`card--${ct.type}`);
|
|
592
|
-
}
|
|
593
|
-
const type = this.#cardTypeConfig?.type;
|
|
594
|
-
if (type)
|
|
595
|
-
this.#cardEl.classList.add(`card--${type}`);
|
|
596
|
-
if (this.#cardTypeLabel) {
|
|
597
|
-
this.#cardTypeLabel.textContent = this.#cardTypeConfig?.label ?? '';
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
// ── Error helpers ─────────────────────────────────────────────────────────
|
|
601
|
-
#showError(container, message) {
|
|
602
|
-
if (!container)
|
|
603
|
-
return;
|
|
604
|
-
container.textContent = '';
|
|
605
|
-
const span = document.createElement('span');
|
|
606
|
-
span.textContent = message;
|
|
607
|
-
container.appendChild(span);
|
|
608
|
-
container.classList.remove('hidden');
|
|
609
|
-
}
|
|
610
|
-
#clearError(container) {
|
|
611
|
-
if (!container)
|
|
612
|
-
return;
|
|
613
|
-
container.textContent = '';
|
|
614
|
-
container.classList.add('hidden');
|
|
615
|
-
}
|
|
616
|
-
// ── Hidden inputs for light DOM form participation ─────────────────────────
|
|
617
|
-
#createHiddenInputs() {
|
|
618
|
-
const fieldName = this.getAttribute('name');
|
|
619
|
-
if (!fieldName)
|
|
620
|
-
return;
|
|
621
|
-
// Card number: stores last4 only — full PAN must never reach your server
|
|
622
|
-
this.#hiddenNumber = document.createElement('input');
|
|
623
|
-
this.#hiddenNumber.type = 'hidden';
|
|
624
|
-
this.#hiddenNumber.name = fieldName;
|
|
625
|
-
this.appendChild(this.#hiddenNumber);
|
|
626
|
-
this.#hiddenExpiry = document.createElement('input');
|
|
627
|
-
this.#hiddenExpiry.type = 'hidden';
|
|
628
|
-
this.#hiddenExpiry.name = `${fieldName}-expiry`;
|
|
629
|
-
this.appendChild(this.#hiddenExpiry);
|
|
630
|
-
this.#hiddenName = document.createElement('input');
|
|
631
|
-
this.#hiddenName.type = 'hidden';
|
|
632
|
-
this.#hiddenName.name = `${fieldName}-holder`;
|
|
633
|
-
this.appendChild(this.#hiddenName);
|
|
634
|
-
// No hidden input for CVC — never submit CVC to your own server
|
|
635
|
-
}
|
|
636
|
-
#syncHiddenInputs() {
|
|
637
|
-
if (this.#hiddenNumber)
|
|
638
|
-
this.#hiddenNumber.value = this.#cardDigits.slice(-4);
|
|
639
|
-
if (this.#hiddenExpiry)
|
|
640
|
-
this.#hiddenExpiry.value = this.#expiryValue;
|
|
641
|
-
if (this.#hiddenName)
|
|
642
|
-
this.#hiddenName.value = this.#cardholderName;
|
|
643
|
-
}
|
|
644
|
-
// ── Event dispatch ────────────────────────────────────────────────────────
|
|
645
|
-
#dispatchChangeEvent() {
|
|
646
|
-
const [rawMonth, rawYear] = this.#expiryValue.split('/');
|
|
647
|
-
this.dispatchEvent(new CustomEvent('secure-card', {
|
|
648
|
-
bubbles: true,
|
|
649
|
-
composed: true,
|
|
650
|
-
detail: {
|
|
651
|
-
name: this.getAttribute('name') ?? '',
|
|
652
|
-
cardType: this.#cardTypeConfig?.type ?? 'unknown',
|
|
653
|
-
last4: this.#cardDigits.slice(-4),
|
|
654
|
-
expiryMonth: parseInt(rawMonth ?? '0', 10) || 0,
|
|
655
|
-
expiryYear: parseInt(rawYear ?? '0', 10) || 0,
|
|
656
|
-
cardholderName: this.#cardholderName,
|
|
657
|
-
valid: this.valid,
|
|
658
|
-
tier: this.securityTier,
|
|
659
|
-
// Full PAN and CVC are intentionally absent — use getCardData() for SDK calls
|
|
660
|
-
},
|
|
661
|
-
}));
|
|
662
|
-
}
|
|
663
|
-
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
664
|
-
disconnectedCallback() {
|
|
665
|
-
// Wipe all sensitive state from memory
|
|
666
|
-
this.#cardDigits = '';
|
|
667
|
-
this.#cvcDigits = '';
|
|
668
|
-
this.#expiryValue = '';
|
|
669
|
-
this.#cardholderName = '';
|
|
670
|
-
if (this.#numberInput)
|
|
671
|
-
this.#numberInput.value = '';
|
|
672
|
-
if (this.#expiryInput)
|
|
673
|
-
this.#expiryInput.value = '';
|
|
674
|
-
if (this.#cvcInput)
|
|
675
|
-
this.#cvcInput.value = '';
|
|
676
|
-
if (this.#nameInput)
|
|
677
|
-
this.#nameInput.value = '';
|
|
678
|
-
if (this.#hiddenNumber)
|
|
679
|
-
this.#hiddenNumber.value = '';
|
|
680
|
-
if (this.#hiddenExpiry)
|
|
681
|
-
this.#hiddenExpiry.value = '';
|
|
682
|
-
if (this.#hiddenName)
|
|
683
|
-
this.#hiddenName.value = '';
|
|
684
|
-
super.disconnectedCallback();
|
|
685
|
-
}
|
|
686
|
-
// ── Public API ────────────────────────────────────────────────────────────
|
|
687
|
-
/**
|
|
688
|
-
* True when all visible, required fields pass validation.
|
|
689
|
-
*/
|
|
690
|
-
get valid() {
|
|
691
|
-
const numberOk = this.#cardDigits.length >= 12 && luhnValid(this.#cardDigits);
|
|
692
|
-
const [mm, yy] = this.#expiryValue.split('/');
|
|
693
|
-
const month = parseInt(mm ?? '', 10);
|
|
694
|
-
const year = parseInt(yy ?? '', 10);
|
|
695
|
-
const expiryOk = !isNaN(month) &&
|
|
696
|
-
!isNaN(year) &&
|
|
697
|
-
month >= 1 &&
|
|
698
|
-
month <= 12 &&
|
|
699
|
-
(yy ?? '').length >= 2 &&
|
|
700
|
-
isExpiryValid(month, year);
|
|
701
|
-
const cvcLen = this.#cardTypeConfig?.cvcLength ?? 3;
|
|
702
|
-
const cvcOk = this.#cvcDigits.length === cvcLen;
|
|
703
|
-
const nameOk = !this.hasAttribute('show-name') || this.#cardholderName.trim().length > 0;
|
|
704
|
-
return numberOk && expiryOk && cvcOk && nameOk;
|
|
705
|
-
}
|
|
706
|
-
/**
|
|
707
|
-
* Card type detected from the entered number prefix.
|
|
708
|
-
*/
|
|
709
|
-
get cardType() {
|
|
710
|
-
return this.#cardTypeConfig?.type ?? 'unknown';
|
|
711
|
-
}
|
|
712
|
-
/**
|
|
713
|
-
* Last 4 digits of the entered card number. Safe to display and log.
|
|
714
|
-
*/
|
|
715
|
-
get last4() {
|
|
716
|
-
return this.#cardDigits.slice(-4);
|
|
717
|
-
}
|
|
718
|
-
/**
|
|
719
|
-
* The name attribute value.
|
|
720
|
-
*/
|
|
721
|
-
get name() {
|
|
722
|
-
return this.getAttribute('name') ?? '';
|
|
723
|
-
}
|
|
724
|
-
/**
|
|
725
|
-
* Returns raw card data for immediate handoff to a payment SDK tokeniser.
|
|
726
|
-
*
|
|
727
|
-
* SECURITY: Pass this data only to a PCI-compliant processor's client SDK
|
|
728
|
-
* (e.g. Stripe.js createToken, Braintree tokenizeCard). Never send raw card
|
|
729
|
-
* numbers or CVCs to your own server.
|
|
730
|
-
*
|
|
731
|
-
* Returns null if the form is not yet valid.
|
|
732
|
-
*/
|
|
733
|
-
getCardData() {
|
|
734
|
-
if (!this.valid)
|
|
735
|
-
return null;
|
|
736
|
-
return {
|
|
737
|
-
number: this.#cardDigits,
|
|
738
|
-
expiry: this.#expiryValue,
|
|
739
|
-
cvc: this.#cvcDigits,
|
|
740
|
-
name: this.#cardholderName,
|
|
741
|
-
};
|
|
742
|
-
}
|
|
743
|
-
/**
|
|
744
|
-
* Clears all fields and resets component state.
|
|
745
|
-
*/
|
|
746
|
-
reset() {
|
|
747
|
-
this.#cardDigits = '';
|
|
748
|
-
this.#cvcDigits = '';
|
|
749
|
-
this.#expiryValue = '';
|
|
750
|
-
this.#cardholderName = '';
|
|
751
|
-
this.#cardTypeConfig = null;
|
|
752
|
-
if (this.#numberInput)
|
|
753
|
-
this.#numberInput.value = '';
|
|
754
|
-
if (this.#expiryInput)
|
|
755
|
-
this.#expiryInput.value = '';
|
|
756
|
-
if (this.#cvcInput)
|
|
757
|
-
this.#cvcInput.value = '';
|
|
758
|
-
if (this.#nameInput)
|
|
759
|
-
this.#nameInput.value = '';
|
|
760
|
-
this.#syncHiddenInputs();
|
|
761
|
-
this.#flipCard(false);
|
|
762
|
-
this.#updateCardType();
|
|
763
|
-
const defaultFormat = [4, 4, 4, 4];
|
|
764
|
-
if (this.#cardNumberDisplay) {
|
|
765
|
-
this.#cardNumberDisplay.textContent = formatDigits('•'.repeat(16), defaultFormat);
|
|
766
|
-
}
|
|
767
|
-
if (this.#cardExpiryDisplay)
|
|
768
|
-
this.#cardExpiryDisplay.textContent = 'MM/YY';
|
|
769
|
-
if (this.#cardNameDisplay)
|
|
770
|
-
this.#cardNameDisplay.textContent = 'FULL NAME';
|
|
771
|
-
if (this.#cardCvcDisplay)
|
|
772
|
-
this.#cardCvcDisplay.textContent = '•••';
|
|
773
|
-
this.#clearError(this.#numberError);
|
|
774
|
-
this.#clearError(this.#expiryError);
|
|
775
|
-
this.#clearError(this.#cvcError);
|
|
776
|
-
this.#clearError(this.#nameError);
|
|
777
|
-
this.audit('card-reset', {});
|
|
778
|
-
}
|
|
779
|
-
/**
|
|
780
|
-
* Focuses the card number input.
|
|
781
|
-
*/
|
|
782
|
-
focus() {
|
|
783
|
-
this.#numberInput?.focus();
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
customElements.define('secure-card', SecureCard);
|
|
787
|
-
//# sourceMappingURL=secure-card.js.map
|
|
22
|
+
*/import{SecureBaseComponent as k}from"../../core/base-component.js";const $=Object.freeze([{type:"amex",pattern:/^3[47]/,format:[4,6,5],cvcLength:4,lengths:[15],label:"Amex"},{type:"diners",pattern:/^3(?:0[0-5]|[68])/,format:[4,6,4],cvcLength:3,lengths:[14],label:"Diners"},{type:"discover",pattern:/^6(?:011|5[0-9]{2})/,format:[4,4,4,4],cvcLength:3,lengths:[16],label:"Discover"},{type:"jcb",pattern:/^(?:2131|1800|35\d{3})/,format:[4,4,4,4],cvcLength:3,lengths:[16],label:"JCB"},{type:"mastercard",pattern:/^(?:5[1-5]|2[2-7])/,format:[4,4,4,4],cvcLength:3,lengths:[16],label:"Mastercard"},{type:"visa",pattern:/^4/,format:[4,4,4,4],cvcLength:3,lengths:[13,16,19],label:"Visa"}]);function M(h){return h?$.find(t=>t.pattern.test(h))??null:null}function o(h,t){let e="",i=0;for(let s=0;s<t.length;s++){const r=h.slice(i,i+t[s]);if(!r)break;s>0&&(e+=" "),e+=r,i+=t[s]}return e}function q(h,t){const e=t.reduce((n,l)=>n+l,0);if(!h)return o("\u2022".repeat(e),t);const i=h.padEnd(e,"\u2022").slice(0,e),s=Math.max(0,i.length-4),r="\u2022".repeat(s)+i.slice(s);return o(r,t)}function S(h){if(!h||h.length<12)return!1;let t=0,e=!1;for(let i=h.length-1;i>=0;i--){const s=h[i];if(!s)return!1;const r=parseInt(s,10);if(isNaN(r))return!1;let n=r;e&&(n*=2,n>9&&(n-=9)),t+=n,e=!e}return t%10===0}function T(h,t){const e=new Date,i=e.getFullYear()%100,s=e.getMonth()+1;return!(t<i||t===i&&h<s)}let D=0;class F extends k{#t=null;#i=null;#e=null;#s=null;#d=null;#l=null;#m=null;#b=null;#L=null;#f=null;#u=null;#v=null;#E=null;#A=null;#y=null;#C=null;#g=null;#a="";#c="";#o="";#x="";#n=null;#r;constructor(){super(),this.#r=`secure-card-${++D}`}static get observedAttributes(){return[...super.observedAttributes,"name","label","show-name"]}render(){const t=document.createDocumentFragment(),e=this.hasAttribute("disabled"),i=this.hasAttribute("show-name"),s=this.getAttribute("label")??"",r=document.createElement("div");r.className="card-container",r.setAttribute("part","container");const n=document.createElement("div");n.className="card-scene",n.setAttribute("aria-hidden","true"),this.#d=document.createElement("div"),this.#d.className="card";const l=document.createElement("div");l.className="card-face card-front";const c=document.createElement("div");c.className="card-top-row";const p=document.createElement("div");p.className="card-chip",this.#L=document.createElement("div"),this.#L.className="card-type-label",c.appendChild(p),c.appendChild(this.#L),l.appendChild(c),this.#l=document.createElement("div"),this.#l.className="card-number-display",this.#l.textContent="\u2022\u2022\u2022\u2022 \u2022\u2022\u2022\u2022 \u2022\u2022\u2022\u2022 \u2022\u2022\u2022\u2022",l.appendChild(this.#l);const m=document.createElement("div");m.className="card-bottom-row";const b=document.createElement("div");b.className="card-name-section";const A=document.createElement("div");A.className="card-field-label",A.textContent="Card holder",this.#b=document.createElement("div"),this.#b.className="card-name-display",this.#b.textContent="FULL NAME",b.appendChild(A),b.appendChild(this.#b);const f=document.createElement("div");f.className="card-expiry-section";const N=document.createElement("div");N.className="card-field-label",N.textContent="Expires",this.#m=document.createElement("div"),this.#m.className="card-expiry-display",this.#m.textContent="MM/YY",f.appendChild(N),f.appendChild(this.#m),m.appendChild(b),m.appendChild(f),l.appendChild(m);const v=document.createElement("div");v.className="card-face card-back";const w=document.createElement("div");w.className="card-strip";const y=document.createElement("div");y.className="card-cvc-area";const L=document.createElement("div");L.className="card-cvc-label",L.textContent="CVC",this.#f=document.createElement("div"),this.#f.className="card-cvc-display",this.#f.textContent="\u2022\u2022\u2022",y.appendChild(L),y.appendChild(this.#f),v.appendChild(w),v.appendChild(y),this.#d.appendChild(l),this.#d.appendChild(v),n.appendChild(this.#d);const d=document.createElement("div");if(d.className="card-fields",s){const a=document.createElement("div");a.className="card-legend",a.setAttribute("part","label"),a.textContent=this.sanitizeValue(s),d.appendChild(a)}const C=this.#w(`${this.#r}-number`),I=C.querySelector("label");I.textContent="Card number",this.#t=document.createElement("input"),this.#t.id=`${this.#r}-number`,this.#t.className="card-input card-number-input",this.#t.setAttribute("type","text"),this.#t.setAttribute("inputmode","numeric"),this.#t.setAttribute("autocomplete","cc-number"),this.#t.setAttribute("placeholder","0000 0000 0000 0000"),this.#t.setAttribute("maxlength","23"),this.#t.setAttribute("aria-required","true"),this.#t.setAttribute("aria-describedby",`${this.#r}-number-error`),this.#t.setAttribute("part","number-input"),e&&(this.#t.disabled=!0),this.#u=C.querySelector(".error-container"),this.#u.id=`${this.#r}-number-error`,C.querySelector(".input-wrapper").appendChild(this.#t),d.appendChild(C);const g=document.createElement("div");g.className="field-row";const x=this.#w(`${this.#r}-expiry`);x.querySelector("label").textContent="Expiry date",this.#i=document.createElement("input"),this.#i.id=`${this.#r}-expiry`,this.#i.className="card-input",this.#i.setAttribute("type","text"),this.#i.setAttribute("inputmode","numeric"),this.#i.setAttribute("autocomplete","cc-exp"),this.#i.setAttribute("placeholder","MM/YY"),this.#i.setAttribute("maxlength","5"),this.#i.setAttribute("aria-required","true"),this.#i.setAttribute("aria-describedby",`${this.#r}-expiry-error`),this.#i.setAttribute("part","expiry-input"),e&&(this.#i.disabled=!0),this.#v=x.querySelector(".error-container"),this.#v.id=`${this.#r}-expiry-error`,x.querySelector(".input-wrapper").appendChild(this.#i);const E=this.#w(`${this.#r}-cvc`);E.querySelector("label").textContent="Security code",this.#e=document.createElement("input"),this.#e.id=`${this.#r}-cvc`,this.#e.className="card-input cvc-input",this.#e.setAttribute("type","password"),this.#e.setAttribute("inputmode","numeric"),this.#e.setAttribute("autocomplete","cc-csc"),this.#e.setAttribute("placeholder","\u2022\u2022\u2022"),this.#e.setAttribute("maxlength","4"),this.#e.setAttribute("aria-required","true"),this.#e.setAttribute("aria-describedby",`${this.#r}-cvc-error`),this.#e.setAttribute("part","cvc-input"),e&&(this.#e.disabled=!0),this.#E=E.querySelector(".error-container"),this.#E.id=`${this.#r}-cvc-error`,E.querySelector(".input-wrapper").appendChild(this.#e),g.appendChild(x),g.appendChild(E),d.appendChild(g);const u=this.#w(`${this.#r}-name`);return u.id=`${this.#r}-name-group`,u.querySelector("label").textContent="Cardholder name",i||(u.hidden=!0),this.#s=document.createElement("input"),this.#s.id=`${this.#r}-name`,this.#s.className="card-input",this.#s.setAttribute("type","text"),this.#s.setAttribute("autocomplete","cc-name"),this.#s.setAttribute("placeholder","Name as it appears on card"),this.#s.setAttribute("spellcheck","false"),this.#s.setAttribute("aria-describedby",`${this.#r}-name-error`),this.#s.setAttribute("part","name-input"),e&&(this.#s.disabled=!0),this.#A=u.querySelector(".error-container"),this.#A.id=`${this.#r}-name-error`,u.querySelector(".input-wrapper").appendChild(this.#s),d.appendChild(u),r.appendChild(n),r.appendChild(d),this.#G(),this.addComponentStyles(new URL("./secure-card.css",import.meta.url).href),this.#t.addEventListener("input",a=>{this.recordTelemetryInput(a),this.#I(a)}),this.#t.addEventListener("focus",()=>{this.recordTelemetryFocus(),this.#k()}),this.#t.addEventListener("blur",()=>{this.recordTelemetryBlur(),this.#M()}),this.#i.addEventListener("input",a=>{this.recordTelemetryInput(a),this.#D(a)}),this.#i.addEventListener("focus",()=>{this.recordTelemetryFocus(),this.#N(!1)}),this.#i.addEventListener("blur",()=>{this.recordTelemetryBlur(),this.#F()}),this.#e.addEventListener("input",a=>{this.recordTelemetryInput(a),this.#Y(a)}),this.#e.addEventListener("focus",()=>{this.recordTelemetryFocus(),this.#N(!0)}),this.#e.addEventListener("blur",()=>{this.recordTelemetryBlur(),this.#B()}),this.#s.addEventListener("input",a=>{this.recordTelemetryInput(a),this.#R(a)}),this.#s.addEventListener("focus",()=>{this.recordTelemetryFocus(),this.#N(!1)}),this.#s.addEventListener("blur",()=>{this.recordTelemetryBlur(),this.#V()}),t.appendChild(r),t}#w(t){const e=document.createElement("div");e.className="field-group";const i=document.createElement("label");i.htmlFor=t,i.className="field-label";const s=document.createElement("div");s.className="input-wrapper",s.setAttribute("part","wrapper");const r=document.createElement("div");return r.className="error-container hidden",r.setAttribute("role","alert"),r.setAttribute("part","error"),e.appendChild(i),e.appendChild(s),e.appendChild(r),e}handleAttributeChange(t,e,i){if(this.shadowRoot)switch(t){case"disabled":{const s=i!==null;this.#t&&(this.#t.disabled=s),this.#i&&(this.#i.disabled=s),this.#e&&(this.#e.disabled=s),this.#s&&(this.#s.disabled=s);break}case"show-name":{const s=this.shadowRoot.querySelector(`#${this.#r}-name-group`);s&&(s.hidden=i===null);break}}}#I(t){const e=t.target,i=e.value.replace(/\D/g,""),s=this.#n?.type;this.#n=M(i);const r=this.#n?.format??[4,4,4,4],n=r.reduce((l,c)=>l+c,0);if(this.#a=i.slice(0,n),e.value=o(this.#a,r),this.#n?.type!==s){const l=n+r.length-1;e.setAttribute("maxlength",String(l));const c=this.#n?.cvcLength??3;this.#e&&this.#e.setAttribute("maxlength",String(c)),this.#T()}this.#l&&(this.#l.textContent=e.value||o("\u2022".repeat(n),r)),this.#h(this.#u),this.#$(),this.#S()}#k(){if(this.#t&&this.#a){const t=this.#n?.format??[4,4,4,4],e=o(this.#a,t);this.#t.value=e,this.#l&&(this.#l.textContent=e)}this.#N(!1),this.#h(this.#u)}#M(){const t=this.checkRateLimit();if(!t.allowed){this.#p(this.#u,`Too many attempts. Try again in ${Math.ceil(t.retryAfter/1e3)}s.`);return}this.#a?S(this.#a)?(this.#h(this.#u),this.#t?.removeAttribute("aria-invalid")):(this.#p(this.#u,"Invalid card number"),this.#t?.setAttribute("aria-invalid","true")):(this.#p(this.#u,"Card number is required"),this.#t?.setAttribute("aria-invalid","true")),this.#q(),this.audit("card-number-blur",{cardType:this.#n?.type??"unknown"})}#q(){const t=this.#n?.format??[4,4,4,4],e=q(this.#a,t);this.#t&&(this.#t.value=e),this.#l&&(this.#l.textContent=e)}#D(t){const e=t.target,i=e.value.replace(/\D/g,"").slice(0,4),s=i.length>2?`${i.slice(0,2)}/${i.slice(2)}`:i;e.value=s,this.#o=s,this.#m&&(this.#m.textContent=s||"MM/YY"),this.#h(this.#v),this.#$(),this.#S()}#F(){const t=this.#o.split("/");if(!this.#o){this.#p(this.#v,"Expiry date is required"),this.#i?.setAttribute("aria-invalid","true");return}const e=parseInt(t[0]??"",10),i=parseInt(t[1]??"",10);isNaN(e)||isNaN(i)||e<1||e>12||(t[1]??"").length<2?(this.#p(this.#v,"Enter a valid expiry date (MM/YY)"),this.#i?.setAttribute("aria-invalid","true")):T(e,i)?(this.#h(this.#v),this.#i?.removeAttribute("aria-invalid")):(this.#p(this.#v,"This card has expired"),this.#i?.setAttribute("aria-invalid","true")),this.audit("card-expiry-blur",{})}#Y(t){const e=t.target,i=e.value.replace(/\D/g,""),s=this.#n?.cvcLength??3;this.#c=i.slice(0,s),e.value=this.#c,this.#f&&(this.#f.textContent=this.#c.length>0?"\u2022".repeat(this.#c.length):"\u2022\u2022\u2022"),this.#h(this.#E),this.#S()}#B(){const t=this.#n?.cvcLength??3;this.#c?this.#c.length<t?(this.#p(this.#E,`Security code must be ${t} digits`),this.#e?.setAttribute("aria-invalid","true")):(this.#h(this.#E),this.#e?.removeAttribute("aria-invalid")):(this.#p(this.#E,"Security code is required"),this.#e?.setAttribute("aria-invalid","true")),this.#N(!1),this.audit("card-cvc-blur",{})}#R(t){const e=t.target,i=e.value.replace(/[^a-zA-Z\s\-']/g,"").slice(0,50);e.value=i,this.#x=i,this.#b&&(this.#b.textContent=i.toUpperCase()||"FULL NAME"),this.#h(this.#A),this.#$(),this.#S()}#V(){this.hasAttribute("show-name")&&(this.#x.trim()?(this.#h(this.#A),this.#s?.removeAttribute("aria-invalid")):(this.#p(this.#A,"Cardholder name is required"),this.#s?.setAttribute("aria-invalid","true")))}#N(t){this.#d?.classList.toggle("is-flipped",t)}#T(){if(!this.#d)return;for(const e of $)this.#d.classList.remove(`card--${e.type}`);const t=this.#n?.type;t&&this.#d.classList.add(`card--${t}`),this.#L&&(this.#L.textContent=this.#n?.label??"")}#p(t,e){if(!t)return;t.textContent="";const i=document.createElement("span");i.textContent=e,t.appendChild(i),t.classList.remove("hidden")}#h(t){t&&(t.textContent="",t.classList.add("hidden"))}#G(){const t=this.getAttribute("name");t&&(this.#y=document.createElement("input"),this.#y.type="hidden",this.#y.name=t,this.appendChild(this.#y),this.#C=document.createElement("input"),this.#C.type="hidden",this.#C.name=`${t}-expiry`,this.appendChild(this.#C),this.#g=document.createElement("input"),this.#g.type="hidden",this.#g.name=`${t}-holder`,this.appendChild(this.#g))}#$(){this.#y&&(this.#y.value=this.#a.slice(-4)),this.#C&&(this.#C.value=this.#o),this.#g&&(this.#g.value=this.#x)}#S(){const[t,e]=this.#o.split("/");this.dispatchEvent(new CustomEvent("secure-card",{bubbles:!0,composed:!0,detail:{name:this.getAttribute("name")??"",cardType:this.#n?.type??"unknown",last4:this.#a.slice(-4),expiryMonth:parseInt(t??"0",10)||0,expiryYear:parseInt(e??"0",10)||0,cardholderName:this.#x,valid:this.valid,tier:this.securityTier}}))}disconnectedCallback(){this.#a="",this.#c="",this.#o="",this.#x="",this.#t&&(this.#t.value=""),this.#i&&(this.#i.value=""),this.#e&&(this.#e.value=""),this.#s&&(this.#s.value=""),this.#y&&(this.#y.value=""),this.#C&&(this.#C.value=""),this.#g&&(this.#g.value=""),super.disconnectedCallback()}get valid(){const t=this.#a.length>=12&&S(this.#a),[e,i]=this.#o.split("/"),s=parseInt(e??"",10),r=parseInt(i??"",10),n=!isNaN(s)&&!isNaN(r)&&s>=1&&s<=12&&(i??"").length>=2&&T(s,r),l=this.#n?.cvcLength??3,c=this.#c.length===l,p=!this.hasAttribute("show-name")||this.#x.trim().length>0;return t&&n&&c&&p}get cardType(){return this.#n?.type??"unknown"}get last4(){return this.#a.slice(-4)}get name(){return this.getAttribute("name")??""}getCardData(){return this.valid?{number:this.#a,expiry:this.#o,cvc:this.#c,name:this.#x}:null}reset(){this.#a="",this.#c="",this.#o="",this.#x="",this.#n=null,this.#t&&(this.#t.value=""),this.#i&&(this.#i.value=""),this.#e&&(this.#e.value=""),this.#s&&(this.#s.value=""),this.#$(),this.#N(!1),this.#T();const t=[4,4,4,4];this.#l&&(this.#l.textContent=o("\u2022".repeat(16),t)),this.#m&&(this.#m.textContent="MM/YY"),this.#b&&(this.#b.textContent="FULL NAME"),this.#f&&(this.#f.textContent="\u2022\u2022\u2022"),this.#h(this.#u),this.#h(this.#v),this.#h(this.#E),this.#h(this.#A),this.audit("card-reset",{})}focus(){this.#t?.focus()}}customElements.define("secure-card",F);export{F as SecureCard};
|