Files
vmall/apps/mall/pages/goods/[id].vue
T
james e1a0a5dbdb feat(mall): run the transaction chain against the live API
Wave 3 of replacing the fixed-data mock adapter: cart, orders, shipments and
invoices flip together, so one purchase runs end to end against the backend.

- cart: CartItemView carries the line's shop and the SKU's stock, so the cart
  keeps grouping per shop and the quantity stepper caps at real stock instead
  of a hard-coded 999
- contract: Shipment.items is optional and Invoice.invoice_no nullable, both
  matching what the API actually returns. Invoice was declared twice in
  types.ts and TypeScript merges duplicate interfaces, so the duplicate had to
  go for the change to take effect at all
- an anonymous add-to-cart redirects to /login?redirect=..., and sign-in
  honours only same-origin paths
- the fixed-data adapter learns the new cart fields, and its persisted state
  key moves to v2 because a cart saved by an older build is no longer valid
- order surfaces drop their storeById lookups and keep the generic store label
  until the public store read arrives

Verified end to end: two-shop cart grouping with live shop names, stock caps
read from the API, checkout, payment, shipment, delivery confirmation and an
issued invoice. Rollback re-verified with every domain on fixed data and the
backend stopped.

Also checks off Wave 3 in docs/TBD-migrate-wave.md and re-points that file at
the mock content that remains.

OpenSpec change: openspec/changes/replace-mock-api-wave-3
2026-09-17 16:33:22 +00:00

757 lines
21 KiB
Vue

