Wave 1 of replacing the fixed-data mock adapter. The mall now selects its API adapter per domain, with catalog and currency served live while auth, cart, orders, shipments and invoices stay on fixed data. Backend: - seed the 6 x 2 x 2 category tree as reference data (migration 0005). The API exposes no category write route, so this cannot come from the seed script - filter public product listing by the category subtree with a recursive CTE, matching the mock's existing behaviour instead of exact-match - add sort=price with order=asc|desc, validated by hand so an unsupported value returns the project's ApiError 400 shape rather than axum's own rejection Mall: - replace the all-or-nothing mockApi boolean with a liveDomains list composed through a typed per-domain pick map - source home floors, the category menu, search and product detail from the catalog API; banners, promos, quick links, store card and comment/coupon content stay local display-only content - drop the brand facet and the sales/comments sorts: no backend model backs them - fix salesOf/commentCountOf, which parsed digits out of the product id and so rendered "NaN sold" for live UUID ids; they now hash the id Seed: 24 products across 4 shops, idempotent on re-run. Note: the mall defaults to a live catalog, so pnpm dev:mall now expects the API to be running; set NUXT_PUBLIC_LIVE_DOMAINS to an empty array for all-mock work. OpenSpec change: openspec/changes/replace-mock-api-wave-1
176 lines
4.1 KiB
Vue
176 lines
4.1 KiB
Vue
<script setup lang="ts">
|
|
import type { Category } from "@vmall/shared";
|
|
import { t as pick } from "@vmall/shared";
|
|
|
|
const props = withDefaults(defineProps<{ pinned?: boolean }>(), { pinned: false });
|
|
|
|
const { locale } = useI18n();
|
|
const { $api } = useNuxtApp();
|
|
const open = ref(false);
|
|
const hoverId = ref<string | null>(null);
|
|
|
|
// The catalog domain serves this live in Wave 1; the fixed-data adapter answers
|
|
// the same call when it is rolled back. Errors are captured rather than thrown,
|
|
// so a backend outage leaves the menu empty instead of breaking the shell.
|
|
const { data: categories } = await useAsyncData("shell-categories", () => $api.listCategories());
|
|
|
|
const childrenOf = (parentId: string): Category[] =>
|
|
(categories.value ?? [])
|
|
.filter((category) => category.parent_id === parentId)
|
|
.sort((a, b) => a.position - b.position);
|
|
|
|
const cats = computed(() =>
|
|
(categories.value ?? [])
|
|
.filter((category) => category.parent_id === null)
|
|
.sort((a, b) => a.position - b.position)
|
|
.map((cat) => ({
|
|
cat,
|
|
children: childrenOf(cat.id).map((cc) => ({ cat: cc, grandchildren: childrenOf(cc.id) })),
|
|
})),
|
|
);
|
|
|
|
const active = computed(() => cats.value.find((g) => g.cat.id === hoverId.value) ?? null);
|
|
|
|
defineExpose({ open });
|
|
</script>
|
|
|
|
<template>
|
|
<div class="catmenu" :class="{ pinned: props.pinned }" @mouseleave="hoverId = null">
|
|
<ul class="lvl1">
|
|
<li
|
|
v-for="g in cats"
|
|
:key="g.cat.id"
|
|
:class="{ on: hoverId === g.cat.id }"
|
|
@mouseenter="hoverId = g.cat.id"
|
|
>
|
|
<NuxtLink :to="`/search?category=${g.cat.id}`" class="c1">{{ pick(g.cat.name, locale) }}</NuxtLink>
|
|
<span class="c2s">
|
|
<NuxtLink
|
|
v-for="cc in g.children.slice(0, 3)"
|
|
:key="cc.cat.id"
|
|
:to="`/search?category=${cc.cat.id}`"
|
|
class="c2"
|
|
>{{ pick(cc.cat.name, locale) }}</NuxtLink>
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
<div v-if="active" class="sub">
|
|
<div v-for="cc in active.children" :key="cc.cat.id" class="sub-row">
|
|
<NuxtLink :to="`/search?category=${cc.cat.id}`" class="sub-title">{{ pick(cc.cat.name, locale) }}</NuxtLink>
|
|
<div class="sub-items">
|
|
<NuxtLink
|
|
v-for="gc in cc.grandchildren"
|
|
:key="gc.id"
|
|
:to="`/search?category=${gc.id}`"
|
|
>{{ pick(gc.name, locale) }}</NuxtLink>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.catmenu {
|
|
position: absolute;
|
|
top: 40px;
|
|
left: 0;
|
|
z-index: 700;
|
|
display: flex;
|
|
width: 960px;
|
|
}
|
|
/* pinned: in-flow left rail of the home hero row, flyout overlays the carousel */
|
|
.catmenu.pinned {
|
|
position: relative;
|
|
top: auto;
|
|
left: auto;
|
|
flex: none;
|
|
width: 240px;
|
|
height: 450px;
|
|
}
|
|
.lvl1 {
|
|
width: 240px;
|
|
margin: 0;
|
|
padding: 8px 0;
|
|
list-style: none;
|
|
background: rgba(30, 30, 30, 0.92);
|
|
min-height: 434px;
|
|
}
|
|
.lvl1 li {
|
|
padding: 10px 15px;
|
|
cursor: pointer;
|
|
}
|
|
.lvl1 li.on {
|
|
background: #fff;
|
|
}
|
|
.lvl1 li.on .c1,
|
|
.lvl1 li.on .c2 {
|
|
color: var(--mall-red);
|
|
}
|
|
.c1 {
|
|
display: block;
|
|
color: #fff;
|
|
font-size: 14px;
|
|
text-decoration: none;
|
|
}
|
|
.c2s {
|
|
display: block;
|
|
margin-top: 2px;
|
|
}
|
|
.c2 {
|
|
color: #bbb;
|
|
font-size: 12px;
|
|
margin-right: 8px;
|
|
text-decoration: none;
|
|
}
|
|
.c2:hover {
|
|
color: #fff;
|
|
}
|
|
.sub {
|
|
flex: 1;
|
|
background: #fff;
|
|
box-shadow: var(--mall-shadow);
|
|
padding: 20px 30px;
|
|
min-height: 434px;
|
|
}
|
|
.sub-row {
|
|
display: flex;
|
|
border-bottom: 1px solid var(--mall-line);
|
|
padding: 12px 0;
|
|
}
|
|
.sub-row:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
.sub-title {
|
|
width: 120px;
|
|
font-weight: 600;
|
|
color: var(--mall-ink);
|
|
text-decoration: none;
|
|
}
|
|
.sub-items a {
|
|
display: inline-block;
|
|
margin: 0 18px 8px 0;
|
|
color: var(--mall-muted);
|
|
font-size: 13px;
|
|
text-decoration: none;
|
|
}
|
|
.sub-items a:hover {
|
|
color: var(--mall-red);
|
|
}
|
|
/* pinned overrides: 240x450 rail, flyout opens to the right over the carousel */
|
|
.catmenu.pinned .lvl1 {
|
|
box-sizing: border-box;
|
|
height: 450px;
|
|
min-height: 450px;
|
|
}
|
|
.catmenu.pinned .sub {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 240px;
|
|
box-sizing: border-box;
|
|
width: 700px;
|
|
height: 450px;
|
|
min-height: 450px;
|
|
overflow: auto;
|
|
}
|
|
</style>
|