- 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
115 lines
6.6 KiB
Vue
115 lines
6.6 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="mb-4 text-xl font-bold text-text">{{ t("user.dashboard") }}</h1>
|
||
<div class="mb-5 grid gap-3 sm:grid-cols-3">
|
||
<div class="border border-border bg-primary-soft p-[18px]"><span class="block text-xs text-muted">{{ t("user.statsBalance") }}</span><strong class="mt-2.5 block text-xl text-primary"><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-border bg-primary-soft p-[18px]"><span class="block text-xs text-muted">{{ t("user.statsPoints") }}</span><strong class="mt-2.5 block text-xl text-primary">{{ stats ? stats.points : t("common.loading") }}</strong></div>
|
||
<div class="border border-border bg-primary-soft p-[18px]"><span class="block text-xs text-muted">{{ t("user.statsFrozen") }}</span><strong class="mt-2.5 block text-xl text-primary"><PriceText v-if="stats" :amount-minor="stats.frozen_minor" :currency="stats.currency" /><template v-else>{{ t("common.loading") }}</template></strong></div>
|
||
</div>
|
||
<div class="grid grid-cols-3 border-t border-border sm:grid-cols-5">
|
||
<NuxtLink v-for="item in statusLinks" :key="item.key" :to="item.to" class="flex flex-col items-center gap-2 px-2 py-4 text-xs text-muted no-underline hover:text-primary sm:border-l sm:border-border first:sm:border-l-0"><span>{{ item.label }}</span><b class="text-lg font-semibold text-primary">{{ item.count }}</b></NuxtLink>
|
||
</div>
|
||
</VCard>
|
||
|
||
<VCard>
|
||
<h2 class="mb-4 flex items-center justify-between text-lg font-semibold text-text">{{ t("user.recentOrders") }} <NuxtLink class="text-sm font-normal text-primary no-underline" to="/user/orders">{{ t("user.viewAll") }} ›</NuxtLink></h2>
|
||
<div v-if="loading" class="py-5 text-muted">{{ 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="flex items-center justify-between gap-3 bg-bg px-3 py-2.5 text-xs text-muted"><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="h-11 w-11 border border-border object-contain" :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" /></div>
|
||
<div class="flex-1 text-xs text-muted">{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</div>
|
||
<div class="text-right text-xs"><span class="mb-1 block text-muted">{{ t("user.orderTotal") }}</span><PriceText class="font-semibold text-primary" :amount-minor="order.total_minor" :currency="order.currency" /></div>
|
||
<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>
|
||
</article>
|
||
</div>
|
||
</VCard>
|
||
|
||
<VCard>
|
||
<h2 class="mb-4 text-lg font-semibold text-text">{{ 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="block border border-border p-2.5 text-text no-underline hover:border-primary">
|
||
<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="mt-2 block text-sm font-semibold text-primary" :amount-minor="item.product.price_minor" :currency="item.product.currency" />
|
||
</NuxtLink>
|
||
</div>
|
||
</VCard>
|
||
</div>
|
||
</template>
|
||
|