Files
tehran_taj/apps/user-app/src/components/home/ShowcaseSlider.vue
T
s.esmaeili f441ca8289 feat(user-apps): appearance settings (dark mode + reduced motion), product morph, filter sheet
Applies to both user-app and user-app-v2. Logic and structure are shared;
each app keeps its own look.

Appearance settings
- New "تنظیمات ظاهری" page (/settings/appearance), opened from the
  dashboard and available without login. It offers a light/dark/system
  theme and a "fast & light" motion switch.
- appearanceStore saves the choice to localStorage. A small inline boot
  script in index.html applies it before first paint so there is no
  white flash. The "system" option follows OS changes live.
- New ToggleSwitch component.

Dark mode
- Theme LESS tokens now compile to CSS custom properties. Light/dark
  values come from a generated virtual:palette.css (themeConfig/
  appearance.ts, with colors.json + new colors.dark.json).
- New LESS alpha() plugin replaces fade() on theme tokens.
- New semantic tokens: page, onInk, heroSurface, hairlineStrong,
  photoMat, photoStage.
- The dark palette is layered (page < card < panel < border) so
  surfaces separate without shadows.
- Hard-coded colors were replaced by tokens according to their role.
- antd inputs/text get dark overrides; vant uses its own
  .van-theme-dark.
- Light mode looks the same as before.

Reduced motion
- With html.motion-reduced, transitions are capped at 120ms, keyframe
  loops stop, and backdrop blurs and permanent will-change are dropped.
- Spinners and the toast progress bar (which closes the toast) are
  exempt via .keep-motion.
- Carousel autoplay, the v1 Lottie and the product morph honor the
  setting.

Product detail
- Opening a product is now a container-transform morph from the tapped
  card (ProductMorph). It replaces the bottom Drawer and uses composited
  transform/opacity only.
- The detail view is seeded with the card's data, so it no longer
  opens blank and jumps.

Filters and sheets
- Filters are now a full-width bottom sheet. Selections stay a draft
  until applied, so the result list no longer re-renders while
  dragging the slider.
- DropDown collapse animates grid-template-rows instead of a capped
  max-height.
- v2 IslandSheet is restyled to the v2 design language (API unchanged).

Fixes
- filtersStore.reset() shallow-copied shared arrays, so clearing the
  filters never cleared the selected brands.
2026-10-04 16:18:56 +03:30

292 lines
8.6 KiB
Vue

