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.
292 lines
8.6 KiB
Vue
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>
|