300 lines
7.8 KiB
TypeScript
300 lines
7.8 KiB
TypeScript
/**
|
|
* 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<number, { variants: PodVariant[]; ts: number }>();
|
|
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<string, string> {
|
|
const h: Record<string, string> = {
|
|
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<PodVariant[]> {
|
|
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<number | null> {
|
|
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<string> {
|
|
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<PodMockup[] | null> {
|
|
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<PodOrder> {
|
|
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<PodOrder> {
|
|
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,
|
|
};
|
|
}
|
|
}
|