Some refactoring
This commit is contained in:
+19
-65
@@ -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");
|
||||
await SyncService.sync(printfulProducts);
|
||||
return "";
|
||||
}
|
||||
catch (error) {
|
||||
throw error;
|
||||
}
|
||||
finally {
|
||||
store.job.isSyncing = false;
|
||||
store.job.printfulProductId = undefined;
|
||||
}
|
||||
}, {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import avgWeightDataRaw from "$lib/data/avg-weights.json"
|
||||
// DATA MODELS
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
export interface Player {
|
||||
export type Player = {
|
||||
metrics: Metrics;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T = any> {
|
||||
code: number;
|
||||
result: T;
|
||||
}
|
||||
|
||||
export interface MetaDataMulti<T = any> {
|
||||
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<SyncProduct[]> {
|
||||
const allSyncProducts: SyncProduct[] = [];
|
||||
export default class PrintfulService {
|
||||
static Products = class {
|
||||
static async getAll(offset: number = 0): Promise<Printful.Products.SyncProduct[]> {
|
||||
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<SyncProduct> = await res.json();
|
||||
const payload: Printful.Products.MetaDataMulti<Printful.Products.SyncProduct> = 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<Product> {
|
||||
static async get(printfulProductId: number): Promise<Printful.Products.Product> {
|
||||
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<Product> = await res.json();
|
||||
const payload: Printful.Products.MetaDataSingle<Printful.Products.Product> = await res.json();
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
export async function update(printfulProductId: number, printfulProduct: DeepPartial<Product>) {
|
||||
static async update(printfulProductId: number, printfulProduct: DeepPartial<Printful.Products.Product>) {
|
||||
const res = await fetch(`${env().API_URL}/store/products/${printfulProductId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
@@ -191,37 +64,10 @@ 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<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;
|
||||
}
|
||||
}
|
||||
|
||||
export const env = () => {
|
||||
export const env = () => {
|
||||
if (typeof Bun === "undefined") {
|
||||
throw new Error("Must be in a server context. Make sure to run using --bun.");
|
||||
}
|
||||
@@ -239,5 +85,4 @@ export namespace Printful {
|
||||
"X-PF-Store-Id": `${vars.STORE_ID}`
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<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);
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,188 @@
|
||||
export namespace Printful {
|
||||
export namespace Products {
|
||||
export interface MetaDataSingle<T = any> {
|
||||
code: number;
|
||||
result: T;
|
||||
}
|
||||
|
||||
export interface MetaDataMulti<T = any> {
|
||||
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<string, string>,
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>,
|
||||
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<Sku>[]) {
|
||||
export default class WebflowService {
|
||||
static Products = class {
|
||||
static Skus = class {
|
||||
static async create(webflowProductId: string, skus: DeepPartial<Webflow.Products.Skus.Sku>[]) {
|
||||
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<Sku>) {
|
||||
static async update(webflowProductId: string, webflowSkuId: string, webflowSku: DeepPartial<Webflow.Products.Skus.Sku>) {
|
||||
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<ProductAndSku>) {
|
||||
static async create(webflowProductAndSku: DeepPartial<Webflow.Products.ProductAndSku>) {
|
||||
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<Webflow.Products.ProductAndSkus> {
|
||||
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<ProductAndSkus[]> {
|
||||
static async getAll(): Promise<Webflow.Products.ProductAndSkus[]> {
|
||||
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<ProductAndSkus | undefined> {
|
||||
static async get(webflowProductId: string): Promise<Webflow.Products.ProductAndSkus | undefined> {
|
||||
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<ProductAndSku>) {
|
||||
static async update(webflowProductId: string, webflowProduct: DeepPartial<Webflow.Products.ProductAndSku>) {
|
||||
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,19 +129,10 @@ 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 = () => {
|
||||
const env = () => {
|
||||
if (typeof Bun === "undefined") {
|
||||
throw new Error("Must be in a server context. Make sure to run using --bun.");
|
||||
}
|
||||
@@ -325,6 +149,5 @@ export namespace Webflow {
|
||||
API_COLLECTIONS_URL: `https://api.webflow.com/v2/collections/${vars.COLLECTIONS_ID}`,
|
||||
AUTH_HEADER: { "Authorization": `bearer ${vars.AUTH_TOKEN}` }
|
||||
};
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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];
|
||||
//
|
||||
|
||||
@@ -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<PageServerLoad>[0]) => {
|
||||
return {
|
||||
products: {
|
||||
printful: Printful.Products.getAll(),
|
||||
webflow: Webflow.Products.getAll(),
|
||||
printful: PrintfulService.Products.getAll(),
|
||||
webflow: WebflowService.Products.getAll(),
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { api } from "$lib/api.js";
|
||||
import type { Printful } from "$lib/services/commerce/printful";
|
||||
import type { Printful } from "$lib/services/commerce/util/types.js";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
Reference in New Issue
Block a user