core/next-commerce/next-commerce.tstypescript
/**
 * Main SDK class providing programmatic API access
 * This is the public interface for advanced users who need direct access to SDK functionality
 */

import type {
  Campaign,
  CallbackType,
  CallbackData,
  EventMap,
} from '@/types/global';
import { cartOperations } from '@/state/cart';
import { EventBus } from '@/core/events';
import { Logger } from '@/core/logger';
import * as cartMethods from '@/core/next-commerce/next-commerce.cart';
import * as campaignMethods from '@/core/next-commerce/next-commerce.campaign';
import * as eventMethods from '@/core/next-commerce/next-commerce.events';
import * as analyticsMethods from '@/core/next-commerce/next-commerce.analytics';
import * as attributionMethods from '@/core/next-commerce/next-commerce.attribution';
import * as shippingMethods from '@/core/next-commerce/next-commerce.shipping';
import type {
  ShippingMethodInfo,
  SelectedShippingMethod,
} from '@/core/next-commerce/next-commerce.shipping';
import * as utilityMethods from '@/core/next-commerce/next-commerce.utility';
import * as couponMethods from '@/core/next-commerce/next-commerce.coupons';
import * as popupMethods from '@/core/next-commerce/next-commerce.popups';
import type {
  PopupsState,
  ExitIntentOptions,
} from '@/core/next-commerce/next-commerce.popups';
import * as upsellMethods from '@/core/next-commerce/next-commerce.upsells';
import type { AddUpsellOptions } from '@/core/next-commerce/next-commerce.upsells';
import * as urlParamMethods from '@/core/next-commerce/next-commerce.url-params';

/**
 * The programmatic SDK facade — the scriptable counterpart to the `data-next-*`
 * attributes. A single instance is created during initialization and exposed as
 * `window.next`, so most code obtains it directly rather than constructing one.
 *
 * Use it to read cart/campaign state, drive the cart ({@link NextCommerce.cart}),
 * subscribe to events, and fire analytics — all without touching the DOM layer.
 *
 * This class is a thin orchestrator: the constructor and singleton accessor live
 * here, and every other method delegates to a same-named function extracted
 * verbatim into a sibling module grouped by `@category`
 * (`next-commerce.cart.ts`, `next-commerce.analytics.ts`, …). Splitting this way
 * keeps the class — and the published `window.next` member list — exactly where
 * it was; only the implementation moved.
 *
 * @example
 * ```ts
 * const sdk = window.next; // created by the SDK on load
 *
 * // React to cart changes
 * sdk.on('cart:updated', cart => render(cart.total));
 *
 * // Drive the cart from code
 * await sdk.cart.addItem({ packageId: 2, quantity: 1, isUpsell: false });
 *
 * // Read current state
 * const { total } = sdk.getCartTotals();
 * ```
 *
 * @category Core
 */
export class NextCommerce {
  private static instance: NextCommerce;
  private logger: Logger;
  private eventBus: EventBus;
  private callbacks = new Map<CallbackType, Set<Function>>();
  /** Owned lazily by {@link NextCommerce.exitIntent}. */
  private popupsState: PopupsState = {
    exitIntentEnhancer: null,
  };

  private constructor() {
    this.logger = new Logger('NextCommerce');
    this.eventBus = EventBus.getInstance();
  }

  /**
   * Returns the shared SDK instance, creating it on first call.
   *
   * @returns The singleton `NextCommerce` (the same object exposed as `window.next`).
   * @category Core
   */
  public static getInstance(): NextCommerce {
    if (!NextCommerce.instance) {
      NextCommerce.instance = new NextCommerce();
    }
    return NextCommerce.instance;
  }

  /**
   * The programmatic cart API — the blessed way to drive the cart in code.
   * Backed by the cart operations layer (`@/state/cart/operations`).
   *
   * @example
   * ```ts
   * await sdk.cart.addItem({ packageId: 2, quantity: 1, isUpsell: false });
   * await sdk.cart.updateQuantity(2, 3);
   * await sdk.cart.applyCoupon('SAVE10');
   * ```
   *
   * @category Cart
   */
  public get cart() {
    return cartOperations;
  }

