537 lines
21 KiB
Vue
537 lines
21 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="bg-bg text-text min-h-screen pb-16 font-sans">
|
|
<UiBreadcrumb :items="breadcrumbItems" />
|
|
<main v-if="detail" class="max-w-mall mx-auto w-full px-4">
|
|
<section
|
|
class="border-border bg-surface grid overflow-hidden rounded-lg border shadow-sm lg:grid-cols-2"
|
|
>
|
|
<div class="p-5">
|
|
<div class="bg-bg flex aspect-square items-center justify-center overflow-hidden">
|
|
<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="border-border bg-surface hover:border-primary h-16 w-16 overflow-hidden rounded-sm border p-0"
|
|
:class="galleryIndex === index ? 'border-primary border-2' : ''"
|
|
: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-border border-l p-5 max-lg:border-t max-lg:border-l-0">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h1 class="text-text m-0 text-xl font-bold">
|
|
{{ pick(detail.product.name, locale) }}
|
|
</h1>
|
|
<p class="text-muted mt-2 text-sm leading-relaxed">
|
|
{{ 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="text-danger my-2 text-sm">{{ favoriteError }}</p>
|
|
<div class="bg-primary-soft my-4 flex flex-wrap items-baseline gap-2 px-4 py-3">
|
|
<span class="text-muted text-xs">{{ t("product.currentPrice") }}</span
|
|
><strong v-if="matchedSku" class="text-primary text-2xl font-semibold"
|
|
><PriceText
|
|
:amount-minor="matchedSku.price_minor"
|
|
:currency="matchedSku.currency" /></strong
|
|
><strong v-else class="text-primary text-2xl font-semibold">{{
|
|
t("product.unavailable")
|
|
}}</strong
|
|
><span v-if="matchedSku" class="text-muted text-xs"
|
|
>{{ t("product.marketPrice") }}
|
|
<s><PriceText :amount-minor="marketPrice" :currency="matchedSku.currency" /></s
|
|
></span>
|
|
</div>
|
|
<div class="text-muted mb-4 flex gap-5 text-xs">
|
|
<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="text-muted w-16 shrink-0 text-sm">{{ t("product.coupons") }}</span>
|
|
<div class="flex flex-wrap gap-2">
|
|
<span
|
|
v-for="coupon in detail.coupons"
|
|
:key="coupon.id"
|
|
class="border-primary/30 bg-primary-soft text-primary rounded-sm border px-2 py-1 text-xs"
|
|
>{{ pick(coupon.title, locale) }}
|
|
<small class="text-muted block text-[11px]">{{
|
|
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-danger text-sm">{{ claimError }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-for="group in attributeGroups" :key="group.key" class="mb-3 flex gap-3">
|
|
<span class="text-muted w-16 shrink-0 pt-1 text-sm">{{ group.key }}</span>
|
|
<div class="flex flex-wrap gap-2">
|
|
<button
|
|
v-for="value in group.values"
|
|
:key="value"
|
|
type="button"
|
|
class="border-border bg-surface text-text hover:border-primary hover:text-primary rounded-md border px-3 py-1.5 text-sm"
|
|
: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="text-muted w-16 shrink-0 text-sm">{{ 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="text-danger my-2 text-sm">
|
|
{{ t("product.noStock") }}
|
|
</p>
|
|
<p v-else-if="!matchedSku" class="text-danger my-2 text-sm">
|
|
{{ 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="border-primary mb-3 border-l-[3px] pl-2 text-sm font-medium">
|
|
{{ t("product.store") }}
|
|
</h2>
|
|
<div class="border-border flex items-center gap-2.5 border-b pb-3">
|
|
<img
|
|
v-if="detail.store.logo"
|
|
class="border-border h-12 w-12 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="text-muted my-3 text-xs">
|
|
<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="text-muted flex items-center justify-between py-1 text-xs"
|
|
>
|
|
<span>{{ row.label }}</span
|
|
><UiRatingStars :value="row.value" :size="13" /></div
|
|
></template>
|
|
<div class="mt-3 grid gap-2">
|
|
<NuxtLink
|
|
class="border-primary bg-primary hover:bg-primary-hover rounded-md border px-3 py-2 text-center text-sm font-medium text-white no-underline"
|
|
:to="`/stores/${detail.store.slug}`"
|
|
>{{ t("product.enterStore") }}</NuxtLink
|
|
><VBtn type="button" disabled>{{ t("product.contact") }}</VBtn>
|
|
</div>
|
|
</VCard>
|
|
<VCard
|
|
><h2 class="border-primary mb-2 border-l-[3px] 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="border-border text-text grid grid-cols-[48px_minmax(0,1fr)] gap-2 border-t py-2 text-xs 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="text-primary mt-1 block text-right"
|
|
><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-text text-sm leading-relaxed">
|
|
{{ 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-text text-sm leading-relaxed">
|
|
{{ pick(detail.store.after_sale, locale) }}
|
|
</p>
|
|
</div></template
|
|
></UiTabs
|
|
></VCard
|
|
>
|
|
</section>
|
|
</main>
|
|
<UiEmptyState v-else :text="t('product.productNotFound')" />
|
|
</div>
|
|
</template>
|