feat: add public catalog web foundation
This commit is contained in:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup>
|
||||
import { RouterView } from 'vue-router';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user