import type { TryOnSyncResponse } from './types'; /** * Error thrown when the brain hard-stops a try-on because the store's quota is * exhausted (HTTP 402) — the storefront button should already be hidden by the * plugin's cached status, but this covers the ~10-minute cache race. */ export class QuotaExhaustedError extends Error { constructor( message = 'Quota exhausted' ) { super( message ); this.name = 'QuotaExhaustedError'; } } /** * Error thrown when the visitor is rate-limited (HTTP 429). */ export class VisitorRateLimitedError extends Error { public cooldownMinutesRemaining?: number; constructor( message = 'Visitor rate limited', cooldownMinutesRemaining?: number ) { super( message ); this.name = 'VisitorRateLimitedError'; this.cooldownMinutesRemaining = cooldownMinutesRemaining; } } /** * Client for the brain storefront try-on gateway. * * POST {apiBaseUrl} * headers: X-Aisthetix-Key: * X-Aisthetix-Visitor: (per-visitor rate-limit key) * body: multipart/form-data user_image= clothing_image= * product_category= (optional, provider routing) * -> 200 { image: "", cached: bool } * -> 402 quota exhausted (hard stop) * -> 429 visitor rate-limited * * The browser talks DIRECTLY to the brain — never through WordPress/PHP. */ export class VirtualTryOnAPI { private endpoint: string; private publishableKey: string; private visitorId: string; /** * @param endpoint Full try-on endpoint, e.g. {BRAIN_URL}/api/storefront/tryon * @param publishableKey Domain-restricted publishable key. * @param visitorId Stable per-visitor id; the brain keys its * visitor_usage cooldown to this (DESIGN.md #15, §7). */ constructor( endpoint: string, publishableKey: string, visitorId: string ) { // Accept either the full endpoint or a base URL ending in a slash. this.endpoint = endpoint.replace( /\/$/, '' ); this.publishableKey = publishableKey; this.visitorId = visitorId; } /** * Run a synchronous try-on. Returns the result image directly (no polling). * * @param userImageBase64 Shopper avatar (data URL or raw base64). * @param clothingImageBase64 Garment image (data URL or raw base64). * @param productCategory Comma-joined product category slugs, used by the * brain to route to a category-aware provider (e.g. * swimwear). Omitted from the request when empty. * @param abortSignal Optional cancellation signal. */ async tryOnSync( userImageBase64: string, clothingImageBase64: string, productCategory = '', abortSignal?: AbortSignal ): Promise { const formData = new FormData(); formData.append( 'user_image', this.base64ToBlob( userImageBase64 ), 'user.jpg' ); formData.append( 'clothing_image', this.base64ToBlob( clothingImageBase64 ), 'clothing.jpg' ); if ( productCategory ) { formData.append( 'product_category', productCategory ); } const response = await fetch( this.endpoint, { method: 'POST', headers: { // Publishable key; Origin must match the store's registered domain. 'X-Aisthetix-Key': this.publishableKey, // Per-visitor identifier the brain uses to enforce the // visitor_usage rate-limit cooldown (429 path). 'X-Aisthetix-Visitor': this.visitorId, }, body: formData, signal: abortSignal, } ); if ( response.status === 402 ) { throw new QuotaExhaustedError(); } if ( response.status === 429 ) { const body = await response.json().catch( () => ( {} ) ); const cooldown = typeof body?.cooldownMinutesRemaining === 'number' ? body.cooldownMinutesRemaining : undefined; throw new VisitorRateLimitedError( 'Visitor rate limited', cooldown ); } if ( ! response.ok ) { const error = await response.json().catch( () => ( { detail: 'Unknown error' } ) ); throw new Error( error.detail || `API error: ${ response.status }` ); } return response.json(); } /** * Convert a base64 / data-URL string to a Blob for multipart upload. */ private base64ToBlob( base64: string ): Blob { const base64Data = base64.replace( /^data:image\/\w+;base64,/, '' ); const binaryString = atob( base64Data ); const bytes = new Uint8Array( binaryString.length ); for ( let i = 0; i < binaryString.length; i++ ) { bytes[ i ] = binaryString.charCodeAt( i ); } return new Blob( [ bytes ], { type: 'image/jpeg' } ); } }