218 lines
7.7 KiB
Vue
218 lines
7.7 KiB
Vue
<script setup lang="ts">
|
||
import type { AccountSummary, Order, ProductFavorite } from "@vmall/shared";
|
||
import { t as pick } from "@vmall/shared";
|
||
|
||
definePageMeta({ middleware: "auth" });
|
||
|
||
const { locale, t } = useI18n();
|
||
const { $api } = useNuxtApp();
|
||
const orders = ref<Order[]>([]);
|
||
const stats = ref<AccountSummary | null>(null);
|
||
const favoriteProducts = ref<ProductFavorite[]>([]);
|
||
const favoriteProductTotal = ref(0);
|
||
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;
|
||
}
|
||
}
|
||
|
||
async function loadFavorites(): Promise<void> {
|
||
try {
|
||
const page = await $api.listFavorites({ kind: "product", page: 1, per_page: 8 });
|
||
favoriteProducts.value = page.items.filter(
|
||
(row): row is ProductFavorite => row.kind === "product",
|
||
);
|
||
favoriteProductTotal.value = page.total;
|
||
} catch {
|
||
favoriteProducts.value = [];
|
||
favoriteProductTotal.value = 0;
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
void loadOrders();
|
||
void loadStats();
|
||
void loadFavorites();
|
||
});
|
||
|
||
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",
|
||
},
|
||
]);
|
||
</script>
|
||
|
||
<template>
|
||
<div class="space-y-4">
|
||
<VCard class="pb-0">
|
||
<h1 class="text-text mb-4 text-xl font-bold">{{ t("user.dashboard") }}</h1>
|
||
<div class="mb-5 grid gap-3 sm:grid-cols-3">
|
||
<div class="border-border bg-primary-soft border p-[18px]">
|
||
<span class="text-muted block text-xs">{{ t("user.statsBalance") }}</span
|
||
><strong class="text-primary mt-2.5 block text-xl"
|
||
><PriceText
|
||
v-if="stats"
|
||
:amount-minor="stats.balance_minor"
|
||
:currency="stats.currency"
|
||
/><template v-else>{{ t("common.loading") }}</template></strong
|
||
>
|
||
</div>
|
||
<div class="border-border bg-primary-soft border p-[18px]">
|
||
<span class="text-muted block text-xs">{{ t("user.statsPoints") }}</span
|
||
><strong class="text-primary mt-2.5 block text-xl">{{
|
||
stats ? stats.points : t("common.loading")
|
||
}}</strong>
|
||
</div>
|
||
<div class="border-border bg-primary-soft border p-[18px]">
|
||
<span class="text-muted block text-xs">{{ t("user.statsFrozen") }}</span
|
||
><strong class="text-primary mt-2.5 block text-xl"
|
||
><PriceText
|
||
v-if="stats"
|
||
:amount-minor="stats.frozen_minor"
|
||
:currency="stats.currency"
|
||
/><template v-else>{{ t("common.loading") }}</template></strong
|
||
>
|
||
</div>
|
||
</div>
|
||
<div class="border-border grid grid-cols-3 border-t sm:grid-cols-5">
|
||
<NuxtLink
|
||
v-for="item in statusLinks"
|
||
:key="item.key"
|
||
:to="item.to"
|
||
class="text-muted hover:text-primary sm:border-border flex flex-col items-center gap-2 px-2 py-4 text-xs no-underline sm:border-l first:sm:border-l-0"
|
||
><span>{{ item.label }}</span
|
||
><b class="text-primary text-lg font-semibold">{{ item.count }}</b></NuxtLink
|
||
>
|
||
</div>
|
||
</VCard>
|
||
|
||
<VCard>
|
||
<h2 class="text-text mb-4 flex items-center justify-between text-lg font-semibold">
|
||
{{ t("user.recentOrders") }}
|
||
<NuxtLink class="text-primary text-sm font-normal no-underline" to="/user/orders"
|
||
>{{ t("user.viewAll") }} ›</NuxtLink
|
||
>
|
||
</h2>
|
||
<div v-if="loading" class="text-muted py-5">{{ t("common.loading") }}</div>
|
||
<UiEmptyState v-else-if="orders.length === 0" :text="t('user.noOrders')" />
|
||
<div v-else class="space-y-3">
|
||
<article v-for="order in orders.slice(0, 3)" :key="order.id" class="border-border border">
|
||
<header
|
||
class="bg-bg text-muted flex items-center justify-between gap-3 px-3 py-2.5 text-xs"
|
||
>
|
||
<span>{{ t("user.orderNo") }} {{ order.order_no }}</span
|
||
><StatusBadge :status="order.status" kind="order" />
|
||
</header>
|
||
<div class="flex items-center gap-4 p-3 max-sm:flex-wrap">
|
||
<div class="flex min-w-[140px] gap-1">
|
||
<img
|
||
v-for="item in order.items"
|
||
:key="item.id"
|
||
class="border-border h-11 w-11 border object-contain"
|
||
:src="item.image || '/mock/product-1.svg'"
|
||
:alt="pick(item.product_name, locale)"
|
||
/>
|
||
</div>
|
||
<div class="text-muted flex-1 text-xs">
|
||
{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}
|
||
</div>
|
||
<div class="text-right text-xs">
|
||
<span class="text-muted mb-1 block">{{ t("user.orderTotal") }}</span
|
||
><PriceText
|
||
class="text-primary font-semibold"
|
||
:amount-minor="order.total_minor"
|
||
:currency="order.currency"
|
||
/>
|
||
</div>
|
||
<NuxtLink
|
||
class="border-border bg-surface text-text inline-flex items-center rounded-md border px-2.5 py-1 text-[13px] font-medium no-underline"
|
||
:to="`/user/orders/${order.id}`"
|
||
>{{ t("user.viewDetails") }}</NuxtLink
|
||
>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
</VCard>
|
||
|
||
<VCard>
|
||
<h2 class="text-text mb-4 text-lg font-semibold">
|
||
{{ t("user.favoriteProducts") }} ({{ favoriteProductTotal }})
|
||
</h2>
|
||
<UiEmptyState v-if="favoriteProducts.length === 0" :text="t('user.noFavorites')" />
|
||
<div v-else class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||
<NuxtLink
|
||
v-for="item in favoriteProducts"
|
||
:key="item.id"
|
||
:to="`/goods/${item.product.slug}`"
|
||
class="border-border text-text hover:border-primary block border p-2.5 no-underline"
|
||
>
|
||
<img
|
||
class="mb-2 block h-[120px] w-full object-contain"
|
||
:src="item.product.image || '/mock/product-1.svg'"
|
||
:alt="pick(item.product.name, locale)"
|
||
/>
|
||
<span class="block h-[34px] overflow-hidden text-xs leading-[17px]">{{
|
||
pick(item.product.name, locale)
|
||
}}</span>
|
||
<PriceText
|
||
v-if="item.product.price_minor !== null && item.product.currency"
|
||
class="text-primary mt-2 block text-sm font-semibold"
|
||
:amount-minor="item.product.price_minor"
|
||
:currency="item.product.currency"
|
||
/>
|
||
</NuxtLink>
|
||
</div>
|
||
</VCard>
|
||
</div>
|
||
</template>
|