diff --git a/src/lib/api.ts b/src/lib/api.ts index d82bde3..c468af0 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -7,6 +7,8 @@ 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)); + export const app = new Elysia({ prefix: "/api" }) .use(cors({ origin: '*' @@ -25,58 +27,78 @@ export const app = new Elysia({ prefix: "/api" }) const { player, activityPerformances } = body as CalcRequest; const levelCalculator = new LevelCalculator(MAIN_STANDARDS); return { levels: levelCalculator.calculate(player, activityPerformances) }; + }, { + error({ error }) { + console.error(error); + return { + message: "Failed to calculate", + error + } + } }) // COMMERCE .post("/products/sync", async ({ body, set }) => { console.log("Syncing..."); - try { - const printfulProducts = await Printful.Products.getAll(); - const webflowProducts = await Webflow.Products.getAll(); - for (const p of printfulProducts) { - const exists = webflowProducts.some(wp => wp.id === p.external_id); + const printfulProducts = await Printful.Products.getAll(); + const webflowProducts = await Webflow.Products.getAll(); + for (const printfulProduct of printfulProducts) { + await sleep(2000); + console.log(printfulProduct.name, printfulProduct.id, printfulProduct.external_id); + const fullPrintfulProduct = await Printful.Products.get(printfulProduct.id); - const fullPrintfulProduct = await Printful.Products.get(p.id); - if (exists) - await Webflow.Products.updateUsingPrintful(fullPrintfulProduct); - else - await Webflow.Products.createUsingPrintful(fullPrintfulProduct); - } - } - catch (error) { - console.error(error); - set.status = 500; - return { error: "internal_error" }; + 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 ""; + }, { + error({ error }) { + console.error(error); + return { + message: "Failed to sync products", + error + } + } }) // WEBHOOKS .post("/webhook/printful", async ({ body, set }) => { const payload = body as Printful.Webhook.EventPayload; - try { - switch (payload.type) { - case Printful.Webhook.Event.ProductUpdated: { - const prod = await Printful.Products.get(payload.data.sync_product.id); - const exists = await Webflow.Products.exists(prod.sync_product.external_id); - if (exists) await Webflow.Products.updateUsingPrintful(prod); - else await Webflow.Products.createUsingPrintful(prod); - break; - } - case Printful.Webhook.Event.ProductDeleted: { - await Webflow.Products.remove(payload.data.sync_product.external_id); - break; - } + switch (payload.type) { + case Printful.Webhook.Event.ProductUpdated: { + const printfulProduct = await Printful.Products.get(payload.data.sync_product.id); + + 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); + + break; + } + case Printful.Webhook.Event.ProductDeleted: { + await Webflow.Products.remove(payload.data.sync_product.external_id); + break; } - } catch (error) { - console.error(error); - set.status = 500; - return { error: "internal_error" }; } return ""; + }, { + error({ error }) { + console.error(error); + return { + message: "Failed to execute webhook", + error + } + } }); export type App = typeof app; diff --git a/src/lib/services/calculator/util.ts b/src/lib/services/calculator/util.ts index c708f47..ee6e355 100644 --- a/src/lib/services/calculator/util.ts +++ b/src/lib/services/calculator/util.ts @@ -102,6 +102,7 @@ export const range = (length: number) => Array.from({ length: length }, (_, i) = // return interpolatedAvg; // } + export const getAvgWeight = (gender: Gender, age: number) => { type AvgWeightData = { metadata: { diff --git a/src/lib/services/commerce/printful.ts b/src/lib/services/commerce/printful.ts index eb2bc0c..cc58cbe 100644 --- a/src/lib/services/commerce/printful.ts +++ b/src/lib/services/commerce/printful.ts @@ -1,5 +1,15 @@ +import { formatSlug, type DeepPartial } from "./util"; +import type { Webflow } from "./webflow"; + export namespace Printful { export const API_URL = "https://api.printful.com"; + const AUTH_TOKEN = typeof Bun === "undefined" ? "" : Bun.env.PRINTFUL_AUTH; + const STORE_ID = typeof Bun === "undefined" ? "" : Bun.env.PRINTFUL_STORE_ID; + + const AUTH_HEADERS = { + "Authorization": `Bearer ${AUTH_TOKEN}`, + "X-PF-Store-Id": `${STORE_ID}` + }; export namespace Webhook { // meta @@ -61,12 +71,12 @@ export namespace Printful { } } - interface Option { + type Option = { id: string; value: string; } - interface File { + type File = { type: string; id: number; url: string; @@ -87,72 +97,63 @@ export namespace Printful { stitch_count_tier: string; } - export interface SyncProduct { - sync_product: { - id: number; - external_id: string; - name: string; - variants: number; - synced: number; - thumbnail_url: string; - is_ignored: boolean; - }, - sync_variants: { - id: number; - external_id: string; - sync_product_id: number; - name: string; - synced: boolean; + 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; - 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; - }[] + 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 get(syncProductId: number): Promise { - const payload: MetaDataSingle = await ( - await fetch(`${Printful.API_URL}/store/products/${syncProductId}`, { - method: "GET", - headers: { - "Authorization": `Bearer ${Bun.env.PRINTFUL_AUTH}`, - "X-PF-Store-Id": `${Bun.env.PRINTFUL_STORE_ID}` - } - }) - ).json(); - - return payload.result; - } - - export async function getAll(offset: number = 0): Promise { - const allSyncProducts: SyncProduct["sync_product"][] = []; + export async function getAll(offset: number = 0): Promise { + const allSyncProducts: SyncProduct[] = []; while (true) { try { - const payload: MetaDataMulti = await ( - await fetch(`${Printful.API_URL}/store/products?offset=${offset}`, { - method: "GET", - headers: { - "Authorization": `Bearer ${Bun.env.PRINTFUL_AUTH}`, - "X-PF-Store-Id": `${Bun.env.PRINTFUL_STORE_ID}` - } - }) - ).json(); + const res = await fetch(`${Printful.API_URL}/store/products?offset=${offset}`, { + method: "GET", + headers: { ...AUTH_HEADERS } + }) + + if (!res.ok) { + console.log(res.statusText); + } + + const payload: MetaDataMulti = await res.json(); allSyncProducts.push(...payload.result); offset = allSyncProducts.length; @@ -160,17 +161,71 @@ export namespace Printful { if (allSyncProducts.length >= payload.paging.total) break; } catch (error) { - break; + console.error("Printful product get all failed:", error); + throw new Error("Failed to get all Printful products",); } } return allSyncProducts; } + export async function get(printfulProductId: number): Promise { + const res = await fetch(`${Printful.API_URL}/store/products/${printfulProductId}`, { + method: "GET", + headers: { ...AUTH_HEADERS } + }); - export function getVariantPreviewUrl(syncVariant: Printful.Products.SyncProduct["sync_variants"][number]): string { - const previewFile = syncVariant.files.find(f => f.type === "preview"); - return previewFile?.preview_url ?? syncVariant.product.image; + if (!res.ok) { + console.error("Printful product update failed:", res.statusText); + throw new Error("Failed to update Printful product"); + } + + const payload: MetaDataSingle = await res.json(); + return payload.result; + } + + export async function update(printfulProductId: number, printfulProduct: DeepPartial) { + const res = await fetch(`${Printful.API_URL}/store/products/${printfulProductId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...AUTH_HEADERS + }, + body: JSON.stringify(printfulProduct) + }); + + if (!res.ok) { + console.error("Printful product update failed:", res.statusText); + 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; } } } diff --git a/src/lib/services/commerce/util.ts b/src/lib/services/commerce/util.ts new file mode 100644 index 0000000..6de3a4f --- /dev/null +++ b/src/lib/services/commerce/util.ts @@ -0,0 +1,26 @@ +export type DeepPartial = T extends object ? { + [P in keyof T]?: DeepPartial; +} : T; + +export const formatSlug = (name: string): string => { + return name + .toLowerCase() + .replace(/[^a-z0-9-_]/g, '-') // replace illegal chars with dashes + .replace(/^-+/, '') // remove leading dashes + .replace(/-+$/, '') // remove trailing dashes + .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/webflow.ts b/src/lib/services/commerce/webflow.ts index 6e0f548..dc0fbff 100644 --- a/src/lib/services/commerce/webflow.ts +++ b/src/lib/services/commerce/webflow.ts @@ -1,301 +1,322 @@ import { Printful } from "$lib/services/commerce/printful" +import { formatSlug, getProductImageUrls, type DeepPartial } from "./util"; export namespace Webflow { - export const API_SITES_URL = `https://api.webflow.com/v2/sites/${Bun.env.WEBFLOW_SITE_ID}`; - export const API_COLLECTIONS_URL = `https://api.webflow.com/v2/collections/${Bun.env.WEBFLOW_COLLECTION_ID}`; + const SITE_ID = typeof Bun === "undefined" ? "" : Bun.env.WEBFLOW_SITE_ID; + const COLLECTIONS_ID = typeof Bun === "undefined" ? "" : Bun.env.WEBFLOW_COLLECTION_ID; + const AUTH_TOKEN = typeof Bun === "undefined" ? "" : Bun.env.WEBFLOW_AUTH; - const authHeader = { "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}` }; + export const API_SITES_URL = `https://api.webflow.com/v2/sites/${SITE_ID}`; + export const API_COLLECTIONS_URL = `https://api.webflow.com/v2/collections/${COLLECTIONS_ID}`; - const formatSlug = (name: string): string => { - return name - .toLowerCase() - .replace(/[^a-z0-9-_]/g, '-') // replace illegal chars with dashes - .replace(/^-+/, '') // remove leading dashes - .replace(/-+$/, '') // remove trailing dashes - .replace(/--+/g, '-') // collapse multiple dashes - .replace(/^([^a-z0-9_])/, '_$1'); // if it starts with an invalid char, prefix underscore - }; + const AUTH_HEADER = { "Authorization": `bearer ${AUTH_TOKEN}` }; export namespace Products { - export interface Product { - id: string, - fieldData: { - name: string, - slug: string, - description: string - }, - skus: Sku[] - } - - export interface Sku { - id?: string, - fieldData: { - name: string; - slug: string; - "sku-values"?: Record, - price: { - value: number; - unit: string; - currency: string; - }; - "main-image"?: string; - } - } - - export interface UpsertProduct { - product: { + export namespace Skus { + export type Sku = { + 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; - }[]; - }[]; + "sku-values"?: Record, + price: { + value: number; + unit: string; + currency: string; + }; + "main-image"?: string; + "more-images"?: + { + fileId?: string, + url: string, + alt?: string, + }[] } - }; - sku: Sku; - publishStatus?: "staging" | "live"; - } - - export async function getAll(): Promise { - let res = await fetch(`${Webflow.API_SITES_URL}/products`, { - method: "GET", - headers: { - ...authHeader, - } - }); - const resObj = await res.json(); - return resObj.items as Product[]; - } - - export async function remove(webflowProductId: string) { - await fetch(`${Webflow.API_COLLECTIONS_URL}/items/${webflowProductId}`, { - method: "DELETE", - headers: { - "Content-Type": "application/json", - "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}` - }, - body: JSON.stringify({}) - }); - } - - export async function exists(webflowProductId: string): Promise { - let findProductResponse = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}`, { - method: "GET", - headers: { - "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`, - } - }); - return findProductResponse.status === 200; - } - - export async function updateUsingPrintful(printfulProduct: Printful.Products.SyncProduct) { - const webflowProductId = printfulProduct.sync_product.external_id; - - const printfulVariants = printfulProduct.sync_variants; - const webflowVariants: Products.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": Printful.Products.getVariantPreviewUrl(printfulVariant) - } - }); } - const foundColors = Array.from(new Set(printfulVariants.map(v => v.color))); - const foundSizes = Array.from(new Set(printfulVariants.map(v => v.size))); - - let updateProductResponse = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`, - }, - body: JSON.stringify({ - "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 - })) - }, - ] - } + export async function create(webflowProductId: string, skus: DeepPartial[]) { + const res = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}/skus`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...AUTH_HEADER }, - "sku": webflowVariants[0], - } as Products.UpsertProduct) - }); - updateProductResponse = await updateProductResponse.json(); + body: JSON.stringify({ + "skus": skus + }) + }); - const getProductResponse = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}`, { - method: "GET", - headers: { - "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}` + if (!res.ok) { + console.error("Webflow product SKU create failed:", res.statusText); + throw new Error("Failed to create Webflow product SKU"); } - }); - const webflowProduct = await getProductResponse.json(); - - for (let i = 0; i < webflowProduct["skus"].length; ++i) { - const webflowSkuId = printfulVariants[i].external_id; + } + export async function update(webflowProductId: string, webflowSkuId: string, webflowSku: DeepPartial) { const res = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}/skus/${webflowSkuId}`, { method: "PATCH", headers: { "Content-Type": "application/json", - "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}` + ...AUTH_HEADER }, body: JSON.stringify({ - "sku": webflowVariants[i] + "sku": webflowSku }) }); + if (!res.ok) { + console.error("Webflow product SKU update failed:", await res.json()); + throw new Error("Failed to update Webflow product SKU"); + } } } - export async function createUsingPrintful(printfulProduct: Printful.Products.SyncProduct) { - const printfulVariants = printfulProduct.sync_variants; - - const webflowVariants: Products.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": Printful.Products.getVariantPreviewUrl(printfulVariant) - } - }); + 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) { + const res = await fetch(`${Webflow.API_SITES_URL}/products`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...AUTH_HEADER + }, + body: JSON.stringify(webflowProductAndSku) + }); + + if (!res.ok) { + console.error("Webflow product create failed:", res.statusText); + console.log(await res.json()); + throw new Error("Failed to create Webflow product"); + } + + const createdWebflowProduct = await res.json(); + 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))); - // CREATE WEBFLOW PRODUCT - let addProductResponse = await (await fetch(`${Webflow.API_SITES_URL}/products`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`, + 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 + })) + }, + ] + } }, - body: JSON.stringify({ - "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], - } as Products.UpsertProduct) - }))?.json(); + "sku": webflowVariants[0], + }); - if (!addProductResponse?.product?.id) { - console.error("Webflow product creation failed:", addProductResponse); + // 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 webflowProductId = addProductResponse["product"]["id"]; 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 + })) + }); - // CREATE WEBFLOW PRODUCT SKUs - let createSkuResponse = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}/skus`, { - method: "POST", + return webflowProduct; + } + + export async function getAll(): Promise { + const res = await fetch(`${Webflow.API_SITES_URL}/products`, { + method: "GET", + headers: { + ...AUTH_HEADER, + } + }); + + if (!res.ok) { + console.error("Webflow product get all failed:", res.statusText); + throw new Error("Failed to get all Webflow products"); + } + + const payload = await res.json(); + return payload.items as ProductAndSkus[]; + } + + export async function get(webflowProductId: string): Promise { + const res = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}`, { + method: "GET", + headers: { + ...AUTH_HEADER, + } + }); + + if (res.status === 404 || res.status === 400) { + return; + } + + if (!res.ok) { + console.error("Webflow product get failed:", res.statusText); + throw new Error("Failed to get Webflow product"); + } + + const payload = await res.json(); + return payload as ProductAndSkus; + } + + export async function update(webflowProductId: string, webflowProduct: DeepPartial) { + const res = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}`, { + method: "PATCH", headers: { "Content-Type": "application/json", - "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}` + ...AUTH_HEADER }, - body: JSON.stringify({ - "skus": webflowVariants.slice(1) - }) + body: JSON.stringify(webflowProduct) }); - const createSkuObj = await createSkuResponse.json(); - const responseSkus = [addProductResponse["sku"], ...createSkuObj["skus"]]; + if (!res.ok) { + console.error("Webflow product update failed:", res.statusText); + throw new Error("Failed to update Webflow product"); + } + } - // SYNC WEBFLOW/PRINTFUL PRODUCT AND VARIANT IDs - let modifyPrintfulProductResponse = await fetch(`${Printful.API_URL}/store/products/${printfulProductId}`, { - method: "PUT", + 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) { + const res = await fetch(`${Webflow.API_COLLECTIONS_URL}/items/${webflowProductId}`, { + method: "DELETE", headers: { "Content-Type": "application/json", - "Authorization": `Bearer ${Bun.env.PRINTFUL_AUTH}`, - "X-PF-Store-Id": Bun.env.PRINTFUL_STORE_ID ?? "" + ...AUTH_HEADER }, - body: JSON.stringify({ - "sync_product": { - "external_id": String(webflowProductId) - }, - "sync_variants": responseSkus.map((sku, i) => ({ - "id": Number(printfulVariants[i].id), // printful variant id - "external_id": String(sku.id), // webflow variant id - })) - }) + body: JSON.stringify({}) }); - modifyPrintfulProductResponse = await modifyPrintfulProductResponse.json(); + + if (!res.ok) { + console.error("Webflow product remove failed:", res.statusText); + throw new Error("Failed to remove Webflow product"); + } + } + + export async function syncImages(webflowProductAndSkus: ProductAndSkus) { + const productSlug = webflowProductAndSkus.product.fieldData.slug; + console.log(productSlug); + const productImageUrls = await getProductImageUrls(productSlug); + console.log(productImageUrls); + 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 })); + console.log(sku.fieldData["more-images"]); + await Webflow.Products.Skus.update(webflowProductAndSkus.product.id, sku.id, sku); + } } } } diff --git a/src/lib/test/test-calculator.ts b/src/lib/test/test-calculator.ts index eb44531..d7cc806 100644 --- a/src/lib/test/test-calculator.ts +++ b/src/lib/test/test-calculator.ts @@ -1,6 +1,5 @@ -import { Standards, type ActivityStandards } from "$lib/services/calculator/main"; import { Activity, ftToCm, Gender, getAvgWeight, inToCm, kgToLb, lbToKg, minToMs, secToMs, type ActivityPerformance, type Player } from "$lib/services/calculator/util"; -import allStandardsRaw from "$lib/data/standards.json" assert { type: "json" }; +import { Webflow } from "$lib/services/commerce/webflow"; const player: Player = { metrics: { @@ -41,7 +40,55 @@ const computedPerformances: ActivityPerformance[] = [ }, ]; -const standards = new Standards(allStandardsRaw as ActivityStandards, { maxLevel: 10 }); +// const res = await fetch(`${Webflow.API_SITES_URL}/products/${}/skus/${"68d0154f97e90776a5680740"}`, { +// method: "PATCH", +// headers: { +// "Content-Type": "application/json", +// "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}` +// }, +// body: JSON.stringify({ +// "sku": webflowVariants[i] +// }) +// }); + +const productId = "68d080a129f538cd565bf56d"; + + +const product = await Webflow.Products.get("68d080a129f538cd565bf56d"); +const products = await Webflow.Products.getAll(); + + + +const skuId = "68d080a5d90e8263e52242e9"; + + +// const sku = product.skus[0]; +// +// sku.fieldData["more-images"] = [ +// { +// "url": "https://website-media.bladeandbrawn.com/product-images/henry-iv-flag-black-10.png", +// }, +// { +// "url": "https://website-media.bladeandbrawn.com/product-images/henry-iv-flag-black-20.png", +// }, +// ]; + +// console.log("Fetching..."); +// const res = await fetch(`${Webflow.API_SITES_URL}/products/${productId}/skus/${skuId}`, { +// method: "PATCH", +// headers: { +// "Content-Type": "application/json", +// "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}` +// }, +// body: JSON.stringify({ +// "sku": sku +// }) +// }); +// console.log(res); +// +// const productAfter = (await Webflow.Products.getAll())[0]; +// +// console.log(productAfter.skus[0].fieldData["more-images"]); // const newStandards: Standards = { // "Back Squat": [], diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 442be05..c0dc4af 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -19,9 +19,9 @@
{@render children?.()} - + + +