93 Commits
Author SHA1 Message Date
mohmdsdg d7a743ba09 feat(trade): separate gross price, fee and net payable into three colored rows 2026-10-11 22:44:10 +03:30
mohmdsdgandCursor 751d9a34e3 Reuse the live price stream for the product catalog and physical delivery list.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 22:11:55 +03:30
mohmdsdgandCursor 6facf5d72d Show greater price alerts above the product name and smaller ones below.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 20:09:45 +03:30
mohmdsdg da68f0a582 Reload wallet assets after a physical delivery request.
The kashkool balance updates with the same assets refresh as the other user apps.
2026-09-29 20:20:28 +03:30
mohmdsdg 9cb0979606 chore: sync price monitor updates 2026-09-28 18:04:49 +03:30
mohmdsdg f778064aef chore: sync latest updates 2026-09-28 14:52:43 +03:30
mohmdsdg 1ac5095177 chore: sync project updates 2026-09-28 14:12:30 +03:30
mohmdsdgandCursor bc64ff47b2 Open the price stream on the TV board with the TV key header.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 22:27:46 +03:30
mohmdsdgandCursor 8083c4f06d Match the TV header quote to the product list buy and sell prices.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 21:42:17 +03:30
mohmdsdgandCopilot 7770c763cc chore: sync graphify and UI updates
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-22 11:36:14 +03:30
mohmdsdgandCursor b4a8546ce6 Stop applying special pricing twice on live quotes so sell stays below buy.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 23:59:55 +03:30
mohmdsdgandCursor 3b83eac099 Keep gold quotes live from last and apply SSE catalog list so cards refresh without a page reload.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 18:20:06 +03:30
mohmdsdgandCursor c623a32bb5 fix: colleague price products use DealProduct, not indexGold
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 22:11:59 +03:30
mohmdsdgandCursor 03f509d83e Keep the TV MontZar credit logo small and load it from bundled assets.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 12:23:13 +03:30
mohmdsdgandCursor a33f130341 Prefer password login on the home screen with register and forgot-password actions.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 17:33:28 +03:30
mohmdsdgandCursor 7ab78bd550 Stop multiplying colleague prices by weight and fee from unit price.
Colleague quotes are per piece, and the settings service charge is a percent of that price times count.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 22:21:08 +03:30
mohmdsdg 71da064cab fix some things 2026-09-14 12:57:53 +03:30
mohmdsdgandCursor 569e62fef4 Hide redundant amount label between product title and price.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 20:43:45 +03:30
mohmdsdgandCursor fcc7c70310 Merge origin/kashkool into kashkool.
Keep the /gold trade product fix and bring in the remote catalog, account menu, and product-page updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 18:38:12 +03:30
mohmdsdg fdc24083cf fix price 2026-09-13 18:33:38 +03:30
mohmdsdg 6f725bc0ef fix some bugs from ghatreh 2026-09-13 16:32:37 +03:30
mohmdsdgandCursor f6b1044a17 Drive the account menu from settings and show notification/permission actions.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 13:25:29 +03:30
mohmdsdgandCursor f52575db9c Show the price-alert action on the account page when enabled.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 11:54:55 +03:30
mohmdsdgandCursor 7f8ae8bd74 Fix single TV news headline showing twice and keep empty news hidden.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 17:30:48 +03:30
mohmdsdg b211c8773b tv changes 2026-09-11 16:44:28 +03:30
mohmdsdg b78fbbdc4e Hide after-hours prices and skip stock checks when the shop is not a store. 2026-09-11 00:49:29 +03:30
mohmdsdg 0da9f68353 better theme for tv 2026-09-10 19:46:35 +03:30
mohmdsdgandCursor d4e3a99e61 Let melted products trade without store inventory, and split TV sell prices with extra tickers.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 15:10:10 +03:30
mohmdsdgandCursor c2f6f9d226 fix(register): hide or require referral from settings like other apps
Also keep exact trade grams from rial and poll colleague wait every 10s.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 21:03:32 +03:30
mohmdsdgandCursor 0f6d8b37f4 Match SSE ping comments to the 20s server heartbeat.
Rename the equals price-trigger condition to reach-this-price.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 18:11:26 +03:30
mohmdsdgandCursor 5015fb2838 fix(trade): keep exact grams when the input shows rounded display
Percent and cash conversions no longer lose precision by rounding the gram field to three decimals.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 14:56:14 +03:30
mohmdsdgandCursor e9abc9840a Close colleague wait modal when order is no longer pending.
Settle on pending:false or any non-waiting status, and stop polling after a max wait so rejected orders do not leave the overlay stuck.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 22:49:32 +03:30
mohmdsdgandCursor 47beece729 fix(logo): keep absolute nerkh avatar URLs intact
Stop rewriting legacy upload hosts to SuperAdmin; only prefix relative /uploads/ paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 20:10:11 +03:30
mohmdsdgandCursor 7ab391d48f fix: rewrite nerkh logo URLs to the SuperAdmin host
App logos now follow get_user.user.avatar instead of leftover nerkh.gold paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 16:56:54 +03:30
mohmdsdgandCursor 065a0b3178 fix(tv): show board prices from tv_price instead of list price
The monitor uses the TV formula field so the catalog list price stays unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 21:35:54 +03:30
mohmdsdgandCursor 4104235546 feat: landing settings helper plus wallet and profile UX fixes
Expose landing_* readers, fix OTP contrast on dark modal, and keep auth badge/card submit loading consistent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 17:02:22 +03:30
mohmdsdgandCursor bc2acc3eb6 fix(trade): use stream closed and trade_open instead of device clock
Changing the phone time can no longer open a product that the server has closed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 12:58:19 +03:30
mohmdsdgandCursor 2d3c590c71 fix(products): ignore foreign SSE catalogs
Drop list events from other shops and honor show_in_list so the grid stops jumping.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 10:58:54 +03:30
mohmdsdgandCursor cb9762b0d6 fix(products): treat SSE list as full active catalog
Stop keeping deactivated products when the stream shrinks, update prices in place to avoid grid flicker, and only keep admin-active items.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 10:12:16 +03:30
mohmdsdgandCursor 3e15cb1481 fix(products): hide empty categories and stabilize catalog list
Only show active products, keep partial SSE lists from dropping items, and hide category tabs with no products.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 09:55:32 +03:30
mohmdsdg 57780ae99c fix bug 2026-09-04 01:50:59 +03:30
mohmdsdgandCursor 3dda7fcf60 fix(build): drop missing transactions page data import.
Ghatreh lacks useTransactionsPageData; the dynamic import broke Vite production builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 00:43:00 +03:30
mohmdsdgandCursor bddd591d63 feat(boot): use boot-loader as splash with cached tenant logo
Remove the Vue splash overlay and unused duplicate public logos from dist output.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 20:24:44 +03:30
mohmdsdgandCursor 0d19412c96 feat(trade): wait on colleague order until status is final
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:55:08 +03:30
mohmdsdgandCursor 164bb267f1 fix(trade): show three gram decimals when converting from rial
Keep the rial-locked order amount unchanged; only the gram input display is capped.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 15:45:13 +03:30
mohmdsdgandCursor 8929501be5 Show pending-admin messages from profile and bank-card save responses.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 21:35:11 +03:30
mohmdsdgandCursor 383d371d00 Update auto-generated Vue component type declarations.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 21:19:27 +03:30
mohmdsdgandCursor 5b06034255 Show accounting adjust notes on transactions and polish input/lottie boot.
Transaction detail surfaces note text for adjustments; Persian digit input and local Lottie preload are included.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 21:18:29 +03:30
mohmdsdgandCursor 449fad5ed1 fix(price): delay empty-quote UI until settle window
Avoid flashing no-quote too early while waiting for live/SSE price after first empty response.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 18:47:42 +03:30
mohmdsdgandCursor 059ba0b565 fix(files): join storage URLs onto /server so public images load
Relative storage paths were resolved on the SPA host instead of the API, so uploaded files 404'd.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 18:01:25 +03:30
mohmdsdgandCursor 4675a5b5e3 fix(home): show quote skeleton while loading and harden auth/images
Prefer server quote status, avoid false no-quote before ready, toast on 401 re-login, and keep product/banner image URLs absolute.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 16:59:29 +03:30
mohmdsdgandCursor ff3a1cb00b fix(products): absolutize image URLs and preserve them on SSE merge
Relative full_src now uses node_server, and empty live-list file payloads no longer wipe catalog images.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 20:49:53 +03:30
mohmdsdgandCursor 29579f80d3 fix(products): keep newly uploaded image when SSE list is stale
The live product stream was replacing the catalog with an older payload, so the new photo flashed then jumped back. Keep the newer file_id and stop resetting the img on unrelated price ticks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 16:14:49 +03:30
mohmdsdgandCursor 16ade71598 feat(user): gate menus by SuperAdmin flags and show physical delivery
Hide fund/earn/gift entries when access is off, add matching route guards, and surface physical delivery instead of price alert on mobile profile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 14:19:26 +03:30
mohmdsdgandCursor 2238a34c53 fix(price): hide stored fallback when last-price display is off
Treat only live quote sources as open; a leftover database price should not stay on screen after the admin switch is turned off.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 03:01:10 +03:30
mohmdsdgandCursor ea71591557 fix(price): apply live trading-hours settings without refresh
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 02:29:14 +03:30
mohmdsdg e87ba066db feat(user): about software page, enamad badge, and deal sheet UI polish
Add public about-software flow, login-only enamad, and deal buy/sell UI refinements for the store user app.
2026-08-24 19:39:07 +03:30
mohmdsdg bb4bcdf600 fix tv 3 2026-08-22 22:35:10 +03:30
mohmdsdg 1171390996 fix tv 2026-08-22 22:05:59 +03:30
mohmdsdg be59cca162 fix tv 2026-08-22 21:50:39 +03:30
mohmdsdgandCursor 444178df05 feat: add gold day calculator and polish desktop UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 21:35:18 +03:30
MojtabaSoumi d2737a4998 1405/05/29-add tv page 2026-08-20 10:06:55 +03:30
mohmdsdgandCursor 866ffbf774 fix(fee): default service fee to 0 when setting is empty
Read fee_service_charge_percentage only; no implicit 0.01 fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 19:55:42 +03:30
mohmdsdgandCursor 66807fca8d fix(price-trigger): SMS-only alerts in ghatreh user app
Match khaneghi_user price trigger SMS-only behavior and UI copy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 19:02:46 +03:30
mohmdsdgandCursor 47ac13b114 feat: mobile keyboard auto-scroll for input fields
- initMobileInputKeyboardScroll utility using visualViewport API
- mobile-keyboard-scroll.less for scroll-margin and padding adjustments

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:52:00 +03:30
mohmdsdgandCursor e578ba521d feat: add product select to price alerts
Customer can pick a product when creating an alert and see it on the card.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 22:23:25 +03:30
mohmdsdgandCursor e01192fb5e feat: align fee summary, special pricing and support chat with modular
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 17:57:22 +03:30
mohmdsdgandCursor e9c5830d4e fix: merge SuperAdmin settings over local can_edit locks
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 17:57:22 +03:30
mohmdsdgandCursor 9d391aeb95 feat: show selected products on public /tv board
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 17:16:59 +03:30
mohmdsdgandCursor a9d0cba909 feat: load program-settings from SuperAdmin with 3-host failover
Use /api/public/program-settings only and retry montzar/montech/montest
three rounds before showing the bootstrap error.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 16:10:35 +03:30
mohmdsdgandCursor 5d3ca82236 feat: align piggy, support chat and numeric input with modular app
Persian keyboards send ٫ ، and / as decimal or thousand separators, so amount
and weight fields now normalize input before parsing. Support chat reopens an
existing thread instead of starting a new one, and axios surfaces the server
message on failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 22:17:17 +03:30
mohmdsdgandCursor d2183c9a3c feat: failover SuperAdmin domains for get_user/get_version
Replace nerkh.gold with adm.montzar/montech/montest and prefer last successful URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:13:42 +03:30
MojtabaSoumi 4361e71030 1405/05/15-change redirect route 2026-08-06 11:54:35 +03:30
mohmdsdg 46807e3089 added gholak 2026-08-03 22:36:23 +03:30
mohmdsdg d6608283f6 fix some bugs 2026-08-03 11:52:44 +03:30
mohmdsdg e41a898686 fix pargar and login 2 2026-08-01 14:10:21 +03:30
mohmdsdg 0cd3772517 fix pargar and login 2026-08-01 14:04:26 +03:30
mohmdsdg 5b0bb45e52 added chat 2 2026-07-30 18:33:08 +03:30
mohmdsdg 106b0ebbdf fix some bugs from accounting 7 2026-07-28 21:14:34 +03:30
mohmdsdg a49b20dfd4 fix some bugs from accounting 7 2026-07-28 21:02:44 +03:30
mohmdsdg 7cd3fac28b fix pargar mande 2026-07-25 12:13:30 +03:30
mohmdsdg 9319906737 fix some bugs 7 2026-07-22 12:20:39 +03:30
mohmdsdg 0106c94d24 fix some bugs 2026-07-19 21:49:31 +03:30
MojtabaSoumi 37d86ba36e 1405/04/27
-responive bugs
2026-07-18 15:46:38 +03:30
MojtabaSoumi c2a355f2de 1405/04/27
-
2026-07-18 11:30:40 +03:30
mohmdsdg bc80ddc1f1 Merge branch 'kashkool' of https://get.feep.ir/ghatreh/ghatreh_user into kashkool 2026-07-16 15:22:50 +03:30
mohmdsdg 362040a649 update 2026-07-16 15:21:53 +03:30
MojtabaSoumi a5c714a15b 1405/04/25
-
2026-07-16 12:00:57 +03:30
mohmdsdg 5d390fb225 update 2026-07-16 11:14:58 +03:30
mohmdsdg 51e3c3f0cb added pargar 2026-07-05 20:49:01 +03:30
mohmdsdg e4d92831c2 fix bugs 2026-07-02 16:26:08 +03:30
mohmdsdg 3561e8ae07 Merge remote-tracking branch 'origin/kashkool' into kashkool 2026-07-02 09:43:22 +03:30
mohmdsdg ce8029919c fix bugs 2026-07-01 22:29:18 +03:30
284 changed files with 63462 additions and 2720 deletions
+1
View File
@@ -0,0 +1 @@
graphify-out/graph.json merge=graphify
+11
View File
@@ -13,9 +13,11 @@ declare module 'vue' {
ADivider: typeof import('ant-design-vue/es')['Divider']
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AImage: typeof import('ant-design-vue/es')['Image']
AInput: typeof import('ant-design-vue/es')['Input']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
AModal: typeof import('ant-design-vue/es')['Modal']
AppCreditLink: typeof import('./src/components/AppCreditLink.vue')['default']
ARadio: typeof import('ant-design-vue/es')['Radio']
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
@@ -50,13 +52,18 @@ declare module 'vue' {
Disabled: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/disabled.vue')['default']
DisabledDate: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/disabled-date.vue')['default']
Empty: typeof import('./src/components/empty.vue')['default']
EnamadBadge: typeof import('./src/components/EnamadBadge.vue')['default']
ExtraFooter: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/extra-footer.vue')['default']
Format: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/format.vue')['default']
GoldDayCalculatorModal: typeof import('./src/components/GoldDayCalculatorModal.vue')['default']
GoldLoading: typeof import('./src/components/GoldLoading.vue')['default']
InstallButton: typeof import('./src/components/InstallButton.vue')['default']
ListAnim: typeof import('./src/components/ListAnim.vue')['default']
Mode: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/mode.vue')['default']
PresettedRanges: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/presetted-ranges.vue')['default']
ProductImage: typeof import('./src/components/ProductImage.vue')['default']
PwaInstallGuideModal: typeof import('./src/components/pwa/PwaInstallGuideModal.vue')['default']
PwaInstallStepIllustration: typeof import('./src/components/pwa/PwaInstallStepIllustration.vue')['default']
RangePicker: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/range-picker.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
@@ -64,9 +71,13 @@ declare module 'vue' {
Size: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/size.vue')['default']
StartEnd: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/start-end.vue')['default']
Suffix: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/suffix.vue')['default']
SupportContactBar: typeof import('./src/components/SupportContactBar.vue')['default']
SupportMessengerBrandIcon: typeof import('./src/components/SupportMessengerBrandIcon.vue')['default']
SupportMessengerPicker: typeof import('./src/components/SupportMessengerPicker.vue')['default']
Switchable: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/switchable.vue')['default']
Text: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/text.vue')['default']
Time: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/time.vue')['default']
TradeFeeConfirmSummary: typeof import('./src/components/TradeFeeConfirmSummary.vue')['default']
TransactionSkeleton: typeof import('./src/components/TransactionSkeleton.vue')['default']
VanActionSheet: typeof import('vant/es')['ActionSheet']
VanDialog: typeof import('vant/es')['Dialog']
+12
View File
@@ -0,0 +1,12 @@
{
"0": "useTvMonitorPage",
"1": "ref_utils",
"2": "index.vue",
"3": "productPrice.ts",
"4": "useNewPhysicalRequestPage.ts",
"5": "api/products.ts",
"6": "priceSocket.ts",
"7": "priceSocketState.ts",
"8": "useProductsPageData.ts",
"9": "AuthPriceStream"
}
+1
View File
@@ -0,0 +1 @@
{"0": "d7734412cf0800ce", "1": "67d1701e4c6cea74", "2": "a1988eda2ebb6afe", "3": "18bc618fd6f328bb", "4": "b16e1aea09ac331f", "5": "8d51306eb7d0daf2", "6": "fe2bcdb916bbc9f9", "7": "ac69b3434e72782c", "8": "004ed482633014da", "9": "08b88f1fc8c91c56"}
+1
View File
@@ -0,0 +1 @@
.
@@ -0,0 +1,10 @@
{
"0": "useTvMonitorPage",
"1": "useTvMonitorPage.ts",
"2": "index.vue",
"3": "productPrice.ts",
"4": "getProductBaseBuyPrice",
"5": "ref_models",
"6": "isTvProductPriceInactive",
"7": "applyPriceOffsetPercent"
}
+140
View File
@@ -0,0 +1,140 @@
# Graph Report - ghatreh_user (2026-09-27)
## Corpus Check
- 598 files · ~506,745 words
- Verdict: corpus is large enough that graph structure adds value.
- Unclassified: 51 file(s) not represented in the graph (top: .less 25, .eot 6, .ttf 6)
## Summary
- 84 nodes · 154 edges · 8 communities
- Extraction: 91% EXTRACTED · 9% INFERRED · 0% AMBIGUOUS · INFERRED: 14 edges (avg confidence: 0.85)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `8083c4f0`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
## Community Hubs (Navigation)
- useTvMonitorPage
- useTvMonitorPage.ts
- index.vue
- productPrice.ts
- getProductBaseBuyPrice
- ref_models
- isTvProductPriceInactive
- applyPriceOffsetPercent
## God Nodes (most connected - your core abstractions)
1. `useTvMonitorPage()` - 25 edges
2. `getProductBaseBuyPrice()` - 10 edges
3. `getProductBuyPrice()` - 7 edges
4. `getProductBaseSellPrice()` - 6 edges
5. `getProductSellPrice()` - 6 edges
6. `productUsesLastPriceIfZero()` - 5 edges
7. `applyPriceOffsetPercent()` - 5 edges
8. `isProductPriceInactive()` - 5 edges
9. `formatTvPrice()` - 5 edges
10. `cashFeeOptions()` - 4 edges
## Surprising Connections (you probably didn't know these)
- `useTvMonitorPage()` --indirect_call--> `isShowOnTv()` [INFERRED]
src/views/tv/composables/useTvMonitorPage.ts → src/views/tv/composables/useTvMonitorPage.ts _Bridges community 1 → community 0_
- `colleagueSideUnit()` --calls--> `getColleagueSourcedUnitPrice()` [EXTRACTED]
src/utils/productPrice.ts → src/utils/productPrice.ts _Bridges community 4 → community 7_
- `getProductBuyPrice()` --calls--> `getProductBaseBuyPrice()` [EXTRACTED]
src/utils/productPrice.ts → src/utils/productPrice.ts _Bridges community 4 → community 3_
- `getTvProductBuyPrice()` --calls--> `getProductBuyPrice()` [EXTRACTED]
src/utils/productPrice.ts → src/utils/productPrice.ts _Bridges community 3 → community 6_
- `isTvProductPriceInactive()` --calls--> `isProductPriceInactive()` [EXTRACTED]
src/utils/productPrice.ts → src/utils/productPrice.ts _Bridges community 4 → community 6_
## Import Cycles
- None detected.
## Communities (8 total, 0 thin omitted)
### Community 0 - "useTvMonitorPage"
Cohesion: 0.14
Nodes (17): floorTvToman(), formatTvPrice(), parseTvNews(), useTvMonitorPage(), hideCursorSoon(), isProductTvInactive(), liveQuoteLabel(), loadProducts() (+9 more)
### Community 1 - "useTvMonitorPage.ts"
Cohesion: 0.17
Nodes (15): ref_api, ref_assets, ref_moment, ref_moment_jalaali, ref_store, ref_utils, ref_vue, ref_vueuse_core (+7 more)
### Community 2 - "index.vue"
Cohesion: 0.19
Nodes (15): ref_components, ref_views, coinColumns, extraTickerLoop, loopNews(), loopTicker(), marqueeCopies(), newsDuration() (+7 more)
### Community 3 - "productPrice.ts"
Cohesion: 0.44
Nodes (9): buyCashFee(), cashFeeOptions(), getProductBuyPrice(), getProductSellPrice(), hasProductUnitWeight(), isProductWithoutDisplayPrice(), productPriceAmountFactor(), productUsesLiveBoardQuote() (+1 more)
### Community 4 - "getProductBaseBuyPrice"
Cohesion: 0.43
Nodes (8): colleagueSideUnit(), getProductBaseBuyPrice(), getProductBaseSellPrice(), getReferenceProductBaseBuyPrice(), hasReferenceReadingMethod(), isProductPriceInactive(), isProductVisible(), productUsesLastPriceIfZero()
### Community 5 - "ref_models"
Cohesion: 0.40
Nodes (4): ref_models, CountPInventory, PInventory, Product
### Community 6 - "isTvProductPriceInactive"
Cohesion: 0.50
Nodes (5): getTvProductBuyPrice(), getTvProductSellPrice(), isTvProductPriceInactive(), tvBuyUnitPrice(), tvSellUnitPrice()
### Community 7 - "applyPriceOffsetPercent"
Cohesion: 0.67
Nodes (3): applyPriceOffsetPercent(), getColleagueSourcedUnitPrice(), resolvePriceOffsetRatio()
## Knowledge Gaps
- **6 isolated node(s):** `Product`, `PInventory`, `CountPInventory`, `{ theme, themeVars, toggleTheme }`, `{
rootEl,
brandName,
brandLogo,
nowLabel,
dateLabel,
parsianCoins,
bankCoins,
hasCoinBoard,
hasBoardContent,
tickerProducts,
tickerTitle,
extraTickerProducts,
extraTickerTitle,
tvNewsItems,
newsTickerTitle,
statusBanner,
showMainQuote,
mainQuoteReady,
mainQuoteReady,
liveBuy,
liveSell,
isLive,
isFullscreen,
cursorHidden,
loading,
productWeightLabel,
tickerItemLabel,
isProductTvInactive,
productBuyLabel,
productSellLabel,
liveQuoteLabel,
toggleFullscreen,
}` (+1 more)
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 18 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `useTvMonitorPage()` connect `useTvMonitorPage` to `useTvMonitorPage.ts`?**
_High betweenness centrality (0.342) - this node is a cross-community bridge._
- **Why does `formatTvPrice()` connect `useTvMonitorPage` to `useTvMonitorPage.ts`?**
_High betweenness centrality (0.026) - this node is a cross-community bridge._
- **Why does `getProductBaseBuyPrice()` connect `getProductBaseBuyPrice` to `productPrice.ts`, `applyPriceOffsetPercent`?**
_High betweenness centrality (0.004) - this node is a cross-community bridge._
- **Are the 13 inferred relationships involving `useTvMonitorPage()` (e.g. with `isBankCoin()` and `isGoldBar()`) actually correct?**
_`useTvMonitorPage()` has 13 INFERRED edges - model-reasoned connections that need verification._
- **What connects `Product`, `PInventory`, `CountPInventory` to the rest of the system?**
_6 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `useTvMonitorPage` be split into smaller, more focused modules?**
_Cohesion score 0.1380952380952381 - nodes in this community are weakly interconnected._
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
{
"0": "useTvMonitorPage",
"1": "ref_utils",
"2": "index.vue",
"3": "productPrice.ts",
"4": "forceReconnect",
"5": "api/products.ts",
"6": "priceSocket.ts",
"7": "connect",
"8": "useProductsPageData.ts",
"9": "AuthPriceStream",
"10": "initPriceRealtimeChannel"
}
+119
View File
@@ -0,0 +1,119 @@
# Graph Report - ghatreh_user (2026-09-28)
## Corpus Check
- 598 files · ~506,942 words
- Verdict: corpus is large enough that graph structure adds value.
- Unclassified: 51 file(s) not represented in the graph (top: .less 25, .eot 6, .ttf 6)
## Summary
- 196 nodes · 413 edges · 11 communities (10 shown, 1 thin omitted)
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 27 edges (avg confidence: 0.85)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `1ac50951`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
## Community Hubs (Navigation)
- useTvMonitorPage
- ref_utils
- index.vue
- productPrice.ts
- forceReconnect
- api/products.ts
- priceSocket.ts
- connect
- useProductsPageData.ts
- AuthPriceStream
- initPriceRealtimeChannel
## God Nodes (most connected - your core abstractions)
1. `useTvMonitorPage()` - 28 edges
2. `connect()` - 15 edges
3. `getProductBaseBuyPrice()` - 10 edges
4. `touchStreamActivity()` - 9 edges
5. `runFallbackPoll()` - 9 edges
6. `bindStreamEvents()` - 9 edges
7. `forceReconnect()` - 9 edges
8. `maybeShowNetworkAlert()` - 8 edges
9. `disconnect()` - 8 edges
10. `applyProductsList()` - 8 edges
## Surprising Connections (you probably didn't know these)
- `connect()` --calls--> `AuthPriceStream` [EXTRACTED]
src/utils/priceSocket.ts → src/utils/priceSocket.ts _Bridges community 9 → community 7_
- `connect()` --calls--> `resolveStreamUrl()` [EXTRACTED]
src/utils/priceSocket.ts → src/utils/priceSocket.ts _Bridges community 10 → community 7_
- `forceReconnect()` --calls--> `clearReconnectTimer()` [EXTRACTED]
src/utils/priceSocket.ts → src/utils/priceSocket.ts _Bridges community 7 → community 4_
- `connect()` --calls--> `touchStreamActivity()` [EXTRACTED]
src/utils/priceSocket.ts → src/utils/priceSocket.ts _Bridges community 1 → community 7_
- `connect()` --calls--> `stopFallbackPolling()` [EXTRACTED]
src/utils/priceSocket.ts → src/utils/priceSocket.ts _Bridges community 6 → community 7_
## Import Cycles
- None detected.
## Communities (11 total, 1 thin omitted)
### Community 0 - "useTvMonitorPage"
Cohesion: 0.09
Nodes (33): ref_api, ref_assets, ref_moment, ref_moment_jalaali, ref_vueuse_core, bankCoinRank(), floorTvToman(), formatTvPrice() (+25 more)
### Community 1 - "ref_utils"
Cohesion: 0.23
Nodes (15): ref_composables, ref_store, ref_utils, applyAppNotificationFromStream(), applyLastFromStream(), applySettingsFromStream(), applyUnreadCountFromStreamPayload(), bindStreamEvents() (+7 more)
### Community 2 - "index.vue"
Cohesion: 0.19
Nodes (15): ref_components, ref_vue, coinColumns, extraTickerLoop, loopNews(), loopTicker(), marqueeCopies(), newsDuration() (+7 more)
### Community 3 - "productPrice.ts"
Cohesion: 0.19
Nodes (25): applyPriceOffsetPercent(), buyCashFee(), cashFeeOptions(), colleagueSideUnit(), getColleagueSourcedUnitPrice(), getProductBaseBuyPrice(), getProductBaseSellPrice(), getProductBuyPrice() (+17 more)
### Community 4 - "forceReconnect"
Cohesion: 0.21
Nodes (16): bindBrowserNetworkListeners(), forceReconnect(), isPageVisible(), maybeShowNetworkAlert(), notifyPriceLastFetchFailed(), onBrowserOffline(), onBrowserOnline(), onPageShow() (+8 more)
### Community 5 - "api/products.ts"
Cohesion: 0.15
Nodes (5): ref_models, TvServerTime, CountPInventory, PInventory, Product
### Community 6 - "priceSocket.ts"
Cohesion: 0.16
Nodes (12): ref_ant_design_vue, ref_views, clearFallbackPollTimer(), clearPriceNetworkAlert(), getPriceSourceStaleMs(), isMobileDevice(), isRealPriceOutage(), onTvBoard() (+4 more)
### Community 7 - "connect"
Cohesion: 0.36
Nodes (10): clearConnectingLifecycle(), clearReconnectTimer(), connect(), disconnect(), disconnectSourceOnly(), handleStreamLost(), markConnectingLifecycle(), scheduleReconnect() (+2 more)
### Community 8 - "useProductsPageData.ts"
Cohesion: 0.12
Nodes (31): applyProductsList(), applyProductsListFromSocket(), applySocketProductList(), changeProductsTab(), flagOff(), flagOn(), initialized, initProductsPageData() (+23 more)
### Community 10 - "initPriceRealtimeChannel"
Cohesion: 0.60
Nodes (5): initPriceRealtimeChannel(), isPriceWebSocketEnabled(), isTruthySetting(), resolveStreamPath(), resolveStreamUrl()
## Knowledge Gaps
- **13 isolated node(s):** `initialized`, `productsActiveKey`, `productsTypeItems`, `productsFirstItems`, `productsItems` (+8 more)
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 37 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
- **1 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `AuthPriceStream` connect `AuthPriceStream` to `priceSocket.ts`, `connect`?**
_High betweenness centrality (0.035) - this node is a cross-community bridge._
- **Why does `runFallbackPoll()` connect `priceSocket.ts` to `ref_utils`, `forceReconnect`?**
_High betweenness centrality (0.019) - this node is a cross-community bridge._
- **Are the 13 inferred relationships involving `useTvMonitorPage()` (e.g. with `isBankCoin()` and `isGoldBar()`) actually correct?**
_`useTvMonitorPage()` has 13 INFERRED edges - model-reasoned connections that need verification._
- **What connects `initialized`, `productsActiveKey`, `productsTypeItems` to the rest of the system?**
_13 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `useTvMonitorPage` be split into smaller, more focused modules?**
_Cohesion score 0.09309309309309309 - nodes in this community are weakly interconnected._
- **Should `useProductsPageData.ts` be split into smaller, more focused modules?**
_Cohesion score 0.11931818181818182 - nodes in this community are weakly interconnected._
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
{
"0": "useTvMonitorPage",
"1": "ref_utils",
"2": "index.vue",
"3": "productPrice.ts",
"5": "api/products.ts",
"6": "priceSocket.ts",
"8": "useProductsPageData.ts",
"9": "AuthPriceStream"
}
+100
View File
@@ -0,0 +1,100 @@
# Graph Report - ghatreh_user (2026-09-28)
## Corpus Check
- 598 files · ~506,836 words
- Verdict: corpus is large enough that graph structure adds value.
- Unclassified: 51 file(s) not represented in the graph (top: .less 25, .eot 6, .ttf 6)
## Summary
- 198 nodes · 409 edges · 8 communities (7 shown, 1 thin omitted)
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 28 edges (avg confidence: 0.85)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `9cb09796`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
## Community Hubs (Navigation)
- useTvMonitorPage
- ref_utils
- index.vue
- productPrice.ts
- api/products.ts
- priceSocket.ts
- useProductsPageData.ts
- AuthPriceStream
## God Nodes (most connected - your core abstractions)
1. `useTvMonitorPage()` - 25 edges
2. `connect()` - 15 edges
3. `getProductBaseBuyPrice()` - 10 edges
4. `touchStreamActivity()` - 9 edges
5. `bindStreamEvents()` - 9 edges
6. `forceReconnect()` - 9 edges
7. `runFallbackPoll()` - 8 edges
8. `maybeShowNetworkAlert()` - 8 edges
9. `disconnect()` - 8 edges
10. `applyProductsList()` - 8 edges
## Surprising Connections (you probably didn't know these)
- `connect()` --calls--> `AuthPriceStream` [EXTRACTED]
src/utils/priceSocket.ts → src/utils/priceSocket.ts _Bridges community 9 → community 6_
- `connect()` --calls--> `touchStreamActivity()` [EXTRACTED]
src/utils/priceSocket.ts → src/utils/priceSocket.ts _Bridges community 1 → community 6_
## Import Cycles
- None detected.
## Communities (8 total, 1 thin omitted)
### Community 0 - "useTvMonitorPage"
Cohesion: 0.10
Nodes (30): ref_api, ref_assets, ref_moment, ref_moment_jalaali, ref_vueuse_core, bankCoinRank(), floorTvToman(), formatTvPrice() (+22 more)
### Community 1 - "ref_utils"
Cohesion: 0.23
Nodes (15): ref_composables, ref_store, ref_utils, applyAppNotificationFromStream(), applyLastFromStream(), applySettingsFromStream(), applyUnreadCountFromStreamPayload(), bindStreamEvents() (+7 more)
### Community 2 - "index.vue"
Cohesion: 0.10
Nodes (23): ref_components, ref_views, ref_vue, priceSocketConnected, priceStreamConnectingSince, priceStreamLifecycle, priceStreamList, priceStreamProducts (+15 more)
### Community 3 - "productPrice.ts"
Cohesion: 0.20
Nodes (24): applyPriceOffsetPercent(), buyCashFee(), cashFeeOptions(), getColleagueSourcedUnitPrice(), getProductBaseBuyPrice(), getProductBaseSellPrice(), getProductBuyPrice(), getProductSellPrice() (+16 more)
### Community 5 - "api/products.ts"
Cohesion: 0.15
Nodes (5): ref_models, TvServerTime, CountPInventory, PInventory, Product
### Community 6 - "priceSocket.ts"
Cohesion: 0.11
Nodes (41): ref_ant_design_vue, bindBrowserNetworkListeners(), clearConnectingLifecycle(), clearFallbackPollTimer(), clearPriceNetworkAlert(), clearReconnectTimer(), connect(), disconnect() (+33 more)
### Community 8 - "useProductsPageData.ts"
Cohesion: 0.12
Nodes (31): applyProductsList(), applyProductsListFromSocket(), applySocketProductList(), changeProductsTab(), flagOff(), flagOn(), initialized, initProductsPageData() (+23 more)
## Knowledge Gaps
- **20 isolated node(s):** `Product`, `PInventory`, `CountPInventory`, `priceSocketConnected`, `priceWssEnabled` (+15 more)
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 43 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
- **1 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `AuthPriceStream` connect `AuthPriceStream` to `priceSocket.ts`?**
_High betweenness centrality (0.035) - this node is a cross-community bridge._
- **Why does `runFallbackPoll()` connect `priceSocket.ts` to `ref_utils`, `index.vue`?**
_High betweenness centrality (0.016) - this node is a cross-community bridge._
- **Are the 14 inferred relationships involving `useTvMonitorPage()` (e.g. with `isBankCoin()` and `isGoldBar()`) actually correct?**
_`useTvMonitorPage()` has 14 INFERRED edges - model-reasoned connections that need verification._
- **What connects `Product`, `PInventory`, `CountPInventory` to the rest of the system?**
_20 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `useTvMonitorPage` be split into smaller, more focused modules?**
_Cohesion score 0.09982174688057041 - nodes in this community are weakly interconnected._
- **Should `index.vue` be split into smaller, more focused modules?**
_Cohesion score 0.10153846153846154 - nodes in this community are weakly interconnected._
- **Should `priceSocket.ts` be split into smaller, more focused modules?**
_Cohesion score 0.10638297872340426 - nodes in this community are weakly interconnected._
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
# Graph Report - ghatreh_user (2026-09-29)
## Corpus Check
- 598 files · ~506,875 words
- Verdict: corpus is large enough that graph structure adds value.
- Unclassified: 51 file(s) not represented in the graph (top: .less 25, .eot 6, .ttf 6)
## Summary
- 208 nodes · 426 edges · 10 communities (9 shown, 1 thin omitted)
- Extraction: 92% EXTRACTED · 8% INFERRED · 0% AMBIGUOUS · INFERRED: 33 edges (avg confidence: 0.85)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `da68f0a5`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
## Community Hubs (Navigation)
- useTvMonitorPage
- ref_utils
- index.vue
- productPrice.ts
- useNewPhysicalRequestPage.ts
- api/products.ts
- priceSocket.ts
- priceSocketState.ts
- useProductsPageData.ts
- AuthPriceStream
## God Nodes (most connected - your core abstractions)
1. `useTvMonitorPage()` - 25 edges
2. `connect()` - 15 edges
3. `getProductBaseBuyPrice()` - 10 edges
4. `bindStreamEvents()` - 9 edges
5. `touchStreamActivity()` - 9 edges
6. `forceReconnect()` - 9 edges
7. `useNewPhysicalRequestPage()` - 8 edges
8. `applyProductsList()` - 8 edges
9. `applySocketProductList()` - 8 edges
10. `disconnect()` - 8 edges
## Surprising Connections (you probably didn't know these)
- `connect()` --calls--> `AuthPriceStream` [EXTRACTED]
src/utils/priceSocket.ts → src/utils/priceSocket.ts _Bridges community 9 → community 6_
- `connect()` --calls--> `bindStreamEvents()` [EXTRACTED]
src/utils/priceSocket.ts → src/utils/priceSocket.ts _Bridges community 1 → community 6_
## Import Cycles
- None detected.
## Communities (10 total, 1 thin omitted)
### Community 0 - "useTvMonitorPage"
Cohesion: 0.10
Nodes (29): ref_assets, ref_moment, ref_moment_jalaali, ref_vueuse_core, bankCoinRank(), floorTvToman(), formatTvPrice(), isBankCoin() (+21 more)
### Community 1 - "ref_utils"
Cohesion: 0.23
Nodes (15): ref_composables, ref_store, ref_utils, applyAppNotificationFromStream(), applyLastFromStream(), applySettingsFromStream(), applyUnreadCountFromStreamPayload(), bindStreamEvents() (+7 more)
### Community 2 - "index.vue"
Cohesion: 0.19
Nodes (15): ref_components, ref_views, coinColumns, extraTickerLoop, loopNews(), loopTicker(), marqueeCopies(), newsDuration() (+7 more)
### Community 3 - "productPrice.ts"
Cohesion: 0.20
Nodes (24): applyPriceOffsetPercent(), buyCashFee(), cashFeeOptions(), getColleagueSourcedUnitPrice(), getProductBaseBuyPrice(), getProductBaseSellPrice(), getProductBuyPrice(), getProductSellPrice() (+16 more)
### Community 4 - "useNewPhysicalRequestPage.ts"
Cohesion: 0.12
Nodes (13): ref_ant_design_vue, ref_api, ref_lodash, ref_router, NEW_PHYSICAL_REQUEST_KEY, NewPhysicalRequestContext, useNewPhysicalRequestPage(), getAddress() (+5 more)
### Community 5 - "api/products.ts"
Cohesion: 0.15
Nodes (5): ref_models, TvServerTime, CountPInventory, PInventory, Product
### Community 6 - "priceSocket.ts"
Cohesion: 0.14
Nodes (40): bindBrowserNetworkListeners(), clearConnectingLifecycle(), clearFallbackPollTimer(), clearPriceNetworkAlert(), clearReconnectTimer(), connect(), disconnect(), disconnectSourceOnly() (+32 more)
### Community 7 - "priceSocketState.ts"
Cohesion: 0.20
Nodes (8): ref_vue, priceSocketConnected, priceStreamConnectingSince, priceStreamLifecycle, priceStreamList, priceStreamProducts, priceStreamServerUnix, priceWssEnabled
### Community 8 - "useProductsPageData.ts"
Cohesion: 0.12
Nodes (31): applyProductsList(), applyProductsListFromSocket(), applySocketProductList(), changeProductsTab(), flagOff(), flagOn(), initialized, initProductsPageData() (+23 more)
## Knowledge Gaps
- **22 isolated node(s):** `NewPhysicalRequestContext`, `NEW_PHYSICAL_REQUEST_KEY`, `initialized`, `productsActiveKey`, `productsTypeItems` (+17 more)
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 45 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
- **1 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `AuthPriceStream` connect `AuthPriceStream` to `priceSocket.ts`?**
_High betweenness centrality (0.033) - this node is a cross-community bridge._
- **Are the 14 inferred relationships involving `useTvMonitorPage()` (e.g. with `isBankCoin()` and `isGoldBar()`) actually correct?**
_`useTvMonitorPage()` has 14 INFERRED edges - model-reasoned connections that need verification._
- **What connects `NewPhysicalRequestContext`, `NEW_PHYSICAL_REQUEST_KEY`, `initialized` to the rest of the system?**
_22 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `useTvMonitorPage` be split into smaller, more focused modules?**
_Cohesion score 0.10416666666666667 - nodes in this community are weakly interconnected._
- **Should `useNewPhysicalRequestPage.ts` be split into smaller, more focused modules?**
_Cohesion score 0.12418300653594772 - nodes in this community are weakly interconnected._
- **Should `priceSocket.ts` be split into smaller, more focused modules?**
_Cohesion score 0.1378048780487805 - nodes in this community are weakly interconnected._
- **Should `useProductsPageData.ts` be split into smaller, more focused modules?**
_Cohesion score 0.125 - nodes in this community are weakly interconnected._
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+253 -79
View File
@@ -41,8 +41,14 @@
}
.boot-loader {
min-height: 100vh;
position: fixed;
inset: 0;
z-index: 2147483646;
width: 100%;
min-height: 100%;
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
direction: rtl;
@@ -53,71 +59,36 @@
"Segoe UI",
sans-serif;
background:
radial-gradient(circle at top, var(--boot-orb), transparent 38%),
linear-gradient(
135deg,
var(--boot-bg) 0%,
var(--boot-bg-soft) 100%
);
radial-gradient(ellipse 70% 45% at 50% 28%, var(--boot-orb), transparent 70%),
linear-gradient(180deg, var(--boot-bg) 0%, var(--boot-bg-soft) 100%);
color: var(--boot-text);
overflow: hidden;
}
.boot-loader__card {
position: relative;
width: min(340px, calc(100vw - 48px));
padding: 30px 22px 28px;
border-radius: 24px;
text-align: center;
background: var(--boot-card);
border: 1px solid var(--boot-border);
box-shadow: 0 18px 48px var(--boot-shadow);
backdrop-filter: blur(10px);
.boot-loader__logo {
height: min(20vh, 160px);
width: auto;
max-width: min(70vw, 280px);
object-fit: contain;
opacity: 0;
transition: opacity 0.28s ease;
}
.boot-loader__orb {
position: absolute;
top: -72px;
left: 50%;
width: 150px;
height: 150px;
transform: translateX(-50%);
border-radius: 999px;
background: var(--boot-orb);
filter: blur(8px);
pointer-events: none;
.boot-loader__logo.is-ready {
opacity: 1;
}
.boot-loader__spinner {
position: relative;
z-index: 1;
width: 56px;
height: 56px;
margin: 0 auto 20px;
position: absolute;
bottom: calc(1.5rem + env(safe-area-inset-bottom, 0px));
width: 26px;
height: 26px;
border-radius: 999px;
border: 4px solid var(--boot-spinner-track);
border: 3px solid var(--boot-spinner-track);
border-top-color: var(--boot-accent);
animation: boot-spin 0.9s linear infinite;
}
.boot-loader__title {
position: relative;
z-index: 1;
margin: 0;
font-size: 19px;
font-weight: 800;
letter-spacing: -0.02em;
}
.boot-loader__text {
position: relative;
z-index: 1;
margin: 10px 0 0;
color: var(--boot-muted);
font-size: 14px;
line-height: 2;
}
@keyframes boot-spin {
to {
transform: rotate(360deg);
@@ -143,16 +114,25 @@
try {
var rawCookie = getCookie(CONFIG_COOKIE_NAME)
if (!rawCookie) return null
var decoded = decodeURIComponent(rawCookie)
var payload = JSON.parse(decoded)
return payload && payload.config ? payload.config : null
if (rawCookie) {
var decoded = decodeURIComponent(rawCookie)
var payload = JSON.parse(decoded)
if (payload && payload.config) return payload.config
}
} catch (error) {
console.warn('Boot theme: failed to read cached config', error)
return null
}
try {
var rawLs = localStorage.getItem('APP_CONFIG_LS_CACHE')
if (!rawLs) return null
var lsPayload = JSON.parse(rawLs)
if (lsPayload && lsPayload.config) return lsPayload.config
} catch (error) {
console.warn('Boot theme: failed to read LS config cache', error)
}
return null
}
function normalizeHex(hex) {
@@ -238,6 +218,146 @@
document.documentElement.style.setProperty(name, value)
}
var BOOT_LOGO_KEY = 'BOOT_LOGO_URL'
var BOOT_LOGO_VERSION_KEY = 'BOOT_LOGO_VERSION'
function rewriteLegacyBootLogoUrl(url) {
var value = String(url || '').trim()
if (!value) return ''
if (value.indexOf('/uploads/') === 0) {
var base = 'https://adm.montzar.ir'
try {
var last = localStorage.getItem('CONFIG_Success_URL')
if (last) {
var sa = new URL(last)
var path = sa.pathname.replace(/\/get_user\/?$/i, '').replace(/\/+$/, '')
base = path && path !== '/' ? sa.origin + path : sa.origin
}
} catch (e) {}
return base + value
}
return value
}
function pickAvatarRaw(value) {
if (value == null) return ''
if (typeof value === 'string') return String(value).trim()
if (typeof value === 'object') {
return String(value.full_src || value.url || value.src || value.avatar || '').trim()
}
return ''
}
function logoCacheValid() {
try {
var v = String(localStorage.getItem('version') || '').trim()
var lv = String(localStorage.getItem(BOOT_LOGO_VERSION_KEY) || '').trim()
if (v && lv && v !== lv) return false
return true
} catch (e) {
return false
}
}
function readCachedLogoUrl() {
if (!logoCacheValid()) return ''
try {
return String(localStorage.getItem(BOOT_LOGO_KEY) || '').trim()
} catch (e) {
return ''
}
}
function resolveAbsoluteLogoUrl(raw) {
if (!raw) return ''
if (/^(https?:\/\/|data:|blob:)/i.test(raw)) {
var abs = window.location.protocol === 'https:' ? raw.replace(/^http:\/\//i, 'https://') : raw
return rewriteLegacyBootLogoUrl(abs)
}
var path = raw.replace(/^\/+/, '')
return window.location.origin + '/server/' + path
}
function resolveBootLogoUrl(config) {
var nested = config && config.user && typeof config.user === 'object' ? config.user : {}
var fromUser = rewriteLegacyBootLogoUrl(pickAvatarRaw(nested.avatar))
if (fromUser) return resolveAbsoluteLogoUrl(fromUser)
var cached = readCachedLogoUrl()
if (cached && /^(data:|blob:)/i.test(cached)) return cached
return resolveAbsoluteLogoUrl(cached)
}
function persistLogoCache(logoUrl) {
try {
var v = String(localStorage.getItem('version') || '').trim()
if (v) localStorage.setItem(BOOT_LOGO_VERSION_KEY, v)
if (!logoUrl) return
localStorage.setItem(BOOT_LOGO_KEY, logoUrl)
if (logoUrl.indexOf('data:') === 0 || typeof fetch !== 'function') return
try {
if (new URL(logoUrl, window.location.href).origin !== window.location.origin) return
} catch (e) {
return
}
fetch(logoUrl, { mode: 'cors', credentials: 'omit' })
.then(function (res) {
return res && res.ok ? res.blob() : Promise.reject()
})
.then(function (blob) {
if (!blob || blob.size > 450000) return
var reader = new FileReader()
reader.onloadend = function () {
try {
if (typeof reader.result === 'string' && reader.result.indexOf('data:') === 0) {
localStorage.setItem(BOOT_LOGO_KEY, reader.result)
}
} catch (e) {}
}
reader.readAsDataURL(blob)
})
.catch(function () {})
} catch (e) {}
}
function applyBootLogo(config) {
var img = document.getElementById('boot-logo')
if (!img) return
var logoUrl = resolveBootLogoUrl(config || {})
if (!logoUrl) {
img.hidden = true
img.removeAttribute('src')
img.classList.remove('is-ready')
return
}
function markReady() {
img.hidden = false
img.removeAttribute('hidden')
img.classList.add('is-ready')
persistLogoCache(logoUrl)
}
img.onload = markReady
img.onerror = function () {
img.hidden = true
img.classList.remove('is-ready')
}
if (img.getAttribute('src') === logoUrl && img.complete && img.naturalWidth > 0) {
markReady()
return
}
img.hidden = false
img.removeAttribute('hidden')
img.src = logoUrl
if (logoUrl.indexOf('data:') === 0) markReady()
}
window.__APPLY_BOOT_BRANDING__ = applyBootLogo
function applyBootTheme() {
var config = readCachedConfig()
var user = config && config.user ? config.user : null
@@ -289,6 +409,7 @@
}
applyBootTheme()
applyBootLogo(readCachedConfig())
})()
</script>
</head>
@@ -296,15 +417,15 @@
<body>
<div id="app">
<div class="boot-loader">
<div class="boot-loader__card">
<div class="boot-loader__orb"></div>
<div class="boot-loader__spinner"></div>
<h1 class="boot-loader__title">در حال هدایت به سامانه</h1>
<p class="boot-loader__text">لطفاً چند لحظه صبر کنید...</p>
</div>
<img id="boot-logo" class="boot-loader__logo" alt="" decoding="async" hidden />
<div class="boot-loader__spinner" aria-hidden="true"></div>
</div>
</div>
<script>
if (typeof window.__APPLY_BOOT_BRANDING__ === 'function') {
window.__APPLY_BOOT_BRANDING__()
}
</script>
<script>
;(function () {
@@ -325,20 +446,63 @@
return window.location.protocol === 'https:' ? 'https:' : 'http:'
}
function resolveTenantDomain() {
var host = String(window.location.hostname || '').trim()
if (!host || host === 'localhost' || host === '127.0.0.1') {
return 'k.montzar.ir'
}
return host
}
function getSuperAdminBases() {
return [
'https://adm.montzar.ir',
'https://adm.montech.ir',
'https://adm.montest.ir'
]
}
function isDevHost() {
var host = String(window.location.hostname || '').trim()
return host === 'localhost' || host === '127.0.0.1'
}
function getVersionUrls() {
return ['https://nerkh.gold/get_version', 'https://tablo.talasevin.ir/get_version', 'https://abshode-service.montgit.ir/server/get_version']
var domain = encodeURIComponent(resolveTenantDomain())
var urls = getSuperAdminBases().map(function (base) {
return base + '/get_version?domain=' + domain
})
if (isDevHost()) {
var protocol = getProtocol()
urls = urls.concat([
'https://tablo.talasevin.ir/get_version',
'https://abshode-service.montgit.ir/server/get_version',
protocol + '//212.23.201.173:8381/get_version',
protocol + '//91.212.174.137:4040/server/get_version'
])
}
return urls
}
function getConfigUrls() {
var protocol = getProtocol()
var domain = encodeURIComponent(resolveTenantDomain())
var urls = getSuperAdminBases().map(function (base) {
return base + '/get_user?domain=' + domain
})
return [
protocol + '//nerkh.gold/get_user',
protocol + '//tablo.talasevin.ir/get_user',
protocol + '//abshode-service.montgit.ir/server/get_user',
protocol + '//212.23.201.173:8381/get_user',
protocol + '//91.212.174.137:4040/server/get_user'
]
if (isDevHost()) {
var protocol = getProtocol()
urls = urls.concat([
protocol + '//tablo.talasevin.ir/get_user',
protocol + '//abshode-service.montgit.ir/server/get_user',
protocol + '//212.23.201.173:8381/get_user',
protocol + '//91.212.174.137:4040/server/get_user'
])
}
return urls
}
function sortVersionUrls(urls) {
@@ -453,6 +617,9 @@
localStorage.setItem(VERSION_SUCCESS_URL_KEY, url)
return version
} catch (error) {
if (localStorage.getItem(VERSION_SUCCESS_URL_KEY) === url) {
localStorage.removeItem(VERSION_SUCCESS_URL_KEY)
}
console.warn('Version failed:', url, error)
return undefined
} finally {
@@ -496,6 +663,9 @@
url: url
}
} catch (error) {
if (localStorage.getItem(CONFIG_SUCCESS_URL_KEY) === url) {
localStorage.removeItem(CONFIG_SUCCESS_URL_KEY)
}
console.warn('Config failed:', url, 'round:', round, error)
}
}
@@ -522,6 +692,10 @@
CONFIG_COOKIE_MAX_AGE_SECONDS +
'; SameSite=Lax' +
secure
if (typeof window.__APPLY_BOOT_BRANDING__ === 'function') {
window.__APPLY_BOOT_BRANDING__(config)
}
} catch (error) {
console.warn('Failed to write config cookie:', error)
}
@@ -610,10 +784,10 @@
'<div style="font-size: 42px; margin-bottom: 12px;">⚠️</div>' +
'<h2 style="margin: 0 0 12px; font-size: 20px;">خطا در بارگذاری برنامه</h2>' +
'<p style="margin: 0 0 20px; color: var(--boot-muted); line-height: 2; font-size: 14px;">' +
'ارتباط با سرور برقرار نشد یا فایل‌های برنامه به‌درستی دریافت نشدند.' +
'برقراری ارتباط با سرور امکان‌پذیر نیست. لطفاً اتصال اینترنت خود را بررسی کرده و صفحه را تازه‌سازی نمایید.' +
'</p>' +
'<button onclick="window.__APP_BOOT_SIMPLE_RETRY__()" style="padding: 11px 22px; border: 0; border-radius: 12px; background: var(--boot-accent); color: white; cursor: pointer; font-weight: 700;">' +
'تلاش مجدد' +
'تازه‌سازی صفحه' +
'</button>' +
'<button onclick="window.__APP_BOOT_CLEAN_RELOAD__()" style="margin-right: 8px; padding: 11px 18px; border: 1px solid var(--boot-border); border-radius: 12px; background: transparent; color: var(--boot-text); cursor: pointer; font-weight: 700;">' +
'پاک‌سازی و تلاش مجدد' +
@@ -625,8 +799,8 @@
async function cleanFetchAndReload() {
renderRecovering('در حال دریافت تنظیمات جدید برنامه...')
var version = await loadRemoteVersion(5)
var freshConfigResult = await fetchFreshConfig(2)
var version = await loadRemoteVersion(3)
var freshConfigResult = await fetchFreshConfig(3)
clearReadableCookies()
// clearLocalStorageExceptToken()
+2 -1
View File
@@ -39,7 +39,8 @@
"vue3-apexcharts": "^1.4.4",
"vue3-lazyload": "^0.3.8",
"vue3-odometer": "^0.1.3",
"vue3-otp-input": "^0.4.1"
"vue3-otp-input": "^0.4.1",
"wavesurfer.js": "^7.12.6"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.2",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 583 KiB

File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"nm":"Pre-comp 1","ddd":0,"h":1200,"w":1200,"meta":{"g":"LottieFiles AE 0.1.21"},"layers":[{"ty":4,"nm":"arrow down Outlines","sr":1,"st":0,"op":1020,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[256,256,0],"ix":1},"s":{"a":0,"k":[46.875,46.875,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[600,528,0],"t":0,"ti":[0,0,0],"to":[0,-15.333,0]},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[600,436,0],"t":13,"ti":[0,-15.333,0],"to":[0,0,0]},{"s":[600,528,0],"t":27}],"ix":2,"x":"var $bm_rt;\n$bm_rt = loopOut('cycle');"},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":4,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[7.831,7.793],[7.792,-7.828],[0,0],[0,0],[11.045,0],[0,-11.046],[0,0],[0,0],[7.829,-7.792],[-7.791,-7.829],[0,0],[-0.005,-0.006],[-7.819,7.833],[-0.005,0.006],[0,0]],"o":[[-7.829,-7.792],[0,0],[0,0],[0,-11.046],[-11.046,0],[0,0],[0,0],[-7.792,-7.828],[-7.83,7.793],[0,0],[0.006,0.007],[7.792,7.809],[0.006,-0.007],[0,0],[7.79,-7.828]],"v":[[118.109,117.328],[89.825,117.394],[20.001,187.557],[20.001,-236],[0.001,-256],[-19.999,-236],[-19.999,187.557],[-89.824,117.393],[-118.107,117.327],[-118.175,145.612],[-14.175,250.116],[-14.157,250.134],[14.158,250.135],[14.176,250.117],[118.176,145.613]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.1608,0.1608,0.3843],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[255.999,256],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":1},{"ty":4,"nm":"Shape Layer 1","sr":1,"st":0,"op":1020,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[141,141,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[622.56,380.04,0],"t":0,"ti":[0,0,0],"to":[0,-15.333,0]},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[622.56,288.04,0],"t":13,"ti":[0,-15.333,0],"to":[0,0,0]},{"s":[622.56,380.04,0],"t":27}],"ix":2,"x":"var $bm_rt;\n$bm_rt = loopOut('cycle');"},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Ellipse 1","ix":1,"cix":2,"np":3,"it":[{"ty":"el","bm":0,"hd":false,"mn":"ADBE Vector Shape - Ellipse","nm":"Ellipse Path 1","d":1,"p":{"a":0,"k":[0,0],"ix":3},"s":{"a":0,"k":[144,144],"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[1,1,1],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[-16,156],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":2}],"v":"5.5.7","fr":30,"op":1020,"ip":0,"assets":[]}
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

