Files
vmall/apps/mall/pages/goods/[id].vue
T

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>