feat(user-apps): appearance settings (dark mode + reduced motion), product morph, filter sheet

Applies to both user-app and user-app-v2. Logic and structure are shared;
each app keeps its own look.

Appearance settings
- New "تنظیمات ظاهری" page (/settings/appearance), opened from the
  dashboard and available without login. It offers a light/dark/system
  theme and a "fast & light" motion switch.
- appearanceStore saves the choice to localStorage. A small inline boot
  script in index.html applies it before first paint so there is no
  white flash. The "system" option follows OS changes live.
- New ToggleSwitch component.

Dark mode
- Theme LESS tokens now compile to CSS custom properties. Light/dark
  values come from a generated virtual:palette.css (themeConfig/
  appearance.ts, with colors.json + new colors.dark.json).
- New LESS alpha() plugin replaces fade() on theme tokens.
- New semantic tokens: page, onInk, heroSurface, hairlineStrong,
  photoMat, photoStage.
- The dark palette is layered (page < card < panel < border) so
  surfaces separate without shadows.
- Hard-coded colors were replaced by tokens according to their role.
- antd inputs/text get dark overrides; vant uses its own
  .van-theme-dark.
- Light mode looks the same as before.

Reduced motion
- With html.motion-reduced, transitions are capped at 120ms, keyframe
  loops stop, and backdrop blurs and permanent will-change are dropped.
- Spinners and the toast progress bar (which closes the toast) are
  exempt via .keep-motion.
- Carousel autoplay, the v1 Lottie and the product morph honor the
  setting.

Product detail
- Opening a product is now a container-transform morph from the tapped
  card (ProductMorph). It replaces the bottom Drawer and uses composited
  transform/opacity only.
- The detail view is seeded with the card's data, so it no longer
  opens blank and jumps.

Filters and sheets
- Filters are now a full-width bottom sheet. Selections stay a draft
  until applied, so the result list no longer re-renders while
  dragging the slider.
- DropDown collapse animates grid-template-rows instead of a capped
  max-height.
- v2 IslandSheet is restyled to the v2 design language (API unchanged).

Fixes
- filtersStore.reset() shallow-copied shared arrays, so clearing the
  filters never cleared the selected brands.
This commit is contained in:
s.esmaeili
2026-10-04 16:18:56 +03:30
parent c05352d6fd
commit f441ca8289
102 changed files with 4602 additions and 1053 deletions
@@ -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;