Files
tehran_taj/apps/user-app-v2/src/components/ui/ToastCard.vue
T
s.esmaeili f441ca8289 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.
2026-10-04 16:18:56 +03:30

326 lines
7.5 KiB
Vue

<!--
One toast: a glass card in the same family as the bottom nav — a square
icon tile on the start side, title + message, and a thin countdown line.
The countdown is a CSS animation; its `animationend` is what closes the
toast, so pausing it (finger held on the card) pauses the timer too, with
no JS clock to keep in sync. Swipe sideways or up to throw it away.
-->
<template>
<div
class="toast"
:class="[`toast--${item.type}`, { 'is-held': held, 'is-dragging': dragging }]"
:style="dragStyle"
role="status"
@pointerdown="onDown"
@pointermove="onMove"
@pointerup="onUp"
@pointercancel="onUp"
>
<span class="toast__tile" aria-hidden="true">
<span class="toast__ripple" />
<Icon :icon="item.icon" size="22" />
</span>
<div class="toast__body">
<div v-if="item.title" class="toast__title">{{ item.title }}</div>
<div class="toast__message">{{ item.message }}</div>
</div>
<span
v-if="item.duration > 0"
class="toast__progress keep-motion"
:style="{ animationDuration: `${item.duration}ms` }"
@animationend="emit('close')"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import Icon from '../icon/Icon.vue'
import type { ToastItem } from '../../composables/useToast'
defineProps<{ item: ToastItem }>()
const emit = defineEmits<{ (e: 'close'): void }>()
// A drag past either threshold dismisses; anything shorter springs back.
const SWIPE_X = 80
const SWIPE_UP = 36
const held = ref(false)
const dragging = ref(false)
const dx = ref(0)
const dy = ref(0)
let startX = 0
let startY = 0
const flung = ref(false)
let downAt = 0
const dragStyle = computed(() => {
if (flung.value) {
return {
transform: `translate(${dx.value}px, ${dy.value}px)`,
opacity: '0',
transition: 'transform 0.22s ease-in, opacity 0.22s ease-in',
}
}
if (!dragging.value) return undefined
// Upward drags follow the finger; downward ones are damped so the card
// only stretches a little, like pulling on something anchored at the top.
const y = dy.value < 0 ? dy.value : dy.value * 0.25
const fade = 1 - Math.min(Math.abs(dx.value) / 220, 0.6)
return {
transform: `translate(${dx.value}px, ${y}px) rotate(${dx.value * 0.03}deg)`,
opacity: String(fade),
transition: 'none',
}
})
function onDown(e: PointerEvent) {
held.value = true
startX = e.clientX
startY = e.clientY
downAt = Date.now()
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}
function onMove(e: PointerEvent) {
if (!held.value) return
dx.value = e.clientX - startX
dy.value = e.clientY - startY
if (!dragging.value && Math.hypot(dx.value, dy.value) > 6) dragging.value = true
}
function onUp() {
held.value = false
if (!dragging.value) {
// A quick tap closes it; a long hold was only pausing it to read.
if (Date.now() - downAt < 250) emit('close')
return
}
if (Math.abs(dx.value) > SWIPE_X || dy.value < -SWIPE_UP) {
flung.value = true
dx.value = Math.abs(dx.value) > SWIPE_X ? Math.sign(dx.value) * window.innerWidth : dx.value
dy.value = dy.value < -SWIPE_UP ? -160 : dy.value
dragging.value = false
setTimeout(() => emit('close'), 200)
return
}
dragging.value = false
dx.value = 0
dy.value = 0
}
</script>
<style scoped lang="less">
.toast {
--accent: @wine;
position: relative;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 10px 12px 14px;
border-radius: 20px;
overflow: hidden;
pointer-events: auto;
touch-action: none;
cursor: grab;
transition: transform 0.45s cubic-bezier(0.2, 1.4, 0.4, 1), opacity 0.3s ease;
// Same glass recipe as the bottom nav, so the two read as one system.
background:
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 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);
// Edge light, masked to a 1px ring.
&::after {
content: '';
position: absolute;
inset: 0;
padding: 1px;
border-radius: inherit;
background: linear-gradient(
145deg,
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,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
&.is-held:not(.is-dragging) {
transform: scale(0.98);
}
&.is-dragging {
cursor: grabbing;
}
}
.toast--success {
--accent: @green;
}
.toast--error {
--accent: @red;
}
.toast--warning {
--accent: @secondary;
}
.toast--info {
--accent: @wine;
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.toast {
background: alpha(@canvas, 96%);
}
}
// Square rounded tile — the same shape as the nav tiles — tinted by type.
.toast__tile {
position: relative;
flex: 0 0 42px;
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 14px;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 16%, transparent);
:deep(svg) {
position: relative;
animation: toast-icon-pop 0.6s 0.12s cubic-bezier(0.2, 1.6, 0.4, 1) both;
}
}
// A ring that pulses out of the tile once as the toast lands.
.toast__ripple {
position: absolute;
inset: 0;
border-radius: inherit;
border: 2px solid var(--accent);
opacity: 0;
animation: toast-ripple 0.9s 0.2s ease-out both;
}
.toast--error .toast__tile :deep(svg) {
animation:
toast-icon-pop 0.6s 0.12s cubic-bezier(0.2, 1.6, 0.4, 1) both,
toast-shake 0.45s 0.55s ease-in-out;
}
.toast__body {
flex: 1;
min-width: 0;
text-align: right;
}
.toast__title {
margin-bottom: 2px;
font-size: 13px;
font-weight: 700;
line-height: 1.5;
color: @ink;
}
.toast__message {
font-size: 12.5px;
font-weight: 500;
line-height: 1.6;
color: @inkSoft;
}
// Countdown line along the bottom, draining toward the start side.
.toast__progress {
position: absolute;
right: 14px;
left: 14px;
bottom: 5px;
height: 2.5px;
border-radius: 2px;
background: var(--accent);
opacity: 0.55;
transform-origin: right center;
animation-name: toast-drain;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
.toast.is-held .toast__progress {
animation-play-state: paused;
}
@keyframes toast-drain {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
@keyframes toast-icon-pop {
0% {
transform: scale(0) rotate(-30deg);
opacity: 0;
}
100% {
transform: scale(1) rotate(0);
opacity: 1;
}
}
@keyframes toast-ripple {
0% {
transform: scale(1);
opacity: 0.5;
}
100% {
transform: scale(1.6);
opacity: 0;
}
}
@keyframes toast-shake {
0%,
100% {
transform: translateX(0);
}
20%,
60% {
transform: translateX(-3px);
}
40%,
80% {
transform: translateX(3px);
}
}
@media (prefers-reduced-motion: reduce) {
.toast__tile :deep(svg),
.toast__ripple {
animation: none !important;
}
}
</style>