diff --git a/src/lib/api.ts b/src/lib/api.ts index c5278f9..0e6e655 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -3,20 +3,11 @@ import { Elysia } from "elysia"; import { cors } from "@elysiajs/cors"; import { LevelCalculator, Standards, type ActivityStandards } from "$lib/services/calculator/main"; import type { ActivityPerformance, Player } from "$lib/services/calculator/util"; -import { Webflow } from "$lib/services/commerce/webflow"; -import { Printful } from "$lib/services/commerce/printful"; import rawStandards from "$lib/data/standards.json" assert { type: "json" } - -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - -const syncProduct = async (printfulProduct: Printful.Products.Product) => { - let webflowProduct = await Webflow.Products.get(printfulProduct.sync_product.external_id); - if (webflowProduct) - await Webflow.Products.updateUsingPrintful(printfulProduct); - else - webflowProduct = await Webflow.Products.createUsingPrintful(printfulProduct); - await Webflow.Products.syncImages(webflowProduct); -}; +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"; export const app = new Elysia({ prefix: "/api" }) .use(cors({ @@ -27,7 +18,7 @@ export const app = new Elysia({ prefix: "/api" }) // CALCULATOR - .post("/calculate", async ({ body, store }) => { + .post("/calculate", async ({ body }) => { const MAIN_STANDARDS = new Standards(rawStandards as ActivityStandards); interface CalcRequest { player: Player; @@ -49,55 +40,19 @@ export const app = new Elysia({ prefix: "/api" }) // COMMERCE .group("/products", app => app .group("/sync", app => app - .state("job", { - isSyncing: false, - printfulProductId: undefined as undefined | number - }) - .get("/", async ({ store }) => { - return store.job; - }) - .post("/:printfulProductId?", async ({ params, store }) => { - if (store.job.isSyncing) return ""; - store.job.isSyncing = true; - try { - if (params.printfulProductId) { - const printfulProductId = +params.printfulProductId; + .get("/", async ({ }) => SyncService.state) + .post("/:printfulProductId?", async ({ params }) => { + SyncService.state.isSyncing = true; + SyncService.state.printfulProductId = params.printfulProductId ? + +params.printfulProductId : + undefined; - store.job.printfulProductId = printfulProductId; + const printfulProducts = params.printfulProductId ? + [(await PrintfulService.Products.get(+params.printfulProductId)).sync_product] : + await PrintfulService.Products.getAll(); - const printfulProduct = await Printful.Products.get(printfulProductId); - await syncProduct(printfulProduct); - } - else { - console.log("Syncing all products..."); - - const printfulProducts = await Printful.Products.getAll(); - const webflowProducts = await Webflow.Products.getAll(); - for (const printfulProduct of printfulProducts) { - store.job.printfulProductId = printfulProduct.id; - - await sleep(2000); - const fullPrintfulProduct = await Printful.Products.get(printfulProduct.id); - - let webflowProduct = webflowProducts - .find(webflowProduct => webflowProduct.product.id === printfulProduct.external_id); - if (webflowProduct) - await Webflow.Products.updateUsingPrintful(fullPrintfulProduct); - else - webflowProduct = await Webflow.Products.createUsingPrintful(fullPrintfulProduct); - await Webflow.Products.syncImages(webflowProduct); - } - } - console.log("Done"); - return ""; - } - catch (error) { - throw error; - } - finally { - store.job.isSyncing = false; - store.job.printfulProductId = undefined; - } + await SyncService.sync(printfulProducts); + return ""; }, { error({ error }) { console.error(error); @@ -111,17 +66,16 @@ export const app = new Elysia({ prefix: "/api" }) ) // WEBHOOKS - - .post("/webhook/printful", async ({ body, set }) => { + .post("/webhook/printful", async ({ body }) => { const payload = body as Printful.Webhook.EventPayload; switch (payload.type) { case Printful.Webhook.Event.ProductUpdated: { - const printfulProduct = await Printful.Products.get(payload.data.sync_product.id); - await syncProduct(printfulProduct); + const printfulProduct = payload.data.sync_product; + await SyncService.sync([printfulProduct]); break; } case Printful.Webhook.Event.ProductDeleted: { - await Webflow.Products.remove(payload.data.sync_product.external_id); + await WebflowService.Products.remove(payload.data.sync_product.external_id); break; } } diff --git a/src/lib/services/calculator/util.ts b/src/lib/services/calculator/util.ts index ee6e355..7791c7c 100644 --- a/src/lib/services/calculator/util.ts +++ b/src/lib/services/calculator/util.ts @@ -5,7 +5,7 @@ import avgWeightDataRaw from "$lib/data/avg-weights.json" // DATA MODELS // ------------------------------------------------------------------------------------------------- -export interface Player { +export type Player = { metrics: Metrics; } diff --git a/src/lib/services/commerce/printful.ts b/src/lib/services/commerce/printful.ts index 25120bb..8accea6 100644 --- a/src/lib/services/commerce/printful.ts +++ b/src/lib/services/commerce/printful.ts @@ -1,137 +1,10 @@ -import { formatSlug, type DeepPartial } from "./util"; -import type { Webflow } from "./webflow"; +import type { Printful } from "./util/types"; +import { type DeepPartial } from "./util/misc"; -export namespace Printful { - export namespace Webhook { - // meta - interface MetaData { - created: number; - retries: number; - store: number; - } - - export enum Event { - ProductUpdated = "product_updated", - ProductDeleted = "product_deleted" - } - - // product updated - export interface ProductUpdated extends MetaData { - type: Event.ProductUpdated, - data: { - sync_product: { - id: number; - external_id: string; - name: string; - variants: number; - synced: number; - thumbnail_url: string; - is_ignored: boolean; - }; - } - } - - // product deleted - export interface ProductDeleted extends MetaData { - type: Event.ProductDeleted, - data: { - sync_product: { - id: number; - external_id: string; - name: string; - }; - } - } - - export type EventPayload = ProductUpdated | ProductDeleted - } - - export namespace Products { - export interface MetaDataSingle { - code: number; - result: T; - } - - export interface MetaDataMulti { - code: number; - result: T[]; - paging: { - total: number, - offset: number, - limit: number - } - } - - type Option = { - id: string; - value: string; - } - - type File = { - type: string; - id: number; - url: string; - options: Option[]; - hash: string; - filename: string; - mime_type: string; - size: number; - width: number; - height: number; - dpi: number; - status: string; - created: number; - thumbnail_url: string; - preview_url: string; - visible: boolean; - is_temporary: boolean; - stitch_count_tier: string; - } - - export type Product = { - sync_product: SyncProduct, - sync_variants: SyncVariant[] - } - - export type SyncProduct = { - id: number; - external_id: string; - name: string; - variants: number; - synced: number; - thumbnail_url: string; - is_ignored: boolean; - } - - export type SyncVariant = { - id: number; - external_id: string; - sync_product_id: number; - name: string; - synced: boolean; - variant_id: number; - retail_price: string; - currency: string; - is_ignored: boolean; - sku: string; - product: { - variant_id: number; - product_id: number; - image: string; - name: string; - }; - files: File[]; - options: Option[]; - main_category_id: number; - warehouse_product_id: number; - warehouse_product_variant_id: number; - size: string; - color: string; - availability_status: string; - } - - export async function getAll(offset: number = 0): Promise { - const allSyncProducts: SyncProduct[] = []; +export default class PrintfulService { + static Products = class { + static async getAll(offset: number = 0): Promise { + const allSyncProducts: Printful.Products.SyncProduct[] = []; while (true) { try { @@ -145,7 +18,7 @@ export namespace Printful { throw new Error("Failed to get all Printful products",); } - const payload: MetaDataMulti = await res.json(); + const payload: Printful.Products.MetaDataMulti = await res.json(); allSyncProducts.push(...payload.result); offset = allSyncProducts.length; @@ -161,7 +34,7 @@ export namespace Printful { return allSyncProducts; } - export async function get(printfulProductId: number): Promise { + static async get(printfulProductId: number): Promise { const res = await fetch(`${env().API_URL}/store/products/${printfulProductId}`, { method: "GET", headers: { ...env().AUTH_HEADERS } @@ -172,11 +45,11 @@ export namespace Printful { throw new Error("Failed to update Printful product"); } - const payload: MetaDataSingle = await res.json(); + const payload: Printful.Products.MetaDataSingle = await res.json(); return payload.result; } - export async function update(printfulProductId: number, printfulProduct: DeepPartial) { + static async update(printfulProductId: number, printfulProduct: DeepPartial) { const res = await fetch(`${env().API_URL}/store/products/${printfulProductId}`, { method: "PUT", headers: { @@ -191,53 +64,25 @@ export namespace Printful { throw new Error("Failed to update Printful product"); } } + } +} - export function convertVariantsToWebflowSkus(printfulVariants: SyncVariant[]) { - const getVariantMainImage = (syncVariant: SyncVariant): string => { - const previewFile = syncVariant.files.find(f => f.type === "preview"); - return previewFile?.preview_url ?? syncVariant.product.image; - } - - const webflowVariants: DeepPartial[] = []; - 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; - } +export const env = () => { + if (typeof Bun === "undefined") { + throw new Error("Must be in a server context. Make sure to run using --bun."); } - export const env = () => { - if (typeof Bun === "undefined") { - throw new Error("Must be in a server context. Make sure to run using --bun."); - } - - const vars = { - API_URL: "https://api.printful.com", - AUTH_TOKEN: Bun.env.PRINTFUL_AUTH, - STORE_ID: Bun.env.PRINTFUL_STORE_ID - }; - - return { - ...vars, - AUTH_HEADERS: { - "Authorization": `Bearer ${vars.AUTH_TOKEN}`, - "X-PF-Store-Id": `${vars.STORE_ID}` - } - }; + const vars = { + API_URL: "https://api.printful.com", + AUTH_TOKEN: Bun.env.PRINTFUL_AUTH, + STORE_ID: Bun.env.PRINTFUL_STORE_ID }; -} + + return { + ...vars, + AUTH_HEADERS: { + "Authorization": `Bearer ${vars.AUTH_TOKEN}`, + "X-PF-Store-Id": `${vars.STORE_ID}` + } + }; +}; diff --git a/src/lib/services/commerce/sync.ts b/src/lib/services/commerce/sync.ts new file mode 100644 index 0000000..4e0ed1c --- /dev/null +++ b/src/lib/services/commerce/sync.ts @@ -0,0 +1,229 @@ +import type { Printful, Webflow } from "./util/types"; +import { formatSlug, type DeepPartial } from "./util/misc"; +import WebflowService from "./webflow"; +import PrintfulService from "./printful"; + +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"; + +export default class SyncService { + static state = { + isSyncing: false, + printfulProductId: undefined as undefined | number + } + + static async sync(printfulProducts: Printful.Products.SyncProduct[]) { + 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)) { + const mainProductName = this.getMainProductName(printfulProduct.name); + const mainProductAlreadyInserted = mainPrintfulProducts + .some(p => p.name.includes(mainProductName)); + if (!mainProductAlreadyInserted) { + mainPrintfulProducts.push(printfulProduct); + } + } + else { + mainPrintfulProducts.push(printfulProduct); + } + } + + for (const printfulProduct of mainPrintfulProducts) { + this.state.isSyncing = true; + this.state.printfulProductId = printfulProduct.id; + await sleep(2000); + + const fullPrintfulProduct = await PrintfulService.Products.get(printfulProduct.id); + const printfulVariants = fullPrintfulProduct.sync_variants; + const webflowVariants = this.convertPrintfulVariantsToWebflowSkus(printfulVariants); + + const foundColors = Array.from(new Set(printfulVariants.map(v => v.color))); + const foundSizes = Array.from(new Set(printfulVariants.map(v => v.size))); + + // SYNC PRODUCT DATA + let webflowProduct = webflowProducts + .find(webflowProduct => webflowProduct.product.id === printfulProduct.external_id); + if (webflowProduct) { + // SYNC PRODUCT UPDATE + const webflowProductId = fullPrintfulProduct.sync_product.external_id; + WebflowService.Products.update(webflowProductId, { + "product": { + "fieldData": { + "name": fullPrintfulProduct.sync_product.name, + "slug": formatSlug(fullPrintfulProduct.sync_product.name), + "shippable": true, + "tax-category": "standard-taxable", + "sku-properties": [ + { + "id": "color", + "name": "Color", + "enum": foundColors.map(color => ({ + "id": color, + "slug": formatSlug(color), + "name": color + })) + }, + { + "id": "size", + "name": "Size", + "enum": foundSizes.map(size => ({ + "id": size, + "slug": formatSlug(size), + "name": size + })) + }, + ] + } + } + }); + + // 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]); + } + } + else { + // SYNC PRODUCT CREATE + const webflowProductId = await WebflowService.Products.create({ + "product": { + "fieldData": { + "name": fullPrintfulProduct.sync_product.name, + "slug": formatSlug(fullPrintfulProduct.sync_product.name), + "shippable": true, + "tax-category": "standard-taxable", + "sku-properties": [ + { + "id": "color", + "name": "Color", + "enum": foundColors.map(color => ({ + "id": color, + "slug": formatSlug(color), + "name": color + })) + }, + { + "id": "size", + "name": "Size", + "enum": foundSizes.map(size => ({ + "id": size, + "slug": formatSlug(size), + "name": size + })) + }, + ] + } + }, + "sku": webflowVariants[0], + }); + + // create webflow product SKUs + await WebflowService.Products.Skus.create(webflowProductId, webflowVariants.slice(1)); + + webflowProduct = await WebflowService.Products.get(webflowProductId); + if (!webflowProduct) { + 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 + })) + }); + } + + // 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); + } + } + } + catch (error) { + throw error; + } + finally { + this.state.isSyncing = false; + this.state.printfulProductId = undefined; + } + + } + + 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[] = []; + 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); + 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; + }; + + private static isSpecialPrintfulVariant = (printfulProduct: Printful.Products.SyncProduct) => { + const colorInName = this.findColorInProductName(printfulProduct.name); + return !!colorInName; + } + + private static getMainProductName = (productName: string) => { + const colorInName = this.findColorInProductName(productName); + if (colorInName) { + return productName.replace(colorInName, "").trimEnd(); + } + else { + return productName; + } + }; +} diff --git a/src/lib/services/commerce/util.ts b/src/lib/services/commerce/util/misc.ts similarity index 50% rename from src/lib/services/commerce/util.ts rename to src/lib/services/commerce/util/misc.ts index 6de3a4f..9d49491 100644 --- a/src/lib/services/commerce/util.ts +++ b/src/lib/services/commerce/util/misc.ts @@ -11,16 +11,3 @@ export const formatSlug = (name: string): string => { .replace(/--+/g, '-') // collapse multiple dashes .replace(/^([^a-z0-9_])/, '_$1'); // if it starts with an invalid char, prefix underscore }; - -export const 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); - throw new Error("Failed to get product image keys"); - } - const productImageKeys: string[] = await res.json(); - return productImageKeys.map(key => `${WEBSITE_MEDIA_URL}/${key}`); -} - -export const R2_WORKER_URL = "https://r2-worker.xominus.workers.dev"; -export const WEBSITE_MEDIA_URL = "https://website-media.bladeandbrawn.com"; diff --git a/src/lib/services/commerce/util/types.ts b/src/lib/services/commerce/util/types.ts new file mode 100644 index 0000000..c6ced2d --- /dev/null +++ b/src/lib/services/commerce/util/types.ts @@ -0,0 +1,188 @@ +export namespace Printful { + export namespace Products { + export interface MetaDataSingle { + code: number; + result: T; + } + + export interface MetaDataMulti { + code: number; + result: T[]; + paging: { + total: number, + offset: number, + limit: number + } + } + + type Option = { + id: string; + value: string; + } + + type File = { + type: string; + id: number; + url: string; + options: Option[]; + hash: string; + filename: string; + mime_type: string; + size: number; + width: number; + height: number; + dpi: number; + status: string; + created: number; + thumbnail_url: string; + preview_url: string; + visible: boolean; + is_temporary: boolean; + stitch_count_tier: string; + } + + export type Product = { + sync_product: SyncProduct, + sync_variants: SyncVariant[] + } + + export type SyncProduct = { + id: number; + external_id: string; + name: string; + variants: number; + synced: number; + thumbnail_url: string; + is_ignored: boolean; + } + + export type SyncVariant = { + id: number; + external_id: string; + sync_product_id: number; + name: string; + synced: boolean; + variant_id: number; + retail_price: string; + currency: string; + is_ignored: boolean; + sku: string; + product: { + variant_id: number; + product_id: number; + image: string; + name: string; + }; + files: File[]; + options: Option[]; + main_category_id: number; + warehouse_product_id: number; + warehouse_product_variant_id: number; + size: string; + color: string; + availability_status: string; + } + } + + export namespace Webhook { + // meta + interface MetaData { + created: number; + retries: number; + store: number; + } + + export enum Event { + ProductUpdated = "product_updated", + ProductDeleted = "product_deleted" + } + + // product updated + export interface ProductUpdated extends MetaData { + type: Event.ProductUpdated, + data: { + sync_product: { + id: number; + external_id: string; + name: string; + variants: number; + synced: number; + thumbnail_url: string; + is_ignored: boolean; + }; + } + } + + // product deleted + export interface ProductDeleted extends MetaData { + type: Event.ProductDeleted, + data: { + sync_product: { + id: number; + external_id: string; + name: string; + }; + } + } + + export type EventPayload = ProductUpdated | ProductDeleted + } +} + +export namespace Webflow { + export namespace Products { + export namespace Skus { + export type Sku = { + id: string, + fieldData: { + name: string; + slug: string; + "sku-values"?: Record, + price: { + value: number; + unit: string; + currency: string; + }; + "main-image"?: string; + "more-images"?: + { + fileId?: string, + url: string, + alt?: string, + }[] + } + } + } + + export type Product = { + id: string, + fieldData: { + name: string; + slug: string; + description?: string; + shippable?: boolean; + "tax-category"?: string; + "sku-properties": { + id: string; + name: string; + enum: { + id: string; + name: string; + slug: string; + }[]; + }[]; + } + } + + export interface ProductAndSkus { + product: Product + skus: Skus.Sku[] + } + + export interface ProductAndSku { + product: Product; + sku: Skus.Sku; + publishStatus?: "staging" | "live"; + } + } +} diff --git a/src/lib/services/commerce/webflow.ts b/src/lib/services/commerce/webflow.ts index 24b5e2b..da96eb6 100644 --- a/src/lib/services/commerce/webflow.ts +++ b/src/lib/services/commerce/webflow.ts @@ -1,31 +1,10 @@ -import { Printful } from "$lib/services/commerce/printful" -import { formatSlug, getProductImageUrls, type DeepPartial } from "./util"; +import type { Webflow } from "./util/types"; +import { type DeepPartial } from "./util/misc"; -export namespace Webflow { - export namespace Products { - export namespace Skus { - export type Sku = { - id: string, - fieldData: { - name: string; - slug: string; - "sku-values"?: Record, - price: { - value: number; - unit: string; - currency: string; - }; - "main-image"?: string; - "more-images"?: - { - fileId?: string, - url: string, - alt?: string, - }[] - } - } - - export async function create(webflowProductId: string, skus: DeepPartial[]) { +export default class WebflowService { + static Products = class { + static Skus = class { + static async create(webflowProductId: string, skus: DeepPartial[]) { const res = await fetch(`${env().API_SITES_URL}/products/${webflowProductId}/skus`, { method: "POST", headers: { @@ -43,7 +22,7 @@ export namespace Webflow { } } - export async function update(webflowProductId: string, webflowSkuId: string, webflowSku: DeepPartial) { + static async update(webflowProductId: string, webflowSkuId: string, webflowSku: DeepPartial) { const res = await fetch(`${env().API_SITES_URL}/products/${webflowProductId}/skus/${webflowSkuId}`, { method: "PATCH", headers: { @@ -61,38 +40,7 @@ export namespace Webflow { } } - export type Product = { - id: string, - fieldData: { - name: string; - slug: string; - description?: string; - shippable?: boolean; - "tax-category"?: string; - "sku-properties": { - id: string; - name: string; - enum: { - id: string; - name: string; - slug: string; - }[]; - }[]; - } - } - - export interface ProductAndSkus { - product: Product - skus: Skus.Sku[] - } - - export interface ProductAndSku { - product: Product; - sku: Skus.Sku; - publishStatus?: "staging" | "live"; - } - - export async function create(webflowProductAndSku: DeepPartial) { + static async create(webflowProductAndSku: DeepPartial) { const res = await fetch(`${env().API_SITES_URL}/products`, { method: "POST", headers: { @@ -112,69 +60,7 @@ export namespace Webflow { return createdWebflowProduct.product.id } - export async function createUsingPrintful(printfulProduct: Printful.Products.Product): Promise { - const printfulVariants = printfulProduct.sync_variants; - const webflowVariants = Printful.Products.convertVariantsToWebflowSkus(printfulVariants); - - const foundColors = Array.from(new Set(printfulVariants.map(v => v.color))); - const foundSizes = Array.from(new Set(printfulVariants.map(v => v.size))); - - const webflowProductId = await Webflow.Products.create({ - "product": { - "fieldData": { - "name": printfulProduct.sync_product.name, - "slug": formatSlug(printfulProduct.sync_product.name), - "shippable": true, - "tax-category": "standard-taxable", - "sku-properties": [ - { - "id": "color", - "name": "Color", - "enum": foundColors.map(color => ({ - "id": color, - "slug": formatSlug(color), - "name": color - })) - }, - { - "id": "size", - "name": "Size", - "enum": foundSizes.map(size => ({ - "id": size, - "slug": formatSlug(size), - "name": size - })) - }, - ] - } - }, - "sku": webflowVariants[0], - }); - - // CREATE WEBFLOW PRODUCT SKUs - await Webflow.Products.Skus.create(webflowProductId, webflowVariants.slice(1)); - - const webflowProduct = await Webflow.Products.get(webflowProductId); - if (!webflowProduct) { - console.error("Missing webflow product"); - throw new Error("Failed to create Webflow product"); - } - - const printfulProductId = printfulProduct.sync_product.id; - await Printful.Products.update(printfulProductId, { - "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 - })) - }); - - return webflowProduct; - } - - export async function getAll(): Promise { + static async getAll(): Promise { const res = await fetch(`${env().API_SITES_URL}/products`, { method: "GET", headers: { @@ -188,10 +74,10 @@ export namespace Webflow { } const payload = await res.json(); - return payload.items as ProductAndSkus[]; + return payload.items as Webflow.Products.ProductAndSkus[]; } - export async function get(webflowProductId: string): Promise { + static async get(webflowProductId: string): Promise { const res = await fetch(`${env().API_SITES_URL}/products/${webflowProductId}`, { method: "GET", headers: { @@ -209,10 +95,10 @@ export namespace Webflow { } const payload = await res.json(); - return payload as ProductAndSkus; + return payload as Webflow.Products.ProductAndSkus; } - export async function update(webflowProductId: string, webflowProduct: DeepPartial) { + static async update(webflowProductId: string, webflowProduct: DeepPartial) { const res = await fetch(`${env().API_SITES_URL}/products/${webflowProductId}`, { method: "PATCH", headers: { @@ -228,60 +114,7 @@ export namespace Webflow { } } - export async function updateUsingPrintful(printfulProduct: Printful.Products.Product) { - const webflowProductId = printfulProduct.sync_product.external_id; - - const printfulVariants = printfulProduct.sync_variants; - - const foundColors = Array.from(new Set(printfulVariants.map(v => v.color))); - const foundSizes = Array.from(new Set(printfulVariants.map(v => v.size))); - - // Update product - Webflow.Products.update(webflowProductId, { - "product": { - "fieldData": { - "name": printfulProduct.sync_product.name, - "slug": formatSlug(printfulProduct.sync_product.name), - "shippable": true, - "tax-category": "standard-taxable", - "sku-properties": [ - { - "id": "color", - "name": "Color", - "enum": foundColors.map(color => ({ - "id": color, - "slug": formatSlug(color), - "name": color - })) - }, - { - "id": "size", - "name": "Size", - "enum": foundSizes.map(size => ({ - "id": size, - "slug": formatSlug(size), - "name": size - })) - }, - ] - } - } - }); - - // Update SKUs - const webflowProduct = await Webflow.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 Webflow.Products.Skus.update(webflowProductId, webflowSkuId, webflowProduct.skus[i]); - } - } - - export async function remove(webflowProductId: string) { + static async remove(webflowProductId: string) { const res = await fetch(`${env().API_COLLECTIONS_URL}/items/${webflowProductId}`, { method: "DELETE", headers: { @@ -296,35 +129,25 @@ export namespace Webflow { throw new Error("Failed to remove Webflow product"); } } - - export async function syncImages(webflowProductAndSkus: ProductAndSkus) { - const productSlug = webflowProductAndSkus.product.fieldData.slug; - const productImageUrls = await getProductImageUrls(productSlug); - for (const sku of webflowProductAndSkus.skus) { - sku.fieldData["main-image"] = productImageUrls[0] ?? sku.fieldData["main-image"]; - sku.fieldData["more-images"] = productImageUrls.slice(1).map(url => ({ url })); - await Webflow.Products.Skus.update(webflowProductAndSkus.product.id, sku.id, sku); - } - } } - - const env = () => { - if (typeof Bun === "undefined") { - throw new Error("Must be in a server context. Make sure to run using --bun."); - } - - const vars = { - SITE_ID: Bun.env.WEBFLOW_SITE_ID, - COLLECTIONS_ID: Bun.env.WEBFLOW_COLLECTION_ID, - AUTH_TOKEN: Bun.env.WEBFLOW_AUTH, - }; - - return { - ...vars, - API_SITES_URL: `https://api.webflow.com/v2/sites/${vars.SITE_ID}`, - API_COLLECTIONS_URL: `https://api.webflow.com/v2/collections/${vars.COLLECTIONS_ID}`, - AUTH_HEADER: { "Authorization": `bearer ${vars.AUTH_TOKEN}` } - }; - }; } +const env = () => { + if (typeof Bun === "undefined") { + throw new Error("Must be in a server context. Make sure to run using --bun."); + } + + const vars = { + SITE_ID: Bun.env.WEBFLOW_SITE_ID, + COLLECTIONS_ID: Bun.env.WEBFLOW_COLLECTION_ID, + AUTH_TOKEN: Bun.env.WEBFLOW_AUTH, + }; + + return { + ...vars, + API_SITES_URL: `https://api.webflow.com/v2/sites/${vars.SITE_ID}`, + API_COLLECTIONS_URL: `https://api.webflow.com/v2/collections/${vars.COLLECTIONS_ID}`, + AUTH_HEADER: { "Authorization": `bearer ${vars.AUTH_TOKEN}` } + }; +}; + diff --git a/src/lib/test/test-calculator.ts b/src/lib/test/test-calculator.ts index d7cc806..33a7583 100644 --- a/src/lib/test/test-calculator.ts +++ b/src/lib/test/test-calculator.ts @@ -1,4 +1,5 @@ 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"; const player: Player = { @@ -61,6 +62,7 @@ const products = await Webflow.Products.getAll(); const skuId = "68d080a5d90e8263e52242e9"; +const colors = ["Black", "White", "Red"]; // const sku = product.skus[0]; // diff --git a/src/routes/commerce/+page.server.ts b/src/routes/commerce/+page.server.ts index 30940f4..5398b75 100644 --- a/src/routes/commerce/+page.server.ts +++ b/src/routes/commerce/+page.server.ts @@ -1,12 +1,12 @@ -import { Printful } from '$lib/services/commerce/printful'; -import { Webflow } from '$lib/services/commerce/webflow'; +import PrintfulService from '$lib/services/commerce/printful'; +import WebflowService from '$lib/services/commerce/webflow'; import type { PageServerLoad } from './$types'; export const load = async ({ }: Parameters[0]) => { return { products: { - printful: Printful.Products.getAll(), - webflow: Webflow.Products.getAll(), + printful: PrintfulService.Products.getAll(), + webflow: WebflowService.Products.getAll(), } }; }; diff --git a/src/routes/commerce/+page.svelte b/src/routes/commerce/+page.svelte index 66407b7..ec3b69b 100644 --- a/src/routes/commerce/+page.svelte +++ b/src/routes/commerce/+page.svelte @@ -1,6 +1,6 @@