import type { ProductVariantInput } from './types'; export interface QcVerdict { ok: boolean; reason: string | null } export interface SizeEstimate { recommended_size: string; recommended_variant_id: string; confidence: number; confidence_band: 'low' | 'good' | 'very_confident'; explanation: string; alternatives: { size: string; variant_id: string; reason: string }[]; between_sizes?: boolean; improvement_options?: ( 'add_weight' | 'enter_measurements' | 'retake_photo' )[]; cache_hit?: boolean; } export class SizeEstimateError extends Error { constructor(readonly code: string, readonly reason: string | null, readonly status: number) { super(`size estimation failed: ${code}`); this.name = 'SizeEstimateError'; } } export const MIN_HEIGHT_CM = 80; export const MAX_HEIGHT_CM = 250; export const MIN_WEIGHT_KG = 30; export const MAX_WEIGHT_KG = 300; export function dataUrlToBlob(dataUrl: string): Blob | null { try { const match = /^data:([^;,]+);base64,(.*)$/.exec(dataUrl); if (!match) return null; const binary = atob(match[2]); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return new Blob([bytes], { type: match[1] }); } catch { return null; } } export interface SizeInput { productId: string; productCategories: string[]; productVariants: ProductVariantInput[]; heightCm: number; weightKg?: number | null; fitPreference?: 'tight' | 'regular' | 'loose'; photo?: Blob | null; } export class SizeAPI { constructor( private sizeUrl: string, private qcUrl: string, private publishableKey: string, private visitorId: string, private customerId?: string | null ) {} private headers(): Record { return { 'X-Aisthetix-Key': this.publishableKey, 'X-Aisthetix-Visitor': this.visitorId, ...(this.customerId ? { 'X-Aisthetix-Customer': this.customerId } : {}), }; } async probePhotoQuality(photo: Blob, signal?: AbortSignal): Promise { const form = new FormData(); form.append('photo', photo, 'photo.jpg'); try { const response = await fetch(this.qcUrl, { method: 'POST', headers: this.headers(), body: form, signal }); if (!response.ok) return { ok: true, reason: null }; const body = await response.json(); return typeof body?.ok === 'boolean' ? { ok: body.ok, reason: typeof body.reason === 'string' ? body.reason : null } : { ok: true, reason: null }; } catch { return { ok: true, reason: null }; } } async estimate(input: SizeInput, signal?: AbortSignal): Promise { const form = new FormData(); form.append('productId', input.productId); form.append('productCategories', JSON.stringify(input.productCategories)); form.append('productVariants', JSON.stringify(input.productVariants)); form.append('heightCm', String(input.heightCm)); if (input.weightKg != null) form.append('weightKg', String(input.weightKg)); if (input.fitPreference) form.append('fitPreference', input.fitPreference); if (input.photo) form.append('photo', input.photo, 'photo.jpg'); let response: Response; try { response = await fetch(this.sizeUrl, { method: 'POST', headers: this.headers(), body: form, signal }); } catch { throw new SizeEstimateError('unreachable', null, 0); } const body = await response.json().catch(() => null); if (!response.ok) throw new SizeEstimateError(body?.error || 'estimation_failed', body?.reason || null, response.status); if (!body || typeof body.recommended_size !== 'string' || !body.recommended_size) { throw new SizeEstimateError('unreadable_response', null, response.status); } return body as SizeEstimate; } }