refactor(ui): replace modal dialogs with toast/sheet components

Remove DialogVant/NotifyVant and add Toast and Sheet components with
useToast, useReveal, and usePageTransition composables. Rework
Navigation, RegisterView, and styles across both apps; rebuild dist assets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-04 13:57:25 +03:30
co-authored by Claude Sonnet 5
parent 148b99c4a6
commit c05352d6fd
205 changed files with 3362 additions and 1701 deletions
@@ -1,142 +0,0 @@
<!--
Ported from legacy henza_pwa/src/components/DialogVant.vue.
EXISTING ISSUE, ported as-is (not fixed): this component doesn't declare a
`show`/`modelValue` prop, and the internal `watch(() => props.modelValue, ...)`
references a prop that was never declared (always undefined, so that watcher
effectively never fires — its `showNotify`/timeout auto-hide logic is dead
code). Actual visibility works anyway, because <van-dialog> is this
component's sole root element and Vue's automatic attribute fallthrough
passes the parent's `v-model:show` straight through to it. Preserved exactly.
Visual pass: redesigned to the app's own card language instead of Vant's
stock dialog chrome — rounded @canvas card, the illustration slot framed in
a soft circular badge (@wineWash) rather than floating loose, and the
button row rebuilt as two pill buttons (primary filled @ink, secondary
ghost) instead of Vant's bare text-grid. Every call site only ever fills
the img/title/button slots with content (never Vant's own button classes),
so this is a pure chrome swap: no call site needs to change, and slot
content (including per-call-site text color utility classes already baked
into that content) still renders as-is inside the new layout.
-->
<template>
<van-dialog class="mDialog">
<div class="mDialog__body">
<div class="mDialog__art">
<slot name="img">{{ text2 }}</slot>
</div>
<div class="mDialog__title">
<slot name="title">{{ text1 }}</slot>
</div>
<div class="mDialog__actions">
<slot name="button">{{ text3 }}</slot>
</div>
</div>
</van-dialog>
</template>
<script setup lang="ts">
import { Dialog as VanDialog } from 'vant'
withDefaults(
defineProps<{
timeOut: number
text1?: string
text2?: string
text3?: string
}>(),
{},
)
</script>
<style lang="less">
.van-dialog__confirm,
.van-dialog__cancel {
display: none !important;
}
// Vant's own dialog chrome (white slab, hard corners, drop shadow) is
// replaced wholesale — the shape and elevation now match the rest of the
// app's cards (HomeView's hero, Dashboard's rows) rather than the library
// default.
.mDialog.van-dialog {
width: min(320px, calc(100vw - 48px));
border-radius: 26px;
background: @canvas;
box-shadow: 0 30px 60px -24px rgba(20, 22, 26, 0.45);
overflow: hidden;
}
.mDialog__body {
padding: 26px 22px 20px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
// The illustration sits inside a soft circular badge in the page's wine
// wash, sized to a consistent footprint regardless of the source SVG's
// native dimensions — mirrors DashboardView's icon-badge treatment.
.mDialog__art {
width: 84px;
height: 84px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: @wineWash;
margin-bottom: 18px;
}
.mDialog__art img,
.mDialog__art svg,
.mDialog__art canvas {
width: 56px;
height: 56px;
object-fit: contain;
opacity: 1 !important;
}
.mDialog__title {
font-size: 14.5px;
font-weight: 700;
line-height: 1.9;
color: @ink;
}
// Two pill buttons instead of Vant's bordered text-grid. Each call site
// passes two <div>s with a click handler; the first (the affirmative action)
// is promoted to the filled primary pill by DOM order, the second becomes
// the ghost/secondary pill — no call-site markup changes needed.
.mDialog__actions {
width: 100%;
display: flex;
flex-direction: column;
gap: 9px;
margin-top: 22px;
font-size: 13px;
font-weight: 700;
}
.mDialog__actions > * {
padding: 12px;
border-radius: 14px;
cursor: pointer;
transition: transform 0.18s ease, opacity 0.18s ease;
}
.mDialog__actions > *:active {
transform: scale(0.97);
}
.mDialog__actions > *:first-child {
color: #fff !important;
background: @ink;
}
.mDialog__actions > *:last-child {
color: @inkFaint !important;
background: none;
}
</style>
@@ -1,104 +0,0 @@
<!--
Ported from legacy henza_pwa/src/components/DialogVantOneButton.vue — the
single-action variant of DialogVant (one full-width button instead of a
two-column grid). Used by App.vue's "new version available" dialog.
Same structure as DialogVant.vue: `show` is not a declared prop; the parent's
v-model:show falls through onto <van-dialog>, which is this component's sole
root element. Legacy's internal watch on an undeclared `modelValue` prop was
dead code and is not carried over.
Visual pass: same card chrome as DialogVant.vue's redesign (rounded
@canvas card, wine-wash art badge, filled @ink pill button), kept in sync
so the two dialog variants read as one family. The art badge is sized
larger here than DialogVant's since this component's sole caller
(App.vue's update dialog) fills it with a full animated Lottie player, not
a static icon.
-->
<template>
<van-dialog class="mDialog mDialog--single">
<div class="mDialog__body">
<div class="mDialog__art">
<slot name="img">{{ text2 }}</slot>
</div>
<div class="mDialog__title">
<slot name="title">{{ text1 }}</slot>
</div>
<div class="mDialog__actions">
<slot name="button">{{ text3 }}</slot>
</div>
</div>
</van-dialog>
</template>
<script setup lang="ts">
import { Dialog as VanDialog } from 'vant'
withDefaults(
defineProps<{
timeOut: number
text1?: string
text2?: string
text3?: string
}>(),
{},
)
</script>
<style lang="less">
.van-dialog__confirm,
.van-dialog__cancel {
display: none !important;
}
.mDialog.van-dialog {
width: min(320px, calc(100vw - 48px));
border-radius: 26px;
background: @canvas;
box-shadow: 0 30px 60px -24px rgba(20, 22, 26, 0.45);
overflow: hidden;
}
.mDialog__body {
padding: 26px 22px 20px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.mDialog--single .mDialog__art {
width: 100%;
margin-bottom: 8px;
}
.mDialog__title {
font-size: 14.5px;
font-weight: 700;
line-height: 1.9;
color: @ink;
}
.mDialog__actions {
width: 100%;
display: flex;
flex-direction: column;
gap: 9px;
margin-top: 10px;
font-size: 13px;
font-weight: 700;
}
.mDialog__actions > * {
padding: 12px;
border-radius: 14px;
cursor: pointer;
transition: transform 0.18s ease, opacity 0.18s ease;
color: #fff !important;
background: @ink;
}
.mDialog__actions > *:active {
transform: scale(0.97);
}
</style>
@@ -1,50 +0,0 @@
<!-- Ported from legacy henza_pwa/src/components/NotifyVant.vue -->
<template>
<div v-if="modelValue" class="flex">
<van-notify v-model:show="show" :style="color">
<div class="w-full text-white my-2">
<div class="w-full flex content-end">
<Icon icon="vuesax-linear:info-circle" class="ml-[20px] text-white" size="25" />
<div>
<slot>{{ text }}</slot>
</div>
</div>
</div>
</van-notify>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Notify as VanNotify } from 'vant'
import Icon from './icon/Icon.vue'
const show = ref(true)
const props = withDefaults(
defineProps<{
modelValue: boolean
timeOut: number
text?: string
color?: string
}>(),
{},
)
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
watch(
() => props.modelValue,
() => {
if (props.timeOut !== -1) showNotify()
},
)
const showNotify = () => {
setTimeout(() => {
emit('update:modelValue', false)
}, props.timeOut)
}
</script>
<!--
Legacy also had a `.notify-enter-active`/`.notify-leave-active` style block
referencing an undefined `notify-in` @keyframes and unused by any
<transition name="notify"> in this component — genuinely dead, dropped.
-->
@@ -295,25 +295,15 @@
</Transition>
</Teleport>
<NotifyVant :timeOut="3000" v-model="notify" :color="'background-color: #14161A'">
<div class="font-bold text-right mb-2">ثبت سفارش</div>
<div>محصول با موفقیت به سبد خرید افزوده شد</div>
</NotifyVant>
<DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" />
</template>
<template v-slot:title>
<div class="p-8 text-justify">
برای دسترسی به تمامی امکانات نرم افزار منتزر، ابتدا گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.
</div>
</template>
<template v-slot:button>
<div class="text-center font-bold" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center" @click="loginNotify = false">بیخیال</div>
</template>
</DialogVant>
<IslandSheet
v-model:show="loginNotify"
art="login"
title="ابتدا وارد حساب کاربری شوید"
description="برای دسترسی به تمامی امکانات نرم افزار منتزر، گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید."
confirm-text="ورود/ثبت نام"
cancel-text="بیخیال"
@confirm="router.push({ name: 'login' })"
/>
</Spinner>
</template>
@@ -323,15 +313,14 @@ import { useRouter } from 'vue-router'
import Icon from '../icon/Icon.vue'
import GallerySlider from './GallerySlider.vue'
import Spinner from '../Spinner.vue'
import NotifyVant from '../NotifyVant.vue'
import DialogVant from '../DialogVant.vue'
import { toast } from '../../composables/useToast'
import IslandSheet from '../ui/IslandSheet.vue'
import { useProduct, isVariantOrderable } from '../../composables/useProduct'
import { useProductDetail } from '../../composables/useProductDetail'
import { useFavoritesStore } from '../../stores/favoritesStore'
import { useFiltersStore } from '../../stores/filtersStore'
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 }>()
const emit = defineEmits<{ (e: 'closeDrawer'): void }>()
@@ -350,7 +339,6 @@ const close = () => {
if (router.currentRoute.value.name === 'singleProduct') router.back()
}
const notify = ref(false)
const loginNotify = ref(false)
const imageIndex = ref(0)
const bodyOpen = ref(false)
@@ -499,7 +487,7 @@ const isOrderable = (v: Parameters<typeof isVariantOrderable>[0]) => isVariantOr
const addToCart = () => {
// The store refuses an un-orderable variant; only confirm when it accepted.
if (!cart.addToCart(storeProduct.value)) return
notify.value = true
toast.success('محصول با موفقیت به سبد خرید افزوده شد', { title: 'ثبت سفارش' })
}
const toggleFav = (e: Event) => {
@@ -0,0 +1,487 @@
<!--
Floating "island" action sheet: the app's confirm/notice modal.
Replaces DialogVant / DialogVantOneButton (centered Vant dialogs whose
content was poured into img/title/button slots). The sheet rises from the
bottom but stays inset from the screen edges with rounded corners all
round, the same island shape as the ant-Drawer `island-sheet` in
style.less, so the two kinds of sheet read as one family.
Structured props instead of slots for the buttons, so every call site gets
the same button styling without passing its own markup and color classes:
<IslandSheet
v-model:show="open"
art="logout"
title="..."
description="..."
confirm-text="بله"
cancel-text="بی خیال"
@confirm="doIt"
/>
- `art` picks an illustration from SheetArt, and with it the sheet's tone
(header wash + primary button); `tone` overrides it. An `#art` slot can
replace the illustration entirely.
- No `cancel-text` → a single-button sheet.
- `closeOnClickOverlay` (default true) also governs swipe-down-to-dismiss;
with it off the sheet can only be left through its own buttons, and a
swipe just rubber-bands. No close (×) button: cancel, the backdrop and
the swipe already cover it.
- Performance: every decorative animation is held paused until the slide-in
has finished (`is-ready`, set on Vant's `opened`), so the slide has the
frame budget to itself; the content arrives as one piece, not staggered,
and nothing uses backdrop-filter (blurring the page behind a fading
overlay recomputes every frame and was the main source of jank on phones).
- Cancel closes the sheet itself and emits `cancel`; confirm only emits
`confirm` — the caller decides whether it closes (e.g. a redirect).
-->
<template>
<Popup
:show="show"
position="bottom"
teleport="body"
transition="iSheet-slide"
transition-appear
class="iSheet"
overlay-class="iSheet__overlay"
:close-on-click-overlay="closeOnClickOverlay"
close-on-popstate
@update:show="emit('update:show', $event)"
@opened="ready = true"
@closed="onClosed"
@click="onGapClick"
>
<div
class="iSheet__card"
:class="[`iSheet--${resolvedTone}`, { 'is-dragging': dragging, 'is-ready': ready }]"
:style="cardStyle"
dir="rtl"
role="dialog"
aria-modal="true"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<div class="iSheet__stage">
<span class="iSheet__handle" />
<span class="iSheet__ring iSheet__ring--outer" />
<span class="iSheet__ring iSheet__ring--inner" />
<span v-for="n in 4" :key="n" class="iSheet__spark" :class="`iSheet__spark--${n}`" />
<div class="iSheet__art">
<slot name="art">
<SheetArt v-if="art" :name="art" />
</slot>
</div>
</div>
<h3 class="iSheet__title">{{ title }}</h3>
<p v-if="description" class="iSheet__desc">{{ description }}</p>
<div class="iSheet__actions">
<button type="button" class="iSheet__btn iSheet__btn--primary" @click="emit('confirm')">
{{ confirmText }}
</button>
<button v-if="cancelText" type="button" class="iSheet__btn iSheet__btn--ghost" @click="cancel">
{{ cancelText }}
</button>
</div>
</div>
</Popup>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Popup } from 'vant'
import SheetArt, { type SheetArtName } from './SheetArt.vue'
type Tone = 'ink' | 'wine' | 'navy' | 'gold' | 'green'
const props = withDefaults(
defineProps<{
show: boolean
title: string
description?: string
confirmText: string
cancelText?: string
art?: SheetArtName
tone?: Tone
closeOnClickOverlay?: boolean
}>(),
{ closeOnClickOverlay: true },
)
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'confirm'): void
(e: 'cancel'): void
}>()
const artTone: Record<SheetArtName, Tone> = {
logout: 'wine',
login: 'navy',
order: 'gold',
update: 'navy',
success: 'green',
}
const resolvedTone = computed<Tone>(() => props.tone ?? (props.art ? artTone[props.art] : 'ink'))
const ready = ref(false)
function close() {
emit('update:show', false)
}
function onClosed() {
ready.value = false
dragY.value = 0
}
function cancel() {
emit('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).
const DISMISS_DISTANCE = 90
const DISMISS_VELOCITY = 0.5 // px per ms
const dragY = ref(0)
const dragging = ref(false)
let startY = 0
let startTime = 0
const cardStyle = computed(() =>
dragY.value ? { transform: `translate3d(0, ${dragY.value}px, 0)` } : undefined,
)
function onTouchStart(e: TouchEvent) {
if ((e.target as HTMLElement).closest('button')) return
startY = e.touches[0].clientY
startTime = Date.now()
dragging.value = true
}
function onTouchMove(e: TouchEvent) {
if (!dragging.value) return
const dy = e.touches[0].clientY - startY
// Downward follows the finger when dismissable, otherwise rubber-bands;
// upward always rubber-bands.
dragY.value = dy > 0 ? (props.closeOnClickOverlay ? dy : dy * 0.2) : dy * 0.12
}
function onTouchEnd() {
if (!dragging.value) return
dragging.value = false
const velocity = dragY.value / Math.max(1, Date.now() - startTime)
if (props.closeOnClickOverlay && (dragY.value > DISMISS_DISTANCE || (dragY.value > 30 && velocity > DISMISS_VELOCITY))) {
close()
} else {
dragY.value = 0
}
}
</script>
<style lang="less">
// Global, not scoped: the popup is teleported to <body>.
.iSheet__overlay {
background: rgba(20, 22, 26, 0.48);
}
.iSheet.van-popup {
background: transparent;
overflow: visible;
padding: 0 12px calc(12px + env(safe-area-inset-bottom, 0px));
}
// The iOS sheet curve: fast out of the gate, long soft landing. Transform
// only, on its own compositor layer, so it never waits on paint.
.iSheet-slide-enter-active,
.iSheet-slide-leave-active {
will-change: transform;
}
.iSheet-slide-enter-active {
transition: transform 0.42s cubic-bezier(0.32, 0.72, 0, 1);
}
.iSheet-slide-leave-active {
transition: transform 0.24s cubic-bezier(0.4, 0, 1, 1);
}
// Hold every decorative animation (the stage's rings and sparks, and the
// illustration's own) until the sheet has landed.
.iSheet__card:not(.is-ready) *,
.iSheet__card:not(.is-ready) *::after {
animation-play-state: paused !important;
}
.iSheet-slide-enter-from,
.iSheet-slide-leave-to {
transform: translate3d(0, 100%, 0);
}
// Tones: one accent per job. --t is the accent, --t-rgb its channels for
// translucent uses, --t-hi the lighter top of the button gradient, --t-wash
// and --t-wash2 the header's two background stops.
.iSheet--ink {
--t: @ink;
--t-rgb: 20, 22, 26;
--t-hi: @inkSoft;
--t-wash: #F7F7F8;
--t-wash2: #ECEDEF;
}
.iSheet--wine {
--t: @wine;
--t-rgb: 122, 27, 51;
--t-hi: @wineSoft;
--t-wash: #FDF6F7;
--t-wash2: #F7E2E7;
}
.iSheet--navy {
--t: @primary;
--t-rgb: 28, 45, 86;
--t-hi: #2D4682;
--t-wash: #F5F7FC;
--t-wash2: #E2E8F4;
}
.iSheet--gold {
--t: @secondary;
--t-rgb: 141, 106, 70;
--t-hi: #C39350;
--t-wash: #FDF9F2;
--t-wash2: #F5E7CF;
}
.iSheet--green {
--t: #15803d;
--t-rgb: 22, 163, 74;
--t-hi: #22a052;
--t-wash: #F4FBF6;
--t-wash2: #D9F1E1;
}
.iSheet__card {
position: relative;
max-width: 440px;
margin: 0 auto;
padding: 8px 8px 12px;
border-radius: 30px;
background: @canvas;
text-align: center;
box-shadow:
0 30px 60px -22px rgba(20, 22, 26, 0.5),
0 0 0 1px rgba(20, 22, 26, 0.04);
transition: transform 0.38s cubic-bezier(0.22, 1, 0.36, 1);
touch-action: none;
&.is-dragging {
transition: none;
}
}
// Header: a tinted window with a faint dot grid that fades out towards the
// edges, two soft rings framing the illustration, and a few twinkles.
.iSheet__stage {
position: relative;
height: 172px;
border-radius: 23px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(130% 95% at 50% 0%, var(--t-wash2) 0%, var(--t-wash) 72%);
&::before {
content: '';
position: absolute;
inset: 0;
background-image: radial-gradient(rgba(var(--t-rgb), 0.13) 1px, transparent 1.3px);
background-size: 14px 14px;
-webkit-mask-image: radial-gradient(closest-side, #000 30%, transparent 100%);
mask-image: radial-gradient(closest-side, #000 30%, transparent 100%);
}
}
.iSheet__handle {
position: absolute;
top: 8px;
left: 50%;
width: 38px;
height: 4px;
margin-left: -19px;
border-radius: 4px;
background: rgba(var(--t-rgb), 0.18);
z-index: 2;
}
.iSheet__ring {
position: absolute;
top: 50%;
left: 50%;
border-radius: 50%;
pointer-events: none;
&--outer {
width: 196px;
height: 196px;
margin: -98px 0 0 -98px;
border: 1.5px dashed rgba(var(--t-rgb), 0.16);
}
&--inner {
width: 132px;
height: 132px;
margin: -66px 0 0 -66px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.55) 62%, rgba(255, 255, 255, 0) 72%);
box-shadow: 0 0 0 10px rgba(255, 255, 255, 0.35);
animation: iSheet-breathe 3.2s ease-in-out infinite;
}
}
.iSheet__spark {
position: absolute;
width: 10px;
height: 10px;
background: rgba(var(--t-rgb), 0.45);
clip-path: polygon(50% 0, 62% 38%, 100% 50%, 62% 62%, 50% 100%, 38% 62%, 0 50%, 38% 38%);
animation: iSheet-twinkle 2.6s ease-in-out infinite;
&--1 { top: 30px; right: 24%; }
&--2 { top: 62px; left: 19%; width: 7px; height: 7px; animation-delay: 0.7s; }
&--3 { bottom: 32px; right: 17%; width: 8px; height: 8px; animation-delay: 1.3s; }
&--4 { bottom: 26px; left: 27%; width: 6px; height: 6px; animation-delay: 1.9s; }
}
.iSheet__art {
position: relative;
z-index: 1;
width: 128px;
height: 128px;
> img,
> svg,
> canvas {
width: 100%;
height: 100%;
object-fit: contain;
}
}
// Every element sets its own font-size: style.less's `*` rule pins each one
// to 14px, so nothing inherits.
.iSheet__title {
margin: 20px 16px 0;
font-size: 16.5px;
font-weight: 800;
line-height: 1.75;
color: @ink;
}
.iSheet__desc {
margin: 6px 20px 0;
font-size: 13px;
font-weight: 400;
line-height: 2;
color: @inkMuted;
}
.iSheet__actions {
display: flex;
flex-direction: column;
gap: 6px;
margin: 22px 4px 0;
}
// Scoped under the card on purpose: Tailwind's preflight resets
// `[type='button']` to a transparent, image-less background with the same
// specificity as a single class, and App.vue (which uses this sheet) is
// imported before main.ts loads tailwind.css — so a bare `.iSheet__btn--primary`
// loses on source order and the button renders white-on-white.
.iSheet__card .iSheet__btn {
position: relative;
height: 52px;
border: 0;
border-radius: 18px;
font-size: 14.5px;
font-weight: 700;
cursor: pointer;
overflow: hidden;
-webkit-tap-highlight-color: transparent;
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
&:active {
transform: scale(0.97);
}
&--primary {
color: #fff;
background: linear-gradient(180deg, var(--t-hi) 0%, var(--t) 100%);
box-shadow:
0 12px 24px -12px rgba(var(--t-rgb), 0.75),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
// A single sheen across the button once the sheet has settled.
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 40%;
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, 0.28), transparent);
transform: translateX(-130%) skewX(-18deg);
animation: iSheet-sheen 0.9s ease-in-out 0.15s both;
}
}
&--ghost {
color: @inkMuted;
background: transparent;
&:active {
background: @hairlineSoft;
}
}
}
@keyframes iSheet-breathe {
50% { transform: scale(1.06); }
}
@keyframes iSheet-twinkle {
0%, 100% { transform: scale(0.4) rotate(0deg); opacity: 0.2; }
50% { transform: scale(1) rotate(45deg); opacity: 1; }
}
@keyframes iSheet-sheen {
from { transform: translateX(-130%) skewX(-18deg); }
to { transform: translateX(300%) skewX(-18deg); }
}
@media (prefers-reduced-motion: reduce) {
.iSheet-slide-enter-active,
.iSheet-slide-leave-active {
transition-duration: 0.01s;
}
.iSheet__card *,
.iSheet__card *::after {
animation: none !important;
}
}
</style>
+111 -41
View File
@@ -1,19 +1,14 @@
<!--
Redesigned bottom nav — replaces the old GSAP SVG-notch bar (4 tabs: user,
cart, search, home) with a flat pill-bar of 5 tabs (Home, Search, Likes,
Notifications, Profile), each showing icon + label, and a single sliding
indicator behind the active tab instead of a cut-out notch.
windowData/SVG path math from the old version is gone — the indicator is
now pure CSS, positioned with a transform driven by the active tab's index,
so it needs no measurement of the container width.
Bottom nav — a compact rounded bar of square, icon-only tiles (Home,
Search, Cart, Orders, Profile). A single square indicator slides behind the
active tile; labels live in aria-label/title instead of under the icon.
-->
<template>
<nav class="tabbar" v-show="nav.showNav">
<div class="tabbar__inner" :style="{ '--count': nav.navBarRoutes.length }">
<div class="tabbar__inner">
<div
class="tabbar__indicator"
:style="{ transform: `translateX(${indicatorOffset}%)` }"
:style="{ transform: `translateX(${indicatorOffset})` }"
aria-hidden="true"
/>
<router-link
@@ -22,9 +17,10 @@
:to="{ name: item.name }"
class="tabbar__item"
:class="{ 'is-active': nav.activeLink === item.index }"
:aria-label="item.label"
:title="item.label"
>
<Icon :icon="item.icon" size="21" />
<span class="tabbar__label">{{ item.label }}</span>
<Icon :icon="item.icon" size="22" />
</router-link>
</div>
</nav>
@@ -37,11 +33,14 @@ import { useNavStore } from '../../stores/navStore'
const nav = useNavStore()
// Index is 1-based; the indicator sits in tab slot (activeLink - 1) out of N.
// The whole app runs `direction: rtl` (see App.vue), which flips the grid's
// visual column order but NOT a physical translateX — so slot 0 renders on
// the right, and moving right needs a NEGATIVE offset here.
const indicatorOffset = computed(() => -(nav.activeLink - 1) * 100)
// Index is 1-based; the indicator sits on tile (activeLink - 1). Tiles are a
// fixed size, so one step is one tile plus the gap between tiles.
// The whole app runs `direction: rtl` (see App.vue), which flips the flex
// order but NOT a physical translateX — so tile 0 renders on the right, and
// moving left needs a NEGATIVE offset here.
const indicatorOffset = computed(
() => `calc(${-(nav.activeLink - 1)} * (var(--tile) + var(--gap)))`,
)
</script>
<style scoped lang="less">
@@ -51,69 +50,140 @@ const indicatorOffset = computed(() => -(nav.activeLink - 1) * 100)
right: 0;
bottom: 0;
z-index: 10;
display: flex;
justify-content: center;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
background: transparent;
pointer-events: none;
}
// Compact bar that hugs its square tiles instead of spanning the screen.
.tabbar__inner {
--tile: 52px;
--gap: 6px;
--pad: 6px;
position: relative;
display: grid;
grid-template-columns: repeat(var(--count), 1fr);
max-width: 420px;
margin: 0 auto;
padding: 6px;
display: flex;
gap: var(--gap);
padding: var(--pad);
border-radius: 22px;
background: @canvas;
border: 1px solid @hairline;
box-shadow: 0 10px 28px -12px rgba(20, 22, 26, 0.18);
pointer-events: auto;
// Frosted glass: a thin, mostly clear pane that heavily blurs and
// brightens whatever scrolls beneath it. A light spot sits top-left, a
// faint wine tint pools bottom-right, and the drop shadow lifts the pane.
background:
radial-gradient(120% 140% at 0% 0%, fade(@canvas, 55%) 0%, fade(@canvas, 0%) 55%),
radial-gradient(90% 120% at 100% 100%, fade(@wine, 7%) 0%, fade(@wine, 0%) 60%),
linear-gradient(160deg, fade(@canvas, 42%) 0%, fade(@canvas, 18%) 100%);
-webkit-backdrop-filter: blur(26px) saturate(210%) brightness(1.06);
backdrop-filter: blur(26px) saturate(210%) brightness(1.06);
border: 0;
box-shadow:
inset 0 1px 1px fade(@canvas, 90%),
inset 0 -8px 16px -10px fade(@canvas, 60%),
inset 0 0 0 0.5px fade(@canvas, 35%),
0 18px 40px -14px rgba(20, 22, 26, 0.32),
0 6px 14px -8px fade(@wine, 22%),
0 1px 3px rgba(20, 22, 26, 0.06);
// Curved specular gloss across the top, like light on a bent pane.
&::before {
content: '';
position: absolute;
inset: 1px 8% auto 8%;
height: 55%;
border-radius: 20px 20px 50% 50% / 20px 20px 100% 100%;
background: linear-gradient(180deg, fade(@canvas, 60%) 0%, fade(@canvas, 0%) 100%);
pointer-events: none;
}
// Edge light: a 1px gradient rim, bright where the light hits (top-left)
// and fading out on the far edges. Masked so only the border ring shows.
&::after {
content: '';
position: absolute;
inset: 0;
padding: 1px;
border-radius: inherit;
background: linear-gradient(
145deg,
fade(@canvas, 100%) 0%,
fade(@canvas, 35%) 35%,
fade(@canvas, 10%) 65%,
fade(@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;
}
}
// Browsers without backdrop-filter get a mostly opaque fill so it stays legible.
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.tabbar__inner {
background: fade(@canvas, 94%);
}
}
// Paints above the tiles' own fill (z-index 1) but below their icons (z 2).
.tabbar__indicator {
position: absolute;
inset: 6px 6px 6px auto;
width: calc((100% - 12px) / var(--count));
top: var(--pad);
right: var(--pad);
z-index: 1;
width: var(--tile);
height: var(--tile);
border-radius: 16px;
background: @wineWash;
box-shadow: 0 4px 12px -6px rgba(20, 22, 26, 0.25);
transition: transform 0.35s cubic-bezier(0.65, 0, 0.35, 1);
will-change: transform;
}
.tabbar__item {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 8px 4px;
flex: 0 0 var(--tile);
width: var(--tile);
height: var(--tile);
border-radius: 16px;
color: @inkFaint;
background: @hairlineSoft;
color: @inkMuted;
text-decoration: none;
transition: color 0.25s ease, transform 0.25s cubic-bezier(0.65, 0, 0.35, 1.4);
transition: color 0.25s ease;
}
.tabbar__item :deep(svg) {
position: relative;
z-index: 2;
transition: transform 0.3s cubic-bezier(0.65, 0, 0.35, 1.4);
}
.tabbar__item.is-active {
color: @wine;
transform: translateY(-2px);
}
.tabbar__item.is-active :deep(svg) {
transform: scale(1.08);
}
.tabbar__item:active {
transform: scale(0.94);
// Scale the icon, not the tile: a transform on the tile would make it a
// stacking context and drop the icon beneath the indicator.
.tabbar__item:active :deep(svg) {
transform: scale(0.9);
}
.tabbar__label {
font-size: 10px;
font-weight: 600;
line-height: 1;
opacity: 0.9;
@media (max-width: 360px) {
.tabbar__inner {
--tile: 46px;
--gap: 5px;
}
}
</style>
@@ -38,6 +38,8 @@ const props = withDefaults(
back?: boolean
/** Route name to go to instead of history.back(). */
backTo?: string
/** Custom back handler; wins over backTo and history (e.g. a multi-step form stepping back). */
backAction?: () => void
}>(),
{ back: true },
)
@@ -45,7 +47,8 @@ const props = withDefaults(
const router = useRouter()
function onBack() {
if (props.backTo) router.push({ name: props.backTo })
if (props.backAction) props.backAction()
else if (props.backTo) router.push({ name: props.backTo })
else router.back()
}
</script>
@@ -0,0 +1,330 @@
<!--
Inline, animated illustrations for IslandSheet — one per job the sheet does,
so the picture says what the action is before the text does:
logout — a door swung open with an arrow walking out of it
login — a phone showing a sign-in form, with a lock badge easing open
order — a gold shopping bag that a coin keeps dropping into, ticked
update — a rocket lifting off (a new version "launched")
success — the uploaded documents, stamped with a green check
Inline SVG rather than image files so they can animate, pick up the
sheet's tone, and cost no network request (the update dialog used to pull
a Lottie file from a third-party host on every launch).
Gradient/clip ids go through useId(): several sheets can be mounted on one
page, and a duplicate id would resolve to the first copy in the document —
which, inside a hidden sheet, renders as nothing.
The one-off entrance animations (badge pop, check drawing) replay every
time the sheet opens, because the sheet hides with display:none and CSS
animations restart when an element is displayed again.
-->
<template>
<svg class="sa" :class="`sa--${name}`" viewBox="0 0 120 120" fill="none" aria-hidden="true">
<!-- logout -->
<template v-if="name === 'logout'">
<defs>
<linearGradient :id="ids.a" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#A33C55" />
<stop offset="1" stop-color="#5E1427" />
</linearGradient>
<radialGradient :id="ids.b" cx=".35" cy=".5" r=".75">
<stop offset="0" stop-color="#FFF7E6" />
<stop offset="1" stop-color="#F4C57A" />
</radialGradient>
</defs>
<ellipse class="sa-shadow" cx="62" cy="106" rx="36" ry="4" fill="#7A1B33" opacity=".12" />
<rect x="48" y="18" width="40" height="84" rx="5" :fill="`url(#${ids.b})`" stroke="#7A1B33" stroke-width="3" />
<g class="sa-door">
<path d="M88 18 L88 102 L69 108 L69 12 Z" :fill="`url(#${ids.a})`" stroke="#5E1427" stroke-width="2" stroke-linejoin="round" />
<circle cx="73.5" cy="61" r="2.6" fill="#F4C57A" />
</g>
<g class="sa-arrow" stroke-linecap="round" stroke-linejoin="round">
<path d="M60 60 H22 M33 48 L21 60 L33 72" stroke="#fff" stroke-width="12" />
<path d="M60 60 H22 M33 48 L21 60 L33 72" stroke="#7A1B33" stroke-width="6" />
</g>
</template>
<!-- login -->
<template v-else-if="name === 'login'">
<ellipse class="sa-shadow" cx="60" cy="110" rx="28" ry="4" fill="#1C2D56" opacity=".12" />
<g class="sa-float">
<rect x="34" y="10" width="52" height="92" rx="12" fill="#1C2D56" />
<rect x="38" y="14" width="44" height="84" rx="9" fill="#fff" />
<rect x="52" y="18.5" width="16" height="4" rx="2" fill="#1C2D56" opacity=".85" />
<circle cx="60" cy="42" r="12" fill="#E3E8F4" />
<circle cx="60" cy="39" r="4.6" fill="#1C2D56" />
<path d="M51.5 51 a8.5 6.5 0 0 1 17 0 Z" fill="#1C2D56" />
<rect x="44" y="62" width="32" height="6" rx="3" fill="#ECEDEF" />
<rect class="sa-typing" x="44" y="62" width="22" height="6" rx="3" fill="#CACED8" />
<rect x="44" y="74" width="32" height="10" rx="5" fill="#1C2D56" />
</g>
<g class="sa-pop">
<circle cx="85" cy="86" r="15" fill="#D9A441" stroke="#fff" stroke-width="3" />
<path class="sa-shackle" d="M80 85 v-3.4 a4 4 0 0 1 8 0 V85" stroke="#fff" stroke-width="2.4" stroke-linecap="round" />
<rect x="77.5" y="84.5" width="15" height="10.5" rx="2.6" fill="#fff" />
<circle cx="85" cy="89.8" r="1.7" fill="#D9A441" />
</g>
</template>
<!-- order -->
<template v-else-if="name === 'order'">
<defs>
<linearGradient :id="ids.a" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#EBC06A" />
<stop offset="1" stop-color="#B5843B" />
</linearGradient>
<radialGradient :id="ids.b" cx=".38" cy=".35" r=".7">
<stop offset="0" stop-color="#FFF1C7" />
<stop offset="1" stop-color="#E2AC4A" />
</radialGradient>
</defs>
<ellipse class="sa-shadow" cx="60" cy="108" rx="32" ry="4" fill="#8D6A46" opacity=".16" />
<path d="M46 57 V40 a14 14 0 0 1 28 0 V57" stroke="#8D6A46" stroke-width="4.5" stroke-linecap="round" />
<g class="sa-coin">
<circle cx="60" cy="31" r="10" :fill="`url(#${ids.b})`" stroke="#B5843B" stroke-width="2" />
<circle cx="60" cy="31" r="5.5" stroke="#B5843B" stroke-width="1.5" opacity=".7" />
</g>
<path d="M30 48 H90 L85 100 a6 6 0 0 1 -6 5.5 H41 a6 6 0 0 1 -6 -5.5 Z" :fill="`url(#${ids.a})`" />
<path d="M30 48 H90 L89.4 54.5 H30.6 Z" fill="#8D6A46" opacity=".28" />
<path d="M37 60 L39.5 96" stroke="#fff" stroke-width="3" stroke-linecap="round" opacity=".35" />
<circle cx="46" cy="57" r="2.4" fill="#8D6A46" />
<circle cx="74" cy="57" r="2.4" fill="#8D6A46" />
<g class="sa-pop">
<circle cx="60" cy="80" r="13" fill="#fff" />
<path class="sa-draw" d="M54 80.5 l4.2 4.2 L66.5 76" stroke="#16a34a" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round" />
</g>
</template>
<!-- update -->
<template v-else-if="name === 'update'">
<defs>
<linearGradient :id="ids.a" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFD27A" />
<stop offset="1" stop-color="#F2793B" />
</linearGradient>
<clipPath :id="ids.b">
<path d="M60 10 C76 22 80 44 76 72 H44 C40 44 44 22 60 10 Z" />
</clipPath>
</defs>
<g stroke="#CACED8" stroke-width="3" stroke-linecap="round">
<path class="sa-trail" d="M28 28 v14" />
<path class="sa-trail sa-trail--2" d="M94 40 v12" />
<path class="sa-trail sa-trail--3" d="M22 64 v10" />
<path class="sa-trail sa-trail--4" d="M99 70 v12" />
</g>
<g class="sa-float">
<g class="sa-flame">
<path d="M51 77 Q60 112 69 77 Z" :fill="`url(#${ids.a})`" />
<path d="M55.5 77 Q60 96 64.5 77 Z" fill="#FFF1C9" />
</g>
<path d="M44 56 L31 76 Q31 80 35 79 L46 74 Z" fill="#D9A441" />
<path d="M76 56 L89 76 Q89 80 85 79 L74 74 Z" fill="#D9A441" />
<path d="M60 10 C76 22 80 44 76 72 H44 C40 44 44 22 60 10 Z" fill="#fff" />
<g :clip-path="`url(#${ids.b})`">
<rect x="30" y="0" width="60" height="31" fill="#1C2D56" />
<rect x="30" y="64" width="60" height="10" fill="#E3E8F4" />
</g>
<path d="M60 10 C76 22 80 44 76 72 H44 C40 44 44 22 60 10 Z" stroke="#1C2D56" stroke-width="3" stroke-linejoin="round" />
<circle cx="60" cy="46" r="7.5" fill="#DCE5F7" stroke="#1C2D56" stroke-width="3" />
<circle cx="57.6" cy="43.6" r="2" fill="#fff" />
<rect x="49" y="71" width="22" height="7" rx="2.5" fill="#1C2D56" />
</g>
</template>
<!-- success -->
<template v-else-if="name === 'success'">
<ellipse class="sa-shadow" cx="58" cy="108" rx="32" ry="4" fill="#16a34a" opacity=".14" />
<rect x="26" y="24" width="50" height="70" rx="8" fill="#D6F0DF" transform="rotate(-9 51 59)" />
<rect x="34" y="16" width="50" height="72" rx="8" fill="#fff" stroke="#1f7a45" stroke-width="2.5" />
<circle cx="46" cy="31" r="6" fill="#BFE8CD" />
<rect x="56" y="27" width="20" height="4" rx="2" fill="#E1EFE6" />
<rect x="56" y="34" width="13" height="4" rx="2" fill="#E1EFE6" />
<rect x="42" y="48" width="34" height="4" rx="2" fill="#E1EFE6" />
<rect x="42" y="56" width="27" height="4" rx="2" fill="#E1EFE6" />
<rect x="42" y="64" width="31" height="4" rx="2" fill="#E1EFE6" />
<circle class="sa-ring" cx="84" cy="85" r="17" fill="#16a34a" />
<g class="sa-pop">
<circle cx="84" cy="85" r="17" fill="#16a34a" stroke="#fff" stroke-width="3" />
<path class="sa-draw" d="M76.5 85.5 l5 5 L92 80" stroke="#fff" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round" />
</g>
<g class="sa-confetti">
<rect x="18" y="30" width="6" height="6" rx="1.5" fill="#F4C57A" transform="rotate(20 21 33)" />
<circle cx="98" cy="26" r="3.2" fill="#16a34a" />
<rect x="100" y="52" width="7" height="3.5" rx="1.75" fill="#A33C55" transform="rotate(-30 103.5 53.75)" />
<circle cx="20" cy="72" r="2.6" fill="#335CB1" />
<rect x="88" y="8" width="3.5" height="7" rx="1.75" fill="#D9A441" transform="rotate(35 89.75 11.5)" />
</g>
</template>
</svg>
</template>
<script setup lang="ts">
import { useId } from 'vue'
export type SheetArtName = 'logout' | 'login' | 'order' | 'update' | 'success'
defineProps<{ name: SheetArtName }>()
const uid = useId()
const ids = { a: `${uid}-a`, b: `${uid}-b` }
</script>
<style scoped lang="less">
.sa {
width: 100%;
height: 100%;
overflow: visible;
}
// Everything that moves scales/rotates about its own box, not the SVG origin.
.sa * {
transform-box: fill-box;
transform-origin: center;
}
// Entrances (once per open)
.sa-pop {
animation: sa-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 0.25s both;
}
.sa-draw {
stroke-dasharray: 30;
stroke-dashoffset: 30;
animation: sa-draw 0.45s ease-out 0.6s forwards;
}
// Loops
.sa-shadow {
animation: sa-shadow 3.2s ease-in-out infinite;
}
.sa-float {
animation: sa-float 3.2s ease-in-out infinite;
}
.sa-door {
transform-origin: right center;
animation: sa-door 2.8s ease-in-out infinite;
}
.sa-arrow {
animation: sa-nudge 2.8s ease-in-out infinite;
}
.sa-typing {
transform-origin: right center; // the form is RTL — text grows from the right
animation: sa-typing 2.6s ease-in-out infinite;
}
.sa-shackle {
animation: sa-shackle 2.6s ease-in-out 0.9s infinite;
}
.sa-coin {
animation: sa-coin 2.4s cubic-bezier(0.55, 0, 0.75, 0.4) 0.4s infinite backwards;
}
.sa-flame {
transform-origin: center top;
animation: sa-flame 0.22s ease-in-out infinite alternate;
}
.sa-trail {
animation: sa-trail 1.1s linear infinite;
&--2 { animation-delay: 0.35s; }
&--3 { animation-delay: 0.6s; }
&--4 { animation-delay: 0.85s; }
}
.sa-ring {
animation: sa-ring 2s ease-out 0.8s infinite;
opacity: 0;
}
.sa-confetti > * {
animation: sa-confetti 0.7s cubic-bezier(0.34, 1.56, 0.64, 1) both;
&:nth-child(1) { animation-delay: 0.7s; }
&:nth-child(2) { animation-delay: 0.78s; }
&:nth-child(3) { animation-delay: 0.86s; }
&:nth-child(4) { animation-delay: 0.94s; }
&:nth-child(5) { animation-delay: 1.02s; }
}
@keyframes sa-pop {
from { transform: scale(0); }
to { transform: scale(1); }
}
@keyframes sa-draw {
to { stroke-dashoffset: 0; }
}
@keyframes sa-shadow {
50% { transform: scaleX(0.82); opacity: 0.08; }
}
@keyframes sa-float {
50% { transform: translateY(-4px); }
}
@keyframes sa-door {
0%, 100% { transform: scaleX(1); }
50% { transform: scaleX(0.72); }
}
@keyframes sa-nudge {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(-6px); }
}
@keyframes sa-typing {
0% { transform: scaleX(0.15); }
60%, 100% { transform: scaleX(1); }
}
@keyframes sa-shackle {
0%, 55%, 100% { transform: translateY(0); }
20%, 40% { transform: translateY(-3px); }
}
@keyframes sa-coin {
0% { transform: translateY(-24px); opacity: 0; }
20% { opacity: 1; }
100% { transform: translateY(26px); opacity: 1; }
}
@keyframes sa-flame {
from { transform: scaleY(0.82) scaleX(0.94); }
to { transform: scaleY(1.12) scaleX(1.04); }
}
@keyframes sa-trail {
from { transform: translateY(-8px); opacity: 0; }
30% { opacity: 1; }
to { transform: translateY(22px); opacity: 0; }
}
@keyframes sa-ring {
from { transform: scale(1); opacity: 0.35; }
to { transform: scale(1.7); opacity: 0; }
}
@keyframes sa-confetti {
from { transform: scale(0) translateY(6px); opacity: 0; }
to { transform: scale(1) translateY(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.sa * {
animation: none !important;
}
.sa-draw {
stroke-dashoffset: 0;
}
}
</style>
@@ -0,0 +1,325 @@
<!--
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"
: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%, 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%);
-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%),
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,
fade(@canvas, 100%) 0%,
fade(@canvas, 30%) 40%,
fade(@canvas, 10%) 70%,
fade(@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: fade(@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>
@@ -0,0 +1,89 @@
<!--
Renders the shared toast queue (see composables/useToast.ts). Mounted once
in App.vue. Toasts drop in from the top with a spring, the stack slides to
make room, and a closing toast shrinks up and fades out.
-->
<template>
<Teleport to="body">
<div class="toast-host">
<TransitionGroup name="toast">
<ToastCard v-for="item in toasts" :key="item.id" :item="item" @close="dismiss(item.id)" />
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup lang="ts">
import ToastCard from './ToastCard.vue'
import { useToast } from '../../composables/useToast'
const { toasts, dismiss } = useToast()
</script>
<style lang="less">
.toast-host {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 3000;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: calc(12px + env(safe-area-inset-top, 0px)) 14px 0;
direction: rtl;
pointer-events: none;
> * {
max-width: 420px;
}
}
// Enter: drop from above, slightly small and blurred, then overshoot into place.
.toast-host .toast.toast-enter-active {
transition:
transform 0.6s cubic-bezier(0.2, 1.35, 0.4, 1),
opacity 0.35s ease,
filter 0.4s ease;
}
.toast-host .toast.toast-enter-from {
transform: translateY(-120%) scale(0.88);
opacity: 0;
filter: blur(6px);
}
// Leave: taken out of flow so the stack can glide up behind it.
.toast-host .toast.toast-leave-active {
position: absolute;
left: 14px;
right: 14px;
margin: 0 auto;
transition:
transform 0.32s cubic-bezier(0.5, 0, 0.75, 0),
opacity 0.28s ease,
filter 0.3s ease;
}
.toast-host .toast.toast-leave-to {
transform: translateY(-40%) scale(0.9);
opacity: 0;
filter: blur(4px);
}
.toast-host .toast.toast-move {
transition: transform 0.45s cubic-bezier(0.2, 1.2, 0.4, 1);
}
@media (prefers-reduced-motion: reduce) {
.toast-host .toast.toast-enter-active,
.toast-host .toast.toast-leave-active,
.toast-host .toast.toast-move {
transition: opacity 0.2s ease;
}
.toast-host .toast.toast-enter-from,
.toast-host .toast.toast-leave-to {
transform: none;
filter: none;
}
}
</style>