@namiml/sdk-core 3.4.0-dev.202605141714 → 3.4.0-dev.202605182046

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -98,7 +98,7 @@ const {
98
98
  // version — stamped by scripts/version.sh
99
99
  NAMI_SDK_VERSION: exports.NAMI_SDK_VERSION = "3.4.0",
100
100
  // full package version including dev suffix — stamped by scripts/version.sh
101
- NAMI_SDK_PACKAGE_VERSION: exports.NAMI_SDK_PACKAGE_VERSION = "3.4.0-dev.202605141714",
101
+ NAMI_SDK_PACKAGE_VERSION: exports.NAMI_SDK_PACKAGE_VERSION = "3.4.0-dev.202605182046",
102
102
  // environments
103
103
  PRODUCTION: exports.PRODUCTION = "production", DEVELOPMENT: exports.DEVELOPMENT = "development",
104
104
  // error messages
@@ -7404,19 +7404,34 @@ const mapAnonymousCampaigns = (campaigns, splitPosition, formFactor) => {
7404
7404
  });
7405
7405
  };
7406
7406
  /**
7407
+ * Returns the combined list of API + initial-config campaigns for the current device's
7408
+ * form factor, deduplicated by (type, value) with API entries winning on collision.
7407
7409
  *
7408
- * @returns A combined list of unique campaigns based on both API and Initial, filtered by form factor.
7409
- * This is used to get all campaigns that are applicable to the current device.
7410
+ * Mirrors NamiCampaignManager.allCampaigns() on Apple (Set keyed on hash(value, type))
7411
+ * and Android (.distinct() with server-first ordering). Keying on (type, value) rather
7412
+ * than rule UUID — ensures the same logical placement does not surface twice when the
7413
+ * server has republished a campaign under a new rule id.
7410
7414
  *
7411
- * Note: Since this function returns a unique list of campaigns, and API campaigns take precedence,
7412
- * there may be times when API campaigns are returned that do not yet have paywalls but initial campaigns would.
7415
+ * Note: this list reflects which placements are available, not whether they are launchable.
7416
+ * An API entry surfaced here may reference a paywall that has not yet been published in the
7417
+ * API response. NamiCampaignManager.launch() handles that case independently via
7418
+ * getPaywallDataFromLabel(), which falls back to the initial-config (campaign, paywall) pair
7419
+ * when the API pair is incomplete — matching the merged-paywall-lookup behavior on
7420
+ * Apple and Android.
7413
7421
  */
7414
7422
  const allCampaigns = () => {
7415
7423
  const apiCampaigns = storageService.getCampaignRules(exports.API_CAMPAIGN_RULES) ?? [];
7416
7424
  const initialCampaigns = storageService.getCampaignRules(exports.INITIAL_CAMPAIGN_RULES) ?? [];
7417
7425
  const formFactor = getDeviceFormFactor();
7418
7426
  const campaigns = compact([...apiCampaigns, ...initialCampaigns]).filter((cRule) => cRule.form_factors?.some((f) => f.form_factor === formFactor));
7419
- return uniqBy(campaigns, "rule");
7427
+ const seen = new Set();
7428
+ return campaigns.filter((c) => {
7429
+ const key = `${c.type ?? ''}::${c.value ?? ''}`;
7430
+ if (seen.has(key))
7431
+ return false;
7432
+ seen.add(key);
7433
+ return true;
7434
+ });
7420
7435
  };
