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,202 @@
<template>
<div class="boursecu-transactions pb-[calc(var(--app-bottom-chrome-height)+1rem)]">
<div class="px-4 pt-4 pb-2">
<h1 class="text-xl font-bold text-gray-900">گزارش‌ها</h1>
</div>
<div class="boursecu-transactions__tabs px-4">
<div class="flex gap-5 overflow-x-auto whitespace-nowrap pb-1">
<button
v-for="tab in BOURSECU_TRANSACTION_TABS"
:key="tab.id"
type="button"
class="boursecu-transactions__tab shrink-0"
:class="{ 'boursecu-transactions__tab--active': activeTabId === tab.id }"
@click="setActiveTab(tab.id)"
>
{{ tab.label }}
</button>
</div>
</div>
<div v-if="activeTab.showTradeToggle" class="px-4 mt-4 flex justify-end">
<div class="boursecu-transactions__trade-toggle">
<button
type="button"
class="boursecu-transactions__trade-toggle-item"
:class="{ 'boursecu-transactions__trade-toggle-item--active': tradeSide === 'buy' }"
@click="setTradeSide('buy')"
>
خرید
</button>
<button
type="button"
class="boursecu-transactions__trade-toggle-item"
:class="{ 'boursecu-transactions__trade-toggle-item--active': tradeSide === 'sell' }"
@click="setTradeSide('sell')"
>
فروش
</button>
</div>
</div>
<div class="px-4 mt-4 flex items-center gap-2 flex-wrap">
<div class="flex items-center gap-1 text-sm text-gray-600 shrink-0">
<BasicIcon icon="vuesax-linear:filter" size="16" />
<span>فیلترها:</span>
</div>
<a-select
v-model:value="statusFilter"
allow-clear
class="boursecu-transactions__filter-select min-w-[7rem]"
placeholder="وضعیت"
size="large"
@change="applyInlineFilters"
>
<a-select-option value="active">فعال</a-select-option>
<a-select-option value="inactive">غیرفعال</a-select-option>
</a-select>
<BaseDatePicker
v-model:value="dateFilter"
class="boursecu-transactions__filter-date min-w-[7rem]"
placeholder="تاریخ"
size="large"
@onchange="applyInlineFilters"
/>
</div>
<div ref="listRef" :class="loading ? 'min-h-[50vh]' : ''" class="px-4 mt-5">
<list-anim v-if="!loading">
<Item v-for="item in items" :key="item.id" :item="item" />
<div :ref="bindMobileObserver" class="w-full opacity-0 relative">
در حال بارگذاری
</div>
<TransactionSkeleton v-if="loadingMore" class="mt-1 mb-8" />
</list-anim>
<template v-else>
<TransactionSkeleton v-for="i in 8" :key="i" class="mt-1" />
</template>
<div
v-if="!loading && items.length === 0"
class="boursecu-transactions__empty"
>
<img :src="emptyImage" alt="" class="boursecu-transactions__empty-image" />
<p class="boursecu-transactions__empty-text">{{ emptyText }}</p>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { loading, loadingMore } from '@/utils'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BaseDatePicker from '@/components/BasicPacks/BaseDatePicker/src/BaseDatePicker.vue'
import ListAnim from '@/components/ListAnim.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import emptyImage from '@/assets/img/cash_empty.svg'
import Item from './components/Item.vue'
import { BOURSECU_TRANSACTION_TABS } from './config/boursecuTabs'
import { useTransactionsPageContext } from './composables/useTransactionsPage'
const listRef = ref<HTMLElement | null>(null)
const {
items,
dateFilter,
statusFilter,
activeTabId,
tradeSide,
activeTab,
emptyText,
setActiveTab,
setTradeSide,
applyInlineFilters,
bindMobileObserver,
} = useTransactionsPageContext()
</script>
<style scoped lang="less">
.boursecu-transactions {
background: #fff;
min-height: 100%;
}
.boursecu-transactions__tab {
appearance: none;
cursor: pointer;
border: none;
background: transparent;
padding: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: #8b9490;
border-bottom: 2px solid transparent;
transition: color 0.2s ease, border-color 0.2s ease;
}
.boursecu-transactions__tab--active {
color: #1f2937;
border-bottom-color: #e8b923;
}
.boursecu-transactions__trade-toggle {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: #eceff1;
}
.boursecu-transactions__trade-toggle-item {
appearance: none;
cursor: pointer;
min-width: 5.5rem;
padding: 0.45rem 1rem;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: #6b7280;
font-size: 0.875rem;
font-weight: 600;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
.boursecu-transactions__trade-toggle-item--active {
background: #fff;
border-color: #d4e3de;
color: #1f2937;
}
.boursecu-transactions__filter-select,
.boursecu-transactions__filter-date {
flex: 1;
max-width: 9rem;
}
.boursecu-transactions__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem 4rem;
text-align: center;
}
.boursecu-transactions__empty-image {
width: 11rem;
max-width: 60vw;
height: auto;
object-fit: contain;
}
.boursecu-transactions__empty-text {
margin-top: 1rem;
font-size: 0.95rem;
font-weight: 600;
color: #6b7280;
}
</style>
@@ -0,0 +1,79 @@
<template>
<div>
<div class="absolute inset-0 bg-cover bg-center opacity-60"></div>
<div
class="bg-white w-full transaction-list py-3 px-3 font-bold drop-shadow flex justify-between z-10"
>
<div>لیست تراکنش ها</div>
<BasicIcon icon="vuesax-linear:filter" @click="showFilter = true" />
</div>
<div ref="el" :class="loading ? 'h-screen' : ''" class="px-3 pt-16 pb-10">
<list-anim v-if="!loading">
<Item v-for="item in items" :key="item.id" :item="item" />
<div :ref="bindMobileObserver" v-show="!loading" class="w-full opacity-0 relative">
در حال بارگذاری
</div>
<TransactionSkeleton v-if="loadingMore" class="mt-1 mb-8" />
</list-anim>
<TransactionSkeleton v-for="i in 13" v-else :key="i" class="mt-1" />
<div v-if="items.length <= 0 && !loading" class="flex flex-col items-center justify-center h-80vh">
<DotLottieVue
class="w-11rem h-11rem"
autoplay
src="https://lottie.host/51d3315a-cf88-4c18-bb4d-a31511fd4325/KjSBzcMbi6.json"
/>
<div class="font-bold">{{ emptyText }}</div>
</div>
</div>
<BasicActionSheet v-model:show="showFilter">
<div class="font-bold text-lg">فیلتر</div>
<div class="pb-20 mt-5">
<a-form-item class="w-full gap-2 !flex-col" label="تاریخ" name="password">
<BaseDatePicker v-model:value="dateFilter" class="w-full" size="large" />
</a-form-item>
<BasicButton class="w-full mt-5" type="primary" @click="applyFilter">اعمال فیلتر</BasicButton>
<BasicButton class="w-full mt-2" @click="showFilter = false">بازگشت</BasicButton>
</div>
</BasicActionSheet>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { loading, loadingMore } from '@/utils'
import Item from './components/Item.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BaseDatePicker from '@/components/BasicPacks/BaseDatePicker/src/BaseDatePicker.vue'
import ListAnim from '@/components/ListAnim.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
import { useTransactionsPageContext } from './composables/useTransactionsPage'
const el = ref<HTMLElement | null>(null)
const {
items,
showFilter,
dateFilter,
emptyText,
applyFilter,
bindMobileObserver,
} = useTransactionsPageContext()
</script>
<style scoped lang="less">
.transaction-list {
position: fixed;
right: 0;
left: 0;
@media screen and (min-width: 426px) {
width: 426px;
left: auto;
right: auto;
}
}
</style>
+103 -142
View File
@@ -1,24 +1,70 @@
<template>
<div @click="infoVisible = true">
<!-- {{ item}}-->
<button
v-if="variant === 'desktop'"
type="button"
class="dtxn-row"
@click="infoVisible = true"
>
<span
class="dtxn-row__icon"
:class="isUp ? 'dtxn-row__icon--up' : 'dtxn-row__icon--down'"
>
<Icon :icon="`vuesax-linear:${isUp ? 'arrow-up-2' : 'arrow-down-2'}`" size="18" />
</span>
<div class="dtxn-row__main">
<p class="dtxn-row__title">{{ props.item.title }}</p>
<div class="dtxn-row__meta">
<span>{{ toJalaliOutOfUTC(props.item.expire_at) }}</span>
<span :class="props.item.gold >= 0 ? 'text-green-600' : 'text-red-600'">
{{ props.item.gold }} {{ props.item.unit }}
</span>
<span v-if="isGold">
قیمت معامله:
{{ usePriceWithoutType(parseInt((Math.abs(props.item.cash) / Math.abs(props.item.gold)).toFixed(0))) }}
ریال
</span>
</div>
</div>
<div
class="dtxn-row__amount"
:class="props.item.cash > 0 ? 'dtxn-row__amount--up' : 'dtxn-row__amount--down'"
>
{{ usePriceWithoutType(props.item.cash ?? 0) }} ریال
</div>
<div class="dtxn-row__status">
<ATag color="blue" class="!py-1 !px-4 !ml-0 !text-xs">
{{ props.item.order?.sts_str ?? props.item.withdraw?.sts_str }}
</ATag>
</div>
</button>
<div v-else @click="infoVisible = true">
<div class="flex">
<div :class="isUp? 'bg-green-50':'bg-red-50'" class="rounded-lg p-3 h-fit w-fit flex justify-center items-center">
<Icon :class="isUp? 'text-green-600':'text-red-600'"
:icon="`vuesax-linear:${isUp? 'arrow-up-2':'arrow-down-2'}`" />
<div
:class="isUp ? 'bg-green-50' : 'bg-red-50'"
class="rounded-lg p-3 h-fit w-fit flex justify-center items-center"
>
<Icon
:class="isUp ? 'text-green-600' : 'text-red-600'"
:icon="`vuesax-linear:${isUp ? 'arrow-up-2' : 'arrow-down-2'}`"
/>
</div>
<div class="w-full mr-3">
<div class="flex justify-between w-full">
<div>{{ item.title }}</div>
<div>{{ toJalaliOutOfUTC(item.expire_at) }}</div>
<div>{{ props.item.title }}</div>
<div>{{ toJalaliOutOfUTC(props.item.expire_at) }}</div>
</div>
<div class="flex justify-between mt-2">
<div :class="item.gold >= 0? 'text-green-600':'text-red-600'"
class="text-gray-500 text-xs number-ltr">{{ item.gold }}
<div>{{item.unit}}</div>
<div
:class="props.item.gold >= 0 ? 'text-green-600' : 'text-red-600'"
class="text-gray-500 text-xs number-ltr"
>
{{ props.item.gold }}
<div>{{ props.item.unit }}</div>
</div>
<div class="text-gray-500 flex gap-1 text-xs items-center number-ltr">
<div :class="item.cash > 0 ? 'text-green-600':'text-red-600'" class="text-base">
{{ usePriceWithoutType(item.cash?? 0) }}
<div :class="props.item.cash > 0 ? 'text-green-600' : 'text-red-600'" class="text-base">
{{ usePriceWithoutType(props.item.cash ?? 0) }}
</div>
ریال
</div>
@@ -28,8 +74,9 @@
<div v-if="isGold" class="flex w-full justify-between mt-2">
<div class="text-gray-500">قیمت معامله:</div>
<div class="text-gray-500 flex gap-1 text-xs items-center">
<!-- <div class="text-base">{{ typeof parseInt((Math.abs(item.cash) / Math.abs(item.gold)).toFixed(0)) }}</div>-->
<div class="text-base">{{ usePriceWithoutType(parseInt((Math.abs(item.cash) / Math.abs(item.gold)).toFixed(0))) }}</div>
<div class="text-base">
{{ usePriceWithoutType(parseInt((Math.abs(props.item.cash) / Math.abs(props.item.gold)).toFixed(0))) }}
</div>
ریال
</div>
</div>
@@ -37,149 +84,63 @@
<div class="text-gray-500">وضعیت :</div>
<div class="text-gray-500 flex gap-1 text-xs items-center">
<ATag class="!py-1 !px-6 !ml-0" color="blue">
{{ item.order?.sts_str ?? item.withdraw?.sts_str }}
{{ props.item.order?.sts_str ?? props.item.withdraw?.sts_str }}
</ATag>
</div>
</div>
<!-- <div v-else class="h-8"></div>-->
<a-divider class="!my-3" />
<BasicActionSheet v-model:show="infoVisible">
<div class="font-bold text-lg">{{ props.item.title }}</div>
<div class="mb-8rem mt-5">
<div class="flex justify-between mb-3">
<div class="text-primary">مبلغ:</div>
<div :class="item.cash > 0? 'text-green-600' : 'text-red-600'" class="flex text-xs items-center number-ltr">
<div class="text-base ml-1">{{ usePriceWithoutType(item.cash?? 0) }}</div>
ریال
</div>
</div>
<div v-if="item.order" class="flex justify-between mb-3">
<div class="text-primary">قیمت هر گرم {{ getTradePriceUnitLabel() }} حین معامله:</div>
<div class="flex text-xs items-center number-ltr">
<div class="text-base ml-1">{{ usePriceWithoutType(item.order?.price?? 0) }}</div>
ریال
</div>
</div>
<div class="flex justify-between mb-3">
<div class="text-primary">مقدار {{ item.wallet?.product ? item.wallet.product.title : getMeltedGoldTradeLabel() }} مبادله شده:</div>
<div :class="item.gold >= 0? 'text-green-600' : 'text-red-600'" class="flex flex-col items-end text-xs number-ltr">
<div class="flex items-center">
<div class="text-base ml-1">{{ item.gold }}</div>
{{ item.unit }}
</div>
<div
v-if="item.wallet?.product && getProductTotalWeightLabel(Math.abs(item.gold ?? 0), item.wallet.product)"
class="text-gray-500"
>
{{ getProductTotalWeightLabel(Math.abs(item.gold ?? 0), item.wallet.product) }}
</div>
</div>
</div>
<div class="flex justify-between mb-3">
<div class="text-primary">{{ getCashWalletLabel(item.wallet?.product?.group_type) }}:</div>
<div :class="item.cash > 0? 'text-green-600' : 'text-red-600'" class="flex text-xs items-center number-ltr">
<div class="text-base">{{ usePriceWithoutType(item.balance_cash ?? 0) }}</div>
ریال
</div>
</div>
<div class="flex justify-between mb-3" v-if="item.wallet?.product">
<div class="text-primary">موجودی {{item.wallet.product.title}}:</div>
<div class="flex flex-col items-end text-xs text-yellow-700">
<div class="flex items-center">
<div class="text-base ml-1">{{ item.balance_gold }}</div>
{{ getProductQuantityUnit(item.wallet.product) }}
</div>
<div v-if="getProductUnitWeightLabel(item.wallet.product)" class="text-gray-500">
هر عدد {{ getProductUnitWeightLabel(item.wallet.product) }}
</div>
</div>
</div>
<div class="flex justify-between mb-3" v-else>
<div class="text-primary">{{ getGoldWalletLabel(item.wallet?.product?.group_type) }}:</div>
<div class="flex text-xs text-yellow-700 items-center">
<div class="text-base ml-1">{{ item.balance_gold }}</div>
{{ getWeightUnit() }}
</div>
</div>
<!-- <div class="flex justify-between mb-3">
<div class="text-primary">موجودی کیف پول:</div>
<div class="flex text-xs items-center" :class="isUp? 'text-green-600' : 'text-red-600'"><div class="text-base">{{usePriceWithoutType(12000000)}}</div>ریال</div>
</div>
<div class="flex justify-between mb-3">
<div class="text-primary">موجودی کیف طلا:</div>
<div class="flex items-center" :class="isUp? 'text-red-600' : 'text-green-600'">60 گرم</div>
</div>-->
<div class="flex justify-between mb-3">
<div class="text-primary">وضعیت:</div>
<div class="flex text-xs items-center">
<ATag :color="item.active? 'blue': 'blue'" class="!py-1 !px-6 !ml-0">
{{ item.order?.sts_str ?? item.withdraw?.sts_str }}
</ATag>
</div>
</div>
<div class="flex justify-between mb-3">
<div class="text-primary">تاریخ:</div>
<div>{{ toJalaliNotime(item.expire_at) }}</div>
</div>
<div class="flex justify-between mb-3">
<div class="text-primary">زمان:</div>
<div>{{ toTime(item.expire_at) }}</div>
</div>
<!-- <div class="flex justify-between mb-3">
<div class="text-primary">وضعیت معامله:</div>
<div class="flex text-xs items-center">
<ATag class="!py-1 !px-6 !ml-0" color="orange">
معاملات باز
</ATag>
</div>
</div>-->
<div v-if="item.eaj" class="flex justify-between mb-3">
<div class="text-primary">مهلت ارسال فیش:</div>
<div>{{ item.eaj }}</div>
</div>
<BasicButton class="w-full mt-5" @click="infoVisible = false">بازگشت</BasicButton>
</div>
</BasicActionSheet>
</div>
<BasicActionSheet v-if="variant !== 'desktop'" v-model:show="infoVisible">
<div class="font-bold text-lg">{{ props.item.title }}</div>
<TransactionDetailPanel :item="props.item" variant="mobile" />
<BasicButton class="w-full mt-5" @click="infoVisible = false">بازگشت</BasicButton>
</BasicActionSheet>
<BasicModal
v-else
v-model:visible="infoVisible"
:title="props.item.title"
width="520px"
custom-footer
destroy-on-close
>
<TransactionDetailPanel :item="props.item" variant="desktop" />
<template #footer>
<BasicButton class="w-full" @click="infoVisible = false">بستن</BasicButton>
</template>
</BasicModal>
</template>
<script lang="ts" setup>
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { usePriceWithoutType } from '../../../../utils/usePrice'
import { usePriceWithoutType } from '@/utils/usePrice'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import { computed, ref } from 'vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import type { Transaction } from '@/models/transaction'
import { getCashWalletLabel, getGoldWalletLabel, getMeltedGoldTradeLabel, getProductQuantityUnit, getProductTotalWeightLabel, getProductUnitWeightLabel, getTradePriceUnitLabel, getWeightUnit } from '@/utils/productDealMethod'
import { toJalaliNotime, toJalaliOutOfUTC, toTime } from '../../../../utils/useDateTime'
import { toJalaliOutOfUTC } from '@/utils/useDateTime'
import TransactionDetailPanel from './TransactionDetailPanel.vue'
const infoVisible = ref<boolean>(false)
const infoVisible = ref(false)
interface Prop {
item: Transaction
}
const props = withDefaults(
defineProps<{
item: Transaction
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
const props = withDefaults(defineProps<Prop>(), {})
const isUp = computed(() => props.item.type === 3 || props.item.type === 1 || props.item.type === 8 || props.item.type === 9)
const isGold = computed(() => props.item.type === 1 || props.item.type === 2 || props.item.type === 10)
/*const title = computed(() => {
if (isUp.value) {
if (isGold.value) {
return 'واریز ریالی'
} else {
return 'خرید طلا'
}
} else {
if (isGold.value) {
return 'برداشت ریالی'
} else {
return 'فروش طلا'
}
}
})*/
const isUp = computed(
() =>
props.item.type === 3
|| props.item.type === 1
|| props.item.type === 8
|| props.item.type === 9,
)
const isGold = computed(
() => props.item.type === 1 || props.item.type === 2 || props.item.type === 10,
)
</script>
<style scoped>
</style>
@@ -0,0 +1,131 @@
<template>
<div :class="variant === 'desktop' ? 'dtxn-detail' : 'mb-8rem mt-5'">
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">مبلغ:</div>
<div
:class="[
variant === 'desktop' ? 'dtxn-detail__value' : '',
item.cash > 0 ? 'text-green-600' : 'text-red-600',
'flex text-xs items-center number-ltr',
]"
>
<div class="text-base ml-1">{{ usePriceWithoutType(item.cash ?? 0) }}</div>
ریال
</div>
</div>
<div v-if="item.order" :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">
قیمت هر گرم {{ getTradePriceUnitLabel() }} حین معامله:
</div>
<div :class="['flex text-xs items-center number-ltr', variant === 'desktop' ? 'dtxn-detail__value' : '']">
<div class="text-base ml-1">{{ usePriceWithoutType(item.order?.price ?? 0) }}</div>
ریال
</div>
</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">
مقدار {{ item.wallet?.product ? item.wallet.product.title : getMeltedGoldTradeLabel() }} مبادله شده:
</div>
<div
:class="[
item.gold >= 0 ? 'text-green-600' : 'text-red-600',
'flex flex-col items-end text-xs number-ltr',
variant === 'desktop' ? 'dtxn-detail__value' : '',
]"
>
<div class="flex items-center">
<div class="text-base ml-1">{{ item.gold }}</div>
{{ item.unit }}
</div>
<div
v-if="item.wallet?.product && getProductTotalWeightLabel(Math.abs(item.gold ?? 0), item.wallet.product)"
class="text-gray-500"
>
{{ getProductTotalWeightLabel(Math.abs(item.gold ?? 0), item.wallet.product) }}
</div>
</div>
</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">
{{ getCashWalletLabel(item.wallet?.product?.group_type) }}:
</div>
<div
:class="[
item.cash > 0 ? 'text-green-600' : 'text-red-600',
'flex text-xs items-center number-ltr',
variant === 'desktop' ? 'dtxn-detail__value' : '',
]"
>
<div class="text-base">{{ usePriceWithoutType(item.balance_cash ?? 0) }}</div>
ریال
</div>
</div>
<div v-if="item.wallet?.product" :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">
موجودی {{ item.wallet.product.title }}:
</div>
<div class="flex flex-col items-end text-xs text-yellow-700">
<div class="flex items-center">
<div class="text-base ml-1">{{ item.balance_gold }}</div>
{{ getProductQuantityUnit(item.wallet.product) }}
</div>
<div v-if="getProductUnitWeightLabel(item.wallet.product)" class="text-gray-500">
هر عدد {{ getProductUnitWeightLabel(item.wallet.product) }}
</div>
</div>
</div>
<div v-else :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">
{{ getGoldWalletLabel(item.wallet?.product?.group_type) }}:
</div>
<div class="flex text-xs text-yellow-700 items-center">
<div class="text-base ml-1">{{ item.balance_gold }}</div>
{{ getWeightUnit() }}
</div>
</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">وضعیت:</div>
<div class="flex text-xs items-center">
<ATag color="blue" class="!py-1 !px-6 !ml-0">
{{ item.order?.sts_str ?? item.withdraw?.sts_str }}
</ATag>
</div>
</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">تاریخ:</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__value' : ''">{{ toJalaliNotime(item.expire_at) }}</div>
</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">زمان:</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__value' : ''">{{ toTime(item.expire_at) }}</div>
</div>
<div v-if="item.eaj" :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">مهلت ارسال فیش:</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__value' : ''">{{ item.eaj }}</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { usePriceWithoutType } from '@/utils/usePrice'
import type { Transaction } from '@/models/transaction'
import {
getCashWalletLabel,
getGoldWalletLabel,
getMeltedGoldTradeLabel,
getProductQuantityUnit,
getProductTotalWeightLabel,
getProductUnitWeightLabel,
getTradePriceUnitLabel,
getWeightUnit,
} from '@/utils/productDealMethod'
import { toJalaliNotime, toTime } from '@/utils/useDateTime'
withDefaults(
defineProps<{
item: Transaction
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
</script>
@@ -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'
}
@@ -0,0 +1,103 @@
import type { TableEngineInput } from '@/models/table'
export type BoursecuTransactionTabId =
| 'trades'
| 'deposit'
| 'withdrawal'
| 'physical'
export type BoursecuTradeSide = 'buy' | 'sell'
export type BoursecuTransactionFetchMode = 'transaction' | 'deposit'
export interface BoursecuTransactionTab {
id: BoursecuTransactionTabId
label: string
emptyText: string
showTradeToggle?: boolean
fetchMode?: BoursecuTransactionFetchMode
typeFilters?: number[]
}
export const BOURSECU_TRANSACTION_TABS: BoursecuTransactionTab[] = [
{
id: 'trades',
label: 'همه',
emptyText: 'معامله‌ای برای شما به ثبت نرسیده است',
showTradeToggle: true,
typeFilters: [1, 2],
},
{
id: 'deposit',
label: 'واریز',
emptyText: 'واریزی برای شما به ثبت نرسیده است',
fetchMode: 'deposit',
},
{
id: 'withdrawal',
label: 'برداشت',
emptyText: 'برداشتی برای شما به ثبت نرسیده است',
typeFilters: [4],
},
{
id: 'physical',
label: 'تحویل فیزیکی',
emptyText: 'درخواست تحویل فیزیکی برای شما ثبت نشده است',
typeFilters: [9],
},
]
const TRADE_TYPE_BY_SIDE: Record<BoursecuTradeSide, number> = {
buy: 1,
sell: 2,
}
export function buildBoursecuTransactionPagination(
tab: BoursecuTransactionTab,
tradeSide: BoursecuTradeSide,
dateFilter?: string,
statusFilter?: string
): TableEngineInput {
const filters: TableEngineInput['filters'] = []
if (tab.fetchMode !== 'deposit' && tab.typeFilters?.length) {
const typeValue =
tab.showTradeToggle && tab.typeFilters.includes(TRADE_TYPE_BY_SIDE[tradeSide])
? TRADE_TYPE_BY_SIDE[tradeSide]
: tab.typeFilters[0]
filters.push({
field: 'type',
cr: 'and',
type: 'number',
op: 'eq',
value: typeValue,
})
}
if (dateFilter) {
filters.push({
field: 'created_at',
cr: 'and',
type: 'date',
op: 'eq',
value: dateFilter,
})
}
if (statusFilter === 'active' || statusFilter === 'inactive') {
filters.push({
field: 'active',
cr: 'and',
type: 'boolean',
op: 'eq',
value: statusFilter === 'active',
})
}
return {
page: 0,
limit: 7,
filters,
}
}
@@ -0,0 +1,126 @@
<template>
<div class="dtxn">
<div class="dtxn__container">
<header class="dtxn-topbar">
<p class="dtxn-topbar__eyebrow">گزارش‌ها</p>
<h1 class="dtxn-topbar__title">تراکنش‌ها</h1>
</header>
<div class="dtxn-toolbar">
<div class="dtxn-tabs">
<button
v-for="tab in BOURSECU_TRANSACTION_TABS"
:key="tab.id"
type="button"
class="dtxn-tabs__btn"
:class="{ 'dtxn-tabs__btn--active': activeTabId === tab.id }"
@click="setActiveTab(tab.id)"
>
{{ tab.label }}
</button>
</div>
<div v-if="activeTab.showTradeToggle" class="dtxn-toggle">
<button
type="button"
class="dtxn-toggle__btn"
:class="{ 'dtxn-toggle__btn--active': tradeSide === 'buy' }"
@click="setTradeSide('buy')"
>
خرید
</button>
<button
type="button"
class="dtxn-toggle__btn"
:class="{ 'dtxn-toggle__btn--active': tradeSide === 'sell' }"
@click="setTradeSide('sell')"
>
فروش
</button>
</div>
</div>
<div class="dtxn-filters">
<div class="dtxn-filters__label">
<BasicIcon icon="vuesax-linear:filter" size="16" />
<span>فیلترها:</span>
</div>
<a-select
v-model:value="statusFilter"
allow-clear
class="dtxn-filters__select"
placeholder="وضعیت"
size="large"
@change="applyInlineFilters"
>
<a-select-option value="active">فعال</a-select-option>
<a-select-option value="inactive">غیرفعال</a-select-option>
</a-select>
<BaseDatePicker
v-model:value="dateFilter"
class="dtxn-filters__date"
placeholder="تاریخ"
size="large"
@onchange="applyInlineFilters"
/>
</div>
<div ref="listRef" :class="loading ? 'min-h-[40vh]' : ''" class="mt-5">
<div v-if="!loading" class="dtxn-list">
<list-anim>
<Item
v-for="item in items"
:key="item.id"
:item="item"
variant="desktop"
/>
</list-anim>
<div :ref="bindDesktopObserver" class="w-full opacity-0 relative">
در حال بارگذاری
</div>
<TransactionSkeleton v-if="loadingMore" class="mt-1 mb-8" />
</div>
<template v-else>
<TransactionSkeleton v-for="i in 8" :key="i" class="mt-1" />
</template>
<div v-if="!loading && items.length === 0" class="dtxn-empty">
<img :src="emptyImage" alt="" class="dtxn-empty__image" />
<p class="dtxn-empty__text">{{ emptyText }}</p>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { loading, loadingMore } from '@/utils'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BaseDatePicker from '@/components/BasicPacks/BaseDatePicker/src/BaseDatePicker.vue'
import ListAnim from '@/components/ListAnim.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import emptyImage from '@/assets/img/cash_empty.svg'
import Item from '../components/Item.vue'
import { BOURSECU_TRANSACTION_TABS } from '../config/boursecuTabs'
import { useTransactionsPageContext } from '../composables/useTransactionsPage'
const listRef = ref<HTMLElement | null>(null)
const {
items,
dateFilter,
statusFilter,
activeTabId,
tradeSide,
activeTab,
emptyText,
setActiveTab,
setTradeSide,
applyInlineFilters,
bindDesktopObserver,
} = useTransactionsPageContext()
</script>
<style lang="less" src="./styles/desktop-transactions.less"></style>
@@ -0,0 +1,86 @@
<template>
<div class="dtxn">
<div class="dtxn__container">
<header class="dtxn-topbar">
<p class="dtxn-topbar__eyebrow">گزارش‌ها</p>
<h1 class="dtxn-topbar__title">لیست تراکنش‌ها</h1>
</header>
<div class="dtxn-filters">
<div class="dtxn-filters__label">
<BasicIcon icon="vuesax-linear:filter" size="16" />
<span>فیلتر تاریخ:</span>
</div>
<BaseDatePicker
v-model:value="dateFilter"
class="dtxn-filters__date"
placeholder="تاریخ"
size="large"
/>
<BasicButton type="primary" @click="applyFilter">اعمال فیلتر</BasicButton>
<BasicButton v-if="dateFilter" @click="clearFilter">حذف فیلتر</BasicButton>
</div>
<div ref="el" :class="loading ? 'min-h-[40vh]' : ''" class="mt-5">
<div v-if="!loading" class="dtxn-list">
<list-anim>
<Item
v-for="item in items"
:key="item.id"
:item="item"
variant="desktop"
/>
</list-anim>
<div :ref="bindDesktopObserver" class="w-full opacity-0 relative">
در حال بارگذاری
</div>
<TransactionSkeleton v-if="loadingMore" class="mt-1 mb-8" />
</div>
<template v-else>
<TransactionSkeleton v-for="i in 8" :key="i" class="mt-1" />
</template>
<div v-if="items.length <= 0 && !loading" class="dtxn-empty">
<DotLottieVue
class="w-44 h-44"
autoplay
src="https://lottie.host/51d3315a-cf88-4c18-bb4d-a31511fd4325/KjSBzcMbi6.json"
/>
<p class="dtxn-empty__text">{{ emptyText }}</p>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { loading, loadingMore } from '@/utils'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BaseDatePicker from '@/components/BasicPacks/BaseDatePicker/src/BaseDatePicker.vue'
import ListAnim from '@/components/ListAnim.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
import Item from '../components/Item.vue'
import { useTransactionsPageContext } from '../composables/useTransactionsPage'
const el = ref<HTMLElement | null>(null)
const {
items,
dateFilter,
emptyText,
applyFilter,
reloadTable,
bindDesktopObserver,
} = useTransactionsPageContext()
async function clearFilter() {
dateFilter.value = undefined
await reloadTable()
}
</script>
<style lang="less" src="./styles/desktop-transactions.less"></style>
@@ -0,0 +1,286 @@
/* Premium desktop transactions page */
.dtxn {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.dtxn__container {
max-width: 1180px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.dtxn-topbar {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__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;
}
}
.dtxn-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.dtxn-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
&__btn {
padding: 8px 14px;
border: 1px solid #e5e7eb;
border-radius: 999px;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #6b7280;
font-family: inherit;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
&:hover {
border-color: #d1d5db;
color: #374151;
}
&--active {
background: @App-primary-color;
border-color: @App-primary-color;
color: #fff;
}
}
}
.dtxn-toggle {
display: inline-flex;
gap: 4px;
padding: 4px;
background: #f3f4f6;
border-radius: 10px;
&__btn {
padding: 8px 16px;
border: none;
border-radius: 8px;
background: transparent;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: #6b7280;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
&--active {
background: #fff;
color: #111827;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
}
}
.dtxn-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
&__label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: #6b7280;
}
&__select,
&__date {
min-width: 140px;
}
}
.dtxn-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.dtxn-row {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: 16px;
align-items: center;
width: 100%;
padding: 16px 18px;
border: 1px solid #f3f4f6;
border-radius: 12px;
background: #fff;
text-align: right;
cursor: pointer;
font-family: inherit;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
&:hover {
border-color: #e5e7eb;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.05);
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
flex-shrink: 0;
&--up {
background: #ecfdf3;
color: #027a48;
}
&--down {
background: #fef3f2;
color: #b42318;
}
}
&__main {
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 600;
color: #111827;
margin: 0 0 4px;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
font-size: 12px;
color: #6b7280;
}
&__amount {
font-size: 15px;
font-weight: 600;
direction: ltr;
text-align: left;
white-space: nowrap;
&--up {
color: #027a48;
}
&--down {
color: #b42318;
}
}
&__status {
flex-shrink: 0;
}
}
.dtxn-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 56px 24px;
text-align: center;
background: #fff;
border-radius: 14px;
border: 1px dashed #e5e7eb;
&__image {
width: 140px;
max-width: 40vw;
height: auto;
object-fit: contain;
}
&__text {
margin-top: 16px;
font-size: 14px;
font-weight: 600;
color: #6b7280;
}
}
.dtxn-detail {
display: flex;
flex-direction: column;
gap: 12px;
&__row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid #f3f4f6;
font-size: 13px;
&:last-child {
border-bottom: none;
}
}
&__label {
color: #6b7280;
font-weight: 500;
flex-shrink: 0;
}
&__value {
color: #111827;
font-weight: 600;
text-align: left;
}
}
@media (max-width: 900px) {
.dtxn-row {
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
&__amount,
&__status {
grid-column: 2;
}
&__amount {
justify-self: start;
}
&__status {
justify-self: end;
}
}
}
+30 -202
View File
@@ -1,213 +1,41 @@
<template>
<div >
<!-- <teleport to="body">-->
<!-- <div class="fixed top-0 right-0 left-0 bottom-0"></div>-->
<!-- </teleport>-->
<div class="absolute inset-0 bg-cover bg-center opacity-60"></div>
<div
class="bg-white w-full transaction-list py-3 px-3 font-bold drop-shadow flex justify-between z-10"
>
<div>لیست تراکنش ها</div>
<BasicIcon icon="vuesax-linear:filter" @click="showFilter = true" />
</div>
<!-- <a-spin :spinning="loading">-->
<div
ref="el"
:class="loading ? 'h-screen' : ''"
class="px-3 pt-16 pb-10 "
>
<!-- <div v-for="item in items">ss</div>-->
<list-anim v-if="!loading">
<Item v-for="item in items" :key="item.id" :item="item" />
<div ref="observer" v-show="!loading" class="w-full opacity-0 relative">
در حال بارگذاری
</div>
<TransactionSkeleton v-if="loadingMore" class="mt-1 mb-8" />
</list-anim>
<TransactionSkeleton v-for="i in 13" v-else :key="i" class="mt-1" />
<div v-if="items.length <= 0" class="flex flex-col items-center justify-center h-80vh">
<DotLottieVue
class="w-11rem h-11rem"
autoplay
src="https://lottie.host/51d3315a-cf88-4c18-bb4d-a31511fd4325/KjSBzcMbi6.json"
/>
<div class="font-bold">تراکنشی موجود نیست</div>
</div>
</div>
<!-- </a-spin>-->
<BasicActionSheet v-model:show="showFilter">
<div class="font-bold text-lg">فیلتر</div>
<div class="pb-20 mt-5">
<a-form-item class="w-full gap-2 !flex-col" label="تاریخ" name="password">
<BaseDatePicker v-model:value="dateFilter" class="w-full" size="large" />
</a-form-item>
<BasicButton class="w-full mt-5" type="primary" @click="applyFilter"
>اعمال فیلتر</BasicButton
>
<BasicButton class="w-full mt-2" @click="showFilter = false">بازگشت</BasicButton>
</div>
</BasicActionSheet>
<div class="transactions-page-root">
<ResponsiveViewPair v-if="isBoursecu">
<template #mobile>
<BoursecuTransactionsPage />
</template>
<template #desktop>
<DesktopBoursecuTransactionsView />
</template>
</ResponsiveViewPair>
<ResponsiveViewPair v-else>
<template #mobile>
<DefaultTransactionsPage />
</template>
<template #desktop>
<DesktopDefaultTransactionsView />
</template>
</ResponsiveViewPair>
</div>
</template>
<script lang="ts" setup>
import Item from './components/Item.vue'
/*import {lengthValidation, required} from "@/utils/inputRules";
import {loading} from "@/utils";
import BasicButton from "@/components/BasicPacks/BasicButton.vue";
import BasicActionSheet from "@/components/BasicPacks/BasicActionSheet.vue";
import BasicInput from "@/components/BasicPacks/BasicInput.vue";*/
import { onMounted, ref } from 'vue'
import type { TableEngineInput, TableOutput } from '@/models/table'
import { loading, loadingMore } from '@/utils'
// import {getOrders} from "@/api/order";
import { getTransactionTable } from '@/api/transaction'
import type { Transaction } from '@/models/transaction'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BaseDatePicker from '@/components/BasicPacks/BaseDatePicker/src/BaseDatePicker.vue'
import {useInfiniteScroll, useIntersectionObserver, useScroll} from '@vueuse/core'
import ListAnim from '@/components/ListAnim.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
import img from "@/assets/img/user_ly1.svg";
import ResponsiveViewPair from '@/layouts/components/ResponsiveViewPair.vue'
import { isBoursecuProject } from '@/utils/config'
import { useTransactionsPageProvider } from './composables/useTransactionsPage'
import BoursecuTransactionsPage from './BoursecuTransactionsPage.vue'
import DefaultTransactionsPage from './DefaultTransactionsPage.vue'
import DesktopBoursecuTransactionsView from './desktop/DesktopBoursecuTransactionsView.vue'
import DesktopDefaultTransactionsView from './desktop/DesktopDefaultTransactionsView.vue'
const pagination = ref<TableEngineInput>({ filters: [], limit: 7, page: 0 })
const items = ref<Transaction[]>([])
const tableOutput = ref<TableOutput<Transaction> | null>(null)
const showFilter = ref<boolean>(false)
const dateFilter = ref<string>()
const observer = ref()
// const { y } = useScroll(el)
const isBoursecu = isBoursecuProject()
async function initTable() {
try {
if (dateFilter.value) {
pagination.value.filters = []
pagination.value.filters.push({
field: 'created_at',
cr: 'and',
type: 'date',
op: 'eq',
value: dateFilter.value
})
} else {
pagination.value.filters = []
}
pagination.value.page === 0 ? (loading.value = true) : (loadingMore.value = true)
tableOutput.value = await getTransactionTable(pagination.value)
items.value.push(...tableOutput.value.rows)
// console.log(tableOutput.value)
} catch (e) {
console.log(e)
} finally {
loading.value = false
loadingMore.value = false
}
}
async function applyFilter() {
pagination.value.page = 0
items.value = []
await initTable()
showFilter.value = false
}
onMounted(() => {
initTable()
})
/*useInfiniteScroll(
el,
async () => {
// load more
// console.log('salamss')
if (
tableOutput.value &&
(pagination.value.page + 1) * pagination.value.limit < tableOutput.value.query.total
) {
// console.log('gfgf')
pagination.value.page++
await initTable()
}
},
{ distance: 12 }
)*/
useIntersectionObserver(
observer,
async ([{isIntersecting}]) => {
if ( isIntersecting &&
tableOutput.value &&
(pagination.value.page + 1) * pagination.value.limit < tableOutput.value.query.total
) {
// console.log('gfgf')
pagination.value.page++
await initTable()
}
},
{
root: null,
rootMargin: '300px',
threshold: 0.01
}
)
/*const items = [
{
isUp: true,
hasTransition: true
},
{
isUp: false,
hasTransition: false
},
{
isUp: true,
hasTransition: false
},
{
isUp: true,
hasTransition: true
},
{
isUp: false,
hasTransition: false
},
{
isUp: true,
hasTransition: false
},
{
isUp: true,
hasTransition: true
},
{
isUp: true,
hasTransition: true
},
{
isUp: false,
hasTransition: false
},
{
isUp: true,
hasTransition: true
},
]*/
useTransactionsPageProvider({ boursecu: isBoursecu })
</script>
<style scoped lang="less">
.transaction-list {
position: fixed;
right: 0;
left: 0;
@media screen and (min-width: 426px) {
width: 426px;
left: auto;
right: auto;
}
<style scoped>
.transactions-page-root {
min-height: 100%;
height: 100%;
}
</style>