Files
tehran_taj/apps/user-app/src/components/product/SingleProduct.vue
T
2026-09-16 11:59:35 +03:30

738 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
Product detail — redesigned to match the new home-screen design language
(components/home/*): LESS colour tokens, BEM class names, ink surfaces with a
wine accent, hairline borders and soft long shadows.
WHAT IS SHOWN IS REAL. Every figure comes from data the API genuinely
returns today:
- title, productCode, description, category, brand
- the selected variant's rash weight (گرم) and making charge (اجرت ٪)
- stock, from ProductVariant.qty / isPreOrder
No rial price is printed: a finished piece's price is the live gold rate ×
weight plus wage, computed at order time and not stored on the product, so
printing one here would be invented. Karat and clasp type are likewise absent
from the schema and therefore absent from the page.
FIXED — a real bug, not a restyle. The previous version ran, at module scope:
history.pushState(null, location.href)
window.onpopstate = () => history.go(1)
Because ant-design-vue's Drawer mounts its slot content eagerly, this fired
for *every* product card rendered on the home screen, not just when a detail
view opened, and permanently hijacked the browser/hardware back button for
the rest of the session (forcing it forward). It was carried over from legacy
and flagged in the old file's header as the most impactful bug in the port.
It is removed here; closing is handled by close() alone, which is what the
drawer and the /product/:id route both already rely on.
-->
<template>
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات" v-if="flag && productDetail.productRef.value">
<article class="pd">
<!-- Gallery -->
<div class="pd__gallery">
<button type="button" class="pd__round pd__round--back" aria-label="بازگشت" @click="close">
<Icon icon="vuesax-linear:arrow-right-1" size="19" />
</button>
<button
type="button"
class="pd__round pd__round--fav"
:class="{ 'is-on': favorites.isFavorite(id) }"
aria-label="علاقه‌مندی"
@click="toggleFav"
>
<Icon :icon="favorites.isFavorite(id) ? 'vuesax-linear:heart-fill' : 'vuesax-linear:heart'" size="19" />
</button>
<div v-if="hasImages" class="pd__stage">
<img :src="slides[imageIndex]" :alt="title" class="pd__art" />
<div v-if="slides.length > 1" class="pd__counter">{{ imageIndex + 1 }}/{{ slides.length }}</div>
<div v-if="slides.length > 1" class="pd__dots">
<button
v-for="(s, i) in slides"
:key="s"
type="button"
class="pd__dot"
:class="{ 'is-on': i === imageIndex }"
:aria-label="`تصویر ${i + 1}`"
@click="imageIndex = i"
/>
</div>
</div>
<div v-else class="pd__stage pd__stage--empty">
<Icon icon="vuesax-linear:diamonds" size="46" />
</div>
</div>
<!-- Sheet -->
<div class="pd__sheet">
<header class="pd__head">
<div class="pd__headMain">
<h1 class="pd__title">{{ title }}</h1>
<div class="pd__sub">
<span v-if="productCode" class="pd__code">{{ productCode }}</span>
<span v-if="categoryTitle" class="pd__dotSep">·</span>
<span v-if="categoryTitle">{{ categoryTitle }}</span>
</div>
</div>
<span class="pd__stock" :class="stockClass">{{ stockLabel }}</span>
</header>
<!-- Headline figures -->
<div class="pd__figures">
<div class="pd__figure">
<span class="pd__figureLabel">وزن تقریبی</span>
<span class="pd__figureValue">
{{ min_gram }}
<span class="pd__figureUnit">گرم</span>
</span>
</div>
<div v-if="wage != null" class="pd__figure">
<span class="pd__figureLabel">اجرت</span>
<span class="pd__figureValue">
{{ wageLabel }}
<span class="pd__figureUnit">درصد</span>
</span>
</div>
</div>
<!-- Variants -->
<section v-if="variants && variants.length > 1" class="pd__block">
<h2 class="pd__blockTitle">انتخاب وزن</h2>
<div class="pd__variants no-scrollbar">
<button
v-for="item in variants"
:key="item.id"
type="button"
class="pd__variant"
:class="{ 'is-on': selectedVariant?.id === item.id }"
@click="selectedVariant = item"
>
<span class="pd__variantTitle">{{ item.title }}</span>
<span class="pd__variantWeight">{{ Number(item.rashWeightGrams) }} گرم</span>
</button>
</div>
</section>
<!-- Specs -->
<section class="pd__block">
<h2 class="pd__blockTitle">مشخصات</h2>
<dl class="pd__specs">
<div class="pd__spec">
<dt><Icon icon="vuesax-linear:weight" size="15" /> وزن</dt>
<dd>{{ min_gram }} گرم</dd>
</div>
<div v-if="wage != null" class="pd__spec">
<dt><Icon icon="vuesax-linear:percentage-square" size="15" /> اجرت</dt>
<dd>{{ wageLabel }}٪</dd>
</div>
<div v-if="selectedVariant" class="pd__spec">
<dt><Icon icon="vuesax-linear:diamonds" size="15" /> تنوع</dt>
<dd>{{ selectedVariant.title }}</dd>
</div>
<div v-if="categoryTitle" class="pd__spec">
<dt><Icon icon="vuesax-linear:category" size="15" /> دسته‌بندی</dt>
<dd>{{ categoryTitle }}</dd>
</div>
<div v-if="brandTitle" class="pd__spec">
<dt><Icon icon="vuesax-linear:shop" size="15" /> برند</dt>
<dd>{{ brandTitle }}</dd>
</div>
</dl>
</section>
<!-- Description -->
<section v-if="body" class="pd__block">
<h2 class="pd__blockTitle">توضیحات</h2>
<p class="pd__body" :class="{ 'is-clamped': !bodyOpen }">{{ body }}</p>
<button v-if="bodyIsLong" type="button" class="pd__more" @click="bodyOpen = !bodyOpen">
{{ bodyOpen ? 'بستن' : 'ادامه مطلب' }}
</button>
</section>
<!-- Trust -->
<div class="pd__trust">
<span><Icon icon="vuesax-linear:shield-tick" size="16" /> خرید امن</span>
<span><Icon icon="vuesax-linear:verify" size="16" /> اصالت کالا</span>
</div>
</div>
<!-- Action bar -->
<div class="pd__bar">
<div v-if="inCart" class="pd__qty">
<button type="button" class="pd__qtyBtn" aria-label="افزایش" @click="increment">
<Icon icon="vuesax-linear:add" size="16" />
</button>
<span class="pd__qtyValue">{{ cartQty }}</span>
<button
type="button"
class="pd__qtyBtn"
:aria-label="cartQty === 1 ? 'حذف' : 'کاهش'"
@click="decrement"
>
<Icon :icon="cartQty === 1 ? 'vuesax-linear:trash' : 'vuesax-linear:minus-square'" size="16" />
</button>
</div>
<button
v-if="!inCart"
type="button"
class="pd__cta"
:disabled="!is_exist"
@click="isUserLoggedIn() ? addToCart() : (loginNotify = true)"
>
{{ is_exist ? 'افزودن به سفارش' : 'ناموجود' }}
<Icon v-if="is_exist" icon="vuesax-linear:arrow-left-2" size="17" />
</button>
<router-link v-else class="pd__cta pd__cta--ghost" :to="{ name: 'cart' }">
مشاهده سبد خرید
<Icon icon="vuesax-linear:arrow-left-2" size="17" />
</router-link>
</div>
</article>
<NotifyVant :timeOut="3000" v-model="notify" :color="'background-color: #14161A'">
<div class="font-bold text-right mb-2">ثبت سفارش</div>
<div>محصول با موفقیت به سبد خرید افزوده شد</div>
</NotifyVant>
<DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" />
</template>
<template v-slot:title>
<div class="p-8 text-justify">
برای دسترسی به تمامی امکانات نرم افزار منتزر، ابتدا گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.
</div>
</template>
<template v-slot:button>
<div class="text-center font-bold" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center" @click="loginNotify = false">بیخیال</div>
</template>
</DialogVant>
</Spinner>
</template>
<script setup lang="ts">
import { computed, onBeforeMount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import Icon from '../icon/Icon.vue'
import Spinner from '../Spinner.vue'
import NotifyVant from '../NotifyVant.vue'
import DialogVant from '../DialogVant.vue'
import { useProduct } from '../../composables/useProduct'
import { useProductDetail } from '../../composables/useProductDetail'
import { useFavoritesStore } from '../../stores/favoritesStore'
import { useCartStore, type CartItem } from '../../stores/cartStore'
import { isUserLoggedIn } from '../../utils/checkAccess'
import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg'
const props = defineProps<{ id: string }>()
const emit = defineEmits<{ (e: 'closeDrawer'): void }>()
const router = useRouter()
const favorites = useFavoritesStore()
const cart = useCartStore()
// Used two ways, as in legacy: embedded in a bottom Drawer by the product
// cards, and as a standalone page for deep links (/product/:id). In the drawer
// case the parent listens for closeDrawer; on the routed page there is no
// parent, so fall back to history.back().
const close = () => {
emit('closeDrawer')
if (router.currentRoute.value.name === 'singleProduct') router.back()
}
const notify = ref(false)
const loginNotify = ref(false)
const imageIndex = ref(0)
const bodyOpen = ref(false)
const productDetail = useProductDetail(props.id)
const loading = productDetail.loading
const flag = productDetail.flag
// useProduct needs a real ProductSummary; give it an empty placeholder until
// the detail fetch resolves.
const emptyProduct = {
id: props.id,
title: '',
description: null,
variants: [],
images: [],
category: null,
brand: null,
collections: [],
}
const productAdapter = computed(() => useProduct(productDetail.productRef.value ?? emptyProduct))
const id = computed(() => productAdapter.value.id.value)
const title = computed(() => productAdapter.value.title.value)
const productCode = computed(() => productAdapter.value.productCode.value)
const variants = computed(() => productAdapter.value.variants.value)
const selectedVariant = computed({
get: () => productAdapter.value.selectedVariant.value,
set: (v) => (productAdapter.value.selectedVariant.value = v),
})
const min_gram = computed(() => productAdapter.value.min_gram.value)
const wage = computed(() => productAdapter.value.wage.value)
const qty = computed(() => productAdapter.value.qty.value)
const isPreOrder = computed(() => productAdapter.value.isPreOrder.value)
const is_exist = computed(() => productAdapter.value.is_exist.value)
const body = computed(() => productDetail.productRef.value?.description ?? '')
const bodyIsLong = computed(() => body.value.length > 120)
const categoryTitle = computed(() => productDetail.productRef.value?.category?.title ?? '')
const brandTitle = computed(() => productDetail.productRef.value?.brand?.title ?? '')
const hasImages = computed(() => (productDetail.productRef.value?.images.length ?? 0) > 0)
const slides = computed(() => productDetail.productRef.value?.images.map((i) => i.url) ?? [])
// Keep the gallery index valid if the product (and so its image list) changes.
watch(slides, (list) => {
if (imageIndex.value > list.length - 1) imageIndex.value = 0
})
const wageLabel = computed(() => {
const w = wage.value
if (w == null) return ''
return w % 1 === 0 ? String(w) : w.toFixed(2)
})
const stockLabel = computed(() => {
if (qty.value > 0) return `${qty.value} عدد موجود`
return isPreOrder.value ? 'پیش‌سفارش' : 'ناموجود'
})
const stockClass = computed(() => {
if (qty.value > 0) return 'is-in'
return isPreOrder.value ? 'is-pre' : 'is-out'
})
const storeProduct = computed<CartItem>(() => ({
vid: selectedVariant.value?.id ?? '',
note: null,
pid: productDetail.productRef.value?.id ?? props.id,
product: productDetail.productRef.value ?? (emptyProduct as never),
qty: 1,
}))
// Quantity control reuses the store's updateFeild(...,'qty') path, the same one
// CartItem uses; the store has no dedicated increment/decrement.
const inCart = computed(() => cart.isInCart(storeProduct.value))
const cartIndex = computed(() => cart.find_index(storeProduct.value))
const cartQty = computed(() => (cartIndex.value > -1 ? cart.items[cartIndex.value].qty : 0))
const increment = () => cart.updateFeild(cartQty.value + 1, cartIndex.value, storeProduct.value, 'qty')
const decrement = () => {
if (cartQty.value <= 1) {
cart.remove(storeProduct.value)
return
}
cart.updateFeild(cartQty.value - 1, cartIndex.value, storeProduct.value, 'qty')
}
const addToCart = () => {
cart.addToCart(storeProduct.value)
notify.value = true
}
const toggleFav = (e: Event) => {
e.stopPropagation()
favorites.toggle(props.id)
}
onBeforeMount(async () => {
await productDetail.load()
})
</script>
<style scoped lang="less">
.pd {
position: relative;
min-height: 100vh;
background: @canvasSoft;
padding-bottom: 104px;
}
/* ── Gallery ─────────────────────────────────────────────── */
.pd__gallery {
position: relative;
}
.pd__stage {
position: relative;
aspect-ratio: 1 / 1;
max-height: 52vh;
background: @hairlineSoft;
overflow: hidden;
}
.pd__stage--empty {
display: grid;
place-items: center;
color: @inkFaint;
}
.pd__art {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.pd__round {
position: absolute;
top: 16px;
z-index: 5;
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
cursor: pointer;
color: @inkSoft;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8px);
box-shadow: 0 6px 18px -10px rgba(20, 22, 26, 0.5);
transition: transform 0.18s ease, color 0.18s ease;
}
.pd__round:active {
transform: scale(0.92);
}
.pd__round--back {
inset-inline-end: 16px;
}
.pd__round--fav {
inset-inline-start: 16px;
}
.pd__round--fav.is-on {
color: @wine;
}
.pd__counter {
position: absolute;
bottom: 14px;
inset-inline-start: 14px;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
color: #fff;
background: rgba(20, 22, 26, 0.62);
backdrop-filter: blur(4px);
direction: ltr;
}
.pd__dots {
position: absolute;
bottom: 14px;
inset-inline-end: 14px;
display: flex;
gap: 5px;
}
.pd__dot {
width: 6px;
height: 6px;
padding: 0;
border: 0;
border-radius: 999px;
cursor: pointer;
background: rgba(255, 255, 255, 0.55);
transition: width 0.2s ease, background 0.2s ease;
}
.pd__dot.is-on {
width: 18px;
background: #fff;
}
/* ── Sheet ───────────────────────────────────────────────── */
.pd__sheet {
position: relative;
margin-top: -20px;
border-radius: 22px 22px 0 0;
background: @canvas;
padding: 20px 18px 8px;
}
.pd__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.pd__title {
margin: 0;
font-size: 17px;
font-weight: 800;
line-height: 1.7;
color: @ink;
}
.pd__sub {
display: flex;
align-items: center;
gap: 6px;
padding-top: 5px;
font-size: 11.5px;
color: @inkFaint;
}
.pd__code {
direction: ltr;
font-weight: 700;
color: @inkMuted;
}
.pd__dotSep {
color: @hairline;
}
.pd__stock {
flex-shrink: 0;
padding: 6px 11px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
}
.pd__stock.is-in {
color: #15803d;
background: #eef7f0;
}
.pd__stock.is-pre {
color: @wine;
background: @wineWash;
}
.pd__stock.is-out {
color: @inkMuted;
background: @hairlineSoft;
}
/* ── Figures ─────────────────────────────────────────────── */
.pd__figures {
display: flex;
gap: 10px;
margin-top: 16px;
}
.pd__figure {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
padding: 13px 14px;
border-radius: 16px;
background: @hairlineSoft;
}
.pd__figureLabel {
font-size: 11px;
color: @inkFaint;
}
.pd__figureValue {
font-size: 19px;
font-weight: 800;
color: @ink;
direction: ltr;
text-align: end;
}
.pd__figureUnit {
font-size: 11px;
font-weight: 500;
color: @inkFaint;
padding-inline-start: 3px;
}
/* ── Blocks ──────────────────────────────────────────────── */
.pd__block {
margin-top: 22px;
}
.pd__blockTitle {
margin: 0 0 11px;
font-size: 13.5px;
font-weight: 800;
color: @ink;
}
.pd__variants {
display: flex;
gap: 8px;
overflow-x: auto;
}
.pd__variant {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 14px;
border-radius: 14px;
cursor: pointer;
text-align: start;
background: @canvas;
border: 1px solid @hairline;
transition: border-color 0.18s ease, background 0.18s ease;
}
.pd__variant.is-on {
background: @ink;
border-color: @ink;
}
.pd__variantTitle {
font-size: 12.5px;
font-weight: 700;
color: @ink;
}
.pd__variantWeight {
font-size: 10.5px;
color: @inkFaint;
}
.pd__variant.is-on .pd__variantTitle {
color: #fff;
}
.pd__variant.is-on .pd__variantWeight {
color: rgba(255, 255, 255, 0.6);
}
.pd__specs {
margin: 0;
border: 1px solid @hairline;
border-radius: 16px;
overflow: hidden;
}
.pd__spec {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid @hairlineSoft;
}
.pd__spec:last-child {
border-bottom: 0;
}
.pd__spec dt {
display: flex;
align-items: center;
gap: 7px;
margin: 0;
font-size: 12.5px;
color: @inkMuted;
}
.pd__spec dd {
margin: 0;
font-size: 12.5px;
font-weight: 700;
color: @ink;
}
.pd__body {
margin: 0;
font-size: 12.5px;
line-height: 2.1;
color: @inkMuted;
text-align: justify;
}
.pd__body.is-clamped {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.pd__more {
margin-top: 8px;
padding: 0;
border: 0;
background: none;
cursor: pointer;
font-family: inherit;
font-size: 11.5px;
font-weight: 700;
color: @wine;
}
.pd__trust {
display: flex;
gap: 18px;
margin-top: 22px;
padding: 13px 0 4px;
border-top: 1px solid @hairlineSoft;
}
.pd__trust span {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
color: @inkFaint;
}
/* ── Action bar ──────────────────────────────────────────── */
.pd__bar {
position: fixed;
bottom: 0;
inset-inline: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 10px;
max-width: 768px;
margin: 0 auto;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
background: @canvas;
border-top: 1px solid @hairline;
}
.pd__qty {
display: inline-flex;
align-items: center;
gap: 9px;
flex-shrink: 0;
padding: 7px 9px;
border-radius: 999px;
border: 1px solid @hairline;
background: @hairlineSoft;
}
.pd__qtyBtn {
width: 27px;
height: 27px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
cursor: pointer;
color: @ink;
background: @canvas;
}
.pd__qtyValue {
min-width: 16px;
text-align: center;
font-size: 13px;
font-weight: 800;
color: @ink;
}
.pd__cta {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 48px;
border: 0;
border-radius: 14px;
cursor: pointer;
font-family: inherit;
font-size: 14px;
font-weight: 800;
text-decoration: none;
color: #fff;
background: @ink;
transition: transform 0.18s ease, opacity 0.18s ease;
}
.pd__cta:active {
transform: scale(0.99);
}
.pd__cta:disabled {
cursor: default;
opacity: 0.45;
}
.pd__cta--ghost {
color: @ink;
background: @canvas;
border: 1px solid @ink;
}
</style>