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:
2026-06-14 10:08:46 +03:30
co-authored by Cursor
parent 4404b4cb92
commit a5e458ed25
234 changed files with 18425 additions and 5503 deletions
@@ -0,0 +1,188 @@
import { computed, inject, onMounted, provide, ref, type ComponentPublicInstance, type InjectionKey } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { loading, loadingMore } from '@/utils'
import {
BOURSECU_TRANSACTION_TABS,
type BoursecuTradeSide,
type BoursecuTransactionTab,
type BoursecuTransactionTabId,
} from '../config/boursecuTabs'
import {
getActiveBoursecuTab,
initTransactionsPageData,
loadMoreTransactions,
reloadTransactionsTable,
resetTransactionsPagination,
transactionActiveTabId,
transactionDateFilter,
transactionItems,
transactionPagination,
transactionStatusFilter,
transactionTableOutput,
transactionTradeSide,
transactionsBoursecuMode,
} from './useTransactionsPageData'
export interface TransactionsPageContext {
pagination: typeof transactionPagination
items: typeof transactionItems
tableOutput: typeof transactionTableOutput
showFilter: ReturnType<typeof ref<boolean>>
dateFilter: typeof transactionDateFilter
statusFilter: typeof transactionStatusFilter
mobileObserverEl: ReturnType<typeof ref<HTMLElement | null>>
desktopObserverEl: ReturnType<typeof ref<HTMLElement | null>>
activeTabId: typeof transactionActiveTabId
tradeSide: typeof transactionTradeSide
activeTab: ReturnType<typeof computed<BoursecuTransactionTab>>
emptyText: ReturnType<typeof computed<string>>
resetPagination: typeof resetTransactionsPagination
initTable: () => Promise<void>
reloadTable: typeof reloadTransactionsTable
applyFilter: () => Promise<void>
setActiveTab: (tabId: BoursecuTransactionTabId) => Promise<void>
setTradeSide: (side: BoursecuTradeSide) => Promise<void>
applyInlineFilters: () => Promise<void>
bindMobileObserver: (el: Element | ComponentPublicInstance | null) => void
bindDesktopObserver: (el: Element | ComponentPublicInstance | null) => void
}
export const TRANSACTIONS_PAGE_KEY: InjectionKey<TransactionsPageContext> =
Symbol('transactionsPage')
export function useTransactionsPageProvider(options?: { boursecu?: boolean }) {
const isBoursecu = options?.boursecu ?? false
const isDesktop = useDesktopBreakpoint()
const showFilter = ref(false)
const mobileObserverEl = ref<HTMLElement | null>(null)
const desktopObserverEl = ref<HTMLElement | null>(null)
const activeObserverEl = computed(() =>
isDesktop.value ? desktopObserverEl.value : mobileObserverEl.value,
)
const activeTab = computed<BoursecuTransactionTab>(
() =>
BOURSECU_TRANSACTION_TABS.find((tab) => tab.id === transactionActiveTabId.value) ??
BOURSECU_TRANSACTION_TABS[0],
)
const emptyText = computed(() => {
if (!transactionsBoursecuMode.value) {
return 'تراکنشی موجود نیست'
}
if (activeTab.value.id === 'trades' && transactionTradeSide.value === 'sell') {
return 'معامله فروشی برای شما به ثبت نرسیده است'
}
return activeTab.value.emptyText
})
async function initTable() {
await reloadTransactionsTable()
}
async function applyFilter() {
await reloadTransactionsTable()
showFilter.value = false
}
async function setActiveTab(tabId: BoursecuTransactionTabId) {
if (transactionActiveTabId.value === tabId) {
return
}
transactionActiveTabId.value = tabId
await reloadTransactionsTable()
}
async function setTradeSide(side: BoursecuTradeSide) {
if (transactionTradeSide.value === side) {
return
}
transactionTradeSide.value = side
if (getActiveBoursecuTab().showTradeToggle) {
await reloadTransactionsTable()
}
}
async function applyInlineFilters() {
await reloadTransactionsTable()
}
function resolveObserverElement(el: Element | ComponentPublicInstance | null) {
if (!el) return null
if (el instanceof HTMLElement) return el
const root = (el as ComponentPublicInstance).$el
return root instanceof HTMLElement ? root : null
}
function bindMobileObserver(el: Element | ComponentPublicInstance | null) {
mobileObserverEl.value = resolveObserverElement(el)
}
function bindDesktopObserver(el: Element | ComponentPublicInstance | null) {
desktopObserverEl.value = resolveObserverElement(el)
}
useIntersectionObserver(
activeObserverEl,
async ([entry]) => {
if (entry?.isIntersecting) {
await loadMoreTransactions()
}
},
{
root: null,
rootMargin: '300px',
threshold: 0.01,
},
)
onMounted(async () => {
await initTransactionsPageData(isBoursecu)
})
const context: TransactionsPageContext = {
pagination: transactionPagination,
items: transactionItems,
tableOutput: transactionTableOutput,
showFilter,
dateFilter: transactionDateFilter,
statusFilter: transactionStatusFilter,
mobileObserverEl,
desktopObserverEl,
activeTabId: transactionActiveTabId,
tradeSide: transactionTradeSide,
activeTab,
emptyText,
resetPagination: resetTransactionsPagination,
initTable,
reloadTable: reloadTransactionsTable,
applyFilter,
setActiveTab,
setTradeSide,
applyInlineFilters,
bindMobileObserver,
bindDesktopObserver,
}
provide(TRANSACTIONS_PAGE_KEY, context)
return context
}
export function useTransactionsPageContext() {
const context = inject(TRANSACTIONS_PAGE_KEY)
if (!context) {
throw new Error('useTransactionsPageContext must be used within useTransactionsPageProvider')
}
return context
}
/** @deprecated Use useTransactionsPageProvider in index + useTransactionsPageContext in views. */
export function useTransactionsPage(options?: { boursecu?: boolean }) {
return useTransactionsPageProvider(options)
}
@@ -0,0 +1,157 @@
import { ref } from 'vue'
import { getTransactionTable, getTransactionTable2 } from '@/api/transaction'
import type { TableEngineInput, TableEngineOutput } from '@/models/table'
import type { Transaction } from '@/models/transaction'
import { loading, loadingMore } from '@/utils'
import {
BOURSECU_TRANSACTION_TABS,
buildBoursecuTransactionPagination,
type BoursecuTradeSide,
type BoursecuTransactionTab,
type BoursecuTransactionTabId,
} from '../config/boursecuTabs'
/** Shared transactions datasets — mobile/desktop views read this module state only. */
let initPromise: Promise<void> | null = null
const initialized = ref(false)
export const transactionsBoursecuMode = ref(false)
export const transactionPagination = ref<TableEngineInput>({ filters: [], limit: 7, page: 0 })
export const transactionItems = ref<Transaction[]>([])
export const transactionTableOutput = ref<TableEngineOutput<Transaction> | null>(null)
export const transactionDateFilter = ref<string>()
export const transactionStatusFilter = ref<string>()
export const transactionActiveTabId = ref<BoursecuTransactionTabId>('trades')
export const transactionTradeSide = ref<BoursecuTradeSide>('buy')
export function isTransactionsPageDataInitialized() {
return initialized.value
}
export function getActiveBoursecuTab(): BoursecuTransactionTab {
return (
BOURSECU_TRANSACTION_TABS.find((tab) => tab.id === transactionActiveTabId.value) ??
BOURSECU_TRANSACTION_TABS[0]
)
}
export function resetTransactionsPagination() {
if (transactionsBoursecuMode.value) {
transactionPagination.value = buildBoursecuTransactionPagination(
getActiveBoursecuTab(),
transactionTradeSide.value,
transactionDateFilter.value,
transactionStatusFilter.value,
)
return
}
transactionPagination.value = { filters: [], limit: 7, page: 0 }
if (transactionDateFilter.value) {
transactionPagination.value.filters = [
{
field: 'created_at',
cr: 'and',
type: 'date',
op: 'eq',
value: transactionDateFilter.value,
},
]
}
}
async function fetchTransactionsPage(append: boolean) {
const isFirstPage = transactionPagination.value.page === 0 && !append
if (isFirstPage) {
loading.value = true
} else {
loadingMore.value = true
}
try {
const activeTab = getActiveBoursecuTab()
const fetcher =
transactionsBoursecuMode.value && activeTab.fetchMode === 'deposit'
? getTransactionTable2
: getTransactionTable
transactionTableOutput.value = await fetcher(transactionPagination.value)
const rows = transactionTableOutput.value.rows ?? []
if (isFirstPage) {
transactionItems.value = [...rows]
} else {
transactionItems.value.push(...rows)
}
} catch (error) {
console.error('Error fetching transactions:', error)
} finally {
loading.value = false
loadingMore.value = false
}
}
export async function reloadTransactionsTable() {
transactionPagination.value.page = 0
transactionItems.value = []
resetTransactionsPagination()
await fetchTransactionsPage(false)
}
export async function loadMoreTransactions() {
if (
!transactionTableOutput.value
|| (transactionPagination.value.page + 1) * transactionPagination.value.limit
>= transactionTableOutput.value.query.total
) {
return
}
transactionPagination.value.page++
await fetchTransactionsPage(true)
}
export async function initTransactionsPageData(boursecu: boolean, force = false) {
if (!force && initialized.value && transactionsBoursecuMode.value === boursecu) {
return
}
if (!force && initPromise) {
return initPromise
}
initPromise = (async () => {
try {
if (force) {
resetTransactionsPageData()
}
transactionsBoursecuMode.value = boursecu
transactionPagination.value.page = 0
transactionItems.value = []
resetTransactionsPagination()
await fetchTransactionsPage(false)
initialized.value = true
} catch (error) {
console.error('Transactions page initialization error:', error)
} finally {
initPromise = null
}
})()
return initPromise
}
export function resetTransactionsPageData() {
initialized.value = false
initPromise = null
transactionsBoursecuMode.value = false
transactionPagination.value = { filters: [], limit: 7, page: 0 }
transactionItems.value = []
transactionTableOutput.value = null
transactionDateFilter.value = undefined
transactionStatusFilter.value = undefined
transactionActiveTabId.value = 'trades'
transactionTradeSide.value = 'buy'
}