Split desktop and mobile layouts with shared page data so APIs fetch once per context.
Add layout shells, desktop views, module-level page caches, and UI fixes for auth, home, wallet, transactions, address, and related pages. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<DesktopAppLayout v-if="isDesktopLayout" />
|
||||
<MobileAppLayout v-else />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useDesktopBreakpoint } from './composables/useAppLayout'
|
||||
import DesktopAppLayout from './desktop/DesktopAppLayout.vue'
|
||||
import MobileAppLayout from './mobile/MobileAppLayout.vue'
|
||||
|
||||
const isDesktopLayout = useDesktopBreakpoint()
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="responsive-view-pair">
|
||||
<div
|
||||
v-show="!isDesktop"
|
||||
class="responsive-view-pair__pane"
|
||||
:class="{ 'responsive-pane--hidden': isDesktop }"
|
||||
>
|
||||
<slot name="mobile" />
|
||||
</div>
|
||||
<div
|
||||
v-show="isDesktop"
|
||||
class="responsive-view-pair__pane"
|
||||
:class="{ 'responsive-pane--hidden': !isDesktop }"
|
||||
>
|
||||
<slot name="desktop" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useDesktopBreakpoint } from '../composables/useResponsiveLayout'
|
||||
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.responsive-view-pair {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.responsive-view-pair__pane {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.responsive-pane--hidden {
|
||||
/* v-show already sets display:none; class reserved for layout overrides */
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { onUnmounted, watch } from 'vue'
|
||||
import { DESKTOP_LAYOUT_MIN_WIDTH } from '../constants'
|
||||
|
||||
const DESKTOP_LAYOUT_CLASS = 'app-layout--desktop'
|
||||
|
||||
export function useDesktopBreakpoint() {
|
||||
return useMediaQuery(`(min-width: ${DESKTOP_LAYOUT_MIN_WIDTH}px)`)
|
||||
}
|
||||
|
||||
/** Apply html class for global desktop shell styles. Call once from App.vue. */
|
||||
export function useApplyDesktopLayoutClass() {
|
||||
const isDesktopLayout = useDesktopBreakpoint()
|
||||
|
||||
const stop = watch(
|
||||
isDesktopLayout,
|
||||
(desktop) => {
|
||||
document.documentElement.classList.toggle(DESKTOP_LAYOUT_CLASS, desktop)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
document.documentElement.classList.remove(DESKTOP_LAYOUT_CLASS)
|
||||
})
|
||||
}
|
||||
|
||||
/** @deprecated use useDesktopBreakpoint + useApplyDesktopLayoutClass */
|
||||
export function useAppLayout() {
|
||||
const isDesktopLayout = useDesktopBreakpoint()
|
||||
useApplyDesktopLayoutClass()
|
||||
return { isDesktopLayout }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
export function useAppNavigation() {
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const popVisible = ref(false)
|
||||
const currentRoute = computed(() => route.name)
|
||||
const hideMenu = computed(() => !!route.meta.hideMenu)
|
||||
|
||||
function isNavActive(routeName: string) {
|
||||
return currentRoute.value === routeName
|
||||
}
|
||||
|
||||
function navItemClass(routeName: string) {
|
||||
return isNavActive(routeName) ? 'text-primary' : 'text-gray-400'
|
||||
}
|
||||
|
||||
function navigateTo(routeName: string) {
|
||||
popVisible.value = false
|
||||
router.push({ name: routeName })
|
||||
}
|
||||
|
||||
function toggleOverflowMenu() {
|
||||
popVisible.value = !popVisible.value
|
||||
}
|
||||
|
||||
function closeOverflowMenu() {
|
||||
popVisible.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
popVisible,
|
||||
currentRoute,
|
||||
hideMenu,
|
||||
isNavActive,
|
||||
navItemClass,
|
||||
navigateTo,
|
||||
toggleOverflowMenu,
|
||||
closeOverflowMenu,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { DESKTOP_LAYOUT_MIN_WIDTH } from '../constants'
|
||||
|
||||
/**
|
||||
* Reactive desktop breakpoint. Use only for UI switching — never tie API calls to this ref.
|
||||
*/
|
||||
export function useDesktopBreakpoint() {
|
||||
return useMediaQuery(`(min-width: ${DESKTOP_LAYOUT_MIN_WIDTH}px)`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Guidelines for responsive pages (Vue):
|
||||
*
|
||||
* 1. Call page composables (with onMounted fetch) exactly once in the route `index.vue` shell.
|
||||
* 2. Switch Mobile/Desktop views with `v-show`, not `v-if`, so resize does not destroy children.
|
||||
* 3. Keep `<router-view>` outside responsive chrome wrappers so child routes are not remounted.
|
||||
* 4. Mobile/Desktop views consume shared state via provide/inject — they must not fetch independently.
|
||||
* 5. Prefer store/composable request dedup (in-flight promise + cached result) for shared data.
|
||||
*/
|
||||
|
||||
export const RESPONSIVE_HIDDEN_CLASS = 'responsive-pane--hidden'
|
||||
@@ -0,0 +1,29 @@
|
||||
import { isBoursecuProject } from '@/utils/config'
|
||||
import type { AppNavItem } from '../types'
|
||||
|
||||
const BOURSECU_PRIMARY_NAV: AppNavItem[] = [
|
||||
{ id: 'buyProduct', routeName: 'buyProduct', label: 'سفارشها', icon: 'vuesax-linear:box' },
|
||||
{ id: 'transactions', routeName: 'transactions', label: 'تراکنشها', icon: 'vuesax-linear:receipt-item' },
|
||||
{ id: 'home', routeName: 'home', label: 'صفحه اصلی', icon: 'vuesax-linear:home' },
|
||||
{ id: 'wallet', routeName: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-1' },
|
||||
{ id: 'user', routeName: 'user', label: 'تنظیمات', icon: 'vuesax-linear:setting-2' },
|
||||
]
|
||||
|
||||
const DEFAULT_PRIMARY_NAV: AppNavItem[] = [
|
||||
{ id: 'home', routeName: 'home', label: 'صفحه اصلی', icon: 'vuesax-linear:home' },
|
||||
{ id: 'transactions', routeName: 'transactions', label: 'گزارش', icon: 'vuesax-linear:receipt-item' },
|
||||
{ id: 'buyProduct', routeName: 'buyProduct', label: 'محصولات', icon: 'vuesax-linear:coin' },
|
||||
{ id: 'wallet', routeName: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-1' },
|
||||
{ id: 'user', routeName: 'user', label: 'تنظیمات', icon: 'vuesax-linear:setting-2' },
|
||||
]
|
||||
|
||||
export const SECONDARY_NAV: AppNavItem[] = [
|
||||
{ id: 'notifyTrigger', routeName: 'notifyTrigger', label: 'هشدار قیمت', icon: 'vuesax-linear:bill' },
|
||||
{ id: 'about', routeName: 'about', label: 'درباره ما', icon: 'vuesax-linear:info-circle' },
|
||||
{ id: 'rules', routeName: 'rules', label: 'قوانین و مقررات', icon: 'vuesax-linear:task' },
|
||||
{ id: 'giftCard', routeName: 'giftCard', label: 'ثبت کارت هدیه', icon: 'vuesax-linear:gift' },
|
||||
]
|
||||
|
||||
export function getPrimaryNavItems(): AppNavItem[] {
|
||||
return isBoursecuProject() ? BOURSECU_PRIMARY_NAV : DEFAULT_PRIMARY_NAV
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Minimum viewport width for desktop app shell (sidebar layout). */
|
||||
export const DESKTOP_LAYOUT_MIN_WIDTH = 768
|
||||
|
||||
export const DESKTOP_SIDEBAR_WIDTH = 260
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="desktop-app-layout">
|
||||
<DesktopSidebar v-if="!hideMenu" />
|
||||
<main
|
||||
class="desktop-app-layout__main"
|
||||
:class="{ 'desktop-app-layout__main--full': hideMenu }"
|
||||
>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<component
|
||||
:is="Component"
|
||||
:key="route.fullPath"
|
||||
class="desktop-app-layout__content"
|
||||
/>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, watch } from 'vue'
|
||||
import { useAppNavigation } from '../composables/useAppNavigation'
|
||||
import DesktopSidebar from './DesktopSidebar.vue'
|
||||
|
||||
const NO_SIDEBAR_CLASS = 'app-layout--desktop-no-sidebar'
|
||||
const { hideMenu } = useAppNavigation()
|
||||
|
||||
watch(
|
||||
hideMenu,
|
||||
(hidden) => {
|
||||
document.documentElement.classList.toggle(NO_SIDEBAR_CLASS, hidden)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
document.documentElement.classList.remove(NO_SIDEBAR_CLASS)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" src="./desktop-layout.less"></style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="desktop-nav-item"
|
||||
:class="{ 'desktop-nav-item--active': active }"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<BasicIcon :icon="icon" size="20" class="desktop-nav-item__icon" />
|
||||
<span class="desktop-nav-item__label">{{ label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
|
||||
defineProps<{
|
||||
icon: string
|
||||
label: string
|
||||
active: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<aside
|
||||
class="desktop-sidebar"
|
||||
:class="{ 'desktop-sidebar--boursecu': isBoursecu }"
|
||||
>
|
||||
<div class="desktop-sidebar__brand">
|
||||
<img
|
||||
:src="navLogo"
|
||||
alt=""
|
||||
class="desktop-sidebar__logo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav class="desktop-sidebar__section" aria-label="منوی اصلی">
|
||||
<DesktopNavItem
|
||||
v-for="item in primaryItems"
|
||||
:key="item.id"
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:active="isNavActive(item.routeName)"
|
||||
@click="navigateTo(item.routeName)"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div class="desktop-sidebar__divider" />
|
||||
|
||||
<nav class="desktop-sidebar__section desktop-sidebar__section--secondary" aria-label="منوی فرعی">
|
||||
<DesktopNavItem
|
||||
v-for="item in secondaryItems"
|
||||
:key="item.id"
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:active="isNavActive(item.routeName)"
|
||||
@click="navigateTo(item.routeName)"
|
||||
/>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useConfigStore } from '@/store/configStore'
|
||||
import { getProjectNavLogo, isBoursecuProject } from '@/utils/config'
|
||||
import { getPrimaryNavItems, SECONDARY_NAV } from '../config/navigation'
|
||||
import { useAppNavigation } from '../composables/useAppNavigation'
|
||||
import DesktopNavItem from './DesktopNavItem.vue'
|
||||
|
||||
const config = useConfigStore()
|
||||
const isBoursecu = isBoursecuProject()
|
||||
const navLogo = computed(() => getProjectNavLogo(config.config?.avatar))
|
||||
const primaryItems = getPrimaryNavItems()
|
||||
const secondaryItems = SECONDARY_NAV
|
||||
|
||||
const { isNavActive, navigateTo } = useAppNavigation()
|
||||
</script>
|
||||
@@ -0,0 +1,157 @@
|
||||
.desktop-app-layout {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
background: @prefixCls-background-color;
|
||||
}
|
||||
|
||||
.desktop-app-layout__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.desktop-app-layout__main--full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.desktop-app-layout__content {
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: @prefixCls-background-color;
|
||||
}
|
||||
|
||||
.desktop-sidebar {
|
||||
flex-shrink: 0;
|
||||
width: var(--app-desktop-sidebar-width, 260px);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding: 20px 12px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.desktop-sidebar--boursecu {
|
||||
background: var(--App-brand-nav, #133050);
|
||||
|
||||
.desktop-sidebar__divider {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.desktop-nav-item {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: #fff;
|
||||
|
||||
.desktop-nav-item__icon {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-nav-item__icon {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.desktop-sidebar__logo {
|
||||
max-width: 200px;
|
||||
max-height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-sidebar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 12px 20px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.desktop-sidebar__logo {
|
||||
max-width: 140px;
|
||||
max-height: 48px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.desktop-sidebar__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.desktop-sidebar__section--secondary {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.desktop-sidebar__divider {
|
||||
height: 1px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
margin: 12px 8px;
|
||||
}
|
||||
|
||||
.desktop-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: @App-primary-color;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: @App-primary-color;
|
||||
|
||||
.desktop-nav-item__icon {
|
||||
color: @App-primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-nav-item__icon {
|
||||
flex-shrink: 0;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.desktop-nav-item__label {
|
||||
flex: 1;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.desktop-sidebar__section--secondary .desktop-nav-item {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/* Shared desktop page primitives */
|
||||
|
||||
.dpage-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
|
||||
|
||||
&__back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__eyebrow {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #9ca3af;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin: 6px 0 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.dpage-surface {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.dpage-support {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
> a:first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__bale {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div>
|
||||
<MobileOverflowMenu
|
||||
:visible="popVisible"
|
||||
:overflow-items="overflowItems"
|
||||
@close="closeOverflowMenu"
|
||||
@navigate="navigateTo"
|
||||
/>
|
||||
|
||||
<div :class="['main-with-bottom-chrome', { 'main-with-bottom-chrome--no-nav': hideMenu }]">
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition :name="route.meta.transition || 'fade'">
|
||||
<component :is="Component" class="main-with-bottom-chrome-inner" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
|
||||
<MobileBottomNav
|
||||
v-if="!hideMenu"
|
||||
:is-boursecu="isBoursecu"
|
||||
:nav-logo="navLogo"
|
||||
:nav-item-class="navItemClass"
|
||||
@navigate="navigateTo"
|
||||
@toggle-overflow="toggleOverflowMenu"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useConfigStore } from '@/store/configStore'
|
||||
import { getProjectNavLogo, isBoursecuProject } from '@/utils/config'
|
||||
import { SECONDARY_NAV } from '../config/navigation'
|
||||
import { useAppNavigation } from '../composables/useAppNavigation'
|
||||
import MobileBottomNav from './MobileBottomNav.vue'
|
||||
import MobileOverflowMenu from './MobileOverflowMenu.vue'
|
||||
|
||||
const config = useConfigStore()
|
||||
const isBoursecu = isBoursecuProject()
|
||||
const navLogo = computed(() => getProjectNavLogo(config.config?.avatar))
|
||||
|
||||
const {
|
||||
popVisible,
|
||||
hideMenu,
|
||||
navItemClass,
|
||||
navigateTo,
|
||||
toggleOverflowMenu,
|
||||
closeOverflowMenu,
|
||||
} = useAppNavigation()
|
||||
|
||||
const overflowItems = [
|
||||
{ id: 'user', routeName: 'user', label: 'حساب کاربری', icon: 'vuesax-linear:user' },
|
||||
...SECONDARY_NAV,
|
||||
]
|
||||
</script>
|
||||
|
||||
<style lang="less" src="./mobile-layout.less"></style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="bottom-appbar w-auto mx-auto bg-white">
|
||||
<div class="tabs bottom-0">
|
||||
<div
|
||||
v-if="isBoursecu"
|
||||
:class="navItemClass('buyProduct')"
|
||||
class="tab"
|
||||
@click="$emit('navigate', 'buyProduct')"
|
||||
>
|
||||
<Icon icon="vuesax-linear:box" />
|
||||
<div class="text-xs mt-1 font-bold">سفارش ها</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
:class="navItemClass('home')"
|
||||
class="tab"
|
||||
@click="$emit('navigate', 'home')"
|
||||
>
|
||||
<Icon icon="vuesax-linear:home" />
|
||||
<div class="text-xs mt-1 font-bold">صفحه اصلی</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="navItemClass('transactions')"
|
||||
class="tab"
|
||||
@click="$emit('navigate', 'transactions')"
|
||||
>
|
||||
<Icon icon="vuesax-linear:receipt-item" />
|
||||
<div class="text-xs mt-1 font-bold">{{ isBoursecu ? 'تراکنش ها' : 'گزارش' }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isBoursecu"
|
||||
:class="navItemClass('home')"
|
||||
class="tab"
|
||||
@click="$emit('navigate', 'home')"
|
||||
>
|
||||
<Icon icon="vuesax-linear:home" />
|
||||
<div class="text-xs mt-1 font-bold">صفحه اصلی</div>
|
||||
</div>
|
||||
<div v-else class="main_in" @click="$emit('navigate', 'buyProduct')">
|
||||
<div
|
||||
class="circle_in"
|
||||
:class="isWhiteNavLogo ? 'is-white-nav-logo' : 'text-white'"
|
||||
>
|
||||
<div class="circle_into" @click.stop="$emit('toggle-overflow')">
|
||||
<div
|
||||
v-if="projectName === 'garoosi'"
|
||||
class="p-2 text-tertiary flex flex-col justify-center items-center"
|
||||
>
|
||||
<Icon icon="vuesax-linear:coin" />
|
||||
<div class="text-[9px] font-bold">محصولات</div>
|
||||
</div>
|
||||
<img
|
||||
v-else
|
||||
class="p-2"
|
||||
:src="navLogo"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="navItemClass('wallet')"
|
||||
class="tab"
|
||||
@click="$emit('navigate', 'wallet')"
|
||||
>
|
||||
<Icon icon="vuesax-linear:wallet-1" />
|
||||
<div class="text-xs mt-1 font-bold">کیف پول</div>
|
||||
</div>
|
||||
<div
|
||||
:class="navItemClass('user')"
|
||||
class="tab"
|
||||
@click="$emit('navigate', 'user')"
|
||||
>
|
||||
<Icon icon="vuesax-linear:setting-2" />
|
||||
<div class="text-xs mt-1 font-bold">تنظیمات</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isBoursecu" class="main">
|
||||
<div class="circle" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import Icon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { getConfigApp } from '@/utils/config'
|
||||
|
||||
defineProps<{
|
||||
isBoursecu: boolean
|
||||
navLogo: string
|
||||
navItemClass: (routeName: string) => string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
navigate: [routeName: string]
|
||||
'toggle-overflow': []
|
||||
}>()
|
||||
|
||||
const projectName = getConfigApp('name')
|
||||
const whiteNavLogoProjects = ['goldenramin']
|
||||
const isWhiteNavLogo = computed(() => whiteNavLogoProjects.includes(projectName))
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="top-0 bottom-0 bg-primary wrap-fix z-2000 opacity-60"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
</Transition>
|
||||
<Transition name="slide-fade">
|
||||
<div v-if="visible" class="wrap-fix bottom-11vh z-2000">
|
||||
<div class="relative flex flex-col items-center">
|
||||
<div
|
||||
class="flex items-center justify-center bg-white rounded-full w-[30px] h-[30px] mb-2"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<BasicIcon icon="vuesax-linear:close-circle" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<BasicButton
|
||||
v-for="item in overflowItems"
|
||||
:key="item.id"
|
||||
icon-size="16"
|
||||
class="!font-bold w-15rem"
|
||||
@click="$emit('navigate', item.routeName)"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<BasicIcon :icon="item.icon" size="16" class="absolute right-2" />
|
||||
<div>{{ item.label }}</div>
|
||||
</div>
|
||||
</BasicButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import type { AppNavItem } from '../types'
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
overflowItems: AppNavItem[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
navigate: [routeName: string]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,145 @@
|
||||
.bottom-appbar {
|
||||
z-index: var(--app-z-nav, 5000);
|
||||
height: var(--app-nav-height, 80px);
|
||||
position: fixed;
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
background: #fff;
|
||||
bottom: var(--app-credit-footer-offset, 28px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
box-sizing: border-box;
|
||||
|
||||
@media screen and (min-width: 426px) {
|
||||
width: 426px;
|
||||
max-width: 426px;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
justify-content: space-between;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
z-index: 400;
|
||||
box-shadow: 0 -2px 4px -2px #0000001a;
|
||||
|
||||
.tab {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
flex-flow: column;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.main_in {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
|
||||
.circle_in {
|
||||
margin-top: -10px;
|
||||
border-radius: 50%;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
flex-shrink: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
flex-wrap: wrap;
|
||||
background-color: @App-primary-color;
|
||||
|
||||
.circle_into {
|
||||
overflow: hidden;
|
||||
@apply bg-primary;
|
||||
border-radius: 50%;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
flex-wrap: wrap;
|
||||
border: white 1px solid;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.circle_in.is-white-nav-logo {
|
||||
.circle_into {
|
||||
background: #fff !important;
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
height: 70px;
|
||||
width: 70px;
|
||||
position: relative;
|
||||
|
||||
.circle {
|
||||
border-radius: 50%;
|
||||
height: 70px;
|
||||
width: 70px;
|
||||
z-index: 20;
|
||||
box-shadow: 0 -5px 4px -2px #0000001a;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.v-enter-active,
|
||||
.v-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.v-enter-from,
|
||||
.v-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.4s cubic-bezier(1, 0.5, 0.8, 1);
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.main-with-bottom-chrome {
|
||||
height: calc(100vh - 72px - var(--app-credit-footer-offset, 28px));
|
||||
}
|
||||
|
||||
.main-with-bottom-chrome-inner {
|
||||
height: calc(100vh - 72px - var(--app-credit-footer-offset, 28px));
|
||||
}
|
||||
|
||||
.main-with-bottom-chrome--no-nav,
|
||||
.main-with-bottom-chrome--no-nav .main-with-bottom-chrome-inner {
|
||||
height: calc(100vh - var(--app-credit-footer-offset, 28px));
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
@desktop-layout-min: 768px;
|
||||
@mobile-frame-max: 767.98px;
|
||||
|
||||
/**
|
||||
* Full-width desktop shell — applies on first paint (no JS class needed).
|
||||
* Sidebar-specific wrap-fix offset stays on html.app-layout--desktop.
|
||||
*/
|
||||
@media screen and (min-width: @desktop-layout-min) {
|
||||
html {
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.desktopConfig {
|
||||
position: static !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
min-height: 100vh;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
}
|
||||
|
||||
.wrap-fix {
|
||||
width: auto !important;
|
||||
max-width: none !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
--app-credit-bar-inner: 0px;
|
||||
--app-credit-footer-offset: 0px;
|
||||
}
|
||||
|
||||
.app-credit-footer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Teleport to body escapes parent v-show — hide mobile dock chrome on desktop */
|
||||
.app-dock-add-bar,
|
||||
.wallet-card-slider {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.transaction-list,
|
||||
.card-slider-wrap {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
html.app-layout--desktop {
|
||||
@media screen and (min-width: @desktop-layout-min) {
|
||||
.wrap-fix {
|
||||
right: var(--app-desktop-sidebar-width, 260px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.app-layout--desktop-no-sidebar .wrap-fix {
|
||||
right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Splash — always full viewport; never sidebar-offset */
|
||||
.app-splash-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
max-width: none;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
z-index: 1000000000000;
|
||||
|
||||
&__gradient {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
|
||||
&--top {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&--bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html.app-layout--desktop .app-splash-screen {
|
||||
right: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100vw !important;
|
||||
}
|
||||
|
||||
/* Login — full viewport on desktop */
|
||||
html.app-layout--desktop {
|
||||
.login-page-root,
|
||||
.dauth {
|
||||
width: 100% !important;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.dauth__layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Mobile shell width rules.
|
||||
* Below 426px: full viewport (phones).
|
||||
* 426px–767px: centered 426px frame (tablet-ish mobile).
|
||||
*/
|
||||
|
||||
html:not(.app-layout--desktop) {
|
||||
/* Teleport to body — dock bar must span viewport, not shrink to content */
|
||||
.app-dock-add-bar,
|
||||
.wallet-card-slider {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 425.98px) {
|
||||
body {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#app,
|
||||
.desktopConfig,
|
||||
.app-main,
|
||||
.main-with-bottom-chrome,
|
||||
.main-with-bottom-chrome-inner {
|
||||
width: 100% !important;
|
||||
max-width: 100vw !important;
|
||||
min-width: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.wrap-fix,
|
||||
.bottom-appbar {
|
||||
width: 100% !important;
|
||||
max-width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
margin: 0 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.app-dock-add-bar,
|
||||
.wallet-card-slider {
|
||||
width: 100% !important;
|
||||
max-width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
margin: 0 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 426px) and (max-width: 767.98px) {
|
||||
body {
|
||||
display: flex !important;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.desktopConfig {
|
||||
width: 426px !important;
|
||||
max-width: 426px !important;
|
||||
}
|
||||
|
||||
.wrap-fix,
|
||||
.bottom-appbar {
|
||||
width: 426px !important;
|
||||
max-width: 426px !important;
|
||||
left: 50% !important;
|
||||
right: auto !important;
|
||||
margin: 0 !important;
|
||||
transform: translateX(-50%) !important;
|
||||
}
|
||||
|
||||
.app-dock-add-bar,
|
||||
.wallet-card-slider {
|
||||
width: 426px !important;
|
||||
max-width: 426px !important;
|
||||
left: 50% !important;
|
||||
right: auto !important;
|
||||
margin: 0 !important;
|
||||
transform: translateX(-50%) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface AppNavItem {
|
||||
id: string
|
||||
routeName: string
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
Reference in New Issue
Block a user