@ozura/elements 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +717 -0
  3. package/dist/frame/element-frame.html +22 -0
  4. package/dist/frame/element-frame.js +731 -0
  5. package/dist/frame/element-frame.js.map +1 -0
  6. package/dist/frame/tokenizer-frame.html +11 -0
  7. package/dist/frame/tokenizer-frame.js +328 -0
  8. package/dist/frame/tokenizer-frame.js.map +1 -0
  9. package/dist/oz-elements.esm.js +1190 -0
  10. package/dist/oz-elements.esm.js.map +1 -0
  11. package/dist/oz-elements.umd.js +1202 -0
  12. package/dist/oz-elements.umd.js.map +1 -0
  13. package/dist/react/frame/elementFrame.d.ts +8 -0
  14. package/dist/react/frame/tokenizerFrame.d.ts +13 -0
  15. package/dist/react/index.cjs.js +1407 -0
  16. package/dist/react/index.cjs.js.map +1 -0
  17. package/dist/react/index.esm.js +1400 -0
  18. package/dist/react/index.esm.js.map +1 -0
  19. package/dist/react/react/index.d.ts +214 -0
  20. package/dist/react/sdk/OzElement.d.ts +65 -0
  21. package/dist/react/sdk/OzVault.d.ts +106 -0
  22. package/dist/react/sdk/errors.d.ts +55 -0
  23. package/dist/react/sdk/index.d.ts +5 -0
  24. package/dist/react/server/index.d.ts +140 -0
  25. package/dist/react/types/index.d.ts +432 -0
  26. package/dist/react/utils/appearance.d.ts +13 -0
  27. package/dist/react/utils/billingUtils.d.ts +60 -0
  28. package/dist/react/utils/cardUtils.d.ts +37 -0
  29. package/dist/server/frame/elementFrame.d.ts +8 -0
  30. package/dist/server/frame/tokenizerFrame.d.ts +13 -0
  31. package/dist/server/index.cjs.js +294 -0
  32. package/dist/server/index.cjs.js.map +1 -0
  33. package/dist/server/index.esm.js +290 -0
  34. package/dist/server/index.esm.js.map +1 -0
  35. package/dist/server/sdk/OzElement.d.ts +65 -0
  36. package/dist/server/sdk/OzVault.d.ts +106 -0
  37. package/dist/server/sdk/errors.d.ts +55 -0
  38. package/dist/server/sdk/index.d.ts +5 -0
  39. package/dist/server/server/index.d.ts +140 -0
  40. package/dist/server/types/index.d.ts +432 -0
  41. package/dist/server/utils/appearance.d.ts +13 -0
  42. package/dist/server/utils/billingUtils.d.ts +60 -0
  43. package/dist/server/utils/cardUtils.d.ts +37 -0
  44. package/dist/types/frame/elementFrame.d.ts +8 -0
  45. package/dist/types/frame/tokenizerFrame.d.ts +13 -0
  46. package/dist/types/sdk/OzElement.d.ts +65 -0
  47. package/dist/types/sdk/OzVault.d.ts +106 -0
  48. package/dist/types/sdk/errors.d.ts +55 -0
  49. package/dist/types/sdk/index.d.ts +5 -0
  50. package/dist/types/server/index.d.ts +140 -0
  51. package/dist/types/types/index.d.ts +432 -0
  52. package/dist/types/utils/appearance.d.ts +13 -0
  53. package/dist/types/utils/billingUtils.d.ts +60 -0
  54. package/dist/types/utils/cardUtils.d.ts +37 -0
  55. package/package.json +97 -0
