Files
vmall/apps/mall/pages/stores/[id].vue
T
Chengdong Zhang 0d0e10b97b feat(ui): adopt Tailwind v4 design system and archive change
- 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
2026-09-22 18:22:50 +08:00

194 lines
9.8 KiB
Vue

<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;
return typeof raw === "string" ? raw : raw?.[0] ?? "";
});
// A rejected read (unknown or suspended slug) leaves this null, which renders
// the "unknown store" branch rather than an invented profile.
const { data: shop } = await useAsyncData(
// Keyed by slug: Nuxt remounts this page on param change, and a static key
// would serve the previous store's cached entry without re-running the handler.
`shop-profile-${slug.value}`,
() => $api.getShop(slug.value),
{
watch: [slug],
default: () => null,
},
);
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");
const page = ref(1);
const perPage = 12;
const emptyPage: Paged<Product> = { items: [], total: 0, page: 1, per_page: perPage };
const { data: productResult } = await useAsyncData(
`shop-products-${slug.value}`,
() => {
const current = store.value;
if (!current) return Promise.resolve(emptyPage);
return $api.listProducts({
shop_id: current.id,
sort: sortMode.value === "price" ? "price" : undefined,
order: sortMode.value === "price" ? sortOrder.value : undefined,
page: page.value,
per_page: perPage,
});
},
{ watch: [store, sortMode, sortOrder, page], default: () => emptyPage },
);
const breadcrumb = computed(() => [
{ label: t("stores.breadcrumbHome"), to: "/" },
{ label: t("stores.directory"), to: "/stores" },
{ label: store.value ? pick(store.value.name, locale.value) : t("stores.unknownStore") },
]);
/** Only the scores the platform actually set; nothing is defaulted. */
const rateRows = computed(() => {
const current = store.value;
if (!current) return [];
const rows = [
{ label: t("stores.score"), value: current.score_rating },
{ label: t("stores.agreement"), value: current.score_agreement },
{ label: t("stores.service"), value: current.score_service },
{ label: t("stores.speed"), value: current.score_speed },
];
return rows.filter((row): row is { label: string; value: number } => typeof row.value === "number");
});
function priceMinorOf(product: Product): number {
return lowestSku(product)?.price_minor ?? 0;
}
function currencyOf(product: Product): string {
return lowestSku(product)?.currency ?? "USD";
}
function chooseSort(mode: "default" | "price"): void {
if (sortMode.value === mode && mode !== "default") {
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
} else {
sortMode.value = mode;
sortOrder.value = mode === "price" ? "asc" : "desc";
}
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) {
if (version === favoriteRequestVersion && store.value?.id === current.id) {
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>
<div class="min-h-screen bg-bg pb-16 font-sans text-text">
<UiBreadcrumb :items="breadcrumb" />
<template v-if="store">
<div v-if="store.banner" class="mx-auto h-[350px] w-full max-w-mall overflow-hidden bg-bg px-4 max-sm:h-[220px]"><img class="h-full w-full object-cover" :src="store.banner" :alt="pick(store.name, locale)" /></div>
<div class="mx-auto mt-5 grid w-full max-w-mall gap-5 px-4 lg:grid-cols-[270px_minmax(0,1fr)] lg:items-start">
<aside class="min-w-0 space-y-5">
<VCard>
<div class="flex items-center gap-3 border-b border-border pb-3"><img v-if="store.logo" class="h-[58px] w-[58px] border border-border object-contain" :src="store.logo" :alt="pick(store.name, locale)" /><span v-else class="flex h-[58px] w-[58px] items-center justify-center border border-border bg-bg text-lg text-muted">{{ pick(store.name, locale).slice(0, 1) }}</span><div><h1 class="m-0 text-[15px] leading-[1.45]">{{ pick(store.name, locale) }}</h1><p class="mt-1 text-xs text-primary">{{ t("stores.positiveRate") }}</p></div></div>
<div v-if="rateRows.length" class="space-y-1 border-b border-border py-3"><div v-for="row in rateRows" :key="row.label" class="flex min-h-[25px] items-center gap-1 text-xs text-muted"><span class="w-16">{{ row.label }}</span><UiRatingStars :value="row.value" :size="13" /><strong class="ml-auto font-medium text-primary">{{ row.value.toFixed(1) }}</strong></div></div>
<dl class="my-3 text-xs text-muted"><div v-if="store.company" class="my-2 grid grid-cols-[42px_1fr] gap-2"><dt class="text-muted">{{ t("stores.company") }}</dt><dd class="m-0 min-w-0 break-words">{{ store.company }}</dd></div><div v-if="store.region" class="my-2 grid grid-cols-[42px_1fr] gap-2"><dt class="text-muted">{{ t("stores.region") }}</dt><dd class="m-0 min-w-0 break-words">{{ store.region }}</dd></div><div v-if="store.address" class="my-2 grid grid-cols-[42px_1fr] gap-2"><dt class="text-muted">{{ t("stores.address") }}</dt><dd class="m-0 min-w-0 break-words">{{ pick(store.address, locale) }}</dd></div></dl>
<VBtn class="w-full" :class="favorite ? 'border-primary text-primary' : ''" type="button" :disabled="favoriteLoading || favoriteBusy" @click="toggleFavorite">{{ favorite ? t("stores.favorited") : t("stores.favorite") }}</VBtn>
<p v-if="favoriteError" class="mt-1.5 text-xs text-danger">{{ favoriteError }}</p>
</VCard>
<VCard v-if="store.notice || store.after_sale">
<template v-if="store.notice"><h2 class="mb-3 border-l-[3px] border-primary pl-2 text-sm font-medium">{{ t("stores.notice") }}</h2><p class="text-sm text-text">{{ pick(store.notice, locale) }}</p></template>
<template v-if="store.after_sale"><h2 class="mb-3 border-l-[3px] border-primary pl-2 text-sm font-medium">{{ t("stores.afterSale") }}</h2><p class="text-sm text-text">{{ pick(store.after_sale, locale) }}</p></template>
</VCard>
</aside>
<section class="min-w-0">
<header class="flex items-center justify-between border border-border bg-bg px-4 py-3 max-sm:flex-col max-sm:items-start max-sm:gap-2.5"><h2 class="m-0 border-l-[3px] border-primary pl-2 text-[15px] font-medium">{{ t("stores.storeProducts") }}</h2><div class="flex"><button type="button" class="border-0 border-r border-border bg-transparent px-3 py-0 text-xs text-muted hover:text-primary" :class="sortMode === 'default' ? 'font-bold text-primary' : ''" @click="chooseSort('default')">{{ t("stores.sortDefault") }}</button><button type="button" class="border-0 bg-transparent px-3 py-0 text-xs text-muted hover:text-primary" :class="sortMode === 'price' ? 'font-bold text-primary' : ''" @click="chooseSort('price')">{{ t("stores.sortPrice") }}</button></div></header>
<div v-if="productResult.items.length" class="mt-4 grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(220px,1fr))]"><UiProductCard v-for="product in productResult.items" :key="product.id" :product="product" /></div>
<UiEmptyState v-else :text="t('stores.noProducts')" />
<UiPagination :page="productResult.page" :total="productResult.total" :per-page="productResult.per_page" @change="page = $event" />
</section>
</div>
</template>
<div v-else class="mx-auto min-h-[360px] w-full max-w-mall px-4 pt-[60px] text-center"><UiEmptyState :text="t('stores.unknownStore')" /><NuxtLink to="/stores" class="mt-4 inline-flex items-center rounded-md border border-border bg-surface px-4 py-2 text-sm font-medium text-text no-underline">{{ t("stores.backToStores") }}</NuxtLink></div>
</div>
</template>