7421
7436
  /**
7422
7437
  * Get campaigns by rule key, filtered by form factor.
@@ -13670,6 +13685,19 @@ class NamiFlow extends BasicNamiFlow {
13670
13685
  this.forward(nextStep.id);
13671
13686
  }
13672
13687
  }
13688
+ handleRemoteBack() {
13689
+ const step = this.currentFlowStep;
13690
+ if (step?.actions[NamiReservedActions.REMOTE_BACK]) {
13691
+ this.triggerActions(NamiReservedActions.REMOTE_BACK);
13692
+ return 'handler';
13693
+ }
13694
+ if (this.previousStepAvailable) {
13695
+ this.back();
13696
+ return 'back';
13697
+ }
13698
+ this.finished();
13699
+ return 'dismissed';
13700
+ }
13673
13701
  backToPreviousScreenStep() {
13674
13702
  if (this.previousFlowStep?.allow_back_to === false) {
13675
13703
  logger.warn(`Not allowed to go back to ${this.previousFlowStep.id}`);
@@ -63913,6 +63941,230 @@ const convertLocale = (locale) => {
63913
63941
  return intlLocale.language + (intlLocale.region ?? intlLocale.script ?? '');
63914
63942
  };
63915
63943
 
63944
+ /**
63945
+ * Components that are skipped entirely (they and their subtree do not
63946
+ * contribute text to the page-level announcement).
63947
+ *
63948
+ * - Buttons / interactive controls: their label is appended as the
63949
+ * focused-button suffix, not collected mid-tree. Recursing into them
63950
+ * would double-count the label and pull in non-spoken decorative text.
63951
+ * - Image-like leaves: explicitly skipped per spec.
63952
+ * - Form pickers + QR codes + raw video: not informational text.
63953
+ */
63954
+ const SKIP_COMPONENTS = new Set([
63955
+ 'button',
63956
+ 'playPauseButton',
63957
+ 'volumeButton',
63958
+ 'toggleButton',
63959
+ 'toggleSwitch',
63960
+ 'radio',
63961
+ 'image',
63962
+ 'svgImage',
63963
+ 'symbol',
63964
+ 'qrCode',
63965
+ 'videoUrl',
63966
+ 'segmentPicker',
63967
+ 'segmentPickerItem',
63968
+ ]);
63969
+ /** Component values in {@link SKIP_COMPONENTS} that are interactive controls
63970
+ * (as opposed to images / media / non-text leaves). When a text component
63971
+ * shares a container with one of these, the text is treated as that
63972
+ * button's accessible label and excluded from the page composite. */
63973
+ const BUTTON_COMPONENTS = new Set([
63974
+ 'button',
63975
+ 'playPauseButton',
63976
+ 'volumeButton',
63977
+ 'toggleButton',
63978
+ 'toggleSwitch',
63979
+ 'radio',
63980
+ ]);
63981
+ /** `namiComponentType` values that are always included in the page
63982
+ * composite — even when their structural position (e.g. a sibling of a
63983
+ * utility button) would otherwise mark them for exclusion. Driven by the
63984
+ * spec's schema table:
63985
+ *
63986
+ * | `namiComponentType: "legalText"` | Legal/T&C content (included in tree-walk) |
63987
+ */
63988
+ const ALWAYS_INCLUDE_NAMI_TYPES = new Set(['legalText']);
63989
+ /**
63990
+ * Whether a text leaf inside `parent` should be skipped because it acts as
63991
+ * the accessible label for a sibling button.
63992
+ *
63993
+ * Schema pattern this catches: a container whose direct children include
63994
+ * BOTH a text/text-list AND an interactive button (e.g. Page 3's "Login
63995
+ * Group" — `[ text "Already an NFL+ subscriber?", loginButton "Sign In" ]`).
63996
+ * The text describes the button and is announced when the button is
63997
+ * focused, NOT as page-level content. Without this rule, the composite
63998
+ * leaks "Already an NFL+ subscriber? Subscribe to NFL plus button" instead
63999
+ * of "Subscribe to NFL plus button".
64000
+ *
64001
+ * Texts marked `namiComponentType: "legalText"` are exempt — the spec
64002
+ * explicitly opts legal copy in regardless of its container's other
64003
+ * children (e.g. Page 5's "Bottom Wrapper" mixes a body text, a restore
64004
+ * button, and the legal text — the legal text must still be spoken).
64005
+ */
64006
+ function isButtonSiblingLabel(node, parent) {
64007
+ if (!parent || !Array.isArray(parent.components))
64008
+ return false;
64009
+ if (typeof node.namiComponentType === 'string' &&
64010
+ ALWAYS_INCLUDE_NAMI_TYPES.has(node.namiComponentType)) {
64011
+ return false;
64012
+ }
64013
+ for (const sibling of parent.components) {
64014
+ if (sibling && typeof sibling === 'object') {
64015
+ const s = sibling;
64016
+ if (typeof s.component === 'string' && BUTTON_COMPONENTS.has(s.component)) {
64017
+ return true;
64018
+ }
64019
+ }
64020
+ }
64021
+ return false;
64022
+ }
64023
+ /**
64024
+ * Recursively collect spoken text from a component subtree following the
64025
+ * TV Full-Page Announcement contract.
64026
+ *
64027
+ * Rules (in order):
64028
+ * 1. `null` / `undefined` → contribute nothing.
64029
+ * 2. `hidden: true` → skip the node AND its subtree.
64030
+ * 3. `component` in {@link SKIP_COMPONENTS} → skip the node AND its subtree.
64031
+ * 4. `screenreaderText` set on this container → take that string verbatim
64032
+ * and do NOT recurse (server-supplied override).
64033
+ * 5. `component: "text"` or `"text-list"` → collect, UNLESS the text is a
64034
+ * sibling label for an adjacent button (see
64035
+ * {@link isButtonSiblingLabel}). `namiComponentType: "legalText"` is
64036
+ * exempted from the sibling-label exclusion.
64037
+ * 6. Otherwise → recurse into `components`.
64038
+ */
64039
+ function collectText(node, parent = null) {
64040
+ if (!node || typeof node !== 'object')
64041
+ return [];
64042
+ const n = node;
64043
+ if (n.hidden === true)
64044
+ return [];
64045
+ if (typeof n.component === 'string' && SKIP_COMPONENTS.has(n.component))
64046
+ return [];
64047
+ // Container-level override: when an enclosing container pre-supplies its own
64048
+ // screenreader text, take it verbatim and stop descending — the customer
64049
+ // has authored a polished announcement for this subtree.
64050
+ if (typeof n.screenreaderText === 'string' && n.screenreaderText.trim().length > 0) {
64051
+ return [n.screenreaderText.trim()];
64052
+ }
64053
+ if (n.component === 'text') {
64054
+ if (isButtonSiblingLabel(n, parent))
64055
+ return [];
64056
+ return typeof n.text === 'string' && n.text.length > 0 ? [n.text] : [];
64057
+ }
64058
+ if (n.component === 'text-list') {
64059
+ if (isButtonSiblingLabel(n, parent))
64060
+ return [];
64061
+ if (!Array.isArray(n.texts))
64062
+ return [];
64063
+ return n.texts.filter((t) => typeof t === 'string' && t.length > 0);
64064
+ }
64065
+ if (Array.isArray(n.components)) {
64066
+ const out = [];
64067
+ for (const child of n.components) {
64068
+ out.push(...collectText(child, n));
64069
+ }
64070
+ return out;
64071
+ }
64072
+ return [];
64073
+ }
64074
+ function collectHeaderFooter(slot) {
64075
+ if (!Array.isArray(slot))
64076
+ return [];
64077
+ // The slot itself acts as the synthetic parent so a top-level text inside
64078
+ // a header/footer that sits alongside a top-level button is treated as a
64079
+ // button label and excluded.
64080
+ const syntheticParent = { components: slot };
64081
+ const out = [];
64082
+ for (const node of slot) {
64083
+ out.push(...collectText(node, syntheticParent));
64084
+ }
64085
+ return out;
64086
+ }
64087
+ /**
64088
+ * Join an ordered list of collected text fragments into a single speakable
64089
+ * string. Each fragment is trimmed; empty fragments are dropped. Author
64090
+ * terminators (`.`, `!`, `?`) are preserved so emphasis carries through to
64091
+ * the screenreader; only fragments that lack a terminator get `". "`
64092
+ * inserted before the next fragment. The result is a string that reads
64093
+ * naturally without double-punctuation like `"...REACH!. Watch..."`.
64094
+ */
64095
+ function joinFragments(fragments) {
64096
+ let out = '';
64097
+ for (const raw of fragments) {
64098
+ const trimmed = raw.trim();
64099
+ if (!trimmed)
64100
+ continue;
64101
+ if (!out) {
64102
+ out = trimmed;
64103
+ continue;
64104
+ }
64105
+ const last = out[out.length - 1];
64106
+ const endsWithTerminator = last === '.' || last === '!' || last === '?';
64107
+ out += (endsWithTerminator ? ' ' : '. ') + trimmed;
64108
+ }
64109
+ return out;
64110
+ }
64111
+ /**
64112
+ * Build the full-screen announcement string for a paywall page per the
64113
+ * TV Full-Page Announcement contract
64114
+ * (https://linear.app/nami-product-development/document/tv-full-page-announcement-980f61b96e7c).
64115
+ *
64116
+ * Behaviour:
64117
+ * 1. **Override path.** If `page.screenreaderText` is set, that string is
64118
+ * returned verbatim (followed by `focusedButtonLabel` if provided and
64119
+ * not already present at the tail). Server-authored polished
64120
+ * announcements win over client-side aggregation.
64121
+ *
64122
+ * 2. **Tree-walk fallback.** Otherwise, walk the page's containers in
64123
+ * reading order — `header` → `backgroundContainer` → `contentContainer`
64124
+ * → `footer` — collecting visible `text` / `text-list` descendants in
64125
+ * source order. `image`, `hidden: true` subtrees, interactive controls
64126
+ * (buttons / toggles), and non-text leaves are skipped. The
64127
+ * `focusedButtonLabel` is appended as the final sentence.
64128
+ *
64129
+ * SDKs invoke this once per page on the first focus of the default CTA, and
64130
+ * speak the result via their platform TTS API. On subsequent focuses within
64131
+ * the same page, the SDK announces only the focused element's own label —
64132
+ * this helper is NOT re-called.
64133
+ *
64134
+ * @param page The paywall page to announce.
64135
+ * @param focusedButtonLabel The resolved label of the button that
64136
+ * triggered the announcement (typically the
64137
+ * page's default-focused CTA). For
64138
+ * subscription-plan CTAs this is the dynamic
64139
+ * `"{price} {period} subscription plan button"`
64140
+ * string the backend already resolved.
64141
+ * @returns A single string ready to pass to a platform TTS API. Empty when
64142
+ * the page has no spoken content and no focused button label.
64143
+ */
64144
+ function aggregateScreenreaderText(page, focusedButtonLabel = '') {
64145
+ const trimmedLabel = focusedButtonLabel.trim();
64146
+ // Override path
64147
+ if (typeof page.screenreaderText === 'string' && page.screenreaderText.trim().length > 0) {
64148
+ const override = page.screenreaderText.trim();
64149
+ if (!trimmedLabel)
64150
+ return override;
64151
+ // If the server-authored override already ends with the focused button
64152
+ // label, don't append it again.
64153
+ if (override.toLowerCase().endsWith(trimmedLabel.toLowerCase()))
64154
+ return override;
64155
+ return joinFragments([override, trimmedLabel]);
64156
+ }
64157
+ // Tree-walk path
64158
+ const fragments = [];
64159
+ fragments.push(...collectHeaderFooter(page.header));
64160
+ fragments.push(...collectText(page.backgroundContainer));
64161
+ fragments.push(...collectText(page.contentContainer));
64162
+ fragments.push(...collectHeaderFooter(page.footer));
64163
+ if (trimmedLabel)
64164
+ fragments.push(trimmedLabel);
64165
+ return joinFragments(fragments);
64166
+ }
64167
+
63916
64168
  function namiBuySKU(skuRefId) {
63917
64169
  if (!hasPurchaseManagement("NamiPurchaseManager.buySKU")) {
63918
64170
  return;
@@ -63982,6 +64234,7 @@ exports.SimpleEventTarget = SimpleEventTarget;
63982
64234
  exports.StorageService = StorageService;
63983
64235
  exports.activateEntitlementByPurchase = activateEntitlementByPurchase;
63984
64236
  exports.activeEntitlements = activeEntitlements;
64237
+ exports.aggregateScreenreaderText = aggregateScreenreaderText;
63985
64238
  exports.allCampaigns = allCampaigns;
63986
64239
  exports.allPaywalls = allPaywalls;
63987
64240
  exports.applyEntitlementActivation = applyEntitlementActivation;
package/dist/index.d.ts CHANGED
@@ -113,6 +113,7 @@ declare const NamiFlowStepType: {
113
113
  readonly UNKNOWN: "unknown";
114
114
  };
115
115
  type NamiFlowStepType = (typeof NamiFlowStepType)[keyof typeof NamiFlowStepType];
116
+ type RemoteBackOutcome = 'handler' | 'back' | 'dismissed';
116
117
  declare enum NamiFlowActionFunction {
117
118
  NAVIGATE = "flowNav",
118
119
  BACK = "flowPrev",
@@ -772,6 +773,31 @@ interface TBaseComponent {
772
773
  context?: {
773
774
  [key: string]: any;
774
775
  };
776
+ /**
777
+ * Text spoken by the platform screenreader. Plays two roles depending on
778
+ * where it is set, per the TV Full-Page Announcement contract
779
+ * (https://linear.app/nami-product-development/document/tv-full-page-announcement-980f61b96e7c):
780
+ *
781
+ * - **On focusable elements** (buttons, toggles, etc.): the element's own
782
+ * label, spoken when the element receives focus. For subscription-plan
783
+ * CTAs the backend resolves this dynamically to
784
+ * `"{price} {period} [{tier}] subscription plan button"`.
785
+ *
786
+ * - **On containers** (any TBaseComponent-derived container, and TPages):
787
+ * an optional *server-supplied composite override*. When set, SDKs
788
+ * announce this verbatim on first focus of the page's default CTA
789
+ * instead of tree-walking the page's text. When omitted, SDKs derive
790
+ * the composite by walking visible `text` / `text-list` descendants in
791
+ * source order (skipping `image`, `hidden: true`, and non-text leaves)
792
+ * and appending the focused button's label.
793
+ */
794
+ screenreaderText?: string;
795
+ /**
796
+ * Supplementary hint announced after `screenreaderText` on the element it
797
+ * is set on (e.g. "Double-tap to subscribe"). Optional; SDKs that lack a
798
+ * hint affordance may omit it.
799
+ */
800
+ screenreaderHint?: string;
775
801
  moveX?: number | string;
776
802
  moveY?: string | number;
777
803
  direction?: DirectionType;
@@ -1404,6 +1430,7 @@ declare class NamiFlow extends BasicNamiFlow {
1404
1430
  finished(): void;
1405
1431
  back(): void;
1406
1432
  next(): void;
1433
+ handleRemoteBack(): RemoteBackOutcome;
1407
1434
  private backToPreviousScreenStep;
1408
1435
  forward(stepId: string): void;
1409
1436
  pause(): void;
@@ -1701,6 +1728,22 @@ type TPages = {
1701
1728
  contentContainer: TContainer | null;
1702
1729
  footer: THeaderFooter;
1703
1730
  backgroundContainer: TContainer | null;
1731
+ /**
1732
+ * Optional server-supplied composite announcement for the page. When set,
1733
+ * TV SDKs speak this verbatim on first focus of the default CTA instead of
1734
+ * tree-walking the page's text content. When omitted, SDKs derive the
1735
+ * composite client-side from visible `text` / `text-list` descendants (in
1736
+ * source order, skipping `image`, `hidden: true`, and non-text leaves) and
1737
+ * append the focused button's label.
1738
+ *
1739
+ * Video pages are detected by the presence of `component: "videoUrl"` in
1740
+ * `backgroundContainer` with `autoplayVideo: true` and `loopVideo: false`
1741
+ * — TTS is deferred until playback completes. No additional schema flag is
1742
+ * required for that suppression.
1743
+ *
1744
+ * Contract: https://linear.app/nami-product-development/document/tv-full-page-announcement-980f61b96e7c
1745
+ */
1746
+ screenreaderText?: string;
1704
1747
  };
1705
1748
  type TInitialState = {
1706
1749
  slides?: TCarouselSlidesState;
@@ -2623,6 +2666,41 @@ interface INamiRefsInstance {
2623
2666
  */
2624
2667
  declare function isAnonymousMode(): boolean;
2625
2668
 
2669
+ /**
2670
+ * Build the full-screen announcement string for a paywall page per the
2671
+ * TV Full-Page Announcement contract
2672
+ * (https://linear.app/nami-product-development/document/tv-full-page-announcement-980f61b96e7c).
2673
+ *
2674
+ * Behaviour:
2675
+ * 1. **Override path.** If `page.screenreaderText` is set, that string is
2676
+ * returned verbatim (followed by `focusedButtonLabel` if provided and
2677
+ * not already present at the tail). Server-authored polished
2678
+ * announcements win over client-side aggregation.
2679
+ *
2680
+ * 2. **Tree-walk fallback.** Otherwise, walk the page's containers in
2681
+ * reading order — `header` → `backgroundContainer` → `contentContainer`
2682
+ * → `footer` — collecting visible `text` / `text-list` descendants in
2683
+ * source order. `image`, `hidden: true` subtrees, interactive controls
2684
+ * (buttons / toggles), and non-text leaves are skipped. The
2685
+ * `focusedButtonLabel` is appended as the final sentence.
2686
+ *
2687
+ * SDKs invoke this once per page on the first focus of the default CTA, and
2688
+ * speak the result via their platform TTS API. On subsequent focuses within
2689
+ * the same page, the SDK announces only the focused element's own label —
2690
+ * this helper is NOT re-called.
2691
+ *
2692
+ * @param page The paywall page to announce.
2693
+ * @param focusedButtonLabel The resolved label of the button that
2694
+ * triggered the announcement (typically the
2695
+ * page's default-focused CTA). For
2696
+ * subscription-plan CTAs this is the dynamic
2697
+ * `"{price} {period} subscription plan button"`
2698
+ * string the backend already resolved.
2699
+ * @returns A single string ready to pass to a platform TTS API. Empty when
2700
+ * the page has no spoken content and no focused button label.
2701
+ */
2702
+ declare function aggregateScreenreaderText(page: TPages, focusedButtonLabel?: string): string;
2703
+
2626
2704
  /**
2627
2705
  * Returns the translated string for the given key in the current SDK language.
2628
2706
  * Uses Nami language code from storage (set via Nami.configure({ namiLanguageCode })).
@@ -2675,12 +2753,20 @@ declare const bestUrlCampaignMatch: (incomingUrl: string, campaigns: NamiCampaig
2675
2753
  declare const selectSegment: (segments: NamiCampaignSegment[], splitPosition: number) => NamiCampaignSegment;
2676
2754
  declare const mapAnonymousCampaigns: (campaigns: NamiAnonymousCampaign[], splitPosition: number, formFactor?: TDevice) => NamiCampaign[];
2677
2755
  /**
2756
+ * Returns the combined list of API + initial-config campaigns for the current device's
2757
+ * form factor, deduplicated by (type, value) with API entries winning on collision.
2678
2758
  *
2679
- * @returns A combined list of unique campaigns based on both API and Initial, filtered by form factor.
2680
- * This is used to get all campaigns that are applicable to the current device.
2759
+ * Mirrors NamiCampaignManager.allCampaigns() on Apple (Set keyed on hash(value, type))
2760
+ * and Android (.distinct() with server-first ordering). Keying on (type, value) rather
2761
+ * than rule UUID — ensures the same logical placement does not surface twice when the
2762
+ * server has republished a campaign under a new rule id.
2681
2763
  *
2682
- * Note: Since this function returns a unique list of campaigns, and API campaigns take precedence,
2683
- * there may be times when API campaigns are returned that do not yet have paywalls but initial campaigns would.
2764
+ * Note: this list reflects which placements are available, not whether they are launchable.
2765
+ * An API entry surfaced here may reference a paywall that has not yet been published in the
2766
+ * API response. NamiCampaignManager.launch() handles that case independently via
2767
+ * getPaywallDataFromLabel(), which falls back to the initial-config (campaign, paywall) pair
2768
+ * when the API pair is incomplete — matching the merged-paywall-lookup behavior on
2769
+ * Apple and Android.
2684
2770
  */
2685
2771
  declare const allCampaigns: () => NamiCampaign[];
2686
2772
  declare const getInitialCampaigns: () => NamiCampaign[];
@@ -2985,5 +3071,5 @@ declare const getBillingPeriodNumber: (billingPeriod: string) => number;
2985
3071
  declare const formattedPrice: (price: number) => number;
2986
3072
  declare function toDouble(num: number): number;
2987
3073
 
2988
- export { ALREADY_CONFIGURED, ANONYMOUS_MODE, ANONYMOUS_MODE_ALREADY_OFF, ANONYMOUS_MODE_ALREADY_ON, ANONYMOUS_MODE_LOGIN_NOT_ALLOWED, ANONYMOUS_UUID, APIError, API_ACTIVE_ENTITLEMENTS, API_CAMPAIGN_RULES, API_CAMPAIGN_SESSION_TIMESTAMP, API_CONFIG, API_MAX_CALLS_LIMIT, API_PAYWALLS, API_PRODUCTS, API_RETRY_DELAY_SEC, API_TIMEOUT_LIMIT, API_VERSION, AUTH_DEVICE, AVAILABLE_ACTIVE_ENTITLEMENTS_CHANGED, AVAILABLE_CAMPAIGNS_CHANGED, AccountStateAction, AnonymousCDPError, AnonymousLoginError, AnonymousModeAlreadyOffError, AnonymousModeAlreadyOnError, BASE_STAGING_URL, BASE_URL, BASE_URL_PATH, BadRequestError, BasicNamiFlow, BorderMap, BorderSideMap, CAMPAIGN_NOT_AVAILABLE, CUSTOMER_ATTRIBUTES_KEY_PREFIX, CUSTOMER_JOURNEY_STATE_CHANGED, CUSTOM_HOST_PREFIX, CampaignNotAvailableError, CampaignRuleConversionEventType, CampaignRuleRepository, Capabilities, ClientError, ConfigRepository, ConflictError, CustomerJourneyRepository, DEVELOPMENT, DEVICE_API_TIMEOUT_LIMIT, DEVICE_ID_NOT_SET, DEVICE_ID_REQUIRED, DeviceIDRequiredError, DeviceRepository, EXTENDED_CLIENT_INFO_DELIMITER, EXTENDED_CLIENT_INFO_PREFIX, EXTENDED_PLATFORM, EXTENDED_PLATFORM_VERSION, EXTERNAL_ID_REQUIRED, EntitlementRepository, EntitlementUtils, ExternalIDRequiredError, FLOW_SCREENS_NOT_AVAILABLE, FlowScreensNotAvailableError, HTML_REGEX, INITIAL_APP_CONFIG, INITIAL_CAMPAIGN_RULES, INITIAL_PAYWALLS, INITIAL_PRODUCTS, INITIAL_SESSION_COUNTER_VALUE, INITIAL_SUCCESS, InternalServerError, KEY_SESSION_COUNTER, LIQUID_VARIABLE_REGEX, LOCAL_NAMI_ENTITLEMENTS, LOG_HTTP_REQUESTS, LOG_HTTP_TRAFFIC, LaunchCampaignError, LaunchContextResolver, LogLevel, NAMI_CONFIGURATION, NAMI_CUSTOMER_JOURNEY_STATE, NAMI_LANGUAGE_CODE, NAMI_LAST_IMPRESSION_ID, NAMI_LAUNCH_ID, NAMI_PROFILE, NAMI_PURCHASE_CHANNEL, NAMI_PURCHASE_IMPRESSION_ID, NAMI_SDK_PACKAGE_VERSION, NAMI_SDK_VERSION, NAMI_SESSION_ID, NAMI_STORAGE_KEYS, Nami, NamiAPI, NamiAnimationType, NamiCampaignManager, NamiCampaignRuleType, NamiConditionEvaluator, NamiCustomerManager, NamiEntitlementManager, NamiEventEmitter, NamiFlow, NamiFlowActionFunction, NamiFlowManager, NamiFlowStepType, NamiPaywallAction, NamiPaywallManager, PaywallManagerEvents as NamiPaywallManagerEvents, NamiProfileManager, NamiPurchaseManager, NamiRefs, NamiReservedActions, NotFoundError, PAYWALL_ACTION_EVENT, PLATFORM_ID_REQUIRED, PRODUCTION, PaywallManagerEvents, PaywallRepository, PaywallState, PlacementLabelResolver, PlatformIDRequiredError, ProductRepository, RECONFIG_SUCCESS, RetryLimitExceededError, SDKNotInitializedError, SDK_NOT_INITIALIZED, SERVER_NAMI_ENTITLEMENTS, SESSION_REQUIRED, SHOULD_SHOW_LOADING_INDICATOR, SKU_TEXT_REGEX, SMART_TEXT_PATTERN, STATUS_BAD_REQUEST, STATUS_CONFLICT, STATUS_INTERNAL_SERVER_ERROR, STATUS_NOT_FOUND, STATUS_SUCCESS, SessionService, SimpleEventTarget, StorageService, UNABLE_TO_UPDATE_CDP_ID, USE_STAGING_API, VALIDATE_PRODUCT_GROUPS, VAR_REGEX, activateEntitlementByPurchase, activeEntitlements, allCampaigns, allPaywalls, applyEntitlementActivation, audienceSplitPosition, bestUrlCampaignMatch, bigintToUuid, checkAnySkuHasPromoOffer, checkAnySkuHasTrialOffer, convertISO8601PeriodToText, convertLocale, convertOfferToPricingPhase, createNamiEntitlements, currentSku, empty, extractStandardPricingPhases, formatDate, formattedPrice, generateUUID, getApiCampaigns, getApiPaywalls, getBaseUrl, getBillingPeriodNumber, getCurrencyFormat, getDeviceData, getDeviceFormFactor, getDeviceScaleFactor, getEffectiveWebStyle, getEntitlementRefIdsForSku, getExtendedClientInfo, getFreeTrialPeriod, getInitialCampaigns, getInitialPaywalls, getPaywall, getPaywallDataFromLabel, getPercentagePriceDifference, getPeriodNumberInDays, getPeriodNumberInWeeks, getPlatformAdapters, getPriceDifference, getPricePerMonth, getProductDetail, getPurchaseAdapter, getReferenceSku, getSkuProductDetailKeys, getSkuSmartTextValue, getSlideSmartTextValue, getStandardBillingPeriod, getTranslate, getUrlParams, handleErrors, hasAllPaywalls, hasCapability, hasPurchaseManagement, initialState, invokeHandler, isAnonymousMode, isInitialConfigCompressed, isNamiFlowCampaign, isSubscription, isValidISODate, isValidUrl, logger, mapAnonymousCampaigns, namiBuySKU, normalizeLaunchContext, parseToSemver, postConversion, productDetail, registerPlatformAdapters, registerPurchaseAdapter, selectSegment, setActiveNamiEntitlements, shouldValidateProductGroups, skuItems, skuMapFromEntitlements, storageService, toDouble, toNamiEntitlements, toNamiSKU, tryParseB64Gzip, tryParseJson, updateRelatedSKUsForNamiEntitlement, uuidFromSplitPosition, validateMinSDKVersion };
3074
+ export { ALREADY_CONFIGURED, ANONYMOUS_MODE, ANONYMOUS_MODE_ALREADY_OFF, ANONYMOUS_MODE_ALREADY_ON, ANONYMOUS_MODE_LOGIN_NOT_ALLOWED, ANONYMOUS_UUID, APIError, API_ACTIVE_ENTITLEMENTS, API_CAMPAIGN_RULES, API_CAMPAIGN_SESSION_TIMESTAMP, API_CONFIG, API_MAX_CALLS_LIMIT, API_PAYWALLS, API_PRODUCTS, API_RETRY_DELAY_SEC, API_TIMEOUT_LIMIT, API_VERSION, AUTH_DEVICE, AVAILABLE_ACTIVE_ENTITLEMENTS_CHANGED, AVAILABLE_CAMPAIGNS_CHANGED, AccountStateAction, AnonymousCDPError, AnonymousLoginError, AnonymousModeAlreadyOffError, AnonymousModeAlreadyOnError, BASE_STAGING_URL, BASE_URL, BASE_URL_PATH, BadRequestError, BasicNamiFlow, BorderMap, BorderSideMap, CAMPAIGN_NOT_AVAILABLE, CUSTOMER_ATTRIBUTES_KEY_PREFIX, CUSTOMER_JOURNEY_STATE_CHANGED, CUSTOM_HOST_PREFIX, CampaignNotAvailableError, CampaignRuleConversionEventType, CampaignRuleRepository, Capabilities, ClientError, ConfigRepository, ConflictError, CustomerJourneyRepository, DEVELOPMENT, DEVICE_API_TIMEOUT_LIMIT, DEVICE_ID_NOT_SET, DEVICE_ID_REQUIRED, DeviceIDRequiredError, DeviceRepository, EXTENDED_CLIENT_INFO_DELIMITER, EXTENDED_CLIENT_INFO_PREFIX, EXTENDED_PLATFORM, EXTENDED_PLATFORM_VERSION, EXTERNAL_ID_REQUIRED, EntitlementRepository, EntitlementUtils, ExternalIDRequiredError, FLOW_SCREENS_NOT_AVAILABLE, FlowScreensNotAvailableError, HTML_REGEX, INITIAL_APP_CONFIG, INITIAL_CAMPAIGN_RULES, INITIAL_PAYWALLS, INITIAL_PRODUCTS, INITIAL_SESSION_COUNTER_VALUE, INITIAL_SUCCESS, InternalServerError, KEY_SESSION_COUNTER, LIQUID_VARIABLE_REGEX, LOCAL_NAMI_ENTITLEMENTS, LOG_HTTP_REQUESTS, LOG_HTTP_TRAFFIC, LaunchCampaignError, LaunchContextResolver, LogLevel, NAMI_CONFIGURATION, NAMI_CUSTOMER_JOURNEY_STATE, NAMI_LANGUAGE_CODE, NAMI_LAST_IMPRESSION_ID, NAMI_LAUNCH_ID, NAMI_PROFILE, NAMI_PURCHASE_CHANNEL, NAMI_PURCHASE_IMPRESSION_ID, NAMI_SDK_PACKAGE_VERSION, NAMI_SDK_VERSION, NAMI_SESSION_ID, NAMI_STORAGE_KEYS, Nami, NamiAPI, NamiAnimationType, NamiCampaignManager, NamiCampaignRuleType, NamiConditionEvaluator, NamiCustomerManager, NamiEntitlementManager, NamiEventEmitter, NamiFlow, NamiFlowActionFunction, NamiFlowManager, NamiFlowStepType, NamiPaywallAction, NamiPaywallManager, PaywallManagerEvents as NamiPaywallManagerEvents, NamiProfileManager, NamiPurchaseManager, NamiRefs, NamiReservedActions, NotFoundError, PAYWALL_ACTION_EVENT, PLATFORM_ID_REQUIRED, PRODUCTION, PaywallManagerEvents, PaywallRepository, PaywallState, PlacementLabelResolver, PlatformIDRequiredError, ProductRepository, RECONFIG_SUCCESS, RetryLimitExceededError, SDKNotInitializedError, SDK_NOT_INITIALIZED, SERVER_NAMI_ENTITLEMENTS, SESSION_REQUIRED, SHOULD_SHOW_LOADING_INDICATOR, SKU_TEXT_REGEX, SMART_TEXT_PATTERN, STATUS_BAD_REQUEST, STATUS_CONFLICT, STATUS_INTERNAL_SERVER_ERROR, STATUS_NOT_FOUND, STATUS_SUCCESS, SessionService, SimpleEventTarget, StorageService, UNABLE_TO_UPDATE_CDP_ID, USE_STAGING_API, VALIDATE_PRODUCT_GROUPS, VAR_REGEX, activateEntitlementByPurchase, activeEntitlements, aggregateScreenreaderText, allCampaigns, allPaywalls, applyEntitlementActivation, audienceSplitPosition, bestUrlCampaignMatch, bigintToUuid, checkAnySkuHasPromoOffer, checkAnySkuHasTrialOffer, convertISO8601PeriodToText, convertLocale, convertOfferToPricingPhase, createNamiEntitlements, currentSku, empty, extractStandardPricingPhases, formatDate, formattedPrice, generateUUID, getApiCampaigns, getApiPaywalls, getBaseUrl, getBillingPeriodNumber, getCurrencyFormat, getDeviceData, getDeviceFormFactor, getDeviceScaleFactor, getEffectiveWebStyle, getEntitlementRefIdsForSku, getExtendedClientInfo, getFreeTrialPeriod, getInitialCampaigns, getInitialPaywalls, getPaywall, getPaywallDataFromLabel, getPercentagePriceDifference, getPeriodNumberInDays, getPeriodNumberInWeeks, getPlatformAdapters, getPriceDifference, getPricePerMonth, getProductDetail, getPurchaseAdapter, getReferenceSku, getSkuProductDetailKeys, getSkuSmartTextValue, getSlideSmartTextValue, getStandardBillingPeriod, getTranslate, getUrlParams, handleErrors, hasAllPaywalls, hasCapability, hasPurchaseManagement, initialState, invokeHandler, isAnonymousMode, isInitialConfigCompressed, isNamiFlowCampaign, isSubscription, isValidISODate, isValidUrl, logger, mapAnonymousCampaigns, namiBuySKU, normalizeLaunchContext, parseToSemver, postConversion, productDetail, registerPlatformAdapters, registerPurchaseAdapter, selectSegment, setActiveNamiEntitlements, shouldValidateProductGroups, skuItems, skuMapFromEntitlements, storageService, toDouble, toNamiEntitlements, toNamiSKU, tryParseB64Gzip, tryParseJson, updateRelatedSKUsForNamiEntitlement, uuidFromSplitPosition, validateMinSDKVersion };
2989
3075
  export type { AlignmentType, AmazonProduct, ApiResponse, AppleProduct, AvailableCampaignsResponseHandler, BorderLocationType, BorderSideType, Callback$1 as Callback, CloseHandler, CustomerJourneyState, DeepLinkUrlHandler, Device, DevicePayload, DeviceProfile, DirectionType, ExtendedPlatformInfo, FlexDirectionObject, FlowNavigationOptions, FontCollection, FontDetails, FormFactor, GoogleProduct, IConfig, IDeviceAdapter, IEntitlements$1 as IEntitlements, IPaywall, IPlatformAdapters, IProductsWithComponents, IPurchaseAdapter, ISkuMenu, IStorageAdapter, IUIAdapter, Impression, InitialConfig, InitialConfigCompressed, InitiateStateGroup, LoginResponse, NamiAnimation, NamiAnimationObjectSpec, NamiAnimationSpec, NamiAnonymousCampaign, NamiAppSuppliedVideoDetails, NamiCampaign, NamiCampaignSegment, NamiConfiguration, NamiConfigurationState, NamiEntitlement$1 as NamiEntitlement, NamiFlowAction, NamiFlowAnimation, NamiFlowCampaign, NamiFlowDTO, NamiFlowEventHandler, NamiFlowHandoffStepHandler, NamiFlowObjectDTO, NamiFlowOn, NamiFlowStep, NamiFlowTransition, NamiFlowTransitionDirection, NamiFlowWithObject, NamiInitialConfig, NamiLanguageCodes, NamiLogLevel, NamiPaywallActionHandler, NamiPaywallComponentChange, NamiPaywallEvent, NamiPaywallEventVideoMetadata, NamiPaywallLaunchContext, NamiPresentationStyle, NamiProductDetails, NamiProductOffer, NamiProfile, NamiPurchase, NamiPurchaseCompleteResult, NamiPurchaseDetails, NamiPurchasesState, NamiSKU, NamiSKUType, NamiSubscriptionInterval, NamiSubscriptionPeriod, None, NoneSpec, PaywallActionEvent, PaywallHandle, PaywallResultHandler, PaywallSKU, PricingPhase, ProductGroup, Pulse, PulseSpec, PurchaseContext, PurchaseResult, PurchaseValidationRequest, SKU, SKUActionHandler, ScreenInfo, Session, TBaseComponent, TButtonContainer, TCarouselContainer, TCarouselSlide, TCarouselSlidesState, TCollapseContainer, TComponent, TConditionalAttributes, TConditionalComponent, TContainer, TContainerPosition, TCountdownTimerTextComponent, TDevice, TDisabledButton, TField, TFieldSettings, TFlexProductContainer, THeaderFooter, TImageComponent, TInitialState, TMediaTypes, TOffer, TPages, TPaywallContext, TPaywallLaunchContext, TPaywallMedia, TPaywallTemplate, TPlayPauseButton, TProductContainer, TProductGroup, TProgressBarComponent, TProgressIndicatorComponent, TQRCodeComponent, TRadioButton, TRepeatingGrid, TResponsiveGrid, TSegmentPicker, TSegmentPickerItem, TSemverObj, TSpacerComponent, TStack, TSvgImageComponent, TSymbolComponent, TTestObject, TTextComponent, TTextLikeComponent, TTextListComponent, TToggleButtonComponent, TToggleSwitch, TVariablePattern, TVideoComponent, TVolumeButton, TimerState, TransactionRequest, UserAction, UserActionParameters, Wave, WaveSpec };
package/dist/index.mjs CHANGED
@@ -96,7 +96,7 @@ const {
96
96
  // version — stamped by scripts/version.sh
97
97
  NAMI_SDK_VERSION = "3.4.0",
98
98
  // full package version including dev suffix — stamped by scripts/version.sh
99
- NAMI_SDK_PACKAGE_VERSION = "3.4.0-dev.202605141714",
99
+ NAMI_SDK_PACKAGE_VERSION = "3.4.0-dev.202605182046",
100
100
  // environments
101
101
  PRODUCTION = "production", DEVELOPMENT = "development",
102
102
  // error messages
@@ -7402,19 +7402,34 @@ const mapAnonymousCampaigns = (campaigns, splitPosition, formFactor) => {
7402
7402
  });
7403
7403
  };
7404
7404
  /**
7405
+ * Returns the combined list of API + initial-config campaigns for the current device's
7406
+ * form factor, deduplicated by (type, value) with API entries winning on collision.
7405
7407
  *
7406
- * @returns A combined list of unique campaigns based on both API and Initial, filtered by form factor.
7407
- * This is used to get all campaigns that are applicable to the current device.
7408
+ * Mirrors NamiCampaignManager.allCampaigns() on Apple (Set keyed on hash(value, type))
7409
+ * and Android (.distinct() with server-first ordering). Keying on (type, value) rather
7410
+ * than rule UUID — ensures the same logical placement does not surface twice when the
7411
+ * server has republished a campaign under a new rule id.
7408
7412
  *
7409
- * Note: Since this function returns a unique list of campaigns, and API campaigns take precedence,
7410
- * there may be times when API campaigns are returned that do not yet have paywalls but initial campaigns would.
7413
+ * Note: this list reflects which placements are available, not whether they are launchable.
7414
+ * An API entry surfaced here may reference a paywall that has not yet been published in the
7415
+ * API response. NamiCampaignManager.launch() handles that case independently via
7416
+ * getPaywallDataFromLabel(), which falls back to the initial-config (campaign, paywall) pair
7417
+ * when the API pair is incomplete — matching the merged-paywall-lookup behavior on
7418
+ * Apple and Android.
7411
7419
  */
7412
7420
  const allCampaigns = () => {
7413
7421
  const apiCampaigns = storageService.getCampaignRules(API_CAMPAIGN_RULES) ?? [];
7414
7422
  const initialCampaigns = storageService.getCampaignRules(INITIAL_CAMPAIGN_RULES) ?? [];
7415
7423
  const formFactor = getDeviceFormFactor();
7416
7424
  const campaigns = compact([...apiCampaigns, ...initialCampaigns]).filter((cRule) => cRule.form_factors?.some((f) => f.form_factor === formFactor));
7417
- return uniqBy(campaigns, "rule");
7425
+ const seen = new Set();
7426
+ return campaigns.filter((c) => {
7427
+ const key = `${c.type ?? ''}::${c.value ?? ''}`;
7428
+ if (seen.has(key))
7429
+ return false;
7430
+ seen.add(key);
7431
+ return true;
7432
+ });
7418
7433
  };
7419
7434
  /**
7420
7435
  * Get campaigns by rule key, filtered by form factor.
@@ -13668,6 +13683,19 @@ class NamiFlow extends BasicNamiFlow {
13668
13683
  this.forward(nextStep.id);
13669
13684
  }
13670
13685
  }
13686
+ handleRemoteBack() {
13687
+ const step = this.currentFlowStep;
13688
+ if (step?.actions[NamiReservedActions.REMOTE_BACK]) {
13689
+ this.triggerActions(NamiReservedActions.REMOTE_BACK);
13690
+ return 'handler';
13691
+ }
13692
+ if (this.previousStepAvailable) {
13693
+ this.back();
13694
+ return 'back';
13695
+ }
13696
+ this.finished();
13697
+ return 'dismissed';
13698
+ }
13671
13699
  backToPreviousScreenStep() {
13672
13700
  if (this.previousFlowStep?.allow_back_to === false) {
13673
13701
  logger.warn(`Not allowed to go back to ${this.previousFlowStep.id}`);
@@ -63911,6 +63939,230 @@ const convertLocale = (locale) => {
63911
63939
  return intlLocale.language + (intlLocale.region ?? intlLocale.script ?? '');
63912
63940
  };
63913
63941
 
63942
+ /**
63943
+ * Components that are skipped entirely (they and their subtree do not
63944
+ * contribute text to the page-level announcement).
63945
+ *
63946
+ * - Buttons / interactive controls: their label is appended as the
63947
+ * focused-button suffix, not collected mid-tree. Recursing into them
63948
+ * would double-count the label and pull in non-spoken decorative text.
63949
+ * - Image-like leaves: explicitly skipped per spec.
63950
+ * - Form pickers + QR codes + raw video: not informational text.
63951
+ */
63952
+ const SKIP_COMPONENTS = new Set([
63953
+ 'button',
63954
+ 'playPauseButton',
63955
+ 'volumeButton',
63956
+ 'toggleButton',
63957
+ 'toggleSwitch',
63958
+ 'radio',
63959
+ 'image',
63960
+ 'svgImage',
63961
+ 'symbol',
63962
+ 'qrCode',
63963
+ 'videoUrl',
63964
+ 'segmentPicker',
63965
+ 'segmentPickerItem',
63966
+ ]);
63967
+ /** Component values in {@link SKIP_COMPONENTS} that are interactive controls
63968
+ * (as opposed to images / media / non-text leaves). When a text component
63969
+ * shares a container with one of these, the text is treated as that
63970
+ * button's accessible label and excluded from the page composite. */
63971
+ const BUTTON_COMPONENTS = new Set([
63972
+ 'button',
63973
+ 'playPauseButton',
63974
+ 'volumeButton',
63975
+ 'toggleButton',
63976
+ 'toggleSwitch',
63977
+ 'radio',
63978
+ ]);
63979
+ /** `namiComponentType` values that are always included in the page
63980
+ * composite — even when their structural position (e.g. a sibling of a
63981
+ * utility button) would otherwise mark them for exclusion. Driven by the
63982
+ * spec's schema table:
63983
+ *
63984
+ * | `namiComponentType: "legalText"` | Legal/T&C content (included in tree-walk) |
63985
+ */
63986
+ const ALWAYS_INCLUDE_NAMI_TYPES = new Set(['legalText']);
63987
+ /**
63988
+ * Whether a text leaf inside `parent` should be skipped because it acts as
63989
+ * the accessible label for a sibling button.
63990
+ *
63991
+ * Schema pattern this catches: a container whose direct children include
63992
+ * BOTH a text/text-list AND an interactive button (e.g. Page 3's "Login
63993
+ * Group" — `[ text "Already an NFL+ subscriber?", loginButton "Sign In" ]`).
63994
+ * The text describes the button and is announced when the button is
63995
+ * focused, NOT as page-level content. Without this rule, the composite
63996
+ * leaks "Already an NFL+ subscriber? Subscribe to NFL plus button" instead
63997
+ * of "Subscribe to NFL plus button".
63998
+ *
63999
+ * Texts marked `namiComponentType: "legalText"` are exempt — the spec
64000
+ * explicitly opts legal copy in regardless of its container's other
64001
+ * children (e.g. Page 5's "Bottom Wrapper" mixes a body text, a restore
64002
+ * button, and the legal text — the legal text must still be spoken).
64003
+ */
64004
+ function isButtonSiblingLabel(node, parent) {
64005
+ if (!parent || !Array.isArray(parent.components))
64006
+ return false;
64007
+ if (typeof node.namiComponentType === 'string' &&
64008
+ ALWAYS_INCLUDE_NAMI_TYPES.has(node.namiComponentType)) {
64009
+ return false;
64010
+ }
64011
+ for (const sibling of parent.components) {
64012
+ if (sibling && typeof sibling === 'object') {
64013
+ const s = sibling;
64014
+ if (typeof s.component === 'string' && BUTTON_COMPONENTS.has(s.component)) {
64015
+ return true;
64016
+ }
64017
+ }
64018
+ }
64019
+ return false;
64020
+ }
64021
+ /**
64022
+ * Recursively collect spoken text from a component subtree following the
64023
+ * TV Full-Page Announcement contract.
64024
+ *
64025
+ * Rules (in order):
64026
+ * 1. `null` / `undefined` → contribute nothing.
64027
+ * 2. `hidden: true` → skip the node AND its subtree.
64028
+ * 3. `component` in {@link SKIP_COMPONENTS} → skip the node AND its subtree.
64029
+ * 4. `screenreaderText` set on this container → take that string verbatim
64030
+ * and do NOT recurse (server-supplied override).
64031
+ * 5. `component: "text"` or `"text-list"` → collect, UNLESS the text is a
64032
+ * sibling label for an adjacent button (see
64033
+ * {@link isButtonSiblingLabel}). `namiComponentType: "legalText"` is
64034
+ * exempted from the sibling-label exclusion.
64035
+ * 6. Otherwise → recurse into `components`.
64036
+ */
64037
+ function collectText(node, parent = null) {
64038
+ if (!node || typeof node !== 'object')
64039
+ return [];
64040
+ const n = node;
64041
+ if (n.hidden === true)
64042
+ return [];
64043
+ if (typeof n.component === 'string' && SKIP_COMPONENTS.has(n.component))
64044
+ return [];
64045
+ // Container-level override: when an enclosing container pre-supplies its own
64046
+ // screenreader text, take it verbatim and stop descending — the customer
64047
+ // has authored a polished announcement for this subtree.
64048
+ if (typeof n.screenreaderText === 'string' && n.screenreaderText.trim().length > 0) {
64049
+ return [n.screenreaderText.trim()];
64050
+ }
64051
+ if (n.component === 'text') {
64052
+ if (isButtonSiblingLabel(n, parent))
64053
+ return [];
64054
+ return typeof n.text === 'string' && n.text.length > 0 ? [n.text] : [];
64055
+ }
64056
+ if (n.component === 'text-list') {
64057
+ if (isButtonSiblingLabel(n, parent))
64058
+ return [];
64059
+ if (!Array.isArray(n.texts))
64060
+ return [];
64061
+ return n.texts.filter((t) => typeof t === 'string' && t.length > 0);
64062
+ }
64063
+ if (Array.isArray(n.components)) {
64064
+ const out = [];
64065
+ for (const child of n.components) {
64066
+ out.push(...collectText(child, n));
64067
+ }
64068
+ return out;
64069
+ }
64070
+ return [];
64071
+ }
64072
+ function collectHeaderFooter(slot) {
64073
+ if (!Array.isArray(slot))
64074
+ return [];
64075
+ // The slot itself acts as the synthetic parent so a top-level text inside
64076
+ // a header/footer that sits alongside a top-level button is treated as a
64077
+ // button label and excluded.
64078
+ const syntheticParent = { components: slot };
64079
+ const out = [];
64080
+ for (const node of slot) {
64081
+ out.push(...collectText(node, syntheticParent));
64082
+ }
64083
+ return out;
64084
+ }
64085
+ /**
64086
+ * Join an ordered list of collected text fragments into a single speakable
64087
+ * string. Each fragment is trimmed; empty fragments are dropped. Author
64088
+ * terminators (`.`, `!`, `?`) are preserved so emphasis carries through to
64089
+ * the screenreader; only fragments that lack a terminator get `". "`
64090
+ * inserted before the next fragment. The result is a string that reads
64091
+ * naturally without double-punctuation like `"...REACH!. Watch..."`.
64092
+ */
64093
+ function joinFragments(fragments) {
64094
+ let out = '';
64095
+ for (const raw of fragments) {
64096
+ const trimmed = raw.trim();
64097
+ if (!trimmed)
64098
+ continue;
64099
+ if (!out) {
64100
+ out = trimmed;
64101
+ continue;
64102
+ }
64103
+ const last = out[out.length - 1];
64104
+ const endsWithTerminator = last === '.' || last === '!' || last === '?';
64105
+ out += (endsWithTerminator ? ' ' : '. ') + trimmed;
64106
+ }
64107
+ return out;
64108
+ }
64109
+ /**
64110
+ * Build the full-screen announcement string for a paywall page per the
64111
+ * TV Full-Page Announcement contract
64112
+ * (https://linear.app/nami-product-development/document/tv-full-page-announcement-980f61b96e7c).
64113
+ *
64114
+ * Behaviour:
64115
+ * 1. **Override path.** If `page.screenreaderText` is set, that string is
64116
+ * returned verbatim (followed by `focusedButtonLabel` if provided and
64117
+ * not already present at the tail). Server-authored polished
64118
+ * announcements win over client-side aggregation.
64119
+ *
64120
+ * 2. **Tree-walk fallback.** Otherwise, walk the page's containers in
64121
+ * reading order — `header` → `backgroundContainer` → `contentContainer`
64122
+ * → `footer` — collecting visible `text` / `text-list` descendants in
64123
+ * source order. `image`, `hidden: true` subtrees, interactive controls
64124
+ * (buttons / toggles), and non-text leaves are skipped. The
64125
+ * `focusedButtonLabel` is appended as the final sentence.
64126
+ *
64127
+ * SDKs invoke this once per page on the first focus of the default CTA, and
64128
+ * speak the result via their platform TTS API. On subsequent focuses within
64129
+ * the same page, the SDK announces only the focused element's own label —
64130
+ * this helper is NOT re-called.
64131
+ *
64132
+ * @param page The paywall page to announce.
64133
+ * @param focusedButtonLabel The resolved label of the button that
64134
+ * triggered the announcement (typically the
64135
+ * page's default-focused CTA). For
64136
+ * subscription-plan CTAs this is the dynamic
64137
+ * `"{price} {period} subscription plan button"`
64138
+ * string the backend already resolved.
64139
+ * @returns A single string ready to pass to a platform TTS API. Empty when
64140
+ * the page has no spoken content and no focused button label.
64141
+ */
64142
+ function aggregateScreenreaderText(page, focusedButtonLabel = '') {
64143
+ const trimmedLabel = focusedButtonLabel.trim();
64144
+ // Override path
64145
+ if (typeof page.screenreaderText === 'string' && page.screenreaderText.trim().length > 0) {
64146
+ const override = page.screenreaderText.trim();
64147
+ if (!trimmedLabel)
64148
+ return override;
64149
+ // If the server-authored override already ends with the focused button
64150
+ // label, don't append it again.
64151
+ if (override.toLowerCase().endsWith(trimmedLabel.toLowerCase()))
64152
+ return override;
64153
+ return joinFragments([override, trimmedLabel]);
64154
+ }
64155
+ // Tree-walk path
64156
+ const fragments = [];
64157
+ fragments.push(...collectHeaderFooter(page.header));
64158
+ fragments.push(...collectText(page.backgroundContainer));
64159
+ fragments.push(...collectText(page.contentContainer));
64160
+ fragments.push(...collectHeaderFooter(page.footer));
64161
+ if (trimmedLabel)
64162
+ fragments.push(trimmedLabel);
64163
+ return joinFragments(fragments);
64164
+ }
64165
+
63914
64166
  function namiBuySKU(skuRefId) {
63915
64167
  if (!hasPurchaseManagement("NamiPurchaseManager.buySKU")) {
63916
64168
  return;
@@ -63925,4 +64177,4 @@ function namiBuySKU(skuRefId) {
63925
64177
  return result;
63926
64178
  }
63927
64179
 
63928
- export { ALREADY_CONFIGURED, ANONYMOUS_MODE, ANONYMOUS_MODE_ALREADY_OFF, ANONYMOUS_MODE_ALREADY_ON, ANONYMOUS_MODE_LOGIN_NOT_ALLOWED, ANONYMOUS_UUID, APIError, API_ACTIVE_ENTITLEMENTS, API_CAMPAIGN_RULES, API_CAMPAIGN_SESSION_TIMESTAMP, API_CONFIG, API_MAX_CALLS_LIMIT, API_PAYWALLS, API_PRODUCTS, API_RETRY_DELAY_SEC, API_TIMEOUT_LIMIT, API_VERSION, AUTH_DEVICE, AVAILABLE_ACTIVE_ENTITLEMENTS_CHANGED, AVAILABLE_CAMPAIGNS_CHANGED, AccountStateAction, AnonymousCDPError, AnonymousLoginError, AnonymousModeAlreadyOffError, AnonymousModeAlreadyOnError, BASE_STAGING_URL, BASE_URL, BASE_URL_PATH, BadRequestError, BasicNamiFlow, BorderMap, BorderSideMap, CAMPAIGN_NOT_AVAILABLE, CUSTOMER_ATTRIBUTES_KEY_PREFIX, CUSTOMER_JOURNEY_STATE_CHANGED, CUSTOM_HOST_PREFIX, CampaignNotAvailableError, CampaignRuleConversionEventType, CampaignRuleRepository, Capabilities, ClientError, ConfigRepository, ConflictError, CustomerJourneyRepository, DEVELOPMENT, DEVICE_API_TIMEOUT_LIMIT, DEVICE_ID_NOT_SET, DEVICE_ID_REQUIRED, DeviceIDRequiredError, DeviceRepository, EXTENDED_CLIENT_INFO_DELIMITER, EXTENDED_CLIENT_INFO_PREFIX, EXTENDED_PLATFORM, EXTENDED_PLATFORM_VERSION, EXTERNAL_ID_REQUIRED, EntitlementRepository, EntitlementUtils, ExternalIDRequiredError, FLOW_SCREENS_NOT_AVAILABLE, FlowScreensNotAvailableError, HTML_REGEX, INITIAL_APP_CONFIG, INITIAL_CAMPAIGN_RULES, INITIAL_PAYWALLS, INITIAL_PRODUCTS, INITIAL_SESSION_COUNTER_VALUE, INITIAL_SUCCESS, InternalServerError, KEY_SESSION_COUNTER, LIQUID_VARIABLE_REGEX, LOCAL_NAMI_ENTITLEMENTS, LOG_HTTP_REQUESTS, LOG_HTTP_TRAFFIC, LaunchCampaignError, LaunchContextResolver, LogLevel, NAMI_CONFIGURATION, NAMI_CUSTOMER_JOURNEY_STATE, NAMI_LANGUAGE_CODE, NAMI_LAST_IMPRESSION_ID, NAMI_LAUNCH_ID, NAMI_PROFILE, NAMI_PURCHASE_CHANNEL, NAMI_PURCHASE_IMPRESSION_ID, NAMI_SDK_PACKAGE_VERSION, NAMI_SDK_VERSION, NAMI_SESSION_ID, NAMI_STORAGE_KEYS, Nami, NamiAPI, NamiAnimationType, NamiCampaignManager, NamiCampaignRuleType, NamiConditionEvaluator, NamiCustomerManager, NamiEntitlementManager, NamiEventEmitter, NamiFlow, NamiFlowActionFunction, NamiFlowManager, NamiFlowStepType, NamiPaywallAction, NamiPaywallManager, PaywallManagerEvents as NamiPaywallManagerEvents, NamiProfileManager, NamiPurchaseManager, NamiRefs, NamiReservedActions, NotFoundError, PAYWALL_ACTION_EVENT, PLATFORM_ID_REQUIRED, PRODUCTION, PaywallManagerEvents, PaywallRepository, PaywallState, PlacementLabelResolver, PlatformIDRequiredError, ProductRepository, RECONFIG_SUCCESS, RetryLimitExceededError, SDKNotInitializedError, SDK_NOT_INITIALIZED, SERVER_NAMI_ENTITLEMENTS, SESSION_REQUIRED, SHOULD_SHOW_LOADING_INDICATOR, SKU_TEXT_REGEX, SMART_TEXT_PATTERN, STATUS_BAD_REQUEST, STATUS_CONFLICT, STATUS_INTERNAL_SERVER_ERROR, STATUS_NOT_FOUND, STATUS_SUCCESS, SessionService, SimpleEventTarget, StorageService, UNABLE_TO_UPDATE_CDP_ID, USE_STAGING_API, VALIDATE_PRODUCT_GROUPS, VAR_REGEX, activateEntitlementByPurchase, activeEntitlements, allCampaigns, allPaywalls, applyEntitlementActivation, audienceSplitPosition, bestUrlCampaignMatch, bigintToUuid, checkAnySkuHasPromoOffer, checkAnySkuHasTrialOffer, convertISO8601PeriodToText, convertLocale, convertOfferToPricingPhase, createNamiEntitlements, currentSku, empty, extractStandardPricingPhases, formatDate, formattedPrice, generateUUID, getApiCampaigns, getApiPaywalls, getBaseUrl, getBillingPeriodNumber, getCurrencyFormat, getDeviceData, getDeviceFormFactor, getDeviceScaleFactor, getEffectiveWebStyle, getEntitlementRefIdsForSku, getExtendedClientInfo, getFreeTrialPeriod, getInitialCampaigns, getInitialPaywalls, getPaywall, getPaywallDataFromLabel, getPercentagePriceDifference, getPeriodNumberInDays, getPeriodNumberInWeeks, getPlatformAdapters, getPriceDifference, getPricePerMonth, getProductDetail, getPurchaseAdapter, getReferenceSku, getSkuProductDetailKeys, getSkuSmartTextValue, getSlideSmartTextValue, getStandardBillingPeriod, getTranslate, getUrlParams, handleErrors, hasAllPaywalls, hasCapability, hasPurchaseManagement, initialState, invokeHandler, isAnonymousMode, isInitialConfigCompressed, isNamiFlowCampaign, isSubscription, isValidISODate, isValidUrl, logger, mapAnonymousCampaigns, namiBuySKU, normalizeLaunchContext, parseToSemver, postConversion, productDetail, registerPlatformAdapters, registerPurchaseAdapter, selectSegment, setActiveNamiEntitlements, shouldValidateProductGroups, skuItems, skuMapFromEntitlements, storageService, toDouble, toNamiEntitlements, toNamiSKU, tryParseB64Gzip, tryParseJson, updateRelatedSKUsForNamiEntitlement, uuidFromSplitPosition, validateMinSDKVersion };
64180
+ export { ALREADY_CONFIGURED, ANONYMOUS_MODE, ANONYMOUS_MODE_ALREADY_OFF, ANONYMOUS_MODE_ALREADY_ON, ANONYMOUS_MODE_LOGIN_NOT_ALLOWED, ANONYMOUS_UUID, APIError, API_ACTIVE_ENTITLEMENTS, API_CAMPAIGN_RULES, API_CAMPAIGN_SESSION_TIMESTAMP, API_CONFIG, API_MAX_CALLS_LIMIT, API_PAYWALLS, API_PRODUCTS, API_RETRY_DELAY_SEC, API_TIMEOUT_LIMIT, API_VERSION, AUTH_DEVICE, AVAILABLE_ACTIVE_ENTITLEMENTS_CHANGED, AVAILABLE_CAMPAIGNS_CHANGED, AccountStateAction, AnonymousCDPError, AnonymousLoginError, AnonymousModeAlreadyOffError, AnonymousModeAlreadyOnError, BASE_STAGING_URL, BASE_URL, BASE_URL_PATH, BadRequestError, BasicNamiFlow, BorderMap, BorderSideMap, CAMPAIGN_NOT_AVAILABLE, CUSTOMER_ATTRIBUTES_KEY_PREFIX, CUSTOMER_JOURNEY_STATE_CHANGED, CUSTOM_HOST_PREFIX, CampaignNotAvailableError, CampaignRuleConversionEventType, CampaignRuleRepository, Capabilities, ClientError, ConfigRepository, ConflictError, CustomerJourneyRepository, DEVELOPMENT, DEVICE_API_TIMEOUT_LIMIT, DEVICE_ID_NOT_SET, DEVICE_ID_REQUIRED, DeviceIDRequiredError, DeviceRepository, EXTENDED_CLIENT_INFO_DELIMITER, EXTENDED_CLIENT_INFO_PREFIX, EXTENDED_PLATFORM, EXTENDED_PLATFORM_VERSION, EXTERNAL_ID_REQUIRED, EntitlementRepository, EntitlementUtils, ExternalIDRequiredError, FLOW_SCREENS_NOT_AVAILABLE, FlowScreensNotAvailableError, HTML_REGEX, INITIAL_APP_CONFIG, INITIAL_CAMPAIGN_RULES, INITIAL_PAYWALLS, INITIAL_PRODUCTS, INITIAL_SESSION_COUNTER_VALUE, INITIAL_SUCCESS, InternalServerError, KEY_SESSION_COUNTER, LIQUID_VARIABLE_REGEX, LOCAL_NAMI_ENTITLEMENTS, LOG_HTTP_REQUESTS, LOG_HTTP_TRAFFIC, LaunchCampaignError, LaunchContextResolver, LogLevel, NAMI_CONFIGURATION, NAMI_CUSTOMER_JOURNEY_STATE, NAMI_LANGUAGE_CODE, NAMI_LAST_IMPRESSION_ID, NAMI_LAUNCH_ID, NAMI_PROFILE, NAMI_PURCHASE_CHANNEL, NAMI_PURCHASE_IMPRESSION_ID, NAMI_SDK_PACKAGE_VERSION, NAMI_SDK_VERSION, NAMI_SESSION_ID, NAMI_STORAGE_KEYS, Nami, NamiAPI, NamiAnimationType, NamiCampaignManager, NamiCampaignRuleType, NamiConditionEvaluator, NamiCustomerManager, NamiEntitlementManager, NamiEventEmitter, NamiFlow, NamiFlowActionFunction, NamiFlowManager, NamiFlowStepType, NamiPaywallAction, NamiPaywallManager, PaywallManagerEvents as NamiPaywallManagerEvents, NamiProfileManager, NamiPurchaseManager, NamiRefs, NamiReservedActions, NotFoundError, PAYWALL_ACTION_EVENT, PLATFORM_ID_REQUIRED, PRODUCTION, PaywallManagerEvents, PaywallRepository, PaywallState, PlacementLabelResolver, PlatformIDRequiredError, ProductRepository, RECONFIG_SUCCESS, RetryLimitExceededError, SDKNotInitializedError, SDK_NOT_INITIALIZED, SERVER_NAMI_ENTITLEMENTS, SESSION_REQUIRED, SHOULD_SHOW_LOADING_INDICATOR, SKU_TEXT_REGEX, SMART_TEXT_PATTERN, STATUS_BAD_REQUEST, STATUS_CONFLICT, STATUS_INTERNAL_SERVER_ERROR, STATUS_NOT_FOUND, STATUS_SUCCESS, SessionService, SimpleEventTarget, StorageService, UNABLE_TO_UPDATE_CDP_ID, USE_STAGING_API, VALIDATE_PRODUCT_GROUPS, VAR_REGEX, activateEntitlementByPurchase, activeEntitlements, aggregateScreenreaderText, allCampaigns, allPaywalls, applyEntitlementActivation, audienceSplitPosition, bestUrlCampaignMatch, bigintToUuid, checkAnySkuHasPromoOffer, checkAnySkuHasTrialOffer, convertISO8601PeriodToText, convertLocale, convertOfferToPricingPhase, createNamiEntitlements, currentSku, empty, extractStandardPricingPhases, formatDate, formattedPrice, generateUUID, getApiCampaigns, getApiPaywalls, getBaseUrl, getBillingPeriodNumber, getCurrencyFormat, getDeviceData, getDeviceFormFactor, getDeviceScaleFactor, getEffectiveWebStyle, getEntitlementRefIdsForSku, getExtendedClientInfo, getFreeTrialPeriod, getInitialCampaigns, getInitialPaywalls, getPaywall, getPaywallDataFromLabel, getPercentagePriceDifference, getPeriodNumberInDays, getPeriodNumberInWeeks, getPlatformAdapters, getPriceDifference, getPricePerMonth, getProductDetail, getPurchaseAdapter, getReferenceSku, getSkuProductDetailKeys, getSkuSmartTextValue, getSlideSmartTextValue, getStandardBillingPeriod, getTranslate, getUrlParams, handleErrors, hasAllPaywalls, hasCapability, hasPurchaseManagement, initialState, invokeHandler, isAnonymousMode, isInitialConfigCompressed, isNamiFlowCampaign, isSubscription, isValidISODate, isValidUrl, logger, mapAnonymousCampaigns, namiBuySKU, normalizeLaunchContext, parseToSemver, postConversion, productDetail, registerPlatformAdapters, registerPurchaseAdapter, selectSegment, setActiveNamiEntitlements, shouldValidateProductGroups, skuItems, skuMapFromEntitlements, storageService, toDouble, toNamiEntitlements, toNamiSKU, tryParseB64Gzip, tryParseJson, updateRelatedSKUsForNamiEntitlement, uuidFromSplitPosition, validateMinSDKVersion };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@namiml/sdk-core",
3
- "version": "3.4.0-dev.202605141714",
3
+ "version": "3.4.0-dev.202605182046",
4
4
  "description": "Platform-agnostic core for the Nami SDK — business logic, API, types, and state management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",