  /**
   * Whether a package is currently in the cart.
   * @category Cart
   */
  public hasItemInCart(options: { packageId?: number }): boolean {
    return cartMethods.hasItemInCart(options);
  }

  /**
   * Adds a package to the cart (quantity defaults to 1). No-op if `packageId`
   * is omitted. For upsell adds use {@link NextCommerce.cart}.
   * @category Cart
   */
  public async addItem(options: {
    packageId?: number;
    quantity?: number;
  }): Promise<void> {
    return cartMethods.addItem(options);
  }

  /**
   * Removes a package from the cart entirely. No-op if `packageId` is
   * omitted.
   * @category Cart
   */
  public async removeItem(options: { packageId?: number }): Promise<void> {
    return cartMethods.removeItem(options);
  }

  /**
   * Sets the exact quantity for a package (a quantity of 0 removes it).
   * @category Cart
   */
  public async updateQuantity(options: {
    packageId?: number;
    quantity: number;
  }): Promise<void> {
    return cartMethods.updateQuantity(options);
  }

  /**
   * Empties the cart.
   * @category Cart
   */
  public async clearCart(): Promise<void> {
    return cartMethods.clearCart();
  }

  /**
   * Replaces the entire cart contents with the given items in one atomic swap
   * (used by bundle/package selectors). Existing items not listed are
   * removed.
   * @category Cart
   */
  public async swapCart(
    items: Array<{ packageId: number; quantity: number }>
  ): Promise<void> {
    return cartMethods.swapCart({ logger: this.logger }, items);
  }

  /**
   * A snapshot of the full cart for callbacks — enriched line items, totals,
   * campaign data, and applied vouchers.
   * @category Cart
   */
  public getCartData(): CallbackData {
    return cartMethods.getCartData();
  }

  /**
   * The current cart totals (subtotal, total, discounts, shipping) as
   * `Decimal`s.
   * @category Cart
   */
  public getCartTotals() {
    return cartMethods.getCartTotals();
  }

  /**
   * Total number of units in the cart (sum of item quantities).
   * @category Cart
   */
  public getCartCount(): number {
    return cartMethods.getCartCount();
  }

  /**
   * The loaded campaign (packages, currency, shipping methods), or `null` if
   * it hasn't loaded yet.
   * @category Campaign
   */
  public getCampaignData(): Campaign | null {
    return campaignMethods.getCampaignData();
  }

  /**
   * Looks up a package by its `ref_id` in the loaded campaign.
   * @category Campaign
   */
  public getPackage(id: number): any | null {
    return campaignMethods.getPackage(id);
  }

  /**
   * All variant packages for a product id (variant selection support).
   * @category Campaign
   */
  public getVariantsByProductId(productId: number): any | null {
    return campaignMethods.getVariantsByProductId(productId);
  }

  /**
   * The distinct values available for one variant attribute (e.g. all sizes)
   * of a product — used to build variant pickers.
   * @category Campaign
   */
  public getAvailableVariantAttributes(
    productId: number,
    attributeCode: string
  ): string[] {
    return campaignMethods.getAvailableVariantAttributes(
      productId,
      attributeCode
    );
  }

  /**
   * Resolves the concrete package for a product given a full set of selected
   * variant attributes (e.g. `{ color: 'red', size: 'L' }`).
   * @category Campaign
   */
  public getPackageByVariantSelection(
    productId: number,
    selectedAttributes: Record<string, string>
  ): any | null {
    return campaignMethods.getPackageByVariantSelection(
      productId,
      selectedAttributes
    );
  }

  /**
   * Builds a stable, order-independent key from a set of variant attributes
   * (e.g. `color:red|size:L`) for use as a lookup/map key.
   * @category Campaign
   */
  public createVariantKey(attributes: Record<string, string>): string {
    return campaignMethods.createVariantKey(attributes);
  }

  /**
   * Subscribes to an SDK event. Names and payloads are typed via `EventMap`.
   * @category Events
   */
  public on<K extends keyof EventMap>(
    event: K,
    handler: (data: EventMap[K]) => void
  ): void {
    eventMethods.on(this.eventsContext, event, handler);
  }

  /**
   * Unsubscribes a handler previously registered with {@link NextCommerce.on}.
   * @category Events
   */
  public off<K extends keyof EventMap>(event: K, handler: Function): void {
    eventMethods.off(this.eventsContext, event, handler);
  }