<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import { ApiError } from "@vmall/shared";
import type { Category, Product, Sku } from "@vmall/shared";
import {
MOCK_COUPONS,
MOCK_STORES,
commentStats,
commentsFor,
lowestSku,
salesOf,
storeById,
} from "~/mock/data";
import { useCartStore } from "~/stores/cart";
type AttributeGroup = { key: string; values: string[] };
const route = useRoute();
const router = useRouter();
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const cart = useCartStore();
const routeId = computed(() => {
const value = route.params.id;
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
});
// One request key for the whole page: the ranking rail must be derived from the
// product that was just fetched, and chaining two useAsyncData calls left the
// second handler running before the first had data.
const { data: pageData } = await useAsyncData(
"product-detail",
async () => {
const current = await $api.getProduct(routeId.value);
// The rail uses live catalogue products so its links resolve; the store
// card, comments and coupons stay local display-only content (non-goals).
// Store best sellers, mirroring the mock's salesRankFor(shopId). Ranking by
// category would empty the rail for any product alone in its leaf category.
const siblings = await $api.listProducts({
shop_id: current.shop_id,
per_page: 6,
});
return {
product: current,
related: siblings.items.filter((item) => item.id !== current.id).slice(0, 5),
};
},
{ watch: [routeId], default: () => null },
);
const product = computed(() => pageData.value?.product ?? null);
const related = computed<Product[]>(() => pageData.value?.related ?? []);
const detail = computed(() => {
const current = product.value;
if (!current) return null;
return {
product: current,
store: storeById(current.shop_id) ?? MOCK_STORES[0],
comments: commentsFor(current.id),
commentStats: commentStats(current.id),
coupons: MOCK_COUPONS,
salesRank: related.value,
};
});
/// Display-only sales figure; `salesOf` hashes the id so it is safe for UUIDs.
const store = computed(() => detail.value?.store ?? null);
const selectedAttributes = reactive<Record<string, string>>({});
const quantity = ref(1);
const galleryIndex = ref(0);
const favorite = ref(false);
const cartSuccess = ref(false);
const activeTab = ref("detail");
const attributeGroups = computed<AttributeGroup[]>(() => {
const valuesByKey = new Map<string, Set<string>>();
for (const sku of product.value?.skus ?? []) {
if (!sku.active) continue;
for (const [key, value] of Object.entries(sku.attributes)) {
const values = valuesByKey.get(key) ?? new Set<string>();
values.add(value);
valuesByKey.set(key, values);
}
}
return [...valuesByKey.entries()].map(([key, values]) => ({ key, values: [...values] }));
});
watch(
detail,
(current) => {
for (const key of Object.keys(selectedAttributes)) delete selectedAttributes[key];
const initial = current ? (current.product.skus?.find((sku) => sku.active) ?? lowestSku(current.product)) : null;
if (initial) Object.assign(selectedAttributes, initial.attributes);
quantity.value = 1;
galleryIndex.value = 0;
favorite.value = false;
cartSuccess.value = false;
activeTab.value = "detail";
},
{ immediate: true },
);
const matchedSku = computed<Sku | null>(() => {
const skus = product.value?.skus ?? [];
const keys = attributeGroups.value.map((group) => group.key);
if (!keys.every((key) => selectedAttributes[key])) return null;
return skus.find(
(sku) => sku.active && keys.every((key) => sku.attributes[key] === selectedAttributes[key]),
) ?? null;
});
const stock = computed(() => matchedSku.value?.stock ?? 0);
const marketPrice = computed(() => {
const amount = matchedSku.value?.price_minor;
return amount === undefined ? 0 : Math.round((amount * 115) / 100);
});
const currentImage = computed(() => product.value?.images[galleryIndex.value] ?? product.value?.images[0] ?? "/mock/product-1.svg");
// Shared with the shell's category menu, so the tree is not refetched.
const { data: categories } = await useAsyncData("shell-categories", () => $api.listCategories());
const categoryById = (id: string): Category | undefined =>
(categories.value ?? []).find((category) => category.id === id);
const categoryPath = computed(() => {
const path: Category[] = [];
let current = product.value?.category_id ? categoryById(product.value.category_id) : undefined;
while (current) {
path.unshift(current);
current = current.parent_id ? categoryById(current.parent_id) : undefined;
}
return path;
});
const breadcrumbItems = computed(() => [
{ label: t("product.home"), to: "/" },
...categoryPath.value.map((category) => ({
label: pick(category.name, locale.value),
to: `/search?category=${category.id}`,
})),
{ label: product.value ? pick(product.value.name, locale.value) : t("product.productNotFound") },
]);
const selectAttribute = (key: string, value: string): void => {
selectedAttributes[key] = value;
quantity.value = 1;
cartSuccess.value = false;
};
const addToCart = async (): Promise<boolean> => {
if (!matchedSku.value || stock.value <= 0) return false;
try {
await $api.addCartItem(matchedSku.value.id, quantity.value);
} catch (error) {
// A live cart needs a token; send a signed-out shopper to sign in and back.
if (error instanceof ApiError && error.status === 401) {
await router.push(`/login?redirect=${encodeURIComponent(route.fullPath)}`);
return false;
}
throw error;
}
await cart.refresh();
return true;
};
const buyNow = async (): Promise<void> => {
if (await addToCart()) await router.push("/checkout");
};
const addCart = async (): Promise<void> => {
if (await addToCart()) {
cartSuccess.value = true;
window.setTimeout(() => {
cartSuccess.value = false;
}, 1800);
}
};
const tabs = computed(() => [
{ key: "detail", label: t("product.tabsDetail") },
{ key: "reviews", label: t("product.tabsReviews") },
{ key: "service", label: t("product.tabsAfterSale") },
]);
const rateRows = computed(() => {
const rate = store.value?.rate;
return [
{ key: "overall", value: rate?.score ?? 0, label: t("product.overall") },
{ key: "service", value: rate?.service ?? 0, label: t("product.service") },
{ key: "delivery", value: rate?.speed ?? 0, label: t("product.delivery") },
{ key: "agree", value: rate?.agree ?? 0, label: t("product.quality") },
];
});
const detailImages = computed(() => product.value?.images ?? []);
</script>
<template>
<div class="product-page section-bg">
<UiBreadcrumb :items="breadcrumbItems" />
<main v-if="detail" class="w1200">
<section class="product-top mpanel">
<div class="gallery">
<div class="main-image">
<img class="zoom-image" :src="currentImage" :alt="pick(detail.product.name, locale)" />
</div>
<div class="thumbs" role="list">
<button
v-for="(image, index) in detail.product.images.slice(0, 3)"
:key="`${image}-${index}`"
type="button"
:class="{ active: galleryIndex === index }"
:aria-label="`${t('product.detail')} ${index + 1}`"
@click="galleryIndex = index"
>
<img :src="image" :alt="pick(detail.product.name, locale)" loading="lazy" />
</button>
</div>
</div>
<div class="summary">
<div class="summary-heading">
<div>
<h1>{{ pick(detail.product.name, locale) }}</h1>
<p class="subtitle">{{ pick(detail.product.description, locale) }}</p>
</div>
<button
type="button"
class="favorite"
:class="{ active: favorite }"
:aria-label="favorite ? t('product.unfavorite') : t('product.favorite')"
@click="favorite = !favorite"
>{{ favorite ? "♥" : "♡" }}</button>
</div>
<div class="price-panel">
<span class="price-label">{{ t("product.currentPrice") }}</span>
<strong v-if="matchedSku" class="price"><PriceText :amount-minor="matchedSku.price_minor" :currency="matchedSku.currency" /></strong>
<strong v-else class="price">{{ t("product.unavailable") }}</strong>
<span v-if="matchedSku" class="market-label">{{ t("product.marketPrice") }} <s><PriceText :amount-minor="marketPrice" :currency="matchedSku.currency" /></s></span>
</div>
<div class="summary-meta">
<span>{{ t("product.sold", { n: salesOf(detail.product) }) }}</span>
<span>{{ t("product.commentCount", { n: detail.commentStats.all }) }}</span>
<span :class="{ soldout: stock <= 0 }">{{ stock > 0 ? t("product.stock", { n: stock }) : t("product.noStock") }}</span>
</div>
<div class="coupon-row">
<span class="label">{{ t("product.coupons") }}</span>
<div class="coupon-list">
<span v-for="coupon in detail.coupons" :key="coupon.id" class="coupon">
{{ pick(coupon.title, locale) }}
<small>{{ t("product.expires", { date: coupon.expiresAt }) }}</small>
</span>
</div>
</div>
<div v-for="group in attributeGroups" :key="group.key" class="attribute-row">
<span class="label">{{ group.key }}</span>
<div class="attribute-values">
<button
v-for="value in group.values"
:key="value"
type="button"
:class="{ active: selectedAttributes[group.key] === value }"
@click="selectAttribute(group.key, value)"
>{{ value }}</button>
</div>
</div>
<div class="quantity-row">
<span class="label">{{ t("product.quantity") }}</span>
<UiQtyStepper v-model="quantity" :max="Math.max(1, stock)" />
</div>
<div class="actions">
<button type="button" class="primary-action" :disabled="!matchedSku || stock <= 0" @click="buyNow">{{ t("product.buyNow") }}</button>
<button type="button" class="cart-action" :disabled="!matchedSku || stock <= 0" @click="addCart">{{ cartSuccess ? t("product.addedCart") : t("product.addCart") }}</button>
</div>
<p v-if="matchedSku && stock <= 0" class="stock-warning">{{ t("product.noStock") }}</p>
<p v-else-if="!matchedSku" class="stock-warning">{{ t("product.unavailable") }}</p>
</div>
</section>
<section class="below-grid">
<aside class="left-rail">
<div class="store-card mpanel">
<h2>{{ t("product.store") }}</h2>
<div class="store-heading">
<img :src="detail.store.logo" :alt="pick(detail.store.name, locale)" />
<strong>{{ pick(detail.store.name, locale) }}</strong>
</div>
<dl>
<div><dt>{{ t("product.company") }}</dt><dd>{{ detail.store.company }}</dd></div>
<div><dt>{{ t("product.region") }}</dt><dd>{{ detail.store.region }}</dd></div>
</dl>
<h3>{{ t("product.rates") }}</h3>
<div v-for="row in rateRows" :key="row.key" class="rate-row">
<span>{{ row.label }}</span><UiRatingStars :value="row.value" :size="13" />
</div>
<div class="store-actions">
<NuxtLink class="store-button" :to="`/stores/${detail.store.slug}`">{{ t("product.enterStore") }}</NuxtLink>
<button type="button" class="store-button" disabled>{{ t("product.contact") }}</button>
</div>
</div>
<div class="sales-rank mpanel">
<h2>{{ t("product.salesRank") }}</h2>
<NuxtLink v-for="item in detail.salesRank" :key="item.id" :to="`/goods/${item.slug}`" class="rank-item">
<img :src="item.images[0]" :alt="pick(item.name, locale)" loading="lazy" />
<span class="rank-name">{{ pick(item.name, locale) }}</span>
<span v-if="lowestSku(item)" class="rank-price"><PriceText :amount-minor="lowestSku(item)!.price_minor" :currency="lowestSku(item)!.currency" /></span>
</NuxtLink>
</div>
</aside>
<section class="detail-panel mpanel">
<UiTabs v-model="activeTab" :tabs="tabs">
<template #default="{ active }">
<div v-if="active === 'detail'" class="detail-content">
<h2>{{ t("product.description") }}</h2>
<p>{{ pick(detail.product.description, locale) }}</p>
<img v-for="(image, index) in detailImages" :key="`${image}-detail-${index}`" :src="image" :alt="t('product.detail')" loading="lazy" />
</div>
<div v-else-if="active === 'reviews'" class="reviews-content">
<div class="review-summary">
<strong>{{ t("product.reviewSummary", { rate: detail.commentStats.goodRate, n: detail.commentStats.all }) }}</strong>
<span>{{ t("product.reviewAll") }}</span>
</div>
<article v-for="comment in detail.comments" :key="comment.id" class="review">
<img :src="comment.avatar" :alt="comment.author" loading="lazy" />
<div class="review-body">
<header><strong>{{ comment.author }}</strong><UiRatingStars :value="comment.rating" :size="13" /><time>{{ comment.createdAt }}</time></header>
<p>{{ pick(comment.content, locale) }}</p>
<div v-if="comment.reply" class="reply"><strong>{{ t("product.reply") }}:</strong> {{ pick(comment.reply, locale) }}</div>
</div>
</article>
</div>
<div v-else class="service-content">
<h2>{{ t("product.tabsAfterSale") }}</h2>
<p>{{ pick(detail.store.afterSale, locale) }}</p>
</div>
</template>
</UiTabs>
</section>
</section>
</main>
<UiEmptyState v-else :text="t('product.productNotFound')" />
</div>
</template>
<style scoped>
.product-page {
min-height: 700px;
padding-bottom: 60px;
}
.product-top {
display: grid;
grid-template-columns: 450px 1fr;
gap: 32px;
min-height: 500px;
background: #fff;
border: 1px solid var(--mall-line);
padding: 24px;
}
.gallery {
width: 400px;
}
.main-image {
display: flex;
width: 400px;
height: 400px;
align-items: center;
justify-content: center;
overflow: hidden;
background: #fff;
border: 1px solid var(--mall-line);
}
.zoom-image {
width: 100%;
height: 100%;
object-fit: contain;
transition: transform 220ms ease;
}
.main-image:hover .zoom-image {
transform: scale(1.07);
}
.thumbs {
display: flex;
gap: 12px;
margin-top: 14px;
}
.thumbs button {
width: 74px;
height: 74px;
padding: 4px;
border: 1px solid var(--mall-line);
background: #fff;
cursor: pointer;
}
.thumbs button.active {
border-color: var(--mall-red);
}
.thumbs img {
width: 100%;
height: 100%;
object-fit: contain;
}
.summary {
min-width: 0;
}
.summary-heading {
display: flex;
justify-content: space-between;
gap: 12px;
}
h1 {
margin: 0;
color: var(--mall-ink);
font-size: 22px;
line-height: 1.35;
}
.subtitle {
margin: 8px 0 18px;
color: var(--mall-muted);
font-size: 13px;
}
.favorite {
align-self: flex-start;
border: 0;
background: transparent;
color: var(--mall-faint);
cursor: pointer;
font-size: 28px;
line-height: 1;
}
.favorite.active {
color: var(--mall-red);
}
.price-panel {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 12px;
background: #fff5f5;
padding: 16px;
}
.price-label,
.market-label,
.label {
color: var(--mall-muted);
font-size: 13px;
}
.price {
color: var(--mall-red);
font-size: 28px;
}
.market-label s {
color: var(--mall-faint);
}
.summary-meta {
display: flex;
gap: 28px;
border-bottom: 1px solid var(--mall-line);
color: var(--mall-muted);
font-size: 12px;
padding: 13px 0;
}
.summary-meta .soldout,
.stock-warning {
color: var(--mall-red);
}
.coupon-row,
.attribute-row,
.quantity-row {
display: flex;
align-items: flex-start;
gap: 18px;
border-bottom: 1px solid var(--mall-line);
padding: 15px 0;
}
.coupon-row > .label,
.attribute-row > .label,
.quantity-row > .label {
width: 58px;
flex: 0 0 58px;
line-height: 30px;
}
.coupon-list,
.attribute-values {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.coupon {
display: inline-flex;
flex-direction: column;
gap: 3px;
border: 1px solid var(--mall-red);
color: var(--mall-red);
font-size: 12px;
padding: 5px 8px;
}
.coupon small {
color: var(--mall-muted);
font-size: 10px;
}
.attribute-values button {
min-width: 66px;
border: 1px solid var(--mall-line-dark);
background: #fff;
color: var(--mall-ink);
cursor: pointer;
font-size: 12px;
padding: 6px 12px;
}
.attribute-values button.active {
border-color: var(--mall-red);
color: var(--mall-red);
}
.actions {
display: flex;
gap: 12px;
margin-top: 22px;
}
.primary-action,
.cart-action {
min-width: 136px;
border: 1px solid var(--mall-red);
cursor: pointer;
font-size: 14px;
padding: 11px 22px;
}
.primary-action {
background: #fff0f0;
color: var(--mall-red);
}
.cart-action {
background: var(--mall-red);
color: #fff;
}
.primary-action:disabled,
.cart-action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.stock-warning {
margin: 10px 0 0;
font-size: 12px;
}
.below-grid {
display: grid;
grid-template-columns: 260px 1fr;
gap: 20px;
margin-top: 20px;
}
.left-rail {
display: flex;
flex-direction: column;
gap: 20px;
}
.store-card,
.sales-rank,
.detail-panel {
background: #fff;
border: 1px solid var(--mall-line);
padding: 18px;
}
.store-card h2,
.sales-rank h2,
.detail-content h2,
.service-content h2 {
border-left: 3px solid var(--mall-red);
color: var(--mall-ink);
font-size: 16px;
margin: 0 0 16px;
padding-left: 8px;
}
.store-heading {
display: flex;
align-items: center;
gap: 9px;
font-size: 13px;
}
.store-heading img {
width: 42px;
height: 42px;
object-fit: contain;
}
dl {
margin: 14px 0;
}
dl div {
display: flex;
gap: 6px;
font-size: 12px;
line-height: 1.6;
}
dt {
color: var(--mall-muted);
flex: 0 0 42px;
}
dd {
margin: 0;
overflow-wrap: anywhere;
}
.store-card h3 {
color: var(--mall-muted);
font-size: 12px;
font-weight: 400;
margin: 12px 0 8px;
}
.rate-row {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--mall-muted);
font-size: 12px;
line-height: 25px;
}
.store-actions {
display: flex;
gap: 6px;
margin-top: 14px;
}
.store-button {
flex: 1;
border: 1px solid var(--mall-line-dark);
background: #fff;
color: var(--mall-ink);
cursor: pointer;
font-size: 11px;
padding: 7px 4px;
text-align: center;
text-decoration: none;
}
.store-button:first-child {
border-color: var(--mall-red);
color: var(--mall-red);
}
.store-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.rank-item {
display: grid;
grid-template-columns: 52px 1fr;
gap: 5px 8px;
border-bottom: 1px solid var(--mall-line);
color: inherit;
padding: 10px 0;
text-decoration: none;
}
.rank-item:last-child {
border-bottom: 0;
}
.rank-item img {
grid-row: 1 / span 2;
width: 52px;
height: 52px;
object-fit: contain;
}
.rank-name {
align-self: end;
font-size: 11px;
line-height: 1.35;
max-height: 30px;
overflow: hidden;
}
.rank-price {
align-self: start;
color: var(--mall-red);
font-size: 12px;
}
.detail-panel {
min-height: 550px;
}
.detail-content p,
.service-content p {
color: var(--mall-muted);
font-size: 13px;
line-height: 1.8;
margin: 0 0 18px;
}
.detail-content > img {
display: block;
width: 100%;
max-height: 500px;
margin: 0 auto 14px;
object-fit: contain;
}
.review-summary {
display: flex;
justify-content: space-between;
border-bottom: 1px solid var(--mall-line);
color: var(--mall-red);
font-size: 13px;
padding: 0 0 15px;
}
.review-summary span {
color: var(--mall-muted);
}
.review {
display: flex;
gap: 12px;
border-bottom: 1px solid var(--mall-line);
padding: 18px 0;
}
.review > img {
width: 38px;
height: 38px;
border-radius: 50%;
}
.review-body {
min-width: 0;
flex: 1;
}
.review-body header {
display: flex;
align-items: center;
gap: 10px;
color: var(--mall-muted);
font-size: 12px;
}
.review-body header strong {
color: var(--mall-ink);
}
.review-body time {
margin-left: auto;
}
.review-body p {
color: var(--mall-ink);
font-size: 13px;
line-height: 1.6;
margin: 9px 0;
}
.reply {
background: #fafafa;
color: var(--mall-muted);
font-size: 12px;
padding: 8px 10px;
}
.reply strong {
color: var(--mall-red);
}
@media (max-width: 900px) {
.product-top,
.below-grid {
grid-template-columns: 1fr;
}
.gallery,
.main-image {
width: 100%;
max-width: 400px;
}
}
</style>