Files
tehran_tala/apps/user-app-v2/src/components/product/SingleProduct.vue
T
s.esmaeili b80fa9bcfb Redesign product detail page and fix icon/cart reactivity bugs
- SingleProduct.vue: rebuild gallery/header/action bar as a self-scrolling
  layout with a sticky photo, solid-on-scroll header matching PageBar, and
  a full-screen image preview (blurred overlay, centered thumbnail strip)
- Add GallerySlider.vue: pointer-based swipe carousel isolated in an
  LTR subtree, fixing RTL/transform sign mismatch that caused slide jumps
- Fix Icon.vue: icon was read into a plain ref once at setup and never
  updated on prop change; switch to computed so icon swaps (e.g. cart
  qty stepper trash/minus toggle) actually re-render
- Fix quantity stepper DOM order in SingleProduct.vue/CartItem.vue so
  plus/minus render correctly under RTL flex
- prisma/assign-downloaded-images.js: seed real product photos (FEATURED/
  FEATURED_MOBILE/GALLERY/GALLERY_MOBILE) onto the 5 placeholder products
2026-09-18 16:22:31 +03:30

1316 lines
46 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 — rebuilt against the supplied reference layout, in the home
screen's own design language (components/home/*): LESS colour tokens, BEM
names, @hairlineSoft panels with the same 32px corner radius ProductPanel and
CategoryGrid use, @ink as the heavy surface and @wine as the accent.
Structure, top to bottom — the reference's, not the old stacked-sheet one:
1. a full-bleed swipeable gallery with floating round controls (back /
favourite / cart) and a vertical dot rail down its trailing edge
2. a rounded sheet that rides up over the image, opening with the category
eyebrow on one side and the stock badge on the other
3. the title, with the headline figures (weight, making charge) set beside
it in the place the reference gives the price
4. a chip row for variants — the reference's size selector, filled @ink
when chosen
5. description with an inline "ادامه مطلب" toggle, then the spec table
6. a split action bar: a secondary pill (category / quantity) beside the
dark primary button
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. The reference's rating ("4.8 / 120
reviews") and its struck-through original price are likewise absent from the
schema and therefore absent from the page — their slot is given to the real
weight and wage figures instead, which is why the header reads as two figures
rather than one price.
-->
<template>
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات" v-if="flag && productDetail.productRef.value">
<article class="pd" ref="scroller" @scroll.passive="onScroll">
<!-- ── Header ────────────────────────────────────────── -->
<!-- One bar for both states. At rest it is transparent and its buttons
float on the photo; once the sheet has covered the image it gains a
solid background, a hairline and the product's title, so the same
controls become a real header instead of a second set of buttons
appearing from nowhere. -->
<header class="pd__header" :class="{ 'is-solid': headerSolid }">
<div class="pd__controls">
<button type="button" class="pd__round" aria-label="بازگشت" @click="close">
<Icon icon="vuesax-linear:arrow-right-1" size="18" />
</button>
<!-- Only meaningful once the bar is solid: while the photo is still
showing, the title is already set large inside the sheet. -->
<h2 class="pd__headerTitle">{{ title }}</h2>
<div class="pd__controlsEnd">
<button
type="button"
class="pd__round"
:class="{ 'is-on': favorites.isFavorite(id) }"
aria-label="علاقه‌مندی"
@click="toggleFav"
>
<Icon :icon="favorites.isFavorite(id) ? 'vuesax-linear:heart-fill' : 'vuesax-linear:heart'" size="18" />
</button>
<router-link class="pd__round" :to="{ name: 'cart' }" aria-label="سبد خرید">
<Icon icon="vuesax-linear:bag-2" size="18" />
<span v-if="cart.items.length" class="pd__roundBadge">{{ cart.items.length }}</span>
</router-link>
</div>
</div>
</header>
<!-- ── Gallery ───────────────────────────────────────── -->
<div class="pd__gallery">
<GallerySlider
v-if="hasImages"
v-model:index="imageIndex"
:slides="slides"
:alt="title"
class="pd__stage"
@tap="openPreview"
>
<template #dots="{ index, go }">
<!-- The reference's dot rail: vertical, pinned to the trailing
edge. -->
<div v-if="slides.length > 1" class="pd__rail">
<button
v-for="(s, i) in slides"
:key="s"
type="button"
class="pd__railDot"
:class="{ 'is-on': i === index }"
:aria-label="`تصویر ${i + 1}`"
@click.stop="go(i)"
/>
</div>
</template>
</GallerySlider>
<div v-else class="pd__stage pd__stage--empty">
<Icon icon="vuesax-linear:diamonds" size="27" />
</div>
<!-- Tap-to-preview affordance, so the full-screen viewer is
discoverable without it being the whole stage's only behaviour
(the stage still swipes in place). -->
<span v-if="hasImages" class="pd__expand" aria-hidden="true">
<Icon icon="vuesax-linear:maximize-4" size="15" />
</span>
</div>
<!-- ── Scrolling content ─────────────────────────────── -->
<!-- Sits above the stuck gallery and scrolls over it. The gallery keeps
its place in the flow, so this starts below the image at rest and
needs no spacer of its own. -->
<div class="pd__scroll">
<div class="pd__sheet">
<div class="pd__grip" aria-hidden="true"></div>
<!-- Eyebrow row: the reference's "New Season" ⟷ rating line. Ours
carries the real category and the real stock state. -->
<div class="pd__eyebrow">
<span class="pd__eyebrowText">{{ categoryTitle || 'محصول' }}</span>
<span class="pd__stock" :class="stockClass">
<span class="pd__stockDot" aria-hidden="true"></span>
{{ stockLabel }}
</span>
</div>
<!-- Title ⟷ figures, the reference's title/price split. -->
<header class="pd__head">
<div class="pd__headMain">
<h1 class="pd__title">{{ title }}</h1>
<p v-if="productCode" class="pd__code">{{ productCode }}</p>
</div>
<div class="pd__figures">
<div class="pd__figure">
<span class="pd__figureValue">{{ min_gram }}</span>
<span class="pd__figureLabel">گرم</span>
</div>
<div v-if="wage != null" class="pd__figure pd__figure--accent">
<span class="pd__figureValue">{{ wageLabel }}٪</span>
<span class="pd__figureLabel">اجرت</span>
</div>
</div>
</header>
<!-- Variant chips — the reference's size row. -->
<section v-if="variants && variants.length > 1" class="pd__chips no-scrollbar">
<button
v-for="item in variants"
:key="item.id"
type="button"
class="pd__chip"
:class="{ 'is-on': selectedVariant?.id === item.id }"
@click="selectedVariant = item"
>
{{ item.title }}
</button>
</section>
<!-- Description, with the reference's inline "Read More". -->
<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>
<!-- 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>
</div>
</div>
<!-- ── Action bar ────────────────────────────────────── -->
<div class="pd__bar">
<!-- The reference's left pill. When the piece is not yet in the cart
it deep-links to its category (a real destination); once it is, it
becomes the quantity stepper, which has to live somewhere and this
is the slot the layout already gives it. -->
<!-- Stepper order rule, the same one CartItem.vue follows: plus on
the visual right, minus (or trash at qty 1) on the visual left.
The page is RTL, so a plain flex row already lays its DOM-first
child on the right — plus has to be FIRST in markup, not last, to
land there. (FIXED: this used to be minus/trash, value, plus —
the reverse of the intended layout, which put plus on the visual
left instead of the right.) -->
<div v-if="inCart" class="pd__qty">
<button type="button" class="pd__qtyBtn" aria-label="افزایش" @click="increment">
<Icon icon="vuesax-linear:add" size="15" />
</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'" size="15" />
</button>
</div>
<button v-else-if="categoryTitle" type="button" class="pd__pill" @click="goCategory">
<Icon icon="vuesax-linear:category" size="17" />
<span class="pd__pillText">{{ categoryTitle }}</span>
</button>
<button
v-if="!inCart"
type="button"
class="pd__cta"
:disabled="!is_exist"
@click="isUserLoggedIn() ? addToCart() : (loginNotify = true)"
>
<Icon v-if="is_exist" icon="vuesax-linear:bag-2" size="17" />
{{ is_exist ? 'افزودن به سفارش' : 'ناموجود' }}
</button>
<router-link v-else class="pd__cta" :to="{ name: 'cart' }">
<Icon icon="vuesax-linear:bag-2" size="17" />
مشاهده سبد خرید
</router-link>
</div>
</article>
<!-- ── Full-screen preview ───────────────────────────────── -->
<!-- A sibling of .pd, not nested inside it: .pd is the scroll container
(overflow-y: auto), and an element positioned fixed/absolute inside it
would still be clipped by that overflow in the Drawer mount, the same
reason the gallery is sticky rather than fixed. Sitting outside it
avoids the problem entirely — this is a real overlay, not something
that has to stay in the document's scroll flow. -->
<Teleport to="body">
<Transition name="pd-preview">
<div v-if="previewOpen" class="pd__preview" @click.self="closePreview">
<button type="button" class="pd__previewClose" aria-label="بستن" @click="closePreview">
<Icon icon="vuesax-linear:add" size="20" />
</button>
<div v-if="slides.length > 1" class="pd__previewCounter">{{ imageIndex + 1 }} / {{ slides.length }}</div>
<!-- Same slider, a second instance: `v-model:index` is what keeps it
in lockstep with the inline gallery above (both read/write the
one `imageIndex` ref), not shared drag state — each instance
tracks and settles its own gesture independently. -->
<GallerySlider
v-model:index="imageIndex"
:slides="slides"
:alt="title"
class="pd__previewStage"
fit="contain"
/>
<!-- Horizontal thumbnail strip, replacing the old dot row: each
frame is the actual photo, so jumping to a specific image is
one glance-and-tap instead of counting dots to find it. -->
<div v-if="slides.length > 1" class="pd__previewThumbs no-scrollbar">
<button
v-for="(s, i) in slides"
:key="s"
type="button"
class="pd__previewThumb"
:class="{ 'is-on': i === imageIndex }"
:aria-label="`تصویر ${i + 1}`"
@click="imageIndex = i"
>
<img :src="s" :alt="`${title} ${i + 1}`" draggable="false" />
</button>
</div>
</div>
</Transition>
</Teleport>
<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, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import Icon from '../icon/Icon.vue'
import GallerySlider from './GallerySlider.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 { useFiltersStore } from '../../stores/filtersStore'
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 filters = useFiltersStore()
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)
// ── Header state ─────────────────────────────────────────────
// The bar turns solid once the sheet has scrolled up far enough to have
// covered the photo. Read off the scroll container (not the window) for the
// same reason the gallery is sticky rather than fixed — inside the Drawer the
// window never scrolls at all.
const scroller = ref<HTMLElement | null>(null)
const headerSolid = ref(false)
// Flip a little before the sheet's top edge reaches the bar, so the background
// is already there when the image disappears behind it rather than appearing a
// beat late.
const HEADER_SOLID_OFFSET_PX = 56
function onScroll() {
const el = scroller.value
if (!el) return
// The gallery's own height is the distance the sheet has to travel to cover
// it — measured rather than hard-coded, since the stage is sized in vh and
// clamped, so the number differs per screen.
const galleryHeight = el.querySelector('.pd__gallery')?.clientHeight ?? 0
headerSolid.value = el.scrollTop > Math.max(galleryHeight - HEADER_SOLID_OFFSET_PX, 0)
}
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 categoryId = computed(() => productDetail.productRef.value?.category?.id ?? '')
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
})
// ── Gallery ───────────────────────────────────────────────────
// The swipe/drag mechanics live in GallerySlider.vue now — see that file's
// header for why the old inline implementation was rewritten rather than
// patched (it jumped mid-gesture because of an RTL/transform sign mismatch).
// The inline stage and the full-screen preview below each mount their own
// <GallerySlider>; `imageIndex` is the one thing they share, via v-model, so
// picking an image in either place moves both.
//
// GallerySlider distinguishes a tap from a swipe itself and emits `tap` only
// for the former, so opening the preview needs no drag-jitter bookkeeping here
// the way the old inline handlers did.
const previewOpen = ref(false)
function openPreview() {
if (!hasImages.value) return
previewOpen.value = true
// The preview is teleported to <body> and would otherwise let the page (or
// the Drawer) scroll behind it.
document.body.style.overflow = 'hidden'
}
function closePreview() {
previewOpen.value = false
document.body.style.overflow = ''
}
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)
}
// The action bar's secondary pill. Same handoff HomeView's CategoryGrid makes:
// seed the filter store, then hand over to search. The drawer is closed first,
// since on the home screen this component is mounted inside one and the route
// change alone would leave it open over the destination.
const goCategory = () => {
if (!categoryId.value) return
filters.query.cats = [categoryId.value]
emit('closeDrawer')
router.push({ name: 'search' })
}
onBeforeMount(async () => {
await productDetail.load()
})
// If the preview is left open when this instance goes away — the drawer is
// closed, or the routed page is navigated away from — the body scroll lock it
// set would otherwise outlive it and leave the rest of the app unscrollable.
onBeforeUnmount(() => {
if (previewOpen.value) document.body.style.overflow = ''
})
</script>
<style scoped lang="less">
// The component owns its own scroll container rather than scrolling the page.
//
// This is what makes the fixed image work in BOTH mounts. On /product/:id the
// window scrolls, but inside the home screen's bottom Drawer the scroller is
// ant-design's own drawer body — a `position: fixed` image would escape that
// container and pin itself to the viewport, sitting over the drawer. Scrolling
// this element instead means the gallery is positioned against THIS box in
// both cases, so the behaviour is identical however the detail view is opened.
.pd {
position: relative;
height: 100vh;
overflow-y: auto;
overflow-x: hidden;
background: @canvas;
// Momentum scrolling on iOS, and stop a scroll at either end from chaining
// out to the page (or the drawer) behind.
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* ── Gallery ─────────────────────────────────────────────── */
// Stuck to the top of the scroll container as a backdrop: it never moves, and
// the sheet scrolls up over it. `sticky` rather than `fixed` so it stays inside
// this box — see the note on .pd above.
// Stuck under the header: the photo never moves, and the sheet scrolls up over
// it. `sticky` rather than `fixed` so it stays inside this box — see the note
// on .pd above.
//
// The header above it occupies real space at the top of the scroller, which
// would push the photo down; the negative margin pulls it back so the image
// starts at the very top with the bar floating over it.
.pd__gallery {
position: sticky;
top: 0;
z-index: 1;
// 60px = PageBar's 11px padding + the 38px button + 11px padding.
margin-top: calc(-60px - env(safe-area-inset-top, 0px));
}
/* ── Header ──────────────────────────────────────────────── */
// Stuck to the top of the scroll container, above both the gallery and the
// sheet, so it is the one element that never moves. At rest it is invisible
// chrome — only the floating buttons show; once `is-solid` is set it becomes a
// real bar.
// Solid state matches components/ui/PageBar.vue exactly — same fill, blur,
// hairline, padding and inner max-width — so the detail page's header is the
// same bar every other screen uses (cart, orders, newest…), not a lookalike.
// The only thing this one adds is the transparent rest state it fades in from.
.pd__header {
position: sticky;
top: 0;
z-index: 10;
padding-top: env(safe-area-inset-top, 0px);
// Transparent at rest: the buttons appear to float directly on the photo.
background: transparent;
border-bottom: 1px solid transparent;
transition: background 0.28s ease, border-color 0.28s ease, backdrop-filter 0.28s ease;
}
.pd__header.is-solid {
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(14px);
border-bottom-color: @hairline;
}
.pd__headerTitle {
flex: 1;
min-width: 0;
margin: 0;
text-align: center;
// PageBar's `.bar__title`.
font-size: 14.5px;
font-weight: 800;
color: @ink;
letter-spacing: -0.1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
// Only shown with the solid bar — over the photo the sheet already carries
// the title at full size.
opacity: 0;
transition: opacity 0.28s ease;
}
.is-solid .pd__headerTitle {
opacity: 1;
}
// PageBar's `.bar__inner`: same max-width, padding and gap.
.pd__controls {
max-width: 768px;
margin: 0 auto;
padding: 11px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pd__controlsEnd {
display: flex;
gap: 9px;
}
// The reference's floating white controls — squircles, not circles. Matching
// the sheet's corner family keeps them of a piece with the rest of the page.
// Over the photo: a floating white tile that lifts itself off the image. It
// morphs into PageBar's flat grey button once the header goes solid — the
// transition lists every property that differs between the two states so the
// change reads as one motion rather than a snap.
.pd__round {
position: relative;
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 0;
border-radius: 12px;
cursor: pointer;
text-decoration: none;
color: @ink;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px) saturate(140%);
box-shadow: 0 10px 26px -16px rgba(20, 22, 26, 0.7);
transition: transform 0.18s ease, color 0.28s ease, background 0.28s ease,
box-shadow 0.28s ease;
}
.pd__round:active {
transform: scale(0.92);
}
.pd__round.is-on {
color: #fff;
background: @wine;
}
// Inside the solid bar the buttons become PageBar's `.bar__btn`: a soft grey
// tile at 12px radius, no shadow and no blur — they no longer have to lift
// themselves off a photo. The favourite toggle keeps its filled state; that is
// state, not chrome.
.is-solid .pd__round {
color: @ink;
background: @hairlineSoft;
backdrop-filter: none;
box-shadow: none;
}
.is-solid .pd__round.is-on {
color: #fff;
background: @wine;
}
.pd__roundBadge {
position: absolute;
top: -5px;
inset-inline-start: -5px;
min-width: 18px;
height: 18px;
padding: 0 5px;
display: grid;
place-items: center;
border-radius: 999px;
font-size: 10px;
font-weight: 800;
color: #fff;
background: @wine;
}
.pd__stage {
position: relative;
// Taller than the cropped version needed: with `contain` the piece is fitted
// whole inside this box rather than filling it, so the box has to be roomy
// enough that the photo is not left small.
height: 54vh;
min-height: 320px;
max-height: 460px;
overflow: hidden;
background: @hairlineSoft;
// Horizontal drags are the gallery's; vertical ones still scroll the page.
touch-action: pan-y;
}
.pd__stage--empty {
display: grid;
place-items: center;
color: @inkFaint;
}
// The slider's own track/cell/image rules (.gs__track etc.) live in
// GallerySlider.vue, which this stage now renders — see that file's header
// for why the swipe mechanics were rewritten there instead of staying inline
// here. .pd__stage only supplies the sizing shell (height, background) that
// the slider fills; .gs__img--cover on that component sets the same padding
// and `object-fit: contain` this rule used to.
// Vertical dot rail on the trailing edge, as in the reference.
.pd__rail {
position: absolute;
top: 50%;
inset-inline-end: 12px;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 6px;
padding: 7px 5px;
border-radius: 999px;
background: rgba(20, 22, 26, 0.3);
backdrop-filter: blur(6px);
}
.pd__railDot {
width: 6px;
height: 6px;
padding: 0;
border: 0;
border-radius: 999px;
cursor: pointer;
background: rgba(255, 255, 255, 0.5);
transition: height 0.25s cubic-bezier(0.32, 0.72, 0, 1), background 0.25s ease;
}
.pd__railDot.is-on {
height: 20px;
background: #fff;
}
// Small tap-to-preview affordance, bottom-start corner of the stage — clear of
// the trailing dot rail and the leading controls above it.
.pd__expand {
position: absolute;
bottom: 12px;
inset-inline-start: 12px;
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 10px;
color: #fff;
background: rgba(20, 22, 26, 0.35);
backdrop-filter: blur(6px);
pointer-events: none;
}
/* ── Full-screen preview ─────────────────────────────────── */
// Teleported to <body> — see the template comment on why this sits outside
// .pd rather than nested in it.
.pd__preview {
position: fixed;
inset: 0;
// FIXED: this was 1000, the exact z-index ant-design-vue's Drawer uses for
// its own wrapper (@zindex-modal in its theme, see node_modules/
// ant-design-vue/es/style/themes/default.less). Both are teleported to
// <body> as siblings, so a tie at the same z-index resolves by DOM order —
// fragile and, in the product-card mount (the drawer this component usually
// opens inside), it lost: the Drawer's own content, including .pd__bar at
// its OWN stacking context z-index of 20, painted on top of the preview
// instead of under it — the "چیزی آن پایین به‌هم ریخته" in the screenshot.
// Clearing the modal layer's z-index by a wide margin makes the preview
// unconditionally the topmost layer, regardless of which Teleport happened
// to mount last.
z-index: 2000;
display: flex;
flex-direction: column;
// A dim, blurred veil rather than a near-opaque slab: low-alpha black so
// whatever is behind it (the product photo mid-transition, the page) still
// reads through softly, and `backdrop-filter: blur` on that same layer is
// what actually does the softening — this is the frosted-glass look the
// header bar and the floating gallery controls elsewhere on this page
// already use (see .pd__header.is-solid, .pd__round), not a new material.
background: rgba(10, 10, 12, 0.55);
backdrop-filter: blur(28px) saturate(140%);
}
.pd__previewClose {
position: absolute;
top: calc(14px + env(safe-area-inset-top, 0px));
inset-inline-end: 14px;
z-index: 2;
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 0;
border-radius: 12px;
cursor: pointer;
color: #fff;
background: rgba(0, 0, 0, 0. );
backdrop-filter: blur(10px);
transform: rotate(45deg);
transition: transform 0.18s ease, background 0.18s ease;
}
.pd__previewClose:active {
transform: rotate(45deg) scale(0.92);
}
// FIXED: this box spanned the full width of the screen (inset-inline: 0) to
// centre its text, and at top:24px/height~18px it vertically overlapped the
// close button's box (top:14px, 38px tall — occupying 14–52px) by ~28px. Nothing
// visually showed it there since the text itself is centred and narrow, but the
// full-width transparent box still intercepted pointer events in that overlap
// band, sitting on top of the close button (same z-index, later in the DOM
// wins the tie) — a tap that looked like it landed on the button actually hit
// this invisible strip instead, which is why the button felt like its hitbox
// had drifted below where it was drawn. Text and decoration only, never a
// click target, so it should never have been able to steal one.
.pd__previewCounter {
position: absolute;
top: calc(24px + env(safe-area-inset-top, 0px));
inset-inline-start: 0;
inset-inline-end: 0;
z-index: 2;
text-align: center;
font-size: 12px;
font-weight: 700;
color: rgba(255, 255, 255, 0.75);
direction: ltr;
pointer-events: none;
}
// Sizing shell for the slider it wraps (GallerySlider needs a sized parent —
// `flex: 1` here, `height: 54vh` for .pd__stage above); the track/cell/image
// rules themselves live in GallerySlider.vue, with `fit="contain"` set in the
// template for this instance.
.pd__previewStage {
flex: 1;
min-height: 0;
}
// Horizontal thumbnail strip, replacing the old dot row (which told you HOW
// MANY images there were and which one you were on, but not what any of them
// actually showed — jumping to a specific photo meant counting dots). Real
// frames of the real photos instead: quicker to scan and to reach with a
// single tap, especially past four or five images where dots alone stop being
// legible at a glance.
.pd__previewThumbs {
flex-shrink: 0;
display: flex;
// Centred, not left- or right-anchored: with few thumbnails (the common
// case — most products have a handful of photos) a flex row's default
// start-alignment left the strip hugging one edge instead of sitting under
// the image it belongs to. `justify-content` only affects extra space when
// the row is short enough not to need it — once there are enough thumbnails
// to overflow, `overflow-x: auto` takes over and this has no effect, so
// scrolling to reach the rest still works exactly as before.
justify-content: center;
gap: 8px;
overflow-x: auto;
scroll-snap-type: x proximity;
padding: 10px 16px calc(18px + env(safe-area-inset-bottom, 0px));
}
.pd__previewThumb {
scroll-snap-align: center;
flex: 0 0 auto;
width: 52px;
height: 52px;
padding: 6px;
border: 0;
border-radius: 12px;
cursor: pointer;
overflow: hidden;
// FIXED: was a near-invisible tint (rgba(255,255,255,0.06)) — against the
// dark preview overlay, a piece shot on a transparent or white background
// (the catalogue photos this strip shows) had no surface to sit on and read
// as floating in the dark with no edges. Solid white gives every thumbnail
// the same real ground the piece was actually photographed against, the way
// ProductCard/ProductPanel's own panels do for the exact same reason.
background: #fff;
// A dim, unselected thumbnail is the resting state; the active one lifts to
// full brightness with a ring, so the strip reads as a filmstrip with one
// frame in focus rather than a flat, undifferentiated row.
opacity: 0.65;
outline: 2px solid transparent;
outline-offset: 2px;
transition: opacity 0.2s ease, outline-color 0.2s ease, transform 0.18s ease;
}
.pd__previewThumb:active {
transform: scale(0.94);
}
.pd__previewThumb.is-on {
opacity: 1;
// Still legible against the thumbnail's own new white background because
// `outline-offset: 2px` above pushes the ring OUTSIDE the box — the 2px gap
// between the white square and the white ring shows the dark preview
// overlay behind it, so the ring reads as floating over dark, not as a
// white-on-white line that would vanish.
outline-color: #fff;
}
.pd__previewThumb img {
width: 100%;
height: 100%;
// `contain`, not `cover`: with the white background above, the whole piece
// should sit fully visible on that ground rather than being cropped to fill
// the square — the same reasoning behind `contain` everywhere else in this
// app that shows a piece of jewellery (GallerySlider's own `cover`/`contain`
// fit is for the full-size stage, where the frame is large enough that
// `cover` doesn't lose meaningful detail; a 52px thumbnail has no such room
// to spare).
object-fit: contain;
display: block;
-webkit-user-drag: none;
user-select: none;
}
// Simple fade + settle for the preview opening/closing, independent of the
// gallery's own slide-swipe transition on .pd__track.
.pd-preview-enter-active,
.pd-preview-leave-active {
transition: opacity 0.24s ease;
}
.pd-preview-enter-from,
.pd-preview-leave-to {
opacity: 0;
}
/* ── Sheet ───────────────────────────────────────────────── */
// Rides over the stuck gallery. A higher stacking level than .pd__gallery is
// what lets it cover the image instead of scrolling behind it, and the bottom
// padding is what gives the sheet enough length to scroll at all — without it
// a short product would have nothing to scroll and the image would never be
// covered.
.pd__scroll {
position: relative;
z-index: 2;
}
.pd__sheet {
position: relative;
// Overlaps the image by 8px — just enough for the rounded top edge to sit ON
// the picture rather than below it, without covering the bottom of the
// piece. The radius stays 32px (ProductPanel's, the reel CTA's); only the
// pull-up is this small.
margin-top: -8px;
border-radius: 32px 32px 0 0;
background: @canvas;
padding: 8px 18px 8px;
box-shadow: 0 -18px 40px -30px rgba(20, 22, 26, 0.55);
// Tall enough to fill the screen once it has scrolled up under the header,
// so the end state is a full page rather than a sheet stopping short with a
// strip of photo left under it. Also guarantees the travel exists even for a
// product with almost no detail text.
//
// Subtracts the header and the action bar — the two things pinned outside
// this box — so filling the remainder is exactly one screen, with no extra
// scroll left over at the bottom.
min-height: calc(100vh - 60px - 72px - env(safe-area-inset-top, 0px));
}
.pd__grip {
width: 38px;
height: 4px;
margin: 0 auto 12px;
border-radius: 999px;
background: @hairline;
}
.pd__eyebrow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pd__eyebrowText {
font-size: 11.5px;
font-weight: 600;
color: @inkFaint;
}
.pd__stock {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 11px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
}
.pd__stockDot {
width: 5px;
height: 5px;
border-radius: 999px;
background: currentColor;
}
.pd__stock.is-in {
color: @green;
background: fade(@green, 8%);
}
.pd__stock.is-pre {
color: @wine;
background: @wineWash;
}
.pd__stock.is-out {
color: @inkMuted;
background: @hairlineSoft;
}
/* ── Head ────────────────────────────────────────────────── */
.pd__head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 14px;
padding-top: 10px;
}
.pd__headMain {
min-width: 0;
}
.pd__title {
margin: 0;
// Page title, one step above a section heading in the home screen's scale
// (.home__heading / .sec__title are 16px) — the largest type the design
// system uses for a heading, not a display size of its own.
font-size: 14px;
font-weight: 700;
line-height: 1.7;
color: @ink;
}
.pd__code {
margin: 5px 0 0;
direction: ltr;
text-align: end;
font-size: 11px;
font-weight: 700;
color: @inkFaint;
}
// Occupies the reference's price slot. Two real figures instead of an invented
// price — see the file header.
.pd__figures {
flex-shrink: 0;
display: flex;
align-items: flex-end;
gap: 8px;
}
.pd__figure {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 7px 11px;
// 13px is the radius CategoryGrid's and AccountStrip's small tiles use.
border-radius: 13px;
color: @ink;
background: @hairlineSoft;
}
.pd__figure--accent {
color: @wine;
background: @wineWash;
}
.pd__figureValue {
// A figure, so one step up from body text (13.5px is the heading size in
// this scale) — not the 17px display number it was.
font-size: 14px;
font-weight: 800;
line-height: 1.3;
color: inherit;
direction: ltr;
}
.pd__figureLabel {
font-size: 10px;
font-weight: 500;
color: @inkFaint;
}
.pd__figure--accent .pd__figureLabel {
color: fade(@wine, 65%);
}
/* ── Variant chips ───────────────────────────────────────── */
.pd__chips {
display: flex;
gap: 8px;
margin-top: 20px;
// Bleed to the screen edge so chips scroll past the sheet's gutter, the way
// CategoryGrid's rail does; the padding puts the gutter back inside.
margin-inline: -18px;
padding: 3px 18px;
overflow-x: auto;
}
.pd__chip {
flex: 0 0 auto;
min-width: 42px;
height: 34px;
padding: 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
white-space: nowrap;
border-radius: 999px;
border: 1px solid @hairline;
color: @inkMuted;
background: @canvas;
transition: transform 0.18s ease, background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
.pd__chip:active {
transform: scale(0.95);
}
.pd__chip.is-on {
color: #fff;
background: @ink;
border-color: @ink;
}
/* ── Blocks ──────────────────────────────────────────────── */
.pd__block {
margin-top: 24px;
}
.pd__blockTitle {
margin: 0 0 11px;
font-size: 16px;
font-weight: 600;
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: 800;
color: @ink;
text-decoration: underline;
text-underline-offset: 4px;
}
.pd__specs {
margin: 0;
border-radius: 22px;
background: @hairlineSoft;
overflow: hidden;
}
.pd__spec {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 16px;
border-bottom: 1px solid @hairline;
}
.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;
}
/* ── Action bar ──────────────────────────────────────────── */
// Pinned to the bottom of the scroll container, not the viewport. `sticky`
// with a negative-free bottom keeps it inside .pd — the same reason the
// gallery is sticky rather than fixed: in the Drawer a fixed bar would pin
// itself to the viewport and hang over whatever else is on screen.
.pd__bar {
position: sticky;
bottom: 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: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(18px) saturate(140%);
border-top: 1px solid @hairline;
}
// The reference's outlined left pill.
.pd__pill {
flex-shrink: 0;
max-width: 44%;
height: 48px;
padding: 0 15px;
display: inline-flex;
align-items: center;
gap: 7px;
cursor: pointer;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
border-radius: 999px;
border: 1px solid @hairline;
color: @ink;
background: @canvas;
transition: transform 0.18s ease, background 0.2s ease;
}
.pd__pill:active {
transform: scale(0.97);
}
// A long category name truncates rather than pushing the primary button out of
// the bar.
.pd__pillText {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.pd__qty {
flex-shrink: 0;
height: 48px;
padding: 0 6px;
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 999px;
border: 1px solid @hairline;
background: @canvas;
}
.pd__qtyBtn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
cursor: pointer;
color: @ink;
background: @hairlineSoft;
transition: transform 0.18s ease;
}
.pd__qtyBtn:active {
transform: scale(0.9);
}
.pd__qtyValue {
min-width: 20px;
text-align: center;
font-size: 13px;
font-weight: 800;
color: @ink;
}
.pd__cta {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 48px;
border: 0;
border-radius: 999px;
cursor: pointer;
font-family: inherit;
font-size: 13.5px;
font-weight: 800;
white-space: nowrap;
text-decoration: none;
color: #fff;
background: @ink;
box-shadow: 0 14px 30px -18px rgba(20, 22, 26, 0.9);
transition: transform 0.18s ease, opacity 0.18s ease;
}
.pd__cta:active {
transform: scale(0.985);
}
.pd__cta:disabled {
cursor: default;
opacity: 0.4;
box-shadow: none;
}
</style>