<!--
Editorial hero for the home screen. Self-contained, dependency-free slider:
no swiper CSS to fight, no autoplay jitter, and it keeps working when a
slide has no artwork.
Swipe support: the app sets `direction: rtl !important` on #app (see
src/App.vue), so — same fix as GallerySlider.vue — the track is an isolated
`direction: ltr` subtree (.showcase__track). Inside it, index 0 is first and
a positive translateX moves right, plain LTR arithmetic, regardless of the
page's own direction. The drag gesture itself is still read in screen space
(dragging left = next slide) so it feels natural to an RTL user; only the
track's internal layout/maths is pinned to LTR.
-->
<template>
<section class="showcase" v-if="slides.length">
<div
ref="root"
class="showcase__frame"
:class="{ 'is-dragging': dragging }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerCancel"
>
<div class="showcase__track" :style="trackStyle">
<article
v-for="(slide, i) in slides"
:key="slide.key"
class="showcase__slide"
:class="{ 'is-active': i === index }"
@click="onSlideClick(slide)"
>
<img v-if="slide.image" :src="slide.image" alt="" class="showcase__art" draggable="false" />
</article>
</div>
</div>
<div class="showcase__dots" v-if="slides.length > 1">
<button
v-for="(slide, i) in slides"
:key="slide.key"
type="button"
class="showcase__dot"
:class="{ 'is-active': i === index }"
:aria-label="slide.title"
@click="go(i)"
/>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useAppearanceStore } from '../../stores/appearanceStore'
export interface ShowcaseSlide {
key: string
eyebrow: string
title: string
subtitle: string
cta: string
image?: string
categoryId?: string
/** Admin-uploaded slide's own destination (manage/Const.vue), when it has
* one. Takes priority over `categoryId` in the click handler. */
link?: string
}
const props = defineProps<{ slides: ShowcaseSlide[] }>()
const emit = defineEmits<{ (e: 'select', slide: ShowcaseSlide): void }>()
const index = ref(0)
let timer: ReturnType<typeof setInterval> | undefined
const appearance = useAppearanceStore()
function go(i: number) {
index.value = i
restart()
}
function restart() {
if (timer) clearInterval(timer)
// No autoplay in reduced-motion mode: a slide changing on its own every
// six seconds is exactly the background motion that mode turns off.
if (props.slides.length > 1 && !appearance.isReducedMotion) {
timer = setInterval(() => {
index.value = (index.value + 1) % props.slides.length
}, 6000)
}
}
onMounted(restart)
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
// ── Drag / swipe ─────────────────────────────────────────────
const root = ref<HTMLElement | null>(null)
const dragging = ref(false)
const dragX = ref(0)
let pointerId: number | null = null
let startX = 0
let startY = 0
let trackWidth = 0
let isSwipe = false
const DRAG_COMMIT_RATIO = 0.18
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
startY = e.clientY
trackWidth = el.getBoundingClientRect().width || 1
dragX.value = 0
dragging.value = true
isSwipe = false
el.setPointerCapture?.(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!dragging.value || e.pointerId !== pointerId) return
const dx = e.clientX - startX
const dy = e.clientY - startY
if (!isSwipe && Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > TAP_MAX_PX) isSwipe = true
if (isSwipe) dragX.value = dx
}
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: dragging left (negative dx) is "next slide",
// matching what the gesture means to the person doing it.
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 (isSwipe) restart()
isSwipe = false
}
function onPointerUp(e: PointerEvent) {
if (e.pointerId !== pointerId) return
endDrag()
}
function onPointerCancel(e: PointerEvent) {
if (e.pointerId !== pointerId) return
dragging.value = false
pointerId = null
dragX.value = 0
isSwipe = false
}
function onSlideClick(slide: ShowcaseSlide) {
// A drag also fires a trailing click on release; only treat it as a tap
// (i.e. open the slide) when the gesture never turned into a swipe.
if (isSwipe) return
emit('select', slide)
}
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">
.showcase__frame {
position: relative;
border-radius: 22px;
overflow: hidden;
// Neutral rather than @ink: this only shows before artwork loads, and a
// dark fill read as an unwanted border wherever it was visible at all.
background: @hairlineSoft;
box-shadow: 0 26px 50px -34px rgba(20, 22, 26, 0.85);
aspect-ratio: 320 / 208;
touch-action: pan-y;
user-select: none;
-webkit-user-select: none;
// Forces its own compositing layer so `overflow: hidden` actually clips the
// translated track on Safari/iOS — without this, a sliver of the next
// slide peeks past the rounded edge because the browser composites the
// transformed child (.showcase__track) on a layer that ignores the
// parent's clip until it's promoted the same way.
transform: translateZ(0);
-webkit-transform: translateZ(0);
}
// Isolated from the page's `direction: rtl` — see the file header. Everything
// under this node uses plain LTR layout and LTR-signed transforms.
.showcase__track {
direction: ltr;
display: flex;
height: 100%;
width: 100%;
// A gentle, slightly-overshooting ease — softer settle than a linear/
// ease-out curve, which is what makes the swipe feel "alive" rather than
// mechanical. Slowed down a touch from the old 0.42s so the motion reads
// as a deliberate glide instead of a snap.
transition: transform 0.56s cubic-bezier(0.16, 0.86, 0.3, 1);
will-change: transform;
}
.showcase__frame.is-dragging .showcase__track {
transition: none;
}
.showcase__slide {
position: relative;
flex: 0 0 100%;
width: 100%;
height: 100%;
cursor: pointer;
// Clips its own image's scale-in (.showcase__art below) to this slide's own
// box, so the 1.06x zoom never bleeds into the neighbouring slide.
overflow: hidden;
}
.showcase__art {
// A plain block filling the slide, NOT an absolutely-positioned box.
// `position: absolute` + `inset` made the image's own box a second,
// independently-rounded measurement of the same area the frame already
// sizes via `aspect-ratio`; on real devices the two disagreed by a
// fraction of a pixel and the frame's dark background showed through as a
// hairline border around the artwork. Sizing it as ordinary in-flow
// content removes that second measurement entirely.
display: block;
width: 100%;
height: 100%;
object-fit: cover;
-webkit-user-drag: none;
user-select: none;
// Never settles below 1x: the "breathe in" flourish now runs from a larger
// scale down to exactly 1, so the image is always at least as big as the
// slide and can never reveal what sits behind it.
transform: scale(1.06);
transition: transform 0.56s cubic-bezier(0.16, 0.86, 0.3, 1);
}
.showcase__slide.is-active .showcase__art {
transform: scale(1);
}
.showcase__frame.is-dragging .showcase__art {
transition: none;
}
.showcase__dots {
display: flex;
justify-content: center;
gap: 6px;
padding-top: 12px;
}
.showcase__dot {
width: 6px;
height: 6px;
padding: 0;
border: 0;
cursor: pointer;
border-radius: 999px;
background: @hairline;
transition: width 0.42s cubic-bezier(0.16, 0.86, 0.3, 1), background 0.3s ease;
}
.showcase__dot.is-active {
width: 20px;
background: @wine;
}
</style>