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
+82 -1
View File
@@ -1,11 +1,15 @@
<script setup lang="ts">
import { ApiError } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
import type { Paged, Product } from "@vmall/shared";
import { lowestSku } from "~/utils/product";
import { useSessionStore } from "~/stores/session";
const route = useRoute();
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const session = useSessionStore();
const signInThenReturn = useSignInRedirect();
const slug = computed(() => {
const raw = route.params.id;
@@ -21,6 +25,10 @@ const { data: shop } = await useAsyncData("shop-profile", () => $api.getShop(slu
const store = computed(() => shop.value);
const favorite = ref(false);
const favoriteLoading = ref(false);
const favoriteBusy = ref(false);
const favoriteError = ref("");
let favoriteRequestVersion = 0;
// Only sorts the catalog model can answer; sales and comments have no model.
const sortMode = ref<"default" | "price">("default");
const sortOrder = ref<"asc" | "desc">("desc");
@@ -80,6 +88,67 @@ function chooseSort(mode: "default" | "price"): void {
}
page.value = 1;
}
watch(
() => [store.value?.id, session.isLoggedIn] as const,
async ([id, loggedIn]) => {
const version = ++favoriteRequestVersion;
favorite.value = false;
favoriteLoading.value = Boolean(id && loggedIn);
favoriteBusy.value = false;
favoriteError.value = "";
if (!id || !loggedIn) return;
try {
const result = await $api.listFavorites({ kind: "shop", target_id: id, per_page: 1 });
if (version === favoriteRequestVersion && store.value?.id === id) {
favorite.value = result.items.length > 0;
}
} catch {
if (version === favoriteRequestVersion && store.value?.id === id) {
favoriteError.value = t("stores.favoriteLoadFailed");
}
} finally {
if (version === favoriteRequestVersion) favoriteLoading.value = false;
}
},
{ immediate: true },
);
const toggleFavorite = async (): Promise<void> => {
const current = store.value;
if (!current || favoriteLoading.value || favoriteBusy.value) return;
if (!session.isLoggedIn) {
await signInThenReturn();
return;
}
const version = ++favoriteRequestVersion;
favoriteBusy.value = true;
favoriteError.value = "";
try {
if (favorite.value) {
await $api.removeShopFavorite(current.id);
if (version === favoriteRequestVersion && store.value?.id === current.id) {
favorite.value = false;
}
} else {
await $api.addShopFavorite(current.id);
if (version === favoriteRequestVersion && store.value?.id === current.id) {
favorite.value = true;
}
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
await signInThenReturn();
return;
}
if (version === favoriteRequestVersion && store.value?.id === current.id) {
favoriteError.value = t("stores.favoriteFailed");
}
} finally {
if (version === favoriteRequestVersion) favoriteBusy.value = false;
}
};
</script>
<template>
@@ -112,9 +181,16 @@ function chooseSort(mode: "default" | "price"): void {
<div v-if="store.region"><dt>{{ t("stores.region") }}</dt><dd>{{ store.region }}</dd></div>
<div v-if="store.address"><dt>{{ t("stores.address") }}</dt><dd>{{ pick(store.address, locale) }}</dd></div>
</dl>
<button type="button" class="mbtn favorite-button" :class="{ selected: favorite }" @click="favorite = !favorite">
<button
type="button"
class="mbtn favorite-button"
:class="{ selected: favorite }"
:disabled="favoriteLoading || favoriteBusy"
@click="toggleFavorite"
>
{{ favorite ? t("stores.favorited") : t("stores.favorite") }}
</button>
<p v-if="favoriteError" class="favorite-error">{{ favoriteError }}</p>
</section>
<section v-if="store.notice || store.after_sale" class="mpanel notice-card">
@@ -253,6 +329,11 @@ function chooseSort(mode: "default" | "price"): void {
border-color: var(--mall-red);
color: var(--mall-red);
}
.favorite-error {
margin: 6px 0 0;
color: var(--mall-red);
font-size: 12px;
}
.rail-title {
margin: 0 0 12px;
border-left: 3px solid var(--mall-red);