feat: persist customer product and shop favorites through the live API

Replace mall fixture favorites with customer-scoped endpoints, and send signed-out shoppers back to the page they left after sign-in.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Chengdong Zhang
2026-09-21 18:51:45 +08:00
co-authored by Cursor
parent 94a64ec712
commit 6c1357ec4d
34 changed files with 1783 additions and 119 deletions
+21 -20
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import type { AccountSummary, Order, Product } from "@vmall/shared";
import type { AccountSummary, Order, ProductFavorite } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
import { MOCK_FAVORITES, lowestSku, productById } from "~/mock/data";
definePageMeta({ middleware: "auth" });
@@ -9,6 +8,8 @@ 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> {
@@ -30,9 +31,23 @@ async function loadStats(): Promise<void> {
}
}
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(() => ({
@@ -50,20 +65,6 @@ const statusLinks = computed(() => [
{ 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" },
]);
const favoriteProducts = computed(() =>
MOCK_FAVORITES.filter((favorite) => favorite.kind === "product")
.map((favorite) => productById(favorite.refId))
.filter((product): product is Product => product !== null)
.map((product) => {
const sku = lowestSku(product);
return {
product,
amountMinor: sku?.price_minor ?? null,
currency: sku?.currency ?? null,
};
}),
);
</script>
<template>
@@ -123,13 +124,13 @@ const favoriteProducts = computed(() =>
</section>
<section class="mpanel">
<h2 class="mpanel-title">{{ t("user.favoriteProducts") }}</h2>
<h2 class="mpanel-title">{{ t("user.favoriteProducts") }} ({{ favoriteProductTotal }})</h2>
<UiEmptyState v-if="favoriteProducts.length === 0" :text="t('user.noFavorites')" />
<div v-else class="favorite-grid">
<NuxtLink v-for="item in favoriteProducts" :key="item.product.id" :to="`/goods/${item.product.slug}`" class="favorite-card hover-lift">
<img :src="item.product.images[0] || '/mock/product-1.svg'" :alt="pick(item.product.name, locale)" />
<NuxtLink v-for="item in favoriteProducts" :key="item.id" :to="`/goods/${item.product.slug}`" class="favorite-card hover-lift">
<img :src="item.product.image || '/mock/product-1.svg'" :alt="pick(item.product.name, locale)" />
<span>{{ pick(item.product.name, locale) }}</span>
<PriceText v-if="item.amountMinor !== null && item.currency" :amount-minor="item.amountMinor" :currency="item.currency" />
<PriceText v-if="item.product.price_minor !== null && item.product.currency" :amount-minor="item.product.price_minor" :currency="item.product.currency" />
</NuxtLink>
</div>
</section>