@ozura/elements 0.1.0-beta.6 → 1.0.0

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 (50) hide show
  1. package/README.md +1121 -720
  2. package/dist/frame/element-frame.js +77 -57
  3. package/dist/frame/element-frame.js.map +1 -1
  4. package/dist/frame/tokenizer-frame.html +1 -1
  5. package/dist/frame/tokenizer-frame.js +221 -74
  6. package/dist/frame/tokenizer-frame.js.map +1 -1
  7. package/dist/oz-elements.esm.js +870 -231
  8. package/dist/oz-elements.esm.js.map +1 -1
  9. package/dist/oz-elements.umd.js +870 -230
  10. package/dist/oz-elements.umd.js.map +1 -1
  11. package/dist/react/frame/tokenizerFrame.d.ts +32 -0
  12. package/dist/react/index.cjs.js +1045 -220
  13. package/dist/react/index.cjs.js.map +1 -1
  14. package/dist/react/index.esm.js +1042 -221
  15. package/dist/react/index.esm.js.map +1 -1
  16. package/dist/react/react/index.d.ts +165 -8
  17. package/dist/react/sdk/OzElement.d.ts +34 -3
  18. package/dist/react/sdk/OzVault.d.ts +104 -4
  19. package/dist/react/sdk/errors.d.ts +9 -0
  20. package/dist/react/sdk/index.d.ts +29 -0
  21. package/dist/react/server/index.d.ts +266 -2
  22. package/dist/react/types/index.d.ts +94 -16
  23. package/dist/react/utils/appearance.d.ts +9 -0
  24. package/dist/react/utils/cardUtils.d.ts +14 -0
  25. package/dist/react/utils/uuid.d.ts +12 -0
  26. package/dist/server/frame/tokenizerFrame.d.ts +32 -0
  27. package/dist/server/index.cjs.js +761 -30
  28. package/dist/server/index.cjs.js.map +1 -1
  29. package/dist/server/index.esm.js +757 -31
  30. package/dist/server/index.esm.js.map +1 -1
  31. package/dist/server/sdk/OzElement.d.ts +34 -3
  32. package/dist/server/sdk/OzVault.d.ts +104 -4
  33. package/dist/server/sdk/errors.d.ts +9 -0
  34. package/dist/server/sdk/index.d.ts +29 -0
  35. package/dist/server/server/index.d.ts +266 -2
  36. package/dist/server/types/index.d.ts +94 -16
  37. package/dist/server/utils/appearance.d.ts +9 -0
  38. package/dist/server/utils/cardUtils.d.ts +14 -0
  39. package/dist/server/utils/uuid.d.ts +12 -0
  40. package/dist/types/frame/tokenizerFrame.d.ts +32 -0
  41. package/dist/types/sdk/OzElement.d.ts +34 -3
  42. package/dist/types/sdk/OzVault.d.ts +104 -4
  43. package/dist/types/sdk/errors.d.ts +9 -0
  44. package/dist/types/sdk/index.d.ts +29 -0
  45. package/dist/types/server/index.d.ts +266 -2
  46. package/dist/types/types/index.d.ts +94 -16
  47. package/dist/types/utils/appearance.d.ts +9 -0
  48. package/dist/types/utils/cardUtils.d.ts +14 -0
  49. package/dist/types/utils/uuid.d.ts +12 -0
  50. package/package.json +7 -4