  /**
   * Registers a callback for a lifecycle callback type (e.g. cart/order
   * hooks). Prefer {@link NextCommerce.on} for event-style subscriptions.
   * @category Events
   */
  public registerCallback(
    type: CallbackType,
    callback: (data: CallbackData) => void
  ): void {
    eventMethods.registerCallback(this.eventsContext, type, callback);
  }

  /**
   * Removes a callback registered with {@link NextCommerce.registerCallback}.
   * @category Events
   */
  public unregisterCallback(type: CallbackType, callback: Function): void {
    eventMethods.unregisterCallback(this.eventsContext, type, callback);
  }

  /**
   * Invokes all callbacks registered for a type (errors are caught and
   * logged).
   * @category Events
   */
  public triggerCallback(type: CallbackType, data: CallbackData): void {
    eventMethods.triggerCallback(this.eventsContext, type, data);
  }

  private get eventsContext(): eventMethods.NextCommerceEventsContext {
    return {
      eventBus: this.eventBus,
      callbacks: this.callbacks,
      logger: this.logger,
    };
  }

  // Analytics methods (v2 system)
  /**
   * Reports a list of packages as viewed — a product grid or recommendation
   * rail. `_listId` is accepted and ignored; the list name is the third
   * argument.
   * @category Analytics
   */
  public async trackViewItemList(
    packageIds: (string | number)[],
    _listId?: string,
    listName?: string
  ): Promise<void> {
    return analyticsMethods.trackViewItemList(
      this.logger,
      packageIds,
      _listId,
      listName
    );
  }

  /**
   * Reports one package as viewed. Warns and sends nothing when the package
   * is not in the loaded campaign, so an early call is silently dropped.
   * @category Analytics
   */
  public async trackViewItem(packageId: string | number): Promise<void> {
    return analyticsMethods.trackViewItem(this.logger, packageId);
  }

  /**
   * Reports an add-to-cart that happened outside the SDK's own cart calls.
   * Pairing it with {@link NextCommerce.addItem} reports the add twice.
   * @category Analytics
   */
  public async trackAddToCart(
    packageId: string | number,
    quantity?: number
  ): Promise<void> {
    return analyticsMethods.trackAddToCart(this.logger, packageId, quantity);
  }

  /**
   * Reports a removal that happened outside the SDK's own cart calls. Pairing
   * it with {@link NextCommerce.removeItem} reports the removal twice.
   * @category Analytics
   */
  public async trackRemoveFromCart(
    packageId: string | number,
    quantity?: number
  ): Promise<void> {
    return analyticsMethods.trackRemoveFromCart(
      this.logger,
      packageId,
      quantity
    );
  }

  /**
   * Reports checkout starting, from the current cart. The built-in checkout
   * form already fires this — call it only for a hand-built flow.
   * @category Analytics
   */
  public async trackBeginCheckout(): Promise<void> {
    return analyticsMethods.trackBeginCheckout(this.logger);
  }

  /**
   * Reports a completed order from an order payload. The receipt page already
   * fires this; a second call doubles reported revenue.
   * @category Analytics
   */
  public async trackPurchase(orderData: any): Promise<void> {
    return analyticsMethods.trackPurchase(this.logger, orderData);
  }

  /**
   * Sends an event of the caller's own naming. Nothing validates the name or
   * the payload, so a typo becomes a new event name.
   * @category Analytics
   */
  public async trackCustomEvent(
    eventName: string,
    data?: Record<string, any>
  ): Promise<void> {
    return analyticsMethods.trackCustomEvent(this.logger, eventName, data);
  }

  // User tracking methods
  /**
   * Reports a newsletter or account sign-up. The address goes into the event
   * payload as `customer_email` in the clear — nothing hashes it — so it
   * reaches every configured provider and the browser data layer as plain
   * text.
   * @category Analytics
   */
  public async trackSignUp(email: string): Promise<void> {
    return analyticsMethods.trackSignUp(this.logger, email);
  }

  /**
   * Reports a returning visitor signing in. Carries the address in the clear,
   * exactly as {@link NextCommerce.trackSignUp} does.
   * @category Analytics
   */
  public async trackLogin(email: string): Promise<void> {
    return analyticsMethods.trackLogin(this.logger, email);
  }

