Files
vmall/apps/mall/pages/user/orders/index.vue
T
james d0b6350d2d feat(mall): read the store directory from the API
Wave 5: the store directory, store home and product-page store card stop reading
MOCK_STORES, and the payment and order surfaces name their shop.

- a `shop_profiles` table beside `shops`, so the identity model both consoles
  consume is untouched, with a public `GET /api/shops` and `GET /api/shops/{slug}`
  and an admin `PUT /api/admin/shops/{id}/profile`
- a shop with no profile is still listed, with the fields absent rather than
  invented; the pages guard every block, and a missing logo renders an
  initial-letter placeholder
- `scripts/seed-demo.mjs` upserts a profile per demo shop, since profiles hang
  off shops that script creates
- payment and order pages resolve shop ids to names from one cached shop read,
  retiring the generic "Shop" label
- three things went rather than being faked, following the wave-1 precedent:
  `distanceKm` and its sort (no geo model), the store home's sales/comments
  sorts, and its "best sellers" rail (no sales model)
- `lowestSku` moved out of the fixed-data module into `apps/mall/utils/product.ts`
  and re-exported, so live pages stop importing the mock module for a pure
  helper

Verified: 28 backend tests green including five new shop tests; all three
frontends build; the directory, store home, store card and order cards all render
real data with no distance or sales claims; the fixed-data rollback still renders
the store surfaces with the backend stopped.

Note: `nuxt build` does not typecheck in this repo (no `typescript.typeCheck`,
no `vue-tsc`), which AGENTS.md implies it does. A re-export used here created no
local binding and broke internal callers at runtime while the build stayed green;
`docs/TBD-migrate-wave.md` records the gap.

OpenSpec change: openspec/changes/replace-mock-api-wave-5
2026-09-17 17:13:32 +00:00

