@osano/osano-cmp-react-native 0.19.0 → 0.19.2

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 (63) hide show
  1. package/README.md +2 -0
  2. package/lib/commonjs/ConsentManager.js +23 -0
  3. package/lib/commonjs/ConsentManager.js.map +1 -1
  4. package/lib/commonjs/components/Button.js +7 -2
  5. package/lib/commonjs/components/Button.js.map +1 -1
  6. package/lib/commonjs/iab/tcf/iab-storage-utils.js +53 -49
  7. package/lib/commonjs/iab/tcf/iab-storage-utils.js.map +1 -1
  8. package/lib/commonjs/iab/tcf/middleware.js +20 -23
  9. package/lib/commonjs/iab/tcf/middleware.js.map +1 -1
  10. package/lib/commonjs/iab/tcf/reducer.js +0 -22
  11. package/lib/commonjs/iab/tcf/reducer.js.map +1 -1
  12. package/lib/commonjs/index.js +16 -2
  13. package/lib/commonjs/index.js.map +1 -1
  14. package/lib/commonjs/reducer.js +5 -1
  15. package/lib/commonjs/reducer.js.map +1 -1
  16. package/lib/commonjs/services/consent.js +182 -81
  17. package/lib/commonjs/services/consent.js.map +1 -1
  18. package/lib/commonjs/utils/Translator.js +25 -20
  19. package/lib/commonjs/utils/Translator.js.map +1 -1
  20. package/lib/module/ConsentManager.js +23 -0
  21. package/lib/module/ConsentManager.js.map +1 -1
  22. package/lib/module/components/Button.js +7 -2
  23. package/lib/module/components/Button.js.map +1 -1
  24. package/lib/module/iab/tcf/iab-storage-utils.js +53 -49
  25. package/lib/module/iab/tcf/iab-storage-utils.js.map +1 -1
  26. package/lib/module/iab/tcf/middleware.js +22 -25
  27. package/lib/module/iab/tcf/middleware.js.map +1 -1
  28. package/lib/module/iab/tcf/reducer.js +1 -23
  29. package/lib/module/iab/tcf/reducer.js.map +1 -1
  30. package/lib/module/index.js +17 -3
  31. package/lib/module/index.js.map +1 -1
  32. package/lib/module/reducer.js +5 -1
  33. package/lib/module/reducer.js.map +1 -1
  34. package/lib/module/services/consent.js +178 -79
  35. package/lib/module/services/consent.js.map +1 -1
  36. package/lib/module/utils/Translator.js +25 -20
  37. package/lib/module/utils/Translator.js.map +1 -1
  38. package/lib/typescript/src/ConsentManager.d.ts +11 -1
  39. package/lib/typescript/src/ConsentManager.d.ts.map +1 -1
  40. package/lib/typescript/src/components/Button.d.ts.map +1 -1
  41. package/lib/typescript/src/iab/tcf/iab-storage-utils.d.ts +7 -7
  42. package/lib/typescript/src/iab/tcf/iab-storage-utils.d.ts.map +1 -1
  43. package/lib/typescript/src/iab/tcf/middleware.d.ts.map +1 -1
  44. package/lib/typescript/src/iab/tcf/reducer.d.ts +1 -0
  45. package/lib/typescript/src/iab/tcf/reducer.d.ts.map +1 -1
  46. package/lib/typescript/src/index.d.ts.map +1 -1
  47. package/lib/typescript/src/reducer.d.ts.map +1 -1
  48. package/lib/typescript/src/services/consent.d.ts +28 -0
  49. package/lib/typescript/src/services/consent.d.ts.map +1 -1
  50. package/lib/typescript/src/utils/Translator.d.ts +2 -0
  51. package/lib/typescript/src/utils/Translator.d.ts.map +1 -1
  52. package/package.json +4 -4
  53. package/src/ConsentManager.ts +24 -1
  54. package/src/components/Button.tsx +10 -2
  55. package/src/iab/tcf/iab-storage-utils.ts +101 -56
  56. package/src/iab/tcf/middleware.ts +36 -32
  57. package/src/iab/tcf/reducer.ts +2 -29
  58. package/src/index.tsx +15 -1
  59. package/src/reducer.ts +4 -0
  60. package/src/services/consent.ts +223 -90
  61. package/src/types/consent-config.d.ts +5 -1
  62. package/src/types/store.d.ts +5 -0
  63. package/src/utils/Translator.ts +35 -25
