core/sdk-initializer/sdk-initializer.tstypescript
/**
* SDK Initializer
* Handles auto-initialization, configuration loading, and setup
*/
import { createLogger, Logger, LogLevel } from '@/core/logger';
import { useConfigStore } from '@/state/config';
import { useCampaignStore } from '@/state/campaign';
import { useCartStore, cartOperations } from '@/state/cart';
import { useOrderStore } from '@/state/order';
import { AttributeScanner } from '../attribute-scanner';
import { NextCommerce } from '@/core/next-commerce';
// Debug overlay imported dynamically when needed
import { EventBus } from '@/core/events';
import { getApiClient } from '@/client';
import { CART_STORAGE_KEY } from '@/core/storage';
import { storageScopeFellBack } from '@/core/storage-scope';
import { CountryService } from '@/core/country-service';
import * as urlParamMethods from '@/core/sdk-initializer/sdk-initializer.url-params';
import * as storageResetMethods from '@/core/sdk-initializer/sdk-initializer.storage-reset';
import * as debugUtilsMethods from '@/core/sdk-initializer/sdk-initializer.debug-utils';
import * as locationCurrencyMethods from '@/core/sdk-initializer/sdk-initializer.location-currency';
import * as attributionMethods from '@/core/sdk-initializer/sdk-initializer.attribution';
import type { AttributionCtx } from '@/core/sdk-initializer/sdk-initializer.attribution';
/**
* Boots the SDK on page load: reads meta tag configuration, loads the campaign, restores persisted state, and scans the DOM for `data-next-*` attributes.
*
* @category Core
*/
export class SDKInitializer {
private static logger = createLogger('SDKInitializer');
private static initialized = false;
private static attributeScanner: AttributeScanner | null = null;
private static retryAttempts = 0;
private static maxRetries = 3;
// Shared with `sdk-initializer.attribution.ts` — `attributionListenersCleanup`
// must persist across calls so `setupAttributionListeners` stays idempotent
// through a boot retry or `reinitialize()`.
private static attributionCtx: AttributionCtx = {
logger: this.logger,
attributionListenersCleanup: null,
};
public static async initialize(): Promise<void> {
if (this.initialized) {
this.logger.warn('SDK already initialized');
return;
}
try {
this.logger.info('Initializing NextCommerce Campaign Cart SDK v2...');
// Wait for DOM to be ready
await this.waitForDOM();
// Signal loading state to the page so developers can show skeleton UIs
document.body.setAttribute('data-next-sdk-loading', 'true');
// Load configuration
await this.loadConfiguration();
// NEW: Initialize location and currency detection EARLY (before campaign data)
await locationCurrencyMethods.initializeLocationAndCurrency({
logger: this.logger,
});
// Initialize attribution store
await attributionMethods.initializeAttribution(this.attributionCtx);
// Load campaign data (will now use the detected/selected currency)
await this.loadCampaignData();
// Initialize analytics after campaign data is loaded
await this.initializeAnalytics();
// IMPORTANT: Wait for cart store to fully rehydrate from storage
// This prevents race conditions where display enhancers initialize with empty cart state
await this.waitForStoreRehydration();
// Clear cart if meta[name="next-clear-cart"] content="true"
if (useConfigStore.getState().clearCartOnInit) {
cartOperations.clear();
this.logger.debug('Cart cleared on init (next-clear-cart)');
}
// Initialize global error handler
this.initializeErrorHandler();
// Check if there's a ref_id parameter and load order if found
await this.checkAndLoadOrder();
// Scan DOM for data attributes
await this.scanAndEnhanceDOM();
// Set up ready callback system
this.setupReadyCallbacks();
// Initialize debug utilities if debug mode is enabled
await this.initializeDebugMode();
this.initialized = true;
this.logger.info('SDK initialization complete ✅');
this.retryAttempts = 0;
// Clear global loading state — page skeletons can now hide
document.body.setAttribute('data-next-sdk-loading', 'false');
// Emit initialization event
this.emitInitializedEvent();
} catch (error) {
this.logger.error('SDK initialization failed:', error);
// Leave it "true": nothing after the failed step ran — no DOM scan, no
// window.next, no next:display-ready — so a page revealing itself on
// "false" would show raw un-enhanced markup. True through every retry
// and after the final failure alike.
// Retry logic
if (this.retryAttempts < this.maxRetries) {
this.retryAttempts++;
this.logger.warn(
`Retrying initialization (attempt ${this.retryAttempts}/${this.maxRetries})...`
);
// Wait before retry
await new Promise(resolve =>
setTimeout(resolve, 1000 * this.retryAttempts)
);
return this.initialize();
}
// Step 9's error handler never installs this early in boot, and
// window.next/nextDebug never get created either — this emit is the
// only failure signal a page can subscribe to.
const message = error instanceof Error ? error.message : String(error);
EventBus.getInstance().emit('error:occurred', {
message,
code: 'SDK_INIT_FAILED',
details: { retryAttempts: this.retryAttempts },
});
throw error;
}
}
private static async loadConfiguration(): Promise<void> {
const configStore = useConfigStore.getState();
// Check for reset parameter first
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('reset') === 'true') {
await storageResetMethods.clearAllStorage({ logger: this.logger });
// Remove the reset parameter from URL to avoid infinite loop
urlParams.delete('reset');
const newUrl =
window.location.pathname +
(urlParams.toString() ? '?' + urlParams.toString() : '');
window.history.replaceState({}, '', newUrl);
}
// NEW: Capture ALL URL parameters for session use
await urlParamMethods.captureUrlParameters(
{ logger: this.logger },
urlParams
);
// Check URL parameters for debug mode, forcePackageId, forceShippingId, and forceBundleId
const windowConfig = (window as any).nextConfig;
const debugMode =
urlParams.get('debugger') === 'true' || windowConfig?.debugger === true;
const forcePackageId = urlParams.get('forcePackageId');
const forceShippingId = urlParams.get('forceShippingId');
const forceBundleId = urlParams.get('forceBundleId');
// Load from window.nextConfig first (as defaults)
configStore.loadFromWindow();
// Load from meta tags second (will override window.nextConfig if metatags exist)
configStore.loadFromMeta();
// Override debug mode from URL if present
if (debugMode) {
configStore.updateConfig({ debug: true });
}
// Handle forcePackageId parameter
if (forcePackageId) {
this.logger.info('forcePackageId parameter detected:', forcePackageId);
// Store for later processing after campaign data is loaded
(window as any)._nextForcePackageId = forcePackageId;
}
// Handle forceShippingId parameter
if (forceShippingId) {
this.logger.info('forceShippingId parameter detected:', forceShippingId);
// Store for later processing after campaign data is loaded
(window as any)._nextForceShippingId = forceShippingId;
}
// Handle forceBundleId parameter
// Format: "bundleId" or "selectorId:bundleId" or comma-separated for multi-selector pages
// Consumed by BundleSelectorEnhancer when picking its default card (overrides data-next-selected).
if (forceBundleId) {
this.logger.info('forceBundleId parameter detected:', forceBundleId);
(window as any)._nextForceBundleId = forceBundleId;
}
this.logger.debug(
'Configuration loaded (metatags have priority):',
configStore
);
}
private static async loadCampaignData(): Promise<void> {
const configStore = useConfigStore.getState();
const campaignStore = useCampaignStore.getState();
if (!configStore.apiKey) {
throw new Error(
'API key not found. Please set next-api-key meta tag or window.nextConfig.apiKey'
);
}
// The key was configured, so if the scope could not see it the stores were
// created before the tag was parsed — the one way scoping goes wrong silently.
if (storageScopeFellBack(configStore.apiKey)) {
// One literal, not a concatenation: the generated log reference is built by
// reading these strings out of the source, and it cannot follow a `+`.
this.logger.warn(
'Storage scope fell back to a shared one: next-api-key was not readable when the stores were created, so every campaign on this origin shares a cart. Move the SDK script below the meta tag, or load the module build.'
);
}
// Campaign ID is deprecated and not used by the API - only the API key is needed
// No need to check or warn about it anymore
await campaignStore.loadCampaign(configStore.apiKey);
this.logger.debug('Campaign data loaded');
// Set campaign shipping countries in CountryService for global use
// This ensures country dropdowns only show countries the campaign ships to
if (campaignStore.data?.available_shipping_countries) {
const countryService = CountryService.getInstance();
countryService.setCampaignShippingCountries(
campaignStore.data.available_shipping_countries
);
this.logger.info(
'Campaign shipping countries set globally:',
campaignStore.data.available_shipping_countries.map((c: any) => c.code)
);
}
// Process forcePackageId parameter after campaign data is available
await urlParamMethods.processForcePackageId({ logger: this.logger });
// Process forceShippingId parameter after campaign data is available
await urlParamMethods.processForceShippingId({ logger: this.logger });
// Emit event to notify enhancers that URL parameters have been processed
// This allows enhancers to re-evaluate their conditions after profiles are applied
const eventBus = EventBus.getInstance();
eventBus.emit('sdk:url-parameters-processed', {});
this.logger.debug('Emitted sdk:url-parameters-processed event');
}
private static async initializeAnalytics(): Promise<void> {
// Initialize analytics synchronously to ensure attribution data is ready
// before dl_user_data event is fired
try {
this.logger.info('Initializing analytics v2...');
// Dynamically import new analytics v2 to avoid loading it during initial bundle
const { nextAnalytics } = await import('@/core/analytics/index');
await nextAnalytics.initialize();
this.logger.debug('Analytics v2 initialized successfully');
} catch (error) {
this.logger.warn(
'Analytics v2 initialization failed (non-critical):',
error
);
// Don't throw - analytics failure shouldn't break SDK initialization
}
}
private static initializeErrorHandler(): void {
try {
// Import and initialize error handler
import('@/core/monitoring/error-handler').then(({ errorHandler }) => {
errorHandler.initialize();
this.logger.debug('Error handler initialized');
});
} catch (error) {
this.logger.warn('Error handler initialization failed:', error);
}
}
private static async checkAndLoadOrder(): Promise<void> {
// Check if there's a ref_id or order_ref_id parameter in the URL
const urlParams = new URLSearchParams(window.location.search);
const refId = urlParams.get('ref_id') || urlParams.get('order_ref_id');
if (refId) {
const paramName = urlParams.get('ref_id') ? 'ref_id' : 'order_ref_id';
this.logger.info(
`Page loaded with ${paramName} parameter, auto-loading order:`,
refId
);
try {
const configStore = useConfigStore.getState();
const orderStore = useOrderStore.getState();
const apiClient = getApiClient(configStore.apiKey);
await orderStore.loadOrder(refId, apiClient);
// Read the store again: `orderStore` is the pre-load snapshot, and
// Zustand's `set` builds a new state object rather than mutating it, so
// this used to log `null` on every successful load.
const order = useOrderStore.getState().order;
this.logger.info('Order loaded successfully:', order);
// Log whether the order supports upsells
if (order) {
this.logger.info(
'Order supports upsells:',
order.supports_post_purchase_upsells
);
}
} catch (error) {
this.logger.error('Failed to auto-load order:', error);
// Don't throw - this shouldn't break SDK initialization
}
}
}
private static async scanAndEnhanceDOM(): Promise<void> {
if (this.attributeScanner) {
this.attributeScanner.destroy();
}
this.attributeScanner = new AttributeScanner();
await this.attributeScanner.scanAndEnhance(document.body);
const stats = this.attributeScanner.getStats();
this.logger.info('DOM scanning and enhancement complete', stats);
}
private static setupReadyCallbacks(): void {
const sdk = NextCommerce.getInstance();
if (typeof window !== 'undefined') {
// Execute any queued ready callbacks if they exist
if (Array.isArray((window as any).nextReady)) {
const readyQueue = (window as any).nextReady;
readyQueue.forEach((callback: (sdk: NextCommerce) => void) => {
try {
callback(sdk);
} catch (error) {
this.logger.error('Ready callback error:', error);
}
});
}
// Set up public API as window.next
(window as any).next = sdk;
// Always set up nextReady for future callbacks (whether it existed before or not)
(window as any).nextReady = {
push: (callback: (sdk: NextCommerce) => void) => {
try {
callback(sdk);
} catch (error) {
this.logger.error('Ready callback error:', error);
}
},
};
this.logger.debug(
'nextReady callback system and window.next API initialized'
);
}
}
private static async initializeDebugMode(): Promise<void> {
const configStore = useConfigStore.getState();
if (configStore.debug) {
this.logger.info('Debug mode enabled - initializing debug utilities');
// Set logger to DEBUG level when debug mode is enabled
Logger.setLogLevel(LogLevel.DEBUG);
this.logger.info('Logger level set to DEBUG');
// Initialize debug overlay only in debug mode
const { DebugOverlay } = await import('@/core/debug/debug-overlay');
DebugOverlay.getInstance().initialize();
// Initialize test mode manager
// Removed test mode indicator - using debug overlay instead
// testModeManager.addTestModeIndicator();
// Set up global debug utilities
debugUtilsMethods.setupGlobalDebugUtils({
logger: this.logger,
reinitialize: () => this.reinitialize(),
getInitializationStats: () => this.getInitializationStats(),
});
// Log debug info
this.logger.info('Debug utilities initialized ✅');
}
}
public static isInitialized(): boolean {
return this.initialized;
}
public static async reinitialize(): Promise<void> {
this.logger.info('Reinitializing SDK...');
// Cleanup existing resources
if (this.attributeScanner) {
this.attributeScanner.destroy();
this.attributeScanner = null;
}
this.initialized = false;
this.retryAttempts = 0;
await this.initialize();
}
private static async waitForDOM(): Promise<void> {
if (document.readyState === 'loading') {
return new Promise(resolve => {
const onReady = () => {
document.removeEventListener('DOMContentLoaded', onReady);
document.removeEventListener('readystatechange', onReady);
resolve();
};
document.addEventListener('DOMContentLoaded', onReady);
document.addEventListener('readystatechange', onReady);
});
}
}
private static async waitForStoreRehydration(): Promise<void> {
// Wait for cart store to rehydrate from session storage
// This is crucial to prevent display enhancers from initializing with empty state
const cartStore = useCartStore.getState();
// Check if there's data in sessionStorage that needs to be rehydrated
// Using the shared constant from storage.ts ensures consistency
const storedData = sessionStorage.getItem(CART_STORAGE_KEY);
if (storedData) {
this.logger.debug('Waiting for cart store rehydration...');
// Give the store time to rehydrate and recalculate totals
// The store's onRehydrateStorage callback calls calculateTotals()
// We need to wait for that to complete
await new Promise(resolve => {
// Use a small timeout to ensure the rehydration process completes
// This includes the async calculateTotals() call in the store
setTimeout(resolve, 50);
});
// Force a recalculation to ensure everything is up to date
cartOperations.calculateTotals();
this.logger.debug('Cart store rehydration complete', {
itemCount: cartStore.items.length,
total: cartStore.total,
isEmpty: cartStore.isEmpty,
});
} else {
this.logger.debug('No cart data to rehydrate');
}
}
private static emitInitializedEvent(): void {
if (typeof window !== 'undefined') {
const event = new CustomEvent('next:initialized', {
detail: {
version: '0.2.0',
timestamp: Date.now(),
stats: this.attributeScanner?.getStats(),
},
});
window.dispatchEvent(event);
}
}
public static getAttributeScanner(): AttributeScanner | null {
return this.attributeScanner;
}
public static getInitializationStats(): {
initialized: boolean;
retryAttempts: number;
scannerStats?: ReturnType<AttributeScanner['getStats']>;
} {
return {
initialized: this.initialized,
retryAttempts: this.retryAttempts,
...(this.attributeScanner && {
scannerStats: this.attributeScanner.getStats(),
}),
};
}
}