api/client.tstypescript
/**
* API Client for NextCommerce Campaigns API
*/
import type {
Campaign,
Cart,
Order,
CartBase,
CreateOrder,
AddUpsellLine,
AddressAutocomplete,
CartCalculateSummary,
CartSummary,
} from '@/types/api';
import { Logger, createLogger } from '@/core/logger';
import type { IApiClient } from './client.types';
/**
* The one implementation of `IApiClient` (`@/api/client.types`).
*
* Owns everything shared by every call — the base URL, the `Authorization` header,
* rate-limit handling, error enrichment, and telling an aborted request apart from a
* failed one — so no endpoint method repeats it and no feature touches `fetch`.
*
* **Do not construct one.** `src/client.ts` builds the single instance this page uses;
* ask it with `getApiClient()`. Twelve places used to run `new ApiClient(…)` themselves,
* which produced a dozen identical clients.
*
* **Depend on `IApiClient`, not on this class**, wherever you only need to call the
* API: it is what lets a test supply a compiler-checked fake instead of mocking the
* module by path. The interface is intentionally not re-exported from `src/index.ts` —
* that is the frozen public surface — so import it from `@/api/client.types`.
*
* Because the instance is shared, **every field here must stay per-page, not per-caller**.
* A cache, an in-flight map or an abort controller added to this class would silently be
* shared by every holder — that state belongs in the caller.
* @category Core
*/
export class ApiClient implements IApiClient {
private baseURL = 'https://campaigns.apps.29next.com';
private apiKey: string;
private logger: Logger;
constructor(apiKey: string) {
this.apiKey = apiKey;
this.logger = createLogger('ApiClient');
}
// Campaign endpoints
public async getCampaigns(currency?: string): Promise<Campaign> {
const queryString = currency ? `?currency=${currency}` : '';
return this.request<Campaign>(`/api/v1/campaigns/${queryString}`);
}
// Cart endpoints
public async createCart(
data: CartBase & { currency?: string }
): Promise<Cart> {
return this.request<Cart>('/api/v1/carts/', {
method: 'POST',
body: JSON.stringify(data),
});
}
public async calculateSummary(
data: CartCalculateSummary,
signal?: AbortSignal,
options?: { upsell?: boolean }
): Promise<CartSummary> {
const endpoint = options?.upsell
? '/api/v1/carts/calculate/?upsell=true'
: '/api/v1/carts/calculate/';
return this.request<CartSummary>(endpoint, {
method: 'POST',
body: JSON.stringify(data),
signal,
});
}
// Order endpoints
public async createOrder(
data: CreateOrder & { currency?: string }
): Promise<Order> {
return this.request<Order>('/api/v1/orders/', {
method: 'POST',
body: JSON.stringify(data),
});
}
public async getOrder(refId: string): Promise<Order> {
return this.request<Order>(`/api/v1/orders/${refId}/`);
}
public async addUpsell(refId: string, data: AddUpsellLine): Promise<Order> {
return this.request<Order>(`/api/v1/orders/${refId}/upsells/`, {
method: 'POST',
body: JSON.stringify(data),
});
}
// Prospect Cart endpoints
public async createProspectCart(data: any): Promise<any> {
return this.request('/api/v1/prospect-carts/', {
method: 'POST',
body: JSON.stringify(data),
});
}
public async updateProspectCart(cartId: string, data: any): Promise<any> {
return this.request(`/api/v1/prospect-carts/${cartId}/`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
public async getProspectCart(cartId: string): Promise<any> {
return this.request(`/api/v1/prospect-carts/${cartId}/`);
}
public async abandonProspectCart(cartId: string): Promise<any> {
return this.request(`/api/v1/prospect-carts/${cartId}/abandon/`, {
method: 'POST',
});
}
public async convertProspectCart(cartId: string): Promise<any> {
return this.request(`/api/v1/prospect-carts/${cartId}/convert/`, {
method: 'POST',
});
}
public async getAddressesAutocomplete(
query_text: string,
country?: string,
language?: string,
signal?: AbortSignal
): Promise<any> {
const params = new URLSearchParams({ query_text });
if (country) params.append('country', country);
if (language) params.append('language', language);
return this.request<AddressAutocomplete>(
`/api/v1/addresses/autocomplete/?${params.toString()}`,
{ signal }
);
}
// Generic request handler with error handling and rate limiting
private async request<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const method = options?.method || 'GET';
const url = `${this.baseURL}${endpoint}`;
const headers = {
Authorization: this.apiKey,
'Content-Type': 'application/json',
...options?.headers,
};
this.logger.debug(`API Request: ${method} ${url}`);
let errorMessage: string | undefined;
let retryAfter: number | undefined;
try {
const response = await fetch(url, {
...options,
headers,
});
// Handle rate limiting
if (response.status === 429) {
retryAfter = parseInt(response.headers.get('Retry-After') || '60');
errorMessage = `Rate limited. Retry after ${retryAfter} seconds`;
this.logger.warn(errorMessage);
throw new Error(errorMessage);
}
// Handle other errors
if (!response.ok) {
errorMessage = `API Error: ${response.status} ${response.statusText}`;
// Try to parse error response body
let errorData: any = {};
try {
const text = await response.text();
if (text) {
errorData = JSON.parse(text);
}
} catch (parseError) {
this.logger.warn('Failed to parse error response body');
}
this.logger.error(errorMessage, errorData);
// Create enhanced error with response data
const error = new Error(errorMessage) as any;
error.status = response.status;
error.statusText = response.statusText;
error.responseData = errorData;
throw error;
}
const data = await response.json();
this.logger.debug(`API Response: ${response.status}`, data);
return data;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (isAbortError(error, options?.signal)) {
this.logger.debug('API request aborted:', message);
} else {
this.logger.error('API request failed:', message);
}
throw error;
}
}
/**
* Re-keys this client in place.
*
* **Not part of `IApiClient`, and not for feature code.** There is one client per
* page, so this changes the credentials of every holder at once — including holders
* that cached the instance and will never ask for it again. Use `getApiClient(newKey)`
* from [`@/client`](../client.ts), which builds a client for that key; it reads the key
* back off the instance, so calling this behind its back gets the shared client
* replaced on the next call rather than silently reused.
*/
public setApiKey(apiKey: string): void {
this.apiKey = apiKey;
}
/** The key this client authenticates with. */
public getApiKey(): string {
return this.apiKey;
}
}
function isAbortError(error: unknown, signal?: AbortSignal | null): boolean {
if (signal?.aborted) return true;
if (!(error instanceof Error)) return false;
return error.name === 'AbortError' || /aborted|abort/i.test(error.message);
}