feat(ui): desktop piggy/notifications views, support chat fixes, gift card list

This commit is contained in:
2026-09-09 16:57:41 +03:30
parent 5920866a95
commit 0a3a5d7c82
50 changed files with 3242 additions and 1094 deletions
+177
View File
@@ -0,0 +1,177 @@
---
description: دستورالعمل اجباری AI — پروژه ابشوده/خانگی (توکن کم، پچ محدود)
alwaysApply: true
---
# دستورالعمل اجباری برای هوش مصنوعی — پروژه ابشوده / خانگی
این فایل را کامل به AI بده (Project Rules / Custom Instructions / System prompt).
هدف: **توکن کمتر، یادگیری پایدار، حل سریع اصل کار، بدون تغییر الکی.**
---
## ۱) با هر پرامپت کاربر — همین ترتیب
1. اصل قضیه را **یک خط** مشخص کن (یک باگ / یک UI / یک فیچر).
2. مسیر را از **نقشه و یادداشت** بگیر — کل ریپو را سرچ نکن.
3. فقط **۱ تا ۳ فایل** مربوط را باز/پچ کن.
4. تمام کن؛ جواب کوتاه بده.
5. یک خط **یادداشت بخش** بنویس (پایین ببین) تا دفعهٔ بعد فراموش نشود.
```
پرامپت → اصل کار → نقشه/یادداشت → Grep فقط همان پوشه → پچ محدود → یادداشت → تمام
```
### ممنوع (سوخت توکن)
- دوباره پیدا کردن فایلی که در نقشه/یادداشت هست
- explore / گشت سراسری برای «X کجاست؟»
- خواندن کل فایل بلند وقتی چند ده خط کافی است
- refactor یا «درست کردن» فایل‌های هم‌جوار بدون درخواست
- کپی پچ به سیستم/اپ دیگر «برای کامل بودن»
---
## ۲) محدودهٔ تغییر — حساس
- **فقط** همان چیزی که کاربر گفته؛ جای بی‌ربط را دست نزن.
- مبهم بود (هدف، اپ، برنچ، سیستم حسابداری) → **بپرس**؛ حدس نزن.
- روی **برنچ فعلی** بمان؛ checkout/merge به برنچ دیگر بدون اجازه ممنوع.
- هر زیرپروژه git جدا دارد؛ فقط همان ریپوی مربوط را عوض کن.
| کار | فقط این ریپو |
|-----|----------------|
| ادمین | `khaneghi_admin` |
| API / SQL | `khaneghi_server` |
| اپ کاربر | `khaneghi_user` + تم + `ghatreh_user` (هر سه اگر رفتاری است) |
| سوپرادمین | `controlpanel_server` / `controlpanel_user` |
| بورس مس | فقط با درخواست صریح |
---
## ۳) حسابداری — قانون سخت
سیستم‌ها **جدا** هستند: زرین (`zarin`) / ته‌حساب (`tahesab`) / پرگار (`pargar`).
1. اگر گفت حسابداری/سند/سینک مشتری و **نام نبرد** → اول بپرس کدام؟
2. تا جواب ندادی کد نزن و فایل سیستم دیگر را باز نکن.
3. پچ یک سیستم را به بقیه کپی نکن مگر در همان پیام صریح بگوید.
---
## ۴) یادگیری قوی — یادداشت بخش‌ها
هر کار تمام‌شده را در دفتر پروژه ثبت کن (اگر در این ریپو هست:
`.cursor/skills/abshode-domain-lookup/learned-notes.md`):
```
- YYYY-MM-DD | موضوع | مسیر فایل(ها) | نکتهٔ ۱ خطی
```
قبل از هر Grep سراسری: **یادداشت‌ها را بخوان.**
اگر مسیر آنجا بود، دوباره سرچ نکن.
---
## ۵) نقشهٔ سریع مسیرها
| موضوع | کجا |
|-------|-----|
| تنظیمات ادمین | `khaneghi_admin/src/pages/menuPages/configuration/` |
| فلگ منو از settings | `khaneghi_admin/src/utils/config.ts` |
| مشتریان | `khaneghi_admin/src/pages/menuPages/customers/` |
| همگام ته‌حساب | `…/accounting/components/TahesabCustomerSync.vue`, `TahesabWalletBulkSync.vue` |
| کد حساب همکار | `…/colleagueOrders/components/ColleagueAccountingAccounts.vue` |
| API ته‌حساب (فرانت) | `khaneghi_admin/src/api/tahesab.ts` |
| تنظیمات سرور / overlay سوپرادمین | `SuperAdminProgramSettingsOverlay`, `SettingsController` |
| deploy settings | `khaneghi_server/database/sql/deploy_settings_const.sql` — **value موجود را UPDATE نکن** |
| قیمت ویژه مشتری | `customer_special_pricing` + منوی `customers/special-price` |
نقشهٔ کامل‌تر (اگر موجود): `.cursor/skills/abshode-domain-lookup/project-map.md`
---
## ۶) فرانت — زیاد از Ant + UnoCSS + lodash استفاده کن
اولویت:
1. **BasicPacks** پروژه (دکمه/آیکون/اینپوت/مدال) اگر هست
2. **Ant Design Vue** (`a-button`, `a-input`, `a-table`, `a-spin`, `a-form`, …)
3. **UnoCSS** برای layout و فاصله (`flex`, `grid`, `gap-*`, `w-full`, `min-w-0`, `sm:`)
4. **lodash** برای داده (`get`, `debounce`, `cloneDeep`, `isEmpty`, …) — helper تکراری ننویس
5. CSS/`<style>` فقط وقتی utility کافی نیست (توکن تم، انیمیشن خاص)
```text
❌ button خام + CSS بزرگ برای layout
✅ Ant/Basic + کلاس‌های Uno روی تمپلیت + lodash برای منطق داده
```
- `BasicButton` را با `:deep` برای عوض کردن layout داخلی خراب نکن.
- رنگ: توکن تم؛ از `bg-primary/10` روی CSS variable پرهیز کن (اغلب می‌شکند).
---
## ۷) UI حرفه‌ای — عینک گرافیک‌کار
- هر بلوک جدید: **عنوان + یک خط راهنما** (این کار چیست / دکمه کی زده شود).
- **باکس‌بندی خوب است؛ فضای مرده بد است** — کارت عریض با محتوا فقط در یک گوشه ممنوع.
- یا `sm:grid-cols-2` / `flex` تا پر شود، یا عرض باکس را محدود کن.
- موبایل: اکشن **زیر** توضیح (`flex-col`).
- دسکتاپ: کنار یا ستون دوم پر (`sm:flex-row` / `sm:grid`).
- **نسبت سرچ و دکمه:**
- موبایل: سرچ `w-full`؛ بعد دکمه‌ها با عرض مساوی (`grid` / `1fr`).
- اینپوت باریک کنار دکمهٔ پهن در یک ردیف wrap = زشت → ممنوع.
- بنر گرادیان جیغ و دکمهٔ `block` تمام‌عرض الکی ممنوع مگر اکشن اصلی صفحه باشد.
- فشرده باش: `p-2.5` / `gap-2`؛ عدد و padding غول‌پیکر نگذار.
---
## ۸) مهندسی بک‌اند / فرانت — بدون باگ الکی
- یک مسئولیت در هر کامپوننت / متد / سرویس؛ خدا-فایل نساز.
- UI نازک؛ منطق سنگین در composable یا Service.
- Controller نازک → Service؛ SQL و business را قاطی نکن.
- خطا، خالی، timeout، دوبار کلیک / race را طراحی کن (`running` قفل، early return).
- الگوی همان پوشه را کپی کن؛ اختراع ساختار جدید نکن مگر لازم باشد.
- نام‌گذاری گویا؛ قرارداد API فرانت و بک یکی باشد.
---
## ۹) سوپرادمین / تنظیمات دسترسی
- کلیدهای دسترسی مشتری (`customer_special_pricing`, `has_page_*`, …) مال سوپرادمین‌اند؛ در پنل خانگی معمولاً فقط‌خواندنی.
- منوی وابسته به فلگ باید از `get_settings` / overlay زنده بیاید؛ کش کهنه برای این کلیدها مطلوب نیست.
- دکمهٔ بروزرسانی تنظیمات پیشرفته باید همگام زنده از سوپرادمین را هم بزند اگر فلگ منو می‌خواهد عوض شود.
---
## ۱۰) چک قبل از اتمام
- [ ] diff فقط به درخواست مربوط است؟
- [ ] سیستم حسابداری / اپ / برنچ اشتباه درگیر نشده؟
- [ ] اگر مبهم بود سؤال شد؟
- [ ] یادداشت یک‌خطی نوشته شد؟
- [ ] UI: راهنما + بدون فضای مرده + نسبت سرچ/دکمه درست؟
- [ ] Ant / Uno / lodash به‌جای reinvent؟
- [ ] جواب کوتاه است؛ توضیح اضافه نداری؟
---
## ۱۱) نمونهٔ رفتار درست / غلط
```text
❌ «همگام‌سازی مشتری خراب» → هر سه زرین/ته‌حساب/پرگار را عوض کند
✅ بپرسد کدام؟ بعد فقط همان
❌ UI یک کارت → کل صفحه accounting را redesign کند
✅ فقط همان کارت / همان فایل
❌ هر بار از صفر Grep مونوریپو
✅ learned-notes + project-map → مستقیم فایل
❌ CSS سفارشی ۵۰ خط برای flex ساده
✅ class="flex flex-col gap-2 sm:grid sm:grid-cols-2"
```
---
**پایان.** این سند را به‌روز نگه دار؛ نکات جدید حساس را همین‌جا یا در `learned-notes.md` اضافه کن.
@@ -0,0 +1,26 @@
# یادداشت‌های یادگیری — abshode-domain-lookup
فرمت هر خط:
- YYYY-MM-DD | موضوع | مسیر فایل(ها) | نکتهٔ ۱ خطی
- 2026-09-08 | لیست کارت هدیه کاربر | `views/base/giftCard/` + `api/giftCard.ts` + سرور `GiftCardController::mine` / `GET customer/giftcard/mine` | زیر فرم: available (اختصاصی + عمومی use_once=0 تک‌کد) و history؛ تخفیف فقط نمایش/سبد نه giftcard/use
- 2026-09-08 | تب کارت هدیه/تخفیف در تراکنش‌ها | `transactions/config/boursecuTabs.ts` + `useTransactionsPageData.ts` + `Item.vue` | fetchMode giftcard/discount از historyِ mine؛ واحد تخفیف٪/طلا در Item
- 2026-09-08 | گیر کردن در لاگین بعد رفرش | `app/authBoot.ts` + `setupRouter.ts` | finger unlock در sessionStorage؛ بعد ۳دقیقه fingerprintChecked ریست؛ لاگین بدون userInfo نرود home
- 2026-09-08 | حذف دکمه قلک از خانه | `BoursecuQuickActionsSection.vue` + دسکتاپ quick actions + `useHomePage.ts` | دیگر با enable_product_funds به گرید اضافه نمی‌شود
- 2026-09-08 | پشتیبانی در تنظیمات → چت | `user/composables/useUserPage.ts` | دکمه پشتیبانی مستقیم `router.push({ name: 'support' })` نه منوی پیام‌رسان
- 2026-09-08 | چت دسکتاپ کنار منو | `views/support/index.vue` + `desktop-layout.less` | از ۷۶۸px شل دسکتاپ؛ کارت چت inset با radius ۲۸ کنار سایدبار
- 2026-09-08 | چت+سایدبار اجباری | `support/index.vue` + router `hideMenu` | سایدبار داخل صفحه چت وقتی isDesktop؛ شل بیرونی سایدبار ندارد تا دوبل نشود
- 2026-09-08 | دسکتاپ از ۶۰۰px | `layouts/constants.ts` + shell/frame + `support/index.vue` | آستانه ۷۶۸→۶۰۰؛ سایدبار چت همیشه در DOM و با @media نمایش
- 2026-09-09 | آستانه دسکتاپ ۷۶۸ | `layouts/constants.ts` + shell/frame + `support/index.vue` | از عرض >۷۶۷ (min-width: 768) سایدبار دسکتاپ
- 2026-09-09 | لوگوی هدر پشتیبانی | `SupportChatHeader.vue` | به‌جای config.avatar از `getProjectSplashLogo()` (لوگوی محلی اپ)
- 2026-09-09 | هدر دسکتاپ اعلان‌ها | `DesktopAppNotificationsView.vue` | قبلاً topbar نداشت؛ `DesktopPageTopbar` مثل کیف‌پول/تراکنش‌ها
- 2026-09-09 | جزئیات اعلان دسکتاپ | `appNotifications/index.vue` | دسکتاپ `BasicModal`؛ موبایل `BasicActionSheet`
- 2026-09-09 | بازگشت چت پشتیبانی | `SupportChatHeader.vue` | تایم‌اوت ۸۰ms با beforeEach تداخل داشت؛ `push(back)` یا `replace(user)`
- 2026-09-09 | دکمه ارسال چت | `SupportChatComposer.vue` + `support/index.vue` | BasicButton فقط `disabledB`؛ متن فقط بعد موفقیت پاک شود + toast خطا
- 2026-09-09 | عرض چت موبایل/دسکتاپ | `support/index.vue` + `App.vue` | زیر ۷۶۸ fullbleed؛ دسکتاپ با کلاس isDesktop سایدبار؛ بدون float credit روی پشتیبانی
- 2026-09-09 | چت زیر آستانه | `support/index.vue` | max-width 767 منو مخفی + w-full؛ از ۷۶۸ flex width:0 برای پر کردن باقی‌مانده
- 2026-09-09 | دکمه ریپلای چت | `SupportChatMessages.vue` | native button + همیشه pointer-events؛ فوکوس composer بعد از reply
- 2026-09-09 | قلک دسکتاپ | `piggy/` desktop+mobile + `usePiggyPage` | مثل fund/wallet: topbar + خلاصه + لیست گروه‌ها
- 2026-09-09 | زیرصفحات قلک دسکتاپ | `piggy/new` + `piggy/info` + create/info composable | ساخت/ویرایش و جزئیات با DesktopPageTopbar و گرید دوستونه
- 2026-09-09 | عرض قلک دسکتاپ | `desktop-piggy-create.less` | کانتینر ساخت هم max-width ۱۱۸۰ مثل لیست/جزئیات/کیف‌پول
- 2026-09-09 | نام منوی کاربر | `navigation.ts` + `MobileBottomNav.vue` | «تنظیمات» → «امکانات بیشتر» (دسکتاپ+موبایل)
+173
View File
@@ -0,0 +1,173 @@
# دستورالعمل اجباری برای هوش مصنوعی — پروژه ابشوده / خانگی
این فایل را کامل به AI بده (Project Rules / Custom Instructions / System prompt).
هدف: **توکن کمتر، یادگیری پایدار، حل سریع اصل کار، بدون تغییر الکی.**
---
## ۱) با هر پرامپت کاربر — همین ترتیب
1. اصل قضیه را **یک خط** مشخص کن (یک باگ / یک UI / یک فیچر).
2. مسیر را از **نقشه و یادداشت** بگیر — کل ریپو را سرچ نکن.
3. فقط **۱ تا ۳ فایل** مربوط را باز/پچ کن.
4. تمام کن؛ جواب کوتاه بده.
5. یک خط **یادداشت بخش** بنویس (پایین ببین) تا دفعهٔ بعد فراموش نشود.
```
پرامپت → اصل کار → نقشه/یادداشت → Grep فقط همان پوشه → پچ محدود → یادداشت → تمام
```
### ممنوع (سوخت توکن)
- دوباره پیدا کردن فایلی که در نقشه/یادداشت هست
- explore / گشت سراسری برای «X کجاست؟»
- خواندن کل فایل بلند وقتی چند ده خط کافی است
- refactor یا «درست کردن» فایل‌های هم‌جوار بدون درخواست
- کپی پچ به سیستم/اپ دیگر «برای کامل بودن»
---
## ۲) محدودهٔ تغییر — حساس
- **فقط** همان چیزی که کاربر گفته؛ جای بی‌ربط را دست نزن.
- مبهم بود (هدف، اپ، برنچ، سیستم حسابداری) → **بپرس**؛ حدس نزن.
- روی **برنچ فعلی** بمان؛ checkout/merge به برنچ دیگر بدون اجازه ممنوع.
- هر زیرپروژه git جدا دارد؛ فقط همان ریپوی مربوط را عوض کن.
| کار | فقط این ریپو |
|-----|----------------|
| ادمین | `khaneghi_admin` |
| API / SQL | `khaneghi_server` |
| اپ کاربر | `khaneghi_user` + تم + `ghatreh_user` (هر سه اگر رفتاری است) |
| سوپرادمین | `controlpanel_server` / `controlpanel_user` |
| بورس مس | فقط با درخواست صریح |
---
## ۳) حسابداری — قانون سخت
سیستم‌ها **جدا** هستند: زرین (`zarin`) / ته‌حساب (`tahesab`) / پرگار (`pargar`).
1. اگر گفت حسابداری/سند/سینک مشتری و **نام نبرد** → اول بپرس کدام؟
2. تا جواب ندادی کد نزن و فایل سیستم دیگر را باز نکن.
3. پچ یک سیستم را به بقیه کپی نکن مگر در همان پیام صریح بگوید.
---
## ۴) یادگیری قوی — یادداشت بخش‌ها
هر کار تمام‌شده را در دفتر پروژه ثبت کن (اگر در این ریپو هست:
`.cursor/skills/abshode-domain-lookup/learned-notes.md`):
```
- YYYY-MM-DD | موضوع | مسیر فایل(ها) | نکتهٔ ۱ خطی
```
قبل از هر Grep سراسری: **یادداشت‌ها را بخوان.**
اگر مسیر آنجا بود، دوباره سرچ نکن.
---
## ۵) نقشهٔ سریع مسیرها
| موضوع | کجا |
|-------|-----|
| تنظیمات ادمین | `khaneghi_admin/src/pages/menuPages/configuration/` |
| فلگ منو از settings | `khaneghi_admin/src/utils/config.ts` |
| مشتریان | `khaneghi_admin/src/pages/menuPages/customers/` |
| همگام ته‌حساب | `…/accounting/components/TahesabCustomerSync.vue`, `TahesabWalletBulkSync.vue` |
| کد حساب همکار | `…/colleagueOrders/components/ColleagueAccountingAccounts.vue` |
| API ته‌حساب (فرانت) | `khaneghi_admin/src/api/tahesab.ts` |
| تنظیمات سرور / overlay سوپرادمین | `SuperAdminProgramSettingsOverlay`, `SettingsController` |
| deploy settings | `khaneghi_server/database/sql/deploy_settings_const.sql` — **value موجود را UPDATE نکن** |
| قیمت ویژه مشتری | `customer_special_pricing` + منوی `customers/special-price` |
نقشهٔ کامل‌تر (اگر موجود): `.cursor/skills/abshode-domain-lookup/project-map.md`
---
## ۶) فرانت — زیاد از Ant + UnoCSS + lodash استفاده کن
اولویت:
1. **BasicPacks** پروژه (دکمه/آیکون/اینپوت/مدال) اگر هست
2. **Ant Design Vue** (`a-button`, `a-input`, `a-table`, `a-spin`, `a-form`, …)
3. **UnoCSS** برای layout و فاصله (`flex`, `grid`, `gap-*`, `w-full`, `min-w-0`, `sm:`)
4. **lodash** برای داده (`get`, `debounce`, `cloneDeep`, `isEmpty`, …) — helper تکراری ننویس
5. CSS/`<style>` فقط وقتی utility کافی نیست (توکن تم، انیمیشن خاص)
```text
❌ button خام + CSS بزرگ برای layout
✅ Ant/Basic + کلاس‌های Uno روی تمپلیت + lodash برای منطق داده
```
- `BasicButton` را با `:deep` برای عوض کردن layout داخلی خراب نکن.
- رنگ: توکن تم؛ از `bg-primary/10` روی CSS variable پرهیز کن (اغلب می‌شکند).
---
## ۷) UI حرفه‌ای — عینک گرافیک‌کار
- هر بلوک جدید: **عنوان + یک خط راهنما** (این کار چیست / دکمه کی زده شود).
- **باکس‌بندی خوب است؛ فضای مرده بد است** — کارت عریض با محتوا فقط در یک گوشه ممنوع.
- یا `sm:grid-cols-2` / `flex` تا پر شود، یا عرض باکس را محدود کن.
- موبایل: اکشن **زیر** توضیح (`flex-col`).
- دسکتاپ: کنار یا ستون دوم پر (`sm:flex-row` / `sm:grid`).
- **نسبت سرچ و دکمه:**
- موبایل: سرچ `w-full`؛ بعد دکمه‌ها با عرض مساوی (`grid` / `1fr`).
- اینپوت باریک کنار دکمهٔ پهن در یک ردیف wrap = زشت → ممنوع.
- بنر گرادیان جیغ و دکمهٔ `block` تمام‌عرض الکی ممنوع مگر اکشن اصلی صفحه باشد.
- فشرده باش: `p-2.5` / `gap-2`؛ عدد و padding غول‌پیکر نگذار.
---
## ۸) مهندسی بک‌اند / فرانت — بدون باگ الکی
- یک مسئولیت در هر کامپوننت / متد / سرویس؛ خدا-فایل نساز.
- UI نازک؛ منطق سنگین در composable یا Service.
- Controller نازک → Service؛ SQL و business را قاطی نکن.
- خطا، خالی، timeout، دوبار کلیک / race را طراحی کن (`running` قفل، early return).
- الگوی همان پوشه را کپی کن؛ اختراع ساختار جدید نکن مگر لازم باشد.
- نام‌گذاری گویا؛ قرارداد API فرانت و بک یکی باشد.
---
## ۹) سوپرادمین / تنظیمات دسترسی
- کلیدهای دسترسی مشتری (`customer_special_pricing`, `has_page_*`, …) مال سوپرادمین‌اند؛ در پنل خانگی معمولاً فقط‌خواندنی.
- منوی وابسته به فلگ باید از `get_settings` / overlay زنده بیاید؛ کش کهنه برای این کلیدها مطلوب نیست.
- دکمهٔ بروزرسانی تنظیمات پیشرفته باید همگام زنده از سوپرادمین را هم بزند اگر فلگ منو می‌خواهد عوض شود.
---
## ۱۰) چک قبل از اتمام
- [ ] diff فقط به درخواست مربوط است؟
- [ ] سیستم حسابداری / اپ / برنچ اشتباه درگیر نشده؟
- [ ] اگر مبهم بود سؤال شد؟
- [ ] یادداشت یک‌خطی نوشته شد؟
- [ ] UI: راهنما + بدون فضای مرده + نسبت سرچ/دکمه درست؟
- [ ] Ant / Uno / lodash به‌جای reinvent؟
- [ ] جواب کوتاه است؛ توضیح اضافه نداری؟
---
## ۱۱) نمونهٔ رفتار درست / غلط
```text
❌ «همگام‌سازی مشتری خراب» → هر سه زرین/ته‌حساب/پرگار را عوض کند
✅ بپرسد کدام؟ بعد فقط همان
❌ UI یک کارت → کل صفحه accounting را redesign کند
✅ فقط همان کارت / همان فایل
❌ هر بار از صفر Grep مونوریپو
✅ learned-notes + project-map → مستقیم فایل
❌ CSS سفارشی ۵۰ خط برای flex ساده
✅ class="flex flex-col gap-2 sm:grid sm:grid-cols-2"
```
---
**پایان.** این سند را به‌روز نگه دار؛ نکات جدید حساس را همین‌جا یا در `learned-notes.md` اضافه کن.
+3 -1
View File
@@ -155,6 +155,8 @@ const isDesktop = useDesktopBreakpoint()
const route = useRoute() const route = useRoute()
const showDesktopFloatingCredit = computed(() => { const showDesktopFloatingCredit = computed(() => {
if (!isDesktop.value) return false if (!isDesktop.value) return false
// پشتیبانی خودش سایدبار دارد؛ float روی دکمه ارسال می‌افتد
if (route.name === 'support') return false
return route.name === 'login' || !!route.meta.hideMenu return route.name === 'login' || !!route.meta.hideMenu
}) })
const isLoginRoute = computed(() => route.name === 'login') const isLoginRoute = computed(() => route.name === 'login')
@@ -591,7 +593,7 @@ html.project-boursecu {
} }
@media screen and (min-width: 426px) and (max-width: 767.98px) { @media screen and (min-width: 426px) and (max-width: 767.98px) {
.desktopConfig { html:not(.app-layout--desktop) .desktopConfig {
width: 426px; width: 426px;
position: relative !important; position: relative !important;
} }
+24 -2
View File
@@ -1,5 +1,27 @@
import useAxios from "@/utils/useAxios"; import useAxios from "@/utils/useAxios";
export interface CustomerGiftCardItem {
id: number
giftcard_id: number
code: string
name: string | null
description: string | null
amount: number | string | null
unit: number
kind: 'gift' | 'discount'
scope: 'personal' | 'public'
sts: number
expiry_date: string | null
used_at: string | null
}
//customer export interface CustomerGiftCardMineResponse {
export const registerCodeGiftCard = async (code: string) => await useAxios.post(`/customer/giftcard/use`, {code:code}) available: CustomerGiftCardItem[]
history: CustomerGiftCardItem[]
}
export const registerCodeGiftCard = async (code: string) =>
await useAxios.post(`/customer/giftcard/use`, { code })
export const fetchMyGiftCards = async () =>
await useAxios.get<CustomerGiftCardMineResponse>(`/customer/giftcard/mine`)
+33 -1
View File
@@ -1,17 +1,49 @@
const FINGER_SESSION_KEY = 'boursecu_finger_session_ok'
let fingerPosition = false let fingerPosition = false
function readPersistedFingerSession(): boolean {
try {
return sessionStorage.getItem(FINGER_SESSION_KEY) === '1'
} catch {
return false
}
}
function writePersistedFingerSession(value: boolean) {
try {
if (value) {
sessionStorage.setItem(FINGER_SESSION_KEY, '1')
} else {
sessionStorage.removeItem(FINGER_SESSION_KEY)
}
} catch {
// ignore quota / private mode
}
}
export function setTruth() { export function setTruth() {
fingerPosition = true fingerPosition = true
writePersistedFingerSession(true)
} }
export function setEnableAuthenticate() { export function setEnableAuthenticate() {
fingerPosition = false fingerPosition = false
writePersistedFingerSession(false)
} }
export function getFingerPosition() { export function getFingerPosition() {
return fingerPosition if (fingerPosition) {
return true
}
if (readPersistedFingerSession()) {
fingerPosition = true
return true
}
return false
} }
export function setFingerPosition(value: boolean) { export function setFingerPosition(value: boolean) {
fingerPosition = value fingerPosition = value
writePersistedFingerSession(value)
} }
+2
View File
@@ -160,6 +160,8 @@ export function setupRouterGuards() {
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
if (hasFingerPrint) { if (hasFingerPrint) {
setFingerPosition(false) setFingerPosition(false)
// بعد از انقضا دوباره WebAuthn بخواهد؛ مستقیم لاگین اجباری نکند
fingerprintChecked = false
} }
}, 180000) }, 180000)
} }
+1 -1
View File
@@ -50,7 +50,7 @@ const BOURSECU_PRIMARY_NAV: AppNavItem[] = [
{ {
id: 'user', id: 'user',
routeName: 'user', routeName: 'user',
label: 'تنظیمات', label: 'امکانات بیشتر',
icon: 'vuesax-linear:setting-2', icon: 'vuesax-linear:setting-2',
iconSrc: getBoursecuActionIconSrc('settings'), iconSrc: getBoursecuActionIconSrc('settings'),
}, },
+5
View File
@@ -27,6 +27,11 @@
background: @prefixCls-background-color; background: @prefixCls-background-color;
} }
.desktop-app-layout__content.support-page {
overflow: hidden;
min-height: 0;
}
.desktop-sidebar { .desktop-sidebar {
flex-shrink: 0; flex-shrink: 0;
width: var(--app-desktop-sidebar-width, 260px); width: var(--app-desktop-sidebar-width, 260px);
+1 -1
View File
@@ -73,7 +73,7 @@
class="tab__icon-image" class="tab__icon-image"
/> />
</div> </div>
<div class="text-xs mt-1 font-bold">تنظیمات</div> <div class="text-xs mt-1 font-bold">امکانات بیشتر</div>
</div> </div>
</div> </div>
</div> </div>
+1
View File
@@ -2,6 +2,7 @@
* Mobile shell width rules. * Mobile shell width rules.
* Below 426px: full viewport (phones). * Below 426px: full viewport (phones).
* 426px–767px: centered 426px frame (tablet-ish mobile). * 426px–767px: centered 426px frame (tablet-ish mobile).
* از ۷۶۸px شل دسکتاپ (سایدبار) فعال می‌شود.
*/ */
html:not(.app-layout--desktop) { html:not(.app-layout--desktop) {
+1 -1
View File
@@ -70,7 +70,7 @@ const router = createRouter({
{ {
path: 'support', path: 'support',
name: 'support', name: 'support',
meta: { hideBottomNav: true }, meta: { hideMenu: true, hideBottomNav: true },
component: () => import('@/views/support/index.vue') component: () => import('@/views/support/index.vue')
}, },
{ {
@@ -1,5 +1,12 @@
<template> <template>
<div class="dapp-notif"> <div class="dapp-notif">
<div class="dapp-notif__container">
<DesktopPageTopbar
eyebrow="اعلان‌ها"
title="پیام‌های مدیریت"
subtitle="اعلان‌های ارسال‌شده از طرف مدیریت را اینجا ببینید"
/>
<BrowserNotificationPermissionBanner class="mb-4 max-w-3xl w-full" /> <BrowserNotificationPermissionBanner class="mb-4 max-w-3xl w-full" />
<div v-if="loading && !items.length" class="app-page-content-loading"> <div v-if="loading && !items.length" class="app-page-content-loading">
@@ -26,10 +33,12 @@
</div> </div>
</a-spin> </a-spin>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue' import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
import AppNotificationCard from '../components/AppNotificationCard.vue' import AppNotificationCard from '../components/AppNotificationCard.vue'
import BrowserNotificationPermissionBanner from '../components/BrowserNotificationPermissionBanner.vue' import BrowserNotificationPermissionBanner from '../components/BrowserNotificationPermissionBanner.vue'
import { useAppNotificationsPageContext } from '../composables/useAppNotificationsPage' import { useAppNotificationsPageContext } from '../composables/useAppNotificationsPage'
@@ -39,7 +48,10 @@ const { loading, items, openItem } = useAppNotificationsPageContext()
<style scoped> <style scoped>
.dapp-notif { .dapp-notif {
@apply p-6 min-h-full; @apply min-h-full;
}
.dapp-notif__container {
@apply mx-auto w-full max-w-5xl p-6;
} }
.dapp-notif__empty { .dapp-notif__empty {
@apply flex flex-col items-center gap-2 text-gray-500; @apply flex flex-col items-center gap-2 text-gray-500;
+28
View File
@@ -4,6 +4,7 @@
<MobileAppNotificationsView v-else /> <MobileAppNotificationsView v-else />
<BasicActionSheet <BasicActionSheet
v-if="!isDesktop"
v-model:show="detailVisible" v-model:show="detailVisible"
:title="selectedItem?.title || 'جزئیات اعلان'" :title="selectedItem?.title || 'جزئیات اعلان'"
width="32rem" width="32rem"
@@ -27,11 +28,38 @@
<p class="app-notif-detail__body">{{ selectedItem.body }}</p> <p class="app-notif-detail__body">{{ selectedItem.body }}</p>
</div> </div>
</BasicActionSheet> </BasicActionSheet>
<BasicModal
v-else
v-model:visible="detailVisible"
:title="selectedItem?.title || 'جزئیات اعلان'"
width="520px"
custom-footer
destroy-on-close
@close="closeDetail"
>
<div v-if="selectedItem" class="app-notif-detail">
<div class="app-notif-detail__meta">
<span
class="app-notif-detail__badge"
:class="`app-notif-detail__badge--${selectedItem.severity}`"
>
{{ severityLabel(selectedItem.severity) }}
</span>
</div>
<p class="app-notif-detail__body">{{ selectedItem.body }}</p>
</div>
<template #footer>
<BasicButton class="w-full" @click="closeDetail">بستن</BasicButton>
</template>
</BasicModal>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue' import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout' import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useAppNotificationsPage } from './composables/useAppNotificationsPage' import { useAppNotificationsPage } from './composables/useAppNotificationsPage'
import { severityLabel } from './composables/notificationDisplay' import { severityLabel } from './composables/notificationDisplay'
@@ -76,7 +76,7 @@
پاداش معرفی پاداش معرفی
</div> </div>
<div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'"> <div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'">
• در صفحه اصلی روی "تنظیمات" کلیک کرده و وارد حساب کاربری خود شوید. • در صفحه اصلی روی "امکانات بیشتر" کلیک کرده و وارد حساب کاربری خود شوید.
</div> </div>
<div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'"> <div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'">
• سپس گزینه «اشتراک‌گذاری» یا «کپی لینک» را انتخاب کرده و لینک دعوت را برای دوستانتان ارسال کنید.<br /> • سپس گزینه «اشتراک‌گذاری» یا «کپی لینک» را انتخاب کرده و لینک دعوت را برای دوستانتان ارسال کنید.<br />
@@ -0,0 +1,118 @@
<template>
<div class="flex flex-col gap-3 mt-4" :class="variant === 'desktop' ? 'dgift-lists' : ''">
<a-spin :spinning="listLoading">
<section class="border border-gray-200 rounded-xl p-3 bg-white">
<h3 class="text-sm font-bold m-0 mb-1">کدهای قابل استفاده</h3>
<p class="text-xs text-gray-500 m-0 mb-2.5">
کارت‌های اختصاصی شما و کدهای عمومی فعال. برای ثبت سریع روی کد بزنید.
</p>
<div v-if="available.length" class="flex flex-col gap-2">
<button
v-for="item in available"
:key="`a-${item.id}`"
type="button"
class="w-full text-right border border-gray-100 rounded-lg p-2.5 bg-gray-50 hover:bg-gray-100 transition-colors"
@click="onAvailableClick(item)"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="m-0 text-sm font-bold truncate">{{ item.name || kindLabel(item) }}</p>
<p class="m-0 mt-1 text-xs font-mono tracking-wide">{{ item.code }}</p>
<p class="m-0 mt-1 text-xs text-gray-500">{{ amountLabel(item) }}</p>
</div>
<div class="flex flex-col items-end gap-1 shrink-0">
<span class="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">{{ scopeLabel(item) }}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700">{{ kindLabel(item) }}</span>
</div>
</div>
<p v-if="item.expiry_date" class="m-0 mt-1.5 text-xs text-gray-400">
انقضا: {{ formatDate(item.expiry_date) }}
</p>
</button>
</div>
<p v-else class="text-xs text-gray-400 m-0 py-2">کد قابل استفاده‌ای ندارید.</p>
</section>
<section class="border border-gray-200 rounded-xl p-3 bg-white mt-3">
<h3 class="text-sm font-bold m-0 mb-1">تاریخچه</h3>
<p class="text-xs text-gray-500 m-0 mb-2.5">
کدهایی که قبلاً ثبت یا مصرف کرده‌اید.
</p>
<div v-if="history.length" class="flex flex-col gap-2">
<div
v-for="item in history"
:key="`h-${item.id}`"
class="w-full border border-gray-100 rounded-lg p-2.5 bg-gray-50"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="m-0 text-sm font-bold truncate">{{ item.name || kindLabel(item) }}</p>
<p class="m-0 mt-1 text-xs font-mono tracking-wide">{{ item.code }}</p>
<p class="m-0 mt-1 text-xs text-gray-500">{{ amountLabel(item) }}</p>
</div>
<div class="flex flex-col items-end gap-1 shrink-0">
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-200 text-gray-700">مصرف‌شده</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700">{{ kindLabel(item) }}</span>
</div>
</div>
<p v-if="item.used_at" class="m-0 mt-1.5 text-xs text-gray-400">
تاریخ مصرف: {{ formatDate(item.used_at) }}
</p>
</div>
</div>
<p v-else class="text-xs text-gray-400 m-0 py-2">هنوز کدی مصرف نکرده‌اید.</p>
</section>
</a-spin>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import type { CustomerGiftCardItem } from '@/api/giftCard'
import { useGiftCardPageContext } from '../composables/useGiftCardPage'
withDefaults(
defineProps<{
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
const { available, history, listLoading, fillCode } = useGiftCardPageContext()
function onAvailableClick(item: CustomerGiftCardItem) {
fillCode(item.code)
if (item.kind === 'discount') {
message.info('کد تخفیف در فیلد پر شد؛ هنگام خرید در سبد استفاده کنید')
}
}
function kindLabel(item: CustomerGiftCardItem) {
return item.kind === 'discount' ? 'تخفیف' : 'کارت هدیه'
}
function scopeLabel(item: CustomerGiftCardItem) {
return item.scope === 'personal' ? 'اختصاصی' : 'عمومی'
}
function amountLabel(item: CustomerGiftCardItem) {
const amount = Number(item.amount)
if (!Number.isFinite(amount)) return '—'
if (item.unit === 3) {
const percent = amount <= 1 ? amount * 100 : amount
return `${percent.toLocaleString('fa-IR')}٪ تخفیف`
}
if (item.unit === 2) {
return `${amount.toLocaleString('fa-IR')} طلا`
}
return `${amount.toLocaleString('fa-IR')} ریال`
}
function formatDate(value: string) {
try {
return new Date(value).toLocaleDateString('fa-IR')
} catch {
return value
}
}
</script>
@@ -1,6 +1,10 @@
import { computed, inject, provide, ref, type ComputedRef, type InjectionKey, type Ref } from 'vue' import { computed, inject, onMounted, provide, ref, type ComputedRef, type InjectionKey, type Ref } from 'vue'
import { loading } from '@/utils' import { loading } from '@/utils'
import { registerCodeGiftCard } from '@/api/giftCard' import {
fetchMyGiftCards,
registerCodeGiftCard,
type CustomerGiftCardItem,
} from '@/api/giftCard'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
export interface GiftCardPageContext { export interface GiftCardPageContext {
@@ -8,6 +12,11 @@ export interface GiftCardPageContext {
disableButton: ComputedRef<boolean> disableButton: ComputedRef<boolean>
loading: typeof loading loading: typeof loading
registerGiftCard: () => Promise<void> registerGiftCard: () => Promise<void>
available: Ref<CustomerGiftCardItem[]>
history: Ref<CustomerGiftCardItem[]>
listLoading: Ref<boolean>
loadMyGiftCards: () => Promise<void>
fillCode: (code: string) => void
} }
export const GIFT_CARD_PAGE_CONTEXT_KEY: InjectionKey<GiftCardPageContext> = export const GIFT_CARD_PAGE_CONTEXT_KEY: InjectionKey<GiftCardPageContext> =
@@ -17,6 +26,9 @@ export function useGiftCardPage() {
const form = ref<{ code: string }>({ const form = ref<{ code: string }>({
code: '', code: '',
}) })
const available = ref<CustomerGiftCardItem[]>([])
const history = ref<CustomerGiftCardItem[]>([])
const listLoading = ref(false)
const disableButton = computed(() => { const disableButton = computed(() => {
if (!loading.value) { if (!loading.value) {
@@ -25,12 +37,42 @@ export function useGiftCardPage() {
return true return true
}) })
async function loadMyGiftCards() {
try {
listLoading.value = true
const res = await fetchMyGiftCards()
available.value = res?.available ?? []
history.value = res?.history ?? []
} catch {
available.value = []
history.value = []
} finally {
listLoading.value = false
}
}
function fillCode(code: string) {
form.value.code = code
}
async function registerGiftCard() { async function registerGiftCard() {
const code = form.value.code.trim()
if (!code) return
const matched =
available.value.find((item) => item.code === code) ||
history.value.find((item) => item.code === code)
if (matched?.kind === 'discount') {
message.info('کد تخفیف را هنگام ثبت سفارش در سبد خرید وارد کنید')
return
}
try { try {
loading.value = true loading.value = true
await registerCodeGiftCard(form.value.code) await registerCodeGiftCard(code)
message.success('کد کارت هدیه با موفقیت ثبت شد') message.success('کد کارت هدیه با موفقیت ثبت شد')
form.value.code = '' form.value.code = ''
await loadMyGiftCards()
} catch (e: any) { } catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در ثبت کد کارت هدیه') message.error(e?.response?.data?.message || 'خطا در ثبت کد کارت هدیه')
} finally { } finally {
@@ -38,11 +80,20 @@ export function useGiftCardPage() {
} }
} }
onMounted(() => {
void loadMyGiftCards()
})
const context: GiftCardPageContext = { const context: GiftCardPageContext = {
form, form,
disableButton, disableButton,
loading, loading,
registerGiftCard, registerGiftCard,
available,
history,
listLoading,
loadMyGiftCards,
fillCode,
} }
provide(GIFT_CARD_PAGE_CONTEXT_KEY, context) provide(GIFT_CARD_PAGE_CONTEXT_KEY, context)
@@ -24,6 +24,7 @@
</div> </div>
</div> </div>
<GiftCardForm variant="desktop" /> <GiftCardForm variant="desktop" />
<GiftCardLists variant="desktop" />
</section> </section>
<aside class="dgift-surface dgift-guide"> <aside class="dgift-surface dgift-guide">
@@ -47,4 +48,5 @@
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue' import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router' import router from '@/router'
import GiftCardForm from '../components/GiftCardForm.vue' import GiftCardForm from '../components/GiftCardForm.vue'
import GiftCardLists from '../components/GiftCardLists.vue'
</script> </script>
@@ -2,12 +2,13 @@
<div> <div>
<MobilePageHeader title="کارت هدیه" show-back /> <MobilePageHeader title="کارت هدیه" show-back />
<div class="px-5 mpage-content pb-2"> <div class="px-5 mpage-content pb-2">
<div class="border-gray-200 border-1 rounded-xl p-5 trans h-40 relative"> <div class="border-gray-200 border-1 rounded-xl p-5 trans relative">
<bg_main /> <bg_main />
<div class="z-100 relative"> <div class="z-100 relative">
<GiftCardForm variant="mobile" /> <GiftCardForm variant="mobile" />
</div> </div>
</div> </div>
<GiftCardLists variant="mobile" />
</div> </div>
</div> </div>
</template> </template>
@@ -16,4 +17,5 @@
import Bg_main from '@/components/bg_main.vue' import Bg_main from '@/components/bg_main.vue'
import MobilePageHeader from '@/layouts/components/MobilePageHeader.vue' import MobilePageHeader from '@/layouts/components/MobilePageHeader.vue'
import GiftCardForm from '../components/GiftCardForm.vue' import GiftCardForm from '../components/GiftCardForm.vue'
import GiftCardLists from '../components/GiftCardLists.vue'
</script> </script>
@@ -111,15 +111,6 @@ export function useHomePage() {
}, },
] ]
if (settingsStore.isSettingTruthy('enable_product_funds')) {
actions.push({
name: 'piggy',
icon: 'vuesax-linear:safe-home',
label: 'قلک',
handler: () => router.push({ name: 'piggy' }),
})
}
return actions return actions
}) })
@@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import router from '@/router' import router from '@/router'
import { useSettingsStore } from '@/store/settingsStore'
import { getBoursecuActionIconSrc } from '@/utils/config'
import HomeQuickActionBox from '../../components/HomeQuickActionBox.vue' import HomeQuickActionBox from '../../components/HomeQuickActionBox.vue'
import DesktopHomeCard from './DesktopHomeCard.vue' import DesktopHomeCard from './DesktopHomeCard.vue'
import { boursecuQuickActions } from '../../layouts/configs/boursecuQuickActions' import { boursecuQuickActions } from '../../layouts/configs/boursecuQuickActions'
@@ -17,23 +15,7 @@ const props = withDefaults(
}, },
) )
const settingsStore = useSettingsStore() const resolvedActions = computed(() => props.actions ?? [...boursecuQuickActions])
const resolvedActions = computed(() => {
if (props.actions) return props.actions
const list = [...boursecuQuickActions]
if (settingsStore.isSettingTruthy('enable_product_funds')) {
list.push({
id: 'piggy',
label: 'قلک',
icon: 'vuesax-linear:safe-home',
iconSrc: getBoursecuActionIconSrc('piggy'),
route: { name: 'piggy' },
})
}
return list
})
function handleClick(action: HomeQuickActionItem) { function handleClick(action: HomeQuickActionItem) {
if (action.handler) { if (action.handler) {
@@ -3,8 +3,6 @@ import { computed } from 'vue'
import router from '@/router' import router from '@/router'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue' import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import DesktopHomeSection from './DesktopHomeSection.vue' import DesktopHomeSection from './DesktopHomeSection.vue'
import { useSettingsStore } from '@/store/settingsStore'
import { getBoursecuActionIconSrc } from '@/utils/config'
import { boursecuQuickActions } from '../../layouts/configs/boursecuQuickActions' import { boursecuQuickActions } from '../../layouts/configs/boursecuQuickActions'
import type { HomeQuickActionItem } from '../../layouts/types' import type { HomeQuickActionItem } from '../../layouts/types'
@@ -19,23 +17,7 @@ const props = withDefaults(
}, },
) )
const settingsStore = useSettingsStore() const resolvedActions = computed(() => props.actions ?? [...boursecuQuickActions])
const resolvedActions = computed(() => {
if (props.actions) return props.actions
const list = [...boursecuQuickActions]
if (settingsStore.isSettingTruthy('enable_product_funds')) {
list.push({
id: 'piggy',
label: 'قلک',
icon: 'vuesax-linear:safe-home',
iconSrc: getBoursecuActionIconSrc('piggy'),
route: { name: 'piggy' },
})
}
return list
})
function navigate(action: HomeQuickActionItem) { function navigate(action: HomeQuickActionItem) {
if (action.handler) { if (action.handler) {
@@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import router from '@/router' import router from '@/router'
import { useSettingsStore } from '@/store/settingsStore'
import { getBoursecuActionIconSrc } from '@/utils/config'
import HomeQuickActionBox from '../../components/HomeQuickActionBox.vue' import HomeQuickActionBox from '../../components/HomeQuickActionBox.vue'
import { boursecuQuickActions } from '../configs/boursecuQuickActions' import { boursecuQuickActions } from '../configs/boursecuQuickActions'
import type { HomeQuickActionItem } from '../types' import type { HomeQuickActionItem } from '../types'
@@ -16,23 +14,7 @@ const props = withDefaults(
} }
) )
const settingsStore = useSettingsStore() const resolvedActions = computed(() => props.actions ?? [...boursecuQuickActions])
const resolvedActions = computed(() => {
if (props.actions) return props.actions
const list = [...boursecuQuickActions]
if (settingsStore.isSettingTruthy('enable_product_funds')) {
list.push({
id: 'piggy',
label: 'قلک',
icon: 'vuesax-linear:safe-home',
iconSrc: getBoursecuActionIconSrc('piggy'),
route: { name: 'piggy' },
})
}
return list
})
function handleClick(action: HomeQuickActionItem) { function handleClick(action: HomeQuickActionItem) {
if (action.handler) { if (action.handler) {
@@ -0,0 +1,54 @@
<template>
<BasicModal
:visible="visible"
:title="isDeposit ? 'واریز به قلک' : 'برداشت از قلک'"
width="440px"
custom-footer
destroy-on-close
@update:visible="emit('update:visible', $event)"
@close="emit('update:visible', false)"
>
<div class="flex flex-col gap-3 p-1">
<p class="m-0 text-xs leading-5 text-gray-500">
{{
isDeposit
? `از موجودی آزاد «${fundTitle}» — حداکثر ${maxLabel} گرم`
: `برگرداندن به آزاد — حداکثر ${maxLabel} گرم`
}}
</p>
<BasicInput v-model:value="localValue" type="number" placeholder="مقدار به گرم" />
</div>
<template #footer>
<BasicButton class="w-full" type="primary" :disabled="busy" @click="emit('submit')">
تأیید
</BasicButton>
</template>
</BasicModal>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
const props = defineProps<{
visible: boolean
isDeposit: boolean
busy?: boolean
fundTitle: string
maxLabel: string
value?: string | number
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'update:value', value: string | number | undefined): void
(e: 'submit'): void
}>()
const localValue = computed({
get: () => props.value,
set: (v) => emit('update:value', v),
})
</script>
@@ -0,0 +1,100 @@
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import router from '@/router'
import { createFund, editFund, getFundById } from '@/api/fund'
import { getProducts } from '@/api/products'
import type { Product } from '@/models/products'
import { useFundUserStore } from '@/store/fundStore'
import { useSettingsStore } from '@/store/settingsStore'
import { PIGGY_PRESETS, piggyIconName } from '../piggyIcons'
export function usePiggyCreatePage() {
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
})
const pageTitle = computed(() => (editId.value ? 'ویرایش قلک' : 'ساخت قلک'))
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 ? 'قلک ویرایش شد' : 'قلک ساخته شد')
void 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('قابلیت قلک غیرفعال است')
void 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
}
})
return {
busy,
title,
productId,
products,
lockedProductTitle,
presets,
editId,
pageTitle,
piggyIconName,
goBack,
submit,
}
}
@@ -0,0 +1,152 @@
import { computed, onMounted, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import router from '@/router'
import {
deleteFund,
fundDeposit,
fundWithdraw,
getFundById,
getFundTransactionTable,
} from '@/api/fund'
import type { Fund } from '@/models/fund'
import type { Transaction } from '@/models/transaction'
import { useWalletAssets } from '@/composables/useWalletAssets'
import { useFundUserStore } from '@/store/fundStore'
import { useSettingsStore } from '@/store/settingsStore'
import { formatPiggyAmount } from '../piggyIcons'
export function usePiggyInfoPage() {
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('حذف شد')
void router.replace({ name: 'piggy' })
},
})
}
function goEdit() {
void router.push({ name: 'piggy_add', query: { id: String(fundId.value) } })
}
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('قابلیت قلک غیرفعال است')
void router.replace({ name: 'user' })
return
}
try {
busy.value = true
await reload()
} catch (e) {
console.log(e)
} finally {
busy.value = false
}
})
return {
busy,
fund,
transactions,
txVisible,
isDeposit,
txValue,
fundId,
potGold,
freeGold,
formatPiggyAmount,
goBack,
openTx,
submitTx,
onDelete,
goEdit,
}
}
@@ -0,0 +1,64 @@
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import router from '@/router'
import type { Fund } from '@/models/fund'
import { useFundUserStore } from '@/store/fundStore'
import { useSettingsStore } from '@/store/settingsStore'
import {
formatPiggyAmount,
fundWalletGold,
groupFundsByProduct,
piggyIconName,
} from '../piggyIcons'
export function usePiggyPage() {
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() {
void router.push({ name: 'piggy_add' })
}
function openInfo(id: number) {
void router.push({ name: 'piggy_info', params: { id: String(id) } })
}
onMounted(async () => {
await settingsStore.fetchSettings()
if (!settingsStore.isSettingTruthy('enable_product_funds')) {
message.warning('قابلیت قلک غیرفعال است')
void router.replace({ name: 'user' })
return
}
try {
busy.value = true
await fundStore.getMyPiggyFunds()
} finally {
busy.value = false
}
})
return {
busy,
rows,
groups,
totalGold,
formatPiggyAmount,
fundWalletGold,
piggyIconName,
goBack,
goAdd,
openInfo,
}
}
@@ -0,0 +1,117 @@
<template>
<div class="dpiggy-create">
<div class="dpiggy-create__container">
<DesktopPageTopbar
eyebrow="دارایی"
:title="pageTitle"
:subtitle="
editId
? 'عنوان قلک را ویرایش کنید'
: 'محصول و نام قلک را مشخص کنید'
"
show-back
/>
<a-spin :spinning="busy">
<div class="dpiggy-create__layout">
<section class="dpiggy-create__card">
<div class="dpiggy-create__step-head">
<span class="dpiggy-create__step-num">۱</span>
<div class="min-w-0">
<h2 class="dpiggy-create__step-title">محصول</h2>
<p class="dpiggy-create__step-desc">این قلک فقط برای همین محصول پر می‌شود</p>
</div>
</div>
<div v-if="editId" class="dpiggy-create__locked">
<BasicIcon icon="vuesax-linear:box-1" size="18" />
<span class="truncate">{{ lockedProductTitle || 'محصول ثابت' }}</span>
</div>
<div v-else class="dpiggy-create__products">
<button
v-for="p in products"
:key="p.id"
type="button"
class="dpiggy-create__product"
:class="{ 'dpiggy-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="dpiggy-create__card">
<div class="dpiggy-create__step-head">
<span class="dpiggy-create__step-num">۲</span>
<div class="min-w-0">
<h2 class="dpiggy-create__step-title">نام قلک</h2>
<p class="dpiggy-create__step-desc">مثلاً پس‌انداز، هدیه یا ازدواج</p>
</div>
</div>
<div class="dpiggy-create__title-row">
<div class="dpiggy-create__title-icon">
<BasicIcon :icon="piggyIconName(title)" size="22" />
</div>
<BasicInput
v-model:value="title"
class="!min-w-0 !flex-1"
placeholder="عنوان قلک"
maxlength="25"
size="large"
/>
</div>
<div class="dpiggy-create__presets">
<button
v-for="preset in presets"
:key="preset"
type="button"
class="dpiggy-create__chip"
:class="{ 'dpiggy-create__chip--on': title === preset }"
@click="title = preset"
>
{{ preset }}
</button>
</div>
</section>
<BasicButton
class="dpiggy-create__submit"
type="primary"
size="large"
:disabled="busy"
@click="submit"
>
{{ editId ? 'ذخیره' : 'ساخت قلک' }}
</BasicButton>
</div>
</a-spin>
</div>
</div>
</template>
<script setup lang="ts">
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
import { usePiggyCreatePage } from '../composables/usePiggyCreatePage'
const {
busy,
title,
productId,
products,
lockedProductTitle,
presets,
editId,
pageTitle,
piggyIconName,
submit,
} = usePiggyCreatePage()
</script>
<style lang="less" src="./styles/desktop-piggy-create.less"></style>
@@ -0,0 +1,117 @@
<template>
<div class="dpiggy-info">
<div class="dpiggy-info__container">
<DesktopPageTopbar
eyebrow="دارایی"
:title="fund?.title || 'جزئیات قلک'"
:subtitle="fund?.product?.title || 'موجودی و جابه‌جایی قلک'"
show-back
>
<template #trailing>
<div class="flex items-center gap-2">
<BasicButton @click="goEdit">ویرایش</BasicButton>
<BasicButton danger @click="onDelete">حذف</BasicButton>
</div>
</template>
</DesktopPageTopbar>
<a-spin :spinning="busy">
<div class="dpiggy-info__layout">
<aside class="dpiggy-info__side">
<section class="dpiggy-info__hero">
<p class="dpiggy-info__product">{{ fund?.product?.title || '—' }}</p>
<p class="dpiggy-info__label">موجودی قلک</p>
<p class="dpiggy-info__value" dir="ltr">
{{ formatPiggyAmount(potGold) }}
<span>گرم</span>
</p>
<p class="dpiggy-info__free">
آزاد قابل واریز:
<strong dir="ltr">{{ formatPiggyAmount(freeGold) }}</strong>
گرم
</p>
</section>
<div class="dpiggy-info__actions">
<BasicButton class="dpiggy-info__action" @click="openTx(false)">
<div class="dpiggy-info__action-inner">
<BasicIcon icon="vuesax-linear:export" size="18" />
<span>برداشت به آزاد</span>
</div>
</BasicButton>
<BasicButton class="dpiggy-info__action" type="primary" @click="openTx(true)">
<div class="dpiggy-info__action-inner">
<BasicIcon icon="vuesax-linear:import" size="18" />
<span>واریز از آزاد</span>
</div>
</BasicButton>
</div>
</aside>
<section class="dpiggy-info__tx">
<div class="dpiggy-info__tx-header">
<h2 class="dpiggy-info__tx-title">تراکنش‌های این قلک</h2>
<span class="dpiggy-info__tx-count">{{ transactions.length }} مورد</span>
</div>
<div v-if="!transactions.length" class="dpiggy-info__tx-empty">
<BasicIcon icon="vuesax-linear:receipt-2" size="32" />
<p class="m-0 text-sm">هنوز جابه‌جایی ثبت نشده</p>
</div>
<div v-else class="dpiggy-info__tx-list">
<div v-for="tx in transactions" :key="tx.id" class="dpiggy-info__tx-row">
<div class="dpiggy-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-xs text-gray-400">{{ tx.created_at || '' }}</div>
</div>
<div class="dpiggy-info__tx-amount" dir="ltr">
{{ formatPiggyAmount(Number(tx.gold ?? 0)) }}
</div>
</div>
</div>
</section>
</div>
</a-spin>
</div>
<PiggyTxModal
v-model:visible="txVisible"
:is-deposit="isDeposit"
:busy="busy"
:fund-title="fund?.product?.title || 'محصول'"
:max-label="isDeposit ? formatPiggyAmount(freeGold) : formatPiggyAmount(potGold)"
v-model:value="txValue"
@submit="submitTx"
/>
</div>
</template>
<script setup lang="ts">
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
import PiggyTxModal from '../components/PiggyTxModal.vue'
import { usePiggyInfoPage } from '../composables/usePiggyInfoPage'
const {
busy,
fund,
transactions,
txVisible,
isDeposit,
txValue,
potGold,
freeGold,
formatPiggyAmount,
openTx,
submitTx,
onDelete,
goEdit,
} = usePiggyInfoPage()
</script>
<style lang="less" src="./styles/desktop-piggy-info.less"></style>
@@ -0,0 +1,126 @@
<template>
<div class="dpiggy">
<div class="dpiggy__container">
<DesktopPageTopbar
eyebrow="دارایی"
title="قلک‌ها"
subtitle="موجودی جداشده از هر محصول را در قلک‌ها مدیریت کنید"
show-back
/>
<a-spin :spinning="busy">
<div class="dpiggy-toolbar">
<section class="dpiggy-summary">
<div class="min-w-0">
<p class="dpiggy-summary__label">جمع موجودی داخل قلک‌ها</p>
<p class="dpiggy-summary__value" dir="ltr">
{{ formatPiggyAmount(totalGold) }}
<span class="dpiggy-summary__unit">گرم</span>
</p>
<div class="dpiggy-summary__meta">
<span>{{ rows.length }} قلک</span>
<span class="dpiggy-summary__dot" />
<span>{{ groups.length }} محصول</span>
</div>
</div>
<div class="dpiggy-summary__badge">
<BasicIcon icon="vuesax-linear:safe-home" size="24" />
</div>
</section>
<BasicButton type="primary" class="dpiggy-add" @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>
</div>
<section class="dpiggy-list">
<div class="dpiggy-list__header">
<h2 class="dpiggy-list__title">لیست قلک‌ها</h2>
<span class="dpiggy-list__count">{{ rows.length }} مورد</span>
</div>
<div v-if="!rows.length" class="dpiggy-empty">
<div class="dpiggy-empty__icon">
<BasicIcon icon="vuesax-linear:safe-home" size="36" />
</div>
<p class="dpiggy-empty__title">قلکی ندارید</p>
<p class="dpiggy-empty__desc">
برای جدا کردن بخشی از موجودی هر محصول (مثلاً آبشده یا سکه) یک قلک بسازید.
</p>
<BasicButton type="primary" class="mt-4" @click="goAdd">ساخت اولین قلک</BasicButton>
</div>
<div v-else class="dpiggy-groups">
<article
v-for="group in groups"
:key="group.productId"
class="dpiggy-group"
>
<header class="dpiggy-group__head">
<div class="min-w-0">
<p class="dpiggy-group__name">{{ group.productTitle }}</p>
<p class="dpiggy-group__count">{{ group.funds.length }} قلک</p>
</div>
<p class="dpiggy-group__total" dir="ltr">
{{ formatPiggyAmount(group.totalGold) }}
<span>گرم</span>
</p>
</header>
<div class="dpiggy-group__list">
<button
v-for="item in group.funds"
:key="item.id"
type="button"
class="dpiggy-row"
@click="openInfo(item.id!)"
>
<div class="dpiggy-row__icon">
<BasicIcon :icon="piggyIconName(item.title)" size="20" />
</div>
<div class="dpiggy-row__main">
<div class="dpiggy-row__title">{{ item.title || 'بدون عنوان' }}</div>
<div class="dpiggy-row__sub">کد {{ item.id }}</div>
</div>
<div class="dpiggy-row__amount" dir="ltr">
<strong>{{ formatPiggyAmount(fundWalletGold(item)) }}</strong>
<span>گرم</span>
</div>
<BasicIcon
class="dpiggy-row__chevron"
icon="vuesax-linear:arrow-left-1"
size="16"
/>
</button>
</div>
</article>
</div>
</section>
</a-spin>
</div>
</div>
</template>
<script setup lang="ts">
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
import { usePiggyPage } from '../composables/usePiggyPage'
const {
busy,
rows,
groups,
totalGold,
formatPiggyAmount,
fundWalletGold,
piggyIconName,
goAdd,
openInfo,
} = usePiggyPage()
</script>
<style lang="less" src="./styles/desktop-piggy.less"></style>
@@ -0,0 +1,163 @@
/* Desktop piggy create */
.dpiggy-create {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.dpiggy-create__container {
max-width: 1180px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.dpiggy-create__layout {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
.dpiggy-create__card {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
padding: 22px 24px;
}
.dpiggy-create__step-head {
display: flex;
align-items: flex-start;
gap: 14px;
margin-bottom: 18px;
}
.dpiggy-create__step-num {
flex-shrink: 0;
display: flex;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
border-radius: 999px;
background: @App-primary-color;
color: #fff;
font-size: 14px;
font-weight: 700;
}
.dpiggy-create__step-title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #111827;
}
.dpiggy-create__step-desc {
margin: 4px 0 0;
font-size: 13px;
color: #6b7280;
line-height: 1.6;
}
.dpiggy-create__products {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 10px;
}
.dpiggy-create__product {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
padding: 14px 16px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 14px;
background: #fafbfc;
text-align: right;
font-size: 14px;
color: #374151;
font-family: inherit;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
&:hover {
border-color: color-mix(in srgb, @App-primary-color 35%, transparent);
}
&--on {
background: @App-primary-color;
border-color: @App-primary-color;
color: #fff;
}
}
.dpiggy-create__locked {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-radius: 14px;
background: #f3f4f6;
font-size: 14px;
font-weight: 700;
color: #111827;
}
.dpiggy-create__title-row {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
}
.dpiggy-create__title-icon {
flex-shrink: 0;
display: flex;
width: 48px;
height: 48px;
align-items: center;
justify-content: center;
border-radius: 14px;
background: color-mix(in srgb, @App-primary-color 8%, #fff);
color: @App-primary-color;
}
.dpiggy-create__presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.dpiggy-create__chip {
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 999px;
background: #f3f4f6;
padding: 8px 14px;
font-size: 13px;
color: #4b5563;
font-family: inherit;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
&:hover {
border-color: color-mix(in srgb, @App-primary-color 30%, transparent);
}
&--on {
background: @App-primary-color;
border-color: @App-primary-color;
color: #fff;
}
}
.dpiggy-create__submit {
width: 100%;
min-height: 48px !important;
border-radius: 14px !important;
font-weight: 600 !important;
}
@@ -0,0 +1,177 @@
/* Desktop piggy info */
.dpiggy-info {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.dpiggy-info__container {
max-width: 1180px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.dpiggy-info__layout {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.dpiggy-info__side {
display: flex;
flex-direction: column;
gap: 16px;
}
.dpiggy-info__hero {
background: @App-primary-color;
color: #fff;
border-radius: 16px;
padding: 24px;
box-shadow: 0 12px 28px color-mix(in srgb, @App-primary-color 28%, transparent);
}
.dpiggy-info__product {
margin: 0;
display: inline-flex;
max-width: 100%;
padding: 6px 12px;
border-radius: 999px;
background: rgb(255 255 255 / 0.14);
font-size: 12px;
color: rgb(255 255 255 / 0.9);
}
.dpiggy-info__label {
margin: 18px 0 0;
font-size: 13px;
color: rgb(255 255 255 / 0.75);
}
.dpiggy-info__value {
margin: 6px 0 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
font-size: 36px;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.1;
span {
font-size: 14px;
font-weight: 500;
color: rgb(255 255 255 / 0.75);
}
}
.dpiggy-info__free {
margin: 14px 0 0;
font-size: 13px;
line-height: 1.6;
color: rgb(255 255 255 / 0.72);
strong {
color: #fff;
}
}
.dpiggy-info__actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.dpiggy-info__action {
min-height: 44px !important;
border-radius: 12px !important;
}
.dpiggy-info__action-inner {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
}
.dpiggy-info__tx {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
padding: 20px 22px;
min-height: 320px;
}
.dpiggy-info__tx-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.dpiggy-info__tx-title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #111827;
}
.dpiggy-info__tx-count {
font-size: 13px;
color: #6b7280;
}
.dpiggy-info__tx-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 48px 16px;
color: #9ca3af;
text-align: center;
}
.dpiggy-info__tx-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.dpiggy-info__tx-row {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
background: #f8fafc;
border: 1px solid rgba(15, 23, 42, 0.04);
}
.dpiggy-info__tx-dot {
width: 10px;
height: 10px;
flex-shrink: 0;
border-radius: 999px;
background: @App-primary-color;
}
.dpiggy-info__tx-amount {
flex-shrink: 0;
font-weight: 700;
color: @App-primary-color;
}
@media (max-width: 960px) {
.dpiggy-info__layout {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,297 @@
/* Desktop piggy — aligned with fund/wallet desktop pages */
.dpiggy {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.dpiggy__container {
max-width: 1180px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.dpiggy-toolbar {
display: flex;
flex-wrap: wrap;
align-items: stretch;
gap: 16px;
margin-bottom: 24px;
}
.dpiggy-summary {
flex: 1 1 280px;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 22px;
border-radius: 16px;
background: @App-primary-color;
color: #fff;
box-shadow: 0 10px 28px color-mix(in srgb, @App-primary-color 28%, transparent);
&__label {
margin: 0;
font-size: 13px;
font-weight: 500;
color: rgb(255 255 255 / 0.8);
}
&__value {
margin: 6px 0 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.15;
}
&__unit {
font-size: 13px;
font-weight: 500;
color: rgb(255 255 255 / 0.75);
}
&__meta {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font-size: 12px;
color: rgb(255 255 255 / 0.72);
}
&__dot {
width: 4px;
height: 4px;
border-radius: 999px;
background: rgb(255 255 255 / 0.45);
}
&__badge {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 14px;
background: rgb(255 255 255 / 0.14);
}
}
.dpiggy-add {
flex: 0 0 auto;
align-self: stretch;
min-width: 180px;
height: auto !important;
min-height: 100%;
padding: 0 22px !important;
border-radius: 16px !important;
font-weight: 600 !important;
}
.dpiggy-list {
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
&__title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #111827;
}
&__count {
font-size: 13px;
color: #6b7280;
}
}
.dpiggy-groups {
display: flex;
flex-direction: column;
gap: 16px;
}
.dpiggy-group {
overflow: hidden;
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 20px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
background: #fafbfc;
}
&__name {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #111827;
}
&__count {
margin: 4px 0 0;
font-size: 12px;
color: #6b7280;
}
&__total {
margin: 0;
display: flex;
align-items: baseline;
gap: 6px;
font-size: 15px;
font-weight: 700;
color: @App-primary-color;
span {
font-size: 12px;
font-weight: 500;
color: #6b7280;
}
}
&__list {
display: flex;
flex-direction: column;
}
}
.dpiggy-row {
display: flex;
width: 100%;
min-width: 0;
align-items: center;
gap: 14px;
padding: 14px 20px;
border: none;
border-bottom: 1px solid rgba(15, 23, 42, 0.05);
background: transparent;
text-align: right;
font-family: inherit;
cursor: pointer;
transition: background 0.15s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background: #f8fafc;
}
&__icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 12px;
background: color-mix(in srgb, @App-primary-color 8%, #fff);
color: @App-primary-color;
}
&__main {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #111827;
}
&__sub {
margin-top: 3px;
font-size: 12px;
color: #9ca3af;
}
&__amount {
display: flex;
flex-direction: column;
align-items: flex-end;
flex-shrink: 0;
line-height: 1.25;
strong {
font-size: 14px;
font-weight: 700;
color: #111827;
}
span {
font-size: 11px;
color: #6b7280;
}
}
&__chevron {
flex-shrink: 0;
color: #d1d5db;
}
}
.dpiggy-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 48px 24px;
text-align: center;
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
margin-bottom: 8px;
border-radius: 16px;
background: #f3f4f6;
color: @App-primary-color;
}
&__title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #111827;
}
&__desc {
margin: 0;
max-width: 28rem;
font-size: 14px;
line-height: 1.7;
color: #6b7280;
}
}
+22 -267
View File
@@ -1,280 +1,35 @@
<template> <template>
<div class="piggy-page"> <div class="piggy-page-root">
<div class="piggy-page__inner"> <DesktopPiggyView v-if="isDesktop" />
<header class="piggy-bar"> <MobilePiggyView v-else />
<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> </div>
</template> </template>
<script setup lang="ts"> <script lang="ts" setup>
import { computed, onMounted, ref } from 'vue' import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import router from '@/router' import DesktopPiggyView from './desktop/DesktopPiggyView.vue'
import { useFundUserStore } from '@/store/fundStore' import MobilePiggyView from './mobile/MobilePiggyView.vue'
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 isDesktop = useDesktopBreakpoint()
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> </script>
<style scoped> <style scoped>
.piggy-page { .piggy-page-root {
box-sizing: border-box; display: flex;
min-height: 100%; flex-direction: column;
min-height: 0;
height: 100%;
width: 100%; width: 100%;
background: #f9fafb; max-width: 100%;
padding: 0 12px calc(var(--app-bottom-chrome-height, 108px) + 1rem); overflow: hidden;
}
.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-root > :deep(.piggy-page),
.piggy-page { .piggy-page-root > :deep(.dpiggy) {
padding: 16px 16px 2rem; flex: 1 1 0;
} min-height: 0;
.piggy-page__inner { width: 100%;
max-width: 28rem; max-width: 100%;
} overflow-y: auto;
.piggy-bar {
border-radius: 12px;
}
} }
</style> </style>
+22 -304
View File
@@ -1,317 +1,35 @@
<template> <template>
<div class="piggy-info"> <div class="piggy-info-root">
<div class="piggy-info__inner"> <DesktopPiggyInfoView v-if="isDesktop" />
<header class="piggy-info__hero"> <MobilePiggyInfoView v-else />
<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> </div>
</template> </template>
<script setup lang="ts"> <script lang="ts" setup>
import { computed, onMounted, ref } from 'vue' import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import router from '@/router' import DesktopPiggyInfoView from './desktop/DesktopPiggyInfoView.vue'
import { import MobilePiggyInfoView from './mobile/MobilePiggyInfoView.vue'
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 isDesktop = useDesktopBreakpoint()
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> </script>
<style scoped> <style scoped>
.piggy-info { .piggy-info-root {
box-sizing: border-box; display: flex;
min-height: 100%; flex-direction: column;
min-height: 0;
height: 100%;
width: 100%; width: 100%;
background: #f9fafb; max-width: 100%;
padding: 0 12px calc(var(--app-bottom-chrome-height, 108px) + 1rem); overflow: hidden;
}
.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-root > :deep(.piggy-info),
.piggy-info { .piggy-info-root > :deep(.dpiggy-info) {
padding: 16px 16px 2rem; flex: 1 1 0;
} min-height: 0;
.piggy-info__inner { width: 100%;
max-width: 26rem; max-width: 100%;
overflow: hidden; overflow-y: auto;
border-radius: 16px;
}
.piggy-info__hero {
border-radius: 16px 16px 0 0;
}
.piggy-info__actions {
flex-direction: row;
}
} }
</style> </style>
@@ -0,0 +1,266 @@
<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">{{ pageTitle }}</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 BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import { usePiggyCreatePage } from '../composables/usePiggyCreatePage'
const {
busy,
title,
productId,
products,
lockedProductTitle,
presets,
editId,
pageTitle,
piggyIconName,
goBack,
submit,
} = usePiggyCreatePage()
</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;
}
</style>
@@ -0,0 +1,186 @@
<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>
<PiggyTxModal
v-model:visible="txVisible"
:is-deposit="isDeposit"
:busy="busy"
:fund-title="fund?.product?.title || 'محصول'"
:max-label="isDeposit ? formatPiggyAmount(freeGold) : formatPiggyAmount(potGold)"
v-model:value="txValue"
@submit="submitTx"
/>
</div>
</template>
<script setup lang="ts">
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import PiggyTxModal from '../components/PiggyTxModal.vue'
import { usePiggyInfoPage } from '../composables/usePiggyInfoPage'
const {
busy,
fund,
transactions,
txVisible,
isDeposit,
txValue,
potGold,
freeGold,
formatPiggyAmount,
goBack,
openTx,
submitTx,
onDelete,
} = usePiggyInfoPage()
</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;
}
</style>
@@ -0,0 +1,244 @@
<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 BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { usePiggyPage } from '../composables/usePiggyPage'
const {
busy,
rows,
groups,
totalGold,
formatPiggyAmount,
fundWalletGold,
piggyIconName,
goBack,
goAdd,
openInfo,
} = usePiggyPage()
</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;
}
</style>
+20 -341
View File
@@ -1,356 +1,35 @@
<template> <template>
<div class="piggy-create"> <div class="piggy-create-root">
<div class="piggy-create__inner"> <DesktopPiggyCreateView v-if="isDesktop" />
<header class="piggy-bar"> <MobilePiggyCreateView v-else />
<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> </div>
</template> </template>
<script setup lang="ts"> <script lang="ts" setup>
import { computed, onMounted, ref } from 'vue' import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import router from '@/router' import DesktopPiggyCreateView from './desktop/DesktopPiggyCreateView.vue'
import { createFund, editFund, getFundById } from '@/api/fund' import MobilePiggyCreateView from './mobile/MobilePiggyCreateView.vue'
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 isDesktop = useDesktopBreakpoint()
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> </script>
<style scoped> <style scoped>
.piggy-create { .piggy-create-root {
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; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; min-height: 0;
padding-top: 16px; height: 100%;
} width: 100%;
.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%; max-width: 100%;
min-width: 0; overflow: hidden;
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 { .piggy-create-root > :deep(.piggy-create),
background: var(--App-primary, #0a1b37); .piggy-create-root > :deep(.dpiggy-create) {
color: #fff; flex: 1 1 0;
} min-height: 0;
width: 100%;
.piggy-create__locked { max-width: 100%;
display: flex; overflow-y: auto;
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> </style>
@@ -26,7 +26,7 @@
class="dtxn-row__amount" class="dtxn-row__amount"
:class="props.item.cash > 0 ? 'dtxn-row__amount--up' : 'dtxn-row__amount--down'" :class="props.item.cash > 0 ? 'dtxn-row__amount--up' : 'dtxn-row__amount--down'"
> >
{{ usePriceWithoutType(props.item.cash ?? 0) }} ریال {{ usePriceWithoutType(props.item.cash ?? 0) }}{{ amountSuffix }}
</div> </div>
<div class="dtxn-row__status"> <div class="dtxn-row__status">
<ATag v-if="statusLabel" color="blue" class="!py-1 !px-4 !ml-0 !text-xs"> <ATag v-if="statusLabel" color="blue" class="!py-1 !px-4 !ml-0 !text-xs">
@@ -56,7 +56,7 @@
<div :class="props.item.cash > 0 ? 'text-green-600' : 'text-red-600'" class="text-base"> <div :class="props.item.cash > 0 ? 'text-green-600' : 'text-red-600'" class="text-base">
{{ usePriceWithoutType(props.item.cash ?? 0) }} {{ usePriceWithoutType(props.item.cash ?? 0) }}
</div> </div>
ریال {{ amountSuffix.trim() }}
</div> </div>
</div> </div>
</div> </div>
@@ -130,6 +130,12 @@ const props = withDefaults(
const tradePrice = computed(() => getTransactionTradePrice(props.item)) const tradePrice = computed(() => getTransactionTradePrice(props.item))
const statusLabel = computed(() => resolveTransactionStatusLabel(props.item)) const statusLabel = computed(() => resolveTransactionStatusLabel(props.item))
const amountSuffix = computed(() => {
if (props.item.unit === 'تخفیف') return '٪'
if (props.item.unit === 'طلا') return ' طلا'
return ' ریال'
})
const isUp = computed( const isUp = computed(
() => () =>
props.item.type === 3 props.item.type === 3
@@ -1,6 +1,10 @@
import { ref } from 'vue' import { ref } from 'vue'
import { getTransactionTable, getTransactionTable2 } from '@/api/transaction' import { getTransactionTable, getTransactionTable2 } from '@/api/transaction'
import { getPhysicalReceiveList } from '@/api/physicalreceive' import { getPhysicalReceiveList } from '@/api/physicalreceive'
import {
fetchMyGiftCards,
type CustomerGiftCardItem,
} from '@/api/giftCard'
import type { TableEngineInput, TableEngineOutput } from '@/models/table' import type { TableEngineInput, TableEngineOutput } from '@/models/table'
import type { Transaction } from '@/models/transaction' import type { Transaction } from '@/models/transaction'
import type { Physicalreceive } from '@/models/physicalreceive' import type { Physicalreceive } from '@/models/physicalreceive'
@@ -13,6 +17,54 @@ import {
type BoursecuTransactionTabId, type BoursecuTransactionTabId,
} from '../config/boursecuTabs' } from '../config/boursecuTabs'
function mapGiftHistoryToTransaction(item: CustomerGiftCardItem): Transaction {
const amount = Number(item.amount)
const isDiscount = item.kind === 'discount'
const baseTitle = item.name?.trim() || (isDiscount ? 'کد تخفیف' : 'کارت هدیه')
const codePart = item.code ? ` · ${item.code}` : ''
if (isDiscount) {
const percent = Number.isFinite(amount) ? (amount <= 1 ? amount * 100 : amount) : 0
return {
id: item.id,
title: `${baseTitle}${codePart}`,
cash: percent,
expire_at: item.used_at || item.expiry_date || undefined,
sts_str: 'مصرف‌شده',
note: item.code,
unit: 'تخفیف',
type: 3,
tracked_number: 0,
}
}
if (item.unit === 2) {
return {
id: item.id,
title: `${baseTitle}${codePart}`,
cash: Number.isFinite(amount) ? amount : 0,
expire_at: item.used_at || item.expiry_date || undefined,
sts_str: 'مصرف‌شده',
note: item.code,
unit: 'طلا',
type: 3,
tracked_number: 0,
}
}
return {
id: item.id,
title: `${baseTitle}${codePart}`,
cash: Number.isFinite(amount) ? amount : 0,
expire_at: item.used_at || item.expiry_date || undefined,
sts_str: 'مصرف‌شده',
note: item.code,
unit: 'ریال',
type: 3,
tracked_number: 0,
}
}
/** Shared transactions page state — refetched on every page visit (no list cache). */ /** Shared transactions page state — refetched on every page visit (no list cache). */
let activeFetchId = 0 let activeFetchId = 0
@@ -60,10 +112,72 @@ function resolveTransactionFetcher() {
return getTransactionTable return getTransactionTable
} }
async function fetchGiftCodeTransactionsPage(
fetchId: number,
kind: 'gift' | 'discount',
): Promise<boolean> {
try {
const res = await fetchMyGiftCards()
if (fetchId !== activeFetchId) {
return false
}
const rows = (res?.history ?? [])
.filter((item) => item.kind === kind)
.map(mapGiftHistoryToTransaction)
transactionTableOutput.value = {
rows,
query: {
filters: [],
filtersOpt: null,
key: null,
limit: rows.length || 10,
page: 0,
search: '',
select: null,
sort: null,
total: rows.length,
},
}
transactionItems.value = rows
physicalReceiveItems.value = []
return true
} catch (error) {
if (fetchId === activeFetchId) {
console.error('Error fetching gift/discount transactions:', error)
transactionItems.value = []
physicalReceiveItems.value = []
transactionTableOutput.value = {
rows: [],
query: {
filters: [],
filtersOpt: null,
key: null,
limit: 10,
page: 0,
search: '',
select: null,
sort: null,
total: 0,
},
}
}
return false
}
}
async function fetchTransactionsPage(append: boolean): Promise<boolean> { async function fetchTransactionsPage(append: boolean): Promise<boolean> {
const fetchId = ++activeFetchId const fetchId = ++activeFetchId
const isFirstPage = transactionPagination.value.page === 0 && !append const isFirstPage = transactionPagination.value.page === 0 && !append
const isPhysical = getActiveBoursecuTab().fetchMode === 'physical' const activeTab = getActiveBoursecuTab()
const isPhysical = activeTab.fetchMode === 'physical'
const giftKind =
activeTab.fetchMode === 'giftcard'
? 'gift'
: activeTab.fetchMode === 'discount'
? 'discount'
: null
if (isFirstPage) { if (isFirstPage) {
loading.value = true loading.value = true
@@ -73,6 +187,14 @@ async function fetchTransactionsPage(append: boolean): Promise<boolean> {
} }
try { try {
if (giftKind) {
// لیست کامل از mine؛ صفحه‌بندی سمت سرور ندارد
if (append) {
return false
}
return await fetchGiftCodeTransactionsPage(fetchId, giftKind)
}
const output = await resolveTransactionFetcher()(transactionPagination.value) const output = await resolveTransactionFetcher()(transactionPagination.value)
if (fetchId !== activeFetchId) { if (fetchId !== activeFetchId) {
return false return false
@@ -128,6 +250,11 @@ export async function loadMoreTransactions() {
return return
} }
const fetchMode = getActiveBoursecuTab().fetchMode
if (fetchMode === 'giftcard' || fetchMode === 'discount') {
return
}
if ( if (
!transactionTableOutput.value !transactionTableOutput.value
|| (transactionPagination.value.page + 1) * transactionPagination.value.limit || (transactionPagination.value.page + 1) * transactionPagination.value.limit
@@ -5,10 +5,17 @@ export type BoursecuTransactionTabId =
| 'deposit' | 'deposit'
| 'withdrawal' | 'withdrawal'
| 'physical' | 'physical'
| 'giftcard'
| 'discount'
export type BoursecuTradeSide = 'buy' | 'sell' export type BoursecuTradeSide = 'buy' | 'sell'
export type BoursecuTransactionFetchMode = 'transaction' | 'deposit' | 'physical' export type BoursecuTransactionFetchMode =
| 'transaction'
| 'deposit'
| 'physical'
| 'giftcard'
| 'discount'
export type BoursecuTradeStatusFilter = 'completed' | 'pending' | 'cancelled' export type BoursecuTradeStatusFilter = 'completed' | 'pending' | 'cancelled'
export type BoursecuFulfillmentStatusFilter = 'delivered' | 'registered' | 'cancelled' export type BoursecuFulfillmentStatusFilter = 'delivered' | 'registered' | 'cancelled'
@@ -78,6 +85,18 @@ export const BOURSECU_TRANSACTION_TABS: BoursecuTransactionTab[] = [
fetchMode: 'physical', fetchMode: 'physical',
showAdvancedFilters: true, showAdvancedFilters: true,
}, },
{
id: 'giftcard',
label: 'کارت هدیه',
emptyText: 'کارت هدیه‌ای برای شما ثبت نشده است',
fetchMode: 'giftcard',
},
{
id: 'discount',
label: 'کد تخفیف',
emptyText: 'کد تخفیفی برای شما ثبت نشده است',
fetchMode: 'discount',
},
] ]
const TRADE_TYPE_BY_SIDE: Record<BoursecuTradeSide, number> = { const TRADE_TYPE_BY_SIDE: Record<BoursecuTradeSide, number> = {
@@ -20,7 +20,6 @@ import { getBoursecuActionIconSrc } from '@/utils/config'
import { getReferralShareText, getReferralShareTitle, shareReferralText } from '@/utils/referralShare' import { getReferralShareText, getReferralShareTitle, shareReferralText } from '@/utils/referralShare'
import { normalizeAuthFormFields } from '@/utils/persianDigits' import { normalizeAuthFormFields } from '@/utils/persianDigits'
import { useSettingsStore } from '@/store/settingsStore' import { useSettingsStore } from '@/store/settingsStore'
import { useSupportMessenger } from '@/composables/useSupportMessenger'
import { useTrackingSystemsFeature } from '@/composables/useTrackingSystemsFeature' import { useTrackingSystemsFeature } from '@/composables/useTrackingSystemsFeature'
import { openGoldDayCalculator } from '@/composables/useGoldDayCalculator' import { openGoldDayCalculator } from '@/composables/useGoldDayCalculator'
import { isUserSupportChatEnabled, unreadSupportCount } from '@/utils/chat' import { isUserSupportChatEnabled, unreadSupportCount } from '@/utils/chat'
@@ -88,7 +87,6 @@ export function useUserPage() {
const editPasswordVisible = ref(false) const editPasswordVisible = ref(false)
const showLogoutConfirm = ref(false) const showLogoutConfirm = ref(false)
const localAvatarUrl = ref<string | null>(null) const localAvatarUrl = ref<string | null>(null)
const { openSupportMessenger } = useSupportMessenger()
const { trackingSystemsEnabled } = useTrackingSystemsFeature() const { trackingSystemsEnabled } = useTrackingSystemsFeature()
const profileAvatarUrl = computed( const profileAvatarUrl = computed(
@@ -159,9 +157,7 @@ export function useUserPage() {
id: 'support', id: 'support',
label: 'پشتیبانی', label: 'پشتیبانی',
icon: 'vuesax-linear:messages-2', icon: 'vuesax-linear:messages-2',
onClick: () => { onClick: () => router.push({ name: 'support' }),
void openSupportMessenger()
},
visible: isUserSupportChatEnabled(), visible: isUserSupportChatEnabled(),
badge: unreadSupportCount.value > 0 ? unreadSupportCount.value : undefined, badge: unreadSupportCount.value > 0 ? unreadSupportCount.value : undefined,
}, },
@@ -64,7 +64,7 @@
</section> </section>
<section class="duser-surface duser-actions"> <section class="duser-surface duser-actions">
<h3 class="duser-actions__title">تنظیمات و خدمات</h3> <h3 class="duser-actions__title">امکانات و خدمات</h3>
<div class="duser-actions__list"> <div class="duser-actions__list">
<button <button
v-for="action in menuActions" v-for="action in menuActions"
+5 -8
View File
@@ -407,17 +407,14 @@ async function login(accessToken: string | undefined) {
setToken(accessToken) setToken(accessToken)
syncPriceRealtimeChannel((key) => setting.getSettingByKey(key)) syncPriceRealtimeChannel((key) => setting.getSettingByKey(key))
await userStore.getMyInfo() await userStore.getMyInfo()
// console.log(URLUserType()) if (!userStore.userInfo) {
// console.log(userStore.userInfo?.type) removeToken()
/* if (userStore.userInfo?.type === (URLUserType() === 'customer' ? 1 : 2)) {*/ message.error('ورود انجام شد ولی دریافت اطلاعات کاربر ممکن نشد. اتصال سرور را بررسی کنید.')
return
}
await walletStore.getMyWallet() await walletStore.getMyWallet()
setTruth() setTruth()
await completeLoginNavigation() await completeLoginNavigation()
/* } else {
removeToken()
userStore.removeInformation()
message.error('شما مجوز ورود به این بخش را ندارید')
}*/
} }
function phoneVerification() { function phoneVerification() {
@@ -92,6 +92,10 @@ export function useLoginForm() {
async function login(accessToken: string | undefined) { async function login(accessToken: string | undefined) {
setToken(accessToken) setToken(accessToken)
await userStore.getMyInfo() await userStore.getMyInfo()
if (!userStore.userInfo) {
message.error('ورود انجام شد ولی دریافت اطلاعات کاربر ممکن نشد. اتصال سرور را بررسی کنید.')
return
}
await walletStore.getMyWallet() await walletStore.getMyWallet()
setTruth() setTruth()
await completeLoginNavigation() await completeLoginNavigation()
@@ -174,9 +174,9 @@
<div class="support-composer__row flex items-end gap-2"> <div class="support-composer__row flex items-end gap-2">
<BasicButton <BasicButton
type="primary" type="primary"
:disabled="disabled || isRecording || (!modelValue.trim() && attachments.length === 0)" html-type="button"
:disabled-b="!canSend || !!disabled || isRecording"
class="support-send-btn !h-12 !w-12 shrink-0 !p-0" class="support-send-btn !h-12 !w-12 shrink-0 !p-0"
@mousedown.prevent
@click="submit" @click="submit"
> >
<BasicIcon icon="vuesax-linear:direct-right" size="20" /> <BasicIcon icon="vuesax-linear:direct-right" size="20" />
@@ -197,9 +197,9 @@
<BasicButton <BasicButton
class="support-tool-btn flex !h-10 !w-10 shrink-0 items-center justify-center rounded-xl" class="support-tool-btn flex !h-10 !w-10 shrink-0 items-center justify-center rounded-xl"
:disabled="disabled || isRecording" :disabled-b="!!disabled || isRecording"
html-type="button"
type="link" type="link"
@mousedown.prevent
@click="openFilePicker" @click="openFilePicker"
> >
<BasicIcon icon="vuesax-linear:gallery" size="20" /> <BasicIcon icon="vuesax-linear:gallery" size="20" />
@@ -259,6 +259,10 @@ let recordPlugin: ReturnType<typeof RecordPlugin.create> | null = null
const createId = () => `${Date.now()}-${Math.floor(Math.random() * 100000)}` const createId = () => `${Date.now()}-${Math.floor(Math.random() * 100000)}`
const canSend = computed(
() => props.modelValue.trim().length > 0 || attachments.value.length > 0,
)
const getReplySenderLabel = (sender: string) => { const getReplySenderLabel = (sender: string) => {
if (sender === 'user') return 'خودتان' if (sender === 'user') return 'خودتان'
if (sender === 'support') return 'پشتیبانی' if (sender === 'support') return 'پشتیبانی'
@@ -535,7 +539,7 @@ const submit = async () => {
replyTo: props.replyingTo ?? null replyTo: props.replyingTo ?? null
}) })
emit('update:modelValue', '') // متن را والد بعد از موفقیت پاک می‌کند؛ پیوست‌ها را همین‌جا خالی می‌کنیم
clearComposerAttachments() clearComposerAttachments()
await nextTick() await nextTick()
@@ -1,9 +1,14 @@
<template> <template>
<header class="support-header"> <header class="support-header">
<div class="support-header__inner"> <div class="support-header__inner">
<div class="bg-glass rounded-full p-2" @click="$router.back()"> <button
type="button"
class="support-header__back bg-glass rounded-full p-2"
aria-label="بازگشت"
@click="goBack"
>
<BasicIcon icon="vuesax-linear:arrow-right" /> <BasicIcon icon="vuesax-linear:arrow-right" />
</div> </button>
<div class="support-header__icon"> <div class="support-header__icon">
<img <img
v-if="logoUrl" v-if="logoUrl"
@@ -37,23 +42,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue' import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useConfigStore } from '@/store/configStore' import { getProjectSplashLogo } from '@/utils/config'
defineProps<{ defineProps<{
title: string title: string
subtitle?: string subtitle?: string
}>() }>()
const config = useConfigStore() const router = useRouter()
const logoUrl = computed(() => String(config.config?.avatar || '').trim()) const logoUrl = computed(() => getProjectSplashLogo())
function goBack() {
const back = (window.history.state as { back?: string | null } | null)?.back
if (typeof back === 'string' && back.length > 0) {
void router.push(back)
return
}
// ورود معمول از تنظیمات/پروفایل است
void router.replace({ name: 'user' })
}
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.support-header { .support-header {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 2; z-index: 20;
border-bottom: 1px solid color-mix(in srgb, var(--colorPrimary, #2563eb) 10%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--colorPrimary, #2563eb) 10%, transparent);
background: linear-gradient( background: linear-gradient(
180deg, 180deg,
@@ -74,6 +90,20 @@ const logoUrl = computed(() => String(config.config?.avatar || '').trim())
padding: 12px 14px; padding: 12px 14px;
} }
.support-header__back {
flex: 0 0 auto;
border: none;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
line-height: 0;
position: relative;
z-index: 21;
pointer-events: auto;
}
.support-header__icon { .support-header__icon {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -95,7 +125,6 @@ const logoUrl = computed(() => String(config.config?.avatar || '').trim())
object-fit: contain; object-fit: contain;
object-position: center; object-position: center;
padding: 6px; padding: 6px;
background: #fff;
} }
.support-header__icon-svg { .support-header__icon-svg {
@@ -272,8 +272,8 @@
</div> </div>
</div> </div>
</div> </div>
<BasicButton <button
type="primary" type="button"
class="support-reply-button" class="support-reply-button"
:class=" :class="
message.sender === 'user' message.sender === 'user'
@@ -281,14 +281,12 @@
: 'support-reply-button--support' : 'support-reply-button--support'
" "
title="پاسخ" title="پاسخ"
aria-label="پاسخ"
@pointerdown.stop @pointerdown.stop
@pointerup.stop @click.stop.prevent="emit('reply', message)"
@pointercancel.stop
@pointermove.stop
@click.stop="emit('reply', message)"
> >
<BasicIcon icon="vuesax-linear:undo" size="16" /> <BasicIcon icon="vuesax-linear:undo" size="16" />
</BasicButton> </button>
</div> </div>
</section> </section>
@@ -336,7 +334,6 @@
import { computed, nextTick, ref, watch, onBeforeUnmount } from 'vue' import { computed, nextTick, ref, watch, onBeforeUnmount } from 'vue'
import type { ChatMessage } from '@/models/support' import type { ChatMessage } from '@/models/support'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue' import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { useConfigStore } from '@/store/configStore' import { useConfigStore } from '@/store/configStore'
const props = defineProps<{ const props = defineProps<{
@@ -1210,16 +1207,17 @@ defineExpose({
padding: 0 !important; padding: 0 !important;
border: 1px solid color-mix(in srgb, var(--colorPrimary, #2563eb) 18%, transparent) !important; border: 1px solid color-mix(in srgb, var(--colorPrimary, #2563eb) 18%, transparent) !important;
border-radius: 999px !important; border-radius: 999px !important;
background: color-mix(in srgb, var(--bg, #fff) 92%, transparent) !important; background: #fff !important;
color: var(--colorPrimary, #2563eb) !important; color: var(--colorPrimary, #2563eb) !important;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 8px 18px color-mix(in srgb, var(--colorPrimary, #2563eb) 10%, transparent); box-shadow: 0 8px 18px color-mix(in srgb, var(--colorPrimary, #2563eb) 10%, transparent);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
opacity: 0; cursor: pointer;
pointer-events: none; opacity: 1;
transform: scale(0.92); pointer-events: auto;
transform: scale(1);
transition: transition:
opacity 0.15s ease, opacity 0.15s ease,
transform 0.15s ease; transform 0.15s ease;
@@ -1229,19 +1227,13 @@ defineExpose({
display: block; display: block;
} }
.support-row:hover .support-reply-button,
.support-row:focus-within .support-reply-button {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
.support-reply-button--user { .support-reply-button--user {
order: 0; order: 0;
} }
.support-reply-button--support { .support-reply-button--support {
order: -1; order: -1;
background: #fff !important;
} }
.support-avatar { .support-avatar {
+182 -13
View File
@@ -1,5 +1,11 @@
<template> <template>
<div class="support-page main-with-bottom-chrome-inner--contained"> <div
class="support-page main-with-bottom-chrome-inner--contained"
:class="{ 'support-page--desktop': isDesktop }"
>
<DesktopSidebar v-if="isDesktop" class="support-page__sidebar" />
<div class="support-page__main">
<div class="support-shell"> <div class="support-shell">
<SupportChatHeader title="پشتیبانی" /> <SupportChatHeader title="پشتیبانی" />
@@ -25,6 +31,7 @@
/> />
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -32,6 +39,8 @@ import { computed, nextTick, onMounted, onBeforeUnmount, ref } from 'vue'
import SupportChatHeader from './components/SupportChatHeader.vue' import SupportChatHeader from './components/SupportChatHeader.vue'
import SupportChatMessages from './components/SupportChatMessages.vue' import SupportChatMessages from './components/SupportChatMessages.vue'
import SupportChatComposer from './components/SupportChatComposer.vue' import SupportChatComposer from './components/SupportChatComposer.vue'
import DesktopSidebar from '@/layouts/desktop/DesktopSidebar.vue'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import type { import type {
ChatAttachment, ChatAttachment,
ChatMessage, ChatMessage,
@@ -55,7 +64,9 @@ import {
canRequestBrowserNotification, canRequestBrowserNotification,
requestBrowserNotificationPermission, requestBrowserNotificationPermission,
} from '@/utils/browserNotification' } from '@/utils/browserNotification'
import { message } from 'ant-design-vue'
const isDesktop = useDesktopBreakpoint()
const draft = ref('') const draft = ref('')
const isSending = ref(false) const isSending = ref(false)
const supportTyping = ref(false) const supportTyping = ref(false)
@@ -382,10 +393,12 @@ const toReplyPreview = (message: ChatMessage): ChatReplyPreview => {
const handleReply = async (message: ChatMessage) => { const handleReply = async (message: ChatMessage) => {
replyingTo.value = toReplyPreview(message) replyingTo.value = toReplyPreview(message)
console.log('replying to:', replyingTo.value)
await nextTick() await nextTick()
// فوکوس روی کادر نوشتن تا پیش‌نمایش پاسخ دیده شود
const textarea = document.querySelector(
'.support-composer textarea',
) as HTMLTextAreaElement | null
textarea?.focus()
} }
const clearReply = () => { const clearReply = () => {
@@ -512,6 +525,7 @@ const messages = computed<ChatMessage[]>(() => {
onMounted(async () => { onMounted(async () => {
setSupportChatPageOpen(true) setSupportChatPageOpen(true)
document.documentElement.classList.add('support-page-active')
if (canRequestBrowserNotification()) { if (canRequestBrowserNotification()) {
void requestBrowserNotificationPermission() void requestBrowserNotificationPermission()
@@ -537,6 +551,7 @@ onMounted(async () => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
setSupportChatPageOpen(false) setSupportChatPageOpen(false)
document.documentElement.classList.remove('support-page-active')
}) })
const loadOlderMessages = async () => { const loadOlderMessages = async () => {
@@ -662,13 +677,15 @@ const sendMessage = async (payload: ComposerSendPayload) => {
} }
clearReply() clearReply()
draft.value = ''
await nextTick() await nextTick()
messagesRef.value?.scrollToBottom(true) messagesRef.value?.scrollToBottom(true)
} catch (error) { } catch (error) {
console.error('send message failed:', error) console.error('send message failed:', error)
draft.value = text
message.error('ارسال پیام انجام نشد. اتصال پشتیبانی را بررسی کنید.')
} finally { } finally {
draft.value = ''
isSending.value = false isSending.value = false
} }
} }
@@ -676,14 +693,30 @@ const sendMessage = async (payload: ComposerSendPayload) => {
<style scoped lang="less"> <style scoped lang="less">
.support-page { .support-page {
width: 100%;
max-width: 100%;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
font-family: var(--font-family, iran-sans), serif; font-family: var(--font-family, iran-sans), serif;
display: flex;
flex-direction: column;
}
.support-page__main {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
} }
.support-shell { .support-shell {
width: 100%;
max-width: 100%;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
display: grid; display: grid;
@@ -711,16 +744,152 @@ const sendMessage = async (payload: ComposerSendPayload) => {
box-shadow: none; box-shadow: none;
} }
@media (min-width: 768px) { .support-page__sidebar {
.support-page { display: none !important;
height: calc(100dvh - 4.5rem); }
padding: 12px 16px; </style>
<style lang="less">
/* زیر آستانه دسکتاپ: تمام‌عرض، بدون منو */
@media screen and (max-width: 767.98px) {
html.support-page-active {
body {
display: block !important;
justify-content: unset !important;
} }
.support-shell { #app {
border-radius: 16px; display: block !important;
border: 1px solid rgb(var(--colorPrimaryRGB, 37 99 235) / 0.12); width: 100% !important;
box-shadow: 0 16px 48px rgb(15 23 42 / 0.06); max-width: none !important;
}
.desktopConfig,
.app-main,
.mobile-app-layout,
.main-with-bottom-chrome,
.main-with-bottom-chrome-inner,
.support-page {
width: 100% !important;
max-width: none !important;
margin: 0 !important;
}
.wrap-fix,
.bottom-appbar,
.app-credit-footer--mobile {
width: 100% !important;
max-width: 100vw !important;
left: 0 !important;
right: 0 !important;
margin: 0 !important;
transform: none !important;
}
}
.support-page,
.support-page.support-page--desktop {
flex-direction: column !important;
align-items: stretch !important;
width: 100% !important;
max-width: none !important;
height: 100% !important;
max-height: none !important;
min-height: 0 !important;
background: transparent !important;
padding: 0 !important;
}
.support-page .support-page__sidebar,
.support-page--desktop .support-page__sidebar,
.support-page__sidebar.desktop-sidebar {
display: none !important;
width: 0 !important;
min-width: 0 !important;
overflow: hidden !important;
pointer-events: none !important;
}
.support-page .support-page__main,
.support-page--desktop .support-page__main {
width: 100% !important;
max-width: none !important;
flex: 1 1 auto !important;
padding: 0 !important;
}
.support-page .support-shell,
.support-page--desktop .support-shell {
width: 100% !important;
max-width: none !important;
border-radius: 0 !important;
border: 0 !important;
box-shadow: none !important;
}
}
/* از آستانه دسکتاپ: منو + چت پرکننده باقی‌مانده */
@media screen and (min-width: 768px) {
.support-page.support-page--desktop {
flex-direction: row !important;
align-items: stretch !important;
gap: 0 !important;
width: 100% !important;
max-width: none !important;
background: #eef1f5 !important;
overflow: hidden !important;
height: 100vh !important;
max-height: 100vh !important;
min-height: 100vh !important;
padding: 0 !important;
}
.support-page.support-page--desktop .support-page__sidebar,
.support-page.support-page--desktop > .support-page__sidebar.desktop-sidebar {
display: flex !important;
flex: 0 0 var(--app-desktop-sidebar-width, 260px) !important;
width: var(--app-desktop-sidebar-width, 260px) !important;
min-width: var(--app-desktop-sidebar-width, 260px) !important;
max-width: var(--app-desktop-sidebar-width, 260px) !important;
position: relative !important;
height: 100vh !important;
max-height: 100vh !important;
align-self: stretch !important;
z-index: 1 !important;
pointer-events: auto !important;
overflow: hidden !important;
}
.support-page.support-page--desktop .support-page__main {
flex: 1 1 0 !important;
width: 0 !important;
min-width: 0 !important;
max-width: none !important;
height: 100% !important;
padding: 20px 24px 24px !important;
box-sizing: border-box !important;
position: relative !important;
z-index: 2 !important;
}
.support-page.support-page--desktop .support-shell {
width: 100% !important;
max-width: none !important;
height: 100% !important;
border-radius: 28px !important;
border: 1px solid rgba(15, 23, 42, 0.08) !important;
box-shadow:
0 1px 2px rgba(15, 23, 42, 0.04),
0 18px 48px rgba(15, 23, 42, 0.08) !important;
}
.support-page.desktop-app-layout__content.support-page--desktop {
overflow: hidden !important;
min-height: 0 !important;
}
body:has(.support-page--desktop) .app-credit-footer--desktop-float {
display: none !important;
} }
} }
</style> </style>