diff --git a/apps/web/src/assets/styles/global.scss b/apps/web/src/assets/styles/global.scss
index 00f1782..88c32fe 100644
--- a/apps/web/src/assets/styles/global.scss
+++ b/apps/web/src/assets/styles/global.scss
@@ -27,11 +27,12 @@ h1 { max-width: 680px; margin: 12px 0 18px; font-size: clamp(3.2rem, 9vw, 7rem);
h2 { margin: 0; font-size: 1.8rem; font-weight: 500; }
.results-heading span { color: #77786f; font: 0.85rem Arial, sans-serif; }
.listing-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 24px; }
-.listing-card { display: grid; grid-template-columns: 60px 1fr; gap: 18px; min-height: 180px; padding: 20px; border: 1px solid #d6d3c7; background: #fffdf8; }
-.listing-card__mark { display: grid; place-items: center; aspect-ratio: 1; align-self: start; background: #d9e7df; color: #386052; font: 700 0.85rem Arial, sans-serif; }
-.listing-card h3 { margin: 8px 0 16px; font-size: 1.25rem; font-weight: 500; }
-.listing-card__status { margin: 0; color: #386052; }
-.listing-card__price { margin: 0; font: 700 1rem Arial, sans-serif; }
+.detail-page { width: min(920px, calc(100% - 40px)); min-height: 100vh; margin: 0 auto; padding: 48px 0 88px; }
+.detail-back { color: #386052; font: 700 0.85rem Arial, sans-serif; }
+.detail-content { max-width: 680px; padding-top: 96px; }
+.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; }
.catalog-state { padding: 48px 0; color: #61625a; font: 1rem/1.5 Arial, sans-serif; }
.catalog-state--error { color: #a3482a; }
diff --git a/apps/web/src/components/molecules/ListingCard.scss b/apps/web/src/components/molecules/ListingCard.scss
new file mode 100644
index 0000000..0980214
--- /dev/null
+++ b/apps/web/src/components/molecules/ListingCard.scss
@@ -0,0 +1,19 @@
+.listing-card {
+ display: grid;
+ grid-template-columns: 60px 1fr;
+ gap: 18px;
+ min-height: 180px;
+ padding: 20px;
+ border: 1px solid #d6d3c7;
+ background: #fffdf8;
+ color: #22231f;
+ text-decoration: none;
+ transition: border-color 160ms ease, transform 160ms ease;
+}
+
+.listing-card:hover { border-color: #386052; transform: translateY(-2px); }
+.listing-card:focus-visible { outline: 3px solid #a3482a; outline-offset: 4px; }
+.listing-card__mark { display: grid; place-items: center; aspect-ratio: 1; align-self: start; background: #d9e7df; color: #386052; font: 700 0.85rem Arial, sans-serif; }
+.listing-card__status { display: block; margin-top: 2px; color: #386052; font: 700 0.75rem/1.2 Arial, sans-serif; letter-spacing: 0.14em; text-transform: uppercase; }
+.listing-card__name { display: block; margin: 8px 0 16px; font-size: 1.25rem; }
+.listing-card__price { display: block; font: 700 1rem Arial, sans-serif; }
\ No newline at end of file
diff --git a/apps/web/src/components/molecules/ListingCard.stories.js b/apps/web/src/components/molecules/ListingCard.stories.js
new file mode 100644
index 0000000..4340832
--- /dev/null
+++ b/apps/web/src/components/molecules/ListingCard.stories.js
@@ -0,0 +1,12 @@
+export default { title: 'Molecules/ListingCard' };
+
+export const available = {
+ args: {
+ listing: {
+ id: 'listing-1',
+ productName: 'Charizard ex',
+ askingPriceMinor: 2499,
+ status: 'AVAILABLE',
+ },
+ },
+};
\ No newline at end of file
diff --git a/apps/web/src/components/molecules/ListingCard.vue b/apps/web/src/components/molecules/ListingCard.vue
new file mode 100644
index 0000000..891f36f
--- /dev/null
+++ b/apps/web/src/components/molecules/ListingCard.vue
@@ -0,0 +1,25 @@
+
+
+
+
+ PK
+
+ {{ listing.status }}
+ {{ listing.productName }}
+ ${{ (listing.askingPriceMinor / 100).toFixed(2) }}
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/web/src/pages/CatalogPage.vue b/apps/web/src/pages/CatalogPage.vue
index a753d1d..f597d46 100644
--- a/apps/web/src/pages/CatalogPage.vue
+++ b/apps/web/src/pages/CatalogPage.vue
@@ -2,6 +2,7 @@
import { onMounted } from 'vue';
import { useCatalogSearch } from '@/composables/useCatalogSearch.js';
+import ListingCard from '@/components/molecules/ListingCard.vue';
const { query, result, isLoading, error, load } = useCatalogSearch();
@@ -30,14 +31,7 @@ onMounted(load);
{{ error.message }}
No relevant matches found.
-
- PK
-
-
{{ item.status }}
-
{{ item.productName }}
-
${{ (item.askingPriceMinor / 100).toFixed(2) }}
-
-
+
diff --git a/apps/web/src/pages/ListingDetailPage.vue b/apps/web/src/pages/ListingDetailPage.vue
new file mode 100644
index 0000000..e75b0c5
--- /dev/null
+++ b/apps/web/src/pages/ListingDetailPage.vue
@@ -0,0 +1,35 @@
+
+
+
+
+ Back to catalog
+ Loading listing...
+ {{ error.message }}
+
+ {{ listing.status }}
+ {{ listing.productName }}
+ ${{ (listing.askingPriceMinor / 100).toFixed(2) }}
+ {{ listing.notes }}
+
+
+
\ No newline at end of file
diff --git a/apps/web/src/router/index.js b/apps/web/src/router/index.js
index 338dc8c..7221871 100644
--- a/apps/web/src/router/index.js
+++ b/apps/web/src/router/index.js
@@ -1,10 +1,12 @@
import { createRouter, createWebHistory } from 'vue-router';
import CatalogPage from '@/pages/CatalogPage.vue';
+import ListingDetailPage from '@/pages/ListingDetailPage.vue';
export default createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'catalog', component: CatalogPage },
+ { path: '/listings/:id', name: 'listing-detail', component: ListingDetailPage },
],
});
\ 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 5087534..06eb1fb 100644
--- a/apps/web/src/services/catalog-service.js
+++ b/apps/web/src/services/catalog-service.js
@@ -1,4 +1,4 @@
-const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api/v1';
+const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? 'http://localhost:3000/api/v1';
export async function searchCatalog({ query = '', page = 1, limit = 20 } = {}) {
const params = new URLSearchParams({ query, page: String(page), limit: String(limit) });
@@ -8,6 +8,17 @@ export async function searchCatalog({ query = '', page = 1, limit = 20 } = {}) {
throw new Error('Catalog search is unavailable.');
}
+ const payload = await response.json();
+ return payload.data;
+}
+
+export async function getPublicListing(id) {
+ const response = await fetch(`${apiBaseUrl}/public/listings/${encodeURIComponent(id)}`);
+
+ if (!response.ok) {
+ throw new Error(response.status === 404 ? 'Listing not found.' : 'Listing 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 7e4f206..ce4ba7d 100644
--- a/apps/web/tests/catalog-service.test.js
+++ b/apps/web/tests/catalog-service.test.js
@@ -1,6 +1,18 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-test('web workspace exposes a catalog test entrypoint', () => {
- assert.ok(true);
+import { getPublicListing } from '../src/services/catalog-service.js';
+
+test('fetches a public listing detail through the API service', async () => {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async (url) => {
+ assert.equal(url, 'http://localhost:3000/api/v1/public/listings/listing-1');
+ return { ok: true, async json() { return { data: { id: 'listing-1' } }; } };
+ };
+
+ try {
+ assert.deepEqual(await getPublicListing('listing-1'), { id: 'listing-1' });
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
});
\ No newline at end of file
diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md
index e084ad1..5064f86 100644
--- a/docs/IMPLEMENTATION-STATUS.md
+++ b/docs/IMPLEMENTATION-STATUS.md
@@ -97,6 +97,7 @@ docker context use orbstack
- Public inventory collection and detail reads are available at `GET /api/v1/public/inventory` and `GET /api/v1/public/inventory/:id`; responses join only active physical items to available or pending listings and exclude private item notes.
- The public seller directory is available at `GET /api/v1/public/sellers`; it returns only sellers with visible inventory and exposes display identity without user or contact fields.
- 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.
- `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.