wip(freight): migration 0017 + model/column-list groundwork for add-freight-templates
This commit is contained in:
+229
-26
@@ -18,7 +18,7 @@ const signInThenReturn = useSignInRedirect();
|
||||
|
||||
const routeId = computed(() => {
|
||||
const value = route.params.id;
|
||||
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
||||
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
|
||||
@@ -120,7 +120,9 @@ 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;
|
||||
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;
|
||||
@@ -134,18 +136,22 @@ 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;
|
||||
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");
|
||||
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());
|
||||
@@ -293,41 +299,238 @@ const detailImages = computed(() => product.value?.images ?? []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-bg pb-16 font-sans text-text">
|
||||
<div class="bg-bg text-text min-h-screen pb-16 font-sans">
|
||||
<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">
|
||||
<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="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 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-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 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="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-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="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 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="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>
|
||||
<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="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>
|
||||
<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-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>
|
||||
<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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user