  // Advanced analytics methods
  /**
   * Turns verbose analytics logging on or off at runtime. Unrelated to the
   * debug overlay, which is `?debugger=true` or `window.nextConfig.debugger`.
   * @category Analytics
   */
  public async setDebugMode(enabled: boolean): Promise<void> {
    return analyticsMethods.setDebugMode(this.logger, enabled);
  }

  /**
   * Discards the cached page context so the next event is built from the
   * current route. Needed in a single-page app, where no page load resets it.
   * @category Analytics
   */
  public async invalidateAnalyticsContext(): Promise<void> {
    return analyticsMethods.invalidateAnalyticsContext(this.logger);
  }

  // Attribution metadata methods
  /**
   * Adds one key to the attribution metadata sent with the order, merging so
   * the automatically collected fields survive.
   * @category Metadata
   */
  public addMetadata(key: string, value: any): void {
    attributionMethods.addMetadata(this.logger, key, value);
  }

  /**
   * Adds several keys to the attribution metadata. Merges rather than
   * replaces, despite the name — a true replace would wipe the automatic
   * fields.
   * @category Metadata
   */
  public setMetadata(metadata: Record<string, any>): void {
    attributionMethods.setMetadata(this.logger, metadata);
  }

  /**
   * Drops caller-supplied metadata while preserving the automatic fields
   * (`landing_page`, `referrer`, `device`, `device_type`, `domain`,
   * `timestamp`).
   * @category Metadata
   */
  public clearMetadata(): void {
    attributionMethods.clearMetadata(this.logger);
  }

  /**
   * The attribution metadata as stored. `undefined` means the read failed; an
   * empty bag is `{}`.
   * @category Metadata
   */
  public getMetadata(): Record<string, any> | undefined {
    return attributionMethods.getMetadata(this.logger);
  }

  /**
   * Overwrites the collected attribution — funnel, affiliate, `utm_*`. This
   * decides who is credited for the sale, so it is a reporting change.
   * @category Attribution
   */
  public setAttribution(attribution: Record<string, any>): void {
    attributionMethods.setAttribution(this.logger, attribution);
  }

  /**
   * Attribution in the shape sent to the order API, not the raw store — the
   * right thing to log when an order is attributed wrongly.
   * @category Attribution
   */
  public getAttribution(): Record<string, any> | undefined {
    return attributionMethods.getAttribution(this.logger);
  }

  /**
   * Prints the whole attribution state to the console. Returns nothing; use
   * {@link NextCommerce.getAttribution} when you need a value.
   * @category Attribution
   */
  public debugAttribution(): void {
    attributionMethods.debugAttribution(this.logger);
  }

  /**
   * All shipping methods available in the loaded campaign.
   * @category Shipping
   */
  public getShippingMethods(): ShippingMethodInfo[] {
    return shippingMethods.getShippingMethods();
  }

  /**
   * The currently selected shipping method, or `null` if none chosen yet.
   * @category Shipping
   */
  public getSelectedShippingMethod(): SelectedShippingMethod | null {
    return shippingMethods.getSelectedShippingMethod();
  }

  /**
   * Selects a shipping method by id and recalculates cart totals. Throws if
   * the id isn't in the campaign's shipping methods.
   * @category Shipping
   */
  public async setShippingMethod(methodId: number): Promise<void> {
    return shippingMethods.setShippingMethod(methodId);
  }

  /**
   * The resolved SDK version (runtime loader value if present, else build-
   * time).
   * @category Utility
   */
  public getVersion(): string {
    return utilityMethods.getVersion();
  }

  /**
   * Formats an amount using the campaign currency (or an override), e.g.
   * `$19.99`.
   * @category Utility
   */
  public formatPrice(amount: number, currency?: string): string {
    return utilityMethods.formatPrice(amount, currency);
  }

  /**
   * Lightweight pre-checkout validation (currently: cart must not be empty).
   * @category Utility
   */
  public validateCheckout(): { valid: boolean; errors: string[] } {
    return utilityMethods.validateCheckout();
  }

