feat: add public catalog web foundation

This commit is contained in:
2026-09-08 19:37:59 -04:00
parent a76d777a6b
commit 3d17637711
13 changed files with 2001 additions and 1 deletions
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="A searchable Pokemon inventory catalog." />
<title>Pokemon Inventory</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@pokemon-inventory/web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "node --test tests/*.test.js"
},
"dependencies": {
"vue": "^3.5.21",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"sass": "^1.93.2",
"vite": "^7.1.7"
}
}
+7
View File
@@ -0,0 +1,7 @@
<script setup>
import { RouterView } from 'vue-router';
</script>
<template>
<RouterView />
</template>
+42
View File
@@ -0,0 +1,42 @@
:root {
color: #22231f;
background: #f3efe6;
font-family: Georgia, 'Times New Roman', serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
button, input { font: inherit; }
.catalog-page {
min-height: 100vh;
background: radial-gradient(circle at 85% 12%, #d9e7df 0, transparent 30%), #f3efe6;
}
.catalog-header, .catalog-results { width: min(1120px, calc(100% - 40px)); margin: 0 auto; }
.catalog-header { padding: 88px 0 72px; }
.catalog-kicker, .listing-card__status { color: #a3482a; font: 700 0.75rem/1.2 Arial, sans-serif; letter-spacing: 0.14em; text-transform: uppercase; }
h1 { max-width: 680px; margin: 12px 0 18px; font-size: clamp(3.2rem, 9vw, 7rem); line-height: 0.92; font-weight: 500; }
.catalog-intro { max-width: 480px; color: #61625a; font: 1.1rem/1.5 Arial, sans-serif; }
.search-field { display: block; max-width: 720px; margin-top: 48px; color: #61625a; font: 700 0.8rem/1 Arial, sans-serif; text-transform: uppercase; letter-spacing: 0.08em; }
.search-field input { display: block; width: 100%; margin-top: 12px; padding: 18px 20px; border: 1px solid #9ca69b; border-radius: 2px; background: #fffdf8; color: #22231f; font-size: 1.05rem; letter-spacing: 0; text-transform: none; outline-offset: 4px; }
.catalog-results { padding-bottom: 88px; }
.results-heading { display: flex; align-items: baseline; justify-content: space-between; border-bottom: 1px solid #c9c8bd; padding-bottom: 14px; }
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; }
.catalog-state { padding: 48px 0; color: #61625a; font: 1rem/1.5 Arial, sans-serif; }
.catalog-state--error { color: #a3482a; }
@media (max-width: 720px) {
.catalog-header { padding-top: 56px; }
.listing-grid { grid-template-columns: 1fr; }
h1 { font-size: 4rem; }
}
@@ -0,0 +1,30 @@
import { ref, watch } from 'vue';
import { searchCatalog } from '@/services/catalog-service.js';
export function useCatalogSearch() {
const query = ref('');
const result = ref({ items: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 } });
const isLoading = ref(false);
const error = ref(null);
let requestId = 0;
async function load() {
const currentRequestId = ++requestId;
isLoading.value = true;
error.value = null;
try {
const nextResult = await searchCatalog({ query: query.value });
if (currentRequestId === requestId) result.value = nextResult;
} catch (requestError) {
if (currentRequestId === requestId) error.value = requestError;
} finally {
if (currentRequestId === requestId) isLoading.value = false;
}
}
watch(query, load);
return { query, result, isLoading, error, load };
}
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router/index.js';
import './assets/styles/global.scss';
createApp(App).use(router).mount('#app');
+44
View File
@@ -0,0 +1,44 @@
<script setup>
import { onMounted } from 'vue';
import { useCatalogSearch } from '@/composables/useCatalogSearch.js';
const { query, result, isLoading, error, load } = useCatalogSearch();
onMounted(load);
</script>
<template>
<main class="catalog-page">
<header class="catalog-header">
<p class="catalog-kicker">Pokemon inventory</p>
<h1>Find the next favorite.</h1>
<p class="catalog-intro">Browse available cards, collectibles, and carefully cataloged pieces.</p>
<label class="search-field">
<span>Search the catalog</span>
<input v-model="query" type="search" placeholder="Try Charizard, 151, plush..." />
</label>
</header>
<section class="catalog-results" aria-live="polite">
<div class="results-heading">
<h2>Available now</h2>
<span v-if="!isLoading">{{ result.pagination.total }} results</span>
</div>
<p v-if="isLoading" class="catalog-state">Searching the catalog...</p>
<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>
</div>
</section>
</main>
</template>
+10
View File
@@ -0,0 +1,10 @@
import { createRouter, createWebHistory } from 'vue-router';
import CatalogPage from '@/pages/CatalogPage.vue';
export default createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'catalog', component: CatalogPage },
],
});
+13
View File
@@ -0,0 +1,13 @@
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) });
const response = await fetch(`${apiBaseUrl}/public/search?${params}`);
if (!response.ok) {
throw new Error('Catalog search is unavailable.');
}
const payload = await response.json();
return payload.data;
}
+6
View File
@@ -0,0 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
test('web workspace exposes a catalog test entrypoint', () => {
assert.ok(true);
});
+16
View File
@@ -0,0 +1,16 @@
import { fileURLToPath, URL } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
},
});
+2 -1
View File
@@ -96,6 +96,7 @@ docker context use orbstack
- Public listing collection and detail reads are available at `GET /api/v1/public/listings` and `GET /api/v1/public/listings/:id`; collection responses include pagination metadata and detail reads hide internal inventory identity.
- 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.
- `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.
@@ -140,7 +141,7 @@ Work through one bounded vertical slice at a time. The linked documentation rema
- No initial User/Seller is provisioned in the development database yet. Run `npm run provision:admin` after setting its required local `.env` variables.
- Listings are partially implemented: draft creation, seller-scoped reads, publication gating, lifecycle writes, archive behavior, sold/archived inventory checks, and item-owned image metadata are complete; storage-backed processing remains.
- Sales and acquisition domain foundations, concrete image rendering, normalized search, public search/product reads, queue transport, persistent worker jobs, web UI, CI, and deployment services remain incomplete to varying degrees.
- Sales and acquisition domain foundations, concrete image rendering, normalized search, public API reads beyond the catalog, queue transport, persistent worker jobs, fuller web UI, CI, and deployment services remain incomplete to varying degrees.
- `npm install` currently reports dependency audit findings. Review and remediate them before production deployment; do not use `npm audit fix --force` without reviewing its changes.
## Local Commands
+1792
View File
File diff suppressed because it is too large Load Diff