SearchView: Removed the dynamic collection-tile grid (e.g. "پرفروشهای این هفته") and the "نتایج / N محصول" section header from the UI. Underlying data/filtering logic untouched.
DashboardView ("میز کار"): Rebuilt from scratch with a new structure, distinct from the old card+grid layout but visually aligned with HomeView (same blurred bar, @ink/@wine palette, staged reveal animation on mount):
Single hero card (ink→wine gradient) showing balance, gold/fiat debt, and identity — no quick-action icons inside it anymore.
Live gold rate folded into a slim inline row at the bottom instead of a titled section.
Iterated shadow/spacing on the hero card per feedback (softer, less clipped).
DialogVant / DialogVantOneButton (shared modal components, used in 7+ call sites: Dashboard, Cart, SingleProduct, Register, App): Replaced Vant's default dialog chrome with the app's own rounded-card language — circular icon badge for the illustration, pill-shaped primary/secondary buttons. No call-site markup changed.
LoginView: Rebuilt from scratch — full-bleed dark hero (brand + step copy) overlapped by a floating white form sheet, animated crossfade transition between the mobile-number and OTP steps, same staged entrance animation as Home/Dashboard. All auth logic (API check, OTP stub, session login) unchanged.
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
<!--
|
||||
"محصولات جدید" rail, per the design: the centred card sits larger than its two
|
||||
neighbours, and the rail loops endlessly — after the last product the next
|
||||
card is a "مشاهده همه محصولات جدید" tile that routes to the newest-products
|
||||
page, and past that the rail wraps back to the first product.
|
||||
|
||||
Shares its geometry with HeroCarousel (same wrapped-offset maths, same drag
|
||||
handling) but is a separate component rather than a prop-flag on that one:
|
||||
this rail carries cards with their own markup and a trailing CTA tile, while
|
||||
the hero carries edge-to-edge artwork. Merging them would mean a component
|
||||
that is two layouts behind a flag.
|
||||
-->
|
||||
<template>
|
||||
<section class="reel">
|
||||
<div
|
||||
class="reel__stage"
|
||||
ref="stage"
|
||||
@pointerdown="onDown"
|
||||
@pointermove="onMove"
|
||||
@pointerup="onUp"
|
||||
@pointercancel="onUp"
|
||||
>
|
||||
<div
|
||||
v-for="(slot, i) in slots"
|
||||
:key="slot.key"
|
||||
class="reel__cell"
|
||||
:class="{ 'is-active': i === index }"
|
||||
:style="cellStyle(i)"
|
||||
>
|
||||
<ProductPanel
|
||||
v-if="slot.kind === 'product'"
|
||||
:value="slot.product!"
|
||||
@open="onOpen(i, $event)"
|
||||
/>
|
||||
|
||||
<button v-else type="button" class="reel__all" @click="onAll(i)">
|
||||
<span class="reel__allMark" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="26" />
|
||||
</span>
|
||||
<span class="reel__allText">مشاهده همه محصولات جدید</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import ProductPanel from './ProductPanel.vue'
|
||||
import type { ProductSummary } from '../../api/client'
|
||||
|
||||
interface Cell {
|
||||
key: string
|
||||
kind: 'product' | 'all'
|
||||
product?: ProductSummary
|
||||
}
|
||||
|
||||
const props = defineProps<{ products: ProductSummary[] }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'open', value: ProductSummary): void
|
||||
(e: 'all'): void
|
||||
}>()
|
||||
|
||||
const index = ref(0)
|
||||
const stage = ref<HTMLElement | null>(null)
|
||||
|
||||
const dragging = ref(false)
|
||||
const drag = ref(0)
|
||||
let startX = 0
|
||||
let pointerId: number | null = null
|
||||
|
||||
const SWIPE_THRESHOLD = 46
|
||||
|
||||
// Geometry of the rail — see the derivation in cellStyle().
|
||||
const NEIGHBOUR_SCALE = 0.78
|
||||
const NEIGHBOUR_SHIFT = 92.5
|
||||
// Side cards also recede rather than just shrinking: a little transparency
|
||||
// sells the depth without a perspective transform. Their vertical alignment is
|
||||
// not done here — `transform-origin: bottom` on the cell pins every card's
|
||||
// bottom edge to the same baseline, so the side cards shrink upward and stay
|
||||
// flush with the centre card no matter what NEIGHBOUR_SCALE becomes.
|
||||
const NEIGHBOUR_OPACITY = 0.62
|
||||
|
||||
// The CTA is the last cell, so the loop reads: products → "see all" → back to
|
||||
// the first product.
|
||||
const slots = computed<Cell[]>(() => [
|
||||
...props.products.map((p) => ({ key: p.id, kind: 'product' as const, product: p })),
|
||||
{ key: '__all__', kind: 'all' as const },
|
||||
])
|
||||
|
||||
const count = computed(() => slots.value.length)
|
||||
|
||||
function cellStyle(i: number) {
|
||||
// Signed distance from the centre, wrapped so the rail loops in both
|
||||
// directions rather than running out at either end.
|
||||
let offset = i - index.value
|
||||
if (count.value > 2) {
|
||||
if (offset > count.value / 2) offset -= count.value
|
||||
if (offset < -count.value / 2) offset += count.value
|
||||
}
|
||||
|
||||
const dragShift = dragging.value ? drag.value : 0
|
||||
|
||||
// Design: the centre card is full size, the flanking ones noticeably
|
||||
// smaller, with clear air between them. Cells beyond the immediate
|
||||
// neighbours are parked out of sight.
|
||||
//
|
||||
// NEIGHBOUR_SHIFT is not a free-hand number. `scale` shrinks a cell about
|
||||
// its own centre, pulling its inner edge back toward the middle by half the
|
||||
// shrinkage, so a translate chosen by eye ends up overlapping the centre
|
||||
// card. Solving for a real gap, in units of cell width:
|
||||
// shift = (gap + W/2 + W*scale/2) / W where W = cell width (% of stage)
|
||||
// With W = 74% (the 13% insets below), scale 0.78 and a 2.6% gap, that is
|
||||
// 92.5% — see the CSS note on .reel__cell.
|
||||
const translate = offset * NEIGHBOUR_SHIFT + dragShift
|
||||
|
||||
// `progress` is how far this cell sits from the centre *including the live
|
||||
// drag*, so scale/opacity/drop interpolate continuously under the finger
|
||||
// instead of snapping between two states on release. This is what makes the
|
||||
// rail feel physical: the card you are pulling in grows as it arrives.
|
||||
const progress = Math.min(Math.abs(offset + dragShift / NEIGHBOUR_SHIFT), 1)
|
||||
|
||||
const scale = 1 - (1 - NEIGHBOUR_SCALE) * progress
|
||||
const near = 1 - (1 - NEIGHBOUR_OPACITY) * progress
|
||||
|
||||
// Only the centre card and its two neighbours are painted; the rest are
|
||||
// parked invisible so a long catalogue does not stack 20 layers.
|
||||
const visible = Math.abs(offset) <= 1
|
||||
const opacity = visible ? near : 0
|
||||
|
||||
return {
|
||||
transform: `translate3d(${translate}%, 0, 0) scale(${scale})`,
|
||||
opacity: String(opacity),
|
||||
zIndex: String(10 - Math.abs(offset)),
|
||||
pointerEvents: visible ? 'auto' : 'none',
|
||||
// A gentle overshoot on settle reads as "snapping into place" rather than
|
||||
// sliding to a stop. Nothing animates while the finger is down — the
|
||||
// transform already tracks it frame by frame.
|
||||
transition: dragging.value
|
||||
? 'none'
|
||||
: 'transform .55s cubic-bezier(.21,1.02,.36,1), opacity .45s ease',
|
||||
} as Record<string, string>
|
||||
}
|
||||
|
||||
function go(i: number) {
|
||||
index.value = ((i % count.value) + count.value) % count.value
|
||||
}
|
||||
function next() {
|
||||
go(index.value + 1)
|
||||
}
|
||||
function prev() {
|
||||
go(index.value - 1)
|
||||
}
|
||||
|
||||
function onDown(e: PointerEvent) {
|
||||
if (count.value < 2) return
|
||||
dragging.value = true
|
||||
startX = e.clientX
|
||||
drag.value = 0
|
||||
pointerId = e.pointerId
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)
|
||||
}
|
||||
|
||||
function onMove(e: PointerEvent) {
|
||||
if (!dragging.value || e.pointerId !== pointerId) return
|
||||
const width = stage.value?.clientWidth || 1
|
||||
drag.value = ((e.clientX - startX) / width) * 100
|
||||
}
|
||||
|
||||
function onUp(e: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
const moved = e.clientX - startX
|
||||
dragging.value = false
|
||||
drag.value = 0
|
||||
pointerId = null
|
||||
|
||||
// RTL: dragging right pushes the stack toward the previous card.
|
||||
if (moved > SWIPE_THRESHOLD) prev()
|
||||
else if (moved < -SWIPE_THRESHOLD) next()
|
||||
}
|
||||
|
||||
// A tap on a side card centres it instead of opening it, and a tap that ended
|
||||
// a drag does neither.
|
||||
function onOpen(i: number, product: ProductSummary) {
|
||||
if (Math.abs(drag.value) > 1) return
|
||||
if (i !== index.value) {
|
||||
go(i)
|
||||
return
|
||||
}
|
||||
emit('open', product)
|
||||
}
|
||||
|
||||
function onAll(i: number) {
|
||||
if (Math.abs(drag.value) > 1) return
|
||||
if (i !== index.value) {
|
||||
go(i)
|
||||
return
|
||||
}
|
||||
emit('all')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.reel__stage {
|
||||
position: relative;
|
||||
// ProductPanel's own content height at full scale: 18 padding + 148 art
|
||||
// + 14 + title + 9 + meta + 16 padding. The cells are absolutely positioned
|
||||
// against this box, so the room the active card's shadow needs below is
|
||||
// margin, not padding — padding would stretch them.
|
||||
height: 252px;
|
||||
touch-action: pan-y;
|
||||
user-select: none;
|
||||
// No overflow clip here: it would cut the active card's shadow and the
|
||||
// dropped side cards. Cells past the immediate neighbours are already
|
||||
// opacity 0, so nothing spills into the gutter anyway.
|
||||
margin: 0 -16px 26px;
|
||||
}
|
||||
|
||||
.reel__cell {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
// 13% insets put the centre card at 74% of the stage, matching the design.
|
||||
// NEIGHBOUR_SHIFT in the script is derived from this width — changing one
|
||||
// without the other reintroduces the overlap it was chosen to remove.
|
||||
left: 13%;
|
||||
right: 13%;
|
||||
// Pins the bottom edge: side cards shrink upward instead of about their
|
||||
// middle, so every card in the rail sits on the same baseline. Horizontal
|
||||
// scaling still happens about the centre, so the gap maths above is
|
||||
// unaffected.
|
||||
transform-origin: bottom center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
// The centre card lifts off the page; the shadow fades in with the same curve
|
||||
// the transform settles on, so growing and lifting read as one motion.
|
||||
.reel__cell > :deep(.panel),
|
||||
.reel__all {
|
||||
transition: box-shadow 0.55s cubic-bezier(0.21, 1.02, 0.36, 1);
|
||||
}
|
||||
|
||||
.reel__cell.is-active > :deep(.panel),
|
||||
.reel__cell.is-active .reel__all {
|
||||
box-shadow: 0 18px 38px -24px rgba(20, 22, 26, 0.55);
|
||||
}
|
||||
|
||||
// ProductPanel fills its cell so the scale transform drives the size.
|
||||
.reel__cell > :deep(.panel) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.reel__all {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
padding: 18px 16px;
|
||||
border: 0;
|
||||
border-radius: 32px;
|
||||
// Same fill as ProductPanel, so the CTA reads as one more card in the rail
|
||||
// rather than a different kind of thing.
|
||||
background: @hairlineSoft;
|
||||
color: @wine;
|
||||
transition: transform 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.reel__all:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
.reel__allMark {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
// The card now shares ProductPanel's fill, so the mark needs its own tint to
|
||||
// stay visible against it.
|
||||
background: @canvas;
|
||||
}
|
||||
|
||||
.reel__allText {
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
</style>
|
||||
Reference in New Issue
Block a user