Wave 6, the last substantive piece of the mock-API migration. Wave 1 removed the brand facet and the sales/comments sorts for want of a model; sales turn out to be derivable from order_items and a brand model is a table plus a column. - a `brands` table with a nullable `products.brand_id` and an ordered admin replace, mirroring categories and storefront content; a public `GET /api/brands` and a `brand_id` filter on the catalog, which the search page's facet uses - `sold_count` per product, computed from `order_items` joined to orders that reached payment, so an abandoned or cancelled checkout cannot count as a sale. It is computed per read rather than stored, so it cannot drift from the orders that produced it - `sort=sales` alongside `sort=price`; anything else is still a 400 - merchants can set a product's brand through the existing product upsert - the review UI is gone: the card's review figure and the product detail page's reviews tab, summary and replies. There is no reviews model, and the mall attributed invented comments to named shoppers and showed a "good rate". The now-unreferenced fabrication helpers went with it (`salesOf`, `commentCountOf`, `commentsFor`, `commentStats`, `salesRankFor`, `productDetail`, `storeDetail`) Two bugs found by checking rather than trusting: the fixed-data `listProducts` had silently ignored `brand_id`, `sort` and `order`, so the restored facet rendered but filtered nothing until the rollback check caught it; and the seed's brand lookup read back through the shared `r` variable the product loop reassigns, working once and then throwing. Verified: 29 backend tests green including a new brand-and-sales case; all three frontends build; searching filters by brand (24 to 6) and sorts by sales with counts matching the API; a product page offers detail and after-sale tabs only, with a real sold count; the fixed-data rollback filters by brand too. OpenSpec change: openspec/changes/replace-mock-api-wave-6
90 lines
2.0 KiB
Vue
90 lines
2.0 KiB
Vue
<script setup lang="ts">
|
|
import type { Product } from "@vmall/shared";
|
|
import { t as pick } from "@vmall/shared";
|
|
import { lowestSku } from "~/utils/product";
|
|
|
|
const props = defineProps<{ product: Product }>();
|
|
const { locale } = useI18n();
|
|
|
|
const sku = computed(() => lowestSku(props.product));
|
|
// Reported by the catalog API from paid orders; no reviews figure exists.
|
|
const sold = computed(() => props.product.sold_count);
|
|
</script>
|
|
|
|
<template>
|
|
<NuxtLink :to="`/goods/${product.slug}`" class="card hover-lift">
|
|
<div class="img">
|
|
<img :src="product.images[0] || '/mock/product-1.svg'" :alt="pick(product.name, locale)" loading="lazy" />
|
|
</div>
|
|
<div class="body">
|
|
<p class="name">{{ pick(product.name, locale) }}</p>
|
|
<p class="sub">{{ pick(product.description, locale) }}</p>
|
|
<p class="price-row">
|
|
<span v-if="sku" class="price"><PriceText :amount-minor="sku.price_minor" :currency="sku.currency" /></span>
|
|
<s v-if="sku" class="market"><PriceText :amount-minor="sku.price_minor + 10000" :currency="sku.currency" /></s>
|
|
</p>
|
|
<p class="meta">{{ $t("product.salesCount", { n: sold }) }}</p>
|
|
</div>
|
|
</NuxtLink>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.card {
|
|
display: block;
|
|
background: #fff;
|
|
text-decoration: none;
|
|
color: inherit;
|
|
border: 1px solid var(--mall-line);
|
|
}
|
|
.img {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 16px;
|
|
}
|
|
.img img {
|
|
width: 180px;
|
|
height: 180px;
|
|
object-fit: contain;
|
|
}
|
|
.body {
|
|
padding: 0 14px 14px;
|
|
}
|
|
.name {
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
height: 37px;
|
|
overflow: hidden;
|
|
margin: 0;
|
|
}
|
|
.card:hover .name {
|
|
color: var(--mall-red);
|
|
}
|
|
.sub {
|
|
font-size: 12px;
|
|
color: var(--mall-faint);
|
|
margin: 4px 0 8px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.price-row {
|
|
margin: 0;
|
|
}
|
|
.price {
|
|
color: var(--mall-red);
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
margin-right: 8px;
|
|
}
|
|
.market {
|
|
color: var(--mall-faint);
|
|
font-size: 12px;
|
|
}
|
|
.meta {
|
|
margin: 6px 0 0;
|
|
font-size: 12px;
|
|
color: var(--mall-faint);
|
|
}
|
|
</style>
|