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,
},
});