breakout into monorepo
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@blade-and-brawn/commerce",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./printful"
|
||||
export * from "./sync"
|
||||
export * from "./webflow"
|
||||
export * from "./webflow"
|
||||
export * from "./util/misc"
|
||||
export * from "./util/types"
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { Printful } from "./util/types";
|
||||
import { FetchError, type DeepPartial } from "./util/misc";
|
||||
|
||||
export class PrintfulService {
|
||||
static Products = class {
|
||||
static Variants = class {
|
||||
static async get(
|
||||
variantId: number | string,
|
||||
): Promise<Printful.Products.SyncVariant | undefined> {
|
||||
const res = await fetch(
|
||||
`${env().API_URL}/store/variants/${variantId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { ...env().AUTH_HEADERS },
|
||||
},
|
||||
);
|
||||
|
||||
if (res.status === 404 || res.status === 400) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FetchError("Failed to get Printful variant", res);
|
||||
}
|
||||
|
||||
const payload =
|
||||
(await res.json()) as Printful.Products.MetaDataSingle<Printful.Products.SyncVariant>;
|
||||
return payload.result;
|
||||
}
|
||||
};
|
||||
|
||||
static async getAll(
|
||||
offset: number = 0,
|
||||
): Promise<Printful.Products.SyncProduct[]> {
|
||||
const allSyncProducts: Printful.Products.SyncProduct[] = [];
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${env().API_URL}/store/products?offset=${offset}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { ...env().AUTH_HEADERS },
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FetchError(
|
||||
"Failed to get all Printful products",
|
||||
res,
|
||||
);
|
||||
}
|
||||
|
||||
const payload =
|
||||
(await res.json()) as Printful.Products.MetaDataMulti<Printful.Products.SyncProduct>;
|
||||
|
||||
allSyncProducts.push(...payload.result);
|
||||
offset = allSyncProducts.length;
|
||||
|
||||
if (allSyncProducts.length >= payload.paging.total) break;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return allSyncProducts;
|
||||
}
|
||||
|
||||
static async get(
|
||||
productId: number | string,
|
||||
): Promise<Printful.Products.Product | undefined> {
|
||||
const res = await fetch(
|
||||
`${env().API_URL}/store/products/${productId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { ...env().AUTH_HEADERS },
|
||||
},
|
||||
);
|
||||
|
||||
if (res.status === 404 || res.status === 400) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FetchError("Failed to get Printful product", res);
|
||||
}
|
||||
|
||||
const payload =
|
||||
(await res.json()) as Printful.Products.MetaDataSingle<Printful.Products.Product>;
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
static async update(
|
||||
printfulProductId: number,
|
||||
printfulProduct: DeepPartial<Printful.Products.Product>,
|
||||
) {
|
||||
const res = await fetch(
|
||||
`${env().API_URL}/store/products/${printfulProductId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...env().AUTH_HEADERS,
|
||||
},
|
||||
body: JSON.stringify(printfulProduct),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FetchError("Failed to update Printful product", res);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
static Orders = class {
|
||||
static async create(printfulOrder: Printful.Orders.Order) {
|
||||
const res = await fetch(`${env().API_URL}/orders`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...env().AUTH_HEADERS,
|
||||
},
|
||||
body: JSON.stringify(printfulOrder),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FetchError("Failed to create printful order", res);
|
||||
}
|
||||
}
|
||||
|
||||
static async getAll(
|
||||
offset: number = 0,
|
||||
): Promise<Printful.Orders.Order[]> {
|
||||
const allPrintfulOrders: Printful.Orders.Order[] = [];
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${env().API_URL}/orders?offset=${offset}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { ...env().AUTH_HEADERS },
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FetchError(
|
||||
"Failed to get all Printful orders",
|
||||
res,
|
||||
);
|
||||
}
|
||||
|
||||
const payload =
|
||||
(await res.json()) as Printful.Products.MetaDataMulti<Printful.Orders.Order>;
|
||||
|
||||
allPrintfulOrders.push(...payload.result);
|
||||
offset = allPrintfulOrders.length;
|
||||
|
||||
if (allPrintfulOrders.length >= payload.paging.total) break;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return allPrintfulOrders;
|
||||
}
|
||||
};
|
||||
|
||||
static Util = class {
|
||||
static getVariantMainImage(
|
||||
syncVariant: Printful.Products.SyncVariant,
|
||||
): string {
|
||||
const previewFile = syncVariant.files.find(
|
||||
(f) => f.type === "preview",
|
||||
);
|
||||
return previewFile?.preview_url ?? syncVariant.product.image;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const env = () => {
|
||||
if (typeof Bun === "undefined") {
|
||||
throw new Error(
|
||||
"Must be in a server context. Make sure to run using --bun.",
|
||||
);
|
||||
}
|
||||
|
||||
const vars = {
|
||||
AUTH_TOKEN: Bun.env.PRINTFUL_AUTH,
|
||||
STORE_ID: Bun.env.PRINTFUL_STORE_ID,
|
||||
} as Record<string, string>;
|
||||
|
||||
for (const varKey in vars) {
|
||||
if (!vars[varKey]) {
|
||||
console.log(`Missing ${varKey} environment variable`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...vars,
|
||||
API_URL: "https://api.printful.com",
|
||||
AUTH_HEADERS: {
|
||||
Authorization: `Bearer ${vars.AUTH_TOKEN}`,
|
||||
"X-PF-Store-Id": `${vars.STORE_ID}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,359 @@
|
||||
import type { Printful, Webflow } from "./util/types";
|
||||
import { FetchError, 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";
|
||||
|
||||
type MainProduct = {
|
||||
name: string;
|
||||
externalId: string;
|
||||
colors: Set<string>;
|
||||
variants: {
|
||||
color: string;
|
||||
product: Printful.Products.Product;
|
||||
}[];
|
||||
};
|
||||
|
||||
export class SyncService {
|
||||
static state = {
|
||||
isSyncing: false,
|
||||
syncingIds: [] as number[],
|
||||
};
|
||||
|
||||
static async sync(printfulProductId: number | undefined) {
|
||||
this.state.isSyncing = true;
|
||||
|
||||
const mainPrintfulProducts: MainProduct[] = [];
|
||||
const webflowProducts: Webflow.Products.ProductAndSkus[] = [];
|
||||
try {
|
||||
let printfulProducts = await PrintfulService.Products.getAll();
|
||||
if (printfulProductId) {
|
||||
const printfulProduct = printfulProducts.find(
|
||||
(p) => p.id === printfulProductId,
|
||||
);
|
||||
if (printfulProduct) {
|
||||
const mainProductName = this.getMainProductName(
|
||||
printfulProduct.name,
|
||||
);
|
||||
printfulProducts = printfulProducts.filter((p) =>
|
||||
p.name.includes(mainProductName),
|
||||
);
|
||||
}
|
||||
}
|
||||
webflowProducts.push(...(await WebflowService.Products.getAll()));
|
||||
|
||||
// Generate main printful products
|
||||
console.log("Generating main products...");
|
||||
for (const printfulProduct of printfulProducts) {
|
||||
const mainProductName = this.getMainProductName(
|
||||
printfulProduct.name,
|
||||
);
|
||||
|
||||
let mainPrintfulProduct = mainPrintfulProducts.find((mp) =>
|
||||
mp.name.includes(mainProductName),
|
||||
);
|
||||
if (!mainPrintfulProduct) {
|
||||
mainPrintfulProduct = {
|
||||
name: mainProductName,
|
||||
variants: [],
|
||||
externalId: printfulProduct.external_id,
|
||||
colors: new Set(),
|
||||
};
|
||||
mainPrintfulProducts.push(mainPrintfulProduct);
|
||||
}
|
||||
|
||||
const productColor = this.findColorInProductName(
|
||||
printfulProduct.name,
|
||||
);
|
||||
mainPrintfulProduct.variants.push({
|
||||
color: productColor,
|
||||
product: (await PrintfulService.Products.get(
|
||||
printfulProduct.id,
|
||||
))!,
|
||||
});
|
||||
mainPrintfulProduct.colors.add(productColor);
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
this.state.isSyncing = false;
|
||||
this.state.syncingIds = [];
|
||||
}
|
||||
|
||||
// TODO: Validate main printful products
|
||||
|
||||
// Sync the main printful products
|
||||
console.log("Syncing main products...");
|
||||
for (const mainPrintfulProduct of mainPrintfulProducts) {
|
||||
try {
|
||||
this.state.isSyncing = true;
|
||||
this.state.syncingIds = mainPrintfulProduct.variants.map(
|
||||
(v) => v.product.sync_product.id,
|
||||
);
|
||||
|
||||
const foundColors = mainPrintfulProduct.colors;
|
||||
const foundSizes: Set<string> = new Set();
|
||||
|
||||
const webflowSkus: DeepPartial<Webflow.Products.Skus.Sku>[] =
|
||||
[];
|
||||
|
||||
for (const specialVariant of mainPrintfulProduct.variants) {
|
||||
for (const printfulVariant of specialVariant.product
|
||||
.sync_variants) {
|
||||
// check if this variant size is in all other special variants
|
||||
let sizeIsInAllVariants = true;
|
||||
for (const specialVariant of mainPrintfulProduct.variants) {
|
||||
const sizes =
|
||||
specialVariant.product.sync_variants.map(
|
||||
(v) => v.size,
|
||||
);
|
||||
if (!sizes.includes(printfulVariant.size))
|
||||
sizeIsInAllVariants = false;
|
||||
}
|
||||
|
||||
if (sizeIsInAllVariants) {
|
||||
foundSizes.add(printfulVariant.size);
|
||||
|
||||
webflowSkus.push({
|
||||
id: printfulVariant.external_id,
|
||||
fieldData: {
|
||||
name: printfulVariant.name,
|
||||
slug: formatSlug(printfulVariant.name),
|
||||
"sku-values": {
|
||||
color: specialVariant.color,
|
||||
size: printfulVariant.size,
|
||||
},
|
||||
price: {
|
||||
value: Math.floor(
|
||||
+printfulVariant.retail_price * 100,
|
||||
),
|
||||
unit: printfulVariant.currency,
|
||||
currency: printfulVariant.currency,
|
||||
},
|
||||
"main-image":
|
||||
PrintfulService.Util.getVariantMainImage(
|
||||
printfulVariant,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SYNC PRODUCT DATA
|
||||
let existingWebflowProduct = webflowProducts.find(
|
||||
(webflowProduct) =>
|
||||
webflowProduct.product.id ===
|
||||
mainPrintfulProduct.externalId.split("-")[0],
|
||||
);
|
||||
if (existingWebflowProduct) {
|
||||
// SYNC PRODUCT UPDATE
|
||||
|
||||
// do not update images
|
||||
for (const webflowSku of webflowSkus) {
|
||||
delete webflowSku.fieldData?.["main-image"];
|
||||
}
|
||||
|
||||
const webflowProductId =
|
||||
mainPrintfulProduct.externalId.split("-")[0];
|
||||
if (!webflowProductId) {
|
||||
throw new Error("Malformed printful product ID");
|
||||
}
|
||||
WebflowService.Products.update(webflowProductId, {
|
||||
product: {
|
||||
fieldData: {
|
||||
name: mainPrintfulProduct.name,
|
||||
slug: formatSlug(mainPrintfulProduct.name),
|
||||
shippable: true,
|
||||
"tax-category": "standard-taxable",
|
||||
"sku-properties": [
|
||||
{
|
||||
id: "color",
|
||||
name: "Color",
|
||||
enum: Array.from(foundColors).map(
|
||||
(color) => ({
|
||||
id: color,
|
||||
slug: formatSlug(color),
|
||||
name: color,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "size",
|
||||
name: "Size",
|
||||
enum: Array.from(foundSizes).map(
|
||||
(size) => ({
|
||||
id: size,
|
||||
slug: formatSlug(size),
|
||||
name: size,
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sku: webflowSkus[0],
|
||||
});
|
||||
|
||||
for (const webflowSku of webflowSkus.slice(1)) {
|
||||
const existingWebflowSku =
|
||||
existingWebflowProduct.skus.find(
|
||||
(sku) => sku.id === webflowSku.id,
|
||||
);
|
||||
if (webflowSku.id && existingWebflowSku) {
|
||||
await WebflowService.Products.Skus.update(
|
||||
webflowProductId,
|
||||
webflowSku.id,
|
||||
webflowSku,
|
||||
);
|
||||
} else {
|
||||
await WebflowService.Products.Skus.create(
|
||||
webflowProductId,
|
||||
[webflowSku],
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// SYNC PRODUCT CREATE
|
||||
const webflowProductId =
|
||||
await WebflowService.Products.create({
|
||||
product: {
|
||||
fieldData: {
|
||||
name: mainPrintfulProduct.name,
|
||||
slug: formatSlug(mainPrintfulProduct.name),
|
||||
shippable: true,
|
||||
"tax-category": "standard-taxable",
|
||||
"sku-properties": [
|
||||
{
|
||||
id: "color",
|
||||
name: "Color",
|
||||
enum: Array.from(foundColors).map(
|
||||
(color) => ({
|
||||
id: color,
|
||||
slug: formatSlug(color),
|
||||
name: color,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "size",
|
||||
name: "Size",
|
||||
enum: Array.from(foundSizes).map(
|
||||
(size) => ({
|
||||
id: size,
|
||||
slug: formatSlug(size),
|
||||
name: size,
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sku: webflowSkus[0],
|
||||
});
|
||||
|
||||
// create webflow product SKUs
|
||||
await WebflowService.Products.Skus.create(
|
||||
webflowProductId,
|
||||
webflowSkus.slice(1),
|
||||
);
|
||||
|
||||
existingWebflowProduct =
|
||||
await WebflowService.Products.get(webflowProductId);
|
||||
if (!existingWebflowProduct) {
|
||||
console.error("Missing webflow product");
|
||||
throw new Error("Failed to create Webflow product");
|
||||
}
|
||||
|
||||
for (const specialVariant of mainPrintfulProduct.variants) {
|
||||
const newPrintfulVariants: DeepPartial<Printful.Products.SyncVariant>[] =
|
||||
[];
|
||||
for (const printfulVariant of specialVariant.product
|
||||
.sync_variants) {
|
||||
const associatedWebflowSku =
|
||||
existingWebflowProduct.skus.find(
|
||||
(sku) =>
|
||||
sku.fieldData["sku-values"]?.[
|
||||
"color"
|
||||
] === printfulVariant.color &&
|
||||
sku.fieldData["sku-values"]?.[
|
||||
"size"
|
||||
] === printfulVariant.size,
|
||||
);
|
||||
if (associatedWebflowSku) {
|
||||
newPrintfulVariants.push({
|
||||
id: printfulVariant.id, // printful variant id
|
||||
external_id: String(
|
||||
associatedWebflowSku.id,
|
||||
), // webflow variant id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(10000);
|
||||
await PrintfulService.Products.update(
|
||||
specialVariant.product.sync_product.id,
|
||||
{
|
||||
sync_product: {
|
||||
id: specialVariant.product.sync_product.id,
|
||||
external_id: `${webflowProductId}-${specialVariant.color}`,
|
||||
},
|
||||
sync_variants: newPrintfulVariants,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// SYNC IMAGES
|
||||
// for (const sku of existingWebflowProduct.skus) {
|
||||
// const skuColor = sku.fieldData["sku-values"]?.["color"]?.toLowerCase() ?? "None";
|
||||
// const skuSlug = `${existingWebflowProduct.product.fieldData.slug}-${skuColor}`;
|
||||
// const skuImageUrls = await this.getProductImageUrls(skuSlug);
|
||||
//
|
||||
// sku.fieldData["main-image"] = skuImageUrls[0] ?? sku.fieldData["main-image"];
|
||||
// sku.fieldData["more-images"] = skuImageUrls.slice(1).map(url => ({ url }));
|
||||
//
|
||||
// await WebflowService.Products.Skus.update(existingWebflowProduct.product.id, sku.id, sku);
|
||||
// }
|
||||
//
|
||||
if (err instanceof FetchError) {
|
||||
console.error(err.message, err.payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.state.isSyncing = false;
|
||||
this.state.syncingIds = [];
|
||||
console.log("Done");
|
||||
}
|
||||
|
||||
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:",
|
||||
await res.json(),
|
||||
);
|
||||
throw new Error("Failed to get product image keys");
|
||||
}
|
||||
const productImageKeys: string[] = (await res.json()) as string[];
|
||||
return productImageKeys.map((key) => `${WEBSITE_MEDIA_URL}/${key}`);
|
||||
};
|
||||
|
||||
public static findColorInProductName = (productName: string): string => {
|
||||
const m = productName.match(/\[([^\]]+)\]/);
|
||||
return m?.[1] ? m[1] : "N/A";
|
||||
};
|
||||
|
||||
public static getMainProductName = (productName: string) => {
|
||||
const colorInName = this.findColorInProductName(productName);
|
||||
if (colorInName) {
|
||||
return productName.replace(`[${colorInName}]`, "").trimEnd();
|
||||
} else {
|
||||
return productName;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export type DeepPartial<T> = T extends object
|
||||
? {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
}
|
||||
: 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 class FetchError extends Error {
|
||||
public payload: Object | undefined;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
public response: Response,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "FetchError";
|
||||
}
|
||||
|
||||
async parse() {
|
||||
try {
|
||||
this.payload = (await this.response.clone().json()) as Object;
|
||||
} catch {
|
||||
this.payload = await this.response.text().catch(() => undefined);
|
||||
} finally {
|
||||
return this.payload;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
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 Orders {
|
||||
export type Order = {
|
||||
external_id: string;
|
||||
shipping: string;
|
||||
recipient: {
|
||||
name: string;
|
||||
company?: string;
|
||||
address1: string;
|
||||
address2: string;
|
||||
city: string;
|
||||
state_code: string;
|
||||
state_name?: string;
|
||||
country_code: string;
|
||||
country_name?: string;
|
||||
zip: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
tax_number?: string;
|
||||
};
|
||||
items: Array<{ external_variant_id: string; quantity: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
export namespace Webhook {
|
||||
// meta
|
||||
interface MetaData<E extends Event, D> {
|
||||
type: E;
|
||||
created: number;
|
||||
retries: number;
|
||||
store: number;
|
||||
data: D;
|
||||
}
|
||||
|
||||
export enum Event {
|
||||
ProductUpdated = "product_updated",
|
||||
ProductDeleted = "product_deleted",
|
||||
PackageShipped = "package_shipped",
|
||||
}
|
||||
|
||||
export interface ProductUpdated extends MetaData<
|
||||
Event.ProductUpdated,
|
||||
{
|
||||
sync_product: {
|
||||
id: number;
|
||||
external_id: string;
|
||||
name: string;
|
||||
variants: number;
|
||||
synced: number;
|
||||
thumbnail_url: string;
|
||||
is_ignored: boolean;
|
||||
};
|
||||
}
|
||||
> {}
|
||||
|
||||
export interface ProductDeleted extends MetaData<
|
||||
Event.ProductDeleted,
|
||||
{
|
||||
sync_product: {
|
||||
id: number;
|
||||
external_id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
> {}
|
||||
|
||||
export interface PackageShipped extends MetaData<
|
||||
Event.PackageShipped,
|
||||
{
|
||||
shipment: {
|
||||
id: number;
|
||||
carrier: string;
|
||||
service: string;
|
||||
tracking_number: string;
|
||||
tracking_url: string;
|
||||
created: number;
|
||||
ship_date: string;
|
||||
shipped_at: number;
|
||||
reshipment: boolean;
|
||||
};
|
||||
order: {
|
||||
id: number;
|
||||
external_id: string;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
> {}
|
||||
|
||||
export type EventPayload =
|
||||
| ProductUpdated
|
||||
| ProductDeleted
|
||||
| PackageShipped;
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Orders {
|
||||
export type Order = {
|
||||
orderId: string;
|
||||
status:
|
||||
| "pending"
|
||||
| "unfulfilled"
|
||||
| "fulfilled"
|
||||
| "disputed"
|
||||
| "dispute-lost"
|
||||
| "refunded";
|
||||
comment: string;
|
||||
orderComment: string;
|
||||
acceptedOn: string;
|
||||
fulfilledOn: string | null;
|
||||
refundedOn: string | null;
|
||||
disputedOn: string | null;
|
||||
disputeUpdatedOn: string | null;
|
||||
disputeLastStatus: string | null;
|
||||
customerPaid: {
|
||||
unit: string;
|
||||
value: string;
|
||||
string: string;
|
||||
};
|
||||
netAmount: {
|
||||
unit: string;
|
||||
value: string;
|
||||
string: string;
|
||||
};
|
||||
applicationFee: {
|
||||
unit: string;
|
||||
value: string;
|
||||
string: string;
|
||||
};
|
||||
allAddresses: Array<{
|
||||
type: string;
|
||||
addressee: string;
|
||||
line1: string;
|
||||
line2: string;
|
||||
city: string;
|
||||
state: string;
|
||||
country: string;
|
||||
postalCode: string;
|
||||
}>;
|
||||
shippingAddress: {
|
||||
type: string;
|
||||
japanType: string;
|
||||
addressee: string;
|
||||
line1: string;
|
||||
line2: string;
|
||||
city: string;
|
||||
state: string;
|
||||
country: string;
|
||||
postalCode: string;
|
||||
};
|
||||
billingAddress: {
|
||||
type: string;
|
||||
addressee: string;
|
||||
line1: string;
|
||||
line2: string;
|
||||
city: string;
|
||||
state: string;
|
||||
country: string;
|
||||
postalCode: string;
|
||||
};
|
||||
shippingProvider: string;
|
||||
shippingTracking: string;
|
||||
shippingTrackingURL: string;
|
||||
customerInfo: {
|
||||
fullName: string;
|
||||
email: string;
|
||||
};
|
||||
purchasedItems: Array<{
|
||||
count: number;
|
||||
rowTotal: {
|
||||
unit: string;
|
||||
value: string;
|
||||
string: string;
|
||||
};
|
||||
productId: string;
|
||||
productName: string;
|
||||
productSlug: string;
|
||||
variantId: string;
|
||||
variantName: string;
|
||||
variantSlug: string;
|
||||
variantSKU: string;
|
||||
variantImage: {
|
||||
url: string;
|
||||
file: {
|
||||
size: number;
|
||||
originalFileName: string;
|
||||
createdOn: string;
|
||||
contentType: string;
|
||||
width: number;
|
||||
height: number;
|
||||
variants: {
|
||||
size: number;
|
||||
url: string;
|
||||
originalFileName: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
variantPrice: {
|
||||
unit: string;
|
||||
value: string;
|
||||
string: string;
|
||||
};
|
||||
weight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
length: number;
|
||||
}>;
|
||||
purchasedItemsCount: number;
|
||||
stripeDetails: {
|
||||
subscriptionId: string | null;
|
||||
paymentMethod: string;
|
||||
paymentIntentId: string;
|
||||
customerId: string;
|
||||
chargeId: string;
|
||||
disputeId: string | null;
|
||||
refundId: string;
|
||||
refundReason: string;
|
||||
};
|
||||
stripeCard: {
|
||||
last4: string;
|
||||
brand: string;
|
||||
ownerName: string;
|
||||
expires: {
|
||||
year: number;
|
||||
month: number;
|
||||
};
|
||||
};
|
||||
paypalDetails: Object;
|
||||
customData: Array<Object>;
|
||||
metadata: {
|
||||
isBuyNow: boolean;
|
||||
hasDownloads: boolean;
|
||||
paymentProcessor: string;
|
||||
};
|
||||
isCustomerDeleted: boolean;
|
||||
isShippingRequired: boolean;
|
||||
totals: {
|
||||
subtotal: {
|
||||
unit: string;
|
||||
value: string;
|
||||
string: string;
|
||||
};
|
||||
extras: Array<{
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: {
|
||||
unit: string;
|
||||
value: string;
|
||||
string: string;
|
||||
};
|
||||
}>;
|
||||
total: {
|
||||
unit: string;
|
||||
value: string;
|
||||
string: string;
|
||||
};
|
||||
};
|
||||
downloadFiles: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export namespace Webhook {
|
||||
interface MetaData<E extends Event, D> {
|
||||
triggerType: E;
|
||||
payload: D;
|
||||
}
|
||||
|
||||
export enum Event {
|
||||
OrderCreated = "ecomm_new_order",
|
||||
OrderUpdated = "ecomm_order_changed",
|
||||
}
|
||||
|
||||
export interface OrderCreated extends MetaData<
|
||||
Event.OrderCreated,
|
||||
Orders.Order
|
||||
> {}
|
||||
export interface OrderUpdated extends MetaData<
|
||||
Event.OrderUpdated,
|
||||
Orders.Order
|
||||
> {}
|
||||
|
||||
export type EventPayload = OrderCreated | OrderUpdated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import type { Webflow } from "./util/types";
|
||||
import { FetchError, type DeepPartial } from "./util/misc";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export class WebflowService {
|
||||
static Products = class {
|
||||
static Skus = class {
|
||||
static async create(
|
||||
webflowProductId: string,
|
||||
skus: DeepPartial<Webflow.Products.Skus.Sku>[],
|
||||
): Promise<string[]> {
|
||||
const res = await fetch(
|
||||
`${env().API_SITES_URL}/products/${webflowProductId}/skus`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...env().AUTH_HEADER,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
skus: skus,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FetchError(
|
||||
"Failed to create Webflow product SKU",
|
||||
res,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = (await res.json()) as {
|
||||
skus: { id: string }[];
|
||||
};
|
||||
return payload.skus.map((sku) => sku.id);
|
||||
}
|
||||
|
||||
static async update(
|
||||
webflowProductId: string,
|
||||
webflowSkuId: string,
|
||||
webflowSku: DeepPartial<Webflow.Products.Skus.Sku>,
|
||||
): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${env().API_SITES_URL}/products/${webflowProductId}/skus/${webflowSkuId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...env().AUTH_HEADER,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sku: webflowSku,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new FetchError(
|
||||
"Failed to update Webflow product SKU",
|
||||
res,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
static async create(
|
||||
webflowProductAndSku: DeepPartial<Webflow.Products.ProductAndSku>,
|
||||
): Promise<string> {
|
||||
const res = await fetch(`${env().API_SITES_URL}/products`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...env().AUTH_HEADER,
|
||||
},
|
||||
body: JSON.stringify(webflowProductAndSku),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FetchError("Failed to create Webflow product", res);
|
||||
}
|
||||
|
||||
const createdWebflowProduct = (await res.json()) as {
|
||||
product: { id: string };
|
||||
};
|
||||
return createdWebflowProduct.product.id;
|
||||
}
|
||||
|
||||
static async getAll(): Promise<Webflow.Products.ProductAndSkus[]> {
|
||||
const res = await fetch(`${env().API_SITES_URL}/products`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
...env().AUTH_HEADER,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FetchError("Failed to get all Webflow products", res);
|
||||
}
|
||||
|
||||
const payload = (await res.json()) as {
|
||||
items: Webflow.Products.ProductAndSkus[];
|
||||
};
|
||||
return payload.items;
|
||||
}
|
||||
|
||||
static async get(
|
||||
webflowProductId: string,
|
||||
): Promise<Webflow.Products.ProductAndSkus | undefined> {
|
||||
const res = await fetch(
|
||||
`${env().API_SITES_URL}/products/${webflowProductId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
...env().AUTH_HEADER,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (res.status === 404 || res.status === 400) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FetchError("Failed to get Webflow product", res);
|
||||
}
|
||||
|
||||
const payload =
|
||||
(await res.json()) as Webflow.Products.ProductAndSkus;
|
||||
return payload;
|
||||
}
|
||||
|
||||
static async update(
|
||||
webflowProductId: string,
|
||||
webflowProduct: DeepPartial<Webflow.Products.ProductAndSku>,
|
||||
): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${env().API_SITES_URL}/products/${webflowProductId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...env().AUTH_HEADER,
|
||||
},
|
||||
body: JSON.stringify(webflowProduct),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FetchError("Failed to update Webflow product", res);
|
||||
}
|
||||
}
|
||||
|
||||
static async remove(webflowProductId: string): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${env().API_COLLECTIONS_URL}/items/${webflowProductId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...env().AUTH_HEADER,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FetchError("Failed to remove Webflow product", res);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
static Orders = class {
|
||||
static async getAll(
|
||||
opt: { status?: Webflow.Orders.Order["status"] } = {},
|
||||
): Promise<Webflow.Orders.Order[]> {
|
||||
// TODO: pagination
|
||||
const params = new URLSearchParams();
|
||||
if (opt.status !== undefined) {
|
||||
params.set("status", opt.status);
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${env().API_SITES_URL}/orders?${params.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { ...env().AUTH_HEADER },
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FetchError("Failed to get all Webflow orders", res);
|
||||
}
|
||||
|
||||
const payload = (await res.json()) as {
|
||||
orders: Webflow.Orders.Order[];
|
||||
};
|
||||
return payload.orders;
|
||||
}
|
||||
|
||||
static async update(
|
||||
webflowOrderId: string,
|
||||
webflowOrderUpdate: {
|
||||
comment?: string;
|
||||
shippingProvider?: string;
|
||||
shippingTracking?: string;
|
||||
shippingTrackingURL: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${env().API_SITES_URL}/orders/${webflowOrderId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...env().AUTH_HEADER,
|
||||
},
|
||||
body: JSON.stringify(webflowOrderUpdate),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FetchError("Failed to update Webflow order", res);
|
||||
}
|
||||
}
|
||||
|
||||
static async fulfill(
|
||||
webflowOrderId: string,
|
||||
opt: { sendOrderFulfilledEmail?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${env().API_SITES_URL}/orders/${webflowOrderId}/fulfill`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...env().AUTH_HEADER,
|
||||
},
|
||||
body: JSON.stringify(opt),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FetchError("Failed to fulfill Webflow order", res);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
static Util = class {
|
||||
static verifyWebflowSignature(
|
||||
request: Request,
|
||||
body: unknown,
|
||||
): Boolean {
|
||||
try {
|
||||
const timestamp = request.headers.get("x-webflow-timestamp");
|
||||
if (!timestamp) {
|
||||
throw new Error("No timestamp provided");
|
||||
}
|
||||
const providedSignature = request.headers.get(
|
||||
"x-webflow-signature",
|
||||
);
|
||||
if (!providedSignature) {
|
||||
throw new Error("No signature provided");
|
||||
}
|
||||
|
||||
const secret = env().WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("No secret provided");
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
throw new Error("Body is empty");
|
||||
}
|
||||
|
||||
const requestTimestamp = parseInt(timestamp, 10);
|
||||
const data = `${requestTimestamp}:${JSON.stringify(body)}`;
|
||||
const hash = crypto
|
||||
.createHmac("sha256", secret)
|
||||
.update(data)
|
||||
.digest("hex");
|
||||
|
||||
if (
|
||||
!crypto.timingSafeEqual(
|
||||
Buffer.from(hash, "hex"),
|
||||
Buffer.from(providedSignature, "hex"),
|
||||
)
|
||||
) {
|
||||
throw new Error("Invalid signature");
|
||||
}
|
||||
|
||||
const currentTime = Date.now();
|
||||
|
||||
if (currentTime - requestTimestamp > 300000) {
|
||||
throw new Error("Request is older than 5 minutes");
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Error verifying signature: ${err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
WEBHOOK_SECRET: Bun.env.WEBFLOW_WEBHOOK_SECRET,
|
||||
} as Record<string, string>;
|
||||
|
||||
for (const varKey in vars) {
|
||||
if (!vars[varKey]) {
|
||||
console.log(`Missing ${varKey} environment variable`);
|
||||
}
|
||||
}
|
||||
|
||||
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}` },
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
}
|
||||
Reference in New Issue
Block a user