feat: add public listing detail web flow
This commit is contained in:
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user