Files
hedena_gold/apps/user-app-v2/src/components/product/GallerySlider.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

288 lines
11 KiB
Vue

<!--
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>