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:
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')))
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)',
|
||||
})
|
||||
|
||||
@@ -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_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],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user