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 @@ + + + + + \ 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.

-
- -
-

{{ 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 @@ + + + \ 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.