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