import type { Printful, Webflow } from "./util/types"; import { FetchError, formatSlug, type DeepPartial } from "./util/misc"; import { WebflowService } from "./webflow"; import { PrintfulService } from "./printful"; import { redis } from "bun"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export const R2_WORKER_URL = "https://r2-worker.xominus.workers.dev"; export const WEBSITE_MEDIA_URL = "https://website-media.bladeandbrawn.com"; type MainProduct = { name: string; externalId: string; colors: Set; variants: { color: string; product: Printful.Products.Product; }[]; }; type SyncState = { isSyncing: boolean; syncingIds: number[]; }; export abstract class SyncService { static async sync(printfulProductId?: number) { await this.setState({ isSyncing: true, syncingIds: [] }); // Populate products const mainPrintfulProducts: MainProduct[] = []; const webflowProducts: Webflow.Products.ProductAndSkus[] = []; try { let printfulProducts = await PrintfulService.Products.getAll(); if (printfulProductId) { const printfulProduct = printfulProducts.find( (p) => p.id === printfulProductId, ); if (printfulProduct) { const mainProductName = this.getMainProductName( printfulProduct.name, ); printfulProducts = printfulProducts.filter((p) => p.name.includes(mainProductName), ); } } webflowProducts.push(...(await WebflowService.Products.getAll())); // Generate main printful products console.log("Generating main products..."); for (const printfulProduct of printfulProducts) { const mainProductName = this.getMainProductName( printfulProduct.name, ); let mainPrintfulProduct = mainPrintfulProducts.find((mp) => mp.name.includes(mainProductName), ); if (!mainPrintfulProduct) { mainPrintfulProduct = { name: mainProductName, variants: [], externalId: printfulProduct.external_id, colors: new Set(), }; mainPrintfulProducts.push(mainPrintfulProduct); } const productColor = this.findColorInProductName( printfulProduct.name, ); mainPrintfulProduct.variants.push({ color: productColor, product: (await PrintfulService.Products.get( printfulProduct.id, ))!, }); mainPrintfulProduct.colors.add(productColor); } } catch (error) { await this.setState({ isSyncing: false, syncingIds: [] }); throw error; } // TODO: Validate main printful products // Sync the main printful products console.log("Syncing main products..."); for (const mainPrintfulProduct of mainPrintfulProducts) { try { await this.setState({ isSyncing: true, syncingIds: mainPrintfulProduct.variants.map( (v) => v.product.sync_product.id, ) }); const foundColors = mainPrintfulProduct.colors; const foundSizes: Set = new Set(); const webflowSkus: DeepPartial[] = []; for (const specialVariant of mainPrintfulProduct.variants) { for (const printfulVariant of specialVariant.product .sync_variants) { // check if this variant size is in all other special variants let sizeIsInAllVariants = true; for (const specialVariant of mainPrintfulProduct.variants) { const sizes = specialVariant.product.sync_variants.map( (v) => v.size, ); if (!sizes.includes(printfulVariant.size)) sizeIsInAllVariants = false; } if (sizeIsInAllVariants) { foundSizes.add(printfulVariant.size); webflowSkus.push({ id: printfulVariant.external_id, fieldData: { name: printfulVariant.name, slug: formatSlug(printfulVariant.name), "sku-values": { color: specialVariant.color, size: printfulVariant.size, }, price: { value: Math.floor( +printfulVariant.retail_price * 100, ), unit: printfulVariant.currency, currency: printfulVariant.currency, }, "main-image": PrintfulService.Util.getVariantMainImage( printfulVariant, ), }, }); } } } // SYNC PRODUCT DATA let existingWebflowProduct = webflowProducts.find( (webflowProduct) => webflowProduct.product.id === mainPrintfulProduct.externalId.split("-")[0], ); if (existingWebflowProduct) { // SYNC PRODUCT UPDATE // do not update images for (const webflowSku of webflowSkus) { delete webflowSku.fieldData?.["main-image"]; } const webflowProductId = mainPrintfulProduct.externalId.split("-")[0]; if (!webflowProductId) { throw new Error("Malformed printful product ID"); } await WebflowService.Products.update(webflowProductId, { product: { fieldData: { name: mainPrintfulProduct.name, slug: formatSlug(mainPrintfulProduct.name), shippable: true, "tax-category": "standard-taxable", "sku-properties": [ { id: "color", name: "Color", enum: Array.from(foundColors).map( (color) => ({ id: color, slug: formatSlug(color), name: color, }), ), }, { id: "size", name: "Size", enum: Array.from(foundSizes).map( (size) => ({ id: size, slug: formatSlug(size), name: size, }), ), }, ], }, }, sku: webflowSkus[0], }); for (const webflowSku of webflowSkus.slice(1)) { const existingWebflowSku = existingWebflowProduct.skus.find( (sku) => sku.id === webflowSku.id, ); if (webflowSku.id && existingWebflowSku) { await WebflowService.Products.Skus.update( webflowProductId, webflowSku.id, webflowSku, ); } else { await WebflowService.Products.Skus.create( webflowProductId, [webflowSku], ); } } } else { // SYNC PRODUCT CREATE const webflowProductId = await WebflowService.Products.create({ product: { fieldData: { name: mainPrintfulProduct.name, slug: formatSlug(mainPrintfulProduct.name), shippable: true, "tax-category": "standard-taxable", "sku-properties": [ { id: "color", name: "Color", enum: Array.from(foundColors).map( (color) => ({ id: color, slug: formatSlug(color), name: color, }), ), }, { id: "size", name: "Size", enum: Array.from(foundSizes).map( (size) => ({ id: size, slug: formatSlug(size), name: size, }), ), }, ], }, }, sku: webflowSkus[0], }); // create webflow product SKUs await WebflowService.Products.Skus.create( webflowProductId, webflowSkus.slice(1), ); existingWebflowProduct = await WebflowService.Products.get(webflowProductId); if (!existingWebflowProduct) { console.error("Missing webflow product"); throw new Error("Failed to create Webflow product"); } for (const specialVariant of mainPrintfulProduct.variants) { const newPrintfulVariants: DeepPartial[] = []; for (const printfulVariant of specialVariant.product .sync_variants) { const associatedWebflowSku = existingWebflowProduct.skus.find( (sku) => sku.fieldData["sku-values"]?.[ "color" ] === printfulVariant.color && sku.fieldData["sku-values"]?.[ "size" ] === printfulVariant.size, ); if (associatedWebflowSku) { newPrintfulVariants.push({ id: printfulVariant.id, // printful variant id external_id: String( associatedWebflowSku.id, ), // webflow variant id }); } } await sleep(10000); await PrintfulService.Products.update( specialVariant.product.sync_product.id, { sync_product: { id: specialVariant.product.sync_product.id, external_id: `${webflowProductId}-${specialVariant.color}`, }, sync_variants: newPrintfulVariants, }, ); } } } catch (err) { if (err instanceof FetchError) { console.error(err.message, err.payload); } } } await this.setState({ isSyncing: false, syncingIds: [] }); console.log("Done"); } public static async setState(state: SyncState) { await redis.set("commerce:sync:state", JSON.stringify(state)); await redis.expire("commerce:sync:state", 1800); } public static async getState(): Promise { const val = await redis.get("commerce:sync:state"); if (val) return JSON.parse(val); return { isSyncing: false, syncingIds: [] } } public static findColorInProductName(productName: string): string { const m = productName.match(/\[([^\]]+)\]/); return m?.[1] ? m[1] : "N/A"; }; public static getMainProductName(productName: string) { const colorInName = this.findColorInProductName(productName); if (colorInName) { return productName.replace(`[${colorInName}]`, "").trimEnd(); } else { return productName; } }; private static async getProductImageUrls(slug: string) { const res = await fetch(`${R2_WORKER_URL}/product-images/${slug}`); if (!res.ok) { console.error( "Failed to get product image keys:", await res.json(), ); throw new Error("Failed to get product image keys"); } const productImageKeys: string[] = (await res.json()) as string[]; return productImageKeys.map((key) => `${WEBSITE_MEDIA_URL}/${key}`); }; }