@@ -29,10 +29,9 @@ const CONSENTING_PLATFORM_TAG = IS_OFFICIAL_SAMPLE
29
29
  ? `${Platform.OS}.sample-react-native`
30
30
  : `${Platform.OS}.react-native`;
31
31
 
32
- const overrideConfig: Partial<Config> = {
33
- // Override config here
34
- variant: 'two',
35
- };
32
+ // Local-testing hook only — any key set here clobbers the fetched config
33
+ // (including the x-osano-* headers), so it must ship empty.
34
+ const overrideConfig: Partial<Config> = {};
36
35
 
37
36
  export type ConsentPayload = {
38
37
  customerId: string;
@@ -94,6 +93,36 @@ const lastConsentDateKey = (configId: string, userSuffix: string) =>
94
93
  const legacyLastConsentDateKey = (userSuffix: string) =>
95
94
  `${STORAGE_KEY}_last_recorded_consent_date${userSuffix}`;
96
95
 
96
+ const parseStoredTimestamp = (json: string | null | undefined): string => {
97
+ if (!json) {
98
+ return '';
99
+ }
100
+ try {
101
+ return JSON.parse(json) as string;
102
+ } catch {
103
+ return '';
104
+ }
105
+ };
106
+
107
+ const retrieveTimestampForSuffix = async (
108
+ configId: string,
109
+ userSuffix: string,
110
+ ): Promise<string> => {
111
+ try {
112
+ const scoped = parseStoredTimestamp(
113
+ await getStorage(lastConsentDateKey(configId, userSuffix)),
114
+ );
115
+ if (scoped) {
116
+ return scoped;
117
+ }
118
+ return parseStoredTimestamp(
119
+ await getStorage(legacyLastConsentDateKey(userSuffix)),
120
+ );
121
+ } catch {
122
+ return '';
123
+ }
124
+ };
125
+
97
126
  function consentRecordedRemotely(
98
127
  configId: string,
99
128
  success: boolean,
@@ -208,48 +237,47 @@ export const migrateConsentStorage = async (
208
237
  return null;
209
238
  }
210
239
 
211
- return new Promise<ConsentCategoryType[] | null>((resolve) => {
212
- const oldKey = `${STORAGE_KEY}_${cmp.configId}`; // Legacy key without user suffix
213
- const oldTimestampKey = `${STORAGE_KEY}_last_recorded_consent_date`; // Legacy timestamp key
240
+ if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
241
+ return null;
242
+ }
214
243
 
215
- switch (Platform.OS) {
216
- case 'ios':
217
- case 'android':
218
- getStorage(oldKey)
219
- .then((oldConsent) => {
220
- if (oldConsent) {
221
- let consent = JSON.parse(oldConsent) as ConsentCategoryType[];
222
- if (consent.length > 0) {
223
- consent = formatConsentArray(
224
- normalizeDoNotSellConsent(
225
- makeConsentFromCategories(consent),
226
- {
227
- enableDoNotSell: cmp.config.enableDoNotSell,
228
- doNotSellCategories: cmp.config.doNotSellCategories,
229
- },
230
- ),
231
- );
232
- // Store with new user-specific key
233
- storeConsent(cmp, consent).then(() => {
234
- // Clean up old storage
235
- clearStorage(oldKey);
236
- clearStorage(oldTimestampKey);
237
- resolve(consent);
238
- });
239
- return;
240
- }
241
- }
242
- resolve(null); // No old consent found
243
- })
244
- .catch(() => {
245
- resolve(null); // Old key not found
246
- });
247
- break;
248
- default:
249
- resolve(null);
250
- break;
244
+ const oldKey = `${STORAGE_KEY}_${cmp.configId}`;
245
+ try {
246
+ const oldConsent = await getStorage(oldKey);
247
+ if (!oldConsent) {
248
+ return null;
251
249
  }
252
- });
250
+ let consent = JSON.parse(oldConsent) as ConsentCategoryType[];
251
+ if (consent.length === 0) {
252
+ return null;
253
+ }
254
+ consent = formatConsentArray(
255
+ normalizeDoNotSellConsent(makeConsentFromCategories(consent), {
256
+ enableDoNotSell: cmp.config.enableDoNotSell,
257
+ doNotSellCategories: cmp.config.doNotSellCategories,
258
+ }),
259
+ );
260
+ const sourceTimestamp = await retrieveTimestampForSuffix(cmp.configId, '');
261
+ const parsedSourceTimestamp = new Date(sourceTimestamp);
262
+ const migrationTimestamp = Number.isNaN(parsedSourceTimestamp.getTime())
263
+ ? new Date(0)
264
+ : parsedSourceTimestamp;
265
+ await storeConsent(cmp, consent, undefined, migrationTimestamp);
266
+ await Promise.all(
267
+ [
268
+ oldKey,
269
+ lastConsentDateKey(cmp.configId, ''),
270
+ legacyLastConsentDateKey(''),
271
+ ].map(async (key) => {
272
+ try {
273
+ await clearStorage(key);
274
+ } catch {}
275
+ }),
276
+ );
277
+ return consent;
278
+ } catch {
279
+ return null;
280
+ }
253
281
  };
254
282
 
255
283
  // The consent to fall back to when a visitor has no stored consent yet. Flavor 7
@@ -284,6 +312,25 @@ const clearToFirstLoadDefault = async (
284
312
  return seed;
285
313
  };
286
314
 
315
+ const retrieveStoredConsent = async (
316
+ cmp: ConsentManager,
317
+ ): Promise<ConsentCategoryType[] | null> => {
318
+ const hashedExtUsrData = hashExtUsrData(
319
+ cmp.customerId,
320
+ cmp.configId,
321
+ cmp.extUsrData,
322
+ );
323
+ const userSuffix = hashedExtUsrData ? `_${hashedExtUsrData}` : '';
324
+ try {
325
+ const stored = await getStorage(
326
+ `${STORAGE_KEY}_${cmp.configId}${userSuffix}`,
327
+ );
328
+ return stored ? (JSON.parse(stored) as ConsentCategoryType[]) : null;
329
+ } catch {
330
+ return null;
331
+ }
332
+ };
333
+
287
334
  // After an expired/invalid record, settle on a persisted essential-only state.
288
335
  // Persisting (rather than only clearing the payload with clearConsent) keeps a
289
336
  // subsequent load in this safe state instead of falling back to the first-load
@@ -293,17 +340,47 @@ const clearToFirstLoadDefault = async (
293
340
  // storage would otherwise re-seed the first-load default.
294
341
  const settleEssentialOnly = (
295
342
  cmp: ConsentManager,
296
- ): Promise<ConsentCategoryType[]> =>
297
- storeConsent(cmp, [ConsentCategory.ESSENTIAL]);
343
+ // Keep invalidation sticky until the visitor records a new choice.
344
+ consentDate?: string | Date,
345
+ ): Promise<ConsentCategoryType[]> => {
346
+ const categories: ConsentCategoryType[] = [ConsentCategory.ESSENTIAL];
347
+ if (
348
+ cmp.config?.isDoNotSellVisible &&
349
+ (cmp.config.enableDoNotSellDefault ||
350
+ cmp.consent?.includes(ConsentCategory.OPT_OUT))
351
+ ) {
352
+ categories.push(ConsentCategory.OPT_OUT);
353
+ }
354
+ return storeConsent(cmp, categories, undefined, consentDate);
355
+ };
356
+
357
+ const markConsentInvalidated = (cmp: ConsentManager) => {
358
+ cmp.consentInvalidated = true;
359
+ cmp.consentPromptRequired = true;
360
+ };
298
361
 
299
362
  const retrieveConsent = async (cmp: ConsentManager) => {
300
- // If stored consent has aged past its expiration window (production
301
- // per-country interval, or the debug testing override), settle on a persisted
302
- // essential-only state so callers see essential-only and a later load stays
303
- // here (mirrors Android's clearExpiredConsent() + essential-only
304
- // getConsentedCategories()).
305
- if (await isConsentExpired(cmp)) {
306
- return settleEssentialOnly(cmp);
363
+ // Invalidate stored consent for either independent reason — the record
364
+ // predates the forceReconsent stamp, or it aged past its expiration window —
365
+ // and settle on essential-only. Only read the timestamp when there is a stamp
366
+ // or window to evaluate, so a no-signal load skips the read as the previous
367
+ // expiry-only path did. `consentInvalidated` (read in index.tsx) drives the
368
+ // auto re-prompt; it is set only for a previously recorded consent, never on
369
+ // the first-run path.
370
+ const forceSeconds = effectiveForceReconsentSeconds(cmp);
371
+ const expirationSeconds = await effectiveExpirationSeconds(cmp);
372
+ if (forceSeconds !== null || expirationSeconds !== null) {
373
+ const timestamp = await retrieveLastConsentTimestamp(cmp);
374
+ const expired = isTimestampExpired(timestamp, expirationSeconds);
375
+ const forced = predatesForceReconsent(cmp, timestamp);
376
+ if (expired || forced) {
377
+ const storedConsent = await retrieveStoredConsent(cmp);
378
+ if (storedConsent?.length) {
379
+ cmp.consent = storedConsent;
380
+ markConsentInvalidated(cmp);
381
+ return settleEssentialOnly(cmp, timestamp);
382
+ }
383
+ }
307
384
  }
308
385
  return new Promise<ConsentCategoryType[]>((resolve) => {
309
386
  const resConsent = (consentJson: string | null | undefined) => {
@@ -337,7 +414,15 @@ const retrieveConsent = async (cmp: ConsentManager) => {
337
414
  // Try migration if new key not found
338
415
  const migratedConsent = await migrateConsentStorage(cmp);
339
416
  if (migratedConsent) {
340
- resolve(migratedConsent);
417
+ const timestamp = await retrieveLastConsentTimestamp(cmp);
418
+ const expired = isTimestampExpired(timestamp, expirationSeconds);
419
+ const forced = predatesForceReconsent(cmp, timestamp);
420
+ if (expired || forced) {
421
+ markConsentInvalidated(cmp);
422
+ resolve(await settleEssentialOnly(cmp, timestamp));
423
+ } else {
424
+ resolve(migratedConsent);
425
+ }
341
426
  } else {
342
427
  // No consent found, seed the first-load default
343
428
  clearToFirstLoadDefault(cmp).then(resolve);
@@ -365,33 +450,7 @@ const retrieveLastConsentTimestamp = async (
365
450
  cmp.extUsrData,
366
451
  );
367
452
  const userSuffix = hashedExtUsrData ? `_${hashedExtUsrData}` : '';
368
-
369
- const parse = (json: string | null | undefined): string => {
370
- if (!json) {
371
- return '';
372
- }
373
- try {
374
- return JSON.parse(json) as string;
375
- } catch {
376
- return '';
377
- }
378
- };
379
-
380
- try {
381
- const scoped = parse(
382
- await getStorage(lastConsentDateKey(cmp.configId, userSuffix)),
383
- );
384
- if (scoped) {
385
- return scoped;
386
- }
387
- // Fall back to the legacy un-scoped key for installs written before the
388
- // timestamp was scoped by configId.
389
- return parse(await getStorage(legacyLastConsentDateKey(userSuffix)));
390
- } catch {
391
- // Fail closed: a storage rejection resolves to no timestamp instead of
392
- // hanging the consent load.
393
- return '';
394
- }
453
+ return retrieveTimestampForSuffix(cmp.configId, userSuffix);
395
454
  };
396
455
 
397
456
  /**
@@ -436,6 +495,72 @@ const isTimestampExpired = (
436
495
  return Date.now() - consentedAtMs > seconds * 1000;
437
496
  };
438
497
 
498
+ /**
499
+ * Resolves the effective `forceReconsent` signal in whole seconds, validating
500
+ * before trusting it. The raw field ships over the network and is human-edited,
501
+ * so treat any malformed value as "no signal" (null) rather than invalidating
502
+ * every consent:
503
+ *
504
+ * - absent / null → null (no signal)
505
+ * - <= 0 → null (guards zero and negatives)
506
+ * - in the future → null (clock skew, or a value mistakenly stamped in
507
+ * milliseconds, which reads as ~1000× "now" ⇒ future)
508
+ *
509
+ * Failing toward *not* invalidating means the worst a bad value causes is a
510
+ * missed prompt, never a permanent lockout.
511
+ */
512
+ export const effectiveForceReconsentSeconds = (
513
+ cmp: ConsentManager,
514
+ ): number | null => {
515
+ const raw: unknown = cmp.config?.forceReconsent;
516
+ if (raw == null) {
517
+ return null;
518
+ }
519
+ const seconds =
520
+ typeof raw === 'number'
521
+ ? raw
522
+ : typeof raw === 'string' && raw.trim() !== ''
523
+ ? Number(raw)
524
+ : Number.NaN;
525
+ if (!Number.isFinite(seconds) || seconds <= 0) {
526
+ return null;
527
+ }
528
+ if (seconds > Math.floor(Date.now() / 1000)) {
529
+ return null;
530
+ }
531
+ return seconds;
532
+ };
533
+
534
+ /**
535
+ * True when a consent record predates the server's `forceReconsent` stamp.
536
+ * `timestamp` is the recorded-consent instant (stored as an ISO/ms string).
537
+ *
538
+ * Unit alignment is the subtle part: the signal is in SECONDS and the timestamp
539
+ * is in MILLISECONDS, so the signal is scaled up (`* 1000`). The boundary is
540
+ * strict `<` — consent recorded exactly at the stamp instant stays valid.
541
+ *
542
+ * A missing/unparseable timestamp fails closed (returns false), so first-run
543
+ * (no timestamp) is not wrongly invalidated. `storeConsent` always co-stamps,
544
+ * so a consented-but-timestampless record is unreachable here anyway.
545
+ */
546
+ export const predatesForceReconsent = (
547
+ cmp: ConsentManager,
548
+ timestamp: string | number | Date | null | undefined,
549
+ ): boolean => {
550
+ const forceSeconds = effectiveForceReconsentSeconds(cmp);
551
+ if (forceSeconds === null) {
552
+ return false;
553
+ }
554
+ if (!timestamp) {
555
+ return false;
556
+ }
557
+ const consentedAtMs = new Date(timestamp).getTime();
558
+ if (Number.isNaN(consentedAtMs)) {
559
+ return false;
560
+ }
561
+ return consentedAtMs < forceSeconds * 1000;
562
+ };
563
+
439
564
  /**
440
565
  * Returns true when the locally stored consent has aged past its expiration
441
566
  * window (see {@link effectiveExpirationSeconds}).
@@ -455,6 +580,8 @@ export const recordConsent = (
455
580
  cmp: ConsentManager,
456
581
  consent?: ConsentCategoryType[],
457
582
  ) => {
583
+ cmp.consentInvalidated = false;
584
+ cmp.consentPromptRequired = false;
458
585
  const payload = createConsentRecordPayload(cmp, consent);
459
586
  return API.post(`${CONSENT_URI}/record`, payload, {
460
587
  referrer: cmp.consentingDomain,
@@ -539,18 +666,22 @@ export const fetchConsent = async (cmp: ConsentManager) => {
539
666
 
540
667
  // Evaluate the remote record against the server's own timestamp (when the
541
668
  // visitor actually consented), not "now", so an already-expired remote
542
- // record is never restored and re-dated as fresh.
669
+ // record is never restored and re-dated as fresh. A record that predates
670
+ // the server's forceReconsent stamp is stale for the same reason — applying
671
+ // it would silently re-grant the very consent the signal invalidated.
543
672
  const seconds = await effectiveExpirationSeconds(cmp);
544
- const remoteExpired = isTimestampExpired(lastConsentTimestamp, seconds);
673
+ const remoteForced = predatesForceReconsent(cmp, lastConsentTimestamp);
674
+ const remoteStale =
675
+ isTimestampExpired(lastConsentTimestamp, seconds) || remoteForced;
545
676
 
546
677
  // If extUsrData is not null and not empty, always use remote consent (cross-device consent enabled)
547
678
  if (extUsrData && extUsrData.trim() !== '') {
548
- if (remoteExpired) {
549
- // The authoritative remote record has expired; settle on a persisted
550
- // essential-only state rather than restoring stale consent, so a
551
- // subsequent load stays here instead of falling back to the
552
- // first-load default.
553
- return settleEssentialOnly(cmp);
679
+ if (remoteStale) {
680
+ // The authoritative remote record is stale (expired or predates the
681
+ // stamp); settle essential-only and flag the invalidation rather than
682
+ // restoring it.
683
+ markConsentInvalidated(cmp);
684
+ return settleEssentialOnly(cmp, lastConsentTimestamp);
554
685
  }
555
686
  const remoteConsentCategories = formatConsentArray(
556
687
  normalizeDoNotSellConsent(
@@ -573,6 +704,7 @@ export const fetchConsent = async (cmp: ConsentManager) => {
573
704
  true,
574
705
  lastConsentTimestamp,
575
706
  );
707
+ cmp.consentPromptRequired = false;
576
708
  return remoteConsentCategories;
577
709
  }
578
710
 
@@ -580,7 +712,7 @@ export const fetchConsent = async (cmp: ConsentManager) => {
580
712
  const internalTimestamp = await retrieveLastConsentTimestamp(cmp);
581
713
  try {
582
714
  if (
583
- !remoteExpired &&
715
+ !remoteStale &&
584
716
  (new Date(lastConsentTimestamp) > new Date(internalTimestamp) ||
585
717
  internalTimestamp === '')
586
718
  ) {
@@ -598,6 +730,7 @@ export const fetchConsent = async (cmp: ConsentManager) => {
598
730
  true,
599
731
  lastConsentTimestamp,
600
732
  );
733
+ cmp.consentPromptRequired = false;
601
734
 
602
735
  return remoteConsentCategories;
603
736
  }
@@ -56,7 +56,11 @@ export type Config = {
56
56
  customerId: string;
57
57
  domains: Array<string>;
58
58
  forceManagePreferences: boolean;
59
- forceReconsent?: Date;
59
+ // Server-stamped Unix timestamp in whole SECONDS. When present, consent
60
+ // recorded before this instant is treated as no longer valid, re-prompting the
61
+ // visitor. Read-only on the client (the server authors it); absent/undefined
62
+ // means "no signal". See src/services/consent.ts effectiveForceReconsentSeconds.
63
+ forceReconsent?: number;
60
64
  gdprApplies?: boolean;
61
65
  googleConsent: boolean;
62
66
  gpcSupport: boolean;
@@ -30,6 +30,11 @@ export type OsanoState = {
30
30
  scrollToType: string;
31
31
  scrollToId: number;
32
32
  stack: string[];
33
+ // Transient marker set by a RELOAD whose consent was invalidated (expired or
34
+ // predating the forceReconsent stamp). The IAB TCF middleware reads it on
35
+ // RELOAD/SET_CONFIG to wipe stale TC strings instead of rehydrating them.
36
+ // Reset on SAVE_BEGIN (the user re-consented) and on the next clean load.
37
+ consentInvalidated?: boolean;
33
38
  };
34
39
 
35
40
  export type ActionCreator<S, T> = (payload?: T) => OsanoAction<S, T>;
@@ -100,6 +100,8 @@ export default class Translator extends EventEmitter {
100
100
  private _langLoadFailureSubscription?: EventSubscription;
101
101
 
102
102
  private _loading: Record<string, Promise<string | undefined>> = {};
103
+ private _loadedCustomerId?: string;
104
+ private _loadedConfigId?: string;
103
105
  load(
104
106
  desiredLocale?: string,
105
107
  onLangLoadSuccess?: () => void,
@@ -114,6 +116,16 @@ export default class Translator extends EventEmitter {
114
116
  const locale = allLocales.shift();
115
117
  const customerId = this._consentManager.customerId;
116
118
  const configId = this._consentManager.configId;
119
+ // Overrides are keyed only by locale — stale copy from a previous
120
+ // customer/config would survive the switch and skip the refetch below.
121
+ if (
122
+ customerId !== this._loadedCustomerId ||
123
+ configId !== this._loadedConfigId
124
+ ) {
125
+ this._overrides = {};
126
+ this._loadedCustomerId = customerId;
127
+ this._loadedConfigId = configId;
128
+ }
117
129
  this._langLoadFailureSubscription =
118
130
  onLangLoadFailure && this.on('langLoadFailure', onLangLoadFailure);
119
131
  this._langLoadSuccessSubscription =
@@ -284,40 +296,38 @@ export default class Translator extends EventEmitter {
284
296
  const pathStr = Array.isArray(path) ? path.join('.') : path;
285
297
  const fallback =
286
298
  typeof defaultPhrase === 'string' ? defaultPhrase : `${pathStr}`;
287
- const phrases = locales[`${locale}`];
288
- const override = this._overrides[`${locale}`];
289
299
 
290
300
  let translation: string | number | LocalizedStrings | undefined;
291
301
 
292
- // Priority-based translation lookup
293
- const searchPaths = [
294
- // Mobile Customer-specific
295
- [override, `mobile.${pathStr}`],
296
- // Web Customer-specific
297
- [override, pathStr],
298
- // Mobile Global
299
- [phrases, `mobile.${pathStr}`],
300
- // Web Global
301
- [phrases, pathStr],
302
- ] as [Record<string, string>, string][];
303
- // Search in customer-specific overrides first, then global phrases
302
+ // load() stores phrases under whatever locale it managed to fetch (e.g.
303
+ // en-us.json may 403 while en.json exists), so search every fallback
304
+ // bucket. Overrides beat built-in phrases only within the active language
305
+ // — customer copy loaded via the English fallback must not repaint a
306
+ // non-English UI.
307
+ const normalized = locale.toLowerCase().replace(/_/g, '-');
308
+ const lookupLocales = [
309
+ ...new Set([locale, ...Translator.fallbackLanguages(normalized)]),
310
+ ].filter((lookupLocale): lookupLocale is string => !!lookupLocale);
311
+ const baseLanguage = normalized.split('-')[0];
312
+ const overrideLocales = lookupLocales.filter(
313
+ (lookupLocale) =>
314
+ lookupLocale === locale || lookupLocale.split('-')[0] === baseLanguage,
315
+ );
316
+ const searchPaths: [LocalizedStrings | undefined, string][] = [];
317
+ for (const lookupLocale of overrideLocales) {
318
+ const override = this._overrides[`${lookupLocale}`];
319
+ searchPaths.push([override, `mobile.${pathStr}`], [override, pathStr]);
320
+ }
321
+ for (const lookupLocale of lookupLocales) {
322
+ const phrases = locales[`${lookupLocale}`];
323
+ searchPaths.push([phrases, `mobile.${pathStr}`], [phrases, pathStr]);
324
+ }
304
325
  for (const [source, searchPath] of searchPaths) {
305
326
  if (source) {
306
327
  translation = get(source, searchPath);
307
328
  if (translation) break;
308
329
  }
309
330
  }
310
- // Base translatedResourceBundle (fallback to default locale)
311
- if (translation == null && locale !== Translator.DEFAULT_LANGUAGE) {
312
- const defaultPhrases = locales[Translator.DEFAULT_LANGUAGE];
313
- if (defaultPhrases) {
314
- translation = get(
315
- defaultPhrases,
316
- `mobile.${pathStr}`,
317
- get(defaultPhrases, pathStr),
318
- );
319
- }
320
- }
321
331
  // Use fallback if nothing found
322
332
  if (!translation) {
323
333
  translation = fallback;