- Add tailwindcss v4 + @tailwindcss/vite to mall, shop-admin, admin - Add @vmall/shared/theme.css tokens with html[data-accent] presets - Add @vmall/ui kit (VBtn/VBadge/VField/VInput/VCard/VPanel/VTable/VPage, VAccentSwatch, useAccent) as a Nuxt module - Convert all three apps to kit + utilities; delete ui.css/mall.css and every <style scoped>; consoles get accent presets, mall locked to red - Fix VCard boolean prop default (padding) and PDP/store stale useAsyncData keys on param navigation - Archive adopt-tailwind-design-system; new frontend-ui capability spec
140 lines
5.9 KiB
Vue
140 lines
5.9 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>
|
||
<VCard class="min-h-[560px]">
|
||
<h1 class="mb-4 text-xl font-bold text-text">{{ t("user.ordersTitle") }}</h1>
|
||
<UiTabs :model-value="activeFilter" :tabs="tabs" @update:model-value="changeFilter">
|
||
<template #default>
|
||
<div v-if="loading" class="py-[30px] text-muted">{{ t("common.loading") }}</div>
|
||
<UiEmptyState v-else-if="filteredOrders.length === 0" :text="t('user.noOrders')" />
|
||
<div v-else class="space-y-3.5">
|
||
<VCard v-for="order in filteredOrders" :key="order.id" :padded="false" class="overflow-hidden">
|
||
<header class="flex items-center justify-between gap-2.5 border-b border-border bg-bg px-3.5 py-3 text-xs text-muted max-sm:items-start">
|
||
<div class="flex min-w-0 items-center gap-3.5 max-sm:flex-col max-sm:items-start max-sm:gap-1"><strong class="text-[13px] font-semibold text-text">{{ 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="px-3.5 py-1">
|
||
<div v-for="item in order.items" :key="item.id" class="flex items-center gap-3 border-b border-border py-2.5 last:border-b-0">
|
||
<img class="h-[54px] w-[54px] border border-border object-contain" :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" />
|
||
<div class="flex min-w-0 flex-1 flex-col gap-1 text-[13px]"><span class="truncate">{{ pick(item.product_name, locale) }}</span><small class="text-[11px] text-muted">{{ item.sku_code }} × {{ item.qty }}</small></div>
|
||
<PriceText class="text-[13px] text-muted" :amount-minor="item.unit_price_minor" :currency="order.currency" />
|
||
</div>
|
||
</div>
|
||
<footer class="flex items-center justify-between gap-3 border-t border-border px-3.5 py-3 text-xs text-muted max-sm:flex-col max-sm:items-start">
|
||
<span>{{ t("user.orderTotal") }} <strong class="text-primary"><PriceText :amount-minor="order.total_minor" :currency="order.currency" /></strong></span>
|
||
<div class="flex gap-2">
|
||
<VBtn v-if="order.status === 'pending_payment'" variant="primary" size="sm" type="button" :disabled="workingId === order.id" @click="pay(order)">{{ t("user.payNow") }}</VBtn>
|
||
<VBtn v-if="order.status === 'pending_payment'" size="sm" type="button" :disabled="workingId === order.id" @click="cancel(order)">{{ t("user.cancelOrder") }}</VBtn>
|
||
<NuxtLink class="inline-flex items-center rounded-md border border-border bg-surface px-2.5 py-1 text-[13px] font-medium text-text no-underline" :to="`/user/orders/${order.id}`">{{ t("user.viewDetails") }}</NuxtLink>
|
||
</div>
|
||
</footer>
|
||
</VCard>
|
||
</div>
|
||
<UiPagination :page="page" :total="total" :per-page="perPage" @change="loadOrders" />
|
||
</template>
|
||
</UiTabs>
|
||
</VCard>
|
||
</template>
|
||
|