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:
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user