+317 -121
View File
@@ -1,64 +1,31 @@
<template>
<template>
<div class="desktopConfig w-full right-0 left-0">
<transition mode="out-in" name="fade">
<AConfigProvider
v-if="!showSplash"
:theme="{
token: {
colorPrimary: primaryColor,
colorLink: primaryColor,
colorLinkActive: primaryColor,
colorLinkHover: primaryColor
colorPrimary: colors.primary,
colorLink: colors.primary,
colorLinkActive: colors.primary,
colorLinkHover: colors['primary-rgba']
}
}"
direction="rtl"
>
<router-view v-slot="{ Component, route }">
<transition :name="route.meta.transition || 'fade'">
<!--
Key only the top-level matched route (e.g. main vs login), not fullPath.
Using fullPath remounted the whole AppLayoutShell (sidebar/bottom nav)
on every page change and reloaded the menu logo each time.
-->
<component
:is="Component"
:key="route.matched[0]?.name ?? route.name"
class="app-main"
/>
<component :is="Component" :key="route.fullPath" class="app-main" />
</transition>
</router-view>
</AConfigProvider>
<div v-else class="app-splash-screen flex items-center justify-center bg-white">
<div class="app-splash-screen__gradient app-splash-screen__gradient--top h-1/3 flex">
<img :src="img_topGradiant" alt="" class="absolute z-10 w-full h-full object-cover" />
<img :src="img_pattern" alt="" class="absolute w-full h-full object-none" />
</div>
<div class="app-splash-screen__gradient app-splash-screen__gradient--bottom h-1/3">
<img
:src="img_bottomGradiant"
alt=""
class="absolute z-10 w-full h-full object-cover"
/>
<img :src="img_pattern" alt="" class="absolute w-full h-full object-none" />
</div>
<div class="splash_appear_fade_in relative z-10">
<img :src="logoFull" class="h-[20vh] max-h-[180px] w-auto object-contain" alt="" />
</div>
<div class="app-splash-screen__version home_appear_fade_in_up">
<div class="text-center text-xs">نسخه: {{ displayVersion }}</div>
</div>
<div class="app-splash-screen__spinner">
<a-spin :indicator="indicator" size="small" />
</div>
</div>
</transition>
<div
v-if="showSupportButton && !showSplash"
v-if="showSupportButton && route.name !== 'support' && !hideAppChrome"
id="support-button"
ref="supportButtonEl"
class="draggable-support-button"
:class="{ 'is-dragging': supportDragging }"
:class="{ 'is-dragging': supportDragging, 'is-picker-open': supportPickerOpen }"
:style="supportButtonStyle"
title="پشتیبانی"
@pointerdown="onSupportPointerDown"
@@ -69,95 +36,124 @@
<div class="support-button-inner" :style="{ '--support-accent': supportAccentColor }">
<BasicIcon :icon="supportIcon" size="18" class="support-button-icon" />
<span class="support-button-label">پشتیبانی</span>
<span
v-if="supportChatUnreadCount > 0"
class="support-fab-badge"
>
{{ supportChatUnreadCount > 99 ? '99+' : supportChatUnreadCount }}
</span>
</div>
</div>
<SupportMessengerPicker
v-model:open="supportPickerOpen"
:entries="supportMessengerEntries"
:anchor-el="pickerAnchorEl"
@select="openMessengerEntry"
/>
<VersionUpdateModal />
<PwaInstallGuideModal
v-if="!hideAppChrome"
v-model:open="guideOpen"
:platform="platform"
@complete="completeGuide()"
/>
<div
v-if="!hideAppChrome && !isDesktop"
class="app-credit-footer app-credit-footer--mobile"
:class="{ 'app-credit-footer--login': isLoginRoute }"
>
<EnamadBadge v-if="isLoginRoute" size="sm" />
<AppCreditLink variant="mobile" />
</div>
<div
v-if="!hideAppChrome && showDesktopFloatingCredit"
class="app-credit-footer app-credit-footer--desktop-float"
:class="{ 'app-credit-footer--login': isLoginRoute }"
>
<EnamadBadge v-if="isLoginRoute" size="sm" />
<AppCreditLink variant="float" />
</div>
<GoldDayCalculatorModal />
<GoldLoading v-model:show="colleagueOrderWaiting" />
</div>
</template>
<script lang="ts" setup>
import { colors } from './themeConfig'
import pkg from '../package.json'
import { getDisplayAppVersion } from '@/config/config-cache'
import { getAppPrimaryColor } from '@/config/brand-theme'
import { computed, defineAsyncComponent, h, onMounted, ref } from 'vue'
import img_topGradiant from '@/views/login/assets/img/gr_top_white.svg'
import img_pattern from '@/views/login/assets/img/pattern.svg'
import { LoadingOutlined } from '@ant-design/icons-vue'
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import { showBrowserNotification } from '@/utils/browserNotification'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import {
buildSupportMessengerUrl,
loadSupportMessengerFromSettings,
supportMessengerSettings,
} from '@/utils/supportMessenger'
import { useSupportMessenger } from '@/composables/useSupportMessenger'
import { loadSupportMessengerFromSettings } from '@/utils/supportMessenger'
import SupportMessengerPicker from '@/components/SupportMessengerPicker.vue'
import { useDraggableFloatingButton } from '@/utils/useDraggableFloatingButton'
import { useApplyDesktopLayoutClass } from '@/layouts/composables/useAppLayout'
import { getAppLogoFullUrl } from '@/config/brand-assets'
import img_bottomGradiant from '@/views/login/assets/img/gr_bottom_white.svg'
const logoFull = getAppLogoFullUrl()
import { useApplyDesktopLayoutClass, useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import AppCreditLink from '@/components/AppCreditLink.vue'
import EnamadBadge from '@/components/EnamadBadge.vue'
import { usePwaInstall } from '@/composables/usePwaInstall'
import PwaInstallGuideModal from '@/components/pwa/PwaInstallGuideModal.vue'
import GoldDayCalculatorModal from '@/components/GoldDayCalculatorModal.vue'
import GoldLoading from '@/components/GoldLoading.vue'
import { colleagueOrderWaiting, resumeColleagueOrderIfPending } from '@/composables/useColleagueOrderWait'
import { useUserStore } from '@/store/userStore'
import { useSettingsStore } from '@/store/settingsStore'
import { connectChatIfExists, disconnectChat, isSettingFlagOn, unreadSupportCount, useChatService } from '@/utils/chat'
import { getToken } from '@/utils'
const VersionUpdateModal = defineAsyncComponent(
() => import('@/modules/version/VersionUpdateModal.vue')
)
const indicator = h(LoadingOutlined, {
style: {
fontSize: '24px'
},
spin: true
})
const displayVersion = getDisplayAppVersion(pkg.version)
const showSplash = ref(true)
const primaryColor = getAppPrimaryColor(colors.primary || '#000000')
const route = useRoute()
const hideAppChrome = computed(() => route.meta.hideChrome === true || route.name === 'tvMonitor')
useApplyDesktopLayoutClass()
const supportLink = computed(() =>
buildSupportMessengerUrl(
supportMessengerSettings.value.messenger,
supportMessengerSettings.value.messengerId
)
const isDesktop = useDesktopBreakpoint()
const showDesktopFloatingCredit = computed(() => {
if (!isDesktop.value) return false
return route.name === 'login' || !!route.meta.hideMenu
})
const isLoginRoute = computed(() => route.name === 'login')
const {
guideOpen,
platform,
completeGuide,
} = usePwaInstall()
const {
supportPickerOpen,
supportPickerAnchor,
supportMessengerEntries,
messengerMeta,
isChatEnabled,
hasMessengerLink: showSupportButton,
openMessengerEntry,
openSupportMessenger,
} = useSupportMessenger()
const supportButtonEl = ref<HTMLElement | null>(null)
const pickerAnchorEl = computed(() => supportPickerAnchor.value ?? supportButtonEl.value)
const supportChatUnreadCount = computed(() =>
isChatEnabled.value ? Number(unreadSupportCount.value) || 0 : 0,
)
const showSupportButton = computed(() => !!supportLink.value)
const supportIcon = computed(() => messengerMeta.value.icon)
const supportIcon = computed(() => {
const messenger = (supportMessengerSettings.value.messenger ?? '').toLowerCase()
switch (messenger) {
case 'whatsapp':
return 'vuesax-linear:whatsapp'
case 'telegram':
return 'vuesax-linear:send-2'
case 'bale':
return 'vuesax-linear:messages-2'
default:
return 'vuesax-linear:messages-2'
}
})
const supportAccentColor = computed(() => {
const messenger = (supportMessengerSettings.value.messenger ?? '').toLowerCase()
switch (messenger) {
case 'whatsapp':
return '#22c55e'
case 'telegram':
return '#0ea5e9'
case 'bale':
return '#16a34a'
case 'rubika':
return '#7c3aed'
case 'eitaa':
return '#f59e0b'
case 'soroush':
return '#2563eb'
default:
return primaryColor || '#334155'
}
})
const supportAccentColor = computed(() =>
(messengerMeta.value.accent !== '#334155'
? messengerMeta.value.accent
: colors.primary) || '#334155',
)
const {
dragging: supportDragging,
@@ -173,29 +169,205 @@ const {
defaultRightOffset: 16,
})
function openSupportLink() {
if (!supportLink.value) return
window.open(supportLink.value, '_blank', 'noopener,noreferrer')
}
function handleSupportPointerUp(event: PointerEvent) {
if (supportPointerUp(event)) {
openSupportLink()
}
const clicked = supportPointerUp(event)
if (!clicked) return
event.stopPropagation()
void openSupportMessenger(supportButtonEl.value)
}
onMounted(async () => {
await loadSupportMessengerFromSettings()
setTimeout(() => {
showSplash.value = false
}, 2000)
})
const userStore = useUserStore()
const settingsStore = useSettingsStore()
const { latestIncomingNotification } = useChatService()
let lastConnectedChatSessionKey = ''
let lastSupportToastAt = 0
watch(
() => [
userStore.userInfo?.chat_room_id,
userStore.userInfo?.chat_user_id,
settingsStore.getSettingByKey('chat_api_key'),
settingsStore.getSettingByKey('show_to_users_chat'),
settingsStore.getSettingByKey('has_chat'),
getToken(),
],
async ([roomId, chatUserId, chatApiKey, chatAdminFlag, chatCustomerFlag, token]) => {
const chatOn =
isSettingFlagOn(chatAdminFlag) && isSettingFlagOn(chatCustomerFlag)
if (!token || !chatOn || !chatApiKey || !roomId || !chatUserId) {
lastConnectedChatSessionKey = ''
disconnectChat()
return
}
const sessionKey = `${roomId}:${chatUserId}:${chatApiKey}`
if (lastConnectedChatSessionKey === sessionKey) return
try {
await connectChatIfExists()
lastConnectedChatSessionKey = sessionKey
} catch (error) {
lastConnectedChatSessionKey = ''
console.error('connectChatIfExists failed:', error)
}
},
{ immediate: true },
)
watch(
() => getToken(),
(token) => {
if (token) void resumeColleagueOrderIfPending()
},
{ immediate: true },
)
watch(latestIncomingNotification, (payload) => {
if (!payload) return
if (payload.at === lastSupportToastAt) return
lastSupportToastAt = payload.at
// فقط وقتی خارج از صفحهٔ چت (گیت داخل chat.ts)؛ داخل چت نوتیف نمی‌آید
if (typeof document === 'undefined' || document.visibilityState === 'visible') {
message.info({
content: `پیام جدید پشتیبانی: ${payload.body}`,
duration: 3,
})
}
void showBrowserNotification({
title: payload.title || 'پشتیبانی',
body: payload.body || 'پیام جدید',
tag: 'support-chat-message',
url: '/support',
})
})
</script>
<style lang="less">
.app-credit-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000000;
box-sizing: content-box;
}
.app-credit-footer--mobile {
width: 100%;
max-width: 426px;
margin-left: auto;
margin-right: auto;
min-height: var(--app-credit-bar-inner);
padding: 4px 0 var(--app-safe-bottom);
background: #fff;
box-shadow: 0 -2px 4px -2px #0000001a;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
font-size: 9px;
}
.app-credit-footer--login.app-credit-footer--mobile {
min-height: 72px;
}
:root:has(.app-credit-footer--login) {
--app-credit-bar-inner: 72px;
}
.app-credit-footer--desktop-float {
left: auto;
right: 20px;
bottom: 16px;
width: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.app-credit-link {
display: inline-flex;
align-items: center;
text-decoration: none;
color: inherit;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.85;
}
&__text {
font-weight: 500;
white-space: nowrap;
}
&__logo {
width: auto;
object-fit: contain;
}
&--mobile {
gap: 4px;
padding: 4px 0;
color: inherit;
.app-credit-link__logo {
height: 12px;
}
}
&--sidebar {
flex-direction: column;
gap: 6px;
padding: 4px;
color: #9ca3af;
font-size: 10px;
.app-credit-link__logo {
height: 15px;
opacity: 0.85;
}
}
&--float {
gap: 6px;
padding: 7px 14px;
font-size: 10px;
color: #6b7280;
background: #fff;
border-radius: 999px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.06);
.app-credit-link__logo {
height: 13px;
}
}
&--tv {
gap: 8px;
padding: 6px 0;
font-size: 12px;
color: #6b7280;
.app-credit-link__logo {
height: 16px;
}
}
}
.updateButton .van-dialog__confirm .van-button__text {
color: var(--app-primary-color, #a72b77) !important;
color: @primary-color !important;
font-weight: 600;
font-size: 15px;
}
@@ -213,6 +385,7 @@ onMounted(async () => {
}
.support-button-inner {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
@@ -238,6 +411,24 @@ onMounted(async () => {
}
}
.support-fab-badge {
position: absolute;
top: -6px;
left: -4px;
z-index: 2;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: #dc2626;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 18px;
text-align: center;
box-shadow: 0 0 0 2px #fff;
}
#support-button:not(.is-dragging):hover .support-button-inner {
transform: translateY(-1px);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
@@ -246,4 +437,9 @@ onMounted(async () => {
#support-button.is-dragging .support-button-inner {
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.22);
}
#support-button.is-picker-open .support-button-inner {
transform: scale(0.94);
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.12);
}
</style>
+24
View File
@@ -0,0 +1,24 @@
import useAxios from '@/utils/useAxios'
export type AppNotificationSeverity = 'info' | 'success' | 'warning' | 'danger'
export interface CustomerAppNotification {
id: number
title: string
body: string
severity: AppNotificationSeverity
created_at?: string
is_read: boolean
}
export const getAppNotifications = async () =>
await useAxios.get<CustomerAppNotification[]>(`/customer/app_notifications`)
export const getAppNotificationsUnreadCount = async () =>
await useAxios.get<{ unread_count: number }>(`/customer/app_notifications/unread-count`)
export const markAppNotificationRead = async (id: number) =>
await useAxios.post(`/customer/app_notifications/${id}/read`)
export const markAllAppNotificationsRead = async () =>
await useAxios.post(`/customer/app_notifications/read-all`)
+1 -6
View File
@@ -1,11 +1,6 @@
import useAxios from "@/utils/useAxios";
import type {PInventory, Product} from "@/models/products";
import type {PWallet, Wallet} from '@/models/wallet'
import type {Order} from "@/models/order";
import {useUserStore} from "@/store/userStore";
const userStore = useUserStore()
export const checkoutCart = async (orders: Order[]): Promise<any> => {
return await useAxios.post<Order[]>(`/customer/cart/checkout`, orders)
}
}
+6
View File
@@ -0,0 +1,6 @@
import useAxios from '@/utils/useAxios'
import type { User } from '@/models/login'
export const startChat = async (): Promise<{ message: string; user: User }> => {
return await useAxios.get<{ message: string; user: User }>(`/customer/start-chat`)
}
+13 -3
View File
@@ -5,13 +5,23 @@ import type {Ref, UnwrapRef} from "vue";
import type {Transaction} from "@/models/transaction";
import {useUserStore} from "@/store/userStore";
const userStore = useUserStore()
function customerOrderPrefix() {
// lazy: این ماژول ممکن است قبل از app.use(pinia) لود شود
return `/customer${!useUserStore().isSingle ? '' : '/single'}`
}
export const createOrder = async (obj: Order): Promise<any> => {
return await useAxios.post<Order>(`/customer${!userStore.isSingle ? '' : '/single'}/order`, obj)
return await useAxios.post<Order>(`${customerOrderPrefix()}/order`, obj)
}
export const getPendingColleagueOrder = async (): Promise<any> => {
return await useAxios.get(`${customerOrderPrefix()}/order/pending`)
}
export const getColleagueOrderStatus = async (id: number): Promise<any> => {
return await useAxios.get(`${customerOrderPrefix()}/order/${id}/status`)
}
export const getOrders = async (tbl: Ref<UnwrapRef<TableInput>>): Promise<TableOutput<Transaction>> => {
return await useAxios.post<TableOutput<Order>>(`/customer${!userStore.isSingle ? '' : '/single'}/order/table`, tbl)
return await useAxios.post<TableOutput<Order>>(`${customerOrderPrefix()}/order/table`, tbl)
}
+31 -3
View File
@@ -1,11 +1,39 @@
import useAxios from "@/utils/useAxios";
import type {PInventory, Product} from "@/models/products";
import type { PWallet, Wallet } from '@/models/wallet'
import { priceStreamList } from "@/utils/priceSocketState";
export const getProducts = async (): Promise<any> => {
return await useAxios.get<Product[]>(`/customer/product/list`, { timeout: 15000 })
export const getProducts = async (force = false): Promise<Product[]> => {
if (!force && Array.isArray(priceStreamList.value) && priceStreamList.value.length > 0) {
return priceStreamList.value as unknown as Product[]
}
const response = await useAxios.get<Product[]>(`/customer/product/list`, { timeout: 15000 })
return Array.isArray(response) ? response : []
}
export const getTvProducts = async (): Promise<Product[]> => {
const response = await useAxios.get<unknown>(`/customer/tv/products`, { timeout: 15000 })
return Array.isArray(response) ? (response as Product[]) : []
}
export type TvServerTime = {
unix: number
iso: string
timezone: string
stream_key?: string
}
export const getTvServerTime = async (): Promise<TvServerTime | null> => {
try {
const response = await useAxios.get<TvServerTime>(`/customer/tv/time`, { timeout: 10000 })
if (response && typeof (response as TvServerTime).unix === 'number') {
return response as TvServerTime
}
} catch {
/* ignore */
}
return null
}
export const getProductById = async ( id:number|null): Promise<any> => {
return await useAxios.get<Product[]>(`/customer/product/list/${id}`, { timeout: 15000 })
}
+4 -2
View File
@@ -7,6 +7,7 @@ import { setupRouterGuards } from '@/app/setupRouter'
import { useSettingsStore } from '@/store/settingsStore'
import { initPriceRealtimeChannel } from '@/utils/priceSocket'
import { initMobileScrollRestoration } from '@/layouts/composables/useMobileScrollReset'
import { initMobileInputKeyboardScroll } from '@/utils/mobileInputKeyboardScroll'
type BootWindow = Window & {
__BOOT_TIMEOUT__?: number
@@ -48,13 +49,13 @@ function showBootstrapError(error: unknown) {
<div style="font-size: 42px; margin-bottom: 12px;">⚠️</div>
<h2 style="margin: 0 0 12px; font-size: 20px;">خطا در راه‌اندازی برنامه</h2>
<p style="margin: 0 0 20px; color: var(--boot-muted, rgba(15,23,42,0.62)); line-height: 2; font-size: 14px;">
لطفاً اتصال اینترنت خود را بررسی کنید و دوباره تلاش کنید.
برقراری ارتباط با سرور امکان‌پذیر نیست. لطفاً اتصال اینترنت خود را بررسی کرده و صفحه را تازه‌سازی نمایید.
</p>
<button
onclick="location.reload()"
style="padding: 11px 22px; border: 0; border-radius: 12px; background: var(--boot-accent, #2563eb); color: white; cursor: pointer; font-weight: 700;"
>
تلاش مجدد
تازه‌سازی صفحه
</button>
</div>
</div>
@@ -76,6 +77,7 @@ export async function bootstrapApp() {
initPriceRealtimeChannel((key) => settingsStore.getSettingByKey(key))
initMobileScrollRestoration()
initMobileInputKeyboardScroll()
setupRouterGuards()
app.mount('#app')
+3 -2
View File
@@ -1,14 +1,15 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createPinia, setActivePinia } from 'pinia'
import VueLazyLoad from 'vue3-lazyload'
import VueApexCharts from 'vue3-apexcharts'
import App from '@/App.vue'
import router from '@/router'
export function createVueApp() {
const app = createApp(App)
const pinia = createPinia()
setActivePinia(pinia)
const app = createApp(App)
app.use(pinia)
app.use(VueLazyLoad, {})
app.use(router)
+83 -69
View File
@@ -2,7 +2,6 @@ import { computed, watch } from 'vue'
import router from '@/router'
import { authenticateWithFingerprint, getToken, isIphone, removeToken } from '@/utils'
import { useUserStore } from '@/store/userStore'
import { useWalletStore } from '@/store/walletStore'
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
import { useAuthStore } from '@/store/useAuthStore'
import {
@@ -13,49 +12,10 @@ import {
setFingerTimeOut,
setHasFingerPrint
} from '@/app/authBoot'
import { recoverMobileViewportAfterAuth } from '@/layouts/composables/useMobileScrollReset'
import {
getDefaultLandingRoute,
hasAbshodeTrading,
hasHomePage,
hasProductShop,
} from '@/config/project-profile'
const GUEST_PUBLIC_ROUTES = ['login', '404', 'about', 'cart'] as const
function getGuestPublicRoutes(): string[] {
const routes: string[] = [...GUEST_PUBLIC_ROUTES]
if (hasProductShop()) {
routes.push('buyProduct', 'productDetail')
}
return routes
}
function enforceProjectRouteAccess(
to: RouteLocationNormalized,
next: NavigationGuardNext
): boolean {
if (!hasHomePage() && to.name === 'home') {
next({ name: getDefaultLandingRoute() })
return true
}
if (!hasAbshodeTrading() && to.name === 'gold') {
next({ name: getDefaultLandingRoute() })
return true
}
if (!hasProductShop() && (to.name === 'buyProduct' || to.name === 'productDetail')) {
next({ name: getDefaultLandingRoute() })
return true
}
return false
}
import { clearVantOverlays, recoverMobileViewportAfterAuth } from '@/layouts/composables/useMobileScrollReset'
export function setupRouterGuards() {
const userStore = useUserStore()
const walletStore = useWalletStore()
const authStore = useAuthStore()
const isApple: boolean = isIphone()
@@ -68,20 +28,22 @@ export function setupRouterGuards() {
return
}
const tasks: Promise<unknown>[] = [
walletStore.getLimitedPrice(),
walletStore.getLimitedBuyGold(),
]
if (!userStore.userInfo) {
tasks.push(userStore.getMyInfo())
await userStore.getMyInfo()
}
}
await Promise.all(tasks)
function isGuestAllowed(to: RouteLocationNormalized) {
if (to.meta?.public) {
return true
}
return ['login', '404', 'buyProduct', 'productDetail', 'about', 'aboutSoftware', 'cart', 'tvMonitor'].includes(
to.name as string,
)
}
function goToLogin(to: RouteLocationNormalized, next: NavigationGuardNext) {
if (getGuestPublicRoutes().includes(to.name as string)) {
if (isGuestAllowed(to)) {
return next()
}
if (to.name !== 'login') {
@@ -90,10 +52,9 @@ export function setupRouterGuards() {
return next()
}
function goToLanding(to: RouteLocationNormalized, next: NavigationGuardNext) {
const landing = getDefaultLandingRoute()
if (to.name !== landing) {
return next({ name: landing })
function goToHome(to: RouteLocationNormalized, next: NavigationGuardNext) {
if (to.name !== 'buyProduct') {
return next({ name: 'buyProduct' })
}
return next()
}
@@ -101,10 +62,69 @@ export function setupRouterGuards() {
router.beforeEach(async (to, from, next) => {
authStore.setLoading(true)
clearFingerTimeout()
setFingerTimeOut(to)
if (to.name !== 'tvMonitor' && !to.meta?.hideChrome) {
setFingerTimeOut(to)
}
if (enforceProjectRouteAccess(to, next)) {
return
if (to.name === 'notifyTrigger' && getToken()) {
const { useSettingsStore } = await import('@/store/settingsStore')
const settingsStore = useSettingsStore()
if (!settingsStore.loaded) {
await settingsStore.fetchSettings()
}
if (!settingsStore.isSettingTruthy('has_page_price_trigger')) {
return next({ name: (from.name as string) || 'user' })
}
}
if (to.name === 'appNotifications' && getToken()) {
const { useSettingsStore } = await import('@/store/settingsStore')
const settingsStore = useSettingsStore()
if (!settingsStore.loaded) {
await settingsStore.fetchSettings()
}
if (!settingsStore.isSettingTruthy('has_page_notification')) {
return next({ name: (from.name as string) || 'home' })
}
}
if (to.name === 'giftCard' && getToken()) {
const { useSettingsStore } = await import('@/store/settingsStore')
const settingsStore = useSettingsStore()
if (!settingsStore.loaded) {
await settingsStore.fetchSettings()
}
if (!settingsStore.isSettingTruthy('has_page_gift_card')) {
return next({ name: (from.name as string) || 'user' })
}
}
if (
getToken() &&
(to.name === 'piggy' ||
to.name === 'fund' ||
to.name === 'fund_info' ||
to.name === 'fund_add')
) {
const { useSettingsStore } = await import('@/store/settingsStore')
const settingsStore = useSettingsStore()
if (!settingsStore.loaded) {
await settingsStore.fetchSettings()
}
if (!settingsStore.isSettingTruthy('enable_product_funds')) {
return next({ name: (from.name as string) || 'home' })
}
}
if (to.name === 'earnMoney' && getToken()) {
const { useSettingsStore } = await import('@/store/settingsStore')
const settingsStore = useSettingsStore()
if (!settingsStore.loaded) {
await settingsStore.fetchSettings()
}
if (!settingsStore.isSettingTruthy('has_page_earn_money')) {
return next({ name: (from.name as string) || 'user' })
}
}
if (isFirst) {
@@ -126,15 +146,12 @@ export function setupRouterGuards() {
}
} else {
if (to.name == 'login') {
return goToLanding(to, next)
}
if (enforceProjectRouteAccess(to, next)) {
return
goToHome(to, next)
}
return next()
}
} else {
if (getGuestPublicRoutes().includes(to.name as string)) {
if (isGuestAllowed(to)) {
return next()
}
goToLogin(to, next)
@@ -143,24 +160,18 @@ export function setupRouterGuards() {
if (getFingerPosition()) {
if (getToken()) {
if (to.name === 'login') {
return next({ name: (from.name as string) || getDefaultLandingRoute() })
}
if (enforceProjectRouteAccess(to, next)) {
return
return next({ name: from.name || 'buyProduct' })
}
return next()
}
if (getGuestPublicRoutes().includes(to.name as string)) {
if (isGuestAllowed(to)) {
return next()
}
return next({ name: 'login' })
}
if (getToken() && !getHasFingerPrint()) {
if (enforceProjectRouteAccess(to, next)) {
return
}
next()
} else {
goToLogin(to, next)
@@ -169,6 +180,9 @@ export function setupRouterGuards() {
})
router.afterEach((to, from) => {
if (to.name === 'login') {
clearVantOverlays()
}
if (document.documentElement.classList.contains('app-layout--desktop')) return
if (from.name === 'login') {
recoverMobileViewportAfterAuth()
+45 -45
View File
@@ -10,7 +10,10 @@
@import (less) '../../views/base/user/desktop/styles/desktop-user.less';
@import (less) '../../views/base/priceTrigger/desktop/styles/desktop-price-trigger.less';
@import (less) '../../views/base/home/desktop/styles/desktop-home.less';
@import (less) '../../views/base/gold/desktop/styles/desktop-gold.less';
@import (less) '../../views/login/desktop/styles/desktop-login.less';
@import (less) '../../layouts/desktop/styles/desktop-inner-pages.less';
@import (less) './mobile-keyboard-scroll.less';
html {
scroll-behavior: smooth;
@@ -61,19 +64,13 @@ body {
:root {
color-scheme: only light;
--app-primary-color: #a72b77;
--app-secondary-color: #a72b77;
--app-tertiary-color: #e0be7f;
--app-background-color: #ffffff;
--app-primary-dark: #711d4d;
--app-primary-light: #c45f9a;
--app-safe-top: env(safe-area-inset-top, 0px);
--app-safe-bottom: env(safe-area-inset-bottom, 0px);
--app-credit-bar-inner: 0px;
--app-credit-bar-inner: 28px;
--app-home-header-offset: calc(4.25rem + var(--app-safe-top));
--app-credit-footer-offset: var(--app-safe-bottom);
--app-credit-footer-offset: calc(var(--app-credit-bar-inner) + var(--app-safe-bottom));
--app-nav-height: 80px;
--app-bottom-chrome-height: calc(var(--app-nav-height) + var(--app-safe-bottom));
--app-bottom-chrome-height: calc(var(--app-nav-height) + var(--app-credit-footer-offset));
--app-add-card-height: 88px;
--wallet-card-dock-overlap: 52px;
--app-z-nav: 5000;
@@ -96,6 +93,13 @@ body {
}
}
@media (min-width: 500px) {
.login-vip-page__body{
height: 20vh !important;
overflow: auto;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
@@ -110,9 +114,24 @@ body {
z-index: 5001 !important;
}
//.van-overlay{
// z-index: 2000 !important;
//}
.van-overlay{
z-index: 5000 !important;
background: rgba(0, 0, 0, 0.55) !important;
pointer-events: auto !important;
}
.van-dialog {
z-index: var(--app-z-dialog, 5300) !important;
}
body.van-overflow-hidden {
.bottom-appbar,
.app-credit-footer,
#support-button {
visibility: hidden !important;
pointer-events: none !important;
}
}
.color-transition {
transition: color 0.3s ease;
@@ -164,13 +183,19 @@ body {
border: 1px solid #d1d5db;
border-radius: 4px;
background-color: white;
color: #111827;
caret-color: #111827;
-webkit-text-fill-color: #111827;
color-scheme: light;
//@apply bg-gray-50;
&:focus-visible {
background-color: #fff;
color: #111827;
-webkit-text-fill-color: #111827;
//display: none;
outline: none;
border: 1px solid var(--app-primary-color, #a72b77) !important;
border: 1px solid #a72b77 !important;
}
}
@@ -368,49 +393,24 @@ body {
}
.ant-radio-group-solid
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover{
color: var(--app-primary-color, #a72b77) !important;
color: #a72b77 !important;
}
.ant-input-group-addon{
background-color: var(--app-primary-color, #a72b77) !important;
color: var(--app-tertiary-color, #e0be7f) !important;
background-color: #a72b77 !important;
color: #e0be7f !important;
}
.ant-slider-track{
background-color: var(--app-primary-color, #a72b77) !important;
background-color: #a72b77 !important;
}
.ant-radio-checked .ant-radio-inner,.ant-radio-group-solid .ant-radio-button-wrapper-checked {
border-color: var(--app-primary-color)!important;
background-color: var(--app-primary-color)!important
}
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover:before {
background-color: var(--app-primary-color)!important
}
.ant-slider-handle {
background-color: var(--app-primary-color, #a72b77) !important;
border-color: var(--app-tertiary-color, #e0be7f) !important;
}
.ant-btn-primary{
background-color: var(--app-primary-color, #a72b77) !important;
color: var(--app-tertiary-color, #ffffff) !important;
border-color: var(--app-primary-color, #a72b77) !important;;
background-color: #a72b77 !important;
}
.ant-drawer-body{
padding: 0px !important;
}
.ant-radio-group-solid .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover{
color: white !important;
}
.ant-spin{
color: var(--app-tertiary-color, #000000) !important;
}
.ant-spin-dot-item{
background-color: var(--app-tertiary-color, #a72b77) !important;
}
.ant-btn:hover{
color: var(--app-primary-color, #a72b77) !important;
border-color: var(--app-primary-color, #a72b77) !important;;
}
@@ -0,0 +1,51 @@
:root {
--app-keyboard-inset: 0px;
--app-visual-viewport-height: 100dvh;
--app-visual-viewport-offset-top: 0px;
}
@media screen and (max-width: 767.98px) {
input,
textarea,
select,
.ant-input,
.ant-input-affix-wrapper,
.ant-select-selection-search-input {
scroll-margin-bottom: calc(var(--app-keyboard-inset) + 16px);
}
html[data-keyboard-open='true'] {
.main-with-bottom-chrome,
.login-vip-page__body,
.login-page,
.login-page-root,
.ant-modal-body,
.ant-drawer-body,
.van-action-sheet__content,
.basic-action-sheet__body {
scroll-padding-bottom: calc(var(--app-keyboard-inset) + 16px);
}
.main-with-bottom-chrome,
.login-vip-page__body,
.login-page {
padding-bottom: var(--app-keyboard-inset);
}
.login-mobile {
height: var(--app-visual-viewport-height, 100dvh);
max-height: var(--app-visual-viewport-height, 100dvh);
}
.login-page.flex {
height: var(--app-visual-viewport-height, 92vh) !important;
max-height: var(--app-visual-viewport-height, 92vh) !important;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
align-items: flex-start !important;
justify-content: flex-start !important;
padding-top: 0.75rem;
}
}
}
-1
View File
@@ -1,6 +1,5 @@
@prefixCls: 'noname';
/* مقادیر ثابت برای Ant Design Less (fade و توابع رنگ). تم واقعی در runtime با --app-primary-color ست می‌شود. */
@primary-color: #a72b77;
@secondary-color: #a72b77;
@App-primary-color: #a72b77;
Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<circle cx="128" cy="128" r="124" fill="#12B76A"/>
<!-- Bootstrap Icons telephone-fill — گوشی کامل -->
<g transform="translate(48 48) scale(10)">
<path
fill="#fff"
d="M1.885.511a1.745 1.745 0 0 1 2.612.163L6.29 2.98c.329.423.445.967.315 1.49l-.547 2.19a.678.678 0 0 0 .178.643l2.457 2.457a.678.678 0 0 0 .644.178l2.189-.547a1.745 1.745 0 0 1 1.49.315l2.306 1.794c.829.645.905 1.87.163 2.611l-1.034 1.034c-.74.74-1.846 1.065-2.877.702a18.634 18.634 0 0 1-7.01-4.42 18.634 18.634 0 0 1-4.42-7.009c-.362-1.03-.038-2.137.703-2.877L1.885.511z"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 680 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" fill="none">
<circle cx="128" cy="128" r="128" fill="#3B82F6"/>
<path
fill="#fff"
d="M78 76h100c11 0 20 9 20 20v64c0 11-9 20-20 20h-52l-30 26v-26H78c-11 0-20-9-20-20V96c0-11 9-20 20-20zm16 40h68v14H94v-14zm0 28h48v14H94v-14z"
/>
</svg>

After

Width:  |  Height:  |  Size: 337 B

+7
View File
@@ -0,0 +1,7 @@
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="#1CA0E2"/>
<g transform="translate(12 12)">
<path d="M14.4501 0.922337C14.4249 0.922337 14.3996 0.922337 14.3749 0.922337C13.9177 0.865759 13.4564 0.848106 12.9962 0.869568C12.3937 0.891042 11.7968 0.992415 11.2211 1.17103C10.5799 1.36599 9.98324 1.6851 9.46511 2.11021C9.27918 2.26684 9.10383 2.43563 8.94023 2.61545C8.62473 2.95789 8.39401 3.35478 8.19696 3.77245C8.09086 3.997 7.99543 4.22773 7.8955 4.45565C7.88203 4.48764 7.8708 4.5202 7.86014 4.55333C7.82589 4.66167 7.79558 4.77114 7.75853 4.87836C7.65492 5.2025 7.5737 5.53337 7.51545 5.86863C7.45552 6.17826 7.41539 6.4914 7.39532 6.80613C7.37904 7.03574 7.36669 7.2659 7.36613 7.49607C7.36593 7.89339 7.3888 8.29039 7.43461 8.68506C7.49075 9.19592 7.58787 9.70059 7.69341 10.2036C7.81298 10.7756 7.99094 11.3303 8.1644 11.8877C8.33787 12.4452 8.5394 12.9847 8.73757 13.5297C8.89756 13.9716 9.04576 14.4167 9.16702 14.8709C9.24027 15.1385 9.29245 15.4114 9.32309 15.6871C9.37053 16.0792 9.35422 16.4763 9.27481 16.8632C9.26358 16.9126 9.25179 16.9614 9.23719 17.0097C9.17854 17.206 9.10843 17.3987 9.02724 17.5868C8.97171 17.7142 8.90383 17.8359 8.82458 17.95C8.54294 18.3624 8.19617 18.7262 7.79782 19.0273C7.33066 19.3746 6.78803 19.6066 6.21418 19.7043C5.97198 19.7479 5.72653 19.7708 5.48046 19.7728C4.65255 19.785 3.84735 19.5021 3.20913 18.9745C3.06738 18.8636 2.93586 18.7402 2.81616 18.6057C2.79988 18.5866 2.78024 18.5704 2.76003 18.553C2.73982 18.5355 2.73701 18.5479 2.72578 18.553C2.70445 18.5423 2.69715 18.5198 2.68536 18.5019C2.53435 18.2655 2.37492 18.0348 2.23289 17.7923C2.02466 17.4393 1.83484 17.0758 1.66422 16.7032C1.4634 16.2612 1.29071 15.8068 1.14719 15.343C1.028 14.9577 0.929988 14.5662 0.853591 14.1703C0.802506 13.9081 0.760402 13.6437 0.730649 13.3776C0.707633 13.171 0.686862 12.9644 0.674512 12.7567C0.663846 12.6024 0.663846 12.448 0.655425 12.2942C0.647566 12.1645 0.644197 12.0354 0.647004 11.9062C0.650934 11.7204 0.654302 11.5341 0.666091 11.3488C0.679564 11.1349 0.696405 10.921 0.722229 10.7088C0.800805 10.0199 0.942301 9.33971 1.14495 8.67664C1.27499 8.25508 1.42887 7.84124 1.60584 7.43712C1.89207 6.78413 2.23982 6.15987 2.64438 5.57279C3.12563 4.87288 3.68354 4.22892 4.30774 3.65288C5.11591 2.9082 6.02747 2.28423 7.01414 1.80033C7.73201 1.44668 8.48602 1.17169 9.26302 0.980159C9.59984 0.898198 9.93667 0.829149 10.2814 0.776941C10.4554 0.750369 10.6303 0.728288 10.8062 0.710698C10.9943 0.691611 11.1829 0.681506 11.371 0.663542C11.4367 0.657367 11.5035 0.65849 11.5697 0.657367C11.9065 0.649508 12.2383 0.649508 12.5723 0.661297C12.7015 0.665788 12.83 0.681506 12.9586 0.69105C13.0338 0.696664 13.109 0.703962 13.1831 0.711259C13.4533 0.737083 13.7213 0.774508 13.987 0.823535C14.1229 0.848797 14.2587 0.875743 14.3951 0.901566C14.4154 0.901603 14.4349 0.908983 14.4501 0.922337Z" fill="#ffffff"/>
<path d="M5.25874 21.1173C5.27981 21.122 5.29921 21.1323 5.31488 21.1471C5.63283 21.3639 5.96476 21.5595 6.30852 21.7326C6.92863 22.0651 7.58142 22.3326 8.2565 22.5309C8.68006 22.6535 9.11143 22.7472 9.54766 22.8116C10.1444 22.8977 10.748 22.9264 11.3502 22.8974C11.7252 22.8804 12.0987 22.8397 12.4685 22.7756C12.6768 22.7403 12.8834 22.6976 13.0894 22.6516C13.6547 22.5202 14.2077 22.3411 14.7426 22.116C15.2004 21.927 15.6441 21.7057 16.0703 21.4536C16.5961 21.1443 17.0675 20.7507 17.4659 20.2887C17.8669 19.8343 18.2251 19.3438 18.5359 18.8235C19.0119 18.0108 19.35 17.1247 19.5362 16.2013C19.5979 15.8984 19.6419 15.5921 19.6682 15.2841C19.6987 14.9384 19.7084 14.5912 19.6974 14.2444C19.6623 13.1958 19.4489 12.1608 19.0664 11.1838C18.9917 10.9929 18.9131 10.8037 18.8351 10.614C18.8194 10.5763 18.8008 10.5399 18.7818 10.5017C18.4002 9.77858 17.9286 9.1067 17.3783 8.50205C17.0213 8.10516 16.6334 7.7397 16.2449 7.37481C15.9642 7.1132 15.6661 6.87687 15.3658 6.64109C15.1553 6.47267 14.938 6.31829 14.7365 6.1409C14.6708 6.07719 14.6006 6.01828 14.5265 5.96463C14.5033 5.94836 14.482 5.92953 14.4631 5.90849C14.3716 5.81306 14.2778 5.71931 14.1897 5.62106C13.9048 5.30938 13.7133 4.9238 13.6373 4.50841C13.5599 4.04993 13.605 3.57914 13.7681 3.14371C14.0347 2.46051 14.5209 2.00355 15.2063 1.7571C15.6623 1.58876 16.1576 1.55753 16.6311 1.66728C16.6586 1.67346 16.6873 1.67683 16.7148 1.68132C16.716 1.68322 16.7176 1.68484 16.7195 1.68607C16.7214 1.68729 16.7235 1.68809 16.7258 1.68841C16.728 1.68873 16.7303 1.68856 16.7325 1.68792C16.7346 1.68728 16.7366 1.68617 16.7383 1.68469C16.8039 1.70602 16.8668 1.73484 16.9258 1.77058C17.6094 2.10085 18.2582 2.4986 18.8626 2.95789C19.1186 3.1525 19.366 3.35834 19.6047 3.57541C19.8577 3.8037 20.1001 4.04303 20.3317 4.29341C21.5814 5.64007 22.4838 7.27123 22.9606 9.04547C23.0482 9.37163 23.1201 9.70059 23.1801 10.0324C23.2149 10.2277 23.2434 10.424 23.2655 10.6213C23.2885 10.8312 23.3087 11.0412 23.3216 11.2517C23.3312 11.3836 23.3345 11.5161 23.339 11.6486C23.3424 11.7608 23.3508 11.8686 23.3491 11.9792C23.3463 12.1998 23.3441 12.421 23.33 12.6411C23.3147 12.8858 23.2926 13.1304 23.2638 13.3748C23.0982 14.7647 22.6752 16.1116 22.0164 17.3466C21.8345 17.6892 21.6334 18.0213 21.4141 18.3413C21.3957 18.3665 21.3825 18.3952 21.3754 18.4255C21.3682 18.4559 21.3673 18.4874 21.3725 18.5181C21.3966 18.677 21.4185 18.8359 21.4404 18.9953C21.4623 19.1547 21.4837 19.3198 21.505 19.482C21.528 19.6566 21.551 19.8318 21.5735 20.0064C21.5886 20.1287 21.6016 20.2506 21.6167 20.3724C21.633 20.5071 21.6509 20.6413 21.6672 20.7754C21.6835 20.9096 21.6975 21.032 21.7127 21.16C21.7279 21.288 21.7413 21.4042 21.7565 21.526C21.7722 21.6574 21.789 21.7887 21.8053 21.919C21.8233 22.0627 21.8407 22.207 21.8581 22.3507C21.8733 22.4759 21.8873 22.6005 21.9019 22.7257C21.9097 22.7913 21.9182 22.857 21.926 22.9227C21.9339 22.9884 21.9182 23.0064 21.8463 22.9884C21.7851 22.9732 21.7245 22.9558 21.6639 22.9396L20.2486 22.5612C19.7995 22.4416 19.3538 22.3232 18.9069 22.2025C18.5662 22.111 18.226 22.0166 17.8858 21.9218C17.8224 21.9044 17.7578 21.8881 17.6949 21.8656C17.6773 21.8594 17.6585 21.8573 17.6399 21.8594C17.6213 21.8616 17.6035 21.8679 17.5877 21.878C17.3474 22.0172 17.0993 22.1424 16.8489 22.262C16.493 22.4309 16.1283 22.5808 15.7565 22.7111C15.361 22.8498 14.958 22.9664 14.5495 23.0602C14.3008 23.1164 14.051 23.1686 13.7984 23.204C13.6395 23.2264 13.4812 23.255 13.3218 23.2724C13.1467 23.291 12.9709 23.3073 12.7947 23.319C12.4674 23.3404 12.1395 23.3544 11.8117 23.3482C11.6444 23.3449 11.4775 23.3385 11.3109 23.3291C11.1599 23.3202 11.0089 23.3089 10.8585 23.2921C10.6429 23.2674 10.4273 23.241 10.2129 23.2068C9.96701 23.1675 9.72281 23.122 9.4831 23.067C8.84985 22.9227 8.23008 22.7246 7.63056 22.4747C7.28999 22.3321 6.9571 22.1735 6.63187 21.9987C6.17367 21.752 5.73289 21.4742 5.31264 21.1673C5.28962 21.1555 5.26492 21.1448 5.25874 21.1173Z" fill="#ffffff"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><defs><linearGradient id="SVG6DaOZcwt" x1="50%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stop-color="#2aabee"/><stop offset="100%" stop-color="#229ed9"/></linearGradient></defs><path fill="url(#SVG6DaOZcwt)" d="M128 0C94.06 0 61.48 13.494 37.5 37.49A128.04 128.04 0 0 0 0 128c0 33.934 13.5 66.514 37.5 90.51C61.48 242.506 94.06 256 128 256s66.52-13.494 90.5-37.49c24-23.996 37.5-56.576 37.5-90.51s-13.5-66.514-37.5-90.51C194.52 13.494 161.94 0 128 0"/><path fill="#fff" d="M57.94 126.648q55.98-24.384 74.64-32.152c35.56-14.786 42.94-17.354 47.76-17.441c1.06-.017 3.42.245 4.96 1.49c1.28 1.05 1.64 2.47 1.82 3.467c.16.996.38 3.266.2 5.038c-1.92 20.24-10.26 69.356-14.5 92.026c-1.78 9.592-5.32 12.808-8.74 13.122c-7.44.684-13.08-4.912-20.28-9.63c-11.26-7.386-17.62-11.982-28.56-19.188c-12.64-8.328-4.44-12.906 2.76-20.386c1.88-1.958 34.64-31.748 35.26-34.45c.08-.338.16-1.598-.6-2.262c-.74-.666-1.84-.438-2.64-.258c-1.14.256-19.12 12.152-54 35.686c-5.1 3.508-9.72 5.218-13.88 5.128c-4.56-.098-13.36-2.584-19.9-4.708c-8-2.606-14.38-3.984-13.82-8.41c.28-2.304 3.46-4.662 9.52-7.072"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 258"><defs><linearGradient id="SVGK3KZq49U" x1="50%" x2="50%" y1="100%" y2="0%"><stop offset="0%" stop-color="#1faf38"/><stop offset="100%" stop-color="#60d669"/></linearGradient><linearGradient id="SVGefMkoEOd" x1="50%" x2="50%" y1="100%" y2="0%"><stop offset="0%" stop-color="#f9f9f9"/><stop offset="100%" stop-color="#fff"/></linearGradient></defs><path fill="url(#SVGK3KZq49U)" d="M5.463 127.456c-.006 21.677 5.658 42.843 16.428 61.499L4.433 252.697l65.232-17.104a123 123 0 0 0 58.8 14.97h.054c67.815 0 123.018-55.183 123.047-123.01c.013-32.867-12.775-63.773-36.009-87.025c-23.23-23.25-54.125-36.061-87.043-36.076c-67.823 0-123.022 55.18-123.05 123.004"/><path fill="url(#SVGefMkoEOd)" d="M1.07 127.416c-.007 22.457 5.86 44.38 17.014 63.704L0 257.147l67.571-17.717c18.618 10.151 39.58 15.503 60.91 15.511h.055c70.248 0 127.434-57.168 127.464-127.423c.012-34.048-13.236-66.065-37.3-90.15C194.633 13.286 162.633.014 128.536 0C58.276 0 1.099 57.16 1.071 127.416m40.24 60.376l-2.523-4.005c-10.606-16.864-16.204-36.352-16.196-56.363C22.614 69.029 70.138 21.52 128.576 21.52c28.3.012 54.896 11.044 74.9 31.06c20.003 20.018 31.01 46.628 31.003 74.93c-.026 58.395-47.551 105.91-105.943 105.91h-.042c-19.013-.01-37.66-5.116-53.922-14.765l-3.87-2.295l-40.098 10.513z"/><path fill="#fff" d="M96.678 74.148c-2.386-5.303-4.897-5.41-7.166-5.503c-1.858-.08-3.982-.074-6.104-.074c-2.124 0-5.575.799-8.492 3.984c-2.92 3.188-11.148 10.892-11.148 26.561s11.413 30.813 13.004 32.94c1.593 2.123 22.033 35.307 54.405 48.073c26.904 10.609 32.379 8.499 38.218 7.967c5.84-.53 18.844-7.702 21.497-15.139c2.655-7.436 2.655-13.81 1.859-15.142c-.796-1.327-2.92-2.124-6.105-3.716s-18.844-9.298-21.763-10.361c-2.92-1.062-5.043-1.592-7.167 1.597c-2.124 3.184-8.223 10.356-10.082 12.48c-1.857 2.129-3.716 2.394-6.9.801c-3.187-1.598-13.444-4.957-25.613-15.806c-9.468-8.442-15.86-18.867-17.718-22.056c-1.858-3.184-.199-4.91 1.398-6.497c1.431-1.427 3.186-3.719 4.78-5.578c1.588-1.86 2.118-3.187 3.18-5.311c1.063-2.126.531-3.986-.264-5.579c-.798-1.593-6.987-17.343-9.819-23.64"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+98
View File
@@ -0,0 +1,98 @@
<template>
<a
href="https://montzar.ir"
target="_blank"
rel="noopener noreferrer"
class="app-credit-link"
:class="`app-credit-link--${variant}`"
>
<span class="app-credit-link__text">توسعه و طراحی توسط</span>
<img :src="logoMontzarUrl" alt="montzar" class="app-credit-link__logo" />
</a>
</template>
<script lang="ts" setup>
import logoMontzarUrl from '@/assets/img/logo-montzar.png'
withDefaults(
defineProps<{
variant?: 'mobile' | 'sidebar' | 'float' | 'tv'
}>(),
{ variant: 'mobile' },
)
</script>
<style lang="less">
.app-credit-link {
display: inline-flex;
align-items: center;
text-decoration: none;
color: inherit;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.85;
}
&__text {
font-weight: 500;
white-space: nowrap;
}
&__logo {
width: auto;
max-width: 140px;
object-fit: contain;
}
&--mobile {
gap: 4px;
padding: 4px 0;
color: inherit;
.app-credit-link__logo {
height: 12px;
}
}
&--sidebar {
flex-direction: column;
gap: 6px;
padding: 4px;
color: #9ca3af;
font-size: 10px;
.app-credit-link__logo {
height: 15px;
opacity: 0.85;
}
}
&--float {
gap: 6px;
padding: 7px 14px;
font-size: 10px;
color: #6b7280;
background: #fff;
border-radius: 999px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.06);
.app-credit-link__logo {
height: 13px;
}
}
&--tv {
gap: 8px;
padding: 6px 0;
font-size: 12px;
color: #6b7280;
.app-credit-link__logo {
height: 16px;
max-width: 120px;
}
}
}
</style>
@@ -26,7 +26,7 @@ const _attrs = computed(() => {
const props = withDefaults(defineProps<Props>(), {
value: '',
format: 'YYYY/MM/DD'
format: 'YYYY-MM-DD'
})
const emit = defineEmits<{
@@ -16,7 +16,7 @@
</style>-->
<template>
<VanActionSheet :show="show" @update:show="$emit('update:show', $event)" :placement="placement" teleport="body">
<VanActionSheet :show="show" overlay lock-scroll close-on-click-overlay teleport="body" :placement="placement" @update:show="$emit('update:show', $event)">
<div class="justify-center cursor-pointer flex mt-1" @click="$emit('update:show', false)">
<span class="rounded-sm w-12 border-t-2 border-t-solid border-t-gray-500 mt-2"></span>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@
<AButton :disabled="props.disabledB" :type="props.type" :size="props.size" :class="[prefixCls, _class]" :loading="props.loading">
<!-- py-1 px-2-->
<div
class="flex items-center gap-1 justify-center flex-row-reverse gap-2 px-2"
class="flex items-center gap-1 justify-center flex-row-reverse gap-2 py-1 px-2"
:class="[{ 'flex-row-reverse': suffixIcon }]"
>
<slot v-if="$slots.default"></slot>
-6
View File
@@ -12,7 +12,6 @@
import icons from '@/components/BasicPacks/tools/icons/icons.json'
import {usePrefix} from "@/utils/usePrefix";
import {computed, onMounted, ref, withDefaults, defineProps} from "vue";
import { getAppPrimaryColor } from '@/config/brand-theme'
interface Props {
icon: string
@@ -29,11 +28,6 @@ const tempIcon = ref<string>()
const refIcon = ref<HTMLDivElement>()
tempIcon.value = icons[`${props.icon}`] as string
const color = computed(() => {
if (!props.color) return props.color
const raw = String(props.color).trim()
if (raw.includes('--app-primary-color') || raw === 'primary') {
return getAppPrimaryColor()
}
return props.color
})
+28 -26
View File
@@ -14,6 +14,7 @@
:type="props.type"
class=""
@change="handlerChange"
@input="handlerChange"
/>
<a-input
v-else
@@ -28,6 +29,9 @@
:pattern="props.pattern"
class=""
@change="handlerChange"
@input="handlerChange"
@blur="emits('blur', $event)"
@focus="emits('focus', $event)"
/>
</div>
</template>
@@ -36,8 +40,11 @@
import {usePrefix} from "@/utils/usePrefix";
import {computed} from "vue";
import {usePrice} from "@/components/BasicPacks/BasicPriceInput/src/utils/usePrice";
import {persianToEnglishDigits, shouldNormalizeInputDigits} from '@/utils/persianDigits';
import { getAppPrimaryColor } from '@/config/brand-theme'
import {
normalizeDecimalInput,
persianToEnglishDigits,
shouldNormalizeDecimalSeparators,
} from '@/utils/persianDigits';
//Props
interface Prop {
@@ -56,23 +63,13 @@ interface Prop {
placeHolder?: String
}
function resolveCssColor(value?: String | string | null) {
if (!value) return value
const raw = String(value).trim()
if (raw.includes('--app-primary-color') || raw === 'primary') {
return getAppPrimaryColor()
}
return raw
}
const addonBgColor = computed(() => props.addonBgColor)
const addonTextColor = computed(() => props.addonTextColor)
const props = withDefaults(defineProps<Prop>(), {
isPassword: false
})
const addonBgColorResolved = computed(() => resolveCssColor(props.addonBgColor) || getAppPrimaryColor())
const addonTextColorResolved = computed(() => resolveCssColor(props.addonTextColor) || 'white')
const emits = defineEmits(['update:value', 'onchange'])
const emits = defineEmits(['update:value', 'onchange', 'blur', 'focus'])
const localValue = computed({
set(value) {
@@ -90,18 +87,23 @@ function normalizeValue(value: unknown) {
return value
}
let normalized = String(value)
if (shouldNormalizeInputDigits(props)) {
normalized = persianToEnglishDigits(normalized)
if (shouldNormalizeDecimalSeparators(props)) {
const isPrice = String(props.type ?? '').trim().toLowerCase() === 'price'
return normalizeDecimalInput(value as string | number, {
commaAsThousand: isPrice,
})
}
if (props.type === 'price') {
normalized = normalized.replaceAll(',', '')
}
return normalized
return persianToEnglishDigits(String(value))
}
const handlerChange = (e: any) => {
const normalized = normalizeValue(e.target.value)
const handlerChange = (e: Event | string) => {
const target = typeof e === 'string' ? null : (e.target as HTMLInputElement | null)
const raw = typeof e === 'string' ? e : target?.value
const normalized = normalizeValue(raw)
if (target && typeof normalized === 'string' && target.value !== normalized) {
target.value = normalized
}
emits('update:value', normalized)
emits('onchange', normalized)
}
@@ -129,8 +131,8 @@ const {prefixCls} = usePrefix('input')
}
.ant-input-group-addon {
background-color: v-bind(addonBgColorResolved);
color: v-bind(addonTextColorResolved);
background-color: v-bind(addonBgColor);
color: v-bind(addonTextColor);
}
}
</style>
+8 -3
View File
@@ -2,7 +2,7 @@
<div>
<a-modal
v-model:visible="localValue"
:title="props.title"
:title="slots.title ? undefined : props.title"
centered
@ok="emits('ok')"
@cancel="emits('close')"
@@ -11,9 +11,13 @@
:width="props.width"
:ok-text="props.okText"
:cancel-text="props.cancelText"
:confirm-loading="props.loading"
:closable="props.closable"
:wrapClassName="props.wrapClassName"
>
<template v-if="slots.title" #title>
<slot name="title" />
</template>
<template v-if="props.customFooter" #footer> <slot name="footer" /></template>
<a-spin :spinning="props.loading">
<div class="overflow-y-auto py-2 px-1 relative max-h-[83vh]" id="body">
@@ -25,8 +29,7 @@
</template>
<script setup lang="ts">
//define emit
import { computed } from 'vue'
import { computed, useSlots } from 'vue'
const emits = defineEmits(['ok', 'close', 'update:visible'])
@@ -53,6 +56,8 @@ const props = withDefaults(defineProps<Prop>(), {
closable: true
})
const slots = useSlots()
const localValue = computed({
get() {
return props.visible
@@ -10,7 +10,7 @@
import {usePrice} from './utils/usePrice'
import {computed} from "vue";
import BasicInput from "../../BasicInput.vue";
import {persianToEnglishDigits} from '@/utils/persianDigits'
import {normalizeDecimalInput} from '@/utils/persianDigits'
interface Props {
value?: string | number
@@ -24,7 +24,7 @@ const props = withDefaults(defineProps<Props>(), {})
const _val = computed({
set(val: string) {
emit('update:value', persianToEnglishDigits(val).replaceAll(',', ''))
emit('update:value', normalizeDecimalInput(val))
},
get() {
return props.value ? usePrice(props.value as number, true, '').trim() : ''
@@ -1,17 +1 @@
export function usePrice(price: number, digit = true, unit = 'ریال') {
let _price = price.toString()
if (digit) {
_price = _price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
return `${_price} ${unit}`
}
export function useTowDecimal(num: string | number): string | number {
const temp = num.toString().match(/^-?\d+(?:\.\d{0,2})?/)
if (temp && temp.length > 0) return temp[0]
return Math.round(+num * 100) / 100
}
export function useFormat(num: string | number, unit = '') {
return usePrice(Math.abs(+useTowDecimal(num)), true, unit)
}
export { usePrice, usePriceWithoutType, useTowDecimal, useFormat } from '@/utils/usePrice'
+2 -2
View File
@@ -1,4 +1,4 @@
<template>
<template>
<div class="w-full">
<div>
{{ props.title }}
@@ -105,7 +105,7 @@ const handleChange = (e: any) => {
//padding: 5px;
/* .ant-select {
.ant-select-selector {
border-color: var(--app-primary-color, #a72b77);
border-color: @primary-color;
border-radius: @border-radius-base;
}
}*/
+861
View File
@@ -0,0 +1,861 @@
<template>
<div
ref="containerRef"
class="product-picker-root"
:class="{
'product-picker-root--compact': compact,
'product-picker-root--open': isOpen,
}"
>
<label v-if="label" class="product-picker__label">{{ label }}</label>
<!-- سلکتور جمع‌وجور -->
<div
class="product-picker__control"
:class="{
'product-picker__control--clickable': !disabled && !isEdit,
'product-picker__control--disabled': disabled,
'product-picker__control--locked': isEdit,
'product-picker__control--active': isOpen,
}"
role="button"
:tabindex="disabled ? -1 : 0"
:aria-label="label || placeholder"
@click="toggleOpen"
@keydown.enter.prevent="toggleOpen"
>
<div class="product-picker__value">
<span class="product-picker__icon" aria-hidden="true">
<BasicIcon
:icon="isEdit ? 'vuesax-linear:lock' : 'vuesax-linear:box-1'"
:size="compact ? 16 : 18"
:color="selectedItem ? 'var(--App-secondary-color, #e0be7f)' : 'var(--app-text-soft, #9ca3af)'"
/>
</span>
<span
class="product-picker__text"
:class="{ 'product-picker__text--placeholder': !selectedItem && !lockedTitle }"
>
{{ displayTitle }}
</span>
<span v-if="displayUnit" class="product-picker__unit">({{ displayUnit }})</span>
<span v-if="displaySubtitle" class="product-picker__subtitle">{{ displaySubtitle }}</span>
</div>
<div class="product-picker__trailing">
<button
v-if="allowClear && selectedItem && !disabled && !isEdit"
type="button"
class="product-picker__clear-btn"
aria-label="پاک کردن"
@click.stop="handleClear"
>
<BasicIcon icon="vuesax-linear:close-circle" size="16" color="var(--app-text-soft, #9ca3af)" />
</button>
<span class="product-picker__arrow" aria-hidden="true">
<BasicIcon
v-if="!isEdit"
:icon="isOpen ? 'vuesax-linear:arrow-up-1' : 'vuesax-linear:arrow-down-1'"
size="16"
color="var(--app-text-soft, #9ca3af)"
/>
<BasicIcon v-else icon="vuesax-linear:lock" size="14" color="var(--app-text-soft, #9ca3af)" />
</span>
</div>
</div>
<!-- دراپ‌داون دسکتاپ -->
<div
v-if="isDesktop && isOpen && !disabled && !isEdit"
class="product-picker__dropdown"
:style="dropdownStyle"
>
<div class="product-picker__search">
<span class="product-picker__search-icon" aria-hidden="true">
<BasicIcon icon="vuesax-linear:search-normal" size="16" color="var(--app-text-soft, #9ca3af)" />
</span>
<input
ref="desktopSearchInputRef"
v-model="searchQuery"
type="text"
class="product-picker__search-input"
placeholder="جستجوی محصول..."
autocomplete="off"
/>
<button
v-if="searchQuery"
type="button"
class="product-picker__clear-query-btn"
aria-label="پاک کردن جستجو"
@click="searchQuery = ''"
>
<BasicIcon icon="vuesax-linear:close-circle" size="15" color="var(--app-text-soft, #9ca3af)" />
</button>
</div>
<div class="product-picker__list" role="listbox">
<button
v-for="p in filteredList"
:key="getItemKey(p)"
type="button"
role="option"
class="product-picker__item"
:class="{ 'product-picker__item--selected': isSelected(p) }"
:aria-selected="isSelected(p)"
@click="selectItem(p)"
>
<div class="product-picker__item-info">
<span class="product-picker__item-icon">
<BasicIcon
icon="vuesax-linear:box-1"
size="16"
:color="isSelected(p) ? 'var(--App-secondary-color, #e0be7f)' : 'inherit'"
/>
</span>
<span class="product-picker__item-title">{{ getItemTitle(p) }}</span>
<span v-if="getItemUnit(p)" class="product-picker__item-unit">({{ getItemUnit(p) }})</span>
<span v-if="getItemSubtitle(p)" class="product-picker__item-subtitle">{{ getItemSubtitle(p) }}</span>
</div>
<span v-if="isSelected(p)" class="product-picker__item-check">
<BasicIcon icon="vuesax-linear:tick-circle" size="18" color="var(--App-secondary-color, #e0be7f)" />
</span>
</button>
<div v-if="loading" class="product-picker__loading">
<BasicIcon icon="vuesax-linear:box-time" size="20" class="animate-spin" color="var(--App-secondary-color, #e0be7f)" />
<span>در حال دریافت محصولات...</span>
</div>
<div v-else-if="filteredList.length === 0" class="product-picker__empty">
<BasicIcon icon="vuesax-linear:info-circle" size="20" color="var(--app-text-soft, #9ca3af)" />
<span>{{ emptyText }}</span>
</div>
</div>
</div>
<!-- اکشن‌شیت موبایل -->
<BasicActionSheet
v-if="!isDesktop"
v-model:show="isSheetOpen"
:title="sheetTitle"
:closable="true"
@closed="onSheetClosed"
>
<div class="product-picker-sheet">
<div class="product-picker-sheet__search">
<span class="product-picker-sheet__search-icon" aria-hidden="true">
<BasicIcon icon="vuesax-linear:search-normal" size="18" color="var(--app-text-soft, #9ca3af)" />
</span>
<input
ref="mobileSearchInputRef"
v-model="searchQuery"
type="text"
class="product-picker-sheet__search-input"
placeholder="جستجوی نام یا کد محصول..."
autocomplete="off"
/>
<button
v-if="searchQuery"
type="button"
class="product-picker-sheet__clear-btn"
aria-label="پاک کردن جستجو"
@click="searchQuery = ''"
>
<BasicIcon icon="vuesax-linear:close-circle" size="18" color="var(--app-text-soft, #9ca3af)" />
</button>
</div>
<div class="product-picker-sheet__list" role="listbox">
<button
v-for="p in filteredList"
:key="getItemKey(p)"
type="button"
role="option"
class="product-picker-sheet__item"
:class="{ 'product-picker-sheet__item--selected': isSelected(p) }"
:aria-selected="isSelected(p)"
@click="selectItem(p)"
>
<div class="product-picker-sheet__item-info">
<span class="product-picker-sheet__item-icon">
<BasicIcon
icon="vuesax-linear:box-1"
size="18"
:color="isSelected(p) ? 'var(--App-secondary-color, #e0be7f)' : 'inherit'"
/>
</span>
<div class="product-picker-sheet__item-texts">
<span class="product-picker-sheet__item-title">{{ getItemTitle(p) }}</span>
<span
v-if="getItemSubtitle(p) || getItemUnit(p)"
class="product-picker-sheet__item-meta"
>
{{ getItemSubtitle(p) || `واحد: ${getItemUnit(p)}` }}
</span>
</div>
</div>
<span v-if="isSelected(p)" class="product-picker-sheet__item-check">
<BasicIcon icon="vuesax-linear:tick-circle" size="20" color="var(--App-secondary-color, #e0be7f)" />
</span>
</button>
<div v-if="loading" class="product-picker-sheet__loading">
<BasicIcon icon="vuesax-linear:box-time" size="26" class="animate-spin" color="var(--App-secondary-color, #e0be7f)" />
<span class="mt-2 text-sm text-gray-400">در حال دریافت محصولات...</span>
</div>
<div v-else-if="filteredList.length === 0" class="product-picker-sheet__empty">
<BasicIcon icon="vuesax-linear:info-circle" size="24" color="var(--app-text-soft, #9ca3af)" />
<span>{{ emptyText }}</span>
</div>
</div>
</div>
</BasicActionSheet>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
interface Props {
modelValue?: number | string | null
value?: number | string | null
items?: any[]
products?: any[]
keyName?: string
valueName?: string
unitName?: string
subtitleName?: string
placeholder?: string
sheetTitle?: string
label?: string
disabled?: boolean
loading?: boolean
isEdit?: boolean
lockedTitle?: string
allowClear?: boolean
compact?: boolean
emptyText?: string
dropdownStyle?: Record<string, any>
}
const props = withDefaults(defineProps<Props>(), {
modelValue: undefined,
value: undefined,
items: undefined,
products: undefined,
keyName: 'id',
valueName: 'title',
unitName: 'unit',
subtitleName: 'subtitle',
placeholder: 'انتخاب محصول...',
sheetTitle: 'انتخاب محصول',
label: '',
disabled: false,
loading: false,
isEdit: false,
lockedTitle: '',
allowClear: false,
compact: false,
emptyText: 'محصولی یافت نشد',
dropdownStyle: undefined,
})
const emit = defineEmits<{
'update:modelValue': [val: any]
'update:value': [val: any]
'change': [item: any]
'clear': []
}>()
const isDesktop = useDesktopBreakpoint()
const containerRef = ref<HTMLElement>()
const desktopSearchInputRef = ref<HTMLInputElement>()
const mobileSearchInputRef = ref<HTMLInputElement>()
const isOpen = ref(false)
const isSheetOpen = ref(false)
const searchQuery = ref('')
const effectiveList = computed(() => {
if (Array.isArray(props.items)) return props.items
if (Array.isArray(props.products)) return props.products
return []
})
const currentValue = computed(() => {
if (props.modelValue !== undefined) return props.modelValue
return props.value
})
function getItemKey(item: any): any {
if (!item) return ''
return item[props.keyName] !== undefined ? item[props.keyName] : item.id
}
function getItemTitle(item: any): string {
if (!item) return ''
return String(item[props.valueName] || item.title || item.name || `محصول ${getItemKey(item)}`)
}
function getItemUnit(item: any): string {
if (!item) return ''
return item[props.unitName] || item.unit || ''
}
function getItemSubtitle(item: any): string {
if (!item) return ''
return item[props.subtitleName] || item.subtitle || ''
}
const selectedItem = computed(() => {
const val = currentValue.value
if (val === undefined || val === null || val === '') return null
return effectiveList.value.find((p) => String(getItemKey(p)) === String(val)) ?? null
})
const displayTitle = computed(() => {
if (props.isEdit && props.lockedTitle) return props.lockedTitle
if (selectedItem.value) return getItemTitle(selectedItem.value)
const val = currentValue.value
if (val !== undefined && val !== null && val !== '') return `محصول ${val}`
return props.placeholder
})
const displayUnit = computed(() => {
if (props.isEdit) return ''
return selectedItem.value ? getItemUnit(selectedItem.value) : ''
})
const displaySubtitle = computed(() => {
if (props.isEdit) return ''
return selectedItem.value ? getItemSubtitle(selectedItem.value) : ''
})
function isSelected(item: any): boolean {
const val = currentValue.value
if (val === undefined || val === null || val === '') return false
return String(getItemKey(item)) === String(val)
}
function normalizeText(text: string): string {
return (text || '')
.toLowerCase()
.replace(/ي/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.trim()
}
const filteredList = computed(() => {
const q = normalizeText(searchQuery.value)
if (!q) return effectiveList.value
return effectiveList.value.filter((p) => {
const title = normalizeText(getItemTitle(p))
const unit = normalizeText(getItemUnit(p))
const subtitle = normalizeText(getItemSubtitle(p))
const key = String(getItemKey(p))
return title.includes(q) || unit.includes(q) || subtitle.includes(q) || key.includes(q)
})
})
function toggleOpen() {
if (props.disabled || props.isEdit) return
if (isDesktop.value) {
isOpen.value = !isOpen.value
if (isOpen.value) {
nextTick(() => {
desktopSearchInputRef.value?.focus()
})
} else {
searchQuery.value = ''
}
} else {
isSheetOpen.value = true
nextTick(() => {
mobileSearchInputRef.value?.focus()
})
}
}
function selectItem(item: any) {
const key = getItemKey(item)
emit('update:modelValue', key)
emit('update:value', key)
emit('change', item)
isOpen.value = false
isSheetOpen.value = false
searchQuery.value = ''
}
function handleClear() {
emit('update:modelValue', undefined)
emit('update:value', undefined)
emit('change', undefined)
emit('clear')
}
function onSheetClosed() {
searchQuery.value = ''
}
function handleClickOutside(e: MouseEvent) {
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
isOpen.value = false
searchQuery.value = ''
}
}
onMounted(() => {
window.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped lang="less">
.product-picker-root {
position: relative;
display: flex;
flex-direction: column;
gap: 0.375rem;
width: 100%;
direction: rtl;
text-align: right;
z-index: 1;
&--open {
z-index: 50;
}
--picker-bg: var(--app-surface, var(--admin-bg-surface, #ffffff));
--picker-border: var(--app-border, var(--admin-border, #e2e8f0));
--picker-border-focus: var(--app-border-strong, var(--App-secondary-color, #e0be7f));
--picker-text: var(--app-text-primary, var(--admin-text, #0f172a));
--picker-text-soft: var(--app-text-muted, var(--admin-text-secondary, #64748b));
--picker-inset: var(--app-surface-inset, rgba(15, 23, 42, 0.03));
}
:global(html[data-theme='dark']) .product-picker-root,
:global(html.dark) .product-picker-root {
--picker-bg: var(--app-surface, #1e2026);
--picker-border: var(--app-border, rgba(255, 255, 255, 0.12));
--picker-border-focus: var(--app-border-strong, rgba(255, 255, 255, 0.28));
--picker-text: var(--app-text-primary, #ffffff);
--picker-text-soft: var(--app-text-muted, rgba(255, 255, 255, 0.55));
--picker-inset: var(--app-surface-inset, rgba(255, 255, 255, 0.04));
}
.product-picker__label {
font-size: 0.8125rem;
font-weight: 700;
color: var(--picker-text);
}
.product-picker__control {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 2.65rem;
padding: 0 0.75rem;
border-radius: 0.65rem;
border: 1px solid var(--picker-border);
background: var(--picker-bg);
color: var(--picker-text);
direction: rtl;
transition: all 0.18s ease;
user-select: none;
.product-picker-root--compact & {
min-height: 2.35rem;
padding: 0 0.6rem;
border-radius: 0.5rem;
}
&--clickable {
cursor: pointer;
&:hover {
border-color: var(--picker-border-focus);
}
}
&--active {
border-color: var(--picker-border-focus);
box-shadow: 0 0 0 2px rgba(227, 176, 86, 0.15);
}
&--disabled {
opacity: 0.6;
cursor: not-allowed;
background: var(--picker-inset);
}
&--locked {
background: var(--picker-inset);
cursor: default;
}
}
.product-picker__value {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
flex: 1;
}
.product-picker__icon {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.product-picker__text {
font-size: 0.875rem;
font-weight: 600;
color: var(--picker-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.product-picker-root--compact & {
font-size: 0.8125rem;
}
&--placeholder {
color: var(--picker-text-soft);
font-weight: 400;
}
}
.product-picker__unit {
font-size: 0.75rem;
color: var(--picker-text-soft);
flex-shrink: 0;
}
.product-picker__subtitle {
font-size: 0.72rem;
color: var(--App-secondary-color, #e0be7f);
font-weight: 600;
flex-shrink: 0;
}
.product-picker__trailing {
display: flex;
align-items: center;
gap: 0.35rem;
flex-shrink: 0;
}
.product-picker__clear-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
padding: 2px;
cursor: pointer;
border-radius: 9999px;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.8;
}
}
.product-picker__arrow {
display: inline-flex;
align-items: center;
justify-content: center;
}
/* دراپ‌داون دسکتاپ */
.product-picker__dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
left: 0;
z-index: 1050;
padding: 0.5rem;
border-radius: 0.75rem;
border: 1px solid var(--picker-border);
background: var(--picker-bg);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.product-picker__search {
display: flex;
align-items: center;
gap: 0.5rem;
height: 2.25rem;
padding: 0 0.6rem;
border-radius: 0.5rem;
background: var(--picker-inset);
border: 1px solid var(--picker-border);
}
.product-picker__search-icon {
display: inline-flex;
flex-shrink: 0;
}
.product-picker__search-input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: var(--picker-text);
font-size: 0.8125rem;
text-align: right;
direction: rtl;
&::placeholder {
color: var(--picker-text-soft);
}
}
.product-picker__clear-query-btn {
border: none;
background: transparent;
padding: 0;
cursor: pointer;
display: inline-flex;
}
.product-picker__list {
display: flex;
flex-direction: column;
gap: 0.2rem;
max-height: 14rem;
overflow-y: auto;
}
.product-picker__item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.5rem 0.65rem;
border-radius: 0.5rem;
border: 1px solid transparent;
background: transparent;
cursor: pointer;
transition: all 0.15s ease;
outline: none;
text-align: right;
&:hover {
background: var(--picker-inset);
border-color: var(--picker-border);
}
&--selected {
background: rgba(224, 190, 127, 0.12);
border-color: var(--App-secondary-color, #e0be7f);
}
}
.product-picker__item-info {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.product-picker__item-icon {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.product-picker__item-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--picker-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.product-picker__item-unit {
font-size: 0.72rem;
color: var(--picker-text-soft);
}
.product-picker__item-subtitle {
font-size: 0.72rem;
color: var(--App-secondary-color, #e0be7f);
font-weight: 600;
}
.product-picker__item-check {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.product-picker__empty {
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 1.25rem 0.5rem;
color: var(--picker-text-soft);
font-size: 0.8125rem;
}
/* شیت موبایل */
.product-picker-sheet {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding-bottom: 1.5rem;
direction: rtl;
text-align: right;
}
.product-picker-sheet__search {
display: flex;
align-items: center;
gap: 0.5rem;
height: 2.75rem;
padding: 0 0.85rem;
border-radius: 0.75rem;
background: var(--picker-inset);
border: 1px solid var(--picker-border);
}
.product-picker-sheet__search-icon {
display: inline-flex;
flex-shrink: 0;
}
.product-picker-sheet__search-input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: var(--picker-text);
font-size: 0.875rem;
font-weight: 500;
text-align: right;
direction: rtl;
&::placeholder {
color: var(--picker-text-soft);
}
}
.product-picker-sheet__clear-btn {
border: none;
background: transparent;
padding: 0;
cursor: pointer;
display: inline-flex;
}
.product-picker-sheet__list {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 55vh;
overflow-y: auto;
}
.product-picker-sheet__item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: 0.65rem;
border: 1px solid var(--picker-border);
background: var(--picker-bg);
cursor: pointer;
transition: all 0.15s ease;
outline: none;
text-align: right;
&:hover {
border-color: var(--App-secondary-color, #e0be7f);
}
&--selected {
border-color: var(--App-secondary-color, #e0be7f);
background: rgba(224, 190, 127, 0.1);
}
}
.product-picker-sheet__item-info {
display: flex;
align-items: center;
gap: 0.65rem;
min-width: 0;
}
.product-picker-sheet__item-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
border-radius: 0.5rem;
background: var(--picker-inset);
flex-shrink: 0;
.product-picker-sheet__item--selected & {
background: var(--App-secondary-color, #e0be7f);
color: #fff;
}
}
.product-picker-sheet__item-texts {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
}
.product-picker-sheet__item-title {
font-size: 0.85rem;
font-weight: 700;
color: var(--picker-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.product-picker-sheet__item-meta {
font-size: 0.7rem;
color: var(--picker-text-soft);
font-weight: 500;
}
.product-picker-sheet__item-check {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.product-picker-sheet__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 2rem 1rem;
color: var(--picker-text-soft);
font-size: 0.8125rem;
text-align: center;
}
</style>
File diff suppressed because one or more lines are too long
+70
View File
@@ -0,0 +1,70 @@
<template>
<a
v-if="href && src"
referrerpolicy="origin"
target="_blank"
rel="noopener noreferrer"
:href="href"
class="enamad-badge"
:class="`enamad-badge--${size}`"
>
<img
referrerpolicy="origin"
:src="src"
alt="enamad"
class="enamad-badge__img"
:code="code || undefined"
/>
</a>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useSettingsStore } from '@/store/settingsStore'
withDefaults(
defineProps<{
size?: 'sm' | 'lg'
}>(),
{ size: 'sm' },
)
const settingsStore = useSettingsStore()
const href = computed(() =>
String(settingsStore.getSettingByKey('enamad_url') ?? '').trim(),
)
const src = computed(() =>
String(settingsStore.getSettingByKey('enamad_logo') ?? '').trim(),
)
const code = computed(() => {
try {
const u = new URL(href.value || src.value)
return u.searchParams.get('Code') || u.searchParams.get('code') || ''
} catch {
return ''
}
})
</script>
<style scoped lang="less">
.enamad-badge {
display: inline-flex;
line-height: 0;
}
.enamad-badge__img {
width: auto;
cursor: pointer;
object-fit: contain;
}
.enamad-badge--sm .enamad-badge__img {
height: 40px;
}
.enamad-badge--lg .enamad-badge__img {
height: 120px;
max-width: 100%;
}
</style>
+307
View File
@@ -0,0 +1,307 @@
<template>
<BasicModal
v-model:visible="visible"
width="400px"
destroy-on-close
custom-footer
wrap-class-name="gold-day-calc-modal"
>
<template #title>
<div class="gold-day-calc-modal__title">
<BasicIcon icon="vuesax-linear:chart-2" size="20" class="gold-day-calc-modal__title-icon" />
<span>محاسبه‌گر طلای شما</span>
</div>
</template>
<div class="gold-day-calc" dir="rtl">
<BasicInput
v-model:value="weight"
title="وزن طلا (گرم)"
type="tel"
place-holder="مثلاً ۱۰.۵"
inputmode="decimal"
/>
<BasicInput
:value="unitPriceInput"
title="قیمت روز طلا"
type="tel"
disabled
addon-before="ریال"
place-holder="قیمت صفحه اصلی"
/>
<BasicInput
v-model:value="feePercent"
title="اجرت ساخت (%)"
type="tel"
place-holder="مثلاً ۷"
inputmode="decimal"
/>
<BasicInput
v-model:value="taxPercent"
title="سود و مالیات (%)"
type="tel"
place-holder="مثلاً ۹"
inputmode="decimal"
/>
<div class="gold-day-calc__result rounded-xl border border-solid border-gray-200 bg-gray-50 p-3">
<div class="text-xs text-gray-500 mb-1">کل نهایی</div>
<div class="gold-day-calc__total text-lg font-bold text-primary">
<span class="gold-day-calc__num">{{ formattedTotal }}</span>
<span class="text-sm font-medium text-gray-600">ریال</span>
</div>
<div class="mt-2 flex flex-col gap-1 text-xs text-gray-600">
<div class="flex justify-between gap-2">
<span>هزینه طلا</span>
<span class="gold-day-calc__num">{{ formatRial(breakdown.goldCost) }}</span>
</div>
<div class="flex justify-between gap-2">
<span>اجرت</span>
<span class="gold-day-calc__num">{{ formatRial(breakdown.feeCost) }}</span>
</div>
<div class="flex justify-between gap-2">
<span>سود و مالیات</span>
<span class="gold-day-calc__num">{{ formatRial(breakdown.taxCost) }}</span>
</div>
</div>
</div>
</div>
<template #footer>
<BasicButton type="primary" class="!w-full" size="middle" @click="recalculate">
محاسبه مجدد
</BasicButton>
</template>
</BasicModal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { goldDayCalculatorVisible } from '@/composables/useGoldDayCalculator'
import {
getLastPrices,
getTradeBuyPrice,
lastPrice,
startLastPricePolling,
stopLastPricePolling,
} from '@/utils/price'
import { getPriceDisplayState, NO_QUOTE_MESSAGE } from '@/utils/storeHours'
import { usePriceWithoutType } from '@/utils/usePrice'
import { persianToEnglishDigits } from '@/utils/persianDigits'
const visible = goldDayCalculatorVisible
const weight = ref<string | number>('')
const feePercent = ref<string | number>('')
const taxPercent = ref<string | number>('')
const tick = ref(0)
function parseNum(raw: string | number | undefined): number {
const s = persianToEnglishDigits(String(raw ?? ''))
.replace(/,/g, '')
.replace(/[^\d.]/g, '')
const n = Number.parseFloat(s)
return Number.isFinite(n) ? n : 0
}
const liveUnitPrice = computed(() => {
void tick.value
if (getPriceDisplayState() !== 'open') return 0
return getTradeBuyPrice(lastPrice.value) || 0
})
const unitPriceInput = computed(() => {
void tick.value
const state = getPriceDisplayState()
if (state === 'closed') return 'فروشگاه بسته است'
if (state === 'no_quote') return NO_QUOTE_MESSAGE
const p = liveUnitPrice.value
return p > 0 ? usePriceWithoutType(p) : ''
})
const breakdown = computed(() => {
void tick.value
const w = parseNum(weight.value)
const fee = parseNum(feePercent.value)
const tax = parseNum(taxPercent.value)
const unit = liveUnitPrice.value
const goldCost = w * unit
const feeCost = goldCost * (fee / 100)
const taxCost = (goldCost + feeCost) * (tax / 100)
const total = goldCost + feeCost + taxCost
return { goldCost, feeCost, taxCost, total }
})
const formattedTotal = computed(() => formatRial(breakdown.value.total))
function formatRial(n: number) {
if (!Number.isFinite(n) || n <= 0) return '۰'
return usePriceWithoutType(Math.round(n))
}
function recalculate() {
tick.value += 1
void getLastPrices()
}
watch(visible, async (open) => {
if (open) {
startLastPricePolling()
await getLastPrices()
tick.value += 1
} else {
stopLastPricePolling()
}
})
</script>
<style scoped>
.gold-day-calc {
display: flex;
flex-direction: column;
gap: 0.75rem;
direction: rtl;
text-align: right;
}
/* فقط متن لیبل RTL — گروه اینپوت LTR بماند تا addon ریال نشکند */
.gold-day-calc :deep(.ant-input-group) {
direction: ltr;
display: table;
width: 100%;
}
.gold-day-calc :deep(.ant-input-group > .ant-input) {
direction: ltr;
text-align: left;
}
.gold-day-calc :deep(.ant-input:not(.ant-input-group *)) {
text-align: right;
}
.gold-day-calc__result {
direction: rtl;
text-align: right;
}
.gold-day-calc__total {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: flex-start;
gap: 0.35rem;
}
.gold-day-calc__num {
direction: ltr;
unicode-bidi: isolate;
display: inline-block;
}
</style>
<style lang="less">
.gold-day-calc-modal {
.ant-modal-content {
direction: rtl;
display: flex;
flex-direction: column;
max-height: min(88dvh, 640px);
overflow: hidden;
}
.ant-modal-header {
flex-shrink: 0;
direction: rtl;
text-align: right;
/* ضربدر فیزیکی چپ — پدینگ راست کم تا عنوان نزدیک لبه راست بماند */
padding-left: 2.75rem;
padding-right: 1rem;
}
.ant-modal-title {
text-align: right;
width: 100%;
}
.gold-day-calc-modal__title {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
direction: rtl;
width: 100%;
}
.gold-day-calc-modal__title-icon {
flex-shrink: 0;
color: var(--App-secondary-color, #c9a062);
}
/* ضربدر سمت چپ — عنوان سمت راست */
.ant-modal-close {
inset-inline-start: 0 !important;
inset-inline-end: auto !important;
right: auto !important;
left: 0 !important;
}
.ant-modal-body {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
padding-bottom: 0.25rem;
max-height: none;
}
#body {
max-height: min(48dvh, 360px) !important;
overflow-y: auto !important;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.ant-modal-footer {
flex-shrink: 0;
margin-top: 0;
padding-top: 0.75rem;
border-top: 1px solid rgba(148, 163, 184, 0.25);
}
}
@media screen and (max-width: 767.98px) {
.gold-day-calc-modal {
.ant-modal {
max-width: calc(100vw - 1.5rem);
margin: 0 auto;
}
.ant-modal-content {
max-height: min(86dvh, 100%);
}
/* بین هدر و دکمه — ارتفاع کمتر تا اسکرول بخورد */
#body {
max-height: min(42dvh, 280px) !important;
}
.ant-modal-body {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
}
}
@media screen and (max-width: 400px) {
.gold-day-calc-modal #body {
max-height: min(38dvh, 240px) !important;
}
}
</style>
+27 -18
View File
@@ -1,9 +1,12 @@
<template>
<van-dialog
class="updateButton"
confirm-button-text="متوجه شدم"
v-model:show="show"
class="gold-loading-dialog updateButton"
teleport="body"
:z-index="zIndex"
:show-confirm-button="false"
@confirm="update()"
:close-on-click-overlay="false"
:close-on-popstate="false"
>
<div class="flex flex-col items-center p-2">
<DotLottieVue class="w-15rem h-15rem" autoplay loop src="/animIsTracking.json" />
@@ -16,21 +19,27 @@
</template>
<script setup lang="ts">
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { update } from 'lodash'
import { ref } from 'vue'
import anim from '@/assets/img/anim/anim1.json'
import { computed } from 'vue'
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
interface Prop {
title: string
info?: string
infoHead?: string
isLine?: boolean
value?: string | number
}
const props = withDefaults(defineProps<Prop>(), {
isLine: false
import { APP_Z_OVERLAY } from '@/utils/zIndex'
const props = withDefaults(
defineProps<{
show?: boolean
zIndex?: number
}>(),
{
show: false,
zIndex: APP_Z_OVERLAY,
},
)
const emit = defineEmits<{
'update:show': [value: boolean]
}>()
const show = computed({
get: () => props.show,
set: (value: boolean) => emit('update:show', value),
})
</script>
<style scoped lang="less"></style>
+42
View File
@@ -0,0 +1,42 @@
<template>
<div v-if="showInstallEntry" class="mt-2 w-full">
<BasicButton class="!w-full" @click="onClick">
{{ canNativeInstall ? 'نصب اپلیکیشن' : 'آموزش نصب' }}
</BasicButton>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { usePwaInstall } from '@/composables/usePwaInstall'
import { PWA_MANIFEST_READY_EVENT } from '@/modules/pwa/manifest'
import {
hasNativeInstallPrompt,
initPwaInstallPromptListener,
} from '@/modules/pwa/pwaInstall'
const { tryInstallOrOpenGuide, showInstallEntry } = usePwaInstall()
const canNativeInstall = ref(false)
function refreshCanInstall() {
canNativeInstall.value = hasNativeInstallPrompt()
}
async function onClick() {
await tryInstallOrOpenGuide()
refreshCanInstall()
}
onMounted(() => {
initPwaInstallPromptListener()
refreshCanInstall()
window.addEventListener(PWA_MANIFEST_READY_EVENT, refreshCanInstall)
window.addEventListener('appinstalled', refreshCanInstall)
})
onUnmounted(() => {
window.removeEventListener(PWA_MANIFEST_READY_EVENT, refreshCanInstall)
window.removeEventListener('appinstalled', refreshCanInstall)
})
</script>
+14 -4
View File
@@ -29,16 +29,26 @@ const props = withDefaults(
}
)
function productImageKey(product?: ProductImageSource): string {
return [
product?.file_id ?? '',
product?.file?.id ?? '',
product?.file?.full_src ?? '',
product?.file?.src ?? '',
product?.file?.url ?? '',
product?.img ?? '',
].join('|')
}
const currentSrc = ref(resolveProductImageSrc(props.product))
const usingFallback = ref(false)
watch(
() => props.product,
(product) => {
() => productImageKey(props.product),
() => {
usingFallback.value = false
currentSrc.value = resolveProductImageSrc(product)
currentSrc.value = resolveProductImageSrc(props.product)
},
{ deep: true }
)
function handleError() {
+89
View File
@@ -0,0 +1,89 @@
<template>
<div :class="variant === 'desktop' ? desktopRootClass : mobileRootClass">
<button
ref="triggerEl"
type="button"
class="support-contact-bar__btn"
:disabled="!canContactSupport"
@click="onSupportClick"
>
<BasicIcon icon="vuesax-linear:messages-2" size="20" color="#fff" />
<span>ارتباط با پشتیبانی</span>
</button>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useSupportMessenger } from '@/composables/useSupportMessenger'
import { computed, ref } from 'vue'
withDefaults(
defineProps<{
variant?: 'mobile' | 'desktop'
mobileRootClass?: string
desktopRootClass?: string
}>(),
{
variant: 'mobile',
mobileRootClass: 'wrap-fix bottom-0 px-10 pb-3rem flex bg-white',
desktopRootClass: 'dpage-support',
},
)
const {
supportPhoneLink,
hasMessengerLink,
hasSupportPhoneLink,
openSupportMessenger,
} = useSupportMessenger()
const triggerEl = ref<HTMLElement | null>(null)
const canContactSupport = computed(() => hasMessengerLink.value || hasSupportPhoneLink.value)
async function onSupportClick() {
// همان رفتار دکمه شناور پشتیبانی — بدون پیش‌نویس واریز
if (hasMessengerLink.value) {
await openSupportMessenger(triggerEl.value)
return
}
if (hasSupportPhoneLink.value && supportPhoneLink.value) {
window.location.href = supportPhoneLink.value
}
}
</script>
<style scoped lang="less">
.support-contact-bar__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
min-height: 2.75rem;
padding: 0.65rem 1rem;
border: 0;
border-radius: 0.75rem;
background: var(--App-primary, @App-primary-color);
color: #fff;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s ease, transform 0.15s ease;
&:active {
transform: scale(0.98);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.dpage-support {
width: 100%;
}
</style>
@@ -0,0 +1,72 @@
<template>
<span
class="support-brand-icon"
:class="{ 'support-brand-icon--eitaa': normalized === 'eitaa' }"
aria-hidden="true"
>
<img
v-if="logoSrc"
:src="logoSrc"
:alt="normalized"
class="support-brand-icon__img"
draggable="false"
/>
<svg v-else viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" class="support-brand-icon__svg">
<circle cx="24" cy="24" r="24" fill="#64748B" />
<path fill="#fff" d="M16 18h16v2H16v-2zm0 5h16v2H16v-2zm0 5h10v2H16v-2z" />
</svg>
</span>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import baleLogo from '@/assets/messengers/bale.png'
import callLogo from '@/assets/messengers/call.svg'
import eitaaLogo from '@/assets/messengers/eitaa.png'
import rubikaLogo from '@/assets/messengers/rubika.png'
import smsLogo from '@/assets/messengers/sms.svg'
import soroushLogo from '@/assets/messengers/soroush.svg'
import telegramLogo from '@/assets/messengers/telegram.svg'
import whatsappLogo from '@/assets/messengers/whatsapp.svg'
const LOGOS: Record<string, string> = {
bale: baleLogo,
telegram: telegramLogo,
whatsapp: whatsappLogo,
rubika: rubikaLogo,
eitaa: eitaaLogo,
soroush: soroushLogo,
call: callLogo,
sms: smsLogo,
}
const props = defineProps<{ messenger: string }>()
const normalized = computed(() => (props.messenger ?? '').trim().toLowerCase())
const logoSrc = computed(() => LOGOS[normalized.value])
</script>
<style scoped lang="less">
.support-brand-icon {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
overflow: hidden;
}
.support-brand-icon--eitaa {
background: #e37600;
.support-brand-icon__img {
filter: brightness(0) invert(1);
}
}
.support-brand-icon__img,
.support-brand-icon__svg {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
+300
View File
@@ -0,0 +1,300 @@
<template>
<Teleport to="body">
<Transition name="support-picker-backdrop">
<div
v-if="open"
class="support-messenger-picker__backdrop"
:class="{ 'is-active': backdropActive }"
@click="onBackdropClick"
/>
</Transition>
<div
v-show="open || closing"
class="support-messenger-picker__stack"
:class="[
{ 'is-open': open },
expandUp ? 'support-messenger-picker__stack--up' : 'support-messenger-picker__stack--down',
]"
:style="stackStyle"
role="menu"
aria-label="انتخاب پیام‌رسان"
>
<button
v-for="(entry, index) in entries"
:key="`${entry.messenger}-${entry.id}`"
type="button"
class="support-messenger-picker__btn"
:style="{ '--i': index, '--total': entries.length }"
:title="entry.label"
:aria-label="entry.label"
@click.stop="select(entry)"
>
<SupportMessengerBrandIcon v-if="entry.messenger !== 'chat'" :messenger="entry.messenger" />
<BasicIcon
v-else
:icon="entry.icon || 'vuesax-linear:messages-2'"
size="22"
:color="entry.accent || '#16a34a'"
/>
</button>
</div>
</Teleport>
</template>
<script lang="ts" setup>
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import SupportMessengerBrandIcon from '@/components/SupportMessengerBrandIcon.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import type { ResolvedSupportMessengerEntry } from '@/utils/supportMessenger'
const props = withDefaults(
defineProps<{
open: boolean
entries: ResolvedSupportMessengerEntry[]
anchorEl?: HTMLElement | null
}>(),
{
anchorEl: null,
},
)
const emit = defineEmits<{
'update:open': [value: boolean]
select: [entry: ResolvedSupportMessengerEntry]
}>()
const BTN_SIZE = 46
const GAP = 12
const STACK_GAP = 10
const CLOSE_MS = 220
const BACKDROP_GUARD_MS = 380
const stackStyle = ref<Record<string, string>>({})
const closing = ref(false)
const expandUp = ref(true)
const backdropActive = ref(false)
let closeTimer: ReturnType<typeof setTimeout> | null = null
let backdropTimer: ReturnType<typeof setTimeout> | null = null
function close() {
emit('update:open', false)
}
function select(entry: ResolvedSupportMessengerEntry) {
emit('select', entry)
close()
}
function clearTimers() {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
if (backdropTimer) {
clearTimeout(backdropTimer)
backdropTimer = null
}
}
function onBackdropClick() {
if (!backdropActive.value) return
close()
}
function armBackdropGuard() {
backdropActive.value = false
if (backdropTimer) clearTimeout(backdropTimer)
backdropTimer = setTimeout(() => {
backdropActive.value = true
backdropTimer = null
}, BACKDROP_GUARD_MS)
}
async function reposition() {
await nextTick()
const anchor = props.anchorEl
const count = props.entries.length
if (!anchor || count === 0) {
expandUp.value = true
stackStyle.value = {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
right: 'auto',
bottom: 'auto',
'--stack-rise': '56px',
}
return
}
const rect = anchor.getBoundingClientRect()
const stackHeight = count * BTN_SIZE + Math.max(0, count - 1) * GAP
const anchorCenterX = rect.left + rect.width / 2
const anchorCenterY = rect.top + rect.height / 2
const right = Math.max(12, window.innerWidth - anchorCenterX - BTN_SIZE / 2)
expandUp.value = anchorCenterY > window.innerHeight / 2
if (expandUp.value) {
const bottom = Math.max(12, window.innerHeight - rect.top + STACK_GAP)
stackStyle.value = {
right: `${right}px`,
bottom: `${bottom}px`,
left: 'auto',
top: 'auto',
transform: 'none',
'--stack-rise': `${Math.min(stackHeight + 6, 220)}px`,
}
} else {
const top = Math.max(12, rect.bottom + STACK_GAP)
stackStyle.value = {
right: `${right}px`,
top: `${top}px`,
left: 'auto',
bottom: 'auto',
transform: 'none',
'--stack-rise': `${Math.min(stackHeight + 6, 220)}px`,
}
}
}
watch(
() => props.open,
(isOpen) => {
clearTimers()
if (isOpen) {
closing.value = false
armBackdropGuard()
void reposition()
return
}
backdropActive.value = false
closing.value = true
const delay = CLOSE_MS + Math.max(0, props.entries.length - 1) * 28
closeTimer = setTimeout(() => {
closing.value = false
closeTimer = null
}, delay)
},
{ flush: 'post' },
)
watch(
() => [props.entries.length, props.anchorEl] as const,
() => {
if (props.open) void reposition()
},
)
function onViewportChange() {
if (props.open || closing.value) void reposition()
}
if (typeof window !== 'undefined') {
window.addEventListener('resize', onViewportChange, { passive: true })
window.addEventListener('scroll', onViewportChange, { passive: true, capture: true })
}
onBeforeUnmount(() => {
clearTimers()
if (typeof window === 'undefined') return
window.removeEventListener('resize', onViewportChange, { capture: true })
window.removeEventListener('scroll', onViewportChange, { capture: true })
})
</script>
<style scoped lang="less">
.support-messenger-picker__backdrop {
position: fixed;
inset: 0;
z-index: 5195;
background: rgba(15, 23, 42, 0.2);
pointer-events: none;
&.is-active {
pointer-events: auto;
}
}
.support-messenger-picker__stack {
position: fixed;
z-index: 5205;
display: flex;
align-items: center;
gap: 12px;
pointer-events: none;
}
.support-messenger-picker__stack--up {
flex-direction: column-reverse;
}
.support-messenger-picker__stack--down {
flex-direction: column;
}
.support-messenger-picker__btn {
--i: 0;
--total: 1;
--stack-rise: 56px;
pointer-events: auto;
width: 46px;
height: 46px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
cursor: pointer;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.2);
opacity: 0;
will-change: transform, opacity;
transition:
transform 0.22s cubic-bezier(0.2, 1, 0.3, 1),
opacity 0.16s ease,
box-shadow 0.16s ease;
transition-delay: calc(var(--i) * 38ms);
}
.support-messenger-picker__stack--up .support-messenger-picker__btn {
transform-origin: center bottom;
transform: translateY(calc(var(--stack-rise) * 0.55)) scale(0.2);
}
.support-messenger-picker__stack--down .support-messenger-picker__btn {
transform-origin: center top;
transform: translateY(calc(var(--stack-rise) * -0.55)) scale(0.2);
}
.support-messenger-picker__stack.is-open .support-messenger-picker__btn {
opacity: 1;
transform: translateY(0) scale(1);
}
.support-messenger-picker__stack:not(.is-open) .support-messenger-picker__btn {
transition-delay: calc((var(--total) - 1 - var(--i)) * 24ms);
transition-duration: 0.18s, 0.14s, 0.14s;
opacity: 0;
pointer-events: none;
}
.support-messenger-picker__stack--up:not(.is-open) .support-messenger-picker__btn {
transform: translateY(calc(var(--stack-rise) * 0.4)) scale(0.15);
}
.support-messenger-picker__stack--down:not(.is-open) .support-messenger-picker__btn {
transform: translateY(calc(var(--stack-rise) * -0.4)) scale(0.15);
}
.support-picker-backdrop-enter-active,
.support-picker-backdrop-leave-active {
transition: opacity 0.16s ease;
}
.support-picker-backdrop-enter-from,
.support-picker-backdrop-leave-to {
opacity: 0;
}
</style>
+227
View File
@@ -0,0 +1,227 @@
<template>
<div class="trade-fee-summary">
<div class="trade-fee-summary__quote">
<div class="trade-fee-summary__label">مظنه هر {{ weightUnit }}</div>
<div class="trade-fee-summary__value trade-fee-summary__value--strong">
<span>{{ formattedUnitPrice }}</span>
<span class="trade-fee-summary__unit">ریال</span>
</div>
</div>
<div
v-for="(section, index) in breakdown.sections"
:key="section.heading"
class="trade-fee-summary__section"
:class="{ 'trade-fee-summary__section--divided': index > 0 }"
>
<div class="trade-fee-summary__heading">{{ section.heading }}</div>
<div
v-for="(row, rowIndex) in section.rows"
:key="`${section.heading}-${rowIndex}`"
class="trade-fee-summary__row"
:class="{
'trade-fee-summary__row--divider': row.dividerBefore,
'trade-fee-summary__row--bold': row.bold,
}"
>
<div
class="trade-fee-summary__label"
:class="{ 'trade-fee-summary__label--strong': row.bold }"
>
<span v-if="row.plus" class="trade-fee-summary__sign trade-fee-summary__sign--plus">+</span>
<span v-else-if="row.minus && !row.bold" class="trade-fee-summary__sign trade-fee-summary__sign--minus">−</span>
{{ row.label }}
</div>
<div
class="trade-fee-summary__value"
:class="{
'trade-fee-summary__value--minus': row.minus && !row.bold,
'trade-fee-summary__value--plus': row.plus,
'trade-fee-summary__value--strong': row.bold,
}"
>
<template v-if="row.gram != null">
<span>{{ formatTradeGramDisplay(row.gram) }}</span>
<span class="trade-fee-summary__unit">{{ weightUnit }}</span>
</template>
<template v-else-if="row.rial != null">
<span v-if="row.minus && row.bold">−</span>
<span>{{ formatRial(row.rial) }}</span>
<span class="trade-fee-summary__unit">ریال</span>
</template>
</div>
</div>
<div v-if="section.footnote" class="trade-fee-summary__footnote">
{{ section.footnote }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { usePriceWithoutType } from '@/utils/usePrice'
import {
buildTradeConfirmBreakdown,
formatTradeGramDisplay,
type TradeInputLock,
} from '@/utils/serviceFee'
const props = defineProps<{
isBuy: boolean
inputLock: TradeInputLock
displayTradeGoldAmount: number | string
weightUnit: string
unitPriceRial: number
feeInGrams: number
equivalentPrice: string
payablePrice: number
orderGoldAmount: number
feeInRial: number
sellGoldDebit?: number
}>()
const formattedUnitPrice = computed(() =>
usePriceWithoutType(Math.round(props.unitPriceRial)),
)
function formatRial(value: number) {
return usePriceWithoutType(Math.round(value))
}
const breakdown = computed(() =>
buildTradeConfirmBreakdown({
isBuy: props.isBuy,
inputLock: props.inputLock,
orderGoldAmount: props.orderGoldAmount,
feeInGrams: props.feeInGrams,
feeInRial: props.feeInRial,
payablePrice: props.payablePrice,
unitPriceRial: props.unitPriceRial,
sellGoldDebit: props.sellGoldDebit,
}),
)
</script>
<style scoped lang="less">
.trade-fee-summary {
width: 100%;
padding: 0.75rem;
border-radius: 0.75rem;
background: var(--app-surface-inset, var(--app-surface));
border: 1px solid var(--app-border);
}
.trade-fee-summary__quote {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--app-border);
}
.trade-fee-summary__section {
margin-top: 0.75rem;
&--divided {
padding-top: 0.75rem;
border-top: 1px solid var(--app-border);
}
}
.trade-fee-summary__heading {
margin-bottom: 0.5rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--app-text-primary);
text-align: right;
}
.trade-fee-summary__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.25rem 0;
&--divider {
margin-top: 0.25rem;
padding-top: 0.5rem;
border-top: 1px dashed var(--app-border);
}
&--bold {
font-size: 0.9375rem;
font-weight: 700;
}
}
.trade-fee-summary__label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--app-text-secondary);
text-align: right;
&--strong {
font-weight: 700;
color: var(--app-text-primary);
}
}
.trade-fee-summary__value {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--app-text-primary);
text-align: left;
direction: ltr;
&--strong {
font-weight: 700;
}
&--minus {
color: var(--app-down, #ef4444);
}
&--plus {
color: var(--app-up, #22c55e);
}
}
.trade-fee-summary__unit {
font-size: 0.6875rem;
font-weight: 500;
color: var(--app-text-muted);
}
.trade-fee-summary__sign {
margin-left: 0.25rem;
&--plus {
color: var(--app-up, #22c55e);
}
&--minus {
color: var(--app-down, #ef4444);
}
}
.trade-fee-summary__footnote {
margin-top: 0.5rem;
padding: 0.5rem 0.625rem;
border-radius: 0.625rem;
background: var(--app-surface, rgba(255, 255, 255, 0.04));
border: 1px solid var(--app-border);
font-size: 0.75rem;
font-weight: 500;
line-height: 1.7;
color: var(--app-text-secondary);
text-align: right;
}
</style>
+375
View File
@@ -0,0 +1,375 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import {
PWA_GUIDE_STEP_LABELS,
PWA_INSTALL_GUIDE,
type PwaPlatform,
} from '@/modules/pwa/pwaInstallGuideSteps'
import { getDefaultPwaPlatform } from '@/modules/pwa/pwaInstall'
import PwaInstallStepIllustration from '@/components/pwa/PwaInstallStepIllustration.vue'
const props = defineProps<{
open: boolean
platform?: PwaPlatform
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
complete: []
close: []
}>()
const activePlatform = ref<PwaPlatform>(props.platform ?? getDefaultPwaPlatform())
const stepIndex = ref(0)
const guide = computed(() => PWA_INSTALL_GUIDE[activePlatform.value])
const currentStep = computed(() => guide.value.steps[stepIndex.value])
const totalSteps = computed(() => guide.value.steps.length)
const isFirstStep = computed(() => stepIndex.value === 0)
const isLastStep = computed(() => stepIndex.value === totalSteps.value - 1)
const stepLabel = computed(() => PWA_GUIDE_STEP_LABELS[stepIndex.value] ?? String(stepIndex.value + 1))
watch(
() => props.open,
(visible) => {
if (!visible) return
activePlatform.value = props.platform ?? getDefaultPwaPlatform()
stepIndex.value = 0
},
)
watch(
() => props.platform,
(value) => {
if (value) activePlatform.value = value
},
)
function close() {
emit('update:open', false)
emit('close')
}
function selectPlatform(next: PwaPlatform) {
if (activePlatform.value === next) return
activePlatform.value = next
stepIndex.value = 0
}
function goNext() {
if (isLastStep.value) {
emit('complete')
close()
return
}
stepIndex.value += 1
}
function goPrev() {
if (isFirstStep.value) return
stepIndex.value -= 1
}
function completeGuide() {
emit('complete')
close()
}
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="pwa-install-guide"
role="dialog"
aria-modal="true"
aria-labelledby="pwa-install-guide-title"
@click.self="close"
>
<div class="pwa-install-guide__card">
<div class="pwa-install-guide__tabs">
<button
type="button"
class="pwa-install-guide__tab"
:class="{ 'pwa-install-guide__tab--active': activePlatform === 'ios' }"
@click="selectPlatform('ios')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="7" y="2" width="10" height="20" rx="2.5" stroke="currentColor" stroke-width="1.5" />
<circle cx="12" cy="18.5" r="1" fill="currentColor" />
</svg>
<span>آیفون</span>
</button>
<button
type="button"
class="pwa-install-guide__tab"
:class="{ 'pwa-install-guide__tab--active': activePlatform === 'android' }"
@click="selectPlatform('android')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 4v12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M8.5 8.5 12 5l3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M6 16h12v3a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-3Z" stroke="currentColor" stroke-width="1.5" />
</svg>
<span>اندروید</span>
</button>
</div>
<div class="pwa-install-guide__header">
<span class="pwa-install-guide__progress">
مرحله {{ stepLabel }} از {{ PWA_GUIDE_STEP_LABELS.length }}
</span>
<h2 id="pwa-install-guide-title" class="pwa-install-guide__title">
{{ guide.title }}
</h2>
</div>
<div class="pwa-install-guide__content">
<span class="pwa-install-guide__badge">{{ stepLabel }}</span>
<PwaInstallStepIllustration :type="currentStep.illustration" />
<p class="pwa-install-guide__text">{{ currentStep.text }}</p>
<p v-if="currentStep.hint" class="pwa-install-guide__hint">{{ currentStep.hint }}</p>
</div>
<div class="pwa-install-guide__nav">
<template v-if="isLastStep">
<BasicButton class="pwa-install-guide__primary" type="primary" @click="completeGuide">
متوجه شدم
</BasicButton>
</template>
<template v-else>
<button
type="button"
class="pwa-install-guide__nav-btn"
:disabled="isFirstStep"
aria-label="مرحله قبل"
@click="goPrev"
>
‹
</button>
<div class="pwa-install-guide__dots" aria-hidden="true">
<span
v-for="(_, index) in totalSteps"
:key="index"
class="pwa-install-guide__dot"
:class="{ 'pwa-install-guide__dot--active': index === stepIndex }"
/>
</div>
<button
type="button"
class="pwa-install-guide__nav-btn"
aria-label="مرحله بعد"
@click="goNext"
>
›
</button>
</template>
</div>
<template v-if="!isLastStep">
<BasicButton class="pwa-install-guide__primary" type="primary" @click="goNext">
بعدی
<span class="pwa-install-guide__primary-chevron">›</span>
</BasicButton>
<button type="button" class="pwa-install-guide__close" @click="close">
بستن
</button>
</template>
</div>
</div>
</Teleport>
</template>
<style scoped lang="less">
.pwa-install-guide {
position: fixed;
inset: 0;
z-index: var(--app-z-modal, 5500);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(15, 23, 42, 0.45);
}
.pwa-install-guide__card {
width: min(100%, 360px);
background: #fff;
border-radius: 24px;
padding: 14px 16px 18px;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.18);
}
.pwa-install-guide__tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 4px;
border-radius: 14px;
background: #f1f5f9;
}
.pwa-install-guide__tab {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: none;
border-radius: 10px;
padding: 10px 8px;
font-size: 13px;
font-weight: 600;
color: #64748b;
background: transparent;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
&--active {
color: #0f172a;
background: #fff;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
}
}
.pwa-install-guide__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 14px 2px 10px;
}
.pwa-install-guide__progress {
font-size: 12px;
color: #94a3b8;
white-space: nowrap;
}
.pwa-install-guide__title {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #0f172a;
text-align: right;
}
.pwa-install-guide__content {
position: relative;
border: 1px solid #e2e8f0;
border-radius: 18px;
background: #f8fafc;
padding: 14px 14px 16px;
min-height: 280px;
}
.pwa-install-guide__badge {
position: absolute;
top: 12px;
right: 12px;
width: 28px;
height: 28px;
border-radius: 999px;
background: #2563eb;
color: #fff;
font-size: 13px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
}
.pwa-install-guide__text {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.8;
color: #334155;
text-align: center;
}
.pwa-install-guide__hint {
margin: 8px 0 0;
font-size: 11px;
line-height: 1.7;
color: #64748b;
text-align: center;
}
.pwa-install-guide__nav {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin: 14px 0 12px;
.pwa-install-guide__primary {
width: 100%;
margin: 0;
}
}
.pwa-install-guide__nav-btn {
width: 34px;
height: 34px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
color: #334155;
font-size: 20px;
line-height: 1;
cursor: pointer;
&:disabled {
opacity: 0.35;
cursor: not-allowed;
}
}
.pwa-install-guide__dots {
display: flex;
align-items: center;
gap: 6px;
}
.pwa-install-guide__dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: #cbd5e1;
transition: width 0.15s ease, background 0.15s ease;
&--active {
width: 22px;
background: #2563eb;
}
}
.pwa-install-guide__primary {
width: 100%;
height: 44px;
border-radius: 14px !important;
font-weight: 700;
}
.pwa-install-guide__primary-chevron {
margin-right: 4px;
font-size: 16px;
}
.pwa-install-guide__close {
display: block;
width: 100%;
margin-top: 10px;
border: none;
background: transparent;
color: #64748b;
font-size: 13px;
cursor: pointer;
}
</style>
@@ -0,0 +1,413 @@
<script setup lang="ts">
import type { PwaGuideIllustration } from '@/modules/pwa/pwaInstallGuideSteps'
import shareAppleIcon from '@/assets/img/share-apple.png'
import addToHomeScreenIcon from '@/assets/img/Add-to-homeScreen.png'
defineProps<{
type: PwaGuideIllustration
}>()
</script>
<template>
<div
class="pwa-step-illustration"
:class="{
'pwa-step-illustration--android-menu': type === 'android-menu',
'pwa-step-illustration--ios-safari': type === 'ios-safari',
}"
>
<div
class="pwa-step-illustration__phone"
:class="{
'pwa-step-illustration__phone--android-menu': type === 'android-menu',
'pwa-step-illustration__phone--ios-safari': type === 'ios-safari',
}"
>
<div
class="pwa-step-illustration__screen"
:class="{
'pwa-step-illustration__screen--android-menu': type === 'android-menu',
'pwa-step-illustration__screen--ios-safari': type === 'ios-safari',
}"
>
<template v-if="type === 'android-chrome'">
<div class="pwa-step-illustration__browser-bar">
<span class="pwa-step-illustration__dot pwa-step-illustration__dot--red" />
<span class="pwa-step-illustration__dot pwa-step-illustration__dot--yellow" />
<span class="pwa-step-illustration__dot pwa-step-illustration__dot--green" />
</div>
<div class="pwa-step-illustration__chrome-body" />
<div class="pwa-step-illustration__chrome-label">باز در Chrome</div>
</template>
<template v-else-if="type === 'android-menu'">
<div class="android-menu-scene">
<div class="android-menu-scene__toolbar">
<div class="android-menu-scene__url-bar" />
<div class="android-menu-scene__menu-target" aria-hidden="true">
<span class="android-menu-scene__menu-dots">⋮</span>
</div>
</div>
<div class="android-menu-scene__viewport">
<div class="android-menu-scene__dropdown">
<div class="android-menu-scene__dropdown-line" />
<div class="android-menu-scene__dropdown-line android-menu-scene__dropdown-line--short" />
<div class="android-menu-scene__dropdown-install">
Install app
</div>
</div>
</div>
</div>
</template>
<template v-else-if="type === 'android-install'">
<div class="pwa-step-illustration__install-btn">Install</div>
</template>
<template v-else-if="type === 'android-confirm'">
<div class="pwa-step-illustration__app-icon" />
<div class="pwa-step-illustration__home-label">روی صفحه اصلی</div>
</template>
<template v-else-if="type === 'ios-safari'">
<div class="ios-safari-scene">
<div class="ios-safari-scene__toolbar">
<span class="ios-safari-scene__toolbar-label">Safari</span>
</div>
<div class="ios-safari-scene__viewport">
<div class="ios-safari-scene__placeholder" />
</div>
<div class="ios-safari-scene__caption">باز در Safari</div>
</div>
</template>
<template v-else-if="type === 'ios-share'">
<div class="pwa-step-illustration__share-wrap">
<img :src="shareAppleIcon" alt="" class="pwa-step-illustration__share-icon" />
</div>
</template>
<template v-else-if="type === 'ios-add-home'">
<div class="pwa-step-illustration__sheet">
<div class="pwa-step-illustration__sheet-item">Share</div>
<div class="pwa-step-illustration__sheet-item pwa-step-illustration__sheet-item--active">
<img :src="addToHomeScreenIcon" alt="" class="pwa-step-illustration__sheet-icon" />
Add to Home Screen
</div>
</div>
</template>
<template v-else-if="type === 'ios-confirm'">
<div class="pwa-step-illustration__app-icon" />
<div class="pwa-step-illustration__home-label">روی صفحه اصلی</div>
</template>
</div>
</div>
</div>
</template>
<style scoped lang="less">
.pwa-step-illustration {
display: flex;
justify-content: center;
padding: 8px 0 4px;
}
.pwa-step-illustration__phone {
width: 148px;
height: 220px;
border: 2px solid #cbd5e1;
border-radius: 22px;
padding: 10px 8px;
background: #fff;
}
.pwa-step-illustration__screen {
position: relative;
width: 100%;
height: 100%;
border: 1px solid #e2e8f0;
border-radius: 14px;
background: #f8fafc;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}
.pwa-step-illustration__browser-bar {
position: absolute;
top: 10px;
left: 12px;
display: flex;
gap: 4px;
}
.pwa-step-illustration__dot {
width: 7px;
height: 7px;
border-radius: 999px;
&--red { background: #ef4444; }
&--yellow { background: #f59e0b; }
&--green { background: #22c55e; }
}
.pwa-step-illustration__chrome-body {
width: 78%;
height: 42%;
border-radius: 8px;
background: linear-gradient(180deg, #dbeafe 0%, #bfdbfe 100%);
margin-top: 18px;
}
.pwa-step-illustration__chrome-label,
.pwa-step-illustration__home-label {
font-size: 10px;
color: #64748b;
}
.pwa-step-illustration--android-menu,
.pwa-step-illustration--ios-safari {
padding: 12px 0 0;
}
.pwa-step-illustration__phone--android-menu,
.pwa-step-illustration__phone--ios-safari {
width: 132px;
height: 208px;
padding: 0;
border-width: 1.5px;
border-color: #cbd5e1;
border-radius: 20px;
box-shadow: none;
}
.pwa-step-illustration__screen--android-menu,
.pwa-step-illustration__screen--ios-safari {
border: none;
border-radius: 18px;
background: #fff;
display: block;
overflow: hidden;
}
.ios-safari-scene {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.ios-safari-scene__toolbar {
display: flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 8px 10px 6px;
border-bottom: 1px solid #eef2f7;
background: #fff;
}
.ios-safari-scene__toolbar-label {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 72px;
padding: 4px 10px;
border-radius: 999px;
background: #eef2f7;
color: #64748b;
font-size: 9px;
font-weight: 600;
}
.ios-safari-scene__viewport {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 14px 12px 8px;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
}
.ios-safari-scene__placeholder {
width: 78%;
height: 58%;
border-radius: 12px;
background: linear-gradient(180deg, #dbeafe 0%, #bfdbfe 100%);
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.08);
}
.ios-safari-scene__caption {
padding: 0 10px 10px;
text-align: center;
font-size: 9px;
color: #64748b;
background: #fff;
}
.android-menu-scene {
position: relative;
width: 100%;
height: 100%;
}
.android-menu-scene__toolbar {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 10px 8px 8px;
border-bottom: 1px solid #eef2f7;
}
.android-menu-scene__url-bar {
flex: 1;
height: 8px;
border-radius: 999px;
background: #e8edf5;
}
.android-menu-scene__menu-target {
flex: 0 0 auto;
width: 24px;
height: 24px;
border: 1.5px dashed #2563eb;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(37, 99, 235, 0.04);
}
.android-menu-scene__menu-dots {
font-size: 14px;
line-height: 1;
font-weight: 700;
color: #334155;
transform: translateY(-1px);
}
.android-menu-scene__viewport {
position: relative;
height: calc(100% - 41px);
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
}
.android-menu-scene__dropdown {
position: absolute;
top: 6px;
right: 8px;
width: 78px;
padding: 8px 8px 10px;
border-radius: 10px;
background: #fff;
border: 1px solid #e2e8f0;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
}
.android-menu-scene__dropdown-line {
height: 4px;
border-radius: 999px;
background: #e2e8f0;
margin-bottom: 6px;
&--short {
width: 72%;
margin-bottom: 8px;
}
}
.android-menu-scene__dropdown-install {
display: flex;
align-items: center;
justify-content: center;
min-height: 22px;
padding: 4px 6px;
border-radius: 6px;
background: #eff6ff;
border: 1px solid #bfdbfe;
color: #2563eb;
font-size: 8px;
font-weight: 700;
line-height: 1.2;
text-align: center;
}
.pwa-step-illustration__install-btn {
font-size: 11px;
font-weight: 600;
color: #2563eb;
border: 1px solid #2563eb;
border-radius: 8px;
padding: 6px 14px;
background: #eff6ff;
}
.pwa-step-illustration__app-icon {
width: 42px;
height: 42px;
border-radius: 12px;
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.pwa-step-illustration__share-wrap {
position: absolute;
bottom: 14px;
width: 34px;
height: 34px;
border: 2px dashed #2563eb;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
.pwa-step-illustration__share-icon {
width: 18px;
height: 18px;
object-fit: contain;
}
.pwa-step-illustration__sheet {
width: 88%;
border-radius: 10px;
background: #fff;
border: 1px solid #e2e8f0;
overflow: hidden;
}
.pwa-step-illustration__sheet-item {
padding: 8px 10px;
font-size: 9px;
color: #64748b;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
gap: 6px;
&--active {
color: #2563eb;
border: 1px solid #2563eb;
border-radius: 8px;
margin: 6px;
font-weight: 600;
}
}
.pwa-step-illustration__sheet-icon {
width: 14px;
height: 14px;
object-fit: contain;
}
</style>
+220
View File
@@ -0,0 +1,220 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { notification } from 'ant-design-vue'
import { getAppNotificationsUnreadCount } from '@/api/appNotifications'
import { useSettingsStore } from '@/store/settingsStore'
import { getToken } from '@/utils'
import { showBrowserNotification } from '@/utils/browserNotification'
import { priceSocketConnected, priceWssEnabled } from '@/utils/priceSocketState'
import { APP_Z_TOAST } from '@/utils/zIndex'
const unreadCount = ref(0)
let started = false
let lastStreamNotifId = 0
let hydratedUnread = false
let lastShownNotifId = 0
let toastConfigured = false
let hydrateFallbackTimer: ReturnType<typeof setTimeout> | null = null
function ensureNotifToastConfig() {
if (toastConfigured) return
toastConfigured = true
notification.config({
placement: 'topRight',
top: 'calc(var(--app-home-header-offset, 4.25rem) + 0.5rem)',
duration: 5,
})
}
function notificationsEnabled(): boolean {
return Boolean(getToken()) && useSettingsStore().isSettingTruthy('has_page_notification')
}
function clearHydrateFallback() {
if (hydrateFallbackTimer) {
clearTimeout(hydrateFallbackTimer)
hydrateFallbackTimer = null
}
}
/** از SSE رویداد unread-count — بدون HTTP */
function applyUnreadCountFromStream(payload: Record<string, unknown>) {
if (!notificationsEnabled()) {
unreadCount.value = 0
hydratedUnread = false
return
}
const next = Number(payload.unread_count ?? 0) || 0
unreadCount.value = Math.max(0, next)
hydratedUnread = true
clearHydrateFallback()
}
/** فقط وقتی SSE در دسترس نیست یا برای ریفرش دستی صفحه اعلان‌ها */
async function refreshUnreadCount() {
if (!notificationsEnabled()) {
unreadCount.value = 0
hydratedUnread = false
return
}
try {
const data = await getAppNotificationsUnreadCount()
unreadCount.value = Number(data?.unread_count ?? 0) || 0
hydratedUnread = true
} catch {
// silent — badge optional
}
}
function clearUnreadLocally() {
unreadCount.value = 0
}
function showInAppToast(title: string, body: string) {
try {
ensureNotifToastConfig()
notification.open({
message: title || 'اعلان جدید',
description: body || undefined,
placement: 'topRight',
duration: 5,
class: 'app-notif-toast',
style: { zIndex: APP_Z_TOAST },
onClick: () => {
void import('@/router').then((mod) => {
mod.default.push({ name: 'appNotifications' }).catch(() => undefined)
})
},
})
} catch {
// ignore
}
}
async function presentNotification(payload: {
id?: number
title?: string
body?: string
}) {
const id = Number(payload.id ?? 0)
if (id > 0 && id === lastShownNotifId) {
return
}
if (id > 0) {
lastShownNotifId = id
}
const title = String(payload.title ?? '').trim() || 'اعلان جدید'
const body = String(payload.body ?? '').trim()
showInAppToast(title, body)
await showBrowserNotification({
title,
body,
tag: id > 0 ? `app-notification-${id}` : undefined,
url: '/appNotifications',
})
}
/** از SSE رویداد app_notification — بدون polling HTTP */
function bumpUnreadFromStream(payload: Record<string, unknown>) {
if (!notificationsEnabled()) {
return
}
const id = Number(payload.id ?? 0)
if (id > 0 && id === lastStreamNotifId) {
return
}
if (id > 0) {
lastStreamNotifId = id
}
if (payload.unread_count !== undefined && payload.unread_count !== null) {
applyUnreadCountFromStream(payload)
} else {
unreadCount.value = Math.max(0, unreadCount.value) + 1
hydratedUnread = true
clearHydrateFallback()
}
void presentNotification({
id,
title: String(payload.title ?? ''),
body: String(payload.body ?? ''),
})
}
/**
* بدون interval: اگر استریم قیمت روشن است منتظر رویداد SSE می‌مانیم؛
* فقط وقتی wss خاموش است یا SSE تا ۱۲ثانیه نیامد، یک‌بار HTTP می‌زنیم.
*/
function scheduleHydrate() {
if (!notificationsEnabled()) {
unreadCount.value = 0
hydratedUnread = false
clearHydrateFallback()
return
}
if (!priceWssEnabled.value) {
void refreshUnreadCount()
return
}
if (priceSocketConnected.value || hydratedUnread) {
clearHydrateFallback()
return
}
clearHydrateFallback()
hydrateFallbackTimer = setTimeout(() => {
hydrateFallbackTimer = null
if (!hydratedUnread && notificationsEnabled() && !priceSocketConnected.value) {
void refreshUnreadCount()
}
}, 12000)
}
export function useAppNotificationBadge() {
const settingsStore = useSettingsStore()
const enabled = computed(() => settingsStore.isSettingTruthy('has_page_notification'))
onMounted(() => {
started = true
scheduleHydrate()
})
onUnmounted(() => {
// module-level badge — keep state for other headers
})
watch(enabled, (on) => {
if (!on) {
unreadCount.value = 0
hydratedUnread = false
clearHydrateFallback()
} else {
scheduleHydrate()
}
})
watch([priceSocketConnected, priceWssEnabled], () => {
if (!started) return
scheduleHydrate()
})
return {
enabled,
unreadCount,
refreshUnreadCount,
clearUnreadLocally,
}
}
export {
refreshUnreadCount,
clearUnreadLocally,
bumpUnreadFromStream,
applyUnreadCountFromStream,
}
+183
View File
@@ -0,0 +1,183 @@
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import router from '@/router'
import { getColleagueOrderStatus, getPendingColleagueOrder } from '@/api/order'
import { useUserStore } from '@/store/userStore'
import { useWalletStore } from '@/store/walletStore'
import { useFundUserStore } from '@/store/fundStore'
import { getToken } from '@/utils'
export const colleagueOrderWaiting = ref(false)
const CLIENT_POLL_MS = 10000
const MAX_WAIT_MS = 15 * 60 * 1000
const STREAM_EVENT = 'colleague-order'
type WaitResult = 'success' | 'failed' | 'none'
let inflight: Promise<WaitResult> | null = null
export function emitColleagueOrderFromStream(payload: unknown) {
window.dispatchEvent(new CustomEvent(STREAM_EVENT, { detail: payload }))
}
export function isColleagueOrderPending(res: unknown): boolean {
if (!res || typeof res !== 'object') return false
const row = res as Record<string, any>
if (row.pending === true) return true
if (Array.isArray(row) && row[0]?.pending === true) return true
const sts = Number(
row[0]?.sts ?? row.sts ?? row[1]?.order?.sts ?? row.order?.sts ?? NaN
)
return sts === 4
}
export function colleagueOrderIdFromResponse(res: unknown): number | null {
if (!res || typeof res !== 'object') return null
const row = res as Record<string, any>
const id = Number(
row[0]?.order_id ?? row.order_id ?? row[1]?.order?.id ?? row.order?.id ?? NaN
)
return id > 0 ? id : null
}
async function finishSuccess() {
try {
await useUserStore().getMyInfo()
} catch {
/* ignore */
}
try {
await useWalletStore().getMyWallet()
} catch {
/* ignore */
}
try {
await useFundUserStore().getMyFund()
} catch {
/* ignore */
}
message.success('سفارش شما با موفقیت انجام شد')
if (router.currentRoute.value.name !== 'transactions') {
await router.push({ name: 'transactions' })
}
}
function stsFromPayload(payload: Record<string, any> | null | undefined): number | null {
if (!payload) return null
const sts = Number(payload.sts ?? payload.order?.sts)
return Number.isFinite(sts) ? sts : null
}
/** نتیجه قطعی از پاسخ status/pending — null یعنی هنوز باید صبر کرد */
function settleFromPayload(payload: Record<string, any> | null | undefined): WaitResult | null {
if (!payload || typeof payload !== 'object') return null
const pending = payload.pending
const sts = stsFromPayload(payload)
if (sts === 2) return 'success'
if (sts === 3) return 'failed'
if (sts !== null && sts !== 4) return 'failed'
if (pending === false) {
if (sts === 2) return 'success'
return 'failed'
}
return null
}
async function readSettle(orderId: number | null): Promise<WaitResult | null> {
try {
if (orderId && orderId > 0) {
const row = (await getColleagueOrderStatus(orderId)) as Record<string, any>
return settleFromPayload(row)
}
const row = (await getPendingColleagueOrder()) as Record<string, any>
return settleFromPayload(row)
} catch {
return null
}
}
export async function waitForColleagueOrder(orderId?: number | null): Promise<WaitResult> {
if (inflight) return inflight
inflight = (async () => {
colleagueOrderWaiting.value = true
let settled: WaitResult | null = null
const startedAt = Date.now()
const finish = (result: WaitResult) => {
if (settled) return
settled = result
}
const onStream = (event: Event) => {
const detail = (event as CustomEvent).detail as Record<string, any> | undefined
if (orderId && Number(detail?.order_id) > 0 && Number(detail.order_id) !== orderId) {
return
}
const result = settleFromPayload(detail ?? null)
if (result) finish(result)
}
window.addEventListener(STREAM_EVENT, onStream)
try {
while (!settled) {
if (Date.now() - startedAt > MAX_WAIT_MS) {
finish('failed')
break
}
const result = await readSettle(orderId ?? null)
if (result) {
finish(result)
break
}
await new Promise<void>((resolve) => {
const timer = window.setTimeout(resolve, CLIENT_POLL_MS)
const once = () => {
window.clearTimeout(timer)
resolve()
}
window.addEventListener(STREAM_EVENT, once, { once: true })
})
}
} finally {
window.removeEventListener(STREAM_EVENT, onStream)
colleagueOrderWaiting.value = false
}
if (settled === 'success') {
await finishSuccess()
return 'success'
}
message.error('سفارش توسط همکار رد شد')
return 'failed'
})().finally(() => {
inflight = null
})
return inflight
}
export async function afterCreateOrder(res: unknown): Promise<WaitResult> {
if (isColleagueOrderPending(res)) {
return waitForColleagueOrder(colleagueOrderIdFromResponse(res))
}
await finishSuccess()
return 'success'
}
export async function resumeColleagueOrderIfPending(): Promise<WaitResult> {
if (!getToken()) return 'none'
try {
const row = (await getPendingColleagueOrder()) as Record<string, any>
if (!row?.pending) return 'none'
const id = Number(row.order_id ?? row.order?.id ?? 0)
return waitForColleagueOrder(id > 0 ? id : null)
} catch {
return 'none'
}
}
+12
View File
@@ -0,0 +1,12 @@
import { ref } from 'vue'
/** باز/بسته بودن مدال محاسبه‌گر طلا — مشترک بین منوی کاربر و همبرگر */
export const goldDayCalculatorVisible = ref(false)
export function openGoldDayCalculator() {
goldDayCalculatorVisible.value = true
}
export function closeGoldDayCalculator() {
goldDayCalculatorVisible.value = false
}
+42 -14
View File
@@ -1,47 +1,75 @@
import { ref } from 'vue'
import { getProducts } from '@/api/products'
import type { Product } from '@/models/products'
import { getLastStreamProductsList } from '@/utils/productStreamBridge'
import { priceStreamList } from '@/utils/priceSocketState'
/** کش مشترک لیست محصولات — dedupe درخواست‌های polling */
const catalogProducts = ref<Product[]>([])
let catalogPromise: Promise<Product[]> | null = null
export function getProductsCatalogRef() {
if (catalogProducts.value.length === 0 && Array.isArray(priceStreamList.value) && priceStreamList.value.length > 0) {
catalogProducts.value = priceStreamList.value as unknown as Product[]
}
return catalogProducts
}
export function findProductInCatalog(id: number): Product | undefined {
const cached = catalogProducts.value.find((p) => p.id === id)
if (cached) {
return cached
export function syncCatalogFromStream(streamProducts: Product[]) {
if (!Array.isArray(streamProducts) || streamProducts.length === 0) return
if (catalogProducts.value.length === 0) {
catalogProducts.value = streamProducts
} else {
const map = new Map(streamProducts.map((p) => [p.id, p]))
catalogProducts.value.forEach((p) => {
const incoming = map.get(p.id)
if (incoming) {
if (incoming.price !== undefined) p.price = incoming.price
if (incoming.price_buy !== undefined) p.price_buy = incoming.price_buy
if (incoming.price_sell !== undefined) p.price_sell = incoming.price_sell
if (incoming.trade_open !== undefined) p.trade_open = incoming.trade_open
if (incoming.show !== undefined) p.show = incoming.show
}
})
}
}
return getLastStreamProductsList()?.find((p) => p.id === id)
export function findProductInCatalog(id: number): Product | undefined {
return catalogProducts.value.find((p) => p.id === id)
}
export function getProductsCatalog(force = false): Promise<Product[]> {
if (!force) {
const streamed = getLastStreamProductsList()
if (streamed?.length) {
catalogProducts.value = streamed
return Promise.resolve(streamed)
}
if (catalogProducts.value.length > 0) {
return Promise.resolve(catalogProducts.value)
}
if (Array.isArray(priceStreamList.value) && priceStreamList.value.length > 0) {
catalogProducts.value = priceStreamList.value as unknown as Product[]
return Promise.resolve(catalogProducts.value)
}
}
if (!force && catalogPromise) {
return catalogPromise
}
catalogPromise = getProducts()
catalogPromise = getProducts(force)
.then((products) => {
catalogProducts.value = products ?? []
const list = Array.isArray(products) ? products : []
if (list.length > 0) {
catalogProducts.value = list
}
return catalogProducts.value
})
.catch((err) => {
if (catalogProducts.value.length > 0) {
return catalogProducts.value
}
if (Array.isArray(priceStreamList.value) && priceStreamList.value.length > 0) {
catalogProducts.value = priceStreamList.value as unknown as Product[]
return catalogProducts.value
}
throw err
})
.finally(() => {
catalogPromise = null
})
+132
View File
@@ -0,0 +1,132 @@
import { computed, onMounted, ref } from 'vue'
import type { PwaPlatform } from '@/modules/pwa/pwaInstallGuideSteps'
import {
getDefaultPwaPlatform,
initPwaInstallPromptListener,
isPwaInstallBannerDismissed,
markPwaInstallBannerAsShown,
shouldOfferPwaInstall,
shouldShowPwaInstallPrompt,
triggerNativePwaInstall,
} from '@/modules/pwa/pwaInstall'
const guideOpen = ref(false)
const bannerVisible = ref(false)
const platform = ref<PwaPlatform>(getDefaultPwaPlatform())
const installBannerConsumed = ref(isPwaInstallBannerDismissed())
/** false وقتی اپ به‌صورت PWA/TWA باز شده یا همین الان نصب شده */
const offerInstall = ref(shouldOfferPwaInstall())
let listenerReady = false
let offerListenersBound = false
function refreshOfferInstall() {
offerInstall.value = shouldOfferPwaInstall()
}
function bindOfferInstallListeners() {
if (offerListenersBound || typeof window === 'undefined') return
offerListenersBound = true
refreshOfferInstall()
const onInstalled = () => {
offerInstall.value = false
}
window.addEventListener('appinstalled', onInstalled)
const modes = ['standalone', 'fullscreen', 'minimal-ui', 'window-controls-overlay'] as const
const mediaLists = modes.map((mode) => window.matchMedia(`(display-mode: ${mode})`))
const onDisplayModeChange = () => refreshOfferInstall()
for (const mql of mediaLists) {
mql.addEventListener('change', onDisplayModeChange)
}
}
function ensureListener() {
if (listenerReady) return
listenerReady = true
initPwaInstallPromptListener()
bindOfferInstallListeners()
}
export function usePwaInstall() {
ensureListener()
onMounted(() => {
refreshOfferInstall()
})
const canPrompt = computed(() => {
if (installBannerConsumed.value) return false
return shouldShowPwaInstallPrompt()
})
const showInstallEntry = computed(() => offerInstall.value)
function consumeInstallBanner() {
if (installBannerConsumed.value) return
markPwaInstallBannerAsShown()
installBannerConsumed.value = true
bannerVisible.value = false
}
function openGuide(nextPlatform?: PwaPlatform) {
if (!offerInstall.value) return
consumeInstallBanner()
platform.value = nextPlatform ?? getDefaultPwaPlatform()
guideOpen.value = true
}
function closeGuide() {
guideOpen.value = false
}
function showBanner() {
if (!canPrompt.value) return
bannerVisible.value = true
markPwaInstallBannerAsShown()
installBannerConsumed.value = true
}
function hideBanner() {
bannerVisible.value = false
}
function dismissBanner() {
consumeInstallBanner()
}
async function completeGuide() {
await triggerNativePwaInstall()
consumeInstallBanner()
closeGuide()
}
/** نصب مستقیم مرورگر؛ اگر ممکن نبود مدال آموزش. */
async function tryInstallOrOpenGuide(nextPlatform?: PwaPlatform) {
if (!offerInstall.value) return false
const installed = await triggerNativePwaInstall()
if (installed) {
offerInstall.value = false
consumeInstallBanner()
return true
}
openGuide(nextPlatform)
return false
}
return {
guideOpen,
bannerVisible,
platform,
canPrompt,
showInstallEntry,
openGuide,
closeGuide,
showBanner,
hideBanner,
dismissBanner,
completeGuide,
tryInstallOrOpenGuide,
}
}
+296
View File
@@ -0,0 +1,296 @@
import { computed, ref } from 'vue'
import { message } from 'ant-design-vue'
import { useRoute, useRouter } from 'vue-router'
import {
buildDepositSmsUrl,
buildSupportPhoneUrl,
loadSupportMessengerFromSettings,
messengerNeedsClipboardCopy,
openSupportMessengerEntry,
resolveDepositGuideText,
resolveDepositSupportMessage,
resolveSupportMessengerEntries,
resolveSupportMessengerLink,
resolveSupportMessengerMeta,
supportMessengerPickerAnchor,
supportMessengerPickerOpen,
supportMessengerSettings,
type ResolvedSupportMessengerEntry,
} from '@/utils/supportMessenger'
import {
copyDepositFieldValue,
displayDepositCardNumber,
displayDepositSheba,
getDepositGuideVarsFromSettings,
getDepositSettingLabel,
hasDepositFieldValue,
} from '@/utils/depositGuideText'
import { useSettingsStore } from '@/store/settingsStore'
import { isUserSupportChatEnabled } from '@/utils/chat'
/** plain: منوی شناور / درباره ما — بدون پیش‌متن | deposit: مودال واریز — با deposit_support_message_text */
type SupportPickerIntent = 'plain' | 'deposit'
const CHAT_PICKER_ENTRY: ResolvedSupportMessengerEntry = {
messenger: 'chat',
id: 'support-chat',
label: 'چت پشتیبانی',
icon: 'vuesax-linear:messages-2',
accent: '#16a34a',
url: '',
}
export function useSupportMessenger() {
const settingsStore = useSettingsStore()
const router = useRouter()
const route = useRoute()
const supportPickerOpen = supportMessengerPickerOpen
const supportPickerIntent = ref<SupportPickerIntent>('plain')
/** چت فقط بعد از لاگین — روی صفحه ورود در picker نیاید */
const isChatEnabled = computed(() => {
void route.name
return isUserSupportChatEnabled()
})
const legacySupportLink = computed(() => '')
const depositSupportMessage = computed(() => resolveDepositSupportMessage())
const supportMessengerEntries = computed(() =>
resolveSupportMessengerEntries(supportMessengerSettings.value),
)
const pickerEntries = computed(() => {
const list = [...supportMessengerEntries.value]
if (isChatEnabled.value) {
list.unshift(CHAT_PICKER_ENTRY)
}
return list
})
const primaryMessengerEntry = computed(() => supportMessengerEntries.value[0] ?? null)
const messengerType = computed(
() => (primaryMessengerEntry.value?.messenger ?? supportMessengerSettings.value.messenger ?? '').trim().toLowerCase(),
)
const messengerMeta = computed(() => {
if (isChatEnabled.value && supportMessengerEntries.value.length === 0) {
return {
label: CHAT_PICKER_ENTRY.label,
icon: CHAT_PICKER_ENTRY.icon,
accent: CHAT_PICKER_ENTRY.accent,
}
}
return primaryMessengerEntry.value
? {
label: primaryMessengerEntry.value.label,
icon: primaryMessengerEntry.value.icon,
accent: primaryMessengerEntry.value.accent,
}
: resolveSupportMessengerMeta(messengerType.value)
})
const messengerLink = computed(() =>
primaryMessengerEntry.value?.url
|| resolveSupportMessengerLink(
supportMessengerSettings.value.messenger,
supportMessengerSettings.value.messengerId,
legacySupportLink.value,
),
)
function goToSupportChat() {
void router.push({ name: 'support' })
return true
}
async function openMessengerEntry(entry: ResolvedSupportMessengerEntry, text?: string) {
if (entry.messenger === 'chat') {
return goToSupportChat()
}
const messageText =
text !== undefined
? text.trim()
: supportPickerIntent.value === 'deposit'
? depositSupportMessage.value.trim()
: ''
const opened = await openSupportMessengerEntry(entry, messageText, legacySupportLink.value)
if (!opened) return false
if (messageText && messengerNeedsClipboardCopy(entry.messenger)) {
message.success('متن پیام کپی شد — در پیام‌رسان paste کنید')
}
return true
}
let supportPickerOpenedAt = 0
async function openSupportPicker(
intent: SupportPickerIntent,
anchorEl?: HTMLElement | null,
) {
supportPickerIntent.value = intent
if (anchorEl !== undefined) {
supportMessengerPickerAnchor.value = anchorEl
}
const draft =
intent === 'deposit' ? depositSupportMessage.value.trim() : ''
const messengerEntries = supportMessengerEntries.value
const entries = pickerEntries.value
// لیست خالی + چت خاموش → هیچ‌کار
if (entries.length === 0) {
return false
}
// فقط چت (لیست پیام‌رسان خالی) → مستقیم چت
if (isChatEnabled.value && messengerEntries.length === 0) {
return goToSupportChat()
}
// فقط یک گزینه (چت یا یک پیام‌رسان) → مستقیم همان
if (entries.length === 1) {
return openMessengerEntry(entries[0], draft)
}
// چند گزینه (چت + لینک‌ها یا چند پیام‌رسان) → منوی انتخاب
const now = Date.now()
if (supportPickerOpen.value) {
if (now - supportPickerOpenedAt < 450) return true
supportPickerOpen.value = false
return true
}
supportPickerOpen.value = true
supportPickerOpenedAt = now
return true
}
async function openSupportMessenger(anchorEl?: HTMLElement | null) {
return openSupportPicker('plain', anchorEl)
}
async function openDepositSupportMessenger(anchorEl?: HTMLElement | null) {
return openSupportPicker('deposit', anchorEl)
}
function handleSupportMessengerClick(event?: Event) {
const target = event?.currentTarget
const anchor = target instanceof HTMLElement ? target : null
void openSupportMessenger(anchor)
}
const depositSmsLink = computed(() =>
buildDepositSmsUrl(
supportMessengerSettings.value.depositSmsSupportNumber,
depositSupportMessage.value,
),
)
const supportPhoneLink = computed(() => {
const fromSettings = buildSupportPhoneUrl(
supportMessengerSettings.value.depositSmsSupportNumber,
)
if (fromSettings) return fromSettings
const legacy = legacySupportLink.value
if (legacy.startsWith('tel:') && legacy.length > 4) return legacy
return ''
})
const supportMessengerText = computed(() => resolveDepositGuideText())
const depositCardNumberRaw = computed(() => {
void settingsStore.settings
const fromMessenger = supportMessengerSettings.value.depositCardNumber?.trim()
if (fromMessenger) return fromMessenger
return String(getDepositGuideVarsFromSettings().cardNumber ?? '').trim()
})
const depositShebaRaw = computed(() => {
void settingsStore.settings
const fromMessenger = supportMessengerSettings.value.depositSheba?.trim()
if (fromMessenger) return fromMessenger
return String(getDepositGuideVarsFromSettings().sheba ?? '').trim()
})
const depositCardNumber = computed(() => displayDepositCardNumber(depositCardNumberRaw.value))
const depositSheba = computed(() => displayDepositSheba(depositShebaRaw.value))
const depositCardLabel = computed(() => {
void settingsStore.settings
return getDepositSettingLabel('deposit_card_number', 'شماره کارت واریز')
})
const depositShebaLabel = computed(() => {
void settingsStore.settings
return getDepositSettingLabel('deposit_sheba', 'شبا واریز')
})
const hasDepositCardValue = computed(() => hasDepositFieldValue(depositCardNumberRaw.value))
const hasDepositShebaValue = computed(() => hasDepositFieldValue(depositShebaRaw.value))
async function copyDepositCardNumber() {
await copyDepositFieldValue(
depositCardNumberRaw.value,
'card',
'شماره کارت ثبت نشده است',
)
}
async function copyDepositShebaNumber() {
await copyDepositFieldValue(
depositShebaRaw.value,
'sheba',
'شماره شبا ثبت نشده است',
)
}
const hasMessengerLink = computed(
() => isChatEnabled.value || supportMessengerEntries.value.length > 0,
)
const hasMultipleMessengers = computed(() => pickerEntries.value.length > 1)
const hasSupportPhoneLink = computed(() => !!supportPhoneLink.value)
const hasSupportActions = computed(() => hasSupportPhoneLink.value || hasMessengerLink.value)
const opensInNewTab = computed(() => /^https?:\/\//i.test(messengerLink.value))
return {
supportPickerOpen,
supportPickerAnchor: supportMessengerPickerAnchor,
supportMessengerEntries: pickerEntries,
isChatEnabled,
messengerType,
messengerMeta,
messengerLink,
supportPhoneLink,
depositSmsLink,
supportMessengerText,
depositSupportMessage,
depositCardNumber,
depositSheba,
depositCardNumberRaw,
depositShebaRaw,
depositCardLabel,
depositShebaLabel,
hasDepositCardValue,
hasDepositShebaValue,
copyDepositCardNumber,
copyDepositShebaNumber,
openSupportMessenger,
openDepositSupportMessenger,
openMessengerEntry,
handleSupportMessengerClick,
goToSupportChat,
hasMessengerLink,
hasMultipleMessengers,
hasSupportPhoneLink,
hasSupportActions,
opensInNewTab,
loadSupportMessengerFromSettings,
}
}
@@ -0,0 +1,21 @@
import { onMounted, ref } from 'vue'
import { fetchTrackingSettings, isTrackingSystemsEnabled } from '@/utils/trackingSystemsSettings'
export function useTrackingSystemsFeature() {
const trackingSystemsEnabled = ref(false)
const trackingSystemsReady = ref(false)
onMounted(async () => {
try {
const settings = await fetchTrackingSettings()
trackingSystemsEnabled.value = isTrackingSystemsEnabled(settings)
} finally {
trackingSystemsReady.value = true
}
})
return {
trackingSystemsEnabled,
trackingSystemsReady,
}
}
+63 -2
View File
@@ -1,14 +1,75 @@
import type { AppConfig } from './types'
import { rewriteLegacyUploadUrl } from './rewrite-legacy-upload-url'
type RawAppConfig = AppConfig & {
user?: Record<string, any>
}
const EMPTY_STRING_CONFIG_KEYS = new Set([
'color',
'secondary_color',
'background_color',
'theme_config',
])
function emptyStringToNull(value: unknown) {
if (typeof value === 'string' && value.trim() === '') {
return null
}
return value
}
function normalizeUserConfig(user?: Record<string, any>) {
if (!user || typeof user !== 'object') {
return user
}
const normalized: Record<string, any> = { ...user }
for (const key of EMPTY_STRING_CONFIG_KEYS) {
if (key in normalized) {
normalized[key] = emptyStringToNull(normalized[key])
}
}
if (typeof normalized.avatar === 'string') {
normalized.avatar = rewriteLegacyUploadUrl(normalized.avatar)
}
return normalized
}
export function normalizeAppConfig(config: RawAppConfig): RawAppConfig {
if (!config || typeof config !== 'object') {
return {}
}
const next: RawAppConfig = { ...config }
if (typeof next.avatar === 'string') {
next.avatar = rewriteLegacyUploadUrl(next.avatar)
}
if (next.user && typeof next.user === 'object') {
next.user = normalizeUserConfig(next.user)
}
return next
}
let appConfig: RawAppConfig | null = null
export function setAppConfig(config: RawAppConfig) {
appConfig = config || {}
appConfig = normalizeAppConfig(config || {})
window.__APP_CONFIG__ = appConfig
try {
const apply = (window as Window & {__APPLY_BOOT_BRANDING__?: (c: unknown) => void})
.__APPLY_BOOT_BRANDING__
if (typeof apply === 'function') apply(appConfig)
} catch {
/* boot DOM ممکن است هنوز نباشد */
}
}
export function getRawAppConfig() {
@@ -31,7 +92,7 @@ export function getTenantConfig() {
export function getConfig<T = any>(key: string, fallback?: T): T {
const config = getTenantConfig()
const value = config?.[key]
const value = emptyStringToNull(config?.[key])
return (value ?? fallback ?? null) as T
}
+19 -9
View File
@@ -1,11 +1,8 @@
import { getConfig } from '@/config/app-config'
import { getAppIconUrl, resolveConfigAssetUrl } from '@/config/brand-assets'
import { applyBrandTheme } from '@/config/brand-theme'
import { rewriteLegacyUploadUrl } from '@/config/rewrite-legacy-upload-url'
import { isPwaManifestReady } from '@/modules/pwa/manifest'
export function applyAppConfig() {
applyBrandTheme()
if (isPwaManifestReady()) return
setTitle()
@@ -17,6 +14,10 @@ function setTitle() {
document.title = getConfig('name_fa', 'در حال بارگذاری...')
}
function getLogoUrl() {
return rewriteLegacyUploadUrl(getConfig<string>('avatar') || '/favicon.png')
}
function setFavicon() {
let faviconLink =
document.querySelector<HTMLLinkElement>("link[rel='icon']") ||
@@ -28,14 +29,14 @@ function setFavicon() {
document.head.appendChild(faviconLink)
}
const logoUrl = getAppIconUrl()
const logoUrl = getLogoUrl()
faviconLink.type = 'image/png'
faviconLink.href = logoUrl
faviconLink.href = logoUrl.startsWith('http') ? logoUrl : resolveRemoteLogo(logoUrl)
}
function setAppleTouchIconFallback() {
const logoUrl = getAppIconUrl()
const logoUrl = getLogoUrl()
if (!logoUrl) return
@@ -57,7 +58,7 @@ function setAppleTouchIconFallback() {
document.head.appendChild(link)
}
link.href = logoUrl
link.href = logoUrl.startsWith('http') ? logoUrl : resolveRemoteLogo(logoUrl)
})
let appleTitle = document.querySelector<HTMLMetaElement>("meta[name='apple-mobile-web-app-title']")
@@ -71,4 +72,13 @@ function setAppleTouchIconFallback() {
appleTitle.content = getConfig('name_fa', 'اپ مشتری')
}
export { resolveConfigAssetUrl }
function resolveRemoteLogo(path: string) {
if (/^(https?:\/\/|data:|blob:)/i.test(path)) return path
const lastSuccess = localStorage.getItem('CONFIG_Success_URL')
const base = lastSuccess
? lastSuccess.replace(/[?#].*$/, '').replace(/\/get_user\/?$/i, '').replace(/\/+$/, '')
: 'https://adm.montzar.ir'
return `${base}/${path.replace(/^\/+/, '')}`
}
-44
View File
@@ -1,44 +0,0 @@
import { getConfig } from '@/config/app-config'
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
const DEFAULT_ICON = '/favicon.png'
const DEFAULT_LOGO = '/logo.png'
const DEFAULT_LOGO_FULL = '/logo_full_.png'
export function resolveConfigAssetUrl(path?: string | null): string {
if (!path) return ''
if (/^(https?:\/\/|data:|blob:)/i.test(path)) return path
const lastSuccess = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
const base = lastSuccess
? lastSuccess.replace(/\/get_user\/?$/i, '').replace(/\/+$/, '')
: 'https://nerkh.gold'
return `${base}/${path.replace(/^\/+/, '')}`
}
function pickConfigAsset(...keys: string[]): string {
for (const key of keys) {
const value = getConfig<string>(key)
if (value) return resolveConfigAssetUrl(value)
}
return ''
}
/** آیکون کوچک اپ — معمولاً فیلد avatar در get_user */
export function getAppIconUrl(fallback = DEFAULT_ICON): string {
return pickConfigAsset('avatar', 'icon') || fallback
}
/** لوگوی فشرده — برای هدر و FAB */
export function getAppLogoUrl(fallback = DEFAULT_LOGO): string {
return pickConfigAsset('logo', 'avatar') || fallback
}
/** لوگوی کامل — برای اسپلش، لاگین و سایدبار */
export function getAppLogoFullUrl(fallback = DEFAULT_LOGO_FULL): string {
return pickConfigAsset('logo_full', 'logo', 'avatar') || fallback
}
-133
View File
@@ -1,133 +0,0 @@
import { getConfig } from '@/config/app-config'
import { colors } from '@/themeConfig'
export const DEFAULT_PRIMARY_COLOR = '#a72b77'
export const DEFAULT_SECONDARY_COLOR = '#a72b77'
export const DEFAULT_TERTIARY_COLOR = '#e0be7f'
export const DEFAULT_BACKGROUND_COLOR = '#ffffff'
function normalizeHex(value: unknown): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
if (!trimmed) return null
const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}`
if (!/^#[0-9a-fA-F]{6}$/.test(withHash) && !/^#[0-9a-fA-F]{3}$/.test(withHash)) {
return null
}
if (withHash.length === 4) {
const [, r, g, b] = withHash
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase()
}
return withHash.toLowerCase()
}
function lightenHex(hex: string, amount = 0.22): string {
const normalized = normalizeHex(hex)
if (!normalized) return hex
const num = parseInt(normalized.slice(1), 16)
const r = (num >> 16) & 255
const g = (num >> 8) & 255
const b = num & 255
const mix = (channel: number) => Math.round(channel + (255 - channel) * amount)
return `#${[mix(r), mix(g), mix(b)]
.map((channel) => channel.toString(16).padStart(2, '0'))
.join('')}`
}
function darkenHex(hex: string, amount = 0.22): string {
const normalized = normalizeHex(hex)
if (!normalized) return hex
const num = parseInt(normalized.slice(1), 16)
const r = Math.round(((num >> 16) & 255) * (1 - amount))
const g = Math.round(((num >> 8) & 255) * (1 - amount))
const b = Math.round((num & 255) * (1 - amount))
return `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, '0')).join('')}`
}
export function getAppPrimaryColor(fallback = DEFAULT_PRIMARY_COLOR): string {
return (
normalizeHex(getConfig('color')) ||
normalizeHex(getConfig('primary_color')) ||
normalizeHex(getConfig('primary')) ||
fallback
)
}
export function getAppSecondaryColor(fallback = DEFAULT_SECONDARY_COLOR): string {
return (
normalizeHex(getConfig('secondary_color')) ||
normalizeHex(getConfig('secondary')) ||
getAppPrimaryColor(fallback)
)
}
export function getAppTertiaryColor(fallback = DEFAULT_TERTIARY_COLOR): string {
return (
normalizeHex(getConfig('tertiary_color')) ||
normalizeHex(getConfig('tertiary')) ||
fallback
)
}
export function getAppBackgroundColor(fallback = DEFAULT_BACKGROUND_COLOR): string {
return (
normalizeHex(getConfig('background_color')) ||
normalizeHex(getConfig('background')) ||
fallback
)
}
export function getAppPrimaryDark(fallback = '#711d4d'): string {
return darkenHex(getAppPrimaryColor(), 0.22) || fallback
}
export function getAppPrimaryLight(fallback = '#c45f9a'): string {
return lightenHex(getAppPrimaryColor(), 0.22) || fallback
}
function setCssVar(name: string, value: string) {
document.documentElement.style.setProperty(name, value)
}
export function applyBrandTheme() {
const primary = getAppPrimaryColor()
const secondary = getAppSecondaryColor()
const tertiary = getAppTertiaryColor()
const background = getAppBackgroundColor()
const primaryDark = getAppPrimaryDark()
const primaryLight = getAppPrimaryLight()
setCssVar('--app-primary-color', primary)
setCssVar('--app-secondary-color', secondary)
setCssVar('--app-tertiary-color', tertiary)
setCssVar('--app-background-color', background)
setCssVar('--app-primary-dark', primaryDark)
setCssVar('--app-primary-light', primaryLight)
colors.primary = primary
colors['primary-rgba'] = primary
colors.secondary = secondary
colors['secondary-rgba'] = secondary
colors.tertiary = tertiary
colors['tertiary-rgba'] = tertiary
const themeMeta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
if (themeMeta) {
themeMeta.content = primary
} else {
const meta = document.createElement('meta')
meta.name = 'theme-color'
meta.content = primary
document.head.appendChild(meta)
}
}
+41
View File
@@ -1,4 +1,5 @@
import type { AppConfig } from '@/config/types'
import { rewriteLegacyUploadUrl } from '@/config/rewrite-legacy-upload-url'
import { resetVersionCheckCache } from '@/config/load-version'
import { clearCachedPwaManifest } from '@/modules/pwa/manifest-cache'
import { tokenName } from '@/themeConfig'
@@ -62,8 +63,28 @@ export function readCachedAppConfig(): AppConfig | null {
}
}
function persistBootLogo(config: AppConfig) {
try {
const nested = (config as {user?: Record<string, unknown>})?.user
const avatar = rewriteLegacyUploadUrl(String(nested?.avatar || '').trim())
const version = String(localStorage.getItem(VERSION_STORAGE_KEY) || '').trim()
const cachedUrl = localStorage.getItem('BOOT_LOGO_URL') || ''
const cachedVer = localStorage.getItem('BOOT_LOGO_VERSION') || ''
const cacheFresh = !!(cachedUrl && version && cachedVer && version === cachedVer)
if (avatar && (!cacheFresh || cachedUrl !== avatar)) localStorage.setItem('BOOT_LOGO_URL', avatar)
if (version) localStorage.setItem('BOOT_LOGO_VERSION', version)
const apply = (window as Window & {__APPLY_BOOT_BRANDING__?: (c: unknown) => void})
.__APPLY_BOOT_BRANDING__
if (typeof apply === 'function') apply(config)
} catch {
/* ignore */
}
}
export function writeCachedAppConfig(config: AppConfig) {
try {
persistBootLogo(config)
const payload: CachedConfigPayload = {
cached_at: Date.now(),
config
@@ -206,12 +227,22 @@ export async function clearBrowserRuntimeCaches() {
function clearLocalStorageExceptToken() {
const token = localStorage.getItem(tokenName)
let bootLogo = ''
let bootLogoVer = ''
try {
bootLogo = localStorage.getItem('BOOT_LOGO_URL') || ''
bootLogoVer = localStorage.getItem('BOOT_LOGO_VERSION') || ''
} catch {
/* ignore */
}
localStorage.clear()
if (token) {
localStorage.setItem(tokenName, token)
}
if (bootLogo) localStorage.setItem('BOOT_LOGO_URL', bootLogo)
if (bootLogoVer) localStorage.setItem('BOOT_LOGO_VERSION', bootLogoVer)
}
export function clearAppStorageExceptToken() {
@@ -254,6 +285,11 @@ export async function refreshConfigCacheForVersion(
writeCachedAppConfig(freshConfig)
finishVersionUpdateBeforeReload(normalizedVersion)
try {
localStorage.setItem('BOOT_LOGO_VERSION', normalizedVersion)
} catch {
/* ignore */
}
return freshConfig
}
@@ -275,6 +311,11 @@ export async function resetAppAndRefreshConfig(
if (normalizedVersion) {
writeStoredAppVersion(normalizedVersion)
try {
localStorage.setItem('BOOT_LOGO_VERSION', normalizedVersion)
} catch {
/* ignore */
}
}
return freshConfig
+65 -28
View File
@@ -1,7 +1,17 @@
import type { AppConfig } from '@/config/types'
import { normalizeAppConfig } from '@/config/app-config'
import { readCachedAppConfig, writeCachedAppConfig } from '@/config/config-cache'
import {
allowSuperAdminFallback,
getSuperAdminBase,
getSuperAdminBases,
isSuperAdminUrl,
resolveTenantDomain,
SA_RETRY_ROUNDS,
} from '@/config/superadmin'
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
export const CONFIG_RETRY_ROUNDS = SA_RETRY_ROUNDS
function getProtocol() {
return window.location.protocol === 'https:' ? 'https:' : 'http:'
@@ -9,24 +19,40 @@ function getProtocol() {
export function getConfigUrls() {
const protocol = getProtocol()
const domain = encodeURIComponent(resolveTenantDomain())
const urls = getSuperAdminBases().map((base) => `${base}/get_user?domain=${domain}`)
return [
`${protocol}//nerkh.gold/get_user`,
`${protocol}//tablo.talasevin.ir/get_user`,
`${protocol}//abshode-service.montgit.ir/server/get_user`,
`${protocol}//212.23.201.173:8381/get_user`,
`${protocol}//91.212.174.137:4040/server/get_user`
]
if (allowSuperAdminFallback()) {
urls.push(
`${protocol}//tablo.talasevin.ir/get_user`,
`${protocol}//abshode-service.montgit.ir/server/get_user`,
`${protocol}//212.23.201.173:8381/get_user`,
`${protocol}//91.212.174.137:4040/server/get_user`,
)
}
return urls
}
export function sortConfigUrls(urls: string[]) {
const lastSuccessUrl = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
if (
!lastSuccessUrl ||
!urls.includes(lastSuccessUrl) ||
!isSuperAdminUrl(lastSuccessUrl)
) {
if (lastSuccessUrl && !isSuperAdminUrl(lastSuccessUrl)) {
localStorage.removeItem(CONFIG_SUCCESS_URL_KEY)
}
return [...urls]
}
return [lastSuccessUrl, ...urls.filter((url) => url !== lastSuccessUrl)]
}
return [...urls].sort((a, b) => {
if (a === lastSuccessUrl) return -1
if (b === lastSuccessUrl) return 1
return 0
})
function clearStaleConfigSuccessUrl(url: string) {
if (localStorage.getItem(CONFIG_SUCCESS_URL_KEY) === url) {
localStorage.removeItem(CONFIG_SUCCESS_URL_KEY)
}
}
async function fetchConfig(url: string, timeout = 3000): Promise<AppConfig> {
@@ -37,10 +63,10 @@ async function fetchConfig(url: string, timeout = 3000): Promise<AppConfig> {
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json'
Accept: 'application/json',
},
cache: 'no-store',
signal: controller.signal
signal: controller.signal,
})
if (!response.ok) {
@@ -53,39 +79,50 @@ async function fetchConfig(url: string, timeout = 3000): Promise<AppConfig> {
throw new Error('Config response is not JSON')
}
return await response.json()
return normalizeAppConfig(await response.json())
} finally {
clearTimeout(timeoutId)
}
}
export async function fetchFreshAppConfig(): Promise<AppConfig> {
export async function fetchFreshAppConfig(rounds = CONFIG_RETRY_ROUNDS): Promise<AppConfig> {
const urls = sortConfigUrls(getConfigUrls())
for (let index = 0; index < urls.length; index++) {
const url = urls[index]
for (let round = 1; round <= rounds; round++) {
for (let index = 0; index < urls.length; index++) {
const url = urls[index]
try {
const timeout = index === urls.length - 1 ? 5000 : 2500
const config = await fetchConfig(url, timeout)
try {
const timeout = index === urls.length - 1 ? 7000 : 4000
const config = await fetchConfig(url, timeout)
localStorage.setItem(CONFIG_SUCCESS_URL_KEY, url)
writeCachedAppConfig(config)
if (isSuperAdminUrl(url)) {
localStorage.setItem(CONFIG_SUCCESS_URL_KEY, url)
} else {
clearStaleConfigSuccessUrl(url)
}
writeCachedAppConfig(config)
return config
} catch (error) {
console.warn(`Config failed from ${url}`, error)
return config
} catch (error) {
clearStaleConfigSuccessUrl(url)
console.warn(`Config failed from ${url}. Round: ${round}`, error)
}
}
}
throw new Error('Cannot load app config from any server')
throw new Error(
getSuperAdminBase()
? `Cannot load from SuperAdmin (${getSuperAdminBase()}).`
: 'Cannot load app config from any server',
)
}
export async function loadAppConfig(): Promise<AppConfig> {
const cachedConfig = readCachedAppConfig()
if (cachedConfig) {
return cachedConfig
return normalizeAppConfig(cachedConfig)
}
return await fetchFreshAppConfig()
+65 -43
View File
@@ -1,3 +1,11 @@
import {
allowSuperAdminFallback,
getSuperAdminBases,
isSuperAdminUrl,
resolveTenantDomain,
SA_RETRY_ROUNDS,
} from '@/config/superadmin'
type VersionResponse =
| string
| number
@@ -9,28 +17,50 @@ type VersionResponse =
}
const VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
const VERSION_RETRY_ROUNDS = 5
export const VERSION_RETRY_ROUNDS = SA_RETRY_ROUNDS
let versionFetchPromise: Promise<string | null> | null = null
let cachedVersionResult: string | null | undefined
function getProtocol() {
return window.location.protocol === 'https:' ? 'https:' : 'http:'
}
function getVersionUrls() {
return [
'https://nerkh.gold/get_version',
'https://tablo.talasevin.ir/get_version',
'https://abshode-service.montgit.ir/server/get_version'
]
const protocol = getProtocol()
const domain = encodeURIComponent(resolveTenantDomain())
const urls = getSuperAdminBases().map((base) => `${base}/get_version?domain=${domain}`)
if (allowSuperAdminFallback()) {
urls.push(
'https://tablo.talasevin.ir/get_version',
'https://abshode-service.montgit.ir/server/get_version',
`${protocol}//212.23.201.173:8381/get_version`,
`${protocol}//91.212.174.137:4040/server/get_version`,
)
}
return urls
}
function sortVersionUrls(urls: string[]) {
const lastSuccessUrl = localStorage.getItem(VERSION_SUCCESS_URL_KEY)
if (!lastSuccessUrl) {
return urls
if (
!lastSuccessUrl ||
!urls.includes(lastSuccessUrl) ||
!isSuperAdminUrl(lastSuccessUrl)
) {
if (lastSuccessUrl && !isSuperAdminUrl(lastSuccessUrl)) {
localStorage.removeItem(VERSION_SUCCESS_URL_KEY)
}
return [...urls]
}
return [lastSuccessUrl, ...urls.filter((url) => url !== lastSuccessUrl)]
}
const others = urls.filter((url) => url !== lastSuccessUrl)
return [lastSuccessUrl, ...others]
function clearStaleVersionSuccessUrl(url: string) {
if (localStorage.getItem(VERSION_SUCCESS_URL_KEY) === url) {
localStorage.removeItem(VERSION_SUCCESS_URL_KEY)
}
}
function readUserVersion(user: unknown) {
@@ -75,32 +105,33 @@ async function fetchVersion(url: string, timeout = 3500): Promise<string | null>
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json'
Accept: 'application/json',
},
cache: 'no-store',
signal: controller.signal
signal: controller.signal,
})
if (!response.ok) {
// Non-2xx/timeout should be treated as "request failed" and we may try the next URL.
throw new Error(`Version request failed with status ${response.status}`)
}
const contentType = response.headers.get('content-type') ?? ''
const data = contentType.includes('application/json')
? ((await response.json()) as VersionResponse)
: ((await response.text()) as VersionResponse)
const version = normalizeVersionResponse(data)
// IMPORTANT: if the request returned 200/ok, we stop here even if we couldn't extract a version.
// This prevents unnecessary looping when the endpoint is "reachable" but returns an empty payload.
if (!version) {
return null
if (!contentType.includes('application/json')) {
throw new Error('Version response is not JSON')
}
localStorage.setItem(VERSION_SUCCESS_URL_KEY, url)
const data = (await response.json()) as VersionResponse
const version = normalizeVersionResponse(data)
if (!version) {
throw new Error('Version response is empty')
}
if (isSuperAdminUrl(url)) {
localStorage.setItem(VERSION_SUCCESS_URL_KEY, url)
} else {
clearStaleVersionSuccessUrl(url)
}
return version
} finally {
@@ -118,10 +149,11 @@ async function fetchVersionWithRetry(rounds = VERSION_RETRY_ROUNDS): Promise<str
try {
const version = await fetchVersion(url, timeout)
// fetchVersion only throws on real request failures (non-ok / timeout).
// If it returns null, we got HTTP 200 but empty version => stop immediately.
return version
if (version) {
return version
}
} catch (error) {
clearStaleVersionSuccessUrl(url)
console.warn(`Version failed from ${url}. Round: ${round}`, error)
}
}
@@ -131,7 +163,6 @@ async function fetchVersionWithRetry(rounds = VERSION_RETRY_ROUNDS): Promise<str
}
export function resetVersionCheckCache() {
cachedVersionResult = undefined
versionFetchPromise = null
}
@@ -146,22 +177,13 @@ export async function loadRemoteAppVersion(options?: {
resetVersionCheckCache()
}
if (cachedVersionResult !== undefined) {
return cachedVersionResult
}
if (versionFetchPromise) {
return versionFetchPromise
}
versionFetchPromise = fetchVersionWithRetry(rounds)
.then((version) => {
cachedVersionResult = version
return version
})
.finally(() => {
versionFetchPromise = null
})
versionFetchPromise = fetchVersionWithRetry(rounds).finally(() => {
versionFetchPromise = null
})
return versionFetchPromise
}

Some files were not shown because too many files have changed in this diff Show More