Files
vmall/apps/mall/components/shell/CategoryMenu.vue
T
james e0e833d0e5 feat(mall): serve catalog and currency from the live API
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
2026-09-17 15:15:25 +00:00

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>