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
This commit is contained in:
s.esmaeili
2026-09-18 16:22:31 +03:30
parent 53a304b5d5
commit b80fa9bcfb
73 changed files with 1035 additions and 180 deletions
+30 -10
View File
@@ -3,7 +3,7 @@
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import icons from './icons.json'
interface Props {
@@ -14,17 +14,37 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
color: null,
})
const tempIcon = ref<string>()
const refIcon = ref<HTMLSpanElement>()
tempIcon.value = (icons as Record<string, string>)[`${props.icon}`]
onMounted(() => {
if (props.size) {
const svg = refIcon.value?.children[0] as SVGElement
svg.setAttribute('width', props.size as string)
svg.setAttribute('height', props.size as string)
}
})
// FIXED: this used to be a plain ref, assigned once at setup —
// tempIcon.value = icons[props.icon]
// — instead of a computed. Vue reuses the same component instance across
// re-renders when an `Icon` sits in the same template position (which every
// caller in this app does; none pass a `:key` keyed to the icon name), so
// setup only runs once per mount and that assignment never ran again. Any
// caller that swaps `icon` on an already-mounted instance — the exact pattern
// the cart quantity steppers use, `:icon="qty === 1 ? 'trash' : 'minus'"` —
// had its SVG freeze on whichever icon happened to render first and silently
// stop tracking the prop after that: incrementing a cart line past 1 never
// swapped the frozen trash glyph back to minus, and the reverse (decrementing
// back down to 1) never swapped a frozen minus back to trash either. A
// computed re-evaluates on every `icon` change, so the markup — and, via the
// watcher below, the width/height attributes size() sets on it — now actually
// track the prop instead of only reading it once.
const tempIcon = computed(() => (icons as Record<string, string>)[`${props.icon}`])
function applySize() {
if (!props.size) return
const svg = refIcon.value?.children[0] as SVGElement | undefined
svg?.setAttribute('width', props.size as string)
svg?.setAttribute('height', props.size as string)
}
// v-html replaces refIcon's children synchronously with the DOM update, but
// that update itself is queued — nextTick() waits for it so applySize() finds
// the new <svg> already in the DOM rather than the previous one (or none, on
// the very first render).
watch(tempIcon, () => nextTick(applySize), { immediate: true })
</script>
<style scoped>
@@ -29,6 +29,12 @@
</div>
<div class="ci__row">
<!-- App-wide stepper order rule (also followed by SingleProduct.vue's
.pd__qty): plus on the visual right, minus (or trash at qty 1) on
the visual left. The page is RTL, so a plain flex row lays its
DOM-first child on the right — plus has to come FIRST in markup,
not last, to land there. Keep both steppers' DOM order in sync if
either one changes. -->
<div class="ci__qty">
<button type="button" class="ci__qtyBtn" aria-label="افزودن" @click="increment">
<Icon icon="vuesax-linear:add" size="16" />
@@ -0,0 +1,287 @@
<!--
Swipeable image slider — a from-scratch rewrite of the gallery/preview
carousel that used to live inline in SingleProduct.vue.
WHY A REWRITE, NOT A PATCH: the old slider swiped correctly by feel but
visibly jumped mid-gesture, in both the inline gallery and the full-screen
preview. The root cause was structural, not a tunable number:
- The whole app sets `direction: rtl !important` on #app (see
src/App.vue). Under RTL, a flex row lays its children right-to-left —
slide 0 sits at the visual RIGHT edge, not the left.
- `transform: translate3d(X%, 0, 0)` is direction-agnostic: a positive X
always moves content toward the visual right, in RTL exactly as in LTR.
- The old track combined `translate3d(index * 100%, 0, 0)` with the page's
ambient RTL flex order. Those two systems don't agree on which way
"forward" is, so the settle position after a drag did not line up with
where the flex box had actually placed that slide — the visual result
was the image overshooting and snapping back, i.e. the reported "jump".
THE FIX: this component's slider track is an isolated `direction: ltr`
subtree (see .gs__track below). Once inside that subtree, slide order,
pointer-delta sign and `translateX` sign are all plain, ordinary LTR
arithmetic — index 0 first, positive X moves right, done — regardless of
what direction the rest of the page is in. The component's own controls
(dots, drag gesture) still read as natural "swipe right for previous
image" to an RTL user because the *gesture* is still interpreted in screen
space; only the slider's internal layout is pinned to LTR so its own maths
is self-consistent.
A few other correctness fixes bundled into the same rewrite, since they were
entangled with the same code:
- Pointer Events instead of Touch Events: one code path for touch, mouse
and pen, with proper pointer capture so a drag that leaves the element
(finger sliding off the edge) is still tracked to completion instead of
silently stopping — the old touch-only handlers dropped the gesture the
moment the finger crossed the element boundary.
- Position is settled against the track's OWN measured width
(getBoundingClientRect), not against `%` of an assumed-100%-per-slide
layout — immune to any box-sizing/padding mismatch between the measured
width and the CSS `flex: 0 0 100%` cell width.
- One component, two instances (the inline stage and the full-screen
preview both render a <GallerySlider>) instead of one set of drag state
manually re-entered for two different DOM subtrees via a `source` tag.
They stay in sync through `v-model:index`, a plain reactive prop/emit
binding — not shared mutable drag variables, which is what the "does a
drag started on A leak into B" bug class was ultimately rooted in.
-->
<template>
<div
ref="root"
class="gs"
:class="{ 'is-dragging': dragging }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerCancel"
@click="onClick"
>
<div class="gs__track" :style="trackStyle">
<div v-for="src in slides" :key="src" class="gs__cell">
<img :src="src" :alt="alt" class="gs__img" :class="fitClass" draggable="false" />
</div>
</div>
<div v-if="showCounter && slides.length > 1" class="gs__counter">{{ index + 1 }}/{{ slides.length }}</div>
<slot name="dots" :index="index" :go="go" />
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
const props = withDefaults(
defineProps<{
slides: string[]
alt: string
/** `cover` (the inline stage's default) or `contain` (the preview's). */
fit?: 'cover' | 'contain'
/** Show the built-in "n/N" pill, bottom-start of the slider.
*
* Off by default. FIXED: both call sites in SingleProduct.vue already
* draw their own overlay chrome in that same corner — the inline stage
* has .pd__expand there, and the preview has its own top-centre
* .pd__previewCounter — so this component's counter used to render on
* top of .pd__expand unconditionally, the two pieces of UI visibly
* overlapping into illegible stacked text/icons. Neither caller wants
* this counter today; a future bare usage that does can opt in. */
showCounter?: boolean
}>(),
{ fit: 'cover', showCounter: false },
)
const index = defineModel<number>('index', { default: 0 })
const emit = defineEmits<{
/** A plain tap — press and release without a real drag. Distinct from a
* native `click`, which a touch sequence also fires synthetically on
* release even after a drag; consumers that want "open on tap only"
* should use this instead of listening for `click` themselves. */
(e: 'tap'): void
}>()
const fitClass = computed(() => (props.fit === 'contain' ? 'gs__img--contain' : 'gs__img--cover'))
const root = ref<HTMLElement | null>(null)
// ── Drag state ────────────────────────────────────────────────
// All local to one instance — the inline stage and the full-screen preview
// each mount their own GallerySlider and so each get their own root, dragX,
// dragging, etc. Nothing here is shared across instances; only `index` is,
// via v-model, which is exactly the one thing that SHOULD stay in sync.
const dragging = ref(false)
const dragX = ref(0)
let pointerId: number | null = null
let startX = 0
let trackWidth = 0
// Past this many pixels of travel, the drag counts as a swipe rather than a
// tap — used both to decide whether to commit to the next/previous slide and
// to suppress the `tap` event so a swipe never also opens the preview.
const DRAG_COMMIT_RATIO = 0.18 // fraction of the track's own width
const TAP_MAX_PX = 6
function onPointerDown(e: PointerEvent) {
if (props.slides.length < 2 || pointerId !== null) return
const el = root.value
if (!el) return
pointerId = e.pointerId
startX = e.clientX
trackWidth = el.getBoundingClientRect().width || 1
dragX.value = 0
dragging.value = true
// Pointer capture keeps delivering move/up events to this element even once
// the pointer leaves it — the touch-events version of this component lost
// the gesture the instant a fast swipe crossed the element's edge.
el.setPointerCapture?.(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!dragging.value || e.pointerId !== pointerId) return
dragX.value = e.clientX - startX
}
function endDrag() {
if (!dragging.value) return
const dx = dragX.value
const ratio = dx / trackWidth
dragging.value = false
pointerId = null
dragX.value = 0
// Screen-space drag direction, translated to an index step. This is the one
// place RTL vs. LTR actually matters for *behaviour* (not layout): dragging
// a finger to the left (negative dx) is "next image" regardless of the
// track's own internal LTR layout, because that is what the gesture means
// to the person doing it, in either reading direction.
if (ratio <= -DRAG_COMMIT_RATIO && index.value < props.slides.length - 1) index.value++
else if (ratio >= DRAG_COMMIT_RATIO && index.value > 0) index.value--
if (Math.abs(dx) <= TAP_MAX_PX) emit('tap')
}
function onPointerUp(e: PointerEvent) {
if (e.pointerId !== pointerId) return
endDrag()
}
function onPointerCancel(e: PointerEvent) {
if (e.pointerId !== pointerId) return
// A cancelled gesture (e.g. the OS takes over for a system swipe) snaps back
// to the current slide rather than committing a possibly-partial drag.
dragging.value = false
pointerId = null
dragX.value = 0
}
// A touch sequence still fires a native `click` on release. `onPointerUp`
// already emits `tap` for a real tap and has already run by the time `click`
// fires, so this handler only needs to swallow the click's default double-
// firing — nothing further to do here, but the listener stays as documentation
// that `click` is deliberately NOT used for the tap-to-preview affordance
// (`tap` is) precisely because `click` also fires after a swipe.
function onClick() {
/* intentionally empty — see comment above */
}
function go(i: number) {
index.value = i
}
// ── Track position ───────────────────────────────────────────
// The whole point of isolating this in its own `direction: ltr` subtree (see
// .gs__track in <style>): inside it, "index 0 is first" and "positive X moves
// right" are both just true, with no page-level RTL to reconcile against.
const trackStyle = computed(() => {
const base = -index.value * 100
const dragPercent = trackWidth ? (dragX.value / trackWidth) * 100 : 0
return {
transform: `translate3d(${base + dragPercent}%, 0, 0)`,
transition: dragging.value ? 'none' : undefined,
}
})
onBeforeUnmount(() => {
if (pointerId !== null && root.value) {
try {
root.value.releasePointerCapture(pointerId)
} catch {
/* element may already be detached */
}
}
})
</script>
<style scoped lang="less">
.gs {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
touch-action: pan-y;
user-select: none;
-webkit-user-select: none;
}
// Isolated from the page's `direction: rtl` — see the file header. Everything
// under this node uses plain LTR layout and LTR-signed transforms.
.gs__track {
direction: ltr;
display: flex;
height: 100%;
width: 100%;
transition: transform 0.42s cubic-bezier(0.22, 0.61, 0.36, 1);
will-change: transform;
}
.gs.is-dragging .gs__track {
transition: none;
}
.gs__cell {
flex: 0 0 100%;
width: 100%;
height: 100%;
}
.gs__img {
width: 100%;
height: 100%;
display: block;
-webkit-user-drag: none;
user-select: none;
}
.gs__img--cover {
// The inline stage's own padding/background is supplied by the caller
// (.pd__stage); this component only sets the fit mode.
object-fit: contain;
padding: 46px 20px 20px;
}
.gs__img--contain {
object-fit: contain;
padding: 20px 22px;
}
.gs__counter {
position: absolute;
bottom: 14px;
// Placed with a logical property (so it sits at the trailing edge in the
// page's own RTL reading order) — this is UI chrome layered on top of the
// slider, not part of the isolated LTR track, so it should still follow the
// page's direction like everything else in the app.
inset-inline-start: 14px;
padding: 4px 9px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
color: #fff;
background: rgba(20, 22, 26, 0.45);
backdrop-filter: blur(4px);
direction: ltr;
pointer-events: none;
}
</style>
@@ -69,35 +69,40 @@
<!-- ── Gallery ───────────────────────────────────────── -->
<div class="pd__gallery">
<div
<GallerySlider
v-if="hasImages"
v-model:index="imageIndex"
:slides="slides"
:alt="title"
class="pd__stage"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend="onTouchEnd"
@tap="openPreview"
>
<div class="pd__track" :class="{ 'is-dragging': dragging }" :style="{ transform: `translate3d(${trackOffset}, 0, 0)` }">
<div v-for="src in slides" :key="src" class="pd__slide">
<img :src="src" :alt="title" class="pd__art" />
<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>
</div>
<!-- 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 === imageIndex }"
:aria-label="`تصویر ${i + 1}`"
@click="imageIndex = i"
/>
</div>
</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 ─────────────────────────────── -->
@@ -198,7 +203,18 @@
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"
@@ -207,10 +223,6 @@
>
<Icon :icon="cartQty === 1 ? 'vuesax-linear:trash' : 'vuesax-linear:minus'" size="15" />
</button>
<span class="pd__qtyValue">{{ cartQty }}</span>
<button type="button" class="pd__qtyBtn" aria-label="افزایش" @click="increment">
<Icon icon="vuesax-linear:add" size="15" />
</button>
</div>
<button v-else-if="categoryTitle" type="button" class="pd__pill" @click="goCategory">
<Icon icon="vuesax-linear:category" size="17" />
@@ -234,6 +246,54 @@
</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>
@@ -257,9 +317,10 @@
</template>
<script setup lang="ts">
import { computed, onBeforeMount, ref, watch } from 'vue'
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'
@@ -361,48 +422,30 @@ watch(slides, (list) => {
if (imageIndex.value > list.length - 1) imageIndex.value = 0
})
// ── Gallery swipe ────────────────────────────────────────────
// The slides are stacked on one line and the track is translated to bring the
// active one on screen. The page is RTL, so the line runs right-to-left and
// slide N sits at +N × 100%: a drag towards the start of the line (negative dx
// in screen coordinates) advances to the next image.
// ── 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.
//
// A plain transform slider rather than a carousel library — the app already
// pulls swiper in for the home rails, but this is a few lines of maths over
// three or four images and does not need one.
const dragX = ref(0)
const dragging = ref(false)
let startX = 0
// 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)
// While dragging, the finger's travel is carried as a raw pixel delta on top of
// the settled percentage, so the image tracks the thumb instead of snapping.
const trackOffset = computed(() =>
dragging.value ? `calc(${imageIndex.value * 100}% + ${dragX.value}px)` : `${imageIndex.value * 100}%`,
)
// Past this much travel the swipe commits to the next image; below it the
// track springs back to where it started.
const SWIPE_COMMIT_PX = 48
function onTouchStart(e: TouchEvent) {
if (slides.value.length < 2) return
startX = e.touches[0].clientX
dragX.value = 0
dragging.value = true
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 onTouchMove(e: TouchEvent) {
if (!dragging.value) return
dragX.value = e.touches[0].clientX - startX
}
function onTouchEnd() {
if (!dragging.value) return
const dx = dragX.value
dragging.value = false
dragX.value = 0
if (dx <= -SWIPE_COMMIT_PX && imageIndex.value < slides.value.length - 1) imageIndex.value++
else if (dx >= SWIPE_COMMIT_PX && imageIndex.value > 0) imageIndex.value--
function closePreview() {
previewOpen.value = false
document.body.style.overflow = ''
}
const wageLabel = computed(() => {
@@ -467,6 +510,13 @@ const goCategory = () => {
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">
@@ -656,47 +706,12 @@ onBeforeMount(async () => {
color: @inkFaint;
}
.pd__track {
display: flex;
height: 100%;
// Settles with the same long decelerate the home screen's reveal uses, so
// the two read as one system. Suppressed mid-drag so the image sits under
// the finger rather than lagging behind it.
transition: transform 0.5s cubic-bezier(0.32, 0.72, 0, 1);
will-change: transform;
}
.pd__track.is-dragging {
transition: none;
}
.pd__slide {
flex: 0 0 100%;
height: 100%;
// Each slide sits one full width further along the line; with the track
// translated by +index × 100% the active one lands on screen.
margin-inline-start: -100%;
}
.pd__slide:first-child {
margin-inline-start: 0;
}
.pd__art {
width: 100%;
height: 100%;
// `contain`, not `cover`: a piece of jewellery is the subject of its own
// photo, so cropping it to fill the frame cut the edges off. Every other
// card in the app (ProductCard, ProductPanel, CartItem) uses `contain` for
// the same reason — the whole piece has to be visible.
object-fit: contain;
// Breathing room so the piece does not touch the frame or sit under the
// floating controls at the top.
padding: 46px 20px 20px;
display: block;
// Otherwise a drag on the image starts the browser's own image drag and the
// touch sequence never reaches touchend.
-webkit-user-drag: none;
user-select: none;
}
// 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 {
@@ -727,6 +742,196 @@ onBeforeMount(async () => {
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