/** * Printful v2 API client. * * Handles catalog lookup, mockup generation, and order submission. * API v2 docs: https://developers.printful.com/docs/v2-beta/ * Rate limit: 120 req/60s (leaky bucket), lower for mockups. */ import type { PodOrder, PodRecipient, PodMockup, PodVariant } from "./types"; const BASE_URL = "https://api.printful.com/v2"; /** In-memory cache for catalog variants: product_id -> { variants, ts } */ const variantCache = new Map(); const VARIANT_CACHE_TTL = 86_400_000; // 24 hours in ms export class PrintfulClient { private apiToken: string; private storeId: string; private sandbox: boolean; readonly enabled: boolean; constructor() { this.apiToken = process.env.PRINTFUL_API_TOKEN || ""; this.storeId = process.env.PRINTFUL_STORE_ID || ""; this.sandbox = process.env.POD_SANDBOX_MODE !== "false"; this.enabled = !!this.apiToken; } private get headers(): Record { const h: Record = { Authorization: `Bearer ${this.apiToken}`, "Content-Type": "application/json", }; if (this.storeId) h["X-PF-Store-Id"] = this.storeId; return h; } // ── Catalog ── async getCatalogVariants(productId: number): Promise { const cached = variantCache.get(productId); if (cached && Date.now() - cached.ts < VARIANT_CACHE_TTL) { return cached.variants; } const resp = await fetch( `${BASE_URL}/catalog-products/${productId}/catalog-variants`, { headers: this.headers }, ); if (!resp.ok) throw new Error(`Printful catalog error: ${resp.status}`); const data = (await resp.json()) as { data?: any[] }; const raw = data.data || []; const variants: PodVariant[] = raw.map((v: any) => ({ id: v.id, size: v.size || "", color: v.color || "", colorCode: v.color_code || "", })); variantCache.set(productId, { variants, ts: Date.now() }); return variants; } /** * Resolve (product_id, size, color) -> Printful catalog_variant_id. * Our metadata uses SKU "71" + variants ["S","M","L",...]. * Printful orders require numeric catalog_variant_id. */ async resolveVariantId( productId: number, size: string, color = "Black", ): Promise { const variants = await this.getCatalogVariants(productId); // Exact match on size + color for (const v of variants) { if ( v.size.toUpperCase() === size.toUpperCase() && v.color.toLowerCase().includes(color.toLowerCase()) ) { return v.id; } } // Fallback: match size only for (const v of variants) { if (v.size.toUpperCase() === size.toUpperCase()) { return v.id; } } return null; } // ── Mockup Generation ── /** * Start async mockup generation task (v2 format). * Returns task_id to poll with getMockupTask(). */ async createMockupTask( productId: number, variantIds: number[], imageUrl: string, placement = "front", technique = "dtg", ): Promise { const payload = { products: [ { source: "catalog", catalog_product_id: productId, catalog_variant_ids: variantIds, placements: [ { placement, technique, layers: [{ type: "file", url: imageUrl }], }, ], }, ], format: "png", }; const resp = await fetch(`${BASE_URL}/mockup-tasks`, { method: "POST", headers: this.headers, body: JSON.stringify(payload), }); if (!resp.ok) throw new Error(`Printful mockup task error: ${resp.status}`); const result = (await resp.json()) as { data?: any }; const rawData = result.data; const data = Array.isArray(rawData) && rawData.length ? rawData[0] : rawData; const taskId = data?.id || data?.task_key || data?.task_id; return String(taskId); } /** Poll mockup task status. */ async getMockupTask(taskId: string): Promise<{ status: string; mockups?: PodMockup[]; failureReasons?: string[]; }> { const resp = await fetch( `${BASE_URL}/mockup-tasks?id=${encodeURIComponent(taskId)}`, { headers: this.headers }, ); if (!resp.ok) throw new Error(`Printful mockup poll error: ${resp.status}`); const result = (await resp.json()) as { data?: any }; const raw = result.data; const data = Array.isArray(raw) && raw.length ? raw[0] : (typeof raw === "object" ? raw : {}); const mockups: PodMockup[] = ( data.mockups || data.catalog_variant_mockups || [] ).map((m: any) => ({ variantId: m.catalog_variant_id || m.variant_id, mockupUrl: m.mockup_url || m.url || "", placement: m.placement || "front", })); return { status: data.status || "", mockups, failureReasons: data.failure_reasons, }; } /** * Create mockup task and poll until complete. * Returns mockup list or null on failure/timeout. */ async generateMockupAndWait( productId: number, variantIds: number[], imageUrl: string, placement = "front", technique = "dtg", maxPolls = 20, pollIntervalMs = 3000, ): Promise { const taskId = await this.createMockupTask( productId, variantIds, imageUrl, placement, technique, ); for (let i = 0; i < maxPolls; i++) { await new Promise((r) => setTimeout(r, pollIntervalMs)); const result = await this.getMockupTask(taskId); if (result.status === "completed") { return result.mockups || []; } if (result.status === "failed") { console.error(`Printful mockup task ${taskId} failed:`, result.failureReasons); return null; } } console.warn(`Printful mockup task ${taskId} timed out after ${maxPolls} polls`); return null; } // ── Orders ── async createOrder( items: { catalogVariantId: number; quantity: number; imageUrl: string; placement?: string; }[], recipient: PodRecipient, ): Promise { if (!this.enabled) throw new Error("Printful API token not configured"); const orderItems = items.map((item) => ({ source: "catalog", catalog_variant_id: item.catalogVariantId, quantity: item.quantity, placements: [ { placement: item.placement || "front", technique: "dtg", layers: [{ type: "file", url: item.imageUrl }], }, ], })); const payload: any = { recipient: { name: recipient.name, address1: recipient.address1, ...(recipient.address2 ? { address2: recipient.address2 } : {}), city: recipient.city, ...(recipient.stateCode ? { state_code: recipient.stateCode } : {}), country_code: recipient.countryCode, zip: recipient.zip, ...(recipient.email ? { email: recipient.email } : {}), }, items: orderItems, }; if (this.sandbox) payload.draft = true; const resp = await fetch(`${BASE_URL}/orders`, { method: "POST", headers: this.headers, body: JSON.stringify(payload), }); if (!resp.ok) { const text = await resp.text(); throw new Error(`Printful order error ${resp.status}: ${text.slice(0, 500)}`); } const result = (await resp.json()) as { data?: any }; const data = result.data || {}; return { id: String(data.id), provider: "printful", status: data.status || "draft", items: items.map((i) => ({ sku: String(i.catalogVariantId), quantity: i.quantity, imageUrl: i.imageUrl, placement: i.placement || "front", })), recipient, createdAt: new Date().toISOString(), raw: data, }; } async getOrder(orderId: string): Promise { const resp = await fetch(`${BASE_URL}/orders/${orderId}`, { headers: this.headers, }); if (!resp.ok) throw new Error(`Printful get order error: ${resp.status}`); const result = (await resp.json()) as { data?: any }; const data = result.data || {}; return { id: String(data.id), provider: "printful", status: data.status || "unknown", items: [], recipient: { name: "", address1: "", city: "", countryCode: "", zip: "" }, createdAt: data.created_at || new Date().toISOString(), raw: data, }; } }