Port modular features into boursecu and fix desktop login UX.
Add support chat, piggy funds, PWA banner, notification/SSE sync, and messenger legacy guards while keeping boursecu theme; also balance login panels and unblock login navigation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<div class="piggy-page">
|
||||
<div class="piggy-page__inner">
|
||||
<header class="piggy-bar">
|
||||
<button type="button" class="piggy-bar__btn" aria-label="بازگشت" @click="goBack">
|
||||
<BasicIcon icon="vuesax-linear:arrow-right-1" size="22" />
|
||||
</button>
|
||||
<h1 class="piggy-bar__title">قلکها</h1>
|
||||
<button type="button" class="piggy-bar__btn piggy-bar__btn--primary" aria-label="قلک جدید" @click="goAdd">
|
||||
<BasicIcon icon="vuesax-linear:add" size="20" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<a-spin :spinning="busy">
|
||||
<section class="piggy-summary">
|
||||
<div class="piggy-summary__card">
|
||||
<div class="piggy-summary__row">
|
||||
<div class="min-w-0">
|
||||
<p class="piggy-summary__label">جمع موجودی داخل قلکها</p>
|
||||
<p class="piggy-summary__value" dir="ltr">
|
||||
{{ formatPiggyAmount(totalGold) }}
|
||||
<span class="piggy-summary__unit">گرم</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="piggy-summary__badge">
|
||||
<BasicIcon icon="vuesax-linear:safe-home" size="22" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="piggy-summary__meta">
|
||||
<span>{{ rows.length }} قلک</span>
|
||||
<span class="piggy-summary__dot" />
|
||||
<span>{{ groups.length }} محصول</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BasicButton class="!w-full mt-3" type="primary" size="middle" block @click="goAdd">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<BasicIcon icon="vuesax-linear:add-circle" size="18" />
|
||||
<span>{{ rows.length ? 'افزودن قلک' : 'ساخت اولین قلک' }}</span>
|
||||
</div>
|
||||
</BasicButton>
|
||||
</section>
|
||||
|
||||
<section class="piggy-body">
|
||||
<div v-if="!rows.length" class="piggy-empty">
|
||||
<div class="piggy-empty__icon">
|
||||
<BasicIcon icon="vuesax-linear:safe-home" size="36" />
|
||||
</div>
|
||||
<p class="piggy-empty__title">قلکی ندارید</p>
|
||||
<p class="piggy-empty__desc">
|
||||
برای جدا کردن بخشی از موجودی هر محصول (مثلاً آبشده یا سکه) یک قلک بسازید.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="piggy-groups">
|
||||
<div v-for="group in groups" :key="group.productId" class="piggy-group">
|
||||
<div class="piggy-group__head">
|
||||
<div class="min-w-0">
|
||||
<p class="piggy-group__name">{{ group.productTitle }}</p>
|
||||
<p class="piggy-group__count">{{ group.funds.length }} قلک</p>
|
||||
</div>
|
||||
<p class="piggy-group__total" dir="ltr">
|
||||
{{ formatPiggyAmount(group.totalGold) }}
|
||||
<span>گرم</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="piggy-group__list">
|
||||
<button
|
||||
v-for="item in group.funds"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="piggy-row"
|
||||
@click="openInfo(item.id!)"
|
||||
>
|
||||
<div class="piggy-row__icon">
|
||||
<BasicIcon :icon="piggyIconName(item.title)" size="20" />
|
||||
</div>
|
||||
<div class="piggy-row__main">
|
||||
<div class="piggy-row__title">{{ item.title || 'بدون عنوان' }}</div>
|
||||
<div class="piggy-row__sub">کد {{ item.id }}</div>
|
||||
</div>
|
||||
<div class="piggy-row__amount" dir="ltr">
|
||||
<strong>{{ formatPiggyAmount(fundWalletGold(item)) }}</strong>
|
||||
<span>گرم</span>
|
||||
</div>
|
||||
<BasicIcon class="piggy-row__chevron" icon="vuesax-linear:arrow-left-1" size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import router from '@/router'
|
||||
import { useFundUserStore } from '@/store/fundStore'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { Fund } from '@/models/fund'
|
||||
import {
|
||||
formatPiggyAmount,
|
||||
fundWalletGold,
|
||||
groupFundsByProduct,
|
||||
piggyIconName,
|
||||
} from './piggyIcons'
|
||||
|
||||
const fundStore = useFundUserStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const busy = ref(false)
|
||||
|
||||
const rows = computed(() => (fundStore.myPiggyFunds?.rows ?? []) as Fund[])
|
||||
const groups = computed(() => groupFundsByProduct(rows.value))
|
||||
const totalGold = computed(() => rows.value.reduce((sum, item) => sum + fundWalletGold(item), 0))
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
function goAdd() {
|
||||
router.push({ name: 'piggy_add' })
|
||||
}
|
||||
function openInfo(id: number) {
|
||||
router.push({ name: 'piggy_info', params: { id: String(id) } })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsStore.fetchSettings()
|
||||
if (!settingsStore.isSettingTruthy('enable_product_funds')) {
|
||||
message.warning('قابلیت قلک غیرفعال است')
|
||||
router.replace({ name: 'user' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
busy.value = true
|
||||
await fundStore.getMyPiggyFunds()
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.piggy-page {
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
background: #f9fafb;
|
||||
padding: 0 12px calc(var(--app-bottom-chrome-height, 108px) + 1rem);
|
||||
}
|
||||
.piggy-page__inner {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 26rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
.piggy-bar {
|
||||
@apply sticky top-0 z-20 flex items-center justify-between bg-white px-3 py-3;
|
||||
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
.piggy-bar__title {
|
||||
@apply m-0 truncate px-2 text-base font-bold text-gray-900;
|
||||
}
|
||||
.piggy-bar__btn {
|
||||
@apply flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-none bg-gray-100 text-gray-800;
|
||||
}
|
||||
.piggy-bar__btn--primary {
|
||||
@apply bg-primary text-white;
|
||||
}
|
||||
.piggy-summary {
|
||||
@apply px-3 pt-4 sm:px-4;
|
||||
}
|
||||
.piggy-summary__card {
|
||||
@apply rounded-2xl bg-primary p-4 text-white;
|
||||
}
|
||||
.piggy-summary__row {
|
||||
@apply flex items-start justify-between gap-3;
|
||||
}
|
||||
.piggy-summary__label {
|
||||
@apply m-0 text-sm text-white/80;
|
||||
}
|
||||
.piggy-summary__value {
|
||||
@apply m-0 mt-1 flex flex-wrap items-baseline gap-2 text-2xl font-bold sm:text-3xl;
|
||||
}
|
||||
.piggy-summary__unit {
|
||||
@apply text-sm font-medium text-white/75;
|
||||
}
|
||||
.piggy-summary__badge {
|
||||
@apply flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-white/15;
|
||||
}
|
||||
.piggy-summary__meta {
|
||||
@apply mt-4 flex flex-wrap items-center gap-2 text-xs text-white/75;
|
||||
}
|
||||
.piggy-summary__dot {
|
||||
@apply h-1 w-1 rounded-full bg-white/50;
|
||||
}
|
||||
.piggy-body {
|
||||
@apply px-3 pt-4 sm:px-4;
|
||||
}
|
||||
.piggy-groups {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
.piggy-group {
|
||||
@apply overflow-hidden rounded-2xl bg-white;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
.piggy-group__head {
|
||||
@apply flex items-center justify-between gap-3 border-0 border-b border-solid border-gray-100 px-3 py-3 sm:px-4;
|
||||
}
|
||||
.piggy-group__name {
|
||||
@apply m-0 truncate text-sm font-bold text-gray-900;
|
||||
}
|
||||
.piggy-group__count {
|
||||
@apply m-0 mt-0.5 text-[11px] text-gray-500;
|
||||
}
|
||||
.piggy-group__total {
|
||||
@apply m-0 flex shrink-0 items-baseline gap-1 text-sm font-bold text-primary;
|
||||
}
|
||||
.piggy-group__total span {
|
||||
@apply text-[11px] font-medium text-gray-500;
|
||||
}
|
||||
.piggy-group__list {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
.piggy-row {
|
||||
@apply flex w-full min-w-0 items-center gap-2 border-0 border-b border-solid border-gray-50 bg-transparent px-2.5 py-3 text-right last:border-b-0 sm:gap-3 sm:px-3;
|
||||
}
|
||||
.piggy-row__icon {
|
||||
@apply flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-50 text-primary sm:h-10 sm:w-10;
|
||||
}
|
||||
.piggy-row__main {
|
||||
@apply min-w-0 flex-1;
|
||||
}
|
||||
.piggy-row__title {
|
||||
@apply truncate text-sm font-bold text-gray-900;
|
||||
}
|
||||
.piggy-row__sub {
|
||||
@apply mt-0.5 text-[11px] text-gray-400;
|
||||
}
|
||||
.piggy-row__amount {
|
||||
@apply flex shrink-0 flex-col items-end leading-tight;
|
||||
}
|
||||
.piggy-row__amount strong {
|
||||
@apply text-sm font-bold text-gray-900;
|
||||
}
|
||||
.piggy-row__amount span {
|
||||
@apply text-[11px] text-gray-500;
|
||||
}
|
||||
.piggy-row__chevron {
|
||||
@apply shrink-0 text-gray-300;
|
||||
}
|
||||
.piggy-empty {
|
||||
@apply rounded-2xl bg-white px-5 py-10 text-center;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
.piggy-empty__icon {
|
||||
@apply mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-2xl bg-gray-50 text-primary;
|
||||
}
|
||||
.piggy-empty__title {
|
||||
@apply m-0 text-base font-bold text-gray-900;
|
||||
}
|
||||
.piggy-empty__desc {
|
||||
@apply m-0 mt-2 text-sm leading-6 text-gray-500;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.piggy-page {
|
||||
padding: 16px 16px 2rem;
|
||||
}
|
||||
.piggy-page__inner {
|
||||
max-width: 28rem;
|
||||
}
|
||||
.piggy-bar {
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<div class="piggy-info">
|
||||
<div class="piggy-info__inner">
|
||||
<header class="piggy-info__hero">
|
||||
<div class="piggy-info__top">
|
||||
<button type="button" class="piggy-info__btn" aria-label="بازگشت" @click="goBack">
|
||||
<BasicIcon icon="vuesax-linear:arrow-right-1" size="22" />
|
||||
</button>
|
||||
<h1 class="piggy-info__title">{{ fund?.title || 'قلک' }}</h1>
|
||||
<button type="button" class="piggy-info__btn" aria-label="حذف" @click="onDelete">
|
||||
<BasicIcon icon="vuesax-linear:trash" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="busy">
|
||||
<div class="piggy-info__balance-block">
|
||||
<p class="piggy-info__product">{{ fund?.product?.title || '—' }}</p>
|
||||
<p class="piggy-info__label">موجودی قلک</p>
|
||||
<p class="piggy-info__value" dir="ltr">
|
||||
{{ formatPiggyAmount(potGold) }}
|
||||
<span>گرم</span>
|
||||
</p>
|
||||
<p class="piggy-info__free">
|
||||
آزاد قابل واریز:
|
||||
<strong dir="ltr">{{ formatPiggyAmount(freeGold) }}</strong>
|
||||
گرم
|
||||
</p>
|
||||
</div>
|
||||
</a-spin>
|
||||
</header>
|
||||
|
||||
<div class="piggy-info__sheet">
|
||||
<div class="piggy-info__actions">
|
||||
<BasicButton class="piggy-info__action" size="middle" @click="openTx(false)">
|
||||
<div class="piggy-info__action-inner">
|
||||
<BasicIcon icon="vuesax-linear:export" size="18" />
|
||||
<span>برداشت به آزاد</span>
|
||||
</div>
|
||||
</BasicButton>
|
||||
<BasicButton class="piggy-info__action" type="primary" size="middle" @click="openTx(true)">
|
||||
<div class="piggy-info__action-inner">
|
||||
<BasicIcon icon="vuesax-linear:import" size="18" />
|
||||
<span>واریز از آزاد</span>
|
||||
</div>
|
||||
</BasicButton>
|
||||
</div>
|
||||
|
||||
<section class="piggy-info__tx">
|
||||
<h2 class="piggy-info__tx-title">تراکنشهای این قلک</h2>
|
||||
<div v-if="!transactions.length" class="piggy-info__tx-empty">
|
||||
<BasicIcon icon="vuesax-linear:receipt-2" size="28" />
|
||||
<p class="m-0 mt-2 text-sm text-gray-500">هنوز جابهجایی ثبت نشده</p>
|
||||
</div>
|
||||
<div v-else class="piggy-info__tx-list">
|
||||
<div v-for="tx in transactions" :key="tx.id" class="piggy-info__tx-row">
|
||||
<div class="piggy-info__tx-dot" />
|
||||
<div class="min-w-0 flex-1 text-right">
|
||||
<div class="truncate text-sm font-bold text-gray-900">
|
||||
{{ tx.type_str || tx.type || 'تراکنش' }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-[11px] text-gray-400">{{ tx.created_at || '' }}</div>
|
||||
</div>
|
||||
<div class="shrink-0 font-bold text-primary" dir="ltr">
|
||||
{{ formatPiggyAmount(Number(tx.gold ?? 0)) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BasicModal v-model:visible="txVisible" :title="isDeposit ? 'واریز به قلک' : 'برداشت از قلک'">
|
||||
<div class="flex flex-col gap-3 p-1">
|
||||
<p class="m-0 text-xs leading-5 text-gray-500">
|
||||
{{
|
||||
isDeposit
|
||||
? `از موجودی آزاد «${fund?.product?.title || 'محصول'}» — حداکثر ${formatPiggyAmount(freeGold)} گرم`
|
||||
: `برگرداندن به آزاد — حداکثر ${formatPiggyAmount(potGold)} گرم`
|
||||
}}
|
||||
</p>
|
||||
<BasicInput v-model:value="txValue" type="number" placeholder="مقدار به گرم" />
|
||||
<BasicButton class="!w-full" type="primary" block :disabled="busy" @click="submitTx">
|
||||
تأیید
|
||||
</BasicButton>
|
||||
</div>
|
||||
</BasicModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import router from '@/router'
|
||||
import {
|
||||
deleteFund,
|
||||
fundDeposit,
|
||||
fundWithdraw,
|
||||
getFundById,
|
||||
getFundTransactionTable,
|
||||
} from '@/api/fund'
|
||||
import { useFundUserStore } from '@/store/fundStore'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { useWalletAssets } from '@/composables/useWalletAssets'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import type { Fund } from '@/models/fund'
|
||||
import type { Transaction } from '@/models/transaction'
|
||||
import { formatPiggyAmount } from './piggyIcons'
|
||||
|
||||
const fundStore = useFundUserStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const { assetsSummary, loadWalletAssets } = useWalletAssets()
|
||||
|
||||
const busy = ref(false)
|
||||
const fund = ref<Fund>()
|
||||
const transactions = ref<Transaction[]>([])
|
||||
const txVisible = ref(false)
|
||||
const isDeposit = ref(true)
|
||||
const txValue = ref<string | number | undefined>()
|
||||
|
||||
const fundId = computed(() => parseInt(String(router.currentRoute.value.params.id), 10))
|
||||
|
||||
const potGold = computed(() => {
|
||||
const wallets = Array.isArray(fund.value?.wallet)
|
||||
? fund.value!.wallet
|
||||
: fund.value?.wallet
|
||||
? [fund.value.wallet]
|
||||
: []
|
||||
return wallets.reduce((acc, w) => acc + Number((w as { gold?: number })?.gold ?? 0), 0)
|
||||
})
|
||||
|
||||
const freeGold = computed(() => {
|
||||
const productId = Number(fund.value?.product_id ?? 0)
|
||||
if (!productId) return 0
|
||||
const cards = assetsSummary.value?.inventory_cards ?? []
|
||||
const card = cards.find((c: any) => Number(c.product_id ?? c.id) === productId)
|
||||
if (card && card.quantity != null) return Number(card.quantity)
|
||||
return Number(assetsSummary.value?.metal_totals?.[0]?.weight ?? 0)
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
function openTx(deposit: boolean) {
|
||||
isDeposit.value = deposit
|
||||
txValue.value = undefined
|
||||
txVisible.value = true
|
||||
}
|
||||
|
||||
async function submitTx() {
|
||||
const value = Number(txValue.value)
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
message.error('مقدار نامعتبر است')
|
||||
return
|
||||
}
|
||||
try {
|
||||
busy.value = true
|
||||
const payload = { fund_id: fundId.value, value }
|
||||
if (isDeposit.value) await fundDeposit(payload)
|
||||
else await fundWithdraw(payload)
|
||||
message.success('انجام شد')
|
||||
txVisible.value = false
|
||||
await reload()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (potGold.value > 0) {
|
||||
message.error('ابتدا موجودی قلک را صفر کنید')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title: 'حذف قلک',
|
||||
content: 'آیا از حذف این قلک مطمئن هستید؟',
|
||||
okText: 'حذف',
|
||||
cancelText: 'انصراف',
|
||||
async onOk() {
|
||||
await deleteFund(fundId.value)
|
||||
await fundStore.getMyPiggyFunds()
|
||||
message.success('حذف شد')
|
||||
router.replace({ name: 'piggy' })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
fund.value = await getFundById(fundId.value)
|
||||
const walletId = Array.isArray(fund.value?.wallet)
|
||||
? fund.value?.wallet?.[0]?.id
|
||||
: (fund.value?.wallet as { id?: number } | undefined)?.id
|
||||
if (walletId) {
|
||||
const table = await getFundTransactionTable({ wallet_id: walletId } as any)
|
||||
transactions.value = (table?.rows ?? []) as Transaction[]
|
||||
} else {
|
||||
transactions.value = []
|
||||
}
|
||||
await loadWalletAssets()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsStore.fetchSettings()
|
||||
if (!settingsStore.isSettingTruthy('enable_product_funds')) {
|
||||
message.warning('قابلیت قلک غیرفعال است')
|
||||
router.replace({ name: 'user' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
busy.value = true
|
||||
await reload()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.piggy-info {
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
background: #f9fafb;
|
||||
padding: 0 12px calc(var(--app-bottom-chrome-height, 108px) + 1rem);
|
||||
}
|
||||
.piggy-info__inner {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 26rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
.piggy-info__hero {
|
||||
@apply bg-primary px-3 pb-8 pt-3 text-white;
|
||||
}
|
||||
.piggy-info__top {
|
||||
@apply flex items-center justify-between gap-2;
|
||||
}
|
||||
.piggy-info__btn {
|
||||
@apply flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-none bg-white/15 text-white;
|
||||
}
|
||||
.piggy-info__title {
|
||||
@apply m-0 min-w-0 flex-1 truncate text-center text-base font-bold text-white;
|
||||
}
|
||||
.piggy-info__balance-block {
|
||||
@apply mt-6 px-2 text-center;
|
||||
}
|
||||
.piggy-info__product {
|
||||
@apply m-0 inline-flex max-w-full truncate rounded-full bg-white/15 px-3 py-1 text-xs text-white/90;
|
||||
}
|
||||
.piggy-info__label {
|
||||
@apply m-0 mt-4 text-sm text-white/75;
|
||||
}
|
||||
.piggy-info__value {
|
||||
@apply m-0 mt-1 flex flex-wrap items-baseline justify-center gap-2 text-3xl font-bold sm:text-4xl;
|
||||
}
|
||||
.piggy-info__value span {
|
||||
@apply text-sm font-medium text-white/75;
|
||||
}
|
||||
.piggy-info__free {
|
||||
@apply m-0 mt-3 text-xs leading-5 text-white/70;
|
||||
}
|
||||
.piggy-info__free strong {
|
||||
@apply text-white;
|
||||
}
|
||||
.piggy-info__sheet {
|
||||
@apply relative z-10 -mt-4 rounded-t-3xl bg-gray-50 px-3 pt-5 sm:px-4;
|
||||
}
|
||||
.piggy-info__actions {
|
||||
@apply flex flex-col gap-2 sm:flex-row;
|
||||
}
|
||||
.piggy-info__action {
|
||||
@apply !w-full sm:!flex-1;
|
||||
}
|
||||
.piggy-info__action-inner {
|
||||
@apply flex items-center justify-center gap-2 text-sm;
|
||||
}
|
||||
.piggy-info__tx {
|
||||
@apply mt-5;
|
||||
}
|
||||
.piggy-info__tx-title {
|
||||
@apply m-0 mb-3 text-base font-bold text-gray-900;
|
||||
}
|
||||
.piggy-info__tx-empty {
|
||||
@apply flex flex-col items-center rounded-2xl bg-white px-4 py-10 text-gray-400;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
.piggy-info__tx-list {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
.piggy-info__tx-row {
|
||||
@apply flex min-w-0 items-center gap-3 rounded-2xl bg-white p-3;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
.piggy-info__tx-dot {
|
||||
@apply h-2.5 w-2.5 shrink-0 rounded-full bg-primary;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.piggy-info {
|
||||
padding: 16px 16px 2rem;
|
||||
}
|
||||
.piggy-info__inner {
|
||||
max-width: 26rem;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.piggy-info__hero {
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
.piggy-info__actions {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,356 @@
|
||||
<template>
|
||||
<div class="piggy-create">
|
||||
<div class="piggy-create__inner">
|
||||
<header class="piggy-bar">
|
||||
<button type="button" class="piggy-bar__btn" aria-label="بازگشت" @click="goBack">
|
||||
<BasicIcon icon="vuesax-linear:arrow-right-1" size="22" />
|
||||
</button>
|
||||
<h1 class="piggy-bar__title">{{ editId ? 'ویرایش قلک' : 'ساخت قلک' }}</h1>
|
||||
<span class="w-9 shrink-0" />
|
||||
</header>
|
||||
|
||||
<a-spin :spinning="busy">
|
||||
<div class="piggy-create__body">
|
||||
<section class="piggy-create__step">
|
||||
<div class="piggy-create__step-head">
|
||||
<span class="piggy-create__step-num">۱</span>
|
||||
<div class="min-w-0">
|
||||
<h2 class="piggy-create__step-title">محصول</h2>
|
||||
<p class="piggy-create__step-desc">این قلک فقط برای همین محصول پر میشود</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="editId" class="piggy-create__locked">
|
||||
<BasicIcon icon="vuesax-linear:box-1" size="18" />
|
||||
<span class="truncate">{{ lockedProductTitle || 'محصول ثابت' }}</span>
|
||||
</div>
|
||||
<div v-else class="piggy-create__products">
|
||||
<button
|
||||
v-for="p in products"
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="piggy-create__product"
|
||||
:class="{ 'piggy-create__product--on': productId === p.id }"
|
||||
@click="productId = p.id"
|
||||
>
|
||||
<BasicIcon icon="vuesax-linear:box-1" size="18" />
|
||||
<span class="min-w-0 truncate">{{ p.title || `محصول ${p.id}` }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="piggy-create__step">
|
||||
<div class="piggy-create__step-head">
|
||||
<span class="piggy-create__step-num">۲</span>
|
||||
<div class="min-w-0">
|
||||
<h2 class="piggy-create__step-title">نام قلک</h2>
|
||||
<p class="piggy-create__step-desc">مثلاً پسانداز، هدیه یا ازدواج</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="piggy-create__title-row">
|
||||
<div class="piggy-create__title-icon">
|
||||
<BasicIcon :icon="piggyIconName(title)" size="22" />
|
||||
</div>
|
||||
<BasicInput v-model:value="title" class="!min-w-0 !flex-1" placeholder="عنوان قلک" maxlength="25" />
|
||||
</div>
|
||||
|
||||
<div class="piggy-create__presets">
|
||||
<button
|
||||
v-for="preset in presets"
|
||||
:key="preset"
|
||||
type="button"
|
||||
class="piggy-create__chip"
|
||||
:class="{ 'piggy-create__chip--on': title === preset }"
|
||||
@click="title = preset"
|
||||
>
|
||||
{{ preset }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<BasicButton class="!w-full" type="primary" size="middle" block :disabled="busy" @click="submit">
|
||||
{{ editId ? 'ذخیره' : 'ساخت قلک' }}
|
||||
</BasicButton>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import router from '@/router'
|
||||
import { createFund, editFund, getFundById } from '@/api/fund'
|
||||
import { getProducts } from '@/api/products'
|
||||
import { useFundUserStore } from '@/store/fundStore'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { Product } from '@/models/products'
|
||||
import { PIGGY_PRESETS, piggyIconName } from './piggyIcons'
|
||||
|
||||
const fundStore = useFundUserStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const busy = ref(false)
|
||||
const title = ref('پس انداز')
|
||||
const productId = ref<number | undefined>()
|
||||
const products = ref<Product[]>([])
|
||||
const lockedProductTitle = ref('')
|
||||
const presets = [...PIGGY_PRESETS]
|
||||
|
||||
const editId = computed(() => {
|
||||
const raw = router.currentRoute.value.query.id
|
||||
const n = parseInt(String(raw ?? ''), 10)
|
||||
return Number.isFinite(n) && n > 0 ? n : 0
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!title.value.trim()) {
|
||||
message.error('عنوان قلک را وارد کنید')
|
||||
return
|
||||
}
|
||||
if (!editId.value && !productId.value) {
|
||||
message.error('محصول قلک را انتخاب کنید')
|
||||
return
|
||||
}
|
||||
try {
|
||||
busy.value = true
|
||||
if (editId.value) {
|
||||
await editFund(editId.value, { title: title.value.trim() })
|
||||
} else {
|
||||
await createFund({ title: title.value.trim(), product_id: productId.value })
|
||||
}
|
||||
await fundStore.getMyPiggyFunds()
|
||||
message.success(editId.value ? 'قلک ویرایش شد' : 'قلک ساخته شد')
|
||||
router.replace({ name: 'piggy' })
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsStore.fetchSettings()
|
||||
if (!settingsStore.isSettingTruthy('enable_product_funds')) {
|
||||
message.warning('قابلیت قلک غیرفعال است')
|
||||
router.replace({ name: 'user' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
busy.value = true
|
||||
const list = await getProducts()
|
||||
products.value = Array.isArray(list) ? list : ((list as any)?.rows ?? (list as any)?.data ?? [])
|
||||
if (editId.value) {
|
||||
const fund = await getFundById(editId.value)
|
||||
title.value = fund?.title ?? ''
|
||||
productId.value = fund?.product_id
|
||||
lockedProductTitle.value = fund?.product?.title || ''
|
||||
} else if (products.value.length === 1) {
|
||||
productId.value = products.value[0]?.id
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.piggy-create {
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
background: #f9fafb;
|
||||
padding: 0 12px calc(var(--app-bottom-chrome-height, 108px) + 1rem);
|
||||
}
|
||||
|
||||
.piggy-create__inner {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 26rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.piggy-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.piggy-bar__title {
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.piggy-bar__btn {
|
||||
display: flex;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: #f3f4f6;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.piggy-create__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.piggy-create__step {
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
padding: 14px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.piggy-create__step-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.piggy-create__step-num {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background: var(--App-primary, #0a1b37);
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.piggy-create__step-title {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.piggy-create__step-desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.piggy-create__products {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.piggy-create__product {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #f9fafb;
|
||||
padding: 10px 14px;
|
||||
text-align: right;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.piggy-create__product--on {
|
||||
background: var(--App-primary, #0a1b37);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.piggy-create__locked {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 12px;
|
||||
background: #f9fafb;
|
||||
padding: 12px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.piggy-create__title-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.piggy-create__title-icon {
|
||||
display: flex;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: #f9fafb;
|
||||
color: var(--App-primary, #0a1b37);
|
||||
}
|
||||
|
||||
.piggy-create__presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.piggy-create__chip {
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: #f3f4f6;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.75rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.piggy-create__chip--on {
|
||||
background: var(--App-primary, #0a1b37);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.piggy-create {
|
||||
padding: 16px 16px 2rem;
|
||||
}
|
||||
|
||||
.piggy-create__inner {
|
||||
max-width: 26rem;
|
||||
}
|
||||
|
||||
.piggy-bar {
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Fund } from '@/models/fund'
|
||||
|
||||
export const PIGGY_PRESETS = [
|
||||
'پس انداز',
|
||||
'هدیه',
|
||||
'ازدواج',
|
||||
'تعمیرات',
|
||||
'گردش',
|
||||
'خرید لوازم منزل',
|
||||
] as const
|
||||
|
||||
/** آیکون vuesax برای عنوان قلک — بدون تصویر سهبعدی گیجکننده */
|
||||
export function piggyIconName(title?: string | null): string {
|
||||
switch ((title ?? '').trim()) {
|
||||
case 'خرید لوازم منزل':
|
||||
return 'vuesax-linear:house-2'
|
||||
case 'هدیه':
|
||||
return 'vuesax-linear:gift'
|
||||
case 'گردش':
|
||||
return 'vuesax-linear:airplane'
|
||||
case 'ازدواج':
|
||||
return 'vuesax-linear:heart'
|
||||
case 'تعمیرات':
|
||||
return 'vuesax-linear:setting-2'
|
||||
case 'پس انداز':
|
||||
default:
|
||||
return 'vuesax-linear:safe-home'
|
||||
}
|
||||
}
|
||||
|
||||
export function fundWalletGold(item?: Fund | null): number {
|
||||
if (!item) return 0
|
||||
const wallets = Array.isArray(item.wallet) ? item.wallet : item.wallet ? [item.wallet] : []
|
||||
return wallets.reduce((acc, w) => acc + Number((w as { gold?: number })?.gold ?? 0), 0)
|
||||
}
|
||||
|
||||
export function fundProductLabel(item?: Fund | null): string {
|
||||
if (!item) return '—'
|
||||
return item.product?.title || (item as { product_title?: string }).product_title || '—'
|
||||
}
|
||||
|
||||
export function fundProductId(item?: Fund | null): number {
|
||||
return Number(item?.product_id ?? item?.product?.id ?? 0)
|
||||
}
|
||||
|
||||
export function formatPiggyAmount(value: number): string {
|
||||
const n = Number(value) || 0
|
||||
if (Math.abs(n) < 0.0005) return '۰'
|
||||
return n.toLocaleString('fa-IR', { maximumFractionDigits: 3 })
|
||||
}
|
||||
|
||||
export type PiggyProductGroup = {
|
||||
productId: number
|
||||
productTitle: string
|
||||
totalGold: number
|
||||
funds: Fund[]
|
||||
}
|
||||
|
||||
export function groupFundsByProduct(funds: Fund[]): PiggyProductGroup[] {
|
||||
const map = new Map<number, PiggyProductGroup>()
|
||||
for (const fund of funds) {
|
||||
const productId = fundProductId(fund) || -1
|
||||
const productTitle = fundProductLabel(fund)
|
||||
let group = map.get(productId)
|
||||
if (!group) {
|
||||
group = { productId, productTitle, totalGold: 0, funds: [] }
|
||||
map.set(productId, group)
|
||||
}
|
||||
group.funds.push(fund)
|
||||
group.totalGold += fundWalletGold(fund)
|
||||
}
|
||||
return [...map.values()].sort((a, b) => a.productTitle.localeCompare(b.productTitle, 'fa'))
|
||||
}
|
||||
Reference in New Issue
Block a user