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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user