144 lines
6.2 KiB
Vue
144 lines
6.2 KiB
Vue
<script setup lang="ts">
|
|
import type { Product, Sku } from "@vmall/shared";
|
|
import { t as localized } from "@vmall/shared";
|
|
|
|
const route = useRoute();
|
|
const { locale } = useI18n();
|
|
const { $api } = useNuxtApp();
|
|
const session = useSessionStore();
|
|
const product = ref<Product | null>(null);
|
|
const selectedSkuId = ref("");
|
|
const quantity = ref(1);
|
|
const mainImage = ref("");
|
|
const loading = ref(true);
|
|
const submitting = ref(false);
|
|
const error = ref("");
|
|
|
|
const skus = computed(() => (product.value?.skus ?? []).filter((sku) => sku.active));
|
|
const selectedSku = computed<Sku | null>(() => skus.value.find((sku) => sku.id === selectedSkuId.value) ?? skus.value[0] ?? null);
|
|
|
|
function errorMessage(value: unknown): string {
|
|
return value instanceof Error ? value.message : $t("mall.loadFailed");
|
|
}
|
|
|
|
async function loadProduct(): Promise<void> {
|
|
loading.value = true;
|
|
error.value = "";
|
|
try {
|
|
product.value = await $api.getProduct(String(route.params.id));
|
|
mainImage.value = product.value.images[0] ?? "";
|
|
selectedSkuId.value = skus.value[0]?.id ?? "";
|
|
} catch (value) {
|
|
error.value = errorMessage(value);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function addToCart(): Promise<void> {
|
|
const sku = selectedSku.value;
|
|
if (!sku || sku.stock < 1) return;
|
|
if (!localStorage.getItem("vmall.token") || session.user?.role !== "customer") {
|
|
await navigateTo("/login");
|
|
return;
|
|
}
|
|
const qty = Math.max(1, Math.min(sku.stock, Math.trunc(quantity.value)));
|
|
submitting.value = true;
|
|
error.value = "";
|
|
try {
|
|
await $api.addCartItem(sku.id, qty);
|
|
await navigateTo("/cart");
|
|
} catch (value) {
|
|
error.value = errorMessage(value);
|
|
} finally {
|
|
submitting.value = false;
|
|
}
|
|
}
|
|
|
|
watch(selectedSku, (sku) => {
|
|
if (sku && quantity.value > sku.stock) quantity.value = Math.max(1, sku.stock);
|
|
});
|
|
|
|
onMounted(async () => {
|
|
session.hydrate();
|
|
await loadProduct();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<section>
|
|
<NuxtLink to="/" class="muted">← {{ $t("common.back") }}</NuxtLink>
|
|
<p v-if="loading" class="muted mt">{{ $t("common.loading") }}</p>
|
|
<p v-else-if="error" class="error-text" role="alert">{{ error }}</p>
|
|
<div v-else-if="product" class="detail-layout mt">
|
|
<div>
|
|
<div class="main-image">
|
|
<img v-if="mainImage" :src="mainImage" :alt="localized(product.name, locale)">
|
|
<div v-else class="image-placeholder">{{ $t("mall.noImage") }}</div>
|
|
</div>
|
|
<div v-if="product.images.length > 1" class="thumbs">
|
|
<button v-for="image in product.images" :key="image" class="thumb" :class="{ selected: image === mainImage }" type="button" @click="mainImage = image">
|
|
<img :src="image" :alt="localized(product.name, locale)">
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card product-info">
|
|
<h1 class="page-title">{{ localized(product.name, locale) }}</h1>
|
|
<p class="muted">{{ localized(product.description, locale) }}</p>
|
|
|
|
<div class="field">
|
|
<label>{{ $t("mall.chooseSku") }}</label>
|
|
<div v-if="skus.length" class="sku-list">
|
|
<label v-for="sku in skus" :key="sku.id" class="sku-option" :class="{ selected: sku.id === selectedSkuId }">
|
|
<input v-model="selectedSkuId" type="radio" name="sku" :value="sku.id" :disabled="sku.stock < 1">
|
|
<span>
|
|
<strong>{{ sku.sku_code }}</strong>
|
|
<span v-for="(value, key) in sku.attributes" :key="key" class="attribute">{{ key }}: {{ value }}</span>
|
|
</span>
|
|
<span class="sku-meta">
|
|
<PriceText :amount-minor="sku.price_minor" :currency="sku.currency" />
|
|
<span class="muted">{{ sku.stock }} {{ $t("mall.available") }}</span>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
<p v-else class="muted">{{ $t("mall.unavailable") }}</p>
|
|
</div>
|
|
|
|
<div v-if="selectedSku" class="purchase-row">
|
|
<div class="field quantity-field">
|
|
<label for="product-quantity">{{ $t("mall.quantity") }}</label>
|
|
<input id="product-quantity" v-model.number="quantity" type="number" min="1" :max="selectedSku.stock">
|
|
</div>
|
|
<div class="selected-price"><PriceText :amount-minor="selectedSku.price_minor" :currency="selectedSku.currency" /></div>
|
|
</div>
|
|
<button class="btn primary" :disabled="submitting || !selectedSku || selectedSku.stock < 1" @click="addToCart">
|
|
{{ submitting ? $t("common.loading") : $t("product.addToCart") }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 1fr); gap: 24px; align-items: start; }
|
|
.main-image { overflow: hidden; border-radius: var(--radius); background: #eef1f5; }
|
|
.main-image img, .image-placeholder { display: block; width: 100%; aspect-ratio: 1; object-fit: cover; }
|
|
.image-placeholder { display: grid; place-items: center; color: var(--muted); }
|
|
.thumbs { display: flex; gap: 8px; margin-top: 10px; }
|
|
.thumb { padding: 0; border: 2px solid transparent; background: none; cursor: pointer; border-radius: var(--radius); overflow: hidden; }
|
|
.thumb.selected { border-color: var(--primary); }
|
|
.thumb img { width: 64px; height: 64px; display: block; object-fit: cover; }
|
|
.product-info .page-title { margin-bottom: 8px; }
|
|
.sku-list { display: grid; gap: 8px; }
|
|
.sku-option { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: start; border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; cursor: pointer; }
|
|
.sku-option.selected { border-color: var(--primary); background: #f7f9ff; }
|
|
.sku-option input { width: auto; margin-top: 3px; }
|
|
.attribute { display: inline-block; margin-left: 10px; color: var(--muted); font-size: 12px; }
|
|
.sku-meta { display: grid; justify-items: end; gap: 2px; color: var(--primary); font-weight: 600; }
|
|
.sku-meta .muted { font-weight: 400; font-size: 12px; }
|
|
.purchase-row { display: flex; align-items: end; gap: 16px; margin: 18px 0; }
|
|
.quantity-field { width: 120px; margin: 0; }
|
|
.selected-price { color: var(--primary); font-size: 20px; font-weight: 700; padding-bottom: 8px; }
|
|
@media (max-width: 760px) { .detail-layout { grid-template-columns: 1fr; } }
|
|
</style>
|