Make products table more consistent with other tables
This commit is contained in:
+21
-9
@@ -13,7 +13,7 @@ import {
|
|||||||
import { DEFAULT_NAME, env, log } from "./util";
|
import { DEFAULT_NAME, env, log } from "./util";
|
||||||
import serverTiming from "@elysia/server-timing";
|
import serverTiming from "@elysia/server-timing";
|
||||||
import jwt from "@elysia/jwt";
|
import jwt from "@elysia/jwt";
|
||||||
import { CommerceService } from "./services/commerce";
|
import { CommerceService, WOrderStatusSchema } from "./services/commerce";
|
||||||
import cluster from "node:cluster";
|
import cluster from "node:cluster";
|
||||||
import { randomUUIDv7, sleep } from "bun";
|
import { randomUUIDv7, sleep } from "bun";
|
||||||
import { CalculatorService, CalculatorUnavailableError } from "./services/calculator";
|
import { CalculatorService, CalculatorUnavailableError } from "./services/calculator";
|
||||||
@@ -201,6 +201,25 @@ export const app = new Elysia()
|
|||||||
// COMMERCE
|
// COMMERCE
|
||||||
.group("/commerce", { auth: true }, (app) => app
|
.group("/commerce", { auth: true }, (app) => app
|
||||||
.group("/products", (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
|
.group("/sync", (app) => app
|
||||||
// Sync status
|
// Sync status
|
||||||
.get("/", async ({ sessionId }) => {
|
.get("/", async ({ sessionId }) => {
|
||||||
@@ -251,14 +270,7 @@ export const app = new Elysia()
|
|||||||
return wOrders.map((wOrder, i) => ({ wOrder, isSynced: isSyncedFlags[i] }));
|
return wOrders.map((wOrder, i) => ({ wOrder, isSynced: isSyncedFlags[i] }));
|
||||||
}, {
|
}, {
|
||||||
query: t.Object({
|
query: t.Object({
|
||||||
status: t.Optional(t.Union([
|
status: t.Optional(WOrderStatusSchema),
|
||||||
t.Literal("pending"),
|
|
||||||
t.Literal("unfulfilled"),
|
|
||||||
t.Literal("fulfilled"),
|
|
||||||
t.Literal("disputed"),
|
|
||||||
t.Literal("dispute-lost"),
|
|
||||||
t.Literal("refunded"),
|
|
||||||
])),
|
|
||||||
limit: t.Optional(t.Numeric()),
|
limit: t.Optional(t.Numeric()),
|
||||||
offset: t.Optional(t.Numeric()),
|
offset: t.Optional(t.Numeric()),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ import zipcodesUs from "zipcodes-us";
|
|||||||
import { secToMs } from "@blade-and-brawn/domain";
|
import { secToMs } from "@blade-and-brawn/domain";
|
||||||
import { EventsService } from "./events";
|
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 {
|
export class CommerceService {
|
||||||
readonly Printful = new PrintfulClient({
|
readonly Printful = new PrintfulClient({
|
||||||
token: env.PRINTFUL_AUTH,
|
token: env.PRINTFUL_AUTH,
|
||||||
|
|||||||
@@ -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
|
|
||||||
});
|
|
||||||
@@ -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"]
|
Awaited<ReturnType<typeof api.commerce.orders.get>>["data"]
|
||||||
>[number];
|
>[number];
|
||||||
|
|
||||||
const LIMIT = 25;
|
const LIMIT = 5;
|
||||||
|
|
||||||
let orders = $state<OrderRow[]>([]);
|
let orders = $state<OrderRow[]>([]);
|
||||||
let loading = $state(true);
|
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">
|
<script lang="ts">
|
||||||
import type { Printful } from "@blade-and-brawn/commerce";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { api } from "$lib/api.js";
|
import { api } from "$lib/api.js";
|
||||||
import type { EventStatus } from "@blade-and-brawn/api";
|
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";
|
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({
|
const synchronizer = $state({
|
||||||
status: "none" as SyncStatus,
|
status: "none" as SyncStatus,
|
||||||
syncingPProductIds: [] as number[],
|
syncingPProductIds: [] as number[],
|
||||||
@@ -30,8 +75,12 @@
|
|||||||
},
|
},
|
||||||
startPolling: () => {
|
startPolling: () => {
|
||||||
const intervalId = setInterval(async () => {
|
const intervalId = setInterval(async () => {
|
||||||
|
const wasInProgress = synchronizer.isInProgress;
|
||||||
await synchronizer.poll();
|
await synchronizer.poll();
|
||||||
if (!synchronizer.isInProgress) clearInterval(intervalId);
|
if (!synchronizer.isInProgress) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
if (wasInProgress) refresh();
|
||||||
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
},
|
},
|
||||||
syncAll: async () => {
|
syncAll: async () => {
|
||||||
@@ -49,23 +98,15 @@
|
|||||||
if (synchronizer.isInProgress) {
|
if (synchronizer.isInProgress) {
|
||||||
synchronizer.startPolling();
|
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>
|
</script>
|
||||||
|
|
||||||
{#await data.products.printful}
|
<div class="w-full flex items-center gap-2 flex-wrap mt-10 mb-5">
|
||||||
<p>Loading...</p>
|
|
||||||
{:then pProducts}
|
|
||||||
<button
|
<button
|
||||||
disabled={synchronizer.isInProgress}
|
disabled={synchronizer.isInProgress}
|
||||||
onclick={() => synchronizer.syncAll()}
|
onclick={() => synchronizer.syncAll()}
|
||||||
class="btn btn-xl mb-5 mt-10"
|
class="btn btn-xl"
|
||||||
>
|
>
|
||||||
{#if synchronizer.isInProgress}
|
{#if synchronizer.isInProgress}
|
||||||
Syncing...
|
Syncing...
|
||||||
@@ -73,64 +114,89 @@
|
|||||||
Sync all
|
Sync all
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<div class="w-full overflow-x-auto">
|
|
||||||
<table class="table">
|
<button class="btn btn-sm" onclick={refresh} disabled={loading}>
|
||||||
<thead>
|
{loading ? "Refreshing..." : "Refresh"}
|
||||||
<tr>
|
</button>
|
||||||
<th>Name</th>
|
|
||||||
<th>Status</th>
|
<div class="flex-1"></div>
|
||||||
<th>Sync</th>
|
|
||||||
</tr>
|
<button
|
||||||
</thead>
|
class="btn btn-sm"
|
||||||
<tbody>
|
onclick={prevPage}
|
||||||
{#each pProducts as pProduct}
|
disabled={offset === 0 || loading}
|
||||||
<tr class="hover:bg-base-300">
|
>
|
||||||
<td
|
Prev
|
||||||
><button
|
</button>
|
||||||
onclick={async () => {
|
<button
|
||||||
const res = await api.commerce
|
class="btn btn-sm"
|
||||||
.products({
|
onclick={nextPage}
|
||||||
pProductId: pProduct.id,
|
disabled={products.length < LIMIT || loading}
|
||||||
})
|
>
|
||||||
.get();
|
Next
|
||||||
console.log(res.data);
|
</button>
|
||||||
}}
|
</div>
|
||||||
class="cursor-pointer underline"
|
|
||||||
>
|
{#if loadError}
|
||||||
{pProduct.name}
|
<div role="alert" class="alert alert-error w-full mb-4">
|
||||||
</button></td
|
<span>{loadError}</span>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
{:else}
|
|
||||||
<div class="badge badge-error">
|
|
||||||
Desynced
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/await}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
disabled={synchronizer.status === "processing"}
|
|
||||||
onclick={() => synchronizer.sync(pProduct.id)}
|
|
||||||
class="btn"
|
|
||||||
>
|
|
||||||
Sync
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
{:catch error}
|
{/if}
|
||||||
<p>Something went wrong: {error.message}</p>
|
|
||||||
{/await}
|
<div class="w-full overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Sync</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each products as product (product.pProduct.id)}
|
||||||
|
<tr class="hover:bg-base-300">
|
||||||
|
<td
|
||||||
|
><button
|
||||||
|
onclick={async () => {
|
||||||
|
const res = await api.commerce
|
||||||
|
.products({
|
||||||
|
pProductId: product.pProduct.id,
|
||||||
|
})
|
||||||
|
.get();
|
||||||
|
console.log(res.data);
|
||||||
|
}}
|
||||||
|
class="cursor-pointer underline"
|
||||||
|
>
|
||||||
|
{product.pProduct.name}
|
||||||
|
</button></td
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
{#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>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
disabled={synchronizer.status === "processing"}
|
||||||
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user