  /**
   * Applies a coupon code and recalculates totals. Returns `{ success,
   * message }` — `success: false` when the code is already applied or
   * invalid.
   * @category Coupons
   */
  public async applyCoupon(
    code: string
  ): Promise<{ success: boolean; message: string }> {
    return couponMethods.applyCoupon(code);
  }

  /**
   * Removes a previously applied coupon and recalculates totals.
   * @category Coupons
   */
  public removeCoupon(code: string): void {
    couponMethods.removeCoupon(code);
  }

  /**
   * The coupon codes currently applied to the cart.
   * @category Coupons
   */
  public getCoupons(): string[] {
    return couponMethods.getCoupons();
  }

  // Exit Intent - Simple approach
  /**
   * Arms the exit-intent popup, lazy-loading its enhancer on the first call.
   * Rethrows when that import fails.
   * @category Popups
   */
  public async exitIntent(options: ExitIntentOptions): Promise<void> {
    return popupMethods.exitIntent(
      { state: this.popupsState, logger: this.logger },
      options
    );
  }

  /**
   * Stops the exit-intent popup from appearing again. No-op when {@link NextCommerce.exitIntent} was never called.
   * @category Popups
   */
  public disableExitIntent(): void {
    popupMethods.disableExitIntent({ state: this.popupsState });
  }

  // Upsell methods
  /**
   * Adds packages to the already-paid order, charging the saved payment
   * method. Throws when there is no order in session, when the order cannot
   * take upsells or is mid-processing, and when neither `packageId` nor
   * `items` is given.
   * @category Upsells
   */
  public async addUpsell(options: AddUpsellOptions): Promise<any> {
    return upsellMethods.addUpsell(
      { logger: this.logger, eventBus: this.eventBus },
      options
    );
  }

  /**
   * Whether the order in session can take a post-purchase upsell right now.
   * Also `false` while one is processing, so it guards a double submit.
   * @category Upsells
   */
  public canAddUpsells(): boolean {
    return upsellMethods.canAddUpsells();
  }

  /**
   * Package ids already accepted on this order, as strings rather than
   * numbers.
   * @category Upsells
   */
  public getCompletedUpsells(): string[] {
    return upsellMethods.getCompletedUpsells();
  }

  /**
   * Whether a package was already accepted on this order — checks the
   * completed list and the accepted entries of the upsell journey, so it
   * survives a reload.
   * @category Upsells
   */
  public isUpsellAlreadyAdded(packageId: number): boolean {
    return upsellMethods.isUpsellAlreadyAdded(packageId);
  }

  // URL Parameter Methods
  /**
   * Sets one captured URL parameter for the rest of the session. Does not
   * touch the address bar.
   * @category URL Parameters
   */
  public setParam(key: string, value: string): void {
    urlParamMethods.setParam(this.logger, key, value);
  }

  /**
   * Sets several captured URL parameters, replacing the keys named and
   * leaving the rest alone.
   * @category URL Parameters
   */
  public setParams(params: Record<string, string>): void {
    urlParamMethods.setParams(this.logger, params);
  }

  /**
   * Reads one captured URL parameter. `null` when it was never captured.
   * @category URL Parameters
   */
  public getParam(key: string): string | null {
    return urlParamMethods.getParam(key);
  }

  /**
   * Every URL parameter captured for this session.
   * @category URL Parameters
   */
  public getAllParams(): Record<string, string> {
    return urlParamMethods.getAllParams();
  }

  /**
   * Whether a parameter was captured, including one present with an empty
   * value.
   * @category URL Parameters
   */
  public hasParam(key: string): boolean {
    return urlParamMethods.hasParam(key);
  }

  /**
   * Forgets one captured URL parameter.
   * @category URL Parameters
   */
  public clearParam(key: string): void {
    urlParamMethods.clearParam(this.logger, key);
  }

  /**
   * Forgets every captured URL parameter — `utm_*` values included, which
   * attribution reads.
   * @category URL Parameters
   */
  public clearAllParams(): void {
    urlParamMethods.clearAllParams(this.logger);
  }

  /**
   * Adds parameters to the captured set without disturbing keys it does not
   * name.
   * @category URL Parameters
   */
  public mergeParams(params: Record<string, string>): void {
    urlParamMethods.mergeParams(this.logger, params);
  }
}