Files
vmall/apps/mall/pages/user/index.vue
T
james 7a2745fb16 feat(api): add customer accounts with live summary and append-only ledger
One balance row per (user, kind, currency): available and frozen carry the
platform base currency, points carries none. Monetary and points rows use
separate partial unique indexes because a plain UNIQUE lets NULL repeat.

Balance changes go through transactional primitives that debits guard with a
conditional update, credits add atomically, and freeze/release move both sides
in one transaction after locking rows by primary key. Every change appends an
immutable entry holding its resulting balance. Registration and a migration
backfill create the zero rows; GET /api/me/stats is the only public surface and
no endpoint mutates a balance.

The mall buyer center and points page drop the USER_STATS fixture for the
shared contract; the fixture stays exported so the fixed-data adapter can still
serve the account domain as a rollback path.

Implements openspec change add-customer-accounts.
2026-09-18 11:54:53 +00:00

292 lines
8.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { AccountSummary, Order, Product } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
import { MOCK_FAVORITES, lowestSku, productById } from "~/mock/data";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const orders = ref<Order[]>([]);
const stats = ref<AccountSummary | null>(null);
const loading = ref(true);
async function loadOrders(): Promise<void> {
loading.value = true;
try {
const result = await $api.listMyOrders();
orders.value = result.items;
} finally {
loading.value = false;
}
}
// Live balances; shows a placeholder rather than a fixture if the call fails.
async function loadStats(): Promise<void> {
try {
stats.value = await $api.getAccountSummary();
} catch {
stats.value = null;
}
}
onMounted(() => {
void loadOrders();
void loadStats();
});
const counts = computed(() => ({
pendingPayment: orders.value.filter((o) => o.status === "pending_payment").length,
pendingShipment: orders.value.filter((o) => o.status === "paid").length,
pendingReceipt: orders.value.filter((o) => o.status === "shipped").length,
completed: orders.value.filter((o) => o.status === "completed").length,
afterSale: 0,
}));
const statusLinks = computed(() => [
{ key: "pendingPayment", label: t("user.pendingPayment"), count: counts.value.pendingPayment, to: "/user/orders?status=pending_payment" },
{ key: "pendingShipment", label: t("user.pendingShipment"), count: counts.value.pendingShipment, to: "/user/orders?status=paid" },
{ key: "pendingReceipt", label: t("user.pendingReceipt"), count: counts.value.pendingReceipt, to: "/user/orders?status=shipped" },
{ key: "completed", label: t("user.completed"), count: counts.value.completed, to: "/user/orders?status=completed" },
{ key: "afterSale", label: t("user.afterSale"), count: counts.value.afterSale, to: "/user/orders?status=after-sale" },
]);
const favoriteProducts = computed(() =>
MOCK_FAVORITES.filter((favorite) => favorite.kind === "product")
.map((favorite) => productById(favorite.refId))
.filter((product): product is Product => product !== null)
.map((product) => {
const sku = lowestSku(product);
return {
product,
amountMinor: sku?.price_minor ?? null,
currency: sku?.currency ?? null,
};
}),
);
</script>
<template>
<div>
<section class="mpanel welcome-panel">
<h1 class="mpanel-title">{{ t("user.dashboard") }}</h1>
<div class="stats-grid">
<div class="stat-card">
<span>{{ t("user.statsBalance") }}</span>
<strong v-if="stats"><PriceText :amount-minor="stats.balance_minor" :currency="stats.currency" /></strong>
<strong v-else>{{ t("common.loading") }}</strong>
</div>
<div class="stat-card">
<span>{{ t("user.statsPoints") }}</span>
<strong>{{ stats ? stats.points : t("common.loading") }}</strong>
</div>
<div class="stat-card">
<span>{{ t("user.statsFrozen") }}</span>
<strong v-if="stats"><PriceText :amount-minor="stats.frozen_minor" :currency="stats.currency" /></strong>
<strong v-else>{{ t("common.loading") }}</strong>
</div>
</div>
<div class="status-links">
<NuxtLink v-for="item in statusLinks" :key="item.key" :to="item.to" class="status-link">
<span>{{ item.label }}</span>
<b>{{ item.count }}</b>
</NuxtLink>
</div>
</section>
<section class="mpanel">
<h2 class="mpanel-title">
{{ t("user.recentOrders") }}
<NuxtLink class="more" to="/user/orders">{{ t("user.viewAll") }} </NuxtLink>
</h2>
<div v-if="loading" class="muted">{{ t("common.loading") }}</div>
<UiEmptyState v-else-if="orders.length === 0" :text="t('user.noOrders')" />
<div v-else class="recent-orders">
<article v-for="order in orders.slice(0, 3)" :key="order.id" class="recent-order">
<header>
<span>{{ t("user.orderNo") }} {{ order.order_no }}</span>
<StatusBadge :status="order.status" kind="order" />
</header>
<div class="order-body">
<div class="item-previews">
<img v-for="item in order.items" :key="item.id" :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" />
</div>
<div class="order-date">{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</div>
<div class="order-total">
<span>{{ t("user.orderTotal") }}</span>
<PriceText :amount-minor="order.total_minor" :currency="order.currency" />
</div>
<NuxtLink class="mbtn" :to="`/user/orders/${order.id}`">{{ t("user.viewDetails") }}</NuxtLink>
</div>
</article>
</div>
</section>
<section class="mpanel">
<h2 class="mpanel-title">{{ t("user.favoriteProducts") }}</h2>
<UiEmptyState v-if="favoriteProducts.length === 0" :text="t('user.noFavorites')" />
<div v-else class="favorite-grid">
<NuxtLink v-for="item in favoriteProducts" :key="item.product.id" :to="`/goods/${item.product.slug}`" class="favorite-card hover-lift">
<img :src="item.product.images[0] || '/mock/product-1.svg'" :alt="pick(item.product.name, locale)" />
<span>{{ pick(item.product.name, locale) }}</span>
<PriceText v-if="item.amountMinor !== null && item.currency" :amount-minor="item.amountMinor" :currency="item.currency" />
</NuxtLink>
</div>
</section>
</div>
</template>
<style scoped>
.welcome-panel {
padding-bottom: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
padding: 18px;
border: 1px solid var(--mall-line);
background: #fffafa;
}
.stat-card span {
display: block;
color: var(--mall-muted);
font-size: 12px;
}
.stat-card strong {
display: block;
margin-top: 10px;
color: var(--mall-red);
font-size: 20px;
}
.status-links {
display: grid;
grid-template-columns: repeat(5, 1fr);
border-top: 1px solid var(--mall-line);
}
.status-link {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 8px;
color: var(--mall-muted);
font-size: 12px;
text-decoration: none;
}
.status-link + .status-link {
border-left: 1px solid var(--mall-line);
}
.status-link b {
color: var(--mall-red);
font-size: 18px;
font-weight: 600;
}
.muted {
color: var(--mall-faint);
padding: 20px 0;
}
.recent-order {
border: 1px solid var(--mall-line);
margin-bottom: 12px;
}
.recent-order:last-child {
margin-bottom: 0;
}
.recent-order header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: #fafafa;
color: var(--mall-muted);
font-size: 12px;
}
.order-body {
display: flex;
align-items: center;
gap: 16px;
padding: 12px;
}
.item-previews {
display: flex;
gap: 5px;
min-width: 140px;
}
.item-previews img {
width: 44px;
height: 44px;
object-fit: contain;
border: 1px solid var(--mall-line);
}
.order-date {
flex: 1;
color: var(--mall-faint);
font-size: 12px;
}
.order-total {
text-align: right;
font-size: 12px;
}
.order-total span {
display: block;
margin-bottom: 3px;
color: var(--mall-faint);
}
.order-total :deep(.price) {
color: var(--mall-red);
font-weight: 600;
}
.favorite-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.favorite-card {
display: block;
padding: 10px;
border: 1px solid var(--mall-line);
color: var(--mall-ink);
text-decoration: none;
}
.favorite-card img {
display: block;
width: 100%;
height: 120px;
object-fit: contain;
margin-bottom: 8px;
}
.favorite-card span {
display: block;
height: 34px;
overflow: hidden;
font-size: 12px;
line-height: 17px;
}
.favorite-card :deep(.price) {
display: block;
margin-top: 8px;
color: var(--mall-red);
font-size: 14px;
font-weight: 600;
}
@media (max-width: 760px) {
.status-links {
grid-template-columns: repeat(3, 1fr);
}
.order-body {
align-items: flex-start;
flex-wrap: wrap;
}
.order-date {
flex: 0 0 calc(100% - 156px);
}
.favorite-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>