Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f7a98e064 | ||
|
|
db29b7d0d3 | ||
|
|
6e898323d4 | ||
|
|
3e4c0e018e |
@@ -1 +0,0 @@
|
||||
graphify-out/graph.json merge=graphify
|
||||
@@ -7,17 +7,14 @@ export {}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ABadge: typeof import('ant-design-vue/es')['Badge']
|
||||
AButton: typeof import('ant-design-vue/es')['Button']
|
||||
AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
|
||||
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']
|
||||
@@ -52,18 +49,12 @@ 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']
|
||||
@@ -71,13 +62,9 @@ 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']
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"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 +0,0 @@
|
||||
{"0": "d7734412cf0800ce", "1": "67d1701e4c6cea74", "2": "a1988eda2ebb6afe", "3": "18bc618fd6f328bb", "4": "b16e1aea09ac331f", "5": "8d51306eb7d0daf2", "6": "fe2bcdb916bbc9f9", "7": "ac69b3434e72782c", "8": "004ed482633014da", "9": "08b88f1fc8c91c56"}
|
||||
@@ -1 +0,0 @@
|
||||
.
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"0": "useTvMonitorPage",
|
||||
"1": "useTvMonitorPage.ts",
|
||||
"2": "index.vue",
|
||||
"3": "productPrice.ts",
|
||||
"4": "getProductBaseBuyPrice",
|
||||
"5": "ref_models",
|
||||
"6": "isTvProductPriceInactive",
|
||||
"7": "applyPriceOffsetPercent"
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
# 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._
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
# 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._
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"0": "useTvMonitorPage",
|
||||
"1": "ref_utils",
|
||||
"2": "index.vue",
|
||||
"3": "productPrice.ts",
|
||||
"5": "api/products.ts",
|
||||
"6": "priceSocket.ts",
|
||||
"8": "useProductsPageData.ts",
|
||||
"9": "AuthPriceStream"
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
# 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._
|
||||
@@ -1,110 +0,0 @@
|
||||
# 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._
|
||||
@@ -1,870 +1,49 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<title>Ghatreh</title>
|
||||
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
|
||||
/>
|
||||
|
||||
<title>در حال بارگذاری...</title>
|
||||
<!---->
|
||||
<style>
|
||||
:root {
|
||||
--boot-bg: #ffffff;
|
||||
--boot-bg-soft: #f8fafc;
|
||||
--boot-card: rgba(255, 255, 255, 0.88);
|
||||
--boot-border: rgba(15, 23, 42, 0.08);
|
||||
--boot-text: #111827;
|
||||
--boot-muted: rgba(15, 23, 42, 0.62);
|
||||
--boot-accent: #2563eb;
|
||||
--boot-orb: rgba(37, 99, 235, 0.10);
|
||||
--boot-spinner-track: rgba(15, 23, 42, 0.10);
|
||||
--boot-shadow: rgba(15, 23, 42, 0.10);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--boot-bg);
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.boot-loader {
|
||||
#whatsapp-button {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483646;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
min-height: 100dvh;
|
||||
bottom: 90px;
|
||||
right: 20px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
direction: rtl;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
background:
|
||||
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;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.boot-loader__logo {
|
||||
height: min(20vh, 160px);
|
||||
width: auto;
|
||||
max-width: min(70vw, 280px);
|
||||
object-fit: contain;
|
||||
opacity: 0;
|
||||
transition: opacity 0.28s ease;
|
||||
#whatsapp-button:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 14px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.boot-loader__logo.is-ready {
|
||||
#whatsapp-button:hover + #whatsapp-tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.boot-loader__spinner {
|
||||
position: absolute;
|
||||
bottom: calc(1.5rem + env(safe-area-inset-bottom, 0px));
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
border: 3px solid var(--boot-spinner-track);
|
||||
border-top-color: var(--boot-accent);
|
||||
animation: boot-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes boot-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
;(function () {
|
||||
var CONFIG_COOKIE_NAME = 'APP_CONFIG_CACHE'
|
||||
|
||||
function getCookie(name) {
|
||||
var cookie = document.cookie.split('; ').find(function (row) {
|
||||
return row.indexOf(name + '=') === 0
|
||||
})
|
||||
|
||||
if (!cookie) return null
|
||||
|
||||
return cookie.substring(name.length + 1)
|
||||
}
|
||||
|
||||
function readCachedConfig() {
|
||||
try {
|
||||
var rawCookie = getCookie(CONFIG_COOKIE_NAME)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!hex || typeof hex !== 'string') return null
|
||||
|
||||
var value = hex.trim()
|
||||
|
||||
if (!value) return null
|
||||
|
||||
if (value[0] !== '#') {
|
||||
value = '#' + value
|
||||
}
|
||||
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(value)) {
|
||||
value =
|
||||
'#' +
|
||||
value[1] +
|
||||
value[1] +
|
||||
value[2] +
|
||||
value[2] +
|
||||
value[3] +
|
||||
value[3]
|
||||
}
|
||||
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(value)) return null
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
var normalized = normalizeHex(hex)
|
||||
|
||||
if (!normalized) return null
|
||||
|
||||
var num = parseInt(normalized.slice(1), 16)
|
||||
|
||||
return {
|
||||
r: (num >> 16) & 255,
|
||||
g: (num >> 8) & 255,
|
||||
b: num & 255
|
||||
}
|
||||
}
|
||||
|
||||
function rgbToHex(r, g, b) {
|
||||
function toHex(value) {
|
||||
var hex = Math.max(0, Math.min(255, Math.round(value))).toString(16)
|
||||
return hex.length === 1 ? '0' + hex : hex
|
||||
}
|
||||
|
||||
return '#' + toHex(r) + toHex(g) + toHex(b)
|
||||
}
|
||||
|
||||
function mix(hexA, hexB, weight) {
|
||||
var a = hexToRgb(hexA)
|
||||
var b = hexToRgb(hexB)
|
||||
|
||||
if (!a || !b) return hexA
|
||||
|
||||
return rgbToHex(
|
||||
a.r * (1 - weight) + b.r * weight,
|
||||
a.g * (1 - weight) + b.g * weight,
|
||||
a.b * (1 - weight) + b.b * weight
|
||||
)
|
||||
}
|
||||
|
||||
function brightness(hex) {
|
||||
var rgb = hexToRgb(hex)
|
||||
|
||||
if (!rgb) return 0
|
||||
|
||||
return (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000
|
||||
}
|
||||
|
||||
function hexToRgba(hex, alpha) {
|
||||
var rgb = hexToRgb(hex)
|
||||
|
||||
if (!rgb) return 'rgba(96, 165, 250, ' + alpha + ')'
|
||||
|
||||
return 'rgba(' + rgb.r + ', ' + rgb.g + ', ' + rgb.b + ', ' + alpha + ')'
|
||||
}
|
||||
|
||||
function setVar(name, value) {
|
||||
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
|
||||
|
||||
if (!user) return
|
||||
|
||||
var tenantColor = normalizeHex(user.color)
|
||||
var tenantSecondary = normalizeHex(user.secondary_color)
|
||||
var tenantBackground = normalizeHex(user.background_color)
|
||||
|
||||
/**
|
||||
* مهم:
|
||||
* background_color بکگراند اپ است.
|
||||
* اگر نبود، لودینگ باید روشن بماند، نه اینکه color را بکگراند کنیم.
|
||||
*/
|
||||
var bg = tenantBackground || '#ffffff'
|
||||
|
||||
/**
|
||||
* color و secondary_color فقط برای accent استفاده میشوند.
|
||||
*/
|
||||
var accent = tenantSecondary || tenantColor || '#2563eb'
|
||||
|
||||
var isLight = brightness(bg) > 155
|
||||
|
||||
var bgSoft = isLight ? mix(bg, accent, 0.045) : mix(bg, '#ffffff', 0.055)
|
||||
|
||||
var text = isLight ? '#111827' : '#ffffff'
|
||||
var muted = isLight ? 'rgba(15, 23, 42, 0.62)' : 'rgba(255, 255, 255, 0.70)'
|
||||
|
||||
var card = isLight ? 'rgba(255, 255, 255, 0.88)' : 'rgba(255, 255, 255, 0.07)'
|
||||
var border = isLight ? 'rgba(15, 23, 42, 0.08)' : 'rgba(255, 255, 255, 0.12)'
|
||||
|
||||
var orb = hexToRgba(accent, isLight ? 0.10 : 0.18)
|
||||
var spinnerTrack = isLight ? 'rgba(15, 23, 42, 0.10)' : 'rgba(255, 255, 255, 0.14)'
|
||||
var shadow = isLight ? 'rgba(15, 23, 42, 0.10)' : 'rgba(0, 0, 0, 0.24)'
|
||||
|
||||
setVar('--boot-bg', bg)
|
||||
setVar('--boot-bg-soft', bgSoft)
|
||||
setVar('--boot-accent', accent)
|
||||
setVar('--boot-text', text)
|
||||
setVar('--boot-muted', muted)
|
||||
setVar('--boot-card', card)
|
||||
setVar('--boot-border', border)
|
||||
setVar('--boot-orb', orb)
|
||||
setVar('--boot-spinner-track', spinnerTrack)
|
||||
setVar('--boot-shadow', shadow)
|
||||
|
||||
document.title = user.name_fa || user.name || 'در حال بارگذاری...'
|
||||
}
|
||||
|
||||
applyBootTheme()
|
||||
applyBootLogo(readCachedConfig())
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="boot-loader">
|
||||
<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 () {
|
||||
var BOOT_TIMEOUT_MS = 90000
|
||||
|
||||
var RETRY_KEY = '__APP_BOOT_RETRY_CLICKED__'
|
||||
var RECOVERY_KEY = '__APP_BOOT_RECOVERY_DONE__'
|
||||
|
||||
var CONFIG_COOKIE_NAME = 'APP_CONFIG_CACHE'
|
||||
var CONFIG_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7
|
||||
|
||||
var TOKEN_STORAGE_KEY = 'liman_user_token'
|
||||
var VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
|
||||
var VERSION_STORAGE_KEY = 'version'
|
||||
var CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
|
||||
|
||||
function getProtocol() {
|
||||
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() {
|
||||
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 domain = encodeURIComponent(resolveTenantDomain())
|
||||
var urls = getSuperAdminBases().map(function (base) {
|
||||
return base + '/get_user?domain=' + domain
|
||||
})
|
||||
|
||||
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) {
|
||||
var lastSuccessUrl = localStorage.getItem(VERSION_SUCCESS_URL_KEY)
|
||||
|
||||
if (!lastSuccessUrl) {
|
||||
return urls.slice()
|
||||
}
|
||||
|
||||
return [lastSuccessUrl].concat(urls.filter(function (url) {
|
||||
return url !== lastSuccessUrl
|
||||
}))
|
||||
}
|
||||
|
||||
function sortByLastSuccess(urls) {
|
||||
var lastSuccessUrl = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
|
||||
|
||||
return urls.slice().sort(function (a, b) {
|
||||
if (a === lastSuccessUrl) return -1
|
||||
if (b === lastSuccessUrl) return 1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeVersionResponse(data) {
|
||||
if (typeof data === 'string' || typeof data === 'number') {
|
||||
return String(data).trim()
|
||||
}
|
||||
|
||||
if (!data) return ''
|
||||
|
||||
if (typeof data.data === 'string' || typeof data.data === 'number') {
|
||||
return String(data.data).trim()
|
||||
}
|
||||
|
||||
var version =
|
||||
data.version ||
|
||||
data.value ||
|
||||
(data.data && typeof data.data === 'object' ? data.data.version : '')
|
||||
|
||||
return version ? String(version).trim() : ''
|
||||
}
|
||||
|
||||
async function fetchJsonOrText(url, timeout) {
|
||||
var controller = new AbortController()
|
||||
|
||||
var timeoutId = window.setTimeout(function () {
|
||||
controller.abort()
|
||||
}, timeout)
|
||||
|
||||
try {
|
||||
var response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
cache: 'no-store',
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Request failed: ' + response.status)
|
||||
}
|
||||
|
||||
var contentType = response.headers.get('content-type') || ''
|
||||
|
||||
if (contentType.indexOf('application/json') !== -1) {
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
return await response.text()
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVersionFromUrl(url, timeout) {
|
||||
var controller = new AbortController()
|
||||
var timeoutId = window.setTimeout(function () {
|
||||
controller.abort()
|
||||
}, timeout)
|
||||
|
||||
try {
|
||||
var response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
cache: 'no-store',
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Version request failed: ' + response.status)
|
||||
}
|
||||
|
||||
var contentType = response.headers.get('content-type') || ''
|
||||
var data
|
||||
|
||||
if (contentType.indexOf('application/json') !== -1) {
|
||||
data = await response.json()
|
||||
} else {
|
||||
data = await response.text()
|
||||
}
|
||||
|
||||
var version = normalizeVersionResponse(data)
|
||||
|
||||
if (!version) {
|
||||
throw new Error('Version response is empty')
|
||||
}
|
||||
|
||||
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 {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRemoteVersion(rounds) {
|
||||
var urls = sortVersionUrls(getVersionUrls())
|
||||
|
||||
for (var round = 1; round <= rounds; round++) {
|
||||
for (var i = 0; i < urls.length; i++) {
|
||||
var timeout = i === urls.length - 1 ? 6000 : 3500
|
||||
var result = await fetchVersionFromUrl(urls[i], timeout)
|
||||
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function fetchFreshConfig(rounds) {
|
||||
var urls = sortByLastSuccess(getConfigUrls())
|
||||
|
||||
for (var round = 1; round <= rounds; round++) {
|
||||
for (var i = 0; i < urls.length; i++) {
|
||||
var url = urls[i]
|
||||
|
||||
try {
|
||||
var config = await fetchJsonOrText(url, i === urls.length - 1 ? 7000 : 4000)
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new Error('Config response is invalid')
|
||||
}
|
||||
|
||||
return {
|
||||
config: config,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Cannot fetch fresh config')
|
||||
}
|
||||
|
||||
function writeConfigCookie(config) {
|
||||
try {
|
||||
var payload = {
|
||||
cached_at: Date.now(),
|
||||
config: config
|
||||
}
|
||||
|
||||
var encoded = encodeURIComponent(JSON.stringify(payload))
|
||||
var secure = window.location.protocol === 'https:' ? '; Secure' : ''
|
||||
|
||||
document.cookie =
|
||||
CONFIG_COOKIE_NAME +
|
||||
'=' +
|
||||
encoded +
|
||||
'; Path=/; Max-Age=' +
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
function clearReadableCookies() {
|
||||
document.cookie.split(';').forEach(function (cookie) {
|
||||
var name = cookie.split('=')[0] && cookie.split('=')[0].trim()
|
||||
|
||||
if (!name) return
|
||||
|
||||
document.cookie = name + '=; Path=/; Max-Age=0; SameSite=Lax'
|
||||
})
|
||||
}
|
||||
|
||||
function clearLocalStorageExceptToken() {
|
||||
var token = localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
|
||||
localStorage.clear()
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||
}
|
||||
}
|
||||
|
||||
async function clearServiceWorkersAndCaches() {
|
||||
try {
|
||||
if ('serviceWorker' in navigator) {
|
||||
var registrations = await navigator.serviceWorker.getRegistrations()
|
||||
|
||||
await Promise.all(
|
||||
registrations.map(function (registration) {
|
||||
return registration.unregister()
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to unregister service workers:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
if ('caches' in window) {
|
||||
var keys = await caches.keys()
|
||||
|
||||
await Promise.all(
|
||||
keys.map(function (key) {
|
||||
return caches.delete(key)
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear caches:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function reloadWithCacheBust() {
|
||||
var url = new URL(window.location.href)
|
||||
url.searchParams.set('__boot_recover', String(Date.now()))
|
||||
window.location.replace(url.toString())
|
||||
}
|
||||
|
||||
function renderRecovering(text) {
|
||||
var app = document.getElementById('app')
|
||||
if (!app) return
|
||||
|
||||
app.innerHTML =
|
||||
'<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; direction: rtl; font-family: sans-serif; padding: 24px; background: var(--boot-bg); color: var(--boot-text);">' +
|
||||
'<div style="width: min(420px, 100%); text-align: center; background: var(--boot-card); border: 1px solid var(--boot-border); border-radius: 24px; padding: 28px 22px;">' +
|
||||
'<div style="width: 54px; height: 54px; margin: 0 auto 18px; border-radius: 999px; border: 4px solid rgba(255,255,255,0.18); border-top-color: var(--boot-accent); animation: bootSpin 0.9s linear infinite;"></div>' +
|
||||
'<style>@keyframes bootSpin { to { transform: rotate(360deg); } }</style>' +
|
||||
'<h2 style="margin: 0 0 12px; font-size: 20px;">در حال بازیابی برنامه</h2>' +
|
||||
'<p style="margin: 0; color: var(--boot-muted); line-height: 2; font-size: 14px;">' +
|
||||
(text || 'لطفاً چند لحظه صبر کنید.') +
|
||||
'</p>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
}
|
||||
|
||||
function renderBootError() {
|
||||
var app = document.getElementById('app')
|
||||
if (!app) return
|
||||
|
||||
app.innerHTML =
|
||||
'<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; direction: rtl; font-family: sans-serif; padding: 24px; background: var(--boot-bg); color: var(--boot-text);">' +
|
||||
'<div style="width: min(420px, 100%); text-align: center; background: var(--boot-card); border: 1px solid var(--boot-border); border-radius: 24px; padding: 28px 22px;">' +
|
||||
'<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;">' +
|
||||
'پاکسازی و تلاش مجدد' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
}
|
||||
|
||||
async function cleanFetchAndReload() {
|
||||
renderRecovering('در حال دریافت تنظیمات جدید برنامه...')
|
||||
|
||||
var version = await loadRemoteVersion(3)
|
||||
var freshConfigResult = await fetchFreshConfig(3)
|
||||
|
||||
clearReadableCookies()
|
||||
// clearLocalStorageExceptToken()
|
||||
await clearServiceWorkersAndCaches()
|
||||
|
||||
writeConfigCookie(freshConfigResult.config)
|
||||
localStorage.setItem(CONFIG_SUCCESS_URL_KEY, freshConfigResult.url)
|
||||
|
||||
if (version) {
|
||||
localStorage.setItem(VERSION_STORAGE_KEY, version)
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(RETRY_KEY)
|
||||
sessionStorage.removeItem(RECOVERY_KEY)
|
||||
|
||||
reloadWithCacheBust()
|
||||
}
|
||||
|
||||
window.__APP_BOOT_SIMPLE_RETRY__ = function () {
|
||||
sessionStorage.setItem(RETRY_KEY, '1')
|
||||
reloadWithCacheBust()
|
||||
}
|
||||
|
||||
window.__APP_BOOT_CLEAN_RELOAD__ = async function () {
|
||||
try {
|
||||
sessionStorage.setItem(RECOVERY_KEY, '1')
|
||||
await cleanFetchAndReload()
|
||||
} catch (error) {
|
||||
console.error('Manual boot recovery failed:', error)
|
||||
renderBootError()
|
||||
}
|
||||
}
|
||||
|
||||
window.__BOOT_TIMEOUT__ = window.setTimeout(async function () {
|
||||
if (window.__APP_BOOTSTRAPPED__) return
|
||||
|
||||
var app = document.getElementById('app')
|
||||
var stillBooting = app && app.querySelector('.boot-loader')
|
||||
|
||||
if (!stillBooting) return
|
||||
|
||||
if (!sessionStorage.getItem(RETRY_KEY)) {
|
||||
renderBootError()
|
||||
return
|
||||
}
|
||||
|
||||
if (!sessionStorage.getItem(RECOVERY_KEY)) {
|
||||
sessionStorage.setItem(RECOVERY_KEY, '1')
|
||||
|
||||
try {
|
||||
await cleanFetchAndReload()
|
||||
} catch (error) {
|
||||
console.error('Auto boot recovery after retry failed:', error)
|
||||
renderBootError()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
renderBootError()
|
||||
}, BOOT_TIMEOUT_MS)
|
||||
})()
|
||||
</script>
|
||||
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<a href="https://web.rubika.ir/#c=u0I8F1U0a939b0df5ba9f73618d33d7c" target="_blank" id="whatsapp-button">
|
||||
<img class="p-2" src="/src/assets/img/rubika.webp" alt="" />
|
||||
</a>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"axios": "^1.6.2",
|
||||
"dayjalali": "^1.0.1",
|
||||
"echarts": "^5.4.3",
|
||||
"express": "^5.2.1",
|
||||
"jalaliday": "^2.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
@@ -39,8 +38,7 @@
|
||||
"vue3-apexcharts": "^1.4.4",
|
||||
"vue3-lazyload": "^0.3.8",
|
||||
"vue3-odometer": "^0.1.3",
|
||||
"vue3-otp-input": "^0.4.1",
|
||||
"wavesurfer.js": "^7.12.6"
|
||||
"vue3-otp-input": "^0.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.3.2",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "ir.ghatreh.app",
|
||||
"sha256_cert_fingerprints": ["60:54:C6:C1:22:6C:2E:EA:A1:05:02:B1:17:F5:7D:49:1A:C0:84:FF:4C:ED:FD:42:D9:7F:DF:B2:70:37:8E:03"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 284 KiB |
@@ -1 +0,0 @@
|
||||
{"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":[]}
|
||||
|
After Width: | Height: | Size: 30 KiB |
@@ -1,7 +1,15 @@
|
||||
<template>
|
||||
<div class="desktopConfig w-full right-0 left-0">
|
||||
<div class="desktopConfig w-full right-0 left-0 absolute">
|
||||
<div v-if="popVisible" class="top-0 bottom-0 wrap-fix bg-primary z-100 opacity-60">
|
||||
<div class="absolute right-0 left-0 bottom-10vh flex flex-col items-center">
|
||||
<div><BasicButton>درباره ما</BasicButton></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
<transition mode="out-in" name="fade">
|
||||
<AConfigProvider
|
||||
v-if="!showSplash"
|
||||
:theme="{
|
||||
token: {
|
||||
colorPrimary: colors.primary,
|
||||
@@ -13,433 +21,125 @@
|
||||
direction="rtl"
|
||||
>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<!-- Use any custom transition and to `fade` -->
|
||||
<transition :name="route.meta.transition || 'fade'">
|
||||
<component :is="Component" :key="route.fullPath" class="app-main" />
|
||||
<div>
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
</transition>
|
||||
</router-view>
|
||||
</AConfigProvider>
|
||||
</transition>
|
||||
|
||||
<div
|
||||
v-if="showSupportButton && route.name !== 'support' && !hideAppChrome"
|
||||
id="support-button"
|
||||
ref="supportButtonEl"
|
||||
class="draggable-support-button"
|
||||
:class="{ 'is-dragging': supportDragging, 'is-picker-open': supportPickerOpen }"
|
||||
:style="supportButtonStyle"
|
||||
title="پشتیبانی"
|
||||
@pointerdown="onSupportPointerDown"
|
||||
@pointermove="onSupportPointerMove"
|
||||
@pointerup="handleSupportPointerUp"
|
||||
@pointercancel="onSupportPointerCancel"
|
||||
>
|
||||
<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 v-else class="flex bottom-0 wrap-fix top-0 bg-white z-1000000000000 justify-center">
|
||||
<div class="top-0 wrap-fix h-1/3 w-screen flex">
|
||||
<img :src="img_topGradiant" alt="" class="absolute z-10 w-screen h-full object-cover" />
|
||||
<img :src="img_pattern" alt="" class="absolute w-screen h-full object-none" />
|
||||
</div>
|
||||
<div class="wrap-fix bottom-0 h-1/3 w-screen">
|
||||
<img
|
||||
:src="img_bottomGradiant"
|
||||
alt=""
|
||||
class="absolute z-10 w-screen h-full object-cover"
|
||||
/>
|
||||
<img :src="img_pattern" alt="" class="absolute w-screen h-full object-none" />
|
||||
</div>
|
||||
<div class="self-center splash_appear_fade_in">
|
||||
<img :src="logoFull" class="h-[20vh]">
|
||||
<!-- <img :src="logo" alt="" class="z-100 bg-primary rounded-full w-full aspect-square self-center h-40" />-->
|
||||
</div>
|
||||
<div class="fixed w-full bottom-5 home_appear_fade_in_up">
|
||||
<div class="text-center text-xs">نسخه: {{ pkg.version }}</div>
|
||||
</div>
|
||||
<div class="absolute bottom-10">
|
||||
<a-spin :indicator="indicator" size="small" />
|
||||
</div>
|
||||
</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 }"
|
||||
</transition>
|
||||
<van-dialog
|
||||
v-model:show="versionDialog"
|
||||
class="updateButton"
|
||||
confirm-button-text="به روزرسانی کنید"
|
||||
show-confirm-button
|
||||
@confirm="update()"
|
||||
>
|
||||
<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 class="flex flex-col items-center p-2">
|
||||
<DotLottieVue
|
||||
class="w-full aspect-square"
|
||||
autoplay
|
||||
loop
|
||||
src="https://lottie.host/cb620e8e-c64a-42e8-a6b5-8844146e5999/nmft7oBO8q.json"
|
||||
/>
|
||||
<div class="home_appear_fade_in_up mt-2">نسخه جدید قطره منتشر شد!</div>
|
||||
</div>
|
||||
</van-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { colors } from './themeConfig'
|
||||
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 { useSupportMessenger } from '@/composables/useSupportMessenger'
|
||||
import { loadSupportMessengerFromSettings } from '@/utils/supportMessenger'
|
||||
import SupportMessengerPicker from '@/components/SupportMessengerPicker.vue'
|
||||
import { useDraggableFloatingButton } from '@/utils/useDraggableFloatingButton'
|
||||
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'
|
||||
import pkg from '../package.json'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/vue'
|
||||
import { h, onMounted, ref } from 'vue'
|
||||
import { update } from 'lodash'
|
||||
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'
|
||||
|
||||
const VersionUpdateModal = defineAsyncComponent(
|
||||
() => import('@/modules/version/VersionUpdateModal.vue')
|
||||
)
|
||||
import img_bottomGradiant from '@/views/login/assets/img/gr_bottom_white.svg'
|
||||
import logoFull from '@/assets/img/logo_full_.png'
|
||||
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const hideAppChrome = computed(() => route.meta.hideChrome === true || route.name === 'tvMonitor')
|
||||
const { offlineReady, needRefresh, updateServiceWorker } = useRegisterSW()
|
||||
|
||||
useApplyDesktopLayoutClass()
|
||||
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
const showDesktopFloatingCredit = computed(() => {
|
||||
if (!isDesktop.value) return false
|
||||
return route.name === 'login' || !!route.meta.hideMenu
|
||||
const indicator = h(LoadingOutlined, {
|
||||
style: {
|
||||
fontSize: '24px'
|
||||
},
|
||||
spin: true
|
||||
})
|
||||
const isLoginRoute = computed(() => route.name === 'login')
|
||||
const showSplash = ref(true)
|
||||
|
||||
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 supportIcon = computed(() => messengerMeta.value.icon)
|
||||
|
||||
const supportAccentColor = computed(() =>
|
||||
(messengerMeta.value.accent !== '#334155'
|
||||
? messengerMeta.value.accent
|
||||
: colors.primary) || '#334155',
|
||||
)
|
||||
|
||||
const {
|
||||
dragging: supportDragging,
|
||||
fabStyle: supportButtonStyle,
|
||||
handlePointerDown: onSupportPointerDown,
|
||||
handlePointerMove: onSupportPointerMove,
|
||||
handlePointerUp: supportPointerUp,
|
||||
handlePointerCancel: onSupportPointerCancel,
|
||||
} = useDraggableFloatingButton('ghatreh_support_button_position_v1', {
|
||||
width: 118,
|
||||
height: 42,
|
||||
defaultBottomGap: 12,
|
||||
defaultRightOffset: 16,
|
||||
onMounted(() => {
|
||||
checkVersion()
|
||||
setTimeout(() => {
|
||||
showSplash.value = false
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
function handleSupportPointerUp(event: PointerEvent) {
|
||||
const clicked = supportPointerUp(event)
|
||||
if (!clicked) return
|
||||
event.stopPropagation()
|
||||
void openSupportMessenger(supportButtonEl.value)
|
||||
const versionDialog = ref<boolean>(false)
|
||||
|
||||
function checkVersion() {
|
||||
const version = localStorage.getItem('version')
|
||||
// if (true) {
|
||||
if (!version){
|
||||
update()
|
||||
}
|
||||
if (version != pkg.version) {
|
||||
versionDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSupportMessengerFromSettings()
|
||||
})
|
||||
|
||||
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',
|
||||
})
|
||||
})
|
||||
function update() {
|
||||
localStorage.setItem('version', pkg.version)
|
||||
updateServiceWorker()
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
</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: @primary-color !important;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
#support-button {
|
||||
position: fixed;
|
||||
z-index: 5100;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
border-radius: 9999px;
|
||||
|
||||
&.is-dragging {
|
||||
cursor: grabbing;
|
||||
@media screen and (min-width: 426px) {
|
||||
.desktopConfig {
|
||||
width: 426px;
|
||||
position: relative !important;
|
||||
}
|
||||
}
|
||||
|
||||
.support-button-inner {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 14px 9px 12px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.14);
|
||||
color: #1e293b;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
direction: rtl;
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||
|
||||
.support-button-icon {
|
||||
color: var(--support-accent, #334155);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.support-button-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
#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>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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 +1,11 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import useAxios from '@/utils/useAxios'
|
||||
|
||||
export interface StoreCategory {
|
||||
id: number
|
||||
name: string
|
||||
sort_order?: number
|
||||
}
|
||||
|
||||
export const getStoreCategories = async (): Promise<StoreCategory[]> =>
|
||||
await useAxios.get<StoreCategory[]>('/customer/categories')
|
||||
@@ -1,6 +0,0 @@
|
||||
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`)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import useAxios from '@/utils/useAxios'
|
||||
|
||||
export async function getSettings() {
|
||||
return await useAxios.get(`api/get_settings`)
|
||||
}
|
||||
@@ -5,23 +5,13 @@ import type {Ref, UnwrapRef} from "vue";
|
||||
import type {Transaction} from "@/models/transaction";
|
||||
import {useUserStore} from "@/store/userStore";
|
||||
|
||||
function customerOrderPrefix() {
|
||||
// lazy: این ماژول ممکن است قبل از app.use(pinia) لود شود
|
||||
return `/customer${!useUserStore().isSingle ? '' : '/single'}`
|
||||
}
|
||||
const userStore = useUserStore()
|
||||
|
||||
export const createOrder = async (obj: Order): Promise<any> => {
|
||||
return await useAxios.post<Order>(`${customerOrderPrefix()}/order`, obj)
|
||||
return await useAxios.post<Order>(`/customer${!userStore.isSingle ? '' : '/single'}/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>>(`${customerOrderPrefix()}/order/table`, tbl)
|
||||
return await useAxios.post<TableOutput<Order>>(`/customer${!userStore.isSingle ? '' : '/single'}/order/table`, tbl)
|
||||
}
|
||||
|
||||
@@ -1,41 +1,13 @@
|
||||
import useAxios from "@/utils/useAxios";
|
||||
import type {PInventory, Product} from "@/models/products";
|
||||
import { priceStreamList } from "@/utils/priceSocketState";
|
||||
import type { PWallet, Wallet } from '@/models/wallet'
|
||||
|
||||
|
||||
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 getProducts = async (): Promise<any> => {
|
||||
return await useAxios.get<Product[]>(`/customer/product/list`)
|
||||
}
|
||||
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 })
|
||||
return await useAxios.get<Product[]>(`/customer/product/list/${id}`)
|
||||
}
|
||||
export const getProductInventory = async (): Promise<any> => {
|
||||
return await useAxios.post<PInventory[]>(`/customer/wallet/product/table`)
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import useAxios from '@/utils/useAxios'
|
||||
import type { Card, Wallet } from '@/models/wallet'
|
||||
import type { WalletAssetsSummary } from '@/models/walletAssets'
|
||||
import type {Transaction} from '@/models/transaction'
|
||||
|
||||
export const getWallet = async () => await useAxios.get<Wallet>(`/customer/wallet/me`)
|
||||
|
||||
export const getWalletAssets = async () =>
|
||||
await useAxios.get<WalletAssetsSummary>(`/customer/wallet/assets`)
|
||||
|
||||
export const withdraw = async (obj: Transaction): Promise<any> => {
|
||||
return await useAxios.post<Transaction>(`/customer/wallet/withdraw`, obj)
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
import router from '@/router'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
let fingerPosition = false
|
||||
let fingerTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let hasFingerPrint = false
|
||||
|
||||
export function setTruth() {
|
||||
fingerPosition = true
|
||||
}
|
||||
|
||||
export function setEnableAuthenticate() {
|
||||
fingerPosition = false
|
||||
}
|
||||
|
||||
export function setFingerPosition(value: boolean) {
|
||||
fingerPosition = value
|
||||
}
|
||||
|
||||
export function getFingerPosition() {
|
||||
return fingerPosition
|
||||
}
|
||||
|
||||
export function setHasFingerPrint(value: boolean) {
|
||||
hasFingerPrint = value
|
||||
}
|
||||
|
||||
export function getHasFingerPrint() {
|
||||
return hasFingerPrint
|
||||
}
|
||||
|
||||
export function clearFingerTimeout() {
|
||||
if (fingerTimeoutId) {
|
||||
clearTimeout(fingerTimeoutId)
|
||||
fingerTimeoutId = null
|
||||
}
|
||||
}
|
||||
|
||||
export function setFingerTimeOut(to: RouteLocationNormalized) {
|
||||
clearFingerTimeout()
|
||||
|
||||
fingerTimeoutId = setTimeout(async () => {
|
||||
if (!hasFingerPrint) return
|
||||
|
||||
fingerPosition = false
|
||||
await router.push({ name: 'login' })
|
||||
|
||||
if (to.name !== 'login') {
|
||||
message.warn('شما مدت زیادی با کشکول گلد کار نکردید، لطفا دوباره وارد شوید.')
|
||||
}
|
||||
}, 180000)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { loadAppConfig } from '@/config/load-config'
|
||||
import { setAppConfig } from '@/config/app-config'
|
||||
import { applyAppConfig } from '@/config/apply-config'
|
||||
import { startPwaManifest } from '@/modules/pwa/manifest'
|
||||
import { createVueApp } from '@/app/createApp'
|
||||
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
|
||||
__APP_BOOTSTRAPPED__?: boolean
|
||||
}
|
||||
|
||||
function clearBootTimeout() {
|
||||
const bootWindow = window as BootWindow
|
||||
|
||||
if (bootWindow.__BOOT_TIMEOUT__) {
|
||||
window.clearTimeout(bootWindow.__BOOT_TIMEOUT__)
|
||||
bootWindow.__BOOT_TIMEOUT__ = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
window.setTimeout(() => {
|
||||
reject(new Error(message))
|
||||
}, ms)
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
function showBootstrapError(error: unknown) {
|
||||
clearBootTimeout()
|
||||
|
||||
console.error('Bootstrap failed:', error)
|
||||
|
||||
const appRoot = document.querySelector('#app')
|
||||
|
||||
if (!appRoot) return
|
||||
|
||||
appRoot.innerHTML = `
|
||||
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; direction: rtl; font-family: sans-serif; padding: 24px; background: var(--boot-bg, #ffffff); color: var(--boot-text, #111827);">
|
||||
<div style="width: min(420px, 100%); text-align: center; background: var(--boot-card, #fff); border: 1px solid var(--boot-border, rgba(15,23,42,0.08)); border-radius: 24px; padding: 28px 22px;">
|
||||
<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>
|
||||
`
|
||||
}
|
||||
|
||||
export async function bootstrapApp() {
|
||||
try {
|
||||
const config = await withTimeout(loadAppConfig(), 20000, 'Config loading timeout')
|
||||
|
||||
setAppConfig(config)
|
||||
startPwaManifest(config)
|
||||
applyAppConfig()
|
||||
|
||||
const { app } = createVueApp()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
await withTimeout(settingsStore.fetchSettings(), 45000, 'Settings loading timeout')
|
||||
initPriceRealtimeChannel((key) => settingsStore.getSettingByKey(key))
|
||||
|
||||
initMobileScrollRestoration()
|
||||
initMobileInputKeyboardScroll()
|
||||
setupRouterGuards()
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
;(window as BootWindow).__APP_BOOTSTRAPPED__ = true
|
||||
clearBootTimeout()
|
||||
sessionStorage.removeItem('__APP_BOOT_RECOVERY_DONE__')
|
||||
sessionStorage.removeItem('__APP_BOOT_RETRY_CLICKED__')
|
||||
} catch (error) {
|
||||
showBootstrapError(error)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createApp } from 'vue'
|
||||
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 pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(pinia)
|
||||
app.use(VueLazyLoad, {})
|
||||
app.use(router)
|
||||
app.use(VueApexCharts)
|
||||
|
||||
return { app, pinia }
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { computed, watch } from 'vue'
|
||||
import router from '@/router'
|
||||
import { authenticateWithFingerprint, getToken, isIphone, removeToken } from '@/utils'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
|
||||
import { useAuthStore } from '@/store/useAuthStore'
|
||||
import {
|
||||
clearFingerTimeout,
|
||||
getFingerPosition,
|
||||
getHasFingerPrint,
|
||||
setFingerPosition,
|
||||
setFingerTimeOut,
|
||||
setHasFingerPrint
|
||||
} from '@/app/authBoot'
|
||||
import { clearVantOverlays, recoverMobileViewportAfterAuth } from '@/layouts/composables/useMobileScrollReset'
|
||||
|
||||
export function setupRouterGuards() {
|
||||
const userStore = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isApple: boolean = isIphone()
|
||||
let isFirst = true
|
||||
|
||||
const currentRoute = computed(() => router.currentRoute.value.fullPath)
|
||||
|
||||
async function start() {
|
||||
if (!getToken()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!userStore.userInfo) {
|
||||
await userStore.getMyInfo()
|
||||
}
|
||||
}
|
||||
|
||||
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 (isGuestAllowed(to)) {
|
||||
return next()
|
||||
}
|
||||
if (to.name !== 'login') {
|
||||
return next({ name: 'login' })
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
||||
function goToHome(to: RouteLocationNormalized, next: NavigationGuardNext) {
|
||||
if (to.name !== 'buyProduct') {
|
||||
return next({ name: 'buyProduct' })
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
authStore.setLoading(true)
|
||||
clearFingerTimeout()
|
||||
if (to.name !== 'tvMonitor' && !to.meta?.hideChrome) {
|
||||
setFingerTimeOut(to)
|
||||
}
|
||||
|
||||
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) {
|
||||
isFirst = false
|
||||
|
||||
if (getToken()) {
|
||||
await userStore.getMyInfo()
|
||||
const finger_print = userStore.userInfo.finger_print
|
||||
setHasFingerPrint(finger_print)
|
||||
|
||||
if (isApple && finger_print) {
|
||||
removeToken()
|
||||
goToLogin(to, next)
|
||||
} else if (!isApple && finger_print) {
|
||||
setFingerPosition(await authenticateWithFingerprint())
|
||||
|
||||
if (!getFingerPosition()) {
|
||||
goToLogin(to, next)
|
||||
}
|
||||
} else {
|
||||
if (to.name == 'login') {
|
||||
goToHome(to, next)
|
||||
}
|
||||
return next()
|
||||
}
|
||||
} else {
|
||||
if (isGuestAllowed(to)) {
|
||||
return next()
|
||||
}
|
||||
goToLogin(to, next)
|
||||
}
|
||||
} else {
|
||||
if (getFingerPosition()) {
|
||||
if (getToken()) {
|
||||
if (to.name === 'login') {
|
||||
return next({ name: from.name || 'buyProduct' })
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
||||
if (isGuestAllowed(to)) {
|
||||
return next()
|
||||
}
|
||||
return next({ name: 'login' })
|
||||
}
|
||||
|
||||
if (getToken() && !getHasFingerPrint()) {
|
||||
next()
|
||||
} else {
|
||||
goToLogin(to, next)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
router.afterEach((to, from) => {
|
||||
if (to.name === 'login') {
|
||||
clearVantOverlays()
|
||||
}
|
||||
if (document.documentElement.classList.contains('app-layout--desktop')) return
|
||||
if (from.name === 'login') {
|
||||
recoverMobileViewportAfterAuth()
|
||||
}
|
||||
})
|
||||
|
||||
watch(currentRoute, async () => (userStore.userInfo ? '' : await start()))
|
||||
}
|
||||
@@ -1,19 +1,5 @@
|
||||
@import (less) './var.less';
|
||||
@import (less) './fonts.less';
|
||||
@import (less) '../../layouts/styles/desktop-shell.less';
|
||||
@import (less) '../../layouts/styles/mobile-frame.less';
|
||||
@import (less) '../../layouts/styles/mobile-page-common.less';
|
||||
@import (less) '../../layouts/desktop/styles/desktop-page-common.less';
|
||||
@import (less) '../../views/base/fund/desktop/styles/desktop-fund.less';
|
||||
@import (less) '../../views/base/transactions/desktop/styles/desktop-transactions.less';
|
||||
@import (less) '../../views/base/wallet/desktop/styles/desktop-wallet.less';
|
||||
@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;
|
||||
@@ -64,21 +50,6 @@ body {
|
||||
|
||||
:root {
|
||||
color-scheme: only light;
|
||||
--app-safe-top: env(safe-area-inset-top, 0px);
|
||||
--app-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--app-credit-bar-inner: 28px;
|
||||
--app-home-header-offset: calc(4.25rem + var(--app-safe-top));
|
||||
--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-credit-footer-offset));
|
||||
--app-add-card-height: 88px;
|
||||
--wallet-card-dock-overlap: 52px;
|
||||
--app-z-nav: 5000;
|
||||
--app-z-wallet-dock: 4490;
|
||||
--app-z-wallet-add-bar: 4510;
|
||||
--app-z-action-sheet: 5200;
|
||||
--app-z-dialog: 5300;
|
||||
--app-z-modal: 5500;
|
||||
}
|
||||
|
||||
.ant-form-item {
|
||||
@@ -93,13 +64,6 @@ 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,38 +74,18 @@ body {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.van-action-sheet{
|
||||
z-index: 5001 !important;
|
||||
/*.van-action-sheet{
|
||||
z-index: 2001 !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;
|
||||
}
|
||||
}
|
||||
z-index: 2000 !important;
|
||||
}*/
|
||||
|
||||
.color-transition {
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.background-color-transition {
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
@@ -183,19 +127,13 @@ body.van-overflow-hidden {
|
||||
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 #a72b77 !important;
|
||||
border: 1px solid #521620 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,18 +331,18 @@ body.van-overflow-hidden {
|
||||
}
|
||||
.ant-radio-group-solid
|
||||
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover{
|
||||
color: #a72b77 !important;
|
||||
color: #521620 !important;
|
||||
}
|
||||
.ant-input-group-addon{
|
||||
background-color: #a72b77 !important;
|
||||
background-color: #521620 !important;
|
||||
color: #e0be7f !important;
|
||||
}
|
||||
.ant-slider-track{
|
||||
background-color: #a72b77 !important;
|
||||
background-color: #521620 !important;
|
||||
|
||||
}
|
||||
.ant-slider-handle {
|
||||
background-color: #a72b77 !important;
|
||||
background-color: #521620 !important;
|
||||
|
||||
}
|
||||
|
||||
@@ -413,4 +351,20 @@ body.van-overflow-hidden {
|
||||
}
|
||||
.ant-radio-group-solid .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover{
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.ant-spin-dot-item,
|
||||
.ant-spin-dot i {
|
||||
background-color: @primary-color !important;
|
||||
}
|
||||
|
||||
/* لودینگ محتوای صفحه — وسط ناحیه محتوا (موبایل و دسکتاپ یکسان) */
|
||||
.app-page-content-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: min(52vh, 22rem);
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
: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,8 +1,7 @@
|
||||
@prefixCls: 'noname';
|
||||
|
||||
@primary-color: #a72b77;
|
||||
@secondary-color: #a72b77;
|
||||
@App-primary-color: #a72b77;
|
||||
@primary-color: #521620;
|
||||
@secondary-color: #521620;
|
||||
@prefixCls-font-family: 'iran-sans';
|
||||
@prefixCls-background-color: #fff;
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 607 B |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 450 KiB |
|
Before Width: | Height: | Size: 583 KiB After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 40 KiB |
@@ -1,10 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 680 B |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -1,7 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 337 B |
@@ -1,7 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 6.7 KiB |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,98 +0,0 @@
|
||||
<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" overlay lock-scroll close-on-click-overlay teleport="body" :placement="placement" @update:show="$emit('update:show', $event)">
|
||||
<VanActionSheet :show="show" @update:show="$emit('update:show', $event)" :placement="placement" teleport="body">
|
||||
<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>
|
||||
|
||||
@@ -41,6 +41,11 @@ const _class = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
const _loading = computed(() => {
|
||||
if (props.loading){
|
||||
return 'flex'
|
||||
} else return 'unset'
|
||||
})
|
||||
const { prefixCls } = usePrefix('base-button')
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -53,6 +58,9 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
<style lang="less">
|
||||
@prefix:~'@{prefixCls}-base-button';
|
||||
.@{prefix}{
|
||||
//width: fit-content;
|
||||
height: auto;
|
||||
/*padding: 8px;*/
|
||||
display: v-bind(_loading);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
:type="props.type"
|
||||
class=""
|
||||
@change="handlerChange"
|
||||
@input="handlerChange"
|
||||
/>
|
||||
<a-input
|
||||
v-else
|
||||
@@ -29,9 +28,6 @@
|
||||
:pattern="props.pattern"
|
||||
class=""
|
||||
@change="handlerChange"
|
||||
@input="handlerChange"
|
||||
@blur="emits('blur', $event)"
|
||||
@focus="emits('focus', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -40,11 +36,6 @@
|
||||
import {usePrefix} from "@/utils/usePrefix";
|
||||
import {computed} from "vue";
|
||||
import {usePrice} from "@/components/BasicPacks/BasicPriceInput/src/utils/usePrice";
|
||||
import {
|
||||
normalizeDecimalInput,
|
||||
persianToEnglishDigits,
|
||||
shouldNormalizeDecimalSeparators,
|
||||
} from '@/utils/persianDigits';
|
||||
|
||||
//Props
|
||||
interface Prop {
|
||||
@@ -65,47 +56,29 @@ interface Prop {
|
||||
|
||||
const addonBgColor = computed(() => props.addonBgColor)
|
||||
const addonTextColor = computed(() => props.addonTextColor)
|
||||
//emits
|
||||
const localValue = computed({
|
||||
set(value) {
|
||||
// console.log(value?.toString().replaceAll(',', ''))
|
||||
props.type === 'price' ? emits('update:value', value?.toString().replaceAll(',', '')) : emits('update:value', value)
|
||||
},
|
||||
get() {
|
||||
let a = props.type === 'price' ? props.value ? usePrice(props.value as number, true, '').trim() : '' : props.value
|
||||
// console.log(props.value)
|
||||
return a
|
||||
},
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<Prop>(), {
|
||||
isPassword: false
|
||||
})
|
||||
const emits = defineEmits(['update:value', 'onchange', 'blur', 'focus'])
|
||||
const emits = defineEmits(['update:value', 'onchange'])
|
||||
|
||||
const localValue = computed({
|
||||
set(value) {
|
||||
emits('update:value', normalizeValue(value))
|
||||
},
|
||||
get() {
|
||||
return props.type === 'price'
|
||||
? props.value ? usePrice(props.value as number, true, '').trim() : ''
|
||||
: props.value
|
||||
},
|
||||
})
|
||||
|
||||
function normalizeValue(value: unknown) {
|
||||
if (value == null || value === '') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (shouldNormalizeDecimalSeparators(props)) {
|
||||
const isPrice = String(props.type ?? '').trim().toLowerCase() === 'price'
|
||||
return normalizeDecimalInput(value as string | number, {
|
||||
commaAsThousand: isPrice,
|
||||
})
|
||||
}
|
||||
|
||||
return persianToEnglishDigits(String(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)
|
||||
const handlerChange = (e: any) => {
|
||||
// emits('onchange', e.target.value)
|
||||
props.type === 'price' ? emits('update:value', e.target.value?.toString().replaceAll(',', '')) : emits('update:value', e.target.value)
|
||||
props.type === 'price' ? emits('onchange', e.target.value?.toString().replaceAll(',', '')) : emits('onchange', e.target.value)
|
||||
// console.log('sas')
|
||||
}
|
||||
|
||||
//css style
|
||||
@@ -116,6 +89,11 @@ const {prefixCls} = usePrefix('input')
|
||||
@prefix: ~'@{prefixCls}-input';
|
||||
|
||||
.@{prefix} {
|
||||
/* .ant-input-group-addon:first-child {
|
||||
!* border: #d9d9d9 1px solid;
|
||||
border-radius: 0 6px 6px 0;*!
|
||||
}*/
|
||||
|
||||
.ant-input-group {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<a-modal
|
||||
v-model:visible="localValue"
|
||||
:title="slots.title ? undefined : props.title"
|
||||
:title="props.title"
|
||||
centered
|
||||
@ok="emits('ok')"
|
||||
@cancel="emits('close')"
|
||||
@@ -11,13 +11,9 @@
|
||||
: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">
|
||||
@@ -29,7 +25,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots } from 'vue'
|
||||
//define emit
|
||||
import { computed } from 'vue'
|
||||
|
||||
const emits = defineEmits(['ok', 'close', 'update:visible'])
|
||||
|
||||
@@ -56,8 +53,6 @@ const props = withDefaults(defineProps<Prop>(), {
|
||||
closable: true
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const localValue = computed({
|
||||
get() {
|
||||
return props.visible
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<BasicInput v-model:value="_val" type="price">
|
||||
<BasicInput v-model:value="_val">
|
||||
<template v-for="(_, slotKey) in $slots" #[slotKey]="data">
|
||||
<slot :name="slotKey" v-bind="data || {}"></slot>
|
||||
</template>
|
||||
@@ -10,7 +10,6 @@
|
||||
import {usePrice} from './utils/usePrice'
|
||||
import {computed} from "vue";
|
||||
import BasicInput from "../../BasicInput.vue";
|
||||
import {normalizeDecimalInput} from '@/utils/persianDigits'
|
||||
|
||||
interface Props {
|
||||
value?: string | number
|
||||
@@ -24,7 +23,9 @@ const props = withDefaults(defineProps<Props>(), {})
|
||||
|
||||
const _val = computed({
|
||||
set(val: string) {
|
||||
emit('update:value', normalizeDecimalInput(val))
|
||||
// const temp = val.toString().replaceAll(',', '')
|
||||
const temp = val.toString().replaceAll(',', '')
|
||||
emit('update:value', temp)
|
||||
},
|
||||
get() {
|
||||
return props.value ? usePrice(props.value as number, true, '').trim() : ''
|
||||
|
||||
@@ -1 +1,17 @@
|
||||
export { usePrice, usePriceWithoutType, useTowDecimal, useFormat } from '@/utils/usePrice'
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,861 +0,0 @@
|
||||
<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>
|
||||
@@ -1,70 +0,0 @@
|
||||
<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>
|
||||
@@ -1,307 +0,0 @@
|
||||
<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>
|
||||
@@ -1,12 +1,9 @@
|
||||
<template>
|
||||
<van-dialog
|
||||
v-model:show="show"
|
||||
class="gold-loading-dialog updateButton"
|
||||
teleport="body"
|
||||
:z-index="zIndex"
|
||||
class="updateButton"
|
||||
confirm-button-text="متوجه شدم"
|
||||
:show-confirm-button="false"
|
||||
:close-on-click-overlay="false"
|
||||
:close-on-popstate="false"
|
||||
@confirm="update()"
|
||||
>
|
||||
<div class="flex flex-col items-center p-2">
|
||||
<DotLottieVue class="w-15rem h-15rem" autoplay loop src="/animIsTracking.json" />
|
||||
@@ -19,27 +16,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { update } from 'lodash'
|
||||
import { ref } from 'vue'
|
||||
import anim from '@/assets/img/anim/anim1.json'
|
||||
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
|
||||
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),
|
||||
interface Prop {
|
||||
title: string
|
||||
info?: string
|
||||
infoHead?: string
|
||||
isLine?: boolean
|
||||
value?: string | number
|
||||
}
|
||||
const props = withDefaults(defineProps<Prop>(), {
|
||||
isLine: false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less"></style>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<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>
|
||||
@@ -1,66 +0,0 @@
|
||||
<template>
|
||||
<img
|
||||
:src="currentSrc"
|
||||
:class="[imgClass, dimmed && 'product-image--dimmed']"
|
||||
:alt="alt"
|
||||
@error="handleError"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
getProductFallbackImage,
|
||||
resolveProductImageSrc,
|
||||
type ProductImageSource
|
||||
} from '@/utils/productImage'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
product?: ProductImageSource
|
||||
imgClass?: string
|
||||
alt?: string
|
||||
dimmed?: boolean
|
||||
}>(),
|
||||
{
|
||||
imgClass: 'max-h-full max-w-full object-contain',
|
||||
alt: '',
|
||||
dimmed: false
|
||||
}
|
||||
)
|
||||
|
||||
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(
|
||||
() => productImageKey(props.product),
|
||||
() => {
|
||||
usingFallback.value = false
|
||||
currentSrc.value = resolveProductImageSrc(props.product)
|
||||
},
|
||||
)
|
||||
|
||||
function handleError() {
|
||||
if (usingFallback.value) return
|
||||
usingFallback.value = true
|
||||
currentSrc.value = getProductFallbackImage(props.product)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-image--dimmed {
|
||||
opacity: 0.45;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,89 +0,0 @@
|
||||
<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>
|
||||
@@ -1,72 +0,0 @@
|
||||
<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>
|
||||
@@ -1,300 +0,0 @@
|
||||
<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>
|
||||
@@ -1,227 +0,0 @@
|
||||
<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>
|
||||
@@ -1,375 +0,0 @@
|
||||
<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>
|
||||