@@ -0,0 +1,731 @@
1
+ (function () {
2
+ 'use strict';
3
+
4
+ /**
5
+ * cardUtils.ts — pure card validation and formatting helpers.
6
+ *
7
+ * These functions contain no DOM or browser dependencies and are shared
8
+ * between the element frame runtime and the test suite.
9
+ */
10
+ // ─── Luhn algorithm ──────────────────────────────────────────────────────────
11
+ /** Returns true when the digit string passes the Luhn checksum. */
12
+ function luhn(digits) {
13
+ let sum = 0;
14
+ let doubled = false;
15
+ for (let i = digits.length - 1; i >= 0; i--) {
16
+ let d = parseInt(digits[i], 10);
17
+ if (doubled) {
18
+ d *= 2;
19
+ if (d > 9)
20
+ d -= 9;
21
+ }
22
+ sum += d;
23
+ doubled = !doubled;
24
+ }
25
+ return sum % 10 === 0;
26
+ }
27
+ /** Detects the card brand from the leading digits. Returns 'unknown' for no match. */
28
+ function detectBrand(num) {
29
+ if (/^4/.test(num))
30
+ return 'visa';
31
+ if (/^5[1-5]|^2[2-7]/.test(num))
32
+ return 'mastercard';
33
+ if (/^3[47]/.test(num))
34
+ return 'amex';
35
+ if (/^3(?:0[0-5]|[68])/.test(num))
36
+ return 'dinersclub';
37
+ if (/^6(?:011|5)/.test(num))
38
+ return 'discover';
39
+ if (/^(?:2131|1800|35\d{3})/.test(num))
40
+ return 'jcb';
41
+ return 'unknown';
42
+ }
43
+ /** Maximum digit length allowed for a given brand. */
44
+ function getCardMaxLength(brand) {
45
+ if (brand === 'amex')
46
+ return 15;
47
+ if (brand === 'dinersclub')
48
+ return 14;
49
+ return 19;
50
+ }
51
+ // ─── Formatting ───────────────────────────────────────────────────────────────
52
+ /**
53
+ * Formats raw card digits into the display string for the given brand.
54
+ * Amex: 4-6-5 grouping. All others: groups of 4.
55
+ */
56
+ function formatCard(digits, brand) {
57
+ if (brand === 'amex') {
58
+ return [digits.slice(0, 4), digits.slice(4, 10), digits.slice(10, 15)]
59
+ .filter(Boolean)
60
+ .join(' ');
61
+ }
62
+ return digits.replace(/(.{4})(?=.)/g, '$1 ');
63
+ }
64
+ /**
65
+ * Validates a raw 4-digit MMYY string (as stored by the element frame).
66
+ * Month is clamped to 01–12 at input time, so only past-date and
67
+ * incomplete checks are needed here.
68
+ */
69
+ function validateExpiry(rawMMYY) {
70
+ const complete = rawMMYY.length === 4;
71
+ if (!complete)
72
+ return { complete: false, valid: false };
73
+ const month = rawMMYY.slice(0, 2);
74
+ const year = rawMMYY.slice(2, 4);
75
+ const m = parseInt(month, 10);
76
+ const y = parseInt(year, 10);
77
+ if (m < 1 || m > 12) {
78
+ return { complete: true, valid: false, month, year, error: 'Invalid or expired date' };
79
+ }
80
+ const now = new Date();
81
+ const cy = now.getFullYear() % 100;
82
+ const cm = now.getMonth() + 1;
83
+ if (y < cy || (y === cy && m < cm)) {
84
+ return { complete: true, valid: false, month, year, error: 'Invalid or expired date' };
85
+ }
86
+ return { complete: true, valid: true, month, year };
87
+ }
88
+
89
+ /**
90
+ * elementFrame.ts — runs inside an <iframe> served from Ozura's domain.
91
+ *
92
+ * Renders a single card or bank input, manages formatting/validation, and
93
+ * communicates with the host SDK via postMessage. Raw values NEVER leave this
94
+ * file except when sent directly to the tokenizer frame (same-origin).
95
+ */
96
+ /**
97
+ * Validates a US ABA routing number using the standard checksum algorithm.
98
+ * The checksum is: (3*(d1+d4+d7) + 7*(d2+d5+d8) + 1*(d3+d6+d9)) mod 10 === 0
99
+ */
100
+ function validateRoutingNumber(routing) {
101
+ if (!/^\d{9}$/.test(routing))
102
+ return false;
103
+ const d = routing.split('').map(Number);
104
+ const sum = 3 * (d[0] + d[3] + d[6]) + 7 * (d[1] + d[4] + d[7]) + (d[2] + d[5] + d[8]);
105
+ return sum % 10 === 0;
106
+ }
107
+ const ALLOWED_STYLE_PROPERTIES = new Set([
108
+ // Typography
109
+ 'color', 'fontSize', 'fontFamily', 'fontWeight', 'fontStyle', 'fontVariant',
110
+ 'fontSmoothing', 'webkitFontSmoothing', 'mozOsxFontSmoothing',
111
+ 'letterSpacing', 'lineHeight', 'textAlign', 'textDecoration', 'textShadow',
112
+ 'textTransform',
113
+ // Spacing
114
+ 'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
115
+ // Background
116
+ 'backgroundColor', 'opacity',
117
+ // Border
118
+ 'border', 'borderColor', 'borderWidth', 'borderStyle', 'borderRadius',
119
+ 'borderTop', 'borderRight', 'borderBottom', 'borderLeft',
120
+ 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor',
121
+ 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
122
+ 'borderTopStyle', 'borderRightStyle', 'borderBottomStyle', 'borderLeftStyle',
123
+ 'borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius',
124
+ // Box shadow & outline
125
+ 'boxShadow', 'outline', 'outlineColor', 'outlineWidth', 'outlineStyle', 'outlineOffset',
126
+ // Cursor & caret
127
+ 'cursor', 'caretColor',
128
+ // Sizing
129
+ 'height', 'minHeight', 'maxHeight',
130
+ // Transition
131
+ 'transition',
132
+ ]);
133
+ const BLOCKED_VALUE_PATTERNS = /url\s*\(|expression\s*\(|javascript\s*:|vbscript\s*:|@import|behavior\s*:|binding\s*:|-moz-binding|-webkit-binding|<\s*script|<\s*style|\\[0-9a-fA-F]/i;
134
+ const MAX_STYLE_VALUE_LENGTH = 200;
135
+ const MAX_PLACEHOLDER_LENGTH = 100;
136
+ /** Matches `url('https://...')` or `url(https://...)` with optional `format('woff2')`. */
137
+ const FONT_SRC_PATTERN = /^url\(\s*['"]?https:\/\/[^\s'")<>]+['"]?\s*\)(\s+format\(\s*['"][a-z0-9]+['"]\s*\))?(,\s*url\(\s*['"]?https:\/\/[^\s'")<>]+['"]?\s*\)(\s+format\(\s*['"][a-z0-9]+['"]\s*\))?)*$/i;
138
+ const CSS_BREAKOUT = /[{};<>]/;
139
+ function sanitizeCssString(value, maxLen) {
140
+ const trimmed = value.slice(0, maxLen);
141
+ return trimmed.replace(/['"\\]/g, '');
142
+ }
143
+ function sanitizeCssToken(value, maxLen = 40) {
144
+ const trimmed = value.slice(0, maxLen);
145
+ if (CSS_BREAKOUT.test(trimmed))
146
+ return '';
147
+ return trimmed;
148
+ }
149
+ // ─── Brand icons ──────────────────────────────────────────────────────────────
150
+ function getBrandSvg(brand) {
151
+ switch (brand) {
152
+ case 'visa':
153
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 38 24" width="38" height="24"><rect width="38" height="24" rx="3" fill="#1a1f71"/><text x="19" y="17" font-family="Arial,sans-serif" font-size="11" font-weight="800" fill="white" text-anchor="middle" letter-spacing="1.5">VISA</text></svg>`;
154
+ case 'mastercard':
155
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 38 24" width="38" height="24"><rect width="38" height="24" rx="3" fill="#252525"/><circle cx="15" cy="12" r="7.5" fill="#eb001b"/><circle cx="23" cy="12" r="7.5" fill="#f79e1b"/><path d="M19 5.9a7.5 7.5 0 0 1 0 12.2A7.5 7.5 0 0 1 19 5.9z" fill="#ff5f00"/></svg>`;
156
+ case 'amex':
157
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 38 24" width="38" height="24"><rect width="38" height="24" rx="3" fill="#2557d6"/><text x="19" y="12" font-family="Arial,sans-serif" font-size="6" font-weight="700" fill="white" text-anchor="middle" letter-spacing=".4">AMERICAN</text><text x="19" y="20" font-family="Arial,sans-serif" font-size="6" font-weight="700" fill="white" text-anchor="middle" letter-spacing=".4">EXPRESS</text></svg>`;
158
+ case 'discover':
159
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 38 24" width="38" height="24"><rect width="38" height="24" rx="3" fill="#fff" stroke="#e5e7eb" stroke-width="1"/><ellipse cx="29" cy="12" rx="11" ry="12" fill="#f76f20"/><text x="10" y="16" font-family="Arial,sans-serif" font-size="6" font-weight="700" fill="#231f20" text-anchor="middle">DIS</text></svg>`;
160
+ case 'dinersclub':
161
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 38 24" width="38" height="24"><rect width="38" height="24" rx="3" fill="#fff" stroke="#e5e7eb" stroke-width="1"/><circle cx="16" cy="12" r="7" fill="none" stroke="#231f20" stroke-width="1.5"/><circle cx="22" cy="12" r="7" fill="none" stroke="#231f20" stroke-width="1.5"/></svg>`;
162
+ case 'jcb':
163
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 38 24" width="38" height="24"><rect width="38" height="24" rx="3" fill="#fff" stroke="#e5e7eb" stroke-width="1"/><rect x="6" y="3" width="8" height="18" rx="3" fill="#003087"/><rect x="15" y="3" width="8" height="18" rx="3" fill="#cc0000"/><rect x="24" y="3" width="8" height="18" rx="3" fill="#009f6b"/><text x="10" y="16" font-family="Arial,sans-serif" font-size="8" font-weight="700" fill="white" text-anchor="middle">J</text><text x="19" y="16" font-family="Arial,sans-serif" font-size="8" font-weight="700" fill="white" text-anchor="middle">C</text><text x="28" y="16" font-family="Arial,sans-serif" font-size="8" font-weight="700" fill="white" text-anchor="middle">B</text></svg>`;
164
+ default:
165
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 38 24" width="38" height="24"><rect width="38" height="24" rx="3" fill="none" stroke="#d1d5db" stroke-width="1.5" stroke-dasharray="3,2"/><rect x="5" y="8" width="28" height="3" rx="1" fill="#e5e7eb"/><rect x="5" y="14" width="12" height="3" rx="1" fill="#e5e7eb"/></svg>`;
166
+ }
167
+ }
168
+ // ─── Element frame ────────────────────────────────────────────────────────────
169
+ class ElementFrame {
170
+ constructor() {
171
+ this.hostOrigin = '';
172
+ this.tokenizerName = '';
173
+ this.frameOrigin = location.origin;
174
+ this.rawValue = '';
175
+ this.cardBrand = 'unknown';
176
+ this.cvvMaxLength = 3;
177
+ this._composing = false;
178
+ this.iconEl = null;
179
+ this.options = {};
180
+ this.placeholderStyleEl = null;
181
+ // ─── Font injection ──────────────────────────────────────────────────────
182
+ this.fontsInjected = false;
183
+ const p = new URLSearchParams(location.hash.slice(1));
184
+ this.elementType = p.get('type') || 'cardNumber';
185
+ this.vaultId = p.get('vaultId') || '';
186
+ this.frameId = p.get('frameId') || '';
187
+ const fromUrl = p.get('parentOrigin');
188
+ if (fromUrl)
189
+ this.hostOrigin = fromUrl;
190
+ this.buildDOM();
191
+ window.addEventListener('message', this.onMessage.bind(this));
192
+ this.postToParent({ type: 'OZ_FRAME_READY', frameId: this.frameId });
193
+ }
194
+ // ─── DOM ─────────────────────────────────────────────────────────────────
195
+ buildDOM() {
196
+ document.documentElement.style.cssText = 'height:100%;background:transparent;';
197
+ document.body.style.cssText = 'margin:0;padding:0;background:transparent;overflow:hidden;height:100%;';
198
+ this.containerEl = document.createElement('div');
199
+ this.containerEl.style.cssText = 'width:100%;height:100%;box-sizing:border-box;display:flex;align-items:center;';
200
+ this.inputEl = document.createElement('input');
201
+ this.inputEl.type = this.getInputType();
202
+ this.inputEl.autocomplete = this.getAutocomplete();
203
+ this.inputEl.placeholder = this.getDefaultPlaceholder();
204
+ this.inputEl.setAttribute('aria-label', this.getAriaLabel());
205
+ this.inputEl.setAttribute('aria-required', 'true');
206
+ this.inputEl.setAttribute('aria-invalid', 'false');
207
+ this.inputEl.setAttribute('aria-describedby', 'oz-error');
208
+ // Card number gets flex:1 to leave room for the brand icon; others fill full width.
209
+ const inputWidthStyle = this.elementType === 'cardNumber' ? 'flex:1;min-width:0' : 'width:100%';
210
+ this.inputEl.style.cssText = [
211
+ inputWidthStyle,
212
+ 'border:none',
213
+ 'outline:none',
214
+ 'background:transparent',
215
+ 'font-size:16px',
216
+ 'font-family:inherit',
217
+ 'color:#1a1a2e',
218
+ 'padding:12px 14px',
219
+ 'box-sizing:border-box',
220
+ 'line-height:1.5',
221
+ 'cursor:text',
222
+ ].join(';');
223
+ this.inputEl.addEventListener('input', this.onInput.bind(this));
224
+ this.inputEl.addEventListener('compositionstart', () => { this._composing = true; });
225
+ this.inputEl.addEventListener('compositionend', () => {
226
+ this._composing = false;
227
+ this.onInput({ target: this.inputEl });
228
+ });
229
+ this.inputEl.addEventListener('focus', this.onFocus.bind(this));
230
+ this.inputEl.addEventListener('blur', this.onBlur.bind(this));
231
+ this.inputEl.addEventListener('keydown', this.onKeydown.bind(this));
232
+ this.containerEl.appendChild(this.inputEl);
233
+ // Brand icon — only for card number element
234
+ if (this.elementType === 'cardNumber') {
235
+ this.iconEl = document.createElement('div');
236
+ this.iconEl.style.cssText = 'flex-shrink:0;display:flex;align-items:center;padding-right:10px;opacity:0.9;';
237
+ this.iconEl.innerHTML = getBrandSvg('unknown');
238
+ this.containerEl.appendChild(this.iconEl);
239
+ }
240
+ this.liveRegion = document.createElement('span');
241
+ this.liveRegion.id = 'oz-error';
242
+ this.liveRegion.setAttribute('aria-live', 'polite');
243
+ this.liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';
244
+ this.containerEl.appendChild(this.liveRegion);
245
+ document.body.appendChild(this.containerEl);
246
+ }
247
+ getInputType() {
248
+ // CVV and account number use password type to prevent shoulder surfing
249
+ return (this.elementType === 'cvv' || this.elementType === 'accountNumber') ? 'password' : 'tel';
250
+ }
251
+ getAutocomplete() {
252
+ if (this.elementType === 'cardNumber')
253
+ return 'cc-number';
254
+ if (this.elementType === 'cvv')
255
+ return 'cc-csc';
256
+ if (this.elementType === 'expirationDate')
257
+ return 'cc-exp';
258
+ return 'off';
259
+ }
260
+ getDefaultPlaceholder() {
261
+ if (this.elementType === 'cardNumber')
262
+ return '1234 5678 9012 3456';
263
+ if (this.elementType === 'cvv')
264
+ return 'CVV';
265
+ if (this.elementType === 'expirationDate')
266
+ return 'MM / YY';
267
+ if (this.elementType === 'accountNumber')
268
+ return 'Account number';
269
+ if (this.elementType === 'routingNumber')
270
+ return 'Routing number';
271
+ return '';
272
+ }
273
+ getAriaLabel() {
274
+ if (this.elementType === 'cardNumber')
275
+ return 'Card number';
276
+ if (this.elementType === 'cvv')
277
+ return 'Security code';
278
+ if (this.elementType === 'expirationDate')
279
+ return 'Expiration date';
280
+ if (this.elementType === 'accountNumber')
281
+ return 'Bank account number';
282
+ if (this.elementType === 'routingNumber')
283
+ return 'Routing number';
284
+ return 'Input';
285
+ }
286
+ // ─── Input handling ───────────────────────────────────────────────────────
287
+ onInput(e) {
288
+ var _a;
289
+ if (this._composing)
290
+ return;
291
+ const target = e.target;
292
+ if (this.elementType === 'cardNumber') {
293
+ const maxLen = getCardMaxLength(this.cardBrand);
294
+ const digits = target.value.replace(/\D/g, '').slice(0, maxLen);
295
+ this.rawValue = digits;
296
+ this.cardBrand = detectBrand(digits);
297
+ const formatted = formatCard(digits, this.cardBrand);
298
+ if (target.value !== formatted) {
299
+ // Track how many real digits precede the cursor in the browser-updated
300
+ // string, then find where those same digits land in the reformatted
301
+ // string. This keeps the cursor on the correct digit even when spaces
302
+ // are inserted or removed between groups.
303
+ const pos = (_a = target.selectionStart) !== null && _a !== void 0 ? _a : formatted.length;
304
+ const digitsBeforeCursor = target.value.substring(0, pos).replace(/\D/g, '').length;
305
+ target.value = formatted;
306
+ let newPos = formatted.length;
307
+ if (digitsBeforeCursor === 0) {
308
+ newPos = 0;
309
+ }
310
+ else {
311
+ let seen = 0;
312
+ for (let i = 0; i < formatted.length; i++) {
313
+ if (formatted[i] !== ' ') {
314
+ seen++;
315
+ if (seen === digitsBeforeCursor) {
316
+ newPos = i + 1;
317
+ break;
318
+ }
319
+ }
320
+ }
321
+ }
322
+ try {
323
+ target.setSelectionRange(newPos, newPos);
324
+ }
325
+ catch ( /* ignore */_b) { /* ignore */ }
326
+ }
327
+ this.updateBrandIcon(this.cardBrand);
328
+ }
329
+ else if (this.elementType === 'cvv') {
330
+ const digits = target.value.replace(/\D/g, '').slice(0, this.cvvMaxLength);
331
+ this.rawValue = digits;
332
+ if (target.value !== digits)
333
+ target.value = digits;
334
+ }
335
+ else if (this.elementType === 'expirationDate') {
336
+ let digits = target.value.replace(/\D/g, '').slice(0, 4);
337
+ if (digits.length >= 2) {
338
+ const m = parseInt(digits.slice(0, 2), 10);
339
+ if (m === 0)
340
+ digits = '01' + digits.slice(2);
341
+ else if (m > 12)
342
+ digits = '12' + digits.slice(2);
343
+ }
344
+ this.rawValue = digits;
345
+ const formatted = digits.length > 2 ? `${digits.slice(0, 2)} / ${digits.slice(2)}` : digits;
346
+ if (target.value !== formatted)
347
+ target.value = formatted;
348
+ }
349
+ else if (this.elementType === 'accountNumber') {
350
+ // Bank account numbers: 4–17 digits, digits only
351
+ const digits = target.value.replace(/\D/g, '').slice(0, 17);
352
+ this.rawValue = digits;
353
+ if (target.value !== digits)
354
+ target.value = digits;
355
+ }
356
+ else if (this.elementType === 'routingNumber') {
357
+ // US routing numbers are always exactly 9 digits
358
+ const digits = target.value.replace(/\D/g, '').slice(0, 9);
359
+ this.rawValue = digits;
360
+ if (target.value !== digits)
361
+ target.value = digits;
362
+ }
363
+ this.emitChange();
364
+ }
365
+ onKeydown(e) {
366
+ if (this.elementType === 'expirationDate' && e.key === 'Backspace') {
367
+ const input = e.target;
368
+ if (input.value.endsWith(' / ')) {
369
+ e.preventDefault();
370
+ const digits = this.rawValue.slice(0, -1);
371
+ this.rawValue = digits;
372
+ input.value = digits;
373
+ this.emitChange();
374
+ }
375
+ }
376
+ }
377
+ onFocus() {
378
+ var _a;
379
+ if ((_a = this.options.style) === null || _a === void 0 ? void 0 : _a.focus)
380
+ this.applyStyles(this.options.style.focus);
381
+ this.postToParent({ type: 'OZ_FOCUS', frameId: this.frameId });
382
+ }
383
+ onBlur() {
384
+ var _a, _b, _c, _d;
385
+ if ((_a = this.options.style) === null || _a === void 0 ? void 0 : _a.base)
386
+ this.applyStyles(this.options.style.base);
387
+ const valid = this.isValid();
388
+ const complete = this.isComplete();
389
+ const empty = this.rawValue.length === 0;
390
+ const invalid = !valid && !empty;
391
+ if (invalid && ((_b = this.options.style) === null || _b === void 0 ? void 0 : _b.invalid)) {
392
+ this.applyStyles(this.options.style.invalid);
393
+ }
394
+ else if (valid && ((_c = this.options.style) === null || _c === void 0 ? void 0 : _c.complete)) {
395
+ this.applyStyles(this.options.style.complete);
396
+ }
397
+ this.inputEl.setAttribute('aria-invalid', String(invalid));
398
+ if (invalid) {
399
+ this.liveRegion.textContent = (_d = this.getError()) !== null && _d !== void 0 ? _d : '';
400
+ }
401
+ this.postToParent({
402
+ type: 'OZ_BLUR',
403
+ frameId: this.frameId,
404
+ empty,
405
+ complete,
406
+ valid,
407
+ error: this.getError(),
408
+ });
409
+ }
410
+ // ─── Brand icon ───────────────────────────────────────────────────────────
411
+ updateBrandIcon(brand) {
412
+ if (this.iconEl) {
413
+ this.iconEl.innerHTML = getBrandSvg(brand);
414
+ }
415
+ }
416
+ // ─── Validation ───────────────────────────────────────────────────────────
417
+ isComplete() {
418
+ if (this.elementType === 'cardNumber') {
419
+ const max = getCardMaxLength(this.cardBrand);
420
+ const min = this.cardBrand === 'amex' || this.cardBrand === 'dinersclub' ? max : 13;
421
+ return this.rawValue.length >= min && this.rawValue.length <= max;
422
+ }
423
+ if (this.elementType === 'cvv')
424
+ return this.rawValue.length === this.cvvMaxLength;
425
+ if (this.elementType === 'expirationDate')
426
+ return this.rawValue.length === 4;
427
+ if (this.elementType === 'accountNumber')
428
+ return this.rawValue.length >= 4 && this.rawValue.length <= 17;
429
+ if (this.elementType === 'routingNumber')
430
+ return this.rawValue.length === 9;
431
+ return false;
432
+ }
433
+ isValid() {
434
+ if (!this.isComplete())
435
+ return false;
436
+ if (this.elementType === 'cardNumber')
437
+ return luhn(this.rawValue);
438
+ if (this.elementType === 'cvv')
439
+ return /^\d{3,4}$/.test(this.rawValue);
440
+ if (this.elementType === 'expirationDate')
441
+ return validateExpiry(this.rawValue).valid;
442
+ if (this.elementType === 'accountNumber')
443
+ return /^\d{4,17}$/.test(this.rawValue);
444
+ if (this.elementType === 'routingNumber')
445
+ return validateRoutingNumber(this.rawValue);
446
+ return false;
447
+ }
448
+ getError() {
449
+ var _a;
450
+ if (this.rawValue.length === 0)
451
+ return undefined;
452
+ if (this.isValid())
453
+ return undefined;
454
+ if (this.elementType === 'cardNumber') {
455
+ if (!this.isComplete())
456
+ return undefined;
457
+ return 'Invalid card number';
458
+ }
459
+ if (this.elementType === 'expirationDate') {
460
+ if (!this.isComplete())
461
+ return undefined;
462
+ return (_a = validateExpiry(this.rawValue).error) !== null && _a !== void 0 ? _a : 'Invalid or expired date';
463
+ }
464
+ if (this.elementType === 'cvv') {
465
+ if (!this.isComplete())
466
+ return undefined;
467
+ return 'Invalid CVV';
468
+ }
469
+ if (this.elementType === 'accountNumber') {
470
+ if (!this.isComplete())
471
+ return undefined;
472
+ return 'Invalid account number';
473
+ }
474
+ if (this.elementType === 'routingNumber') {
475
+ if (!this.isComplete())
476
+ return undefined;
477
+ return 'Invalid routing number';
478
+ }
479
+ return undefined;
480
+ }
481
+ emitChange() {
482
+ const error = this.getError();
483
+ const isInvalid = error !== undefined;
484
+ this.inputEl.setAttribute('aria-invalid', String(isInvalid));
485
+ this.liveRegion.textContent = error !== null && error !== void 0 ? error : '';
486
+ const payload = {
487
+ type: 'OZ_CHANGE',
488
+ frameId: this.frameId,
489
+ empty: this.rawValue.length === 0,
490
+ complete: this.isComplete(),
491
+ valid: this.isValid(),
492
+ error,
493
+ };
494
+ if (this.elementType === 'cardNumber') {
495
+ payload.cardBrand = this.cardBrand;
496
+ }
497
+ if (this.elementType === 'expirationDate' && this.rawValue.length >= 2) {
498
+ payload.month = this.rawValue.slice(0, 2);
499
+ payload.year = this.rawValue.slice(2, 4);
500
+ }
501
+ this.postToParent(payload);
502
+ }
503
+ // ─── postMessage handling ─────────────────────────────────────────────────
504
+ onMessage(event) {
505
+ const msg = event.data;
506
+ if (!msg || msg.__oz !== true || msg.vaultId !== this.vaultId)
507
+ return;
508
+ if (!this.hostOrigin) {
509
+ this.hostOrigin = event.origin;
510
+ }
511
+ else if (event.origin !== this.hostOrigin) {
512
+ return;
513
+ }
514
+ switch (msg.type) {
515
+ case 'OZ_INIT':
516
+ this.applyOptions(msg.options);
517
+ if (msg.fonts)
518
+ this.injectFonts(msg.fonts);
519
+ break;
520
+ case 'OZ_UPDATE':
521
+ this.applyOptions(msg.options);
522
+ break;
523
+ case 'OZ_CLEAR':
524
+ this.rawValue = '';
525
+ this.inputEl.value = '';
526
+ this.cardBrand = 'unknown';
527
+ if (this.elementType === 'cardNumber')
528
+ this.updateBrandIcon('unknown');
529
+ this.emitChange();
530
+ break;
531
+ case 'OZ_FOCUS_REQUEST':
532
+ this.inputEl.focus();
533
+ break;
534
+ case 'OZ_BLUR_REQUEST':
535
+ this.inputEl.blur();
536
+ break;
537
+ case 'OZ_SET_CVV_LENGTH':
538
+ if (this.elementType === 'cvv') {
539
+ this.cvvMaxLength = msg.length;
540
+ if (this.rawValue.length > this.cvvMaxLength) {
541
+ this.rawValue = this.rawValue.slice(0, this.cvvMaxLength);
542
+ this.inputEl.value = this.rawValue;
543
+ this.emitChange();
544
+ }
545
+ }
546
+ break;
547
+ case 'OZ_SET_TOKENIZER_NAME':
548
+ this.tokenizerName = msg.tokenizerName;
549
+ break;
550
+ case 'OZ_BEGIN_COLLECT':
551
+ this.sendValueToTokenizer(msg.requestId);
552
+ break;
553
+ }
554
+ }
555
+ applyOptions(options) {
556
+ var _a, _b, _c, _d;
557
+ if (!options)
558
+ return;
559
+ const styleBefore = this.options.style;
560
+ this.options = Object.assign(Object.assign({}, this.options), options);
561
+ // Don't clobber existing style with undefined (e.g. partial update like { placeholder: 'new' }).
562
+ if (options.style === undefined && styleBefore !== undefined) {
563
+ this.options.style = styleBefore;
564
+ }
565
+ if (options.placeholder !== undefined) {
566
+ const safe = String(options.placeholder).slice(0, MAX_PLACEHOLDER_LENGTH);
567
+ this.inputEl.placeholder = safe;
568
+ }
569
+ if (options.disabled !== undefined)
570
+ this.inputEl.disabled = options.disabled;
571
+ if ((_a = options.style) === null || _a === void 0 ? void 0 : _a.base) {
572
+ this.applyStyles(options.style.base);
573
+ // Apply same background to container and icon so the whole field (including card logo area) matches
574
+ const bg = (_b = options.style.base) === null || _b === void 0 ? void 0 : _b.backgroundColor;
575
+ if (bg !== undefined && bg !== '') {
576
+ this.containerEl.style.backgroundColor = bg;
577
+ if (this.iconEl)
578
+ this.iconEl.style.backgroundColor = bg;
579
+ }
580
+ else {
581
+ this.containerEl.style.backgroundColor = '';
582
+ if (this.iconEl)
583
+ this.iconEl.style.backgroundColor = '';
584
+ }
585
+ }
586
+ if ((_c = options.style) === null || _c === void 0 ? void 0 : _c.placeholder)
587
+ this.applyPlaceholderStyles(options.style.placeholder);
588
+ // Re-apply focus styles when the input is focused so caret/color updates take effect immediately
589
+ if (document.activeElement === this.inputEl && ((_d = this.options.style) === null || _d === void 0 ? void 0 : _d.focus)) {
590
+ this.applyStyles(this.options.style.focus);
591
+ }
592
+ this.sendResize();
593
+ }
594
+ applyStyles(styles) {
595
+ Object.entries(styles).forEach(([k, v]) => {
596
+ if (v === undefined)
597
+ return;
598
+ if (!ALLOWED_STYLE_PROPERTIES.has(k))
599
+ return;
600
+ if (typeof v !== 'string')
601
+ return;
602
+ if (v.length > MAX_STYLE_VALUE_LENGTH)
603
+ return;
604
+ if (BLOCKED_VALUE_PATTERNS.test(v))
605
+ return;
606
+ this.inputEl.style[k] = v;
607
+ // Chrome password inputs use -webkit-text-fill-color which overrides color
608
+ if (k === 'color')
609
+ this.inputEl.style.setProperty('-webkit-text-fill-color', v);
610
+ });
611
+ }
612
+ applyPlaceholderStyles(styles) {
613
+ const rules = [];
614
+ let placeholderColor;
615
+ Object.entries(styles).forEach(([k, v]) => {
616
+ if (v === undefined)
617
+ return;
618
+ if (!ALLOWED_STYLE_PROPERTIES.has(k))
619
+ return;
620
+ if (typeof v !== 'string')
621
+ return;
622
+ if (v.length > MAX_STYLE_VALUE_LENGTH)
623
+ return;
624
+ if (BLOCKED_VALUE_PATTERNS.test(v))
625
+ return;
626
+ if (CSS_BREAKOUT.test(v))
627
+ return;
628
+ const cssProp = k.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`);
629
+ rules.push(`${cssProp}: ${v} !important`);
630
+ if (k === 'color')
631
+ placeholderColor = v;
632
+ });
633
+ // So placeholder text is not overridden by input's -webkit-text-fill-color, set it explicitly
634
+ if (placeholderColor)
635
+ rules.push(`-webkit-text-fill-color: ${placeholderColor} !important`);
636
+ if (rules.length === 0)
637
+ return;
638
+ this.inputEl.id = ElementFrame.PLACEHOLDER_INPUT_ID;
639
+ if (this.placeholderStyleEl)
640
+ this.placeholderStyleEl.remove();
641
+ this.placeholderStyleEl = document.createElement('style');
642
+ this.placeholderStyleEl.textContent = `#${ElementFrame.PLACEHOLDER_INPUT_ID}::placeholder { ${rules.join('; ')}; }`;
643
+ document.head.appendChild(this.placeholderStyleEl);
644
+ }
645
+ injectFonts(fonts) {
646
+ if (this.fontsInjected)
647
+ return;
648
+ this.fontsInjected = true;
649
+ for (const font of fonts) {
650
+ if ('cssSrc' in font && typeof font.cssSrc === 'string') {
651
+ const url = font.cssSrc.trim();
652
+ if (!url.startsWith('https://'))
653
+ continue;
654
+ if (BLOCKED_VALUE_PATTERNS.test(url))
655
+ continue;
656
+ const link = document.createElement('link');
657
+ link.rel = 'stylesheet';
658
+ link.href = url;
659
+ document.head.appendChild(link);
660
+ }
661
+ else if ('family' in font && 'src' in font) {
662
+ const family = sanitizeCssString(String(font.family || ''), 100);
663
+ const src = String(font.src || '');
664
+ if (!family || !src)
665
+ continue;
666
+ if (!FONT_SRC_PATTERN.test(src))
667
+ continue;
668
+ const weight = sanitizeCssToken(String(font.weight || '400'));
669
+ const style = sanitizeCssToken(String(font.style || 'normal'));
670
+ const display = sanitizeCssToken(String(font.display || 'swap'));
671
+ const unicodeRange = font.unicodeRange ? `unicode-range: ${sanitizeCssToken(String(font.unicodeRange), 200)};` : '';
672
+ const rule = `@font-face { font-family: '${family}'; src: ${src}; font-weight: ${weight}; font-style: ${style}; font-display: ${display}; ${unicodeRange} }`;
673
+ const styleEl = document.createElement('style');
674
+ styleEl.textContent = rule;
675
+ document.head.appendChild(styleEl);
676
+ }
677
+ }
678
+ }
679
+ // ─── Secure value delivery to tokenizer frame ─────────────────────────────
680
+ sendValueToTokenizer(requestId) {
681
+ if (!this.tokenizerName) {
682
+ console.warn('[OzElement] No tokenizer name set — cannot deliver value.');
683
+ return;
684
+ }
685
+ try {
686
+ const tokenizerWindow = window.parent[this.tokenizerName];
687
+ if (!tokenizerWindow)
688
+ return;
689
+ // Security: only send to a same-origin frame.
690
+ const targetOrigin = tokenizerWindow.location.origin;
691
+ if (targetOrigin !== this.frameOrigin)
692
+ return;
693
+ const fieldMsg = {
694
+ __oz: true,
695
+ vaultId: this.vaultId,
696
+ type: 'OZ_FIELD_VALUE',
697
+ requestId,
698
+ fieldType: this.elementType,
699
+ value: this.rawValue,
700
+ };
701
+ if (this.elementType === 'cardNumber') {
702
+ fieldMsg.last4 = this.rawValue.slice(-4);
703
+ fieldMsg.brand = this.cardBrand;
704
+ }
705
+ else if (this.elementType === 'expirationDate') {
706
+ fieldMsg.expMonth = this.rawValue.slice(0, 2);
707
+ fieldMsg.expYear = `20${this.rawValue.slice(2, 4)}`;
708
+ }
709
+ else if (this.elementType === 'accountNumber') {
710
+ fieldMsg.last4 = this.rawValue.slice(-4);
711
+ }
712
+ tokenizerWindow.postMessage(fieldMsg, this.frameOrigin);
713
+ }
714
+ catch (_a) {
715
+ // Cross-origin or frame not found — silently refuse
716
+ }
717
+ }
718
+ sendResize() {
719
+ const height = this.inputEl.offsetHeight || 46;
720
+ this.postToParent({ type: 'OZ_RESIZE', frameId: this.frameId, height });
721
+ }
722
+ postToParent(data) {
723
+ const msg = Object.assign({ __oz: true, vaultId: this.vaultId }, data);
724
+ window.parent.postMessage(msg, this.hostOrigin || window.location.origin);
725
+ }
726
+ }
727
+ ElementFrame.PLACEHOLDER_INPUT_ID = 'oz-element-input';
728
+ new ElementFrame();
729
+
730
+ })();
731
+ //# sourceMappingURL=element-frame.js.map