256 lines
7.1 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 { Order, OrderStatus } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const route = useRoute();
// Shared with the store directory; orders carry only a shop id.
const { data: shops } = await useAsyncData("shops", () => $api.listShops(), { default: () => [] });
function shopName(shopId: string): string {
const shop = shops.value.find((entry) => entry.id === shopId);
return shop ? pick(shop.name, locale.value) : t("user.shop");
}
const activeFilter = ref("all");const orders = ref<Order[]>([]);
const page = ref(1);
const total = ref(0);
const perPage = ref(10);
const loading = ref(true);
const workingId = ref("");
const filterStatuses: Record<string, OrderStatus[]> = {
all: [],
pending_payment: ["pending_payment"],
pending_shipment: ["paid", "fulfilling"],
pending_receipt: ["shipped"],
completed: ["completed"],
cancelled: ["cancelled"],
};
const tabs = computed(() => [
{ key: "all", label: t("user.filterAll") },
{ key: "pending_payment", label: t("user.filterPendingPayment") },
{ key: "pending_shipment", label: t("user.filterPendingShipment") },
{ key: "pending_receipt", label: t("user.filterPendingReceipt") },
{ key: "completed", label: t("user.filterCompleted") },
{ key: "cancelled", label: t("user.filterCancelled") },
]);
const filteredOrders = computed(() => {
const statuses = filterStatuses[activeFilter.value] ?? [];
return statuses.length === 0 ? orders.value : orders.value.filter((order) => statuses.includes(order.status));
});
function setInitialFilter(): void {
const queryStatus = String(route.query.status ?? "");
const mapped = queryStatus === "paid" || queryStatus === "fulfilling"
? "pending_shipment"
: queryStatus === "shipped"
? "pending_receipt"
: queryStatus === "pending_payment" || queryStatus === "completed" || queryStatus === "cancelled"
? queryStatus
: "all";
activeFilter.value = mapped;
}
async function loadOrders(nextPage = page.value): Promise<void> {
loading.value = true;
try {
const result = await $api.listMyOrders(nextPage);
orders.value = result.items;
page.value = result.page;
total.value = result.total;
perPage.value = result.per_page;
} finally {
loading.value = false;
}
}
async function pay(order: Order): Promise<void> {
workingId.value = order.id;
try {
await $api.payOrder(order.id);
await loadOrders();
} finally {
workingId.value = "";
}
}
async function cancel(order: Order): Promise<void> {
workingId.value = order.id;
try {
await $api.cancelOrder(order.id);
await loadOrders();
} finally {
workingId.value = "";
}
}
function changeFilter(key: string): void {
activeFilter.value = key;
}
onMounted(() => {
setInitialFilter();
void loadOrders();
});
</script>
<template>
<section class="mpanel orders-panel">
<h1 class="mpanel-title">{{ t("user.ordersTitle") }}</h1>
<UiTabs :model-value="activeFilter" :tabs="tabs" @update:model-value="changeFilter">
<template #default>
<div v-if="loading" class="muted">{{ t("common.loading") }}</div>
<UiEmptyState v-else-if="filteredOrders.length === 0" :text="t('user.noOrders')" />
<div v-else class="order-list">
<article v-for="order in filteredOrders" :key="order.id" class="order-card">
<header class="order-header">
<div>
<strong>{{ shopName(order.shop_id) }}</strong>
<span>{{ t("user.orderNo") }} {{ order.order_no }}</span>
<span>{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</span>
</div>
<StatusBadge :status="order.status" kind="order" />
</header>
<div class="order-items">
<div v-for="item in order.items" :key="item.id" class="order-item">
<img :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" />
<div class="item-name">
<span>{{ pick(item.product_name, locale) }}</span>
<small>{{ item.sku_code }} × {{ item.qty }}</small>
</div>
<PriceText :amount-minor="item.unit_price_minor" :currency="order.currency" />
</div>
</div>
<footer class="order-footer">
<span>{{ t("user.orderTotal") }} <strong><PriceText :amount-minor="order.total_minor" :currency="order.currency" /></strong></span>
<div class="actions">
<button v-if="order.status === 'pending_payment'" class="mbtn red" type="button" :disabled="workingId === order.id" @click="pay(order)">{{ t("user.payNow") }}</button>
<button v-if="order.status === 'pending_payment'" class="mbtn" type="button" :disabled="workingId === order.id" @click="cancel(order)">{{ t("user.cancelOrder") }}</button>
<NuxtLink class="mbtn" :to="`/user/orders/${order.id}`">{{ t("user.viewDetails") }}</NuxtLink>
</div>
</footer>
</article>
</div>
<UiPagination :page="page" :total="total" :per-page="perPage" @change="loadOrders" />
</template>
</UiTabs>
</section>
</template>
<style scoped>
.orders-panel {
min-height: 560px;
}
.muted {
padding: 30px 0;
color: var(--mall-faint);
}
.order-card {
margin-bottom: 14px;
border: 1px solid var(--mall-line);
}
.order-card:last-child {
margin-bottom: 0;
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 12px 14px;
background: #fafafa;
border-bottom: 1px solid var(--mall-line);
}
.order-header > div {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
color: var(--mall-faint);
font-size: 12px;
}
.order-header strong {
color: var(--mall-ink);
font-size: 13px;
font-weight: 600;
}
.order-items {
padding: 4px 14px;
}
.order-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
}
.order-item + .order-item {
border-top: 1px solid var(--mall-line);
}
.order-item img {
width: 54px;
height: 54px;
border: 1px solid var(--mall-line);
object-fit: contain;
}
.item-name {
display: flex;
flex: 1;
flex-direction: column;
gap: 5px;
min-width: 0;
font-size: 13px;
}
.item-name span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-name small {
color: var(--mall-faint);
font-size: 11px;
}
.order-item :deep(.price) {
color: var(--mall-muted);
font-size: 13px;
}
.order-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-top: 1px solid var(--mall-line);
color: var(--mall-muted);
font-size: 12px;
}
.order-footer strong :deep(.price) {
color: var(--mall-red);
font-size: 16px;
}
.actions {
display: flex;
gap: 8px;
}
.actions .mbtn {
padding: 5px 11px;
}
@media (max-width: 760px) {
.order-header > div {
align-items: flex-start;
flex-direction: column;
gap: 3px;
}
.order-footer {
align-items: flex-start;
flex-direction: column;
}
}
</style>