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:
co-authored by
Claude Sonnet 5
parent
148b99c4a6
commit
c05352d6fd
@@ -34,9 +34,11 @@
|
||||
</template>
|
||||
|
||||
<template v-slot:button>
|
||||
<div class="text-center text-[#1C2D56]" @click="update()">به روز رسانی</div>
|
||||
<div class="text-center text-[#23493c]" @click="update()">به روز رسانی</div>
|
||||
</template>
|
||||
</DialogVantOneButton>
|
||||
|
||||
<ToastHost />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -48,6 +50,7 @@ import { useRegisterSW } from 'virtual:pwa-register/vue'
|
||||
import MasterLayout from './layout/MasterLayout.vue'
|
||||
import FullPageLayout from './layout/FullPageLayout.vue'
|
||||
import DialogVantOneButton from './components/DialogVantOneButton.vue'
|
||||
import ToastHost from './components/ui/ToastHost.vue'
|
||||
import { useRefresh } from './composables/useRefresh'
|
||||
import pkg from '../package.json'
|
||||
|
||||
|
||||
@@ -607,3 +607,84 @@ input:focus-visible {
|
||||
width: 100%;
|
||||
height: 40vh
|
||||
}
|
||||
|
||||
// v-lazy images (vue3-lazyload sets lazy="loading" | "loaded" | "error").
|
||||
// Hidden until the real file has loaded, then faded in, so a card never shows
|
||||
// a placeholder that is then swapped out, or a photo painting in top-down.
|
||||
img[lazy='loading'] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
img[lazy='loaded'],
|
||||
img[lazy='error'] {
|
||||
opacity: 1;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
|
||||
// Shimmering placeholder block for content that is still loading.
|
||||
.skeleton {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: @hairlineSoft;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: translateX(100%);
|
||||
background: linear-gradient(90deg, transparent, fade(#fff, 65%), transparent);
|
||||
animation: skeleton-sweep 1.3s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// RTL page, so the sweep runs right to left.
|
||||
@keyframes skeleton-sweep {
|
||||
to {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
// First-paint entrance (driven by composables/useReveal.ts): put `reveal` on
|
||||
// each section and `is-revealed` on their container once the data is in. Each
|
||||
// section rises a little, scales up from just under full size and fades in,
|
||||
// one beat (--reveal-delay) after the one above it.
|
||||
//
|
||||
// The curve is a long decelerate with no overshoot — it starts fast, settles
|
||||
// slowly and never bounces back. The travel is deliberately small (14px, 2% of
|
||||
// scale): the content should look like it was always there and simply came
|
||||
// into focus, not like it flew in.
|
||||
//
|
||||
// The settled state is `transform: none`, not an identity transform: any
|
||||
// transform left on a section would make it the containing block for fixed
|
||||
// elements inside it and give it a permanent stacking context.
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 14px, 0) scale(0.98);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.is-revealed .reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
will-change: auto;
|
||||
transition:
|
||||
opacity 0.62s cubic-bezier(0.32, 0.72, 0, 1) var(--reveal-delay, 0ms),
|
||||
transform 0.62s cubic-bezier(0.32, 0.72, 0, 1) var(--reveal-delay, 0ms);
|
||||
}
|
||||
|
||||
// Respect a reduced-motion preference: show the content, skip the choreography.
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.is-revealed .reveal {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ withDefaults(
|
||||
display: none !important;
|
||||
}
|
||||
.van-dialog__confirm .van-button__text {
|
||||
color: #1c2d56;
|
||||
color: #23493c;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ withDefaults(
|
||||
display: none !important;
|
||||
}
|
||||
.van-dialog__confirm .van-button__text {
|
||||
color: #1c2d56;
|
||||
color: #23493c;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
-->
|
||||
@@ -78,7 +78,7 @@ const fiat = computed(() => (props.customer ? props.customer.fiatDebtRial.split(
|
||||
.acct {
|
||||
border-radius: 20px;
|
||||
padding: 18px;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
color: #fff;
|
||||
box-shadow: 0 22px 44px -32px rgba(20, 22, 26, 0.9);
|
||||
}
|
||||
|
||||
@@ -61,8 +61,8 @@ const emit = defineEmits<{ (e: 'select', cat: HomeCategory): void }>()
|
||||
}
|
||||
|
||||
.rail__chip.is-active {
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
background: @primary;
|
||||
border-color: @primary;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 24px -18px rgba(20, 22, 26, 0.9);
|
||||
}
|
||||
|
||||
@@ -1017,11 +1017,11 @@
|
||||
"vuesax-linear:youtube": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M17 20H7C4 20 2 18 2 15V9C2 6 4 4 7 4H17C20 4 22 6 22 9V15C22 18 20 20 17 20Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M11.4001 9.5L13.9001 11C14.8001 11.6 14.8001 12.5 13.9001 13.1L11.4001 14.6C10.4001 15.2 9.6001 14.7 9.6001 13.6V10.6C9.6001 9.3 10.4001 8.9 11.4001 9.5Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
|
||||
"vuesax-linear:zel-(zel)": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M10.5 11.5V14.3L8 15.8L5.5 14.3V11.5L8 10L9.2 10.7L10.5 11.5Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linejoin=\"round\"/>\n<path d=\"M18.5 11.5V14.3L16 15.8L13.5 14.3V11.5L14.8 10.7L16 10L18.5 11.5Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linejoin=\"round\"/>\n<path d=\"M14.8 9.29999V10.7L13.5 11.5V13.2L12 14.1L10.5 13.2V11.5L9.19995 10.7V9.29999L12 7.79999L14.8 9.29999Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linejoin=\"round\"/>\n<path d=\"M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linejoin=\"round\"/>\n</svg>\n",
|
||||
"vuesax-linear:zoom": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6 9.69999V12.5C6 14 7.3 15.3 8.8 15.3H12.5C13 15.3 13.4 14.9 13.4 14.4V11.6C13.4 10.1 12.2 8.79999 10.6 8.79999H6.9C6.4 8.79999 6 9.19999 6 9.69999Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\"/>\n<path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M18 9.20001V14.8L15.2 12.9V11.1L18 9.20001Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\"/>\n</svg>\n",
|
||||
"vuesax-linear:number-18": "<svg width=\"17\" height=\"14\" viewBox=\"0 0 17 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M1.05502 13.4129C1.05502 13.3105 1.05502 13.1901 1.05502 13.0516C1.05502 12.9131 1.05502 12.7686 1.05502 12.6181C1.05502 12.4675 1.05502 12.32 1.05502 12.1755C1.06084 12.031 1.06665 11.8985 1.07246 11.7781V11.7871C1.07827 11.4981 1.08118 11.2 1.08118 10.8929C1.08699 10.5798 1.0899 10.2516 1.0899 9.90839C1.0899 9.24602 1.08118 8.55957 1.06374 7.84903C1.05212 7.1385 1.01143 6.40086 0.941674 5.63613C0.87192 4.86538 0.764383 4.07054 0.619063 3.25161C0.473743 2.42667 0.267389 1.57161 0 0.686452L2.03157 0C2.32803 0.981505 2.55763 1.92387 2.72039 2.8271C2.88896 3.7243 3.01103 4.58538 3.0866 5.41032C3.16798 6.22925 3.21738 7.01505 3.23482 7.76774C3.25226 8.52043 3.26098 9.24301 3.26098 9.93548C3.26098 10.2787 3.25807 10.6099 3.25226 10.929C3.25226 11.2422 3.24935 11.5432 3.24354 11.8323C3.23773 11.9527 3.23192 12.0852 3.2261 12.2297C3.2261 12.3682 3.2261 12.5097 3.2261 12.6542C3.2261 12.7987 3.2261 12.9372 3.2261 13.0697C3.2261 13.2022 3.2261 13.3166 3.2261 13.4129H1.05502Z\" fill=\"#1C2D56\"/>\n<path d=\"M13.6693 6.05161C14.1052 7.3643 14.608 8.57763 15.1777 9.69161C15.7532 10.7996 16.3606 11.754 17 12.5548L15.3172 14C14.9684 13.5604 14.6226 13.0486 14.2796 12.4645C13.9425 11.8804 13.6082 11.2331 13.2769 10.5226C12.9456 9.80602 12.6201 9.03226 12.3004 8.20129C11.9806 7.37032 11.6639 6.48817 11.35 5.55484C11.1058 6.30753 10.8355 7.08129 10.5391 7.87613C10.2426 8.67097 9.92292 9.44473 9.57996 10.1974C9.24282 10.9441 8.89115 11.6456 8.52494 12.3019C8.15873 12.9523 7.78381 13.5123 7.40016 13.9819L5.69992 12.5548C6.008 12.1815 6.31317 11.748 6.61544 11.2542C6.9177 10.7604 7.21125 10.2335 7.49608 9.67355C7.7809 9.10753 8.0512 8.51742 8.30696 7.90323C8.56854 7.28903 8.80977 6.67183 9.03065 6.05161C9.19341 5.52774 9.35036 5.00086 9.50149 4.47097C9.65262 3.94108 9.78341 3.42925 9.89386 2.93548C10.0043 2.44172 10.0915 1.97806 10.1554 1.54452C10.2252 1.10495 10.2601 0.713549 10.2601 0.370324H12.4224C12.4224 0.737635 12.4515 1.13505 12.5096 1.56258C12.5736 1.99011 12.6578 2.44473 12.7625 2.92645C12.8729 3.40817 13.0037 3.91097 13.1548 4.43484C13.3118 4.95871 13.4833 5.49763 13.6693 6.05161Z\" fill=\"#1C2D56\"/>\n</svg>\n",
|
||||
"vuesax-linear:number-24": "<svg width=\"21\" height=\"12\" viewBox=\"0 0 21 12\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M7.59689 0.399477C7.61323 0.446474 7.64862 0.558746 7.70308 0.736292C7.75754 0.913838 7.812 1.12794 7.86646 1.37859C7.92092 1.62402 7.96993 1.89034 8.01349 2.17755C8.05706 2.45953 8.07884 2.73107 8.07884 2.99217C8.07884 3.53003 7.98626 3.99739 7.80111 4.39426C7.61595 4.79112 7.37633 5.1201 7.08226 5.3812C6.78819 5.63708 6.45327 5.82768 6.07751 5.953C5.7072 6.07833 5.33416 6.14099 4.9584 6.14099C4.58809 6.14099 4.24772 6.09922 3.93731 6.01567C3.63235 5.92689 3.34644 5.8094 3.0796 5.66318C3.12861 6.11749 3.16673 6.56919 3.19396 7.01828C3.22119 7.46736 3.24025 7.94256 3.25114 8.44386C3.26203 8.93995 3.26748 9.47781 3.26748 10.0574C3.27293 10.6371 3.27293 11.2846 3.26748 12H1.25798V10.1044C1.25798 9.48825 1.24709 8.83812 1.2253 8.15405C1.20897 7.46475 1.15996 6.74151 1.07827 5.98433C0.996581 5.22715 0.871328 4.43342 0.702508 3.60313C0.533688 2.77285 0.299519 1.906 0 1.00261L1.93598 0.40731L2.2709 1.36292C2.29813 1.44125 2.34169 1.56658 2.4016 1.7389C2.46695 1.906 2.55136 2.09661 2.65483 2.3107C2.7583 2.51958 2.88083 2.73629 3.02242 2.96084C3.16946 3.18016 3.33827 3.38381 3.52888 3.5718C3.71948 3.75457 3.93187 3.906 4.16604 4.02611C4.40565 4.14099 4.66977 4.19843 4.9584 4.19843C5.02375 4.19843 5.11633 4.18799 5.23614 4.1671C5.36139 4.14621 5.48392 4.09661 5.60373 4.01828C5.72898 3.93473 5.8379 3.81201 5.93048 3.65013C6.02305 3.48825 6.06934 3.26893 6.06934 2.99217C6.06934 2.83029 6.05573 2.65535 6.0285 2.46736C6.00127 2.27415 5.96587 2.08877 5.92231 1.91123C5.88419 1.72846 5.84062 1.55875 5.79161 1.40209C5.74804 1.24021 5.7072 1.10444 5.66908 0.994778L7.59689 0.399477Z\" fill=\"#1C2D56\"/>\n<path d=\"M17.9907 4.62924C18.1432 4.62924 18.2929 4.62924 18.44 4.62924C18.587 4.62402 18.734 4.61619 18.8811 4.60574C19.0281 4.5953 19.1806 4.58224 19.3385 4.56658C19.5019 4.54569 19.6762 4.51958 19.8613 4.48825L20.3188 6.25065C20.0356 6.32898 19.7633 6.39687 19.5019 6.45431C19.2405 6.50653 18.9709 6.55091 18.6932 6.58747C18.4155 6.6188 18.1187 6.64491 17.8028 6.6658C17.4924 6.68146 17.1412 6.68929 16.7491 6.68929C16.1827 6.68929 15.6054 6.64752 15.0173 6.56397C14.4346 6.48042 13.8791 6.32898 13.3509 6.10966C13.3836 6.53264 13.4108 6.91906 13.4326 7.26893C13.4598 7.6188 13.4789 7.95039 13.4897 8.26371C13.5006 8.57702 13.5088 8.88251 13.5143 9.18016C13.5197 9.47781 13.5224 9.7859 13.5224 10.1044C13.5224 10.3238 13.5197 10.5483 13.5143 10.7781C13.5143 11.0026 13.5143 11.248 13.5143 11.5144V12H11.4884V11.5065C11.4884 10.7389 11.483 10.0209 11.4721 9.35248C11.4666 8.68407 11.4476 8.05222 11.4149 7.45692C11.3877 6.86162 11.3468 6.29243 11.2924 5.74935C11.2379 5.20627 11.1644 4.67363 11.0718 4.15144C10.9792 3.62924 10.8649 3.10966 10.7287 2.59269C10.5926 2.07572 10.4292 1.54569 10.2386 1.00261L12.1991 0.40731C12.2862 0.678851 12.3652 0.94778 12.436 1.2141C12.5122 1.48042 12.5857 1.75196 12.6565 2.02872C12.7001 2.18016 12.7382 2.33681 12.7709 2.49869C12.809 2.65535 12.8499 2.81201 12.8934 2.96867C12.9915 3.33943 13.1439 3.6423 13.3509 3.87728C13.5633 4.10705 13.792 4.27676 14.037 4.38642C14.0044 4.30809 13.969 4.22454 13.9309 4.13577C13.8927 4.047 13.8573 3.94778 13.8247 3.83812C13.7974 3.72324 13.7729 3.59791 13.7511 3.46214C13.7348 3.32637 13.7266 3.17493 13.7266 3.00783C13.7266 2.53264 13.8219 2.10966 14.0125 1.7389C14.2086 1.36292 14.4618 1.047 14.7722 0.791123C15.0881 0.530026 15.4421 0.334203 15.8342 0.203655C16.2317 0.067885 16.6292 0 17.0268 0C17.3426 0 17.634 0.0365535 17.9008 0.10966C18.1731 0.177546 18.4264 0.266318 18.6605 0.375979C18.8947 0.485639 19.1153 0.608355 19.3222 0.744125C19.5346 0.874673 19.7415 1.00261 19.943 1.12794L18.9791 2.78068C18.7885 2.65013 18.6088 2.53525 18.44 2.43603C18.2712 2.33159 18.1078 2.24543 17.9499 2.17755C17.7919 2.10966 17.6367 2.05744 17.4842 2.02089C17.3372 1.97911 17.1847 1.95561 17.0268 1.95039H16.9778C16.8362 1.95039 16.6837 1.97389 16.5203 2.02089C16.357 2.06788 16.2045 2.14099 16.0629 2.24021C15.9213 2.33942 15.8015 2.46214 15.7035 2.60835C15.6109 2.74935 15.5646 2.91123 15.5646 3.09399C15.5646 3.39164 15.6381 3.63969 15.7851 3.83812C15.9322 4.03655 16.1201 4.19582 16.3488 4.31593C16.583 4.43081 16.8444 4.51175 17.133 4.55875C17.4216 4.60574 17.7075 4.62924 17.9907 4.62924Z\" fill=\"#1C2D56\"/>\n</svg>\n",
|
||||
"vuesax-linear:number-18": "<svg width=\"17\" height=\"14\" viewBox=\"0 0 17 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M1.05502 13.4129C1.05502 13.3105 1.05502 13.1901 1.05502 13.0516C1.05502 12.9131 1.05502 12.7686 1.05502 12.6181C1.05502 12.4675 1.05502 12.32 1.05502 12.1755C1.06084 12.031 1.06665 11.8985 1.07246 11.7781V11.7871C1.07827 11.4981 1.08118 11.2 1.08118 10.8929C1.08699 10.5798 1.0899 10.2516 1.0899 9.90839C1.0899 9.24602 1.08118 8.55957 1.06374 7.84903C1.05212 7.1385 1.01143 6.40086 0.941674 5.63613C0.87192 4.86538 0.764383 4.07054 0.619063 3.25161C0.473743 2.42667 0.267389 1.57161 0 0.686452L2.03157 0C2.32803 0.981505 2.55763 1.92387 2.72039 2.8271C2.88896 3.7243 3.01103 4.58538 3.0866 5.41032C3.16798 6.22925 3.21738 7.01505 3.23482 7.76774C3.25226 8.52043 3.26098 9.24301 3.26098 9.93548C3.26098 10.2787 3.25807 10.6099 3.25226 10.929C3.25226 11.2422 3.24935 11.5432 3.24354 11.8323C3.23773 11.9527 3.23192 12.0852 3.2261 12.2297C3.2261 12.3682 3.2261 12.5097 3.2261 12.6542C3.2261 12.7987 3.2261 12.9372 3.2261 13.0697C3.2261 13.2022 3.2261 13.3166 3.2261 13.4129H1.05502Z\" fill=\"#23493c\"/>\n<path d=\"M13.6693 6.05161C14.1052 7.3643 14.608 8.57763 15.1777 9.69161C15.7532 10.7996 16.3606 11.754 17 12.5548L15.3172 14C14.9684 13.5604 14.6226 13.0486 14.2796 12.4645C13.9425 11.8804 13.6082 11.2331 13.2769 10.5226C12.9456 9.80602 12.6201 9.03226 12.3004 8.20129C11.9806 7.37032 11.6639 6.48817 11.35 5.55484C11.1058 6.30753 10.8355 7.08129 10.5391 7.87613C10.2426 8.67097 9.92292 9.44473 9.57996 10.1974C9.24282 10.9441 8.89115 11.6456 8.52494 12.3019C8.15873 12.9523 7.78381 13.5123 7.40016 13.9819L5.69992 12.5548C6.008 12.1815 6.31317 11.748 6.61544 11.2542C6.9177 10.7604 7.21125 10.2335 7.49608 9.67355C7.7809 9.10753 8.0512 8.51742 8.30696 7.90323C8.56854 7.28903 8.80977 6.67183 9.03065 6.05161C9.19341 5.52774 9.35036 5.00086 9.50149 4.47097C9.65262 3.94108 9.78341 3.42925 9.89386 2.93548C10.0043 2.44172 10.0915 1.97806 10.1554 1.54452C10.2252 1.10495 10.2601 0.713549 10.2601 0.370324H12.4224C12.4224 0.737635 12.4515 1.13505 12.5096 1.56258C12.5736 1.99011 12.6578 2.44473 12.7625 2.92645C12.8729 3.40817 13.0037 3.91097 13.1548 4.43484C13.3118 4.95871 13.4833 5.49763 13.6693 6.05161Z\" fill=\"#23493c\"/>\n</svg>\n",
|
||||
"vuesax-linear:number-24": "<svg width=\"21\" height=\"12\" viewBox=\"0 0 21 12\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M7.59689 0.399477C7.61323 0.446474 7.64862 0.558746 7.70308 0.736292C7.75754 0.913838 7.812 1.12794 7.86646 1.37859C7.92092 1.62402 7.96993 1.89034 8.01349 2.17755C8.05706 2.45953 8.07884 2.73107 8.07884 2.99217C8.07884 3.53003 7.98626 3.99739 7.80111 4.39426C7.61595 4.79112 7.37633 5.1201 7.08226 5.3812C6.78819 5.63708 6.45327 5.82768 6.07751 5.953C5.7072 6.07833 5.33416 6.14099 4.9584 6.14099C4.58809 6.14099 4.24772 6.09922 3.93731 6.01567C3.63235 5.92689 3.34644 5.8094 3.0796 5.66318C3.12861 6.11749 3.16673 6.56919 3.19396 7.01828C3.22119 7.46736 3.24025 7.94256 3.25114 8.44386C3.26203 8.93995 3.26748 9.47781 3.26748 10.0574C3.27293 10.6371 3.27293 11.2846 3.26748 12H1.25798V10.1044C1.25798 9.48825 1.24709 8.83812 1.2253 8.15405C1.20897 7.46475 1.15996 6.74151 1.07827 5.98433C0.996581 5.22715 0.871328 4.43342 0.702508 3.60313C0.533688 2.77285 0.299519 1.906 0 1.00261L1.93598 0.40731L2.2709 1.36292C2.29813 1.44125 2.34169 1.56658 2.4016 1.7389C2.46695 1.906 2.55136 2.09661 2.65483 2.3107C2.7583 2.51958 2.88083 2.73629 3.02242 2.96084C3.16946 3.18016 3.33827 3.38381 3.52888 3.5718C3.71948 3.75457 3.93187 3.906 4.16604 4.02611C4.40565 4.14099 4.66977 4.19843 4.9584 4.19843C5.02375 4.19843 5.11633 4.18799 5.23614 4.1671C5.36139 4.14621 5.48392 4.09661 5.60373 4.01828C5.72898 3.93473 5.8379 3.81201 5.93048 3.65013C6.02305 3.48825 6.06934 3.26893 6.06934 2.99217C6.06934 2.83029 6.05573 2.65535 6.0285 2.46736C6.00127 2.27415 5.96587 2.08877 5.92231 1.91123C5.88419 1.72846 5.84062 1.55875 5.79161 1.40209C5.74804 1.24021 5.7072 1.10444 5.66908 0.994778L7.59689 0.399477Z\" fill=\"#23493c\"/>\n<path d=\"M17.9907 4.62924C18.1432 4.62924 18.2929 4.62924 18.44 4.62924C18.587 4.62402 18.734 4.61619 18.8811 4.60574C19.0281 4.5953 19.1806 4.58224 19.3385 4.56658C19.5019 4.54569 19.6762 4.51958 19.8613 4.48825L20.3188 6.25065C20.0356 6.32898 19.7633 6.39687 19.5019 6.45431C19.2405 6.50653 18.9709 6.55091 18.6932 6.58747C18.4155 6.6188 18.1187 6.64491 17.8028 6.6658C17.4924 6.68146 17.1412 6.68929 16.7491 6.68929C16.1827 6.68929 15.6054 6.64752 15.0173 6.56397C14.4346 6.48042 13.8791 6.32898 13.3509 6.10966C13.3836 6.53264 13.4108 6.91906 13.4326 7.26893C13.4598 7.6188 13.4789 7.95039 13.4897 8.26371C13.5006 8.57702 13.5088 8.88251 13.5143 9.18016C13.5197 9.47781 13.5224 9.7859 13.5224 10.1044C13.5224 10.3238 13.5197 10.5483 13.5143 10.7781C13.5143 11.0026 13.5143 11.248 13.5143 11.5144V12H11.4884V11.5065C11.4884 10.7389 11.483 10.0209 11.4721 9.35248C11.4666 8.68407 11.4476 8.05222 11.4149 7.45692C11.3877 6.86162 11.3468 6.29243 11.2924 5.74935C11.2379 5.20627 11.1644 4.67363 11.0718 4.15144C10.9792 3.62924 10.8649 3.10966 10.7287 2.59269C10.5926 2.07572 10.4292 1.54569 10.2386 1.00261L12.1991 0.40731C12.2862 0.678851 12.3652 0.94778 12.436 1.2141C12.5122 1.48042 12.5857 1.75196 12.6565 2.02872C12.7001 2.18016 12.7382 2.33681 12.7709 2.49869C12.809 2.65535 12.8499 2.81201 12.8934 2.96867C12.9915 3.33943 13.1439 3.6423 13.3509 3.87728C13.5633 4.10705 13.792 4.27676 14.037 4.38642C14.0044 4.30809 13.969 4.22454 13.9309 4.13577C13.8927 4.047 13.8573 3.94778 13.8247 3.83812C13.7974 3.72324 13.7729 3.59791 13.7511 3.46214C13.7348 3.32637 13.7266 3.17493 13.7266 3.00783C13.7266 2.53264 13.8219 2.10966 14.0125 1.7389C14.2086 1.36292 14.4618 1.047 14.7722 0.791123C15.0881 0.530026 15.4421 0.334203 15.8342 0.203655C16.2317 0.067885 16.6292 0 17.0268 0C17.3426 0 17.634 0.0365535 17.9008 0.10966C18.1731 0.177546 18.4264 0.266318 18.6605 0.375979C18.8947 0.485639 19.1153 0.608355 19.3222 0.744125C19.5346 0.874673 19.7415 1.00261 19.943 1.12794L18.9791 2.78068C18.7885 2.65013 18.6088 2.53525 18.44 2.43603C18.2712 2.33159 18.1078 2.24543 17.9499 2.17755C17.7919 2.10966 17.6367 2.05744 17.4842 2.02089C17.3372 1.97911 17.1847 1.95561 17.0268 1.95039H16.9778C16.8362 1.95039 16.6837 1.97389 16.5203 2.02089C16.357 2.06788 16.2045 2.14099 16.0629 2.24021C15.9213 2.33942 15.8015 2.46214 15.7035 2.60835C15.6109 2.74935 15.5646 2.91123 15.5646 3.09399C15.5646 3.39164 15.6381 3.63969 15.7851 3.83812C15.9322 4.03655 16.1201 4.19582 16.3488 4.31593C16.583 4.43081 16.8444 4.51175 17.133 4.55875C17.4216 4.60574 17.7075 4.62924 17.9907 4.62924Z\" fill=\"#23493c\"/>\n</svg>\n",
|
||||
"vuesax-linear:heart-fill": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M16.44 3.09998C14.63 3.09998 13.01 3.97998 12 5.32998C10.99 3.97998 9.37 3.09998 7.56 3.09998C4.49 3.09998 2 5.59998 2 8.68998C2 9.87998 2.19 10.98 2.52 12C4.1 17 8.97 19.99 11.38 20.81C11.72 20.93 12.28 20.93 12.62 20.81C15.03 19.99 19.9 17 21.48 12C21.81 10.98 22 9.87998 22 8.68998C22 5.59998 19.51 3.09998 16.44 3.09998Z\" fill=\"#8D6A46\"/>\n</svg>\n",
|
||||
"vuesax-linear:usd-coins": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M7.1 16.8999C9.8 19.5999 14.3 19.5999 17 16.8999\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M16.9 7.09995C14.2 4.39995 9.69999 4.39995 6.99999 7.09995\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
|
||||
"vuesax-linear:heart-transparent": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M12.62 20.81C12.28 20.93 11.72 20.93 11.38 20.81C8.48 19.82 2 15.69 2 8.68998C2 5.59998 4.49 3.09998 7.56 3.09998C9.38 3.09998 10.99 3.97998 12 5.33998C13.01 3.97998 14.63 3.09998 16.44 3.09998C19.51 3.09998 22 5.59998 22 8.68998C22 15.69 15.52 19.82 12.62 20.81Z\" fill=\"white\" fill-opacity=\"0.01\" stroke=\"#8D6A46\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
|
||||
"vuesax-linear:shield_dollar": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\">\n<path d=\"M10.49 2.23L5.49997 4.11C4.34997 4.54 3.40997 5.9 3.40997 7.12V14.55C3.40997 15.73 4.18997 17.28 5.13997 17.99L9.43997 21.2C10.85 22.26 13.17 22.26 14.58 21.2L18.88 17.99C19.83 17.28 20.61 15.73 20.61 14.55V7.12C20.61 5.89 19.67 4.53 18.52 4.1L13.53 2.23C12.68 1.92 11.32 1.92 10.49 2.23Z\" stroke=\"#1C2D56\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M9 13.4905C9 14.4976 9.78333 15.281 10.7905 15.281H12.6929C13.4762 15.281 14.2595 14.6095 14.2595 13.7143C14.2595 12.7071 13.8119 12.3714 13.2524 12.1476L10.119 11.0286C9.33571 10.9167 9 10.581 9 9.68572C9 8.79048 9.67143 8.11905 10.5667 8.11905H12.581C13.5881 8.11905 14.3714 8.90238 14.3714 9.90952\" stroke=\"#1C2D56\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M11.5739 7V16.4\" stroke=\"#1C2D56\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n"
|
||||
"vuesax-linear:shield_dollar": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\">\n<path d=\"M10.49 2.23L5.49997 4.11C4.34997 4.54 3.40997 5.9 3.40997 7.12V14.55C3.40997 15.73 4.18997 17.28 5.13997 17.99L9.43997 21.2C10.85 22.26 13.17 22.26 14.58 21.2L18.88 17.99C19.83 17.28 20.61 15.73 20.61 14.55V7.12C20.61 5.89 19.67 4.53 18.52 4.1L13.53 2.23C12.68 1.92 11.32 1.92 10.49 2.23Z\" stroke=\"#23493c\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M9 13.4905C9 14.4976 9.78333 15.281 10.7905 15.281H12.6929C13.4762 15.281 14.2595 14.6095 14.2595 13.7143C14.2595 12.7071 13.8119 12.3714 13.2524 12.1476L10.119 11.0286C9.33571 10.9167 9 10.581 9 9.68572C9 8.79048 9.67143 8.11905 10.5667 8.11905H12.581C13.5881 8.11905 14.3714 8.90238 14.3714 9.90952\" stroke=\"#23493c\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M11.5739 7V16.4\" stroke=\"#23493c\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n"
|
||||
|
||||
}
|
||||
@@ -161,7 +161,7 @@ const wageLabel = computed(() => {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
}
|
||||
|
||||
.pc__flag--soft {
|
||||
|
||||
@@ -273,11 +273,6 @@
|
||||
</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" />
|
||||
@@ -301,7 +296,7 @@ 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 { toast } from '../../composables/useToast'
|
||||
import DialogVant from '../DialogVant.vue'
|
||||
import { useProduct, isVariantOrderable } from '../../composables/useProduct'
|
||||
import { useProductDetail } from '../../composables/useProductDetail'
|
||||
@@ -327,7 +322,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)
|
||||
@@ -484,7 +478,7 @@ const addToCart = () => {
|
||||
// Previously the "added to cart" toast fired either way, telling the customer
|
||||
// something had been added when nothing had. Kept in step with user-app-v2.
|
||||
if (!cart.addToCart(storeProduct.value)) return
|
||||
notify.value = true
|
||||
toast.success('محصول با موفقیت به سبد خرید افزوده شد', { title: 'ثبت سفارش' })
|
||||
}
|
||||
|
||||
const toggleFav = (e: Event) => {
|
||||
@@ -732,8 +726,8 @@ onBeforeUnmount(() => {
|
||||
transition: border-color 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
.pd__variant.is-on {
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
background: @primary;
|
||||
border-color: @primary;
|
||||
}
|
||||
.pd__variantImg {
|
||||
width: 100%;
|
||||
@@ -931,7 +925,7 @@ onBeforeUnmount(() => {
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
transition: transform 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
.pd__cta:active {
|
||||
@@ -942,9 +936,9 @@ onBeforeUnmount(() => {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.pd__cta--ghost {
|
||||
color: @ink;
|
||||
color: @primary;
|
||||
background: @canvas;
|
||||
border: 1px solid @ink;
|
||||
border: 1px solid @primary;
|
||||
}
|
||||
|
||||
/* ── Full-screen preview ─────────────────────────────────── */
|
||||
|
||||
@@ -186,8 +186,8 @@ const closeFilters = (sendQuery: boolean): void => {
|
||||
|
||||
.filt__chip.is-on {
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
background: @primary;
|
||||
border-color: @primary;
|
||||
}
|
||||
|
||||
.filt__chip:active {
|
||||
@@ -280,7 +280,7 @@ const closeFilters = (sendQuery: boolean): void => {
|
||||
|
||||
.filt__apply {
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
}
|
||||
|
||||
.filt__clear:active,
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Lets the router hold its scroll until MasterLayout's outgoing page has
|
||||
* faded out.
|
||||
*
|
||||
* vue-router runs scrollBehavior right after the new route renders, which with
|
||||
* an out-in transition is while the OLD page is still fading — so a scrolled
|
||||
* page would visibly snap to the top on its way out. MasterLayout reports the
|
||||
* leave here, and scrollBehavior awaits `afterPageLeave()`, so the scroll lands
|
||||
* while the new page is still fully transparent.
|
||||
*
|
||||
* Routes in FullPageLayout have no transition: no leave is ever reported for
|
||||
* them and `afterPageLeave()` resolves at once.
|
||||
*/
|
||||
|
||||
// Never hold the scroll longer than this, even if a leave hook goes missing.
|
||||
const MAX_WAIT_MS = 400
|
||||
|
||||
let leaving: Promise<void> | null = null
|
||||
let finish: (() => void) | null = null
|
||||
|
||||
export function pageLeaveStarted() {
|
||||
finish?.()
|
||||
leaving = new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(done, MAX_WAIT_MS)
|
||||
function done() {
|
||||
clearTimeout(timer)
|
||||
if (finish === done) {
|
||||
finish = null
|
||||
leaving = null
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
finish = done
|
||||
})
|
||||
}
|
||||
|
||||
export function pageLeaveEnded() {
|
||||
finish?.()
|
||||
}
|
||||
|
||||
export function afterPageLeave(): Promise<void> {
|
||||
return leaving ?? Promise.resolve()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* First-paint entrance shared by the tab screens (Home, Dashboard, Cart):
|
||||
* once a screen's first data load lands, its sections rise and fade in one
|
||||
* after another. The styles are the global `.reveal` / `.is-revealed` pair in
|
||||
* assets/styles/style.less.
|
||||
*
|
||||
* MasterLayout renders its routes without keep-alive, so a screen remounts on
|
||||
* every return to its tab. Which screens have already played is kept here in
|
||||
* module scope, so the entrance is a first-run flourish that resets only on a
|
||||
* full reload — replaying it on each tab switch would fight the page
|
||||
* transition rather than add to it. (A `let` at the top of <script setup>
|
||||
* would not do this: that code runs once per mount.)
|
||||
*/
|
||||
const played = new Set<string>()
|
||||
|
||||
export function useReveal(key: string, stepMs = 70) {
|
||||
const revealed = ref(played.has(key))
|
||||
|
||||
// Each item starts a beat after the one before it. Kept small, so the whole
|
||||
// sequence finishes well under a second and reads as the page arriving.
|
||||
function revealDelay(i: number) {
|
||||
return { '--reveal-delay': `${i * stepMs}ms` }
|
||||
}
|
||||
|
||||
// Call once the first load has populated the DOM. Waits for the new nodes
|
||||
// to be mounted, then for one painted frame in their "before" state — with
|
||||
// a single frame the browser can resolve both states in the same style pass
|
||||
// and there is nothing to animate.
|
||||
async function reveal() {
|
||||
if (revealed.value) return
|
||||
await nextTick()
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => {
|
||||
revealed.value = true
|
||||
played.add(key)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return { revealed, revealDelay, reveal }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
/**
|
||||
* App-wide toasts. One shared queue, rendered by <ToastHost> (mounted once in
|
||||
* App.vue), so any view or store can call `toast.success(...)` without placing
|
||||
* a component in its own template.
|
||||
*/
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
export interface ToastOptions {
|
||||
title?: string
|
||||
/** ms before it closes by itself; 0 keeps it until the user dismisses it. */
|
||||
duration?: number
|
||||
/** Custom icon name; defaults to one per type. */
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export interface ToastItem {
|
||||
id: number
|
||||
type: ToastType
|
||||
message: string
|
||||
title?: string
|
||||
icon: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
const DEFAULT_DURATION = 3200
|
||||
// Older toasts beyond this are dropped so the stack never covers the screen.
|
||||
const MAX_VISIBLE = 3
|
||||
|
||||
const ICONS: Record<ToastType, string> = {
|
||||
success: 'vuesax-linear:tick-circle',
|
||||
error: 'vuesax-linear:close-circle',
|
||||
info: 'vuesax-linear:info-circle',
|
||||
warning: 'vuesax-linear:warning-2',
|
||||
}
|
||||
|
||||
const state = reactive<{ items: ToastItem[] }>({ items: [] })
|
||||
let nextId = 1
|
||||
|
||||
function show(type: ToastType, message: string, options: ToastOptions = {}): number {
|
||||
const id = nextId++
|
||||
state.items.push({
|
||||
id,
|
||||
type,
|
||||
message,
|
||||
title: options.title,
|
||||
icon: options.icon ?? ICONS[type],
|
||||
duration: options.duration ?? DEFAULT_DURATION,
|
||||
})
|
||||
if (state.items.length > MAX_VISIBLE) state.items.splice(0, state.items.length - MAX_VISIBLE)
|
||||
return id
|
||||
}
|
||||
|
||||
function dismiss(id: number) {
|
||||
const i = state.items.findIndex((t) => t.id === id)
|
||||
if (i !== -1) state.items.splice(i, 1)
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
show,
|
||||
dismiss,
|
||||
success: (message: string, options?: ToastOptions) => show('success', message, options),
|
||||
error: (message: string, options?: ToastOptions) => show('error', message, options),
|
||||
info: (message: string, options?: ToastOptions) => show('info', message, options),
|
||||
warning: (message: string, options?: ToastOptions) => show('warning', message, options),
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return { toasts: state.items, ...toast }
|
||||
}
|
||||
@@ -8,7 +8,17 @@
|
||||
-->
|
||||
<template>
|
||||
<section ref="content">
|
||||
<router-view />
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition
|
||||
name="page"
|
||||
mode="out-in"
|
||||
@before-leave="pageLeaveStarted"
|
||||
@after-leave="pageLeaveEnded"
|
||||
@leave-cancelled="pageLeaveEnded"
|
||||
>
|
||||
<component :is="Component" :key="route.name" />
|
||||
</transition>
|
||||
</router-view>
|
||||
<Navigation />
|
||||
<div class="bg-primary top-[-2rem] fixed h-[2rem] w-full z-10" />
|
||||
</section>
|
||||
@@ -19,10 +29,16 @@ import { onBeforeMount, ref } from 'vue'
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import Navigation from '../components/ui/Navigation.vue'
|
||||
import { useWindowDataStore } from '../stores/windowDataStore'
|
||||
import { pageLeaveStarted, pageLeaveEnded } from '../composables/usePageTransition'
|
||||
|
||||
const windowData = useWindowDataStore()
|
||||
const content = ref<HTMLElement | null>(null)
|
||||
|
||||
// Route changes here are tab switches, not a hierarchy, so there is no "back"
|
||||
// or "forward" direction to honour. The outgoing page fades out quickly and
|
||||
// the new one fades in; the sense of the page settling into place comes from
|
||||
// each screen's own section reveal (composables/useReveal.ts).
|
||||
|
||||
onBeforeMount(() => {
|
||||
windowData.setWidth(window.innerWidth)
|
||||
})
|
||||
@@ -34,3 +50,38 @@ useResizeObserver(content, (entries) => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Not scoped: these classes land on the routed page component, which carries
|
||||
its own scope id, not this layout's.
|
||||
|
||||
Opacity only, deliberately. A transform (or filter) on the page root makes it
|
||||
the containing block for every position: fixed element inside the page — the
|
||||
top bars, PageBar, the cart's checkout bar — so for the length of the
|
||||
transition they would ride along with the page, then snap back to the screen
|
||||
edge when it ends. Opacity has no such effect, so nothing jumps.
|
||||
-->
|
||||
<style lang="less">
|
||||
// Leave is deliberately shorter than enter. With mode="out-in" the two run
|
||||
// back to back, so a slow exit is dead time the user feels as lag; a quick
|
||||
// fade out and an unhurried fade in reads as responsive but smooth.
|
||||
.page-leave-active {
|
||||
transition: opacity 0.16s cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
|
||||
.page-enter-active {
|
||||
transition: opacity 0.42s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.page-enter-from,
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: opacity 0.12s linear;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -33,11 +33,14 @@ setUnauthorizedHandler(() => {
|
||||
void router.push({ name: 'login' });
|
||||
}
|
||||
});
|
||||
// Ported from legacy main.ts:32-46 — Vector.svg is both the while-loading and
|
||||
// the on-error placeholder, so a missing/failed image paints the brand mark
|
||||
// instead of collapsing the layout.
|
||||
// Ported from legacy main.ts:32-46, with one change: the brand mark is now only
|
||||
// the on-error placeholder. As the while-loading placeholder it painted the
|
||||
// logo into every card first and then swapped it for the real photo a moment
|
||||
// later — a visible jump on every image. While loading, the library's own
|
||||
// transparent pixel is used instead, the image is held at opacity 0 and fades
|
||||
// in once loaded (see `img[lazy]` in style.less); the card's own background
|
||||
// stands in until then.
|
||||
app.use(VueLazyLoad, {
|
||||
loading: Vector,
|
||||
error: Vector,
|
||||
});
|
||||
app.component('container', Container);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useSessionStore } from '../stores/session';
|
||||
import { useNavStore } from '../stores/navStore';
|
||||
import { useTransitionStore } from '../stores/transitionStore';
|
||||
import { api } from '../api/client';
|
||||
import { afterPageLeave } from '../composables/usePageTransition';
|
||||
|
||||
// Legacy lazy-loaded every route component (`component: () => import(...)`) so
|
||||
// the initial bundle only carries the entry route. Matched here.
|
||||
@@ -10,7 +11,11 @@ export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
// Ported from legacy router/index.ts:225-237 — restore saved scroll on
|
||||
// back/forward, otherwise jump to top on every navigation.
|
||||
scrollBehavior(_to, _from, savedPosition) {
|
||||
// Waits for MasterLayout's outgoing page to finish fading first, so the
|
||||
// scroll happens behind the still-transparent incoming page instead of
|
||||
// jerking the old one to the top mid-fade (see composables/usePageTransition.ts).
|
||||
async scrollBehavior(_to, _from, savedPosition) {
|
||||
await afterPageLeave();
|
||||
if (savedPosition) return savedPosition;
|
||||
return { top: 0 };
|
||||
},
|
||||
|
||||
@@ -38,11 +38,13 @@
|
||||
<template #loading><Loading size="18" /></template>
|
||||
<template #success><span /></template>
|
||||
<template v-if="cart.items.length">
|
||||
<div class="cart__items">
|
||||
<div class="cart__items" :class="{ 'is-revealed': revealed }">
|
||||
<CartItem
|
||||
v-for="item in cart.items"
|
||||
v-for="(item, i) in cart.items"
|
||||
:key="item.pid + item.vid"
|
||||
:value="item"
|
||||
class="reveal"
|
||||
:style="revealDelay(i)"
|
||||
@note="
|
||||
cartData.tempRow = item;
|
||||
cartData.showPopUp = true;
|
||||
@@ -119,7 +121,7 @@
|
||||
</template>
|
||||
<template v-slot:title>آیا مطمئن به ثبت سفارش هستید ؟</template>
|
||||
<template v-slot:button>
|
||||
<div class="text-center text-[#1C2D56]" @click="submitOrder">بله</div>
|
||||
<div class="text-center text-[#23493c]" @click="submitOrder">بله</div>
|
||||
<div class="text-center text-[#6b7280]" @click="cartData.notify = false">بی خیال</div>
|
||||
</template>
|
||||
</DialogVant>
|
||||
@@ -132,7 +134,7 @@
|
||||
<div class="p-8 text-justify">برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید.</div>
|
||||
</template>
|
||||
<template v-slot:button>
|
||||
<div class="text-center text-[#1C2D56]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
|
||||
<div class="text-center text-[#23493c]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
|
||||
<div class="text-center text-danger" @click="loginNotify = false">بیخیال</div>
|
||||
</template>
|
||||
</DialogVant>
|
||||
@@ -153,6 +155,7 @@ import DialogVant from '../components/DialogVant.vue'
|
||||
import CartItem from '../components/product/CartItem.vue'
|
||||
import { useCartStore, type CartItem as CartItemType } from '../stores/cartStore'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import { useReveal } from '../composables/useReveal'
|
||||
import { api } from '../api/client'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -162,6 +165,10 @@ const session = useSessionStore()
|
||||
const loginNotify = ref(false)
|
||||
const refreshing = ref(false)
|
||||
|
||||
// First-paint entrance for the basket rows, the same choreography HomeView
|
||||
// uses with a slightly shorter beat, since a basket can hold many rows.
|
||||
const { revealed, revealDelay, reveal } = useReveal('cart', 60)
|
||||
|
||||
const cartData = reactive({
|
||||
loadingPage: false,
|
||||
showPopUp: false,
|
||||
@@ -227,6 +234,7 @@ async function onRefresh() {
|
||||
|
||||
onMounted(async () => {
|
||||
await cart.init()
|
||||
reveal()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -274,7 +282,7 @@ onMounted(async () => {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
}
|
||||
|
||||
.cart__done {
|
||||
@@ -330,7 +338,7 @@ onMounted(async () => {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
}
|
||||
|
||||
.cart__bar {
|
||||
@@ -390,7 +398,7 @@ onMounted(async () => {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -419,6 +427,6 @@ onMounted(async () => {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -40,20 +40,27 @@
|
||||
<template #loosing><Loading size="18" /></template>
|
||||
<template #loading><Loading size="18" /></template>
|
||||
<template #success><span /></template>
|
||||
<container class="home__body">
|
||||
<RateStrip :quote="goldPrice" class="home__rate" />
|
||||
<container class="home__body" :class="{ 'is-revealed': revealed }">
|
||||
<RateStrip :quote="goldPrice" class="home__rate reveal" :style="revealDelay(0)" />
|
||||
|
||||
<ShowcaseSlider v-if="slides.length" :slides="slides" class="home__hero" @select="onSlide" />
|
||||
<ShowcaseSlider
|
||||
v-if="slides.length"
|
||||
:slides="slides"
|
||||
class="home__hero reveal"
|
||||
:style="revealDelay(1)"
|
||||
@select="onSlide"
|
||||
/>
|
||||
|
||||
<CategoryRail
|
||||
v-if="memory.cats.length"
|
||||
:categories="memory.cats"
|
||||
:active-id="null"
|
||||
class="home__rail"
|
||||
class="home__rail reveal"
|
||||
:style="revealDelay(2)"
|
||||
@select="onCategory"
|
||||
/>
|
||||
|
||||
<section v-if="featured" class="home__section">
|
||||
<section v-if="featured" class="home__section reveal" :style="revealDelay(3)">
|
||||
<SectionHead
|
||||
title="برگزیده منتزر"
|
||||
subtitle="انتخابشده از میان مجموعههای موجود"
|
||||
@@ -67,7 +74,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="newestProducts.length" class="home__section">
|
||||
<section v-if="newestProducts.length" class="home__section reveal" :style="revealDelay(4)">
|
||||
<SectionHead
|
||||
title="تازهها"
|
||||
subtitle="آخرین افزودههای مجموعه"
|
||||
@@ -85,12 +92,12 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="!loading && !products.length" class="home__empty">
|
||||
<section v-if="!loading && !products.length" class="home__empty reveal" :style="revealDelay(3)">
|
||||
<Icon icon="vuesax-linear:box" size="26" />
|
||||
<p>هنوز محصولی در مجموعه ثبت نشده است.</p>
|
||||
</section>
|
||||
|
||||
<div class="home__trust">
|
||||
<div class="home__trust reveal" :style="revealDelay(5)">
|
||||
<div class="home__trustItem">
|
||||
<Icon icon="vuesax-linear:shield-tick" size="18" />
|
||||
<span>خرید امن</span>
|
||||
@@ -137,6 +144,7 @@ import { useConfigStore } from '../stores/configStore'
|
||||
import type { HomeCategory } from '../stores/configStore'
|
||||
import { useCartStore } from '../stores/cartStore'
|
||||
import { useFiltersStore } from '../stores/filtersStore'
|
||||
import { useReveal } from '../composables/useReveal'
|
||||
import { api } from '../api/client'
|
||||
import type { AdminSlide, GoldPriceQuote, ProductSummary } from '../api/client'
|
||||
|
||||
@@ -157,6 +165,11 @@ const adminSlides = ref<AdminSlide[] | null>(null)
|
||||
const productDrawer = ref(false)
|
||||
const activeProductId = ref<string | null>(null)
|
||||
|
||||
// First-paint entrance: played once, after the first data load, so the
|
||||
// sections settle into place instead of popping in piecemeal. Pull-to-refresh
|
||||
// and later returns to this tab do not replay it.
|
||||
const { revealed, revealDelay, reveal } = useReveal('home')
|
||||
|
||||
const featured = computed<ProductSummary | null>(() => products.value[0] ?? null)
|
||||
const gridProducts = computed(() => products.value.slice(1, 5))
|
||||
const newestProducts = computed(() => products.value.slice(5, 13))
|
||||
@@ -271,6 +284,7 @@ async function onRefresh() {
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
loading.value = false
|
||||
reveal()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -405,7 +405,7 @@ onBeforeUnmount(() => observer?.disconnect())
|
||||
padding: 13px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -336,7 +336,7 @@ onMounted(load)
|
||||
padding: 13px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -296,8 +296,8 @@ onBeforeUnmount(() => observer?.disconnect())
|
||||
}
|
||||
|
||||
.rep__tab.is-on {
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
background: @primary;
|
||||
border-color: @primary;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -427,7 +427,7 @@ onBeforeUnmount(() => observer?.disconnect())
|
||||
padding: 13px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -53,7 +53,14 @@
|
||||
</template>
|
||||
</PageBar>
|
||||
|
||||
<Spinner :spinning="loadingPage" tip="درحال دریافت اطلاعات">
|
||||
<!--
|
||||
No full-screen spinner here: it covered the page with the brand logo and
|
||||
then dropped the finished results in all at once. The first load shows a
|
||||
skeleton of the result grid instead, the same size as the real cards, so
|
||||
the results replace it in place; a pull-to-refresh shows only the pull
|
||||
indicator, over the results already on screen.
|
||||
-->
|
||||
<div>
|
||||
<Filters
|
||||
v-model:filterKeys="filterKeys"
|
||||
v-show="showFilter"
|
||||
@@ -68,6 +75,22 @@
|
||||
<template #loading><Loading size="18" /></template>
|
||||
<template #success><span /></template>
|
||||
<container class="search__body">
|
||||
<div v-if="loadingPage && !allProducts.length" class="search__skeleton" aria-hidden="true">
|
||||
<div class="search__skHead">
|
||||
<span class="skeleton search__skLine search__skLine--title" />
|
||||
<span class="skeleton search__skLine search__skLine--sub" />
|
||||
</div>
|
||||
<div class="search__grid">
|
||||
<div v-for="n in SKELETON_CARDS" :key="n" class="search__skCard">
|
||||
<div class="skeleton search__skArt" />
|
||||
<div class="search__skBody">
|
||||
<span class="skeleton search__skLine search__skLine--name" />
|
||||
<span class="skeleton search__skLine search__skLine--meta" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="availableCollections.length" class="search__collections">
|
||||
<button
|
||||
v-for="item in availableCollections"
|
||||
@@ -100,20 +123,23 @@
|
||||
<Spinner :spinning="true" :indicator="true" :fixed="false" tip="" cls="my-[10px]" />
|
||||
</div>
|
||||
|
||||
<section v-else-if="!loadingPage" class="search__empty">
|
||||
<!-- Its own condition, not a v-else of the sentinel above: chained to
|
||||
it, the box also showed under the results whenever they all fit
|
||||
in the first window. Same fix as user-app-v2. -->
|
||||
<section v-if="!filteredProducts.length && !loadingPage" class="search__empty">
|
||||
<Icon icon="vuesax-linear:search-normal-1" size="26" />
|
||||
<p>محصولی با این مشخصات پیدا نشد.</p>
|
||||
</section>
|
||||
</container>
|
||||
</PullRefresh>
|
||||
</Spinner>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { PullRefresh } from 'vant'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import Spinner from '../../components/Spinner.vue'
|
||||
@@ -128,7 +154,12 @@ import { api, type ProductSummary } from '../../api/client'
|
||||
const filters = useFiltersStore()
|
||||
const nav = useNavStore()
|
||||
|
||||
const loadingPage = ref(false)
|
||||
// Starts true: the first render happens before onMounted kicks off the fetch,
|
||||
// and with false the "nothing found" box flashed up for a frame before the
|
||||
// loading state replaced it.
|
||||
const loadingPage = ref(true)
|
||||
// Two rows of the two-column grid — enough to fill the screen under the bar.
|
||||
const SKELETON_CARDS = 6
|
||||
const refreshing = ref(false)
|
||||
const allProducts = ref<ProductSummary[]>([])
|
||||
const showFilter = ref(false)
|
||||
@@ -316,8 +347,8 @@ onMounted(loadProducts)
|
||||
|
||||
.search__chip.is-on {
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
background: @primary;
|
||||
border-color: @primary;
|
||||
}
|
||||
|
||||
.search__chip--filter.is-on {
|
||||
@@ -372,6 +403,58 @@ onMounted(loadProducts)
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
// First-load placeholder. Mirrors SectionHead + ProductCard's box model
|
||||
// (square art, 11/12/13px body padding, an 18px-radius hairline card) so
|
||||
// the real results land exactly where the placeholders were.
|
||||
.search__skHead {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
padding-bottom: 13px;
|
||||
}
|
||||
|
||||
.search__skCard {
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search__skArt {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.search__skBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 11px;
|
||||
padding: 15px 12px 16px;
|
||||
}
|
||||
|
||||
.search__skLine {
|
||||
display: block;
|
||||
height: 11px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.search__skLine--title {
|
||||
width: 72px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.search__skLine--sub {
|
||||
width: 52px;
|
||||
}
|
||||
|
||||
.search__skLine--name {
|
||||
width: 78%;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.search__skLine--meta {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.search__more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showNotify } from 'vant'
|
||||
import { toast } from '../../composables/useToast'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import Spinner from '../../components/Spinner.vue'
|
||||
import DialogVantOneButton from '../../components/DialogVantOneButton.vue'
|
||||
@@ -106,7 +106,7 @@ async function submit() {
|
||||
// The reference checked all three client-side before posting
|
||||
// (completeProfile.vue:191-198); the server requires them too.
|
||||
if (!form.business_license_image_id || !form.shop_image_id || !form.ncode_image_id) {
|
||||
showNotify('لطفا تمامی عکس های خواسته شده را آپلود کنید')
|
||||
toast.warning('لطفا تمامی عکس های خواسته شده را آپلود کنید')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ function finish() {
|
||||
padding: 14px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
|
||||
@@ -49,10 +49,14 @@
|
||||
</header>
|
||||
|
||||
<PullRefresh v-model="refreshing" @refresh="onRefresh">
|
||||
<container class="dash__body">
|
||||
<template #pulling><Loading size="18" /></template>
|
||||
<template #loosing><Loading size="18" /></template>
|
||||
<template #loading><Loading size="18" /></template>
|
||||
<template #success><span /></template>
|
||||
<container class="dash__body" :class="{ 'is-revealed': revealed }">
|
||||
<!-- Signed in: real position from GET /customers/:id -->
|
||||
<template v-if="customer">
|
||||
<section class="dash__balance">
|
||||
<section class="dash__balance reveal" :style="revealDelay(0)">
|
||||
<div class="dash__balanceHead">
|
||||
<div>
|
||||
<div class="dash__eyebrow">{{ remainText }}</div>
|
||||
@@ -79,7 +83,7 @@
|
||||
</template>
|
||||
|
||||
<!-- Signed out: a quiet invitation, no fabricated numbers -->
|
||||
<section v-else class="dash__invite">
|
||||
<section v-else class="dash__invite reveal" :style="revealDelay(0)">
|
||||
<div class="dash__inviteIcon" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:security-user" size="20" />
|
||||
</div>
|
||||
@@ -93,7 +97,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Nav grid -->
|
||||
<section class="dash__section">
|
||||
<section class="dash__section reveal" :style="revealDelay(1)">
|
||||
<SectionHead title="دسترسیها" subtitle="بخشهای حساب شما" />
|
||||
<div class="dash__grid">
|
||||
<button
|
||||
@@ -113,7 +117,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Live 18k rate — same strip the home screen uses -->
|
||||
<section class="dash__section">
|
||||
<section class="dash__section reveal" :style="revealDelay(2)">
|
||||
<SectionHead title="نرخ روز" subtitle="آخرین نرخ اعلامشده" />
|
||||
<RateStrip :quote="goldPrice" />
|
||||
</section>
|
||||
@@ -126,7 +130,7 @@
|
||||
</template>
|
||||
<template v-slot:title>آیا می خواهید از برنامه خارج شوید؟</template>
|
||||
<template v-slot:button>
|
||||
<div class="text-center text-[#1C2D56]" @click="logOut">بله</div>
|
||||
<div class="text-center text-[#23493c]" @click="logOut">بله</div>
|
||||
<div class="text-center text-[#6b7280]" @click="data.notify = false">بی خیال</div>
|
||||
</template>
|
||||
</DialogVant>
|
||||
@@ -142,7 +146,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:button>
|
||||
<div class="text-center text-[#1C2D56]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
|
||||
<div class="text-center text-[#23493c]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
|
||||
<div class="text-center text-danger" @click="data.loginNotify = false">بیخیال</div>
|
||||
</template>
|
||||
</DialogVant>
|
||||
@@ -152,7 +156,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { PullRefresh } from 'vant'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import SectionHead from '../../components/home/SectionHead.vue'
|
||||
import RateStrip from '../../components/home/RateStrip.vue'
|
||||
@@ -161,6 +165,7 @@ import exitImage from '../../assets/img/empty/dialogEmpty5.svg'
|
||||
import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg'
|
||||
import { price } from '../../utils/filters'
|
||||
import { useSessionStore } from '../../stores/session'
|
||||
import { useReveal } from '../../composables/useReveal'
|
||||
import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -243,7 +248,14 @@ async function onRefresh() {
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
onMounted(loadDashboard)
|
||||
// First-paint entrance, the same choreography HomeView uses: played once,
|
||||
// after the first load; pull-to-refresh and tab returns don't replay it.
|
||||
const { revealed, revealDelay, reveal } = useReveal('dashboard')
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDashboard()
|
||||
reveal()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@@ -311,7 +323,7 @@ onMounted(loadDashboard)
|
||||
.dash__balance {
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
color: #fff;
|
||||
box-shadow: 0 14px 30px -22px rgba(20, 22, 26, 0.95);
|
||||
}
|
||||
@@ -455,7 +467,7 @@ onMounted(loadDashboard)
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
}
|
||||
|
||||
.dash__section {
|
||||
|
||||
@@ -105,6 +105,6 @@ onMounted(async () => {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
-->
|
||||
<template>
|
||||
<div class="auth">
|
||||
<NotifyVant :timeOut="2000" v-model="notify.show" :text="notify.text" :color="'background-color: #DC2626'" />
|
||||
|
||||
<PageBar title="ورود" back-to="home" />
|
||||
|
||||
<container class="auth__body">
|
||||
@@ -88,7 +86,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { Form, FormItem, Input } from 'ant-design-vue'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import NotifyVant from '../../components/NotifyVant.vue'
|
||||
import { toast } from '../../composables/useToast'
|
||||
import OtpInput from '../../components/input/OtpInput.vue'
|
||||
import { required, mobile as mobileRule } from '../../utils/validator'
|
||||
import { useSessionStore } from '../../stores/session'
|
||||
@@ -100,7 +98,6 @@ const route = useRoute()
|
||||
const session = useSessionStore()
|
||||
|
||||
const formModel = reactive({ username: undefined as string | undefined })
|
||||
const notify = reactive({ show: false, text: 'اطلاعات کاربری در سیستم وجود ندارد.' })
|
||||
const body = reactive({
|
||||
step: 1,
|
||||
loading: false,
|
||||
@@ -146,8 +143,7 @@ const sendPhone = async () => {
|
||||
// 429 = the send throttle (too soon, or too many in the window). Its message
|
||||
// is already Persian and user-facing, so show it rather than a generic one.
|
||||
const text = e instanceof Error && /\(429\)/.test(e.message) ? extractServerMessage(e) : null
|
||||
notify.text = text ?? 'خطا در ارسال کد. لطفاً دوباره تلاش کنید.'
|
||||
notify.show = true
|
||||
toast.error(text ?? 'خطا در ارسال کد. لطفاً دوباره تلاش کنید.')
|
||||
} finally {
|
||||
body.loading = false
|
||||
}
|
||||
@@ -177,14 +173,13 @@ const sendToken = async (otpCode: string) => {
|
||||
}
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : undefined
|
||||
await router.push(redirect ?? { name: 'home' })
|
||||
notify.text = 'با موفقیت وارد شدید'
|
||||
notify.show = true
|
||||
toast.success('با موفقیت وارد شدید')
|
||||
} catch (e: unknown) {
|
||||
notify.text =
|
||||
toast.error(
|
||||
e instanceof Error && /\(429\)/.test(e.message)
|
||||
? (extractServerMessage(e) ?? 'تعداد تلاشها بیش از حد مجاز است.')
|
||||
: 'کد وارد شده صحیح نیست'
|
||||
notify.show = true
|
||||
: 'کد وارد شده صحیح نیست',
|
||||
)
|
||||
} finally {
|
||||
body.loading = false
|
||||
}
|
||||
@@ -286,7 +281,7 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -338,7 +333,7 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
|
||||
|
||||
.otp-input:focus {
|
||||
outline: none;
|
||||
border-color: @ink;
|
||||
border-color: @primary;
|
||||
}
|
||||
|
||||
.otp-input.error {
|
||||
|
||||
@@ -261,8 +261,8 @@ onMounted(loadOrders)
|
||||
|
||||
.ord__chip.is-on {
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
background: @primary;
|
||||
border-color: @primary;
|
||||
}
|
||||
|
||||
.ord__chip:active {
|
||||
@@ -393,8 +393,8 @@ onMounted(loadOrders)
|
||||
|
||||
.detail__step.is-done .detail__stepDot {
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
background: @primary;
|
||||
border-color: @primary;
|
||||
}
|
||||
|
||||
.detail__step.is-active .detail__stepDot {
|
||||
|
||||
@@ -247,7 +247,7 @@ onMounted(async () => {
|
||||
padding: 14px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
|
||||
@@ -190,7 +190,7 @@ const register = async () => {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
background: @primary;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user