- 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
334 lines
18 KiB
Vue
334 lines
18 KiB
Vue
<script setup lang="ts">
|
|
import { t as pick } from "@vmall/shared";
|
|
import { ApiError } from "@vmall/shared";
|
|
import type { Category, CouponTemplate, Product, Sku } from "@vmall/shared";
|
|
import { lowestSku } from "~/utils/product";
|
|
import { useCartStore } from "~/stores/cart";
|
|
import { useSessionStore } from "~/stores/session";
|
|
|
|
type AttributeGroup = { key: string; values: string[] };
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const { locale, t } = useI18n();
|
|
const { $api } = useNuxtApp();
|
|
const cart = useCartStore();
|
|
const session = useSessionStore();
|
|
const signInThenReturn = useSignInRedirect();
|
|
|
|
const routeId = computed(() => {
|
|
const value = route.params.id;
|
|
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
|
});
|
|
// One request key for the whole page: the ranking rail must be derived from the
|
|
// product that was just fetched, and chaining two useAsyncData calls left the
|
|
// second handler running before the first had data.
|
|
const { data: pageData } = await useAsyncData(
|
|
// Keyed by route param: Nuxt remounts this page on param change, and a
|
|
// static key would serve the previous product's cached entry without
|
|
// re-running the handler.
|
|
`product-detail-${routeId.value}`,
|
|
async () => {
|
|
const current = await $api.getProduct(routeId.value);
|
|
// The rail uses live catalogue products so its links resolve; the store
|
|
// card, comments and coupons stay local display-only content (non-goals).
|
|
// Store best sellers, ranking the shop's own products. Ranking by category
|
|
// would empty the rail for any product alone in its leaf category.
|
|
const siblings = await $api.listProducts({
|
|
shop_id: current.shop_id,
|
|
per_page: 6,
|
|
});
|
|
// Claimable coupons are live; the store card stays local display content.
|
|
// A coupon failure must not blank the product page.
|
|
const coupons = await $api
|
|
.listShopCouponTemplates(current.shop_id)
|
|
.catch(() => [] as CouponTemplate[]);
|
|
return {
|
|
product: current,
|
|
related: siblings.items.filter((item) => item.id !== current.id).slice(0, 5),
|
|
coupons,
|
|
};
|
|
},
|
|
{ watch: [routeId], default: () => null },
|
|
);
|
|
|
|
const product = computed(() => pageData.value?.product ?? null);
|
|
const related = computed<Product[]>(() => pageData.value?.related ?? []);
|
|
|
|
// Shared with the store directory and the order surfaces.
|
|
const { data: shops } = await useAsyncData("shops", () => $api.listShops(), { default: () => [] });
|
|
|
|
const detail = computed(() => {
|
|
const current = product.value;
|
|
if (!current) return null;
|
|
return {
|
|
product: current,
|
|
// Null when the shop has no public profile; the card is then not rendered.
|
|
store: shops.value.find((shop) => shop.id === current.shop_id) ?? null,
|
|
coupons: pageData.value?.coupons ?? [],
|
|
salesRank: related.value,
|
|
};
|
|
});
|
|
|
|
const claimingId = ref("");
|
|
const claimError = ref("");
|
|
const claimedIds = ref<Set<string>>(new Set());
|
|
|
|
async function claim(coupon: CouponTemplate): Promise<void> {
|
|
if (!session.isLoggedIn) {
|
|
await signInThenReturn();
|
|
return;
|
|
}
|
|
claimingId.value = coupon.id;
|
|
claimError.value = "";
|
|
try {
|
|
const owned = await $api.claimCoupon(coupon.id);
|
|
claimedIds.value = new Set(claimedIds.value).add(owned.template_id ?? coupon.id);
|
|
} catch {
|
|
claimError.value = t("product.claimFailed");
|
|
} finally {
|
|
claimingId.value = "";
|
|
}
|
|
}
|
|
|
|
const store = computed(() => detail.value?.store ?? null);
|
|
const selectedAttributes = reactive<Record<string, string>>({});
|
|
const quantity = ref(1);
|
|
const galleryIndex = ref(0);
|
|
const favorite = ref(false);
|
|
const favoriteLoading = ref(false);
|
|
const favoriteBusy = ref(false);
|
|
const favoriteError = ref("");
|
|
let favoriteRequestVersion = 0;
|
|
const cartSuccess = ref(false);
|
|
const activeTab = ref("detail");
|
|
|
|
const attributeGroups = computed<AttributeGroup[]>(() => {
|
|
const valuesByKey = new Map<string, Set<string>>();
|
|
for (const sku of product.value?.skus ?? []) {
|
|
if (!sku.active) continue;
|
|
for (const [key, value] of Object.entries(sku.attributes)) {
|
|
const values = valuesByKey.get(key) ?? new Set<string>();
|
|
values.add(value);
|
|
valuesByKey.set(key, values);
|
|
}
|
|
}
|
|
return [...valuesByKey.entries()].map(([key, values]) => ({ key, values: [...values] }));
|
|
});
|
|
|
|
watch(
|
|
detail,
|
|
(current) => {
|
|
for (const key of Object.keys(selectedAttributes)) delete selectedAttributes[key];
|
|
const initial = current ? (current.product.skus?.find((sku) => sku.active) ?? lowestSku(current.product)) : null;
|
|
if (initial) Object.assign(selectedAttributes, initial.attributes);
|
|
quantity.value = 1;
|
|
galleryIndex.value = 0;
|
|
cartSuccess.value = false;
|
|
activeTab.value = "detail";
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
const matchedSku = computed<Sku | null>(() => {
|
|
const skus = product.value?.skus ?? [];
|
|
const keys = attributeGroups.value.map((group) => group.key);
|
|
if (!keys.every((key) => selectedAttributes[key])) return null;
|
|
return skus.find(
|
|
(sku) => sku.active && keys.every((key) => sku.attributes[key] === selectedAttributes[key]),
|
|
) ?? null;
|
|
});
|
|
|
|
|
|
const stock = computed(() => matchedSku.value?.stock ?? 0);
|
|
const marketPrice = computed(() => {
|
|
const amount = matchedSku.value?.price_minor;
|
|
return amount === undefined ? 0 : Math.round((amount * 115) / 100);
|
|
});
|
|
const currentImage = computed(() => product.value?.images[galleryIndex.value] ?? product.value?.images[0] ?? "/mock/product-1.svg");
|
|
|
|
// Shared with the shell's category menu, so the tree is not refetched.
|
|
const { data: categories } = await useAsyncData("shell-categories", () => $api.listCategories());
|
|
const categoryById = (id: string): Category | undefined =>
|
|
(categories.value ?? []).find((category) => category.id === id);
|
|
const categoryPath = computed(() => {
|
|
const path: Category[] = [];
|
|
let current = product.value?.category_id ? categoryById(product.value.category_id) : undefined;
|
|
while (current) {
|
|
path.unshift(current);
|
|
current = current.parent_id ? categoryById(current.parent_id) : undefined;
|
|
}
|
|
return path;
|
|
});
|
|
|
|
const breadcrumbItems = computed(() => [
|
|
{ label: t("product.home"), to: "/" },
|
|
...categoryPath.value.map((category) => ({
|
|
label: pick(category.name, locale.value),
|
|
to: `/search?category=${category.id}`,
|
|
})),
|
|
{ label: product.value ? pick(product.value.name, locale.value) : t("product.productNotFound") },
|
|
]);
|
|
|
|
const selectAttribute = (key: string, value: string): void => {
|
|
selectedAttributes[key] = value;
|
|
quantity.value = 1;
|
|
cartSuccess.value = false;
|
|
};
|
|
|
|
const addToCart = async (): Promise<boolean> => {
|
|
if (!matchedSku.value || stock.value <= 0) return false;
|
|
try {
|
|
await $api.addCartItem(matchedSku.value.id, quantity.value);
|
|
} catch (error) {
|
|
// A live cart needs a token; send a signed-out shopper to sign in and back.
|
|
if (error instanceof ApiError && error.status === 401) {
|
|
await signInThenReturn();
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
await cart.refresh();
|
|
return true;
|
|
};
|
|
|
|
const buyNow = async (): Promise<void> => {
|
|
if (await addToCart()) await router.push("/checkout");
|
|
};
|
|
|
|
const addCart = async (): Promise<void> => {
|
|
if (await addToCart()) {
|
|
cartSuccess.value = true;
|
|
window.setTimeout(() => {
|
|
cartSuccess.value = false;
|
|
}, 1800);
|
|
}
|
|
};
|
|
|
|
watch(
|
|
() => [product.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 page = await $api.listFavorites({ kind: "product", target_id: id, per_page: 1 });
|
|
if (version === favoriteRequestVersion && product.value?.id === id) {
|
|
favorite.value = page.items.length > 0;
|
|
}
|
|
} catch {
|
|
if (version === favoriteRequestVersion && product.value?.id === id) {
|
|
favoriteError.value = t("product.favoriteLoadFailed");
|
|
}
|
|
} finally {
|
|
if (version === favoriteRequestVersion) favoriteLoading.value = false;
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
const toggleFavorite = async (): Promise<void> => {
|
|
const current = product.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.removeProductFavorite(current.id);
|
|
if (version === favoriteRequestVersion && product.value?.id === current.id) {
|
|
favorite.value = false;
|
|
}
|
|
} else {
|
|
await $api.addProductFavorite(current.id);
|
|
if (version === favoriteRequestVersion && product.value?.id === current.id) {
|
|
favorite.value = true;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof ApiError && error.status === 401) {
|
|
if (version === favoriteRequestVersion && product.value?.id === current.id) {
|
|
await signInThenReturn();
|
|
}
|
|
return;
|
|
}
|
|
if (version === favoriteRequestVersion && product.value?.id === current.id) {
|
|
favoriteError.value = t("product.favoriteFailed");
|
|
}
|
|
} finally {
|
|
if (version === favoriteRequestVersion) favoriteBusy.value = false;
|
|
}
|
|
};
|
|
|
|
// No reviews tab: there is no reviews capability, and the mall will not present
|
|
// invented reviewers and ratings as fact. See the wave-6 design.
|
|
const tabs = computed(() => [
|
|
{ key: "detail", label: t("product.tabsDetail") },
|
|
{ key: "service", label: t("product.tabsAfterSale") },
|
|
]);
|
|
|
|
/** Only the scores the platform actually set; nothing is defaulted. */
|
|
const rateRows = computed(() => {
|
|
const profile = store.value;
|
|
if (!profile) return [];
|
|
const rows = [
|
|
{ key: "overall", value: profile.score_rating, label: t("product.overall") },
|
|
{ key: "service", value: profile.score_service, label: t("product.service") },
|
|
{ key: "delivery", value: profile.score_speed, label: t("product.delivery") },
|
|
{ key: "agree", value: profile.score_agreement, label: t("product.quality") },
|
|
];
|
|
return rows.filter(
|
|
(row): row is { key: string; value: number; label: string } => typeof row.value === "number",
|
|
);
|
|
});
|
|
|
|
const detailImages = computed(() => product.value?.images ?? []);
|
|
</script>
|
|
|
|
<template>
|
|
<div class="min-h-screen bg-bg pb-16 font-sans text-text">
|
|
<UiBreadcrumb :items="breadcrumbItems" />
|
|
<main v-if="detail" class="mx-auto w-full max-w-mall px-4">
|
|
<section class="grid overflow-hidden rounded-lg border border-border bg-surface shadow-sm lg:grid-cols-2">
|
|
<div class="p-5">
|
|
<div class="flex aspect-square items-center justify-center overflow-hidden bg-bg"><img class="h-full w-full object-contain transition-transform hover:scale-105" :src="currentImage" :alt="pick(detail.product.name, locale)" /></div>
|
|
<div class="mt-3 flex gap-2" role="list"><button v-for="(image, index) in detail.product.images.slice(0, 3)" :key="`${image}-${index}`" type="button" class="h-16 w-16 overflow-hidden rounded-sm border border-border bg-surface p-0 hover:border-primary" :class="galleryIndex === index ? 'border-2 border-primary' : ''" :aria-label="`${t('product.detail')} ${index + 1}`" @click="galleryIndex = index"><img class="h-full w-full object-contain" :src="image" :alt="pick(detail.product.name, locale)" loading="lazy" /></button></div>
|
|
</div>
|
|
|
|
<div class="border-l border-border p-5 max-lg:border-l-0 max-lg:border-t">
|
|
<div class="flex items-start justify-between gap-3"><div><h1 class="m-0 text-xl font-bold text-text">{{ pick(detail.product.name, locale) }}</h1><p class="mt-2 text-sm leading-relaxed text-muted">{{ pick(detail.product.description, locale) }}</p></div><VBtn size="sm" type="button" :disabled="favoriteLoading || favoriteBusy" :aria-label="favorite ? t('product.unfavorite') : t('product.favorite')" @click="toggleFavorite">{{ favorite ? "♥" : "♡" }}</VBtn></div>
|
|
<p v-if="favoriteError" class="my-2 text-sm text-danger">{{ favoriteError }}</p>
|
|
<div class="my-4 flex flex-wrap items-baseline gap-2 bg-primary-soft px-4 py-3"><span class="text-xs text-muted">{{ t("product.currentPrice") }}</span><strong v-if="matchedSku" class="text-2xl font-semibold text-primary"><PriceText :amount-minor="matchedSku.price_minor" :currency="matchedSku.currency" /></strong><strong v-else class="text-2xl font-semibold text-primary">{{ t("product.unavailable") }}</strong><span v-if="matchedSku" class="text-xs text-muted">{{ t("product.marketPrice") }} <s><PriceText :amount-minor="marketPrice" :currency="matchedSku.currency" /></s></span></div>
|
|
<div class="mb-4 flex gap-5 text-xs text-muted"><span>{{ t("product.sold", { n: detail.product.sold_count }) }}</span><span :class="stock <= 0 ? 'text-danger' : ''">{{ stock > 0 ? t("product.stock", { n: stock }) : t("product.noStock") }}</span></div>
|
|
|
|
<div v-if="detail.coupons.length > 0" class="mb-4 flex gap-3"><span class="w-16 shrink-0 text-sm text-muted">{{ t("product.coupons") }}</span><div class="flex flex-wrap gap-2"><span v-for="coupon in detail.coupons" :key="coupon.id" class="rounded-sm border border-primary/30 bg-primary-soft px-2 py-1 text-xs text-primary">{{ pick(coupon.title, locale) }} <small class="block text-[11px] text-muted">{{ t("product.expires", { date: coupon.ends_at.slice(0, 10) }) }}</small><VBtn class="mt-1" size="sm" type="button" :disabled="claimingId === coupon.id || claimedIds.has(coupon.id)" @click="claim(coupon)">{{ claimedIds.has(coupon.id) ? t("product.claimed") : t("product.claim") }}</VBtn></span><span v-if="claimError" class="text-sm text-danger">{{ claimError }}</span></div></div>
|
|
|
|
<div v-for="group in attributeGroups" :key="group.key" class="mb-3 flex gap-3"><span class="w-16 shrink-0 pt-1 text-sm text-muted">{{ group.key }}</span><div class="flex flex-wrap gap-2"><button v-for="value in group.values" :key="value" type="button" class="rounded-md border border-border bg-surface px-3 py-1.5 text-sm text-text hover:border-primary hover:text-primary" :class="selectedAttributes[group.key] === value ? 'border-primary bg-primary-soft text-primary' : ''" @click="selectAttribute(group.key, value)">{{ value }}</button></div></div>
|
|
<div class="mb-4 flex items-center gap-3"><span class="w-16 shrink-0 text-sm text-muted">{{ t("product.quantity") }}</span><UiQtyStepper v-model="quantity" :max="Math.max(1, stock)" /></div>
|
|
<div class="flex flex-wrap gap-2"><VBtn variant="primary" type="button" :disabled="!matchedSku || stock <= 0" @click="buyNow">{{ t("product.buyNow") }}</VBtn><VBtn type="button" :disabled="!matchedSku || stock <= 0" @click="addCart">{{ cartSuccess ? t("product.addedCart") : t("product.addCart") }}</VBtn></div>
|
|
<p v-if="matchedSku && stock <= 0" class="my-2 text-sm text-danger">{{ t("product.noStock") }}</p><p v-else-if="!matchedSku" class="my-2 text-sm text-danger">{{ t("product.unavailable") }}</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="mt-5 grid gap-5 lg:grid-cols-[260px_minmax(0,1fr)]">
|
|
<aside class="space-y-5">
|
|
<VCard v-if="detail.store">
|
|
<h2 class="mb-3 border-l-[3px] border-primary pl-2 text-sm font-medium">{{ t("product.store") }}</h2><div class="flex items-center gap-2.5 border-b border-border pb-3"><img v-if="detail.store.logo" class="h-12 w-12 border border-border object-contain" :src="detail.store.logo" :alt="pick(detail.store.name, locale)" /><strong class="text-sm">{{ pick(detail.store.name, locale) }}</strong></div><dl class="my-3 text-xs text-muted"><div v-if="detail.store.company" class="my-2 flex justify-between gap-2"><dt>{{ t("product.company") }}</dt><dd class="m-0 text-right">{{ detail.store.company }}</dd></div><div v-if="detail.store.region" class="my-2 flex justify-between gap-2"><dt>{{ t("product.region") }}</dt><dd class="m-0 text-right">{{ detail.store.region }}</dd></div></dl><template v-if="rateRows.length"><h3 class="mb-2 text-xs font-medium">{{ t("product.rates") }}</h3><div v-for="row in rateRows" :key="row.key" class="flex items-center justify-between py-1 text-xs text-muted"><span>{{ row.label }}</span><UiRatingStars :value="row.value" :size="13" /></div></template><div class="mt-3 grid gap-2"><NuxtLink class="rounded-md border border-primary bg-primary px-3 py-2 text-center text-sm font-medium text-white no-underline hover:bg-primary-hover" :to="`/stores/${detail.store.slug}`">{{ t("product.enterStore") }}</NuxtLink><VBtn type="button" disabled>{{ t("product.contact") }}</VBtn></div>
|
|
</VCard>
|
|
<VCard><h2 class="mb-2 border-l-[3px] border-primary pl-2 text-sm font-medium">{{ t("product.salesRank") }}</h2><NuxtLink v-for="item in detail.salesRank" :key="item.id" :to="`/goods/${item.slug}`" class="grid grid-cols-[48px_minmax(0,1fr)] gap-2 border-t border-border py-2 text-xs text-text no-underline"><img class="h-12 w-12 object-contain" :src="item.images[0]" :alt="pick(item.name, locale)" loading="lazy" /><span class="min-w-0 self-center truncate">{{ pick(item.name, locale) }}<span v-if="lowestSku(item)" class="mt-1 block text-right text-primary"><PriceText :amount-minor="lowestSku(item)!.price_minor" :currency="lowestSku(item)!.currency" /></span></span></NuxtLink></VCard>
|
|
</aside>
|
|
<VCard :padded="false" class="overflow-hidden"><UiTabs v-model="activeTab" :tabs="tabs"><template #default="{ active }"><div v-if="active === 'detail'" class="space-y-3 p-5"><h2 class="text-lg font-semibold">{{ t("product.description") }}</h2><p class="text-sm leading-relaxed text-text">{{ pick(detail.product.description, locale) }}</p><img v-for="(image, index) in detailImages" :key="`${image}-detail-${index}`" class="w-full object-contain" :src="image" :alt="t('product.detail')" loading="lazy" /></div><div v-else class="space-y-3 p-5"><h2 class="text-lg font-semibold">{{ t("product.tabsAfterSale") }}</h2><p v-if="detail.store?.after_sale" class="text-sm leading-relaxed text-text">{{ pick(detail.store.after_sale, locale) }}</p></div></template></UiTabs></VCard>
|
|
</section>
|
|
</main>
|
|
<UiEmptyState v-else :text="t('product.productNotFound')" />
|
|
</div>
|
|
</template>
|
|
|