feat: add public listing detail web flow

This commit is contained in:
2026-09-08 19:40:11 -04:00
parent 3d17637711
commit fd6d4cf37b
10 changed files with 128 additions and 16 deletions
+6 -5
View File
@@ -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; }
@@ -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; }
@@ -0,0 +1,12 @@
export default { title: 'Molecules/ListingCard' };
export const available = {
args: {
listing: {
id: 'listing-1',
productName: 'Charizard ex',
askingPriceMinor: 2499,
status: 'AVAILABLE',
},
},
};
@@ -0,0 +1,25 @@
<script setup>
import { RouterLink } from 'vue-router';
defineProps({
listing: {
type: Object,
required: true,
},
});
</script>
<template>
<RouterLink class="listing-card" :to="{ name: 'listing-detail', params: { id: listing.id } }">
<span class="listing-card__mark" aria-hidden="true">PK</span>
<span>
<span class="listing-card__status">{{ listing.status }}</span>
<span class="listing-card__name">{{ listing.productName }}</span>
<span class="listing-card__price">${{ (listing.askingPriceMinor / 100).toFixed(2) }}</span>
</span>
</RouterLink>
</template>
<style scoped lang="scss">
@use './ListingCard.scss';
</style>
+2 -8
View File
@@ -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);
<p v-else-if="error" class="catalog-state catalog-state--error">{{ error.message }}</p>
<p v-else-if="result.items.length === 0" class="catalog-state">No relevant matches found.</p>
<div v-else class="listing-grid">
<article v-for="item in result.items" :key="item.id" class="listing-card">
<div class="listing-card__mark" aria-hidden="true">PK</div>
<div>
<p class="listing-card__status">{{ item.status }}</p>
<h3>{{ item.productName }}</h3>
<p class="listing-card__price">${{ (item.askingPriceMinor / 100).toFixed(2) }}</p>
</div>
</article>
<ListingCard v-for="item in result.items" :key="item.id" :listing="item" />
</div>
</section>
</main>
+35
View File
@@ -0,0 +1,35 @@
<script setup>
import { onMounted, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { getPublicListing } from '@/services/catalog-service.js';
const route = useRoute();
const listing = ref(null);
const isLoading = ref(true);
const error = ref(null);
onMounted(async () => {
try {
listing.value = await getPublicListing(route.params.id);
} catch (requestError) {
error.value = requestError;
} finally {
isLoading.value = false;
}
});
</script>
<template>
<main class="detail-page">
<RouterLink class="detail-back" :to="{ name: 'catalog' }">Back to catalog</RouterLink>
<p v-if="isLoading" class="catalog-state">Loading listing...</p>
<p v-else-if="error" class="catalog-state catalog-state--error">{{ error.message }}</p>
<article v-else-if="listing" class="detail-content">
<p class="catalog-kicker">{{ listing.status }}</p>
<h1>{{ listing.productName }}</h1>
<p class="detail-price">${{ (listing.askingPriceMinor / 100).toFixed(2) }}</p>
<p v-if="listing.notes" class="detail-notes">{{ listing.notes }}</p>
</article>
</main>
</template>
+2
View File
@@ -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 },
],
});
+12 -1
View File
@@ -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) });
@@ -11,3 +11,14 @@ export async function searchCatalog({ query = '', page = 1, limit = 20 } = {}) {
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;
}
+14 -2
View File
@@ -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;
}
});
+1
View File
@@ -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.