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.
326 lines
7.5 KiB
Vue
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>
|