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.
This commit is contained in:
s.esmaeili
2026-10-04 16:18:56 +03:30
parent c05352d6fd
commit f441ca8289
102 changed files with 4602 additions and 1053 deletions
+25 -2
View File
@@ -5,13 +5,36 @@
<meta charset="UTF-8" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#1C2D56" />
<meta name="theme-color" content="#1C2D56" data-light="#1C2D56" />
<meta name="background-color" content="#1C2D56" />
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport" />
<!--
Appearance boot (see src/stores/appearanceStore.ts): applies the saved
theme / reduced-motion classes before the first paint, so a dark-mode
user never sees a white flash while the bundle loads. Same storage key
and class names as the store, which takes over once the app is up.
-->
<script>
(function () {
try {
var s = JSON.parse(localStorage.getItem('gold-bank:appearance') || '{}');
var mq = function (q) { return window.matchMedia && matchMedia(q).matches; };
var dark = s.theme === 'dark' || ((s.theme || 'system') === 'system' && mq('(prefers-color-scheme: dark)'));
var motion = typeof s.reduceMotion === 'boolean' ? s.reduceMotion : mq('(prefers-reduced-motion: reduce)');
var c = document.documentElement.classList;
if (dark) { c.add('theme-dark', 'van-theme-dark'); document.querySelector('meta[name=theme-color]').setAttribute('content', '#15171A'); }
if (motion) c.add('motion-reduced');
} catch (e) {}
})();
</script>
<style>
/* Until the bundle's palette loads. */
html.theme-dark, html.theme-dark body { background-color: #15171A !important; }
</style>
<title>طلای منتزر</title>
</head>
<body style="background-color: #1C2D56">
<div class="bg-white w-screen h-screen fixed"></div>
<div class="w-screen h-screen fixed" style="background-color: var(--c-page, #fff)"></div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
+2
View File
@@ -29,6 +29,7 @@
@confirm="update()"
/>
<ProductMorph />
<ToastHost />
</div>
</template>
@@ -41,6 +42,7 @@ import MasterLayout from './layout/MasterLayout.vue'
import FullPageLayout from './layout/FullPageLayout.vue'
import IslandSheet from './components/ui/IslandSheet.vue'
import ToastHost from './components/ui/ToastHost.vue'
import ProductMorph from './components/product/ProductMorph.vue'
import { useRefresh } from './composables/useRefresh'
import pkg from '../package.json'
+107 -2
View File
@@ -367,8 +367,8 @@ input:focus-visible {
}
.ant-btn-primary[disabled], .ant-btn-primary[disabled]:hover, .ant-btn-primary[disabled]:focus, .ant-btn-primary[disabled]:active {
background: fade(@white, 20%);
color: fade(@white, 50%);
background: alpha(@white, 20%);
color: alpha(@white, 50%);
border-color: transparent;
}
@@ -654,3 +654,108 @@ input:focus-visible {
background: @hairline;
}
}
/* ── Appearance: theme switch & reduced motion ─────────────────
Classes set on <html> by stores/appearanceStore.ts (and, before the bundle
loads, by the boot script in index.html). */
// One-frame guard while the palette flips: without it every element with a
// colour transition animates from the old theme to the new one at once.
html.theme-switching *,
html.theme-switching *::before,
html.theme-switching *::after {
transition: none !important;
}
// "Fast & light" mode. Not "no motion": the aim is speed without the app
// going dead, so
// - every transition still runs, capped at 120ms with no delay — taps still
// press, colours still change, sheets and pages still arrive, just quick;
// - keyframe animations run one near-instant iteration, so decorative loops
// (pulses, shimmers, illustration loops) stop at rest and one-shot reveals
// land in their final state;
// - backdrop blurs (recomputed every frame anything moves behind them) and
// permanent will-change layers are dropped;
// - .keep-motion opts working motion out: spinners, and the toast progress
// bar whose animationend is what closes the toast. vant's own <Loading>
// spinner is excluded by class.
html.motion-reduced :where(*:not(.keep-motion, .keep-motion *, .van-loading, .van-loading *)),
html.motion-reduced :where(*:not(.keep-motion, .keep-motion *, .van-loading, .van-loading *))::before,
html.motion-reduced :where(*:not(.keep-motion, .keep-motion *, .van-loading, .van-loading *))::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
animation-delay: 0s !important;
transition-duration: 0.12s !important;
transition-delay: 0s !important;
scroll-behavior: auto !important;
}
html.motion-reduced *,
html.motion-reduced *::before,
html.motion-reduced *::after {
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
will-change: auto !important;
}
// Glass that was only legible because of its blur gets a near-solid fill.
html.motion-reduced .tabbar__inner,
html.motion-reduced .toast {
background: alpha(@canvas, 97%) !important;
}
/* ── Dark theme: third-party surfaces ──────────────────────────
ant-design-vue compiles its theme at build time with black text
(fade(@black, 85%)) and white fills; only its surface backgrounds were
switched to the theme tokens (themeConfig.ts). The text and input chrome it
leaves light-only are covered here. vant follows on its own through the
.van-theme-dark class. */
html.theme-dark body {
color: @ink;
}
html.theme-dark {
.ant-input,
.ant-input-affix-wrapper,
.ant-input-number,
textarea.ant-input {
color: @ink;
background-color: @canvas;
border-color: @hairline;
}
.ant-input::placeholder,
textarea.ant-input::placeholder {
color: @inkFaint;
}
.ant-input-affix-wrapper > input.ant-input {
background-color: transparent;
}
.ant-form-item-label > label,
.ant-drawer-title,
.ant-spin-text {
color: @ink;
}
.ant-form-item-explain-error {
color: @red;
}
.ant-empty-description {
color: @inkFaint;
}
.ant-drawer-header {
border-bottom-color: @hairline;
}
.ant-slider-handle {
background-color: @canvas;
}
}
// Dark: the frosted glass (tab bar, toasts) is mostly clear by design, which
// on a dark page leaves nothing to separate it from what scrolls beneath.
// Give it a near-solid card fill and a hairline edge instead.
html.theme-dark .tabbar__inner,
html.theme-dark .toast {
background: alpha(@canvas, 94%) !important;
box-shadow:
inset 0 0 0 1px @hairline,
0 18px 40px -14px rgba(0, 0, 0, 0.6) !important;
}
+2 -1
View File
@@ -3,7 +3,8 @@
<Spin :spinning="spinning" :tip="tip" :size="(size as 'small' | 'default' | 'large')" :delay="delay" :class="fixed ? '!max-h-screen !fixed' : ''">
<template #indicator>
<img v-if="!indicator" src="/img/montzar-logo.png" alt="Montzar" class="spinner-brand" width="48" height="48" />
<div class="loading" v-else>
<!-- keep-motion: a working spinner, exempt from reduced motion. -->
<div class="loading keep-motion" v-else>
<div class="effect-1 effects"></div>
<div class="effect-2 effects"></div>
<div class="effect-3 effects"></div>
@@ -84,6 +84,14 @@ const emit = defineEmits<{ (e: 'select', cat: HomeCategory): void }>()
object-fit: contain;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
// instead of showing as white boxes on a dark card (@photoMat — transparent,
// i.e. no change, in light mode).
html.theme-dark .cats__mark img {
background: @photoMat;
border-radius: 12px;
}
.cats__title {
font-size: 11px;
font-weight: 600;
@@ -50,6 +50,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useAppearanceStore } from '../../stores/appearanceStore'
export interface HeroSlide {
key: string
@@ -77,6 +78,7 @@ let startX = 0
let pointerId: number | null = null
let timer: ReturnType<typeof setInterval> | undefined
const appearance = useAppearanceStore()
const SWIPE_THRESHOLD = 46
@@ -183,7 +185,9 @@ function stop() {
}
function restart() {
stop()
if (props.autoplay && count.value > 1) {
// 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.autoplay && !appearance.isReducedMotion && count.value > 1) {
timer = setInterval(next, 6000)
}
}
@@ -11,7 +11,7 @@
number.
-->
<template>
<article class="panel" @click="emit('open', value)">
<article class="panel" data-product-origin @click="emit('open', value)">
<div class="panel__art">
<img v-lazy="image" :alt="title" />
</div>
@@ -72,6 +72,14 @@ function format(v: number) {
display: block;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
// instead of showing as white boxes on a dark card (@photoMat — transparent,
// i.e. no change, in light mode).
html.theme-dark .panel__art img {
background: @photoMat;
border-radius: 12px;
}
.panel__title {
margin: 14px 0 0;
font-size: 14px;
@@ -41,7 +41,7 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
gap: 10px;
padding: 10px 14px;
border-radius: 14px;
background: @ink;
background: @heroSurface;
color: #fff;
box-shadow: 0 10px 24px -18px rgba(20, 22, 26, 0.9);
}
@@ -61,8 +61,8 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
padding: 6px 12px 6px 10px;
border-radius: 999px;
box-shadow: none;
background: fade(@primary, 6%);
border: 1px solid fade(@primary, 10%);
background: alpha(@primary, 6%);
border: 1px solid alpha(@primary, 10%);
color: @primary;
}
@@ -70,7 +70,7 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
font-size: 11px;
font-weight: 500;
// Softer than the number, so the eye lands on the rate first.
color: fade(@primary, 62%);
color: alpha(@primary, 62%);
}
.rate-strip--compact .rate-strip__num {
@@ -95,7 +95,7 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
.rate-strip--compact .rate-strip__value--muted {
font-size: 11px;
color: fade(@primary, 45%);
color: alpha(@primary, 45%);
}
.rate-strip--compact .rate-strip__unit,
@@ -8,7 +8,7 @@
order time, not stored on the product — printing one here would be invented.
-->
<template>
<article class="card" :class="{ 'card--wide': wide }" @click="emit('open', value)">
<article class="card" :class="{ 'card--wide': wide }" data-product-origin @click="emit('open', value)">
<div class="card__art">
<img v-lazy="image" :alt="title" />
<button type="button" class="card__fav" :class="{ 'is-on': favorites.isFavorite(id) }" @click.stop="favorites.toggle(id)">
@@ -136,6 +136,14 @@ const wageLabel = computed(() => {
display: block;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
// instead of showing as white boxes on a dark card (@photoMat — transparent,
// i.e. no change, in light mode).
html.theme-dark .ci__art img {
background: @photoMat;
border-radius: 12px;
}
.ci__artEmpty {
width: 100%;
height: 100%;
@@ -86,6 +86,14 @@ onMounted(async () => {
display: block;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
// instead of showing as white boxes on a dark card (@photoMat — transparent,
// i.e. no change, in light mode).
html.theme-dark .oi__art img {
background: @photoMat;
border-radius: 12px;
}
.oi__artEmpty {
width: 100%;
height: 100%;
@@ -35,7 +35,7 @@
layout, minus an invented number.
-->
<template>
<article class="pc" @click="open">
<article ref="root" class="pc" data-product-origin @click="open">
<div class="pc__art">
<img v-if="image" v-lazy="image" :alt="title" />
<div v-else class="pc__artEmpty" aria-hidden="true">
@@ -65,25 +65,13 @@
<span class="pc__wage">{{ wageLabel(wage) }}٪ اجرت</span>
</div>
</article>
<Drawer
v-model:visible="drawer"
placement="bottom"
:closable="false"
class="rtl"
height="100vh"
:bodyStyle="{ padding: 0, position: 'relative' }"
>
<SingleProduct v-if="drawer" :id="id" :key="id" @closeDrawer="drawer = false" />
</Drawer>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import SingleProduct from './SingleProduct.vue'
import { useProduct } from '../../composables/useProduct'
import { openProduct } from '../../composables/useProductMorph'
import { useFavoritesStore } from '../../stores/favoritesStore'
import type { ProductSummary } from '../../api/client'
@@ -95,9 +83,10 @@ const props = defineProps<{ value: ProductSummary; wageBefore?: number | null }>
const favorites = useFavoritesStore()
const { image, id, title, min_gram, wage, is_exist, qty, isPreOrder } = useProduct(props.value)
const drawer = ref(false)
// The detail opens as a morph out of this card — see ProductMorph.vue.
const root = ref<HTMLElement | null>(null)
function open() {
drawer.value = true
openProduct(props.value, root.value)
}
// Whole percents print bare (۱۰٪), fractional ones keep two places (۱۰.۵٪) —
@@ -143,6 +132,14 @@ function wageLabel(v: number) {
display: block;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
// instead of showing as white boxes on a dark card (@photoMat — transparent,
// i.e. no change, in light mode).
html.theme-dark .pc__art img {
background: @photoMat;
border-radius: 12px;
}
.pc__artEmpty {
width: 100%;
height: 100%;
@@ -165,7 +162,7 @@ function wageLabel(v: number) {
cursor: pointer;
border-radius: 50%;
color: @inkFaint;
background: rgba(255, 255, 255, 0.75);
background: alpha(@canvas, 75%);
backdrop-filter: blur(6px);
transition: color 0.18s ease, transform 0.18s ease;
}
@@ -185,7 +182,7 @@ function wageLabel(v: number) {
border-radius: 999px;
font-size: 10px;
font-weight: 700;
color: #fff;
color: @onInk;
background: @ink;
}
@@ -0,0 +1,541 @@
<!--
The product detail overlay, opened as a container transform out of the card
that was tapped — the replacement for the full-height bottom Drawer, which
slid a blank sheet up from off-screen and then jumped when the detail fetch
landed and the page finally rendered.
Three layers move, and only one of them is ever repainted:
- the SHAPE: a plain fill in the card's colour, revealed by animating
`clip-path: inset(... round r)` from the card's exact rect and corner
radius out to the whole screen. It holds no content, so repainting it
per frame costs next to nothing.
- the PAGE (SingleProduct, mounted fully formed — it is seeded with the
card's own ProductSummary, so there is nothing to wait for): it grows out
of the card's centre and fades in, by transform and opacity only, so the
compositor moves it without re-rasterising it.
- the GHOST: one fixed <img> of the card's photo, flown by transform from
where the card drew it to where the gallery draws it.
An earlier version clipped the page itself; that forced a full-screen repaint
of the whole page (photos, blurred buttons and all) on every frame and lagged
on phones. Backdrop blurs inside the page are also switched off while
anything is moving, for the same reason, and the scroll lock and the page's
background refresh both wait until the motion is over.
Closing plays the morph back into the card if it is still on screen, and
falls back to a short drop-and-fade if it is not (the list was scrolled or
re-rendered). `prefers-reduced-motion` gets a plain crossfade.
Mounted once, in App.vue. Opened through composables/useProductMorph.ts.
-->
<template>
<Teleport to="body">
<div v-if="current" class="pm" :class="{ 'is-moving': moving, 'is-closing': closing }" dir="rtl">
<div ref="veil" class="pm__veil" aria-hidden="true"></div>
<div ref="shape" class="pm__shape" aria-hidden="true"></div>
<div ref="sheet" class="pm__sheet no-scrollbar">
<SingleProduct
:id="current.product.id"
:key="current.product.id"
:initial="current.product"
@closeDrawer="close"
/>
</div>
<img v-if="ghostSrc" ref="ghost" class="pm__ghost" :src="ghostSrc" alt="" aria-hidden="true" decoding="sync" />
</div>
</Teleport>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { useRoute } from 'vue-router'
import SingleProduct from './SingleProduct.vue'
import { productMorph, type ProductMorphRequest } from '../../composables/useProductMorph'
const route = useRoute()
const current = shallowRef<ProductMorphRequest | null>(null)
const ghostSrc = ref<string | null>(null)
const moving = ref(false)
const closing = ref(false)
const veil = ref<HTMLElement | null>(null)
const shape = ref<HTMLElement | null>(null)
const sheet = ref<HTMLElement | null>(null)
const ghost = ref<HTMLImageElement | null>(null)
// ── Timing ────────────────────────────────────────────────────
// Opening rides a lightly-damped spring (≈1% overshoot): quick off the mark —
// most of the travel is done in the first ~40% — with a short, soft settle.
// Expressed as a CSS `linear()` easing so it still runs on the compositor like
// any other easing; browsers without `linear()` get an ease-out-quint, which
// reads almost the same minus the settle. Closing is shorter and never
// overshoots — going back should feel like letting go, not a second entrance.
const OPEN_MS = 420
const CLOSE_MS = 300
const FALLBACK_OPEN_MS = 300
const FALLBACK_CLOSE_MS = 220
function springEasing(zeta = 0.82, span = 7, samples = 32): string {
const wd = Math.sqrt(1 - zeta * zeta)
const points: string[] = []
for (let i = 0; i <= samples; i++) {
const t = (i / samples) * span
const x = i === samples ? 1 : 1 - Math.exp(-zeta * t) * (Math.cos(wd * t) + (zeta / wd) * Math.sin(wd * t))
points.push(String(+x.toFixed(4)))
}
return `linear(${points.join(', ')})`
}
const supportsLinear = typeof CSS !== 'undefined' && CSS.supports('animation-timing-function', 'linear(0, 1)')
const EASE_OPEN = supportsLinear ? springEasing() : 'cubic-bezier(0.22, 1, 0.36, 1)'
const EASE_CLOSE = 'cubic-bezier(0.32, 0.72, 0, 1)'
const EASE_OUT = 'cubic-bezier(0.22, 1, 0.36, 1)'
// How far the page starts shrunk into the card it grows out of.
const PAGE_FROM_SCALE = 0.9
// The in-app "fast & light" switch (html.motion-reduced, see
// stores/appearanceStore.ts) or the OS accessibility setting.
const reducedMotion = () =>
document.documentElement.classList.contains('motion-reduced') ||
(typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches)
// ── Geometry ──────────────────────────────────────────────────
interface Box {
x: number
y: number
w: number
h: number
}
// Where an <img> actually draws its picture: its box minus padding, then the
// object-fit: contain letterboxing every product photo in this app uses. The
// ghost has to start and end on the drawn picture, not on the element's box,
// or it would visibly snap at both ends.
function drawnBox(img: HTMLImageElement, nw: number, nh: number): Box {
const r = img.getBoundingClientRect()
const cs = getComputedStyle(img)
const pl = parseFloat(cs.paddingLeft) || 0
const pr = parseFloat(cs.paddingRight) || 0
const pt = parseFloat(cs.paddingTop) || 0
const pb = parseFloat(cs.paddingBottom) || 0
const x = r.left + pl
const y = r.top + pt
const w = r.width - pl - pr
const h = r.height - pt - pb
if (cs.objectFit !== 'contain' && cs.objectFit !== 'scale-down') return { x, y, w, h }
let s = Math.min(w / nw, h / nh)
if (cs.objectFit === 'scale-down') s = Math.min(s, 1)
return { x: x + (w - nw * s) / 2, y: y + (h - nh * s) / 2, w: nw * s, h: nh * s }
}
function insetOf(r: DOMRect, radius: string): string {
const vw = document.documentElement.clientWidth
const vh = window.innerHeight
return `inset(${r.top}px ${vw - r.right}px ${vh - r.bottom}px ${r.left}px round ${radius})`
}
const FULL = 'inset(0px 0px 0px 0px round 0px)'
function onScreen(r: DOMRect): boolean {
return r.width > 0 && r.height > 0 && r.bottom > 0 && r.top < window.innerHeight
}
function loaded(img: HTMLImageElement | null | undefined): img is HTMLImageElement {
return !!img && img.complete && img.naturalWidth > 0
}
function sameSrc(a: HTMLImageElement, b: HTMLImageElement): boolean {
return (a.currentSrc || a.src) === (b.currentSrc || b.src)
}
// The gallery image currently showing — the slider keeps every slide in its
// track, so pick the one whose centre sits inside the stage.
function galleryImage(): HTMLImageElement | null {
const stage = sheet.value?.querySelector<HTMLElement>('.pd__stage')
if (!stage) return null
const s = stage.getBoundingClientRect()
const imgs = stage.querySelectorAll<HTMLImageElement>('img')
for (const img of imgs) {
const r = img.getBoundingClientRect()
const cx = r.left + r.width / 2
if (cx > s.left && cx < s.right) return img
}
return null
}
function originImage(origin: HTMLElement | null): HTMLImageElement | null {
return origin?.querySelector<HTMLImageElement>('img') ?? null
}
// Colours and corner radius the shape morphs between: the card's own fill
// (falling back to the page's when the card is transparent) and the page's.
function palette(origin: HTMLElement) {
const pd = sheet.value?.querySelector<HTMLElement>('.pd')
const pageBg = (pd && getComputedStyle(pd).backgroundColor) || '#fff'
const cs = getComputedStyle(origin)
const cardBg = cs.backgroundColor === 'rgba(0, 0, 0, 0)' ? pageBg : cs.backgroundColor
return { pageBg, cardBg, radius: cs.borderRadius || '0px' }
}
function centreOf(r: DOMRect): string {
return `${r.left + r.width / 2}px ${r.top + r.height / 2}px`
}
function ghostFrames(from: Box, to: Box): Keyframe[] {
const t = (b: Box) => `translate(${b.x - to.x}px, ${b.y - to.y}px) scale(${b.w / to.w}, ${b.h / to.h})`
return [{ transform: t(from) }, { transform: t(to) }]
}
function placeGhost(el: HTMLImageElement, at: Box) {
el.style.left = `${at.x}px`
el.style.top = `${at.y}px`
el.style.width = `${at.w}px`
el.style.height = `${at.h}px`
}
// ── Run bookkeeping ───────────────────────────────────────────
// Each open/close takes a new generation; a run that has been superseded by a
// later one skips its own teardown.
let gen = 0
let running: Animation[] = []
let phase: 'idle' | 'opening' | 'open' | 'closing' = 'idle'
let hiddenImgs: HTMLElement[] = []
function hide(el: HTMLElement | null) {
if (!el) return
el.style.opacity = '0'
hiddenImgs.push(el)
}
function unhideAll() {
hiddenImgs.forEach((el) => (el.style.opacity = ''))
hiddenImgs = []
}
function stopRunning() {
running.forEach((a) => a.cancel())
running = []
}
async function settled(anims: Animation[]) {
running = anims
await Promise.all(anims.map((a) => a.finished.catch(() => undefined)))
}
// The page behind must not scroll while the overlay is up. Set on <html>, not
// <body>: SingleProduct's own image preview locks and unlocks <body>, and must
// not be able to release this one. Only applied once the overlay is fully
// open — toggling overflow on the root re-lays-out the page underneath, which
// is a cost the first frame of the morph should not pay.
function lockScroll(on: boolean) {
document.documentElement.style.overflow = on ? 'hidden' : ''
}
// ── Open ──────────────────────────────────────────────────────
async function open(req: ProductMorphRequest) {
const my = ++gen
stopRunning()
unhideAll()
closing.value = false
moving.value = true
phase = 'opening'
const originImg = originImage(req.origin)
const originRect = req.origin?.getBoundingClientRect()
const canMorph = !reducedMotion() && !!req.origin && !!originRect && onScreen(originRect)
ghostSrc.value = canMorph && loaded(originImg) ? originImg.currentSrc || originImg.src : null
current.value = req
// Mounted, laid out and measured inside the same task as the tap — the first
// painted frame is already the first frame of the morph.
await nextTick()
if (my !== gen || !sheet.value || !veil.value) return
let anims: Animation[]
if (canMorph && originRect && req.origin) {
anims = morphOpen(req.origin, originRect, originImg)
} else if (reducedMotion()) {
anims = [
sheet.value.animate({ opacity: [0, 1] }, { duration: 160, easing: 'ease-out', fill: 'both' }),
veil.value.animate({ opacity: [0, 1] }, { duration: 160, easing: 'ease-out', fill: 'both' }),
]
} else {
anims = [
sheet.value.animate(
{ transform: ['translateY(6%)', 'none'], opacity: [0, 1] },
{ duration: FALLBACK_OPEN_MS, easing: EASE_OUT, fill: 'both' },
),
veil.value.animate({ opacity: [0, 1] }, { duration: FALLBACK_OPEN_MS, easing: 'ease-out', fill: 'both' }),
]
}
await settled(anims)
if (my !== gen) return
// The end state of every animation equals the resting CSS, so dropping them
// changes nothing on screen — it just leaves no clip-path or composited
// layers behind while the page is being read and scrolled.
stopRunning()
unhideAll()
ghostSrc.value = null
moving.value = false
phase = 'open'
lockScroll(true)
}
function morphOpen(origin: HTMLElement, originRect: DOMRect, originImg: HTMLImageElement | null): Animation[] {
const sheetEl = sheet.value!
const shapeEl = shape.value!
const veilEl = veil.value!
const { pageBg, cardBg, radius } = palette(origin)
// Measure everything BEFORE any animation starts: once the page is scaled,
// the gallery's rect would come back scaled too.
const targetImg = galleryImage()
const ghostEl = ghost.value
let flight: { from: Box; to: Box } | null = null
if (ghostEl && loaded(originImg) && targetImg && sameSrc(originImg, targetImg)) {
const nw = originImg.naturalWidth
const nh = originImg.naturalHeight
const to = drawnBox(targetImg, nw, nh)
if (to.w > 0 && to.h > 0) flight = { from: drawnBox(originImg, nw, nh), to }
}
sheetEl.style.transformOrigin = centreOf(originRect)
const anims: Animation[] = [
shapeEl.animate(
{ clipPath: [insetOf(originRect, radius), FULL], backgroundColor: [cardBg, pageBg] },
{ duration: OPEN_MS, easing: EASE_OPEN, fill: 'both' },
),
veilEl.animate({ opacity: [0, 1] }, { duration: OPEN_MS * 0.6, easing: 'ease-out', fill: 'both' }),
// The page grows out of the card's centre alongside the shape, and fades
// in a beat behind it — the first frames read as the card lifting, then
// the page arriving inside it.
sheetEl.animate(
{ transform: [`scale(${PAGE_FROM_SCALE})`, 'none'] },
{ duration: OPEN_MS, easing: EASE_OPEN, fill: 'both' },
),
sheetEl.animate(
{ opacity: [0, 1] },
{ duration: OPEN_MS * 0.55, delay: OPEN_MS * 0.1, easing: 'cubic-bezier(0.3, 0, 0.2, 1)', fill: 'both' },
),
]
if (ghostEl && flight && targetImg) {
placeGhost(ghostEl, flight.to)
hide(targetImg)
anims.push(ghostEl.animate(ghostFrames(flight.from, flight.to), { duration: OPEN_MS, easing: EASE_OPEN, fill: 'both' }))
} else {
ghostSrc.value = null
}
return anims
}
// ── Close ─────────────────────────────────────────────────────
async function close() {
if (!current.value || phase === 'closing') return
const my = ++gen
const wasOpening = phase === 'opening'
phase = 'closing'
closing.value = true
moving.value = true
let anims: Animation[]
if (wasOpening && running.length) {
// Closed before it finished opening: play the same motion back from
// wherever it got to, rather than jumping to open and morphing from there.
running.forEach((a) => a.reverse())
anims = running
} else {
stopRunning()
unhideAll()
anims = await closeAnimations()
}
await settled(anims)
if (my !== gen) return
teardown()
}
async function closeAnimations(): Promise<Animation[]> {
const sheetEl = sheet.value
const shapeEl = shape.value
const veilEl = veil.value
if (!sheetEl || !shapeEl || !veilEl) return []
if (reducedMotion()) {
return [
sheetEl.animate({ opacity: [1, 0] }, { duration: 140, easing: 'ease-in', fill: 'forwards' }),
veilEl.animate({ opacity: [1, 0] }, { duration: 140, easing: 'ease-in', fill: 'forwards' }),
]
}
const origin = current.value?.origin ?? null
const originRect = origin?.isConnected ? origin.getBoundingClientRect() : null
if (!origin || !originRect || !onScreen(originRect)) {
return [
sheetEl.animate(
{ transform: ['none', 'translateY(6%)'], opacity: [1, 0] },
{ duration: FALLBACK_CLOSE_MS, easing: 'cubic-bezier(0.4, 0, 1, 1)', fill: 'forwards' },
),
veilEl.animate({ opacity: [1, 0] }, { duration: FALLBACK_CLOSE_MS, easing: 'ease-in', fill: 'forwards' }),
]
}
const { pageBg, cardBg, radius } = palette(origin)
// Measured before anything moves, for the same reason as on the way in.
const originImg = originImage(origin)
const fromImg = galleryImage()
let flight: { from: Box; to: Box } | null = null
if (loaded(originImg) && fromImg && sameSrc(originImg, fromImg) && onScreen(fromImg.getBoundingClientRect())) {
const nw = originImg.naturalWidth
const nh = originImg.naturalHeight
const from = drawnBox(fromImg, nw, nh)
if (from.w > 0 && from.h > 0) flight = { from, to: drawnBox(originImg, nw, nh) }
}
if (flight && originImg) {
ghostSrc.value = originImg.currentSrc || originImg.src
await nextTick()
}
sheetEl.style.transformOrigin = centreOf(originRect)
const anims: Animation[] = [
// The shape fades over its last stretch so the real card — title and all —
// crossfades in under it instead of popping in after it lands.
shapeEl.animate(
[
{ clipPath: FULL, backgroundColor: pageBg, opacity: 1 },
{ opacity: 1, offset: 0.7 },
{ clipPath: insetOf(originRect, radius), backgroundColor: cardBg, opacity: 0 },
],
{ duration: CLOSE_MS, easing: EASE_CLOSE, fill: 'forwards' },
),
sheetEl.animate(
{ transform: ['none', `scale(${PAGE_FROM_SCALE})`] },
{ duration: CLOSE_MS, easing: EASE_CLOSE, fill: 'forwards' },
),
sheetEl.animate({ opacity: [1, 0] }, { duration: CLOSE_MS * 0.45, easing: 'ease-in', fill: 'forwards' }),
veilEl.animate({ opacity: [1, 0] }, { duration: CLOSE_MS, easing: 'ease-out', fill: 'forwards' }),
]
// The photo flies home only if the gallery is still on screen and showing
// the same picture the card does; otherwise the shape alone carries it back.
const ghostEl = ghost.value
if (ghostEl && flight && fromImg) {
// Based on the gallery box this time, so the frames run identity → card.
placeGhost(ghostEl, flight.from)
hide(fromImg)
anims.push(
ghostEl.animate(ghostFrames(flight.to, flight.from).reverse(), {
duration: CLOSE_MS,
easing: EASE_CLOSE,
fill: 'forwards',
}),
)
}
return anims
}
function teardown() {
stopRunning()
unhideAll()
ghostSrc.value = null
moving.value = false
closing.value = false
phase = 'idle'
current.value = null
lockScroll(false)
if (productMorph.value) productMorph.value = null
}
// ── Wiring ────────────────────────────────────────────────────
watch(productMorph, (req) => {
if (req) open(req)
else if (current.value) close()
})
// Anything inside the page that navigates (the cart button, the category pill)
// leaves the overlay behind; it goes with the screen it was opened over.
watch(
() => route.fullPath,
() => {
if (!current.value) return
++gen
teardown()
},
)
onBeforeUnmount(() => {
stopRunning()
unhideAll()
lockScroll(false)
})
</script>
<style scoped lang="less">
.pm {
position: fixed;
inset: 0;
// Same layer the Drawer it replaces sat on; SingleProduct's own full-screen
// image preview is 2000 and must stay above this.
z-index: 1000;
}
.pm.is-closing {
pointer-events: none;
}
.pm__veil {
position: absolute;
inset: 0;
background: rgba(14, 15, 18, 0.32);
}
// Only drawn while something is moving; at rest the page covers it anyway.
.pm__shape {
position: absolute;
inset: 0;
display: none;
}
.pm.is-moving .pm__shape {
display: block;
}
// The scroller for pages that do not scroll themselves (v1's SingleProduct
// grows with its content; v2's is its own 100vh scroller and never overflows
// this). The page's transform is applied HERE rather than on anything inside:
// this box is already pinned to the viewport, so a `position: fixed` action bar
// inside the page stays exactly where it is while the transform is running.
.pm__sheet {
position: absolute;
inset: 0;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
// Backdrop blurs are re-computed on every frame their layer moves, and the page
// has around ten of them (header buttons, action bar…). Their surfaces are
// already ~90% opaque, so dropping the blur for the length of the motion is
// invisible — and it is the single biggest cost the moving page had.
.pm.is-moving :deep(*) {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
.pm__ghost {
position: fixed;
z-index: 2;
transform-origin: 0 0;
object-fit: fill;
pointer-events: none;
will-change: transform;
}
</style>
@@ -321,8 +321,11 @@ import { useFavoritesStore } from '../../stores/favoritesStore'
import { useFiltersStore } from '../../stores/filtersStore'
import { useCartStore, type CartItem } from '../../stores/cartStore'
import { isUserLoggedIn } from '../../utils/checkAccess'
import type { ProductSummary } from '../../api/client'
const props = defineProps<{ id: string }>()
// `initial`: the card's own ProductSummary when opened from a list (see
// ProductMorph.vue) — lets the page render whole on its first frame.
const props = defineProps<{ id: string; initial?: ProductSummary | null }>()
const emit = defineEmits<{ (e: 'closeDrawer'): void }>()
const router = useRouter()
@@ -330,7 +333,7 @@ const favorites = useFavoritesStore()
const filters = useFiltersStore()
const cart = useCartStore()
// Used two ways, as in legacy: embedded in a bottom Drawer by the product
// Used two ways, as in legacy: embedded in the ProductMorph overlay by the product
// cards, and as a standalone page for deep links (/product/:id). In the drawer
// case the parent listens for closeDrawer; on the routed page there is no
// parent, so fall back to history.back().
@@ -366,7 +369,7 @@ function onScroll() {
headerSolid.value = el.scrollTop > Math.max(galleryHeight - HEADER_SOLID_OFFSET_PX, 0)
}
const productDetail = useProductDetail(props.id)
const productDetail = useProductDetail(props.id, props.initial)
const loading = productDetail.loading
const flag = productDetail.flag
@@ -532,7 +535,7 @@ onBeforeUnmount(() => {
height: 100vh;
overflow-y: auto;
overflow-x: hidden;
background: @canvas;
background: @page;
// Momentum scrolling on iOS, and stop a scroll at either end from chaining
// out to the page (or the drawer) behind.
-webkit-overflow-scrolling: touch;
@@ -579,7 +582,7 @@ onBeforeUnmount(() => {
}
.pd__header.is-solid {
background: rgba(255, 255, 255, 0.88);
background: alpha(@canvas, 88%);
backdrop-filter: blur(14px);
border-bottom-color: @hairline;
}
@@ -640,7 +643,7 @@ onBeforeUnmount(() => {
cursor: pointer;
text-decoration: none;
color: @ink;
background: rgba(255, 255, 255, 0.9);
background: alpha(@canvas, 90%);
backdrop-filter: blur(10px) saturate(140%);
box-shadow: 0 10px 26px -16px rgba(20, 22, 26, 0.7);
transition: transform 0.18s ease, color 0.28s ease, background 0.28s ease,
@@ -694,7 +697,7 @@ onBeforeUnmount(() => {
min-height: 320px;
max-height: 460px;
overflow: hidden;
background: @hairlineSoft;
background: @photoStage;
// Horizontal drags are the gallery's; vertical ones still scroll the page.
touch-action: pan-y;
}
@@ -1003,7 +1006,7 @@ onBeforeUnmount(() => {
}
.pd__stock.is-in {
color: @green;
background: fade(@green, 8%);
background: alpha(@green, 8%);
}
.pd__stock.is-pre {
color: @wine;
@@ -1085,7 +1088,7 @@ onBeforeUnmount(() => {
color: @inkFaint;
}
.pd__figure--accent .pd__figureLabel {
color: fade(@wine, 65%);
color: alpha(@wine, 65%);
}
/* ── Variant chips ───────────────────────────────────────── */
@@ -1122,7 +1125,7 @@ onBeforeUnmount(() => {
transform: scale(0.95);
}
.pd__chip.is-on {
color: #fff;
color: @onInk;
background: @ink;
border-color: @ink;
}
@@ -1218,7 +1221,7 @@ onBeforeUnmount(() => {
max-width: 768px;
margin: 0 auto;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
background: rgba(255, 255, 255, 0.9);
background: alpha(@canvas, 90%);
backdrop-filter: blur(18px) saturate(140%);
border-top: 1px solid @hairline;
}
@@ -1304,7 +1307,7 @@ onBeforeUnmount(() => {
font-weight: 800;
white-space: nowrap;
text-decoration: none;
color: #fff;
color: @onInk;
background: @ink;
box-shadow: 0 14px 30px -18px rgba(20, 22, 26, 0.9);
transition: transform 0.18s ease, opacity 0.18s ease;
+474 -179
View File
@@ -13,7 +13,7 @@
- Gram-range filter: real, bounds derived from the actual min/max
rashWeightGrams across all variants (rather than a customer-specific
server-provided bound). Still uses ant-design-vue's Slider for the actual
drag/range behaviour, restyled via :deep() to the wine accent.
drag/range behaviour, restyled to the app's accent.
- DROPPED: color filter — no color concept exists anywhere in the new
Product/ProductVariant schema, not a stubbed-off feature like promotions,
genuinely nothing to filter by.
@@ -23,72 +23,139 @@
concept in the new Customer model).
- DROPPED: discount toggle — no promotions data exists (see useProduct.ts).
Top padding (120px) accounts for PageBar + the search input row with no
chips row below it (chips are hidden while this sheet is open) — same
bar-chrome math SearchView/OrdersView use for their own #below content.
Presentation: a bottom sheet over a dimmed, see-through backdrop. Full width
and attached to the bottom edge — not a floating island, so it has room to
take more filters later — and only as tall as its content, up to 88% of the
screen, past which the body scrolls under a fixed header and action bar.
It replaced an in-place swap of the search page's body (results hidden with
v-show, the bar retitled and its back button hijacked, the tab bar hidden),
which was neither a page nor a modal. Closes on the backdrop, a swipe down
on its header, the × button, or the hardware/browser back
(close-on-popstate).
STRUCTURE IS SHARED, LOOK IS NOT. The template and script here are kept
identical to user-app's Filters.vue; each app styles the same markup in its
own design language (this one: white sheet with the 32px top radius the
detail page's sheet uses, borderless @hairlineSoft panels via DropDown, @ink
pill buttons and chips, squircle controls, @wine accent).
Choices are a DRAFT until "اعمال فیلتر": nothing touches the filters store
while the sheet is open, so dragging the gram slider no longer re-filters and
re-renders the whole result grid behind it on every pointer move (it did, and
that was most of the lag). Dismissing without applying discards the draft.
"پاک کردن فیلتر‌ها" clears only what this sheet owns (brands, gram range) —
it used to reset the whole query, wiping the search text and the category
chip along with it.
-->
<template>
<section class="filt">
<div class="filt__body">
<DropDown text="فیلتر بر اساس برند">
<div v-if="availableBrands.length" class="filt__chips">
<button
v-for="item in availableBrands"
:key="item.id"
type="button"
class="filt__chip"
:class="{ 'is-on': modelValue.brands.includes(item.id) }"
@click="toggleBrand(item.id)"
>
{{ item.title }}
<Popup
:show="show"
position="bottom"
teleport="body"
transition="fSheet-slide"
class="fSheet"
overlay-class="fSheet__overlay"
close-on-popstate
@update:show="emit('update:show', $event)"
@closed="dragY = 0"
>
<div
class="fSheet__card"
:class="{ 'is-dragging': dragging }"
:style="cardStyle"
dir="rtl"
role="dialog"
aria-modal="true"
aria-labelledby="fSheet-title"
>
<!-- The drag handle area. Swipe-to-dismiss starts only here, so a
vertical swipe on the body still scrolls the filters. -->
<header
class="fSheet__head"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<span class="fSheet__grip" aria-hidden="true"></span>
<div class="fSheet__headRow">
<h3 id="fSheet-title" class="fSheet__title">
فیلترها
<span v-if="draftCount" class="fSheet__count">{{ draftCount }}</span>
</h3>
<button type="button" class="fSheet__close" aria-label="بستن" @click="close">
<Icon icon="vuesax-linear:add" size="18" />
</button>
</div>
<p v-else class="filt__empty">برندی برای فیلتر موجود نیست.</p>
</DropDown>
</header>
<DropDown text="فیلتر بر اساس گرم">
<div class="filt__gram">
<Slider
v-model:value="(modelValue.limit_gram as unknown as [number, number])"
:step="0.01"
range
reverse
:min="gramBounds[0]"
:max="gramBounds[1]"
/>
<div class="filt__gramFigures" v-if="modelValue.limit_gram.length > 0">
<span class="filt__gramFigure">
<i>کمترین</i>
{{ modelValue.limit_gram[0] }} گرم
</span>
<span class="filt__gramFigure">
<i>بیشترین</i>
{{ modelValue.limit_gram[1] }} گرم
</span>
<div class="fSheet__body">
<DropDown text="برند" default-open>
<div v-if="availableBrands.length" class="fSheet__chips">
<button
v-for="item in availableBrands"
:key="item.id"
type="button"
class="fSheet__chip"
:class="{ 'is-on': draftBrands.includes(item.id) }"
:aria-pressed="draftBrands.includes(item.id)"
@click="toggleBrand(item.id)"
>
{{ item.title }}
</button>
</div>
</div>
</DropDown>
</div>
<p v-else class="fSheet__empty">برندی برای فیلتر موجود نیست.</p>
</DropDown>
<div class="filt__bar">
<div class="filt__barInner">
<button type="button" class="filt__clear" @click="closeFilters(false)">پاک کردن فیلتر‌ها</button>
<button type="button" class="filt__apply" @click="closeFilters(true)">اعمال فیلتر</button>
<DropDown text="وزن (گرم)" default-open>
<div class="fSheet__gram">
<!-- Tooltips off: they render in ant's own layer (z-index 1070),
underneath this sheet (vant's 2000+), and the figures below
already show both values live. -->
<Slider
:value="draftGram"
:step="0.01"
range
reverse
:min="gramBounds[0]"
:max="gramBounds[1]"
:tooltip-visible="false"
@update:value="onGram"
/>
<div class="fSheet__gramFigures">
<span class="fSheet__gramFigure">
<i>کمترین</i>
<b>{{ fmt(draftGram[0]) }}</b> گرم
</span>
<span class="fSheet__gramFigure">
<i>بیشترین</i>
<b>{{ fmt(draftGram[1]) }}</b> گرم
</span>
</div>
</div>
</DropDown>
</div>
<footer class="fSheet__bar">
<button type="button" class="fSheet__clear" @click="clear">پاک کردن</button>
<button type="button" class="fSheet__apply" @click="apply">اعمال فیلتر</button>
</footer>
</div>
</section>
</Popup>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { Popup } from 'vant'
import { Slider } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import DropDown from '../ui/DropDown.vue'
import type { FiltersQuery } from '../../stores/filtersStore'
import type { ProductSummary } from '../../api/client'
const props = withDefaults(defineProps<{ modelValue: FiltersQuery; filterKeys: string[]; products: ProductSummary[] }>(), {})
const props = defineProps<{ show: boolean; modelValue: FiltersQuery; filterKeys: string[]; products: ProductSummary[] }>()
const emit = defineEmits<{
(e: 'change', value: { resetFilters: boolean }): void
(e: 'update:show', value: boolean): void
(e: 'update:filterKeys', value: string[]): void
}>()
@@ -106,185 +173,413 @@ const gramBounds = computed((): [number, number] => {
return [Math.floor(Math.min(...weights)), Math.ceil(Math.max(...weights))]
})
// The Slider's `range` mode needs a real [start, end] value to place its two
// handles — filtersStore's default `limit_gram: []` has neither, so until
// this seeds it, ant-design-vue's Slider has nothing to derive handle
// positions from and renders no handles at all. Only seeds while still
// empty, so it never clobbers a value the user already dragged.
// ── Draft ─────────────────────────────────────────────────────
const draftBrands = ref<string[]>([])
// Always a real [start, end] pair: ant-design-vue's range Slider places no
// handles at all without one.
const draftGram = ref<[number, number]>([...gramBounds.value])
// Until the user drags, the range follows the catalogue's bounds (they can
// still arrive after the sheet first opens, while the list is loading).
let gramTouched = false
function loadDraft() {
draftBrands.value = [...props.modelValue.brands]
const g = props.modelValue.limit_gram
gramTouched = g.length === 2
draftGram.value = g.length === 2 ? [g[0], g[1]] : [...gramBounds.value]
}
watch(
gramBounds,
(bounds) => {
if (props.modelValue.limit_gram.length === 0) {
props.modelValue.limit_gram = [...bounds]
}
() => props.show,
(open) => {
if (open) loadDraft()
},
{ immediate: true },
)
const toggleBrand = (id: string) => {
const brands = props.modelValue.brands
const index = brands.indexOf(id)
if (index === -1) brands.push(id)
else brands.splice(index, 1)
watch(gramBounds, (bounds) => {
if (!gramTouched) draftGram.value = [...bounds]
})
function onGram(value: [number, number] | number) {
if (!Array.isArray(value)) return
draftGram.value = [value[0], value[1]]
gramTouched = true
}
const closeFilters = (sendQuery: boolean): void => {
const filterKeys: string[] = []
if (sendQuery) {
if (props.modelValue.brands.length > 0) filterKeys.push('brands')
if (
props.modelValue.limit_gram[0] !== undefined &&
props.modelValue.limit_gram[1] !== undefined &&
(props.modelValue.limit_gram[0] !== gramBounds.value[0] || props.modelValue.limit_gram[1] !== gramBounds.value[1])
) {
filterKeys.push('limit_gram')
}
}
if (sendQuery && filterKeys.length > 0) {
emit('update:filterKeys', filterKeys)
emit('change', { resetFilters: false })
} else {
emit('update:filterKeys', [])
emit('change', { resetFilters: true })
}
// A range that still spans the whole catalogue filters nothing out, so it is
// not counted as a filter (and is stored as "no range" on apply).
const gramActive = computed(() => {
const [lo, hi] = draftGram.value
const [min, max] = gramBounds.value
return Math.abs(lo - min) > 0.001 || Math.abs(hi - max) > 0.001
})
const draftCount = computed(() => draftBrands.value.length + (gramActive.value ? 1 : 0))
function toggleBrand(id: string) {
const i = draftBrands.value.indexOf(id)
draftBrands.value = i === -1 ? [...draftBrands.value, id] : draftBrands.value.filter((b) => b !== id)
}
function fmt(n: number) {
return n % 1 === 0 ? String(n) : String(+n.toFixed(2))
}
// ── Commit ────────────────────────────────────────────────────
// Always assigns fresh arrays rather than mutating the store's in place — see
// filtersStore's reset() for why sharing them went wrong.
function apply() {
const q = props.modelValue
q.brands = [...draftBrands.value]
q.limit_gram = gramActive.value ? [...draftGram.value] : []
const keys: string[] = []
if (q.brands.length) keys.push('brands')
if (q.limit_gram.length) keys.push('limit_gram')
emit('update:filterKeys', keys)
close()
}
function clear() {
draftBrands.value = []
draftGram.value = [...gramBounds.value]
gramTouched = false
apply()
}
function close() {
emit('update:show', false)
}
// ── Swipe down to dismiss (header only) ───────────────────────
// The sheet follows the finger down, rubber-bands up, and on release either
// springs back or leaves from where it was let go.
const DISMISS_DISTANCE = 90
const DISMISS_VELOCITY = 0.5 // px per ms
const dragY = ref(0)
const dragging = ref(false)
let startY = 0
let startTime = 0
const cardStyle = computed(() => (dragY.value ? { transform: `translate3d(0, ${dragY.value}px, 0)` } : undefined))
function onTouchStart(e: TouchEvent) {
if ((e.target as HTMLElement).closest('button')) return
startY = e.touches[0].clientY
startTime = Date.now()
dragging.value = true
}
function onTouchMove(e: TouchEvent) {
if (!dragging.value) return
const dy = e.touches[0].clientY - startY
dragY.value = dy > 0 ? dy : dy * 0.12
}
function onTouchEnd() {
if (!dragging.value) return
dragging.value = false
const velocity = dragY.value / Math.max(1, Date.now() - startTime)
if (dragY.value > DISMISS_DISTANCE || (dragY.value > 30 && velocity > DISMISS_VELOCITY)) close()
else dragY.value = 0
}
</script>
<style scoped lang="less">
.filt {
background: @canvas;
min-height: 100vh;
<style lang="less">
// Global, not scoped: the popup is teleported to <body>. Everything is under
// the fSheet prefix.
/* ── Shell (same in both apps) ──────────────────────────────── */
// See-through dim, no blur: blurring the page behind a fading overlay is
// recomputed every frame and is what made sheets janky on phones.
.fSheet__overlay {
background: rgba(20, 22, 26, 0.42);
}
.filt__body {
.fSheet.van-popup {
background: transparent;
overflow: visible;
}
// The iOS sheet curve: fast out of the gate, long soft landing. Transform only.
.fSheet-slide-enter-active,
.fSheet-slide-leave-active {
will-change: transform;
}
.fSheet-slide-enter-active {
transition: transform 0.42s cubic-bezier(0.32, 0.72, 0, 1);
}
.fSheet-slide-leave-active {
transition: transform 0.24s cubic-bezier(0.4, 0, 1, 1);
}
.fSheet-slide-enter-from,
.fSheet-slide-leave-to {
transform: translate3d(0, 100%, 0);
}
.fSheet__card {
position: relative;
// Phone: edge to edge. Wider screens: a centred column, same max width the
// app's pages use.
max-width: 768px;
margin: 0 auto;
padding: 120px 16px 110px;
max-height: 88vh;
max-height: 88dvh;
display: flex;
flex-direction: column;
overflow: hidden;
transition: transform 0.38s cubic-bezier(0.22, 1, 0.36, 1);
&.is-dragging {
transition: none;
}
}
.fSheet__head {
flex: none;
touch-action: none;
}
.fSheet__grip {
display: block;
margin: 0 auto;
}
.fSheet__headRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.filt__chips {
.fSheet__title {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
}
.fSheet__count {
min-width: 20px;
height: 20px;
padding: 0 6px;
display: inline-grid;
place-items: center;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
color: #fff;
}
.fSheet__close {
display: grid;
place-items: center;
border: 0;
cursor: pointer;
transform: rotate(45deg);
transition: transform 0.15s ease;
}
.fSheet__close:active {
transform: rotate(45deg) scale(0.92);
}
.fSheet__body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
display: flex;
flex-direction: column;
}
.fSheet__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filt__chip {
padding: 8px 14px;
border-radius: 999px;
.fSheet__chip {
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: @inkSoft;
background: @canvas;
border: 1px solid @hairline;
transition: all 0.2s ease;
white-space: nowrap;
-webkit-tap-highlight-color: transparent;
transition: color 0.18s ease, background-color 0.18s ease, border-color 0.18s ease, transform 0.15s ease;
}
.fSheet__chip:active {
transform: scale(0.96);
}
.filt__chip.is-on {
color: #fff;
background: @ink;
border-color: @ink;
}
.filt__chip:active {
transform: scale(0.97);
}
.filt__empty {
.fSheet__empty {
margin: 0;
font-size: 12px;
color: @inkFaint;
}
.filt__gram {
padding: 4px 6px 0;
}
.filt__gram :deep(.ant-slider-track) {
background-color: @wine;
}
.filt__gram :deep(.ant-slider-handle) {
border-color: @wine;
}
.filt__gram :deep(.ant-slider-rail) {
background-color: @hairline;
}
.filt__gramFigures {
.fSheet__gramFigures {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 18px;
}
.filt__gramFigure {
font-size: 12px;
.fSheet__gramFigure i {
font-style: normal;
}
.fSheet__bar {
flex: none;
display: flex;
align-items: center;
gap: 10px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
}
.fSheet__clear,
.fSheet__apply {
cursor: pointer;
font-family: inherit;
white-space: nowrap;
transition: transform 0.15s ease;
}
.fSheet__clear:active,
.fSheet__apply:active {
transform: scale(0.98);
}
/* ── Look: user-app-v2 ──────────────────────────────────────── */
// The detail page's sheet: white, 32px top radius, the same grip.
.fSheet__card {
border-radius: 32px 32px 0 0;
background: @canvas;
box-shadow: 0 -18px 40px -30px rgba(20, 22, 26, 0.55);
}
.fSheet__head {
padding: 8px 18px 12px;
}
.fSheet__grip {
width: 38px;
height: 4px;
margin-bottom: 12px;
border-radius: 999px;
background: @hairline;
}
.fSheet__title {
font-size: 16px;
font-weight: 700;
color: @ink;
}
.filt__gramFigure i {
font-style: normal;
font-size: 11px;
font-weight: 600;
color: @inkFaint;
padding-inline-end: 4px;
.fSheet__count {
background: @wine;
}
.filt__bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 5;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(14px);
border-top: 1px solid @hairline;
padding-bottom: 16px;
}
.filt__barInner {
max-width: 768px;
margin: 0 auto;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 10px;
}
.filt__clear,
.filt__apply {
flex: 1;
padding: 13px 18px;
border: 0;
cursor: pointer;
border-radius: 14px;
font-family: inherit;
font-size: 13px;
font-weight: 700;
transition: transform 0.15s ease;
}
.filt__clear {
color: @inkSoft;
// PageBar's / the detail header's squircle control.
.fSheet__close {
width: 38px;
height: 38px;
border-radius: 12px;
color: @ink;
background: @hairlineSoft;
}
.fSheet__body {
gap: 10px;
padding: 2px 16px 16px;
}
// The detail page's variant chips.
.fSheet__chip {
height: 34px;
padding: 0 14px;
border-radius: 999px;
font-size: 12.5px;
font-weight: 700;
color: @inkMuted;
background: @canvas;
border: 1px solid @hairline;
}
.fSheet__chip.is-on {
color: @onInk;
background: @ink;
border-color: @ink;
}
.fSheet__gram {
padding: 2px 4px 0;
}
// A step darker than @hairline: the rail sits on DropDown's @hairlineSoft panel.
.fSheet__gram .ant-slider-rail {
background-color: @hairlineStrong;
}
.fSheet__gram .ant-slider-track {
background-color: @wine;
}
.fSheet__gram .ant-slider-handle {
border-color: @wine;
}
.fSheet__gramFigures {
padding-top: 12px;
}
// The detail page's figure tiles, on white since they sit on a grey panel.
.fSheet__gramFigure {
flex: 1;
display: flex;
align-items: baseline;
justify-content: center;
gap: 5px;
padding: 9px 12px;
border-radius: 13px;
background: @canvas;
font-size: 11px;
color: @inkFaint;
}
.fSheet__gramFigure i {
font-size: 10.5px;
font-weight: 500;
}
.fSheet__gramFigure b {
font-size: 14px;
font-weight: 800;
color: @ink;
direction: ltr;
}
// The detail page's action bar: an outlined pill beside the dark primary pill.
.fSheet__bar {
padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
background: @canvas;
border-top: 1px solid @hairline;
}
.fSheet__clear,
.fSheet__apply {
height: 48px;
border-radius: 999px;
font-size: 13.5px;
font-weight: 800;
}
.fSheet__clear {
flex: 0 0 auto;
padding: 0 22px;
color: @ink;
background: @canvas;
border: 1px solid @hairline;
}
.filt__apply {
color: #fff;
.fSheet__apply {
flex: 1;
border: 0;
color: @onInk;
background: @ink;
}
.filt__clear:active,
.filt__apply:active {
transform: scale(0.98);
box-shadow: 0 14px 30px -18px rgba(20, 22, 26, 0.9);
}
</style>
+110 -45
View File
@@ -4,20 +4,44 @@
raw Tailwind utility classes with legacy tokens (text-primary,
border-lightGray/20), the one accordion in the app that didn't match the
LESS-token look every other surface uses.
FIXED — the collapse itself. It used to v-if the body in and out and animate
`max-height` from 0 to a hard-coded 240px, which broke three ways: content
taller than 240px was cut off for the whole animation and then jumped to full
height at the end; content shorter than 240px opened fast and then closed
with a dead pause (the first stretch of the shrink happened above the real
height, so nothing moved); and the close used `animation: … reverse` on a
`leave-to` class, which Vue does not time leave transitions by, so it
usually just snapped shut. The body's own padding also popped in and out.
Now the body stays mounted and the panel animates `grid-template-rows` from
0fr to 1fr — the browser interpolates to the content's real height, whatever
it is, in both directions, with no measuring and no cap. All the padding
below the header lives inside the clipped row, so it is part of what
animates. Browsers too old to interpolate grid rows (pre-2022) simply open
and close without the slide.
STRUCTURE IS SHARED, LOOK IS NOT: the template, script and the collapse
mechanics are identical in user-app and user-app-v2; only the "Look" block
at the bottom differs, each in its own app's design language.
-->
<template>
<div class="dd" :class="{ 'is-open': show }">
<button type="button" class="dd__head" @click="show = !show">
<button type="button" class="dd__head" :aria-expanded="show" @click="show = !show">
<h3 class="dd__title">
<slot name="text">{{ text }}</slot>
</h3>
<Icon icon="vuesax-linear:arrow-down" class="dd__chevron" size="18" />
<span class="dd__chevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-down" size="16" />
</span>
</button>
<transition name="drop" mode="out-in" appear>
<div class="dd__body" v-if="show">
<slot />
<div class="dd__panel" :inert="!show">
<div class="dd__clip">
<div class="dd__body">
<slot />
</div>
</div>
</transition>
</div>
</div>
</template>
@@ -25,21 +49,16 @@
import { ref } from 'vue'
import Icon from '../icon/Icon.vue'
withDefaults(defineProps<{ text?: string }>(), {})
const show = ref(false)
const props = withDefaults(defineProps<{ text?: string; defaultOpen?: boolean }>(), { defaultOpen: false })
const show = ref(props.defaultOpen)
</script>
<style scoped lang="less">
.dd {
padding: 15px;
border: 1px solid @hairline;
border-radius: 16px;
background: @canvas;
}
.dd.is-open {
border-color: @wineSoft;
}
/* ── Mechanics (same in both apps) ──────────────────────────── */
// One curve for the whole collapse: the iOS sheet curve the app's sheets use —
// quick off the mark, soft landing.
@dd-ease: cubic-bezier(0.32, 0.72, 0, 1);
@dd-time: 0.34s;
.dd__head {
display: flex;
@@ -48,50 +67,96 @@ const show = ref(false)
gap: 12px;
width: 100%;
border: 0;
padding: 0;
cursor: pointer;
background: none;
font-family: inherit;
text-align: start;
-webkit-tap-highlight-color: transparent;
}
.dd__title {
margin: 0;
font-size: 13px;
}
.dd__chevron {
flex: 0 0 auto;
display: grid;
place-items: center;
transition: transform @dd-time @dd-ease, color 0.2s ease, background-color 0.2s ease;
}
.dd.is-open .dd__chevron {
transform: rotate(180deg);
}
.dd__panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows @dd-time @dd-ease;
}
.dd.is-open .dd__panel {
grid-template-rows: 1fr;
}
// The row's content box: `min-height: 0` is what lets the 0fr row actually
// shrink below the content's height, and the clip hides what is outside it.
.dd__clip {
min-height: 0;
overflow: hidden;
}
// Fades and settles in a touch behind the height, and out a touch ahead of it,
// so the content never shows half-cut at the moving edge.
.dd__body {
opacity: 0;
transform: translateY(-6px);
transition: opacity 0.18s ease, transform @dd-time @dd-ease;
}
.dd.is-open .dd__body {
opacity: 1;
transform: none;
transition: opacity 0.24s ease 0.06s, transform @dd-time @dd-ease;
}
@media (prefers-reduced-motion: reduce) {
.dd__panel,
.dd__body,
.dd__chevron {
transition: none !important;
}
}
/* ── Look: user-app-v2 ──────────────────────────────────────── */
// A borderless @hairlineSoft panel, the detail page's spec-table radius; the
// chevron sits in a small white squircle that fills @ink when open, the way
// the app's chips do when chosen.
.dd {
border-radius: 22px;
background: @hairlineSoft;
}
.dd__head {
padding: 14px 16px;
}
.dd__title {
font-size: 14px;
font-weight: 700;
color: @ink;
}
.dd__chevron {
flex: 0 0 auto;
color: @inkFaint;
transition: transform 0.2s ease;
width: 28px;
height: 28px;
border-radius: 9px;
color: @inkMuted;
background: @canvas;
}
.dd.is-open .dd__chevron {
transform: rotate(180deg);
color: @wine;
color: @onInk;
background: @ink;
}
.dd__body {
overflow: hidden;
padding-top: 15px;
}
</style>
<style lang="less">
.drop-enter-active {
animation: drop 0.2s cubic-bezier(0.33, 1, 0.68, 1);
}
.drop-leave-to {
animation: drop 0.2s cubic-bezier(0.33, 1, 0.68, 1) reverse;
}
@keyframes drop {
0% {
max-height: 0;
}
100% {
max-height: 240px;
}
padding: 0 16px 16px;
}
</style>
+108 -211
View File
@@ -1,14 +1,26 @@
<!--
Floating "island" action sheet: the app's confirm/notice modal.
The app's confirm/notice action sheet (logout, place order, sign in, update,
done).
Replaces DialogVant / DialogVantOneButton (centered Vant dialogs whose
content was poured into img/title/button slots). The sheet rises from the
bottom but stays inset from the screen edges with rounded corners all
round, the same island shape as the ant-Drawer `island-sheet` in
style.less, so the two kinds of sheet read as one family.
content was poured into img/title/button slots).
Structured props instead of slots for the buttons, so every call site gets
the same button styling without passing its own markup and color classes:
RESTYLED into user-app-v2's own design language. It used to be a floating
island inset from the screen edges, with a tinted dot-grid header, dashed
rings, twinkling sparks, gradient buttons with a sheen and a per-sheet
colour (navy / gold / green) — none of which appears anywhere else in this
app. It now uses the same parts as the rest of v2:
- a white sheet attached to the bottom edge, with the 32px top radius and
grip of the product detail sheet and the search filter sheet;
- the illustration centred on a borderless @hairlineSoft panel — the
ProductPanel / catalogue-card treatment of a piece of jewellery;
- a centred title and description under it, as on those cards;
- the detail page's action bar: an outlined pill beside a filled pill.
The filled pill is @ink, or @wine for the destructive tone (logout).
The component name, props, events and illustrations are unchanged, so no
call site had to change.
Usage:
<IslandSheet
v-model:show="open"
@@ -21,18 +33,18 @@
/>
- `art` picks an illustration from SheetArt, and with it the sheet's tone
(header wash + primary button); `tone` overrides it. An `#art` slot can
(the filled button's colour); `tone` overrides it. An `#art` slot can
replace the illustration entirely.
- No `cancel-text` → a single-button sheet.
- No `cancel-text` → a single, full-width button.
- `closeOnClickOverlay` (default true) also governs swipe-down-to-dismiss;
with it off the sheet can only be left through its own buttons, and a
swipe just rubber-bands. No close (×) button: cancel, the backdrop and
the swipe already cover it.
- Performance: every decorative animation is held paused until the slide-in
has finished (`is-ready`, set on Vant's `opened`), so the slide has the
frame budget to itself; the content arrives as one piece, not staggered,
and nothing uses backdrop-filter (blurring the page behind a fading
overlay recomputes every frame and was the main source of jank on phones).
- Performance: the illustration's own animations are held paused until the
slide-in has finished (`is-ready`, set on Vant's `opened`), so the slide has
the frame budget to itself; nothing uses backdrop-filter (blurring the page
behind a fading overlay recomputes every frame and was the main source of
jank on phones).
- Cancel closes the sheet itself and emits `cancel`; confirm only emits
`confirm` — the caller decides whether it closes (e.g. a redirect).
-->
@@ -50,11 +62,10 @@
@update:show="emit('update:show', $event)"
@opened="ready = true"
@closed="onClosed"
@click="onGapClick"
>
<div
class="iSheet__card"
:class="[`iSheet--${resolvedTone}`, { 'is-dragging': dragging, 'is-ready': ready }]"
:class="[`iSheet--${resolvedTone}`, { 'is-dragging': dragging, 'is-ready': ready, 'is-single': !cancelText }]"
:style="cardStyle"
dir="rtl"
role="dialog"
@@ -64,11 +75,9 @@
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<div class="iSheet__stage">
<span class="iSheet__handle" />
<span class="iSheet__ring iSheet__ring--outer" />
<span class="iSheet__ring iSheet__ring--inner" />
<span v-for="n in 4" :key="n" class="iSheet__spark" :class="`iSheet__spark--${n}`" />
<span class="iSheet__grip" aria-hidden="true"></span>
<div class="iSheet__panel">
<div class="iSheet__art">
<slot name="art">
<SheetArt v-if="art" :name="art" />
@@ -79,13 +88,15 @@
<h3 class="iSheet__title">{{ title }}</h3>
<p v-if="description" class="iSheet__desc">{{ description }}</p>
<!-- The detail page's bar order: the secondary pill first in markup, so
RTL lays it on the right, and the filled pill fills the rest. -->
<div class="iSheet__actions">
<button type="button" class="iSheet__btn iSheet__btn--primary" @click="emit('confirm')">
{{ confirmText }}
</button>
<button v-if="cancelText" type="button" class="iSheet__btn iSheet__btn--ghost" @click="cancel">
{{ cancelText }}
</button>
<button type="button" class="iSheet__btn iSheet__btn--primary" @click="emit('confirm')">
{{ confirmText }}
</button>
</div>
</div>
</Popup>
@@ -96,6 +107,8 @@ import { computed, ref } from 'vue'
import { Popup } from 'vant'
import SheetArt, { type SheetArtName } from './SheetArt.vue'
// Kept as the original five for API compatibility; v2 renders them as two
// looks — destructive (wine) and everything else (ink). See the styles.
type Tone = 'ink' | 'wine' | 'navy' | 'gold' | 'green'
const props = withDefaults(
@@ -125,7 +138,6 @@ const artTone: Record<SheetArtName, Tone> = {
update: 'navy',
success: 'green',
}
const resolvedTone = computed<Tone>(() => props.tone ?? (props.art ? artTone[props.art] : 'ink'))
const ready = ref(false)
@@ -144,12 +156,6 @@ function cancel() {
close()
}
// The popup is a full-width transparent strip with the card inset inside it;
// a tap in that inset gap is visually a tap on the backdrop, so treat it as one.
function onGapClick(e: MouseEvent) {
if (props.closeOnClickOverlay && e.target === e.currentTarget) close()
}
// Swipe down to dismiss. Only the card moves while dragging; on release it
// either springs back or the popup's own leave transition carries it off
// from wherever the finger left it (reset once fully closed).
@@ -195,14 +201,14 @@ function onTouchEnd() {
<style lang="less">
// Global, not scoped: the popup is teleported to <body>.
// See-through dim, no blur — the same backdrop as the filter sheet.
.iSheet__overlay {
background: rgba(20, 22, 26, 0.48);
background: rgba(20, 22, 26, 0.42);
}
.iSheet.van-popup {
background: transparent;
overflow: visible;
padding: 0 12px calc(12px + env(safe-area-inset-bottom, 0px));
}
// The iOS sheet curve: fast out of the gate, long soft landing. Transform
@@ -211,81 +217,47 @@ function onTouchEnd() {
.iSheet-slide-leave-active {
will-change: transform;
}
.iSheet-slide-enter-active {
transition: transform 0.42s cubic-bezier(0.32, 0.72, 0, 1);
}
.iSheet-slide-leave-active {
transition: transform 0.24s cubic-bezier(0.4, 0, 1, 1);
}
// Hold every decorative animation (the stage's rings and sparks, and the
// illustration's own) until the sheet has landed.
.iSheet__card:not(.is-ready) *,
.iSheet__card:not(.is-ready) *::after {
animation-play-state: paused !important;
}
.iSheet-slide-enter-from,
.iSheet-slide-leave-to {
transform: translate3d(0, 100%, 0);
}
// Tones: one accent per job. --t is the accent, --t-rgb its channels for
// translucent uses, --t-hi the lighter top of the button gradient, --t-wash
// and --t-wash2 the header's two background stops.
.iSheet--ink {
--t: @ink;
--t-rgb: 20, 22, 26;
--t-hi: @inkSoft;
--t-wash: #F7F7F8;
--t-wash2: #ECEDEF;
// Hold the illustration's animations until the sheet has landed.
.iSheet__card:not(.is-ready) *,
.iSheet__card:not(.is-ready) *::after {
animation-play-state: paused !important;
}
// Two looks, one per job: the filled button is @wine for the destructive
// action (logout) and @ink — the app's primary surface — for everything else.
.iSheet__card {
--t: @ink;
--t-on: @onInk;
--t-rgb: 20, 22, 26;
}
.iSheet--wine {
--t: @wine;
--t-on: #fff;
--t-rgb: 122, 27, 51;
--t-hi: @wineSoft;
--t-wash: #FDF6F7;
--t-wash2: #F7E2E7;
}
.iSheet--navy {
--t: @primary;
--t-rgb: 28, 45, 86;
--t-hi: #2D4682;
--t-wash: #F5F7FC;
--t-wash2: #E2E8F4;
}
.iSheet--gold {
--t: @secondary;
--t-rgb: 141, 106, 70;
--t-hi: #C39350;
--t-wash: #FDF9F2;
--t-wash2: #F5E7CF;
}
.iSheet--green {
--t: #15803d;
--t-rgb: 22, 163, 74;
--t-hi: #22a052;
--t-wash: #F4FBF6;
--t-wash2: #D9F1E1;
}
// The detail page's / filter sheet's shell: white, attached to the bottom
// edge, 32px top radius, a centred column on wide screens.
.iSheet__card {
position: relative;
max-width: 440px;
max-width: 768px;
margin: 0 auto;
padding: 8px 8px 12px;
border-radius: 30px;
padding: 8px 16px calc(14px + env(safe-area-inset-bottom, 0px));
border-radius: 32px 32px 0 0;
background: @canvas;
text-align: center;
box-shadow:
0 30px 60px -22px rgba(20, 22, 26, 0.5),
0 0 0 1px rgba(20, 22, 26, 0.04);
box-shadow: 0 -18px 40px -30px rgba(20, 22, 26, 0.55);
transition: transform 0.38s cubic-bezier(0.22, 1, 0.36, 1);
touch-action: none;
@@ -294,84 +266,29 @@ function onTouchEnd() {
}
}
// Header: a tinted window with a faint dot grid that fades out towards the
// edges, two soft rings framing the illustration, and a few twinkles.
.iSheet__stage {
position: relative;
height: 172px;
border-radius: 23px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(130% 95% at 50% 0%, var(--t-wash2) 0%, var(--t-wash) 72%);
&::before {
content: '';
position: absolute;
inset: 0;
background-image: radial-gradient(rgba(var(--t-rgb), 0.13) 1px, transparent 1.3px);
background-size: 14px 14px;
-webkit-mask-image: radial-gradient(closest-side, #000 30%, transparent 100%);
mask-image: radial-gradient(closest-side, #000 30%, transparent 100%);
}
}
.iSheet__handle {
position: absolute;
top: 8px;
left: 50%;
.iSheet__grip {
display: block;
width: 38px;
height: 4px;
margin-left: -19px;
border-radius: 4px;
background: rgba(var(--t-rgb), 0.18);
z-index: 2;
margin: 0 auto 12px;
border-radius: 999px;
background: @hairline;
}
.iSheet__ring {
position: absolute;
top: 50%;
left: 50%;
border-radius: 50%;
pointer-events: none;
&--outer {
width: 196px;
height: 196px;
margin: -98px 0 0 -98px;
border: 1.5px dashed rgba(var(--t-rgb), 0.16);
}
&--inner {
width: 132px;
height: 132px;
margin: -66px 0 0 -66px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.55) 62%, rgba(255, 255, 255, 0) 72%);
box-shadow: 0 0 0 10px rgba(255, 255, 255, 0.35);
animation: iSheet-breathe 3.2s ease-in-out infinite;
}
}
.iSheet__spark {
position: absolute;
width: 10px;
height: 10px;
background: rgba(var(--t-rgb), 0.45);
clip-path: polygon(50% 0, 62% 38%, 100% 50%, 62% 62%, 50% 100%, 38% 62%, 0 50%, 38% 38%);
animation: iSheet-twinkle 2.6s ease-in-out infinite;
&--1 { top: 30px; right: 24%; }
&--2 { top: 62px; left: 19%; width: 7px; height: 7px; animation-delay: 0.7s; }
&--3 { bottom: 32px; right: 17%; width: 8px; height: 8px; animation-delay: 1.3s; }
&--4 { bottom: 26px; left: 27%; width: 6px; height: 6px; animation-delay: 1.9s; }
// The ProductPanel treatment: the subject centred on a borderless soft-grey
// panel at the catalogue cards' 32px radius (one step in, 26px, since it sits
// inside the sheet's own 32px corner).
.iSheet__panel {
height: 150px;
display: grid;
place-items: center;
border-radius: 26px;
background: @hairlineSoft;
}
.iSheet__art {
position: relative;
z-index: 1;
width: 128px;
height: 128px;
width: 112px;
height: 112px;
> img,
> svg,
@@ -383,18 +300,19 @@ function onTouchEnd() {
}
// Every element sets its own font-size: style.less's `*` rule pins each one
// to 14px, so nothing inherits.
// to 14px, so nothing inherits. Sizes are the detail page's: 16px for the
// heading (its section titles), 12.5px / line-height 2 for body copy.
.iSheet__title {
margin: 20px 16px 0;
font-size: 16.5px;
font-weight: 800;
margin: 18px 8px 0;
font-size: 16px;
font-weight: 700;
line-height: 1.75;
color: @ink;
}
.iSheet__desc {
margin: 6px 20px 0;
font-size: 13px;
margin: 6px 12px 0;
font-size: 12.5px;
font-weight: 400;
line-height: 2;
color: @inkMuted;
@@ -402,9 +320,9 @@ function onTouchEnd() {
.iSheet__actions {
display: flex;
flex-direction: column;
gap: 6px;
margin: 22px 4px 0;
align-items: center;
gap: 10px;
margin-top: 20px;
}
// Scoped under the card on purpose: Tailwind's preflight resets
@@ -413,64 +331,44 @@ function onTouchEnd() {
// imported before main.ts loads tailwind.css — so a bare `.iSheet__btn--primary`
// loses on source order and the button renders white-on-white.
.iSheet__card .iSheet__btn {
position: relative;
height: 52px;
border: 0;
border-radius: 18px;
font-size: 14.5px;
font-weight: 700;
height: 48px;
border-radius: 999px;
font-family: inherit;
font-size: 13.5px;
font-weight: 800;
white-space: nowrap;
cursor: pointer;
overflow: hidden;
-webkit-tap-highlight-color: transparent;
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
transition: transform 0.18s ease, background-color 0.18s ease;
&:active {
transform: scale(0.97);
}
&--primary {
color: #fff;
background: linear-gradient(180deg, var(--t-hi) 0%, var(--t) 100%);
box-shadow:
0 12px 24px -12px rgba(var(--t-rgb), 0.75),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
// A single sheen across the button once the sheet has settled.
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 40%;
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, 0.28), transparent);
transform: translateX(-130%) skewX(-18deg);
animation: iSheet-sheen 0.9s ease-in-out 0.15s both;
}
transform: scale(0.98);
}
// The detail page's outlined pill.
&--ghost {
color: @inkMuted;
background: transparent;
flex: 0 0 auto;
min-width: 108px;
padding: 0 22px;
color: @ink;
background: @canvas;
border: 1px solid @hairline;
&:active {
background: @hairlineSoft;
}
}
}
@keyframes iSheet-breathe {
50% { transform: scale(1.06); }
}
@keyframes iSheet-twinkle {
0%, 100% { transform: scale(0.4) rotate(0deg); opacity: 0.2; }
50% { transform: scale(1) rotate(45deg); opacity: 1; }
}
@keyframes iSheet-sheen {
from { transform: translateX(-130%) skewX(-18deg); }
to { transform: translateX(300%) skewX(-18deg); }
// The detail page's primary pill.
&--primary {
flex: 1;
min-width: 0;
padding: 0 18px;
border: 0;
color: var(--t-on);
background: var(--t);
box-shadow: 0 14px 30px -18px rgba(var(--t-rgb), 0.9);
}
}
@media (prefers-reduced-motion: reduce) {
@@ -478,7 +376,6 @@ function onTouchEnd() {
.iSheet-slide-leave-active {
transition-duration: 0.01s;
}
.iSheet__card *,
.iSheet__card *::after {
animation: none !important;
@@ -74,18 +74,18 @@ const indicatorOffset = computed(
// brightens whatever scrolls beneath it. A light spot sits top-left, a
// faint wine tint pools bottom-right, and the drop shadow lifts the pane.
background:
radial-gradient(120% 140% at 0% 0%, fade(@canvas, 55%) 0%, fade(@canvas, 0%) 55%),
radial-gradient(90% 120% at 100% 100%, fade(@wine, 7%) 0%, fade(@wine, 0%) 60%),
linear-gradient(160deg, fade(@canvas, 42%) 0%, fade(@canvas, 18%) 100%);
radial-gradient(120% 140% at 0% 0%, alpha(@canvas, 55%) 0%, alpha(@canvas, 0%) 55%),
radial-gradient(90% 120% at 100% 100%, alpha(@wine, 7%) 0%, alpha(@wine, 0%) 60%),
linear-gradient(160deg, alpha(@canvas, 42%) 0%, alpha(@canvas, 18%) 100%);
-webkit-backdrop-filter: blur(26px) saturate(210%) brightness(1.06);
backdrop-filter: blur(26px) saturate(210%) brightness(1.06);
border: 0;
box-shadow:
inset 0 1px 1px fade(@canvas, 90%),
inset 0 -8px 16px -10px fade(@canvas, 60%),
inset 0 0 0 0.5px fade(@canvas, 35%),
inset 0 1px 1px alpha(@canvas, 90%),
inset 0 -8px 16px -10px alpha(@canvas, 60%),
inset 0 0 0 0.5px alpha(@canvas, 35%),
0 18px 40px -14px rgba(20, 22, 26, 0.32),
0 6px 14px -8px fade(@wine, 22%),
0 6px 14px -8px alpha(@wine, 22%),
0 1px 3px rgba(20, 22, 26, 0.06);
// Curved specular gloss across the top, like light on a bent pane.
@@ -95,7 +95,7 @@ const indicatorOffset = computed(
inset: 1px 8% auto 8%;
height: 55%;
border-radius: 20px 20px 50% 50% / 20px 20px 100% 100%;
background: linear-gradient(180deg, fade(@canvas, 60%) 0%, fade(@canvas, 0%) 100%);
background: linear-gradient(180deg, alpha(@canvas, 60%) 0%, alpha(@canvas, 0%) 100%);
pointer-events: none;
}
@@ -109,10 +109,10 @@ const indicatorOffset = computed(
border-radius: inherit;
background: linear-gradient(
145deg,
fade(@canvas, 100%) 0%,
fade(@canvas, 35%) 35%,
fade(@canvas, 10%) 65%,
fade(@canvas, 70%) 100%
alpha(@canvas, 100%) 0%,
alpha(@canvas, 35%) 35%,
alpha(@canvas, 10%) 65%,
alpha(@canvas, 70%) 100%
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
@@ -126,7 +126,7 @@ const indicatorOffset = computed(
// Browsers without backdrop-filter get a mostly opaque fill so it stays legible.
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.tabbar__inner {
background: fade(@canvas, 94%);
background: alpha(@canvas, 94%);
}
}
@@ -60,7 +60,7 @@ function onBack() {
left: 0;
right: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.88);
background: alpha(@canvas, 88%);
backdrop-filter: blur(14px);
border-bottom: 1px solid @hairline;
}
@@ -29,7 +29,7 @@
<span
v-if="item.duration > 0"
class="toast__progress"
class="toast__progress keep-motion"
:style="{ animationDuration: `${item.duration}ms` }"
@animationend="emit('close')"
/>
@@ -132,14 +132,14 @@ function onUp() {
// Same glass recipe as the bottom nav, so the two read as one system.
background:
radial-gradient(120% 160% at 0% 0%, fade(@canvas, 60%) 0%, fade(@canvas, 0%) 55%),
radial-gradient(80% 140% at 100% 100%, fade(@ink, 4%) 0%, fade(@ink, 0%) 60%),
linear-gradient(160deg, fade(@canvas, 72%) 0%, fade(@canvas, 50%) 100%);
radial-gradient(120% 160% at 0% 0%, alpha(@canvas, 60%) 0%, alpha(@canvas, 0%) 55%),
radial-gradient(80% 140% at 100% 100%, alpha(@ink, 4%) 0%, alpha(@ink, 0%) 60%),
linear-gradient(160deg, alpha(@canvas, 72%) 0%, alpha(@canvas, 50%) 100%);
-webkit-backdrop-filter: blur(24px) saturate(200%) brightness(1.05);
backdrop-filter: blur(24px) saturate(200%) brightness(1.05);
box-shadow:
inset 0 1px 1px fade(@canvas, 90%),
inset 0 0 0 0.5px fade(@canvas, 40%),
inset 0 1px 1px alpha(@canvas, 90%),
inset 0 0 0 0.5px alpha(@canvas, 40%),
0 18px 40px -16px rgba(20, 22, 26, 0.35),
0 1px 3px rgba(20, 22, 26, 0.06);
@@ -152,10 +152,10 @@ function onUp() {
border-radius: inherit;
background: linear-gradient(
145deg,
fade(@canvas, 100%) 0%,
fade(@canvas, 30%) 40%,
fade(@canvas, 10%) 70%,
fade(@canvas, 70%) 100%
alpha(@canvas, 100%) 0%,
alpha(@canvas, 30%) 40%,
alpha(@canvas, 10%) 70%,
alpha(@canvas, 70%) 100%
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
@@ -189,7 +189,7 @@ function onUp() {
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.toast {
background: fade(@canvas, 96%);
background: alpha(@canvas, 96%);
}
}
@@ -0,0 +1,63 @@
<!--
The visual half of an on/off switch: a track and a knob. Deliberately not a
control on its own — the row that holds it is the <button role="switch">,
so the whole row is one tap target and screen readers announce one switch
(see views/user/AppearanceView.vue).
STRUCTURE IS SHARED, LOOK IS NOT: identical markup and script in user-app
and user-app-v2; only the "Look" block differs, per app.
-->
<template>
<span class="tsw" :class="{ 'is-on': on }" aria-hidden="true">
<span class="tsw__knob"></span>
</span>
</template>
<script setup lang="ts">
defineProps<{ on: boolean }>()
</script>
<style scoped lang="less">
/* ── Mechanics (same in both apps) ──────────────────────────── */
.tsw {
position: relative;
flex: 0 0 auto;
display: inline-block;
width: 46px;
height: 28px;
border-radius: 999px;
transition: background-color 0.22s ease;
}
.tsw__knob {
position: absolute;
top: 3px;
// RTL page: "off" sits on the right (start), "on" slides to the left (end).
inset-inline-start: 3px;
width: 22px;
height: 22px;
border-radius: 50%;
box-shadow: 0 2px 6px -1px rgba(20, 22, 26, 0.35);
transition: transform 0.26s cubic-bezier(0.32, 0.72, 0, 1), background-color 0.22s ease;
}
.tsw.is-on .tsw__knob {
transform: translateX(-18px);
}
/* ── Look: user-app-v2 ──────────────────────────────────────── */
// The app's chosen state is a filled @ink (chips, CTAs): the track fills @ink
// and the knob takes @onInk, so it stays a contrasting pair in both themes.
.tsw {
background: @hairline;
}
.tsw__knob {
background: #fff;
}
.tsw.is-on {
background: @ink;
}
.tsw.is-on .tsw__knob {
background: @onInk;
}
</style>
@@ -128,11 +128,11 @@ async function onPick(event: Event) {
}
.is-done .siu__box {
border-bottom-color: #16a34a;
border-bottom-color: @green;
}
.is-error .siu__box {
border-bottom-color: #dc2626;
border-bottom-color: @red;
}
.siu__preview {
@@ -167,12 +167,12 @@ async function onPick(event: Event) {
.siu__badge {
flex: 0 0 auto;
display: flex;
color: #16a34a;
color: @green;
}
.siu__error {
margin: 7px 2px 0;
font-size: 12px;
color: #dc2626;
color: @red;
}
</style>
@@ -4,22 +4,40 @@
// so this fetches the full list and finds the matching product client-side.
// Wasteful for a large catalog, but honest: no fabricated endpoint, no silent
// behavior change.
//
// `initial` seeds the page with the ProductSummary the tapped card already
// holds — the same shape, from the same endpoint — so the detail view renders
// complete on its first frame instead of mounting blank and jumping when the
// fetch lands. The fetch still runs, silently, and only replaces the product
// if the server's copy actually differs (stock moved since the list loaded).
//
// The silent refresh waits until the opening morph (ProductMorph.vue, ≤420ms)
// is over: the response is the whole catalogue, and parsing it — or swapping
// the product and re-rendering — mid-animation drops frames.
import { ref } from 'vue'
import { api, type ProductSummary } from '../api/client'
export function useProductDetail(id: string) {
const SEEDED_REFRESH_DELAY_MS = 600
export function useProductDetail(id: string, initial?: ProductSummary | null) {
const loading = ref(false)
const flag = ref(false)
const flag = ref(Boolean(initial))
const notFound = ref(false)
const productRef = ref<ProductSummary | null>(null)
const productRef = ref<ProductSummary | null>(initial ?? null)
const load = async () => {
loading.value = true
const seeded = productRef.value !== null
if (seeded) await new Promise((resolve) => setTimeout(resolve, SEEDED_REFRESH_DELAY_MS))
else loading.value = true
try {
const products = await api.listProducts()
const found = products.find((p) => p.id === id) ?? null
productRef.value = found
notFound.value = !found
if (seeded) {
if (found && JSON.stringify(found) !== JSON.stringify(productRef.value)) productRef.value = found
} else {
productRef.value = found
notFound.value = !found
}
flag.value = true
} catch (e) {
console.log(e)
@@ -0,0 +1,43 @@
// Opens the product detail as a morph out of the card that was tapped, in
// place of the old full-height bottom Drawer (which slid up from off-screen and
// then jumped once the detail fetch landed). One overlay for the whole app —
// components/product/ProductMorph.vue, mounted once in App.vue — instead of a
// Drawer per card: ProductCard used to carry its own, so every catalogue grid
// rendered one per product.
//
// Callers only say WHICH product. The card it came from is found from the tap
// itself: every card root is marked `data-product-origin`, and the last
// pointerdown is remembered here, so the tap's own target leads back to its
// card without each emit chain (ProductPanel → ProductCarousel → HomeView) having
// to thread an element through. A caller that does hold the element can pass
// it; with neither (keyboard activation), the overlay falls back to a plain
// rise-and-fade instead of a morph.
import { shallowRef } from 'vue'
import type { ProductSummary } from '../api/client'
export interface ProductMorphRequest {
product: ProductSummary
origin: HTMLElement | null
}
export const productMorph = shallowRef<ProductMorphRequest | null>(null)
let lastPointerTarget: Element | null = null
if (typeof document !== 'undefined') {
document.addEventListener(
'pointerdown',
(e) => {
lastPointerTarget = e.target instanceof Element ? e.target : null
},
{ capture: true, passive: true },
)
}
export function openProduct(product: ProductSummary, origin?: HTMLElement | null) {
const fromTap = lastPointerTarget?.closest<HTMLElement>('[data-product-origin]') ?? null
productMorph.value = { product, origin: origin ?? fromTap }
}
export function closeProduct() {
productMorph.value = null
}
+7
View File
@@ -5,12 +5,16 @@ import App from './App.vue';
import { router } from './router';
import { setUnauthorizedHandler } from './api/client';
import { useSessionStore } from './stores/session';
import { useAppearanceStore } from './stores/appearanceStore';
import Container from './components/ui/Container.vue';
import Vector from './assets/img/Vector.svg';
// Global styles, ported from legacy henza_pwa/src/main.ts — same import order
// (ant-design-vue's own theme first, then our Less overrides on top). Legacy
// imported 'vant/lib/index.css' twice (a dead duplicate) — imported once here.
// The light/dark values behind the theme tokens — first, so every stylesheet
// below resolves var(--c-…) against it. See themeConfig/appearance.ts.
import 'virtual:palette.css';
import './assets/styles/tailwind.css';
import 'ant-design-vue/dist/antd.less';
import './assets/styles/style.less';
@@ -19,6 +23,9 @@ import 'vant/lib/index.css';
const app = createApp(App);
app.use(createPinia());
// Theme + reduced motion. index.html already applied the saved classes before
// first paint; this keeps them in sync (and follows the OS while on "system").
useAppearanceStore().init();
app.use(router);
// A 401 from the API means the customer token is gone or expired. Clear the
+2
View File
@@ -37,6 +37,8 @@ export const router = createRouter({
},
{ path: '/profile', name: 'profileData', component: () => import('../views/user/ProfileView.vue'), meta: { requiresAuth: true } },
{ path: '/favorites', name: 'favoritesList', component: () => import('../views/user/FavoritesView.vue'), meta: { requiresAuth: true } },
// Theme + reduced motion. Device preferences, so no requiresAuth.
{ path: '/settings/appearance', name: 'appearanceSettings', component: () => import('../views/user/AppearanceView.vue') },
// D21 — the sales expert's two screens. Reference paths and names kept
// (henza_pwa router/index.ts:171-188). requiresExpert is enforced by the
// guard below AND by the server, which scopes both endpoints on the
@@ -0,0 +1,120 @@
// Appearance preferences: colour theme and reduced motion.
//
// Persisted in one localStorage key (the cartStore pattern). The same key is
// read by the tiny boot script in index.html, which applies the classes before
// the first paint — so a dark-mode user never sees a white flash while the
// bundle loads. This store re-applies them once the app is up and keeps them
// in sync from then on.
//
// What the classes on <html> drive:
// theme-dark the dark palette (virtual:palette.css, see
// themeConfig/appearance.ts) + color-scheme: dark
// van-theme-dark vant 4's own dark CSS variables
// motion-reduced the "fast & light" motion rules in style.less, plus the
// JS-driven motion that reads isReducedMotion (ProductMorph,
// carousel autoplay)
//
// Kept identical in user-app and user-app-v2.
import { defineStore } from 'pinia'
export type ThemeChoice = 'system' | 'light' | 'dark'
interface Stored {
theme: ThemeChoice
reduceMotion: boolean
}
export const APPEARANCE_STORAGE_KEY = 'gold-bank:appearance'
const darkQuery = () => (typeof matchMedia !== 'undefined' ? matchMedia('(prefers-color-scheme: dark)') : null)
const motionQuery = () => (typeof matchMedia !== 'undefined' ? matchMedia('(prefers-reduced-motion: reduce)') : null)
function load(): Stored {
// First run: theme follows the system; motion follows the OS's own
// "reduce motion" accessibility setting.
const fallback: Stored = { theme: 'system', reduceMotion: !!motionQuery()?.matches }
try {
const raw = localStorage.getItem(APPEARANCE_STORAGE_KEY)
if (!raw) return fallback
const parsed = JSON.parse(raw) as Partial<Stored>
return {
theme: parsed.theme === 'light' || parsed.theme === 'dark' || parsed.theme === 'system' ? parsed.theme : fallback.theme,
reduceMotion: typeof parsed.reduceMotion === 'boolean' ? parsed.reduceMotion : fallback.reduceMotion,
}
} catch {
return fallback
}
}
function persist(value: Stored) {
try {
localStorage.setItem(APPEARANCE_STORAGE_KEY, JSON.stringify(value))
} catch {
// Private mode / storage full: the choice still applies for this session.
}
}
// Status-bar / browser chrome colour. Light keeps whatever index.html ships
// (the brand colour, unchanged); dark uses the dark canvas.
const DARK_THEME_COLOR = '#15171A'
let lightThemeColor: string | null = null
export const useAppearanceStore = defineStore('appearance', {
state: () => ({
...load(),
systemDark: !!darkQuery()?.matches,
}),
getters: {
resolvedTheme: (s): 'light' | 'dark' => (s.theme === 'system' ? (s.systemDark ? 'dark' : 'light') : s.theme),
isReducedMotion: (s) => s.reduceMotion,
},
actions: {
init() {
const q = darkQuery()
q?.addEventListener?.('change', (e) => {
this.systemDark = e.matches
if (this.theme === 'system') this.apply()
})
this.apply(false)
},
setTheme(theme: ThemeChoice) {
if (theme === this.theme) return
this.theme = theme
this.save()
this.apply()
},
setReduceMotion(on: boolean) {
if (on === this.reduceMotion) return
this.reduceMotion = on
this.save()
this.apply(false)
},
save() {
persist({ theme: this.theme, reduceMotion: this.reduceMotion })
},
/**
* Writes the classes onto <html>. `quiet` (default) suppresses every CSS
* transition for the frame the palette flips in: otherwise each element
* with a colour transition would animate from the old theme to the new
* one at once — hundreds of simultaneous transitions, a visible stutter.
*/
apply(quiet = true) {
const root = document.documentElement
const dark = this.resolvedTheme === 'dark'
if (quiet) root.classList.add('theme-switching')
root.classList.toggle('theme-dark', dark)
root.classList.toggle('van-theme-dark', dark)
root.classList.toggle('motion-reduced', this.reduceMotion)
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) {
lightThemeColor ??= meta.getAttribute('data-light') ?? meta.getAttribute('content')
meta.setAttribute('content', dark ? DARK_THEME_COLOR : lightThemeColor ?? '')
}
if (quiet) {
// Two frames: one for the new colours to be computed with transitions
// off, one for them to be painted, then transitions come back.
requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove('theme-switching')))
}
},
},
})
+9 -4
View File
@@ -18,7 +18,12 @@ export interface FiltersQuery {
page: number
}
const defaultQuery: FiltersQuery = {
// A factory, not a shared object. FIXED: this used to be one const that both
// the initial state and reset() spread-copied — a shallow copy, so every
// query shared the SAME arrays. Filters.vue pushed brand ids into
// `query.brands` in place, which wrote them into the default itself, and from
// then on reset() "restored" the selected brands instead of clearing them.
const defaultQuery = (): FiltersQuery => ({
colors: [],
brands: [],
ciny_id: [],
@@ -28,13 +33,13 @@ const defaultQuery: FiltersQuery = {
text: undefined,
cats: undefined,
page: 0,
}
})
export const useFiltersStore = defineStore('filters', {
state: () => ({ query: { ...defaultQuery } as FiltersQuery }),
state: () => ({ query: defaultQuery() }),
actions: {
reset() {
this.query = { ...defaultQuery }
this.query = defaultQuery()
},
},
})
+10 -5
View File
@@ -20,12 +20,17 @@ export const ORDER_STATUS_LABELS: Record<string, string> = {
// colored sts 1,3,4,5,6 (never sts=0 or sts=2), an existing gap preserved here
// for the directly-analogous statuses (ADMIN_APPROVED, SUSPENDED, CANCELLED
// stay uncolored, same as legacy's untouched sts=0/2).
//
// Backgrounds are the font colour at 12% alpha (the trailing 1F), not the
// opaque pale tints they used to be: a translucent wash reads the same on a
// white card and stays a soft tint on a dark one, where #EFF6FF & co. were
// glaring light patches.
export const ORDER_STATUS_COLORS: Record<string, { font: string; background: string }> = {
PLACED: { font: '#3B82F6', background: '#EFF6FF' },
SOURCING: { font: '#EAB308', background: '#FEFCE8' },
PREPARING: { font: '#F97316', background: '#FFF7ED' },
DELIVERED_TO_AGENT: { font: '#06B6D4', background: '#ECFEFF' },
DELIVERED_TO_CUSTOMER: { font: '#22C55E', background: '#F0FDF4' },
PLACED: { font: '#3B82F6', background: '#3B82F61F' },
SOURCING: { font: '#EAB308', background: '#EAB3081F' },
PREPARING: { font: '#F97316', background: '#F973161F' },
DELIVERED_TO_AGENT: { font: '#06B6D4', background: '#06B6D41F' },
DELIVERED_TO_CUSTOMER: { font: '#22C55E', background: '#22C55E1F' },
};
// Ported from legacy Orders.vue's 4-step tracker (`steps`/`setCurrentStep`).
+6 -6
View File
@@ -257,7 +257,7 @@ onMounted(async () => {
<style scoped lang="less">
.cart {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -337,7 +337,7 @@ onMounted(async () => {
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
color: #fff;
color: @onInk;
background: @ink;
}
@@ -393,7 +393,7 @@ onMounted(async () => {
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
color: #fff;
color: @onInk;
background: @ink;
}
@@ -403,7 +403,7 @@ onMounted(async () => {
left: 0;
right: 0;
z-index: 5;
background: rgba(255, 255, 255, 0.92);
background: alpha(@canvas, 92%);
backdrop-filter: blur(14px);
border-top: 1px solid @hairline;
padding-bottom: 66px;
@@ -453,7 +453,7 @@ onMounted(async () => {
font-family: inherit;
font-size: 13px;
font-weight: 700;
color: #fff;
color: @onInk;
background: @ink;
transition: transform 0.15s ease;
}
@@ -482,7 +482,7 @@ onMounted(async () => {
font-family: inherit;
font-size: 13px;
font-weight: 700;
color: #fff;
color: @onInk;
background: @ink;
}
</style>
+8 -21
View File
@@ -76,27 +76,15 @@
</section>
</container>
</PullRefresh>
<Drawer
v-model:visible="productDrawer"
placement="bottom"
:closable="false"
class="rtl"
height="100vh"
:bodyStyle="{ padding: 0, position: 'relative' }"
>
<SingleProduct v-if="activeProductId" :id="activeProductId" @closeDrawer="productDrawer = false" />
</Drawer>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import Icon from '../components/icon/Icon.vue'
import SingleProduct from '../components/product/SingleProduct.vue'
import { openProduct as openProductMorph } from '../composables/useProductMorph'
import RateStrip from '../components/home/RateStrip.vue'
import HeroCarousel from '../components/home/HeroCarousel.vue'
import type { HeroSlide } from '../components/home/HeroCarousel.vue'
@@ -128,8 +116,6 @@ const goldPrice = ref<GoldPriceQuote | null>(null)
// wired up; without it, slides configured in the admin panel never showed here.
const adminSlides = ref<AdminSlide[] | null>(null)
const productDrawer = ref(false)
const activeProductId = ref<string | null>(null)
// First-paint entrance. Flipped once, after the first data load, so the
// sections settle into place instead of appearing all at once; pull-to-refresh
@@ -189,9 +175,10 @@ function productImageForCategory(categoryId: string): string | undefined {
return match?.images?.[0]?.url
}
// Opens as a morph out of the tapped card (ProductMorph.vue, mounted in
// App.vue) — the card is found from the tap, so the emit chain stays as is.
function openProduct(product: ProductSummary) {
activeProductId.value = product.id
productDrawer.value = true
openProductMorph(product)
}
function onCategory(cat: HomeCategory) {
@@ -266,7 +253,7 @@ onMounted(async () => {
<style scoped lang="less">
.home {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -276,13 +263,13 @@ onMounted(async () => {
left: 0;
right: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.82);
background: alpha(@canvas, 82%);
backdrop-filter: blur(18px) saturate(140%);
// A hairline rule cut a hard line across the page. A very soft fade under
// the bar separates it from the content that scrolls beneath without
// drawing an edge.
border-bottom: 1px solid fade(@primary, 5%);
box-shadow: 0 8px 20px -18px fade(@primary, 55%);
border-bottom: 1px solid alpha(@primary, 5%);
box-shadow: 0 8px 20px -18px alpha(@primary, 55%);
}
.home__barInner {
+1 -1
View File
@@ -82,7 +82,7 @@ const pull = async () => {
<style scoped lang="less">
.newest {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -316,7 +316,7 @@ onBeforeUnmount(() => observer?.disconnect())
font-size: 11.5px;
font-weight: 600;
color: #06b6d4;
background: #ecfeff;
background: fade(#06b6d4, 12%);
white-space: nowrap;
}
@@ -419,7 +419,7 @@ onBeforeUnmount(() => observer?.disconnect())
border: none;
border-radius: 14px;
background: @ink;
color: #fff;
color: @onInk;
font-family: inherit;
font-size: 14px;
font-weight: 700;
@@ -252,13 +252,13 @@ onMounted(load)
}
.cch__state.is-on {
color: #16a34a;
background: #dcfce7;
color: @green;
background: alpha(@green, 14%);
}
.cch__state.is-off {
color: #dc2626;
background: #fee2e2;
color: @red;
background: alpha(@red, 12%);
}
.cch__facts {
@@ -350,7 +350,7 @@ onMounted(load)
border: none;
border-radius: 14px;
background: @ink;
color: #fff;
color: @onInk;
font-family: inherit;
font-size: 14px;
font-weight: 700;
@@ -189,10 +189,10 @@ function rial(value: string): string {
}
.is-in {
color: #16a34a;
color: @green;
}
.is-out {
color: #dc2626;
color: @red;
}
</style>
@@ -366,11 +366,11 @@ onBeforeUnmount(() => observer?.disconnect())
}
.is-in {
color: #16a34a;
color: @green;
}
.is-out {
color: #dc2626;
color: @red;
}
.rep__more,
@@ -67,7 +67,7 @@ onMounted(async () => {
<style scoped lang="less">
.coll {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -14,12 +14,12 @@
-->
<template>
<div class="search">
<PageBar :title="showFilter ? 'فیلترها' : 'جستجو'" :back="showFilter" @click.capture="onBarClick">
<PageBar title="جستجو">
<template #below>
<div class="search__barExtra">
<SearchInput @search="search" place-holder="جستجوی دسته بندی، محصولات و ..." />
<div v-if="!showFilter" class="search__chips no-scrollbar">
<div class="search__chips no-scrollbar">
<button
type="button"
class="search__chip search__chip--filter"
@@ -54,15 +54,7 @@
</PageBar>
<Spinner :spinning="loadingPage" tip="درحال دریافت اطلاعات">
<Filters
v-model:filterKeys="filterKeys"
v-show="showFilter"
v-model="filters.query"
:products="allProducts"
@change="hideFilters"
/>
<PullRefresh v-show="!showFilter" v-model="refreshing" @refresh="onRefresh">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
@@ -93,12 +85,20 @@
</container>
</PullRefresh>
</Spinner>
<!-- A bottom sheet over a see-through backdrop (teleported to <body>);
see Filters.vue. The result list stays in place underneath. -->
<Filters
v-model:show="showFilter"
v-model:filterKeys="filterKeys"
v-model="filters.query"
:products="allProducts"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
@@ -108,11 +108,9 @@ import Filters from '../../components/search/Filters.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { useFiltersStore } from '../../stores/filtersStore'
import { useNavStore } from '../../stores/navStore'
import { api, type ProductSummary } from '../../api/client'
const filters = useFiltersStore()
const nav = useNavStore()
const loadingPage = ref(false)
const refreshing = ref(false)
@@ -211,50 +209,14 @@ function setCats(id: string | undefined) {
}
function showFilters() {
showFilter.value = true
nav.setNavVisibility(false)
}
function hideFilters(event?: { resetFilters: boolean }) {
showFilter.value = false
nav.setNavVisibility(true)
if (event?.resetFilters) {
filters.reset()
filterKeys.value = []
}
}
// While the filter sheet is open the bar's back button closes the sheet
// rather than leaving the route, matching the legacy "بازگشت" affordance.
function onBarClick(e: MouseEvent) {
if (!showFilter.value) return
const btn = (e.target as HTMLElement)?.closest('button')
if (btn) {
e.stopPropagation()
hideFilters()
}
}
// The filter sheet deliberately hides the bottom tab bar while it is open, so
// its own fixed action bar ("اعمال فیلتر" / "حذف فیلترها") stays clickable —
// the tab bar sits at the same bottom edge with a higher z-index. But leaving
// the page with the sheet still open (browser/swipe back, or tapping a
// collection tile) never ran hideFilters, so that hide leaked: showNav stayed
// false, and on any destination outside the five tab routes the user was left
// with no tab bar at all until a full page refresh reset the store.
//
// Restoring it here rather than in onBeforeUnmount is deliberate: this guard
// runs BEFORE router.afterEach, which then writes the correct visibility for
// the destination and gets the last word. onBeforeUnmount runs after it, and
// would wrongly re-show the bar on full-page routes like /product/:id.
onBeforeRouteLeave(() => {
if (showFilter.value) nav.setNavVisibility(true)
})
onMounted(loadProducts)
</script>
<style scoped lang="less">
.search {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -305,7 +267,7 @@ onMounted(loadProducts)
}
.search__chip.is-on {
color: #fff;
color: @onInk;
background: @ink;
border-color: @ink;
}
@@ -0,0 +1,341 @@
<!--
تنظیمات ظاهری — the colour theme and the "fast & light" motion switch.
Reached from the dashboard; open to signed-out users too (these are device
preferences, not account data). State lives in stores/appearanceStore.ts,
which applies it to <html> immediately — nothing to save or confirm.
The theme previews are drawn with FIXED colours, not theme tokens: each one
has to show what that theme looks like, whichever theme is active now.
STRUCTURE IS SHARED, LOOK IS NOT: template and script are identical in
user-app and user-app-v2; only the "Look" block differs, per app.
-->
<template>
<div class="ap">
<PageBar title="تنظیمات ظاهری" back-to="userPage::hasSubRoute" />
<container class="ap__body">
<!-- ── Theme ───────────────────────────────────────────── -->
<section class="ap__section">
<header class="ap__head">
<h2 id="ap-theme" class="ap__title">پوسته برنامه</h2>
<p class="ap__sub">ظاهر روشن یا تیره، یا هماهنگ با تنظیمات گوشی شما</p>
</header>
<div class="ap__themes" role="radiogroup" aria-labelledby="ap-theme">
<button
v-for="opt in THEMES"
:key="opt.value"
type="button"
role="radio"
class="ap__theme"
:class="{ 'is-on': appearance.theme === opt.value }"
:aria-checked="appearance.theme === opt.value"
@click="appearance.setTheme(opt.value)"
>
<span class="ap__preview" :class="`ap__preview--${opt.value}`" aria-hidden="true">
<span class="ap__pvBar"></span>
<span class="ap__pvCard"></span>
<span class="ap__pvLine"></span>
<span class="ap__pvLine ap__pvLine--short"></span>
<span class="ap__pvBtn"></span>
</span>
<span class="ap__themeLabel">
<Icon :icon="opt.icon" size="15" />
{{ opt.label }}
</span>
<span class="ap__check" aria-hidden="true">
<Icon icon="vuesax-linear:tick-circle" size="18" />
</span>
</button>
</div>
<p v-if="appearance.theme === 'system'" class="ap__note">
الان {{ appearance.resolvedTheme === 'dark' ? 'تیره' : 'روشن' }} است، مطابق تنظیمات گوشی.
</p>
</section>
<!-- ── Motion ──────────────────────────────────────────── -->
<section class="ap__section">
<header class="ap__head">
<h2 class="ap__title">انیمیشن‌ها</h2>
<p class="ap__sub">برای سرعت بیشتر روی گوشی‌های ضعیف‌تر</p>
</header>
<!-- The whole row is the switch: one tap target, one announcement. -->
<button
type="button"
role="switch"
class="ap__row"
:aria-checked="appearance.reduceMotion"
@click="appearance.setReduceMotion(!appearance.reduceMotion)"
>
<span class="ap__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:flash" size="18" />
</span>
<span class="ap__rowText">
<b>حالت سبک و سریع</b>
<small>
حرکت‌های سنگین، افکت‌های شیشه‌ای و اسلاید خودکار خاموش می‌شوند؛ بازخورد کوتاه لمس‌ها و جابه‌جایی‌های سریع
باقی می‌مانند تا برنامه خشک و بی‌روح نشود.
</small>
</span>
<ToggleSwitch :on="appearance.reduceMotion" />
</button>
</section>
</container>
</div>
</template>
<script setup lang="ts">
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import ToggleSwitch from '../../components/ui/ToggleSwitch.vue'
import { useAppearanceStore, type ThemeChoice } from '../../stores/appearanceStore'
const appearance = useAppearanceStore()
const THEMES: { value: ThemeChoice; label: string; icon: string }[] = [
{ value: 'light', label: 'روشن', icon: 'vuesax-linear:sun-1' },
{ value: 'dark', label: 'تیره', icon: 'vuesax-linear:moon' },
{ value: 'system', label: 'مطابق سیستم', icon: 'vuesax-linear:monitor-mobbile' },
]
</script>
<style scoped lang="less">
/* ── Structure (same in both apps) ──────────────────────────── */
.ap {
min-height: 100vh;
}
.ap__body {
padding-top: 76px;
padding-bottom: 90px;
display: flex;
flex-direction: column;
gap: 26px;
}
.ap__title,
.ap__sub,
.ap__note {
margin: 0;
}
.ap__themes {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.ap__theme {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
cursor: pointer;
font-family: inherit;
-webkit-tap-highlight-color: transparent;
transition: transform 0.15s ease, border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
.ap__theme:active {
transform: scale(0.97);
}
.ap__themeLabel {
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.ap__check {
position: absolute;
top: 8px;
inset-inline-end: 8px;
display: grid;
place-items: center;
opacity: 0;
transform: scale(0.6);
transition: opacity 0.18s ease, transform 0.22s cubic-bezier(0.32, 0.72, 0, 1);
}
.ap__theme.is-on .ap__check {
opacity: 1;
transform: none;
}
// Mini screen, fixed colours on purpose (see the header).
.ap__preview {
position: relative;
width: 100%;
aspect-ratio: 3 / 4;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 6%;
padding: 10% 12%;
--pv-bg: #ffffff;
--pv-bar: #f4f5f6;
--pv-card: #ecedef;
--pv-line: #dadce0;
--pv-btn: #14161a;
background: var(--pv-bg);
}
.ap__preview--dark {
--pv-bg: #16171a;
--pv-bar: #1e2024;
--pv-card: #2b2e34;
--pv-line: #3a3e45;
--pv-btn: #f1f2f4;
}
// System: the light screen with its lower-left half in dark.
.ap__preview--system::after {
content: '';
position: absolute;
inset: 0;
background: #16171a;
clip-path: polygon(100% 0, 100% 100%, 0 100%);
opacity: 0.94;
}
.ap__pvBar {
height: 9%;
border-radius: 4px;
background: var(--pv-bar);
}
.ap__pvCard {
flex: 1;
border-radius: 7px;
background: var(--pv-card);
}
.ap__pvLine {
height: 6%;
border-radius: 3px;
background: var(--pv-line);
}
.ap__pvLine--short {
width: 60%;
}
.ap__pvBtn {
height: 11%;
border-radius: 999px;
background: var(--pv-btn);
}
.ap__row {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
font-family: inherit;
text-align: start;
-webkit-tap-highlight-color: transparent;
transition: transform 0.15s ease, border-color 0.2s ease;
}
.ap__row:active {
transform: scale(0.985);
}
.ap__rowIcon {
flex: 0 0 auto;
display: grid;
place-items: center;
}
.ap__rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
/* ── Look: user-app-v2 ──────────────────────────────────────── */
// Borderless @hairlineSoft panels at the 22px radius, chosen = an @ink ring
// with a filled @ink check (the app's chips); the motion row is the
// dashboard's own settings row, wine icon tile and all.
.ap {
background: @page;
}
.ap__head {
margin-bottom: 12px;
}
.ap__title {
font-size: 16px;
font-weight: 700;
color: @ink;
}
.ap__sub {
margin-top: 4px;
font-size: 11.5px;
color: @inkFaint;
}
.ap__note {
margin-top: 10px;
font-size: 11.5px;
color: @inkMuted;
}
.ap__theme {
padding: 10px 10px 12px;
border: 0;
border-radius: 22px;
background: @hairlineSoft;
color: @inkMuted;
box-shadow: inset 0 0 0 1.5px transparent;
}
.ap__theme.is-on {
color: @ink;
box-shadow: inset 0 0 0 1.5px @ink;
}
.ap__preview {
border-radius: 14px;
box-shadow: 0 0 0 1px rgba(20, 22, 26, 0.06);
}
.ap__themeLabel {
font-size: 12px;
font-weight: 700;
}
.ap__check {
width: 22px;
height: 22px;
border-radius: 50%;
color: @onInk;
background: @ink;
}
.ap__row {
padding: 13px 14px;
border: 1px solid @hairline;
border-radius: 17px;
background: @canvas;
}
.ap__row:active {
border-color: alpha(@wine, 20%);
}
.ap__rowIcon {
width: 38px;
height: 38px;
border-radius: 12px;
color: @wine;
background: @wineWash;
}
.ap__rowText b {
font-size: 12.5px;
font-weight: 700;
color: @ink;
}
.ap__rowText small {
font-size: 10.5px;
line-height: 1.9;
color: @inkFaint;
}
</style>
@@ -153,7 +153,7 @@ function finish() {
.cpf__error {
margin: 12px 2px 0;
font-size: 12.5px;
color: #dc2626;
color: @red;
}
.cpf__submit {
@@ -116,7 +116,7 @@
<b>سفارشات</b>
<small>پیگیری سفارش‌های شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="#8A909C" />
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
</button>
<button type="button" class="dash__row" @click="goToDetails(1)">
@@ -127,7 +127,7 @@
<b>اسناد</b>
<small>تراکنش‌ها و رسیدهای حساب</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="#8A909C" />
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
</button>
<button type="button" class="dash__row" @click="goToDetails(2)">
@@ -138,7 +138,7 @@
<b>پروفایل</b>
<small>اطلاعات و تنظیمات حساب</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="#8A909C" />
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
</button>
<button type="button" class="dash__row" @click="goToDetails(3)">
@@ -149,7 +149,7 @@
<b>علاقمندی</b>
<small>محصولات ذخیره‌شده شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="#8A909C" />
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
</button>
<!--
D24 — the account statement. The reference put it on the dashboard
@@ -165,7 +165,20 @@
<b>گزارش</b>
<small>گردش حساب طلایی و ریالی شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="#8A909C" />
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
</button>
<!-- Appearance (theme + reduced motion). Device-level preferences, not
account data, so — unlike every row above — it needs no login. -->
<button type="button" class="dash__row" @click="router.push({ name: 'appearanceSettings' })">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:brush" size="18" />
</span>
<span class="dash__rowText">
<b>تنظیمات ظاهری</b>
<small>پوسته روشن و تیره، انیمیشن‌ها</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
</button>
<!--
@@ -184,7 +197,7 @@
<b>سفارش مشتریان</b>
<small>سفارش‌های مشتریان معرفی‌شده شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="#8A909C" />
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
</button>
<button type="button" class="dash__row" @click="goToDetails(5)">
@@ -195,7 +208,7 @@
<b>مشتریان من</b>
<small>فهرست مشتریان معرفی‌شده شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="#8A909C" />
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
</button>
</template>
</section>
@@ -315,7 +328,7 @@ onMounted(async () => {
<style scoped lang="less">
.dash {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -327,10 +340,10 @@ onMounted(async () => {
left: 0;
right: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.82);
background: alpha(@canvas, 82%);
backdrop-filter: blur(18px) saturate(140%);
border-bottom: 1px solid fade(@primary, 5%);
box-shadow: 0 8px 20px -18px fade(@primary, 55%);
border-bottom: 1px solid alpha(@primary, 5%);
box-shadow: 0 8px 20px -18px alpha(@primary, 55%);
}
.dash__barInner {
@@ -404,7 +417,7 @@ onMounted(async () => {
margin-bottom: 28px;
padding: 20px 18px 16px;
border-radius: 26px;
background: linear-gradient(155deg, @ink 0%, @ink 55%, @wineDeep 130%);
background: linear-gradient(155deg, @heroSurface 0%, @heroSurface 55%, #5E1427 130%);
color: #fff;
box-shadow: 0 22px 40px -26px rgba(20, 22, 26, 0.32);
}
@@ -447,7 +460,7 @@ onMounted(async () => {
background: @wineSoft;
}
.dash__heroSeal.is-creditor {
color: @ink;
color: #14161A;
background: rgba(255, 255, 255, 0.9);
}
@@ -500,7 +513,7 @@ onMounted(async () => {
margin-top: 16px;
padding: 26px 20px;
border-radius: 26px;
background: @ink;
background: @heroSurface;
color: #fff;
display: flex;
flex-direction: column;
@@ -540,7 +553,7 @@ onMounted(async () => {
font-family: inherit;
font-size: 12px;
font-weight: 700;
color: @ink;
color: #14161A;
background: #fff;
transition: transform 0.18s ease;
}
@@ -571,7 +584,7 @@ onMounted(async () => {
}
.dash__row:active {
transform: scale(0.985);
border-color: fade(@wine, 20%);
border-color: alpha(@wine, 20%);
}
.dash__rowIcon {
@@ -64,7 +64,7 @@ onMounted(async () => {
<style scoped lang="less">
.favs {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -104,7 +104,7 @@ onMounted(async () => {
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
color: #fff;
color: @onInk;
background: @ink;
}
</style>
@@ -204,7 +204,7 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
<style scoped lang="less">
.auth {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -280,7 +280,7 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
font-family: inherit;
font-size: 13px;
font-weight: 700;
color: #fff;
color: @onInk;
background: @ink;
transition: transform 0.15s ease, opacity 0.15s ease;
}
@@ -24,7 +24,7 @@ import PageBar from '../../components/ui/PageBar.vue'
<style scoped lang="less">
.notif {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -225,7 +225,7 @@ onMounted(loadOrders)
<style scoped lang="less">
.ord {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -260,7 +260,7 @@ onMounted(loadOrders)
}
.ord__chip.is-on {
color: #fff;
color: @onInk;
background: @ink;
border-color: @ink;
}
@@ -313,7 +313,7 @@ onMounted(loadOrders)
gap: 12px;
padding: 11px 16px;
border-bottom: 1px solid @hairline;
background: rgba(255, 255, 255, 0.92);
background: alpha(@canvas, 92%);
backdrop-filter: blur(14px);
}
@@ -392,7 +392,7 @@ onMounted(loadOrders)
}
.detail__step.is-done .detail__stepDot {
color: #fff;
color: @onInk;
background: @ink;
border-color: @ink;
}
@@ -125,7 +125,7 @@ onMounted(async () => {
<style scoped lang="less">
.prof {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -179,7 +179,7 @@ onMounted(async () => {
.prof__seal.is-ok {
color: @green;
background: #f0fdf4;
background: alpha(@green, 10%);
}
.prof__seal.is-pending {
@@ -170,7 +170,7 @@
<footer class="reg__footer">
<div class="reg__footerInner">
<button type="submit" class="reg__cta" :class="{ 'is-loading': loading }" :disabled="loading">
<span v-if="loading" class="reg__spinner" aria-hidden="true" />
<span v-if="loading" class="reg__spinner keep-motion" aria-hidden="true" />
<span>{{ step === 1 ? 'ادامه' : loading ? 'در حال ثبت…' : 'ثبت درخواست' }}</span>
<Icon v-if="step === 1" icon="vuesax-linear:arrow-left" size="18" />
</button>
@@ -248,7 +248,7 @@ async function onFinish() {
<style scoped lang="less">
.reg {
min-height: 100vh;
background: @canvas;
background: @page;
}
.reg__body {
@@ -407,12 +407,12 @@ async function onFinish() {
}
:deep(.ant-input::placeholder) {
color: #b4b8c0;
color: @inkFaint;
}
:deep(.ant-input:hover),
:deep(.ant-input-affix-wrapper:hover) {
border-color: darken(@hairline, 8%);
border-color: @hairlineStrong;
}
:deep(.ant-input:focus),
@@ -420,7 +420,7 @@ async function onFinish() {
:deep(.ant-input-affix-wrapper-focused) {
border-color: @wine;
background: @canvas;
box-shadow: 0 0 0 4px fade(@wine, 10%);
box-shadow: 0 0 0 4px alpha(@wine, 10%);
.ant-input-prefix {
color: @wine;
@@ -435,12 +435,12 @@ async function onFinish() {
:deep(.ant-form-item-has-error .ant-input),
:deep(.ant-form-item-has-error .ant-input-affix-wrapper) {
border-color: @red !important;
background: fade(@red, 3%);
background: alpha(@red, 3%);
}
:deep(.ant-form-item-has-error .ant-input:focus),
:deep(.ant-form-item-has-error .ant-input-affix-wrapper-focused) {
box-shadow: 0 0 0 4px fade(@red, 10%) !important;
box-shadow: 0 0 0 4px alpha(@red, 10%) !important;
}
:deep(.ant-form-item-has-error .ant-input-affix-wrapper .ant-input) {
@@ -501,7 +501,7 @@ async function onFinish() {
bottom: 0;
z-index: 15;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
background: fade(@canvas, 86%);
background: alpha(@canvas, 86%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
backdrop-filter: blur(16px) saturate(180%);
border-top: 1px solid @hairline;
@@ -526,9 +526,9 @@ async function onFinish() {
font-family: inherit;
font-size: 14px;
font-weight: 800;
color: #fff;
color: @onInk;
background: @ink;
box-shadow: 0 10px 22px -12px fade(@ink, 70%);
box-shadow: 0 10px 22px -12px alpha(@ink, 70%);
transition: transform 0.15s ease, opacity 0.2s ease;
&:active {
@@ -609,7 +609,7 @@ async function onFinish() {
height: 88px;
border-radius: 26px;
color: @green;
background: fade(@green, 10%);
background: alpha(@green, 10%);
animation: reg-pop 0.6s cubic-bezier(0.2, 1.5, 0.4, 1) both;
:deep(svg) {
@@ -152,7 +152,7 @@ onMounted(async () => {
<style scoped lang="less">
.tx {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -193,7 +193,7 @@ onMounted(async () => {
.tx__mark.is-in {
color: @green;
background: #f0fdf4;
background: alpha(@green, 10%);
}
.tx__mark.is-out {
+154
View File
@@ -0,0 +1,154 @@
// Runtime theming (light / dark) for the LESS design tokens.
//
// The app's colours are LESS variables (@canvas, @ink, @wine, …) injected by
// vite.config.ts through `modifyVars`. Before dark mode they compiled straight
// to hex, so nothing could change at runtime. Now the THEME tokens below are
// injected as `var(--c-<name>)` instead, and their values live in a generated
// stylesheet (`virtual:palette.css`, imported first in main.ts):
//
// :root { --c-canvas: #FFFFFF; --c-canvas-rgb: 255 255 255; … }
// html.theme-dark { --c-canvas: #16171A; --c-canvas-rgb: 22 23 26; … }
//
// Light values come from colors.json (unchanged, still the source of truth and
// still what Tailwind reads), dark values from colors.dark.json. A key missing
// from colors.dark.json simply keeps its light value in dark mode.
//
// Not converted, on purpose: `white`, `black`, `secondary`, `danger` and the
// antd keys in themeConfig.ts. ant-design-vue's own LESS compiles with the
// same modifyVars and runs colour functions (fade/tint/colorPalette) on those,
// which a `var()` would break.
//
// LESS colour functions can't operate on a `var()` either, so app code uses
// `alpha(@token, 40%)` instead of `fade(@token, 40%)` — registered below as a
// LESS plugin; for a theme token it emits `rgb(var(--c-token-rgb) / 40%)`, for
// a real colour it behaves exactly like `fade`.
//
// This file is kept identical in user-app and user-app-v2; only the JSONs
// differ per app.
import type { Plugin } from 'vite'
import light from './colors.json'
import dark from './colors.dark.json'
/** Tokens that change with the theme. Everything else in colors.json stays a
* plain compile-time hex value. */
export const THEME_TOKENS = [
'canvas',
'canvasSoft',
'ink',
'inkSoft',
'inkMuted',
'inkFaint',
'hairline',
'hairlineSoft',
'wine',
'wineDeep',
'wineSoft',
'wineWash',
'primary',
'primaryLighten',
'green',
'red',
'info',
'light',
'grey',
'lightGray',
'lightGray1',
'lightGray2',
] as const
/**
* Semantic tokens added for dark mode, for the places where one light-mode
* token did two jobs. Light values reproduce the old look exactly.
* - onInk: text/icons ON an @ink fill (filled buttons, chosen chips). White in
* light; in dark @ink turns light, so this turns dark.
* - heroSurface: decorative dark cards (rate strip, balance card…) that stay
* dark in both themes, so their white text needs no change.
* - hairlineStrong: a step darker than @hairline (was `darken(@hairline, 7%)`).
* - photoMat: behind product photos. Transparent in light (no change); in
* dark a light mat, so photos shot on white don't sit as white boxes on a
* dark card.
* - page: the screen's own ground, behind every card and panel. Same white
* as @canvas in light; in dark it is the deepest layer, so cards (@canvas)
* and panels (@hairlineSoft) stand off it a step at a time.
* - photoStage: the detail page's gallery backdrop — @hairlineSoft in light,
* the same light mat in dark.
*/
const SEMANTIC_LIGHT: Record<string, string> = {
page: (light as Record<string, string>).canvas,
onInk: '#FFFFFF',
heroSurface: '#14161A',
hairlineStrong: '#DADCE0',
photoMat: 'transparent',
photoStage: (light as Record<string, string>).hairlineSoft,
}
type Palette = Record<string, string>
function rgbChannels(hex: string): string | null {
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim())
if (!m) return null
let h = m[1]
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
const n = parseInt(h, 16)
return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`
}
function block(selector: string, values: Palette): string {
const lines: string[] = []
for (const [name, value] of Object.entries(values)) {
lines.push(` --c-${name}: ${value};`)
const rgb = rgbChannels(value)
if (rgb) lines.push(` --c-${name}-rgb: ${rgb};`)
}
return `${selector} {\n${lines.join('\n')}\n}\n`
}
const lightPalette: Palette = {
...Object.fromEntries(THEME_TOKENS.map((t) => [t, (light as Record<string, string>)[t]])),
...SEMANTIC_LIGHT,
}
const darkPalette: Palette = Object.fromEntries(
Object.entries(dark as Record<string, string>).filter(([k]) => !k.startsWith('//')),
)
export const paletteCss =
block(':root', lightPalette) +
// `color-scheme` lets native bits (inputs, scrollbars, autofill) follow.
block('html.theme-dark', darkPalette).replace('{\n', '{\n color-scheme: dark;\n')
/** LESS variables for every theme/semantic token: `@canvas` → `var(--c-canvas)`. */
export const themeLessVarOverrides: Record<string, string> = Object.fromEntries(
Object.keys(lightPalette).map((t) => [t, `var(--c-${t})`]),
)
/** Raw light hex values, for places that need a real colour at build time
* (PWA manifest). */
export const lightHex = light as Record<string, string>
/** Serves `virtual:palette.css`. */
export function palettePlugin(): Plugin {
const id = 'virtual:palette.css'
const resolved = '\0' + id
return {
name: 'gold-bank-palette',
resolveId: (source) => (source === id ? resolved : undefined),
load: (source) => (source === resolved ? paletteCss : undefined),
}
}
/** LESS plugin adding `alpha(color, amount)` — see the file header. */
export const lessAlphaPlugin = {
install(less: any, _pm: unknown, functions: any) {
functions.add('alpha', function (this: unknown, color: any, amount: any) {
if (color && color.type === 'Color') {
const fade = less.functions.functionRegistry.get('fade')
return fade.call(this, color, amount)
}
const css: string = typeof color?.toCSS === 'function' ? color.toCSS({}) : String(color?.value ?? '')
const m = /var\(\s*--c-([A-Za-z0-9]+)\s*\)/.exec(css)
if (!m) throw new Error(`alpha(): expected a colour or a theme token, got "${css}"`)
const pct = amount.unit && amount.unit.toString() === '%' ? amount.value : amount.value * 100
return new less.tree.Anonymous(`rgb(var(--c-${m[1]}-rgb) / ${pct}%)`)
})
},
}
@@ -0,0 +1,31 @@
{
"//": "Dark-mode values for the theme tokens in appearance.ts; missing keys keep their light value. Built as elevation layers so surfaces separate without shadows: page (deepest) < canvas (cards, sheets, bars) < hairlineSoft (panels, chips) < hairline (borders). v2: @primary (navy) is only ever a text accent here, so it lifts to a light navy.",
"page": "#15171A",
"canvasSoft": "#15171A",
"canvas": "#1F2125",
"hairlineSoft": "#2A2D32",
"hairline": "#3A3D44",
"hairlineStrong": "#4B4F57",
"ink": "#F2F3F5",
"inkSoft": "#E1E3E7",
"inkMuted": "#B2B7C0",
"inkFaint": "#8F95A0",
"wine": "#C9566F",
"wineDeep": "#A8435C",
"wineSoft": "#DC8197",
"wineWash": "#3A222B",
"primary": "#A3B6EA",
"primaryLighten": "#B8C8F0",
"green": "#45C677",
"red": "#F26161",
"info": "#7C9DE6",
"light": "#1F2125",
"grey": "#9298A2",
"lightGray": "#757A84",
"lightGray1": "#4A4E56",
"lightGray2": "#3A3D44",
"onInk": "#15171A",
"heroSurface": "#2C2F36",
"photoMat": "#FFFFFF",
"photoStage": "#F2F3F5"
}
+11 -1
View File
@@ -3,6 +3,7 @@
// out of scope for a presentation-only port and superseded by this app's own
// src/api/client.ts, which already talks to the new NestJS API.)
import data from './colors.json'
import { themeLessVarOverrides } from './appearance'
const colors = data
const curve = {
@@ -40,4 +41,13 @@ const vars = {
'transition-duration': '.5s',
'transition-delay': '.5s',
}
export const $themeLessVars = Object.assign(vars, colors)
// Theme tokens (see appearance.ts) are overridden last, so @canvas, @ink, …
// compile to var(--c-…) and switch with the theme at runtime. The antd keys
// above were already resolved to hex from `colors`, so antd still gets real
// colours for its colour functions; only its surface backgrounds follow the
// theme.
export const $themeLessVars = Object.assign(vars, colors, themeLessVarOverrides, {
'component-background': 'var(--c-canvas)',
'body-background': 'var(--c-page)',
'popover-background': 'var(--c-canvas)',
})
+7 -2
View File
@@ -3,6 +3,7 @@ import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
import { VitePWA } from 'vite-plugin-pwa';
import { $themeLessVars } from './themeConfig/themeConfig';
import { lessAlphaPlugin, lightHex, palettePlugin } from './themeConfig/appearance';
// Ports are defined once in the repo-root .env (USER_APP_V2_PORT / API_PORT)
// — see that file's comment block.
@@ -15,6 +16,8 @@ export default defineConfig({
envDir: rootDir,
plugins: [
vue(),
// virtual:palette.css — the light/dark values behind the theme tokens.
palettePlugin(),
// Ported from legacy vite.config.ts:18-52. Same injectManifest strategy,
// same src/sw.ts entry, same manifest values (name/colors/icons/display).
VitePWA({
@@ -29,8 +32,8 @@ export default defineConfig({
name: 'Montzar',
short_name: 'Montzar',
description: 'Montzar',
theme_color: $themeLessVars.primary,
background_color: $themeLessVars.primary,
theme_color: lightHex.primary,
background_color: lightHex.primary,
start_url: '/',
display: 'standalone',
icons: [
@@ -57,6 +60,8 @@ export default defineConfig({
less: {
modifyVars: $themeLessVars,
javascriptEnabled: true,
// alpha(@token, N%) — fade() for theme tokens; see appearance.ts.
plugins: [lessAlphaPlugin],
},
},
},
+25 -2
View File
@@ -5,13 +5,36 @@
<meta charset="UTF-8" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#23493c" />
<meta name="theme-color" content="#23493c" data-light="#23493c" />
<meta name="background-color" content="#23493c" />
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport" />
<!--
Appearance boot (see src/stores/appearanceStore.ts): applies the saved
theme / reduced-motion classes before the first paint, so a dark-mode
user never sees a white flash while the bundle loads. Same storage key
and class names as the store, which takes over once the app is up.
-->
<script>
(function () {
try {
var s = JSON.parse(localStorage.getItem('gold-bank:appearance') || '{}');
var mq = function (q) { return window.matchMedia && matchMedia(q).matches; };
var dark = s.theme === 'dark' || ((s.theme || 'system') === 'system' && mq('(prefers-color-scheme: dark)'));
var motion = typeof s.reduceMotion === 'boolean' ? s.reduceMotion : mq('(prefers-reduced-motion: reduce)');
var c = document.documentElement.classList;
if (dark) { c.add('theme-dark', 'van-theme-dark'); document.querySelector('meta[name=theme-color]').setAttribute('content', '#15171A'); }
if (motion) c.add('motion-reduced');
} catch (e) {}
})();
</script>
<style>
/* Until the bundle's palette loads. */
html.theme-dark, html.theme-dark body { background-color: #15171A !important; }
</style>
<title>طلای منتزر</title>
</head>
<body style="background-color: #fff">
<div class="bg-white w-screen h-screen fixed"></div>
<div class="w-screen h-screen fixed" style="background-color: var(--c-page, #fff)"></div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
+8 -3
View File
@@ -21,10 +21,11 @@
<DialogVantOneButton :timeOut="-1" v-model:show="versionDialog" :closeOnClickOverlay="true">
<template v-slot:img>
<!-- Still (first frame) in reduced-motion mode. -->
<DotLottieVue
class="w-full aspect-square"
autoplay
loop
:autoplay="!appearance.isReducedMotion"
:loop="!appearance.isReducedMotion"
src="https://lottie.host/cb620e8e-c64a-42e8-a6b5-8844146e5999/nmft7oBO8q.json"
/>
</template>
@@ -34,10 +35,11 @@
</template>
<template v-slot:button>
<div class="text-center text-[#23493c]" @click="update()">به روز رسانی</div>
<div class="text-center text-[color:var(--c-primary)]" @click="update()">به روز رسانی</div>
</template>
</DialogVantOneButton>
<ProductMorph />
<ToastHost />
</div>
</template>
@@ -51,10 +53,13 @@ import MasterLayout from './layout/MasterLayout.vue'
import FullPageLayout from './layout/FullPageLayout.vue'
import DialogVantOneButton from './components/DialogVantOneButton.vue'
import ToastHost from './components/ui/ToastHost.vue'
import ProductMorph from './components/product/ProductMorph.vue'
import { useRefresh } from './composables/useRefresh'
import { useAppearanceStore } from './stores/appearanceStore'
import pkg from '../package.json'
const router = useRouter()
const appearance = useAppearanceStore()
const { updateServiceWorker } = useRegisterSW()
const getLayout = computed(() => {
+107 -3
View File
@@ -367,8 +367,8 @@ input:focus-visible {
}
.ant-btn-primary[disabled], .ant-btn-primary[disabled]:hover, .ant-btn-primary[disabled]:focus, .ant-btn-primary[disabled]:active {
background: fade(@white, 20%);
color: fade(@white, 50%);
background: alpha(@white, 20%);
color: alpha(@white, 50%);
border-color: transparent;
}
@@ -632,7 +632,7 @@ img[lazy='error'] {
position: absolute;
inset: 0;
transform: translateX(100%);
background: linear-gradient(90deg, transparent, fade(#fff, 65%), transparent);
background: linear-gradient(90deg, transparent, alpha(@canvas, 65%), transparent);
animation: skeleton-sweep 1.3s ease-in-out infinite;
}
}
@@ -688,3 +688,107 @@ img[lazy='error'] {
transition: none;
}
}
/* ── Appearance: theme switch & reduced motion ─────────────────
Classes set on <html> by stores/appearanceStore.ts (and, before the bundle
loads, by the boot script in index.html). */
// One-frame guard while the palette flips: without it every element with a
// colour transition animates from the old theme to the new one at once.
html.theme-switching *,
html.theme-switching *::before,
html.theme-switching *::after {
transition: none !important;
}
// "Fast & light" mode. Not "no motion": the aim is speed without the app
// going dead, so
// - every transition still runs, capped at 120ms with no delay — taps still
// press, colours still change, sheets and pages still arrive, just quick;
// - keyframe animations run one near-instant iteration, so decorative loops
// (pulses, shimmers, illustration loops) stop at rest and one-shot reveals
// land in their final state;
// - backdrop blurs (recomputed every frame anything moves behind them) and
// permanent will-change layers are dropped;
// - .keep-motion opts working motion out: spinners, and the toast progress
// bar whose animationend is what closes the toast. vant's own <Loading>
// spinner is excluded by class.
html.motion-reduced :where(*:not(.keep-motion, .keep-motion *, .van-loading, .van-loading *)),
html.motion-reduced :where(*:not(.keep-motion, .keep-motion *, .van-loading, .van-loading *))::before,
html.motion-reduced :where(*:not(.keep-motion, .keep-motion *, .van-loading, .van-loading *))::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
animation-delay: 0s !important;
transition-duration: 0.12s !important;
transition-delay: 0s !important;
scroll-behavior: auto !important;
}
html.motion-reduced *,
html.motion-reduced *::before,
html.motion-reduced *::after {
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
will-change: auto !important;
}
// Glass that was only legible because of its blur gets a near-solid fill.
html.motion-reduced .tabbar__inner,
html.motion-reduced .toast {
background: alpha(@canvas, 97%) !important;
}
/* ── Dark theme: third-party surfaces ──────────────────────────
ant-design-vue compiles its theme at build time with black text
(fade(@black, 85%)) and white fills; only its surface backgrounds were
switched to the theme tokens (themeConfig.ts). The text and input chrome it
leaves light-only are covered here. vant follows on its own through the
.van-theme-dark class. */
html.theme-dark body {
color: @ink;
}
html.theme-dark {
.ant-input,
.ant-input-affix-wrapper,
.ant-input-number,
textarea.ant-input {
color: @ink;
background-color: @canvas;
border-color: @hairline;
}
.ant-input::placeholder,
textarea.ant-input::placeholder {
color: @inkFaint;
}
.ant-input-affix-wrapper > input.ant-input {
background-color: transparent;
}
.ant-form-item-label > label,
.ant-drawer-title,
.ant-spin-text {
color: @ink;
}
.ant-form-item-explain-error {
color: @red;
}
.ant-empty-description {
color: @inkFaint;
}
.ant-drawer-header {
border-bottom-color: @hairline;
}
.ant-slider-handle {
background-color: @canvas;
}
}
// Dark: the toast's frosted glass is mostly clear by design, which on a dark
// page leaves nothing to separate it from what scrolls beneath. Give it a
// near-solid card fill and a hairline edge instead.
html.theme-dark .toast {
background: alpha(@canvas, 94%) !important;
box-shadow:
inset 0 0 0 1px @hairline,
0 18px 40px -14px rgba(0, 0, 0, 0.6) !important;
}
+3 -3
View File
@@ -17,7 +17,7 @@
<div class="w-full flex justify-center text-[18px] font-semibold">
<slot name="title">{{ text1 }}</slot>
</div>
<div class="grid grid-cols-2 border-t border-solid border-[#E5E7EB] pt-4 mx-2 mt-6 font-semibold text-[15px]">
<div class="grid grid-cols-2 border-t border-solid border-[color:var(--c-hairline)] pt-4 mx-2 mt-6 font-semibold text-[15px]">
<slot name="button">{{ text3 }}</slot>
</div>
</div>
@@ -44,12 +44,12 @@ withDefaults(
display: none !important;
}
.van-dialog__confirm .van-button__text {
color: #23493c;
color: @primary;
font-weight: 600;
font-size: 15px;
}
.van-dialog__cancel .van-button__text {
color: #6b7280;
color: @inkMuted;
font-weight: 600;
font-size: 15px;
}
@@ -17,7 +17,7 @@
<div class="w-full flex justify-center text-[18px] font-semibold">
<slot name="title">{{ text1 }}</slot>
</div>
<div class="border-t border-solid border-[#E5E7EB] pt-4 mx-2 mt-6 font-semibold text-[15px]">
<div class="border-t border-solid border-[color:var(--c-hairline)] pt-4 mx-2 mt-6 font-semibold text-[15px]">
<slot name="button">{{ text3 }}</slot>
</div>
</div>
@@ -44,12 +44,12 @@ withDefaults(
display: none !important;
}
.van-dialog__confirm .van-button__text {
color: #23493c;
color: @primary;
font-weight: 600;
font-size: 15px;
}
.van-dialog__cancel .van-button__text {
color: #6b7280;
color: @inkMuted;
font-weight: 600;
font-size: 15px;
}
+2 -1
View File
@@ -3,7 +3,8 @@
<Spin :spinning="spinning" :tip="tip" :size="(size as 'small' | 'default' | 'large')" :delay="delay" :class="fixed ? '!max-h-screen !fixed' : ''">
<template #indicator>
<img v-if="!indicator" src="/img/montzar-logo.png" alt="Montzar" class="spinner-brand" width="48" height="48" />
<div class="loading" v-else>
<!-- keep-motion: a working spinner, exempt from reduced motion. -->
<div class="loading keep-motion" v-else>
<div class="effect-1 effects"></div>
<div class="effect-2 effects"></div>
<div class="effect-3 effects"></div>
@@ -88,6 +88,14 @@ const emit = defineEmits<{ (e: 'select', cat: HomeCategory): void }>()
opacity: 0.85;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
// instead of showing as white boxes on a dark card (@photoMat — transparent,
// i.e. no change, in light mode).
html.theme-dark .rail__mark img {
background: @photoMat;
border-radius: 12px;
}
.rail__title {
white-space: nowrap;
}
@@ -8,7 +8,7 @@
order time, not stored on the product — printing one here would be invented.
-->
<template>
<article class="card" :class="{ 'card--wide': wide }" @click="emit('open', value)">
<article class="card" :class="{ 'card--wide': wide }" data-product-origin @click="emit('open', value)">
<div class="card__art">
<img v-lazy="image" :alt="title" />
<button type="button" class="card__fav" :class="{ 'is-on': favorites.isFavorite(id) }" @click.stop="favorites.toggle(id)">
@@ -84,6 +84,14 @@ const wageLabel = computed(() => {
display: block;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
// instead of showing as white boxes on a dark card (@photoMat — transparent,
// i.e. no change, in light mode).
html.theme-dark .card__art img {
background: @photoMat;
border-radius: 12px;
}
.card__fav {
position: absolute;
top: 9px;
@@ -96,7 +104,7 @@ const wageLabel = computed(() => {
cursor: pointer;
border-radius: 50%;
color: @inkMuted;
background: rgba(255, 255, 255, 0.9);
background: alpha(@canvas, 90%);
backdrop-filter: blur(6px);
transition: color 0.18s ease, transform 0.18s ease;
}
@@ -51,6 +51,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useAppearanceStore } from '../../stores/appearanceStore'
export interface ShowcaseSlide {
key: string
@@ -70,6 +71,7 @@ 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
@@ -77,7 +79,9 @@ function go(i: number) {
}
function restart() {
if (timer) clearInterval(timer)
if (props.slides.length > 1) {
// 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)
@@ -129,6 +129,14 @@ const wageLabel = computed(() => {
display: block;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
// instead of showing as white boxes on a dark card (@photoMat — transparent,
// i.e. no change, in light mode).
html.theme-dark .ci__art img {
background: @photoMat;
border-radius: 12px;
}
.ci__artEmpty {
width: 100%;
height: 100%;
@@ -86,6 +86,14 @@ onMounted(async () => {
display: block;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
// instead of showing as white boxes on a dark card (@photoMat — transparent,
// i.e. no change, in light mode).
html.theme-dark .oi__art img {
background: @photoMat;
border-radius: 12px;
}
.oi__artEmpty {
width: 100%;
height: 100%;
@@ -20,7 +20,7 @@
stored on the product.
-->
<template>
<article class="pc" @click="open">
<article ref="root" class="pc" data-product-origin @click="open">
<div class="pc__art">
<img v-if="image" v-lazy="image" :alt="title" />
<div v-else class="pc__artEmpty" aria-hidden="true">
@@ -49,25 +49,13 @@
</div>
</div>
</article>
<Drawer
v-model:visible="drawer"
placement="bottom"
:closable="false"
class="rtl"
height="100vh"
:bodyStyle="{ padding: 0, position: 'relative' }"
>
<SingleProduct v-if="drawer" :id="id" :key="id" @closeDrawer="drawer = false" />
</Drawer>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import SingleProduct from './SingleProduct.vue'
import { useProduct } from '../../composables/useProduct'
import { openProduct } from '../../composables/useProductMorph'
import { useFavoritesStore } from '../../stores/favoritesStore'
import type { ProductSummary } from '../../api/client'
@@ -76,9 +64,10 @@ const props = defineProps<{ value: ProductSummary }>()
const favorites = useFavoritesStore()
const { image, id, title, min_gram, wage, is_exist, qty, isPreOrder } = useProduct(props.value)
const drawer = ref(false)
// The detail opens as a morph out of this card — see ProductMorph.vue.
const root = ref<HTMLElement | null>(null)
function open() {
drawer.value = true
openProduct(props.value, root.value)
}
const wageLabel = computed(() => {
@@ -121,6 +110,14 @@ const wageLabel = computed(() => {
display: block;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
// instead of showing as white boxes on a dark card (@photoMat — transparent,
// i.e. no change, in light mode).
html.theme-dark .pc__art img {
background: @photoMat;
border-radius: 12px;
}
.pc__artEmpty {
width: 100%;
height: 100%;
@@ -141,7 +138,7 @@ const wageLabel = computed(() => {
cursor: pointer;
border-radius: 50%;
color: @inkMuted;
background: rgba(255, 255, 255, 0.9);
background: alpha(@canvas, 90%);
backdrop-filter: blur(6px);
transition: color 0.18s ease, transform 0.18s ease;
}
@@ -0,0 +1,541 @@
<!--
The product detail overlay, opened as a container transform out of the card
that was tapped — the replacement for the full-height bottom Drawer, which
slid a blank sheet up from off-screen and then jumped when the detail fetch
landed and the page finally rendered.
Three layers move, and only one of them is ever repainted:
- the SHAPE: a plain fill in the card's colour, revealed by animating
`clip-path: inset(... round r)` from the card's exact rect and corner
radius out to the whole screen. It holds no content, so repainting it
per frame costs next to nothing.
- the PAGE (SingleProduct, mounted fully formed — it is seeded with the
card's own ProductSummary, so there is nothing to wait for): it grows out
of the card's centre and fades in, by transform and opacity only, so the
compositor moves it without re-rasterising it.
- the GHOST: one fixed <img> of the card's photo, flown by transform from
where the card drew it to where the gallery draws it.
An earlier version clipped the page itself; that forced a full-screen repaint
of the whole page (photos, blurred buttons and all) on every frame and lagged
on phones. Backdrop blurs inside the page are also switched off while
anything is moving, for the same reason, and the scroll lock and the page's
background refresh both wait until the motion is over.
Closing plays the morph back into the card if it is still on screen, and
falls back to a short drop-and-fade if it is not (the list was scrolled or
re-rendered). `prefers-reduced-motion` gets a plain crossfade.
Mounted once, in App.vue. Opened through composables/useProductMorph.ts.
-->
<template>
<Teleport to="body">
<div v-if="current" class="pm" :class="{ 'is-moving': moving, 'is-closing': closing }" dir="rtl">
<div ref="veil" class="pm__veil" aria-hidden="true"></div>
<div ref="shape" class="pm__shape" aria-hidden="true"></div>
<div ref="sheet" class="pm__sheet no-scrollbar">
<SingleProduct
:id="current.product.id"
:key="current.product.id"
:initial="current.product"
@closeDrawer="close"
/>
</div>
<img v-if="ghostSrc" ref="ghost" class="pm__ghost" :src="ghostSrc" alt="" aria-hidden="true" decoding="sync" />
</div>
</Teleport>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { useRoute } from 'vue-router'
import SingleProduct from './SingleProduct.vue'
import { productMorph, type ProductMorphRequest } from '../../composables/useProductMorph'
const route = useRoute()
const current = shallowRef<ProductMorphRequest | null>(null)
const ghostSrc = ref<string | null>(null)
const moving = ref(false)
const closing = ref(false)
const veil = ref<HTMLElement | null>(null)
const shape = ref<HTMLElement | null>(null)
const sheet = ref<HTMLElement | null>(null)
const ghost = ref<HTMLImageElement | null>(null)
// ── Timing ────────────────────────────────────────────────────
// Opening rides a lightly-damped spring (≈1% overshoot): quick off the mark —
// most of the travel is done in the first ~40% — with a short, soft settle.
// Expressed as a CSS `linear()` easing so it still runs on the compositor like
// any other easing; browsers without `linear()` get an ease-out-quint, which
// reads almost the same minus the settle. Closing is shorter and never
// overshoots — going back should feel like letting go, not a second entrance.
const OPEN_MS = 420
const CLOSE_MS = 300
const FALLBACK_OPEN_MS = 300
const FALLBACK_CLOSE_MS = 220
function springEasing(zeta = 0.82, span = 7, samples = 32): string {
const wd = Math.sqrt(1 - zeta * zeta)
const points: string[] = []
for (let i = 0; i <= samples; i++) {
const t = (i / samples) * span
const x = i === samples ? 1 : 1 - Math.exp(-zeta * t) * (Math.cos(wd * t) + (zeta / wd) * Math.sin(wd * t))
points.push(String(+x.toFixed(4)))
}
return `linear(${points.join(', ')})`
}
const supportsLinear = typeof CSS !== 'undefined' && CSS.supports('animation-timing-function', 'linear(0, 1)')
const EASE_OPEN = supportsLinear ? springEasing() : 'cubic-bezier(0.22, 1, 0.36, 1)'
const EASE_CLOSE = 'cubic-bezier(0.32, 0.72, 0, 1)'
const EASE_OUT = 'cubic-bezier(0.22, 1, 0.36, 1)'
// How far the page starts shrunk into the card it grows out of.
const PAGE_FROM_SCALE = 0.9
// The in-app "fast & light" switch (html.motion-reduced, see
// stores/appearanceStore.ts) or the OS accessibility setting.
const reducedMotion = () =>
document.documentElement.classList.contains('motion-reduced') ||
(typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches)
// ── Geometry ──────────────────────────────────────────────────
interface Box {
x: number
y: number
w: number
h: number
}
// Where an <img> actually draws its picture: its box minus padding, then the
// object-fit: contain letterboxing every product photo in this app uses. The
// ghost has to start and end on the drawn picture, not on the element's box,
// or it would visibly snap at both ends.
function drawnBox(img: HTMLImageElement, nw: number, nh: number): Box {
const r = img.getBoundingClientRect()
const cs = getComputedStyle(img)
const pl = parseFloat(cs.paddingLeft) || 0
const pr = parseFloat(cs.paddingRight) || 0
const pt = parseFloat(cs.paddingTop) || 0
const pb = parseFloat(cs.paddingBottom) || 0
const x = r.left + pl
const y = r.top + pt
const w = r.width - pl - pr
const h = r.height - pt - pb
if (cs.objectFit !== 'contain' && cs.objectFit !== 'scale-down') return { x, y, w, h }
let s = Math.min(w / nw, h / nh)
if (cs.objectFit === 'scale-down') s = Math.min(s, 1)
return { x: x + (w - nw * s) / 2, y: y + (h - nh * s) / 2, w: nw * s, h: nh * s }
}
function insetOf(r: DOMRect, radius: string): string {
const vw = document.documentElement.clientWidth
const vh = window.innerHeight
return `inset(${r.top}px ${vw - r.right}px ${vh - r.bottom}px ${r.left}px round ${radius})`
}
const FULL = 'inset(0px 0px 0px 0px round 0px)'
function onScreen(r: DOMRect): boolean {
return r.width > 0 && r.height > 0 && r.bottom > 0 && r.top < window.innerHeight
}
function loaded(img: HTMLImageElement | null | undefined): img is HTMLImageElement {
return !!img && img.complete && img.naturalWidth > 0
}
function sameSrc(a: HTMLImageElement, b: HTMLImageElement): boolean {
return (a.currentSrc || a.src) === (b.currentSrc || b.src)
}
// The gallery image currently showing — the slider keeps every slide in its
// track, so pick the one whose centre sits inside the stage.
function galleryImage(): HTMLImageElement | null {
const stage = sheet.value?.querySelector<HTMLElement>('.pd__stage')
if (!stage) return null
const s = stage.getBoundingClientRect()
const imgs = stage.querySelectorAll<HTMLImageElement>('img')
for (const img of imgs) {
const r = img.getBoundingClientRect()
const cx = r.left + r.width / 2
if (cx > s.left && cx < s.right) return img
}
return null
}
function originImage(origin: HTMLElement | null): HTMLImageElement | null {
return origin?.querySelector<HTMLImageElement>('img') ?? null
}
// Colours and corner radius the shape morphs between: the card's own fill
// (falling back to the page's when the card is transparent) and the page's.
function palette(origin: HTMLElement) {
const pd = sheet.value?.querySelector<HTMLElement>('.pd')
const pageBg = (pd && getComputedStyle(pd).backgroundColor) || '#fff'
const cs = getComputedStyle(origin)
const cardBg = cs.backgroundColor === 'rgba(0, 0, 0, 0)' ? pageBg : cs.backgroundColor
return { pageBg, cardBg, radius: cs.borderRadius || '0px' }
}
function centreOf(r: DOMRect): string {
return `${r.left + r.width / 2}px ${r.top + r.height / 2}px`
}
function ghostFrames(from: Box, to: Box): Keyframe[] {
const t = (b: Box) => `translate(${b.x - to.x}px, ${b.y - to.y}px) scale(${b.w / to.w}, ${b.h / to.h})`
return [{ transform: t(from) }, { transform: t(to) }]
}
function placeGhost(el: HTMLImageElement, at: Box) {
el.style.left = `${at.x}px`
el.style.top = `${at.y}px`
el.style.width = `${at.w}px`
el.style.height = `${at.h}px`
}
// ── Run bookkeeping ───────────────────────────────────────────
// Each open/close takes a new generation; a run that has been superseded by a
// later one skips its own teardown.
let gen = 0
let running: Animation[] = []
let phase: 'idle' | 'opening' | 'open' | 'closing' = 'idle'
let hiddenImgs: HTMLElement[] = []
function hide(el: HTMLElement | null) {
if (!el) return
el.style.opacity = '0'
hiddenImgs.push(el)
}
function unhideAll() {
hiddenImgs.forEach((el) => (el.style.opacity = ''))
hiddenImgs = []
}
function stopRunning() {
running.forEach((a) => a.cancel())
running = []
}
async function settled(anims: Animation[]) {
running = anims
await Promise.all(anims.map((a) => a.finished.catch(() => undefined)))
}
// The page behind must not scroll while the overlay is up. Set on <html>, not
// <body>: SingleProduct's own image preview locks and unlocks <body>, and must
// not be able to release this one. Only applied once the overlay is fully
// open — toggling overflow on the root re-lays-out the page underneath, which
// is a cost the first frame of the morph should not pay.
function lockScroll(on: boolean) {
document.documentElement.style.overflow = on ? 'hidden' : ''
}
// ── Open ──────────────────────────────────────────────────────
async function open(req: ProductMorphRequest) {
const my = ++gen
stopRunning()
unhideAll()
closing.value = false
moving.value = true
phase = 'opening'
const originImg = originImage(req.origin)
const originRect = req.origin?.getBoundingClientRect()
const canMorph = !reducedMotion() && !!req.origin && !!originRect && onScreen(originRect)
ghostSrc.value = canMorph && loaded(originImg) ? originImg.currentSrc || originImg.src : null
current.value = req
// Mounted, laid out and measured inside the same task as the tap — the first
// painted frame is already the first frame of the morph.
await nextTick()
if (my !== gen || !sheet.value || !veil.value) return
let anims: Animation[]
if (canMorph && originRect && req.origin) {
anims = morphOpen(req.origin, originRect, originImg)
} else if (reducedMotion()) {
anims = [
sheet.value.animate({ opacity: [0, 1] }, { duration: 160, easing: 'ease-out', fill: 'both' }),
veil.value.animate({ opacity: [0, 1] }, { duration: 160, easing: 'ease-out', fill: 'both' }),
]
} else {
anims = [
sheet.value.animate(
{ transform: ['translateY(6%)', 'none'], opacity: [0, 1] },
{ duration: FALLBACK_OPEN_MS, easing: EASE_OUT, fill: 'both' },
),
veil.value.animate({ opacity: [0, 1] }, { duration: FALLBACK_OPEN_MS, easing: 'ease-out', fill: 'both' }),
]
}
await settled(anims)
if (my !== gen) return
// The end state of every animation equals the resting CSS, so dropping them
// changes nothing on screen — it just leaves no clip-path or composited
// layers behind while the page is being read and scrolled.
stopRunning()
unhideAll()
ghostSrc.value = null
moving.value = false
phase = 'open'
lockScroll(true)
}
function morphOpen(origin: HTMLElement, originRect: DOMRect, originImg: HTMLImageElement | null): Animation[] {
const sheetEl = sheet.value!
const shapeEl = shape.value!
const veilEl = veil.value!
const { pageBg, cardBg, radius } = palette(origin)
// Measure everything BEFORE any animation starts: once the page is scaled,
// the gallery's rect would come back scaled too.
const targetImg = galleryImage()
const ghostEl = ghost.value
let flight: { from: Box; to: Box } | null = null
if (ghostEl && loaded(originImg) && targetImg && sameSrc(originImg, targetImg)) {
const nw = originImg.naturalWidth
const nh = originImg.naturalHeight
const to = drawnBox(targetImg, nw, nh)
if (to.w > 0 && to.h > 0) flight = { from: drawnBox(originImg, nw, nh), to }
}
sheetEl.style.transformOrigin = centreOf(originRect)
const anims: Animation[] = [
shapeEl.animate(
{ clipPath: [insetOf(originRect, radius), FULL], backgroundColor: [cardBg, pageBg] },
{ duration: OPEN_MS, easing: EASE_OPEN, fill: 'both' },
),
veilEl.animate({ opacity: [0, 1] }, { duration: OPEN_MS * 0.6, easing: 'ease-out', fill: 'both' }),
// The page grows out of the card's centre alongside the shape, and fades
// in a beat behind it — the first frames read as the card lifting, then
// the page arriving inside it.
sheetEl.animate(
{ transform: [`scale(${PAGE_FROM_SCALE})`, 'none'] },
{ duration: OPEN_MS, easing: EASE_OPEN, fill: 'both' },
),
sheetEl.animate(
{ opacity: [0, 1] },
{ duration: OPEN_MS * 0.55, delay: OPEN_MS * 0.1, easing: 'cubic-bezier(0.3, 0, 0.2, 1)', fill: 'both' },
),
]
if (ghostEl && flight && targetImg) {
placeGhost(ghostEl, flight.to)
hide(targetImg)
anims.push(ghostEl.animate(ghostFrames(flight.from, flight.to), { duration: OPEN_MS, easing: EASE_OPEN, fill: 'both' }))
} else {
ghostSrc.value = null
}
return anims
}
// ── Close ─────────────────────────────────────────────────────
async function close() {
if (!current.value || phase === 'closing') return
const my = ++gen
const wasOpening = phase === 'opening'
phase = 'closing'
closing.value = true
moving.value = true
let anims: Animation[]
if (wasOpening && running.length) {
// Closed before it finished opening: play the same motion back from
// wherever it got to, rather than jumping to open and morphing from there.
running.forEach((a) => a.reverse())
anims = running
} else {
stopRunning()
unhideAll()
anims = await closeAnimations()
}
await settled(anims)
if (my !== gen) return
teardown()
}
async function closeAnimations(): Promise<Animation[]> {
const sheetEl = sheet.value
const shapeEl = shape.value
const veilEl = veil.value
if (!sheetEl || !shapeEl || !veilEl) return []
if (reducedMotion()) {
return [
sheetEl.animate({ opacity: [1, 0] }, { duration: 140, easing: 'ease-in', fill: 'forwards' }),
veilEl.animate({ opacity: [1, 0] }, { duration: 140, easing: 'ease-in', fill: 'forwards' }),
]
}
const origin = current.value?.origin ?? null
const originRect = origin?.isConnected ? origin.getBoundingClientRect() : null
if (!origin || !originRect || !onScreen(originRect)) {
return [
sheetEl.animate(
{ transform: ['none', 'translateY(6%)'], opacity: [1, 0] },
{ duration: FALLBACK_CLOSE_MS, easing: 'cubic-bezier(0.4, 0, 1, 1)', fill: 'forwards' },
),
veilEl.animate({ opacity: [1, 0] }, { duration: FALLBACK_CLOSE_MS, easing: 'ease-in', fill: 'forwards' }),
]
}
const { pageBg, cardBg, radius } = palette(origin)
// Measured before anything moves, for the same reason as on the way in.
const originImg = originImage(origin)
const fromImg = galleryImage()
let flight: { from: Box; to: Box } | null = null
if (loaded(originImg) && fromImg && sameSrc(originImg, fromImg) && onScreen(fromImg.getBoundingClientRect())) {
const nw = originImg.naturalWidth
const nh = originImg.naturalHeight
const from = drawnBox(fromImg, nw, nh)
if (from.w > 0 && from.h > 0) flight = { from, to: drawnBox(originImg, nw, nh) }
}
if (flight && originImg) {
ghostSrc.value = originImg.currentSrc || originImg.src
await nextTick()
}
sheetEl.style.transformOrigin = centreOf(originRect)
const anims: Animation[] = [
// The shape fades over its last stretch so the real card — title and all —
// crossfades in under it instead of popping in after it lands.
shapeEl.animate(
[
{ clipPath: FULL, backgroundColor: pageBg, opacity: 1 },
{ opacity: 1, offset: 0.7 },
{ clipPath: insetOf(originRect, radius), backgroundColor: cardBg, opacity: 0 },
],
{ duration: CLOSE_MS, easing: EASE_CLOSE, fill: 'forwards' },
),
sheetEl.animate(
{ transform: ['none', `scale(${PAGE_FROM_SCALE})`] },
{ duration: CLOSE_MS, easing: EASE_CLOSE, fill: 'forwards' },
),
sheetEl.animate({ opacity: [1, 0] }, { duration: CLOSE_MS * 0.45, easing: 'ease-in', fill: 'forwards' }),
veilEl.animate({ opacity: [1, 0] }, { duration: CLOSE_MS, easing: 'ease-out', fill: 'forwards' }),
]
// The photo flies home only if the gallery is still on screen and showing
// the same picture the card does; otherwise the shape alone carries it back.
const ghostEl = ghost.value
if (ghostEl && flight && fromImg) {
// Based on the gallery box this time, so the frames run identity → card.
placeGhost(ghostEl, flight.from)
hide(fromImg)
anims.push(
ghostEl.animate(ghostFrames(flight.to, flight.from).reverse(), {
duration: CLOSE_MS,
easing: EASE_CLOSE,
fill: 'forwards',
}),
)
}
return anims
}
function teardown() {
stopRunning()
unhideAll()
ghostSrc.value = null
moving.value = false
closing.value = false
phase = 'idle'
current.value = null
lockScroll(false)
if (productMorph.value) productMorph.value = null
}
// ── Wiring ────────────────────────────────────────────────────
watch(productMorph, (req) => {
if (req) open(req)
else if (current.value) close()
})
// Anything inside the page that navigates (the cart button, the category pill)
// leaves the overlay behind; it goes with the screen it was opened over.
watch(
() => route.fullPath,
() => {
if (!current.value) return
++gen
teardown()
},
)
onBeforeUnmount(() => {
stopRunning()
unhideAll()
lockScroll(false)
})
</script>
<style scoped lang="less">
.pm {
position: fixed;
inset: 0;
// Same layer the Drawer it replaces sat on; SingleProduct's own full-screen
// image preview is 2000 and must stay above this.
z-index: 1000;
}
.pm.is-closing {
pointer-events: none;
}
.pm__veil {
position: absolute;
inset: 0;
background: rgba(14, 15, 18, 0.32);
}
// Only drawn while something is moving; at rest the page covers it anyway.
.pm__shape {
position: absolute;
inset: 0;
display: none;
}
.pm.is-moving .pm__shape {
display: block;
}
// The scroller for pages that do not scroll themselves (v1's SingleProduct
// grows with its content; v2's is its own 100vh scroller and never overflows
// this). The page's transform is applied HERE rather than on anything inside:
// this box is already pinned to the viewport, so a `position: fixed` action bar
// inside the page stays exactly where it is while the transform is running.
.pm__sheet {
position: absolute;
inset: 0;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
// Backdrop blurs are re-computed on every frame their layer moves, and the page
// has around ten of them (header buttons, action bar…). Their surfaces are
// already ~90% opaque, so dropping the blur for the length of the motion is
// invisible — and it is the single biggest cost the moving page had.
.pm.is-moving :deep(*) {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
.pm__ghost {
position: fixed;
z-index: 2;
transform-origin: 0 0;
object-fit: fill;
pointer-events: none;
will-change: transform;
}
</style>
@@ -300,20 +300,22 @@ import { toast } from '../../composables/useToast'
import DialogVant from '../DialogVant.vue'
import { useProduct, isVariantOrderable } from '../../composables/useProduct'
import { useProductDetail } from '../../composables/useProductDetail'
import type { ProductVariantSummary } from '../../api/client'
import type { ProductSummary, ProductVariantSummary } from '../../api/client'
import { useFavoritesStore } from '../../stores/favoritesStore'
import { useCartStore, type CartItem } from '../../stores/cartStore'
import { isUserLoggedIn } from '../../utils/checkAccess'
import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg'
const props = defineProps<{ id: string }>()
// `initial`: the card's own ProductSummary when opened from a list (see
// ProductMorph.vue) — lets the page render whole on its first frame.
const props = defineProps<{ id: string; initial?: ProductSummary | null }>()
const emit = defineEmits<{ (e: 'closeDrawer'): void }>()
const router = useRouter()
const favorites = useFavoritesStore()
const cart = useCartStore()
// Used two ways, as in legacy: embedded in a bottom Drawer by the product
// Used two ways, as in legacy: embedded in the ProductMorph overlay by the product
// cards, and as a standalone page for deep links (/product/:id). In the drawer
// case the parent listens for closeDrawer; on the routed page there is no
// parent, so fall back to history.back().
@@ -326,7 +328,7 @@ const loginNotify = ref(false)
const imageIndex = ref(0)
const bodyOpen = ref(false)
const productDetail = useProductDetail(props.id)
const productDetail = useProductDetail(props.id, props.initial)
const loading = productDetail.loading
const flag = productDetail.flag
@@ -515,7 +517,7 @@ onBeforeUnmount(() => {
position: relative;
aspect-ratio: 1 / 1;
max-height: 52vh;
background: @hairlineSoft;
background: @photoStage;
overflow: hidden;
}
@@ -544,7 +546,7 @@ onBeforeUnmount(() => {
border-radius: 50%;
cursor: pointer;
color: @inkSoft;
background: rgba(255, 255, 255, 0.92);
background: alpha(@canvas, 92%);
backdrop-filter: blur(8px);
box-shadow: 0 6px 18px -10px rgba(20, 22, 26, 0.5);
transition: transform 0.18s ease, color 0.18s ease;
+454 -168
View File
@@ -13,7 +13,7 @@
- Gram-range filter: real, bounds derived from the actual min/max
rashWeightGrams across all variants (rather than a customer-specific
server-provided bound). Still uses ant-design-vue's Slider for the actual
drag/range behaviour, restyled via :deep() to the wine accent.
drag/range behaviour, restyled to the app's accent.
- DROPPED: color filter — no color concept exists anywhere in the new
Product/ProductVariant schema, not a stubbed-off feature like promotions,
genuinely nothing to filter by.
@@ -23,72 +23,139 @@
concept in the new Customer model).
- DROPPED: discount toggle — no promotions data exists (see useProduct.ts).
Top padding (120px) accounts for PageBar + the search input row with no
chips row below it (chips are hidden while this sheet is open) — same
bar-chrome math SearchView/OrdersView use for their own #below content.
Presentation: a bottom sheet over a dimmed, see-through backdrop. Full width
and attached to the bottom edge — not a floating island, so it has room to
take more filters later — and only as tall as its content, up to 88% of the
screen, past which the body scrolls under a fixed header and action bar.
It replaced an in-place swap of the search page's body (results hidden with
v-show, the bar retitled and its back button hijacked, the tab bar hidden),
which was neither a page nor a modal. Closes on the backdrop, a swipe down
on its header, the × button, or the hardware/browser back
(close-on-popstate).
STRUCTURE IS SHARED, LOOK IS NOT. The template and script here are kept
identical to user-app-v2's Filters.vue; each app styles the same markup in
its own design language (this one: soft-grey sheet with the 22px top radius
the detail page's sheet uses, white hairline-bordered cards via DropDown,
@primary 14px-radius buttons and pill chips, round controls).
Choices are a DRAFT until "اعمال فیلتر": nothing touches the filters store
while the sheet is open, so dragging the gram slider no longer re-filters and
re-renders the whole result grid behind it on every pointer move (it did, and
that was most of the lag). Dismissing without applying discards the draft.
"پاک کردن فیلتر‌ها" clears only what this sheet owns (brands, gram range) —
it used to reset the whole query, wiping the search text and the category
chip along with it.
-->
<template>
<section class="filt">
<div class="filt__body">
<DropDown text="فیلتر بر اساس برند">
<div v-if="availableBrands.length" class="filt__chips">
<button
v-for="item in availableBrands"
:key="item.id"
type="button"
class="filt__chip"
:class="{ 'is-on': modelValue.brands.includes(item.id) }"
@click="toggleBrand(item.id)"
>
{{ item.title }}
<Popup
:show="show"
position="bottom"
teleport="body"
transition="fSheet-slide"
class="fSheet"
overlay-class="fSheet__overlay"
close-on-popstate
@update:show="emit('update:show', $event)"
@closed="dragY = 0"
>
<div
class="fSheet__card"
:class="{ 'is-dragging': dragging }"
:style="cardStyle"
dir="rtl"
role="dialog"
aria-modal="true"
aria-labelledby="fSheet-title"
>
<!-- The drag handle area. Swipe-to-dismiss starts only here, so a
vertical swipe on the body still scrolls the filters. -->
<header
class="fSheet__head"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<span class="fSheet__grip" aria-hidden="true"></span>
<div class="fSheet__headRow">
<h3 id="fSheet-title" class="fSheet__title">
فیلترها
<span v-if="draftCount" class="fSheet__count">{{ draftCount }}</span>
</h3>
<button type="button" class="fSheet__close" aria-label="بستن" @click="close">
<Icon icon="vuesax-linear:add" size="18" />
</button>
</div>
<p v-else class="filt__empty">برندی برای فیلتر موجود نیست.</p>
</DropDown>
</header>
<DropDown text="فیلتر بر اساس گرم">
<div class="filt__gram">
<Slider
v-model:value="(modelValue.limit_gram as unknown as [number, number])"
:step="0.01"
range
reverse
:min="gramBounds[0]"
:max="gramBounds[1]"
/>
<div class="filt__gramFigures" v-if="modelValue.limit_gram.length > 0">
<span class="filt__gramFigure">
<i>کمترین</i>
{{ modelValue.limit_gram[0] }} گرم
</span>
<span class="filt__gramFigure">
<i>بیشترین</i>
{{ modelValue.limit_gram[1] }} گرم
</span>
<div class="fSheet__body">
<DropDown text="برند" default-open>
<div v-if="availableBrands.length" class="fSheet__chips">
<button
v-for="item in availableBrands"
:key="item.id"
type="button"
class="fSheet__chip"
:class="{ 'is-on': draftBrands.includes(item.id) }"
:aria-pressed="draftBrands.includes(item.id)"
@click="toggleBrand(item.id)"
>
{{ item.title }}
</button>
</div>
</div>
</DropDown>
</div>
<p v-else class="fSheet__empty">برندی برای فیلتر موجود نیست.</p>
</DropDown>
<div class="filt__bar">
<div class="filt__barInner">
<button type="button" class="filt__clear" @click="closeFilters(false)">پاک کردن فیلتر‌ها</button>
<button type="button" class="filt__apply" @click="closeFilters(true)">اعمال فیلتر</button>
<DropDown text="وزن (گرم)" default-open>
<div class="fSheet__gram">
<!-- Tooltips off: they render in ant's own layer (z-index 1070),
underneath this sheet (vant's 2000+), and the figures below
already show both values live. -->
<Slider
:value="draftGram"
:step="0.01"
range
reverse
:min="gramBounds[0]"
:max="gramBounds[1]"
:tooltip-visible="false"
@update:value="onGram"
/>
<div class="fSheet__gramFigures">
<span class="fSheet__gramFigure">
<i>کمترین</i>
<b>{{ fmt(draftGram[0]) }}</b> گرم
</span>
<span class="fSheet__gramFigure">
<i>بیشترین</i>
<b>{{ fmt(draftGram[1]) }}</b> گرم
</span>
</div>
</div>
</DropDown>
</div>
<footer class="fSheet__bar">
<button type="button" class="fSheet__clear" @click="clear">پاک کردن</button>
<button type="button" class="fSheet__apply" @click="apply">اعمال فیلتر</button>
</footer>
</div>
</section>
</Popup>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { Popup } from 'vant'
import { Slider } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import DropDown from '../ui/DropDown.vue'
import type { FiltersQuery } from '../../stores/filtersStore'
import type { ProductSummary } from '../../api/client'
const props = withDefaults(defineProps<{ modelValue: FiltersQuery; filterKeys: string[]; products: ProductSummary[] }>(), {})
const props = defineProps<{ show: boolean; modelValue: FiltersQuery; filterKeys: string[]; products: ProductSummary[] }>()
const emit = defineEmits<{
(e: 'change', value: { resetFilters: boolean }): void
(e: 'update:show', value: boolean): void
(e: 'update:filterKeys', value: string[]): void
}>()
@@ -106,185 +173,404 @@ const gramBounds = computed((): [number, number] => {
return [Math.floor(Math.min(...weights)), Math.ceil(Math.max(...weights))]
})
// The Slider's `range` mode needs a real [start, end] value to place its two
// handles — filtersStore's default `limit_gram: []` has neither, so until
// this seeds it, ant-design-vue's Slider has nothing to derive handle
// positions from and renders no handles at all. Only seeds while still
// empty, so it never clobbers a value the user already dragged.
// ── Draft ─────────────────────────────────────────────────────
const draftBrands = ref<string[]>([])
// Always a real [start, end] pair: ant-design-vue's range Slider places no
// handles at all without one.
const draftGram = ref<[number, number]>([...gramBounds.value])
// Until the user drags, the range follows the catalogue's bounds (they can
// still arrive after the sheet first opens, while the list is loading).
let gramTouched = false
function loadDraft() {
draftBrands.value = [...props.modelValue.brands]
const g = props.modelValue.limit_gram
gramTouched = g.length === 2
draftGram.value = g.length === 2 ? [g[0], g[1]] : [...gramBounds.value]
}
watch(
gramBounds,
(bounds) => {
if (props.modelValue.limit_gram.length === 0) {
props.modelValue.limit_gram = [...bounds]
}
() => props.show,
(open) => {
if (open) loadDraft()
},
{ immediate: true },
)
const toggleBrand = (id: string) => {
const brands = props.modelValue.brands
const index = brands.indexOf(id)
if (index === -1) brands.push(id)
else brands.splice(index, 1)
watch(gramBounds, (bounds) => {
if (!gramTouched) draftGram.value = [...bounds]
})
function onGram(value: [number, number] | number) {
if (!Array.isArray(value)) return
draftGram.value = [value[0], value[1]]
gramTouched = true
}
const closeFilters = (sendQuery: boolean): void => {
const filterKeys: string[] = []
if (sendQuery) {
if (props.modelValue.brands.length > 0) filterKeys.push('brands')
if (
props.modelValue.limit_gram[0] !== undefined &&
props.modelValue.limit_gram[1] !== undefined &&
(props.modelValue.limit_gram[0] !== gramBounds.value[0] || props.modelValue.limit_gram[1] !== gramBounds.value[1])
) {
filterKeys.push('limit_gram')
}
}
if (sendQuery && filterKeys.length > 0) {
emit('update:filterKeys', filterKeys)
emit('change', { resetFilters: false })
} else {
emit('update:filterKeys', [])
emit('change', { resetFilters: true })
}
// A range that still spans the whole catalogue filters nothing out, so it is
// not counted as a filter (and is stored as "no range" on apply).
const gramActive = computed(() => {
const [lo, hi] = draftGram.value
const [min, max] = gramBounds.value
return Math.abs(lo - min) > 0.001 || Math.abs(hi - max) > 0.001
})
const draftCount = computed(() => draftBrands.value.length + (gramActive.value ? 1 : 0))
function toggleBrand(id: string) {
const i = draftBrands.value.indexOf(id)
draftBrands.value = i === -1 ? [...draftBrands.value, id] : draftBrands.value.filter((b) => b !== id)
}
function fmt(n: number) {
return n % 1 === 0 ? String(n) : String(+n.toFixed(2))
}
// ── Commit ────────────────────────────────────────────────────
// Always assigns fresh arrays rather than mutating the store's in place — see
// filtersStore's reset() for why sharing them went wrong.
function apply() {
const q = props.modelValue
q.brands = [...draftBrands.value]
q.limit_gram = gramActive.value ? [...draftGram.value] : []
const keys: string[] = []
if (q.brands.length) keys.push('brands')
if (q.limit_gram.length) keys.push('limit_gram')
emit('update:filterKeys', keys)
close()
}
function clear() {
draftBrands.value = []
draftGram.value = [...gramBounds.value]
gramTouched = false
apply()
}
function close() {
emit('update:show', false)
}
// ── Swipe down to dismiss (header only) ───────────────────────
// The sheet follows the finger down, rubber-bands up, and on release either
// springs back or leaves from where it was let go.
const DISMISS_DISTANCE = 90
const DISMISS_VELOCITY = 0.5 // px per ms
const dragY = ref(0)
const dragging = ref(false)
let startY = 0
let startTime = 0
const cardStyle = computed(() => (dragY.value ? { transform: `translate3d(0, ${dragY.value}px, 0)` } : undefined))
function onTouchStart(e: TouchEvent) {
if ((e.target as HTMLElement).closest('button')) return
startY = e.touches[0].clientY
startTime = Date.now()
dragging.value = true
}
function onTouchMove(e: TouchEvent) {
if (!dragging.value) return
const dy = e.touches[0].clientY - startY
dragY.value = dy > 0 ? dy : dy * 0.12
}
function onTouchEnd() {
if (!dragging.value) return
dragging.value = false
const velocity = dragY.value / Math.max(1, Date.now() - startTime)
if (dragY.value > DISMISS_DISTANCE || (dragY.value > 30 && velocity > DISMISS_VELOCITY)) close()
else dragY.value = 0
}
</script>
<style scoped lang="less">
.filt {
background: @canvas;
min-height: 100vh;
<style lang="less">
// Global, not scoped: the popup is teleported to <body>. Everything is under
// the fSheet prefix.
/* ── Shell (same in both apps) ──────────────────────────────── */
// See-through dim, no blur: blurring the page behind a fading overlay is
// recomputed every frame and is what made sheets janky on phones.
.fSheet__overlay {
background: rgba(20, 22, 26, 0.42);
}
.filt__body {
.fSheet.van-popup {
background: transparent;
overflow: visible;
}
// The iOS sheet curve: fast out of the gate, long soft landing. Transform only.
.fSheet-slide-enter-active,
.fSheet-slide-leave-active {
will-change: transform;
}
.fSheet-slide-enter-active {
transition: transform 0.42s cubic-bezier(0.32, 0.72, 0, 1);
}
.fSheet-slide-leave-active {
transition: transform 0.24s cubic-bezier(0.4, 0, 1, 1);
}
.fSheet-slide-enter-from,
.fSheet-slide-leave-to {
transform: translate3d(0, 100%, 0);
}
.fSheet__card {
position: relative;
// Phone: edge to edge. Wider screens: a centred column, same max width the
// app's pages use.
max-width: 768px;
margin: 0 auto;
padding: 120px 16px 110px;
max-height: 88vh;
max-height: 88dvh;
display: flex;
flex-direction: column;
overflow: hidden;
transition: transform 0.38s cubic-bezier(0.22, 1, 0.36, 1);
&.is-dragging {
transition: none;
}
}
.fSheet__head {
flex: none;
touch-action: none;
}
.fSheet__grip {
display: block;
margin: 0 auto;
}
.fSheet__headRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.filt__chips {
.fSheet__title {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
}
.fSheet__count {
min-width: 20px;
height: 20px;
padding: 0 6px;
display: inline-grid;
place-items: center;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
color: #fff;
}
.fSheet__close {
display: grid;
place-items: center;
border: 0;
cursor: pointer;
transform: rotate(45deg);
transition: transform 0.15s ease;
}
.fSheet__close:active {
transform: rotate(45deg) scale(0.92);
}
.fSheet__body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
display: flex;
flex-direction: column;
}
.fSheet__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filt__chip {
padding: 8px 14px;
border-radius: 999px;
.fSheet__chip {
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: @inkSoft;
background: @canvas;
border: 1px solid @hairline;
transition: all 0.2s ease;
white-space: nowrap;
-webkit-tap-highlight-color: transparent;
transition: color 0.18s ease, background-color 0.18s ease, border-color 0.18s ease, transform 0.15s ease;
}
.fSheet__chip:active {
transform: scale(0.96);
}
.filt__chip.is-on {
color: #fff;
background: @primary;
border-color: @primary;
}
.filt__chip:active {
transform: scale(0.97);
}
.filt__empty {
.fSheet__empty {
margin: 0;
font-size: 12px;
color: @inkFaint;
}
.filt__gram {
padding: 4px 6px 0;
}
.filt__gram :deep(.ant-slider-track) {
background-color: @wine;
}
.filt__gram :deep(.ant-slider-handle) {
border-color: @wine;
}
.filt__gram :deep(.ant-slider-rail) {
background-color: @hairline;
}
.filt__gramFigures {
.fSheet__gramFigures {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 18px;
}
.filt__gramFigure {
.fSheet__gramFigure i {
font-style: normal;
}
.fSheet__bar {
flex: none;
display: flex;
align-items: center;
gap: 10px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
}
.fSheet__clear,
.fSheet__apply {
cursor: pointer;
font-family: inherit;
white-space: nowrap;
transition: transform 0.15s ease;
}
.fSheet__clear:active,
.fSheet__apply:active {
transform: scale(0.98);
}
/* ── Look: user-app ─────────────────────────────────────────── */
// The detail page's sheet: a soft-grey ground with the 22px top radius, so the
// white bordered filter cards stand off it the way the page's cards do.
.fSheet__card {
border-radius: 22px 22px 0 0;
background: @canvasSoft;
box-shadow: 0 -12px 34px -22px rgba(20, 22, 26, 0.45);
}
.fSheet__head {
padding: 8px 16px 12px;
}
.fSheet__grip {
width: 36px;
height: 4px;
margin-bottom: 12px;
border-radius: 999px;
background: @lightGray2;
}
.fSheet__title {
font-size: 15px;
font-weight: 800;
color: @ink;
letter-spacing: -0.1px;
}
.fSheet__count {
background: @wine;
}
// The detail page's round floating controls.
.fSheet__close {
width: 36px;
height: 36px;
border-radius: 50%;
color: @inkSoft;
background: @canvas;
border: 1px solid @hairline;
}
.fSheet__body {
gap: 12px;
padding: 2px 16px 16px;
}
// The search page's chips.
.fSheet__chip {
padding: 7px 14px;
border-radius: 999px;
font-size: 12.5px;
font-weight: 600;
color: @inkSoft;
background: @canvas;
border: 1px solid @hairline;
}
.fSheet__chip.is-on {
color: #fff;
background: @primary;
border-color: @primary;
}
.fSheet__gram {
padding: 2px 6px 0;
}
.fSheet__gram .ant-slider-rail {
background-color: @hairline;
}
.fSheet__gram .ant-slider-track {
background-color: @primary;
}
.fSheet__gram .ant-slider-handle {
border-color: @primary;
}
.fSheet__gramFigures {
padding-top: 14px;
}
.fSheet__gramFigure {
font-size: 12px;
font-weight: 700;
color: @ink;
}
.filt__gramFigure i {
font-style: normal;
.fSheet__gramFigure i {
font-size: 11px;
font-weight: 600;
color: @inkFaint;
padding-inline-end: 4px;
}
.filt__bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 5;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(14px);
border-top: 1px solid @hairline;
padding-bottom: 16px;
.fSheet__gramFigure b {
font-weight: 800;
}
.filt__barInner {
max-width: 768px;
margin: 0 auto;
// The detail page's action bar: white, hairline on top, 14px-radius buttons.
.fSheet__bar {
padding: 12px 16px;
display: flex;
align-items: center;
gap: 10px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
background: @canvas;
border-top: 1px solid @hairline;
}
.filt__clear,
.filt__apply {
flex: 1;
padding: 13px 18px;
border: 0;
cursor: pointer;
.fSheet__clear,
.fSheet__apply {
height: 48px;
border-radius: 14px;
font-family: inherit;
font-size: 13px;
font-weight: 700;
transition: transform 0.15s ease;
font-size: 13.5px;
font-weight: 800;
}
.filt__clear {
color: @inkSoft;
background: @hairlineSoft;
.fSheet__clear {
flex: 0 0 auto;
padding: 0 20px;
color: @ink;
background: @canvas;
border: 1px solid @hairline;
}
.filt__apply {
.fSheet__apply {
flex: 1;
border: 0;
color: #fff;
background: @primary;
}
.filt__clear:active,
.filt__apply:active {
transform: scale(0.98);
}
</style>
+109 -45
View File
@@ -4,20 +4,44 @@
raw Tailwind utility classes with legacy tokens (text-primary,
border-lightGray/20), the one accordion in the app that didn't match the
LESS-token look every other surface uses.
FIXED — the collapse itself. It used to v-if the body in and out and animate
`max-height` from 0 to a hard-coded 240px, which broke three ways: content
taller than 240px was cut off for the whole animation and then jumped to full
height at the end; content shorter than 240px opened fast and then closed
with a dead pause (the first stretch of the shrink happened above the real
height, so nothing moved); and the close used `animation: … reverse` on a
`leave-to` class, which Vue does not time leave transitions by, so it
usually just snapped shut. The body's own padding also popped in and out.
Now the body stays mounted and the panel animates `grid-template-rows` from
0fr to 1fr — the browser interpolates to the content's real height, whatever
it is, in both directions, with no measuring and no cap. All the padding
below the header lives inside the clipped row, so it is part of what
animates. Browsers too old to interpolate grid rows (pre-2022) simply open
and close without the slide.
STRUCTURE IS SHARED, LOOK IS NOT: the template, script and the collapse
mechanics are identical in user-app and user-app-v2; only the "Look" block
at the bottom differs, each in its own app's design language.
-->
<template>
<div class="dd" :class="{ 'is-open': show }">
<button type="button" class="dd__head" @click="show = !show">
<button type="button" class="dd__head" :aria-expanded="show" @click="show = !show">
<h3 class="dd__title">
<slot name="text">{{ text }}</slot>
</h3>
<Icon icon="vuesax-linear:arrow-down" class="dd__chevron" size="18" />
<span class="dd__chevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-down" size="16" />
</span>
</button>
<transition name="drop" mode="out-in" appear>
<div class="dd__body" v-if="show">
<slot />
<div class="dd__panel" :inert="!show">
<div class="dd__clip">
<div class="dd__body">
<slot />
</div>
</div>
</transition>
</div>
</div>
</template>
@@ -25,21 +49,16 @@
import { ref } from 'vue'
import Icon from '../icon/Icon.vue'
withDefaults(defineProps<{ text?: string }>(), {})
const show = ref(false)
const props = withDefaults(defineProps<{ text?: string; defaultOpen?: boolean }>(), { defaultOpen: false })
const show = ref(props.defaultOpen)
</script>
<style scoped lang="less">
.dd {
padding: 15px;
border: 1px solid @hairline;
border-radius: 16px;
background: @canvas;
}
.dd.is-open {
border-color: @wineSoft;
}
/* ── Mechanics (same in both apps) ──────────────────────────── */
// One curve for the whole collapse: the iOS sheet curve the app's sheets use —
// quick off the mark, soft landing.
@dd-ease: cubic-bezier(0.32, 0.72, 0, 1);
@dd-time: 0.34s;
.dd__head {
display: flex;
@@ -48,50 +67,95 @@ const show = ref(false)
gap: 12px;
width: 100%;
border: 0;
padding: 0;
cursor: pointer;
background: none;
font-family: inherit;
text-align: start;
-webkit-tap-highlight-color: transparent;
}
.dd__title {
margin: 0;
font-size: 13px;
font-weight: 700;
color: @ink;
}
.dd__chevron {
flex: 0 0 auto;
color: @inkFaint;
transition: transform 0.2s ease;
display: grid;
place-items: center;
transition: transform @dd-time @dd-ease, color 0.2s ease, background-color 0.2s ease;
}
.dd.is-open .dd__chevron {
transform: rotate(180deg);
color: @wine;
}
.dd__panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows @dd-time @dd-ease;
}
.dd.is-open .dd__panel {
grid-template-rows: 1fr;
}
// The row's content box: `min-height: 0` is what lets the 0fr row actually
// shrink below the content's height, and the clip hides what is outside it.
.dd__clip {
min-height: 0;
overflow: hidden;
}
// Fades and settles in a touch behind the height, and out a touch ahead of it,
// so the content never shows half-cut at the moving edge.
.dd__body {
opacity: 0;
transform: translateY(-6px);
transition: opacity 0.18s ease, transform @dd-time @dd-ease;
}
.dd.is-open .dd__body {
opacity: 1;
transform: none;
transition: opacity 0.24s ease 0.06s, transform @dd-time @dd-ease;
}
@media (prefers-reduced-motion: reduce) {
.dd__panel,
.dd__body,
.dd__chevron {
transition: none !important;
}
}
/* ── Look: user-app ─────────────────────────────────────────── */
// A white card with a hairline border and the 16px radius the detail page's
// spec table uses; the border and chevron take the brand green when open.
.dd {
border: 1px solid @hairline;
border-radius: 16px;
background: @canvas;
transition: border-color 0.2s ease;
}
.dd.is-open {
border-color: alpha(@primary, 28%);
}
.dd__head {
padding: 15px;
}
.dd__title {
font-size: 13.5px;
font-weight: 800;
color: @ink;
}
.dd__chevron {
color: @inkFaint;
}
.dd.is-open .dd__chevron {
color: @primary;
}
.dd__body {
overflow: hidden;
padding-top: 15px;
}
</style>
<style lang="less">
.drop-enter-active {
animation: drop 0.2s cubic-bezier(0.33, 1, 0.68, 1);
}
.drop-leave-to {
animation: drop 0.2s cubic-bezier(0.33, 1, 0.68, 1) reverse;
}
@keyframes drop {
0% {
max-height: 0;
}
100% {
max-height: 240px;
}
padding: 0 15px 15px;
}
</style>
+1 -1
View File
@@ -57,7 +57,7 @@ function onBack() {
left: 0;
right: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.88);
background: alpha(@canvas, 88%);
backdrop-filter: blur(14px);
border-bottom: 1px solid @hairline;
}
+11 -11
View File
@@ -29,7 +29,7 @@
<span
v-if="item.duration > 0"
class="toast__progress"
class="toast__progress keep-motion"
:style="{ animationDuration: `${item.duration}ms` }"
@animationend="emit('close')"
/>
@@ -132,14 +132,14 @@ function onUp() {
// Same glass recipe as the bottom nav, so the two read as one system.
background:
radial-gradient(120% 160% at 0% 0%, fade(@canvas, 60%) 0%, fade(@canvas, 0%) 55%),
radial-gradient(80% 140% at 100% 100%, fade(@ink, 4%) 0%, fade(@ink, 0%) 60%),
linear-gradient(160deg, fade(@canvas, 72%) 0%, fade(@canvas, 50%) 100%);
radial-gradient(120% 160% at 0% 0%, alpha(@canvas, 60%) 0%, alpha(@canvas, 0%) 55%),
radial-gradient(80% 140% at 100% 100%, alpha(@ink, 4%) 0%, alpha(@ink, 0%) 60%),
linear-gradient(160deg, alpha(@canvas, 72%) 0%, alpha(@canvas, 50%) 100%);
-webkit-backdrop-filter: blur(24px) saturate(200%) brightness(1.05);
backdrop-filter: blur(24px) saturate(200%) brightness(1.05);
box-shadow:
inset 0 1px 1px fade(@canvas, 90%),
inset 0 0 0 0.5px fade(@canvas, 40%),
inset 0 1px 1px alpha(@canvas, 90%),
inset 0 0 0 0.5px alpha(@canvas, 40%),
0 18px 40px -16px rgba(20, 22, 26, 0.35),
0 1px 3px rgba(20, 22, 26, 0.06);
@@ -152,10 +152,10 @@ function onUp() {
border-radius: inherit;
background: linear-gradient(
145deg,
fade(@canvas, 100%) 0%,
fade(@canvas, 30%) 40%,
fade(@canvas, 10%) 70%,
fade(@canvas, 70%) 100%
alpha(@canvas, 100%) 0%,
alpha(@canvas, 30%) 40%,
alpha(@canvas, 10%) 70%,
alpha(@canvas, 70%) 100%
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
@@ -189,7 +189,7 @@ function onUp() {
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.toast {
background: fade(@canvas, 96%);
background: alpha(@canvas, 96%);
}
}
@@ -0,0 +1,59 @@
<!--
The visual half of an on/off switch: a track and a knob. Deliberately not a
control on its own — the row that holds it is the <button role="switch">,
so the whole row is one tap target and screen readers announce one switch
(see views/user/AppearanceView.vue).
STRUCTURE IS SHARED, LOOK IS NOT: identical markup and script in user-app
and user-app-v2; only the "Look" block differs, per app.
-->
<template>
<span class="tsw" :class="{ 'is-on': on }" aria-hidden="true">
<span class="tsw__knob"></span>
</span>
</template>
<script setup lang="ts">
defineProps<{ on: boolean }>()
</script>
<style scoped lang="less">
/* ── Mechanics (same in both apps) ──────────────────────────── */
.tsw {
position: relative;
flex: 0 0 auto;
display: inline-block;
width: 46px;
height: 28px;
border-radius: 999px;
transition: background-color 0.22s ease;
}
.tsw__knob {
position: absolute;
top: 3px;
// RTL page: "off" sits on the right (start), "on" slides to the left (end).
inset-inline-start: 3px;
width: 22px;
height: 22px;
border-radius: 50%;
box-shadow: 0 2px 6px -1px rgba(20, 22, 26, 0.35);
transition: transform 0.26s cubic-bezier(0.32, 0.72, 0, 1), background-color 0.22s ease;
}
.tsw.is-on .tsw__knob {
transform: translateX(-18px);
}
/* ── Look: user-app ─────────────────────────────────────────── */
// The app's chosen state is brand green (@primary, as on chips and CTAs).
.tsw {
background: @lightGray1;
}
.tsw__knob {
background: #fff;
}
.tsw.is-on {
background: @primary;
}
</style>
@@ -121,11 +121,11 @@ async function onPick(event: Event) {
.is-done .siu__box {
border-style: solid;
border-color: #16a34a;
border-color: @green;
}
.is-error .siu__box {
border-color: #dc2626;
border-color: @red;
}
.siu__preview {
@@ -155,12 +155,12 @@ async function onPick(event: Event) {
left: 10px;
z-index: 1;
display: flex;
color: #16a34a;
color: @green;
}
.siu__error {
margin: 7px 2px 0;
font-size: 12px;
color: #dc2626;
color: @red;
}
</style>
@@ -4,22 +4,40 @@
// so this fetches the full list and finds the matching product client-side.
// Wasteful for a large catalog, but honest: no fabricated endpoint, no silent
// behavior change.
//
// `initial` seeds the page with the ProductSummary the tapped card already
// holds — the same shape, from the same endpoint — so the detail view renders
// complete on its first frame instead of mounting blank and jumping when the
// fetch lands. The fetch still runs, silently, and only replaces the product
// if the server's copy actually differs (stock moved since the list loaded).
//
// The silent refresh waits until the opening morph (ProductMorph.vue, ≤420ms)
// is over: the response is the whole catalogue, and parsing it — or swapping
// the product and re-rendering — mid-animation drops frames.
import { ref } from 'vue'
import { api, type ProductSummary } from '../api/client'
export function useProductDetail(id: string) {
const SEEDED_REFRESH_DELAY_MS = 600
export function useProductDetail(id: string, initial?: ProductSummary | null) {
const loading = ref(false)
const flag = ref(false)
const flag = ref(Boolean(initial))
const notFound = ref(false)
const productRef = ref<ProductSummary | null>(null)
const productRef = ref<ProductSummary | null>(initial ?? null)
const load = async () => {
loading.value = true
const seeded = productRef.value !== null
if (seeded) await new Promise((resolve) => setTimeout(resolve, SEEDED_REFRESH_DELAY_MS))
else loading.value = true
try {
const products = await api.listProducts()
const found = products.find((p) => p.id === id) ?? null
productRef.value = found
notFound.value = !found
if (seeded) {
if (found && JSON.stringify(found) !== JSON.stringify(productRef.value)) productRef.value = found
} else {
productRef.value = found
notFound.value = !found
}
flag.value = true
} catch (e) {
console.log(e)
@@ -0,0 +1,43 @@
// Opens the product detail as a morph out of the card that was tapped, in
// place of the old full-height bottom Drawer (which slid up from off-screen and
// then jumped once the detail fetch landed). One overlay for the whole app —
// components/product/ProductMorph.vue, mounted once in App.vue — instead of a
// Drawer per card: ProductCard used to carry its own, so every catalogue grid
// rendered one per product.
//
// Callers only say WHICH product. The card it came from is found from the tap
// itself: every card root is marked `data-product-origin`, and the last
// pointerdown is remembered here, so the tap's own target leads back to its
// card without each emit chain (ProductPanel → ProductCarousel → HomeView) having
// to thread an element through. A caller that does hold the element can pass
// it; with neither (keyboard activation), the overlay falls back to a plain
// rise-and-fade instead of a morph.
import { shallowRef } from 'vue'
import type { ProductSummary } from '../api/client'
export interface ProductMorphRequest {
product: ProductSummary
origin: HTMLElement | null
}
export const productMorph = shallowRef<ProductMorphRequest | null>(null)
let lastPointerTarget: Element | null = null
if (typeof document !== 'undefined') {
document.addEventListener(
'pointerdown',
(e) => {
lastPointerTarget = e.target instanceof Element ? e.target : null
},
{ capture: true, passive: true },
)
}
export function openProduct(product: ProductSummary, origin?: HTMLElement | null) {
const fromTap = lastPointerTarget?.closest<HTMLElement>('[data-product-origin]') ?? null
productMorph.value = { product, origin: origin ?? fromTap }
}
export function closeProduct() {
productMorph.value = null
}
+7
View File
@@ -5,6 +5,7 @@ import App from './App.vue';
import { router } from './router';
import { setUnauthorizedHandler } from './api/client';
import { useSessionStore } from './stores/session';
import { useAppearanceStore } from './stores/appearanceStore';
import Container from './components/ui/Container.vue';
// TODO: temporary placeholder — swap Vector.svg back in (or a proper loading
// mark) once one exists; montzar-logo.png is the brand logo, borrowed here
@@ -14,6 +15,9 @@ import Vector from './assets/img/montzar-logo.png';
// Global styles, ported from legacy henza_pwa/src/main.ts — same import order
// (ant-design-vue's own theme first, then our Less overrides on top). Legacy
// imported 'vant/lib/index.css' twice (a dead duplicate) — imported once here.
// The light/dark values behind the theme tokens — first, so every stylesheet
// below resolves var(--c-…) against it. See themeConfig/appearance.ts.
import 'virtual:palette.css';
import './assets/styles/tailwind.css';
import 'ant-design-vue/dist/antd.less';
import './assets/styles/style.less';
@@ -22,6 +26,9 @@ import 'vant/lib/index.css';
const app = createApp(App);
app.use(createPinia());
// Theme + reduced motion. index.html already applied the saved classes before
// first paint; this keeps them in sync (and follows the OS while on "system").
useAppearanceStore().init();
app.use(router);
// A 401 from the API means the customer token is gone or expired. Clear the
+2
View File
@@ -42,6 +42,8 @@ export const router = createRouter({
},
{ path: '/profile', name: 'profileData', component: () => import('../views/user/ProfileView.vue'), meta: { requiresAuth: true } },
{ path: '/favorites', name: 'favoritesList', component: () => import('../views/user/FavoritesView.vue'), meta: { requiresAuth: true } },
// Theme + reduced motion. Device preferences, so no requiresAuth.
{ path: '/settings/appearance', name: 'appearanceSettings', component: () => import('../views/user/AppearanceView.vue') },
// D21 — the sales expert's two screens. Reference paths and names kept
// (henza_pwa router/index.ts:171-188). requiresExpert is enforced by the
// guard below AND by the server, which scopes both endpoints on the
+120
View File
@@ -0,0 +1,120 @@
// Appearance preferences: colour theme and reduced motion.
//
// Persisted in one localStorage key (the cartStore pattern). The same key is
// read by the tiny boot script in index.html, which applies the classes before
// the first paint — so a dark-mode user never sees a white flash while the
// bundle loads. This store re-applies them once the app is up and keeps them
// in sync from then on.
//
// What the classes on <html> drive:
// theme-dark the dark palette (virtual:palette.css, see
// themeConfig/appearance.ts) + color-scheme: dark
// van-theme-dark vant 4's own dark CSS variables
// motion-reduced the "fast & light" motion rules in style.less, plus the
// JS-driven motion that reads isReducedMotion (ProductMorph,
// carousel autoplay)
//
// Kept identical in user-app and user-app-v2.
import { defineStore } from 'pinia'
export type ThemeChoice = 'system' | 'light' | 'dark'
interface Stored {
theme: ThemeChoice
reduceMotion: boolean
}
export const APPEARANCE_STORAGE_KEY = 'gold-bank:appearance'
const darkQuery = () => (typeof matchMedia !== 'undefined' ? matchMedia('(prefers-color-scheme: dark)') : null)
const motionQuery = () => (typeof matchMedia !== 'undefined' ? matchMedia('(prefers-reduced-motion: reduce)') : null)
function load(): Stored {
// First run: theme follows the system; motion follows the OS's own
// "reduce motion" accessibility setting.
const fallback: Stored = { theme: 'system', reduceMotion: !!motionQuery()?.matches }
try {
const raw = localStorage.getItem(APPEARANCE_STORAGE_KEY)
if (!raw) return fallback
const parsed = JSON.parse(raw) as Partial<Stored>
return {
theme: parsed.theme === 'light' || parsed.theme === 'dark' || parsed.theme === 'system' ? parsed.theme : fallback.theme,
reduceMotion: typeof parsed.reduceMotion === 'boolean' ? parsed.reduceMotion : fallback.reduceMotion,
}
} catch {
return fallback
}
}
function persist(value: Stored) {
try {
localStorage.setItem(APPEARANCE_STORAGE_KEY, JSON.stringify(value))
} catch {
// Private mode / storage full: the choice still applies for this session.
}
}
// Status-bar / browser chrome colour. Light keeps whatever index.html ships
// (the brand colour, unchanged); dark uses the dark canvas.
const DARK_THEME_COLOR = '#15171A'
let lightThemeColor: string | null = null
export const useAppearanceStore = defineStore('appearance', {
state: () => ({
...load(),
systemDark: !!darkQuery()?.matches,
}),
getters: {
resolvedTheme: (s): 'light' | 'dark' => (s.theme === 'system' ? (s.systemDark ? 'dark' : 'light') : s.theme),
isReducedMotion: (s) => s.reduceMotion,
},
actions: {
init() {
const q = darkQuery()
q?.addEventListener?.('change', (e) => {
this.systemDark = e.matches
if (this.theme === 'system') this.apply()
})
this.apply(false)
},
setTheme(theme: ThemeChoice) {
if (theme === this.theme) return
this.theme = theme
this.save()
this.apply()
},
setReduceMotion(on: boolean) {
if (on === this.reduceMotion) return
this.reduceMotion = on
this.save()
this.apply(false)
},
save() {
persist({ theme: this.theme, reduceMotion: this.reduceMotion })
},
/**
* Writes the classes onto <html>. `quiet` (default) suppresses every CSS
* transition for the frame the palette flips in: otherwise each element
* with a colour transition would animate from the old theme to the new
* one at once — hundreds of simultaneous transitions, a visible stutter.
*/
apply(quiet = true) {
const root = document.documentElement
const dark = this.resolvedTheme === 'dark'
if (quiet) root.classList.add('theme-switching')
root.classList.toggle('theme-dark', dark)
root.classList.toggle('van-theme-dark', dark)
root.classList.toggle('motion-reduced', this.reduceMotion)
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) {
lightThemeColor ??= meta.getAttribute('data-light') ?? meta.getAttribute('content')
meta.setAttribute('content', dark ? DARK_THEME_COLOR : lightThemeColor ?? '')
}
if (quiet) {
// Two frames: one for the new colours to be computed with transitions
// off, one for them to be painted, then transitions come back.
requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove('theme-switching')))
}
},
},
})
+9 -4
View File
@@ -18,7 +18,12 @@ export interface FiltersQuery {
page: number
}
const defaultQuery: FiltersQuery = {
// A factory, not a shared object. FIXED: this used to be one const that both
// the initial state and reset() spread-copied — a shallow copy, so every
// query shared the SAME arrays. Filters.vue pushed brand ids into
// `query.brands` in place, which wrote them into the default itself, and from
// then on reset() "restored" the selected brands instead of clearing them.
const defaultQuery = (): FiltersQuery => ({
colors: [],
brands: [],
ciny_id: [],
@@ -28,13 +33,13 @@ const defaultQuery: FiltersQuery = {
text: undefined,
cats: undefined,
page: 0,
}
})
export const useFiltersStore = defineStore('filters', {
state: () => ({ query: { ...defaultQuery } as FiltersQuery }),
state: () => ({ query: defaultQuery() }),
actions: {
reset() {
this.query = { ...defaultQuery }
this.query = defaultQuery()
},
},
})
+10 -5
View File
@@ -20,12 +20,17 @@ export const ORDER_STATUS_LABELS: Record<string, string> = {
// colored sts 1,3,4,5,6 (never sts=0 or sts=2), an existing gap preserved here
// for the directly-analogous statuses (ADMIN_APPROVED, SUSPENDED, CANCELLED
// stay uncolored, same as legacy's untouched sts=0/2).
//
// Backgrounds are the font colour at 12% alpha (the trailing 1F), not the
// opaque pale tints they used to be: a translucent wash reads the same on a
// white card and stays a soft tint on a dark one, where #EFF6FF & co. were
// glaring light patches.
export const ORDER_STATUS_COLORS: Record<string, { font: string; background: string }> = {
PLACED: { font: '#3B82F6', background: '#EFF6FF' },
SOURCING: { font: '#EAB308', background: '#FEFCE8' },
PREPARING: { font: '#F97316', background: '#FFF7ED' },
DELIVERED_TO_AGENT: { font: '#06B6D4', background: '#ECFEFF' },
DELIVERED_TO_CUSTOMER: { font: '#22C55E', background: '#F0FDF4' },
PLACED: { font: '#3B82F6', background: '#3B82F61F' },
SOURCING: { font: '#EAB308', background: '#EAB3081F' },
PREPARING: { font: '#F97316', background: '#F973161F' },
DELIVERED_TO_AGENT: { font: '#06B6D4', background: '#06B6D41F' },
DELIVERED_TO_CUSTOMER: { font: '#22C55E', background: '#22C55E1F' },
};
// Ported from legacy Orders.vue's 4-step tracker (`steps`/`setCurrentStep`).
+5 -5
View File
@@ -121,8 +121,8 @@
</template>
<template v-slot:title>آیا مطمئن به ثبت سفارش هستید ؟</template>
<template v-slot:button>
<div class="text-center text-[#23493c]" @click="submitOrder">بله</div>
<div class="text-center text-[#6b7280]" @click="cartData.notify = false">بی خیال</div>
<div class="text-center text-[color:var(--c-primary)]" @click="submitOrder">بله</div>
<div class="text-center text-[color:var(--c-inkMuted)]" @click="cartData.notify = false">بی خیال</div>
</template>
</DialogVant>
@@ -134,7 +134,7 @@
<div class="p-8 text-justify">برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید.</div>
</template>
<template v-slot:button>
<div class="text-center text-[#23493c]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center text-[color:var(--c-primary)]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center text-danger" @click="loginNotify = false">بیخیال</div>
</template>
</DialogVant>
@@ -240,7 +240,7 @@ onMounted(async () => {
<style scoped lang="less">
.cart {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -347,7 +347,7 @@ onMounted(async () => {
left: 0;
right: 0;
z-index: 5;
background: rgba(255, 255, 255, 0.92);
background: alpha(@canvas, 92%);
backdrop-filter: blur(14px);
border-top: 1px solid @hairline;
padding-bottom: 66px;
+7 -20
View File
@@ -113,27 +113,15 @@
</div>
</container>
</PullRefresh>
<Drawer
v-model:visible="productDrawer"
placement="bottom"
:closable="false"
class="rtl"
height="100vh"
:bodyStyle="{ padding: 0, position: 'relative' }"
>
<SingleProduct v-if="activeProductId" :id="activeProductId" @closeDrawer="productDrawer = false" />
</Drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import Icon from '../components/icon/Icon.vue'
import SingleProduct from '../components/product/SingleProduct.vue'
import { openProduct as openProductMorph } from '../composables/useProductMorph'
import RateStrip from '../components/home/RateStrip.vue'
import ShowcaseSlider from '../components/home/ShowcaseSlider.vue'
import type { ShowcaseSlide } from '../components/home/ShowcaseSlider.vue'
@@ -162,8 +150,6 @@ const goldPrice = ref<GoldPriceQuote | null>(null)
// knows whether to wait or fall back to the category-derived hero.
const adminSlides = ref<AdminSlide[] | null>(null)
const productDrawer = ref(false)
const activeProductId = ref<string | null>(null)
// First-paint entrance: played once, after the first data load, so the
// sections settle into place instead of popping in piecemeal. Pull-to-refresh
@@ -219,9 +205,10 @@ function productImageForCategory(categoryId: string): string | undefined {
return match?.images?.[0]?.url
}
// Opens as a morph out of the tapped card (ProductMorph.vue, mounted in
// App.vue) — the card is found from the tap, so the emit chain stays as is.
function openProduct(product: ProductSummary) {
activeProductId.value = product.id
productDrawer.value = true
openProductMorph(product)
}
function onCategory(cat: HomeCategory) {
@@ -290,7 +277,7 @@ onMounted(async () => {
<style scoped lang="less">
.home {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -300,7 +287,7 @@ onMounted(async () => {
left: 0;
right: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.88);
background: alpha(@canvas, 88%);
backdrop-filter: blur(14px);
border-bottom: 1px solid @hairline;
}
@@ -346,7 +333,7 @@ onMounted(async () => {
font-weight: 700;
color: #fff;
background: @wine;
border: 2px solid #fff;
border: 2px solid @canvas;
}
.home__body {
+1 -1
View File
@@ -73,7 +73,7 @@ const pull = async () => {
<style scoped lang="less">
.newest {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -308,7 +308,7 @@ onBeforeUnmount(() => observer?.disconnect())
font-size: 12px;
font-weight: 600;
color: #06b6d4;
background: #ecfeff;
background: fade(#06b6d4, 12%);
white-space: nowrap;
}
@@ -245,13 +245,13 @@ onMounted(load)
}
.cch__state.is-on {
color: #16a34a;
background: #dcfce7;
color: @green;
background: alpha(@green, 14%);
}
.cch__state.is-off {
color: #dc2626;
background: #fee2e2;
color: @red;
background: alpha(@red, 12%);
}
.cch__facts {
@@ -179,10 +179,10 @@ function rial(value: string): string {
}
.is-in {
color: #16a34a;
color: @green;
}
.is-out {
color: #dc2626;
color: @red;
}
</style>
@@ -360,11 +360,11 @@ onBeforeUnmount(() => observer?.disconnect())
}
.is-in {
color: #16a34a;
color: @green;
}
.is-out {
color: #dc2626;
color: @red;
}
.rep__more,
@@ -67,7 +67,7 @@ onMounted(async () => {
<style scoped lang="less">
.coll {
background: @canvas;
background: @page;
min-height: 100vh;
}
+13 -51
View File
@@ -14,12 +14,12 @@
-->
<template>
<div class="search">
<PageBar :title="showFilter ? 'فیلترها' : 'جستجو'" :back="showFilter" @click.capture="onBarClick">
<PageBar title="جستجو">
<template #below>
<div class="search__barExtra">
<SearchInput @search="search" place-holder="جستجوی دسته بندی، محصولات و ..." />
<div v-if="!showFilter" class="search__chips no-scrollbar">
<div class="search__chips no-scrollbar">
<button
type="button"
class="search__chip search__chip--filter"
@@ -61,15 +61,7 @@
indicator, over the results already on screen.
-->
<div>
<Filters
v-model:filterKeys="filterKeys"
v-show="showFilter"
v-model="filters.query"
:products="allProducts"
@change="hideFilters"
/>
<PullRefresh v-show="!showFilter" v-model="refreshing" @refresh="onRefresh">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
@@ -133,12 +125,20 @@
</container>
</PullRefresh>
</div>
<!-- A bottom sheet over a see-through backdrop (teleported to <body>);
see Filters.vue. The result list stays in place underneath. -->
<Filters
v-model:show="showFilter"
v-model:filterKeys="filterKeys"
v-model="filters.query"
:products="allProducts"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
@@ -148,11 +148,9 @@ import Filters from '../../components/search/Filters.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { useFiltersStore } from '../../stores/filtersStore'
import { useNavStore } from '../../stores/navStore'
import { api, type ProductSummary } from '../../api/client'
const filters = useFiltersStore()
const nav = useNavStore()
// Starts true: the first render happens before onMounted kicks off the fetch,
// and with false the "nothing found" box flashed up for a frame before the
@@ -256,50 +254,14 @@ function setCats(id: string | undefined) {
}
function showFilters() {
showFilter.value = true
nav.setNavVisibility(false)
}
function hideFilters(event?: { resetFilters: boolean }) {
showFilter.value = false
nav.setNavVisibility(true)
if (event?.resetFilters) {
filters.reset()
filterKeys.value = []
}
}
// While the filter sheet is open the bar's back button closes the sheet
// rather than leaving the route, matching the legacy "بازگشت" affordance.
function onBarClick(e: MouseEvent) {
if (!showFilter.value) return
const btn = (e.target as HTMLElement)?.closest('button')
if (btn) {
e.stopPropagation()
hideFilters()
}
}
// The filter sheet deliberately hides the bottom tab bar while it is open, so
// its own fixed action bar ("اعمال فیلتر" / "حذف فیلترها") stays clickable —
// the tab bar sits at the same bottom edge with a higher z-index. But leaving
// the page with the sheet still open (browser/swipe back, or tapping a
// collection tile) never ran hideFilters, so that hide leaked: showNav stayed
// false, and on any destination outside the five tab routes the user was left
// with no tab bar at all until a full page refresh reset the store.
//
// Restoring it here rather than in onBeforeUnmount is deliberate: this guard
// runs BEFORE router.afterEach, which then writes the correct visibility for
// the destination and gets the last word. onBeforeUnmount runs after it, and
// would wrongly re-show the bar on full-page routes like /product/:id.
onBeforeRouteLeave(() => {
if (showFilter.value) nav.setNavVisibility(true)
})
onMounted(loadProducts)
</script>
<style scoped lang="less">
.search {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -0,0 +1,340 @@
<!--
تنظیمات ظاهری — the colour theme and the "fast & light" motion switch.
Reached from the dashboard; open to signed-out users too (these are device
preferences, not account data). State lives in stores/appearanceStore.ts,
which applies it to <html> immediately — nothing to save or confirm.
The theme previews are drawn with FIXED colours, not theme tokens: each one
has to show what that theme looks like, whichever theme is active now.
STRUCTURE IS SHARED, LOOK IS NOT: template and script are identical in
user-app and user-app-v2; only the "Look" block differs, per app.
-->
<template>
<div class="ap">
<PageBar title="تنظیمات ظاهری" back-to="userPage::hasSubRoute" />
<container class="ap__body">
<!-- ── Theme ───────────────────────────────────────────── -->
<section class="ap__section">
<header class="ap__head">
<h2 id="ap-theme" class="ap__title">پوسته برنامه</h2>
<p class="ap__sub">ظاهر روشن یا تیره، یا هماهنگ با تنظیمات گوشی شما</p>
</header>
<div class="ap__themes" role="radiogroup" aria-labelledby="ap-theme">
<button
v-for="opt in THEMES"
:key="opt.value"
type="button"
role="radio"
class="ap__theme"
:class="{ 'is-on': appearance.theme === opt.value }"
:aria-checked="appearance.theme === opt.value"
@click="appearance.setTheme(opt.value)"
>
<span class="ap__preview" :class="`ap__preview--${opt.value}`" aria-hidden="true">
<span class="ap__pvBar"></span>
<span class="ap__pvCard"></span>
<span class="ap__pvLine"></span>
<span class="ap__pvLine ap__pvLine--short"></span>
<span class="ap__pvBtn"></span>
</span>
<span class="ap__themeLabel">
<Icon :icon="opt.icon" size="15" />
{{ opt.label }}
</span>
<span class="ap__check" aria-hidden="true">
<Icon icon="vuesax-linear:tick-circle" size="18" />
</span>
</button>
</div>
<p v-if="appearance.theme === 'system'" class="ap__note">
الان {{ appearance.resolvedTheme === 'dark' ? 'تیره' : 'روشن' }} است، مطابق تنظیمات گوشی.
</p>
</section>
<!-- ── Motion ──────────────────────────────────────────── -->
<section class="ap__section">
<header class="ap__head">
<h2 class="ap__title">انیمیشن‌ها</h2>
<p class="ap__sub">برای سرعت بیشتر روی گوشی‌های ضعیف‌تر</p>
</header>
<!-- The whole row is the switch: one tap target, one announcement. -->
<button
type="button"
role="switch"
class="ap__row"
:aria-checked="appearance.reduceMotion"
@click="appearance.setReduceMotion(!appearance.reduceMotion)"
>
<span class="ap__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:flash" size="18" />
</span>
<span class="ap__rowText">
<b>حالت سبک و سریع</b>
<small>
حرکت‌های سنگین، افکت‌های شیشه‌ای و اسلاید خودکار خاموش می‌شوند؛ بازخورد کوتاه لمس‌ها و جابه‌جایی‌های سریع
باقی می‌مانند تا برنامه خشک و بی‌روح نشود.
</small>
</span>
<ToggleSwitch :on="appearance.reduceMotion" />
</button>
</section>
</container>
</div>
</template>
<script setup lang="ts">
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import ToggleSwitch from '../../components/ui/ToggleSwitch.vue'
import { useAppearanceStore, type ThemeChoice } from '../../stores/appearanceStore'
const appearance = useAppearanceStore()
const THEMES: { value: ThemeChoice; label: string; icon: string }[] = [
{ value: 'light', label: 'روشن', icon: 'vuesax-linear:sun-1' },
{ value: 'dark', label: 'تیره', icon: 'vuesax-linear:moon' },
{ value: 'system', label: 'مطابق سیستم', icon: 'vuesax-linear:monitor-mobbile' },
]
</script>
<style scoped lang="less">
/* ── Structure (same in both apps) ──────────────────────────── */
.ap {
min-height: 100vh;
}
.ap__body {
padding-top: 76px;
padding-bottom: 90px;
display: flex;
flex-direction: column;
gap: 26px;
}
.ap__title,
.ap__sub,
.ap__note {
margin: 0;
}
.ap__themes {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.ap__theme {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
cursor: pointer;
font-family: inherit;
-webkit-tap-highlight-color: transparent;
transition: transform 0.15s ease, border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
.ap__theme:active {
transform: scale(0.97);
}
.ap__themeLabel {
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.ap__check {
position: absolute;
top: 8px;
inset-inline-end: 8px;
display: grid;
place-items: center;
opacity: 0;
transform: scale(0.6);
transition: opacity 0.18s ease, transform 0.22s cubic-bezier(0.32, 0.72, 0, 1);
}
.ap__theme.is-on .ap__check {
opacity: 1;
transform: none;
}
// Mini screen, fixed colours on purpose (see the header).
.ap__preview {
position: relative;
width: 100%;
aspect-ratio: 3 / 4;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 6%;
padding: 10% 12%;
--pv-bg: #ffffff;
--pv-bar: #f4f5f6;
--pv-card: #ecedef;
--pv-line: #dadce0;
--pv-btn: #14161a;
background: var(--pv-bg);
}
.ap__preview--dark {
--pv-bg: #16171a;
--pv-bar: #1e2024;
--pv-card: #2b2e34;
--pv-line: #3a3e45;
--pv-btn: #f1f2f4;
}
// System: the light screen with its lower-left half in dark.
.ap__preview--system::after {
content: '';
position: absolute;
inset: 0;
background: #16171a;
clip-path: polygon(100% 0, 100% 100%, 0 100%);
opacity: 0.94;
}
.ap__pvBar {
height: 9%;
border-radius: 4px;
background: var(--pv-bar);
}
.ap__pvCard {
flex: 1;
border-radius: 7px;
background: var(--pv-card);
}
.ap__pvLine {
height: 6%;
border-radius: 3px;
background: var(--pv-line);
}
.ap__pvLine--short {
width: 60%;
}
.ap__pvBtn {
height: 11%;
border-radius: 999px;
background: var(--pv-btn);
}
.ap__row {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
font-family: inherit;
text-align: start;
-webkit-tap-highlight-color: transparent;
transition: transform 0.15s ease, border-color 0.2s ease;
}
.ap__row:active {
transform: scale(0.985);
}
.ap__rowIcon {
flex: 0 0 auto;
display: grid;
place-items: center;
}
.ap__rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
/* ── Look: user-app ─────────────────────────────────────────── */
// White hairline-bordered cards at the 16px radius on the soft-grey ground,
// chosen = a brand-green border with a green check (the app's chips and
// variant picker); the motion row is a card with the dashboard tiles' grey
// icon tile.
.ap {
background: @canvasSoft;
}
.ap__head {
margin-bottom: 13px;
}
.ap__title {
font-size: 15px;
font-weight: 800;
color: @ink;
letter-spacing: -0.1px;
}
.ap__sub {
margin-top: 5px;
font-size: 11.5px;
color: @inkFaint;
}
.ap__note {
margin-top: 10px;
font-size: 11.5px;
color: @inkMuted;
}
.ap__theme {
padding: 8px 8px 11px;
border: 1px solid @hairline;
border-radius: 16px;
background: @canvas;
color: @inkMuted;
}
.ap__theme.is-on {
color: @ink;
border-color: @primary;
box-shadow: 0 0 0 1px @primary;
}
.ap__preview {
border-radius: 11px;
box-shadow: 0 0 0 1px rgba(20, 22, 26, 0.07);
}
.ap__themeLabel {
font-size: 12px;
font-weight: 700;
}
.ap__check {
width: 22px;
height: 22px;
border-radius: 50%;
color: #fff;
background: @primary;
}
.ap__row {
padding: 14px;
border: 1px solid @hairline;
border-radius: 16px;
background: @canvas;
}
.ap__rowIcon {
width: 38px;
height: 38px;
border-radius: 12px;
color: @inkSoft;
background: @hairlineSoft;
}
.ap__rowText b {
font-size: 13px;
font-weight: 800;
color: @ink;
}
.ap__rowText small {
font-size: 11px;
line-height: 1.9;
color: @inkFaint;
}
</style>
@@ -153,7 +153,7 @@ function finish() {
.cpf__error {
margin: 2px;
font-size: 12.5px;
color: #dc2626;
color: @red;
}
.cpf__submit {
+20 -9
View File
@@ -130,8 +130,8 @@
</template>
<template v-slot:title>آیا می خواهید از برنامه خارج شوید؟</template>
<template v-slot:button>
<div class="text-center text-[#23493c]" @click="logOut">بله</div>
<div class="text-center text-[#6b7280]" @click="data.notify = false">بی خیال</div>
<div class="text-center text-[color:var(--c-primary)]" @click="logOut">بله</div>
<div class="text-center text-[color:var(--c-inkMuted)]" @click="data.notify = false">بی خیال</div>
</template>
</DialogVant>
@@ -146,7 +146,7 @@
</div>
</template>
<template v-slot:button>
<div class="text-center text-[#23493c]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center text-[color:var(--c-primary)]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center text-danger" @click="data.loginNotify = false">بیخیال</div>
</template>
</DialogVant>
@@ -208,11 +208,22 @@ const EXPERT_TILES = [
{ id: 5, icon: 'vuesax-linear:profile-2user', title: 'مشتریان من', disabled: false },
]
const list = computed(() =>
session.isSalesExpert ? [...BASE_TILES, ...EXPERT_TILES] : BASE_TILES,
)
// Appearance (theme + reduced motion) — always last, and open to everyone:
// device preferences, not account data. See goToDetails.
const APPEARANCE_TILE = { id: 7, icon: 'vuesax-linear:brush', title: 'ظاهر', disabled: false }
const list = computed(() => [
...BASE_TILES,
...(session.isSalesExpert ? EXPERT_TILES : []),
APPEARANCE_TILE,
])
function goToDetails(id: number) {
// The only tile that needs no login.
if (id === 7) {
router.push({ name: 'appearanceSettings' })
return
}
if (!session.isLoggedIn) {
data.loginNotify = true
return
@@ -260,7 +271,7 @@ onMounted(async () => {
<style scoped lang="less">
.dash {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -270,7 +281,7 @@ onMounted(async () => {
left: 0;
right: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.88);
background: alpha(@canvas, 88%);
backdrop-filter: blur(14px);
border-bottom: 1px solid @hairline;
}
@@ -368,7 +379,7 @@ onMounted(async () => {
}
.dash__seal.is-creditor {
color: @ink;
color: #14161A;
background: rgba(255, 255, 255, 0.9);
}
@@ -64,7 +64,7 @@ onMounted(async () => {
<style scoped lang="less">
.favs {
background: @canvas;
background: @page;
min-height: 100vh;
}
+1 -1
View File
@@ -204,7 +204,7 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
<style scoped lang="less">
.auth {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -24,7 +24,7 @@ import PageBar from '../../components/ui/PageBar.vue'
<style scoped lang="less">
.notif {
background: @canvas;
background: @page;
min-height: 100vh;
}
+2 -2
View File
@@ -225,7 +225,7 @@ onMounted(loadOrders)
<style scoped lang="less">
.ord {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -313,7 +313,7 @@ onMounted(loadOrders)
gap: 12px;
padding: 11px 16px;
border-bottom: 1px solid @hairline;
background: rgba(255, 255, 255, 0.92);
background: alpha(@canvas, 92%);
backdrop-filter: blur(14px);
}
+2 -2
View File
@@ -125,7 +125,7 @@ onMounted(async () => {
<style scoped lang="less">
.prof {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -179,7 +179,7 @@ onMounted(async () => {
.prof__seal.is-ok {
color: @green;
background: #f0fdf4;
background: alpha(@green, 10%);
}
.prof__seal.is-pending {
@@ -127,7 +127,7 @@ const register = async () => {
<style scoped lang="less">
.auth {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -152,7 +152,7 @@ onMounted(async () => {
<style scoped lang="less">
.tx {
background: @canvas;
background: @page;
min-height: 100vh;
}
@@ -193,7 +193,7 @@ onMounted(async () => {
.tx__mark.is-in {
color: @green;
background: #f0fdf4;
background: alpha(@green, 10%);
}
.tx__mark.is-out {
+154
View File
@@ -0,0 +1,154 @@
// Runtime theming (light / dark) for the LESS design tokens.
//
// The app's colours are LESS variables (@canvas, @ink, @wine, …) injected by
// vite.config.ts through `modifyVars`. Before dark mode they compiled straight
// to hex, so nothing could change at runtime. Now the THEME tokens below are
// injected as `var(--c-<name>)` instead, and their values live in a generated
// stylesheet (`virtual:palette.css`, imported first in main.ts):
//
// :root { --c-canvas: #FFFFFF; --c-canvas-rgb: 255 255 255; … }
// html.theme-dark { --c-canvas: #16171A; --c-canvas-rgb: 22 23 26; … }
//
// Light values come from colors.json (unchanged, still the source of truth and
// still what Tailwind reads), dark values from colors.dark.json. A key missing
// from colors.dark.json simply keeps its light value in dark mode.
//
// Not converted, on purpose: `white`, `black`, `secondary`, `danger` and the
// antd keys in themeConfig.ts. ant-design-vue's own LESS compiles with the
// same modifyVars and runs colour functions (fade/tint/colorPalette) on those,
// which a `var()` would break.
//
// LESS colour functions can't operate on a `var()` either, so app code uses
// `alpha(@token, 40%)` instead of `fade(@token, 40%)` — registered below as a
// LESS plugin; for a theme token it emits `rgb(var(--c-token-rgb) / 40%)`, for
// a real colour it behaves exactly like `fade`.
//
// This file is kept identical in user-app and user-app-v2; only the JSONs
// differ per app.
import type { Plugin } from 'vite'
import light from './colors.json'
import dark from './colors.dark.json'
/** Tokens that change with the theme. Everything else in colors.json stays a
* plain compile-time hex value. */
export const THEME_TOKENS = [
'canvas',
'canvasSoft',
'ink',
'inkSoft',
'inkMuted',
'inkFaint',
'hairline',
'hairlineSoft',
'wine',
'wineDeep',
'wineSoft',
'wineWash',
'primary',
'primaryLighten',
'green',
'red',
'info',
'light',
'grey',
'lightGray',
'lightGray1',
'lightGray2',
] as const
/**
* Semantic tokens added for dark mode, for the places where one light-mode
* token did two jobs. Light values reproduce the old look exactly.
* - onInk: text/icons ON an @ink fill (filled buttons, chosen chips). White in
* light; in dark @ink turns light, so this turns dark.
* - heroSurface: decorative dark cards (rate strip, balance card…) that stay
* dark in both themes, so their white text needs no change.
* - hairlineStrong: a step darker than @hairline (was `darken(@hairline, 7%)`).
* - photoMat: behind product photos. Transparent in light (no change); in
* dark a light mat, so photos shot on white don't sit as white boxes on a
* dark card.
* - page: the screen's own ground, behind every card and panel. Same white
* as @canvas in light; in dark it is the deepest layer, so cards (@canvas)
* and panels (@hairlineSoft) stand off it a step at a time.
* - photoStage: the detail page's gallery backdrop — @hairlineSoft in light,
* the same light mat in dark.
*/
const SEMANTIC_LIGHT: Record<string, string> = {
page: (light as Record<string, string>).canvas,
onInk: '#FFFFFF',
heroSurface: '#14161A',
hairlineStrong: '#DADCE0',
photoMat: 'transparent',
photoStage: (light as Record<string, string>).hairlineSoft,
}
type Palette = Record<string, string>
function rgbChannels(hex: string): string | null {
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim())
if (!m) return null
let h = m[1]
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
const n = parseInt(h, 16)
return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`
}
function block(selector: string, values: Palette): string {
const lines: string[] = []
for (const [name, value] of Object.entries(values)) {
lines.push(` --c-${name}: ${value};`)
const rgb = rgbChannels(value)
if (rgb) lines.push(` --c-${name}-rgb: ${rgb};`)
}
return `${selector} {\n${lines.join('\n')}\n}\n`
}
const lightPalette: Palette = {
...Object.fromEntries(THEME_TOKENS.map((t) => [t, (light as Record<string, string>)[t]])),
...SEMANTIC_LIGHT,
}
const darkPalette: Palette = Object.fromEntries(
Object.entries(dark as Record<string, string>).filter(([k]) => !k.startsWith('//')),
)
export const paletteCss =
block(':root', lightPalette) +
// `color-scheme` lets native bits (inputs, scrollbars, autofill) follow.
block('html.theme-dark', darkPalette).replace('{\n', '{\n color-scheme: dark;\n')
/** LESS variables for every theme/semantic token: `@canvas` → `var(--c-canvas)`. */
export const themeLessVarOverrides: Record<string, string> = Object.fromEntries(
Object.keys(lightPalette).map((t) => [t, `var(--c-${t})`]),
)
/** Raw light hex values, for places that need a real colour at build time
* (PWA manifest). */
export const lightHex = light as Record<string, string>
/** Serves `virtual:palette.css`. */
export function palettePlugin(): Plugin {
const id = 'virtual:palette.css'
const resolved = '\0' + id
return {
name: 'gold-bank-palette',
resolveId: (source) => (source === id ? resolved : undefined),
load: (source) => (source === resolved ? paletteCss : undefined),
}
}
/** LESS plugin adding `alpha(color, amount)` — see the file header. */
export const lessAlphaPlugin = {
install(less: any, _pm: unknown, functions: any) {
functions.add('alpha', function (this: unknown, color: any, amount: any) {
if (color && color.type === 'Color') {
const fade = less.functions.functionRegistry.get('fade')
return fade.call(this, color, amount)
}
const css: string = typeof color?.toCSS === 'function' ? color.toCSS({}) : String(color?.value ?? '')
const m = /var\(\s*--c-([A-Za-z0-9]+)\s*\)/.exec(css)
if (!m) throw new Error(`alpha(): expected a colour or a theme token, got "${css}"`)
const pct = amount.unit && amount.unit.toString() === '%' ? amount.value : amount.value * 100
return new less.tree.Anonymous(`rgb(var(--c-${m[1]}-rgb) / ${pct}%)`)
})
},
}
@@ -0,0 +1,31 @@
{
"//": "Dark-mode values for the theme tokens in appearance.ts; missing keys keep their light value. Built as elevation layers so surfaces separate without shadows: page (deepest) < canvas (cards, sheets, bars) < hairlineSoft (panels, chips) < hairline (borders). v1: @primary (brand green) is both a fill under white text and a text accent, so it lifts to a mid green that works as both.",
"page": "#15171A",
"canvasSoft": "#15171A",
"canvas": "#1F2125",
"hairlineSoft": "#2A2D32",
"hairline": "#3A3D44",
"hairlineStrong": "#4B4F57",
"ink": "#F2F3F5",
"inkSoft": "#E1E3E7",
"inkMuted": "#B2B7C0",
"inkFaint": "#8F95A0",
"wine": "#C9566F",
"wineDeep": "#A8435C",
"wineSoft": "#DC8197",
"wineWash": "#3A222B",
"primary": "#33836A",
"primaryLighten": "#3F9478",
"green": "#45C677",
"red": "#F26161",
"info": "#7C9DE6",
"light": "#1F2125",
"grey": "#9298A2",
"lightGray": "#757A84",
"lightGray1": "#4A4E56",
"lightGray2": "#3A3D44",
"onInk": "#15171A",
"heroSurface": "#2C2F36",
"photoMat": "#FFFFFF",
"photoStage": "#F2F3F5"
}

Some files were not shown because too many files have changed in this diff Show More