Improve boursecu UX: banners, support FAB, wallet deposit guide, fund/physical layouts, nav, and sidebar credit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-23 14:10:40 +02:00
co-authored by Cursor
parent 21345318ba
commit 4fd12277e7
40 changed files with 1844 additions and 305 deletions
+86 -18
View File
@@ -1,32 +1,100 @@
<template>
<div :class="variant === 'desktop' ? 'dpage-support' : 'wrap-fix bottom-0 px-10 pb-3rem flex gap-2 bg-white'">
<a
class="w-full"
:href="supportHref"
:target="opensSupportInNewTab ? '_blank' : undefined"
:rel="opensSupportInNewTab ? 'noopener noreferrer' : undefined"
<div :class="variant === 'desktop' ? desktopRootClass : mobileRootClass">
<button
ref="triggerEl"
type="button"
class="support-contact-bar__btn"
:disabled="!canContactSupport"
@click="onSupportClick"
>
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a :href="baleHref" target="_blank" rel="noopener noreferrer">
<img src="@/assets/img/bale.png" alt="بله" :class="variant === 'desktop' ? 'dpage-support__bale' : 'h-10 w-10'" />
</a>
<BasicIcon icon="vuesax-linear:messages-2" size="20" color="#fff" />
<span>ارتباط با پشتیبانی</span>
</button>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { getProjectBaleContactLink, getProjectSupportContactLink } from '@/utils/config'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useSupportMessenger } from '@/composables/useSupportMessenger'
import { computed, ref } from 'vue'
withDefaults(
defineProps<{
variant?: 'mobile' | 'desktop'
mobileRootClass?: string
desktopRootClass?: string
}>(),
{ variant: 'mobile' },
{
variant: 'mobile',
mobileRootClass: 'wrap-fix bottom-0 px-10 pb-3rem flex bg-white',
desktopRootClass: 'dpage-support',
},
)
const supportHref = computed(() => getProjectSupportContactLink())
const baleHref = computed(() => getProjectBaleContactLink())
const opensSupportInNewTab = computed(() => supportHref.value.startsWith('http'))
const {
supportPhoneLink,
hasMessengerLink,
hasSupportPhoneLink,
openSupportMessenger,
} = useSupportMessenger()
const triggerEl = ref<HTMLElement | null>(null)
const canContactSupport = computed(() => hasMessengerLink.value || hasSupportPhoneLink.value)
async function onSupportClick() {
// همان رفتار دکمه شناور پشتیبانی — بدون پیش‌نویس واریز
if (hasMessengerLink.value) {
await openSupportMessenger(triggerEl.value)
return
}
if (hasSupportPhoneLink.value && supportPhoneLink.value) {
window.location.href = supportPhoneLink.value
}
}
</script>
<style scoped lang="less">
.support-contact-bar__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
min-height: 2.75rem;
padding: 0.65rem 1rem;
border: 0;
border-radius: 0.75rem;
background: var(--App-primary, var(--ant-color-primary, #7f1d1d));
color: #fff;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s ease, transform 0.15s ease;
&:active {
transform: scale(0.98);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.dpage-support {
width: 100%;
}
</style>
@@ -0,0 +1,43 @@
<template>
<span
class="block w-full h-full rounded-full overflow-hidden shrink-0 bg-slate-500"
:class="{ 'bg-[#e37600]': normalized === 'eitaa' }"
aria-hidden="true"
>
<img
v-if="logoSrc"
:src="logoSrc"
:alt="normalized"
class="block w-full h-full object-cover object-center bg-white"
draggable="false"
/>
<span v-else class="block w-full h-full bg-slate-500" />
</span>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import baleLogo from '@/assets/messengers/bale.png'
import callLogo from '@/assets/messengers/call.svg'
import eitaaLogo from '@/assets/messengers/eitaa.png'
import rubikaLogo from '@/assets/messengers/rubika.png'
import smsLogo from '@/assets/messengers/sms.svg'
import soroushLogo from '@/assets/messengers/soroush.svg'
import telegramLogo from '@/assets/messengers/telegram.svg'
import whatsappLogo from '@/assets/messengers/whatsapp.svg'
const LOGOS: Record<string, string> = {
bale: baleLogo,
telegram: telegramLogo,
whatsapp: whatsappLogo,
rubika: rubikaLogo,
eitaa: eitaaLogo,
soroush: soroushLogo,
call: callLogo,
sms: smsLogo,
}
const props = defineProps<{ messenger: string }>()
const normalized = computed(() => (props.messenger ?? '').trim().toLowerCase())
const logoSrc = computed(() => LOGOS[normalized.value])
</script>
+259
View File
@@ -0,0 +1,259 @@
<template>
<Teleport to="body">
<Transition name="support-picker-fade">
<div
v-if="open"
class="fixed inset-0 bg-slate-900/20"
:class="backdropActive ? 'pointer-events-auto' : 'pointer-events-none'"
:style="{ zIndex: pickerZIndex }"
@click="onBackdropClick"
/>
</Transition>
<div
v-show="open || closing"
class="fixed flex pointer-events-none"
:class="expandUp ? 'flex-col-reverse' : 'flex-col'"
:style="{ ...stackStyle, zIndex: stackZIndex }"
role="menu"
aria-label="انتخاب پیام‌رسان"
>
<div
class="support-picker-panel pointer-events-auto flex flex-col gap-2 rounded-2xl p-1 shadow-lg"
:class="{ 'is-open': panelOpen }"
>
<button
v-for="(entry, index) in entries"
:key="`${entry.messenger}-${entry.id}`"
type="button"
class="support-picker-item m-0 border-0 bg-transparent p-0 cursor-pointer"
:style="{ '--i': index, '--total': entries.length }"
:title="entry.label"
:aria-label="entry.label"
@click.stop="select(entry)"
>
<span class="inline-flex items-center gap-2 h-11 rounded-full bg-white shadow-md py-1 pl-3 pr-1 w-full">
<span class="block w-[30px] h-[30px] shrink-0">
<SupportMessengerBrandIcon :messenger="entry.messenger" />
</span>
<span class="text-sm font-600 text-slate-800 whitespace-nowrap leading-none">
{{ entry.label }}
</span>
</span>
</button>
</div>
</div>
</Teleport>
</template>
<script lang="ts" setup>
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import SupportMessengerBrandIcon from '@/components/SupportMessengerBrandIcon.vue'
import type { ResolvedSupportMessengerEntry } from '@/utils/supportMessenger'
import { APP_Z_SUPPORT_PICKER } from '@/utils/zIndex'
const props = withDefaults(
defineProps<{
open: boolean
entries: ResolvedSupportMessengerEntry[]
anchorEl?: HTMLElement | null
}>(),
{
anchorEl: null,
},
)
const emit = defineEmits<{
'update:open': [value: boolean]
select: [entry: ResolvedSupportMessengerEntry]
}>()
const ITEM_H = 46
const GAP = 8
const PANEL_PAD = 24
const STACK_GAP = 10
const EDGE_PAD = 12
const CLOSE_MS = 220
const BACKDROP_GUARD_MS = 380
const pickerZIndex = APP_Z_SUPPORT_PICKER
const stackZIndex = APP_Z_SUPPORT_PICKER + 10
const stackStyle = ref<Record<string, string>>({})
const closing = ref(false)
const expandUp = ref(true)
const backdropActive = ref(false)
/** یک فریم بعد از open تا transition باز شدن دیده شود */
const panelOpen = ref(false)
let closeTimer: ReturnType<typeof setTimeout> | null = null
let backdropTimer: ReturnType<typeof setTimeout> | null = null
let openRaf = 0
function close() {
emit('update:open', false)
}
function select(entry: ResolvedSupportMessengerEntry) {
emit('select', entry)
close()
}
function clearTimers() {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
if (backdropTimer) {
clearTimeout(backdropTimer)
backdropTimer = null
}
if (openRaf) {
cancelAnimationFrame(openRaf)
openRaf = 0
}
}
function onBackdropClick() {
if (!backdropActive.value) return
close()
}
function armBackdropGuard() {
backdropActive.value = false
if (backdropTimer) clearTimeout(backdropTimer)
backdropTimer = setTimeout(() => {
backdropActive.value = true
backdropTimer = null
}, BACKDROP_GUARD_MS)
}
function schedulePanelOpen() {
panelOpen.value = false
openRaf = requestAnimationFrame(() => {
openRaf = requestAnimationFrame(() => {
panelOpen.value = true
openRaf = 0
})
})
}
async function reposition() {
await nextTick()
const anchor = props.anchorEl
const count = props.entries.length
if (!anchor || count === 0) {
expandUp.value = true
stackStyle.value = {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
right: 'auto',
bottom: 'auto',
}
return
}
const rect = anchor.getBoundingClientRect()
const stackHeight = count * ITEM_H + Math.max(0, count - 1) * GAP + PANEL_PAD
const right = Math.max(EDGE_PAD, window.innerWidth - rect.right)
const spaceAbove = rect.top
const spaceBelow = window.innerHeight - rect.bottom
expandUp.value = spaceAbove >= stackHeight + 24 || spaceAbove >= spaceBelow
if (expandUp.value) {
stackStyle.value = {
right: `${right}px`,
bottom: `${Math.max(EDGE_PAD, window.innerHeight - rect.top + STACK_GAP)}px`,
left: 'auto',
top: 'auto',
transform: 'none',
}
} else {
stackStyle.value = {
right: `${right}px`,
top: `${Math.max(EDGE_PAD, rect.bottom + STACK_GAP)}px`,
left: 'auto',
bottom: 'auto',
transform: 'none',
}
}
}
watch(
() => props.open,
async (isOpen) => {
clearTimers()
if (isOpen) {
closing.value = false
panelOpen.value = false
armBackdropGuard()
await reposition()
await nextTick()
schedulePanelOpen()
return
}
panelOpen.value = false
backdropActive.value = false
closing.value = true
const delay = CLOSE_MS + Math.max(0, props.entries.length - 1) * 28
closeTimer = setTimeout(() => {
closing.value = false
closeTimer = null
}, delay)
},
{ flush: 'post' },
)
watch(
() => [props.entries.length, props.anchorEl] as const,
() => {
if (props.open) void reposition()
},
)
function onViewportChange() {
if (props.open || closing.value) void reposition()
}
if (typeof window !== 'undefined') {
window.addEventListener('resize', onViewportChange, { passive: true })
window.addEventListener('scroll', onViewportChange, { passive: true, capture: true })
}
onBeforeUnmount(() => {
clearTimers()
if (typeof window === 'undefined') return
window.removeEventListener('resize', onViewportChange, { capture: true })
window.removeEventListener('scroll', onViewportChange, { capture: true })
})
</script>
<style scoped>
/* انیمیشن باز/بسته — فقط جایی که utility کافی نیست */
.support-picker-item {
--i: 0;
opacity: 0;
transform: translateY(12px) scale(0.92);
transform-origin: right center;
transition:
transform 0.22s cubic-bezier(0.2, 1, 0.3, 1),
opacity 0.16s ease;
transition-delay: calc(var(--i) * 38ms);
}
.support-picker-panel.is-open .support-picker-item {
opacity: 1;
transform: translateY(0) scale(1);
}
.support-picker-fade-enter-active,
.support-picker-fade-leave-active {
transition: opacity 0.16s ease;
}
.support-picker-fade-enter-from,
.support-picker-fade-leave-to {
opacity: 0;
}
</style>