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
+66 -29
View File
@@ -1,37 +1,74 @@
import { ref } from 'vue'
import { getConstByKey } from '@/api/const'
export function useBanners() {
const banners = ref([])
const loading = ref(false)
/** Module-level banner state — shared by mobile and desktop home layouts. */
const banners = ref<unknown[]>([])
const bannersLoading = ref(false)
let bannersPromise: Promise<void> | null = null
let bannersLoaded = false
const loadBanners = async () => {
try {
loading.value = true
const response = await getConstByKey('header_banner')
if (!response) return
async function fetchBanners(force = false) {
if (!force && bannersLoaded) {
return
}
let list = []
if (typeof response === 'string') {
list = JSON.parse(response)
} else if (typeof response === 'object' && !Array.isArray(response) && 'value' in response) {
list = typeof response.value === 'string' ? JSON.parse(response.value) : response.value
} else if (Array.isArray(response) && response.length > 0) {
const first = response[0]
if (first.value) {
list = typeof first.value === 'string' ? JSON.parse(first.value) : first.value
} else if (first.icon) {
list = response
}
}
if (!force && bannersPromise) {
return bannersPromise
}
banners.value = Array.isArray(list) ? list.filter(b => b.show === true) : []
} catch (error) {
console.error('Error loading banners:', error)
} finally {
loading.value = false
bannersPromise = (async () => {
try {
bannersLoading.value = true
const response = await getConstByKey('header_banner')
if (!response) {
banners.value = []
return
}
let list: unknown[] = []
if (typeof response === 'string') {
list = JSON.parse(response)
} else if (typeof response === 'object' && !Array.isArray(response) && 'value' in response) {
const value = (response as { value: unknown }).value
list = typeof value === 'string' ? JSON.parse(value) : (value as unknown[])
} else if (Array.isArray(response) && response.length > 0) {
const first = response[0] as { value?: unknown; icon?: unknown }
if (first.value) {
list = typeof first.value === 'string' ? JSON.parse(first.value) : (first.value as unknown[])
} else if (first.icon) {
list = response
}
}
}
return { banners, loading, loadBanners }
}
banners.value = Array.isArray(list)
? list.filter((banner) => (banner as { show?: boolean }).show === true)
: []
bannersLoaded = true
} catch (error) {
console.error('Error loading banners:', error)
} finally {
bannersLoading.value = false
bannersPromise = null
}
})()
return bannersPromise
}
export function useBanners() {
return {
banners,
loading: bannersLoading,
loadBanners: fetchBanners,
}
}
export function loadHomeBanners(force = false) {
return fetchBanners(force)
}
export function resetHomeBanners() {
banners.value = []
bannersLoaded = false
bannersPromise = null
}
@@ -0,0 +1,237 @@
import { computed, inject, onMounted, onUnmounted, provide, ref, type InjectionKey } from 'vue'
import router from '@/router'
import { loading, removeToken } from '@/utils'
import { useUserStore } from '@/store/userStore'
import { useWalletStore } from '@/store/walletStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { WalletMetalTotal } from '@/models/walletAssets'
import { formatMetalAssetsInfoText, formatRialAssetsInfoText } from '@/utils/productAssets'
import { useConfigStore } from '@/store/configStore'
import { lastPrice, stopLastPricePolling } from '@/utils/price'
import { message, Modal } from 'ant-design-vue'
import { getReferralShareText, getReferralShareTitle } from '@/utils/config'
import { useBanners } from './useBanners'
import { usePriceChart, type ChartMetalType } from './usePriceChart'
import type { HomeActionItem } from '../layouts/types'
import { getHomeLayoutId } from '../layouts/registry'
import { initHomePageData, resetHomePageData } from './useHomePageData'
import { resetWalletPageData } from '../../wallet/composables/useWalletPageData'
import { resetAddressPageData } from '../../address/composables/useAddressPageData'
import { resetTransactionsPageData } from '../../transactions/composables/useTransactionsPageData'
import { resetProductsPageData } from '../../products/composables/useProductsPageData'
export interface HomePageContext {
loading: typeof loading
isGaroosi: ReturnType<typeof computed<boolean>>
limitedPrice: ReturnType<typeof computed>
showClosedMessage: ReturnType<typeof computed<boolean>>
closedMessage: ReturnType<typeof computed>
mainActions: ReturnType<typeof computed<HomeActionItem[]>>
banners: ReturnType<typeof useBanners>['banners']
priceChartSeries: ReturnType<typeof usePriceChart>['priceChartSeries']
chartSelection: ReturnType<typeof usePriceChart>['chartSelection']
updateChartData: ReturnType<typeof usePriceChart>['updateChartData']
selectedChartMetal: ReturnType<typeof usePriceChart>['selectedChartMetal']
setChartMetal: (metal: ChartMetalType) => void
isBoursecuHomeLayout: ReturnType<typeof computed<boolean>>
handleBannerClick: (banner: { typeClick?: string; modalText?: string; link?: string }) => void
openGoldModal: (metal: WalletMetalTotal) => void
openRialModal: () => void
}
export const HOME_PAGE_CONTEXT_KEY: InjectionKey<HomePageContext> = Symbol('homePageContext')
export function useHomePage() {
const userStore = useUserStore()
const walletStore = useWalletStore()
const configStore = useConfigStore()
const { assetsSummary, clearWalletAssets } = useWalletAssets()
const showLogoutConfirm = ref(false)
const wholeBalanceVisible = ref(false)
const showTextBanner = ref(false)
const showShare = ref(false)
const TextBanner = ref('')
const { banners } = useBanners()
const {
updateChartData,
priceChartSeries,
chartSelection,
selectedChartMetal,
setChartMetal,
} = usePriceChart()
const isBoursecuHomeLayout = computed(() => getHomeLayoutId() === 'boursecu')
const isGaroosi = computed(() => configStore.config?.name === 'garoosi')
const closedMessage = computed(() => userStore.getValueByKey('closed_message'))
const showClosedMessage = computed(
() => Boolean(closedMessage.value && lastPrice?.value?.price_last === 0)
)
const limitedPrice = computed(() => walletStore.limitedPrice?.[0])
const goldVal = computed(() => lastPrice.value?.price_last ?? 1)
const mainActions = computed<HomeActionItem[]>(() => [
{
name: 'buySell',
icon: 'vuesax-linear:money-receive',
label: 'خرید و فروش',
handler: () => router.push({ name: 'buyProduct' }),
},
{
name: 'deposit',
icon: 'vuesax-linear:card-add',
label: isGaroosi.value ? 'واریز و برداشت' : 'واریز نقدی',
handler: () => router.push({ name: 'wallet', query: { state: 'deposit' } }),
},
{
name: 'balance',
icon: 'vuesax-linear:receipt-item',
label: 'موجودی',
handler: () => {
wholeBalanceVisible.value = true
},
},
{
name: 'assets',
icon: 'vuesax-linear:cup',
label: isGaroosi.value ? 'دعوت از دوستان' : 'دارایی ها',
handler: () => (isGaroosi.value ? shareCode() : router.push({ name: 'fund' })),
},
])
async function shareCode() {
const text = getReferralShareText({
userName: userStore.userInfo?.name,
projectName: configStore.config.name,
referralId: userStore.userInfo?.referral_id,
})
try {
await navigator.share({
title: getReferralShareTitle(configStore.config.name),
text,
})
} catch {
copyCode()
}
}
function copyCode() {
const code = userStore.userInfo?.referral_id
if (code) {
navigator.clipboard.writeText(code)
message.info('کد رونوشت شد')
}
}
async function logout() {
userStore.userInfo = undefined
walletStore.walletInfo = undefined
clearWalletAssets()
resetHomePageData()
resetWalletPageData()
resetAddressPageData()
resetTransactionsPageData()
resetProductsPageData()
removeToken()
await router.push({ name: 'login' })
window.location.reload()
}
function openGoldModal(metal: WalletMetalTotal) {
Modal.confirm({
title: formatMetalAssetsInfoText(metal),
okText: 'متوجه شدم',
centered: true,
cancelButtonProps: { style: { display: 'none' } },
})
}
function openRialModal() {
const summary = assetsSummary.value
Modal.confirm({
title: formatRialAssetsInfoText(
summary?.cash_wallet ?? 0,
summary?.assets_rial_value ?? 0
),
okText: 'متوجه شدم',
centered: true,
cancelButtonProps: { style: { display: 'none' } },
})
}
function handleBannerClick(banner: { typeClick?: string; modalText?: string; link?: string }) {
const handlers: Record<string, () => void> = {
share: () => {
showShare.value = true
},
modal: () => {
if (banner.modalText) {
TextBanner.value = banner.modalText
showTextBanner.value = true
}
},
link: () => {
if (banner.link) {
if (banner.link.startsWith('http')) {
window.open(banner.link, '_blank')
} else {
router.push(banner.link)
}
}
},
}
handlers[banner.typeClick ?? '']?.()
}
const context: HomePageContext = {
loading,
isGaroosi,
limitedPrice,
showClosedMessage,
closedMessage,
mainActions,
banners,
priceChartSeries,
chartSelection,
updateChartData,
selectedChartMetal,
setChartMetal,
isBoursecuHomeLayout,
handleBannerClick,
openGoldModal,
openRialModal,
}
provide(HOME_PAGE_CONTEXT_KEY, context)
onUnmounted(() => {
stopLastPricePolling()
})
onMounted(() => {
// Single shared fetch for mobile + desktop — safe to call on remount (deduped in useHomePageData)
void initHomePageData()
})
return {
userStore,
walletStore,
showLogoutConfirm,
wholeBalanceVisible,
showTextBanner,
showShare,
TextBanner,
goldVal,
logout,
}
}
export function useHomePageContext() {
const context = inject(HOME_PAGE_CONTEXT_KEY)
if (!context) {
throw new Error('useHomePageContext must be used within useHomePage')
}
return context
}
@@ -0,0 +1,76 @@
import { ref } from 'vue'
import { loading } from '@/utils'
import { getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
import { useWalletAssets } from '@/composables/useWalletAssets'
import { clearProductsCatalog } from '@/composables/useProductsCatalog'
import { getHomeLayoutId } from '../layouts/registry'
import { fetchHomeChartPrices, loadHomeMaftolOffset, resetHomePriceChart } from './usePriceChart'
import { loadHomeBanners, resetHomeBanners } from './useBanners'
import { loadHomeFunds, resetHomeFunds } from './useUserFunds'
/**
* Single entry point for all homepage API calls.
* Mobile and desktop layouts must not fetch independently — they consume shared module state.
*/
let initPromise: Promise<void> | null = null
const initialized = ref(false)
export function isHomePageDataInitialized() {
return initialized.value
}
export async function initHomePageData(force = false) {
if (!force && initialized.value) {
// Resume live price updates when returning to home without refetching datasets
startLastPricePolling()
return
}
if (!force && initPromise) {
return initPromise
}
initPromise = (async () => {
try {
loading.value = true
const { loadWalletAssets } = useWalletAssets()
await Promise.all([
loadHomeFunds(force),
loadWalletAssets(force),
getLastPrices(),
fetchHomeChartPrices(force),
startLastPricePolling(),
loadHomeBanners(force),
])
if (getHomeLayoutId() === 'boursecu') {
await loadHomeMaftolOffset(force)
}
initialized.value = true
} catch (error) {
console.error('Home page initialization error:', error)
} finally {
loading.value = false
initPromise = null
}
})()
return initPromise
}
/** Clears homepage caches — call on logout or when a full refresh is required. */
export function resetHomePageData() {
initialized.value = false
initPromise = null
stopLastPricePolling()
resetHomeBanners()
resetHomeFunds()
resetHomePriceChart()
clearProductsCatalog()
}
export function useHomePageInitState() {
return { initialized }
}
+219 -27
View File
@@ -1,33 +1,225 @@
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { getAllPrices } from '@/api/home'
import type { Product } from '@/models/products'
import { getProductsCatalog } from '@/composables/useProductsCatalog'
import { getHomeLayoutId } from '../layouts/registry'
import { getProductBuyPrice, resolvePriceOffsetRatio } from '@/utils/productPrice'
import { isKatodMesGroup, isMaftolMesGroup } from '@/utils/productDealMethod'
import { getTradeBuyPrice, lastPrice } from '@/utils/price'
export type ChartMetalType = 'katod' | 'maftol'
type ChartPoint = [number, number]
/** Module-level chart state — one dataset shared by mobile and desktop PriceChart instances. */
const priceChartSeries = ref<{ name: string; data: ChartPoint[] }[]>([
{ name: 'قیمت', data: [] },
])
const rawChartPoints = ref<ChartPoint[]>([])
const chartSelection = ref('DAILY')
const chartLoading = ref(false)
const selectedChartMetal = ref<ChartMetalType>('katod')
const maftolChartRatio = ref(0)
let chartFetchPromise: Promise<void> | null = null
let chartInitialized = false
let maftolOffsetPromise: Promise<void> | null = null
let maftolOffsetLoaded = false
function findKatodProduct(products: Product[]) {
return (
products.find((product) => isKatodMesGroup(product.group_type))
?? products.find((product) => (product.title ?? '').includes('کاتد'))
)
}
function findMaftolProduct(products: Product[]) {
return (
products.find((product) => isMaftolMesGroup(product.group_type))
?? products.find((product) => (product.title ?? '').includes('مفتول'))
)
}
function applyChartRatio(basePrice: number, ratio: number) {
if (!ratio) {
return basePrice
}
return Math.round(basePrice + basePrice * ratio)
}
function deriveMaftolChartRatio(maftolProduct?: Product, katodProduct?: Product) {
const offsetRatio = resolvePriceOffsetRatio(maftolProduct?.price_offset_percent)
if (offsetRatio) {
return offsetRatio
}
if (!maftolProduct) {
return 0
}
const basePrice = getTradeBuyPrice(lastPrice.value)
const katodPrice = katodProduct ? getProductBuyPrice(katodProduct) : basePrice
const maftolPrice = getProductBuyPrice(maftolProduct)
const referencePrice = katodPrice || basePrice
if (!referencePrice || !maftolPrice || referencePrice === maftolPrice) {
return 0
}
return (maftolPrice - referencePrice) / referencePrice
}
function normalizeChartTimestamp(timestamp: number) {
return timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp
}
function resolvePricesMap(response: unknown): Record<string, Array<{ timestamp: number; value: number }>> | null {
if (!response || typeof response !== 'object') {
return null
}
const body = response as Record<string, unknown>
const nested = body.data
if (nested && typeof nested === 'object' && 'prices' in nested) {
return (nested as { prices: Record<string, Array<{ timestamp: number; value: number }>> }).prices
}
if ('prices' in body) {
return body.prices as Record<string, Array<{ timestamp: number; value: number }>>
}
return null
}
function buildChartPoints(items: Array<{ timestamp: number; value: number }>): ChartPoint[] {
return items.map((item) => [
normalizeChartTimestamp(item.timestamp),
item.value - 1,
])
}
function syncChartSeries() {
const data =
getHomeLayoutId() === 'boursecu' && selectedChartMetal.value === 'maftol'
? rawChartPoints.value.map(
([timestamp, value]) =>
[timestamp, applyChartRatio(value, maftolChartRatio.value)] as ChartPoint
)
: rawChartPoints.value
priceChartSeries.value[0].data = data
}
watch([selectedChartMetal, maftolChartRatio], () => {
syncChartSeries()
})
async function fetchPrices(force = false) {
if (!force && chartInitialized && rawChartPoints.value.length > 0) {
syncChartSeries()
return
}
if (!force && chartFetchPromise) {
return chartFetchPromise
}
chartFetchPromise = (async () => {
try {
chartLoading.value = true
const response = await getAllPrices()
const data = resolvePricesMap(response)
const selectedData = data?.[chartSelection.value] ?? []
rawChartPoints.value = buildChartPoints(selectedData)
syncChartSeries()
chartInitialized = true
} catch (error) {
console.error('Error fetching prices:', error)
} finally {
chartLoading.value = false
chartFetchPromise = null
}
})()
return chartFetchPromise
}
async function loadMaftolOffset(force = false) {
if (getHomeLayoutId() !== 'boursecu') {
maftolChartRatio.value = 0
syncChartSeries()
return
}
if (!force && maftolOffsetLoaded) {
return
}
if (!force && maftolOffsetPromise) {
return maftolOffsetPromise
}
maftolOffsetPromise = (async () => {
try {
const products = await getProductsCatalog(force)
const katodProduct = findKatodProduct(products)
const maftolProduct = findMaftolProduct(products)
maftolChartRatio.value = deriveMaftolChartRatio(maftolProduct, katodProduct)
syncChartSeries()
maftolOffsetLoaded = true
} catch (error) {
console.error('Error loading maftol chart ratio:', error)
maftolChartRatio.value = 0
syncChartSeries()
} finally {
maftolOffsetPromise = null
}
})()
return maftolOffsetPromise
}
function updateChartData(timeline: string) {
chartSelection.value = timeline
void fetchPrices(true)
}
function setChartMetal(metal: ChartMetalType) {
selectedChartMetal.value = metal
}
export function usePriceChart() {
const priceChartSeries = ref([{ name: 'قیمت', data: [] }])
const selection = ref('DAILY')
const chartLoading = ref(false)
return {
priceChartSeries,
chartSelection,
chartLoading,
selectedChartMetal,
maftolChartRatio,
updateChartData,
fetchPrices,
loadMaftolOffset,
setChartMetal,
}
}
const toPersianNumber = (num: number) => {
return num.toString().replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[d])
}
export function fetchHomeChartPrices(force = false) {
return fetchPrices(force)
}
const fetchPrices = async () => {
try {
chartLoading.value = true
const response = await getAllPrices()
const data = response.data?.prices
const selectedData = data[selection.value] || []
priceChartSeries.value[0].data = selectedData.map((item: any) => [item.timestamp, item.value - 1])
} catch (error) {
console.error('Error fetching prices:', error)
} finally {
chartLoading.value = false
}
}
export function loadHomeMaftolOffset(force = false) {
return loadMaftolOffset(force)
}
const updateChartData = (timeline: string) => {
selection.value = timeline
fetchPrices()
}
return { priceChartSeries, chartLoading, updateChartData, fetchPrices }
}
export function resetHomePriceChart() {
priceChartSeries.value = [{ name: 'قیمت', data: [] }]
rawChartPoints.value = []
chartSelection.value = 'DAILY'
selectedChartMetal.value = 'katod'
maftolChartRatio.value = 0
chartInitialized = false
chartFetchPromise = null
maftolOffsetLoaded = false
maftolOffsetPromise = null
}
+48 -16
View File
@@ -1,22 +1,54 @@
import { ref } from 'vue'
import { useFundUserStore } from '@/store/fundStore'
export function useUserFunds() {
const funds = ref([])
const loading = ref(false)
const fundStore = useFundUserStore()
/** Module-level fund list — shared across home layout variants. */
const funds = ref<unknown[]>([])
const fundsLoading = ref(false)
let fundsPromise: Promise<void> | null = null
let fundsLoaded = false
const loadFunds = async () => {
try {
loading.value = true
const result = await fundStore.getMyFund()
funds.value = result?.rows || []
} catch (error) {
console.error('Error loading funds:', error)
} finally {
loading.value = false
}
async function fetchFunds(force = false) {
if (!force && fundsLoaded) {
return
}
if (!force && fundsPromise) {
return fundsPromise
}
const fundStore = useFundUserStore()
fundsPromise = (async () => {
try {
fundsLoading.value = true
const result = await fundStore.getMyFund()
funds.value = result?.rows || []
fundsLoaded = true
} catch (error) {
console.error('Error loading funds:', error)
} finally {
fundsLoading.value = false
fundsPromise = null
}
})()
return { funds, loading, loadFunds }
}
return fundsPromise
}
export function useUserFunds() {
return {
funds,
loading: fundsLoading,
loadFunds: fetchFunds,
}
}
export function loadHomeFunds(force = false) {
return fetchFunds(force)
}
export function resetHomeFunds() {
funds.value = []
fundsLoaded = false
fundsPromise = null
}