diff --git a/packages/commerce/src/sync.ts b/packages/commerce/src/sync.ts index 0334ce7..bcffc4b 100644 --- a/packages/commerce/src/sync.ts +++ b/packages/commerce/src/sync.ts @@ -10,9 +10,10 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); type MetaPrintfulProduct = { name: string; externalId: string; - colors: Set; + isColorGrouped: boolean; + colorGroups: Set; variants: { - color: string; + colorGroup: string | null; product: Printful.Products.Product; }[]; }; @@ -48,69 +49,60 @@ export class ProductSyncer { this.log.debug("populating printful products"); let printfulProducts = await this.printful.Products.list({ forceAll: true }); // optionally filter to sync only the given printful product - const printfulProductFilter = printfulProducts.find((p) => p.id === printfulProductId); - if (printfulProductFilter) { + if (printfulProductId) { + const printfulProductFilter = printfulProducts.find((p) => p.id === printfulProductId); + if (!printfulProductFilter) { + this.log.warn({ printfulProductId }, "printful product not found, skipping sync"); + return true; + } const metaPrintfulProductName = this.getMetaPrintfulProductName(printfulProductFilter.name); - printfulProducts = printfulProducts.filter((p) => p.name.includes(metaPrintfulProductName)); + printfulProducts = printfulProducts.filter((p) => + this.getMetaPrintfulProductName(p.name) === metaPrintfulProductName + ); } this.log.info({ count: printfulProducts.length }, "generating meta printful products"); - const metaPrintfulProducts: MetaPrintfulProduct[] = []; - for (const printfulProduct of printfulProducts) { - const metaPrintfulProductName = this.getMetaPrintfulProductName(printfulProduct.name); - - let metaPrintfulProduct = metaPrintfulProducts.find((mp) => mp.name.includes(metaPrintfulProductName)); - if (!metaPrintfulProduct) { - this.log.debug({ metaPrintfulProductName }, "appending meta printful product"); - metaPrintfulProduct = { - name: metaPrintfulProductName, - variants: [], - externalId: printfulProduct.external_id, - colors: new Set(), - }; - metaPrintfulProducts.push(metaPrintfulProduct); - } - - const productColor = this.findColorInProductName(printfulProduct.name); - this.log.debug({ productColor }, "appending meta printful product variant"); - metaPrintfulProduct.variants.push({ - color: productColor, - product: (await this.printful.Products.get(printfulProduct.id))!, - }); - metaPrintfulProduct.colors.add(productColor); - } - - // TODO: Validate main printful products + const metaPrintfulProducts = await this.generateMetaPrintfulProducts(printfulProducts); for (const metaPrintfulProduct of metaPrintfulProducts) { - this.log.info({ name: metaPrintfulProduct.name }, "syncing meta printful product"); + this.log.info({ name: metaPrintfulProduct.name, externalId: metaPrintfulProduct.externalId }, "syncing meta printful product"); await this.setState({ printfulProductIds: metaPrintfulProduct.variants.map( (v) => v.product.sync_product.id, ), }); - const foundColors = metaPrintfulProduct.colors; + // For color-grouped products, colors are the bracket-extracted values. + // For normal products, colors are collected from the variant data below. + const foundColors: Set = metaPrintfulProduct.isColorGrouped + ? metaPrintfulProduct.colorGroups + : new Set(); const foundSizes: Set = new Set(); const webflowSkus: DeepPartial[] = []; for (const metaVariant of metaPrintfulProduct.variants) { for (const printfulVariant of metaVariant.product.sync_variants) { + let skuColor: string; + + if (metaPrintfulProduct.isColorGrouped) { + // Only include sizes present across every color variant. + const sizeInAll = metaPrintfulProduct.variants.every((v) => + v.product.sync_variants.some((sv) => sv.size === printfulVariant.size) + ); + if (!sizeInAll) continue; + skuColor = metaVariant.colorGroup!; + } + else { + skuColor = printfulVariant.color; + foundColors.add(skuColor); + } + this.log.debug( - { size: printfulVariant.size, color: metaVariant.color }, + { size: printfulVariant.size, color: skuColor, retailPrice: printfulVariant.retail_price }, "generating webflow SKU from printful variant" ); - // check if this variant size is in all other special variants, if its not then skip it - let sizeIsInAllVariants = true; - for (const metaVariant of metaPrintfulProduct.variants) { - const sizes = metaVariant.product.sync_variants.map((v) => v.size); - if (!sizes.includes(printfulVariant.size)) - sizeIsInAllVariants = false; - } - if (!sizeIsInAllVariants) continue; - foundSizes.add(printfulVariant.size); webflowSkus.push({ @@ -119,7 +111,7 @@ export class ProductSyncer { name: printfulVariant.name, slug: formatSlug(printfulVariant.name), "sku-values": { - color: metaVariant.color, + color: skuColor, size: printfulVariant.size, }, price: { @@ -152,6 +144,13 @@ export class ProductSyncer { const webflowProductId = metaPrintfulProduct.externalId.split("-")[0]; if (!webflowProductId) throw new Error("Malformed printful product ID"); + if (!webflowSkus[0]) throw new Error("no webflow SKUs generated — all variants were filtered out"); + const firstExistingWebflowSku = this.resolveWebflowSku( + existingWebflowProduct.skus, + webflowSkus[0].fieldData?.["sku-values"]?.["color"], + webflowSkus[0].fieldData?.["sku-values"]?.["size"], + webflowSkus[0].id, + ); await this.webflow.Products.update(webflowProductId, { product: { fieldData: { @@ -181,17 +180,22 @@ export class ProductSyncer { ], }, }, - sku: webflowSkus[0], + sku: firstExistingWebflowSku + ? { ...webflowSkus[0], id: firstExistingWebflowSku.id } + : webflowSkus[0], }); - for (const webflowSku of webflowSkus.slice(1)) { - const existingWebflowSku = existingWebflowProduct.skus.find( - (sku) => sku.id === webflowSku.id, + for (const webflowSku of webflowSkus) { + const existingWebflowSku = this.resolveWebflowSku( + existingWebflowProduct.skus, + webflowSku.fieldData?.["sku-values"]?.["color"], + webflowSku.fieldData?.["sku-values"]?.["size"], + webflowSku.id, ); - if (webflowSku.id && existingWebflowSku) { + if (existingWebflowSku) { await this.webflow.Products.Skus.update( webflowProductId, - webflowSku.id, + existingWebflowSku.id, webflowSku, ); } else { @@ -205,6 +209,8 @@ export class ProductSyncer { // SYNC PRODUCT CREATE this.log.info("existing webflow product not found, creating it"); + if (!webflowSkus[0]) throw new Error("no webflow SKUs generated — all variants were filtered out"); + const webflowProductId = await this.webflow.Products.create({ product: { fieldData: { @@ -238,10 +244,8 @@ export class ProductSyncer { }); // create webflow product SKUs - await this.webflow.Products.Skus.create( - webflowProductId, - webflowSkus.slice(1), - ); + if (webflowSkus.length > 1) + await this.webflow.Products.Skus.create(webflowProductId, webflowSkus.slice(1)); existingWebflowProduct = await this.webflow.Products.get(webflowProductId); if (!existingWebflowProduct) @@ -250,10 +254,16 @@ export class ProductSyncer { for (const metaVariant of metaPrintfulProduct.variants) { const newPrintfulVariants: DeepPartial[] = []; for (const printfulVariant of metaVariant.product.sync_variants) { - const associatedWebflowSku = existingWebflowProduct.skus.find( - (sku) => - sku.fieldData["sku-values"]?.["color"] === printfulVariant.color && - sku.fieldData["sku-values"]?.["size"] === printfulVariant.size, + // For color-grouped products, the webflow SKU color is the bracket color. + // For normal products, the webflow SKU color is the variant's own color. + const skuColor = metaPrintfulProduct.isColorGrouped + ? metaVariant.colorGroup! + : printfulVariant.color; + + const associatedWebflowSku = this.resolveWebflowSku( + existingWebflowProduct.skus, + skuColor, + printfulVariant.size, ); if (associatedWebflowSku) { newPrintfulVariants.push({ @@ -270,7 +280,9 @@ export class ProductSyncer { { sync_product: { id: metaVariant.product.sync_product.id, - external_id: `${webflowProductId}-${metaVariant.color}`, + external_id: metaPrintfulProduct.isColorGrouped + ? `${webflowProductId}-${metaVariant.colorGroup}` + : webflowProductId, }, sync_variants: newPrintfulVariants, }, @@ -291,17 +303,62 @@ export class ProductSyncer { return true; } - findColorInProductName(productName: string): string { + findColorInProductName(productName: string): string | null { const m = productName.match(/\[([^\]]+)\]/); - return m?.[1] ? m[1] : "N/A"; + return m?.[1] ?? null; + } + + async generateMetaPrintfulProducts(printfulProducts: Printful.Products.SyncProduct[]) { + const metaPrintfulProducts: MetaPrintfulProduct[] = []; + for (const printfulProduct of printfulProducts) { + const metaPrintfulProductName = this.getMetaPrintfulProductName(printfulProduct.name); + const colorGroup = this.findColorInProductName(printfulProduct.name); + + let metaPrintfulProduct = metaPrintfulProducts.find((mp) => mp.name === metaPrintfulProductName); + if (!metaPrintfulProduct) { + this.log.debug({ metaPrintfulProductName, externalId: printfulProduct.external_id }, "appending meta printful product"); + metaPrintfulProduct = { + name: metaPrintfulProductName, + variants: [], + externalId: printfulProduct.external_id, + isColorGrouped: colorGroup !== null, + colorGroups: new Set(), + }; + metaPrintfulProducts.push(metaPrintfulProduct); + } + + this.log.debug({ colorGroup }, "appending meta printful product variant"); + const product = await this.printful.Products.get(printfulProduct.id); + if (!product) throw new Error(`Printful product ${printfulProduct.id} not found`); + metaPrintfulProduct.variants.push({ colorGroup, product }); + if (colorGroup) metaPrintfulProduct.colorGroups.add(colorGroup); + // Prefer a non-empty external_id so a newly added color (which has no + // external_id yet) doesn't shadow an already-synced sibling's ID. + if (!metaPrintfulProduct.externalId && printfulProduct.external_id) + metaPrintfulProduct.externalId = printfulProduct.external_id; + } + return metaPrintfulProducts; } getMetaPrintfulProductName(productName: string): string { - const colorInName = this.findColorInProductName(productName); - if (colorInName) - return productName.replace(`[${colorInName}]`, "").trimEnd(); - else - return productName; + const color = this.findColorInProductName(productName); + if (color) + return productName.replace(`[${color}]`, "").trimEnd(); + return productName; + } + + private resolveWebflowSku( + existingSkus: Webflow.Products.Skus.Sku[], + color: string | undefined, + size: string | undefined, + id?: string, + ): Webflow.Products.Skus.Sku | undefined { + return existingSkus.find( + (sku) => + (id && sku.id === id) || + (sku.fieldData["sku-values"]?.["color"] === color && + sku.fieldData["sku-values"]?.["size"] === size), + ); } async isRunning(): Promise {