This commit is contained in:
Dominic Ferrando
2025-10-13 14:55:51 -04:00
parent 7f0a51d34c
commit ba9354fede
8 changed files with 331 additions and 195 deletions
+13 -12
View File
@@ -6,8 +6,8 @@ import type { ActivityPerformance, Player } from "$lib/services/calculator/util"
import rawStandards from "$lib/data/standards.json" assert { type: "json" }
import { Printful } from "./services/commerce/util/types";
import WebflowService from "./services/commerce/webflow";
import PrintfulService from "./services/commerce/printful";
import SyncService from "./services/commerce/sync";
import PrintfulService from "./services/commerce/printful";
export const app = new Elysia({ prefix: "/api" })
.use(cors({
@@ -42,17 +42,10 @@ export const app = new Elysia({ prefix: "/api" })
.group("/sync", app => app
.get("/", async ({ }) => SyncService.state)
.post("/:printfulProductId?", async ({ params }) => {
SyncService.state.isSyncing = true;
SyncService.state.printfulProductId = params.printfulProductId ?
const printfulProductId = params.printfulProductId ?
+params.printfulProductId :
undefined;
const printfulProducts = params.printfulProductId ?
[(await PrintfulService.Products.get(+params.printfulProductId)).sync_product] :
await PrintfulService.Products.getAll();
await SyncService.sync(printfulProducts);
return "";
await SyncService.sync(printfulProductId);
}, {
error({ error }) {
console.error(error);
@@ -63,6 +56,15 @@ export const app = new Elysia({ prefix: "/api" })
}
})
)
.get("/:printfulProductId", async ({ params }) => {
const printfulProductId = +params.printfulProductId;
const printfulProduct = await PrintfulService.Products.get(printfulProductId);
const webflowProductId = printfulProduct.sync_product.external_id.split("-")[0];
const webflowProduct = await WebflowService.Products.get(webflowProductId);
return { printfulProduct, webflowProduct };
})
)
// WEBHOOKS
@@ -71,7 +73,7 @@ export const app = new Elysia({ prefix: "/api" })
switch (payload.type) {
case Printful.Webhook.Event.ProductUpdated: {
const printfulProduct = payload.data.sync_product;
await SyncService.sync([printfulProduct]);
await SyncService.sync(printfulProduct.id);
break;
}
case Printful.Webhook.Event.ProductDeleted: {
@@ -79,7 +81,6 @@ export const app = new Elysia({ prefix: "/api" })
break;
}
}
return "";
}, {
error({ error }) {
console.error(error);
+10 -3
View File
@@ -41,8 +41,8 @@ export default class PrintfulService {
});
if (!res.ok) {
console.error("Printful product update failed:", res.statusText);
throw new Error("Failed to update Printful product");
console.error("Printful product get failed:", await res.json());
throw new Error("Failed to get Printful product");
}
const payload: Printful.Products.MetaDataSingle<Printful.Products.Product> = await res.json();
@@ -60,11 +60,18 @@ export default class PrintfulService {
});
if (!res.ok) {
console.error("Printful product update failed:", res.statusText);
console.error("Printful product update failed:", await res.json());
throw new Error("Failed to update Printful product");
}
}
}
static Util = class {
static getVariantMainImage(syncVariant: Printful.Products.SyncVariant): string {
const previewFile = syncVariant.files.find(f => f.type === "preview");
return previewFile?.preview_url ?? syncVariant.product.image;
}
}
}
export const env = () => {
+153 -106
View File
@@ -8,62 +8,126 @@ 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<string>,
variants: {
color: string,
product: Printful.Products.Product
}[]
}
export default class SyncService {
static state = {
isSyncing: false,
printfulProductId: undefined as undefined | number
syncingIds: [] as number[]
}
static async sync(printfulProducts: Printful.Products.SyncProduct[]) {
static async sync(printfulProductId: number | undefined) {
console.log("Syncing...");
this.state.isSyncing = true;
try {
const webflowProducts = await WebflowService.Products.getAll();
const mainPrintfulProducts: Printful.Products.SyncProduct[] = [];
for (const printfulProduct of printfulProducts) {
if (this.isSpecialPrintfulVariant(printfulProduct)) {
let printfulProducts = await PrintfulService.Products.getAll();
if (printfulProductId) {
const printfulProduct = printfulProducts.find(p => p.id === printfulProductId);
if (printfulProduct) {
const mainProductName = this.getMainProductName(printfulProduct.name);
const mainProductAlreadyInserted = mainPrintfulProducts
.some(p => p.name.includes(mainProductName));
if (!mainProductAlreadyInserted) {
mainPrintfulProducts.push(printfulProduct);
}
}
else {
mainPrintfulProducts.push(printfulProduct);
printfulProducts = printfulProducts.filter(p => p.name.includes(mainProductName));
}
}
const webflowProducts = await WebflowService.Products.getAll();
for (const printfulProduct of mainPrintfulProducts) {
// Generate main printful products
console.log("Generating main products...");
const mainPrintfulProducts: MainProduct[] = [];
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);
}
// TODO: Validate main printful products
// Sync the main printful products
for (const mainPrintfulProduct of mainPrintfulProducts) {
this.state.isSyncing = true;
this.state.printfulProductId = printfulProduct.id;
await sleep(2000);
this.state.syncingIds = mainPrintfulProduct.variants.map(v => v.product.sync_product.id);
const fullPrintfulProduct = await PrintfulService.Products.get(printfulProduct.id);
const printfulVariants = fullPrintfulProduct.sync_variants;
const webflowVariants = this.convertPrintfulVariantsToWebflowSkus(printfulVariants);
const foundColors = mainPrintfulProduct.colors;
const foundSizes: Set<string> = new Set();
const foundColors = Array.from(new Set(printfulVariants.map(v => v.color)));
const foundSizes = Array.from(new Set(printfulVariants.map(v => v.size)));
const webflowSkus: DeepPartial<Webflow.Products.Skus.Sku>[] = [];
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 webflowProduct = webflowProducts
.find(webflowProduct => webflowProduct.product.id === printfulProduct.external_id);
if (webflowProduct) {
let existingWebflowProduct = webflowProducts
.find(webflowProduct => webflowProduct.product.id === mainPrintfulProduct.externalId.split("-")[0]);
if (existingWebflowProduct) {
// SYNC PRODUCT UPDATE
const webflowProductId = fullPrintfulProduct.sync_product.external_id;
const webflowProductId = mainPrintfulProduct.externalId.split("-")[0];
WebflowService.Products.update(webflowProductId, {
"product": {
"fieldData": {
"name": fullPrintfulProduct.sync_product.name,
"slug": formatSlug(fullPrintfulProduct.sync_product.name),
"name": mainPrintfulProduct.name,
"slug": formatSlug(mainPrintfulProduct.name),
"shippable": true,
"tax-category": "standard-taxable",
"sku-properties": [
{
"id": "color",
"name": "Color",
"enum": foundColors.map(color => ({
"enum": Array.from(foundColors).map(color => ({
"id": color,
"slug": formatSlug(color),
"name": color
@@ -72,7 +136,7 @@ export default class SyncService {
{
"id": "size",
"name": "Size",
"enum": foundSizes.map(size => ({
"enum": Array.from(foundSizes).map(size => ({
"id": size,
"slug": formatSlug(size),
"name": size
@@ -80,19 +144,18 @@ export default class SyncService {
},
]
}
}
},
"sku": webflowSkus[0]
});
// Update webflow product SKUs
const webflowProduct = await WebflowService.Products.get(webflowProductId);
if (!webflowProduct) {
console.error("Missing webflow product");
throw new Error("Failed to create Webflow product");
}
for (let i = 0; i < webflowProduct.skus.length; ++i) {
const webflowSkuId = printfulVariants[i].external_id;
await WebflowService.Products.Skus.update(webflowProductId, webflowSkuId, webflowProduct.skus[i]);
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 {
@@ -100,15 +163,15 @@ export default class SyncService {
const webflowProductId = await WebflowService.Products.create({
"product": {
"fieldData": {
"name": fullPrintfulProduct.sync_product.name,
"slug": formatSlug(fullPrintfulProduct.sync_product.name),
"name": mainPrintfulProduct.name,
"slug": formatSlug(mainPrintfulProduct.name),
"shippable": true,
"tax-category": "standard-taxable",
"sku-properties": [
{
"id": "color",
"name": "Color",
"enum": foundColors.map(color => ({
"enum": Array.from(foundColors).map(color => ({
"id": color,
"slug": formatSlug(color),
"name": color
@@ -117,7 +180,7 @@ export default class SyncService {
{
"id": "size",
"name": "Size",
"enum": foundSizes.map(size => ({
"enum": Array.from(foundSizes).map(size => ({
"id": size,
"slug": formatSlug(size),
"name": size
@@ -126,101 +189,85 @@ export default class SyncService {
]
}
},
"sku": webflowVariants[0],
"sku": webflowSkus[0],
});
// create webflow product SKUs
await WebflowService.Products.Skus.create(webflowProductId, webflowVariants.slice(1));
await WebflowService.Products.Skus.create(webflowProductId, webflowSkus.slice(1));
webflowProduct = await WebflowService.Products.get(webflowProductId);
if (!webflowProduct) {
existingWebflowProduct = await WebflowService.Products.get(webflowProductId);
if (!existingWebflowProduct) {
console.error("Missing webflow product");
throw new Error("Failed to create Webflow product");
}
await PrintfulService.Products.update(printfulProduct.id, {
"sync_product": {
"external_id": String(webflowProductId)
},
"sync_variants": webflowProduct.skus.map((sku, i) => ({
"id": Number(printfulVariants[i].id), // printful variant id
"external_id": String(sku.id), // webflow variant id
}))
});
for (const specialVariant of mainPrintfulProduct.variants) {
const newPrintfulVariants: DeepPartial<Printful.Products.SyncVariant>[] = [];
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
});
}
}
// SYNC IMAGES
const productSlug = webflowProduct.product.fieldData.slug;
const productImageUrls = await this.getProductImageUrls(productSlug);
for (const sku of webflowProduct.skus) {
sku.fieldData["main-image"] = productImageUrls[0] ?? sku.fieldData["main-image"];
sku.fieldData["more-images"] = productImageUrls.slice(1).map(url => ({ url }));
await WebflowService.Products.Skus.update(webflowProduct.product.id, sku.id, sku);
for (const sku of existingWebflowProduct.skus) {
const skuColor = sku.fieldData["sku-values"]?.["color"]?.toLowerCase() ?? "None";
const skuSlug = `${existingWebflowProduct.product.fieldData.slug}-${skuColor}`;
const skuImageUrls = await this.getProductImageUrls(skuSlug);
sku.fieldData["main-image"] = skuImageUrls[0] ?? sku.fieldData["main-image"];
sku.fieldData["more-images"] = skuImageUrls.slice(1).map(url => ({ url }));
await WebflowService.Products.Skus.update(existingWebflowProduct.product.id, sku.id, sku);
}
}
}
catch (error) {
console.error(error);
throw error;
}
finally {
this.state.isSyncing = false;
this.state.printfulProductId = undefined;
this.state.syncingIds = [];
}
}
private static convertPrintfulVariantsToWebflowSkus(printfulVariants: Printful.Products.SyncVariant[]) {
const getVariantMainImage = (syncVariant: Printful.Products.SyncVariant): string => {
const previewFile = syncVariant.files.find(f => f.type === "preview");
return previewFile?.preview_url ?? syncVariant.product.image;
}
const webflowVariants: DeepPartial<Webflow.Products.Skus.Sku>[] = [];
for (const printfulVariant of printfulVariants) {
webflowVariants.push({
"fieldData": {
"name": printfulVariant.name,
"slug": formatSlug(printfulVariant.name),
"sku-values": {
"color": printfulVariant.color,
"size": printfulVariant.size,
},
"price": {
"value": +printfulVariant.retail_price * 100,
"unit": printfulVariant.currency,
"currency": printfulVariant.currency
},
"main-image": getVariantMainImage(printfulVariant)
}
});
}
return webflowVariants;
}
private static getProductImageUrls = async (slug: string) => {
const res = await (fetch(`${R2_WORKER_URL}/product-images/${slug}`));
if (!res.ok) {
console.error("Failed to get product image keys:", res.statusText);
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();
return productImageKeys.map(key => `${WEBSITE_MEDIA_URL}/${key}`);
}
private static findColorInProductName = (productName: string): string | undefined => {
const m = productName.match(/\[([^\]]+)\]$/);
return m ? m[1] : undefined;
public static findColorInProductName = (productName: string): string => {
const m = productName.match(/\[([^\]]+)\]/);
return m ? m[1] : "N/A";
};
private static isSpecialPrintfulVariant = (printfulProduct: Printful.Products.SyncProduct) => {
const colorInName = this.findColorInProductName(printfulProduct.name);
return !!colorInName;
}
private static getMainProductName = (productName: string) => {
public static getMainProductName = (productName: string) => {
const colorInName = this.findColorInProductName(productName);
if (colorInName) {
return productName.replace(colorInName, "").trimEnd();
return productName.replace(`[${colorInName}]`, "").trimEnd();
}
else {
return productName;
+2 -2
View File
@@ -17,7 +17,7 @@ export default class WebflowService {
});
if (!res.ok) {
console.error("Webflow product SKU create failed:", res.statusText);
console.error("Webflow product SKU create failed:", await res.json());
throw new Error("Failed to create Webflow product SKU");
}
}
@@ -109,7 +109,7 @@ export default class WebflowService {
});
if (!res.ok) {
console.error("Webflow product update failed:", res.statusText);
console.error("Webflow product update failed:", await res.json());
throw new Error("Failed to update Webflow product");
}
}
+70 -6
View File
@@ -1,6 +1,11 @@
import { Activity, ftToCm, Gender, getAvgWeight, inToCm, kgToLb, lbToKg, minToMs, secToMs, type ActivityPerformance, type Player } from "$lib/services/calculator/util";
import { Printful } from "$lib/services/commerce/printful";
import { Webflow } from "$lib/services/commerce/webflow";
import PrintfulService from "$lib/services/commerce/printful";
import SyncService from "$lib/services/commerce/sync";
import type { DeepPartial } from "$lib/services/commerce/util/misc";
import type { Printful } from "$lib/services/commerce/util/types";
import WebflowService from "$lib/services/commerce/webflow";
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const player: Player = {
metrics: {
@@ -52,12 +57,71 @@ const computedPerformances: ActivityPerformance[] = [
// })
// });
const productId = "68d080a129f538cd565bf56d";
const p = await WebflowService.Products.get("68e66dc4633e577c91eda713");
console.log(p);
// const printfulProducts = await PrintfulService.Products.getAll();
// const webflowProducts = await WebflowService.Products.getAll();
//
// for (const printfulProduct of printfulProducts) {
// const fullPrintfulProduct = await PrintfulService.Products.get(printfulProduct.id);
// console.log(fullPrintfulProduct.sync_product.name);
// const existingWebflowProduct = webflowProducts.find(p => fullPrintfulProduct.sync_product.name.includes(p.product.fieldData.name))
// if (!existingWebflowProduct) {
// continue;
// }
// console.log(existingWebflowProduct.product.fieldData.name);
//
// console.log(existingWebflowProduct.skus.map(s => s.fieldData["sku-values"]));
// console.log(fullPrintfulProduct.sync_variants.map(s => ({ color: s.color, size: s.size })));
//
// const newPrintfulVariants: DeepPartial<Printful.Products.SyncVariant>[] = [];
// for (const printfulVariant of fullPrintfulProduct.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
// });
// }
// }
//
// if (!newPrintfulVariants.length) {
// continue;
// }
//
// await sleep(10000);
// await PrintfulService.Products.update(printfulProduct.id, {
// "sync_product": {
// "id": printfulProduct.id,
// "external_id": existingWebflowProduct.product.id + "-" + SyncService.findColorInProductName(printfulProduct.name)
// },
// "sync_variants": newPrintfulVariants
// });
// }
// console.log("DONE");
const product = await Webflow.Products.get("68d080a129f538cd565bf56d");
const products = await Webflow.Products.getAll();
//
// await WebflowService.Products.update(wProduct?.product.id!, wProduct!);
//
// const sku = {
// "fieldData": {
// "name": "Test",
// "slug": "test-test",
// "sku-values": {
// "color": "black",
// "size": "L",
// },
// "price": {
// "value": +0 * 100,
// "unit": "USD",
// },
// "main-image": "https://cdn.prod.website-files.com/675a3fbe966e58ea9aa710e4/68d09d3d8e1e8bad26a9cf07_knight-title-tank-white-10.png"
// }
// }
//
const skuId = "68d080a5d90e8263e52242e9";
+29 -10
View File
@@ -7,12 +7,12 @@
const synchronizer = $state({
isSyncing: false,
printfulProductId: undefined as undefined | number,
syncingIds: [] as number[],
poll: async function () {
const res = await api.products.sync.get();
if (res.data) {
synchronizer.isSyncing = res.data.isSyncing;
synchronizer.printfulProductId = res.data.printfulProductId;
synchronizer.isSyncing = res.data.isSyncing ?? false;
synchronizer.syncingIds = res.data.syncingIds ?? [];
}
},
startPolling: () => {
@@ -27,9 +27,7 @@
},
sync: async (printfulProductId: number) => {
synchronizer.startPolling();
await api.products
.sync({ printfulProductId: String(printfulProductId) })
.post();
await api.products.sync({ printfulProductId }).post();
},
});
@@ -45,7 +43,7 @@
) => {
const webflowProducts = await data.products.webflow;
return webflowProducts.some(
(p) => p.product.id === printfulProduct.external_id,
(p) => p.product.id === printfulProduct.external_id.split("-")[0],
);
};
</script>
@@ -58,7 +56,11 @@
onclick={() => synchronizer.syncAll()}
class="btn btn-xl mb-5"
>
Sync all
{#if synchronizer.isSyncing}
Syncing...
{:else}
Sync all
{/if}
</button>
<div class="w-full overflow-x-auto">
<table class="table">
@@ -72,10 +74,27 @@
<tbody>
{#each printfulProducts as printfulProduct}
<tr class="hover:bg-base-300">
<td>{printfulProduct.name}</td>
<td
><button
onclick={async () => {
const productData = await api
.products({
printfulProductId:
printfulProduct.id,
})
.get();
console.log(
await productData.response.json(),
);
}}
class="cursor-pointer underline"
>
{printfulProduct.name}
</button></td
>
<td>
{#await isProductSynced(printfulProduct) then isSynced}
{#if synchronizer.printfulProductId === printfulProduct.id}
{#if synchronizer.syncingIds.includes(printfulProduct.id)}
<div class="badge badge-warning">
Syncing...
</div>