Replace the home hero with a spring carousel and default theme to light.
Adds Carousel under keep-alive-safe autoplay, peeks side cards past the container, and stops following the OS dark preference on first run. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -19,7 +19,8 @@
|
|||||||
try {
|
try {
|
||||||
var s = JSON.parse(localStorage.getItem('hedena-gold:appearance') || '{}');
|
var s = JSON.parse(localStorage.getItem('hedena-gold:appearance') || '{}');
|
||||||
var mq = function (q) { return window.matchMedia && matchMedia(q).matches; };
|
var mq = function (q) { return window.matchMedia && matchMedia(q).matches; };
|
||||||
var dark = s.theme === 'dark' || ((s.theme || 'system') === 'system' && mq('(prefers-color-scheme: dark)'));
|
// Default is light when nothing is saved (matches appearanceStore).
|
||||||
|
var dark = s.theme === 'dark' || (s.theme === 'system' && mq('(prefers-color-scheme: dark)'));
|
||||||
var motion = typeof s.reduceMotion === 'boolean' ? s.reduceMotion : mq('(prefers-reduced-motion: reduce)');
|
var motion = typeof s.reduceMotion === 'boolean' ? s.reduceMotion : mq('(prefers-reduced-motion: reduce)');
|
||||||
var c = document.documentElement.classList;
|
var c = document.documentElement.classList;
|
||||||
if (dark) { c.add('theme-dark', 'van-theme-dark'); document.querySelector('meta[name=theme-color]').setAttribute('content', '#15171A'); }
|
if (dark) { c.add('theme-dark', 'van-theme-dark'); document.querySelector('meta[name=theme-color]').setAttribute('content', '#15171A'); }
|
||||||
|
|||||||
@@ -0,0 +1,507 @@
|
|||||||
|
<!--
|
||||||
|
Spring-physics card carousel for the home screen (see
|
||||||
|
docs/plans/2026-10-16-home-carousel-design.md).
|
||||||
|
|
||||||
|
Center card large, side cards scaled down; drag / flick settle with a
|
||||||
|
damped spring. Internal layout is an isolated `direction: ltr` subtree
|
||||||
|
(same rationale as GallerySlider) so translate/scale maths stay coherent
|
||||||
|
under the app's RTL chrome. Gestures are still screen-space.
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<section class="carousel" v-if="slides.length">
|
||||||
|
<div
|
||||||
|
ref="viewport"
|
||||||
|
class="carousel__viewport"
|
||||||
|
:class="{ 'is-dragging': dragging }"
|
||||||
|
:style="{ '--carousel-radius': `${borderRadius}px` }"
|
||||||
|
@pointerdown="onPointerDown"
|
||||||
|
@pointermove="onPointerMove"
|
||||||
|
@pointerup="onPointerUp"
|
||||||
|
@pointercancel="onPointerUp"
|
||||||
|
>
|
||||||
|
<div ref="stage" class="carousel__stage">
|
||||||
|
<article
|
||||||
|
v-for="(card, i) in cards"
|
||||||
|
:key="card.key"
|
||||||
|
class="carousel__card"
|
||||||
|
:data-index="i"
|
||||||
|
:style="cardStyles[i]"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="card.image"
|
||||||
|
:src="card.image"
|
||||||
|
alt=""
|
||||||
|
class="carousel__art"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="carousel__dots" v-if="slides.length > 1" dir="ltr">
|
||||||
|
<button
|
||||||
|
v-for="(slide, i) in slides"
|
||||||
|
:key="slide.key"
|
||||||
|
type="button"
|
||||||
|
class="carousel__dot"
|
||||||
|
:class="{ 'is-active': i === activeIndex }"
|
||||||
|
:aria-label="`اسلاید ${i + 1}`"
|
||||||
|
@click="goTo(i)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
onActivated,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onDeactivated,
|
||||||
|
onMounted,
|
||||||
|
ref,
|
||||||
|
shallowRef,
|
||||||
|
watch,
|
||||||
|
} from 'vue'
|
||||||
|
import type { ShowcaseSlide } from './ShowcaseSlider.vue'
|
||||||
|
import { useAppearanceStore } from '../../stores/appearanceStore'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
slides: ShowcaseSlide[]
|
||||||
|
sideScale?: number
|
||||||
|
gap?: number
|
||||||
|
borderRadius?: number
|
||||||
|
stiffness?: number
|
||||||
|
damping?: number
|
||||||
|
autoplay?: boolean
|
||||||
|
autoplayInterval?: number
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
// Slightly larger neighbours so peeks read as cards, not hairlines.
|
||||||
|
sideScale: 0.72,
|
||||||
|
gap: 3,
|
||||||
|
// Match ShowcaseSlider frame radius so the active card reads the same.
|
||||||
|
borderRadius: 22,
|
||||||
|
// Premium settle: low stiffness + higher damping = slow, heavy, almost no bounce.
|
||||||
|
stiffness: 70,
|
||||||
|
damping: 28,
|
||||||
|
autoplay: true,
|
||||||
|
autoplayInterval: 7500,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Fraction of stage width for each card — wider center, tighter side peeks. */
|
||||||
|
const CARD_WIDTH_RATIO = 0.92
|
||||||
|
|
||||||
|
const appearance = useAppearanceStore()
|
||||||
|
|
||||||
|
/** Pad copies so wrap never shows empty slots on either side. */
|
||||||
|
const cards = computed(() => {
|
||||||
|
const base = props.slides.filter((s) => s.image)
|
||||||
|
if (!base.length) return [] as { key: string; image: string }[]
|
||||||
|
const out: { key: string; image: string }[] = []
|
||||||
|
let n = 0
|
||||||
|
while (out.length < Math.max(6, base.length * 2)) {
|
||||||
|
for (const s of base) {
|
||||||
|
out.push({ key: `${s.key}#${n++}`, image: s.image as string })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const viewport = ref<HTMLElement | null>(null)
|
||||||
|
const stage = ref<HTMLElement | null>(null)
|
||||||
|
const dragging = ref(false)
|
||||||
|
const activeIndex = ref(0)
|
||||||
|
|
||||||
|
type CardStyle = {
|
||||||
|
opacity: string
|
||||||
|
pointerEvents: 'auto' | 'none'
|
||||||
|
zIndex: string
|
||||||
|
transform: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardStyles = shallowRef<CardStyle[]>([])
|
||||||
|
|
||||||
|
let position = 0
|
||||||
|
let target = 0
|
||||||
|
let velocity = 0
|
||||||
|
let isDragging = false
|
||||||
|
let startX = 0
|
||||||
|
let startPosition = 0
|
||||||
|
let lastX = 0
|
||||||
|
let lastTime = 0
|
||||||
|
let dragVelocity = 0
|
||||||
|
let totalDragDistance = 0
|
||||||
|
let pointerId: number | null = null
|
||||||
|
let rafId: number | null = null
|
||||||
|
let lastFrameTime = 0
|
||||||
|
let autoplayTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
function getWrappedOffset(index: number, pos: number, length: number) {
|
||||||
|
let diff = (index - pos) % length
|
||||||
|
if (diff < -length / 2) diff += length
|
||||||
|
if (diff >= length / 2) diff -= length
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveIndex() {
|
||||||
|
const len = props.slides.filter((s) => s.image).length || 1
|
||||||
|
return ((Math.round(target) % len) + len) % len
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyChange() {
|
||||||
|
activeIndex.value = getActiveIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const list = cards.value
|
||||||
|
const total = list.length
|
||||||
|
if (!total) {
|
||||||
|
cardStyles.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center card is CARD_WIDTH_RATIO of stage; scaled neighbours peek beside it.
|
||||||
|
const stageWidth = stage.value?.clientWidth || 380
|
||||||
|
const baseCardWidth = stageWidth * CARD_WIDTH_RATIO
|
||||||
|
const sideScale = props.sideScale
|
||||||
|
const gap = props.gap
|
||||||
|
const stepDistance = baseCardWidth * 0.5 + baseCardWidth * sideScale * 0.5 + gap
|
||||||
|
|
||||||
|
const next: CardStyle[] = new Array(total)
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
const offset = getWrappedOffset(i, position, total)
|
||||||
|
const absOffset = Math.abs(offset)
|
||||||
|
const clamped = Math.min(absOffset, 1)
|
||||||
|
const scale = 1 - clamped * (1 - sideScale)
|
||||||
|
const translateX = offset * stepDistance
|
||||||
|
const isVisible = absOffset < 2.1
|
||||||
|
|
||||||
|
next[i] = {
|
||||||
|
opacity: isVisible ? '1' : '0',
|
||||||
|
pointerEvents: isVisible ? 'auto' : 'none',
|
||||||
|
zIndex: String(100 - Math.round(absOffset * 20)),
|
||||||
|
transform: `translate3d(calc(-50% + ${translateX.toFixed(2)}px), 0, 0) scale(${scale.toFixed(4)})`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cardStyles.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSpringLoop() {
|
||||||
|
if (appearance.isReducedMotion) {
|
||||||
|
position = target
|
||||||
|
velocity = 0
|
||||||
|
render()
|
||||||
|
rafId = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rafId != null) cancelAnimationFrame(rafId)
|
||||||
|
lastFrameTime = performance.now()
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const dt = Math.min((now - lastFrameTime) / 1000, 0.064)
|
||||||
|
lastFrameTime = now
|
||||||
|
|
||||||
|
const displacement = position - target
|
||||||
|
const springForce = -props.stiffness * displacement
|
||||||
|
const dampingForce = -props.damping * velocity
|
||||||
|
const acceleration = springForce + dampingForce
|
||||||
|
|
||||||
|
velocity += acceleration * dt
|
||||||
|
position += velocity * dt
|
||||||
|
render()
|
||||||
|
|
||||||
|
if (Math.abs(velocity) < 0.002 && Math.abs(target - position) < 0.001) {
|
||||||
|
position = target
|
||||||
|
velocity = 0
|
||||||
|
render()
|
||||||
|
rafId = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (cards.value.length < 2 || pointerId !== null) return
|
||||||
|
if (e.button !== undefined && e.button !== 0) return
|
||||||
|
const el = viewport.value
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
pointerId = e.pointerId
|
||||||
|
isDragging = true
|
||||||
|
dragging.value = true
|
||||||
|
stopAutoplay()
|
||||||
|
|
||||||
|
if (rafId != null) {
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
rafId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
startX = e.clientX
|
||||||
|
lastX = e.clientX
|
||||||
|
startPosition = position
|
||||||
|
velocity = 0
|
||||||
|
dragVelocity = 0
|
||||||
|
totalDragDistance = 0
|
||||||
|
lastTime = performance.now()
|
||||||
|
el.setPointerCapture?.(e.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!isDragging || e.pointerId !== pointerId) return
|
||||||
|
|
||||||
|
const now = performance.now()
|
||||||
|
const dt = Math.max(1, now - lastTime)
|
||||||
|
const dx = e.clientX - startX
|
||||||
|
const stepDx = e.clientX - lastX
|
||||||
|
|
||||||
|
totalDragDistance = Math.abs(dx)
|
||||||
|
|
||||||
|
const cardWidth = (stage.value?.clientWidth ?? 0) * CARD_WIDTH_RATIO || 320
|
||||||
|
position = startPosition - dx / cardWidth
|
||||||
|
dragVelocity = -(stepDx / cardWidth) / (dt / 1000)
|
||||||
|
|
||||||
|
lastX = e.clientX
|
||||||
|
lastTime = now
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(e: PointerEvent) {
|
||||||
|
if (!isDragging || e.pointerId !== pointerId) return
|
||||||
|
isDragging = false
|
||||||
|
dragging.value = false
|
||||||
|
pointerId = null
|
||||||
|
|
||||||
|
const total = cards.value.length
|
||||||
|
|
||||||
|
// Tap a side card → spring to it
|
||||||
|
if (totalDragDistance < 6 && e.target instanceof Element) {
|
||||||
|
const clicked = e.target.closest('.carousel__card') as HTMLElement | null
|
||||||
|
if (clicked) {
|
||||||
|
const cardIdx = Number(clicked.dataset.index)
|
||||||
|
if (!Number.isNaN(cardIdx)) {
|
||||||
|
const offset = getWrappedOffset(cardIdx, position, total)
|
||||||
|
if (Math.abs(offset) > 0.25) {
|
||||||
|
target = Math.round(position + offset)
|
||||||
|
startSpringLoop()
|
||||||
|
notifyChange()
|
||||||
|
if (props.autoplay) startAutoplay()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
velocity = dragVelocity
|
||||||
|
const projected = position + velocity * 0.16
|
||||||
|
target = Math.round(projected)
|
||||||
|
startSpringLoop()
|
||||||
|
notifyChange()
|
||||||
|
if (props.autoplay) startAutoplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goTo(baseIndex: number) {
|
||||||
|
const len = props.slides.filter((s) => s.image).length
|
||||||
|
if (len < 2) return
|
||||||
|
const currentBase = getActiveIndex()
|
||||||
|
let diff = baseIndex - currentBase
|
||||||
|
if (diff > len / 2) diff -= len
|
||||||
|
if (diff < -len / 2) diff += len
|
||||||
|
target = Math.round(target) + diff
|
||||||
|
startSpringLoop()
|
||||||
|
notifyChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** False while the home tab is keep-alive-cached off-screen. */
|
||||||
|
let pageActive = true
|
||||||
|
|
||||||
|
function startAutoplay() {
|
||||||
|
stopAutoplay()
|
||||||
|
if (!pageActive) return
|
||||||
|
if (!props.autoplay || appearance.isReducedMotion || props.slides.length < 2) return
|
||||||
|
autoplayTimer = setInterval(() => {
|
||||||
|
if (!isDragging && pageActive) {
|
||||||
|
target = Math.round(target) + 1
|
||||||
|
startSpringLoop()
|
||||||
|
notifyChange()
|
||||||
|
}
|
||||||
|
}, props.autoplayInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAutoplay() {
|
||||||
|
if (autoplayTimer) {
|
||||||
|
clearInterval(autoplayTimer)
|
||||||
|
autoplayTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pause motion while cached off-screen so return doesn't catch up many slides. */
|
||||||
|
function pauseForLeave() {
|
||||||
|
pageActive = false
|
||||||
|
stopAutoplay()
|
||||||
|
if (rafId != null) {
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
rafId = null
|
||||||
|
}
|
||||||
|
position = target
|
||||||
|
velocity = 0
|
||||||
|
isDragging = false
|
||||||
|
dragging.value = false
|
||||||
|
render()
|
||||||
|
notifyChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumeOnEnter() {
|
||||||
|
pageActive = true
|
||||||
|
render()
|
||||||
|
if (props.autoplay) startAutoplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetToStart() {
|
||||||
|
position = 0
|
||||||
|
target = 0
|
||||||
|
velocity = 0
|
||||||
|
notifyChange()
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
pageActive = true
|
||||||
|
resetToStart()
|
||||||
|
if (stage.value && typeof ResizeObserver !== 'undefined') {
|
||||||
|
resizeObserver = new ResizeObserver(() => render())
|
||||||
|
resizeObserver.observe(stage.value)
|
||||||
|
}
|
||||||
|
if (props.autoplay) startAutoplay()
|
||||||
|
})
|
||||||
|
|
||||||
|
// HomeView is keep-alive'd — without these, setInterval keeps advancing
|
||||||
|
// target while the user is on another tab, then the spring "catches up"
|
||||||
|
// through several slides the moment they return.
|
||||||
|
onDeactivated(pauseForLeave)
|
||||||
|
onActivated(resumeOnEnter)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.slides,
|
||||||
|
() => {
|
||||||
|
resetToStart()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.sideScale, props.gap, props.stiffness, props.damping] as const,
|
||||||
|
() => render(),
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.autoplay,
|
||||||
|
(on) => {
|
||||||
|
if (on) startAutoplay()
|
||||||
|
else stopAutoplay()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
pauseForLeave()
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
if (pointerId !== null && viewport.value) {
|
||||||
|
try {
|
||||||
|
viewport.value.releasePointerCapture(pointerId)
|
||||||
|
} catch {
|
||||||
|
/* detached */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
// Break out of Container `px-[16px]` so side peeks sit at the screen edge
|
||||||
|
// instead of leaving empty gutters inside the padded column.
|
||||||
|
.carousel {
|
||||||
|
width: calc(100% + 32px);
|
||||||
|
margin-inline: -16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__viewport {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: pan-y;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
cursor: grab;
|
||||||
|
// Top: room for bottom-anchored scale. Bottom: room for card drop-shadow
|
||||||
|
// so overflow:hidden does not shear the soft edge.
|
||||||
|
padding: 10px 0 28px;
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__viewport.is-dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same aspect as ShowcaseSlider; cards ~92% wide so neighbours hug the center.
|
||||||
|
.carousel__stage {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 320 / 208;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__card {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 92%;
|
||||||
|
height: 100%;
|
||||||
|
transform-origin: center bottom;
|
||||||
|
will-change: transform;
|
||||||
|
border-radius: var(--carousel-radius, 22px);
|
||||||
|
overflow: hidden;
|
||||||
|
background: @hairlineSoft;
|
||||||
|
box-shadow: 0 14px 28px -12px rgba(20, 22, 26, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__art {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
pointer-events: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__dots {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
// Re-align with the padded column after the bleed; extra air under the stage.
|
||||||
|
padding: 4px 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__dot.is-active {
|
||||||
|
width: 20px;
|
||||||
|
background: @wine;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -30,9 +30,10 @@ const darkQuery = () => (typeof matchMedia !== 'undefined' ? matchMedia('(prefer
|
|||||||
const motionQuery = () => (typeof matchMedia !== 'undefined' ? matchMedia('(prefers-reduced-motion: reduce)') : null)
|
const motionQuery = () => (typeof matchMedia !== 'undefined' ? matchMedia('(prefers-reduced-motion: reduce)') : null)
|
||||||
|
|
||||||
function load(): Stored {
|
function load(): Stored {
|
||||||
// First run: theme follows the system; motion follows the OS's own
|
// First run: light theme by default; motion follows the OS's own
|
||||||
// "reduce motion" accessibility setting.
|
// "reduce motion" accessibility setting. Users can still pick system/dark
|
||||||
const fallback: Stored = { theme: 'system', reduceMotion: !!motionQuery()?.matches }
|
// in Appearance settings.
|
||||||
|
const fallback: Stored = { theme: 'light', reduceMotion: !!motionQuery()?.matches }
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(APPEARANCE_STORAGE_KEY)
|
const raw = localStorage.getItem(APPEARANCE_STORAGE_KEY)
|
||||||
if (!raw) return fallback
|
if (!raw) return fallback
|
||||||
|
|||||||
@@ -4,11 +4,10 @@
|
|||||||
Layout intent — shop first, account second:
|
Layout intent — shop first, account second:
|
||||||
1. a slim brand bar with search + cart
|
1. a slim brand bar with search + cart
|
||||||
2. the live 18k rate strip (real: GET /pricing/latest)
|
2. the live 18k rate strip (real: GET /pricing/latest)
|
||||||
3. an editorial showcase hero built from real categories
|
3. spring card carousel hero (admin slider images)
|
||||||
4. the category rail (real: derived from GET /catalog/products)
|
4. the category rail (real: derived from GET /catalog/products)
|
||||||
5. "برگزیده" — staggered 2-col masonry (wireframe: unequal card heights, flush bottoms)
|
5. "برگزیده" — staggered 2-col masonry (wireframe: unequal card heights, flush bottoms)
|
||||||
6. "تازهها" — full-width horizontal ProductRow list
|
6. "تازهها" — full-width horizontal ProductRow list
|
||||||
7. the account block (real: GET /customers/:id when signed in)
|
|
||||||
|
|
||||||
Data honesty, carried over from the previous version deliberately:
|
Data honesty, carried over from the previous version deliberately:
|
||||||
- the API exposes no curated home payload, no promotions and no per-product
|
- the API exposes no curated home payload, no promotions and no per-product
|
||||||
@@ -43,7 +42,7 @@
|
|||||||
<container class="home__body" :class="{ 'is-revealed': revealed }">
|
<container class="home__body" :class="{ 'is-revealed': revealed }">
|
||||||
<RateStrip :quote="goldPrice" class="home__rate reveal" :style="revealDelay(0)" />
|
<RateStrip :quote="goldPrice" class="home__rate reveal" :style="revealDelay(0)" />
|
||||||
|
|
||||||
<ShowcaseSlider
|
<Carousel
|
||||||
v-if="slides.length"
|
v-if="slides.length"
|
||||||
:slides="slides"
|
:slides="slides"
|
||||||
class="home__hero reveal"
|
class="home__hero reveal"
|
||||||
@@ -115,8 +114,8 @@ import { useRouter } from 'vue-router'
|
|||||||
import { Loading, PullRefresh } from 'vant'
|
import { Loading, PullRefresh } from 'vant'
|
||||||
import Icon from '../components/icon/Icon.vue'
|
import Icon from '../components/icon/Icon.vue'
|
||||||
import RateStrip from '../components/home/RateStrip.vue'
|
import RateStrip from '../components/home/RateStrip.vue'
|
||||||
import ShowcaseSlider from '../components/home/ShowcaseSlider.vue'
|
|
||||||
import type { ShowcaseSlide } from '../components/home/ShowcaseSlider.vue'
|
import type { ShowcaseSlide } from '../components/home/ShowcaseSlider.vue'
|
||||||
|
import Carousel from '../components/home/Carousel.vue'
|
||||||
import CategoryRail from '../components/home/CategoryRail.vue'
|
import CategoryRail from '../components/home/CategoryRail.vue'
|
||||||
import ProductCard from '../components/product/ProductCard.vue'
|
import ProductCard from '../components/product/ProductCard.vue'
|
||||||
import ProductRow from '../components/product/ProductRow.vue'
|
import ProductRow from '../components/product/ProductRow.vue'
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Home Carousel — spring physics cards
|
||||||
|
|
||||||
|
**Status:** approved
|
||||||
|
**Date:** 2026-10-16
|
||||||
|
**Scope:** `apps/user-app` home (`Carousel.vue` below `ShowcaseSlider`)
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
A Framer-style spring carousel under the existing crossfade hero: center card large, side cards scaled down, infinite wrap, drag/flick with spring settle. Same admin slide images as `ShowcaseSlider`.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Choice | Decision |
|
||||||
|
|--------|----------|
|
||||||
|
| Stack | Pure Vue SFC (no Swiper / Motion) |
|
||||||
|
| Name / path | `components/home/Carousel.vue` |
|
||||||
|
| Placement | Immediately below `ShowcaseSlider`, above `CategoryRail` |
|
||||||
|
| Data | Same `slides` computed from admin home slider |
|
||||||
|
| Props | `slides` (+ optional spring/layout knobs with defaults) |
|
||||||
|
| Dots | Yes, pill style aligned with showcase |
|
||||||
|
| Out of scope | Demo inspector panel, Prev/Next chrome, new admin API |
|
||||||
|
|
||||||
|
## Motion defaults
|
||||||
|
|
||||||
|
- `sideScale`: 0.60
|
||||||
|
- `gap`: 14px
|
||||||
|
- `borderRadius`: 22px (match ShowcaseSlider)
|
||||||
|
- Frame `aspect-ratio`: `320 / 208` (match ShowcaseSlider); center card full-bleed
|
||||||
|
|
||||||
|
- `stiffness`: 180
|
||||||
|
- `damping`: 22
|
||||||
|
- Internal layout `direction: ltr` (gesture still screen-space)
|
||||||
|
- Autoplay off by default; respect reduced-motion when enabled later
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
Replacing `ShowcaseSlider`, inventing slide destinations, or adding external animation libraries.
|
||||||
Reference in New Issue
Block a user