diff --git a/apps/user-app-v2/src/components/home/CategoryGrid.vue b/apps/user-app-v2/src/components/home/CategoryGrid.vue
index 8049d65..2fdcf83 100644
--- a/apps/user-app-v2/src/components/home/CategoryGrid.vue
+++ b/apps/user-app-v2/src/components/home/CategoryGrid.vue
@@ -84,6 +84,14 @@ const emit = defineEmits<{ (e: 'select', cat: HomeCategory): void }>()
object-fit: contain;
}
+// Dark mode: product photos are shot on white, so they sit on a light mat
+// instead of showing as white boxes on a dark card (@photoMat — transparent,
+// i.e. no change, in light mode).
+html.theme-dark .cats__mark img {
+ background: @photoMat;
+ border-radius: 12px;
+}
+
.cats__title {
font-size: 11px;
font-weight: 600;
diff --git a/apps/user-app-v2/src/components/home/HeroCarousel.vue b/apps/user-app-v2/src/components/home/HeroCarousel.vue
index 0f0ce20..28a1091 100644
--- a/apps/user-app-v2/src/components/home/HeroCarousel.vue
+++ b/apps/user-app-v2/src/components/home/HeroCarousel.vue
@@ -50,6 +50,7 @@
+
+
diff --git a/apps/user-app-v2/src/components/product/SingleProduct.vue b/apps/user-app-v2/src/components/product/SingleProduct.vue
index 85c50ba..e887a67 100644
--- a/apps/user-app-v2/src/components/product/SingleProduct.vue
+++ b/apps/user-app-v2/src/components/product/SingleProduct.vue
@@ -321,8 +321,11 @@ import { useFavoritesStore } from '../../stores/favoritesStore'
import { useFiltersStore } from '../../stores/filtersStore'
import { useCartStore, type CartItem } from '../../stores/cartStore'
import { isUserLoggedIn } from '../../utils/checkAccess'
+import type { ProductSummary } from '../../api/client'
-const props = defineProps<{ id: string }>()
+// `initial`: the card's own ProductSummary when opened from a list (see
+// ProductMorph.vue) — lets the page render whole on its first frame.
+const props = defineProps<{ id: string; initial?: ProductSummary | null }>()
const emit = defineEmits<{ (e: 'closeDrawer'): void }>()
const router = useRouter()
@@ -330,7 +333,7 @@ const favorites = useFavoritesStore()
const filters = useFiltersStore()
const cart = useCartStore()
-// Used two ways, as in legacy: embedded in a bottom Drawer by the product
+// Used two ways, as in legacy: embedded in the ProductMorph overlay by the product
// cards, and as a standalone page for deep links (/product/:id). In the drawer
// case the parent listens for closeDrawer; on the routed page there is no
// parent, so fall back to history.back().
@@ -366,7 +369,7 @@ function onScroll() {
headerSolid.value = el.scrollTop > Math.max(galleryHeight - HEADER_SOLID_OFFSET_PX, 0)
}
-const productDetail = useProductDetail(props.id)
+const productDetail = useProductDetail(props.id, props.initial)
const loading = productDetail.loading
const flag = productDetail.flag
@@ -532,7 +535,7 @@ onBeforeUnmount(() => {
height: 100vh;
overflow-y: auto;
overflow-x: hidden;
- background: @canvas;
+ background: @page;
// Momentum scrolling on iOS, and stop a scroll at either end from chaining
// out to the page (or the drawer) behind.
-webkit-overflow-scrolling: touch;
@@ -579,7 +582,7 @@ onBeforeUnmount(() => {
}
.pd__header.is-solid {
- background: rgba(255, 255, 255, 0.88);
+ background: alpha(@canvas, 88%);
backdrop-filter: blur(14px);
border-bottom-color: @hairline;
}
@@ -640,7 +643,7 @@ onBeforeUnmount(() => {
cursor: pointer;
text-decoration: none;
color: @ink;
- background: rgba(255, 255, 255, 0.9);
+ background: alpha(@canvas, 90%);
backdrop-filter: blur(10px) saturate(140%);
box-shadow: 0 10px 26px -16px rgba(20, 22, 26, 0.7);
transition: transform 0.18s ease, color 0.28s ease, background 0.28s ease,
@@ -694,7 +697,7 @@ onBeforeUnmount(() => {
min-height: 320px;
max-height: 460px;
overflow: hidden;
- background: @hairlineSoft;
+ background: @photoStage;
// Horizontal drags are the gallery's; vertical ones still scroll the page.
touch-action: pan-y;
}
@@ -1003,7 +1006,7 @@ onBeforeUnmount(() => {
}
.pd__stock.is-in {
color: @green;
- background: fade(@green, 8%);
+ background: alpha(@green, 8%);
}
.pd__stock.is-pre {
color: @wine;
@@ -1085,7 +1088,7 @@ onBeforeUnmount(() => {
color: @inkFaint;
}
.pd__figure--accent .pd__figureLabel {
- color: fade(@wine, 65%);
+ color: alpha(@wine, 65%);
}
/* ── Variant chips ───────────────────────────────────────── */
@@ -1122,7 +1125,7 @@ onBeforeUnmount(() => {
transform: scale(0.95);
}
.pd__chip.is-on {
- color: #fff;
+ color: @onInk;
background: @ink;
border-color: @ink;
}
@@ -1218,7 +1221,7 @@ onBeforeUnmount(() => {
max-width: 768px;
margin: 0 auto;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
- background: rgba(255, 255, 255, 0.9);
+ background: alpha(@canvas, 90%);
backdrop-filter: blur(18px) saturate(140%);
border-top: 1px solid @hairline;
}
@@ -1304,7 +1307,7 @@ onBeforeUnmount(() => {
font-weight: 800;
white-space: nowrap;
text-decoration: none;
- color: #fff;
+ color: @onInk;
background: @ink;
box-shadow: 0 14px 30px -18px rgba(20, 22, 26, 0.9);
transition: transform 0.18s ease, opacity 0.18s ease;
diff --git a/apps/user-app-v2/src/components/search/Filters.vue b/apps/user-app-v2/src/components/search/Filters.vue
index 9f6360d..19a5ac5 100644
--- a/apps/user-app-v2/src/components/search/Filters.vue
+++ b/apps/user-app-v2/src/components/search/Filters.vue
@@ -13,7 +13,7 @@
- Gram-range filter: real, bounds derived from the actual min/max
rashWeightGrams across all variants (rather than a customer-specific
server-provided bound). Still uses ant-design-vue's Slider for the actual
- drag/range behaviour, restyled via :deep() to the wine accent.
+ drag/range behaviour, restyled to the app's accent.
- DROPPED: color filter — no color concept exists anywhere in the new
Product/ProductVariant schema, not a stubbed-off feature like promotions,
genuinely nothing to filter by.
@@ -23,72 +23,139 @@
concept in the new Customer model).
- DROPPED: discount toggle — no promotions data exists (see useProduct.ts).
- Top padding (120px) accounts for PageBar + the search input row with no
- chips row below it (chips are hidden while this sheet is open) — same
- bar-chrome math SearchView/OrdersView use for their own #below content.
+ Presentation: a bottom sheet over a dimmed, see-through backdrop. Full width
+ and attached to the bottom edge — not a floating island, so it has room to
+ take more filters later — and only as tall as its content, up to 88% of the
+ screen, past which the body scrolls under a fixed header and action bar.
+ It replaced an in-place swap of the search page's body (results hidden with
+ v-show, the bar retitled and its back button hijacked, the tab bar hidden),
+ which was neither a page nor a modal. Closes on the backdrop, a swipe down
+ on its header, the × button, or the hardware/browser back
+ (close-on-popstate).
+
+ STRUCTURE IS SHARED, LOOK IS NOT. The template and script here are kept
+ identical to user-app's Filters.vue; each app styles the same markup in its
+ own design language (this one: white sheet with the 32px top radius the
+ detail page's sheet uses, borderless @hairlineSoft panels via DropDown, @ink
+ pill buttons and chips, squircle controls, @wine accent).
+
+ Choices are a DRAFT until "اعمال فیلتر": nothing touches the filters store
+ while the sheet is open, so dragging the gram slider no longer re-filters and
+ re-renders the whole result grid behind it on every pointer move (it did, and
+ that was most of the lag). Dismissing without applying discards the draft.
+ "پاک کردن فیلترها" clears only what this sheet owns (brands, gram range) —
+ it used to reset the whole query, wiping the search text and the category
+ chip along with it.
-->
-
-
-
-
-
- {{ item.title }}
+
+
+
+
-
-
-
-
+
برندی برای فیلتر موجود نیست.
+
-
-
-
پاک کردن فیلترها
-
اعمال فیلتر
+
+
+
+
+
+
+ کمترین
+ {{ fmt(draftGram[0]) }} گرم
+
+
+ بیشترین
+ {{ fmt(draftGram[1]) }} گرم
+
+
+
+
+
+
+ پاک کردن
+ اعمال فیلتر
+
-
+
-
diff --git a/apps/user-app-v2/src/components/ui/DropDown.vue b/apps/user-app-v2/src/components/ui/DropDown.vue
index ad67f60..3d4b379 100644
--- a/apps/user-app-v2/src/components/ui/DropDown.vue
+++ b/apps/user-app-v2/src/components/ui/DropDown.vue
@@ -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.
-->
-
+
{{ text }}
-
+
+
+
-
-
@@ -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)
-
-
diff --git a/apps/user-app-v2/src/components/ui/IslandSheet.vue b/apps/user-app-v2/src/components/ui/IslandSheet.vue
index 59c82b6..8673201 100644
--- a/apps/user-app-v2/src/components/ui/IslandSheet.vue
+++ b/apps/user-app-v2/src/components/ui/IslandSheet.vue
@@ -1,14 +1,26 @@
@@ -50,11 +62,10 @@
@update:show="emit('update:show', $event)"
@opened="ready = true"
@closed="onClosed"
- @click="onGapClick"
>
-
-
-
-
-
+
+
+
@@ -79,13 +88,15 @@
{{ title }}
{{ description }}
+
-
- {{ confirmText }}
-
{{ cancelText }}
+
+ {{ confirmText }}
+
@@ -96,6 +107,8 @@ import { computed, ref } from 'vue'
import { Popup } from 'vant'
import SheetArt, { type SheetArtName } from './SheetArt.vue'
+// Kept as the original five for API compatibility; v2 renders them as two
+// looks — destructive (wine) and everything else (ink). See the styles.
type Tone = 'ink' | 'wine' | 'navy' | 'gold' | 'green'
const props = withDefaults(
@@ -125,7 +138,6 @@ const artTone: Record
= {
update: 'navy',
success: 'green',
}
-
const resolvedTone = computed(() => props.tone ?? (props.art ? artTone[props.art] : 'ink'))
const ready = ref(false)
@@ -144,12 +156,6 @@ function cancel() {
close()
}
-// The popup is a full-width transparent strip with the card inset inside it;
-// a tap in that inset gap is visually a tap on the backdrop, so treat it as one.
-function onGapClick(e: MouseEvent) {
- if (props.closeOnClickOverlay && e.target === e.currentTarget) close()
-}
-
// Swipe down to dismiss. Only the card moves while dragging; on release it
// either springs back or the popup's own leave transition carries it off
// from wherever the finger left it (reset once fully closed).
@@ -195,14 +201,14 @@ function onTouchEnd() {
diff --git a/apps/user-app-v2/src/components/upload/SingleImageUploader.vue b/apps/user-app-v2/src/components/upload/SingleImageUploader.vue
index a1038ca..0b5a9b8 100644
--- a/apps/user-app-v2/src/components/upload/SingleImageUploader.vue
+++ b/apps/user-app-v2/src/components/upload/SingleImageUploader.vue
@@ -128,11 +128,11 @@ async function onPick(event: Event) {
}
.is-done .siu__box {
- border-bottom-color: #16a34a;
+ border-bottom-color: @green;
}
.is-error .siu__box {
- border-bottom-color: #dc2626;
+ border-bottom-color: @red;
}
.siu__preview {
@@ -167,12 +167,12 @@ async function onPick(event: Event) {
.siu__badge {
flex: 0 0 auto;
display: flex;
- color: #16a34a;
+ color: @green;
}
.siu__error {
margin: 7px 2px 0;
font-size: 12px;
- color: #dc2626;
+ color: @red;
}
diff --git a/apps/user-app-v2/src/composables/useProductDetail.ts b/apps/user-app-v2/src/composables/useProductDetail.ts
index 2b3386d..7aa1709 100644
--- a/apps/user-app-v2/src/composables/useProductDetail.ts
+++ b/apps/user-app-v2/src/composables/useProductDetail.ts
@@ -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(null)
+ const productRef = ref(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)
diff --git a/apps/user-app-v2/src/composables/useProductMorph.ts b/apps/user-app-v2/src/composables/useProductMorph.ts
new file mode 100644
index 0000000..6f3f0b6
--- /dev/null
+++ b/apps/user-app-v2/src/composables/useProductMorph.ts
@@ -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(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('[data-product-origin]') ?? null
+ productMorph.value = { product, origin: origin ?? fromTap }
+}
+
+export function closeProduct() {
+ productMorph.value = null
+}
diff --git a/apps/user-app-v2/src/main.ts b/apps/user-app-v2/src/main.ts
index e1c1eae..440a161 100644
--- a/apps/user-app-v2/src/main.ts
+++ b/apps/user-app-v2/src/main.ts
@@ -5,12 +5,16 @@ import App from './App.vue';
import { router } from './router';
import { setUnauthorizedHandler } from './api/client';
import { useSessionStore } from './stores/session';
+import { useAppearanceStore } from './stores/appearanceStore';
import Container from './components/ui/Container.vue';
import Vector from './assets/img/Vector.svg';
// Global styles, ported from legacy henza_pwa/src/main.ts — same import order
// (ant-design-vue's own theme first, then our Less overrides on top). Legacy
// imported 'vant/lib/index.css' twice (a dead duplicate) — imported once here.
+// The light/dark values behind the theme tokens — first, so every stylesheet
+// below resolves var(--c-…) against it. See themeConfig/appearance.ts.
+import 'virtual:palette.css';
import './assets/styles/tailwind.css';
import 'ant-design-vue/dist/antd.less';
import './assets/styles/style.less';
@@ -19,6 +23,9 @@ import 'vant/lib/index.css';
const app = createApp(App);
app.use(createPinia());
+// Theme + reduced motion. index.html already applied the saved classes before
+// first paint; this keeps them in sync (and follows the OS while on "system").
+useAppearanceStore().init();
app.use(router);
// A 401 from the API means the customer token is gone or expired. Clear the
diff --git a/apps/user-app-v2/src/router/index.ts b/apps/user-app-v2/src/router/index.ts
index 8a0910d..a2c5181 100644
--- a/apps/user-app-v2/src/router/index.ts
+++ b/apps/user-app-v2/src/router/index.ts
@@ -37,6 +37,8 @@ export const router = createRouter({
},
{ path: '/profile', name: 'profileData', component: () => import('../views/user/ProfileView.vue'), meta: { requiresAuth: true } },
{ path: '/favorites', name: 'favoritesList', component: () => import('../views/user/FavoritesView.vue'), meta: { requiresAuth: true } },
+ // Theme + reduced motion. Device preferences, so no requiresAuth.
+ { path: '/settings/appearance', name: 'appearanceSettings', component: () => import('../views/user/AppearanceView.vue') },
// D21 — the sales expert's two screens. Reference paths and names kept
// (henza_pwa router/index.ts:171-188). requiresExpert is enforced by the
// guard below AND by the server, which scopes both endpoints on the
diff --git a/apps/user-app-v2/src/stores/appearanceStore.ts b/apps/user-app-v2/src/stores/appearanceStore.ts
new file mode 100644
index 0000000..aee27a6
--- /dev/null
+++ b/apps/user-app-v2/src/stores/appearanceStore.ts
@@ -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 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
+ 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 . `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')))
+ }
+ },
+ },
+})
diff --git a/apps/user-app-v2/src/stores/filtersStore.ts b/apps/user-app-v2/src/stores/filtersStore.ts
index 83f5991..419360c 100644
--- a/apps/user-app-v2/src/stores/filtersStore.ts
+++ b/apps/user-app-v2/src/stores/filtersStore.ts
@@ -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()
},
},
})
diff --git a/apps/user-app-v2/src/utils/orderStatus.ts b/apps/user-app-v2/src/utils/orderStatus.ts
index 455966a..ff66d1a 100644
--- a/apps/user-app-v2/src/utils/orderStatus.ts
+++ b/apps/user-app-v2/src/utils/orderStatus.ts
@@ -20,12 +20,17 @@ export const ORDER_STATUS_LABELS: Record = {
// 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 = {
- 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`).
diff --git a/apps/user-app-v2/src/views/CartView.vue b/apps/user-app-v2/src/views/CartView.vue
index 672cfbc..7a13fca 100644
--- a/apps/user-app-v2/src/views/CartView.vue
+++ b/apps/user-app-v2/src/views/CartView.vue
@@ -257,7 +257,7 @@ onMounted(async () => {
diff --git a/apps/user-app-v2/src/views/HomeView.vue b/apps/user-app-v2/src/views/HomeView.vue
index cc7e60b..0ca1e79 100644
--- a/apps/user-app-v2/src/views/HomeView.vue
+++ b/apps/user-app-v2/src/views/HomeView.vue
@@ -76,27 +76,15 @@
-
-
-
-
diff --git a/apps/user-app-v2/src/views/user/CompleteProfileView.vue b/apps/user-app-v2/src/views/user/CompleteProfileView.vue
index 39b16d5..45a3e4a 100644
--- a/apps/user-app-v2/src/views/user/CompleteProfileView.vue
+++ b/apps/user-app-v2/src/views/user/CompleteProfileView.vue
@@ -153,7 +153,7 @@ function finish() {
.cpf__error {
margin: 12px 2px 0;
font-size: 12.5px;
- color: #dc2626;
+ color: @red;
}
.cpf__submit {
diff --git a/apps/user-app-v2/src/views/user/DashboardView.vue b/apps/user-app-v2/src/views/user/DashboardView.vue
index 934c0b3..058368a 100644
--- a/apps/user-app-v2/src/views/user/DashboardView.vue
+++ b/apps/user-app-v2/src/views/user/DashboardView.vue
@@ -116,7 +116,7 @@
سفارشات
پیگیری سفارشهای شما
-
+
@@ -127,7 +127,7 @@
اسناد
تراکنشها و رسیدهای حساب
-
+
@@ -138,7 +138,7 @@
پروفایل
اطلاعات و تنظیمات حساب
-
+
@@ -149,7 +149,7 @@
علاقمندی
محصولات ذخیرهشده شما
-
+
+
+
+
+
+
+ تنظیمات ظاهری
+ پوسته روشن و تیره، انیمیشنها
+
+
+
+
طلای منتزر
-
+
diff --git a/apps/user-app/src/App.vue b/apps/user-app/src/App.vue
index adb20b1..ad0ee9e 100644
--- a/apps/user-app/src/App.vue
+++ b/apps/user-app/src/App.vue
@@ -21,10 +21,11 @@
+
@@ -34,10 +35,11 @@
- به روز رسانی
+ به روز رسانی
+
@@ -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(() => {
diff --git a/apps/user-app/src/assets/styles/style.less b/apps/user-app/src/assets/styles/style.less
index 6874567..281a222 100644
--- a/apps/user-app/src/assets/styles/style.less
+++ b/apps/user-app/src/assets/styles/style.less
@@ -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 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
+// 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;
+}
diff --git a/apps/user-app/src/components/DialogVant.vue b/apps/user-app/src/components/DialogVant.vue
index 3bd4bce..9ffd7d1 100644
--- a/apps/user-app/src/components/DialogVant.vue
+++ b/apps/user-app/src/components/DialogVant.vue
@@ -17,7 +17,7 @@
{{ text1 }}
-
@@ -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;
}
diff --git a/apps/user-app/src/components/DialogVantOneButton.vue b/apps/user-app/src/components/DialogVantOneButton.vue
index efb6c65..70ad7c3 100644
--- a/apps/user-app/src/components/DialogVantOneButton.vue
+++ b/apps/user-app/src/components/DialogVantOneButton.vue
@@ -17,7 +17,7 @@
{{ text1 }}
-
@@ -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;
}
diff --git a/apps/user-app/src/components/Spinner.vue b/apps/user-app/src/components/Spinner.vue
index 5617189..76e0eb0 100644
--- a/apps/user-app/src/components/Spinner.vue
+++ b/apps/user-app/src/components/Spinner.vue
@@ -3,7 +3,8 @@
-
+
+
diff --git a/apps/user-app/src/components/home/CategoryRail.vue b/apps/user-app/src/components/home/CategoryRail.vue
index 042b5c4..28f1c8c 100644
--- a/apps/user-app/src/components/home/CategoryRail.vue
+++ b/apps/user-app/src/components/home/CategoryRail.vue
@@ -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;
}
diff --git a/apps/user-app/src/components/home/ShowcaseCard.vue b/apps/user-app/src/components/home/ShowcaseCard.vue
index 9d12202..861c535 100644
--- a/apps/user-app/src/components/home/ShowcaseCard.vue
+++ b/apps/user-app/src/components/home/ShowcaseCard.vue
@@ -8,7 +8,7 @@
order time, not stored on the product — printing one here would be invented.
-->
-
+
@@ -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;
}
diff --git a/apps/user-app/src/components/home/ShowcaseSlider.vue b/apps/user-app/src/components/home/ShowcaseSlider.vue
index e7b000f..a244141 100644
--- a/apps/user-app/src/components/home/ShowcaseSlider.vue
+++ b/apps/user-app/src/components/home/ShowcaseSlider.vue
@@ -51,6 +51,7 @@
+
+
diff --git a/apps/user-app/src/components/product/SingleProduct.vue b/apps/user-app/src/components/product/SingleProduct.vue
index 612e590..3b7d21b 100644
--- a/apps/user-app/src/components/product/SingleProduct.vue
+++ b/apps/user-app/src/components/product/SingleProduct.vue
@@ -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;
diff --git a/apps/user-app/src/components/search/Filters.vue b/apps/user-app/src/components/search/Filters.vue
index 838ab7b..1d51ffe 100644
--- a/apps/user-app/src/components/search/Filters.vue
+++ b/apps/user-app/src/components/search/Filters.vue
@@ -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.
-->
-
-
-
-
-
- {{ item.title }}
+
+
+
+
-
-
-
-
+
برندی برای فیلتر موجود نیست.
+
-
-
-
پاک کردن فیلترها
-
اعمال فیلتر
+
+
+
+
+
+
+ کمترین
+ {{ fmt(draftGram[0]) }} گرم
+
+
+ بیشترین
+ {{ fmt(draftGram[1]) }} گرم
+
+
+
+
+
+
+ پاک کردن
+ اعمال فیلتر
+
-
+
-
diff --git a/apps/user-app/src/components/ui/DropDown.vue b/apps/user-app/src/components/ui/DropDown.vue
index ad67f60..4085080 100644
--- a/apps/user-app/src/components/ui/DropDown.vue
+++ b/apps/user-app/src/components/ui/DropDown.vue
@@ -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.
-->
-
+
{{ text }}
-
+
+
+
-
-
@@ -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)
-
-
diff --git a/apps/user-app/src/components/ui/PageBar.vue b/apps/user-app/src/components/ui/PageBar.vue
index 391226f..f6e2eaa 100644
--- a/apps/user-app/src/components/ui/PageBar.vue
+++ b/apps/user-app/src/components/ui/PageBar.vue
@@ -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;
}
diff --git a/apps/user-app/src/components/ui/ToastCard.vue b/apps/user-app/src/components/ui/ToastCard.vue
index 0df89a0..2d93b02 100644
--- a/apps/user-app/src/components/ui/ToastCard.vue
+++ b/apps/user-app/src/components/ui/ToastCard.vue
@@ -29,7 +29,7 @@
@@ -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%);
}
}
diff --git a/apps/user-app/src/components/ui/ToggleSwitch.vue b/apps/user-app/src/components/ui/ToggleSwitch.vue
new file mode 100644
index 0000000..c44f54b
--- /dev/null
+++ b/apps/user-app/src/components/ui/ToggleSwitch.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/user-app/src/components/upload/SingleImageUploader.vue b/apps/user-app/src/components/upload/SingleImageUploader.vue
index c2c2f5c..11d545e 100644
--- a/apps/user-app/src/components/upload/SingleImageUploader.vue
+++ b/apps/user-app/src/components/upload/SingleImageUploader.vue
@@ -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;
}
diff --git a/apps/user-app/src/composables/useProductDetail.ts b/apps/user-app/src/composables/useProductDetail.ts
index 2b3386d..7aa1709 100644
--- a/apps/user-app/src/composables/useProductDetail.ts
+++ b/apps/user-app/src/composables/useProductDetail.ts
@@ -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(null)
+ const productRef = ref(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)
diff --git a/apps/user-app/src/composables/useProductMorph.ts b/apps/user-app/src/composables/useProductMorph.ts
new file mode 100644
index 0000000..6f3f0b6
--- /dev/null
+++ b/apps/user-app/src/composables/useProductMorph.ts
@@ -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(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('[data-product-origin]') ?? null
+ productMorph.value = { product, origin: origin ?? fromTap }
+}
+
+export function closeProduct() {
+ productMorph.value = null
+}
diff --git a/apps/user-app/src/main.ts b/apps/user-app/src/main.ts
index adfdc54..36f97cf 100644
--- a/apps/user-app/src/main.ts
+++ b/apps/user-app/src/main.ts
@@ -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
diff --git a/apps/user-app/src/router/index.ts b/apps/user-app/src/router/index.ts
index 747e1c6..600df1c 100644
--- a/apps/user-app/src/router/index.ts
+++ b/apps/user-app/src/router/index.ts
@@ -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
diff --git a/apps/user-app/src/stores/appearanceStore.ts b/apps/user-app/src/stores/appearanceStore.ts
new file mode 100644
index 0000000..aee27a6
--- /dev/null
+++ b/apps/user-app/src/stores/appearanceStore.ts
@@ -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 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
+ 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 . `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')))
+ }
+ },
+ },
+})
diff --git a/apps/user-app/src/stores/filtersStore.ts b/apps/user-app/src/stores/filtersStore.ts
index 83f5991..419360c 100644
--- a/apps/user-app/src/stores/filtersStore.ts
+++ b/apps/user-app/src/stores/filtersStore.ts
@@ -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()
},
},
})
diff --git a/apps/user-app/src/utils/orderStatus.ts b/apps/user-app/src/utils/orderStatus.ts
index 455966a..ff66d1a 100644
--- a/apps/user-app/src/utils/orderStatus.ts
+++ b/apps/user-app/src/utils/orderStatus.ts
@@ -20,12 +20,17 @@ export const ORDER_STATUS_LABELS: Record = {
// 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 = {
- 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`).
diff --git a/apps/user-app/src/views/CartView.vue b/apps/user-app/src/views/CartView.vue
index 9238667..62baf2b 100644
--- a/apps/user-app/src/views/CartView.vue
+++ b/apps/user-app/src/views/CartView.vue
@@ -121,8 +121,8 @@
آیا مطمئن به ثبت سفارش هستید ؟
- بله
- بی خیال
+ بله
+ بی خیال
@@ -134,7 +134,7 @@
برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید.
- ورود/ثبت نام
+ ورود/ثبت نام
بیخیال
@@ -240,7 +240,7 @@ onMounted(async () => {
diff --git a/apps/user-app/src/views/report/ReportView.vue b/apps/user-app/src/views/report/ReportView.vue
index dad250c..84581a0 100644
--- a/apps/user-app/src/views/report/ReportView.vue
+++ b/apps/user-app/src/views/report/ReportView.vue
@@ -360,11 +360,11 @@ onBeforeUnmount(() => observer?.disconnect())
}
.is-in {
- color: #16a34a;
+ color: @green;
}
.is-out {
- color: #dc2626;
+ color: @red;
}
.rep__more,
diff --git a/apps/user-app/src/views/search/CollectionView.vue b/apps/user-app/src/views/search/CollectionView.vue
index b86ff10..5227520 100644
--- a/apps/user-app/src/views/search/CollectionView.vue
+++ b/apps/user-app/src/views/search/CollectionView.vue
@@ -67,7 +67,7 @@ onMounted(async () => {
diff --git a/apps/user-app/src/views/user/CompleteProfileView.vue b/apps/user-app/src/views/user/CompleteProfileView.vue
index 72bbd08..600f824 100644
--- a/apps/user-app/src/views/user/CompleteProfileView.vue
+++ b/apps/user-app/src/views/user/CompleteProfileView.vue
@@ -153,7 +153,7 @@ function finish() {
.cpf__error {
margin: 2px;
font-size: 12.5px;
- color: #dc2626;
+ color: @red;
}
.cpf__submit {
diff --git a/apps/user-app/src/views/user/DashboardView.vue b/apps/user-app/src/views/user/DashboardView.vue
index 6305e97..a9620f0 100644
--- a/apps/user-app/src/views/user/DashboardView.vue
+++ b/apps/user-app/src/views/user/DashboardView.vue
@@ -130,8 +130,8 @@
آیا می خواهید از برنامه خارج شوید؟
- بله
- بی خیال
+ بله
+ بی خیال
@@ -146,7 +146,7 @@
- ورود/ثبت نام
+ ورود/ثبت نام
بیخیال
@@ -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 () => {