@@ -131,6 +131,15 @@ function mergeStyleConfigs(a, b) {
131
131
  * Resolution order: theme defaults → variable overrides.
132
132
  * The returned config is then used as the "base appearance" that
133
133
  * per-element `style` overrides merge on top of.
134
+ *
135
+ * @remarks
136
+ * - `appearance: undefined` → no styles injected (element iframes use their
137
+ * own minimal built-in defaults).
138
+ * - `appearance: {}` or `appearance: { variables: {...} }` without an explicit
139
+ * `theme` → the `'default'` theme is used as the base. Omitting `theme`
140
+ * does NOT mean "no theme" — it means `theme: 'default'`. To opt out of
141
+ * the preset themes entirely, use per-element `style` overrides without
142
+ * passing an `appearance` prop at all.
134
143
  */
135
144
  function resolveAppearance(appearance) {
136
145
  var _a, _b;
@@ -156,7 +165,117 @@ function mergeAppearanceWithElementStyle(appearance, elementStyle) {
156
165
  return mergeStyleConfigs(appearance, elementStyle);
157
166
  }
158
167
 
159
- const BLOCKED_CSS_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;
168
+ /**
169
+ * errors.ts — error types and normalisation for OzElements.
170
+ *
171
+ * Three normalisation functions:
172
+ * - normalizeVaultError — maps raw vault /tokenize errors to user-facing messages (card flows)
173
+ * - normalizeBankVaultError — maps raw vault /tokenize errors to user-facing messages (bank/ACH flows)
174
+ * - normalizeCardSaleError — maps raw cardSale API errors to user-facing messages
175
+ *
176
+ * Error keys in normalizeCardSaleError are taken directly from checkout's
177
+ * errorMapping.ts so the same error strings produce the same user-facing copy.
178
+ */
179
+ const OZ_ERROR_CODES = new Set(['network', 'timeout', 'auth', 'validation', 'server', 'config', 'unknown']);
180
+ /** Returns true and narrows to OzErrorCode when `value` is a valid member of the union. */
181
+ function isOzErrorCode(value) {
182
+ return typeof value === 'string' && OZ_ERROR_CODES.has(value);
183
+ }
184
+ class OzError extends Error {
185
+ constructor(message, raw, errorCode) {
186
+ super(message);
187
+ this.name = 'OzError';
188
+ this.raw = raw !== null && raw !== void 0 ? raw : message;
189
+ this.errorCode = errorCode !== null && errorCode !== void 0 ? errorCode : 'unknown';
190
+ this.retryable = this.errorCode === 'network' || this.errorCode === 'timeout' || this.errorCode === 'server';
191
+ }
192
+ }
193
+ /** Shared patterns that apply to both card and bank vault errors. */
194
+ function normalizeCommonVaultError(msg) {
195
+ if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('authentication') || msg.includes('forbidden')) {
196
+ return 'Authentication failed. Check your vault API key configuration.';
197
+ }
198
+ if (msg.includes('network') || msg.includes('failed to fetch') || msg.includes('networkerror')) {
199
+ return 'A network error occurred. Please check your connection and try again.';
200
+ }
201
+ if (msg.includes('timeout') || msg.includes('timed out')) {
202
+ return 'The request timed out. Please try again.';
203
+ }
204
+ if (msg.includes('http 5') || msg.includes('500') || msg.includes('502') || msg.includes('503')) {
205
+ return 'A server error occurred. Please try again shortly.';
206
+ }
207
+ return null;
208
+ }
209
+ /**
210
+ * Maps a raw vault /tokenize error string to a user-facing message for card flows.
211
+ * Falls back to the original string if no pattern matches.
212
+ */
213
+ function normalizeVaultError(raw) {
214
+ const msg = raw.toLowerCase();
215
+ if (msg.includes('card number') || msg.includes('invalid card') || msg.includes('luhn')) {
216
+ return 'The card number is invalid. Please check and try again.';
217
+ }
218
+ if (msg.includes('expir')) {
219
+ return 'The card expiration date is invalid or the card has expired.';
220
+ }
221
+ if (msg.includes('cvv') || msg.includes('cvc') || msg.includes('security code')) {
222
+ return 'The CVV code is invalid. Please check and try again.';
223
+ }
224
+ if (msg.includes('insufficient') || msg.includes('funds')) {
225
+ return 'Your card has insufficient funds. Please use a different card.';
226
+ }
227
+ if (msg.includes('declined') || msg.includes('do not honor')) {
228
+ return 'Your card was declined. Please try a different card or contact your bank.';
229
+ }
230
+ const common = normalizeCommonVaultError(msg);
231
+ if (common)
232
+ return common;
233
+ return raw;
234
+ }
235
+ /**
236
+ * Maps a raw vault /tokenize error string to a user-facing message for bank (ACH) flows.
237
+ * Uses bank-specific pattern matching so card-specific messages are never shown for
238
+ * bank errors. Falls back to the original string if no pattern matches.
239
+ */
240
+ function normalizeBankVaultError(raw) {
241
+ const msg = raw.toLowerCase();
242
+ if (msg.includes('account number') || msg.includes('account_number') || msg.includes('invalid account')) {
243
+ return 'The bank account number is invalid. Please check and try again.';
244
+ }
245
+ if (msg.includes('routing number') || msg.includes('routing_number') || msg.includes('invalid routing') || /\baba\b/.test(msg)) {
246
+ return 'The routing number is invalid. Please check and try again.';
247
+ }
248
+ const common = normalizeCommonVaultError(msg);
249
+ if (common)
250
+ return common;
251
+ return raw;
252
+ }
253
+
254
+ /**
255
+ * Generates a RFC 4122 v4 UUID.
256
+ *
257
+ * Uses `crypto.randomUUID()` when available (Chrome 92+, Firefox 95+,
258
+ * Safari 15.4+, Node 14.17+) and falls back to `crypto.getRandomValues()`
259
+ * for older environments (Safari 14, some embedded WebViews, older Node).
260
+ *
261
+ * Both paths use the same CSPRNG — the difference is only in API surface.
262
+ *
263
+ * @internal
264
+ */
265
+ function uuid() {
266
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
267
+ return crypto.randomUUID();
268
+ }
269
+ // Fallback: build UUID v4 from random bytes
270
+ const bytes = new Uint8Array(16);
271
+ crypto.getRandomValues(bytes);
272
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
273
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant RFC 4122
274
+ const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
275
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
276
+ }
277
+
278
+ const BLOCKED_CSS_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]|var\s*\(/i;
160
279
  const CSS_BREAKOUT = /[{};<>]/;
161
280
  const MAX_CSS_VALUE_LEN = 200;
162
281
  function sanitizeStyleObj(obj) {
@@ -188,6 +307,11 @@ function sanitizeStyles(style) {
188
307
  function sanitizeOptions(options) {
189
308
  var _a;
190
309
  const result = Object.assign(Object.assign({}, options), { placeholder: (_a = options.placeholder) === null || _a === void 0 ? void 0 : _a.slice(0, 100) });
310
+ // Coerce to boolean so a string "false" (truthy in JS) does not accidentally
311
+ // disable the input when the SDK is consumed from plain JavaScript.
312
+ if (options.disabled !== undefined) {
313
+ result.disabled = Boolean(options.disabled);
314
+ }
191
315
  // Only set style when provided; omitting it avoids clobbering existing style
192
316
  // when merging (e.g. update({ placeholder: 'new' }) must not overwrite style with undefined).
193
317
  if (options.style !== undefined) {
@@ -215,7 +339,7 @@ class OzElement {
215
339
  this.frameOrigin = new URL(frameBaseUrl).origin;
216
340
  this.fonts = fonts;
217
341
  this.appearanceStyle = appearanceStyle;
218
- this.frameId = `oz-${elementType}-${Math.random().toString(36).slice(2, 10)}`;
342
+ this.frameId = `oz-${elementType}-${uuid()}`;
219
343
  }
220
344
  /** The element type this proxy represents. */
221
345
  get type() {
@@ -233,22 +357,27 @@ class OzElement {
233
357
  mount(target) {
234
358
  var _a;
235
359
  if (this._destroyed)
236
- throw new Error('OzElements: cannot mount a destroyed element.');
360
+ throw new OzError('Cannot mount a destroyed element.');
237
361
  if (this.iframe)
238
362
  this.unmount();
239
363
  const container = typeof target === 'string'
240
364
  ? document.querySelector(target)
241
365
  : target;
242
366
  if (!container)
243
- throw new Error(typeof target === 'string'
244
- ? `OzElements: mount target not found — no element matches "${target}"`
245
- : `OzElements: mount target not found — the provided HTMLElement is null or undefined`);
367
+ throw new OzError(typeof target === 'string'
368
+ ? `Mount target not found — no element matches "${target}"`
369
+ : 'Mount target not found — the provided HTMLElement is null or undefined');
246
370
  const iframe = document.createElement('iframe');
247
371
  iframe.setAttribute('frameborder', '0');
248
372
  iframe.setAttribute('scrolling', 'no');
249
373
  iframe.setAttribute('allowtransparency', 'true');
250
374
  iframe.style.cssText = 'border:none;width:100%;height:46px;display:block;overflow:hidden;';
251
375
  iframe.title = `Secure ${this.elementType} input`;
376
+ // Note: the `sandbox` attribute is intentionally NOT set. Field values are
377
+ // delivered to the tokenizer iframe via a MessageChannel port (transferred
378
+ // in OZ_BEGIN_COLLECT), so no window.parent named-frame lookup is needed.
379
+ // The security boundary is the vault URL hardcoded at build time and the
380
+ // origin checks on every postMessage, not the sandbox flag.
252
381
  // Use hash instead of query string — survives clean-URL redirects from static servers.
253
382
  // parentOrigin lets the frame target postMessage to the merchant origin instead of '*'.
254
383
  const parentOrigin = typeof window !== 'undefined' ? window.location.origin : '';
@@ -264,6 +393,11 @@ class OzElement {
264
393
  }
265
394
  }, timeout);
266
395
  }
396
+ /**
397
+ * Subscribe to an element event. Returns `this` for chaining.
398
+ * @param event - Event name: `'change'`, `'focus'`, `'blur'`, `'ready'`, or `'loaderror'`.
399
+ * @param callback - Handler invoked with the event payload.
400
+ */
267
401
  on(event, callback) {
268
402
  if (this._destroyed)
269
403
  return this;
@@ -272,6 +406,10 @@ class OzElement {
272
406
  this.handlers.get(event).push(callback);
273
407
  return this;
274
408
  }
409
+ /**
410
+ * Remove a previously registered event handler.
411
+ * Has no effect if the handler is not registered.
412
+ */
275
413
  off(event, callback) {
276
414
  const list = this.handlers.get(event);
277
415
  if (list) {
@@ -281,6 +419,10 @@ class OzElement {
281
419
  }
282
420
  return this;
283
421
  }
422
+ /**
423
+ * Subscribe to an event for a single invocation. The handler is automatically
424
+ * removed after it fires once.
425
+ */
284
426
  once(event, callback) {
285
427
  const wrapper = (payload) => {
286
428
  this.off(event, wrapper);
@@ -288,13 +430,24 @@ class OzElement {
288
430
  };
289
431
  return this.on(event, wrapper);
290
432
  }
433
+ /**
434
+ * Dynamically update element options (placeholder, style, etc.) without
435
+ * re-mounting the iframe. Only the provided keys are merged; omitted keys
436
+ * retain their current values.
437
+ */
291
438
  update(options) {
292
439
  if (this._destroyed)
293
440
  return;
294
441
  const safe = sanitizeOptions(options);
442
+ // Re-merge vault appearance when style is updated so focus/invalid/complete/
443
+ // placeholder buckets from the theme are not stripped by a partial style object.
444
+ if (safe.style !== undefined) {
445
+ safe.style = mergeAppearanceWithElementStyle(this.appearanceStyle, safe.style);
446
+ }
295
447
  this.options = Object.assign(Object.assign({}, this.options), safe);
296
448
  this.post({ type: 'OZ_UPDATE', options: safe });
297
449
  }
450
+ /** Clear the current field value without removing the element from the DOM. */
298
451
  clear() {
299
452
  if (this._destroyed)
300
453
  return;
@@ -307,6 +460,8 @@ class OzElement {
307
460
  */
308
461
  unmount() {
309
462
  var _a;
463
+ if (this._destroyed)
464
+ return;
310
465
  if (this._loadTimer) {
311
466
  clearTimeout(this._loadTimer);
312
467
  this._loadTimer = null;
@@ -339,18 +494,31 @@ class OzElement {
339
494
  this._destroyed = true;
340
495
  }
341
496
  // ─── Called by OzVault ───────────────────────────────────────────────────
342
- setTokenizerName(tokenizerName) {
343
- this.post({ type: 'OZ_SET_TOKENIZER_NAME', tokenizerName });
344
- }
345
- beginCollect(requestId) {
346
- this.post({ type: 'OZ_BEGIN_COLLECT', requestId });
497
+ /**
498
+ * Sends OZ_BEGIN_COLLECT to the element iframe, transferring `port` so the
499
+ * iframe can post its field value directly to the tokenizer without going
500
+ * through the merchant page (no named-window lookup required).
501
+ * @internal
502
+ */
503
+ beginCollect(requestId, port) {
504
+ if (this._destroyed)
505
+ return;
506
+ this.sendWithTransfer({ type: 'OZ_BEGIN_COLLECT', requestId }, [port]);
347
507
  }
348
- /** Tell a CVV element how many digits to expect. Called automatically when card brand changes. */
508
+ /**
509
+ * Tell a CVV element how many digits to expect. Called automatically when card brand changes.
510
+ * @internal
511
+ */
349
512
  setCvvLength(length) {
513
+ if (this._destroyed)
514
+ return;
350
515
  this.post({ type: 'OZ_SET_CVV_LENGTH', length });
351
516
  }
517
+ /** @internal */
352
518
  handleMessage(msg) {
353
519
  var _a, _b;
520
+ if (this._destroyed)
521
+ return;
354
522
  switch (msg.type) {
355
523
  case 'OZ_FRAME_READY': {
356
524
  this._ready = true;
@@ -364,6 +532,19 @@ class OzElement {
364
532
  this.pendingMessages.forEach(m => this.send(m));
365
533
  this.pendingMessages = [];
366
534
  this.emit('ready', undefined);
535
+ // Warn if the mount container collapses to zero height — the input will
536
+ // be invisible but functional, which is hard to debug. Check after one
537
+ // animation frame so the browser has completed layout. Guard against
538
+ // non-rendering environments (jsdom, SSR) where all rects are zero.
539
+ if (typeof requestAnimationFrame !== 'undefined') {
540
+ requestAnimationFrame(() => {
541
+ const inRealBrowser = document.documentElement.getBoundingClientRect().height > 0;
542
+ if (inRealBrowser && this.iframe && this.iframe.getBoundingClientRect().height === 0) {
543
+ console.warn(`[OzElement] "${this.elementType}" mounted but has zero height. ` +
544
+ `Check that the container has a visible height (not overflow:hidden or height:0).`);
545
+ }
546
+ });
547
+ }
367
548
  break;
368
549
  }
369
550
  case 'OZ_CHANGE':
@@ -390,7 +571,12 @@ class OzElement {
390
571
  break;
391
572
  case 'OZ_RESIZE':
392
573
  if (this.iframe) {
393
- this.iframe.style.height = `${msg.height}px`;
574
+ // Clamp to a sensible range to prevent a rogue or compromised frame
575
+ // from zeroing out the input or stretching the layout.
576
+ const h = typeof msg.height === 'number' ? msg.height : 0;
577
+ if (h > 0 && h <= 300) {
578
+ this.iframe.style.height = `${h}px`;
579
+ }
394
580
  }
395
581
  break;
396
582
  }
@@ -398,8 +584,16 @@ class OzElement {
398
584
  // ─── Internal ────────────────────────────────────────────────────────────
399
585
  emit(event, payload) {
400
586
  const list = this.handlers.get(event);
401
- if (list)
402
- [...list].forEach(fn => fn(payload));
587
+ if (!list)
588
+ return;
589
+ [...list].forEach(fn => {
590
+ try {
591
+ fn(payload);
592
+ }
593
+ catch (err) {
594
+ console.error(`[OzElement] Unhandled error in '${event}' listener:`, err);
595
+ }
596
+ });
403
597
  }
404
598
  post(data) {
405
599
  const msg = Object.assign({ __oz: true, vaultId: this.vaultId }, data);
@@ -414,87 +608,18 @@ class OzElement {
414
608
  var _a;
415
609
  (_a = this._frameWindow) === null || _a === void 0 ? void 0 : _a.postMessage(msg, this.frameOrigin);
416
610
  }
417
- }
418
-
419
- /**
420
- * errors.ts — error types and normalisation for OzElements.
421
- *
422
- * Three normalisation functions:
423
- * - normalizeVaultError maps raw vault /tokenize errors to user-facing messages (card flows)
424
- * - normalizeBankVaultError — maps raw vault /tokenize errors to user-facing messages (bank/ACH flows)
425
- * - normalizeCardSaleError — maps raw cardSale API errors to user-facing messages
426
- *
427
- * Error keys in normalizeCardSaleError are taken directly from checkout's
428
- * errorMapping.ts so the same error strings produce the same user-facing copy.
429
- */
430
- class OzError extends Error {
431
- constructor(message, raw, errorCode) {
432
- super(message);
433
- this.name = 'OzError';
434
- this.raw = raw !== null && raw !== void 0 ? raw : message;
435
- this.errorCode = errorCode !== null && errorCode !== void 0 ? errorCode : 'unknown';
436
- this.retryable = this.errorCode === 'network' || this.errorCode === 'timeout' || this.errorCode === 'server';
437
- }
438
- }
439
- /** Shared patterns that apply to both card and bank vault errors. */
440
- function normalizeCommonVaultError(msg) {
441
- if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('authentication') || msg.includes('forbidden')) {
442
- return 'Authentication failed. Check your vault API key configuration.';
443
- }
444
- if (msg.includes('network') || msg.includes('failed to fetch') || msg.includes('networkerror')) {
445
- return 'A network error occurred. Please check your connection and try again.';
446
- }
447
- if (msg.includes('timeout') || msg.includes('timed out')) {
448
- return 'The request timed out. Please try again.';
449
- }
450
- if (msg.includes('http 5') || msg.includes('500') || msg.includes('502') || msg.includes('503')) {
451
- return 'A server error occurred. Please try again shortly.';
452
- }
453
- return null;
454
- }
455
- /**
456
- * Maps a raw vault /tokenize error string to a user-facing message for card flows.
457
- * Falls back to the original string if no pattern matches.
458
- */
459
- function normalizeVaultError(raw) {
460
- const msg = raw.toLowerCase();
461
- if (msg.includes('card number') || msg.includes('invalid card') || msg.includes('luhn')) {
462
- return 'The card number is invalid. Please check and try again.';
463
- }
464
- if (msg.includes('expir')) {
465
- return 'The card expiration date is invalid or the card has expired.';
466
- }
467
- if (msg.includes('cvv') || msg.includes('cvc') || msg.includes('security code')) {
468
- return 'The CVV code is invalid. Please check and try again.';
469
- }
470
- if (msg.includes('insufficient') || msg.includes('funds')) {
471
- return 'Your card has insufficient funds. Please use a different card.';
472
- }
473
- if (msg.includes('declined') || msg.includes('do not honor')) {
474
- return 'Your card was declined. Please try a different card or contact your bank.';
475
- }
476
- const common = normalizeCommonVaultError(msg);
477
- if (common)
478
- return common;
479
- return raw;
480
- }
481
- /**
482
- * Maps a raw vault /tokenize error string to a user-facing message for bank (ACH) flows.
483
- * Uses bank-specific pattern matching so card-specific messages are never shown for
484
- * bank errors. Falls back to the original string if no pattern matches.
485
- */
486
- function normalizeBankVaultError(raw) {
487
- const msg = raw.toLowerCase();
488
- if (msg.includes('account number') || msg.includes('account_number') || msg.includes('invalid account')) {
489
- return 'The bank account number is invalid. Please check and try again.';
490
- }
491
- if (msg.includes('routing number') || msg.includes('routing_number') || msg.includes('invalid routing') || /\baba\b/.test(msg)) {
492
- return 'The routing number is invalid. Please check and try again.';
611
+ /** Posts a message with transferable objects (e.g. MessagePort). Bypasses the
612
+ * pending-message queue — only call when the frame is already ready. */
613
+ sendWithTransfer(data, transfer) {
614
+ if (this._destroyed)
615
+ return;
616
+ if (!this._frameWindow) {
617
+ console.error('[OzElement] sendWithTransfer called before frame window is available port will not be transferred. This is a bug.');
618
+ return;
619
+ }
620
+ const msg = Object.assign({ __oz: true, vaultId: this.vaultId }, data);
621
+ this._frameWindow.postMessage(msg, this.frameOrigin, transfer);
493
622
  }
494
- const common = normalizeCommonVaultError(msg);
495
- if (common)
496
- return common;
497
- return raw;
498
623
  }
499
624
 
500
625
  /**
@@ -546,6 +671,14 @@ const US_STATES = {
546
671
  'south dakota': 'SD', tennessee: 'TN', texas: 'TX', utah: 'UT',
547
672
  vermont: 'VT', virginia: 'VA', washington: 'WA', 'west virginia': 'WV',
548
673
  wisconsin: 'WI', wyoming: 'WY',
674
+ // US territories
675
+ 'puerto rico': 'PR', guam: 'GU', 'virgin islands': 'VI',
676
+ 'us virgin islands': 'VI', 'u.s. virgin islands': 'VI',
677
+ 'american samoa': 'AS', 'northern mariana islands': 'MP',
678
+ 'commonwealth of the northern mariana islands': 'MP',
679
+ // Military / diplomatic addresses
680
+ 'armed forces europe': 'AE', 'armed forces pacific': 'AP',
681
+ 'armed forces americas': 'AA',
549
682
  };
550
683
  const US_ABBREVS = new Set(Object.values(US_STATES));
551
684
  const CA_PROVINCES = {
@@ -654,8 +787,15 @@ function validateBilling(billing) {
654
787
  errors.push('billing.address.line2 must be 1–50 characters if provided');
655
788
  if (!isValidBillingField(city))
656
789
  errors.push('billing.address.city must be 1–50 characters');
657
- if (!isValidBillingField(state))
790
+ if (!isValidBillingField(state)) {
658
791
  errors.push('billing.address.state must be 1–50 characters');
792
+ }
793
+ else if (country === 'US' && !US_ABBREVS.has(state)) {
794
+ errors.push(`billing.address.state "${state}" is not a recognized US state or territory abbreviation (e.g. "CA", "NY", "PR")`);
795
+ }
796
+ else if (country === 'CA' && !CA_ABBREVS.has(state)) {
797
+ errors.push(`billing.address.state "${state}" is not a recognized Canadian province or territory abbreviation (e.g. "ON", "BC", "QC")`);
798
+ }
659
799
  // cardSale backend uses strict enum validation on country — must be exactly 2 uppercase letters
660
800
  if (!/^[A-Z]{2}$/.test(country)) {
661
801
  errors.push('billing.address.country must be a 2-letter ISO 3166-1 alpha-2 code (e.g. "US", "CA", "GB")');
@@ -679,13 +819,31 @@ function validateBilling(billing) {
679
819
  return { valid: errors.length === 0, errors, normalized };
680
820
  }
681
821
 
682
- const DEFAULT_FRAME_BASE_URL = "https://lively-hill-097170c0f.4.azurestaticapps.net";
822
+ function isCardMetadata(v) {
823
+ return !!v && typeof v === 'object' && typeof v.last4 === 'string';
824
+ }
825
+ function isBankAccountMetadata(v) {
826
+ return !!v && typeof v === 'object' && typeof v.last4 === 'string';
827
+ }
828
+ const DEFAULT_FRAME_BASE_URL = "https://elements.ozura.com";
683
829
  /**
684
830
  * The main entry point for OzElements. Creates and manages iframe-based
685
831
  * card input elements that keep raw card data isolated from the merchant page.
686
832
  *
833
+ * Use the static `OzVault.create()` factory — do not call `new OzVault()` directly.
834
+ *
687
835
  * @example
688
- * const vault = new OzVault('your_vault_api_key');
836
+ * const vault = await OzVault.create({
837
+ * pubKey: 'pk_live_...',
838
+ * fetchWaxKey: async (sessionId) => {
839
+ * // Call your backend — which calls ozura.mintWaxKey() from @ozura/elements/server
840
+ * const { waxKey } = await fetch('/api/mint-wax', {
841
+ * method: 'POST',
842
+ * body: JSON.stringify({ sessionId }),
843
+ * }).then(r => r.json());
844
+ * return waxKey;
845
+ * },
846
+ * });
689
847
  * const cardNum = vault.createElement('cardNumber');
690
848
  * cardNum.mount('#card-number');
691
849
  * const { token, cvcSession } = await vault.createToken({
@@ -693,8 +851,14 @@ const DEFAULT_FRAME_BASE_URL = "https://lively-hill-097170c0f.4.azurestaticapps.
693
851
  * });
694
852
  */
695
853
  class OzVault {
696
- constructor(apiKey, options) {
697
- var _a, _b;
854
+ /**
855
+ * Internal constructor — use `OzVault.create()` instead.
856
+ * The constructor mounts the tokenizer iframe immediately so it can start
857
+ * loading in parallel while `fetchWaxKey` is being awaited.
858
+ * @internal
859
+ */
860
+ constructor(options, waxKey, tokenizationSessionId) {
861
+ var _a, _b, _c;
698
862
  this.elements = new Map();
699
863
  this.elementsByType = new Map();
700
864
  this.bankElementsByType = new Map();
@@ -707,24 +871,32 @@ class OzVault {
707
871
  this.tokenizerReady = false;
708
872
  this._tokenizing = null;
709
873
  this._destroyed = false;
874
+ // Tracks successful tokenizations against the per-key call budget so the SDK
875
+ // can proactively refresh the wax key after it has been consumed rather than
876
+ // waiting for the next createToken() call to fail.
877
+ this._tokenizeSuccessCount = 0;
710
878
  this._pendingMount = null;
879
+ this._storedFetchWaxKey = null;
880
+ this._waxRefreshing = null;
711
881
  this.loadErrorTimeoutId = null;
712
- if (!apiKey || !apiKey.trim()) {
713
- throw new OzError('A non-empty vault API key is required. Pass your key as the first argument to new OzVault().');
714
- }
715
- this.apiKey = apiKey;
882
+ // Proactive wax refresh on visibility restore after long idle
883
+ this._hiddenAt = null;
884
+ this.waxKey = waxKey;
885
+ this.tokenizationSessionId = tokenizationSessionId;
716
886
  this.pubKey = options.pubKey;
717
887
  this.frameBaseUrl = options.frameBaseUrl || DEFAULT_FRAME_BASE_URL;
718
888
  this.frameOrigin = new URL(this.frameBaseUrl).origin;
719
889
  this.fonts = (_a = options.fonts) !== null && _a !== void 0 ? _a : [];
720
890
  this.resolvedAppearance = resolveAppearance(options.appearance);
721
- this.vaultId = `vault-${crypto.randomUUID()}`;
722
- this.tokenizerName = `__oz_tok_${this.vaultId}`;
891
+ this.vaultId = `vault-${uuid()}`;
892
+ this._maxTokenizeCalls = (_b = options.maxTokenizeCalls) !== null && _b !== void 0 ? _b : 3;
723
893
  this.boundHandleMessage = this.handleMessage.bind(this);
724
894
  window.addEventListener('message', this.boundHandleMessage);
895
+ this.boundHandleVisibility = this.handleVisibilityChange.bind(this);
896
+ document.addEventListener('visibilitychange', this.boundHandleVisibility);
725
897
  this.mountTokenizerFrame();
726
898
  if (options.onLoadError) {
727
- const timeout = (_b = options.loadTimeoutMs) !== null && _b !== void 0 ? _b : 10000;
899
+ const timeout = (_c = options.loadTimeoutMs) !== null && _c !== void 0 ? _c : 10000;
728
900
  this.loadErrorTimeoutId = setTimeout(() => {
729
901
  this.loadErrorTimeoutId = null;
730
902
  if (!this._destroyed && !this.tokenizerReady) {
@@ -732,15 +904,113 @@ class OzVault {
732
904
  }
733
905
  }, timeout);
734
906
  }
907
+ this._onWaxRefresh = options.onWaxRefresh;
908
+ this._onReady = options.onReady;
909
+ }
910
+ /**
911
+ * Creates and returns a ready `OzVault` instance.
912
+ *
913
+ * Internally this:
914
+ * 1. Generates a `tokenizationSessionId` (UUID).
915
+ * 2. Starts loading the hidden tokenizer iframe immediately.
916
+ * 3. Calls `options.fetchWaxKey(tokenizationSessionId)` concurrently — your
917
+ * backend mints a session-bound wax key from the vault and returns it.
918
+ * 4. Resolves with the vault instance once the wax key is stored. The iframe
919
+ * has been loading the whole time, so `isReady` may already be true or
920
+ * will fire shortly after.
921
+ *
922
+ * The returned vault is ready to create elements immediately. `createToken()`
923
+ * additionally requires `vault.isReady` (tokenizer iframe loaded).
924
+ *
925
+ * @throws {OzError} if `fetchWaxKey` throws, returns a non-string value, or returns an empty/whitespace-only string.
926
+ */
927
+ static async create(options, signal) {
928
+ if (!options.pubKey || !options.pubKey.trim()) {
929
+ throw new OzError('pubKey is required in options. Obtain your public key from the Ozura admin.');
930
+ }
931
+ if (typeof options.fetchWaxKey !== 'function') {
932
+ throw new OzError('fetchWaxKey must be a function. See OzVault.create() docs for the expected signature.');
933
+ }
934
+ const tokenizationSessionId = uuid();
935
+ // Construct the vault immediately — this mounts the tokenizer iframe so it
936
+ // starts loading while fetchWaxKey is in flight. The waxKey field starts
937
+ // empty and is set below before create() returns.
938
+ const vault = new OzVault(options, '', tokenizationSessionId);
939
+ // If the caller provides an AbortSignal (e.g. React useEffect cleanup),
940
+ // destroy the vault immediately on abort so the tokenizer iframe and message
941
+ // listener are removed synchronously rather than waiting for fetchWaxKey to
942
+ // settle. This eliminates the brief double-iframe window in React StrictMode.
943
+ const onAbort = () => vault.destroy();
944
+ signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', onAbort, { once: true });
945
+ let waxKey;
946
+ try {
947
+ waxKey = await options.fetchWaxKey(tokenizationSessionId);
948
+ }
949
+ catch (err) {
950
+ signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', onAbort);
951
+ vault.destroy();
952
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted)
953
+ throw new OzError('OzVault.create() was cancelled.');
954
+ // Preserve errorCode/retryable from OzError (e.g. timeout/network from createFetchWaxKey)
955
+ // so callers can distinguish transient failures from config errors.
956
+ const originalCode = err instanceof OzError ? err.errorCode : undefined;
957
+ const msg = err instanceof Error ? err.message : 'Unknown error';
958
+ throw new OzError(`fetchWaxKey threw an error: ${msg}`, undefined, originalCode);
959
+ }
960
+ signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', onAbort);
961
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
962
+ vault.destroy();
963
+ throw new OzError('OzVault.create() was cancelled.');
964
+ }
965
+ if (typeof waxKey !== 'string' || !waxKey.trim()) {
966
+ vault.destroy();
967
+ throw new OzError('fetchWaxKey must return a non-empty wax key string. Check your mint endpoint.');
968
+ }
969
+ // Static methods can access private fields of instances of the same class.
970
+ vault.waxKey = waxKey;
971
+ vault._storedFetchWaxKey = options.fetchWaxKey;
972
+ // If the tokenizer iframe fired OZ_FRAME_READY before fetchWaxKey resolved,
973
+ // the OZ_INIT sent at that point had an empty waxKey. Send a follow-up now
974
+ // so the tokenizer has the key stored before any createToken() call.
975
+ if (vault.tokenizerReady) {
976
+ vault.sendToTokenizer({ type: 'OZ_INIT', frameId: '__tokenizer__', waxKey });
977
+ }
978
+ return vault;
735
979
  }
736
980
  /**
737
981
  * True once the hidden tokenizer iframe has loaded and signalled ready.
738
982
  * Use this to gate the pay button when building custom UIs without React.
739
983
  * React consumers should use the `ready` value returned by `useOzElements()`.
984
+ *
985
+ * Once `true`, remains `true` for the lifetime of this vault instance.
986
+ * It only reverts to `false` after `vault.destroy()` is called, at which
987
+ * point the vault is unusable and a new instance must be created.
988
+ *
989
+ * @remarks
990
+ * This tracks **tokenizer readiness only** — it says nothing about whether
991
+ * the individual element iframes (card number, CVV, etc.) have loaded.
992
+ * A vault can be `isReady === true` while elements are still mounting.
993
+ * To gate a submit button correctly in vanilla JS, wait for every element's
994
+ * `'ready'` event in addition to this flag. In React, use the `ready` value
995
+ * from `useOzElements()` instead, which combines both checks automatically.
740
996
  */
741
997
  get isReady() {
742
998
  return this.tokenizerReady;
743
999
  }
1000
+ /**
1001
+ * Number of successful tokenize calls made against the current wax key.
1002
+ *
1003
+ * Resets to `0` each time the wax key is refreshed (proactively or reactively).
1004
+ * Useful in vanilla JS integrations to display "attempts remaining" UI.
1005
+ * In React, use `tokenizeCount` from `useOzElements()` instead.
1006
+ *
1007
+ * @example
1008
+ * const remaining = 3 - vault.tokenizeCount;
1009
+ * payButton.textContent = remaining > 0 ? `Pay (${remaining} attempts left)` : 'Pay';
1010
+ */
1011
+ get tokenizeCount() {
1012
+ return this._tokenizeSuccessCount;
1013
+ }
744
1014
  /**
745
1015
  * Creates a new OzElement of the given type. Call `.mount(selector)` on the
746
1016
  * returned element to attach it to the DOM.
@@ -773,7 +1043,10 @@ class OzVault {
773
1043
  }
774
1044
  createElementInto(typeMap, type, options) {
775
1045
  if (this._destroyed) {
776
- throw new OzError('Cannot create elements on a destroyed vault. Create a new OzVault instance.');
1046
+ throw new OzError('Cannot create elements on a destroyed vault. Call await OzVault.create() to get a new instance.');
1047
+ }
1048
+ if (this._tokenizing) {
1049
+ throw new OzError('Cannot create or replace elements while a tokenization is in progress. Wait for the active createToken() / createBankToken() call to settle first.');
777
1050
  }
778
1051
  const existing = typeMap.get(type);
779
1052
  if (existing) {
@@ -805,7 +1078,7 @@ class OzVault {
805
1078
  async createBankToken(options) {
806
1079
  var _a, _b;
807
1080
  if (this._destroyed) {
808
- throw new OzError('Cannot tokenize on a destroyed vault. Create a new OzVault instance.');
1081
+ throw new OzError('Cannot tokenize on a destroyed vault. Call await OzVault.create() to get a new instance.');
809
1082
  }
810
1083
  if (!this.tokenizerReady) {
811
1084
  throw new OzError('Vault not ready. Ensure the page is fully loaded before calling createBankToken.');
@@ -822,10 +1095,10 @@ class OzVault {
822
1095
  throw new OzError('lastName is required for bank account tokenization.');
823
1096
  }
824
1097
  if (options.firstName.trim().length > 50) {
825
- throw new OzError('firstName must be 50 characters or fewer');
1098
+ throw new OzError('firstName must be 50 characters or fewer.');
826
1099
  }
827
1100
  if (options.lastName.trim().length > 50) {
828
- throw new OzError('lastName must be 50 characters or fewer');
1101
+ throw new OzError('lastName must be 50 characters or fewer.');
829
1102
  }
830
1103
  const accountEl = this.bankElementsByType.get('accountNumber');
831
1104
  const routingEl = this.bankElementsByType.get('routingNumber');
@@ -842,30 +1115,47 @@ class OzVault {
842
1115
  }
843
1116
  const readyBankElements = [accountEl, routingEl];
844
1117
  this._tokenizing = 'bank';
845
- const requestId = `req-${Math.random().toString(36).slice(2, 10)}`;
1118
+ const requestId = `req-${uuid()}`;
846
1119
  return new Promise((resolve, reject) => {
847
1120
  const cleanup = () => { this._tokenizing = null; };
848
1121
  this.bankTokenizeResolvers.set(requestId, {
849
1122
  resolve: (v) => { cleanup(); resolve(v); },
850
1123
  reject: (e) => { cleanup(); reject(e); },
851
- });
852
- this.sendToTokenizer({
853
- type: 'OZ_BANK_TOKENIZE',
854
- requestId,
855
- apiKey: this.apiKey,
856
- pubKey: this.pubKey,
857
1124
  firstName: options.firstName.trim(),
858
1125
  lastName: options.lastName.trim(),
1126
+ readyElements: readyBankElements,
859
1127
  fieldCount: readyBankElements.length,
860
1128
  });
861
- readyBankElements.forEach(el => el.beginCollect(requestId));
862
- setTimeout(() => {
863
- if (this.bankTokenizeResolvers.has(requestId)) {
864
- this.bankTokenizeResolvers.delete(requestId);
865
- cleanup();
866
- reject(new OzError('Bank tokenization timed out after 30 seconds', undefined, 'timeout'));
867
- }
868
- }, 30000);
1129
+ try {
1130
+ const bankChannels = readyBankElements.map(() => new MessageChannel());
1131
+ this.sendToTokenizer({
1132
+ type: 'OZ_BANK_TOKENIZE',
1133
+ requestId,
1134
+ waxKey: this.waxKey,
1135
+ tokenizationSessionId: this.tokenizationSessionId,
1136
+ pubKey: this.pubKey,
1137
+ firstName: options.firstName.trim(),
1138
+ lastName: options.lastName.trim(),
1139
+ fieldCount: readyBankElements.length,
1140
+ }, bankChannels.map(ch => ch.port1));
1141
+ readyBankElements.forEach((el, i) => el.beginCollect(requestId, bankChannels[i].port2));
1142
+ const bankTimeoutId = setTimeout(() => {
1143
+ if (this.bankTokenizeResolvers.has(requestId)) {
1144
+ this.bankTokenizeResolvers.delete(requestId);
1145
+ this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId });
1146
+ cleanup();
1147
+ reject(new OzError('Bank tokenization timed out after 30 seconds', undefined, 'timeout'));
1148
+ }
1149
+ }, 30000);
1150
+ const bankPendingEntry = this.bankTokenizeResolvers.get(requestId);
1151
+ if (bankPendingEntry)
1152
+ bankPendingEntry.timeoutId = bankTimeoutId;
1153
+ }
1154
+ catch (err) {
1155
+ this.bankTokenizeResolvers.delete(requestId);
1156
+ cleanup();
1157
+ reject(err instanceof OzError ? err : new OzError('Bank tokenization failed to start'));
1158
+ }
869
1159
  });
870
1160
  }
871
1161
  /**
@@ -877,7 +1167,7 @@ class OzVault {
877
1167
  async createToken(options = {}) {
878
1168
  var _a, _b;
879
1169
  if (this._destroyed) {
880
- throw new OzError('Cannot tokenize on a destroyed vault. Create a new OzVault instance.');
1170
+ throw new OzError('Cannot tokenize on a destroyed vault. Call await OzVault.create() to get a new instance.');
881
1171
  }
882
1172
  if (!this.tokenizerReady) {
883
1173
  throw new OzError('Vault not ready. Ensure the page is fully loaded before calling createToken.');
@@ -887,29 +1177,33 @@ class OzVault {
887
1177
  ? 'A bank tokenization is already in progress. Wait for it to complete before calling createToken().'
888
1178
  : 'A card tokenization is already in progress. Wait for it to complete before calling createToken() again.');
889
1179
  }
890
- this._tokenizing = 'card';
891
- const requestId = `req-${Math.random().toString(36).slice(2, 10)}`;
892
- // Only include card elements whose iframes have actually loaded an element
893
- // created but never mounted will never send OZ_FIELD_VALUE, which would
894
- // cause the tokenizer to hang waiting for a value that never arrives.
895
- // Explicitly exclude bank elements (accountNumber/routingNumber) that share
896
- // the same this.elements map; including them would inflate fieldCount and
897
- // allow the accountNumber iframe's last4 to overwrite cardMeta.last4.
898
- const cardElements = new Set(this.elementsByType.values());
899
- const readyElements = [...this.elements.values()].filter(el => el.isReady && cardElements.has(el));
900
- if (readyElements.length === 0) {
901
- this._tokenizing = null;
902
- throw new OzError('No elements are mounted and ready. Mount at least one element before calling createToken.');
1180
+ // All synchronous validation runs before _tokenizing is set so these throws
1181
+ // need no manual cleanup — _tokenizing is still null when they fire.
1182
+ // Collect all card elements that have been created (mounted or not) so we
1183
+ // can require every created field to be ready before proceeding. An element
1184
+ // that was created but whose iframe hasn't loaded yet will never send
1185
+ // OZ_FIELD_VALUE tokenizing without it would silently submit an empty or
1186
+ // incomplete card and produce an opaque vault rejection.
1187
+ // Bank elements (accountNumber/routingNumber) share this.elements but live
1188
+ // in elementsByType under bank-only keys, so they are excluded by the Set.
1189
+ const cardElements = [...this.elementsByType.values()];
1190
+ if (cardElements.length === 0) {
1191
+ throw new OzError('No card elements have been created. Call vault.createElement() for each field before calling createToken.');
1192
+ }
1193
+ const notReady = cardElements.filter(el => !el.isReady);
1194
+ if (notReady.length > 0) {
1195
+ throw new OzError(`Not all elements are ready. Wait for all fields to finish loading before calling createToken. ` +
1196
+ `Not yet ready: ${notReady.map(el => el.type).join(', ')}.`);
903
1197
  }
1198
+ const readyElements = cardElements;
904
1199
  // Validate billing details if provided and extract firstName/lastName for the vault payload.
905
1200
  // billing.firstName/lastName take precedence over the deprecated top-level params.
906
1201
  let normalizedBilling;
907
- let firstName = (_a = options.firstName) !== null && _a !== void 0 ? _a : '';
908
- let lastName = (_b = options.lastName) !== null && _b !== void 0 ? _b : '';
1202
+ let firstName = ((_a = options.firstName) !== null && _a !== void 0 ? _a : '').trim();
1203
+ let lastName = ((_b = options.lastName) !== null && _b !== void 0 ? _b : '').trim();
909
1204
  if (options.billing) {
910
1205
  const result = validateBilling(options.billing);
911
1206
  if (!result.valid) {
912
- this._tokenizing = null;
913
1207
  throw new OzError(`Invalid billing details: ${result.errors.join('; ')}`);
914
1208
  }
915
1209
  normalizedBilling = result.normalized;
@@ -918,40 +1212,57 @@ class OzVault {
918
1212
  }
919
1213
  else {
920
1214
  if (firstName.length > 50) {
921
- this._tokenizing = null;
922
- throw new OzError('firstName must be 50 characters or fewer');
1215
+ throw new OzError('firstName must be 50 characters or fewer.');
923
1216
  }
924
1217
  if (lastName.length > 50) {
925
- this._tokenizing = null;
926
- throw new OzError('lastName must be 50 characters or fewer');
1218
+ throw new OzError('lastName must be 50 characters or fewer.');
927
1219
  }
928
1220
  }
1221
+ this._tokenizing = 'card';
1222
+ const requestId = `req-${uuid()}`;
929
1223
  return new Promise((resolve, reject) => {
930
1224
  const cleanup = () => { this._tokenizing = null; };
931
1225
  this.tokenizeResolvers.set(requestId, {
932
1226
  resolve: (v) => { cleanup(); resolve(v); },
933
1227
  reject: (e) => { cleanup(); reject(e); },
934
1228
  billing: normalizedBilling,
935
- });
936
- // Tell tokenizer frame to expect N field values, then tokenize
937
- this.sendToTokenizer({
938
- type: 'OZ_TOKENIZE',
939
- requestId,
940
- apiKey: this.apiKey,
941
- pubKey: this.pubKey,
942
1229
  firstName,
943
1230
  lastName,
1231
+ readyElements,
944
1232
  fieldCount: readyElements.length,
945
1233
  });
946
- // Tell each ready element frame to send its raw value to the tokenizer
947
- readyElements.forEach(el => el.beginCollect(requestId));
948
- setTimeout(() => {
949
- if (this.tokenizeResolvers.has(requestId)) {
950
- this.tokenizeResolvers.delete(requestId);
951
- cleanup();
952
- reject(new OzError('Tokenization timed out after 30 seconds', undefined, 'timeout'));
953
- }
954
- }, 30000);
1234
+ try {
1235
+ // Tell tokenizer frame to expect N field values, then tokenize
1236
+ const cardChannels = readyElements.map(() => new MessageChannel());
1237
+ this.sendToTokenizer({
1238
+ type: 'OZ_TOKENIZE',
1239
+ requestId,
1240
+ waxKey: this.waxKey,
1241
+ tokenizationSessionId: this.tokenizationSessionId,
1242
+ pubKey: this.pubKey,
1243
+ firstName,
1244
+ lastName,
1245
+ fieldCount: readyElements.length,
1246
+ }, cardChannels.map(ch => ch.port1));
1247
+ // Tell each ready element frame to send its raw value to the tokenizer
1248
+ readyElements.forEach((el, i) => el.beginCollect(requestId, cardChannels[i].port2));
1249
+ const cardTimeoutId = setTimeout(() => {
1250
+ if (this.tokenizeResolvers.has(requestId)) {
1251
+ this.tokenizeResolvers.delete(requestId);
1252
+ this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId });
1253
+ cleanup();
1254
+ reject(new OzError('Tokenization timed out after 30 seconds', undefined, 'timeout'));
1255
+ }
1256
+ }, 30000);
1257
+ const cardPendingEntry = this.tokenizeResolvers.get(requestId);
1258
+ if (cardPendingEntry)
1259
+ cardPendingEntry.timeoutId = cardTimeoutId;
1260
+ }
1261
+ catch (err) {
1262
+ this.tokenizeResolvers.delete(requestId);
1263
+ cleanup();
1264
+ reject(err instanceof OzError ? err : new OzError('Tokenization failed to start'));
1265
+ }
955
1266
  });
956
1267
  }
957
1268
  /**
@@ -965,6 +1276,7 @@ class OzVault {
965
1276
  return;
966
1277
  this._destroyed = true;
967
1278
  window.removeEventListener('message', this.boundHandleMessage);
1279
+ document.removeEventListener('visibilitychange', this.boundHandleVisibility);
968
1280
  if (this._pendingMount) {
969
1281
  document.removeEventListener('DOMContentLoaded', this._pendingMount);
970
1282
  this._pendingMount = null;
@@ -975,11 +1287,17 @@ class OzVault {
975
1287
  }
976
1288
  // Reject any pending tokenize promises so callers aren't left hanging
977
1289
  this._tokenizing = null;
978
- this.tokenizeResolvers.forEach(({ reject }) => {
1290
+ this.tokenizeResolvers.forEach(({ reject, timeoutId }, requestId) => {
1291
+ if (timeoutId != null)
1292
+ clearTimeout(timeoutId);
1293
+ this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId });
979
1294
  reject(new OzError('Vault destroyed before tokenization completed.'));
980
1295
  });
981
1296
  this.tokenizeResolvers.clear();
982
- this.bankTokenizeResolvers.forEach(({ reject }) => {
1297
+ this.bankTokenizeResolvers.forEach(({ reject, timeoutId }, requestId) => {
1298
+ if (timeoutId != null)
1299
+ clearTimeout(timeoutId);
1300
+ this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId });
983
1301
  reject(new OzError('Vault destroyed before bank tokenization completed.'));
984
1302
  });
985
1303
  this.bankTokenizeResolvers.clear();
@@ -988,17 +1306,49 @@ class OzVault {
988
1306
  this.elementsByType.clear();
989
1307
  this.bankElementsByType.clear();
990
1308
  this.completionState.clear();
1309
+ this._tokenizeSuccessCount = 0;
991
1310
  (_a = this.tokenizerFrame) === null || _a === void 0 ? void 0 : _a.remove();
992
1311
  this.tokenizerFrame = null;
993
1312
  this.tokenizerWindow = null;
994
1313
  this.tokenizerReady = false;
995
1314
  }
996
1315
  // ─── Private ─────────────────────────────────────────────────────────────
1316
+ /**
1317
+ * Proactively re-mints the wax key when the page becomes visible again after
1318
+ * a long idle period. Wax keys have a fixed TTL (~30 minutes); a user who
1319
+ * leaves the tab in the background and returns could have an expired key.
1320
+ * Rather than waiting for a failed tokenization to trigger the reactive
1321
+ * refresh path, this pre-empts the failure when the vault is idle.
1322
+ *
1323
+ * Threshold: 20 minutes hidden. Chosen to be comfortably inside the ~30m TTL
1324
+ * while avoiding spurious refreshes for brief tab-switches.
1325
+ */
1326
+ handleVisibilityChange() {
1327
+ if (this._destroyed)
1328
+ return;
1329
+ const REFRESH_THRESHOLD_MS = 20 * 60 * 1000; // 20 minutes
1330
+ if (document.hidden) {
1331
+ this._hiddenAt = Date.now();
1332
+ }
1333
+ else {
1334
+ if (this._hiddenAt !== null &&
1335
+ Date.now() - this._hiddenAt >= REFRESH_THRESHOLD_MS &&
1336
+ this._storedFetchWaxKey &&
1337
+ !this._tokenizing &&
1338
+ !this._waxRefreshing) {
1339
+ this.refreshWaxKey().catch((err) => {
1340
+ // Proactive refresh failure is non-fatal — the reactive path on the
1341
+ // next createToken() call will handle it, including the auth retry.
1342
+ console.warn('[OzVault] Proactive wax key refresh failed:', err instanceof Error ? err.message : err);
1343
+ });
1344
+ }
1345
+ this._hiddenAt = null;
1346
+ }
1347
+ }
997
1348
  mountTokenizerFrame() {
998
1349
  const mount = () => {
999
1350
  this._pendingMount = null;
1000
1351
  const iframe = document.createElement('iframe');
1001
- iframe.name = this.tokenizerName;
1002
1352
  iframe.style.cssText = 'position:absolute;top:-9999px;left:-9999px;width:1px;height:1px;';
1003
1353
  iframe.setAttribute('aria-hidden', 'true');
1004
1354
  iframe.tabIndex = -1;
@@ -1017,6 +1367,8 @@ class OzVault {
1017
1367
  }
1018
1368
  handleMessage(event) {
1019
1369
  var _a;
1370
+ if (this._destroyed)
1371
+ return;
1020
1372
  // Only accept messages from our frame origin (defense in depth; prevents
1021
1373
  // arbitrary pages from injecting OZ_TOKEN_RESULT etc. with a guessed vaultId).
1022
1374
  if (event.origin !== this.frameOrigin)
@@ -1034,14 +1386,17 @@ class OzVault {
1034
1386
  if (frameId) {
1035
1387
  const el = this.elements.get(frameId);
1036
1388
  if (el) {
1389
+ // Reset stale completion state when an element iframe re-loads (e.g. after
1390
+ // unmount() + mount() in a SPA). Without this, wasComplete stays true from
1391
+ // the previous session and justCompleted never fires, breaking auto-advance.
1392
+ if (msg.type === 'OZ_FRAME_READY') {
1393
+ this.completionState.set(frameId, false);
1394
+ }
1037
1395
  // Intercept OZ_CHANGE before forwarding — handle auto-advance and CVV sync
1038
1396
  if (msg.type === 'OZ_CHANGE') {
1039
1397
  this.handleElementChange(msg, el);
1040
1398
  }
1041
1399
  el.handleMessage(msg);
1042
- if (msg.type === 'OZ_FRAME_READY' && this.tokenizerReady) {
1043
- el.setTokenizerName(this.tokenizerName);
1044
- }
1045
1400
  }
1046
1401
  }
1047
1402
  }
@@ -1055,7 +1410,7 @@ class OzVault {
1055
1410
  const complete = msg.complete;
1056
1411
  const valid = msg.valid;
1057
1412
  const wasComplete = (_a = this.completionState.get(el.frameId)) !== null && _a !== void 0 ? _a : false;
1058
- this.completionState.set(el.frameId, complete);
1413
+ this.completionState.set(el.frameId, complete && valid);
1059
1414
  // Require valid too — avoids advancing at 13 digits for unknown-brand cards
1060
1415
  // where isComplete() fires before the user has finished typing.
1061
1416
  const justCompleted = complete && valid && !wasComplete;
@@ -1078,7 +1433,7 @@ class OzVault {
1078
1433
  }
1079
1434
  }
1080
1435
  handleTokenizerMessage(msg) {
1081
- var _a, _b;
1436
+ var _a, _b, _c;
1082
1437
  switch (msg.type) {
1083
1438
  case 'OZ_FRAME_READY':
1084
1439
  this.tokenizerReady = true;
@@ -1087,94 +1442,440 @@ class OzVault {
1087
1442
  this.loadErrorTimeoutId = null;
1088
1443
  }
1089
1444
  this.tokenizerWindow = (_b = (_a = this.tokenizerFrame) === null || _a === void 0 ? void 0 : _a.contentWindow) !== null && _b !== void 0 ? _b : null;
1090
- this.sendToTokenizer({ type: 'OZ_INIT', frameId: '__tokenizer__' });
1091
- this.elements.forEach(el => el.setTokenizerName(this.tokenizerName));
1445
+ // Deliver the wax key via OZ_INIT so the tokenizer stores it internally.
1446
+ // If waxKey is still empty (fetchWaxKey hasn't resolved yet), it will be
1447
+ // sent again from create() once the key is available.
1448
+ this.sendToTokenizer(Object.assign({ type: 'OZ_INIT', frameId: '__tokenizer__' }, (this.waxKey ? { waxKey: this.waxKey } : {})));
1449
+ (_c = this._onReady) === null || _c === void 0 ? void 0 : _c.call(this);
1092
1450
  break;
1093
1451
  case 'OZ_TOKEN_RESULT': {
1452
+ if (typeof msg.requestId !== 'string' || !msg.requestId) {
1453
+ console.error('[OzVault] OZ_TOKEN_RESULT missing requestId — discarding message.');
1454
+ break;
1455
+ }
1094
1456
  const pending = this.tokenizeResolvers.get(msg.requestId);
1095
1457
  if (pending) {
1096
1458
  this.tokenizeResolvers.delete(msg.requestId);
1097
- const card = msg.card;
1098
- pending.resolve(Object.assign(Object.assign({ token: msg.token, cvcSession: msg.cvcSession }, (card ? { card } : {})), (pending.billing ? { billing: pending.billing } : {})));
1459
+ if (pending.timeoutId != null)
1460
+ clearTimeout(pending.timeoutId);
1461
+ const token = msg.token;
1462
+ if (typeof token !== 'string' || !token) {
1463
+ pending.reject(new OzError('Vault returned a token result with a missing or empty token — possible vault API change.', undefined, 'server'));
1464
+ break;
1465
+ }
1466
+ const card = isCardMetadata(msg.card) ? msg.card : undefined;
1467
+ pending.resolve(Object.assign(Object.assign({ token, cvcSession: typeof msg.cvcSession === 'string' && msg.cvcSession ? msg.cvcSession : undefined }, (card ? { card } : {})), (pending.billing ? { billing: pending.billing } : {})));
1468
+ // Increment the per-key success counter and proactively refresh once
1469
+ // the budget is exhausted so the next createToken() call uses a fresh
1470
+ // key without waiting for a vault rejection.
1471
+ this._tokenizeSuccessCount++;
1472
+ if (this._tokenizeSuccessCount >= this._maxTokenizeCalls) {
1473
+ this.refreshWaxKey().catch((err) => {
1474
+ console.warn('[OzVault] Post-budget wax key refresh failed:', err instanceof Error ? err.message : err);
1475
+ });
1476
+ }
1099
1477
  }
1100
1478
  break;
1101
1479
  }
1102
1480
  case 'OZ_TOKEN_ERROR': {
1481
+ if (typeof msg.requestId !== 'string' || !msg.requestId) {
1482
+ console.error('[OzVault] OZ_TOKEN_ERROR missing requestId — discarding message.');
1483
+ break;
1484
+ }
1485
+ const raw = typeof msg.error === 'string' ? msg.error : '';
1486
+ const errorCode = isOzErrorCode(msg.errorCode) ? msg.errorCode : 'unknown';
1103
1487
  const pending = this.tokenizeResolvers.get(msg.requestId);
1104
1488
  if (pending) {
1105
1489
  this.tokenizeResolvers.delete(msg.requestId);
1106
- const raw = msg.error;
1107
- const errorCode = (msg.errorCode || 'unknown');
1490
+ if (pending.timeoutId != null)
1491
+ clearTimeout(pending.timeoutId);
1492
+ // Auto-refresh: if the wax key expired or was consumed and we haven't
1493
+ // already retried for this request, transparently re-mint and retry.
1494
+ if (this.isRefreshableAuthError(errorCode, raw) && !pending.retried && this._storedFetchWaxKey) {
1495
+ this.refreshWaxKey().then(() => {
1496
+ if (this._destroyed) {
1497
+ pending.reject(new OzError('Vault destroyed during wax key refresh.'));
1498
+ return;
1499
+ }
1500
+ const newRequestId = `req-${uuid()}`;
1501
+ // _tokenizing is still 'card' (cleanup() hasn't been called yet)
1502
+ this.tokenizeResolvers.set(newRequestId, Object.assign(Object.assign({}, pending), { retried: true }));
1503
+ try {
1504
+ const retryCardChannels = pending.readyElements.map(() => new MessageChannel());
1505
+ this.sendToTokenizer({
1506
+ type: 'OZ_TOKENIZE',
1507
+ requestId: newRequestId,
1508
+ waxKey: this.waxKey,
1509
+ tokenizationSessionId: this.tokenizationSessionId,
1510
+ pubKey: this.pubKey,
1511
+ firstName: pending.firstName,
1512
+ lastName: pending.lastName,
1513
+ fieldCount: pending.fieldCount,
1514
+ }, retryCardChannels.map(ch => ch.port1));
1515
+ pending.readyElements.forEach((el, i) => el.beginCollect(newRequestId, retryCardChannels[i].port2));
1516
+ const retryCardTimeoutId = setTimeout(() => {
1517
+ if (this.tokenizeResolvers.has(newRequestId)) {
1518
+ this.tokenizeResolvers.delete(newRequestId);
1519
+ this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId: newRequestId });
1520
+ pending.reject(new OzError('Tokenization timed out after wax key refresh.', undefined, 'timeout'));
1521
+ }
1522
+ }, 30000);
1523
+ const retryCardEntry = this.tokenizeResolvers.get(newRequestId);
1524
+ if (retryCardEntry)
1525
+ retryCardEntry.timeoutId = retryCardTimeoutId;
1526
+ }
1527
+ catch (setupErr) {
1528
+ this.tokenizeResolvers.delete(newRequestId);
1529
+ pending.reject(setupErr instanceof OzError ? setupErr : new OzError('Retry tokenization failed to start'));
1530
+ }
1531
+ }).catch((refreshErr) => {
1532
+ const msg = refreshErr instanceof Error ? refreshErr.message : 'Wax key refresh failed';
1533
+ pending.reject(new OzError(msg, undefined, 'auth'));
1534
+ });
1535
+ break;
1536
+ }
1108
1537
  pending.reject(new OzError(normalizeVaultError(raw), raw, errorCode));
1109
1538
  }
1110
1539
  // Also check bank resolvers — both card and bank errors use OZ_TOKEN_ERROR
1111
1540
  const bankPending = this.bankTokenizeResolvers.get(msg.requestId);
1112
1541
  if (bankPending) {
1113
1542
  this.bankTokenizeResolvers.delete(msg.requestId);
1114
- const raw = msg.error;
1115
- const errorCode = (msg.errorCode || 'unknown');
1543
+ if (bankPending.timeoutId != null)
1544
+ clearTimeout(bankPending.timeoutId);
1545
+ if (this.isRefreshableAuthError(errorCode, raw) && !bankPending.retried && this._storedFetchWaxKey) {
1546
+ this.refreshWaxKey().then(() => {
1547
+ if (this._destroyed) {
1548
+ bankPending.reject(new OzError('Vault destroyed during wax key refresh.'));
1549
+ return;
1550
+ }
1551
+ const newRequestId = `req-${uuid()}`;
1552
+ this.bankTokenizeResolvers.set(newRequestId, Object.assign(Object.assign({}, bankPending), { retried: true }));
1553
+ try {
1554
+ const retryBankChannels = bankPending.readyElements.map(() => new MessageChannel());
1555
+ this.sendToTokenizer({
1556
+ type: 'OZ_BANK_TOKENIZE',
1557
+ requestId: newRequestId,
1558
+ waxKey: this.waxKey,
1559
+ tokenizationSessionId: this.tokenizationSessionId,
1560
+ pubKey: this.pubKey,
1561
+ firstName: bankPending.firstName,
1562
+ lastName: bankPending.lastName,
1563
+ fieldCount: bankPending.fieldCount,
1564
+ }, retryBankChannels.map(ch => ch.port1));
1565
+ bankPending.readyElements.forEach((el, i) => el.beginCollect(newRequestId, retryBankChannels[i].port2));
1566
+ const retryBankTimeoutId = setTimeout(() => {
1567
+ if (this.bankTokenizeResolvers.has(newRequestId)) {
1568
+ this.bankTokenizeResolvers.delete(newRequestId);
1569
+ this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId: newRequestId });
1570
+ bankPending.reject(new OzError('Bank tokenization timed out after wax key refresh.', undefined, 'timeout'));
1571
+ }
1572
+ }, 30000);
1573
+ const retryBankEntry = this.bankTokenizeResolvers.get(newRequestId);
1574
+ if (retryBankEntry)
1575
+ retryBankEntry.timeoutId = retryBankTimeoutId;
1576
+ }
1577
+ catch (setupErr) {
1578
+ this.bankTokenizeResolvers.delete(newRequestId);
1579
+ bankPending.reject(setupErr instanceof OzError ? setupErr : new OzError('Retry bank tokenization failed to start'));
1580
+ }
1581
+ }).catch((refreshErr) => {
1582
+ const msg = refreshErr instanceof Error ? refreshErr.message : 'Wax key refresh failed';
1583
+ bankPending.reject(new OzError(msg, undefined, 'auth'));
1584
+ });
1585
+ break;
1586
+ }
1116
1587
  bankPending.reject(new OzError(normalizeBankVaultError(raw), raw, errorCode));
1117
1588
  }
1118
1589
  break;
1119
1590
  }
1120
1591
  case 'OZ_BANK_TOKEN_RESULT': {
1592
+ if (typeof msg.requestId !== 'string' || !msg.requestId) {
1593
+ console.error('[OzVault] OZ_BANK_TOKEN_RESULT missing requestId — discarding message.');
1594
+ break;
1595
+ }
1121
1596
  const pending = this.bankTokenizeResolvers.get(msg.requestId);
1122
1597
  if (pending) {
1123
1598
  this.bankTokenizeResolvers.delete(msg.requestId);
1124
- const bank = msg.bank;
1125
- pending.resolve(Object.assign({ token: msg.token }, (bank ? { bank } : {})));
1599
+ if (pending.timeoutId != null)
1600
+ clearTimeout(pending.timeoutId);
1601
+ const token = msg.token;
1602
+ if (typeof token !== 'string' || !token) {
1603
+ pending.reject(new OzError('Vault returned a bank token result with a missing or empty token — possible vault API change.', undefined, 'server'));
1604
+ break;
1605
+ }
1606
+ const bank = isBankAccountMetadata(msg.bank) ? msg.bank : undefined;
1607
+ pending.resolve(Object.assign({ token }, (bank ? { bank } : {})));
1608
+ // Same proactive refresh logic as card tokenization.
1609
+ this._tokenizeSuccessCount++;
1610
+ if (this._tokenizeSuccessCount >= this._maxTokenizeCalls) {
1611
+ this.refreshWaxKey().catch((err) => {
1612
+ console.warn('[OzVault] Post-budget wax key refresh failed:', err instanceof Error ? err.message : err);
1613
+ });
1614
+ }
1126
1615
  }
1127
1616
  break;
1128
1617
  }
1129
1618
  }
1130
1619
  }
1131
- sendToTokenizer(data) {
1620
+ /**
1621
+ * Returns true when an OZ_TOKEN_ERROR should trigger a wax key refresh.
1622
+ *
1623
+ * Primary path: vault returns 401/403 → errorCode 'auth'.
1624
+ * Defensive path: vault returns 400 → errorCode 'validation', but the raw
1625
+ * message contains wax-key-specific language (consumed, expired, invalid key,
1626
+ * etc.). This avoids a hard dependency on the vault returning a unified HTTP
1627
+ * status for consumed-key vs expired-key failures — both should refresh.
1628
+ *
1629
+ * Deliberately excludes 'network', 'timeout', and 'server' codes (transient
1630
+ * errors are already retried in fetchWithRetry) and 'unknown' (too broad).
1631
+ */
1632
+ isRefreshableAuthError(errorCode, raw) {
1633
+ if (errorCode === 'auth')
1634
+ return true;
1635
+ if (errorCode === 'validation') {
1636
+ const msg = raw.toLowerCase();
1637
+ // Only treat validation errors as wax-related if the message explicitly
1638
+ // names the wax/tokenization session mechanism. A bare "session" match
1639
+ // was too broad — any message mentioning "session" (e.g. a merchant
1640
+ // session field error) would trigger a spurious re-mint.
1641
+ return (msg.includes('wax') ||
1642
+ msg.includes('expired') ||
1643
+ msg.includes('consumed') ||
1644
+ msg.includes('invalid key') ||
1645
+ msg.includes('key not found') ||
1646
+ msg.includes('tokenization session'));
1647
+ }
1648
+ return false;
1649
+ }
1650
+ /**
1651
+ * Re-mints the wax key using the stored fetchWaxKey callback and updates
1652
+ * the tokenizer with the new key. Used for transparent auto-refresh when
1653
+ * the vault returns an auth error on tokenization.
1654
+ *
1655
+ * Only one refresh runs at a time — concurrent retries share the same promise.
1656
+ */
1657
+ refreshWaxKey() {
1658
+ var _a;
1659
+ if (this._waxRefreshing)
1660
+ return this._waxRefreshing;
1661
+ if (!this._storedFetchWaxKey) {
1662
+ return Promise.reject(new OzError('Wax key expired and no fetchWaxKey callback is available for auto-refresh. Call OzVault.create() again to obtain a new vault instance.', undefined, 'auth'));
1663
+ }
1664
+ const newSessionId = uuid();
1665
+ (_a = this._onWaxRefresh) === null || _a === void 0 ? void 0 : _a.call(this);
1666
+ this._waxRefreshing = this._storedFetchWaxKey(newSessionId)
1667
+ .then(newWaxKey => {
1668
+ if (typeof newWaxKey !== 'string' || !newWaxKey.trim()) {
1669
+ throw new OzError('fetchWaxKey returned an empty string during auto-refresh.', undefined, 'auth');
1670
+ }
1671
+ if (!this._destroyed) {
1672
+ this.waxKey = newWaxKey;
1673
+ this.tokenizationSessionId = newSessionId;
1674
+ this._tokenizeSuccessCount = 0;
1675
+ }
1676
+ if (!this._destroyed && this.tokenizerReady) {
1677
+ this.sendToTokenizer({ type: 'OZ_INIT', frameId: '__tokenizer__', waxKey: newWaxKey });
1678
+ }
1679
+ })
1680
+ .finally(() => {
1681
+ this._waxRefreshing = null;
1682
+ });
1683
+ return this._waxRefreshing;
1684
+ }
1685
+ sendToTokenizer(data, transfer) {
1132
1686
  var _a;
1133
1687
  const msg = Object.assign({ __oz: true, vaultId: this.vaultId }, data);
1134
- (_a = this.tokenizerWindow) === null || _a === void 0 ? void 0 : _a.postMessage(msg, this.frameOrigin);
1688
+ (_a = this.tokenizerWindow) === null || _a === void 0 ? void 0 : _a.postMessage(msg, this.frameOrigin, transfer !== null && transfer !== void 0 ? transfer : []);
1135
1689
  }
1136
1690
  }
1137
1691
 
1692
+ /**
1693
+ * Creates a ready-to-use `fetchWaxKey` callback for `OzVault.create()` and `<OzElements>`.
1694
+ *
1695
+ * Calls your backend mint endpoint with `{ sessionId }` and returns the wax key string.
1696
+ * Throws on non-OK responses or a missing `waxKey` field so the vault can surface the
1697
+ * error through its normal error path.
1698
+ *
1699
+ * Each call enforces a 10-second per-attempt timeout. On a pure network-level
1700
+ * failure (connection refused, DNS failure, etc.) the call is retried once after
1701
+ * 750ms before throwing. HTTP errors (4xx/5xx) are never retried — they indicate
1702
+ * an endpoint misconfiguration or an invalid key, not a transient failure.
1703
+ *
1704
+ * The mint endpoint is typically the one-line `createMintWaxHandler` / `createMintWaxMiddleware`
1705
+ * from `@ozura/elements/server`.
1706
+ *
1707
+ * @param mintUrl - Absolute or relative URL of your wax-key mint endpoint, e.g. `'/api/mint-wax'`.
1708
+ *
1709
+ * @example
1710
+ * // Vanilla JS
1711
+ * const vault = await OzVault.create({
1712
+ * pubKey: 'pk_live_...',
1713
+ * fetchWaxKey: createFetchWaxKey('/api/mint-wax'),
1714
+ * });
1715
+ *
1716
+ * @example
1717
+ * // React
1718
+ * <OzElements pubKey="pk_live_..." fetchWaxKey={createFetchWaxKey('/api/mint-wax')}>
1719
+ */
1720
+ function createFetchWaxKey(mintUrl) {
1721
+ const TIMEOUT_MS = 10000;
1722
+ // Each attempt gets its own AbortController so a timeout on attempt 1 does
1723
+ // not bleed into the retry. Uses AbortController + setTimeout instead of
1724
+ // AbortSignal.timeout() to support environments without that API.
1725
+ const attemptFetch = (sessionId) => {
1726
+ const controller = new AbortController();
1727
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
1728
+ return fetch(mintUrl, {
1729
+ method: 'POST',
1730
+ headers: { 'Content-Type': 'application/json' },
1731
+ body: JSON.stringify({ sessionId }),
1732
+ signal: controller.signal,
1733
+ }).finally(() => clearTimeout(timer));
1734
+ };
1735
+ return async (sessionId) => {
1736
+ let res;
1737
+ try {
1738
+ res = await attemptFetch(sessionId);
1739
+ }
1740
+ catch (firstErr) {
1741
+ // Abort/timeout should not be retried — the server received nothing or
1742
+ // we already waited the full timeout duration.
1743
+ if (firstErr instanceof Error && (firstErr.name === 'AbortError' || firstErr.name === 'TimeoutError')) {
1744
+ throw new OzError(`Wax key mint timed out after ${TIMEOUT_MS / 1000}s (${mintUrl})`, undefined, 'timeout');
1745
+ }
1746
+ // Pure network error (offline, DNS, connection refused) — retry once
1747
+ // after a short pause in case of a transient blip.
1748
+ await new Promise(resolve => setTimeout(resolve, 750));
1749
+ try {
1750
+ res = await attemptFetch(sessionId);
1751
+ }
1752
+ catch (retryErr) {
1753
+ const msg = retryErr instanceof Error ? retryErr.message : 'Network error';
1754
+ throw new OzError(`Could not reach wax key mint endpoint (${mintUrl}): ${msg}`, undefined, 'network');
1755
+ }
1756
+ }
1757
+ const data = await res.json().catch(() => ({}));
1758
+ if (!res.ok) {
1759
+ throw new OzError(typeof data.error === 'string' && data.error
1760
+ ? data.error
1761
+ : `Wax key mint failed (HTTP ${res.status})`, undefined, res.status >= 500 ? 'server' : res.status === 401 || res.status === 403 ? 'auth' : 'validation');
1762
+ }
1763
+ if (typeof data.waxKey !== 'string' || !data.waxKey.trim()) {
1764
+ throw new OzError('Mint endpoint response is missing waxKey. Check your /api/mint-wax implementation.', undefined, 'validation');
1765
+ }
1766
+ return data.waxKey;
1767
+ };
1768
+ }
1769
+
1138
1770
  const OzContext = createContext({
1139
1771
  vault: null,
1772
+ initError: null,
1140
1773
  notifyReady: () => { },
1141
1774
  notifyUnmount: () => { },
1142
1775
  notifyMount: () => { },
1776
+ notifyTokenize: () => { },
1143
1777
  mountedCount: 0,
1144
1778
  readyCount: 0,
1779
+ tokenizeCount: 0,
1145
1780
  });
1146
1781
  /**
1147
1782
  * Creates and owns an OzVault instance for the lifetime of this component.
1148
1783
  * All `<OzCardNumber />`, `<OzExpiry />`, and `<OzCvv />` children must be
1149
1784
  * rendered inside this provider.
1150
1785
  */
1151
- function OzElements({ apiKey, pubKey, frameBaseUrl, fonts, onLoadError, loadTimeoutMs, appearance, children }) {
1786
+ function OzElements({ fetchWaxKey, pubKey, frameBaseUrl, fonts, onLoadError, loadTimeoutMs, onWaxRefresh, onReady, appearance, maxTokenizeCalls, children }) {
1152
1787
  const [vault, setVault] = useState(null);
1788
+ const [initError, setInitError] = useState(null);
1153
1789
  const [mountedCount, setMountedCount] = useState(0);
1154
1790
  const [readyCount, setReadyCount] = useState(0);
1791
+ const [tokenizeCount, setTokenizeCount] = useState(0);
1155
1792
  const onLoadErrorRef = useRef(onLoadError);
1156
1793
  onLoadErrorRef.current = onLoadError;
1794
+ const onWaxRefreshRef = useRef(onWaxRefresh);
1795
+ onWaxRefreshRef.current = onWaxRefresh;
1796
+ const onReadyRef = useRef(onReady);
1797
+ onReadyRef.current = onReady;
1798
+ // Keep a ref to fetchWaxKey so changes don't trigger vault recreation
1799
+ const fetchWaxKeyRef = useRef(fetchWaxKey);
1800
+ fetchWaxKeyRef.current = fetchWaxKey;
1157
1801
  const appearanceKey = useMemo(() => appearance ? JSON.stringify(appearance) : '', [appearance]);
1158
1802
  const fontsKey = useMemo(() => fonts ? JSON.stringify(fonts) : '', [fonts]);
1159
1803
  useEffect(() => {
1804
+ let cancelled = false;
1805
+ let createdVault = null;
1160
1806
  const parsedAppearance = appearanceKey ? JSON.parse(appearanceKey) : undefined;
1161
1807
  const parsedFonts = fontsKey ? JSON.parse(fontsKey) : undefined;
1162
- const v = new OzVault(apiKey, Object.assign(Object.assign(Object.assign(Object.assign({ pubKey }, (frameBaseUrl ? { frameBaseUrl } : {})), (parsedFonts ? { fonts: parsedFonts } : {})), (parsedAppearance ? { appearance: parsedAppearance } : {})), (onLoadErrorRef.current ? { onLoadError: () => { var _a; return (_a = onLoadErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onLoadErrorRef); }, loadTimeoutMs } : {})));
1163
- setVault(v);
1164
- setMountedCount(0);
1165
- setReadyCount(0);
1808
+ // Guard: onLoadError must fire at most once per effect run. It can be
1809
+ // triggered by two independent paths — the vault's iframe load timeout
1810
+ // (inside OzVault constructor) and the .catch below when fetchWaxKey
1811
+ // rejects after the timeout has already fired. Without this guard both
1812
+ // paths would call the callback.
1813
+ let loadErrorFired = false;
1814
+ const fireLoadError = () => {
1815
+ var _a;
1816
+ if (loadErrorFired)
1817
+ return;
1818
+ loadErrorFired = true;
1819
+ (_a = onLoadErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onLoadErrorRef);
1820
+ };
1821
+ // AbortController passed to create() so that if this effect's cleanup runs
1822
+ // while fetchWaxKey is still in-flight (React StrictMode double-invoke or
1823
+ // rapid prop churn), the tokenizer iframe and message listener are destroyed
1824
+ // synchronously rather than waiting for the promise to settle. Without this,
1825
+ // two hidden iframes and two window listeners briefly coexist.
1826
+ const abortController = new AbortController();
1827
+ OzVault.create(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ pubKey, fetchWaxKey: (sessionId) => fetchWaxKeyRef.current(sessionId) }, (frameBaseUrl ? { frameBaseUrl } : {})), (parsedFonts ? { fonts: parsedFonts } : {})), (parsedAppearance ? { appearance: parsedAppearance } : {})), (onLoadErrorRef.current ? { onLoadError: fireLoadError, loadTimeoutMs } : {})), {
1828
+ // Always install onWaxRefresh internally so we can reset tokenizeCount
1829
+ // when any wax key refresh occurs (reactive TTL expiry, post-budget
1830
+ // proactive, or visibility-change proactive). Without this the React
1831
+ // counter accumulates across key refreshes and diverges from the vault's
1832
+ // internal _tokenizeSuccessCount, which resets to 0 on every refresh.
1833
+ onWaxRefresh: () => {
1834
+ var _a;
1835
+ setTokenizeCount(0);
1836
+ (_a = onWaxRefreshRef.current) === null || _a === void 0 ? void 0 : _a.call(onWaxRefreshRef);
1837
+ } }), (onReadyRef.current ? { onReady: () => { var _a; return (_a = onReadyRef.current) === null || _a === void 0 ? void 0 : _a.call(onReadyRef); } } : {})), (maxTokenizeCalls !== undefined ? { maxTokenizeCalls } : {})), abortController.signal).then(v => {
1838
+ if (cancelled) {
1839
+ v.destroy();
1840
+ return;
1841
+ }
1842
+ createdVault = v;
1843
+ setVault(v);
1844
+ setInitError(null);
1845
+ setMountedCount(0);
1846
+ setReadyCount(0);
1847
+ setTokenizeCount(0);
1848
+ }).catch((err) => {
1849
+ // fetchWaxKey threw, returned a non-string value, returned an empty string,
1850
+ // or create() was cancelled via the AbortSignal — all are suppressed when
1851
+ // cancelled is true (cleanup already ran).
1852
+ if (cancelled)
1853
+ return;
1854
+ const error = err instanceof Error ? err : new OzError('OzVault.create() failed.');
1855
+ setInitError(error);
1856
+ if (onLoadErrorRef.current) {
1857
+ fireLoadError();
1858
+ }
1859
+ else {
1860
+ console.error('[OzElements] OzVault.create() failed. Provide an `onLoadError` prop to handle this gracefully.', err);
1861
+ }
1862
+ });
1166
1863
  return () => {
1167
- v.destroy();
1864
+ cancelled = true;
1865
+ abortController.abort(); // Destroys vault (and its iframe/listener) synchronously if create() is in-flight
1866
+ createdVault === null || createdVault === void 0 ? void 0 : createdVault.destroy();
1168
1867
  setVault(null);
1868
+ setInitError(null);
1169
1869
  };
1170
- }, [apiKey, pubKey, frameBaseUrl, loadTimeoutMs, appearanceKey, fontsKey]);
1870
+ }, [pubKey, frameBaseUrl, loadTimeoutMs, appearanceKey, fontsKey, maxTokenizeCalls]);
1171
1871
  const notifyMount = useCallback(() => setMountedCount(n => n + 1), []);
1172
1872
  const notifyReady = useCallback(() => setReadyCount(n => n + 1), []);
1173
1873
  const notifyUnmount = useCallback(() => {
1174
1874
  setMountedCount(n => Math.max(0, n - 1));
1175
1875
  setReadyCount(n => Math.max(0, n - 1));
1176
1876
  }, []);
1177
- const value = useMemo(() => ({ vault, notifyMount, notifyReady, notifyUnmount, mountedCount, readyCount }), [vault, notifyMount, notifyReady, notifyUnmount, mountedCount, readyCount]);
1877
+ const notifyTokenize = useCallback(() => setTokenizeCount(n => n + 1), []);
1878
+ const value = useMemo(() => ({ vault, initError, notifyMount, notifyReady, notifyUnmount, notifyTokenize, mountedCount, readyCount, tokenizeCount }), [vault, initError, notifyMount, notifyReady, notifyUnmount, notifyTokenize, mountedCount, readyCount, tokenizeCount]);
1178
1879
  return jsx(OzContext.Provider, { value: value, children: children });
1179
1880
  }
1180
1881
  /**
@@ -1182,15 +1883,25 @@ function OzElements({ apiKey, pubKey, frameBaseUrl, fonts, onLoadError, loadTime
1182
1883
  * an `<OzElements>` provider tree.
1183
1884
  */
1184
1885
  function useOzElements() {
1185
- const { vault, mountedCount, readyCount } = useContext(OzContext);
1186
- const createToken = useCallback((options) => {
1886
+ const { vault, initError, mountedCount, readyCount, notifyTokenize, tokenizeCount } = useContext(OzContext);
1887
+ const createToken = useCallback(async (options) => {
1187
1888
  if (!vault) {
1188
1889
  return Promise.reject(new OzError('useOzElements must be called inside an <OzElements> provider.'));
1189
1890
  }
1190
- return vault.createToken(options);
1191
- }, [vault]);
1192
- const ready = vault !== null && mountedCount > 0 && readyCount >= mountedCount;
1193
- return { createToken, ready };
1891
+ const result = await vault.createToken(options);
1892
+ notifyTokenize();
1893
+ return result;
1894
+ }, [vault, notifyTokenize]);
1895
+ const createBankToken = useCallback(async (options) => {
1896
+ if (!vault) {
1897
+ return Promise.reject(new OzError('useOzElements must be called inside an <OzElements> provider.'));
1898
+ }
1899
+ const result = await vault.createBankToken(options);
1900
+ notifyTokenize();
1901
+ return result;
1902
+ }, [vault, notifyTokenize]);
1903
+ const ready = vault !== null && vault.isReady && mountedCount > 0 && readyCount >= mountedCount;
1904
+ return { createToken, createBankToken, ready, initError, tokenizeCount };
1194
1905
  }
1195
1906
  const SKELETON_STYLE = {
1196
1907
  height: 46,
@@ -1220,7 +1931,7 @@ const LOAD_ERROR_STYLE = {
1220
1931
  color: '#991b1b',
1221
1932
  fontSize: 13,
1222
1933
  };
1223
- function OzField({ type, style, placeholder, disabled, loadTimeoutMs, onChange, onFocus, onBlur, onReady, onLoadError, className, }) {
1934
+ function OzFieldBase({ type, variant, style, placeholder, disabled, loadTimeoutMs, onChange, onFocus, onBlur, onReady, onLoadError, className, }) {
1224
1935
  const containerRef = useRef(null);
1225
1936
  const elementRef = useRef(null);
1226
1937
  const [loaded, setLoaded] = useState(false);
@@ -1246,7 +1957,9 @@ function OzField({ type, style, placeholder, disabled, loadTimeoutMs, onChange,
1246
1957
  injectShimmerCSS();
1247
1958
  setLoaded(false);
1248
1959
  setLoadError(null);
1249
- const element = vault.createElement(type, { style, placeholder, disabled, loadTimeoutMs });
1960
+ const element = variant === 'bank'
1961
+ ? vault.createBankElement(type, { style, placeholder, disabled, loadTimeoutMs })
1962
+ : vault.createElement(type, { style, placeholder, disabled, loadTimeoutMs });
1250
1963
  elementRef.current = element;
1251
1964
  notifyMount();
1252
1965
  element.on('ready', () => {
@@ -1260,29 +1973,29 @@ function OzField({ type, style, placeholder, disabled, loadTimeoutMs, onChange,
1260
1973
  element.on('blur', () => { var _a; return (_a = onBlurRef.current) === null || _a === void 0 ? void 0 : _a.call(onBlurRef); });
1261
1974
  element.on('loaderror', (e) => {
1262
1975
  var _a, _b;
1263
- const err = (_a = e === null || e === void 0 ? void 0 : e.error) !== null && _a !== void 0 ? _a : 'Failed to load card field';
1976
+ const err = (_a = e === null || e === void 0 ? void 0 : e.error) !== null && _a !== void 0 ? _a : (variant === 'bank' ? 'Failed to load bank field' : 'Failed to load card field');
1264
1977
  setLoadError(err);
1265
1978
  (_b = onLoadErrorRef.current) === null || _b === void 0 ? void 0 : _b.call(onLoadErrorRef, err);
1266
1979
  });
1267
1980
  element.mount(containerRef.current);
1268
1981
  return () => {
1269
- element.unmount();
1982
+ element.destroy();
1270
1983
  elementRef.current = null;
1271
1984
  setLoaded(false);
1272
1985
  setLoadError(null);
1273
1986
  notifyUnmount();
1274
1987
  };
1275
1988
  // eslint-disable-next-line react-hooks/exhaustive-deps
1276
- }, [vault, type]);
1989
+ }, [vault, type, loadTimeoutMs]);
1277
1990
  return (jsxs("div", { className: className, style: { width: '100%', position: 'relative' }, children: [loadError && jsx("div", { style: LOAD_ERROR_STYLE, role: "alert", children: loadError }), !loaded && !loadError && jsx("div", { style: SKELETON_STYLE }), jsx("div", { ref: containerRef, style: Object.assign({ width: '100%', opacity: loaded ? 1 : 0, transition: 'opacity 0.2s' }, (loadError ? { display: 'none' } : {})) })] }));
1278
1991
  }
1279
1992
  // ─── Public field components ──────────────────────────────────────────────────
1280
1993
  /** Renders a PCI-isolated card number input inside an Ozura iframe. */
1281
- const OzCardNumber = (props) => jsx(OzField, Object.assign({ type: "cardNumber" }, props));
1994
+ const OzCardNumber = (props) => jsx(OzFieldBase, Object.assign({ type: "cardNumber", variant: "card" }, props));
1282
1995
  /** Renders a PCI-isolated expiration date input inside an Ozura iframe. */
1283
- const OzExpiry = (props) => jsx(OzField, Object.assign({ type: "expirationDate" }, props));
1996
+ const OzExpiry = (props) => jsx(OzFieldBase, Object.assign({ type: "expirationDate", variant: "card" }, props));
1284
1997
  /** Renders a PCI-isolated CVV input inside an Ozura iframe. */
1285
- const OzCvv = (props) => jsx(OzField, Object.assign({ type: "cvv" }, props));
1998
+ const OzCvv = (props) => jsx(OzFieldBase, Object.assign({ type: "cvv", variant: "card" }, props));
1286
1999
  const DEFAULT_ERROR_STYLE = {
1287
2000
  color: '#dc2626',
1288
2001
  fontSize: 13,
@@ -1296,6 +2009,11 @@ const DEFAULT_LABEL_STYLE = {
1296
2009
  marginBottom: 4,
1297
2010
  color: '#374151',
1298
2011
  };
2012
+ function renderFieldLabel(text, labelClassName, style) {
2013
+ if (!text)
2014
+ return null;
2015
+ return jsx("label", { className: labelClassName, style: style, children: text });
2016
+ }
1299
2017
  function mergeStyles(base, override) {
1300
2018
  if (!override)
1301
2019
  return base;
@@ -1325,7 +2043,8 @@ function OzCard({ style, styles, classNames, labels, labelStyle, labelClassName,
1325
2043
  expiry: null,
1326
2044
  cvv: null,
1327
2045
  });
1328
- const readyFields = useRef(0);
2046
+ const readyFieldTypes = useRef(new Set());
2047
+ const onReadyFiredRef = useRef(false);
1329
2048
  const vaultRef = useRef(vault);
1330
2049
  const onChangeRef = useRef(onChange);
1331
2050
  const onReadyRef = useRef(onReady);
@@ -1337,17 +2056,41 @@ function OzCard({ style, styles, classNames, labels, labelStyle, labelClassName,
1337
2056
  useEffect(() => { onBlurRef.current = onBlur; }, [onBlur]);
1338
2057
  // When the vault is recreated (e.g. appearance/fonts props change on OzElements),
1339
2058
  // context readyCount is reset but this ref is not. Reset so onReady fires once when all 3 are ready.
2059
+ // The cleanup resets readyFieldTypes when the component unmounts (covers React StrictMode double-invoke
2060
+ // and SPA scenarios where the parent re-mounts this component).
1340
2061
  useEffect(() => {
1341
2062
  if (vault !== vaultRef.current) {
1342
2063
  vaultRef.current = vault;
1343
- readyFields.current = 0;
2064
+ readyFieldTypes.current = new Set();
2065
+ onReadyFiredRef.current = false;
1344
2066
  }
2067
+ return () => {
2068
+ readyFieldTypes.current = new Set();
2069
+ onReadyFiredRef.current = false;
2070
+ };
1345
2071
  }, [vault]);
1346
2072
  const [error, setError] = useState();
1347
- const handleFieldReady = useCallback(() => {
2073
+ const handleCardNumberReady = useCallback(() => {
2074
+ var _a;
2075
+ readyFieldTypes.current.add('cardNumber');
2076
+ if (readyFieldTypes.current.size >= 3 && !onReadyFiredRef.current) {
2077
+ onReadyFiredRef.current = true;
2078
+ (_a = onReadyRef.current) === null || _a === void 0 ? void 0 : _a.call(onReadyRef);
2079
+ }
2080
+ }, []);
2081
+ const handleExpiryReady = useCallback(() => {
2082
+ var _a;
2083
+ readyFieldTypes.current.add('expiry');
2084
+ if (readyFieldTypes.current.size >= 3 && !onReadyFiredRef.current) {
2085
+ onReadyFiredRef.current = true;
2086
+ (_a = onReadyRef.current) === null || _a === void 0 ? void 0 : _a.call(onReadyRef);
2087
+ }
2088
+ }, []);
2089
+ const handleCvvReady = useCallback(() => {
1348
2090
  var _a;
1349
- readyFields.current++;
1350
- if (readyFields.current >= 3) {
2091
+ readyFieldTypes.current.add('cvv');
2092
+ if (readyFieldTypes.current.size >= 3 && !onReadyFiredRef.current) {
2093
+ onReadyFiredRef.current = true;
1351
2094
  (_a = onReadyRef.current) === null || _a === void 0 ? void 0 : _a.call(onReadyRef);
1352
2095
  }
1353
2096
  }, []);
@@ -1366,29 +2109,107 @@ function OzCard({ style, styles, classNames, labels, labelStyle, labelClassName,
1366
2109
  fields: Object.assign({}, fieldState.current),
1367
2110
  });
1368
2111
  }, []);
1369
- const gapValue = typeof gap === 'number' ? gap : undefined;
1370
2112
  const gapStr = typeof gap === 'string' ? gap : `${gap}px`;
1371
2113
  const resolvedLabelStyle = labelStyle
1372
2114
  ? Object.assign(Object.assign({}, DEFAULT_LABEL_STYLE), labelStyle) : DEFAULT_LABEL_STYLE;
1373
- const renderLabel = (text) => {
1374
- if (!text)
1375
- return null;
1376
- return (jsx("label", { className: labelClassName, style: resolvedLabelStyle, children: text }));
1377
- };
2115
+ const renderLabel = (text) => renderFieldLabel(text, labelClassName, resolvedLabelStyle);
1378
2116
  const showError = !hideErrors && error;
1379
2117
  const errorNode = showError
1380
2118
  ? renderError
1381
2119
  ? renderError(error)
1382
2120
  : (jsx("div", { role: "alert", className: errorClassName, style: errorStyle ? Object.assign(Object.assign({}, DEFAULT_ERROR_STYLE), errorStyle) : DEFAULT_ERROR_STYLE, children: error }))
1383
2121
  : null;
1384
- const cardNumberField = (jsxs("div", { children: [renderLabel(labels === null || labels === void 0 ? void 0 : labels.cardNumber), jsx(OzCardNumber, { style: mergeStyles(style, styles === null || styles === void 0 ? void 0 : styles.cardNumber), className: classNames === null || classNames === void 0 ? void 0 : classNames.cardNumber, placeholder: (_a = placeholders === null || placeholders === void 0 ? void 0 : placeholders.cardNumber) !== null && _a !== void 0 ? _a : 'Card number', disabled: disabled, onChange: (e) => { fieldState.current.cardNumber = e; emitChange(); }, onFocus: () => { var _a; return (_a = onFocusRef.current) === null || _a === void 0 ? void 0 : _a.call(onFocusRef, 'cardNumber'); }, onBlur: () => { var _a; return (_a = onBlurRef.current) === null || _a === void 0 ? void 0 : _a.call(onBlurRef, 'cardNumber'); }, onReady: handleFieldReady })] }));
1385
- const expiryField = (jsxs("div", { style: layout === 'default' ? { flex: 1 } : undefined, children: [renderLabel(labels === null || labels === void 0 ? void 0 : labels.expiry), jsx(OzExpiry, { style: mergeStyles(style, styles === null || styles === void 0 ? void 0 : styles.expiry), className: classNames === null || classNames === void 0 ? void 0 : classNames.expiry, placeholder: (_b = placeholders === null || placeholders === void 0 ? void 0 : placeholders.expiry) !== null && _b !== void 0 ? _b : 'MM / YY', disabled: disabled, onChange: (e) => { fieldState.current.expiry = e; emitChange(); }, onFocus: () => { var _a; return (_a = onFocusRef.current) === null || _a === void 0 ? void 0 : _a.call(onFocusRef, 'expiry'); }, onBlur: () => { var _a; return (_a = onBlurRef.current) === null || _a === void 0 ? void 0 : _a.call(onBlurRef, 'expiry'); }, onReady: handleFieldReady })] }));
1386
- const cvvField = (jsxs("div", { style: layout === 'default' ? { flex: 1 } : undefined, children: [renderLabel(labels === null || labels === void 0 ? void 0 : labels.cvv), jsx(OzCvv, { style: mergeStyles(style, styles === null || styles === void 0 ? void 0 : styles.cvv), className: classNames === null || classNames === void 0 ? void 0 : classNames.cvv, placeholder: (_c = placeholders === null || placeholders === void 0 ? void 0 : placeholders.cvv) !== null && _c !== void 0 ? _c : 'CVV', disabled: disabled, onChange: (e) => { fieldState.current.cvv = e; emitChange(); }, onFocus: () => { var _a; return (_a = onFocusRef.current) === null || _a === void 0 ? void 0 : _a.call(onFocusRef, 'cvv'); }, onBlur: () => { var _a; return (_a = onBlurRef.current) === null || _a === void 0 ? void 0 : _a.call(onBlurRef, 'cvv'); }, onReady: handleFieldReady })] }));
2122
+ const cardNumberField = (jsxs("div", { children: [renderLabel(labels === null || labels === void 0 ? void 0 : labels.cardNumber), jsx(OzCardNumber, { style: mergeStyles(style, styles === null || styles === void 0 ? void 0 : styles.cardNumber), className: classNames === null || classNames === void 0 ? void 0 : classNames.cardNumber, placeholder: (_a = placeholders === null || placeholders === void 0 ? void 0 : placeholders.cardNumber) !== null && _a !== void 0 ? _a : 'Card number', disabled: disabled, onChange: (e) => { fieldState.current.cardNumber = e; emitChange(); }, onFocus: () => { var _a; return (_a = onFocusRef.current) === null || _a === void 0 ? void 0 : _a.call(onFocusRef, 'cardNumber'); }, onBlur: () => { var _a; return (_a = onBlurRef.current) === null || _a === void 0 ? void 0 : _a.call(onBlurRef, 'cardNumber'); }, onReady: handleCardNumberReady })] }));
2123
+ const expiryField = (jsxs("div", { style: layout === 'default' ? { flex: 1 } : undefined, children: [renderLabel(labels === null || labels === void 0 ? void 0 : labels.expiry), jsx(OzExpiry, { style: mergeStyles(style, styles === null || styles === void 0 ? void 0 : styles.expiry), className: classNames === null || classNames === void 0 ? void 0 : classNames.expiry, placeholder: (_b = placeholders === null || placeholders === void 0 ? void 0 : placeholders.expiry) !== null && _b !== void 0 ? _b : 'MM / YY', disabled: disabled, onChange: (e) => { fieldState.current.expiry = e; emitChange(); }, onFocus: () => { var _a; return (_a = onFocusRef.current) === null || _a === void 0 ? void 0 : _a.call(onFocusRef, 'expiry'); }, onBlur: () => { var _a; return (_a = onBlurRef.current) === null || _a === void 0 ? void 0 : _a.call(onBlurRef, 'expiry'); }, onReady: handleExpiryReady })] }));
2124
+ const cvvField = (jsxs("div", { style: layout === 'default' ? { flex: 1 } : undefined, children: [renderLabel(labels === null || labels === void 0 ? void 0 : labels.cvv), jsx(OzCvv, { style: mergeStyles(style, styles === null || styles === void 0 ? void 0 : styles.cvv), className: classNames === null || classNames === void 0 ? void 0 : classNames.cvv, placeholder: (_c = placeholders === null || placeholders === void 0 ? void 0 : placeholders.cvv) !== null && _c !== void 0 ? _c : 'CVV', disabled: disabled, onChange: (e) => { fieldState.current.cvv = e; emitChange(); }, onFocus: () => { var _a; return (_a = onFocusRef.current) === null || _a === void 0 ? void 0 : _a.call(onFocusRef, 'cvv'); }, onBlur: () => { var _a; return (_a = onBlurRef.current) === null || _a === void 0 ? void 0 : _a.call(onBlurRef, 'cvv'); }, onReady: handleCvvReady })] }));
1387
2125
  if (layout === 'rows') {
1388
2126
  return (jsxs("div", { className: className, style: { width: '100%', display: 'flex', flexDirection: 'column', gap: gapStr }, children: [cardNumberField, expiryField, cvvField, errorNode] }));
1389
2127
  }
1390
- return (jsxs("div", { className: className, style: { width: '100%' }, children: [cardNumberField, jsxs("div", { className: classNames === null || classNames === void 0 ? void 0 : classNames.row, style: { display: 'flex', gap: gapValue !== null && gapValue !== void 0 ? gapValue : gapStr, marginTop: gapValue !== null && gapValue !== void 0 ? gapValue : gapStr }, children: [expiryField, cvvField] }), errorNode] }));
2128
+ return (jsxs("div", { className: className, style: { width: '100%' }, children: [cardNumberField, jsxs("div", { className: classNames === null || classNames === void 0 ? void 0 : classNames.row, style: { display: 'flex', gap: gapStr, marginTop: gapStr }, children: [expiryField, cvvField] }), errorNode] }));
2129
+ }
2130
+ // ─── Public bank field components ─────────────────────────────────────────────
2131
+ /** Renders a PCI-isolated bank account number input inside an Ozura iframe. */
2132
+ const OzBankAccountNumber = (props) => jsx(OzFieldBase, Object.assign({ type: "accountNumber", variant: "bank" }, props));
2133
+ /** Renders a PCI-isolated routing number input inside an Ozura iframe. */
2134
+ const OzBankRoutingNumber = (props) => jsx(OzFieldBase, Object.assign({ type: "routingNumber", variant: "bank" }, props));
2135
+ /**
2136
+ * Combined bank account input — renders account number and routing number in a
2137
+ * single component with built-in layout, loading skeletons, and inline error display.
2138
+ *
2139
+ * For maximum layout control, use `<OzBankAccountNumber />` and `<OzBankRoutingNumber />`
2140
+ * individually instead.
2141
+ */
2142
+ function OzBankCard({ style, styles, classNames, labels, labelStyle, labelClassName, gap = 8, hideErrors = false, errorStyle, errorClassName, renderError, onChange, onReady, onFocus, onBlur, disabled, className, placeholders, }) {
2143
+ var _a, _b;
2144
+ const { vault } = useContext(OzContext);
2145
+ const fieldState = useRef({
2146
+ accountNumber: null,
2147
+ routingNumber: null,
2148
+ });
2149
+ const readyFieldTypes = useRef(new Set());
2150
+ const onReadyFiredRef = useRef(false);
2151
+ const vaultRef = useRef(vault);
2152
+ const onChangeRef = useRef(onChange);
2153
+ const onReadyRef = useRef(onReady);
2154
+ const onFocusRef = useRef(onFocus);
2155
+ const onBlurRef = useRef(onBlur);
2156
+ useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
2157
+ useEffect(() => { onReadyRef.current = onReady; }, [onReady]);
2158
+ useEffect(() => { onFocusRef.current = onFocus; }, [onFocus]);
2159
+ useEffect(() => { onBlurRef.current = onBlur; }, [onBlur]);
2160
+ useEffect(() => {
2161
+ if (vault !== vaultRef.current) {
2162
+ vaultRef.current = vault;
2163
+ readyFieldTypes.current = new Set();
2164
+ onReadyFiredRef.current = false;
2165
+ }
2166
+ return () => {
2167
+ readyFieldTypes.current = new Set();
2168
+ onReadyFiredRef.current = false;
2169
+ };
2170
+ }, [vault]);
2171
+ const [error, setError] = useState();
2172
+ const handleAccountReady = useCallback(() => {
2173
+ var _a;
2174
+ readyFieldTypes.current.add('accountNumber');
2175
+ if (readyFieldTypes.current.size >= 2 && !onReadyFiredRef.current) {
2176
+ onReadyFiredRef.current = true;
2177
+ (_a = onReadyRef.current) === null || _a === void 0 ? void 0 : _a.call(onReadyRef);
2178
+ }
2179
+ }, []);
2180
+ const handleRoutingReady = useCallback(() => {
2181
+ var _a;
2182
+ readyFieldTypes.current.add('routingNumber');
2183
+ if (readyFieldTypes.current.size >= 2 && !onReadyFiredRef.current) {
2184
+ onReadyFiredRef.current = true;
2185
+ (_a = onReadyRef.current) === null || _a === void 0 ? void 0 : _a.call(onReadyRef);
2186
+ }
2187
+ }, []);
2188
+ const emitChange = useCallback(() => {
2189
+ var _a;
2190
+ const { accountNumber, routingNumber } = fieldState.current;
2191
+ const complete = !!((accountNumber === null || accountNumber === void 0 ? void 0 : accountNumber.complete) && (accountNumber === null || accountNumber === void 0 ? void 0 : accountNumber.valid) &&
2192
+ (routingNumber === null || routingNumber === void 0 ? void 0 : routingNumber.complete) && (routingNumber === null || routingNumber === void 0 ? void 0 : routingNumber.valid));
2193
+ const err = (accountNumber === null || accountNumber === void 0 ? void 0 : accountNumber.error) || (routingNumber === null || routingNumber === void 0 ? void 0 : routingNumber.error) || undefined;
2194
+ setError(err);
2195
+ (_a = onChangeRef.current) === null || _a === void 0 ? void 0 : _a.call(onChangeRef, {
2196
+ complete,
2197
+ error: err,
2198
+ fields: Object.assign({}, fieldState.current),
2199
+ });
2200
+ }, []);
2201
+ const gapStr = typeof gap === 'string' ? gap : `${gap}px`;
2202
+ const resolvedLabelStyle = labelStyle
2203
+ ? Object.assign(Object.assign({}, DEFAULT_LABEL_STYLE), labelStyle) : DEFAULT_LABEL_STYLE;
2204
+ const renderLabel = (text) => renderFieldLabel(text, labelClassName, resolvedLabelStyle);
2205
+ const showError = !hideErrors && error;
2206
+ const errorNode = showError
2207
+ ? renderError
2208
+ ? renderError(error)
2209
+ : (jsx("div", { role: "alert", className: errorClassName, style: errorStyle ? Object.assign(Object.assign({}, DEFAULT_ERROR_STYLE), errorStyle) : DEFAULT_ERROR_STYLE, children: error }))
2210
+ : null;
2211
+ return (jsxs("div", { className: className, style: { width: '100%', display: 'flex', flexDirection: 'column', gap: gapStr }, children: [jsxs("div", { children: [renderLabel(labels === null || labels === void 0 ? void 0 : labels.accountNumber), jsx(OzBankAccountNumber, { style: mergeStyles(style, styles === null || styles === void 0 ? void 0 : styles.accountNumber), className: classNames === null || classNames === void 0 ? void 0 : classNames.accountNumber, placeholder: (_a = placeholders === null || placeholders === void 0 ? void 0 : placeholders.accountNumber) !== null && _a !== void 0 ? _a : 'Account number', disabled: disabled, onChange: (e) => { fieldState.current.accountNumber = e; emitChange(); }, onFocus: () => { var _a; return (_a = onFocusRef.current) === null || _a === void 0 ? void 0 : _a.call(onFocusRef, 'accountNumber'); }, onBlur: () => { var _a; return (_a = onBlurRef.current) === null || _a === void 0 ? void 0 : _a.call(onBlurRef, 'accountNumber'); }, onReady: handleAccountReady })] }), jsxs("div", { children: [renderLabel(labels === null || labels === void 0 ? void 0 : labels.routingNumber), jsx(OzBankRoutingNumber, { style: mergeStyles(style, styles === null || styles === void 0 ? void 0 : styles.routingNumber), className: classNames === null || classNames === void 0 ? void 0 : classNames.routingNumber, placeholder: (_b = placeholders === null || placeholders === void 0 ? void 0 : placeholders.routingNumber) !== null && _b !== void 0 ? _b : 'Routing number', disabled: disabled, onChange: (e) => { fieldState.current.routingNumber = e; emitChange(); }, onFocus: () => { var _a; return (_a = onFocusRef.current) === null || _a === void 0 ? void 0 : _a.call(onFocusRef, 'routingNumber'); }, onBlur: () => { var _a; return (_a = onBlurRef.current) === null || _a === void 0 ? void 0 : _a.call(onBlurRef, 'routingNumber'); }, onReady: handleRoutingReady })] }), errorNode] }));
1391
2212
  }
1392
2213
 
1393
- export { OzCard, OzCardNumber, OzCvv, OzElements, OzExpiry, useOzElements };
2214
+ export { OzBankAccountNumber, OzBankCard, OzBankRoutingNumber, OzCard, OzCardNumber, OzCvv, OzElements, OzExpiry, createFetchWaxKey, useOzElements };
1394
2215
  //# sourceMappingURL=index.esm.js.map