@namiml/sdk-core 3.5.0-dev.202611011626 → 3.5.0-dev.202611020145

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
@@ -106,13 +106,17 @@ const {
106
106
  // version — stamped by scripts/version.sh
107
107
  NAMI_SDK_VERSION: exports.NAMI_SDK_VERSION = "3.5.0",
108
108
  // full package version including dev suffix — stamped by scripts/version.sh
109
- NAMI_SDK_PACKAGE_VERSION: exports.NAMI_SDK_PACKAGE_VERSION = "3.5.0-dev.202611011626",
109
+ NAMI_SDK_PACKAGE_VERSION: exports.NAMI_SDK_PACKAGE_VERSION = "3.5.0-dev.202611020145",
110
110
  // environments
111
111
  PRODUCTION: exports.PRODUCTION = "production", DEVELOPMENT: exports.DEVELOPMENT = "development",
112
112
  // error messages
113
113
  PLATFORM_ID_REQUIRED: exports.PLATFORM_ID_REQUIRED = "Platform ID is required", DEVICE_ID_REQUIRED: exports.DEVICE_ID_REQUIRED = "Device ID is required", EXTERNAL_ID_REQUIRED: exports.EXTERNAL_ID_REQUIRED = "External ID is required", SDK_NOT_INITIALIZED: exports.SDK_NOT_INITIALIZED = "SDK has not been initialized. Make sure to call Nami.configure() first!", CAMPAIGN_NOT_AVAILABLE: exports.CAMPAIGN_NOT_AVAILABLE = "Campaign is not available", FLOW_SCREENS_NOT_AVAILABLE: exports.FLOW_SCREENS_NOT_AVAILABLE = "Flow screens not available", UNABLE_TO_UPDATE_CDP_ID: exports.UNABLE_TO_UPDATE_CDP_ID = "Unable to update CDP id in anonymous mode", ANONYMOUS_MODE_ALREADY_ON: exports.ANONYMOUS_MODE_ALREADY_ON = "Anonymous mode is already turned on.", ANONYMOUS_MODE_ALREADY_OFF: exports.ANONYMOUS_MODE_ALREADY_OFF = "Anonymous mode is already turned off.", ANONYMOUS_MODE_LOGIN_NOT_ALLOWED: exports.ANONYMOUS_MODE_LOGIN_NOT_ALLOWED = "Login/logout are not available in anonymous mode", SESSION_REQUIRED: exports.SESSION_REQUIRED = "Session is required", DEVICE_ID_NOT_SET: exports.DEVICE_ID_NOT_SET = "Nami Device ID is not set",
114
114
  // localstorage keys
115
- AUTH_DEVICE: exports.AUTH_DEVICE = "nami_auth_device", NAMI_CONFIGURATION: exports.NAMI_CONFIGURATION = "nami_configuration", NAMI_PROFILE: exports.NAMI_PROFILE = "nami_profile", API_CONFIG: exports.API_CONFIG = "api_config", API_CAMPAIGN_RULES: exports.API_CAMPAIGN_RULES = "api_campaign_rules", API_PAYWALLS: exports.API_PAYWALLS = "api_paywalls", API_CAMPAIGN_SESSION_TIMESTAMP: exports.API_CAMPAIGN_SESSION_TIMESTAMP = "api_campaign_session_timestamp", API_PRODUCTS: exports.API_PRODUCTS = "api_products", API_ACTIVE_ENTITLEMENTS: exports.API_ACTIVE_ENTITLEMENTS = "api_active_entitlements", SERVER_NAMI_ENTITLEMENTS: exports.SERVER_NAMI_ENTITLEMENTS = "server_active_entitlements", INITIAL_APP_CONFIG: exports.INITIAL_APP_CONFIG = "initial_app_config", INITIAL_CAMPAIGN_RULES: exports.INITIAL_CAMPAIGN_RULES = "initial_campaign_rules", INITIAL_PAYWALLS: exports.INITIAL_PAYWALLS = "initial_paywalls", INITIAL_PRODUCTS: exports.INITIAL_PRODUCTS = "initial_products", LOCAL_NAMI_ENTITLEMENTS: exports.LOCAL_NAMI_ENTITLEMENTS = "local_active_entitlements", CUSTOMER_ATTRIBUTES_KEY_PREFIX: exports.CUSTOMER_ATTRIBUTES_KEY_PREFIX = "nami_cmr_attributes_", NAMI_CUSTOMER_JOURNEY_STATE: exports.NAMI_CUSTOMER_JOURNEY_STATE = "nami_customer_journey_state", ANONYMOUS_MODE: exports.ANONYMOUS_MODE = "nami_anonymous_mode_v2", ANONYMOUS_UUID: exports.ANONYMOUS_UUID = "nami_anonymous_uuid", KEY_SESSION_COUNTER: exports.KEY_SESSION_COUNTER = "nami_session_counter", INITIAL_SESSION_COUNTER_VALUE: exports.INITIAL_SESSION_COUNTER_VALUE = 1, NAMI_LAST_IMPRESSION_ID: exports.NAMI_LAST_IMPRESSION_ID = "nami_last_impression_id", NAMI_PURCHASE_IMPRESSION_ID: exports.NAMI_PURCHASE_IMPRESSION_ID = "nami_purchase_impression_id", NAMI_LAUNCH_ID: exports.NAMI_LAUNCH_ID = "nami_launch_id", NAMI_SESSION_ID: exports.NAMI_SESSION_ID = "nami_session_id", NAMI_LANGUAGE_CODE: exports.NAMI_LANGUAGE_CODE = "nami_language_code", NAMI_PURCHASE_CHANNEL: exports.NAMI_PURCHASE_CHANNEL = "nami_purchase_channel",
115
+ AUTH_DEVICE: exports.AUTH_DEVICE = "nami_auth_device", NAMI_CONFIGURATION: exports.NAMI_CONFIGURATION = "nami_configuration", NAMI_PROFILE: exports.NAMI_PROFILE = "nami_profile", API_CONFIG: exports.API_CONFIG = "api_config", API_CAMPAIGN_RULES: exports.API_CAMPAIGN_RULES = "api_campaign_rules", API_PAYWALLS: exports.API_PAYWALLS = "api_paywalls", API_CAMPAIGN_SESSION_TIMESTAMP: exports.API_CAMPAIGN_SESSION_TIMESTAMP = "api_campaign_session_timestamp", API_PRODUCTS: exports.API_PRODUCTS = "api_products", API_ACTIVE_ENTITLEMENTS: exports.API_ACTIVE_ENTITLEMENTS = "api_active_entitlements", SERVER_NAMI_ENTITLEMENTS: exports.SERVER_NAMI_ENTITLEMENTS = "server_active_entitlements", INITIAL_APP_CONFIG: exports.INITIAL_APP_CONFIG = "initial_app_config", INITIAL_CAMPAIGN_RULES: exports.INITIAL_CAMPAIGN_RULES = "initial_campaign_rules", INITIAL_PAYWALLS: exports.INITIAL_PAYWALLS = "initial_paywalls", INITIAL_PRODUCTS: exports.INITIAL_PRODUCTS = "initial_products", LOCAL_NAMI_ENTITLEMENTS: exports.LOCAL_NAMI_ENTITLEMENTS = "local_active_entitlements", CUSTOMER_ATTRIBUTES_KEY_PREFIX: exports.CUSTOMER_ATTRIBUTES_KEY_PREFIX = "nami_cmr_attributes_", NAMI_CUSTOMER_JOURNEY_STATE: exports.NAMI_CUSTOMER_JOURNEY_STATE = "nami_customer_journey_state",
116
+ // Journey delivery counters (NAM-3229), unrelated to the customer journey state above.
117
+ NAMI_JOURNEY_RULE_STATE: exports.NAMI_JOURNEY_RULE_STATE = "nami_journey_rule_state",
118
+ // Set at the first app_install journey evaluation on this device, whatever its outcome (NAM-3233).
119
+ NAMI_JOURNEY_INSTALL_EVALUATED: exports.NAMI_JOURNEY_INSTALL_EVALUATED = "nami_journey_install_evaluated", ANONYMOUS_MODE: exports.ANONYMOUS_MODE = "nami_anonymous_mode_v2", ANONYMOUS_UUID: exports.ANONYMOUS_UUID = "nami_anonymous_uuid", KEY_SESSION_COUNTER: exports.KEY_SESSION_COUNTER = "nami_session_counter", INITIAL_SESSION_COUNTER_VALUE: exports.INITIAL_SESSION_COUNTER_VALUE = 1, NAMI_LAST_IMPRESSION_ID: exports.NAMI_LAST_IMPRESSION_ID = "nami_last_impression_id", NAMI_PURCHASE_IMPRESSION_ID: exports.NAMI_PURCHASE_IMPRESSION_ID = "nami_purchase_impression_id", NAMI_LAUNCH_ID: exports.NAMI_LAUNCH_ID = "nami_launch_id", NAMI_SESSION_ID: exports.NAMI_SESSION_ID = "nami_session_id", NAMI_LANGUAGE_CODE: exports.NAMI_LANGUAGE_CODE = "nami_language_code", NAMI_PURCHASE_CHANNEL: exports.NAMI_PURCHASE_CHANNEL = "nami_purchase_channel",
116
120
  // API settings
117
121
  API_VERSION: exports.API_VERSION = "v3", BASE_URL_PATH: exports.BASE_URL_PATH = `sdk/${exports.API_VERSION}/platform`, BASE_URL: exports.BASE_URL = "https://app.namiml.com", BASE_STAGING_URL: exports.BASE_STAGING_URL = "https://app-staging.namiml.com", CUSTOM_HOST_PREFIX: exports.CUSTOM_HOST_PREFIX = "namiAPIHost=", USE_STAGING_API: exports.USE_STAGING_API = "useStagingAPI",
118
122
  // // extended client info
@@ -120,6 +124,8 @@ EXTENDED_CLIENT_INFO_PREFIX: exports.EXTENDED_CLIENT_INFO_PREFIX = "extendedClie
120
124
  // Test hook: every platform-config fetch attempt fails before the request leaves.
121
125
  // Same namiCommand as Apple's and Android's, so the shared first-run flows drive it.
122
126
  SIMULATE_CONFIG_FETCH_FAILURE: exports.SIMULATE_CONFIG_FETCH_FAILURE = "simulateConfigFetchFailure",
127
+ // Journeys are evaluated but never raised (NAM-5234); on in every e2e suite but the journey ones.
128
+ DISABLE_JOURNEY_DELIVERY: exports.DISABLE_JOURNEY_DELIVERY = "disableJourneyDelivery",
123
129
  // Test hook: every per-paywall body fetch fails before the request leaves, so the body cache's
124
130
  // failure rows can be exercised without a proxy. Same namiCommand as Apple's and Android's (NAM-4357).
125
131
  SIMULATE_PAYWALL_FETCH_FAILURE: exports.SIMULATE_PAYWALL_FETCH_FAILURE = "simulatePaywallFetchFailure",
@@ -142,6 +148,9 @@ CONFIG_API_TIMEOUT_LIMIT: exports.CONFIG_API_TIMEOUT_LIMIT = 10000,
142
148
  // A foreground within this long of the last launch/resume run is skipped — Apple's
143
149
  // NamiStartupCoordinator.shouldRunResume window.
144
150
  RESUME_THROTTLE_MS: exports.RESUME_THROTTLE_MS = 30000,
151
+ // A return to the foreground at least this long after the background starts a new session —
152
+ // the unified session boundary Apple and Android share (NAM-3234/3235). Drives journey sessions.
153
+ SESSION_GAP_MS: exports.SESSION_GAP_MS = 30000,
145
154
  // status codes
146
155
  STATUS_SUCCESS: exports.STATUS_SUCCESS = 200, STATUS_BAD_REQUEST: exports.STATUS_BAD_REQUEST = 400, STATUS_NOT_FOUND: exports.STATUS_NOT_FOUND = 404, STATUS_CONFLICT: exports.STATUS_CONFLICT = 409, STATUS_INTERNAL_SERVER_ERROR: exports.STATUS_INTERNAL_SERVER_ERROR = 500,
147
156
  // configuration states
@@ -162,7 +171,7 @@ const NAMI_STORAGE_KEYS = [
162
171
  exports.API_PRODUCTS, exports.API_ACTIVE_ENTITLEMENTS, exports.SERVER_NAMI_ENTITLEMENTS,
163
172
  exports.INITIAL_APP_CONFIG, exports.INITIAL_CAMPAIGN_RULES, exports.INITIAL_PAYWALLS, exports.INITIAL_PRODUCTS,
164
173
  exports.LOCAL_NAMI_ENTITLEMENTS,
165
- exports.NAMI_CUSTOMER_JOURNEY_STATE,
174
+ exports.NAMI_CUSTOMER_JOURNEY_STATE, exports.NAMI_JOURNEY_RULE_STATE, exports.NAMI_JOURNEY_INSTALL_EVALUATED,
166
175
  exports.ANONYMOUS_MODE, exports.ANONYMOUS_UUID,
167
176
  exports.KEY_SESSION_COUNTER, exports.NAMI_LAST_IMPRESSION_ID, exports.NAMI_PURCHASE_IMPRESSION_ID,
168
177
  exports.NAMI_LAUNCH_ID, exports.NAMI_SESSION_ID, exports.NAMI_LANGUAGE_CODE, exports.NAMI_PURCHASE_CHANNEL,
@@ -605,7 +614,7 @@ function baseGetTag(value) {
605
614
  * _.isObject(null);
606
615
  * // => false
607
616
  */
608
- function isObject(value) {
617
+ function isObject$1(value) {
609
618
  var type = typeof value;
610
619
  return value != null && (type == 'object' || type == 'function');
611
620
  }
@@ -634,7 +643,7 @@ var asyncTag = '[object AsyncFunction]',
634
643
  * // => false
635
644
  */
636
645
  function isFunction(value) {
637
- if (!isObject(value)) {
646
+ if (!isObject$1(value)) {
638
647
  return false;
639
648
  }
640
649
  // The use of `Object#toString` avoids issues with the `typeof` operator
@@ -722,7 +731,7 @@ var reIsNative = RegExp('^' +
722
731
  * else `false`.
723
732
  */
724
733
  function baseIsNative(value) {
725
- if (!isObject(value) || isMasked(value)) {
734
+ if (!isObject$1(value) || isMasked(value)) {
726
735
  return false;
727
736
  }
728
737
  var pattern = isFunction(value) ? reIsNative : reIsHostCtor;
@@ -2298,7 +2307,7 @@ function baseIsMatch(object, source, matchData, customizer) {
2298
2307
  * equality comparisons, else `false`.
2299
2308
  */
2300
2309
  function isStrictComparable(value) {
2301
- return value === value && !isObject(value);
2310
+ return value === value && !isObject$1(value);
2302
2311
  }
2303
2312
 
2304
2313
  /**
@@ -3525,6 +3534,23 @@ class StorageService {
3525
3534
  getCustomerJourneyState() {
3526
3535
  return this.getItem(exports.NAMI_CUSTOMER_JOURNEY_STATE);
3527
3536
  }
3537
+ /** The raw journey delivery blob; `journeyStateStore` validates its shape. */
3538
+ getJourneyRuleState() {
3539
+ return this.getItem(exports.NAMI_JOURNEY_RULE_STATE);
3540
+ }
3541
+ setJourneyRuleState(state) {
3542
+ this.setItem(exports.NAMI_JOURNEY_RULE_STATE, state);
3543
+ }
3544
+ removeJourneyRuleState() {
3545
+ this.resetItem(exports.NAMI_JOURNEY_RULE_STATE);
3546
+ }
3547
+ /** Whether the one-time app_install journey evaluation has run on this device. */
3548
+ getJourneyInstallEvaluated() {
3549
+ return this.getItem(exports.NAMI_JOURNEY_INSTALL_EVALUATED) === true;
3550
+ }
3551
+ setJourneyInstallEvaluated() {
3552
+ this.setItem(exports.NAMI_JOURNEY_INSTALL_EVALUATED, true);
3553
+ }
3528
3554
  setSessionCounter() {
3529
3555
  const counter = this.getItem(exports.KEY_SESSION_COUNTER);
3530
3556
  const currentCounter = counter ? parseInt(counter) : exports.INITIAL_SESSION_COUNTER_VALUE
@@ -7147,6 +7173,11 @@ const shouldValidateProductGroups = () => {
7147
7173
  const namiCommands = storageService.getNamiConfig()?.namiCommands;
7148
7174
  return namiCommands?.includes(exports.VALIDATE_PRODUCT_GROUPS) ?? false;
7149
7175
  };
7176
+ /** Whether journey delivery is suppressed by the `disableJourneyDelivery` namiCommand (NAM-5234). */
7177
+ const shouldDisableJourneyDelivery = () => {
7178
+ const namiCommands = storageService.getNamiConfig()?.namiCommands;
7179
+ return namiCommands?.includes(exports.DISABLE_JOURNEY_DELIVERY) ?? false;
7180
+ };
7150
7181
  const shouldLogHTTPRequests = () => {
7151
7182
  const namiCommands = storageService.getNamiConfig()?.namiCommands;
7152
7183
  return namiCommands?.includes(exports.LOG_HTTP_REQUESTS) ?? false;
@@ -57042,7 +57073,7 @@ const TRIGGER_EVENT_TYPES = ["app_install", "session_start"];
57042
57073
  const GOAL_TYPES = ["flow_completed", "user_context_set"];
57043
57074
  const PRIORITIES = ["high", "medium", "low"];
57044
57075
  const oneOf = (allowed, value) => allowed.includes(value) ? value : null;
57045
- const numberOrNull = (value) => (typeof value === "number" ? value : null);
57076
+ const numberOrNull$1 = (value) => (typeof value === "number" ? value : null);
57046
57077
  /**
57047
57078
  * Resolves a rule's `journey` object, reading `journey_id` from the top level and the other
57048
57079
  * fields from the `object` envelope when present, else from the top level (the flat contract
@@ -57062,17 +57093,17 @@ function parseJourneyMetadata(dto) {
57062
57093
  ? {
57063
57094
  type: oneOf(GOAL_TYPES, goal.type),
57064
57095
  context_key: goal.context_key ?? null,
57065
- timeout_days: numberOrNull(goal.timeout_days),
57066
- re_eligibility_days: numberOrNull(goal.re_eligibility_days),
57096
+ timeout_days: numberOrNull$1(goal.timeout_days),
57097
+ re_eligibility_days: numberOrNull$1(goal.re_eligibility_days),
57067
57098
  re_entry_after_goal: goal.re_entry_after_goal === true,
57068
57099
  }
57069
57100
  : null,
57070
57101
  delivery: {
57071
57102
  show_once: delivery?.show_once === true,
57072
- max_impressions_total: numberOrNull(delivery?.max_impressions_total),
57073
- max_per_session: numberOrNull(delivery?.max_per_session),
57074
- max_per_day: numberOrNull(delivery?.max_per_day),
57075
- delay_seconds: numberOrNull(delivery?.delay_seconds) ?? 0,
57103
+ max_impressions_total: numberOrNull$1(delivery?.max_impressions_total),
57104
+ max_per_session: numberOrNull$1(delivery?.max_per_session),
57105
+ max_per_day: numberOrNull$1(delivery?.max_per_day),
57106
+ delay_seconds: numberOrNull$1(delivery?.delay_seconds) ?? 0,
57076
57107
  },
57077
57108
  };
57078
57109
  }
@@ -61062,7 +61093,7 @@ var objectCreate = Object.create;
61062
61093
  var baseCreate = (function() {
61063
61094
  function object() {}
61064
61095
  return function(proto) {
61065
- if (!isObject(proto)) {
61096
+ if (!isObject$1(proto)) {
61066
61097
  return {};
61067
61098
  }
61068
61099
  if (objectCreate) {
@@ -61226,7 +61257,7 @@ function baseClone(value, bitmask, customizer, key, object, stack) {
61226
61257
  if (result !== undefined) {
61227
61258
  return result;
61228
61259
  }
61229
- if (!isObject(value)) {
61260
+ if (!isObject$1(value)) {
61230
61261
  return value;
61231
61262
  }
61232
61263
  var isArr = isArray(value);
@@ -61790,8 +61821,11 @@ class PaywallState extends SimpleEventTarget {
61790
61821
  this.providers.push(provider);
61791
61822
  return provider;
61792
61823
  }
61824
+ // `PaywallState.providers`, never `this.providers`: called through a subclass (web's
61825
+ // PaywallContextProvider), an assignment to `this.providers` would create a second array on the
61826
+ // subclass and leave this one holding every closed paywall.
61793
61827
  static remove(provider) {
61794
- this.providers = this.providers.filter(p => p !== provider);
61828
+ PaywallState.providers = PaywallState.providers.filter(p => p !== provider);
61795
61829
  }
61796
61830
  /**
61797
61831
  * Clear the static `providers` array. Called from `Nami.reset()` so
@@ -61800,7 +61834,7 @@ class PaywallState extends SimpleEventTarget {
61800
61834
  * for the next configure() cycle.
61801
61835
  */
61802
61836
  static reset() {
61803
- this.providers = [];
61837
+ PaywallState.providers = [];
61804
61838
  }
61805
61839
  static get currentProvider() {
61806
61840
  return PaywallState.providers[0];
@@ -62579,6 +62613,612 @@ class StartupTelemetry {
62579
62613
  }
62580
62614
  const startupTelemetry = new StartupTelemetry();
62581
62615
 
62616
+ /**
62617
+ * One-shot timer that can pause and resume, keeping the remaining time (NAM-3233). Mirrors
62618
+ * Apple's `JourneyDelayTimer` (NAM-3234). Remaining time is measured with the injected `now`,
62619
+ * a monotonic clock in production, so a wall-clock change cannot shorten or stretch a delay.
62620
+ */
62621
+ class JourneyDelayTimer {
62622
+ constructor(now) {
62623
+ this.now = now;
62624
+ this.handle = null;
62625
+ this.fire = null;
62626
+ this.deadline = 0;
62627
+ this.remaining = null;
62628
+ }
62629
+ /** True while a delay is running or paused. */
62630
+ get isPending() {
62631
+ return this.fire !== null;
62632
+ }
62633
+ /** True while a delay is pending but not counting down (paused, or started paused). */
62634
+ get isPaused() {
62635
+ return this.fire !== null && this.handle === null;
62636
+ }
62637
+ /**
62638
+ * Starts the delay, cancelling any pending one first: the previous `fire` never runs. The
62639
+ * single-slot rule (drop a new winner while one waits) is the caller's, which checks
62640
+ * {@link isPending} first.
62641
+ */
62642
+ start(ms, fire) {
62643
+ this.cancel();
62644
+ this.fire = fire;
62645
+ this.arm(ms);
62646
+ }
62647
+ /** Like {@link start}, but the delay begins paused with `ms` remaining until {@link resume}. */
62648
+ startPaused(ms, fire) {
62649
+ this.cancel();
62650
+ this.fire = fire;
62651
+ this.remaining = Math.max(0, ms);
62652
+ }
62653
+ /** Stops the countdown, remembering what is left. No-op unless running. */
62654
+ pause() {
62655
+ if (this.handle === null)
62656
+ return;
62657
+ this.remaining = Math.max(0, this.deadline - this.now());
62658
+ this.disarm();
62659
+ }
62660
+ /** Re-arms with the remaining time. No-op unless paused. */
62661
+ resume() {
62662
+ if (this.fire === null || this.handle !== null || this.remaining === null)
62663
+ return;
62664
+ const left = this.remaining;
62665
+ this.remaining = null;
62666
+ this.arm(left);
62667
+ }
62668
+ /** Clears everything; the pending `fire` never runs. */
62669
+ cancel() {
62670
+ this.disarm();
62671
+ this.fire = null;
62672
+ this.remaining = null;
62673
+ }
62674
+ arm(ms) {
62675
+ const delay = Math.max(0, ms);
62676
+ this.deadline = this.now() + delay;
62677
+ this.handle = setTimeout(() => {
62678
+ const action = this.fire;
62679
+ this.handle = null;
62680
+ this.fire = null;
62681
+ this.remaining = null;
62682
+ action?.();
62683
+ }, delay);
62684
+ }
62685
+ disarm() {
62686
+ if (this.handle !== null)
62687
+ clearTimeout(this.handle);
62688
+ this.handle = null;
62689
+ }
62690
+ }
62691
+
62692
+ /**
62693
+ * The journey engine's persisted state (NAM-3229), split from the store so the engine can use
62694
+ * these without importing storage. Mirrors Apple's `JourneyRecord.swift` (NAM-3230).
62695
+ */
62696
+ const emptyJourneyRecord = () => ({
62697
+ impressionsTotal: 0,
62698
+ sessionKey: null,
62699
+ sessionCount: 0,
62700
+ dayKey: null,
62701
+ dayCount: 0,
62702
+ lastDismissedAt: null,
62703
+ goalAchievedAt: null,
62704
+ enteredAt: null,
62705
+ });
62706
+ const emptyGlobalRecord = () => ({
62707
+ sessionOrdinal: 0,
62708
+ sessionKey: null,
62709
+ sessionCount: 0,
62710
+ dayKey: null,
62711
+ dayCount: 0,
62712
+ lastAnyImpressionAt: null,
62713
+ });
62714
+ const emptyJourneySnapshot = () => ({ journeys: {}, global: emptyGlobalRecord() });
62715
+
62716
+ const DAY_MS = 86_400_000;
62717
+ const PRIORITY_RANK = { high: 0, medium: 1, low: 2 };
62718
+ /** A counter stored under `storedKey` reads as 0 once the current key has moved on. */
62719
+ const countFor = (storedKey, count, currentKey) => storedKey === currentKey ? count : 0;
62720
+ /**
62721
+ * Pure journey eligibility and selection, plus the state transitions that follow (NAM-3229).
62722
+ * Mirrors Apple's `JourneyEngine` (NAM-3230), whose decision order the Android and Roku engines
62723
+ * share. The clock (`now`) and the time zone are injected; it imports no UI, adapter or storage
62724
+ * module, and never mutates the snapshot it is given.
62725
+ */
62726
+ class JourneyEngine {
62727
+ /**
62728
+ * @param options.timeZone IANA zone that defines the local day for daily counts, read on every
62729
+ * call. When absent or returning undefined, the device's local day is used.
62730
+ */
62731
+ constructor(options = {}) {
62732
+ this.options = options;
62733
+ }
62734
+ /**
62735
+ * The day of `now` as ISO `yyyy-MM-dd`. Always Gregorian with Latin digits: a Buddhist or
62736
+ * Japanese locale calendar must not change the year.
62737
+ */
62738
+ dayKey(now) {
62739
+ const zone = this.options.timeZone?.();
62740
+ if (!zone) {
62741
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
62742
+ }
62743
+ const parts = new Intl.DateTimeFormat("en-US", {
62744
+ timeZone: zone,
62745
+ calendar: "gregory",
62746
+ numberingSystem: "latn",
62747
+ year: "numeric",
62748
+ month: "2-digit",
62749
+ day: "2-digit",
62750
+ }).formatToParts(now);
62751
+ const part = (type) => parts.find((p) => p.type === type)?.value ?? "";
62752
+ return `${part("year").padStart(4, "0")}-${part("month")}-${part("day")}`;
62753
+ }
62754
+ /**
62755
+ * Picks the journey to deliver for `event`, applying decision rows 1-10 in payload order.
62756
+ *
62757
+ * - Rules without journey metadata are skipped without a rejection.
62758
+ * - Only the FIRST rule per `journey_id` in payload order is considered. Later rules sharing an
62759
+ * id neither compete nor get a rejection entry, and the winner's id never appears in
62760
+ * `rejections`.
62761
+ * - The active window (row 2) is the rule's `not_before`/`expires_at`, strict at both ends.
62762
+ * - Priority ties (`high` > `medium` > `low`) are broken by payload order: the wire has no
62763
+ * created timestamp, so the server's order is the only deterministic signal.
62764
+ */
62765
+ select({ event, journeys, snapshot, settings, isContentActive, now }) {
62766
+ const today = this.dayKey(now);
62767
+ const rejections = {};
62768
+ const survivors = [];
62769
+ const seen = new Set();
62770
+ for (const rule of journeys) {
62771
+ const journey = parseJourneyMetadata(rule.journey);
62772
+ if (!journey || seen.has(journey.journey_id))
62773
+ continue;
62774
+ seen.add(journey.journey_id);
62775
+ const rejection = this.perJourneyRejection(rule, journey, event, snapshot, settings, today, now);
62776
+ if (rejection)
62777
+ rejections[journey.journey_id] = rejection;
62778
+ else
62779
+ survivors.push({ rule, journey });
62780
+ }
62781
+ const global = this.globalRejection(snapshot.global, settings, isContentActive, today, now);
62782
+ if (global) {
62783
+ for (const s of survivors)
62784
+ rejections[s.journey.journey_id] = global;
62785
+ return { winner: null, rejections };
62786
+ }
62787
+ // The first survivor of the best rank wins, which is the payload-order tiebreak.
62788
+ let winner = null;
62789
+ for (const s of survivors) {
62790
+ if (!winner || PRIORITY_RANK[s.journey.priority] < PRIORITY_RANK[winner.journey.priority])
62791
+ winner = s;
62792
+ }
62793
+ for (const s of survivors)
62794
+ if (s !== winner)
62795
+ rejections[s.journey.journey_id] = "outranked";
62796
+ return { winner, rejections };
62797
+ }
62798
+ /** Rows 1-7. Returns null when the journey passes all of them. */
62799
+ perJourneyRejection(rule, journey, event, snapshot, settings, today, now) {
62800
+ if (!isSupportedJourney(journey) || journey.trigger.event_type !== event)
62801
+ return "triggerMismatch";
62802
+ if (!isCampaignRuleActive(rule.not_before, rule.expires_at, now))
62803
+ return "outsideWindow";
62804
+ const record = snapshot.journeys[journey.journey_id] ?? emptyJourneyRecord();
62805
+ const { delivery, goal } = journey;
62806
+ const nowMs = now.getTime();
62807
+ if (delivery.show_once) {
62808
+ if (record.impressionsTotal >= 1)
62809
+ return "shownOnce";
62810
+ }
62811
+ else {
62812
+ if (delivery.max_impressions_total != null && record.impressionsTotal >= delivery.max_impressions_total)
62813
+ return "totalCap";
62814
+ // A per-journey cap only tightens the global one.
62815
+ const sessionCap = Math.min(delivery.max_per_session ?? settings.max_per_session, settings.max_per_session);
62816
+ const dayCap = Math.min(delivery.max_per_day ?? settings.max_per_day, settings.max_per_day);
62817
+ if (countFor(record.sessionKey, record.sessionCount, snapshot.global.sessionOrdinal) >= sessionCap)
62818
+ return "sessionCap";
62819
+ if (countFor(record.dayKey, record.dayCount, today) >= dayCap)
62820
+ return "dayCap";
62821
+ const days = goal?.re_eligibility_days;
62822
+ if (days != null && record.lastDismissedAt != null && nowMs < record.lastDismissedAt + days * DAY_MS) {
62823
+ return "reEligibility";
62824
+ }
62825
+ }
62826
+ if (record.goalAchievedAt != null && (delivery.show_once || goal?.re_entry_after_goal !== true))
62827
+ return "goalAchieved";
62828
+ const timeout = goal?.timeout_days;
62829
+ if (timeout != null && record.enteredAt != null && nowMs >= record.enteredAt + timeout * DAY_MS)
62830
+ return "timedOut";
62831
+ return null;
62832
+ }
62833
+ /** Rows 8 and 9: device-wide checks, evaluated once per decision. */
62834
+ globalRejection(global, settings, isContentActive, today, now) {
62835
+ if (countFor(global.sessionKey, global.sessionCount, global.sessionOrdinal) >= settings.max_per_session)
62836
+ return "globalSessionCap";
62837
+ if (countFor(global.dayKey, global.dayCount, today) >= settings.max_per_day)
62838
+ return "globalDayCap";
62839
+ if (global.lastAnyImpressionAt != null && now.getTime() < global.lastAnyImpressionAt + settings.cooldown_seconds * 1000) {
62840
+ return "cooldown";
62841
+ }
62842
+ if (settings.suppress_when_active && isContentActive)
62843
+ return "suppressed";
62844
+ return null;
62845
+ }
62846
+ // Transitions
62847
+ /** Records a delivered journey impression: journey and global counters, entry and cooldown. */
62848
+ recordImpression(s, journeyId, now) {
62849
+ const today = this.dayKey(now);
62850
+ const ordinal = s.global.sessionOrdinal;
62851
+ const record = s.journeys[journeyId] ?? emptyJourneyRecord();
62852
+ const out = this.recordExternalImpression(s, now);
62853
+ return this.withRecord(out, journeyId, {
62854
+ ...record,
62855
+ impressionsTotal: record.impressionsTotal + 1,
62856
+ sessionKey: ordinal,
62857
+ sessionCount: countFor(record.sessionKey, record.sessionCount, ordinal) + 1,
62858
+ dayKey: today,
62859
+ dayCount: countFor(record.dayKey, record.dayCount, today) + 1,
62860
+ enteredAt: record.enteredAt ?? now.getTime(),
62861
+ });
62862
+ }
62863
+ /** Records an impression shown outside Nami journeys: global counters and cooldown only. */
62864
+ recordExternalImpression(s, now) {
62865
+ const today = this.dayKey(now);
62866
+ const g = s.global;
62867
+ return {
62868
+ ...s,
62869
+ global: {
62870
+ ...g,
62871
+ sessionKey: g.sessionOrdinal,
62872
+ sessionCount: countFor(g.sessionKey, g.sessionCount, g.sessionOrdinal) + 1,
62873
+ dayKey: today,
62874
+ dayCount: countFor(g.dayKey, g.dayCount, today) + 1,
62875
+ lastAnyImpressionAt: now.getTime(),
62876
+ },
62877
+ };
62878
+ }
62879
+ /** Records a dismiss; it starts the re-eligibility window but never counts as an impression. */
62880
+ recordDismiss(s, journeyId, now) {
62881
+ return this.withRecord(s, journeyId, { ...(s.journeys[journeyId] ?? emptyJourneyRecord()), lastDismissedAt: now.getTime() });
62882
+ }
62883
+ /** Records goal completion and clears entry so a re-entry journey starts a fresh timeout. */
62884
+ recordGoal(s, journeyId, now) {
62885
+ return this.withRecord(s, journeyId, {
62886
+ ...(s.journeys[journeyId] ?? emptyJourneyRecord()),
62887
+ goalAchievedAt: now.getTime(),
62888
+ enteredAt: null,
62889
+ });
62890
+ }
62891
+ withRecord(s, journeyId, record) {
62892
+ return { ...s, journeys: { ...s.journeys, [journeyId]: record } };
62893
+ }
62894
+ }
62895
+
62896
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
62897
+ const numberOrNull = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
62898
+ const stringOrNull = (value) => (typeof value === "string" ? value : null);
62899
+ // Each field falls back to its default when missing or mistyped, so a partial blob still loads.
62900
+ function decodeRecord(raw) {
62901
+ if (!isObject(raw))
62902
+ return emptyJourneyRecord();
62903
+ return {
62904
+ impressionsTotal: numberOrNull(raw.impressionsTotal) ?? 0,
62905
+ sessionKey: numberOrNull(raw.sessionKey),
62906
+ sessionCount: numberOrNull(raw.sessionCount) ?? 0,
62907
+ dayKey: stringOrNull(raw.dayKey),
62908
+ dayCount: numberOrNull(raw.dayCount) ?? 0,
62909
+ lastDismissedAt: numberOrNull(raw.lastDismissedAt),
62910
+ goalAchievedAt: numberOrNull(raw.goalAchievedAt),
62911
+ enteredAt: numberOrNull(raw.enteredAt),
62912
+ };
62913
+ }
62914
+ function decodeGlobal(raw) {
62915
+ if (!isObject(raw))
62916
+ return emptyGlobalRecord();
62917
+ return {
62918
+ sessionOrdinal: numberOrNull(raw.sessionOrdinal) ?? 0,
62919
+ sessionKey: numberOrNull(raw.sessionKey),
62920
+ sessionCount: numberOrNull(raw.sessionCount) ?? 0,
62921
+ dayKey: stringOrNull(raw.dayKey),
62922
+ dayCount: numberOrNull(raw.dayCount) ?? 0,
62923
+ lastAnyImpressionAt: numberOrNull(raw.lastAnyImpressionAt),
62924
+ };
62925
+ }
62926
+ /** A stored blob as a snapshot. A missing, corrupt or wrongly shaped blob reads as empty. */
62927
+ function decodeSnapshot(raw) {
62928
+ if (!isObject(raw) || !isObject(raw.journeys))
62929
+ return emptyJourneySnapshot();
62930
+ const journeys = {};
62931
+ for (const [id, record] of Object.entries(raw.journeys))
62932
+ journeys[id] = decodeRecord(record);
62933
+ return { journeys, global: decodeGlobal(raw.global) };
62934
+ }
62935
+ /**
62936
+ * Persists the journey engine's counters as one JSON blob under `nami_journey_rule_state`. The
62937
+ * key is in `NAMI_STORAGE_KEYS`, so `storageService.clearAll()` wipes it. There is no in-memory
62938
+ * cache: every read goes to storage, so a wipe can never leave stale counters behind.
62939
+ */
62940
+ const journeyStateStore = {
62941
+ snapshot() {
62942
+ return decodeSnapshot(storageService.getJourneyRuleState());
62943
+ },
62944
+ /** Applies `transform` to the stored snapshot, persists the result and returns it. */
62945
+ update(transform) {
62946
+ const updated = transform(this.snapshot());
62947
+ storageService.setJourneyRuleState(updated);
62948
+ return updated;
62949
+ },
62950
+ /** Starts a new local session: bumps the ordinal, which re-keys per-session counts to 0. */
62951
+ startSession() {
62952
+ this.update((s) => ({ ...s, global: { ...s.global, sessionOrdinal: s.global.sessionOrdinal + 1 } }));
62953
+ },
62954
+ /** Drops all journey state. */
62955
+ clear() {
62956
+ storageService.removeJourneyRuleState();
62957
+ },
62958
+ };
62959
+
62960
+ /**
62961
+ * Two-slot, session-scoped store of the most recent launches.
62962
+ *
62963
+ * On `record(rec)` the existing `current` slot is demoted to `previous` and
62964
+ * `rec` becomes the new `current`. Flow conditions read `previous`, which
62965
+ * structurally excludes the placement that is launching right now (the PPO
62966
+ * requirement): the first launch in a session leaves `previous` undefined.
62967
+ *
62968
+ * State is in-memory only and cleared via `reset()` (wired into `Nami.reset()`).
62969
+ */
62970
+ class LastLaunchStore {
62971
+ static get shared() {
62972
+ if (!this._shared)
62973
+ this._shared = new LastLaunchStore();
62974
+ return this._shared;
62975
+ }
62976
+ /** The Last Launch — the launch immediately before the current one. */
62977
+ get previous() {
62978
+ return this._previous;
62979
+ }
62980
+ /** Demote `current` → `previous`, then set `current` to `rec`. */
62981
+ record(rec) {
62982
+ this._previous = this._current;
62983
+ this._current = rec;
62984
+ }
62985
+ /** Clear both slots. */
62986
+ reset() {
62987
+ this._current = undefined;
62988
+ this._previous = undefined;
62989
+ }
62990
+ }
62991
+
62992
+ // `NamiFlowManager` imports the `nami` module back, so importing it here would close a load-order
62993
+ // cycle (the one `Nami.reset()` sidesteps with a lazy import). It registers this probe when it
62994
+ // loads instead; until then no flow can be open.
62995
+ let flowOpenProbe = () => false;
62996
+ /** Called by `NamiFlowManager` when its module loads. */
62997
+ const registerFlowOpenProbe = (probe) => {
62998
+ flowOpenProbe = probe;
62999
+ };
63000
+ /** Whether a Nami paywall or flow is on screen: a journey never raises over one (NAM-3233). */
63001
+ const isNamiContentActive = () => PaywallState.providers.length > 0 || flowOpenProbe();
63002
+ /** Whether the app is visible, per the device adapter; visible when the adapter cannot tell. */
63003
+ const isAppVisible = () => getPlatformAdapters().device.isAppVisible?.() ?? true;
63004
+ /**
63005
+ * Raises a journey's campaign through the UI adapter, the way `NamiCampaignManager.launch` raises
63006
+ * a placement, and reports whether it was presented (NAM-3233). Mirrors Apple's
63007
+ * `JourneyCoordinator.deliverOnMain` + `raiseCampaign`.
63008
+ *
63009
+ * Re-checks right before the raise: no `Nami.reset()` since the fire (`stillValid`), no paywall or
63010
+ * flow open (whatever `suppress_when_active` says: two would stack), and the app visible. Then the
63011
+ * campaign's content must be there (every flow screen, or the paywall), and the adapter must be able
63012
+ * to present by itself (`presentCampaign`; Expo and the CLI cannot).
63013
+ *
63014
+ * Like a launch, it records the Last Launch store (with no placement label: a journey has no
63015
+ * `value`) and a fresh launch id, so pages, flows, impressions and the rule's segment behave as for
63016
+ * a placement.
63017
+ */
63018
+ function presentJourneyCampaign(campaign, stillValid) {
63019
+ const { ui } = getPlatformAdapters();
63020
+ const skip = (reason) => {
63021
+ logger.debug(`Journey raise skipped for rule ${campaign.rule}: ${reason}`);
63022
+ return false;
63023
+ };
63024
+ if (!stillValid())
63025
+ return skip("Nami.reset() ran after it fired");
63026
+ if (isNamiContentActive())
63027
+ return skip("a paywall or flow is already open");
63028
+ if (!isAppVisible())
63029
+ return skip("the app is not visible");
63030
+ if (!ui.presentCampaign)
63031
+ return skip("this platform cannot present a journey");
63032
+ if (campaign.flow) {
63033
+ if (!campaign.flow.object || !hasAllPaywalls(campaign.flow.object.screens ?? [])) {
63034
+ return skip("its flow screens are not available");
63035
+ }
63036
+ }
63037
+ else if (!campaign.paywall || !getPaywall(campaign.paywall)) {
63038
+ return skip("its paywall is not available");
63039
+ }
63040
+ LastLaunchStore.shared.record({ placementLabel: "", tags: campaign.placement_tags ?? [], customAttributes: {} });
63041
+ storageService.setLaunchId(generateUUID());
63042
+ return ui.presentCampaign(campaign, { customAttributes: {} });
63043
+ }
63044
+
63045
+ const monotonicNow = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
63046
+ const liveJourneyEnvironment = {
63047
+ now: () => new Date(),
63048
+ monotonicNow,
63049
+ journeys: journeyCampaigns,
63050
+ settings: getJourneySettings,
63051
+ contentActive: isNamiContentActive,
63052
+ isAppVisible,
63053
+ present: presentJourneyCampaign,
63054
+ deliveryDisabled: shouldDisableJourneyDelivery,
63055
+ installMarker: {
63056
+ get: () => storageService.getJourneyInstallEvaluated(),
63057
+ set: () => storageService.setJourneyInstallEvaluated(),
63058
+ },
63059
+ };
63060
+ const journeyIdOf = (rule) => parseJourneyMetadata(rule.journey)?.journey_id;
63061
+ /**
63062
+ * Drives journey delivery for the `app_install` and `session_start` triggers (NAM-3233):
63063
+ * evaluation, the single delay slot, the fire-time re-check, delivery and local impression
63064
+ * accounting. A port of Apple's `JourneyCoordinator` (NAM-3234) minus campaign-rule refresh,
63065
+ * which core gains in NAM-3236. JS is single-threaded, so there is no queue: the reset
63066
+ * generation alone keeps work from before a `Nami.reset()` off the fresh state.
63067
+ *
63068
+ * Guards on the way to a raise:
63069
+ * - A winner chosen while the app is hidden starts its delay paused, a 0-second delay included,
63070
+ * and only counts down once the app is visible again.
63071
+ * - When the delay ends, the winner is looked up again by `journey_id` in the CURRENT journey
63072
+ * rules and re-checked against fresh state. Absent (removed or paused by the server, or no
63073
+ * journeys left at all) means dropped: a failed fetch keeps the previous rules, so an empty
63074
+ * list really is empty.
63075
+ * - `present` re-checks reset, open content and visibility right before the raise.
63076
+ */
63077
+ class JourneyCoordinator {
63078
+ constructor(env) {
63079
+ this.env = env;
63080
+ this.engine = new JourneyEngine();
63081
+ this.resetGeneration = 0;
63082
+ this.backgroundedAt = null;
63083
+ this.delay = new JourneyDelayTimer(env.monotonicNow);
63084
+ }
63085
+ /**
63086
+ * The current reset generation; {@link reset} bumps it. A startup pass reads it when it begins
63087
+ * and hands it back to {@link sessionStarted}.
63088
+ */
63089
+ get generation() {
63090
+ return this.resetGeneration;
63091
+ }
63092
+ /** Whether a delay is pending but not counting down. */
63093
+ get isDelayPaused() {
63094
+ return this.delay.isPaused;
63095
+ }
63096
+ // Triggers and lifecycle
63097
+ /**
63098
+ * A new session began (configure, or a return after the session gap). Advances the journey
63099
+ * session ordinal and evaluates the triggers.
63100
+ *
63101
+ * @param generation the {@link generation} read when the startup pass began. A `Nami.reset()`
63102
+ * since then makes the call stale, and it is dropped whole: no ordinal bump, no evaluation,
63103
+ * no install marker. Omitted, nothing is checked.
63104
+ */
63105
+ sessionStarted(isInstallCandidate, generation) {
63106
+ if (generation !== undefined && generation !== this.resetGeneration) {
63107
+ logger.debug("Journey session start dropped: Nami.reset() ran during the startup pass");
63108
+ return;
63109
+ }
63110
+ journeyStateStore.startSession();
63111
+ this.evaluate(isInstallCandidate);
63112
+ }
63113
+ /** Pauses the delay, keeping the remainder, and starts measuring the session gap. */
63114
+ appDidEnterBackground() {
63115
+ this.delay.pause();
63116
+ this.backgroundedAt ??= this.env.monotonicNow();
63117
+ }
63118
+ /**
63119
+ * Resumes the delay with the remaining time, including one held since a hidden start.
63120
+ *
63121
+ * @returns whether this return begins a new session: it came at least {@link SESSION_GAP_MS}
63122
+ * after the background (the session boundary Apple and Android share). A foreground with no
63123
+ * background recorded never does.
63124
+ */
63125
+ appWillEnterForeground() {
63126
+ const hiddenAt = this.backgroundedAt;
63127
+ this.backgroundedAt = null;
63128
+ this.delay.resume();
63129
+ return hiddenAt !== null && this.env.monotonicNow() - hiddenAt >= exports.SESSION_GAP_MS;
63130
+ }
63131
+ /** A placement campaign is about to be raised: a journey still waiting must not fire on top of it. */
63132
+ placementWillRaise() {
63133
+ this.delay.cancel();
63134
+ }
63135
+ /** Cancels the delay and invalidates in-flight work (`Nami.reset()`), before the storage wipe. */
63136
+ reset() {
63137
+ this.delay.cancel();
63138
+ this.backgroundedAt = null;
63139
+ this.resetGeneration += 1;
63140
+ }
63141
+ // Evaluation
63142
+ evaluate(isInstallCandidate) {
63143
+ let selected = null;
63144
+ if (isInstallCandidate && !this.env.installMarker.get()) {
63145
+ const winner = this.select("app_install");
63146
+ if (winner)
63147
+ selected = { event: "app_install", winner };
63148
+ }
63149
+ // The marker is set at the first evaluation whether or not a journey won.
63150
+ if (isInstallCandidate)
63151
+ this.env.installMarker.set();
63152
+ if (!selected) {
63153
+ const winner = this.select("session_start");
63154
+ if (winner)
63155
+ selected = { event: "session_start", winner };
63156
+ }
63157
+ if (!selected)
63158
+ return;
63159
+ const { event, winner } = selected;
63160
+ const journeyId = winner.journey.journey_id;
63161
+ if (this.delay.isPending) {
63162
+ logger.debug(`Journey ${journeyId} dropped: another journey is waiting for its delay`);
63163
+ return;
63164
+ }
63165
+ const ms = Math.max(0, winner.journey.delivery.delay_seconds * 1000);
63166
+ const fire = () => this.fire(event, journeyId);
63167
+ if (this.env.isAppVisible()) {
63168
+ this.delay.start(ms, fire);
63169
+ }
63170
+ else {
63171
+ logger.debug(`Journey ${journeyId} held: the app is not visible, its delay starts paused`);
63172
+ this.delay.startPaused(ms, fire);
63173
+ }
63174
+ }
63175
+ decide(event, journeys) {
63176
+ return this.engine.select({
63177
+ event,
63178
+ journeys,
63179
+ snapshot: journeyStateStore.snapshot(),
63180
+ settings: this.env.settings(),
63181
+ isContentActive: this.env.contentActive(),
63182
+ now: this.env.now(),
63183
+ });
63184
+ }
63185
+ select(event) {
63186
+ const { winner, rejections } = this.decide(event, this.env.journeys());
63187
+ if (!winner && Object.keys(rejections).length > 0) {
63188
+ logger.debug(`No ${event} journey selected: ${JSON.stringify(rejections)}`);
63189
+ }
63190
+ return winner;
63191
+ }
63192
+ /** The delay ended: re-resolve the winner, re-check it against fresh state, then deliver. */
63193
+ fire(event, journeyId) {
63194
+ if (!this.env.isAppVisible()) {
63195
+ // The countdown ended out of sight: hold it again until the foreground instead of dropping it.
63196
+ this.delay.startPaused(0, () => this.fire(event, journeyId));
63197
+ return;
63198
+ }
63199
+ const candidate = this.env.journeys().find((rule) => journeyIdOf(rule) === journeyId);
63200
+ if (!candidate) {
63201
+ logger.debug(`Journey ${journeyId} dropped at fire time: no longer in the journey rules`);
63202
+ return;
63203
+ }
63204
+ const { winner, rejections } = this.decide(event, [candidate]);
63205
+ if (!winner) {
63206
+ logger.debug(`Journey ${journeyId} dropped at fire time: ${rejections[journeyId]}`);
63207
+ return;
63208
+ }
63209
+ if (this.env.deliveryDisabled()) {
63210
+ logger.debug(`Journey ${journeyId} not delivered: disableJourneyDelivery is set`);
63211
+ return;
63212
+ }
63213
+ const generation = this.resetGeneration;
63214
+ const stillValid = () => this.resetGeneration === generation;
63215
+ if (!this.env.present(winner.rule, stillValid) || !stillValid())
63216
+ return;
63217
+ journeyStateStore.update((s) => this.engine.recordImpression(s, journeyId, this.env.now()));
63218
+ }
63219
+ }
63220
+ JourneyCoordinator.shared = new JourneyCoordinator(liveJourneyEnvironment);
63221
+
62582
63222
  class NamiRefs {
62583
63223
  constructor() {
62584
63224
  // In-memory flag for device registration failures (not persisted)
@@ -62594,6 +63234,9 @@ class NamiRefs {
62594
63234
  this.lastConfig = null;
62595
63235
  this.lastStartupRunAt = 0;
62596
63236
  this.startupInFlight = false;
63237
+ // The startup rules fetch, settled either way. With an initial config `configure()` does not
63238
+ // wait for it, but the journey session does, so it evaluates the fetched rules (NAM-3233).
63239
+ this.startupFetch = Promise.resolve();
62597
63240
  // Register this instance to avoid circular dependency issues
62598
63241
  setNamiRefsInstance(this);
62599
63242
  }
@@ -62620,7 +63263,8 @@ class NamiRefs {
62620
63263
  return this.startupDisabled;
62621
63264
  }
62622
63265
  async init(config) {
62623
- this.isFirstSession = storageService.getDevice() === null && !storageService.getAnonymousMode();
63266
+ this.isFirstSession = this.hasNoDevice();
63267
+ const journeyGeneration = JourneyCoordinator.shared.generation;
62624
63268
  // Cleared per configure so a transient failure cannot disable a later run.
62625
63269
  this.startupDisabled = false;
62626
63270
  // `Nami.configure` has already normalised a missing or undecodable initialConfig to `{}`,
@@ -62633,12 +63277,42 @@ class NamiRefs {
62633
63277
  try {
62634
63278
  if (!(await this.fetchPlatformConfig()))
62635
63279
  return;
62636
- await this.runStartupAfterConfig(config);
63280
+ try {
63281
+ await this.runStartupAfterConfig(config);
63282
+ }
63283
+ finally {
63284
+ // Even after a failed step: the rules may be cached (Apple starts it the same way).
63285
+ this.startJourneySession(this.isFirstSession, journeyGeneration);
63286
+ }
62637
63287
  }
62638
63288
  finally {
62639
63289
  this.startupInFlight = false;
62640
63290
  }
62641
63291
  }
63292
+ /**
63293
+ * The app returned to the foreground. A return at least `SESSION_GAP_MS` after the background
63294
+ * starts a new journey session once the {@link resume} config refresh is done, unless startup is
63295
+ * disabled or a configure is already running (it starts its own). Every return runs `resume()`.
63296
+ */
63297
+ async foreground() {
63298
+ const journeys = JourneyCoordinator.shared;
63299
+ const newSession = journeys.appWillEnterForeground();
63300
+ const startNewSession = newSession && this.lastConfig !== null && !this.startupInFlight;
63301
+ const generation = journeys.generation;
63302
+ const isInstallCandidate = this.hasNoDevice();
63303
+ await this.resume();
63304
+ if (startNewSession && !this.startupDisabled) {
63305
+ this.startJourneySession(isInstallCandidate, generation);
63306
+ }
63307
+ }
63308
+ /** No registered device id yet, so the device is a candidate for the `app_install` trigger. */
63309
+ hasNoDevice() {
63310
+ return storageService.getDevice() === null && !storageService.getAnonymousMode();
63311
+ }
63312
+ /** Starts the journey session once the startup rules fetch has settled, without blocking on it. */
63313
+ startJourneySession(isInstallCandidate, generation) {
63314
+ void this.startupFetch.then(() => JourneyCoordinator.shared.sessionStarted(isInstallCandidate, generation));
63315
+ }
62642
63316
  /**
62643
63317
  * The app returned to the foreground. Re-reads the platform config, as Apple's `.appResume`
62644
63318
  * and Android's `onAppComesFromBackground` do, so a config change — the `sdk` kill switch
@@ -62781,6 +63455,7 @@ class NamiRefs {
62781
63455
  }
62782
63456
  throw error;
62783
63457
  });
63458
+ this.startupFetch = promises.then(() => undefined, () => undefined);
62784
63459
  if (!initialConfig.campaign_rules?.length || !initialConfig.paywalls?.length) {
62785
63460
  await promises;
62786
63461
  }
@@ -62952,39 +63627,7 @@ let NamiProfileManager$1 = class NamiProfileManager {
62952
63627
  };
62953
63628
  NamiProfileManager$1.instance = new NamiProfileManager$1();
62954
63629
 
62955
- /**
62956
- * Two-slot, session-scoped store of the most recent launches.
62957
- *
62958
- * On `record(rec)` the existing `current` slot is demoted to `previous` and
62959
- * `rec` becomes the new `current`. Flow conditions read `previous`, which
62960
- * structurally excludes the placement that is launching right now (the PPO
62961
- * requirement): the first launch in a session leaves `previous` undefined.
62962
- *
62963
- * State is in-memory only and cleared via `reset()` (wired into `Nami.reset()`).
62964
- */
62965
- class LastLaunchStore {
62966
- static get shared() {
62967
- if (!this._shared)
62968
- this._shared = new LastLaunchStore();
62969
- return this._shared;
62970
- }
62971
- /** The Last Launch — the launch immediately before the current one. */
62972
- get previous() {
62973
- return this._previous;
62974
- }
62975
- /** Demote `current` → `previous`, then set `current` to `rec`. */
62976
- record(rec) {
62977
- this._previous = this._current;
62978
- this._current = rec;
62979
- }
62980
- /** Clear both slots. */
62981
- reset() {
62982
- this._current = undefined;
62983
- this._previous = undefined;
62984
- }
62985
- }
62986
-
62987
- var _Nami_isInitialized, _Nami_removeForegroundListener;
63630
+ var _Nami_isInitialized, _Nami_removeForegroundListener, _Nami_removeBackgroundListener;
62988
63631
  // NamiFlowManager is intentionally NOT imported at top level — it
62989
63632
  // transitively imports back to this module (`Nami` for logging), and
62990
63633
  // pulling it in here causes a load-order cycle that breaks tests
@@ -62993,6 +63636,7 @@ class Nami {
62993
63636
  constructor() {
62994
63637
  _Nami_isInitialized.set(this, false);
62995
63638
  _Nami_removeForegroundListener.set(this, null);
63639
+ _Nami_removeBackgroundListener.set(this, null);
62996
63640
  this.maxLogging = false;
62997
63641
  }
62998
63642
  get isInitialized() {
@@ -63016,7 +63660,9 @@ class Nami {
63016
63660
  * integrating app, consistent with the Apple and Android SDKs.
63017
63661
  */
63018
63662
  static async reset() {
63019
- var _a;
63663
+ var _a, _b;
63664
+ // Before the wipe, so a pending journey can neither fire nor record into the fresh store.
63665
+ JourneyCoordinator.shared.reset();
63020
63666
  storageService.clearAll();
63021
63667
  // The per-URL paywall body cache keeps its own prefixed keys and an in-memory index (NAM-4357).
63022
63668
  paywallBodyCache.purgeAll();
@@ -63024,6 +63670,8 @@ class Nami {
63024
63670
  __classPrivateFieldSet(Nami.instance, _Nami_isInitialized, false, "f");
63025
63671
  __classPrivateFieldGet((_a = Nami.instance), _Nami_removeForegroundListener, "f")?.call(_a);
63026
63672
  __classPrivateFieldSet(Nami.instance, _Nami_removeForegroundListener, null, "f");
63673
+ __classPrivateFieldGet((_b = Nami.instance), _Nami_removeBackgroundListener, "f")?.call(_b);
63674
+ __classPrivateFieldSet(Nami.instance, _Nami_removeBackgroundListener, null, "f");
63027
63675
  NamiRefs.instance.clearResumeState();
63028
63676
  // In-memory singleton state that previously survived reset and
63029
63677
  // bled into the next configure() cycle.
@@ -63088,16 +63736,24 @@ class Nami {
63088
63736
  config.initialConfig = {};
63089
63737
  }
63090
63738
  /**
63091
- * Subscribes the resume config refresh to the host's foreground signal, once per process —
63092
- * a reconfigure must not stack a second listener (the Android defect NAM-4541 fixed).
63739
+ * Subscribes to the host's foreground and background signals, once per process — a
63740
+ * reconfigure must not stack a second listener (the Android defect NAM-4541 fixed).
63093
63741
  * Registered before startup runs; `resume()` itself skips while startup is in flight.
63742
+ *
63743
+ * A foreground runs the resume config refresh and, after the session gap, a journey session
63744
+ * start (`NamiRefs.foreground`); a background pauses a pending journey and starts the gap
63745
+ * (NAM-3233).
63094
63746
  */
63095
63747
  listenForAppForeground() {
63096
- if (__classPrivateFieldGet(this, _Nami_removeForegroundListener, "f"))
63097
- return;
63098
- __classPrivateFieldSet(this, _Nami_removeForegroundListener, getPlatformAdapters().device.onAppForeground?.(() => {
63099
- void NamiRefs.instance.resume();
63100
- }) ?? null, "f");
63748
+ const device = getPlatformAdapters().device;
63749
+ if (!__classPrivateFieldGet(this, _Nami_removeForegroundListener, "f")) {
63750
+ __classPrivateFieldSet(this, _Nami_removeForegroundListener, device.onAppForeground?.(() => {
63751
+ void NamiRefs.instance.foreground();
63752
+ }) ?? null, "f");
63753
+ }
63754
+ if (!__classPrivateFieldGet(this, _Nami_removeBackgroundListener, "f")) {
63755
+ __classPrivateFieldSet(this, _Nami_removeBackgroundListener, device.onAppBackground?.(() => JourneyCoordinator.shared.appDidEnterBackground()) ?? null, "f");
63756
+ }
63101
63757
  }
63102
63758
  async initializeSDK(config) {
63103
63759
  let fullReconfig = false;
@@ -63151,7 +63807,7 @@ class Nami {
63151
63807
  };
63152
63808
  }
63153
63809
  }
63154
- _Nami_isInitialized = new WeakMap(), _Nami_removeForegroundListener = new WeakMap();
63810
+ _Nami_isInitialized = new WeakMap(), _Nami_removeForegroundListener = new WeakMap(), _Nami_removeBackgroundListener = new WeakMap();
63155
63811
  Nami.instance = new Nami();
63156
63812
 
63157
63813
  const VALID_STYLES = new Set(['fullscreen', 'sheet', 'compact_sheet', 'modal']);
@@ -64723,6 +65379,8 @@ let NamiFlowManager$2 = class NamiFlowManager {
64723
65379
  this.flowOpen = false;
64724
65380
  this.currentFlow = undefined;
64725
65381
  this.lastAnimatedFlowProgress.clear();
65382
+ // Lets an adapter tear down a presentation it owns, e.g. a web journey (NAM-3233).
65383
+ getPlatformAdapters().ui.onFlowFinished?.();
64726
65384
  }
64727
65385
  /**
64728
65386
  * Gets the last animated progress for a component by ID
@@ -64975,6 +65633,8 @@ let NamiFlowManager$2 = class NamiFlowManager {
64975
65633
  return this.instance.getCurrentFlowStepAutoAdvanceDelay();
64976
65634
  }
64977
65635
  };
65636
+ // Journey delivery reads whether a flow is open without importing this module (NAM-3233).
65637
+ registerFlowOpenProbe(() => NamiFlowManager$2.isFlowOpen());
64978
65638
 
64979
65639
  var NamiFlowManager$3 = /*#__PURE__*/Object.freeze({
64980
65640
  __proto__: null,
@@ -66618,6 +67278,9 @@ let NamiCampaignManager$2 = class NamiCampaignManager {
66618
67278
  customAttributes: (context?.customAttributes ?? {}),
66619
67279
  });
66620
67280
  }
67281
+ // A journey still waiting for its delay must not fire on top of this placement (NAM-3233).
67282
+ if (!campaignNotFound)
67283
+ JourneyCoordinator.shared.placementWillRaise();
66621
67284
  const component = getPlatformAdapters().ui.createPaywall(type, value, context);
66622
67285
  // Generate and store launch ID for successful campaign launch
66623
67286
  const launchId = generateUUID();