- 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
194 lines
9.8 KiB
Vue
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>
|
|
|