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