Both expert screens are built in user-app and user-app-v2, with byte-identical
template and script and a per-app style block only — verified by diffing
everything above <style>:
ChildOrdersView "سفارش مشتریان" — legacy ChildOrders.vue. Keeps the
reference's title, search, infinite paging, and per-card item count, total
weight, customer name and the fixed «تحویل مسئول فروش» chip (the endpoint
only returns that status, ApiMobileController:766).
CustomerChoiceView "لیست مشتریان" — legacy customerChoice.vue. Keeps its
title, per-search re-query and the reference's choice to LIST inactive
customers (its active() scope is commented out at :187).
Reachable from inside the app, not just by URL: routes carry the reference's own
paths and names, the dashboard grows the two entries only for an expert (v1 as
tiles, v2 as its own rows), and a router guard sends an ordinary customer back
to their dashboard. None of that is relied on for safety — the server scopes
both endpoints on the AdminUser id in the token and answers 403 otherwise.
Not ported, both recorded in D21: the act-as api_token minting (:197-201) and
the child-order edit drawer, which recomputed an order's money from client-sent
values. The card opens a read-only summary instead.
APP-02 fixed: user-app-v2 gained getHomeSlider and now prefers admin-configured
slides, keeping its category-derived hero as the fallback and its own design.
Session stores stay byte-identical across the apps.
build 5/5, jest 45/45, expert login 36/36, apps smoke 43/43, typecheck clean.
[ref: henza_pwa/src/views/user/src/components/ChildOrders.vue, customerChoice.vue]
[ref: henza_pwa/src/router/index.ts:171-188]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
278 lines
8.3 KiB
Vue
278 lines
8.3 KiB
Vue
<!--
|
|
Coverflow hero for the home screen (design: centred card raised above two
|
|
peeking neighbours, drag/swipe to advance).
|
|
|
|
Dependency-free on purpose: the app ships swiper, but swiper's coverflow
|
|
module drags in its own CSS that fights the app's RTL/less theming, and the
|
|
effect here is three transforms — cheaper to own than to configure.
|
|
|
|
Slides are built from REAL categories (see HomeView), so each one deep-links
|
|
into a genuine filtered search. The artwork is the first catalogue image of
|
|
that category; a slide with no image renders its tile blank rather than
|
|
substituting stock art.
|
|
-->
|
|
<template>
|
|
<section class="hero" v-if="slides.length">
|
|
<div
|
|
class="hero__stage"
|
|
ref="stage"
|
|
@pointerdown="onDown"
|
|
@pointermove="onMove"
|
|
@pointerup="onUp"
|
|
@pointercancel="onUp"
|
|
>
|
|
<article
|
|
v-for="(slide, i) in slides"
|
|
:key="slide.key"
|
|
class="hero__card"
|
|
:class="{ 'is-active': i === index }"
|
|
:style="cardStyle(i)"
|
|
@click="onCardClick(i, slide)"
|
|
>
|
|
<img v-if="slide.image" :src="slide.image" :alt="slide.title" class="hero__art" draggable="false" />
|
|
<div v-else class="hero__artFallback" aria-hidden="true"></div>
|
|
</article>
|
|
</div>
|
|
|
|
<div class="hero__dots" v-if="slides.length > 1">
|
|
<button
|
|
v-for="(slide, i) in slides"
|
|
:key="slide.key"
|
|
type="button"
|
|
class="hero__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'
|
|
|
|
export interface HeroSlide {
|
|
key: string
|
|
title: string
|
|
image?: string
|
|
categoryId?: string
|
|
/** Admin-uploaded slides carry their own destination instead of a category. */
|
|
link?: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<{ slides: HeroSlide[]; autoplay?: boolean }>(), {
|
|
autoplay: true,
|
|
})
|
|
const emit = defineEmits<{ (e: 'select', slide: HeroSlide): void }>()
|
|
|
|
const index = ref(0)
|
|
const stage = ref<HTMLElement | null>(null)
|
|
|
|
// Drag state. `drag` is the live finger offset in px, folded into the active
|
|
// card's transform so the stack tracks the finger instead of snapping on
|
|
// release only.
|
|
const dragging = ref(false)
|
|
const drag = ref(0)
|
|
let startX = 0
|
|
let pointerId: number | null = null
|
|
|
|
let timer: ReturnType<typeof setInterval> | undefined
|
|
|
|
const SWIPE_THRESHOLD = 46
|
|
|
|
// Geometry of the stack — see the derivation in cardStyle().
|
|
const NEIGHBOUR_SCALE = 0.76
|
|
const NEIGHBOUR_SHIFT = 36.6
|
|
// Side cards stay desaturated at all times and sit dimmed behind the centre
|
|
// card; only the front card shows its artwork in colour.
|
|
const NEIGHBOUR_OPACITY = 0.75
|
|
|
|
const count = computed(() => props.slides.length)
|
|
|
|
function cardStyle(i: number) {
|
|
// Signed distance from the active card, wrapped so the rail behaves as a
|
|
// loop: with 3 slides, index 2 sits at -1 from index 0, not +2.
|
|
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
|
|
const abs = Math.abs(offset)
|
|
|
|
// Unlike the product rail, the hero's side cards tuck *behind* the centre
|
|
// card rather than standing beside it — the centre card is wider, taller and
|
|
// fully opaque, and its shadow draws the seam over the two cards under it.
|
|
//
|
|
// NEIGHBOUR_SHIFT is derived, not eyeballed. The visible sliver of a
|
|
// neighbour is measured from its OUTER edge past the centre card's edge:
|
|
// shift = (centreRight + sliver - 50 - W*scale/2) / W
|
|
// where W = cell width (% of stage) and centreRight = 50 + W/2. With
|
|
// W = 73.1% (the 13.5% insets below), scale 0.76 and an 18% sliver → 36.6%.
|
|
// That sliver deliberately puts each neighbour's outer edge past 100% of the
|
|
// stage, so the side cards run off the screen rather than stopping at it.
|
|
const translate = offset * NEIGHBOUR_SHIFT + dragShift
|
|
const scale = offset === 0 ? 1 : NEIGHBOUR_SCALE
|
|
const opacity = abs > 1 ? 0 : abs === 0 ? 1 : NEIGHBOUR_OPACITY
|
|
|
|
return {
|
|
transform: `translate3d(${translate}%, 0, 0) scale(${scale})`,
|
|
opacity: String(opacity),
|
|
zIndex: String(10 - abs),
|
|
pointerEvents: abs > 1 ? 'none' : 'auto',
|
|
transition: dragging.value ? 'none' : 'transform .42s cubic-bezier(.22,.61,.36,1), opacity .42s ease',
|
|
} as Record<string, string>
|
|
}
|
|
|
|
function go(i: number) {
|
|
index.value = ((i % count.value) + count.value) % count.value
|
|
restart()
|
|
}
|
|
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)
|
|
stop()
|
|
}
|
|
|
|
function onMove(e: PointerEvent) {
|
|
if (!dragging.value || e.pointerId !== pointerId) return
|
|
const width = stage.value?.clientWidth || 1
|
|
// Expressed as a % of stage width so it composes with the % translate above.
|
|
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 note: dragging right (positive) moves toward the *previous* card,
|
|
// matching the direction the finger pushes the stack.
|
|
if (moved > SWIPE_THRESHOLD) prev()
|
|
else if (moved < -SWIPE_THRESHOLD) next()
|
|
else restart()
|
|
}
|
|
|
|
function onCardClick(i: number, slide: HeroSlide) {
|
|
// A click that ended a drag must not also open the category.
|
|
if (Math.abs(drag.value) > 1) return
|
|
if (i !== index.value) {
|
|
go(i)
|
|
return
|
|
}
|
|
emit('select', slide)
|
|
}
|
|
|
|
function stop() {
|
|
if (timer) clearInterval(timer)
|
|
timer = undefined
|
|
}
|
|
function restart() {
|
|
stop()
|
|
if (props.autoplay && count.value > 1) {
|
|
timer = setInterval(next, 6000)
|
|
}
|
|
}
|
|
|
|
onMounted(restart)
|
|
onBeforeUnmount(stop)
|
|
</script>
|
|
|
|
<style scoped lang="less">
|
|
.hero__stage {
|
|
position: relative;
|
|
// The centre card is a portrait of aspect 0.777 (measured off the design:
|
|
// 351x452). Driving the stage from an aspect ratio rather than a fixed pixel
|
|
// height keeps that proportion on every screen width — a fixed height made
|
|
// the card too tall and narrow on a phone.
|
|
//
|
|
// Cards span the stage's full height, so the card's height IS the stage's.
|
|
// With the centre card at 73.1% of the stage width, the stage's own
|
|
// width:height ratio is 0.777 / 0.731 ≈ 1.063.
|
|
aspect-ratio: 1.063;
|
|
touch-action: pan-y;
|
|
user-select: none;
|
|
}
|
|
|
|
.hero__card {
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 0;
|
|
// The active card occupies the middle 73.1% of the stage; the side cards sit
|
|
// *behind* it, showing only a sliver past each edge.
|
|
// NEIGHBOUR_SHIFT in the script is derived from this width — changing one
|
|
// without the other breaks the sliver it was chosen to produce.
|
|
left: 13.45%;
|
|
right: 13.45%;
|
|
border-radius: 32px;
|
|
overflow: hidden;
|
|
cursor: pointer;
|
|
background: @hairlineSoft;
|
|
will-change: transform, opacity;
|
|
}
|
|
|
|
// The seam: a shadow cast by the front card onto the two tucked behind it, so
|
|
// the centre card reads as lifted off the stack rather than pasted onto it.
|
|
.hero__card.is-active {
|
|
box-shadow: 0 18px 44px -12px rgba(20, 22, 26, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.9);
|
|
}
|
|
|
|
.hero__art,
|
|
.hero__artFallback {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
display: block;
|
|
// Desaturation animates with the same curve the card settles on, so a card
|
|
// regains its colour as it comes to the front.
|
|
transition: filter 0.42s cubic-bezier(0.22, 0.61, 0.36, 1);
|
|
}
|
|
|
|
// Only the front card shows its artwork in colour; the ones behind it stay
|
|
// black and white at all times. Dimming lives on the cell (NEIGHBOUR_OPACITY),
|
|
// not here, so the two do not compound.
|
|
.hero__card:not(.is-active) .hero__art {
|
|
filter: grayscale(1);
|
|
}
|
|
|
|
.hero__artFallback {
|
|
background: linear-gradient(140deg, @hairlineSoft 0%, @hairline 100%);
|
|
}
|
|
|
|
.hero__dots {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
padding-top: 14px;
|
|
}
|
|
|
|
.hero__dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
padding: 0;
|
|
border: 0;
|
|
cursor: pointer;
|
|
border-radius: 999px;
|
|
background: @hairline;
|
|
transition: all 0.25s ease;
|
|
}
|
|
.hero__dot.is-active {
|
|
width: 20px;
|
|
background: @wine;
|
|
}
|
|
</style>
|