From 5916ce1ff57a7a76d944234fc1b0961b1ca2a340 Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Tue, 8 Sep 2026 19:56:44 -0400 Subject: [PATCH] feat: add public product detail web flow --- apps/web/src/assets/styles/global.scss | 5 +++ apps/web/src/pages/ProductDetailPage.vue | 40 ++++++++++++++++++++++++ apps/web/src/router/index.js | 2 ++ apps/web/src/services/catalog-service.js | 11 +++++++ apps/web/tests/catalog-service.test.js | 15 +++++++++ docs/IMPLEMENTATION-STATUS.md | 1 + 6 files changed, 74 insertions(+) create mode 100644 apps/web/src/pages/ProductDetailPage.vue diff --git a/apps/web/src/assets/styles/global.scss b/apps/web/src/assets/styles/global.scss index 86ef744..1c9146c 100644 --- a/apps/web/src/assets/styles/global.scss +++ b/apps/web/src/assets/styles/global.scss @@ -33,6 +33,10 @@ h2 { margin: 0; font-size: 1.8rem; font-weight: 500; } .detail-content h1 { font-size: clamp(3rem, 8vw, 6rem); } .detail-price { font: 700 1.25rem Arial, sans-serif; } .detail-notes { max-width: 520px; color: #61625a; font: 1.1rem/1.5 Arial, sans-serif; } +.product-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 32px; max-width: 560px; margin-top: 40px; border-top: 1px solid #c9c8bd; padding-top: 22px; } +.product-facts div { display: grid; gap: 6px; } +.product-facts dt { color: #77786f; font: 700 0.72rem Arial, sans-serif; letter-spacing: 0.1em; text-transform: uppercase; } +.product-facts dd { margin: 0; font: 1rem Arial, sans-serif; } .catalog-state { padding: 48px 0; color: #61625a; font: 1rem/1.5 Arial, sans-serif; } .catalog-state--error { color: #a3482a; } .pagination { display: flex; align-items: center; justify-content: center; gap: 18px; margin-top: 28px; color: #61625a; font: 0.85rem Arial, sans-serif; } @@ -45,4 +49,5 @@ h2 { margin: 0; font-size: 1.8rem; font-weight: 500; } .catalog-header { padding-top: 56px; } .listing-grid { grid-template-columns: 1fr; } h1 { font-size: 4rem; } + .product-facts { grid-template-columns: 1fr; } } \ No newline at end of file diff --git a/apps/web/src/pages/ProductDetailPage.vue b/apps/web/src/pages/ProductDetailPage.vue new file mode 100644 index 0000000..7c10576 --- /dev/null +++ b/apps/web/src/pages/ProductDetailPage.vue @@ -0,0 +1,40 @@ + + + \ No newline at end of file diff --git a/apps/web/src/router/index.js b/apps/web/src/router/index.js index 7221871..e9f63de 100644 --- a/apps/web/src/router/index.js +++ b/apps/web/src/router/index.js @@ -2,11 +2,13 @@ import { createRouter, createWebHistory } from 'vue-router'; import CatalogPage from '@/pages/CatalogPage.vue'; import ListingDetailPage from '@/pages/ListingDetailPage.vue'; +import ProductDetailPage from '@/pages/ProductDetailPage.vue'; export default createRouter({ history: createWebHistory(), routes: [ { path: '/', name: 'catalog', component: CatalogPage }, { path: '/listings/:id', name: 'listing-detail', component: ListingDetailPage }, + { path: '/products/:slug', name: 'product-detail', component: ProductDetailPage }, ], }); \ No newline at end of file diff --git a/apps/web/src/services/catalog-service.js b/apps/web/src/services/catalog-service.js index 06eb1fb..1e6c8dc 100644 --- a/apps/web/src/services/catalog-service.js +++ b/apps/web/src/services/catalog-service.js @@ -19,6 +19,17 @@ export async function getPublicListing(id) { throw new Error(response.status === 404 ? 'Listing not found.' : 'Listing is unavailable.'); } + const payload = await response.json(); + return payload.data; +} + +export async function getPublicProduct(slug) { + const response = await fetch(`${apiBaseUrl}/public/products/${encodeURIComponent(slug)}`); + + if (!response.ok) { + throw new Error(response.status === 404 ? 'Product not found.' : 'Product is unavailable.'); + } + const payload = await response.json(); return payload.data; } \ No newline at end of file diff --git a/apps/web/tests/catalog-service.test.js b/apps/web/tests/catalog-service.test.js index b114fa8..e1d30d3 100644 --- a/apps/web/tests/catalog-service.test.js +++ b/apps/web/tests/catalog-service.test.js @@ -30,4 +30,19 @@ test('encodes catalog search pagination in the API request', async () => { } finally { globalThis.fetch = originalFetch; } +}); + +test('fetches a public product using its stable slug URL', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + assert.equal(url, 'http://localhost:3000/api/v1/public/products/pikachu-plush'); + return { ok: true, async json() { return { data: { slug: 'pikachu-plush' } }; } }; + }; + + try { + const { getPublicProduct } = await import('../src/services/catalog-service.js'); + assert.deepEqual(await getPublicProduct('pikachu-plush'), { slug: 'pikachu-plush' }); + } finally { + globalThis.fetch = originalFetch; + } }); \ No newline at end of file diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index a1c1a6e..83c712b 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -99,6 +99,7 @@ docker context use orbstack - The web workspace now has a Vue/Vite public catalog shell with Vue Router, a catalog API service, search composable, loading/error/empty states, and responsive search-first styling. - The web catalog now provides keyboard-accessible listing cards with stable `/listings/:id` links, a public listing detail page, and service-level detail loading/error handling. - Catalog search and pagination are synchronized to the browser URL (`q` and `page`) with accessible previous/next controls; the web port `5173` is declared in the project port registry. +- The web catalog includes a stable `/products/:slug` product detail route with API-backed loading, not-found/error states, and semantic product facts. - `ImageStorage`, traversal-safe local storage, multipart upload transport, and binary signature validation are implemented; derivative processing remains. - Derivative orchestration now defines thumbnail, medium, and large WebP storage outputs behind an injected renderer; the concrete worker-backed renderer remains. - The worker owns an image derivative job runner with explicit processing, completion, and failure outcomes; queue transport and persistent job records remain.