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
256 lines
7.1 KiB
Vue
256 lines
7.1 KiB
Vue
<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>
|