state/order/order.state.tstypescript
/**
* Order Store
* Manages order data and upsell functionality
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Order, AddUpsellLine } from '@/types/api';
import type { IApiClient } from '@/api/client.types';
import { createLogger } from '@/core/logger';
import { EventBus } from '@/core/events';
import { ORDER_STORAGE_KEY } from '@/core/storage';
export interface OrderState {
/**
* The completed order as the API returned it — lines, totals, addresses, and
* the status URL. See {@link Order} for every field. `null` before an
* order has been loaded, which is the normal state of a page opened without a
* `?ref_id`.
*/
order: Order | null;
refId: string | null;
orderLoadedAt: number | null; // Timestamp when order was loaded
// Loading states
isLoading: boolean;
isProcessingUpsell: boolean;
// Error handling
error: string | null;
upsellError: string | null;
// Upsell tracking
pendingUpsells: AddUpsellLine[];
completedUpsells: string[]; // Array of package IDs that were successfully added (deprecated)
completedUpsellPages: string[]; // Array of page paths where upsells were accepted
viewedUpsells: string[]; // Array of package IDs that were viewed (deprecated)
viewedUpsellPages: string[]; // Array of page paths that were viewed
upsellJourney: Array<{
// Track the upsell journey
packageId?: string;
pagePath?: string;
action: 'viewed' | 'accepted' | 'skipped';
timestamp: number;
}>;
}
export interface OrderActions {
// Order management
setOrder: (order: Order) => void;
setRefId: (refId: string) => void;
/**
* Fetches the order behind `refId` and stores it.
*
* @param apiClient The shared client — `getApiClient()` from
* [`@/client`](../../client.ts). Typed at the interface so a test can pass a
* `Pick<IApiClient, 'getOrder'>` fake the compiler checks.
*/
loadOrder: (refId: string, apiClient: IApiClient) => Promise<void>;
clearOrder: () => void;
isOrderExpired: () => boolean;
// Upsell management
/**
* Adds an accepted post-purchase offer to the stored order.
*
* @param apiClient The shared client — see {@link OrderActions.loadOrder}.
*/
addUpsell: (
upsellData: AddUpsellLine,
apiClient: IApiClient
) => Promise<Order | null>;
addPendingUpsell: (upsellData: AddUpsellLine) => void;
removePendingUpsell: (index: number) => void;
clearPendingUpsells: () => void;
markUpsellCompleted: (packageId: string) => void;
markUpsellViewed: (packageId: string) => void;
markUpsellPageViewed: (pagePath: string) => void;
markUpsellSkipped: (packageId: string, pagePath?: string) => void;
// Error handling
setError: (error: string | null) => void;
setUpsellError: (error: string | null) => void;
clearErrors: () => void;
// Loading states
setLoading: (loading: boolean) => void;
setProcessingUpsell: (processing: boolean) => void;
// Utility methods
hasUpsellPageBeenCompleted: (pagePath: string) => boolean;
hasUpsellBeenViewed: (packageId: string) => boolean;
hasUpsellPageBeenViewed: (pagePath: string) => boolean;
getOrderTotal: () => number;
canAddUpsells: () => boolean;
getUpsellJourney: () => any[];
reset: () => void;
}
const logger = createLogger('OrderStore');
const initialState: OrderState = {
order: null,
refId: null,
orderLoadedAt: null,
isLoading: false,
isProcessingUpsell: false,
error: null,
upsellError: null,
pendingUpsells: [],
completedUpsells: [],
completedUpsellPages: [],
viewedUpsells: [],
viewedUpsellPages: [],
upsellJourney: [],
};
/**
* The order store — the completed {@link Order | order} plus post-purchase upsell
* tracking (which upsells were viewed, accepted, or skipped). Populated after
* checkout and used to drive the receipt and upsell pages.
*
* Note: this store expires 15 minutes after the order completes, so upsell pages
* can't be replayed indefinitely.
*
* @example
* ```ts
* const order = useOrderStore.getState().order;
* console.log(order?.number);
* ```
* @category Orders
*/
export const useOrderStore = create<OrderState & OrderActions>()(
persist(
(set, get) => ({
...initialState,
// Order management
setOrder: order => {
logger.debug('Setting order data:', order);
set({
order,
error: null,
orderLoadedAt: Date.now(),
});
},
setRefId: refId => {
logger.debug('Setting ref ID:', refId);
set({ refId });
},
loadOrder: async (refId, apiClient) => {
const state = get();
// Check if we already have this order and it's not expired
if (state.order && state.refId === refId && !get().isOrderExpired()) {
logger.info('Using cached order data:', refId);
return;
}
if (state.isLoading) {
logger.warn('Order loading already in progress');
return;
}
logger.info('Loading order:', refId);
set({ isLoading: true, error: null, refId });
try {
const order = await apiClient.getOrder(refId);
logger.info('Order loaded successfully:', order);
// Extract upsell package IDs from order lines
const upsellPackageIds: string[] = [];
if (order.lines && Array.isArray(order.lines)) {
order.lines.forEach((line: any) => {
if (line.is_upsell && line.product_sku) {
// Try to extract package ID from product_sku
// Common patterns: "package_123", "pkg_123", "123", etc.
const skuMatch = line.product_sku.match(/(\d+)/);
if (skuMatch) {
upsellPackageIds.push(skuMatch[1]);
} else {
// Fallback: use the full SKU as identifier
upsellPackageIds.push(line.product_sku);
}
logger.debug('Detected upsell line:', {
sku: line.product_sku,
title: line.product_title,
extractedId: skuMatch ? skuMatch[1] : line.product_sku,
});
}
});
}
set({
order,
isLoading: false,
isProcessingUpsell: false, // Reset processing state when loading order
error: null,
orderLoadedAt: Date.now(),
completedUpsells: upsellPackageIds,
// Reset journey when loading a new order
upsellJourney: [],
viewedUpsells: [],
viewedUpsellPages: [],
});
logger.debug(
'Populated completed upsells from order:',
upsellPackageIds
);
// The page now has an order to work with, and this is the SDK's only
// producer of `order:completed` — the checkout page deliberately emits
// nothing when it *creates* an order. Emitted after `set`, so a listener
// reading the store sees the loaded order. Analytics hangs `dl_purchase`
// on it: for express checkout and 3-D Secure this is the first moment the
// SDK sees a *paid* order, since the order created on the checkout page
// was still awaiting the gateway (issue #71).
EventBus.getInstance().emit('order:completed', order);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to load order';
logger.error('Failed to load order:', error);
set({
isLoading: false,
error: errorMessage,
order: null,
});
}
},
clearOrder: () => {
logger.debug('Clearing order data');
set({
order: null,
refId: null,
error: null,
orderLoadedAt: null,
});
},
isOrderExpired: () => {
const state = get();
if (!state.orderLoadedAt) return true;
// Order expires after 15 minutes
const EXPIRY_TIME = 15 * 60 * 1000; // 15 minutes in milliseconds
const now = Date.now();
const isExpired = now - state.orderLoadedAt > EXPIRY_TIME;
if (isExpired) {
logger.info('Order data has expired (>15 minutes old)');
}
return isExpired;
},
// Upsell management
addUpsell: async (upsellData, apiClient) => {
const state = get();
if (!state.refId) {
const error = 'No order reference ID available';
logger.error(error);
set({ upsellError: error });
return null;
}
if (state.isProcessingUpsell) {
logger.warn('Upsell processing already in progress');
return null;
}
logger.info('Adding upsell to order:', state.refId, upsellData);
set({ isProcessingUpsell: true, upsellError: null });
try {
const updatedOrder = await apiClient.addUpsell(
state.refId,
upsellData
);
logger.info('Upsell added successfully:', updatedOrder);
// Track the current page as completed
const currentPagePath = window.location.pathname;
const packageIds = upsellData.lines.map(line =>
line.package_id.toString()
);
// Add to journey
const journeyEntries = packageIds.map(id => ({
packageId: id,
pagePath: currentPagePath,
action: 'accepted' as const,
timestamp: Date.now(),
}));
set({
order: updatedOrder,
isProcessingUpsell: false,
upsellError: null,
orderLoadedAt: Date.now(), // Refresh the timestamp
completedUpsells: [...state.completedUpsells, ...packageIds], // Keep for backward compatibility
completedUpsellPages: state.completedUpsellPages.includes(
currentPagePath
)
? state.completedUpsellPages
: [...state.completedUpsellPages, currentPagePath],
upsellJourney: [...state.upsellJourney, ...journeyEntries],
});
return updatedOrder;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to add upsell';
logger.error('Failed to add upsell:', error);
set({
isProcessingUpsell: false,
upsellError: errorMessage,
});
return null;
}
},
addPendingUpsell: upsellData => {
const state = get();
logger.debug('Adding pending upsell:', upsellData);
set({
pendingUpsells: [...state.pendingUpsells, upsellData],
});
},
removePendingUpsell: index => {
const state = get();
const newPendingUpsells = [...state.pendingUpsells];
newPendingUpsells.splice(index, 1);
logger.debug('Removing pending upsell at index:', index);
set({ pendingUpsells: newPendingUpsells });
},
clearPendingUpsells: () => {
logger.debug('Clearing pending upsells');
set({ pendingUpsells: [] });
},
markUpsellCompleted: packageId => {
const state = get();
if (!state.completedUpsells.includes(packageId)) {
logger.debug('Marking upsell as completed:', packageId);
set({
completedUpsells: [...state.completedUpsells, packageId],
});
}
},
markUpsellViewed: packageId => {
const state = get();
if (!state.viewedUpsells.includes(packageId)) {
logger.debug('Marking upsell as viewed:', packageId);
const journeyEntry = {
packageId,
action: 'viewed' as const,
timestamp: Date.now(),
};
set({
viewedUpsells: [...state.viewedUpsells, packageId],
upsellJourney: [...state.upsellJourney, journeyEntry],
});
}
},
markUpsellPageViewed: pagePath => {
const state = get();
if (!state.viewedUpsellPages.includes(pagePath)) {
logger.debug('Marking upsell page as viewed:', pagePath);
const journeyEntry = {
pagePath,
action: 'viewed' as const,
timestamp: Date.now(),
};
set({
viewedUpsellPages: [...state.viewedUpsellPages, pagePath],
upsellJourney: [...state.upsellJourney, journeyEntry],
isProcessingUpsell: false, // Reset processing state when viewing new page
upsellError: null, // Clear any previous errors
});
}
},
markUpsellSkipped: (packageId, pagePath) => {
const state = get();
logger.debug('Marking upsell as skipped:', { packageId, pagePath });
const journeyEntry: {
packageId?: string;
pagePath?: string;
action: 'viewed' | 'accepted' | 'skipped';
timestamp: number;
} = {
action: 'skipped' as const,
timestamp: Date.now(),
};
if (packageId !== undefined) journeyEntry.packageId = packageId;
if (pagePath !== undefined) journeyEntry.pagePath = pagePath;
set({
upsellJourney: [...state.upsellJourney, journeyEntry],
isProcessingUpsell: false, // Reset processing state when skipping
upsellError: null, // Clear any errors
});
},
// Error handling
setError: error => set({ error }),
setUpsellError: error => set({ upsellError: error }),
clearErrors: () => set({ error: null, upsellError: null }),
// Loading states
setLoading: loading => set({ isLoading: loading }),
setProcessingUpsell: processing =>
set({ isProcessingUpsell: processing }),
// Utility methods
hasUpsellPageBeenCompleted: pagePath => {
const state = get();
return state.completedUpsellPages.includes(pagePath);
},
hasUpsellBeenViewed: packageId => {
const state = get();
return state.viewedUpsells.includes(packageId);
},
hasUpsellPageBeenViewed: pagePath => {
const state = get();
return state.viewedUpsellPages.includes(pagePath);
},
getUpsellJourney: () => {
const state = get();
return state.upsellJourney;
},
getOrderTotal: () => {
const state = get();
if (!state.order) return 0;
// Parse the total_incl_tax field (it's a string in decimal format)
return parseFloat(state.order.total_incl_tax || '0');
},
canAddUpsells: () => {
const state = get();
return !!(
state.order &&
state.order.supports_post_purchase_upsells &&
!state.isProcessingUpsell
);
},
reset: () => {
logger.debug('Resetting order store');
set(initialState);
},
}),
{
name: ORDER_STORAGE_KEY,
storage: {
getItem: name => {
const str = sessionStorage.getItem(name);
return str ? JSON.parse(str) : null;
},
setItem: (name, value) => {
sessionStorage.setItem(name, JSON.stringify(value));
},
removeItem: name => {
sessionStorage.removeItem(name);
},
},
}
)
);