feat(ui): desktop piggy/notifications views, support chat fixes, gift card list
This commit is contained in:
@@ -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` | «تنظیمات» → «امکانات بیشتر» (دسکتاپ+موبایل)
|
||||
@@ -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
@@ -155,6 +155,8 @@ const isDesktop = useDesktopBreakpoint()
|
||||
const route = useRoute()
|
||||
const showDesktopFloatingCredit = computed(() => {
|
||||
if (!isDesktop.value) return false
|
||||
// پشتیبانی خودش سایدبار دارد؛ float روی دکمه ارسال میافتد
|
||||
if (route.name === 'support') return false
|
||||
return route.name === 'login' || !!route.meta.hideMenu
|
||||
})
|
||||
const isLoginRoute = computed(() => route.name === 'login')
|
||||
@@ -591,7 +593,7 @@ html.project-boursecu {
|
||||
}
|
||||
|
||||
@media screen and (min-width: 426px) and (max-width: 767.98px) {
|
||||
.desktopConfig {
|
||||
html:not(.app-layout--desktop) .desktopConfig {
|
||||
width: 426px;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
+24
-2
@@ -1,5 +1,27 @@
|
||||
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 const registerCodeGiftCard = async (code: string) => await useAxios.post(`/customer/giftcard/use`, {code:code})
|
||||
export interface CustomerGiftCardMineResponse {
|
||||
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
@@ -1,17 +1,49 @@
|
||||
const FINGER_SESSION_KEY = 'boursecu_finger_session_ok'
|
||||
|
||||
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() {
|
||||
fingerPosition = true
|
||||
writePersistedFingerSession(true)
|
||||
}
|
||||
|
||||
export function setEnableAuthenticate() {
|
||||
fingerPosition = false
|
||||
writePersistedFingerSession(false)
|
||||
}
|
||||
|
||||
export function getFingerPosition() {
|
||||
return fingerPosition
|
||||
if (fingerPosition) {
|
||||
return true
|
||||
}
|
||||
if (readPersistedFingerSession()) {
|
||||
fingerPosition = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function setFingerPosition(value: boolean) {
|
||||
fingerPosition = value
|
||||
writePersistedFingerSession(value)
|
||||
}
|
||||
|
||||
@@ -160,6 +160,8 @@ export function setupRouterGuards() {
|
||||
timeoutId = setTimeout(() => {
|
||||
if (hasFingerPrint) {
|
||||
setFingerPosition(false)
|
||||
// بعد از انقضا دوباره WebAuthn بخواهد؛ مستقیم لاگین اجباری نکند
|
||||
fingerprintChecked = false
|
||||
}
|
||||
}, 180000)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ const BOURSECU_PRIMARY_NAV: AppNavItem[] = [
|
||||
{
|
||||
id: 'user',
|
||||
routeName: 'user',
|
||||
label: 'تنظیمات',
|
||||
label: 'امکانات بیشتر',
|
||||
icon: 'vuesax-linear:setting-2',
|
||||
iconSrc: getBoursecuActionIconSrc('settings'),
|
||||
},
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
background: @prefixCls-background-color;
|
||||
}
|
||||
|
||||
.desktop-app-layout__content.support-page {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.desktop-sidebar {
|
||||
flex-shrink: 0;
|
||||
width: var(--app-desktop-sidebar-width, 260px);
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
class="tab__icon-image"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs mt-1 font-bold">تنظیمات</div>
|
||||
<div class="text-xs mt-1 font-bold">امکانات بیشتر</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Mobile shell width rules.
|
||||
* Below 426px: full viewport (phones).
|
||||
* 426px–767px: centered 426px frame (tablet-ish mobile).
|
||||
* از ۷۶۸px شل دسکتاپ (سایدبار) فعال میشود.
|
||||
*/
|
||||
|
||||
html:not(.app-layout--desktop) {
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ const router = createRouter({
|
||||
{
|
||||
path: 'support',
|
||||
name: 'support',
|
||||
meta: { hideBottomNav: true },
|
||||
meta: { hideMenu: true, hideBottomNav: true },
|
||||
component: () => import('@/views/support/index.vue')
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,35 +1,44 @@
|
||||
<template>
|
||||
<div class="dapp-notif">
|
||||
<BrowserNotificationPermissionBanner class="mb-4 max-w-3xl w-full" />
|
||||
<div class="dapp-notif__container">
|
||||
<DesktopPageTopbar
|
||||
eyebrow="اعلانها"
|
||||
title="پیامهای مدیریت"
|
||||
subtitle="اعلانهای ارسالشده از طرف مدیریت را اینجا ببینید"
|
||||
/>
|
||||
|
||||
<div v-if="loading && !items.length" class="app-page-content-loading">
|
||||
<a-spin size="large" />
|
||||
</div>
|
||||
<BrowserNotificationPermissionBanner class="mb-4 max-w-3xl w-full" />
|
||||
|
||||
<div
|
||||
<div v-if="loading && !items.length" class="app-page-content-loading">
|
||||
<a-spin size="large" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!items.length"
|
||||
class="dapp-notif__empty"
|
||||
>
|
||||
<BasicIcon icon="vuesax-linear:notification-bing" size="36" />
|
||||
<p>اعلانی برای نمایش نیست.</p>
|
||||
</div>
|
||||
|
||||
<a-spin v-else :spinning="loading">
|
||||
<div class="dapp-notif__list">
|
||||
<AppNotificationCard
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
compact
|
||||
@open="openItem"
|
||||
/>
|
||||
>
|
||||
<BasicIcon icon="vuesax-linear:notification-bing" size="36" />
|
||||
<p>اعلانی برای نمایش نیست.</p>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<a-spin v-else :spinning="loading">
|
||||
<div class="dapp-notif__list">
|
||||
<AppNotificationCard
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
compact
|
||||
@open="openItem"
|
||||
/>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
|
||||
import AppNotificationCard from '../components/AppNotificationCard.vue'
|
||||
import BrowserNotificationPermissionBanner from '../components/BrowserNotificationPermissionBanner.vue'
|
||||
import { useAppNotificationsPageContext } from '../composables/useAppNotificationsPage'
|
||||
@@ -39,7 +48,10 @@ const { loading, items, openItem } = useAppNotificationsPageContext()
|
||||
|
||||
<style scoped>
|
||||
.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 {
|
||||
@apply flex flex-col items-center gap-2 text-gray-500;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<MobileAppNotificationsView v-else />
|
||||
|
||||
<BasicActionSheet
|
||||
v-if="!isDesktop"
|
||||
v-model:show="detailVisible"
|
||||
:title="selectedItem?.title || 'جزئیات اعلان'"
|
||||
width="32rem"
|
||||
@@ -27,11 +28,38 @@
|
||||
<p class="app-notif-detail__body">{{ selectedItem.body }}</p>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
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 { useAppNotificationsPage } from './composables/useAppNotificationsPage'
|
||||
import { severityLabel } from './composables/notificationDisplay'
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
پاداش معرفی
|
||||
</div>
|
||||
<div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'">
|
||||
• در صفحه اصلی روی "تنظیمات" کلیک کرده و وارد حساب کاربری خود شوید.
|
||||
• در صفحه اصلی روی "امکانات بیشتر" کلیک کرده و وارد حساب کاربری خود شوید.
|
||||
</div>
|
||||
<div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'">
|
||||
• سپس گزینه «اشتراکگذاری» یا «کپی لینک» را انتخاب کرده و لینک دعوت را برای دوستانتان ارسال کنید.<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 { registerCodeGiftCard } from '@/api/giftCard'
|
||||
import {
|
||||
fetchMyGiftCards,
|
||||
registerCodeGiftCard,
|
||||
type CustomerGiftCardItem,
|
||||
} from '@/api/giftCard'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
export interface GiftCardPageContext {
|
||||
@@ -8,6 +12,11 @@ export interface GiftCardPageContext {
|
||||
disableButton: ComputedRef<boolean>
|
||||
loading: typeof loading
|
||||
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> =
|
||||
@@ -17,6 +26,9 @@ export function useGiftCardPage() {
|
||||
const form = ref<{ code: string }>({
|
||||
code: '',
|
||||
})
|
||||
const available = ref<CustomerGiftCardItem[]>([])
|
||||
const history = ref<CustomerGiftCardItem[]>([])
|
||||
const listLoading = ref(false)
|
||||
|
||||
const disableButton = computed(() => {
|
||||
if (!loading.value) {
|
||||
@@ -25,12 +37,42 @@ export function useGiftCardPage() {
|
||||
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() {
|
||||
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 {
|
||||
loading.value = true
|
||||
await registerCodeGiftCard(form.value.code)
|
||||
await registerCodeGiftCard(code)
|
||||
message.success('کد کارت هدیه با موفقیت ثبت شد')
|
||||
form.value.code = ''
|
||||
await loadMyGiftCards()
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در ثبت کد کارت هدیه')
|
||||
} finally {
|
||||
@@ -38,11 +80,20 @@ export function useGiftCardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadMyGiftCards()
|
||||
})
|
||||
|
||||
const context: GiftCardPageContext = {
|
||||
form,
|
||||
disableButton,
|
||||
loading,
|
||||
registerGiftCard,
|
||||
available,
|
||||
history,
|
||||
listLoading,
|
||||
loadMyGiftCards,
|
||||
fillCode,
|
||||
}
|
||||
|
||||
provide(GIFT_CARD_PAGE_CONTEXT_KEY, context)
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<GiftCardForm variant="desktop" />
|
||||
<GiftCardLists variant="desktop" />
|
||||
</section>
|
||||
|
||||
<aside class="dgift-surface dgift-guide">
|
||||
@@ -47,4 +48,5 @@
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import router from '@/router'
|
||||
import GiftCardForm from '../components/GiftCardForm.vue'
|
||||
import GiftCardLists from '../components/GiftCardLists.vue'
|
||||
</script>
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
<div>
|
||||
<MobilePageHeader title="کارت هدیه" show-back />
|
||||
<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 />
|
||||
<div class="z-100 relative">
|
||||
<GiftCardForm variant="mobile" />
|
||||
</div>
|
||||
</div>
|
||||
<GiftCardLists variant="mobile" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,4 +17,5 @@
|
||||
import Bg_main from '@/components/bg_main.vue'
|
||||
import MobilePageHeader from '@/layouts/components/MobilePageHeader.vue'
|
||||
import GiftCardForm from '../components/GiftCardForm.vue'
|
||||
import GiftCardLists from '../components/GiftCardLists.vue'
|
||||
</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
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import router from '@/router'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { getBoursecuActionIconSrc } from '@/utils/config'
|
||||
import HomeQuickActionBox from '../../components/HomeQuickActionBox.vue'
|
||||
import DesktopHomeCard from './DesktopHomeCard.vue'
|
||||
import { boursecuQuickActions } from '../../layouts/configs/boursecuQuickActions'
|
||||
@@ -17,23 +15,7 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
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
|
||||
})
|
||||
const resolvedActions = computed(() => props.actions ?? [...boursecuQuickActions])
|
||||
|
||||
function handleClick(action: HomeQuickActionItem) {
|
||||
if (action.handler) {
|
||||
|
||||
@@ -3,8 +3,6 @@ import { computed } from 'vue'
|
||||
import router from '@/router'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import DesktopHomeSection from './DesktopHomeSection.vue'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { getBoursecuActionIconSrc } from '@/utils/config'
|
||||
import { boursecuQuickActions } from '../../layouts/configs/boursecuQuickActions'
|
||||
import type { HomeQuickActionItem } from '../../layouts/types'
|
||||
|
||||
@@ -19,23 +17,7 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
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
|
||||
})
|
||||
const resolvedActions = computed(() => props.actions ?? [...boursecuQuickActions])
|
||||
|
||||
function navigate(action: HomeQuickActionItem) {
|
||||
if (action.handler) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import router from '@/router'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { getBoursecuActionIconSrc } from '@/utils/config'
|
||||
import HomeQuickActionBox from '../../components/HomeQuickActionBox.vue'
|
||||
import { boursecuQuickActions } from '../configs/boursecuQuickActions'
|
||||
import type { HomeQuickActionItem } from '../types'
|
||||
@@ -16,23 +14,7 @@ const props = withDefaults(
|
||||
}
|
||||
)
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
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
|
||||
})
|
||||
const resolvedActions = computed(() => props.actions ?? [...boursecuQuickActions])
|
||||
|
||||
function handleClick(action: HomeQuickActionItem) {
|
||||
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
@@ -1,280 +1,35 @@
|
||||
<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 class="piggy-page-root">
|
||||
<DesktopPiggyView v-if="isDesktop" />
|
||||
<MobilePiggyView v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import router from '@/router'
|
||||
import { useFundUserStore } from '@/store/fundStore'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { Fund } from '@/models/fund'
|
||||
import {
|
||||
formatPiggyAmount,
|
||||
fundWalletGold,
|
||||
groupFundsByProduct,
|
||||
piggyIconName,
|
||||
} from './piggyIcons'
|
||||
<script lang="ts" setup>
|
||||
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||
import DesktopPiggyView from './desktop/DesktopPiggyView.vue'
|
||||
import MobilePiggyView from './mobile/MobilePiggyView.vue'
|
||||
|
||||
const fundStore = useFundUserStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const busy = ref(false)
|
||||
|
||||
const rows = computed(() => (fundStore.myPiggyFunds?.rows ?? []) as Fund[])
|
||||
const groups = computed(() => groupFundsByProduct(rows.value))
|
||||
const totalGold = computed(() => rows.value.reduce((sum, item) => sum + fundWalletGold(item), 0))
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
function goAdd() {
|
||||
router.push({ name: 'piggy_add' })
|
||||
}
|
||||
function openInfo(id: number) {
|
||||
router.push({ name: 'piggy_info', params: { id: String(id) } })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsStore.fetchSettings()
|
||||
if (!settingsStore.isSettingTruthy('enable_product_funds')) {
|
||||
message.warning('قابلیت قلک غیرفعال است')
|
||||
router.replace({ name: 'user' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
busy.value = true
|
||||
await fundStore.getMyPiggyFunds()
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
})
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.piggy-page {
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
.piggy-page-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
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;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.piggy-page {
|
||||
padding: 16px 16px 2rem;
|
||||
}
|
||||
.piggy-page__inner {
|
||||
max-width: 28rem;
|
||||
}
|
||||
.piggy-bar {
|
||||
border-radius: 12px;
|
||||
}
|
||||
.piggy-page-root > :deep(.piggy-page),
|
||||
.piggy-page-root > :deep(.dpiggy) {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
+22
-304
@@ -1,317 +1,35 @@
|
||||
<template>
|
||||
<div class="piggy-info">
|
||||
<div class="piggy-info__inner">
|
||||
<header class="piggy-info__hero">
|
||||
<div class="piggy-info__top">
|
||||
<button type="button" class="piggy-info__btn" aria-label="بازگشت" @click="goBack">
|
||||
<BasicIcon icon="vuesax-linear:arrow-right-1" size="22" />
|
||||
</button>
|
||||
<h1 class="piggy-info__title">{{ fund?.title || 'قلک' }}</h1>
|
||||
<button type="button" class="piggy-info__btn" aria-label="حذف" @click="onDelete">
|
||||
<BasicIcon icon="vuesax-linear:trash" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="busy">
|
||||
<div class="piggy-info__balance-block">
|
||||
<p class="piggy-info__product">{{ fund?.product?.title || '—' }}</p>
|
||||
<p class="piggy-info__label">موجودی قلک</p>
|
||||
<p class="piggy-info__value" dir="ltr">
|
||||
{{ formatPiggyAmount(potGold) }}
|
||||
<span>گرم</span>
|
||||
</p>
|
||||
<p class="piggy-info__free">
|
||||
آزاد قابل واریز:
|
||||
<strong dir="ltr">{{ formatPiggyAmount(freeGold) }}</strong>
|
||||
گرم
|
||||
</p>
|
||||
</div>
|
||||
</a-spin>
|
||||
</header>
|
||||
|
||||
<div class="piggy-info__sheet">
|
||||
<div class="piggy-info__actions">
|
||||
<BasicButton class="piggy-info__action" size="middle" @click="openTx(false)">
|
||||
<div class="piggy-info__action-inner">
|
||||
<BasicIcon icon="vuesax-linear:export" size="18" />
|
||||
<span>برداشت به آزاد</span>
|
||||
</div>
|
||||
</BasicButton>
|
||||
<BasicButton class="piggy-info__action" type="primary" size="middle" @click="openTx(true)">
|
||||
<div class="piggy-info__action-inner">
|
||||
<BasicIcon icon="vuesax-linear:import" size="18" />
|
||||
<span>واریز از آزاد</span>
|
||||
</div>
|
||||
</BasicButton>
|
||||
</div>
|
||||
|
||||
<section class="piggy-info__tx">
|
||||
<h2 class="piggy-info__tx-title">تراکنشهای این قلک</h2>
|
||||
<div v-if="!transactions.length" class="piggy-info__tx-empty">
|
||||
<BasicIcon icon="vuesax-linear:receipt-2" size="28" />
|
||||
<p class="m-0 mt-2 text-sm text-gray-500">هنوز جابهجایی ثبت نشده</p>
|
||||
</div>
|
||||
<div v-else class="piggy-info__tx-list">
|
||||
<div v-for="tx in transactions" :key="tx.id" class="piggy-info__tx-row">
|
||||
<div class="piggy-info__tx-dot" />
|
||||
<div class="min-w-0 flex-1 text-right">
|
||||
<div class="truncate text-sm font-bold text-gray-900">
|
||||
{{ tx.type_str || tx.type || 'تراکنش' }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-[11px] text-gray-400">{{ tx.created_at || '' }}</div>
|
||||
</div>
|
||||
<div class="shrink-0 font-bold text-primary" dir="ltr">
|
||||
{{ formatPiggyAmount(Number(tx.gold ?? 0)) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BasicModal v-model:visible="txVisible" :title="isDeposit ? 'واریز به قلک' : 'برداشت از قلک'">
|
||||
<div class="flex flex-col gap-3 p-1">
|
||||
<p class="m-0 text-xs leading-5 text-gray-500">
|
||||
{{
|
||||
isDeposit
|
||||
? `از موجودی آزاد «${fund?.product?.title || 'محصول'}» — حداکثر ${formatPiggyAmount(freeGold)} گرم`
|
||||
: `برگرداندن به آزاد — حداکثر ${formatPiggyAmount(potGold)} گرم`
|
||||
}}
|
||||
</p>
|
||||
<BasicInput v-model:value="txValue" type="number" placeholder="مقدار به گرم" />
|
||||
<BasicButton class="!w-full" type="primary" block :disabled="busy" @click="submitTx">
|
||||
تأیید
|
||||
</BasicButton>
|
||||
</div>
|
||||
</BasicModal>
|
||||
<div class="piggy-info-root">
|
||||
<DesktopPiggyInfoView v-if="isDesktop" />
|
||||
<MobilePiggyInfoView v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import router from '@/router'
|
||||
import {
|
||||
deleteFund,
|
||||
fundDeposit,
|
||||
fundWithdraw,
|
||||
getFundById,
|
||||
getFundTransactionTable,
|
||||
} from '@/api/fund'
|
||||
import { useFundUserStore } from '@/store/fundStore'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { useWalletAssets } from '@/composables/useWalletAssets'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import type { Fund } from '@/models/fund'
|
||||
import type { Transaction } from '@/models/transaction'
|
||||
import { formatPiggyAmount } from './piggyIcons'
|
||||
<script lang="ts" setup>
|
||||
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||
import DesktopPiggyInfoView from './desktop/DesktopPiggyInfoView.vue'
|
||||
import MobilePiggyInfoView from './mobile/MobilePiggyInfoView.vue'
|
||||
|
||||
const fundStore = useFundUserStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const { assetsSummary, loadWalletAssets } = useWalletAssets()
|
||||
|
||||
const busy = ref(false)
|
||||
const fund = ref<Fund>()
|
||||
const transactions = ref<Transaction[]>([])
|
||||
const txVisible = ref(false)
|
||||
const isDeposit = ref(true)
|
||||
const txValue = ref<string | number | undefined>()
|
||||
|
||||
const fundId = computed(() => parseInt(String(router.currentRoute.value.params.id), 10))
|
||||
|
||||
const potGold = computed(() => {
|
||||
const wallets = Array.isArray(fund.value?.wallet)
|
||||
? fund.value!.wallet
|
||||
: fund.value?.wallet
|
||||
? [fund.value.wallet]
|
||||
: []
|
||||
return wallets.reduce((acc, w) => acc + Number((w as { gold?: number })?.gold ?? 0), 0)
|
||||
})
|
||||
|
||||
const freeGold = computed(() => {
|
||||
const productId = Number(fund.value?.product_id ?? 0)
|
||||
if (!productId) return 0
|
||||
const cards = assetsSummary.value?.inventory_cards ?? []
|
||||
const card = cards.find((c: any) => Number(c.product_id ?? c.id) === productId)
|
||||
if (card && card.quantity != null) return Number(card.quantity)
|
||||
return Number(assetsSummary.value?.metal_totals?.[0]?.weight ?? 0)
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
function openTx(deposit: boolean) {
|
||||
isDeposit.value = deposit
|
||||
txValue.value = undefined
|
||||
txVisible.value = true
|
||||
}
|
||||
|
||||
async function submitTx() {
|
||||
const value = Number(txValue.value)
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
message.error('مقدار نامعتبر است')
|
||||
return
|
||||
}
|
||||
try {
|
||||
busy.value = true
|
||||
const payload = { fund_id: fundId.value, value }
|
||||
if (isDeposit.value) await fundDeposit(payload)
|
||||
else await fundWithdraw(payload)
|
||||
message.success('انجام شد')
|
||||
txVisible.value = false
|
||||
await reload()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (potGold.value > 0) {
|
||||
message.error('ابتدا موجودی قلک را صفر کنید')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title: 'حذف قلک',
|
||||
content: 'آیا از حذف این قلک مطمئن هستید؟',
|
||||
okText: 'حذف',
|
||||
cancelText: 'انصراف',
|
||||
async onOk() {
|
||||
await deleteFund(fundId.value)
|
||||
await fundStore.getMyPiggyFunds()
|
||||
message.success('حذف شد')
|
||||
router.replace({ name: 'piggy' })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
fund.value = await getFundById(fundId.value)
|
||||
const walletId = Array.isArray(fund.value?.wallet)
|
||||
? fund.value?.wallet?.[0]?.id
|
||||
: (fund.value?.wallet as { id?: number } | undefined)?.id
|
||||
if (walletId) {
|
||||
const table = await getFundTransactionTable({ wallet_id: walletId } as any)
|
||||
transactions.value = (table?.rows ?? []) as Transaction[]
|
||||
} else {
|
||||
transactions.value = []
|
||||
}
|
||||
await loadWalletAssets()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsStore.fetchSettings()
|
||||
if (!settingsStore.isSettingTruthy('enable_product_funds')) {
|
||||
message.warning('قابلیت قلک غیرفعال است')
|
||||
router.replace({ name: 'user' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
busy.value = true
|
||||
await reload()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
})
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.piggy-info {
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
.piggy-info-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
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;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.piggy-info {
|
||||
padding: 16px 16px 2rem;
|
||||
}
|
||||
.piggy-info__inner {
|
||||
max-width: 26rem;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.piggy-info__hero {
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
.piggy-info__actions {
|
||||
flex-direction: row;
|
||||
}
|
||||
.piggy-info-root > :deep(.piggy-info),
|
||||
.piggy-info-root > :deep(.dpiggy-info) {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</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
@@ -1,356 +1,35 @@
|
||||
<template>
|
||||
<div class="piggy-create">
|
||||
<div class="piggy-create__inner">
|
||||
<header class="piggy-bar">
|
||||
<button type="button" class="piggy-bar__btn" aria-label="بازگشت" @click="goBack">
|
||||
<BasicIcon icon="vuesax-linear:arrow-right-1" size="22" />
|
||||
</button>
|
||||
<h1 class="piggy-bar__title">{{ editId ? 'ویرایش قلک' : 'ساخت قلک' }}</h1>
|
||||
<span class="w-9 shrink-0" />
|
||||
</header>
|
||||
|
||||
<a-spin :spinning="busy">
|
||||
<div class="piggy-create__body">
|
||||
<section class="piggy-create__step">
|
||||
<div class="piggy-create__step-head">
|
||||
<span class="piggy-create__step-num">۱</span>
|
||||
<div class="min-w-0">
|
||||
<h2 class="piggy-create__step-title">محصول</h2>
|
||||
<p class="piggy-create__step-desc">این قلک فقط برای همین محصول پر میشود</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="editId" class="piggy-create__locked">
|
||||
<BasicIcon icon="vuesax-linear:box-1" size="18" />
|
||||
<span class="truncate">{{ lockedProductTitle || 'محصول ثابت' }}</span>
|
||||
</div>
|
||||
<div v-else class="piggy-create__products">
|
||||
<button
|
||||
v-for="p in products"
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="piggy-create__product"
|
||||
:class="{ 'piggy-create__product--on': productId === p.id }"
|
||||
@click="productId = p.id"
|
||||
>
|
||||
<BasicIcon icon="vuesax-linear:box-1" size="18" />
|
||||
<span class="min-w-0 truncate">{{ p.title || `محصول ${p.id}` }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="piggy-create__step">
|
||||
<div class="piggy-create__step-head">
|
||||
<span class="piggy-create__step-num">۲</span>
|
||||
<div class="min-w-0">
|
||||
<h2 class="piggy-create__step-title">نام قلک</h2>
|
||||
<p class="piggy-create__step-desc">مثلاً پسانداز، هدیه یا ازدواج</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="piggy-create__title-row">
|
||||
<div class="piggy-create__title-icon">
|
||||
<BasicIcon :icon="piggyIconName(title)" size="22" />
|
||||
</div>
|
||||
<BasicInput v-model:value="title" class="!min-w-0 !flex-1" placeholder="عنوان قلک" maxlength="25" />
|
||||
</div>
|
||||
|
||||
<div class="piggy-create__presets">
|
||||
<button
|
||||
v-for="preset in presets"
|
||||
:key="preset"
|
||||
type="button"
|
||||
class="piggy-create__chip"
|
||||
:class="{ 'piggy-create__chip--on': title === preset }"
|
||||
@click="title = preset"
|
||||
>
|
||||
{{ preset }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<BasicButton class="!w-full" type="primary" size="middle" block :disabled="busy" @click="submit">
|
||||
{{ editId ? 'ذخیره' : 'ساخت قلک' }}
|
||||
</BasicButton>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
<div class="piggy-create-root">
|
||||
<DesktopPiggyCreateView v-if="isDesktop" />
|
||||
<MobilePiggyCreateView v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import router from '@/router'
|
||||
import { createFund, editFund, getFundById } from '@/api/fund'
|
||||
import { getProducts } from '@/api/products'
|
||||
import { useFundUserStore } from '@/store/fundStore'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { Product } from '@/models/products'
|
||||
import { PIGGY_PRESETS, piggyIconName } from './piggyIcons'
|
||||
<script lang="ts" setup>
|
||||
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||
import DesktopPiggyCreateView from './desktop/DesktopPiggyCreateView.vue'
|
||||
import MobilePiggyCreateView from './mobile/MobilePiggyCreateView.vue'
|
||||
|
||||
const fundStore = useFundUserStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const busy = ref(false)
|
||||
const title = ref('پس انداز')
|
||||
const productId = ref<number | undefined>()
|
||||
const products = ref<Product[]>([])
|
||||
const lockedProductTitle = ref('')
|
||||
const presets = [...PIGGY_PRESETS]
|
||||
|
||||
const editId = computed(() => {
|
||||
const raw = router.currentRoute.value.query.id
|
||||
const n = parseInt(String(raw ?? ''), 10)
|
||||
return Number.isFinite(n) && n > 0 ? n : 0
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!title.value.trim()) {
|
||||
message.error('عنوان قلک را وارد کنید')
|
||||
return
|
||||
}
|
||||
if (!editId.value && !productId.value) {
|
||||
message.error('محصول قلک را انتخاب کنید')
|
||||
return
|
||||
}
|
||||
try {
|
||||
busy.value = true
|
||||
if (editId.value) {
|
||||
await editFund(editId.value, { title: title.value.trim() })
|
||||
} else {
|
||||
await createFund({ title: title.value.trim(), product_id: productId.value })
|
||||
}
|
||||
await fundStore.getMyPiggyFunds()
|
||||
message.success(editId.value ? 'قلک ویرایش شد' : 'قلک ساخته شد')
|
||||
router.replace({ name: 'piggy' })
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsStore.fetchSettings()
|
||||
if (!settingsStore.isSettingTruthy('enable_product_funds')) {
|
||||
message.warning('قابلیت قلک غیرفعال است')
|
||||
router.replace({ name: 'user' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
busy.value = true
|
||||
const list = await getProducts()
|
||||
products.value = Array.isArray(list) ? list : ((list as any)?.rows ?? (list as any)?.data ?? [])
|
||||
if (editId.value) {
|
||||
const fund = await getFundById(editId.value)
|
||||
title.value = fund?.title ?? ''
|
||||
productId.value = fund?.product_id
|
||||
lockedProductTitle.value = fund?.product?.title || ''
|
||||
} else if (products.value.length === 1) {
|
||||
productId.value = products.value[0]?.id
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
})
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
</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 {
|
||||
.piggy-create-root {
|
||||
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;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
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;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.piggy-create__product--on {
|
||||
background: var(--App-primary, #0a1b37);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.piggy-create__locked {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 12px;
|
||||
background: #f9fafb;
|
||||
padding: 12px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.piggy-create__title-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.piggy-create__title-icon {
|
||||
display: flex;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: #f9fafb;
|
||||
color: var(--App-primary, #0a1b37);
|
||||
}
|
||||
|
||||
.piggy-create__presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.piggy-create__chip {
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: #f3f4f6;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.75rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.piggy-create__chip--on {
|
||||
background: var(--App-primary, #0a1b37);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.piggy-create {
|
||||
padding: 16px 16px 2rem;
|
||||
}
|
||||
|
||||
.piggy-create__inner {
|
||||
max-width: 26rem;
|
||||
}
|
||||
|
||||
.piggy-bar {
|
||||
border-radius: 12px;
|
||||
}
|
||||
.piggy-create-root > :deep(.piggy-create),
|
||||
.piggy-create-root > :deep(.dpiggy-create) {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
class="dtxn-row__amount"
|
||||
: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 class="dtxn-row__status">
|
||||
<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">
|
||||
{{ usePriceWithoutType(props.item.cash ?? 0) }}
|
||||
</div>
|
||||
ریال
|
||||
{{ amountSuffix.trim() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,6 +130,12 @@ const props = withDefaults(
|
||||
const tradePrice = computed(() => getTransactionTradePrice(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(
|
||||
() =>
|
||||
props.item.type === 3
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ref } from 'vue'
|
||||
import { getTransactionTable, getTransactionTable2 } from '@/api/transaction'
|
||||
import { getPhysicalReceiveList } from '@/api/physicalreceive'
|
||||
import {
|
||||
fetchMyGiftCards,
|
||||
type CustomerGiftCardItem,
|
||||
} from '@/api/giftCard'
|
||||
import type { TableEngineInput, TableEngineOutput } from '@/models/table'
|
||||
import type { Transaction } from '@/models/transaction'
|
||||
import type { Physicalreceive } from '@/models/physicalreceive'
|
||||
@@ -13,6 +17,54 @@ import {
|
||||
type BoursecuTransactionTabId,
|
||||
} 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). */
|
||||
let activeFetchId = 0
|
||||
|
||||
@@ -60,10 +112,72 @@ function resolveTransactionFetcher() {
|
||||
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> {
|
||||
const fetchId = ++activeFetchId
|
||||
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) {
|
||||
loading.value = true
|
||||
@@ -73,6 +187,14 @@ async function fetchTransactionsPage(append: boolean): Promise<boolean> {
|
||||
}
|
||||
|
||||
try {
|
||||
if (giftKind) {
|
||||
// لیست کامل از mine؛ صفحهبندی سمت سرور ندارد
|
||||
if (append) {
|
||||
return false
|
||||
}
|
||||
return await fetchGiftCodeTransactionsPage(fetchId, giftKind)
|
||||
}
|
||||
|
||||
const output = await resolveTransactionFetcher()(transactionPagination.value)
|
||||
if (fetchId !== activeFetchId) {
|
||||
return false
|
||||
@@ -128,6 +250,11 @@ export async function loadMoreTransactions() {
|
||||
return
|
||||
}
|
||||
|
||||
const fetchMode = getActiveBoursecuTab().fetchMode
|
||||
if (fetchMode === 'giftcard' || fetchMode === 'discount') {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!transactionTableOutput.value
|
||||
|| (transactionPagination.value.page + 1) * transactionPagination.value.limit
|
||||
|
||||
@@ -5,10 +5,17 @@ export type BoursecuTransactionTabId =
|
||||
| 'deposit'
|
||||
| 'withdrawal'
|
||||
| 'physical'
|
||||
| 'giftcard'
|
||||
| 'discount'
|
||||
|
||||
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 BoursecuFulfillmentStatusFilter = 'delivered' | 'registered' | 'cancelled'
|
||||
@@ -78,6 +85,18 @@ export const BOURSECU_TRANSACTION_TABS: BoursecuTransactionTab[] = [
|
||||
fetchMode: 'physical',
|
||||
showAdvancedFilters: true,
|
||||
},
|
||||
{
|
||||
id: 'giftcard',
|
||||
label: 'کارت هدیه',
|
||||
emptyText: 'کارت هدیهای برای شما ثبت نشده است',
|
||||
fetchMode: 'giftcard',
|
||||
},
|
||||
{
|
||||
id: 'discount',
|
||||
label: 'کد تخفیف',
|
||||
emptyText: 'کد تخفیفی برای شما ثبت نشده است',
|
||||
fetchMode: 'discount',
|
||||
},
|
||||
]
|
||||
|
||||
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 { normalizeAuthFormFields } from '@/utils/persianDigits'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { useSupportMessenger } from '@/composables/useSupportMessenger'
|
||||
import { useTrackingSystemsFeature } from '@/composables/useTrackingSystemsFeature'
|
||||
import { openGoldDayCalculator } from '@/composables/useGoldDayCalculator'
|
||||
import { isUserSupportChatEnabled, unreadSupportCount } from '@/utils/chat'
|
||||
@@ -88,7 +87,6 @@ export function useUserPage() {
|
||||
const editPasswordVisible = ref(false)
|
||||
const showLogoutConfirm = ref(false)
|
||||
const localAvatarUrl = ref<string | null>(null)
|
||||
const { openSupportMessenger } = useSupportMessenger()
|
||||
const { trackingSystemsEnabled } = useTrackingSystemsFeature()
|
||||
|
||||
const profileAvatarUrl = computed(
|
||||
@@ -159,9 +157,7 @@ export function useUserPage() {
|
||||
id: 'support',
|
||||
label: 'پشتیبانی',
|
||||
icon: 'vuesax-linear:messages-2',
|
||||
onClick: () => {
|
||||
void openSupportMessenger()
|
||||
},
|
||||
onClick: () => router.push({ name: 'support' }),
|
||||
visible: isUserSupportChatEnabled(),
|
||||
badge: unreadSupportCount.value > 0 ? unreadSupportCount.value : undefined,
|
||||
},
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
</section>
|
||||
|
||||
<section class="duser-surface duser-actions">
|
||||
<h3 class="duser-actions__title">تنظیمات و خدمات</h3>
|
||||
<h3 class="duser-actions__title">امکانات و خدمات</h3>
|
||||
<div class="duser-actions__list">
|
||||
<button
|
||||
v-for="action in menuActions"
|
||||
|
||||
@@ -407,17 +407,14 @@ async function login(accessToken: string | undefined) {
|
||||
setToken(accessToken)
|
||||
syncPriceRealtimeChannel((key) => setting.getSettingByKey(key))
|
||||
await userStore.getMyInfo()
|
||||
// console.log(URLUserType())
|
||||
// console.log(userStore.userInfo?.type)
|
||||
/* if (userStore.userInfo?.type === (URLUserType() === 'customer' ? 1 : 2)) {*/
|
||||
if (!userStore.userInfo) {
|
||||
removeToken()
|
||||
message.error('ورود انجام شد ولی دریافت اطلاعات کاربر ممکن نشد. اتصال سرور را بررسی کنید.')
|
||||
return
|
||||
}
|
||||
await walletStore.getMyWallet()
|
||||
setTruth()
|
||||
await completeLoginNavigation()
|
||||
/* } else {
|
||||
removeToken()
|
||||
userStore.removeInformation()
|
||||
message.error('شما مجوز ورود به این بخش را ندارید')
|
||||
}*/
|
||||
}
|
||||
|
||||
function phoneVerification() {
|
||||
|
||||
@@ -92,6 +92,10 @@ export function useLoginForm() {
|
||||
async function login(accessToken: string | undefined) {
|
||||
setToken(accessToken)
|
||||
await userStore.getMyInfo()
|
||||
if (!userStore.userInfo) {
|
||||
message.error('ورود انجام شد ولی دریافت اطلاعات کاربر ممکن نشد. اتصال سرور را بررسی کنید.')
|
||||
return
|
||||
}
|
||||
await walletStore.getMyWallet()
|
||||
setTruth()
|
||||
await completeLoginNavigation()
|
||||
|
||||
@@ -174,9 +174,9 @@
|
||||
<div class="support-composer__row flex items-end gap-2">
|
||||
<BasicButton
|
||||
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"
|
||||
@mousedown.prevent
|
||||
@click="submit"
|
||||
>
|
||||
<BasicIcon icon="vuesax-linear:direct-right" size="20" />
|
||||
@@ -197,9 +197,9 @@
|
||||
|
||||
<BasicButton
|
||||
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"
|
||||
@mousedown.prevent
|
||||
@click="openFilePicker"
|
||||
>
|
||||
<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 canSend = computed(
|
||||
() => props.modelValue.trim().length > 0 || attachments.value.length > 0,
|
||||
)
|
||||
|
||||
const getReplySenderLabel = (sender: string) => {
|
||||
if (sender === 'user') return 'خودتان'
|
||||
if (sender === 'support') return 'پشتیبانی'
|
||||
@@ -535,7 +539,7 @@ const submit = async () => {
|
||||
replyTo: props.replyingTo ?? null
|
||||
})
|
||||
|
||||
emit('update:modelValue', '')
|
||||
// متن را والد بعد از موفقیت پاک میکند؛ پیوستها را همینجا خالی میکنیم
|
||||
clearComposerAttachments()
|
||||
|
||||
await nextTick()
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<template>
|
||||
<header class="support-header">
|
||||
<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" />
|
||||
</div>
|
||||
</button>
|
||||
<div class="support-header__icon">
|
||||
<img
|
||||
v-if="logoUrl"
|
||||
@@ -37,23 +42,34 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { useConfigStore } from '@/store/configStore'
|
||||
import { getProjectSplashLogo } from '@/utils/config'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
|
||||
const config = useConfigStore()
|
||||
const logoUrl = computed(() => String(config.config?.avatar || '').trim())
|
||||
const router = useRouter()
|
||||
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>
|
||||
|
||||
<style scoped lang="less">
|
||||
.support-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
z-index: 20;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--colorPrimary, #2563eb) 10%, transparent);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
@@ -74,6 +90,20 @@ const logoUrl = computed(() => String(config.config?.avatar || '').trim())
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -95,7 +125,6 @@ const logoUrl = computed(() => String(config.config?.avatar || '').trim())
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
padding: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.support-header__icon-svg {
|
||||
|
||||
@@ -272,8 +272,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BasicButton
|
||||
type="primary"
|
||||
<button
|
||||
type="button"
|
||||
class="support-reply-button"
|
||||
:class="
|
||||
message.sender === 'user'
|
||||
@@ -281,14 +281,12 @@
|
||||
: 'support-reply-button--support'
|
||||
"
|
||||
title="پاسخ"
|
||||
aria-label="پاسخ"
|
||||
@pointerdown.stop
|
||||
@pointerup.stop
|
||||
@pointercancel.stop
|
||||
@pointermove.stop
|
||||
@click.stop="emit('reply', message)"
|
||||
@click.stop.prevent="emit('reply', message)"
|
||||
>
|
||||
<BasicIcon icon="vuesax-linear:undo" size="16" />
|
||||
</BasicButton>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -336,7 +334,6 @@
|
||||
import { computed, nextTick, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import type { ChatMessage } from '@/models/support'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
import { useConfigStore } from '@/store/configStore'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -1210,16 +1207,17 @@ defineExpose({
|
||||
padding: 0 !important;
|
||||
border: 1px solid color-mix(in srgb, var(--colorPrimary, #2563eb) 18%, transparent) !important;
|
||||
border-radius: 999px !important;
|
||||
background: color-mix(in srgb, var(--bg, #fff) 92%, transparent) !important;
|
||||
background: #fff !important;
|
||||
color: var(--colorPrimary, #2563eb) !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 18px color-mix(in srgb, var(--colorPrimary, #2563eb) 10%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(0.92);
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
@@ -1229,19 +1227,13 @@ defineExpose({
|
||||
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 {
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.support-reply-button--support {
|
||||
order: -1;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.support-avatar {
|
||||
|
||||
+203
-34
@@ -1,28 +1,35 @@
|
||||
<template>
|
||||
<div class="support-page main-with-bottom-chrome-inner--contained">
|
||||
<div class="support-shell">
|
||||
<SupportChatHeader title="پشتیبانی" />
|
||||
<div
|
||||
class="support-page main-with-bottom-chrome-inner--contained"
|
||||
:class="{ 'support-page--desktop': isDesktop }"
|
||||
>
|
||||
<DesktopSidebar v-if="isDesktop" class="support-page__sidebar" />
|
||||
|
||||
<SupportChatMessages
|
||||
ref="messagesRef"
|
||||
:messages="messages"
|
||||
:support-typing="supportTyping"
|
||||
:has-more="hasMoreMessages"
|
||||
:loading-older="isFetchingOlderMessages"
|
||||
:loading="chatLoading"
|
||||
:loading-label="loadingLabel"
|
||||
@load-older="loadOlderMessages"
|
||||
@reply="handleReply"
|
||||
@bottom-state-change="setSupportChatAtBottom"
|
||||
/>
|
||||
<div class="support-page__main">
|
||||
<div class="support-shell">
|
||||
<SupportChatHeader title="پشتیبانی" />
|
||||
|
||||
<SupportChatComposer
|
||||
v-model="draft"
|
||||
:disabled="isSending"
|
||||
:replying-to="replyingTo"
|
||||
@cancel-reply="clearReply"
|
||||
@send="sendMessage"
|
||||
/>
|
||||
<SupportChatMessages
|
||||
ref="messagesRef"
|
||||
:messages="messages"
|
||||
:support-typing="supportTyping"
|
||||
:has-more="hasMoreMessages"
|
||||
:loading-older="isFetchingOlderMessages"
|
||||
:loading="chatLoading"
|
||||
:loading-label="loadingLabel"
|
||||
@load-older="loadOlderMessages"
|
||||
@reply="handleReply"
|
||||
@bottom-state-change="setSupportChatAtBottom"
|
||||
/>
|
||||
|
||||
<SupportChatComposer
|
||||
v-model="draft"
|
||||
:disabled="isSending"
|
||||
:replying-to="replyingTo"
|
||||
@cancel-reply="clearReply"
|
||||
@send="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -32,6 +39,8 @@ import { computed, nextTick, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import SupportChatHeader from './components/SupportChatHeader.vue'
|
||||
import SupportChatMessages from './components/SupportChatMessages.vue'
|
||||
import SupportChatComposer from './components/SupportChatComposer.vue'
|
||||
import DesktopSidebar from '@/layouts/desktop/DesktopSidebar.vue'
|
||||
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||
import type {
|
||||
ChatAttachment,
|
||||
ChatMessage,
|
||||
@@ -55,7 +64,9 @@ import {
|
||||
canRequestBrowserNotification,
|
||||
requestBrowserNotificationPermission,
|
||||
} from '@/utils/browserNotification'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
const draft = ref('')
|
||||
const isSending = ref(false)
|
||||
const supportTyping = ref(false)
|
||||
@@ -382,10 +393,12 @@ const toReplyPreview = (message: ChatMessage): ChatReplyPreview => {
|
||||
|
||||
const handleReply = async (message: ChatMessage) => {
|
||||
replyingTo.value = toReplyPreview(message)
|
||||
|
||||
console.log('replying to:', replyingTo.value)
|
||||
|
||||
await nextTick()
|
||||
// فوکوس روی کادر نوشتن تا پیشنمایش پاسخ دیده شود
|
||||
const textarea = document.querySelector(
|
||||
'.support-composer textarea',
|
||||
) as HTMLTextAreaElement | null
|
||||
textarea?.focus()
|
||||
}
|
||||
|
||||
const clearReply = () => {
|
||||
@@ -512,6 +525,7 @@ const messages = computed<ChatMessage[]>(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
setSupportChatPageOpen(true)
|
||||
document.documentElement.classList.add('support-page-active')
|
||||
|
||||
if (canRequestBrowserNotification()) {
|
||||
void requestBrowserNotificationPermission()
|
||||
@@ -537,6 +551,7 @@ onMounted(async () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
setSupportChatPageOpen(false)
|
||||
document.documentElement.classList.remove('support-page-active')
|
||||
})
|
||||
|
||||
const loadOlderMessages = async () => {
|
||||
@@ -662,13 +677,15 @@ const sendMessage = async (payload: ComposerSendPayload) => {
|
||||
}
|
||||
|
||||
clearReply()
|
||||
draft.value = ''
|
||||
|
||||
await nextTick()
|
||||
messagesRef.value?.scrollToBottom(true)
|
||||
} catch (error) {
|
||||
console.error('send message failed:', error)
|
||||
draft.value = text
|
||||
message.error('ارسال پیام انجام نشد. اتصال پشتیبانی را بررسی کنید.')
|
||||
} finally {
|
||||
draft.value = ''
|
||||
isSending.value = false
|
||||
}
|
||||
}
|
||||
@@ -676,14 +693,30 @@ const sendMessage = async (payload: ComposerSendPayload) => {
|
||||
|
||||
<style scoped lang="less">
|
||||
.support-page {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
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 {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
@@ -711,16 +744,152 @@ const sendMessage = async (payload: ComposerSendPayload) => {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.support-page {
|
||||
height: calc(100dvh - 4.5rem);
|
||||
padding: 12px 16px;
|
||||
.support-page__sidebar {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
/* زیر آستانه دسکتاپ: تمامعرض، بدون منو */
|
||||
@media screen and (max-width: 767.98px) {
|
||||
html.support-page-active {
|
||||
body {
|
||||
display: block !important;
|
||||
justify-content: unset !important;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
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-shell {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgb(var(--colorPrimaryRGB, 37 99 235) / 0.12);
|
||||
box-shadow: 0 16px 48px rgb(15 23 42 / 0.06);
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user