Make products table more consistent with other tables

This commit is contained in:
Dominic Ferrando
2026-07-17 15:41:04 -04:00
parent fdbfbb125d
commit 25d8f549ec
7 changed files with 171 additions and 115 deletions
+21 -9
View File
@@ -13,7 +13,7 @@ import {
import { DEFAULT_NAME, env, log } from "./util";
import serverTiming from "@elysia/server-timing";
import jwt from "@elysia/jwt";
import { CommerceService } from "./services/commerce";
import { CommerceService, WOrderStatusSchema } from "./services/commerce";
import cluster from "node:cluster";
import { randomUUIDv7, sleep } from "bun";
import { CalculatorService, CalculatorUnavailableError } from "./services/calculator";
@@ -201,6 +201,25 @@ export const app = new Elysia()
// COMMERCE
.group("/commerce", { auth: true }, (app) => app
.group("/products", (app) => app
.get("/", async ({ query }) => {
const pProducts = await s.Commerce.Printful.Products.list({
limit: query.limit,
offset: query.offset,
});
const isSyncedFlags = await Promise.all(
pProducts.map(async (pProduct) => {
const wProductId = pProduct.external_id.split("-")[0];
if (!wProductId) return false;
return (await s.Commerce.Webflow.Products.get(wProductId)) !== undefined;
})
);
return pProducts.map((pProduct, i) => ({ pProduct, isSynced: isSyncedFlags[i] }));
}, {
query: t.Object({
limit: t.Optional(t.Numeric()),
offset: t.Optional(t.Numeric()),
})
})
.group("/sync", (app) => app
// Sync status
.get("/", async ({ sessionId }) => {
@@ -251,14 +270,7 @@ export const app = new Elysia()
return wOrders.map((wOrder, i) => ({ wOrder, isSynced: isSyncedFlags[i] }));
}, {
query: t.Object({
status: t.Optional(t.Union([
t.Literal("pending"),
t.Literal("unfulfilled"),
t.Literal("fulfilled"),
t.Literal("disputed"),
t.Literal("dispute-lost"),
t.Literal("refunded"),
])),
status: t.Optional(WOrderStatusSchema),
limit: t.Optional(t.Numeric()),
offset: t.Optional(t.Numeric()),
})
+9
View File
@@ -9,6 +9,15 @@ import zipcodesUs from "zipcodes-us";
import { secToMs } from "@blade-and-brawn/domain";
import { EventsService } from "./events";
export const WOrderStatusSchema = t.Union([
t.Literal("pending"),
t.Literal("unfulfilled"),
t.Literal("fulfilled"),
t.Literal("disputed"),
t.Literal("dispute-lost"),
t.Literal("refunded"),
]);
export class CommerceService {
readonly Printful = new PrintfulClient({
token: env.PRINTFUL_AUTH,
-7
View File
@@ -1,7 +0,0 @@
import { PrintfulClient } from "@blade-and-brawn/commerce";
import { env } from "$env/dynamic/private";
export const printful = new PrintfulClient({
token: env.PRINTFUL_AUTH,
storeId: env.PRINTFUL_STORE_ID
});
-12
View File
@@ -1,12 +0,0 @@
import { WebflowClient } from "@blade-and-brawn/commerce";
import { env } from "$env/dynamic/private";
export const webflow = new WebflowClient({
siteId: env.WEBFLOW_SITE_ID,
collectionIds: {
products: env.WEBFLOW_COLLECTION_PRODUCTS_ID,
skus: env.WEBFLOW_COLLECTION_SKUS_ID,
},
token: env.WEBFLOW_AUTH,
webhookSecret: env.WEBFLOW_WEBHOOK_SECRET,
});
@@ -6,7 +6,7 @@
Awaited<ReturnType<typeof api.commerce.orders.get>>["data"]
>[number];
const LIMIT = 25;
const LIMIT = 5;
let orders = $state<OrderRow[]>([]);
let loading = $state(true);
@@ -1,12 +0,0 @@
import type { PageServerLoad } from "./$types";
import { printful } from "$lib/printful";
import { webflow } from "$lib/webflow";
export const load = async ({ }: Parameters<PageServerLoad>[0]) => {
return {
products: {
printful: printful.Products.list({ forceAll: true }),
webflow: webflow.Products.list({ forceAll: true }),
},
};
};
@@ -1,13 +1,58 @@
<script lang="ts">
import type { Printful } from "@blade-and-brawn/commerce";
import { onMount } from "svelte";
import { api } from "$lib/api.js";
import type { EventStatus } from "@blade-and-brawn/api";
const { data } = $props();
type ProductRow = NonNullable<
Awaited<ReturnType<typeof api.commerce.products.get>>["data"]
>[number];
type SyncStatus = EventStatus | "none";
const LIMIT = 25;
let products = $state<ProductRow[]>([]);
let loading = $state(true);
let loadError = $state<string | null>(null);
let offset = $state(0);
function errorMessage(err: unknown): string {
const value = (err as { value?: { error?: string } })?.value;
return (
value?.error ??
(err instanceof Error ? err.message : "Unknown error")
);
}
async function refresh() {
loading = true;
loadError = null;
try {
const res = await api.commerce.products.get({
query: {
limit: LIMIT,
offset,
},
});
if (res.error) throw res.error;
products = res.data as ProductRow[];
} catch (err) {
loadError = errorMessage(err);
} finally {
loading = false;
}
}
function prevPage() {
offset = Math.max(0, offset - LIMIT);
refresh();
}
function nextPage() {
offset += LIMIT;
refresh();
}
const synchronizer = $state({
status: "none" as SyncStatus,
syncingPProductIds: [] as number[],
@@ -30,8 +75,12 @@
},
startPolling: () => {
const intervalId = setInterval(async () => {
const wasInProgress = synchronizer.isInProgress;
await synchronizer.poll();
if (!synchronizer.isInProgress) clearInterval(intervalId);
if (!synchronizer.isInProgress) {
clearInterval(intervalId);
if (wasInProgress) refresh();
}
}, 100);
},
syncAll: async () => {
@@ -49,23 +98,15 @@
if (synchronizer.isInProgress) {
synchronizer.startPolling();
}
await refresh();
});
const isProductSynced = async (pProduct: Printful.Products.SyncProduct) => {
const wProducts = await data.products.webflow;
return wProducts.some(
(p) => p.product.id === pProduct.external_id.split("-")[0],
);
};
</script>
{#await data.products.printful}
<p>Loading...</p>
{:then pProducts}
<div class="w-full flex items-center gap-2 flex-wrap mt-10 mb-5">
<button
disabled={synchronizer.isInProgress}
onclick={() => synchronizer.syncAll()}
class="btn btn-xl mb-5 mt-10"
class="btn btn-xl"
>
{#if synchronizer.isInProgress}
Syncing...
@@ -73,6 +114,35 @@
Sync all
{/if}
</button>
<button class="btn btn-sm" onclick={refresh} disabled={loading}>
{loading ? "Refreshing..." : "Refresh"}
</button>
<div class="flex-1"></div>
<button
class="btn btn-sm"
onclick={prevPage}
disabled={offset === 0 || loading}
>
Prev
</button>
<button
class="btn btn-sm"
onclick={nextPage}
disabled={products.length < LIMIT || loading}
>
Next
</button>
</div>
{#if loadError}
<div role="alert" class="alert alert-error w-full mb-4">
<span>{loadError}</span>
</div>
{/if}
<div class="w-full overflow-x-auto">
<table class="table">
<thead>
@@ -83,54 +153,50 @@
</tr>
</thead>
<tbody>
{#each pProducts as pProduct}
{#each products as product (product.pProduct.id)}
<tr class="hover:bg-base-300">
<td
><button
onclick={async () => {
const res = await api.commerce
.products({
pProductId: pProduct.id,
pProductId: product.pProduct.id,
})
.get();
console.log(res.data);
}}
class="cursor-pointer underline"
>
{pProduct.name}
{product.pProduct.name}
</button></td
>
<td>
{#await isProductSynced(pProduct) then isSynced}
{#if synchronizer.syncingPProductIds.includes(pProduct.id)}
<div class="badge badge-warning">
Syncing...
</div>
{:else if isSynced}
<div class="badge badge-success">
Synced
</div>
{#if synchronizer.syncingPProductIds.includes(product.pProduct.id)}
<div class="badge badge-warning">Syncing...</div>
{:else if product.isSynced}
<div class="badge badge-success">Synced</div>
{:else}
<div class="badge badge-error">
Desynced
</div>
<div class="badge badge-error">Desynced</div>
{/if}
{/await}
</td>
<td>
<button
disabled={synchronizer.status === "processing"}
onclick={() => synchronizer.sync(pProduct.id)}
onclick={() =>
synchronizer.sync(product.pProduct.id)}
class="btn"
>
Sync
</button>
</td>
</tr>
{:else}
<tr>
<td colspan="3" class="text-center opacity-60">
{loading ? "Loading..." : "No products found"}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:catch error}
<p>Something went wrong: {error.message}</p>
{/await}