Split desktop and mobile layouts with shared page data so APIs fetch once per context.

Add layout shells, desktop views, module-level page caches, and UI fixes for auth, home, wallet, transactions, address, and related pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-14 10:08:46 +03:30
co-authored by Cursor
parent 4404b4cb92
commit a5e458ed25
234 changed files with 18425 additions and 5503 deletions
+2
View File
@@ -62,11 +62,13 @@ declare module 'vue' {
Size: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/size.vue')['default'] 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'] 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'] Suffix: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/suffix.vue')['default']
SupportContactBar: typeof import('./src/components/SupportContactBar.vue')['default']
Switchable: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/switchable.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'] 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'] Time: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/time.vue')['default']
TransactionSkeleton: typeof import('./src/components/TransactionSkeleton.vue')['default'] TransactionSkeleton: typeof import('./src/components/TransactionSkeleton.vue')['default']
VanActionSheet: typeof import('vant/es')['ActionSheet'] VanActionSheet: typeof import('vant/es')['ActionSheet']
VanDialog: typeof import('vant/es')['Dialog'] VanDialog: typeof import('vant/es')['Dialog']
VanField: typeof import('vant/es')['Field']
} }
} }
+33
View File
@@ -11,6 +11,39 @@
<meta name="enamad" content="44889195"> <meta name="enamad" content="44889195">
<link rel="manifest" href="https://nerkh.gold/get_user/monifist.json"/> <link rel="manifest" href="https://nerkh.gold/get_user/monifist.json"/>
<title></title> <title></title>
<script>
(function () {
if (window.matchMedia('(min-width: 768px)').matches) {
document.documentElement.classList.add('app-layout--desktop')
}
})()
</script>
<style>
/* Critical: full-viewport splash before Vue/CSS hydrate */
html, body, #app {
margin: 0;
padding: 0;
min-height: 100vh;
}
@media (min-width: 768px) {
html, body, #app {
width: 100%;
max-width: none;
}
body {
display: block !important;
}
}
.app-splash-screen {
position: fixed;
inset: 0;
width: 100vw;
min-height: 100vh;
left: 0 !important;
right: 0 !important;
z-index: 2147483646;
}
</style>
</head> </head>
<body> <body>
+68 -54
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="desktopConfig w-full right-0 left-0 absolute"> <div class="desktopConfig w-full right-0 left-0">
<div v-if="popVisible" class="top-0 bottom-0 wrap-fix bg-primary z-100 opacity-60"> <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 class="absolute right-0 left-0 bottom-10vh flex flex-col items-center">
<div> <div>
@@ -10,60 +10,47 @@
</div> </div>
</div> </div>
<AConfigProvider :theme="antTheme ?? undefined" direction="rtl"> <AConfigProvider :theme="antTheme ?? undefined" direction="rtl">
<transition mode="out-in" name="fade"> <div v-if="showSplash" class="app-splash-screen flex justify-center" :style="{ backgroundColor: getProjectSplashBackground() }">
<template v-if="!isBoursecuProject()">
<div v-if="!showSplash"> <div class="app-splash-screen__gradient app-splash-screen__gradient--top h-1/3 flex">
<router-view v-slot="{ Component, route }"> <img :src="img_topGradiant" alt="" class="absolute z-10 w-full h-full object-cover"/>
<transition :name="(route.meta.transition as string | undefined) || 'fade'"> <img :src="img_pattern" alt="" class="absolute w-full h-full object-none"/>
</div>
<div> <div class="app-splash-screen__gradient app-splash-screen__gradient--bottom h-1/3">
<component :is="Component"/>
</div>
</transition>
</router-view>
</div>
<div
v-else
class="flex bottom-0 wrap-fix top-0 z-1000000000000 justify-center"
:style="{ backgroundColor: getProjectSplashBackground() }"
>
<template v-if="!isBoursecuProject()">
<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>
</template>
<div class="self-center splash_appear_fade_in">
<img <img
:src="getProjectLoginLogo(config?.config?.avatar)" :src="img_bottomGradiant"
:class="hasCustomLoginLogoLayout() alt=""
? 'w-[80vw] max-w-[320px] h-auto object-contain' class="absolute z-10 w-full h-full object-cover"
: 'h-[20vh] object-contain'" />
> <img :src="img_pattern" alt="" class="absolute w-full h-full object-none"/>
</div> </div>
<div class="fixed w-full bottom-5 home_appear_fade_in_up"> </template>
<div <div class="self-center splash_appear_fade_in">
class="text-center text-xs" <img
:class="isBoursecuProject() ? 'text-white' : ''" :src="getProjectLoginLogo(config?.config?.avatar)"
> :class="hasCustomLoginLogoLayout()
نسخه: {{ pkg.version }} ? 'w-[80vw] max-w-[320px] h-auto object-contain'
</div> : 'h-[20vh] object-contain'"
</div> >
<div class="absolute bottom-10"> </div>
<a-spin :indicator="splashIndicator" size="small"/> <div class="fixed w-full bottom-5 home_appear_fade_in_up">
<div
class="text-center text-xs"
:class="isBoursecuProject() ? 'text-white' : ''"
>
نسخه: {{ pkg.version }}
</div> </div>
</div> </div>
</transition> <div class="absolute bottom-10">
<a-spin :indicator="splashIndicator" size="small"/>
</div>
</div>
<div v-else class="app-main">
<router-view v-slot="{ Component, route }">
<component :is="Component" :key="route.fullPath" />
</router-view>
</div>
</AConfigProvider> </AConfigProvider>
<van-dialog <van-dialog
v-model:show="versionDialog" v-model:show="versionDialog"
@@ -115,7 +102,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import pkg from '../package.json' import pkg from '../package.json'
import {useRegisterSW} from 'virtual:pwa-register/vue' import {useRegisterSW} from 'virtual:pwa-register/vue'
import {computed, h, onMounted, ref} from 'vue' import {computed, h, onMounted, ref, watch} from 'vue'
import img_topGradiant from '@/views/login/assets/img/gr_top_white.svg' import img_topGradiant from '@/views/login/assets/img/gr_top_white.svg'
import img_pattern from '@/views/login/assets/img/pattern.svg' import img_pattern from '@/views/login/assets/img/pattern.svg'
import {LoadingOutlined} from '@ant-design/icons-vue' import {LoadingOutlined} from '@ant-design/icons-vue'
@@ -123,9 +110,12 @@ import img_bottomGradiant from '@/views/login/assets/img/gr_bottom_white.svg'
import {DotLottieVue} from '@lottiefiles/dotlottie-vue' import {DotLottieVue} from '@lottiefiles/dotlottie-vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue' import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import {useConfigStore} from "@/store/configStore"; import {useConfigStore} from "@/store/configStore";
import {getProjectFontFamily, getProjectLoginLogo, getProjectSplashBackground, getProjectBrandRed, hasCustomLoginLogoLayout, isBoursecuProject} from "@/utils/config"; import {getProjectFontFamily, getProjectLoginLogo, getProjectSplashBackground, getProjectBrandRed, hasCustomLoginLogoLayout, isBoursecuProject, resetAppSurfaceBackground} from "@/utils/config";
import {ConfigProvider as AConfigProvider} from 'ant-design-vue' import {ConfigProvider as AConfigProvider} from 'ant-design-vue'
import BasicIcon from "@/components/BasicPacks/BasicIcon.vue"; import BasicIcon from "@/components/BasicPacks/BasicIcon.vue";
import { useApplyDesktopLayoutClass } from '@/layouts/composables/useAppLayout'
useApplyDesktopLayoutClass()
const {offlineReady, needRefresh, updateServiceWorker} = useRegisterSW() const {offlineReady, needRefresh, updateServiceWorker} = useRegisterSW()
const config = useConfigStore() const config = useConfigStore()
@@ -171,6 +161,12 @@ onMounted(async () => {
}, 2000) }, 2000)
}) })
watch(showSplash, (visible) => {
if (!visible) {
resetAppSurfaceBackground()
}
})
const versionDialog = ref<boolean>(false) const versionDialog = ref<boolean>(false)
function checkVersion() { function checkVersion() {
@@ -200,6 +196,7 @@ function update() {
--app-credit-footer-offset: calc(var(--app-credit-bar-inner) + var(--app-safe-bottom)); --app-credit-footer-offset: calc(var(--app-credit-bar-inner) + var(--app-safe-bottom));
--app-nav-height: 80px; --app-nav-height: 80px;
--app-bottom-chrome-height: calc(var(--app-nav-height) + var(--app-credit-footer-offset)); --app-bottom-chrome-height: calc(var(--app-nav-height) + var(--app-credit-footer-offset));
--app-desktop-sidebar-width: 260px;
--app-add-card-height: 88px; --app-add-card-height: 88px;
/* بخشی از کارت کیف‌پول زیر نوار «افزودن کارت جدید» دیده می‌شود */ /* بخشی از کارت کیف‌پول زیر نوار «افزودن کارت جدید» دیده می‌شود */
--wallet-card-dock-overlap: 52px; --wallet-card-dock-overlap: 52px;
@@ -210,6 +207,16 @@ function update() {
--app-z-dialog: 5300; --app-z-dialog: 5300;
} }
.app-main {
width: 100%;
min-height: 100vh;
opacity: 1 !important;
}
.desktopConfig {
min-height: 100vh;
}
.app-credit-footer { .app-credit-footer {
position: fixed; position: fixed;
bottom: 0; bottom: 0;
@@ -249,10 +256,17 @@ function update() {
z-index: 9999; z-index: 9999;
} }
@media screen and (min-width: 426px) { @media screen and (min-width: 426px) and (max-width: 767.98px) {
.desktopConfig { .desktopConfig {
width: 426px; width: 426px;
position: relative !important; position: relative !important;
} }
} }
html.app-layout--desktop {
#support-button {
bottom: 12px;
right: calc(var(--app-desktop-sidebar-width, 260px) + 20px);
}
}
</style> </style>
+5
View File
@@ -1,4 +1,9 @@
html.project-boursecu { html.project-boursecu {
.ant-input-group-addon {
background-color: @secondary-color !important;
color: @App-primary-color !important;
}
.text-red, .text-red,
.text-red-600, .text-red-600,
.text-red-700, .text-red-700,
+44 -3
View File
@@ -2,9 +2,29 @@
@import (less) './fonts.less'; @import (less) './fonts.less';
@import (less) './ant.less'; @import (less) './ant.less';
@import (less) './boursecu-theme.less'; @import (less) './boursecu-theme.less';
@import (less) '../../layouts/styles/desktop-shell.less';
@import (less) '../../layouts/styles/mobile-frame.less';
@import (less) '../../views/login/desktop/styles/desktop-auth.less';
@import (less) '../../views/base/user/desktop/styles/desktop-user.less';
@import (less) '../../views/base/products/desktop/styles/desktop-products.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/priceTrigger/desktop/styles/desktop-price-trigger.less';
@import (less) '../../views/base/about/desktop/styles/desktop-about.less';
@import (less) '../../views/base/rules/desktop/styles/desktop-rules.less';
@import (less) '../../views/base/rules/permissions/desktop/styles/desktop-permissions.less';
@import (less) '../../views/base/giftCard/desktop/styles/desktop-gift-card.less';
@import (less) '../../views/base/address/desktop/styles/desktop-address.less';
@import (less) '../../views/base/educate/desktop/styles/desktop-educate.less';
@import (less) '../../views/base/aboutUs/desktop/styles/desktop-about-us.less';
@import (less) '../../views/base/earnMoney/desktop/styles/desktop-earn-money.less';
@import (less) '../../views/base/physicalReceive/desktop/styles/desktop-physical-receive.less';
@import (less) '../../views/base/fund/desktop/styles/desktop-fund.less';
@import (less) '../../views/base/gold/desktop/styles/desktop-gold.less';
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
background-color: @prefixCls-background-color;
} }
body { body {
@@ -34,7 +54,7 @@ body {
@apply flex justify-center relative; @apply flex justify-center relative;
position: relative; position: relative;
} }
@media screen and (min-width: 426px) { @media screen and (min-width: 426px) and (max-width: 767.98px) {
.van-popup--bottom.van-popup--round { .van-popup--bottom.van-popup--round {
width: 426px; width: 426px;
left: calc(50vw - 213px); left: calc(50vw - 213px);
@@ -44,10 +64,31 @@ body {
position: fixed; position: fixed;
right: 0; right: 0;
left: 0; left: 0;
@media screen and (min-width: 426px) { width: 100%;
max-width: 100vw;
box-sizing: border-box;
@media screen and (min-width: 426px) and (max-width: 767.98px) {
width: 426px; width: 426px;
left: auto; max-width: 426px;
left: 50%;
right: auto; right: auto;
transform: translateX(-50%);
}
}
html.app-layout--desktop {
@media screen and (min-width: 426px) {
.van-popup--bottom.van-popup--round {
width: min(480px, calc(100vw - var(--app-desktop-sidebar-width, 260px) - 48px));
left: calc(
(100vw - var(--app-desktop-sidebar-width, 260px) - min(480px, calc(100vw - var(--app-desktop-sidebar-width, 260px) - 48px))) / 2
);
}
}
#goftino_w {
right: calc(var(--app-desktop-sidebar-width, 260px) + 20px) !important;
} }
} }
+1 -10
View File
@@ -33,16 +33,7 @@ $total-items: 6
animation: 1s ease slideInFromRight animation: 1s ease slideInFromRight
animation-fill-mode: both animation-fill-mode: both
//@if($counter < 13) // Duplicate animation block removed — was leaking styles without a selector
// .animate > div
animation: 1s ease 0s 1 slideInFromRight
animation-fill-mode: both
//transform: translateX(100%)
//@for $i from 1 through $total-items
// .animate:nth-child(#{$i})
// animation-delay: .25s * $i
//transform: translateX(100%)
@keyframes slideInFromRight @keyframes slideInFromRight
0% 0%
+38
View File
@@ -0,0 +1,38 @@
<template>
<div :class="variant === 'desktop' ? 'dpage-support' : 'wrap-fix bottom-0 px-10 pb-3rem flex gap-2 bg-white'">
<a class="w-full" :href="supportHref">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a :href="baleHref" target="_blank" rel="noopener noreferrer">
<img src="@/assets/img/bale.png" alt="بله" :class="variant === 'desktop' ? 'dpage-support__bale' : 'h-10 w-10'" />
</a>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { useConfigStore } from '@/store/configStore'
import { getProjectBaleLink, getProjectSupportPhoneLink } from '@/utils/config'
withDefaults(
defineProps<{
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
const config = useConfigStore()
const supportHref = computed(() =>
config.config.name === 'garoosi'
? 'tel:09031265925'
: getProjectSupportPhoneLink(),
)
const baleHref = computed(() =>
config.config.name === 'garoosi'
? 'https://ble.ir/garoosigallery'
: getProjectBaleLink(),
)
</script>
+44
View File
@@ -0,0 +1,44 @@
/**
* Deduplicate async work across remounts (e.g. accidental v-if layout switches).
* Prefer fixing layout with v-show + parent-level fetch; use this in stores/shared loaders.
*/
const cache = new Map<string, unknown>()
const inflight = new Map<string, Promise<unknown>>()
export async function runOnceAsync<T>(key: string, fn: () => Promise<T>, force = false): Promise<T> {
if (!force && cache.has(key)) {
return cache.get(key) as T
}
if (!force && inflight.has(key)) {
return inflight.get(key) as Promise<T>
}
const promise = fn()
.then((result) => {
cache.set(key, result)
return result
})
.finally(() => {
if (inflight.get(key) === promise) {
inflight.delete(key)
}
})
inflight.set(key, promise)
return promise
}
export function resetOnceAsync(key: string) {
cache.delete(key)
inflight.delete(key)
}
export function resetOnceAsyncByPrefix(prefix: string) {
for (const key of [...cache.keys(), ...inflight.keys()]) {
if (key.startsWith(prefix)) {
resetOnceAsync(key)
}
}
}
+32
View File
@@ -0,0 +1,32 @@
import { getProducts } from '@/api/products'
import type { Product } from '@/models/products'
/** Shared product catalog — dedupes getProducts() across wallet assets and chart offset. */
let catalogCache: Product[] | null = null
let catalogPromise: Promise<Product[]> | null = null
export function getProductsCatalog(force = false): Promise<Product[]> {
if (!force && catalogCache) {
return Promise.resolve(catalogCache)
}
if (!force && catalogPromise) {
return catalogPromise
}
catalogPromise = getProducts()
.then((products) => {
catalogCache = products ?? []
return catalogCache
})
.finally(() => {
catalogPromise = null
})
return catalogPromise
}
export function clearProductsCatalog() {
catalogCache = null
catalogPromise = null
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { ref } from 'vue' import { ref } from 'vue'
import { getWalletAssets } from '@/api/wallet' import { getWalletAssets } from '@/api/wallet'
import { getProducts } from '@/api/products' import { getProductsCatalog } from '@/composables/useProductsCatalog'
import type { WalletAssetsSummary } from '@/models/walletAssets' import type { WalletAssetsSummary } from '@/models/walletAssets'
import { enrichInventoryCardsWithCatalog } from '@/utils/productAssets' import { enrichInventoryCardsWithCatalog } from '@/utils/productAssets'
@@ -17,8 +17,8 @@ export function useWalletAssets() {
return assetsPromise return assetsPromise
} }
// سرور مرجع دارایی‌هاست (main_wallet_products هم سمت سرور هندل می‌شود)؛ کلاینت فقط نمایش می‌دهد // Reuses cached getProductsCatalog() — same catalog as chart offset logic
assetsPromise = Promise.all([getWalletAssets(), getProducts()]) assetsPromise = Promise.all([getWalletAssets(), getProductsCatalog(force)])
.then(([data, catalog]) => { .then(([data, catalog]) => {
assetsSummary.value = { assetsSummary.value = {
...data, ...data,
+10
View File
@@ -97,6 +97,16 @@
window.__APP_CONFIG__ = config window.__APP_CONFIG__ = config
window.__CONFIG_READY__ = true window.__CONFIG_READY__ = true
// Early splash background — full viewport before Vue mounts
const projectName = config?.user?.name
if (projectName === 'boursecu') {
const splashBg = '#133050'
document.documentElement.style.backgroundColor = splashBg
document.body.style.backgroundColor = splashBg
const metaTheme = document.querySelector('meta[name="theme-color"]')
if (metaTheme) metaTheme.setAttribute('content', splashBg)
}
// Resolve the promise // Resolve the promise
if (configResolve) { if (configResolve) {
configResolve(config) configResolve(config)
+12
View File
@@ -0,0 +1,12 @@
<template>
<DesktopAppLayout v-if="isDesktopLayout" />
<MobileAppLayout v-else />
</template>
<script lang="ts" setup>
import { useDesktopBreakpoint } from './composables/useAppLayout'
import DesktopAppLayout from './desktop/DesktopAppLayout.vue'
import MobileAppLayout from './mobile/MobileAppLayout.vue'
const isDesktopLayout = useDesktopBreakpoint()
</script>
@@ -0,0 +1,47 @@
<template>
<div class="responsive-view-pair">
<div
v-show="!isDesktop"
class="responsive-view-pair__pane"
:class="{ 'responsive-pane--hidden': isDesktop }"
>
<slot name="mobile" />
</div>
<div
v-show="isDesktop"
class="responsive-view-pair__pane"
:class="{ 'responsive-pane--hidden': !isDesktop }"
>
<slot name="desktop" />
</div>
</div>
</template>
<script lang="ts" setup>
import { useDesktopBreakpoint } from '../composables/useResponsiveLayout'
const isDesktop = useDesktopBreakpoint()
</script>
<style scoped>
.responsive-view-pair {
min-height: 100%;
height: 100%;
width: 100%;
max-width: 100%;
min-width: 0;
overflow-x: hidden;
}
.responsive-view-pair__pane {
min-height: 100%;
height: 100%;
width: 100%;
max-width: 100%;
min-width: 0;
}
.responsive-pane--hidden {
/* v-show already sets display:none; class reserved for layout overrides */
}
</style>
+34
View File
@@ -0,0 +1,34 @@
import { useMediaQuery } from '@vueuse/core'
import { onUnmounted, watch } from 'vue'
import { DESKTOP_LAYOUT_MIN_WIDTH } from '../constants'
const DESKTOP_LAYOUT_CLASS = 'app-layout--desktop'
export function useDesktopBreakpoint() {
return useMediaQuery(`(min-width: ${DESKTOP_LAYOUT_MIN_WIDTH}px)`)
}
/** Apply html class for global desktop shell styles. Call once from App.vue. */
export function useApplyDesktopLayoutClass() {
const isDesktopLayout = useDesktopBreakpoint()
const stop = watch(
isDesktopLayout,
(desktop) => {
document.documentElement.classList.toggle(DESKTOP_LAYOUT_CLASS, desktop)
},
{ immediate: true },
)
onUnmounted(() => {
stop()
document.documentElement.classList.remove(DESKTOP_LAYOUT_CLASS)
})
}
/** @deprecated use useDesktopBreakpoint + useApplyDesktopLayoutClass */
export function useAppLayout() {
const isDesktopLayout = useDesktopBreakpoint()
useApplyDesktopLayoutClass()
return { isDesktopLayout }
}
@@ -0,0 +1,43 @@
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export function useAppNavigation() {
const router = useRouter()
const route = useRoute()
const popVisible = ref(false)
const currentRoute = computed(() => route.name)
const hideMenu = computed(() => !!route.meta.hideMenu)
function isNavActive(routeName: string) {
return currentRoute.value === routeName
}
function navItemClass(routeName: string) {
return isNavActive(routeName) ? 'text-primary' : 'text-gray-400'
}
function navigateTo(routeName: string) {
popVisible.value = false
router.push({ name: routeName })
}
function toggleOverflowMenu() {
popVisible.value = !popVisible.value
}
function closeOverflowMenu() {
popVisible.value = false
}
return {
popVisible,
currentRoute,
hideMenu,
isNavActive,
navItemClass,
navigateTo,
toggleOverflowMenu,
closeOverflowMenu,
}
}
@@ -0,0 +1,21 @@
import { useMediaQuery } from '@vueuse/core'
import { DESKTOP_LAYOUT_MIN_WIDTH } from '../constants'
/**
* Reactive desktop breakpoint. Use only for UI switching — never tie API calls to this ref.
*/
export function useDesktopBreakpoint() {
return useMediaQuery(`(min-width: ${DESKTOP_LAYOUT_MIN_WIDTH}px)`)
}
/**
* Guidelines for responsive pages (Vue):
*
* 1. Call page composables (with onMounted fetch) exactly once in the route `index.vue` shell.
* 2. Switch Mobile/Desktop views with `v-show`, not `v-if`, so resize does not destroy children.
* 3. Keep `<router-view>` outside responsive chrome wrappers so child routes are not remounted.
* 4. Mobile/Desktop views consume shared state via provide/inject — they must not fetch independently.
* 5. Prefer store/composable request dedup (in-flight promise + cached result) for shared data.
*/
export const RESPONSIVE_HIDDEN_CLASS = 'responsive-pane--hidden'
+29
View File
@@ -0,0 +1,29 @@
import { isBoursecuProject } from '@/utils/config'
import type { AppNavItem } from '../types'
const BOURSECU_PRIMARY_NAV: AppNavItem[] = [
{ id: 'buyProduct', routeName: 'buyProduct', label: 'سفارش‌ها', icon: 'vuesax-linear:box' },
{ id: 'transactions', routeName: 'transactions', label: 'تراکنش‌ها', icon: 'vuesax-linear:receipt-item' },
{ id: 'home', routeName: 'home', label: 'صفحه اصلی', icon: 'vuesax-linear:home' },
{ id: 'wallet', routeName: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-1' },
{ id: 'user', routeName: 'user', label: 'تنظیمات', icon: 'vuesax-linear:setting-2' },
]
const DEFAULT_PRIMARY_NAV: AppNavItem[] = [
{ id: 'home', routeName: 'home', label: 'صفحه اصلی', icon: 'vuesax-linear:home' },
{ id: 'transactions', routeName: 'transactions', label: 'گزارش', icon: 'vuesax-linear:receipt-item' },
{ id: 'buyProduct', routeName: 'buyProduct', label: 'محصولات', icon: 'vuesax-linear:coin' },
{ id: 'wallet', routeName: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-1' },
{ id: 'user', routeName: 'user', label: 'تنظیمات', icon: 'vuesax-linear:setting-2' },
]
export const SECONDARY_NAV: AppNavItem[] = [
{ id: 'notifyTrigger', routeName: 'notifyTrigger', label: 'هشدار قیمت', icon: 'vuesax-linear:bill' },
{ id: 'about', routeName: 'about', label: 'درباره ما', icon: 'vuesax-linear:info-circle' },
{ id: 'rules', routeName: 'rules', label: 'قوانین و مقررات', icon: 'vuesax-linear:task' },
{ id: 'giftCard', routeName: 'giftCard', label: 'ثبت کارت هدیه', icon: 'vuesax-linear:gift' },
]
export function getPrimaryNavItems(): AppNavItem[] {
return isBoursecuProject() ? BOURSECU_PRIMARY_NAV : DEFAULT_PRIMARY_NAV
}
+4
View File
@@ -0,0 +1,4 @@
/** Minimum viewport width for desktop app shell (sidebar layout). */
export const DESKTOP_LAYOUT_MIN_WIDTH = 768
export const DESKTOP_SIDEBAR_WIDTH = 260
+40
View File
@@ -0,0 +1,40 @@
<template>
<div class="desktop-app-layout">
<DesktopSidebar v-if="!hideMenu" />
<main
class="desktop-app-layout__main"
:class="{ 'desktop-app-layout__main--full': hideMenu }"
>
<router-view v-slot="{ Component, route }">
<component
:is="Component"
:key="route.fullPath"
class="desktop-app-layout__content"
/>
</router-view>
</main>
</div>
</template>
<script lang="ts" setup>
import { onUnmounted, watch } from 'vue'
import { useAppNavigation } from '../composables/useAppNavigation'
import DesktopSidebar from './DesktopSidebar.vue'
const NO_SIDEBAR_CLASS = 'app-layout--desktop-no-sidebar'
const { hideMenu } = useAppNavigation()
watch(
hideMenu,
(hidden) => {
document.documentElement.classList.toggle(NO_SIDEBAR_CLASS, hidden)
},
{ immediate: true },
)
onUnmounted(() => {
document.documentElement.classList.remove(NO_SIDEBAR_CLASS)
})
</script>
<style lang="less" src="./desktop-layout.less"></style>
+25
View File
@@ -0,0 +1,25 @@
<template>
<button
type="button"
class="desktop-nav-item"
:class="{ 'desktop-nav-item--active': active }"
@click="$emit('click')"
>
<BasicIcon :icon="icon" size="20" class="desktop-nav-item__icon" />
<span class="desktop-nav-item__label">{{ label }}</span>
</button>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
defineProps<{
icon: string
label: string
active: boolean
}>()
defineEmits<{
click: []
}>()
</script>
+55
View File
@@ -0,0 +1,55 @@
<template>
<aside
class="desktop-sidebar"
:class="{ 'desktop-sidebar--boursecu': isBoursecu }"
>
<div class="desktop-sidebar__brand">
<img
:src="navLogo"
alt=""
class="desktop-sidebar__logo"
/>
</div>
<nav class="desktop-sidebar__section" aria-label="منوی اصلی">
<DesktopNavItem
v-for="item in primaryItems"
:key="item.id"
:icon="item.icon"
:label="item.label"
:active="isNavActive(item.routeName)"
@click="navigateTo(item.routeName)"
/>
</nav>
<div class="desktop-sidebar__divider" />
<nav class="desktop-sidebar__section desktop-sidebar__section--secondary" aria-label="منوی فرعی">
<DesktopNavItem
v-for="item in secondaryItems"
:key="item.id"
:icon="item.icon"
:label="item.label"
:active="isNavActive(item.routeName)"
@click="navigateTo(item.routeName)"
/>
</nav>
</aside>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useConfigStore } from '@/store/configStore'
import { getProjectNavLogo, isBoursecuProject } from '@/utils/config'
import { getPrimaryNavItems, SECONDARY_NAV } from '../config/navigation'
import { useAppNavigation } from '../composables/useAppNavigation'
import DesktopNavItem from './DesktopNavItem.vue'
const config = useConfigStore()
const isBoursecu = isBoursecuProject()
const navLogo = computed(() => getProjectNavLogo(config.config?.avatar))
const primaryItems = getPrimaryNavItems()
const secondaryItems = SECONDARY_NAV
const { isNavActive, navigateTo } = useAppNavigation()
</script>
+157
View File
@@ -0,0 +1,157 @@
.desktop-app-layout {
display: flex;
flex-direction: row;
min-height: 100vh;
width: 100%;
max-width: 100vw;
background: @prefixCls-background-color;
}
.desktop-app-layout__main {
flex: 1;
min-width: 0;
height: 100vh;
overflow: hidden;
}
.desktop-app-layout__main--full {
width: 100%;
}
.desktop-app-layout__content {
height: 100vh;
width: 100%;
min-height: 100vh;
overflow-y: auto;
overflow-x: hidden;
background: @prefixCls-background-color;
}
.desktop-sidebar {
flex-shrink: 0;
width: var(--app-desktop-sidebar-width, 260px);
height: 100vh;
display: flex;
flex-direction: column;
background: #fff;
position: sticky;
top: 0;
z-index: 100;
padding: 20px 12px 16px;
box-sizing: border-box;
}
.desktop-sidebar--boursecu {
background: var(--App-brand-nav, #133050);
.desktop-sidebar__divider {
background: rgba(255, 255, 255, 0.12);
}
.desktop-nav-item {
color: rgba(255, 255, 255, 0.72);
&:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
&--active {
background: rgba(255, 255, 255, 0.14);
color: #fff;
.desktop-nav-item__icon {
color: #fff;
}
}
}
.desktop-nav-item__icon {
color: rgba(255, 255, 255, 0.85);
}
.desktop-sidebar__logo {
max-width: 200px;
max-height: 80px;
}
}
.desktop-sidebar__brand {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 12px 20px;
margin-bottom: 4px;
}
.desktop-sidebar__logo {
max-width: 140px;
max-height: 48px;
width: auto;
height: auto;
object-fit: contain;
}
.desktop-sidebar__section {
display: flex;
flex-direction: column;
gap: 4px;
}
.desktop-sidebar__section--secondary {
margin-top: auto;
}
.desktop-sidebar__divider {
height: 1px;
background: rgba(0, 0, 0, 0.08);
margin: 12px 8px;
}
.desktop-nav-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
text-align: right;
font-family: inherit;
font-size: 14px;
font-weight: 600;
color: #6b7280;
transition: background 0.15s ease, color 0.15s ease;
&:hover {
background: rgba(0, 0, 0, 0.04);
color: @App-primary-color;
}
&--active {
background: rgba(0, 0, 0, 0.05);
color: @App-primary-color;
.desktop-nav-item__icon {
color: @App-primary-color;
}
}
}
.desktop-nav-item__icon {
flex-shrink: 0;
color: #9ca3af;
}
.desktop-nav-item__label {
flex: 1;
line-height: 1.3;
}
.desktop-sidebar__section--secondary .desktop-nav-item {
font-size: 13px;
font-weight: 500;
padding: 8px 12px;
}
@@ -0,0 +1,83 @@
/* Shared desktop page primitives */
.dpage-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__back {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
color: #6b7280;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
padding: 8px 0;
&:hover {
color: #111827;
}
}
&__meta {
flex: 1;
min-width: 0;
}
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin: 0 0 4px;
}
&__title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.03em;
color: #111827;
margin: 0;
line-height: 1.2;
}
&__subtitle {
font-size: 14px;
color: #6b7280;
margin: 6px 0 0;
line-height: 1.6;
}
}
.dpage-surface {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.dpage-support {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
> a:first-child {
flex: 1;
min-width: 0;
}
&__bale {
width: 40px;
height: 40px;
object-fit: contain;
flex-shrink: 0;
}
}
+57
View File
@@ -0,0 +1,57 @@
<template>
<div>
<MobileOverflowMenu
:visible="popVisible"
:overflow-items="overflowItems"
@close="closeOverflowMenu"
@navigate="navigateTo"
/>
<div :class="['main-with-bottom-chrome', { 'main-with-bottom-chrome--no-nav': hideMenu }]">
<router-view v-slot="{ Component, route }">
<transition :name="route.meta.transition || 'fade'">
<component :is="Component" class="main-with-bottom-chrome-inner" />
</transition>
</router-view>
</div>
<MobileBottomNav
v-if="!hideMenu"
:is-boursecu="isBoursecu"
:nav-logo="navLogo"
:nav-item-class="navItemClass"
@navigate="navigateTo"
@toggle-overflow="toggleOverflowMenu"
/>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useConfigStore } from '@/store/configStore'
import { getProjectNavLogo, isBoursecuProject } from '@/utils/config'
import { SECONDARY_NAV } from '../config/navigation'
import { useAppNavigation } from '../composables/useAppNavigation'
import MobileBottomNav from './MobileBottomNav.vue'
import MobileOverflowMenu from './MobileOverflowMenu.vue'
const config = useConfigStore()
const isBoursecu = isBoursecuProject()
const navLogo = computed(() => getProjectNavLogo(config.config?.avatar))
const {
popVisible,
hideMenu,
navItemClass,
navigateTo,
toggleOverflowMenu,
closeOverflowMenu,
} = useAppNavigation()
const overflowItems = [
{ id: 'user', routeName: 'user', label: 'حساب کاربری', icon: 'vuesax-linear:user' },
...SECONDARY_NAV,
]
</script>
<style lang="less" src="./mobile-layout.less"></style>
+107
View File
@@ -0,0 +1,107 @@
<template>
<div class="bottom-appbar w-auto mx-auto bg-white">
<div class="tabs bottom-0">
<div
v-if="isBoursecu"
:class="navItemClass('buyProduct')"
class="tab"
@click="$emit('navigate', 'buyProduct')"
>
<Icon icon="vuesax-linear:box" />
<div class="text-xs mt-1 font-bold">سفارش ها</div>
</div>
<div
v-else
:class="navItemClass('home')"
class="tab"
@click="$emit('navigate', 'home')"
>
<Icon icon="vuesax-linear:home" />
<div class="text-xs mt-1 font-bold">صفحه اصلی</div>
</div>
<div
:class="navItemClass('transactions')"
class="tab"
@click="$emit('navigate', 'transactions')"
>
<Icon icon="vuesax-linear:receipt-item" />
<div class="text-xs mt-1 font-bold">{{ isBoursecu ? 'تراکنش ها' : 'گزارش' }}</div>
</div>
<div
v-if="isBoursecu"
:class="navItemClass('home')"
class="tab"
@click="$emit('navigate', 'home')"
>
<Icon icon="vuesax-linear:home" />
<div class="text-xs mt-1 font-bold">صفحه اصلی</div>
</div>
<div v-else class="main_in" @click="$emit('navigate', 'buyProduct')">
<div
class="circle_in"
:class="isWhiteNavLogo ? 'is-white-nav-logo' : 'text-white'"
>
<div class="circle_into" @click.stop="$emit('toggle-overflow')">
<div
v-if="projectName === 'garoosi'"
class="p-2 text-tertiary flex flex-col justify-center items-center"
>
<Icon icon="vuesax-linear:coin" />
<div class="text-[9px] font-bold">محصولات</div>
</div>
<img
v-else
class="p-2"
:src="navLogo"
alt=""
/>
</div>
</div>
</div>
<div
:class="navItemClass('wallet')"
class="tab"
@click="$emit('navigate', 'wallet')"
>
<Icon icon="vuesax-linear:wallet-1" />
<div class="text-xs mt-1 font-bold">کیف پول</div>
</div>
<div
:class="navItemClass('user')"
class="tab"
@click="$emit('navigate', 'user')"
>
<Icon icon="vuesax-linear:setting-2" />
<div class="text-xs mt-1 font-bold">تنظیمات</div>
</div>
</div>
<div v-if="!isBoursecu" class="main">
<div class="circle" />
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { getConfigApp } from '@/utils/config'
defineProps<{
isBoursecu: boolean
navLogo: string
navItemClass: (routeName: string) => string
}>()
defineEmits<{
navigate: [routeName: string]
'toggle-overflow': []
}>()
const projectName = getConfigApp('name')
const whiteNavLogoProjects = ['goldenramin']
const isWhiteNavLogo = computed(() => whiteNavLogoProjects.includes(projectName))
</script>
+53
View File
@@ -0,0 +1,53 @@
<template>
<Teleport to="body">
<Transition>
<div
v-if="visible"
class="top-0 bottom-0 bg-primary wrap-fix z-2000 opacity-60"
@click="$emit('close')"
/>
</Transition>
<Transition name="slide-fade">
<div v-if="visible" class="wrap-fix bottom-11vh z-2000">
<div class="relative flex flex-col items-center">
<div
class="flex items-center justify-center bg-white rounded-full w-[30px] h-[30px] mb-2"
@click="$emit('close')"
>
<BasicIcon icon="vuesax-linear:close-circle" />
</div>
<div class="flex flex-col items-center gap-2">
<BasicButton
v-for="item in overflowItems"
:key="item.id"
icon-size="16"
class="!font-bold w-15rem"
@click="$emit('navigate', item.routeName)"
>
<div class="flex gap-2 items-center">
<BasicIcon :icon="item.icon" size="16" class="absolute right-2" />
<div>{{ item.label }}</div>
</div>
</BasicButton>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script lang="ts" setup>
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import type { AppNavItem } from '../types'
defineProps<{
visible: boolean
overflowItems: AppNavItem[]
}>()
defineEmits<{
close: []
navigate: [routeName: string]
}>()
</script>
+145
View File
@@ -0,0 +1,145 @@
.bottom-appbar {
z-index: var(--app-z-nav, 5000);
height: var(--app-nav-height, 80px);
position: fixed;
display: flex;
justify-content: space-evenly;
background: #fff;
bottom: var(--app-credit-footer-offset, 28px);
right: 0;
left: 0;
width: 100%;
max-width: 100vw;
box-sizing: border-box;
@media screen and (min-width: 426px) {
width: 426px;
max-width: 426px;
left: 50%;
right: auto;
transform: translateX(-50%);
}
.tabs {
display: flex;
height: 100%;
width: 100%;
background: #fff;
justify-content: space-between;
position: absolute;
bottom: 0;
z-index: 400;
box-shadow: 0 -2px 4px -2px #0000001a;
.tab {
width: 80px;
text-align: center;
flex-flow: column;
display: flex;
align-items: center;
justify-content: center;
align-content: center;
}
.main_in {
width: 70px;
height: 70px;
flex-shrink: 0;
align-self: center;
.circle_in {
margin-top: -10px;
border-radius: 50%;
width: 70px;
height: 70px;
flex-shrink: 0;
z-index: 20;
display: flex;
justify-content: center;
align-items: center;
align-content: center;
flex-wrap: wrap;
background-color: @App-primary-color;
.circle_into {
overflow: hidden;
@apply bg-primary;
border-radius: 50%;
width: 56px;
height: 56px;
flex-shrink: 0;
display: flex;
justify-content: center;
align-content: center;
flex-wrap: wrap;
border: white 1px solid;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
.circle_in.is-white-nav-logo {
.circle_into {
background: #fff !important;
border-color: rgba(0, 0, 0, 0.08);
}
}
}
}
.main {
height: 70px;
width: 70px;
position: relative;
.circle {
border-radius: 50%;
height: 70px;
width: 70px;
z-index: 20;
box-shadow: 0 -5px 4px -2px #0000001a;
position: absolute;
}
}
}
.v-enter-active,
.v-leave-active {
transition: opacity 0.5s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
.slide-fade-enter-active {
transition: all 0.3s ease-out;
}
.slide-fade-leave-active {
transition: all 0.4s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateX(20px);
opacity: 0;
}
.main-with-bottom-chrome {
height: calc(100vh - 72px - var(--app-credit-footer-offset, 28px));
}
.main-with-bottom-chrome-inner {
height: calc(100vh - 72px - var(--app-credit-footer-offset, 28px));
}
.main-with-bottom-chrome--no-nav,
.main-with-bottom-chrome--no-nav .main-with-bottom-chrome-inner {
height: calc(100vh - var(--app-credit-footer-offset, 28px));
}
+126
View File
@@ -0,0 +1,126 @@
@desktop-layout-min: 768px;
@mobile-frame-max: 767.98px;
/**
* Full-width desktop shell — applies on first paint (no JS class needed).
* Sidebar-specific wrap-fix offset stays on html.app-layout--desktop.
*/
@media screen and (min-width: @desktop-layout-min) {
html {
width: 100%;
overflow-x: hidden;
}
body {
display: block;
width: 100%;
min-width: 100%;
margin: 0;
padding: 0;
}
#app {
width: 100%;
max-width: none;
min-height: 100vh;
}
.desktopConfig {
position: static !important;
width: 100% !important;
max-width: none !important;
min-height: 100vh;
left: auto !important;
right: auto !important;
}
.wrap-fix {
width: auto !important;
max-width: none !important;
left: 0 !important;
right: 0 !important;
}
:root {
--app-credit-bar-inner: 0px;
--app-credit-footer-offset: 0px;
}
.app-credit-footer {
display: none !important;
}
/* Teleport to body escapes parent v-show — hide mobile dock chrome on desktop */
.app-dock-add-bar,
.wallet-card-slider {
display: none !important;
}
.transaction-list,
.card-slider-wrap {
width: 100% !important;
max-width: none !important;
left: 0 !important;
right: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
}
html.app-layout--desktop {
@media screen and (min-width: @desktop-layout-min) {
.wrap-fix {
right: var(--app-desktop-sidebar-width, 260px) !important;
}
}
&.app-layout--desktop-no-sidebar .wrap-fix {
right: 0 !important;
}
}
/* Splash — always full viewport; never sidebar-offset */
.app-splash-screen {
position: fixed;
inset: 0;
width: 100vw;
min-height: 100vh;
max-width: none;
left: 0 !important;
right: 0 !important;
z-index: 1000000000000;
&__gradient {
position: absolute;
left: 0;
right: 0;
width: 100%;
&--top {
top: 0;
}
&--bottom {
bottom: 0;
}
}
}
html.app-layout--desktop .app-splash-screen {
right: 0 !important;
left: 0 !important;
width: 100vw !important;
}
/* Login — full viewport on desktop */
html.app-layout--desktop {
.login-page-root,
.dauth {
width: 100% !important;
min-height: 100vh;
}
.dauth__layout {
min-height: 100vh;
}
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Mobile shell width rules.
* Below 426px: full viewport (phones).
* 426px–767px: centered 426px frame (tablet-ish mobile).
*/
html:not(.app-layout--desktop) {
/* Teleport to body — dock bar must span viewport, not shrink to content */
.app-dock-add-bar,
.wallet-card-slider {
box-sizing: border-box;
}
@media screen and (max-width: 425.98px) {
body {
display: block !important;
width: 100% !important;
overflow-x: hidden;
}
#app,
.desktopConfig,
.app-main,
.main-with-bottom-chrome,
.main-with-bottom-chrome-inner {
width: 100% !important;
max-width: 100vw !important;
min-width: 0 !important;
margin: 0 !important;
}
.wrap-fix,
.bottom-appbar {
width: 100% !important;
max-width: 100vw !important;
left: 0 !important;
right: 0 !important;
margin: 0 !important;
transform: none !important;
}
.app-dock-add-bar,
.wallet-card-slider {
width: 100% !important;
max-width: 100vw !important;
left: 0 !important;
right: 0 !important;
margin: 0 !important;
transform: none !important;
}
}
@media screen and (min-width: 426px) and (max-width: 767.98px) {
body {
display: flex !important;
justify-content: center;
}
.desktopConfig {
width: 426px !important;
max-width: 426px !important;
}
.wrap-fix,
.bottom-appbar {
width: 426px !important;
max-width: 426px !important;
left: 50% !important;
right: auto !important;
margin: 0 !important;
transform: translateX(-50%) !important;
}
.app-dock-add-bar,
.wallet-card-slider {
width: 426px !important;
max-width: 426px !important;
left: 50% !important;
right: auto !important;
margin: 0 !important;
transform: translateX(-50%) !important;
}
}
}
+6
View File
@@ -0,0 +1,6 @@
export interface AppNavItem {
id: string
routeName: string
label: string
icon: string
}
+14 -10
View File
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
import { getSettings } from '@/api/configuration' import { getSettings } from '@/api/configuration'
import { resolveMeltedGoldLabel, resolveMetalTypeLabel } from '@/utils/metalLabels' import { resolveMeltedGoldLabel, resolveMetalTypeLabel } from '@/utils/metalLabels'
import { resolveTradeWeightUnit } from '@/utils/weightUnit' import { resolveTradeWeightUnit } from '@/utils/weightUnit'
import { runOnceAsync } from '@/composables/useOnceAsync'
export interface AppSetting { export interface AppSetting {
id?: number id?: number
@@ -45,17 +46,20 @@ export const useSettingsStore = defineStore('settings', () => {
settings.value = merged settings.value = merged
} }
async function fetchSettings() { async function fetchSettings(force = false) {
try { return runOnceAsync('settings:all', async () => {
const data = await getSettings() try {
if (Array.isArray(data)) { const data = await getSettings()
mergeSettings(data) if (Array.isArray(data)) {
mergeSettings(data)
}
} catch (e) {
console.log(e)
} finally {
loaded.value = true
} }
} catch (e) { return settings.value
console.log(e) }, force)
} finally {
loaded.value = true
}
} }
const metalTypeSettingValue = computed(() => getSettingByKey('metal_type')) const metalTypeSettingValue = computed(() => getSettingByKey('metal_type'))
+14 -1
View File
@@ -114,7 +114,7 @@ export function getProjectLoginTaglineLines() {
} }
return [ return [
'بورس مس مرجع هوشمند معاملات', 'مرجع هوشمند معاملات',
' و سرمایه‌گذاری در صنعت مس', ' و سرمایه‌گذاری در صنعت مس',
] ]
} }
@@ -123,6 +123,18 @@ export function getProjectSplashBackground() {
return isBoursecuProject() ? BOURSECU_BRAND_NAV : '#ffffff' return isBoursecuProject() ? BOURSECU_BRAND_NAV : '#ffffff'
} }
/** Reset splash-time inline backgrounds so mobile overscroll shows white again. */
export function resetAppSurfaceBackground() {
const background = '#ffffff'
document.documentElement.style.backgroundColor = background
document.body.style.backgroundColor = background
const metaTheme = document.querySelector('meta[name="theme-color"]')
if (metaTheme) {
metaTheme.setAttribute('content', background)
}
}
export function getProjectFontFamily() { export function getProjectFontFamily() {
return getConfigApp('name') === 'boursecu' ? "'dana', sans-serif" : "'iran-sans', sans-serif" return getConfigApp('name') === 'boursecu' ? "'dana', sans-serif" : "'iran-sans', sans-serif"
} }
@@ -142,6 +154,7 @@ export function applyProjectTheme() {
} }
document.documentElement.classList.add('project-boursecu') document.documentElement.classList.add('project-boursecu')
document.documentElement.style.setProperty('--App-brand-nav', BOURSECU_BRAND_NAV)
document.documentElement.style.setProperty('--App-brand-red', BOURSECU_BRAND_RED) document.documentElement.style.setProperty('--App-brand-red', BOURSECU_BRAND_RED)
document.documentElement.style.setProperty('--App-brand-red-50', '#f8ecec') document.documentElement.style.setProperty('--App-brand-red-50', '#f8ecec')
document.documentElement.style.setProperty('--App-brand-red-100', '#f0d8d8') document.documentElement.style.setProperty('--App-brand-red-100', '#f0d8d8')
+29
View File
@@ -0,0 +1,29 @@
const PERSIAN_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const
export function toPersianDigits(value: string | number | null | undefined): string {
if (value === null || value === undefined) return ''
return String(value).replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)] ?? digit)
}
export function toEnglishDigits(value: string): string {
return value.replace(/[۰-۹]/g, (digit) => {
const index = PERSIAN_DIGITS.indexOf(digit as (typeof PERSIAN_DIGITS)[number])
return index >= 0 ? String(index) : digit
})
}
export function formatFixed(value: string | number | null | undefined, decimals = 2): string {
const num = Number(value)
if (!Number.isFinite(num)) return toPersianDigits(String(value ?? ''))
return toPersianDigits(num.toFixed(decimals))
}
export function formatPercent(
value: number,
decimals = 2,
options: { showSign?: boolean; suffix?: string } = {},
): string {
const { showSign = false, suffix = '%' } = options
const prefix = showSign && value > 0 ? '+' : value < 0 ? '-' : showSign ? '+' : ''
return `${prefix}${formatFixed(Math.abs(value), decimals)}${suffix}`
}
+1
View File
@@ -1,3 +1,4 @@
import { toPersianDigits } from '@/utils/persianDigits'
import type { Product } from '@/models/products' import type { Product } from '@/models/products'
import type { UserWalletLine } from '@/models/wallet' import type { UserWalletLine } from '@/models/wallet'
import type { ProductFile } from '@/utils/file' import type { ProductFile } from '@/utils/file'
+26 -2
View File
@@ -2,13 +2,37 @@ import type { Product } from '@/models/products'
import { getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price' import { getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price'
import { isMetalCommodityGroup, isMeltedProductUsingAppPrice, isMeltedTradeGroup } from '@/utils/productCategories' import { isMetalCommodityGroup, isMeltedProductUsingAppPrice, isMeltedTradeGroup } from '@/utils/productCategories'
function applyPriceOffsetPercent(basePrice: number, offsetPercent?: number | null) { export function resolvePriceOffsetRatio(offsetPercent?: number | null) {
const offset = Number(offsetPercent ?? 0) const offset = Number(offsetPercent ?? 0)
if (!offset) { if (!offset) {
return 0
}
// 5 => 5% ، 0.05 => 5% (اعشاری)
if (Math.abs(offset) > 0 && Math.abs(offset) < 1) {
return offset
}
return offset / 100
}
export function applyPriceOffsetPercent(basePrice: number, offsetPercent?: number | null) {
const ratio = resolvePriceOffsetRatio(offsetPercent)
if (!ratio) {
return basePrice return basePrice
} }
return Math.round(basePrice * (1 + offset / 100)) return Math.round(basePrice * (1 + ratio))
}
/** boursecu chart: قیمت + (قیمت × offset) */
export function applyChartPriceOffset(basePrice: number, offsetPercent?: number | null) {
const ratio = resolvePriceOffsetRatio(offsetPercent)
if (!ratio) {
return basePrice
}
return Math.round(basePrice + basePrice * ratio)
} }
/** قیمت خرید نمایشی محصول (همان مقداری که در لیست و اکشن‌شیت نشان داده می‌شود) */ /** قیمت خرید نمایشی محصول (همان مقداری که در لیست و اکشن‌شیت نشان داده می‌شود) */
@@ -0,0 +1,67 @@
<template>
<div
:class="variant === 'desktop'
? (isBoursecu ? 'dabout-content--boursecu' : 'dabout-content--default')
: (isBoursecuProject()
? 'mt-5 border-solid border-2 border-primary text-primary rounded-[12px] p-4 text-xs leading-5'
: 'bg-primary text-white rounded-[12px] p-4 text-xs leading-5')"
class="dabout-content"
>
<template v-for="(section, sectionIndex) in aboutSections" :key="sectionIndex">
<div
v-if="sectionIndex > 0"
:class="variant === 'desktop' ? 'dabout-content__divider' : 'w-full h-1px my-3 bg-tertiary'"
></div>
<div
v-if="section.title"
:class="variant === 'desktop' ? 'dabout-content__title' : 'font-bold text-sm mb-2'"
>
{{ section.title }}
</div>
<div
v-for="(paragraph, paragraphIndex) in section.paragraphs ?? []"
:key="`paragraph-${sectionIndex}-${paragraphIndex}`"
:class="[
variant === 'desktop' ? 'dabout-content__paragraph' : '',
paragraphIndex > 0 ? 'mt-3' : '',
]"
>
{{ paragraph }}
</div>
<ul
v-if="(section.items ?? []).length"
:class="variant === 'desktop' ? 'dabout-content__list' : ''"
>
<li
v-for="(item, itemIndex) in section.items ?? []"
:key="`item-${sectionIndex}-${itemIndex}`"
:class="variant === 'desktop' ? 'dabout-content__list-item' : 'mt-2'"
>
<template v-if="variant !== 'desktop'">• {{ item }}</template>
<template v-else>{{ item }}</template>
</li>
</ul>
</template>
<div
v-if="variant !== 'desktop'"
class="w-full h-1px my-3 bg-tertiary"
></div>
</div>
</template>
<script lang="ts" setup>
import { useAboutPageContext } from '../composables/useAboutPage'
withDefaults(
defineProps<{
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
const {
aboutSections,
isBoursecu,
isBoursecuProject,
} = useAboutPageContext()
</script>
@@ -0,0 +1,41 @@
<template>
<div :class="variant === 'desktop' ? 'dabout-support' : 'wrap-fix bottom-0 px-10 pb-3rem flex gap-2 bg-white'">
<a class="w-full" :href="supportHref">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a :href="baleHref" target="_blank" rel="noopener noreferrer">
<img src="@/assets/img/bale.png" alt="بله" :class="variant === 'desktop' ? 'dabout-support__bale' : 'h-10 w-10'" />
</a>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { useAboutPageContext } from '../composables/useAboutPage'
const props = withDefaults(
defineProps<{
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
const {
config,
getProjectSupportPhoneLink,
getProjectBaleLink,
} = useAboutPageContext()
const supportHref = computed(() =>
config.config.name === 'garoosi'
? 'tel:09031265925'
: getProjectSupportPhoneLink(),
)
const baleHref = computed(() =>
config.config.name === 'garoosi'
? 'https://ble.ir/garoosigallery'
: getProjectBaleLink(),
)
</script>
@@ -0,0 +1,48 @@
import { computed, inject, provide, type InjectionKey, type ComputedRef } from 'vue'
import { useConfigStore } from '@/store/configStore'
import {
getProjectBaleLink,
getProjectSupportPhoneLink,
isBoursecuProject,
} from '@/utils/config'
import { getAboutSections, type AboutSection } from '../aboutContent'
export interface AboutPageContext {
config: ReturnType<typeof useConfigStore>
aboutSections: ComputedRef<AboutSection[]>
isBoursecu: ComputedRef<boolean>
isBoursecuProject: typeof isBoursecuProject
getProjectSupportPhoneLink: typeof getProjectSupportPhoneLink
getProjectBaleLink: typeof getProjectBaleLink
}
export const ABOUT_PAGE_CONTEXT_KEY: InjectionKey<AboutPageContext> = Symbol('aboutPageContext')
export function useAboutPage() {
const config = useConfigStore()
const nameProject = config.config.name_fa
const isBoursecu = computed(() => isBoursecuProject())
const aboutSections = computed(() => getAboutSections(nameProject, isBoursecu.value))
const context: AboutPageContext = {
config,
aboutSections,
isBoursecu,
isBoursecuProject,
getProjectSupportPhoneLink,
getProjectBaleLink,
}
provide(ABOUT_PAGE_CONTEXT_KEY, context)
return context
}
export function useAboutPageContext() {
const context = inject(ABOUT_PAGE_CONTEXT_KEY)
if (!context) {
throw new Error('useAboutPageContext must be used within a page that calls useAboutPage()')
}
return context
}
@@ -0,0 +1,50 @@
<template>
<div class="dabout">
<div class="dabout__container">
<header class="dabout-topbar">
<button type="button" class="dabout-topbar__back" @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
<span>بازگشت</span>
</button>
<div class="dabout-topbar__meta">
<p class="dabout-topbar__eyebrow">اطلاعات</p>
<h1 class="dabout-topbar__title">درباره ما</h1>
</div>
</header>
<div class="dabout-layout">
<section class="dabout-surface dabout-main">
<div class="dabout-main__intro">
<div class="dabout-main__logo">
<BasicIcon icon="vuesax-linear:info-circle" size="28" />
</div>
<div>
<h2 class="dabout-main__project">{{ config.config.name_fa }}</h2>
<p class="dabout-main__tagline">آشنایی با خدمات و چشم‌انداز ما</p>
</div>
</div>
<AboutSectionsContent variant="desktop" />
</section>
<aside class="dabout-surface dabout-side">
<h3 class="dabout-side__title">پشتیبانی و ارتباط</h3>
<p class="dabout-side__text">
در صورت نیاز به راهنمایی یا پاسخ به سوالات، از راه‌های زیر با ما در ارتباط باشید.
</p>
<AboutSupportActions variant="desktop" />
</aside>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import AboutSectionsContent from '../components/AboutSectionsContent.vue'
import AboutSupportActions from '../components/AboutSupportActions.vue'
import { useAboutPageContext } from '../composables/useAboutPage'
const { config } = useAboutPageContext()
</script>
@@ -0,0 +1,234 @@
/* Premium desktop about page */
.dabout {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.dabout__container {
max-width: 1080px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.dabout-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__back {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
color: #6b7280;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
padding: 8px 0;
&:hover {
color: #111827;
}
}
&__meta {
flex: 1;
min-width: 0;
}
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin: 0 0 4px;
}
&__title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.03em;
color: #111827;
margin: 0;
line-height: 1.2;
}
}
.dabout-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 300px);
gap: 20px;
align-items: start;
}
.dabout-surface {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.dabout-main {
padding: 28px;
&__intro {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #eef0f3;
}
&__logo {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 14px;
background: rgba(9, 61, 81, 0.08);
color: @App-primary-color;
flex-shrink: 0;
}
&__project {
font-size: 18px;
font-weight: 700;
color: #111827;
margin: 0 0 4px;
}
&__tagline {
font-size: 13px;
color: #6b7280;
margin: 0;
}
}
.dabout-content {
&--default {
padding: 0;
background: transparent;
color: #374151;
}
&--boursecu {
padding: 20px;
border: 1px solid rgba(9, 61, 81, 0.15);
border-radius: 12px;
background: rgba(9, 61, 81, 0.03);
color: @App-primary-color;
}
&__divider {
width: 100%;
height: 1px;
background: #eef0f3;
margin: 20px 0;
}
&__title {
font-size: 16px;
font-weight: 700;
color: inherit;
margin: 0 0 10px;
}
&__paragraph {
font-size: 14px;
line-height: 1.9;
color: inherit;
text-align: justify;
margin: 0 0 12px;
&:last-child {
margin-bottom: 0;
}
}
&__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
&__list-item {
position: relative;
padding-right: 16px;
font-size: 14px;
line-height: 1.7;
color: inherit;
&::before {
content: '';
position: absolute;
right: 0;
top: 10px;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.6;
}
}
}
.dabout-side {
padding: 24px;
position: sticky;
top: 24px;
&__title {
font-size: 15px;
font-weight: 600;
color: #111827;
margin: 0 0 8px;
}
&__text {
font-size: 13px;
line-height: 1.7;
color: #6b7280;
margin: 0 0 20px;
}
}
.dabout-support {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
> a:first-child {
flex: 1;
min-width: 0;
}
&__bale {
width: 40px;
height: 40px;
object-fit: contain;
flex-shrink: 0;
}
}
@media (max-width: 1023.98px) {
.dabout-layout {
grid-template-columns: 1fr;
}
.dabout-side {
position: static;
}
}
+12 -164
View File
@@ -1,176 +1,24 @@
<template> <template>
<div> <div class="about-page-root">
<div class="bg-white wrap-fix z-200001 py-3 px-3 text-primary font-bold drop-shadow flex"> <DesktopAboutView v-show="isDesktop" />
<div @click="router.back()" class="cursor-pointer"> <MobileAboutView v-show="!isDesktop" />
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">درباره ما</div>
</div>
<div class="pt-20">
<div>
<img :src="img" alt="" class="mt-2 w-full absolute bottom-0" />
</div>
<div class="text-justify text-base z-200000 relative">
<div class="px-10">
<!-- <img :src="logoFull" class="mb-20 mt-5" />-->
<div class="overflow-y-auto h-65vh pb-20">
<div class="bg-primary text-white rounded-[12px] p-4 text-xs leading-5">
<template v-for="(section, sectionIndex) in aboutSections" :key="sectionIndex">
<div v-if="sectionIndex > 0" class="w-full h-1px my-3 bg-tertiary"></div>
<div v-if="section.title" class="font-bold text-sm mb-2">
{{ section.title }}
</div>
<div
v-for="(paragraph, paragraphIndex) in section.paragraphs ?? []"
:key="`paragraph-${sectionIndex}-${paragraphIndex}`"
:class="paragraphIndex > 0 ? 'mt-3' : ''"
>
{{ paragraph }}
</div>
<div
v-for="(item, itemIndex) in section.items ?? []"
:key="`item-${sectionIndex}-${itemIndex}`"
class="mt-2"
>
• {{ item }}
</div>
</template>
<div class="w-full h-1px my-3 bg-tertiary"></div>
</div>
</div>
</div>
<div v-if="config.config.name === 'garoosi'" class="wrap-fix bottom-0 px-10 pb-3rem flex gap-2 bg-white">
<a class="w-full" href="tel:09031265925">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a href="https://ble.ir/garoosigallery">
<img src="@/assets/img/bale.png" alt="" class="h-10 w-10"/>
</a>
</div>
<div v-else class="wrap-fix bottom-0 px-10 pb-3rem flex gap-2 bg-white">
<a class="w-full" :href="getProjectSupportPhoneLink()">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a :href="getProjectBaleLink()" target="_blank" rel="noopener noreferrer">
<img src="@/assets/img/bale.png" alt="بله" class="h-10 w-10"/>
</a>
</div>
</div>
</div>
<!-- <ActionSheet v-model:show="editPassVisible">salam</ActionSheet>-->
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, reactive, ref, computed } from 'vue' import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import img from '@/assets/img/user_ly1.svg' import { useAboutPage } from './composables/useAboutPage'
import gr_bottom from './assets/img/gr_bottom_white.svg' import DesktopAboutView from './desktop/DesktopAboutView.vue'
import { loading } from '@/utils' import MobileAboutView from './mobile/MobileAboutView.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import { lengthValidation, required } from '@/utils/inputRules'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import type { User } from '@/models/login'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { updateMe } from '@/api/me'
import { useUserStore } from '@/store/userStore'
import {useConfigStore} from "@/store/configStore";
import {getProjectBaleLink, getProjectSupportPhoneLink, getReferralShareText, getReferralShareTitle, isBoursecuProject} from "@/utils/config";
import { getAboutSections } from './aboutContent'
const config=useConfigStore() const isDesktop = useDesktopBreakpoint()
const userFormState = ref<User>({ useAboutPage()
fname: '',
lname: '',
mobile: undefined,
birth_date: '',
phone_number: '',
province: '',
city: '',
n_code: undefined
})
const formRef = ref<FormInstance>()
const userStore = useUserStore()
const nameProject= useConfigStore().config.name_fa
const aboutSections = computed(() => getAboutSections(nameProject, isBoursecuProject()))
// const editPassVisible = ref<boolean>(false)
const editPassVisible = ref<boolean>(false)
function setForm() {
userFormState.value = userStore.userInfo
}
function copyCode() {
// nextTick(() => {
const val = userStore.userInfo?.referral_id
navigator.clipboard.writeText(val)
message.info('کد رونوشت شد')
// })
}
function shareCode() {
const val = getReferralShareText({
userName: userStore.userInfo?.name,
projectName: nameProject,
referralId: userStore.userInfo?.referral_id,
})
navigator.share({
title: getReferralShareTitle(nameProject),
text: val
})
}
const resetData = () => {
formRef.value?.resetFields()
userFormState.value = {
fname: '',
lname: '',
mobile: undefined,
birth_date: '',
phone_number: '',
province: '',
city: '',
n_code: undefined
}
}
onMounted(() => {
setForm()
})
function ok() {
formRef.value?.validateFields().then(async () => {
try {
loading.value = true
await updateMe(userFormState.value)
await userStore.getMyInfo()
editPassVisible.value = false
message.success('اطلاعات شما با موفقیت به روز رسانی شد')
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
}
</script> </script>
<style scoped> <style scoped>
/*.fade-enter-active, .about-page-root {
.fade-leave-active { min-height: 100%;
transition: opacity 0.2s ease; height: 100%;
} }
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}*/
/*.trans{
transition: height 1s;
}*/
</style> </style>
@@ -0,0 +1,31 @@
<template>
<div>
<div class="bg-white wrap-fix z-200001 py-3 px-3 text-primary font-bold drop-shadow flex">
<div class="cursor-pointer" @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">درباره ما</div>
</div>
<div class="pt-20">
<div>
<img :src="img" alt="" class="mt-2 w-full absolute bottom-0" />
</div>
<div class="text-justify text-base z-200000 relative">
<div class="px-10">
<div class="overflow-y-auto h-65vh pb-20">
<AboutSectionsContent variant="mobile" />
</div>
</div>
<AboutSupportActions variant="mobile" />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import img from '@/assets/img/user_ly1.svg'
import router from '@/router'
import AboutSectionsContent from '../components/AboutSectionsContent.vue'
import AboutSupportActions from '../components/AboutSupportActions.vue'
</script>
@@ -0,0 +1,55 @@
<template>
<div
:class="variant === 'desktop'
? 'daboutus-content'
: 'mt-5 text-5 border-solid border-2 border-[#093D51] text-[#093D51] rounded-[12px] p-4'"
>
<img
v-if="variant !== 'desktop'"
:src="config.config?.avatar"
class="mb-2 mt-5 w-10rem"
style="justify-self: center"
alt=""
/>
<div :class="variant === 'desktop' ? 'daboutus-content__body' : 'text-sm leading-7 space-y-2'">
<h2 :class="variant === 'desktop' ? 'daboutus-content__title' : 'text-base font-bold mb-3'">درباره ما</h2>
<div :class="variant === 'desktop' ? 'daboutus-content__paragraph' : 'mt-3'">
گروسی گالری بستری امن و آنلاین برای خرید و فروش طلای آبشده فراهم کرده تا کاربران بتوانند سرمایه‌های خرد خود را به راحتی پس‌انداز و مدیریت کنند.
</div>
<div :class="variant === 'desktop' ? 'daboutus-content__paragraph' : 'mt-3'">
هدف ما ایجاد تجربه‌ای سریع، شفاف و کاربرپسند در معاملات طلای آبشده است.
</div>
</div>
<div :class="variant === 'desktop' ? 'daboutus-content__enamad' : ''" style="justify-self: center">
<a
referrerpolicy="origin"
target="_blank"
href="https://trustseal.enamad.ir/?id=710578&Code=DdltBYWJgvc7LJs9eM46SEliXRRU0kSW"
>
<img
referrerpolicy="origin"
src="https://trustseal.enamad.ir/logo.aspx?id=710578&Code=DdltBYWJgvc7LJs9eM46SEliXRRU0kSW"
alt="enamad"
style="cursor:pointer"
code="DdltBYWJgvc7LJs9eM46SEliXRRU0kSW"
/>
</a>
</div>
</div>
</template>
<script lang="ts" setup>
import { useAboutUsPageContext } from '../composables/useAboutUsPage'
withDefaults(
defineProps<{
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
const { config } = useAboutUsPageContext()
</script>
@@ -0,0 +1,27 @@
import { inject, provide, type InjectionKey } from 'vue'
import { useConfigStore } from '@/store/configStore'
export interface AboutUsPageContext {
config: ReturnType<typeof useConfigStore>
}
export const ABOUT_US_PAGE_CONTEXT_KEY: InjectionKey<AboutUsPageContext> = Symbol('aboutUsPageContext')
export function useAboutUsPage() {
const config = useConfigStore()
const context: AboutUsPageContext = {
config,
}
provide(ABOUT_US_PAGE_CONTEXT_KEY, context)
return context
}
export function useAboutUsPageContext() {
const context = inject(ABOUT_US_PAGE_CONTEXT_KEY)
if (!context) {
throw new Error('useAboutUsPageContext must be used within a page that calls useAboutUsPage()')
}
return context
}
@@ -0,0 +1,41 @@
<template>
<div class="daboutus">
<div class="daboutus__container">
<header class="daboutus-topbar">
<button type="button" class="daboutus-topbar__back" @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
<span>بازگشت</span>
</button>
<div class="daboutus-topbar__meta">
<p class="daboutus-topbar__eyebrow">اطلاعات</p>
<h1 class="daboutus-topbar__title">درباره ما</h1>
</div>
</header>
<section class="daboutus-surface daboutus-main">
<div class="daboutus-main__intro">
<div class="daboutus-main__logo">
<img :src="config.config?.avatar" alt="" class="daboutus-main__avatar" />
</div>
<div>
<h2 class="daboutus-main__project">{{ config.config.name_fa }}</h2>
<p class="daboutus-main__tagline">آشنایی با خدمات و چشم‌انداز ما</p>
</div>
</div>
<div class="daboutus-main__scroll">
<AboutUsContentPanel variant="desktop" />
</div>
</section>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import AboutUsContentPanel from '../components/AboutUsContentPanel.vue'
import { useAboutUsPageContext } from '../composables/useAboutUsPage'
const { config } = useAboutUsPageContext()
</script>
@@ -0,0 +1,168 @@
/* Premium desktop about-us page */
.daboutus {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.daboutus__container {
max-width: 860px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.daboutus-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__back {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
color: #6b7280;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
padding: 8px 0;
&:hover {
color: #111827;
}
}
&__meta {
flex: 1;
min-width: 0;
}
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin: 0 0 4px;
}
&__title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.03em;
color: #111827;
margin: 0;
line-height: 1.2;
}
}
.daboutus-surface {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.daboutus-main {
padding: 28px;
display: flex;
flex-direction: column;
min-height: 0;
max-height: calc(100vh - 180px);
&__intro {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #eef0f3;
flex-shrink: 0;
}
&__logo {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 14px;
background: rgba(9, 61, 81, 0.08);
flex-shrink: 0;
overflow: hidden;
}
&__avatar {
width: 100%;
height: 100%;
object-fit: contain;
padding: 6px;
}
&__project {
font-size: 18px;
font-weight: 700;
color: #111827;
margin: 0 0 4px;
}
&__tagline {
font-size: 13px;
color: #6b7280;
margin: 0;
}
&__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-left: 4px;
margin-left: -4px;
}
}
.daboutus-content {
padding: 20px;
border: 1px solid rgba(9, 61, 81, 0.15);
border-radius: 12px;
background: rgba(9, 61, 81, 0.03);
color: @App-primary-color;
&__title {
font-size: 16px;
font-weight: 700;
color: inherit;
margin: 0 0 12px;
}
&__paragraph {
font-size: 14px;
line-height: 1.9;
color: inherit;
text-align: justify;
margin: 0 0 12px;
&:last-child {
margin-bottom: 0;
}
}
&__enamad {
display: flex;
justify-content: center;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid rgba(9, 61, 81, 0.12);
}
}
@media (max-width: 1023.98px) {
.daboutus-main {
max-height: none;
}
}
+13 -53
View File
@@ -1,64 +1,24 @@
<template> <template>
<div> <div class="about-us-page-root">
<div class="wrap-fix z-1 bg-white py-3 px-3 text-black font-bold drop-shadow flex"> <DesktopAboutUsView v-show="isDesktop" />
<div @click="router.back()" class="cursor-pointer"> <MobileAboutUsView v-show="!isDesktop" />
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">درباره ما</div>
</div>
<div class="pt-20">
<div>
<img :src="img" alt="" class="mt-2 w-full absolute bottom-0" />
</div>
<div class="text-justify text-base z-200000 relative">
<div class="px-10">
<img :src="config.config?.avatar" class="mb-2 mt-5 w-10rem" style="justify-self: center"/>
<div class="overflow-y-auto h-65vh pb-20 border-[#093D51]">
<div
class="mt-5 text-5 border-solid border-2 border-[#093D51] text-[#093D51] rounded-[12px] p-4"
>
<div class="text-sm leading-7 space-y-2">
<h2 class="text-base font-bold mb-3">درباره ما</h2>
<div class="mt-3"> گروسی گالری بستری امن و آنلاین برای خرید و فروش طلای آبشده فراهم کرده تا کاربران بتوانند سرمایه‌های خرد خود را به راحتی پس‌انداز و مدیریت کنند. </div>
<div class="mt-3"> هدف ما ایجاد تجربه‌ای سریع، شفاف و کاربرپسند در معاملات طلای آبشده است. </div>
<!-- <div class="mt-4 border-t pt-3">
<h3 class="font-semibold mb-2">اطلاعات تماس</h3>
<div class="space-y-1">
<div> <span class="font-medium">ایمیل:</span> <a href="mailto:hajiabolfath@gmail.com" class="text-blue-600">hajiabolfath@gmail.com</a> </div>
<div> <span class="font-medium">کد پستی:</span> ۳۷۱۳۷۶۴۸۹۰ </div>
<div> <span class="font-medium">آدرس:</span> استان قم، شهرستان قم، بخش مرکزی، شهر قم، محله ارم (بازار)، کوچه مرعشی نجفی ۸، خیابان آیت‌الله مرعشی نجفی (ارم)، تجاری ارم، طبقه ۱، واحد ۱۵۵</div>
</div>
</div>-->
</div>
<div style="justify-self: center">
<a referrerpolicy='origin' target='_blank' href='https://trustseal.enamad.ir/?id=710578&Code=DdltBYWJgvc7LJs9eM46SEliXRRU0kSW'>
<img referrerpolicy='origin' src='https://trustseal.enamad.ir/logo.aspx?id=710578&Code=DdltBYWJgvc7LJs9eM46SEliXRRU0kSW'
alt='enamad'
style='cursor:pointer'
code='DdltBYWJgvc7LJs9eM46SEliXRRU0kSW'
>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import {useConfigStore} from "@/store/configStore"; import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import img from '@/assets/img/user_ly1.svg' import { useAboutUsPage } from './composables/useAboutUsPage'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue' import DesktopAboutUsView from './desktop/DesktopAboutUsView.vue'
import router from '@/router' import MobileAboutUsView from './mobile/MobileAboutUsView.vue'
const config = useConfigStore() const isDesktop = useDesktopBreakpoint()
useAboutUsPage()
</script> </script>
<style scoped> <style scoped>
.about-us-page-root {
min-height: 100%;
height: 100%;
}
</style> </style>
@@ -0,0 +1,29 @@
<template>
<div>
<div class="wrap-fix z-1 bg-white py-3 px-3 text-black font-bold drop-shadow flex">
<div class="cursor-pointer" @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">درباره ما</div>
</div>
<div class="pt-20">
<div>
<img :src="img" alt="" class="mt-2 w-full absolute bottom-0" />
</div>
<div class="text-justify text-base z-200000 relative">
<div class="px-10">
<div class="overflow-y-auto h-65vh pb-20 border-[#093D51]">
<AboutUsContentPanel variant="mobile" />
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import img from '@/assets/img/user_ly1.svg'
import router from '@/router'
import AboutUsContentPanel from '../components/AboutUsContentPanel.vue'
</script>
@@ -0,0 +1,101 @@
<template>
<a-form
ref="formRef"
:model="address"
:class="variant === 'desktop' ? 'daddr-form' : '!mt-5'"
name="address-form"
>
<div :class="variant === 'desktop' ? 'daddr-form__fields' : 'flex flex-col items-center'">
<div
v-if="variant !== 'desktop'"
class="font-bold text-lg mb-2"
>
آدرس
</div>
<div
:class="variant === 'desktop'
? 'daddr-form__grid'
: 'flex flex-col gap-4 pb-20 w-full h-60vh overflow-y-scroll'"
>
<a-form-item label="استان" name="province" :rules="[required()]">
<BasicSelect
v-model:value="address.province"
:items="provinceList"
value-name="title"
key-name="title"
:size="variant === 'desktop' ? 'large' : undefined"
@change="changeProvince"
/>
</a-form-item>
<a-form-item label="شهر" name="city" :rules="[required()]">
<BasicSelect
v-model:value="address.city"
:disabled="!cityList"
value-name="title"
:items="cityList"
key-name="title"
:size="variant === 'desktop' ? 'large' : undefined"
/>
</a-form-item>
<a-form-item label="کد پستی" name="zip" :rules="[required()]">
<BasicInput
v-model:value="address.zip"
placeholder="کد پستی را وارد نمایید"
:size="variant === 'desktop' ? 'large' : undefined"
/>
</a-form-item>
<a-form-item label="تلفن تماس" name="phone" :rules="[required()]">
<BasicInput
v-model:value="address.phone"
placeholder="تلفن تماس را وارد نمایید"
:size="variant === 'desktop' ? 'large' : undefined"
/>
</a-form-item>
<a-form-item
label="آدرس کامل"
name="address"
:rules="[required()]"
:class="variant === 'desktop' ? 'daddr-form__address-item' : ''"
>
<BasicTextArea
v-model:value="address.address"
placeholder="آدرس کامل را وارد نمایید"
/>
</a-form-item>
<BasicButton
:class="variant === 'desktop' ? 'daddr-form__submit' : '!text-white px-3 pb-4rem'"
type="primary"
@click="handleOk"
>
{{ editId ? 'ویرایش آدرس' : 'ثبت آدرس' }}
</BasicButton>
</div>
</div>
</a-form>
</template>
<script lang="ts" setup>
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import BasicSelect from '@/components/BasicPacks/BasicSelect.vue'
import BasicTextArea from '@/components/BasicPacks/BasicTextArea.vue'
import { required } from '@/utils/inputRules'
import { useAddressPageContext } from '../composables/useAddressPage'
withDefaults(
defineProps<{
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
const {
address,
formRef,
editId,
provinceList,
cityList,
changeProvince,
handleOk,
} = useAddressPageContext()
</script>
@@ -0,0 +1,94 @@
<template>
<div class="address-mobile-item shadow mb-2 p-2 rounded-lg w-full">
<div class="flex justify-between">
<div>
<div class="font-semibold text-base mb-2">{{ item.address }}</div>
</div>
</div>
<div class="flex flex-col gap-2 mt-2">
<BasicInfoItem is-line title="استان" :value="item.province" />
<BasicInfoItem is-line title="شهر" :value="item.city" />
<BasicInfoItem is-line title="کد پستی" :value="item.zip" />
<BasicInfoItem is-line title="شماره تلفن" :value="item.phone" />
</div>
<a-divider class="!my-3" />
<div class="flex justify-between mt-1">
<div class="mb-2">{{ formatAddressDate(item.created_at) }}</div>
<div class="flex gap-2">
<BasicIcon
color="red"
icon="vuesax-linear:trash"
class="p-1"
@click.stop="deleteConfirmVisible = true"
/>
<BasicIcon
color="green"
icon="vuesax-linear:edit"
class="p-1"
@click.stop="emits('edit', item)"
/>
</div>
</div>
<DialogVant
v-model:show="deleteConfirmVisible"
teleport="body"
:close-on-click-overlay="false"
:time-out="-1"
>
<template #title>
<div class="flex flex-col items-center">
<div class="p-5 text-sm">آیا از حذف آدرس اطمینان دارید؟</div>
</div>
</template>
<template #button>
<div class="text-center text-red flex items-center justify-center" @click="deleteItem">
<a-spin :spinning="deleteLoading" size="small">تایید</a-spin>
</div>
<div class="text-center" @click="deleteConfirmVisible = false">انصراف</div>
</template>
</DialogVant>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { toJalali } from '@/utils/useDateTime'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicInfoItem from '@/components/BasicPacks/BasicInfoItem.vue'
import DialogVant from '@/components/DialogVant.vue'
import type { Address } from '@/models/address'
import { deleteAddress } from '@/api/address'
const deleteLoading = ref(false)
const deleteConfirmVisible = ref(false)
const props = defineProps<{
item: Address
}>()
const emits = defineEmits<{
edit: [item: Address]
refresh: []
}>()
function formatAddressDate(dateTime?: string) {
if (!dateTime) return ''
const formatted = toJalali(dateTime)
return formatted.includes('Invalid') ? '' : formatted
}
async function deleteItem() {
try {
deleteLoading.value = true
await deleteAddress(props.item.id!)
deleteConfirmVisible.value = false
emits('refresh')
} catch (e) {
console.log(e)
} finally {
deleteLoading.value = false
}
}
</script>
+113 -108
View File
@@ -1,112 +1,155 @@
<template> <template>
<div @click="infoVisible = true" class="shadow mb-2 p-2 rounded-lg"> <div v-if="variant === 'desktop'" class="daddr-card">
<p class="daddr-card__address">{{ item.address }}</p>
<div class="daddr-card__meta">
<div class="daddr-card__row">
<span class="daddr-card__label">استان:</span>
<span class="daddr-card__value">{{ item.province }}</span>
</div>
<div class="daddr-card__row">
<span class="daddr-card__label">شهر:</span>
<span class="daddr-card__value">{{ item.city }}</span>
</div>
<div class="daddr-card__row">
<span class="daddr-card__label">کد پستی:</span>
<span class="daddr-card__value">{{ item.zip }}</span>
</div>
<div class="daddr-card__row">
<span class="daddr-card__label">شماره تلفن:</span>
<span class="daddr-card__value">{{ item.phone }}</span>
</div>
</div>
<div class="daddr-card__actions">
<button
type="button"
class="daddr-card__icon-btn daddr-card__icon-btn--edit"
@click.stop="emits('edit', item)"
>
<BasicIcon color="green" icon="vuesax-linear:edit" size="18" />
</button>
<button
type="button"
class="daddr-card__icon-btn daddr-card__icon-btn--danger"
@click.stop="deleteConfirmVisible = true"
>
<BasicIcon color="red" icon="vuesax-linear:trash" size="18" />
</button>
</div>
<div class="daddr-card__footer">
<span class="daddr-card__date">{{ formatAddressDate(item.created_at) }}</span>
</div>
<DialogVant
v-model:show="deleteConfirmVisible"
:close-on-click-overlay="false"
:time-out="-1"
teleport="body"
>
<template #title>
<div class="flex flex-col items-center">
<div class="p-5 text-sm">آیا از حذف آدرس اطمینان دارید؟</div>
</div>
</template>
<template #button>
<div class="text-center text-red flex items-center justify-center" @click="deleteItem">
<a-spin :spinning="deleteLoading" size="small">تایید</a-spin>
</div>
<div class="text-center" @click="deleteConfirmVisible = false">انصراف</div>
</template>
</DialogVant>
</div>
<div v-else class="shadow mb-2 p-2 rounded-lg">
<div class="flex justify-between"> <div class="flex justify-between">
<div> <div>
<div class="font-semibold text-base mb-2">{{item.address }}</div> <div class="font-semibold text-base mb-2">{{ item.address }}</div>
</div> </div>
</div> </div>
<div class="flex flex-col gap-2 mt-2"> <div class="flex flex-col gap-2 mt-2">
<BasicInfoItem is-line title="استان" :value="item.province"/> <BasicInfoItem is-line title="استان" :value="item.province" />
<BasicInfoItem is-line title="شهر" :value="item.city"/> <BasicInfoItem is-line title="شهر" :value="item.city" />
<BasicInfoItem is-line title="کد پستی" :value="item.zip"/> <BasicInfoItem is-line title="کد پستی" :value="item.zip" />
<BasicInfoItem is-line title="شماره تلفن" :value="item.phone"/> <BasicInfoItem is-line title="شماره تلفن" :value="item.phone" />
</div> </div>
<a-divider class="!my-3" /> <a-divider class="!my-3" />
<div class="flex justify-between mt-1"> <div class="flex justify-between mt-1">
<div class="mb-2">{{toJalali(item.created_at)}}</div> <div class="mb-2">{{ formatAddressDate(item.created_at) }}</div>
<div class="flex gap-2 "> <div class="flex gap-2">
<BasicIcon color="red" icon="vuesax-linear:trash" @click.stop="deleteConfirmVisible = true" class="p-1"/> <BasicIcon
<BasicIcon color="green" icon="vuesax-linear:edit" @click.stop="emits('edit',item)" class="p-1"/> color="red"
icon="vuesax-linear:trash"
class="p-1"
@click.stop="deleteConfirmVisible = true"
/>
<BasicIcon
color="green"
icon="vuesax-linear:edit"
class="p-1"
@click.stop="emits('edit', item)"
/>
</div> </div>
</div> </div>
</div> <DialogVant
v-model:show="deleteConfirmVisible"
<!-- <teleport to="app">--> teleport="body"
<DialogVant v-model:show="deleteConfirmVisible" teleport="body" :closeOnClickOverlay="false" :timeOut="-1"> :close-on-click-overlay="false"
<!-- <template v-slot:img> :time-out="-1"
<img :src="registerDelayImage" alt="" class="opacity-80"> >
</template>--> <template #title>
<template v-slot:title>
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<div class="p-5 text-sm"> <div class="p-5 text-sm">آیا از حذف آدرس اطمینان دارید؟</div>
آیا از حذف آدرس اطمینان دارید؟
</div>
</div> </div>
</template> </template>
<template #button>
<template v-slot:button>
<div class="text-center text-red flex items-center justify-center" @click="deleteItem"> <div class="text-center text-red flex items-center justify-center" @click="deleteItem">
<a-spin :spinning="deleteLoading" size="small"> <a-spin :spinning="deleteLoading" size="small">تایید</a-spin>
تایید
</a-spin>
</div>
<div class="text-center" @click="deleteConfirmVisible = false">
انصراف
</div> </div>
<div class="text-center" @click="deleteConfirmVisible = false">انصراف</div>
</template> </template>
</DialogVant> </DialogVant>
<!-- </teleport>--> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'
import { computed, onMounted, ref } from 'vue' import { toJalali } from '@/utils/useDateTime'
import { toJalali } from '../../../../utils/useDateTime'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue' import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicInfoItem from '@/components/BasicPacks/BasicInfoItem.vue' import BasicInfoItem from '@/components/BasicPacks/BasicInfoItem.vue'
import DialogVant from '@/components/DialogVant.vue' import DialogVant from '@/components/DialogVant.vue'
import { updatePriceTrigger } from '@/api/triggerPrice'
import { loading } from '@/utils'
import type { Address } from '@/models/address' import type { Address } from '@/models/address'
import { deleteAddress } from '@/api/address' import { deleteAddress } from '@/api/address'
const infoVisible = ref<boolean>(false)
const isActive = ref<boolean>(false)
const deleteLoading = ref(false) const deleteLoading = ref(false)
const deleteConfirmVisible = ref(false) const deleteConfirmVisible = ref(false)
interface Prop { const props = withDefaults(
item: Address defineProps<{
} item: Address
function changeActive(val: boolean) { variant?: 'mobile' | 'desktop'
console.log(val) }>(),
try { { variant: 'mobile' },
loading.value = true )
updatePriceTrigger(props?.item.id, {is_active: val})
} catch (e) {
console.log(e)
isActive.value = !isActive.value
} finally {
loading.value = false
}
}
function returnPriceCondition(val: string) { const emits = defineEmits<{
switch (val) { edit: [item: Address]
case '==': return 'مساوی' refresh: []
case '<': return 'کوچکتر' }>()
// case '==': return 'مساوی'
} function formatAddressDate(dateTime?: string) {
/* if (val === '=='){ if (!dateTime) return ''
return 'مساوی' const formatted = toJalali(dateTime)
} else if (val === '<'){ return formatted.includes('Invalid') ? '' : formatted
return 'کوچکتر'
}*/
} }
async function deleteItem() { async function deleteItem() {
try { try {
deleteLoading.value = true deleteLoading.value = true
await deleteAddress(props.item.id) await deleteAddress(props.item.id!)
deleteConfirmVisible.value = false deleteConfirmVisible.value = false
emits('refresh') emits('refresh')
} catch (e) { } catch (e) {
@@ -115,42 +158,4 @@ async function deleteItem() {
deleteLoading.value = false deleteLoading.value = false
} }
} }
function returnAction(val: string) {
switch (val) {
case 'sms': return 'پیامک'
case 'email': return 'ایمیل'
case 'notify': return 'نوتیفیکیشن'
// case '==': return 'مساوی'
}
/* if (val === '=='){
return 'مساوی'
} else if (val === '<'){
return 'کوچکتر'
}*/
}
const props = withDefaults(defineProps<Prop>(), {})
const emits = defineEmits(['edit','refresh'])
/*const title = computed(() => {
if (isUp.value) {
if (isGold.value) {
return 'واریز ریالی'
} else {
return 'خرید طلا'
}
} else {
if (isGold.value) {
return 'برداشت ریالی'
} else {
return 'فروش طلا'
}
}
})*/
</script> </script>
<style scoped>
</style>
@@ -0,0 +1,145 @@
import { inject, onMounted, provide, ref, type InjectionKey, type Ref } from 'vue'
import { cloneDeep } from 'lodash'
import { type FormInstance, message } from 'ant-design-vue'
import { createAddress, getCity, updateAddress } from '@/api/address'
import type { Address } from '@/models/address'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { loading, loadingMore } from '@/utils'
import { isBazretalaProject } from '@/utils/config'
import {
addressItems,
addressProvinceList,
initAddressPageData,
refreshAddressItems,
} from './useAddressPageData'
export interface AddressPageContext {
provinceList: typeof addressProvinceList
cityList: Ref<any[] | undefined>
items: typeof addressItems
addAddressVisible: Ref<boolean>
editId: Ref<number | undefined>
address: Ref<Address>
formRef: Ref<FormInstance | undefined>
loading: typeof loading
loadingMore: typeof loadingMore
isBazretalaProject: typeof isBazretalaProject
resetFields: () => void
openAdd: () => void
openEdit: (val: Address) => void
handleOk: (e?: MouseEvent) => void
initItems: () => Promise<void>
changeProvince: (obj: { id: number }) => Promise<void>
}
export const ADDRESS_PAGE_CONTEXT_KEY: InjectionKey<AddressPageContext> =
Symbol('addressPageContext')
const emptyAddress = (): Address => ({
city: '',
province: '',
phone: '',
address: '',
zip: '',
default_address: false,
})
export function useAddressPage() {
const isDesktop = useDesktopBreakpoint()
const cityList = ref<any[]>()
const addAddressVisible = ref(false)
const editId = ref<number | undefined>(undefined)
const formRef = ref<FormInstance>()
const address = ref<Address>(emptyAddress())
function resetFields() {
editId.value = undefined
address.value = emptyAddress()
cityList.value = undefined
}
function openAdd() {
resetFields()
editId.value = undefined
addAddressVisible.value = !isDesktop.value
}
async function openEdit(val: Address) {
address.value = cloneDeep(val)
editId.value = address.value.id
if (address.value.province) {
const province = addressProvinceList.value.find((item) => item.title === address.value.province)
if (province?.id) {
cityList.value = await getCity(province.id)
}
}
addAddressVisible.value = true
}
const handleOk = (_e?: MouseEvent) => {
formRef.value?.validate().then(async () => {
if (editId.value) {
await updateAddress(editId.value, address.value)
} else {
await createAddress(address.value)
}
await initItems()
addAddressVisible.value = false
message.success('آدرس با موفقیت ثبت شد')
resetFields()
}).catch((error) => {
console.log('error', error)
})
}
async function initItems() {
await refreshAddressItems()
}
const changeProvince = async (obj: { id: number }) => {
try {
loading.value = true
cityList.value = undefined
address.value.city = undefined
cityList.value = await getCity(obj.id)
} catch (e) {
message.error('خطا در دریافت شهرها')
} finally {
loading.value = false
}
}
onMounted(async () => {
await initAddressPageData()
})
const context: AddressPageContext = {
provinceList: addressProvinceList,
cityList,
items: addressItems,
addAddressVisible,
editId,
address,
formRef,
loading,
loadingMore,
isBazretalaProject,
resetFields,
openAdd,
openEdit,
handleOk,
initItems,
changeProvince,
}
provide(ADDRESS_PAGE_CONTEXT_KEY, context)
return context
}
export function useAddressPageContext() {
const context = inject(ADDRESS_PAGE_CONTEXT_KEY)
if (!context) {
throw new Error('useAddressPageContext must be used within a page that calls useAddressPage()')
}
return context
}
@@ -0,0 +1,82 @@
import { ref } from 'vue'
import { getProvince, tableAddress } from '@/api/address'
import type { Address } from '@/models/address'
import { loading, loadingMore } from '@/utils'
import { runOnceAsync, resetOnceAsyncByPrefix } from '@/composables/useOnceAsync'
/**
* Shared address page datasets — mobile and desktop views read this module state only.
*/
let initPromise: Promise<void> | null = null
const initialized = ref(false)
export const addressItems = ref<Address[]>([])
export const addressProvinceList = ref<any[]>([])
export function isAddressPageDataInitialized() {
return initialized.value
}
export async function refreshAddressItems() {
try {
loading.value = true
const data = await tableAddress({ page: 0, limit: 150 })
addressItems.value = data.rows
} catch (error) {
console.error('Address list refresh error:', error)
} finally {
loading.value = false
loadingMore.value = false
}
}
async function loadAddressItems() {
const data = await tableAddress({ page: 0, limit: 150 })
addressItems.value = data.rows
}
async function loadProvinces() {
addressProvinceList.value = await getProvince()
}
export async function initAddressPageData(force = false) {
if (!force && initialized.value) {
return
}
if (!force && initPromise) {
return initPromise
}
initPromise = (async () => {
try {
if (force) {
resetAddressPageData()
}
loading.value = true
await Promise.all([
runOnceAsync('address:items', () => loadAddressItems(), force),
runOnceAsync('address:provinces', () => loadProvinces(), force),
])
initialized.value = true
} catch (error) {
console.error('Address page initialization error:', error)
} finally {
loading.value = false
initPromise = null
}
})()
return initPromise
}
export function resetAddressPageData() {
initialized.value = false
initPromise = null
resetOnceAsyncByPrefix('address:')
addressItems.value = []
addressProvinceList.value = []
}
@@ -0,0 +1,86 @@
<template>
<div class="daddr">
<div class="daddr__container">
<header class="daddr-topbar">
<p class="daddr-topbar__eyebrow">حساب کاربری</p>
<h1 class="daddr-topbar__title">آدرس‌ها</h1>
<p class="daddr-topbar__subtitle">آدرس‌های تحویل سفارش خود را مدیریت کنید</p>
</header>
<div
v-if="isBazretalaProject()"
class="daddr-notice"
>
جهت تحویل فیزیکی محصول در تهران و قم ازطریق معرف اقدام فرمائید یا با شماره امورمالی و حسابداری 09925202099 درارتباط باشید.
</div>
<div class="daddr-grid">
<section class="daddr-surface daddr-form">
<div class="daddr-form__header">
<h2 class="daddr-form__title">{{ editId ? 'ویرایش آدرس' : 'ثبت آدرس جدید' }}</h2>
<button
v-if="editId"
type="button"
class="daddr-form__reset"
@click="openAdd()"
>
آدرس جدید
</button>
</div>
<AddressForm variant="desktop" />
</section>
<section class="daddr-surface daddr-list">
<div class="daddr-list__header">
<h3 class="daddr-list__title">آدرس‌های ثبت‌شده</h3>
<span class="daddr-list__count">{{ items.length }} مورد</span>
</div>
<div v-if="loading" class="daddr-list__skeletons">
<TransactionSkeleton v-for="i in 6" :key="i" class="mt-1" />
</div>
<div v-else-if="items.length" class="daddr-list__items">
<list-anim>
<Item
v-for="item in items"
:key="`desktop-${item.id}`"
:item="item"
variant="desktop"
@edit="openEdit"
@refresh="initItems"
/>
</list-anim>
</div>
<div v-else class="daddr-empty">
<BasicIcon icon="vuesax-linear:location" size="36" />
<p>هنوز آدرسی ثبت نشده است.</p>
<button type="button" class="daddr-empty__btn" @click="openAdd()">
افزودن اولین آدرس
</button>
</div>
</section>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import ListAnim from '@/components/ListAnim.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import AddressForm from '../components/AddressForm.vue'
import Item from '../components/Item.vue'
import { useAddressPageContext } from '../composables/useAddressPage'
const {
items,
loading,
editId,
isBazretalaProject,
openAdd,
openEdit,
initItems,
} = useAddressPageContext()
</script>
@@ -0,0 +1,307 @@
/* Premium desktop address page — scoped to desktop shell only */
html.app-layout--desktop {
.daddr {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.daddr__container {
max-width: 1180px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.daddr-topbar {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin: 0 0 4px;
}
&__title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.03em;
color: #111827;
margin: 0 0 6px;
line-height: 1.2;
}
&__subtitle {
font-size: 14px;
color: #6b7280;
margin: 0;
max-width: 520px;
line-height: 1.6;
}
}
.daddr-notice {
margin-bottom: 20px;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid rgba(59, 130, 246, 0.15);
background: rgba(59, 130, 246, 0.06);
color: @App-primary-color;
font-size: 13px;
line-height: 1.8;
text-align: justify;
}
.daddr-grid {
display: grid;
grid-template-columns: minmax(0, 380px) minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.daddr-surface {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.daddr-form {
padding: 24px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 20px;
}
&__title {
font-size: 16px;
font-weight: 600;
color: #111827;
margin: 0;
}
&__reset {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
color: #6b7280;
font-size: 12px;
font-weight: 600;
font-family: inherit;
padding: 6px 10px;
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
&:hover {
border-color: @App-primary-color;
color: @App-primary-color;
}
}
&__fields {
width: 100%;
}
&__grid {
display: flex;
flex-direction: column;
gap: 4px;
}
&__address-item {
margin-bottom: 8px !important;
}
&__submit {
width: 100%;
margin-top: 8px;
}
}
.daddr-list {
display: flex;
flex-direction: column;
min-height: 420px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 20px 24px 0;
}
&__title {
font-size: 16px;
font-weight: 600;
color: #111827;
margin: 0;
}
&__count {
font-size: 12px;
font-weight: 600;
color: #6b7280;
background: #f3f4f6;
border-radius: 999px;
padding: 4px 10px;
}
&__items {
padding: 16px 24px 24px;
display: flex;
flex-direction: column;
gap: 10px;
}
&__skeletons {
padding: 16px 24px 24px;
}
}
.daddr-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px 16px;
padding: 16px 18px;
border: 1px solid #eef0f3;
border-radius: 12px;
background: #fafbfc;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
&:hover {
border-color: #dbe0e6;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
&__address {
grid-column: 1 / -1;
font-size: 16px;
font-weight: 700;
color: #111827;
margin: 0;
line-height: 1.5;
}
&__meta {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
&__row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #6b7280;
}
&__label {
color: #9ca3af;
flex-shrink: 0;
}
&__value {
color: #374151;
font-weight: 600;
}
&__actions {
display: flex;
align-items: flex-start;
gap: 4px;
}
&__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #fff;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
&:hover {
background: #f9fafb;
}
&--danger:hover {
border-color: #fecaca;
background: #fef2f2;
}
&--edit:hover {
border-color: #bbf7d0;
background: #f0fdf4;
}
}
&__footer {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 10px;
border-top: 1px solid #eef0f3;
}
&__date {
font-size: 12px;
color: #9ca3af;
}
}
.daddr-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
color: #6b7280;
text-align: center;
p {
margin: 0;
font-size: 14px;
}
&__btn {
margin-top: 4px;
border: none;
border-radius: 10px;
background: @App-primary-color;
color: #fff;
font-size: 13px;
font-weight: 600;
font-family: inherit;
padding: 10px 16px;
cursor: pointer;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.92;
}
}
}
@media (max-width: 1023.98px) {
.daddr-grid {
grid-template-columns: 1fr;
}
}
}
+29 -246
View File
@@ -1,257 +1,40 @@
<template> <template>
<div> <div v-if="!isDesktop">
<div class="bg-white py-3 px-3 text-primary font-bold drop-shadow wrap-fix flex z-10"> <MobileAddressView />
<div @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">آدرس ها</div>
</div>
<div>
<img :src="img" alt="" class="mt-2 w-full absolute bottom-0" />
</div>
<!-- <a-spin :spinning="loading">-->
<div ref="el" :class="loading? 'h-screen': ''" class="px-3 pt-16 pb-2 overflow-y-scroll"
style="max-height: calc(100vh - 50px)">
<div
v-if="isBazretalaProject()"
class="text-justify text-sm text-primary mb-4 px-1 leading-7"
>
جهت تحویل فیزیکی محصول در تهران و قم ازطریق معرف اقدام فرمائید یا با شماره امورمالی و حسابداری 09925202099 درارتباط باشید.
</div>
<list-anim v-if="!loading" class="pb-[calc(var(--app-bottom-chrome-height)+var(--app-add-card-height)+1rem)]">
<Item v-for="item in items" :key="item.id" :item="item" @edit="openEdit" @refresh="initItems" />
<!-- <TransactionSkeleton v-if="loadingMore" class="mt-1 mb-8" />-->
</list-anim>
<div v-if="loading">
<TransactionSkeleton v-for="i in 13" :key="i" class="mt-1" />
</div>
</div>
<!-- </a-spin>-->
<BasicActionSheet v-model:show="addAddressVisible" class="h-80vh" >
<a-form ref="formRef" :model="address" class="!mt-5" name="basic"
@finish="ok()">
<div class="flex flex-col items-center">
<div class="font-bold text-lg mb-2">آدرس</div>
<div class="flex flex-col gap-4 pb-20 w-full h-60vh overflow-y-scroll">
<a-form-item label="استان" name="province" :rules="[required()]">
<BasicSelect
v-model:value="address.province"
:items="provinceList"
@change="changeProvince"
valueName="title"
keyName="title"
/>
</a-form-item>
<a-form-item label="شهر" name="city" :rules="[required()]">
<BasicSelect
:disabled="!cityList"
v-model:value="address.city"
valueName="title"
:items="cityList"
keyName="title"
/>
</a-form-item>
<a-form-item
label="کد پستی" name="zip" :rules="[required()]">
<BasicInput placeholder="کد پستی را وارد نمایید" v-model:value="address.zip" />
</a-form-item>
<a-form-item
label="تلفن تماس" name="phone" :rules="[required()]"
>
<BasicInput placeholder="تلفن تماس را وارد نمایید" v-model:value="address.phone" />
</a-form-item>
<!-- <FormItem label="کد پستی" name="">
<Input placeholder="کد پستی را وارد نمایید" />
</FormItem>-->
<a-form-item
label="آدرس کامل"
name="address"
:rules="[required()]"
>
<BasicTextArea placeholder="آدرس کامل را وارد نمایید" v-model:value="address.address" />
</a-form-item>
<!-- <div class=" w-full pb-5 !text-white flex justify-center items-center">-->
<BasicButton class=" !text-white px-3 pb-4rem" type="primary" @click="handleOk">ثبت آدرس
</BasicButton>
</div>
<!-- </div>-->
</div>
</a-form>
<BasicActionSheet v-model:show="addAddressVisible" class="h-80vh">
<AddressForm />
</BasicActionSheet> </BasicActionSheet>
<!-- <div class="bg-primary w-3rem h-3rem flex items-center justify-center rounded-lg fixed bottom-12vh left-8vw"
@click="openAdd()">
<BasicIcon icon="vuesax-linear:add" color="white" />
</div>-->
<Teleport to="body">
<div
v-if="!addAddressVisible"
class="add-address app-dock-add-bar"
@click="openAdd()"
>
<div>افزودن آدرس جدید</div>
<BasicIcon icon="vuesax-linear:add" />
</div>
</Teleport>
</div> </div>
<template v-else>
<DesktopAddressView />
<BasicModal
v-model:visible="addAddressVisible"
:title="editId ? 'ویرایش آدرس' : 'ثبت آدرس جدید'"
width="520px"
destroy-on-close
custom-footer
>
<AddressForm variant="desktop" />
<template #footer>
<div></div>
</template>
</BasicModal>
</template>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { createPriceTrigger, getPriceTriggers, updatePriceTrigger } from '@/api/triggerPrice'
const radioStyle = reactive({
display: '',
height: '30px',
lineHeight: '30px'
})
import type { SelectItem } from '@/models/SelectItem'
// import Item from "./components/Item.vue";
/*import {lengthValidation, required} from "@/utils/inputRules";
import {loading} from "@/utils";
import BasicButton from "@/components/BasicPacks/BasicButton.vue";
import BasicActionSheet from "@/components/BasicPacks/BasicActionSheet.vue";
import BasicInput from "@/components/BasicPacks/BasicInput.vue";*/
import { onMounted, reactive, ref } from 'vue'
import type { TableEngineInput, TableOutput } from '@/models/table'
import { loading, loadingMore } from '@/utils'
// import {getOrders} from "@/api/order";
import { getTransactionTable } from '@/api/transaction'
import type { Transaction } from '@/models/transaction'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue' import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue' import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import BaseDatePicker from '@/components/BasicPacks/BaseDatePicker/src/BaseDatePicker.vue' import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useInfiniteScroll, useScroll } from '@vueuse/core' import { useAddressPage } from './composables/useAddressPage'
import ListAnim from '@/components/ListAnim.vue' import AddressForm from './components/AddressForm.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue' import DesktopAddressView from './desktop/DesktopAddressView.vue'
import { DotLottieVue } from '@lottiefiles/dotlottie-vue' import MobileAddressView from './mobile/MobileAddressView.vue'
import { mobileValidation, required } from '@/utils/inputRules'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import type { User } from '@/models/login'
import { type FormInstance, message } from 'ant-design-vue'
import type { PriceTrigger } from '@/models/PriceTrigger'
import BasicSelect from '@/components/BasicPacks/BasicSelect.vue'
import Item from './components/Item.vue'
import { cloneDeep } from 'lodash'
import router from '@/router'
import type { Address } from '@/models/address'
import { createAddress, getCity, getProvince, tableAddress, updateAddress } from '@/api/address'
import BasicTextArea from '@/components/BasicPacks/BasicTextArea.vue'
import img from "@/assets/img/user_ly1.svg";
import { isBazretalaProject } from '@/utils/config'
const provinceList = ref<any[]>([]) const isDesktop = useDesktopBreakpoint()
const cityList = ref<any[]>()
const items = ref<PriceTrigger[]>([])
const addAddressVisible = ref<boolean>(false)
const editId = ref<number | undefined>(undefined)
const el = ref()
const address = ref<Address>({
city: '',
province: '',
phone: '',
address: '',
zip: '',
default_address: false
})
const formRef = ref<FormInstance>()
onMounted(async () => {
await initItems()
provinceList.value = await getProvince()
})
function resetFields() {
editId.value = undefined
address.value = {
city: '',
province: '',
phone: '',
address: '',
zip: '',
default_address: false
}
}
function openAdd() {
resetFields()
editId.value = undefined
addAddressVisible.value = true
}
const handleOk = (e: MouseEvent) => {
formRef.value?.validate().then(async () => {
editId.value ? await updateAddress(editId.value, address.value) : await createAddress(address.value)
await initItems()
addAddressVisible.value = false
message.success('آدرس با موفقیت ثبت شد')
resetFields()
})
.catch((error) => {
console.log('error', error)
})
}
async function initItems() {
try {
items.value = []
const data = await tableAddress({ page: 0, limit: 150 })
items.value = data.rows
// console.log(items.value)
} catch (e) {
console.log(e)
} finally {
loading.value = false
loadingMore.value = false
}
}
const changeProvince = async (obj: any) => {
try {
loading.value = true
cityList.value = undefined
address.value.city = undefined
cityList.value = await getCity(obj.id)
} catch (e) {
message.error('خطا در دریافت شهرها')
} finally {
loading.value = false
}
}
function openEdit(val: PriceTrigger) {
address.value = cloneDeep(val)
editId.value = address.value.id
addAddressVisible.value = true
}
async function ok() {
formRef.value?.validateFields().then(async () => {
try {
loading.value = true
editId.value ? await updatePriceTrigger(editId.value, address.value) : await createPriceTrigger(address.value)
message.success('آدرس با موفقیت ایجاد شد')
await initItems()
addAddressVisible.value = false
//
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
}
const { addAddressVisible, editId } = useAddressPage()
</script> </script>
<style scoped>
</style>
@@ -0,0 +1,72 @@
<template>
<div>
<div class="bg-white py-3 px-3 text-primary font-bold drop-shadow wrap-fix flex z-10">
<div @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">آدرس ها</div>
</div>
<div>
<img :src="img" alt="" class="mt-2 w-full absolute bottom-0" />
</div>
<div
:class="loading ? 'h-screen' : ''"
class="px-3 pt-16 pb-2 overflow-y-scroll"
style="max-height: calc(100vh - 50px)"
>
<div
v-if="isBazretalaProject()"
class="text-justify text-sm text-primary mb-4 px-1 leading-7"
>
جهت تحویل فیزیکی محصول در تهران و قم ازطریق معرف اقدام فرمائید یا با شماره امورمالی و حسابداری 09925202099 درارتباط باشید.
</div>
<div
v-if="!loading"
class="pb-[calc(var(--app-bottom-chrome-height)+var(--app-add-card-height)+1rem)]"
>
<Item
v-for="item in items"
:key="item.id"
:item="item"
@edit="openEdit"
@refresh="initItems"
/>
</div>
<div v-if="loading">
<TransactionSkeleton v-for="i in 13" :key="i" class="mt-1" />
</div>
</div>
<Teleport to="body">
<div
v-if="!addAddressVisible"
class="add-address app-dock-add-bar"
@click="openAdd()"
>
<div>افزودن آدرس جدید</div>
<BasicIcon icon="vuesax-linear:add" />
</div>
</Teleport>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import img from '@/assets/img/user_ly1.svg'
import router from '@/router'
import Item from '../components/Item.vue'
import { useAddressPageContext } from '../composables/useAddressPage'
const {
items,
loading,
addAddressVisible,
isBazretalaProject,
openAdd,
openEdit,
initItems,
} = useAddressPageContext()
</script>
@@ -0,0 +1,90 @@
import { inject, onMounted, provide, ref, type InjectionKey, type Ref } from 'vue'
import { message } from 'ant-design-vue'
import { getEarnMoney } from '@/api/earnMoney'
import type { EarnMoney } from '@/models/earnMoney'
import { useUserStore } from '@/store/userStore'
import { useConfigStore } from '@/store/configStore'
import { getReferralShareText, getReferralShareTitle } from '@/utils/config'
import { loading } from '@/utils'
export interface EarnMoneyPageContext {
data: Ref<EarnMoney | undefined>
userStore: ReturnType<typeof useUserStore>
nameProject: string
isTransaction: Ref<boolean>
loading: typeof loading
setIsTransaction: (value: boolean) => void
copyCode: () => void
shareCode: () => void
loadEarnMoney: () => Promise<void>
}
export const EARN_MONEY_PAGE_CONTEXT_KEY: InjectionKey<EarnMoneyPageContext> =
Symbol('earnMoneyPageContext')
export function useEarnMoneyPage() {
const data = ref<EarnMoney>()
const userStore = useUserStore()
const nameProject = useConfigStore().config.name_fa
const isTransaction = ref<boolean>(false)
function setIsTransaction(value: boolean) {
isTransaction.value = value
}
function copyCode() {
const val = userStore.userInfo?.referral_id
navigator.clipboard.writeText(val)
message.info('کد رونوشت شد')
}
function shareCode() {
const val = getReferralShareText({
userName: userStore.userInfo?.name,
projectName: nameProject,
referralId: userStore.userInfo?.referral_id,
})
navigator.share({
title: getReferralShareTitle(nameProject),
text: val,
})
}
async function loadEarnMoney() {
try {
loading.value = true
data.value = await getEarnMoney()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
onMounted(() => {
loadEarnMoney()
})
const context: EarnMoneyPageContext = {
data,
userStore,
nameProject,
isTransaction,
loading,
setIsTransaction,
copyCode,
shareCode,
loadEarnMoney,
}
provide(EARN_MONEY_PAGE_CONTEXT_KEY, context)
return context
}
export function useEarnMoneyPageContext() {
const context = inject(EARN_MONEY_PAGE_CONTEXT_KEY)
if (!context) {
throw new Error('useEarnMoneyPageContext must be used within a page that calls useEarnMoneyPage()')
}
return context
}
@@ -0,0 +1,121 @@
<template>
<div class="dearn">
<div class="dearn__container">
<header class="dearn-topbar">
<p class="dearn-topbar__eyebrow">بازاریابی</p>
<h1 class="dearn-topbar__title">کسب درآمد</h1>
<p class="dearn-topbar__subtitle">با دعوت دوستان، پاداش دریافت کنید</p>
</header>
<section class="dearn-surface dearn-referral">
<div class="dearn-referral__header">
<div class="dearn-referral__icon">
<img :src="coin" alt="" />
</div>
<div>
<h2 class="dearn-referral__title">دعوت دوستان</h2>
<p class="dearn-referral__desc">هر دعوت موفق 10 سوت طلا هدیه</p>
</div>
</div>
<div class="dearn-referral__code">
<span class="dearn-referral__code-label">کد بازاریابی</span>
<span class="dearn-referral__code-value">{{ userStore.userInfo.referral_id }}</span>
</div>
<div class="dearn-referral__actions">
<BasicButton
icon="vuesax-linear:share"
type="primary"
class="dearn-referral__btn"
icon-size="18"
@click.stop="shareCode()"
>
اشتراک گذاری
</BasicButton>
<BasicButton
icon="vuesax-linear:copy"
class="dearn-referral__btn"
icon-size="18"
@click.stop="copyCode()"
>
رونوشت
</BasicButton>
</div>
</section>
<div class="dearn-stats">
<div class="dearn-stats__card dearn-stats__card--users">
<span class="dearn-stats__value">{{ data?.usersSum ?? 0 }} عدد</span>
<span class="dearn-stats__label">تعداد زیر مجموعه</span>
</div>
<div class="dearn-stats__card dearn-stats__card--gifts">
<span class="dearn-stats__value">{{ data?.giftsSum ?? 0 }} سوت</span>
<span class="dearn-stats__label">میزان کل پاداش ها</span>
</div>
</div>
<div class="dearn-tabs">
<button
type="button"
class="dearn-tabs__btn"
:class="{ 'dearn-tabs__btn--active': isTransaction }"
@click="setIsTransaction(true)"
>
پاداش ها
</button>
<button
type="button"
class="dearn-tabs__btn"
:class="{ 'dearn-tabs__btn--active': !isTransaction }"
@click="setIsTransaction(false)"
>
فهرست زیر مجموعه
</button>
</div>
<section class="dearn-surface dearn-list">
<div v-if="loading" class="dearn-list__loading">
<TransactionSkeleton v-for="i in 6" :key="i" class="mt-1" />
</div>
<template v-else>
<div v-if="isTransaction" class="dearn-list__items">
<GiftCard v-for="(val, i) in data?.gifts" :key="i" :item="val" />
<div v-if="!data?.gifts?.length" class="dearn-empty">
<BasicIcon icon="vuesax-linear:gift" size="36" />
<p>هنوز پاداشی ثبت نشده است.</p>
</div>
</div>
<div v-else class="dearn-list__items">
<EarnCard v-for="(val, i) in data?.users" :key="i" :item="val" />
<div v-if="!data?.users?.length" class="dearn-empty">
<BasicIcon icon="vuesax-linear:people" size="36" />
<p>هنوز زیرمجموعه‌ای ثبت نشده است.</p>
</div>
</div>
</template>
</section>
</div>
</div>
</template>
<script lang="ts" setup>
import coin from '@/assets/img/coin.png'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import EarnCard from '../components/EarnCard.vue'
import GiftCard from '../components/GiftCard.vue'
import { useEarnMoneyPageContext } from '../composables/useEarnMoneyPage'
const {
data,
userStore,
isTransaction,
loading,
setIsTransaction,
copyCode,
shareCode,
} = useEarnMoneyPageContext()
</script>
@@ -0,0 +1,244 @@
/* Premium desktop earn money page */
.dearn {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.dearn__container {
max-width: 960px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.dearn-topbar {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin: 0 0 4px;
}
&__title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.03em;
color: #111827;
margin: 0 0 6px;
line-height: 1.2;
}
&__subtitle {
font-size: 14px;
color: #6b7280;
margin: 0;
}
}
.dearn-surface {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.dearn-referral {
padding: 24px 28px;
margin-bottom: 20px;
&__header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #eef0f3;
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 14px;
background: #dbeafe;
flex-shrink: 0;
img {
width: 28px;
height: 28px;
object-fit: contain;
}
}
&__title {
font-size: 18px;
font-weight: 700;
color: #111827;
margin: 0 0 6px;
}
&__desc {
font-size: 13px;
line-height: 1.6;
color: #6b7280;
margin: 0;
}
&__code {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 14px 18px;
margin-bottom: 16px;
border-radius: 12px;
background: #f9fafb;
border: 1px dashed #d1d5db;
}
&__code-label {
font-size: 12px;
color: #6b7280;
font-weight: 500;
}
&__code-value {
font-size: 18px;
font-weight: 700;
color: @App-primary-color;
letter-spacing: 0.02em;
}
&__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
&__btn {
min-height: 44px;
}
}
.dearn-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
&__card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 20px 16px;
border-radius: 14px;
text-align: center;
&--users {
background: #dbeafe;
color: #2563eb;
}
&--gifts {
background: #fef9c3;
color: #ca8a04;
}
}
&__value {
font-size: 22px;
font-weight: 700;
line-height: 1.2;
}
&__label {
font-size: 13px;
font-weight: 500;
opacity: 0.9;
}
}
.dearn-tabs {
display: inline-flex;
gap: 4px;
padding: 4px;
margin-bottom: 20px;
background: #f3f4f6;
border-radius: 12px;
&__btn {
min-width: 140px;
padding: 10px 18px;
border: none;
border-radius: 10px;
background: transparent;
font-size: 14px;
font-weight: 600;
color: #6b7280;
font-family: inherit;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
&:hover {
color: #374151;
}
&--active {
background: #fff;
color: @App-primary-color;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
}
}
.dearn-list {
padding: 20px 24px 24px;
&__loading,
&__items {
display: flex;
flex-direction: column;
gap: 4px;
}
}
.dearn-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
color: #9ca3af;
text-align: center;
p {
margin: 0;
font-size: 14px;
color: #6b7280;
}
svg,
.basic-icon {
color: #d1d5db;
}
}
@media (max-width: 1023.98px) {
.dearn-stats {
grid-template-columns: 1fr;
}
.dearn-referral__actions {
grid-template-columns: 1fr;
}
}
+14 -149
View File
@@ -1,159 +1,24 @@
<template> <template>
<div> <div class="earn-money-page-root">
<div class="bg-white w-full py-3 px-3 text-primary font-bold drop-shadow flex"> <DesktopEarnMoneyView v-show="isDesktop" />
<div @click="router.back()"> <MobileEarnMoneyView v-show="!isDesktop" />
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">کسب درآمد</div>
</div>
</div> </div>
<div class="p-4">
<div class="text-center p-3 border-solid border-1 rounded-[8px] border-primary ">
<div class="flex items-center">
<div class=" bg-[#DBEAFE] h-10 w-10 rounded-100% flex justify-center items-center">
<img :src="coin" />
</div>
<div class="mr-4">
<div class="flex ">
<div class="font-bold">دعوت دوستان</div>
<!-- <div class="text-xs ">راهنمای کسب درآمد</div>-->
</div>
<div class="text-center mt-2 text-xs">هر دعوت موفق 10 سوت طلا هدیه</div>
</div>
</div>
<div class="flex justify-center mt-4">
<div class="text-[10px] content-center ">کد بازاریابی</div>
<div class="mr-2 font-bold">{{userStore.userInfo.referral_id}}</div>
</div>
<div class="flex mt-4 gap-2">
<BasicButton icon="vuesax-linear:share" size="small" class="flex-1" icon-size="18" @click.stop="shareCode()">
اشتراک گذاری
</BasicButton>
<BasicButton icon="vuesax-linear:copy" size="small" class="flex-1" icon-size="18" @click.stop="copyCode()">
رونوشت
</BasicButton>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mt-4 ">
<div class="text-center bg-[#DBEAFE] p-2 rounded-[8px]">
<div class="font-bold text-[#2563EB] text-md">
{{data?.usersSum}} عدد
</div>
<div class="text-xs text-[#2563EB] mt-2">تعداد زیر مجموعه</div>
</div>
<div class="text-center bg-[#FEF9C3] p-2 rounded-[8px]">
<div class="font-bold text-[#CA8A04] text-md">
{{data?.giftsSum}} سوت
</div>
<div class="text-xs text-[#CA8A04] mt-2">میزان کل پاداش ها</div>
</div>
</div>
<div class="flex justify-evenly w-full px-2 h-11 items-center bg-gray-50 relative rounded-lg mt-6 ">
<div :class="isTransaction?'text-primary bg-white h-8 w-1/2 rounded-lg':'text-gray-400'" class="w-full text-center z-2 flex items-center justify-center"
@click.stop="setIsTransaction(true) ">
پاداش ها
</div>
<div :class="!isTransaction?'text-primary bg-white h-8 w-1/2 rounded-lg':'text-gray-400'" class="w-full text-center z-2 flex items-center justify-center"
@click.stop="setIsTransaction(false) ">
فهرست زیر مجموعه
</div>
</div>
<!-- //**************************************-->
<!-- <div class="flex mt-6">-->
<!-- <div class="bg-[#0A1B37] p-2 rounded-[8px] w-full">-->
<!-- <div class="text-white text-center">فهرست زیرمجموعه</div>-->
<!-- </div>-->
<!-- <div class="bg-[#0A1B37] p-2 rounded-[8px] w-full mr-2">-->
<!-- <div class="text-white text-center">پاداش ها</div>-->
<!-- </div>-->
<!-- </div>-->
<div class="border-solid border-1 border-[#E5E7EB] mt-6 "></div>
<div v-if="isTransaction">
<GiftCard v-for="(val,i) in data?.gifts" :key="i" :item="val"/>
</div>
<div v-else>
<EarnCard v-for="(val,i) in data?.users" :key="i" :item="val"/>
</div>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useEarnMoneyPage } from './composables/useEarnMoneyPage'
import DesktopEarnMoneyView from './desktop/DesktopEarnMoneyView.vue'
import MobileEarnMoneyView from './mobile/MobileEarnMoneyView.vue'
import router from '@/router' const isDesktop = useDesktopBreakpoint()
import coin from '@/assets/img/coin.png'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { useUserStore } from '@/store/userStore'
import { type FormInstance, message } from 'ant-design-vue'
import { onMounted, reactive, ref } from 'vue'
import type { EarnMoney } from '@/models/earnMoney'
import { loading } from '@/utils'
import { getEarnMoney } from '@/api/earnMoney'
import EarnCard from '@/views/base/earnMoney/components/EarnCard.vue'
import TransactionCard from '@/views/base/transactions/components/Item.vue'
import GiftCard from '@/views/base/earnMoney/components/GiftCard.vue'
import {useConfigStore} from "@/store/configStore";
import {getReferralShareText, getReferralShareTitle} from "@/utils/config";
const data = ref<EarnMoney>()
const userStore = useUserStore()
const nameProject= useConfigStore().config.name_fa
const isTransaction = ref<boolean>(false)
function setIsTransaction(value: boolean) {
isTransaction.value = value
}
function copyCode() {
// nextTick(() => {
const val = userStore.userInfo?.referral_id
navigator.clipboard.writeText(val)
message.info('کد رونوشت شد')
// })
}
function shareCode() {
const val = getReferralShareText({
userName: userStore.userInfo?.name,
projectName: nameProject,
referralId: userStore.userInfo?.referral_id,
})
navigator.share({
title: getReferralShareTitle(nameProject),
text: val
})
}
async function earnMoney() {
try {
loading.value = true
data.value = await getEarnMoney()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
function subsetList(){
}
onMounted(async () => {
earnMoney()
})
function useStyle() {
if (data.value?.User) return 'right: 2%;'
// if (isbuy.value) return 'animation-direction: normal'
// else return 'animation-direction: reverse'
else return ' right: 98%; transform: translate(100%, 0);'
}
useEarnMoneyPage()
</script> </script>
<style scoped> <style scoped>
.earn-money-page-root {
</style> min-height: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,101 @@
<template>
<div>
<div class="bg-white w-full py-3 px-3 text-primary font-bold drop-shadow flex">
<div @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">کسب درآمد</div>
</div>
</div>
<div class="p-4">
<div class="text-center p-3 border-solid border-1 rounded-[8px] border-primary">
<div class="flex items-center">
<div class="bg-[#DBEAFE] h-10 w-10 rounded-100% flex justify-center items-center">
<img :src="coin" alt="" />
</div>
<div class="mr-4">
<div class="flex">
<div class="font-bold">دعوت دوستان</div>
</div>
<div class="text-center mt-2 text-xs">هر دعوت موفق 10 سوت طلا هدیه</div>
</div>
</div>
<div class="flex justify-center mt-4">
<div class="text-[10px] content-center">کد بازاریابی</div>
<div class="mr-2 font-bold">{{ userStore.userInfo.referral_id }}</div>
</div>
<div class="flex mt-4 gap-2">
<BasicButton
icon="vuesax-linear:share"
size="small"
class="flex-1"
icon-size="18"
@click.stop="shareCode()"
>
اشتراک گذاری
</BasicButton>
<BasicButton
icon="vuesax-linear:copy"
size="small"
class="flex-1"
icon-size="18"
@click.stop="copyCode()"
>
رونوشت
</BasicButton>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="text-center bg-[#DBEAFE] p-2 rounded-[8px]">
<div class="font-bold text-[#2563EB] text-md">
{{ data?.usersSum }} عدد
</div>
<div class="text-xs text-[#2563EB] mt-2">تعداد زیر مجموعه</div>
</div>
<div class="text-center bg-[#FEF9C3] p-2 rounded-[8px]">
<div class="font-bold text-[#CA8A04] text-md">
{{ data?.giftsSum }} سوت
</div>
<div class="text-xs text-[#CA8A04] mt-2">میزان کل پاداش ها</div>
</div>
</div>
<div
class="flex justify-evenly w-full px-2 h-11 items-center bg-gray-50 relative rounded-lg mt-6"
>
<div
:class="isTransaction ? 'text-primary bg-white h-8 w-1/2 rounded-lg' : 'text-gray-400'"
class="w-full text-center z-2 flex items-center justify-center"
@click.stop="setIsTransaction(true)"
>
پاداش ها
</div>
<div
:class="!isTransaction ? 'text-primary bg-white h-8 w-1/2 rounded-lg' : 'text-gray-400'"
class="w-full text-center z-2 flex items-center justify-center"
@click.stop="setIsTransaction(false)"
>
فهرست زیر مجموعه
</div>
</div>
<div class="border-solid border-1 border-[#E5E7EB] mt-6"></div>
<div v-if="isTransaction">
<GiftCard v-for="(val, i) in data?.gifts" :key="i" :item="val" />
</div>
<div v-else>
<EarnCard v-for="(val, i) in data?.users" :key="i" :item="val" />
</div>
</div>
</template>
<script lang="ts" setup>
import coin from '@/assets/img/coin.png'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import router from '@/router'
import EarnCard from '../components/EarnCard.vue'
import GiftCard from '../components/GiftCard.vue'
import { useEarnMoneyPageContext } from '../composables/useEarnMoneyPage'
const { data, userStore, isTransaction, setIsTransaction, copyCode, shareCode } =
useEarnMoneyPageContext()
</script>
@@ -0,0 +1,103 @@
<template>
<div
:class="variant === 'desktop'
? 'deducate-content'
: 'mt-5 text-5 border-solid border-2 border-[#093D51] text-[#093D51] rounded-[12px] p-4'"
>
<div
v-if="variant === 'desktop'"
class="deducate-content__avatar-wrap"
>
<img :src="config.config.avatar" alt="" class="deducate-content__avatar" />
</div>
<div
v-else
class="h-20vh flex justify-center"
>
<img :src="config.config.avatar" alt="" class="mb-20 h-15vh mt-5" />
</div>
<div :class="variant === 'desktop' ? 'deducate-content__body' : ''">
<div
:class="variant === 'desktop' ? 'deducate-content__section-title' : 'font-bold text-sm text-[#093D51]'"
>
شیوهٔ ورود و ثبت ‌نام در گروسی گالری
</div>
<div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'">
• برای ثبت‌نام در گروسی گالری، اگر قبلاً حساب کاربری دارید می‌توانید از طریق پیامک وارد شوید.<br />
• اگر حساب کاربری ندارید، "از این قسمت" وارد صفحه ثبت نام شده و با وارد کردن نام و شماره تلفنی که با کد ملی شما مطابقت دارد، حساب کاربری بسازید.<br />
• الزامی به وارد کردن کد معرف نیست.
</div>
<img :src="img1" :class="variant === 'desktop' ? 'deducate-content__image' : ''" />
<div :class="variant === 'desktop' ? 'deducate-content__divider' : 'w-full h-1px my-3 bg-secondary'"></div>
<div :class="variant === 'desktop' ? 'deducate-content__section-title' : 'font-bold mt-4 text-sm text-[#093D51]'">
شارژ کیف پول
</div>
<div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'">
• ابتدا کیف پول را در منو انتخاب کرده، مبلغ مورد نظر را وارد نموده و دکمه واریز را انتخاب کنید تا کیف پول شما شارژ شود.<br />
</div>
<img :src="img2" :class="variant === 'desktop' ? 'deducate-content__image' : ''" />
<div :class="variant === 'desktop' ? 'deducate-content__divider' : 'w-full h-1px my-3 bg-secondary'"></div>
<div :class="variant === 'desktop' ? 'deducate-content__section-title' : 'font-bold mt-4 text-sm text-[#093D51]'">
شیوهٔ خرید از گروسی گالری
</div>
<div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'">
• سپس از بخش «خرید و فروش» انتخاب کنید که طلای آب‌شده می‌خواهید یا محصولات دیگر (شامل گوی، شمش یا مکعب).<br />
• توجه داشته باشید کارمزد طلای آب‌شده حدود ۲٪ است و کارمزد ریالی محصولات دیگر در پایین صفحه قیمت درج شده است.
</div>
<img :src="img4" :class="variant === 'desktop' ? 'deducate-content__image' : ''" />
<div :class="variant === 'desktop' ? 'deducate-content__divider' : 'w-full h-1px my-3 bg-secondary'"></div>
<div :class="variant === 'desktop' ? 'deducate-content__section-title' : 'font-bold mt-4 text-sm text-[#093D51]'">
شیوهٔ فروش در گروسی گالری
</div>
<div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'">
• برای فروش محصولاتی که خریداری کرده‌اید، به بخش «خرید و فروش» مراجعه کرده، محصول موردنظر را انتخاب و گزینه فروش را بزنید تا مبلغ قابل دریافت نمایش داده شود.<br />
• مبلغ به کیف پول شما واریز می‌شود و از طریق پیام پشتیبانی می‌توانید درخواست برداشت دهید.<br />
• اگر موجودی کیف پول بیش از ۱۰ میلیون تومان باشد، مبلغ تا پایان روز کاری بعد به حساب اعلام‌شده شما واریز خواهد شد.
</div>
<div :class="variant === 'desktop' ? 'deducate-content__divider' : 'w-full h-1px my-3 bg-secondary'"></div>
<div :class="variant === 'desktop' ? 'deducate-content__section-title' : 'font-bold mt-4 text-sm text-[#093D51]'">
شیوهٔ دریافت فیزیکی طلا (ارسال)
</div>
<div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'">
• ابتدا موجودی محصول موردنظر را با پشتیبانی در واتساپ هماهنگ کنید.<br />
• سپس روی لوگو کلیک کرده، گزینه «برداشت فیزیکی» و بعد «درخواست جدید» را انتخاب کنید.<br />
• محصولات خریداری‌شده و تعداد آن‌ها را مشخص کرده و آدرس خود را ثبت نمایید.<br />
• پشتیبانی عکس و وزن محصول را برای شما ارسال می‌کند.<br />
• ارسال به‌صورت پس‌کرایه و بیمه‌شده انجام می‌شود.<br />
</div>
<div :class="variant === 'desktop' ? 'deducate-content__divider' : 'w-full h-1px my-3 bg-secondary'"></div>
<div :class="variant === 'desktop' ? 'deducate-content__section-title' : 'font-bold mt-4 text-sm text-[#093D51]'">
پاداش معرفی
</div>
<div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'">
• در صفحه اصلی روی "تنظیمات" کلیک کرده و وارد حساب کاربری خود شوید.
</div>
<div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'">
• سپس گزینه «اشتراک‌گذاری» یا «کپی لینک» را انتخاب کرده و لینک دعوت را برای دوستانتان ارسال کنید.<br />
• در صورتی که دوستان شما از طریق لینک شما ثبت‌نام کنند، به‌صورت خودکار ۱ سوت طلا به‌عنوان پاداش دریافت خواهید کرد.
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import img1 from '@/assets/img/1.png'
import img2 from '@/assets/img/2.png'
import img4 from '@/assets/img/4.png'
import { useEducatePageContext } from '../composables/useEducatePage'
withDefaults(
defineProps<{
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
const { config } = useEducatePageContext()
</script>
@@ -0,0 +1,43 @@
<template>
<div
v-if="config.config.name === 'garoosi'"
:class="variant === 'desktop' ? 'deducate-support' : 'wrap-fix bottom-0 px-10 py-6 flex gap-2 bg-white'"
>
<a class="w-full" href="tel:09031265925">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a href="https://ble.ir/garoosigallery">
<img
src="@/assets/img/bale.png"
alt=""
:class="variant === 'desktop' ? 'deducate-support__bale' : 'h-10 w-10'"
/>
</a>
</div>
<div
v-else
:class="variant === 'desktop' ? 'deducate-support' : 'wrap-fix bottom-0 px-10 py-5 flex gap-2 bg-white'"
>
<a class="w-full" href="tel:09306612518">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a href="">
<BasicButton icon="vuesax-linear:whatsapp" />
</a>
</div>
</template>
<script lang="ts" setup>
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { useEducatePageContext } from '../composables/useEducatePage'
withDefaults(
defineProps<{
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
const { config } = useEducatePageContext()
</script>
@@ -0,0 +1,105 @@
import { inject, onMounted, provide, ref, type InjectionKey } from 'vue'
import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { updateMe } from '@/api/me'
import type { User } from '@/models/login'
import { useConfigStore } from '@/store/configStore'
import { useUserStore } from '@/store/userStore'
import { loading } from '@/utils'
export interface EducatePageContext {
config: ReturnType<typeof useConfigStore>
userFormState: ReturnType<typeof ref<User>>
formRef: ReturnType<typeof ref<FormInstance | undefined>>
editPassVisible: ReturnType<typeof ref<boolean>>
setForm: () => void
copyCode: () => void
resetData: () => void
ok: () => void
}
export const EDUCATE_PAGE_CONTEXT_KEY: InjectionKey<EducatePageContext> = Symbol('educatePageContext')
export function useEducatePage() {
const config = useConfigStore()
const userStore = useUserStore()
const userFormState = ref<User>({
fname: '',
lname: '',
mobile: undefined,
birth_date: '',
phone_number: '',
province: '',
city: '',
n_code: undefined,
})
const formRef = ref<FormInstance>()
const editPassVisible = ref<boolean>(false)
function setForm() {
userFormState.value = userStore.userInfo
}
function copyCode() {
const val = userStore.userInfo?.referral_id
navigator.clipboard.writeText(val)
message.info('کد رونوشت شد')
}
const resetData = () => {
formRef.value?.resetFields()
userFormState.value = {
fname: '',
lname: '',
mobile: undefined,
birth_date: '',
phone_number: '',
province: '',
city: '',
n_code: undefined,
}
}
function ok() {
formRef.value?.validateFields().then(async () => {
try {
loading.value = true
await updateMe(userFormState.value)
await userStore.getMyInfo()
editPassVisible.value = false
message.success('اطلاعات شما با موفقیت به روز رسانی شد')
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
}
onMounted(() => {
setForm()
})
const context: EducatePageContext = {
config,
userFormState,
formRef,
editPassVisible,
setForm,
copyCode,
resetData,
ok,
}
provide(EDUCATE_PAGE_CONTEXT_KEY, context)
return context
}
export function useEducatePageContext() {
const context = inject(EDUCATE_PAGE_CONTEXT_KEY)
if (!context) {
throw new Error('useEducatePageContext must be used within a page that calls useEducatePage()')
}
return context
}
@@ -0,0 +1,52 @@
<template>
<div class="deducate">
<div class="deducate__container">
<header class="deducate-topbar">
<button type="button" class="deducate-topbar__back" @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
<span>بازگشت</span>
</button>
<div class="deducate-topbar__meta">
<p class="deducate-topbar__eyebrow">راهنما</p>
<h1 class="deducate-topbar__title">آموزش نرم افزار</h1>
</div>
</header>
<div class="deducate-layout">
<section class="deducate-surface deducate-main">
<div class="deducate-main__intro">
<div class="deducate-main__logo">
<BasicIcon icon="vuesax-linear:book" size="28" />
</div>
<div>
<h2 class="deducate-main__project">{{ config.config.name_fa }}</h2>
<p class="deducate-main__tagline">آموزش گام‌به‌گام استفاده از خدمات</p>
</div>
</div>
<div class="deducate-main__scroll">
<EducateContentPanel variant="desktop" />
</div>
</section>
<aside class="deducate-surface deducate-side">
<h3 class="deducate-side__title">پشتیبانی و ارتباط</h3>
<p class="deducate-side__text">
در صورت نیاز به راهنمایی بیشتر، با پشتیبانی تماس بگیرید.
</p>
<EducateSupportActions variant="desktop" />
</aside>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import EducateContentPanel from '../components/EducateContentPanel.vue'
import EducateSupportActions from '../components/EducateSupportActions.vue'
import { useEducatePageContext } from '../composables/useEducatePage'
const { config } = useEducatePageContext()
</script>
@@ -0,0 +1,231 @@
/* Premium desktop educate page */
.deducate {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.deducate__container {
max-width: 1080px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.deducate-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__back {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
color: #6b7280;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
padding: 8px 0;
&:hover {
color: #111827;
}
}
&__meta {
flex: 1;
min-width: 0;
}
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin: 0 0 4px;
}
&__title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.03em;
color: #111827;
margin: 0;
line-height: 1.2;
}
}
.deducate-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 300px);
gap: 20px;
align-items: start;
}
.deducate-surface {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.deducate-main {
padding: 28px;
display: flex;
flex-direction: column;
min-height: 0;
max-height: calc(100vh - 180px);
&__intro {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #eef0f3;
flex-shrink: 0;
}
&__logo {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 14px;
background: rgba(9, 61, 81, 0.08);
color: @App-primary-color;
flex-shrink: 0;
}
&__project {
font-size: 18px;
font-weight: 700;
color: #111827;
margin: 0 0 4px;
}
&__tagline {
font-size: 13px;
color: #6b7280;
margin: 0;
}
&__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-left: 4px;
margin-left: -4px;
}
}
.deducate-content {
padding: 20px;
border: 1px solid rgba(9, 61, 81, 0.15);
border-radius: 12px;
background: rgba(9, 61, 81, 0.03);
color: @App-primary-color;
&__avatar-wrap {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
&__avatar {
max-height: 120px;
width: auto;
object-fit: contain;
}
&__section-title {
font-size: 15px;
font-weight: 700;
color: inherit;
margin: 0 0 10px;
}
&__text {
font-size: 13px;
line-height: 1.8;
color: inherit;
text-align: justify;
margin: 0 0 12px;
}
&__image {
display: block;
width: 100%;
max-width: 100%;
height: auto;
margin: 12px 0 4px;
border-radius: 8px;
}
&__divider {
width: 100%;
height: 1px;
background: rgba(9, 61, 81, 0.12);
margin: 16px 0;
}
}
.deducate-side {
padding: 24px;
position: sticky;
top: 24px;
&__title {
font-size: 15px;
font-weight: 600;
color: #111827;
margin: 0 0 8px;
}
&__text {
font-size: 13px;
line-height: 1.7;
color: #6b7280;
margin: 0 0 20px;
}
}
.deducate-support {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
> a:first-child {
flex: 1;
min-width: 0;
}
&__bale {
width: 40px;
height: 40px;
object-fit: contain;
flex-shrink: 0;
}
}
@media (max-width: 1023.98px) {
.deducate-layout {
grid-template-columns: 1fr;
}
.deducate-side {
position: static;
}
.deducate-main {
max-height: none;
}
}
+12 -196
View File
@@ -1,208 +1,24 @@
<template> <template>
<div> <div class="educate-page-root">
<div class="wrap-fix z-1 bg-white py-3 px-3 text-black font-bold drop-shadow flex"> <DesktopEducateView v-show="isDesktop" />
<div @click="router.back()" class="cursor-pointer"> <MobileEducateView v-show="!isDesktop" />
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">آموزش نرم افزار</div>
</div>
<div class="pt-10">
<div>
<!-- <img :src="img" alt="" class="mt-2 w-full absolute bottom-0" />-->
<!-- <img :src="config.config.avatar" alt="" class="mt-2 w-full absolute bottom-0">-->
</div>
<div class="text-justify text-base z-200000 relative">
<div class="px-10">
<div class="h-20vh flex justify-center">
<img :src="config.config.avatar" alt="" class="mb-20 h-15vh mt-5">
</div>
<div class="overflow-y-auto h-60vh pb-10 border-[#093D51]">
<div
class="mt-5 text-5 border-solid border-2 border-[#093D51] text-[#093D51] rounded-[12px] p-4"
>
<div class="font-bold text-sm text-[#093D51]">
شیوهٔ ورود و ثبت ‌نام در گروسی گالری
</div>
<div class="mt-5 text-xs leading-5">
• برای ثبت‌نام در گروسی گالری، اگر قبلاً حساب کاربری دارید می‌توانید از طریق پیامک وارد شوید.<br />
• اگر حساب کاربری ندارید، "از این قسمت" وارد صفحه ثبت نام شده و با وارد کردن نام و شماره تلفنی که با کد ملی شما مطابقت دارد، حساب کاربری بسازید.<br />
• الزامی به وارد کردن کد معرف نیست.
</div>
<img :src="img1" />
<div class="w-full h-1px my-3 bg-secondary"></div>
<div class="font-bold mt-4 text-sm text-[#093D51]">
شارژ کیف پول
</div>
<div class="mt-5 text-xs leading-5">
• ابتدا کیف پول را در منو انتخاب کرده، مبلغ مورد نظر را وارد نموده و دکمه واریز را انتخاب کنید تا کیف پول شما شارژ شود.<br />
</div>
<img :src="img2" />
<div class="w-full h-1px my-3 bg-secondary"></div>
<div class="font-bold mt-4 text-sm text-[#093D51]">
شیوهٔ خرید از گروسی گالری
</div>
<div class="mt-5 text-xs leading-5">
• سپس از بخش «خرید و فروش» انتخاب کنید که طلای آب‌شده می‌خواهید یا محصولات دیگر (شامل گوی، شمش یا مکعب).<br />
• توجه داشته باشید کارمزد طلای آب‌شده حدود ۲٪ است و کارمزد ریالی محصولات دیگر در پایین صفحه قیمت درج شده است.
</div>
<img :src="img4" />
<div class="w-full h-1px my-3 bg-secondary"></div>
<div class="font-bold mt-4 text-sm text-[#093D51]">
شیوهٔ فروش در گروسی گالری
</div>
<div class="mt-5 text-xs leading-5">
• برای فروش محصولاتی که خریداری کرده‌اید، به بخش «خرید و فروش» مراجعه کرده، محصول موردنظر را انتخاب و گزینه فروش را بزنید تا مبلغ قابل دریافت نمایش داده شود.<br />
• مبلغ به کیف پول شما واریز می‌شود و از طریق پیام پشتیبانی می‌توانید درخواست برداشت دهید.<br />
• اگر موجودی کیف پول بیش از ۱۰ میلیون تومان باشد، مبلغ تا پایان روز کاری بعد به حساب اعلام‌شده شما واریز خواهد شد.
</div>
<div class="w-full h-1px my-3 bg-secondary"></div>
<div class="font-bold mt-4 text-sm text-[#093D51]">
شیوهٔ دریافت فیزیکی طلا (ارسال)
</div>
<div class="mt-5 text-xs leading-5">
• ابتدا موجودی محصول موردنظر را با پشتیبانی در واتساپ هماهنگ کنید.<br />
• سپس روی لوگو کلیک کرده، گزینه «برداشت فیزیکی» و بعد «درخواست جدید» را انتخاب کنید.<br />
• محصولات خریداری‌شده و تعداد آن‌ها را مشخص کرده و آدرس خود را ثبت نمایید.<br />
• پشتیبانی عکس و وزن محصول را برای شما ارسال می‌کند.<br />
• ارسال به‌صورت پس‌کرایه و بیمه‌شده انجام می‌شود.<br />
</div>
<div class="w-full h-1px my-3 bg-secondary"></div>
<div class="font-bold mt-4 text-sm text-[#093D51]">
پاداش معرفی
</div>
<div class="mt-5 text-xs leading-5">
• در صفحه اصلی روی "تنظیمات" کلیک کرده و وارد حساب کاربری خود شوید.
</div>
<!-- <img :src="img2" />-->
<div class="mt-5 text-xs leading-5">
• سپس گزینه «اشتراک‌گذاری» یا «کپی لینک» را انتخاب کرده و لینک دعوت را برای دوستانتان ارسال کنید.<br />
• در صورتی که دوستان شما از طریق لینک شما ثبت‌نام کنند، به‌صورت خودکار ۱ سوت طلا به‌عنوان پاداش دریافت خواهید کرد.
</div>
<!-- <img :src="img3" />-->
</div>
</div>
</div>
<div v-if="config.config.name === 'garoosi'" class="wrap-fix bottom-0 px-10 py-6 flex gap-2 bg-white">
<a class="w-full" href="tel:09031265925">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a href="https://ble.ir/garoosigallery">
<img src="@/assets/img/bale.png" alt="" class="h-10 w-10"/>
</a>
</div>
<div v-else class="wrap-fix bottom-0 px-10 py-5 flex gap-2 bg-white">
<a class="w-full" href="tel:09306612518">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a href="">
<BasicButton icon="vuesax-linear:whatsapp" />
</a>
</div>
</div>
</div>
<!-- <ActionSheet v-model:show="editPassVisible">salam</ActionSheet>-->
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, reactive, ref } from 'vue' import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import img from '@/assets/img/user_ly1.svg' import { useEducatePage } from './composables/useEducatePage'
import img1 from '@/assets/img/1.png' import DesktopEducateView from './desktop/DesktopEducateView.vue'
import img2 from '@/assets/img/2.png' import MobileEducateView from './mobile/MobileEducateView.vue'
import img4 from '@/assets/img/4.png'
import { loading } from '@/utils'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import type { User } from '@/models/login'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { updateMe } from '@/api/me'
import { useUserStore } from '@/store/userStore'
import {useConfigStore} from "@/store/configStore";
const config=useConfigStore()
const userFormState = ref<User>({
fname: '',
lname: '',
mobile: undefined,
birth_date: '',
phone_number: '',
province: '',
city: '',
n_code: undefined
})
const formRef = ref<FormInstance>()
const userStore = useUserStore()
// const editPassVisible = ref<boolean>(false) const isDesktop = useDesktopBreakpoint()
const editPassVisible = ref<boolean>(false)
function setForm() { useEducatePage()
userFormState.value = userStore.userInfo
}
function copyCode() {
// nextTick(() => {
const val = userStore.userInfo?.referral_id
navigator.clipboard.writeText(val)
message.info('کد رونوشت شد')
// })
}
const resetData = () => {
formRef.value?.resetFields()
userFormState.value = {
fname: '',
lname: '',
mobile: undefined,
birth_date: '',
phone_number: '',
province: '',
city: '',
n_code: undefined
}
}
onMounted(() => {
setForm()
})
function ok() {
formRef.value?.validateFields().then(async () => {
try {
loading.value = true
await updateMe(userFormState.value)
await userStore.getMyInfo()
editPassVisible.value = false
message.success('اطلاعات شما با موفقیت به روز رسانی شد')
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
}
</script> </script>
<style scoped> <style scoped>
/*.fade-enter-active, .educate-page-root {
.fade-leave-active { min-height: 100%;
transition: opacity 0.2s ease; height: 100%;
} }
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}*/
/*.trans{
transition: height 1s;
}*/
</style> </style>
@@ -0,0 +1,27 @@
<template>
<div>
<div class="wrap-fix z-1 bg-white py-3 px-3 text-black font-bold drop-shadow flex">
<div class="cursor-pointer" @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">آموزش نرم افزار</div>
</div>
<div class="pt-10">
<div class="text-justify text-base z-200000 relative">
<div class="px-10">
<div class="overflow-y-auto h-60vh pb-10 border-[#093D51]">
<EducateContentPanel variant="mobile" />
</div>
</div>
<EducateSupportActions variant="mobile" />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import EducateContentPanel from '../components/EducateContentPanel.vue'
import EducateSupportActions from '../components/EducateSupportActions.vue'
</script>
+9 -2
View File
@@ -1,5 +1,9 @@
<template> <template>
<div v-if="item" class="bg-white flex flex-col items-center p-3 rounded-xl"> <div
v-if="item"
class="bg-white flex flex-col items-center p-3 rounded-xl"
:class="{ 'dfund-card': variant === 'desktop' }"
>
<img :src="displayImage" class="w-[87px] h-[87px] object-contain" alt="" @error="onImageError"> <img :src="displayImage" class="w-[87px] h-[87px] object-contain" alt="" @error="onImageError">
<div class="mt-3">{{ item.title ?? '-' }}</div> <div class="mt-3">{{ item.title ?? '-' }}</div>
<div class="mt-1 flex flex-col items-center gap-0.5 text-center"> <div class="mt-1 flex flex-col items-center gap-0.5 text-center">
@@ -21,9 +25,12 @@ import { getProductDisplayImage } from '@/utils/productAssets'
interface Prop { interface Prop {
item: WalletInventoryCard item: WalletInventoryCard
variant?: 'mobile' | 'desktop'
} }
const props = defineProps<Prop>() const props = withDefaults(defineProps<Prop>(), {
variant: 'mobile',
})
const imageSrc = ref('') const imageSrc = ref('')
const displayImage = computed(() => imageSrc.value || resolveImage()) const displayImage = computed(() => imageSrc.value || resolveImage())
@@ -0,0 +1,119 @@
import { computed, inject, onMounted, provide, type InjectionKey, type Ref } from 'vue'
import router from '@/router'
import { loading } from '@/utils'
import { usePriceWithoutType } from '@/utils/usePrice'
import { useConfigStore } from '@/store/configStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { WalletMetalTotal } from '@/models/walletAssets'
import { resolveSummaryMetalWeight, fundPageInventoryCards } from '@/utils/productAssets'
import { useWalletStore } from '@/store/walletStore'
import {
getProjectFundEmptyImage,
getProjectFundEmptyText,
getProjectFundHeaderBackground,
isBazretalaProject,
} from '@/utils/config'
import img from '@/assets/img/user_ly1.svg'
import empty from '@/assets/img/cash_empty.svg'
export interface FundPageContext {
isDesktop: Ref<boolean>
nameProject: string
meta: ReturnType<typeof computed>
inventoryCards: ReturnType<typeof computed>
metalTotals: ReturnType<typeof computed>
primaryMetalTotal: ReturnType<typeof computed>
cashWallet: ReturnType<typeof computed>
totalRialValue: ReturnType<typeof computed>
countBasedInventory: ReturnType<typeof computed>
fundHeaderBackground: string
fundEmptyImage: string
fundEmptyText: string
isBazretalaProject: typeof isBazretalaProject
formatMetalTotal: (metal: WalletMetalTotal) => string
openFundInfo: (productId: number) => void
goBack: () => void
}
export const FUND_PAGE_KEY: InjectionKey<FundPageContext> = Symbol('fundPage')
export function useFundPage(isDesktop: Ref<boolean>) {
const nameProject = useConfigStore().config.name
const { assetsSummary, loadWalletAssets } = useWalletAssets()
const walletStore = useWalletStore()
const fundHeaderBackground = getProjectFundHeaderBackground(img)
const fundEmptyImage = getProjectFundEmptyImage(empty)
const fundEmptyText = getProjectFundEmptyText()
const meta = computed(() => assetsSummary.value?.meta)
const inventoryCards = computed(() =>
fundPageInventoryCards(assetsSummary.value?.inventory_cards ?? [])
)
const metalTotals = computed(() => assetsSummary.value?.metal_totals ?? [])
const primaryMetalTotal = computed(() => metalTotals.value[0])
const cashWallet = computed(() => assetsSummary.value?.cash_wallet ?? 0)
const totalRialValue = computed(() => assetsSummary.value?.total_rial_value ?? 0)
const countBasedInventory = computed(() =>
(assetsSummary.value?.count_products ?? []).reduce(
(sum, item) => sum + Number(item.quantity ?? 0),
0
)
)
function formatMetalTotal(metal: WalletMetalTotal) {
return `${resolveSummaryMetalWeight(metal, inventoryCards.value)}${metal.unit}`
}
function openFundInfo(productId: number) {
router.push({ name: 'fund_info', params: { id: productId } })
}
function goBack() {
router.back()
}
onMounted(async () => {
try {
loading.value = true
const tasks = [loadWalletAssets(true)]
if (isBazretalaProject()) {
tasks.push(walletStore.getMyWallet())
}
await Promise.all(tasks)
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
const context: FundPageContext = {
isDesktop,
nameProject,
meta,
inventoryCards,
metalTotals,
primaryMetalTotal,
cashWallet,
totalRialValue,
countBasedInventory,
fundHeaderBackground,
fundEmptyImage,
fundEmptyText,
isBazretalaProject,
formatMetalTotal,
openFundInfo,
goBack,
}
provide(FUND_PAGE_KEY, context)
return context
}
export function useFundPageContext() {
const context = inject(FUND_PAGE_KEY)
if (!context) {
throw new Error('useFundPageContext must be used within useFundPage')
}
return context
}
@@ -0,0 +1,138 @@
<template>
<div class="dfund">
<div class="dfund__container">
<header class="dfund-topbar">
<button type="button" class="dfund-topbar__back" @click="goBack()">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
بازگشت
</button>
<div class="dfund-topbar__meta">
<p class="dfund-topbar__eyebrow">دارایی</p>
<h1 class="dfund-topbar__title">دارایی‌ها</h1>
<p class="dfund-topbar__subtitle">نمای کلی موجودی محصولات و کیف پول</p>
</div>
</header>
<section
class="dfund-summary"
:class="{ 'dfund-summary--bazretala': isBazretalaProject() }"
>
<img
:src="fundHeaderBackground"
alt=""
class="dfund-summary__bg"
:class="{ 'dfund-summary__bg--cover': isBazretalaProject() }"
/>
<div class="dfund-summary__content">
<h2 class="dfund-summary__title">موجودی دارایی‌ها</h2>
<div v-if="meta?.has_count_based_products" class="dfund-summary__row">
<span>محصولات</span>
<strong>{{ countBasedInventory }} عدد</strong>
</div>
<template v-if="isBazretalaProject()">
<div class="dfund-summary__row">
<span>دارایی نقره</span>
<strong>{{ walletStore.walletInfo?.count_silver ?? 0 }} گرم</strong>
</div>
<div class="dfund-summary__row">
<span>دارایی طلایی</span>
<strong>{{ walletStore.walletInfo?.count_gold_wallet ?? 0 }} گرم</strong>
</div>
</template>
<template v-else-if="meta?.single_metal && primaryMetalTotal">
<div class="dfund-summary__row">
<span>{{ primaryMetalTotal.label }}</span>
<strong>{{ formatMetalTotal(primaryMetalTotal) }}</strong>
</div>
</template>
<template v-else>
<div
v-for="metal in metalTotals"
:key="`metal-${metal.metal_id}`"
class="dfund-summary__row"
>
<span>{{ metal.label }}</span>
<strong>{{ formatMetalTotal(metal) }}</strong>
</div>
</template>
<div
v-if="meta?.has_count_based_products && !isBazretalaProject()"
class="dfund-summary__row"
>
<span>مجموع موجودی</span>
<strong>{{ countBasedInventory }} عدد</strong>
</div>
<div class="dfund-summary__row">
<span>دارایی ریالی</span>
<strong class="number-ltr">{{ usePriceWithoutType(cashWallet) }} ریال</strong>
</div>
<div class="dfund-summary__row dfund-summary__row--highlight">
<span>معادل ریالی مجموع</span>
<strong class="number-ltr">{{ usePriceWithoutType(totalRialValue) }} ریال</strong>
</div>
</div>
</section>
<section class="dfund-inventory">
<div class="dfund-inventory__header">
<h3 class="dfund-inventory__title">لیست دارایی‌ها</h3>
<span class="dfund-inventory__count">{{ inventoryCards.length }} مورد</span>
</div>
<div
v-if="inventoryCards.length === 0 && !loading"
class="dfund-inventory__empty"
>
<img :src="fundEmptyImage" alt="" class="dfund-inventory__empty-img" />
<p v-if="nameProject !== 'abaeigold'">{{ fundEmptyText }}</p>
</div>
<a-spin :spinning="loading">
<div v-if="inventoryCards.length" class="dfund-inventory__grid">
<button
v-for="item in inventoryCards"
:key="item.product_id"
type="button"
class="dfund-inventory__card"
@click="openFundInfo(item.product_id)"
>
<Item :item="item" variant="desktop" />
</button>
</div>
</a-spin>
</section>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { loading } from '@/utils'
import { usePriceWithoutType } from '@/utils/usePrice'
import { useWalletStore } from '@/store/walletStore'
import Item from '../components/Item.vue'
import { useFundPageContext } from '../composables/useFundPage'
const walletStore = useWalletStore()
const {
nameProject,
meta,
inventoryCards,
metalTotals,
primaryMetalTotal,
cashWallet,
totalRialValue,
countBasedInventory,
fundHeaderBackground,
fundEmptyImage,
fundEmptyText,
isBazretalaProject,
formatMetalTotal,
openFundInfo,
goBack,
} = useFundPageContext()
</script>
@@ -0,0 +1,495 @@
/* Premium desktop fund page */
.dfund {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.dfund__container {
max-width: 1180px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.dfund-topbar {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__back {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
color: #6b7280;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
padding: 8px 0;
flex-shrink: 0;
&:hover {
color: #111827;
}
}
&__meta {
flex: 1;
min-width: 0;
}
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin: 0 0 4px;
}
&__title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.03em;
color: #111827;
margin: 0;
line-height: 1.2;
}
&__subtitle {
font-size: 14px;
color: #6b7280;
margin: 6px 0 0;
line-height: 1.6;
}
}
.dfund-summary {
position: relative;
overflow: hidden;
border-radius: 16px;
margin-bottom: 24px;
background: @App-primary-color;
color: #fff;
&--bazretala {
background: #000;
}
&__bg {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
opacity: 0.08;
pointer-events: none;
&--cover {
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.35;
}
}
&__content {
position: relative;
z-index: 1;
padding: 24px 28px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px 24px;
}
&__title {
grid-column: 1 / -1;
font-size: 18px;
font-weight: 600;
margin: 0 0 4px;
color: #fff;
}
&__row {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
span {
opacity: 0.85;
}
strong {
font-size: 16px;
font-weight: 700;
}
&--highlight {
grid-column: 1 / -1;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
}
}
.dfund-surface {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.dfund-inventory {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
padding: 24px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 20px;
}
&__title {
font-size: 16px;
font-weight: 600;
margin: 0;
color: #111827;
}
&__count {
font-size: 13px;
color: #9ca3af;
}
&__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
&__card {
border: none;
background: transparent;
padding: 0;
cursor: pointer;
font-family: inherit;
text-align: inherit;
width: 100%;
&:hover .dfund-card {
border-color: rgba(15, 23, 42, 0.12);
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
}
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 24px;
text-align: center;
color: #6b7280;
}
&__empty-img {
width: 160px;
max-width: 100%;
}
}
.dfund-card {
width: 100%;
border: 1px solid rgba(15, 23, 42, 0.08) !important;
border-radius: 14px !important;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
@media (max-width: 1024px) {
.dfund-summary__content {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dfund-inventory__grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.dfund-inventory__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* Fund info desktop */
.dfund-info {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.dfund-info__container {
max-width: 1180px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.dfund-info-topbar {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__back {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
color: #6b7280;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
padding: 8px 0;
flex-shrink: 0;
&:hover {
color: #111827;
}
}
&__meta {
flex: 1;
min-width: 0;
}
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin: 0 0 4px;
}
&__title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.03em;
color: #111827;
margin: 0;
line-height: 1.2;
}
}
.dfund-info__layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.dfund-info__hero {
padding: 28px 24px;
text-align: center;
}
.dfund-info__image {
width: 120px;
height: 120px;
margin: 0 auto 16px;
border-radius: 16px;
background: #e0f2fe;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
img {
max-width: 80%;
max-height: 80%;
object-fit: contain;
}
}
.dfund-info__balance {
display: flex;
align-items: baseline;
justify-content: center;
gap: 6px;
}
.dfund-info__balance-value {
font-size: 28px;
font-weight: 700;
color: @App-primary-color;
}
.dfund-info__balance-unit {
font-size: 14px;
color: #6b7280;
}
.dfund-info__balance-label {
margin: 8px 0 0;
font-size: 13px;
color: #9ca3af;
}
.dfund-info__transactions {
padding: 24px;
}
.dfund-info__transactions-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.dfund-info__transactions-title {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.dfund-info__transactions-count {
font-size: 13px;
color: #9ca3af;
}
.dfund-info__transactions-list {
max-height: 520px;
overflow-y: auto;
}
/* Fund new desktop */
.dfund-new {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.dfund-new__container {
max-width: 640px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.dfund-new-topbar {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__back {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
color: #6b7280;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
padding: 8px 0;
flex-shrink: 0;
&:hover {
color: #111827;
}
}
&__meta {
flex: 1;
min-width: 0;
}
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin: 0 0 4px;
}
&__title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.03em;
color: #111827;
margin: 0;
line-height: 1.2;
}
}
.dfund-new__card {
padding: 32px 28px;
}
.dfund-new__preview {
display: flex;
justify-content: center;
margin-bottom: 24px;
}
.dfund-new__preview-img {
width: 120px;
height: 120px;
object-fit: contain;
border-radius: 16px;
background: #e0f2fe;
padding: 16px;
}
.dfund-new__label {
display: block;
font-size: 13px;
font-weight: 600;
color: #374151;
margin-bottom: 8px;
}
.dfund-new__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 20px 0 28px;
}
.dfund-new__tag {
padding: 8px 14px;
border-radius: 999px;
border: 1px solid rgba(15, 23, 42, 0.1);
background: #f3f4f6;
color: #4b5563;
font-size: 12px;
font-family: inherit;
cursor: pointer;
&--active {
background: @App-primary-color;
border-color: @App-primary-color;
color: #fff;
}
}
.dfund-new__submit {
width: 100%;
}
+12 -159
View File
@@ -1,170 +1,23 @@
<template> <template>
<div> <div class="fund-page-root">
<div class="bg-white w-full py-3 px-3 text-primary font-bold drop-shadow fixed md:absolute top-0 flex h-12 z-20"> <DesktopFundView v-show="isDesktop" />
<div @click="router.back()"> <MobileFundView v-show="!isDesktop" />
<BasicIcon icon="vuesax-linear:arrow-right-1"/>
</div>
<div class="mr-2">دارایی ها</div>
</div>
<div
class="w-screen md:w-full h-[70vh] fixed md:absolute overflow-hidden"
:class="isBazretalaProject() ? 'bazretala-fund-header' : 'text-tertiary bg-primary'"
>
<img
:src="fundHeaderBackground"
alt=""
:class="isBazretalaProject()
? 'pointer-events-none z-0 absolute inset-0 w-full object-cover object-center'
: 'pointer-events-none z-0 w-full absolute bottom-0 left-0 opacity-5'"
/>
<div
class="pt-15 flex flex-col relative z-10"
:class="isBazretalaProject() ? 'text-white' : 'text-tertiary'"
>
<div class="font-bold mr-5 z-20">موجودی دارایی ها</div>
<div v-if="meta?.has_count_based_products" class="flex mt-4 justify-center mt-2 w-full z-20">
محصولات:
<div class="text-l mr-4 font-bold">{{ countBasedInventory }} عدد</div>
</div>
<template v-if="isBazretalaProject()">
<div class="flex justify-center mt-2 w-full z-20">
دارایی نقره:
<div class="text-l mr-4 font-bold">{{ walletStore.walletInfo?.count_silver ?? 0 }}گرم</div>
</div>
<div class="flex justify-center mt-2 w-full z-20">
دارایی طلایی:
<div class="text-l mr-4 font-bold">{{ walletStore.walletInfo?.count_gold_wallet ?? 0 }}گرم</div>
</div>
</template>
<template v-else-if="meta?.single_metal && primaryMetalTotal">
<div class="flex justify-center mt-2 w-full z-20">
{{ primaryMetalTotal.label }}:
<div class="text-l mr-4 font-bold">{{ formatMetalTotal(primaryMetalTotal) }}</div>
</div>
</template>
<template v-else>
<div
v-for="metal in metalTotals"
:key="`metal-${metal.metal_id}`"
class="flex justify-center mt-2 w-full z-20"
>
{{ metal.label }}:
<div class="text-l mr-4 font-bold">{{ formatMetalTotal(metal) }}</div>
</div>
</template>
<div v-if="meta?.has_count_based_products && !isBazretalaProject()" class="flex justify-center mt-2 w-full z-20">
مجموع موجودی:
<div class="text-l mr-4 font-bold">{{ countBasedInventory }} عدد</div>
</div>
<div class="flex justify-center mt-2 w-full z-20">
دارایی ریالی:
<div class="text-l mr-4 font-bold">{{ usePriceWithoutType(cashWallet) }}ریال</div>
</div>
<div class="flex justify-center mt-2 w-full z-20">
معادل ریالی مجموع دارایی ها:
<div class="text-l mr-4 font-bold">{{ usePriceWithoutType(totalRialValue) }}ریال</div>
</div>
</div>
</div>
<div class="pt-[20rem] ">
<div class="bg-gray-100 w-screen md:w-full z-20 relative rounded-t-3xl h-[calc(100vh-20rem-70px)] overflow-y-auto pb-15"
style="box-shadow: 0px -2px 4px -2px #0000001A;">
<div v-if="inventoryCards.length === 0 && !loading" class="w-full h-full flex justify-center items-center">
<div>
<img
:src="fundEmptyImage"
alt=""
:class="isBazretalaProject() ? 'w-48 max-w-[11rem]' : 'w-70'"
>
<div v-if="nameProject !== 'abaeigold'" class="text-center text-lg">{{ fundEmptyText }}</div>
</div>
</div>
<a-spin :spinning="loading">
<div :class="loading ? 'h-[40vh]' : ''" class="grid grid-cols-2 gap-3 p-5">
<Item
v-for="item in inventoryCards"
:key="item.product_id"
:item="item"
@click="router.push({ name: 'fund_info', params: { id: item.product_id } })"
/>
</div>
</a-spin>
</div>
</div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import router from '@/router' import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue' import { useFundPage } from './composables/useFundPage'
import img from '@/assets/img/user_ly1.svg' import DesktopFundView from './desktop/DesktopFundView.vue'
import empty from '@/assets/img/cash_empty.svg' import MobileFundView from './mobile/MobileFundView.vue'
import Item from './components/Item.vue'
import { onMounted, computed } from 'vue'
import { loading } from '@/utils'
import { usePriceWithoutType } from '@/utils/usePrice'
import { useConfigStore } from '@/store/configStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { WalletMetalTotal } from '@/models/walletAssets'
import { resolveSummaryMetalWeight, fundPageInventoryCards } from '@/utils/productAssets'
import { useWalletStore } from '@/store/walletStore'
import {
getProjectFundEmptyImage,
getProjectFundEmptyText,
getProjectFundHeaderBackground,
isBazretalaProject,
} from '@/utils/config'
const nameProject = useConfigStore().config.name const isDesktop = useDesktopBreakpoint()
const { assetsSummary, loadWalletAssets } = useWalletAssets() useFundPage(isDesktop)
const walletStore = useWalletStore()
const fundHeaderBackground = getProjectFundHeaderBackground(img)
const fundEmptyImage = getProjectFundEmptyImage(empty)
const fundEmptyText = getProjectFundEmptyText()
const meta = computed(() => assetsSummary.value?.meta)
const inventoryCards = computed(() => fundPageInventoryCards(assetsSummary.value?.inventory_cards ?? []))
const metalTotals = computed(() => assetsSummary.value?.metal_totals ?? [])
const primaryMetalTotal = computed(() => metalTotals.value[0])
const cashWallet = computed(() => assetsSummary.value?.cash_wallet ?? 0)
const totalRialValue = computed(() => assetsSummary.value?.total_rial_value ?? 0)
const countBasedInventory = computed(() =>
(assetsSummary.value?.count_products ?? []).reduce((sum, item) => sum + Number(item.quantity ?? 0), 0)
)
onMounted(async () => {
try {
loading.value = true
const tasks = [loadWalletAssets(true)]
if (isBazretalaProject()) {
tasks.push(walletStore.getMyWallet())
}
await Promise.all(tasks)
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
function formatMetalTotal(metal: WalletMetalTotal) {
return `${resolveSummaryMetalWeight(metal, inventoryCards.value)}${metal.unit}`
}
</script> </script>
<style scoped> <style scoped>
.bazretala-fund-header { .fund-page-root {
background-color: #000; min-height: 100%;
} height: 100%;
.bazretala-fund-header::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
background: rgba(0, 0, 0, 0.45);
pointer-events: none;
} }
</style> </style>
@@ -0,0 +1,48 @@
<template>
<div :class="variant === 'desktop' ? 'dfund-info-edit' : ''">
<div v-if="variant !== 'desktop'" class="font-bold text-lg">گنج {{ info?.title }}</div>
<BasicButton
:class="variant === 'desktop' ? 'mt-4 w-full' : 'mt-12 w-full'"
icon="vuesax-linear:edit"
icon-size="16"
@click="$router.push({ name: 'fund_add', query: { id: infoId } })"
>
<div class="flex w-full justify-right">ویرایش</div>
</BasicButton>
<BasicButton
:loading="loading"
:class="variant === 'desktop' ? 'mt-2 w-full !text-red-600' : 'mb-10 mt-2 w-full !text-red-600'"
icon="vuesax-linear:trash"
icon-size="16"
@click="deleteFundConfirm"
>
<div class="flex w-full justify-right">حذف</div>
</BasicButton>
<BasicButton
v-if="variant !== 'desktop'"
class="mb-20 mt-2 w-full"
type="primary"
@click="transactionShow = false"
>
بازگشت
</BasicButton>
</div>
</template>
<script lang="ts" setup>
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { loading } from '@/utils'
import { useFundInfoPageContext } from '../composables/useFundInfoPage'
interface Props {
variant?: 'mobile' | 'desktop'
}
withDefaults(defineProps<Props>(), {
variant: 'mobile',
})
const { info, infoId, transactionShow, deleteFundConfirm } = useFundInfoPageContext()
</script>
@@ -0,0 +1,82 @@
<template>
<div :class="variant === 'desktop' ? 'dfund-info-form' : ''">
<div v-if="variant !== 'desktop'" class="font-bold text-lg">
{{ isDeposit ? 'واریز' : 'برداشت' }}
</div>
<a-form
ref="formRef"
:model="userFormState"
:class="variant === 'desktop' ? '!mt-0' : '!mt-5'"
name="basic"
@finish="transaction()"
>
<a-form-item :rules="[required()]" label="مقدار گرم طلا" name="value" class="!block">
<BasicInput
v-model:value="userFormState.value"
addon-before="گرم"
place-holder="گرم"
type="text"
inputmode="decimal"
size="large"
/>
<div v-if="isDeposit" class="mt-2 text-xs font-bold leading-5">
<div>{{ formatGoldWalletBalanceText(walletStore?.walletInfo?.gold ?? 0) }}</div>
<div v-if="info?.wallet?.gold !== 0">
{{ `موجودی قابل برداشت گنج: ${info?.wallet?.gold ?? 0} گرم` }}
</div>
</div>
<div v-else class="mt-2 text-xs font-bold">
موجودی گنج {{ info?.title }}: {{ info?.wallet?.gold }} گرم
</div>
</a-form-item>
<a-form-item>
<BasicButton
:disabled="transButtonDisable"
class="w-full py-2 mt-2"
html-type="submit"
type="primary"
>
<div>
<a-spin v-if="loading" size="small" />
<div v-else>{{ isDeposit ? 'واریز' : 'برداشت' }}</div>
</div>
</BasicButton>
<BasicButton
v-if="variant !== 'desktop'"
class="mb-20 mt-2 w-full"
@click="transactionShow = false"
>
بازگشت
</BasicButton>
</a-form-item>
</a-form>
</div>
</template>
<script lang="ts" setup>
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import { loading } from '@/utils'
import { required } from '@/utils/inputRules'
import { formatGoldWalletBalanceText } from '@/utils/productDealMethod'
import { useFundInfoPageContext } from '../composables/useFundInfoPage'
interface Props {
variant?: 'mobile' | 'desktop'
}
withDefaults(defineProps<Props>(), {
variant: 'mobile',
})
const {
info,
isDeposit,
formRef,
userFormState,
transButtonDisable,
walletStore,
transactionShow,
transaction,
} = useFundInfoPageContext()
</script>
@@ -0,0 +1,185 @@
import { computed, inject, onMounted, provide, reactive, ref, watch, type InjectionKey } from 'vue'
import router from '@/router'
import { loading } from '@/utils'
import { deleteFund, fundDeposit, fundWithdraw, getFundById, getFundTransactionTable } from '@/api/fund'
import type { FundTransaction } from '@/models/fund'
import type { TableInput, TableOutput } from '@/models/table'
import type { Transaction } from '@/models/transaction'
import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { useWalletStore } from '@/store/walletStore'
import { useFundUserStore } from '@/store/fundStore'
import { getOneProductWallet } from '@/api/products'
import type { Product } from '@/models/products'
import homeImg from '@/assets/img/cash_home.svg'
import giftImg from '@/assets/img/cash_gift.svg'
import circulationImg from '@/assets/img/cash_circulation.svg'
import loveImg from '@/assets/img/cash_love.svg'
import repairsImg from '@/assets/img/cash_repairs.svg'
import savingImg from '@/assets/img/cash_saving.svg'
export const FUND_INFO_PAGE_KEY: InjectionKey<ReturnType<typeof useFundInfoPage>> =
Symbol('fundInfoPage')
export function useFundInfoPage() {
const info = ref<Product>()
const items = ref<Transaction[]>([])
const tableOutput = ref<TableOutput<Transaction> | null>(null)
const pagination = ref<TableInput>({})
const transactionShow = ref<boolean>()
const editShow = ref<boolean>()
const showDeleteConfirm = ref<boolean>()
const isDeposit = ref<boolean>()
const formRef = ref<FormInstance>()
const fundStore = useFundUserStore()
const walletStore = useWalletStore()
const infoId: number = parseInt(String(router.currentRoute.value.params.id))
const userFormState = reactive<FundTransaction>({
value: undefined,
fund_id: infoId,
})
watch(transactionShow, (n) => (!n ? (userFormState.value = undefined) : ''))
const transButtonDisable = computed(() => {
if (!loading.value) {
if (userFormState.value !== undefined) {
if (isDeposit.value) {
return userFormState.value > walletStore?.walletInfo?.gold
}
return userFormState.value > (info.value?.wallet?.gold ?? 0)
}
return true
}
return true
})
const itemImg = computed(() => {
switch (info.value?.title) {
case 'خرید لوازم منزل':
return homeImg
case 'هدیه':
return giftImg
case 'گردش':
return circulationImg
case 'ازدواج':
return loveImg
case 'تعمیرات':
return repairsImg
case 'پس انداز':
return savingImg
default:
return savingImg
}
})
function deleteFundConfirm() {
if (info.value?.wallet === undefined || info.value?.wallet?.gold === 0) {
showDeleteConfirm.value = true
} else {
message.error('ابتدا گنج خود را خالی کنید')
editShow.value = false
}
}
async function deleteFundFunction() {
try {
loading.value = true
showDeleteConfirm.value = false
await deleteFund(infoId)
await fundStore.getMyFund()
router.back()
message.success('گنج با موفقیت حذف شد')
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
async function initInfo() {
try {
loading.value = true
info.value = await getOneProductWallet(infoId)
await initTable()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
async function initTable() {
if (info.value?.wallet) {
try {
loading.value = true
items.value = []
Object.assign(pagination.value, { wallet_id: info.value?.wallet?.id })
tableOutput.value = await getFundTransactionTable(pagination.value)
items.value.push(...tableOutput.value.rows)
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
}
async function transaction() {
formRef.value?.validateFields().then(async () => {
try {
loading.value = true
userFormState.fund_id = infoId
isDeposit.value ? await fundDeposit(userFormState) : await fundWithdraw(userFormState)
await initInfo()
await fundStore.getMyFund()
await walletStore.getMyWallet()
message.success(`با موفقیت${isDeposit.value ? ' واریز' : ' برداشت'} شد`)
transactionShow.value = false
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
}
function goBack() {
router.back()
}
onMounted(() => {
initInfo()
})
const context = {
info,
items,
transactionShow,
editShow,
showDeleteConfirm,
isDeposit,
formRef,
userFormState,
transButtonDisable,
itemImg,
walletStore,
deleteFundConfirm,
deleteFundFunction,
transaction,
goBack,
infoId,
}
provide(FUND_INFO_PAGE_KEY, context)
return context
}
export function useFundInfoPageContext() {
const context = inject(FUND_INFO_PAGE_KEY)
if (!context) {
throw new Error('useFundInfoPageContext must be used within useFundInfoPage')
}
return context
}
@@ -0,0 +1,78 @@
<template>
<div class="dfund-info">
<div class="dfund-info__container">
<header class="dfund-info-topbar">
<button type="button" class="dfund-info-topbar__back" @click="goBack()">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
بازگشت
</button>
<div class="dfund-info-topbar__meta">
<p class="dfund-info-topbar__eyebrow">جزئیات دارایی</p>
<h1 class="dfund-info-topbar__title">{{ info?.title ?? '—' }}</h1>
</div>
</header>
<div class="dfund-info__layout">
<section class="dfund-info__hero dfund-surface">
<a-spin :spinning="loading">
<div class="dfund-info__image">
<img v-if="info?.group_type == 'گوی'" :src="goyImg" alt="" />
<img v-if="info?.group_type == 'مکعب'" :src="mokaabImg" alt="" />
<img v-if="info?.group_type == 'سکه'" :src="sekkeImg" alt="" />
<img v-if="info?.group_type == 'مس'" :src="mesImg" alt="" />
<img v-if="info?.group_type == 'نقره'" :src="noghrehImg" alt="" />
<img
v-if="info?.group_type == 'طلا' || info?.group_type == 'آبشده' || info?.group_type == null"
:src="defaultImg"
alt=""
/>
</div>
</a-spin>
<div class="dfund-info__balance">
<span class="dfund-info__balance-value">{{ info?.wallet?.gold ?? 0 }}</span>
<span class="dfund-info__balance-unit">{{ info?.unit }}</span>
</div>
<p class="dfund-info__balance-label">موجودی گنج {{ info?.title }}</p>
</section>
<section class="dfund-info__transactions dfund-surface">
<div class="dfund-info__transactions-header">
<h2 class="dfund-info__transactions-title">تراکنش‌ها</h2>
<span class="dfund-info__transactions-count">{{ items.length }} مورد</span>
</div>
<div class="dfund-info__transactions-list">
<list-anim v-if="!loading">
<Item
v-for="item in items"
:key="item.id"
:item="item"
:title="info?.title"
/>
</list-anim>
<TransactionSkeleton v-for="i in 6" v-else :key="i" class="mt-1" />
<empty v-if="items.length === 0 && !loading" text="هیچ تراکنشی یافت نشد" />
</div>
</section>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { loading } from '@/utils'
import ListAnim from '@/components/ListAnim.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import Empty from '@/components/empty.vue'
import Item from '../components/Item.vue'
import goyImg from '@/assets/img/goyImg.svg'
import mokaabImg from '@/assets/img/mokaabImg.svg'
import sekkeImg from '@/assets/img/sekkeImg.svg'
import mesImg from '@/assets/img/mesImg.jpg'
import noghrehImg from '@/assets/img/noghrehImg.webp'
import defaultImg from '@/assets/img/defaultAbshodeh.svg'
import { useFundInfoPageContext } from '../composables/useFundInfoPage'
const { info, items, goBack } = useFundInfoPageContext()
</script>
+60 -286
View File
@@ -1,305 +1,79 @@
<template> <template>
<div> <div class="fund-info-page-root">
<div class="bg-white wrap-fix py-3 px-3 text-primary font-bold drop-shadow flex h-12 z-20 justify-between"> <DesktopFundInfoView v-show="isDesktop" />
<div class="flex "> <MobileFundInfoView v-show="!isDesktop" />
<div @click="router.back()" class="cursor-pointer">
<BasicIcon icon="vuesax-linear:arrow-right-1" /> <BasicActionSheet v-if="!isDesktop" v-model:show="transactionShow">
</div> <FundTransactionForm />
<div class="mr-2">{{ info?.title }}</div>
</div>
<!-- <div @click="editShow = true">
<BasicIcon icon="vuesax-linear:setting-4" />
</div>-->
</div>
<div class="pt-20 flex flex-col justify-center items-center">
<a-spin :spinning="loading">
<div class="bg-sky-100 w-[120px] aspect-square flex justify-center rounded-xl">
<!-- <img v-if="!loading" :src="itemImg" alt="">-->
<img v-if="info?.group_type=='گوی'" :src="goyImg">
<img v-if="info?.group_type=='مکعب'" :src="mokaabImg">
<img v-if="info?.group_type=='سکه'" :src="sekkeImg">
<img v-if="info?.group_type=='مس'" :src="mesImg">
<img v-if="info?.group_type=='نقره'" :src="noghrehImg">
<img v-if="info?.group_type=='طلا' || info?.group_type=='آبشده' || info?.group_type== null" :src="defaultImg">
</div>
</a-spin>
<div class="mt-2 font-bold flex text-xs items-center text-gray-500">
<div class="ml-1 text-base text-primary">{{ info?.wallet?.gold?? 0 }}</div>
{{info?.unit}}
</div>
<div class="text-gray-400">موجودی گنج {{ info?.title }}</div>
<div class="w-full p-5">
<!-- <div class="w-full flex gap-3">
<BasicButton class="w-full !bg-[#F3F0E9] !border-0 !rounded" icon="vuesax-linear:add-square" icon-size="18"
@click="()=> {transactionShow = true; isDeposit= false}">برداشت
</BasicButton>
<BasicButton class="w-full !bg-[#F3F0E9] !border-0 !rounded" icon="vuesax-linear:arrow-up" icon-size="18"
@click="()=> {transactionShow = true; isDeposit= true}">واریز
</BasicButton>
</div>-->
<div class=" font-bold mt-5">تراکنش ها</div>
<!-- <a-spin :spinning="loading">-->
<div class="mt-5 pb-17 overflow-y-scroll h-[54vh]">
<list-anim v-if="!loading">
<Item v-for="item in items" :key="item.id" :item="item" :title="info?.title"/>
</list-anim>
<TransactionSkeleton v-for="i in 8" v-else :key="i" class="mt-1" />
<empty v-if="items.length === 0 && !loading" text="هیچ تراکنشی یافت نشد" />
</div>
<!-- </a-spin>-->
</div>
</div>
<BasicActionSheet v-model:show="transactionShow">
<div class="font-bold text-lg">{{ isDeposit ? 'واریز' : 'برداشت' }}</div>
<a-form ref="formRef" :model="userFormState" class="!mt-5" name="basic" @finish="transaction()">
<a-form-item :rules="[required()]" label="مقدار گرم طلا" name="value" class="!block">
<BasicInput v-model:value="userFormState.value"
addon-before="گرم" place-holder="گرم"
type="text" inputmode="decimal"
size="large" />
<div v-if="isDeposit" class="mt-2 text-xs font-bold leading-5">
<div>{{ formatGoldWalletBalanceText(walletStore?.walletInfo?.gold ?? 0) }}</div>
<div v-if="info?.wallet?.gold !== 0">
{{ `موجودی قابل برداشت گنج: ${info?.wallet?.gold ?? 0} گرم` }}
</div>
</div>
<div v-else class="mt-2 text-xs font-bold">موجودی گنج {{ info.title }}: {{ info?.wallet?.gold }} گرم
</div>
</a-form-item>
<a-form-item>
<BasicButton
:disabled="transButtonDisable"
class=" w-full py-2 mt-2"
html-type="submit"
type="primary"
>
<div>
<a-spin v-if="loading" size="small" />
<div v-else>{{ isDeposit ? 'واریز' : 'برداشت' }}</div>
</div>
</BasicButton>
<BasicButton class="mb-20 mt-2 w-full" @click="transactionShow = false">بازگشت</BasicButton>
</a-form-item>
</a-form>
</BasicActionSheet> </BasicActionSheet>
<BasicActionSheet v-model:show="editShow"> <BasicModal
<div class="font-bold text-lg">گنج {{ info?.title }}</div> v-else
v-model:visible="transactionShow"
:title="isDeposit ? 'واریز' : 'برداشت'"
width="480px"
destroy-on-close
custom-footer
>
<FundTransactionForm variant="desktop" />
<template #footer>
<div></div>
</template>
</BasicModal>
<BasicButton class="mt-12 w-full" icon="vuesax-linear:edit" <BasicActionSheet v-if="!isDesktop" v-model:show="editShow">
icon-size="16" @click="$router.push({name: 'fund_add', query:{id:infoId}})"> <FundEditActions />
<div class="flex w-full justify-right">ویرایش</div>
</BasicButton>
<BasicButton :loading="loading" class="mb-10 mt-2 w-full !text-red-600"
icon="vuesax-linear:trash"
icon-size="16"
@click="deleteFundConfirm">
<div class="flex w-full justify-right">حذف</div>
</BasicButton>
<BasicButton class="mb-20 mt-2 w-full" type="primary" @click="transactionShow = false">بازگشت</BasicButton>
</BasicActionSheet> </BasicActionSheet>
<DialogVant v-model:show="showDeleteConfirm" :closeOnClickOverlay="true" :timeOut="-1"> <BasicModal
<!-- <template v-slot:img> v-else
<img :src="registerDelayImage" alt="" class="opacity-80"> v-model:visible="editShow"
</template>--> :title="`گنج ${info?.title ?? ''}`"
width="420px"
<template v-slot:title> destroy-on-close
<div class="p-5 text-sm"> custom-footer
آیا از حذف گنج خود اطمینان دارید؟ >
</div> <FundEditActions variant="desktop" />
<template #footer>
<div></div>
</template> </template>
</BasicModal>
<template v-slot:button> <DialogVant v-model:show="showDeleteConfirm" :close-on-click-overlay="true" :time-out="-1">
<div class="text-center text-red" @click="deleteFundFunction"> <template #title>
حذف <div class="p-5 text-sm">آیا از حذف گنج خود اطمینان دارید؟</div>
</div> </template>
<div class="text-center" @click="showDeleteConfirm = false"> <template #button>
انصراف <div class="text-center text-red" @click="deleteFundFunction">حذف</div>
</div> <div class="text-center" @click="showDeleteConfirm = false">انصراف</div>
</template> </template>
</DialogVant> </DialogVant>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import router from '@/router'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import Item from './components/Item.vue'
import homeImg from '@/assets/img/cash_home.svg'
import giftImg from '@/assets/img/cash_gift.svg'
import circulationImg from '@/assets/img/cash_circulation.svg'
import loveImg from '@/assets/img/cash_love.svg'
import repairsImg from '@/assets/img/cash_repairs.svg'
import savingImg from '@/assets/img/cash_saving.svg'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { loading } from '@/utils'
import { deleteFund, fundDeposit, fundWithdraw, getFundById, getFundTransactionTable } from '@/api/fund'
import type { Fund, FundTransaction } from '@/models/fund'
import type { TableInput, TableOutput } from '@/models/table'
import type { Transaction } from '@/models/transaction'
import Empty from '@/components/empty.vue'
import { required } from '@/utils/inputRules'
import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue' import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import { formatGoldWalletBalanceText } from '@/utils/productDealMethod' import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import { useWalletStore } from '@/store/walletStore'
import ListAnim from '@/components/ListAnim.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import DialogVant from '@/components/DialogVant.vue' import DialogVant from '@/components/DialogVant.vue'
import { useFundUserStore } from '@/store/fundStore' import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { getOneProductWallet } from '@/api/products' import { useFundInfoPage } from './composables/useFundInfoPage'
import goyImg from '@/assets/img/goyImg.svg' import DesktopFundInfoView from './desktop/DesktopFundInfoView.vue'
import mokaabImg from '@/assets/img/mokaabImg.svg' import MobileFundInfoView from './mobile/MobileFundInfoView.vue'
import sekkeImg from "@/assets/img/sekkeImg.svg" import FundTransactionForm from './components/FundTransactionForm.vue'
import mesImg from "@/assets/img/mesImg.jpg" import FundEditActions from './components/FundEditActions.vue'
import noghrehImg from "@/assets/img/noghrehImg.webp"
import defaultImg from '@/assets/img/defaultAbshodeh.svg'
import type { Product } from '@/models/products'
const info = ref<Product>()
const items = ref<Transaction[]>([])
const tableOutput = ref<TableOutput<Transaction> | null>(null)
const pagination = ref<TableInput>({/*limit: 100, page: 0*/ })
const transactionShow = ref<boolean>()
const editShow = ref<boolean>()
const showDeleteConfirm = ref<boolean>()
const isDeposit = ref<boolean>()
const formRef = ref<FormInstance>()
let fundStore = useFundUserStore()
let walletStore = useWalletStore()
onMounted(() => {
initInfo()
// initTable()
})
function deleteFundConfirm() {
if (info.value?.wallet === undefined || info.value?.wallet?.gold === 0) {
showDeleteConfirm.value = true
} else {
message.error('ابتدا گنج خود را خالی کنید')
editShow.value = false
}
}
async function deleteFundFunction() {
try {
loading.value = true
showDeleteConfirm.value = false
await deleteFund(infoId)
await fundStore.getMyFund()
router.back()
message.success('گنج با موفقیت حذف شد')
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
const infoId: number = parseInt(<string>router.currentRoute.value.params.id)
const userFormState = reactive<FundTransaction>({
value: undefined,
fund_id: infoId
})
watch(transactionShow, (n) => !n ? userFormState.value = undefined : '')
const transButtonDisable = computed(() => {
if (!loading.value) {
if (userFormState.value !== undefined) {
if (isDeposit.value) {
return userFormState.value > walletStore?.walletInfo?.gold
} else {
return userFormState.value > info.value?.wallet?.gold
}
// return isDeposit.value ? userFormState.value > walletStore?.walletInfo?.gold : userFormState.value > info.value?.wallet?.gold
} else return true
} else {
return true
}
}
)
async function initInfo() {
try {
loading.value = true
info.value = await getOneProductWallet(infoId)
//pagination.value.page=0
await initTable()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
async function initTable() {
if (info.value?.wallet)
try {
loading.value = true
items.value = []
Object.assign(pagination.value, { wallet_id: info.value?.wallet?.id })
// pagination.value.fund_id = infoId
tableOutput.value = await getFundTransactionTable(pagination.value)
items.value.push(...tableOutput.value.rows)
// console.log(tableOutput.value)
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
async function transaction() {
formRef.value?.validateFields().then(async () => {
try {
loading.value = true
userFormState.fund_id = infoId
isDeposit.value ? await fundDeposit(userFormState) : await fundWithdraw(userFormState)
await initInfo()
// await initTable()
await fundStore.getMyFund()
await walletStore.getMyWallet()
fundStore = useWalletStore()
walletStore = useWalletStore()
message.success(` با موفقیت${isDeposit.value ? ' واریز' : ' برداشت'} شد`)
transactionShow.value = false
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
}
const itemImg = computed(() => {
switch (info.value?.title) {
case 'خرید لوازم منزل':
return homeImg
case 'هدیه':
return giftImg
case 'گردش':
return circulationImg
case 'ازدواج':
return loveImg
case 'تعمیرات':
return repairsImg
case 'پس انداز':
return savingImg
default:
return savingImg
}
})
const isDesktop = useDesktopBreakpoint()
const {
info,
transactionShow,
editShow,
showDeleteConfirm,
isDeposit,
deleteFundFunction,
} = useFundInfoPage()
</script> </script>
<style scoped> <style scoped>
.fund-info-page-root {
min-height: 100%;
height: 100%;
}
</style> </style>
@@ -0,0 +1,63 @@
<template>
<div>
<div
class="bg-white wrap-fix py-3 px-3 text-primary font-bold drop-shadow flex h-12 z-20 justify-between"
>
<div class="flex">
<div class="cursor-pointer" @click="goBack()">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">{{ info?.title }}</div>
</div>
</div>
<div class="pt-20 flex flex-col justify-center items-center">
<a-spin :spinning="loading">
<div class="bg-sky-100 w-[120px] aspect-square flex justify-center rounded-xl">
<img v-if="info?.group_type == 'گوی'" :src="goyImg" alt="" />
<img v-if="info?.group_type == 'مکعب'" :src="mokaabImg" alt="" />
<img v-if="info?.group_type == 'سکه'" :src="sekkeImg" alt="" />
<img v-if="info?.group_type == 'مس'" :src="mesImg" alt="" />
<img v-if="info?.group_type == 'نقره'" :src="noghrehImg" alt="" />
<img
v-if="info?.group_type == 'طلا' || info?.group_type == 'آبشده' || info?.group_type == null"
:src="defaultImg"
alt=""
/>
</div>
</a-spin>
<div class="mt-2 font-bold flex text-xs items-center text-gray-500">
<div class="ml-1 text-base text-primary">{{ info?.wallet?.gold ?? 0 }}</div>
{{ info?.unit }}
</div>
<div class="text-gray-400">موجودی گنج {{ info?.title }}</div>
<div class="w-full p-5">
<div class="font-bold mt-5">تراکنش ها</div>
<div class="mt-5 pb-17 overflow-y-scroll h-[54vh]">
<list-anim v-if="!loading">
<Item v-for="item in items" :key="item.id" :item="item" :title="info?.title" />
</list-anim>
<TransactionSkeleton v-for="i in 8" v-else :key="i" class="mt-1" />
<empty v-if="items.length === 0 && !loading" text="هیچ تراکنشی یافت نشد" />
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { loading } from '@/utils'
import ListAnim from '@/components/ListAnim.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import Empty from '@/components/empty.vue'
import Item from '../components/Item.vue'
import goyImg from '@/assets/img/goyImg.svg'
import mokaabImg from '@/assets/img/mokaabImg.svg'
import sekkeImg from '@/assets/img/sekkeImg.svg'
import mesImg from '@/assets/img/mesImg.jpg'
import noghrehImg from '@/assets/img/noghrehImg.webp'
import defaultImg from '@/assets/img/defaultAbshodeh.svg'
import { useFundInfoPageContext } from '../composables/useFundInfoPage'
const { info, items, goBack } = useFundInfoPageContext()
</script>
@@ -0,0 +1,161 @@
<template>
<div>
<div
class="bg-white w-full py-3 px-3 text-primary font-bold drop-shadow fixed md:absolute top-0 flex h-12 z-20"
>
<div @click="goBack()">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">دارایی ها</div>
</div>
<div
class="w-screen md:w-full h-[70vh] fixed md:absolute overflow-hidden"
:class="isBazretalaProject() ? 'bazretala-fund-header' : 'text-tertiary bg-primary'"
>
<img
:src="fundHeaderBackground"
alt=""
:class="
isBazretalaProject()
? 'pointer-events-none z-0 absolute inset-0 w-full object-cover object-center'
: 'pointer-events-none z-0 w-full absolute bottom-0 left-0 opacity-5'
"
/>
<div
class="pt-15 flex flex-col relative z-10"
:class="isBazretalaProject() ? 'text-white' : 'text-tertiary'"
>
<div class="font-bold mr-5 z-20">موجودی دارایی ها</div>
<div
v-if="meta?.has_count_based_products"
class="flex mt-4 justify-center mt-2 w-full z-20"
>
محصولات:
<div class="text-l mr-4 font-bold">{{ countBasedInventory }} عدد</div>
</div>
<template v-if="isBazretalaProject()">
<div class="flex justify-center mt-2 w-full z-20">
دارایی نقره:
<div class="text-l mr-4 font-bold">
{{ walletStore.walletInfo?.count_silver ?? 0 }}گرم
</div>
</div>
<div class="flex justify-center mt-2 w-full z-20">
دارایی طلایی:
<div class="text-l mr-4 font-bold">
{{ walletStore.walletInfo?.count_gold_wallet ?? 0 }}گرم
</div>
</div>
</template>
<template v-else-if="meta?.single_metal && primaryMetalTotal">
<div class="flex justify-center mt-2 w-full z-20">
{{ primaryMetalTotal.label }}:
<div class="text-l mr-4 font-bold">{{ formatMetalTotal(primaryMetalTotal) }}</div>
</div>
</template>
<template v-else>
<div
v-for="metal in metalTotals"
:key="`metal-${metal.metal_id}`"
class="flex justify-center mt-2 w-full z-20"
>
{{ metal.label }}:
<div class="text-l mr-4 font-bold">{{ formatMetalTotal(metal) }}</div>
</div>
</template>
<div
v-if="meta?.has_count_based_products && !isBazretalaProject()"
class="flex justify-center mt-2 w-full z-20"
>
مجموع موجودی:
<div class="text-l mr-4 font-bold">{{ countBasedInventory }} عدد</div>
</div>
<div class="flex justify-center mt-2 w-full z-20">
دارایی ریالی:
<div class="text-l mr-4 font-bold">{{ usePriceWithoutType(cashWallet) }}ریال</div>
</div>
<div class="flex justify-center mt-2 w-full z-20">
معادل ریالی مجموع دارایی ها:
<div class="text-l mr-4 font-bold">{{ usePriceWithoutType(totalRialValue) }}ریال</div>
</div>
</div>
</div>
<div class="pt-[20rem]">
<div
class="bg-gray-100 w-screen md:w-full z-20 relative rounded-t-3xl h-[calc(100vh-20rem-70px)] overflow-y-auto pb-15"
style="box-shadow: 0px -2px 4px -2px #0000001a"
>
<div
v-if="inventoryCards.length === 0 && !loading"
class="w-full h-full flex justify-center items-center"
>
<div>
<img
:src="fundEmptyImage"
alt=""
:class="isBazretalaProject() ? 'w-48 max-w-[11rem]' : 'w-70'"
/>
<div v-if="nameProject !== 'abaeigold'" class="text-center text-lg">
{{ fundEmptyText }}
</div>
</div>
</div>
<a-spin :spinning="loading">
<div :class="loading ? 'h-[40vh]' : ''" class="grid grid-cols-2 gap-3 p-5">
<Item
v-for="item in inventoryCards"
:key="item.product_id"
:item="item"
@click="openFundInfo(item.product_id)"
/>
</div>
</a-spin>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { loading } from '@/utils'
import { usePriceWithoutType } from '@/utils/usePrice'
import { useWalletStore } from '@/store/walletStore'
import Item from '../components/Item.vue'
import { useFundPageContext } from '../composables/useFundPage'
const walletStore = useWalletStore()
const {
nameProject,
meta,
inventoryCards,
metalTotals,
primaryMetalTotal,
cashWallet,
totalRialValue,
countBasedInventory,
fundHeaderBackground,
fundEmptyImage,
fundEmptyText,
isBazretalaProject,
formatMetalTotal,
openFundInfo,
goBack,
} = useFundPageContext()
</script>
<style scoped>
.bazretala-fund-header {
background-color: #000;
}
.bazretala-fund-header::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
background: rgba(0, 0, 0, 0.45);
pointer-events: none;
}
</style>
@@ -0,0 +1,117 @@
import { computed, inject, onMounted, provide, ref, type InjectionKey } from 'vue'
import router from '@/router'
import { loading } from '@/utils'
import { usePrefix } from '@/utils/usePrefix'
import { createFund, editFund, getFundById } from '@/api/fund'
import { message } from 'ant-design-vue/es'
import { useFundUserStore } from '@/store/fundStore'
import type { Fund } from '@/models/fund'
import homeImg from '@/assets/img/cash_home.svg'
import giftImg from '@/assets/img/cash_gift.svg'
import circulationImg from '@/assets/img/cash_circulation.svg'
import loveImg from '@/assets/img/cash_love.svg'
import repairsImg from '@/assets/img/cash_repairs.svg'
import savingImg from '@/assets/img/cash_saving.svg'
export const FUND_NEW_PAGE_KEY: InjectionKey<ReturnType<typeof useFundNewPage>> =
Symbol('fundNewPage')
const types = [
{ name: 'خرید لوازم منزل' },
{ name: 'پس انداز' },
{ name: 'گردش' },
{ name: 'ازدواج' },
{ name: 'تعمیرات' },
{ name: 'هدیه' },
]
export function useFundNewPage() {
const fundStore = useFundUserStore()
const edit_id = computed(() => parseInt(router.currentRoute.value.query.id as string))
const name = ref<string>('پس انداز')
const { prefixCls } = usePrefix('input_name')
const info = ref<Fund>()
const itemImg = computed(() => {
switch (name.value) {
case 'خرید لوازم منزل':
return homeImg
case 'هدیه':
return giftImg
case 'گردش':
return circulationImg
case 'ازدواج':
return loveImg
case 'تعمیرات':
return repairsImg
case 'پس انداز':
return savingImg
default:
return savingImg
}
})
async function getEditInfo() {
try {
loading.value = true
info.value = await getFundById(edit_id.value)
name.value = info.value?.title ?? ''
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
async function ok() {
try {
loading.value = true
if (!name.value) {
message.error('ابتدا نام گنج را وارد کنید')
return
}
edit_id.value
? await editFund(edit_id.value, { title: name.value })
: await createFund({ title: name.value })
await fundStore.getMyFund()
message.success(`گنج با موفقیت ${edit_id.value ? 'ویرایش' : 'ساخته'} شد`)
router.back()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
function goBack() {
router.back()
}
onMounted(() => {
if (edit_id.value) {
getEditInfo()
}
})
const context = {
types,
edit_id,
name,
prefixCls,
info,
itemImg,
ok,
goBack,
}
provide(FUND_NEW_PAGE_KEY, context)
return context
}
export function useFundNewPageContext() {
const context = inject(FUND_NEW_PAGE_KEY)
if (!context) {
throw new Error('useFundNewPageContext must be used within useFundNewPage')
}
return context
}
@@ -0,0 +1,81 @@
<template>
<div class="dfund-new">
<div class="dfund-new__container">
<header class="dfund-new-topbar">
<button type="button" class="dfund-new-topbar__back" @click="goBack()">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
بازگشت
</button>
<div class="dfund-new-topbar__meta">
<p class="dfund-new-topbar__eyebrow">گنج</p>
<h1 class="dfund-new-topbar__title">
{{ edit_id ? `ویرایش گنج ${name}` : 'صندوق جدید' }}
</h1>
</div>
</header>
<a-spin :spinning="loading">
<section class="dfund-new__card dfund-surface">
<div class="dfund-new__preview">
<img :src="itemImg" alt="" class="dfund-new__preview-img" />
</div>
<div class="dfund-new__field" :class="[prefixCls]">
<label class="dfund-new__label">عنوان گنج</label>
<van-field
v-model="name"
label=""
placeholder="عنوان را وارد کنید"
input-align="center"
:show-word-limit="true"
maxlength="25"
rows="1"
size="small"
/>
</div>
<div class="dfund-new__tags">
<button
v-for="item in types"
:key="item.name"
type="button"
class="dfund-new__tag"
:class="{ 'dfund-new__tag--active': item.name === name }"
@click="name = item.name"
>
{{ item.name }}
</button>
</div>
<BasicButton class="dfund-new__submit" type="primary" :disabled="loading" @click="ok()">
<a-spin v-if="loading" size="small" />
<span v-else>{{ edit_id ? 'ویرایش گنج' : 'ساخت گنج' }}</span>
</BasicButton>
</section>
</a-spin>
</div>
</div>
</template>
<script setup lang="ts">
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { loading } from '@/utils'
import { useFundNewPageContext } from '../composables/useFundNewPage'
const { types, edit_id, name, prefixCls, itemImg, ok, goBack } = useFundNewPageContext()
</script>
<style lang="less">
@prefix: ~'@{prefixCls}-input_name';
.@{prefix} {
.van-field__value {
@apply flex;
}
.van-field__body {
@apply w-full mr-5;
}
}
</style>
+14 -185
View File
@@ -1,194 +1,23 @@
<template> <template>
<div> <div class="fund-new-page-root">
<div class="bg-white wrap-fix py-3 px-3 text-primary font-bold drop-shadow flex h-12 z-20 justify-between"> <DesktopFundNewView v-show="isDesktop" />
<div class="flex "> <MobileFundNewView v-show="!isDesktop" />
<div @click="router.back()" class="cursor-pointer">
<BasicIcon icon="vuesax-linear:arrow-right-1"/>
</div>
<div class="mr-2">{{edit_id? `ویرایش گنج ${name}`: 'صندوق جدید'}}</div>
</div>
<div @click="router.back()">
<!-- <BasicIcon icon="vuesax-linear:setting-4"/>-->
</div>
</div>
<a-spin :spinning="loading">
<div class="pt-20 flex flex-col justify-center items-center">
<div class="bg-sky-100 w-[120px] aspect-square flex justify-center rounded-xl"><img :src="itemImg" alt=""></div>
<!-- <div class="mt-1 text-xs">تغییر عکس</div>-->
<div class="flex mt-3 w-full relative px-5" :class="[prefixCls]">
<!-- <div class="text-primary font-bold mx-auto items-center">پس انداز</div>
<div class="absolute left-5 text-gray-400 text-xs">7/25</div>-->
<van-field
v-model="name"
label=""
class="font-bold"
placeholder="عنوان را وارد کنید"
input-align="center"
:show-word-limit="true"
maxlength="25"
rows="1"
size="small"
/>
</div>
<div class="flex overflow-x-scroll gap-2 whitespace-nowrap max-w-[320px] mt-2 px-3">
<!-- <BasicTags
:items="types"
color="green"
:has-add="false"
/>-->
<!-- <BasicButton v-for="item in types" size="small">{{item.name}}</BasicButton>-->
<div v-for="item in types"
class="border-1 px-3 py-2 flex-nowrap rounded text-xs color-transition background-color-transition"
:key="item.name"
:class="item.name === name? 'bg-primary text-white':'bg-gray-200 text-gray-600'"
@click="name = item.name">
{{ item.name }}
</div>
</div>
<!-- <div class="mt-2 font-bold flex text-xs items-center text-gray-500">
<div class="ml-1 text-base text-primary">{{ router.currentRoute.value.query.amount }}</div>
گرم
</div>
<div class="text-gray-400">موجودی صندوق {{itemName}}</div>
<div class="w-full p-5">
<div class="w-full flex gap-3">
<BasicButton class="w-full !bg-light" icon="vuesax-linear:add-square" icon-size="18">برداشت</BasicButton>
<BasicButton class="w-full !bg-light" icon="vuesax-linear:arrow-up" icon-size="18">واریز</BasicButton>
</div>
<div class="text-lg font-bold mt-5">تراکنش ها</div>
<div class="mt-5 pb-17">
<Item v-for="item in items" :is-up="item.isUp" :has-transition="item.hasTransition" :key="item"/>
</div>
</div>-->
<div class="wrap-fix flex justify-center bottom-23 px-5 z-200" @click="ok()">
<BasicButton class="rounded w-full px-5 py-2 text-center" type="primary" :disabled="loading">
<a-spin v-if="loading" size="small"/>
<div v-else>{{edit_id? 'ویرایش گنج':'ساخت گنج'}}</div>
</BasicButton>
</div>
</div>
</a-spin>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useFundNewPage } from './composables/useFundNewPage'
import DesktopFundNewView from './desktop/DesktopFundNewView.vue'
import MobileFundNewView from './mobile/MobileFundNewView.vue'
import router from "@/router"; const isDesktop = useDesktopBreakpoint()
import {computed, onMounted, ref} from "vue"; useFundNewPage()
import homeImg from "@/assets/img/cash_home.svg";
import giftImg from "@/assets/img/cash_gift.svg";
import circulationImg from "@/assets/img/cash_circulation.svg";
import loveImg from "@/assets/img/cash_love.svg";
import repairsImg from "@/assets/img/cash_repairs.svg";
import savingImg from "@/assets/img/cash_saving.svg";
import {usePrefix} from "@/utils/usePrefix";
import {loading} from "@/utils";
import {createFund, editFund, getFundById} from "@/api/fund";
import {message} from "ant-design-vue/es";
import {useFundUserStore} from "@/store/fundStore";
import BasicButton from "@/components/BasicPacks/BasicButton.vue";
import type {Fund} from "@/models/fund";
const fundStore = useFundUserStore()
const types = [
{
name: 'خرید لوازم منزل'
},
{
name: 'پس انداز'
},
{
name: 'گردش'
},
{
name: 'ازدواج'
},
{
name: 'تعمیرات'
},
{
name: 'هدیه'
},
]
const edit_id = computed(() => {
return parseInt(router.currentRoute.value.query.id as string)
})
const name = ref<string>('پس انداز')
const {prefixCls} = usePrefix('input_name')
const info = ref<Fund>()
async function getEditInfo() {
try {
loading.value = true
info.value = await getFundById(edit_id.value)
name.value = info.value?.title?? ''
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
onMounted(() => {
if (edit_id.value) {
getEditInfo()
}
})
const itemImg = computed(() => {
switch (name.value) {
case 'خرید لوازم منزل':
return homeImg
case 'هدیه':
return giftImg
case 'گردش':
return circulationImg
case 'ازدواج':
return loveImg
case 'تعمیرات':
return repairsImg
case 'پس انداز':
return savingImg
default:
return savingImg
}
})
async function ok() {
try {
loading.value = true
// console.log(name.value)
name.value ? edit_id.value? await editFund(edit_id.value,{title: name.value}) :await createFund({title: name.value}) : () => {
message.error('ابتدا نام گنج را وارد کنید')
return
}
await fundStore.getMyFund()
message.success(`گنج با موفقیت ${edit_id.value? 'ویرایش':'ساخته'} شد`)
router.back()
// name.value? message.warn('ss'): message.error('ابتدا نام صندوق را وارد کنید')
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
</script> </script>
<style lang="less"> <style scoped>
@prefix: ~'@{prefixCls}-input_name'; .fund-new-page-root {
min-height: 100%;
.@{prefix} { height: 100%;
.van-field__value {
@apply flex;
}
.van-field__body {
@apply w-full mr-5;
}
} }
</style> </style>
@@ -0,0 +1,74 @@
<template>
<div>
<div
class="bg-white wrap-fix py-3 px-3 text-primary font-bold drop-shadow flex h-12 z-20 justify-between"
>
<div class="flex">
<div class="cursor-pointer" @click="goBack()">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">{{ edit_id ? `ویرایش گنج ${name}` : 'صندوق جدید' }}</div>
</div>
</div>
<a-spin :spinning="loading">
<div class="pt-20 flex flex-col justify-center items-center">
<div class="bg-sky-100 w-[120px] aspect-square flex justify-center rounded-xl">
<img :src="itemImg" alt="" />
</div>
<div class="flex mt-3 w-full relative px-5" :class="[prefixCls]">
<van-field
v-model="name"
label=""
class="font-bold"
placeholder="عنوان را وارد کنید"
input-align="center"
:show-word-limit="true"
maxlength="25"
rows="1"
size="small"
/>
</div>
<div class="flex overflow-x-scroll gap-2 whitespace-nowrap max-w-[320px] mt-2 px-3">
<div
v-for="item in types"
:key="item.name"
class="border-1 px-3 py-2 flex-nowrap rounded text-xs color-transition background-color-transition"
:class="item.name === name ? 'bg-primary text-white' : 'bg-gray-200 text-gray-600'"
@click="name = item.name"
>
{{ item.name }}
</div>
</div>
<div class="wrap-fix flex justify-center bottom-23 px-5 z-200" @click="ok()">
<BasicButton class="rounded w-full px-5 py-2 text-center" type="primary" :disabled="loading">
<a-spin v-if="loading" size="small" />
<div v-else>{{ edit_id ? 'ویرایش گنج' : 'ساخت گنج' }}</div>
</BasicButton>
</div>
</div>
</a-spin>
</div>
</template>
<script setup lang="ts">
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { loading } from '@/utils'
import { useFundNewPageContext } from '../composables/useFundNewPage'
const { types, edit_id, name, prefixCls, itemImg, ok, goBack } = useFundNewPageContext()
</script>
<style lang="less">
@prefix: ~'@{prefixCls}-input_name';
.@{prefix} {
.van-field__value {
@apply flex;
}
.van-field__body {
@apply w-full mr-5;
}
}
</style>
@@ -0,0 +1,52 @@
<template>
<a-form
:model="form"
class="!mt-0"
name="gift-card-form"
@finish="registerGiftCard"
>
<a-form-item :rules="[required()]" label="کد کارت هدیه" name="code">
<BasicInput
v-model:value="form.code"
place-holder="کد کارت هدیه را وارد کنید"
size="large"
type="text"
/>
</a-form-item>
<p :class="variant === 'desktop' ? 'dgift-form__hint' : 'font-bold text-xs mt-3'">
کد کارت هدیه خود را در فیلد بالا وارد کنید و سپس دکمه ثبت را بزنید.
</p>
<a-form-item>
<BasicButton
:disabled="disableButton"
:class="variant === 'desktop' ? 'dgift-form__submit' : 'w-full py-2 mt-5'"
html-type="submit"
type="primary"
>
<a-spin v-if="loading" size="small" />
<span v-else>ثبت کد</span>
</BasicButton>
</a-form-item>
</a-form>
</template>
<script lang="ts" setup>
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import { required } from '@/utils/inputRules'
import { useGiftCardPageContext } from '../composables/useGiftCardPage'
withDefaults(
defineProps<{
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
const {
form,
disableButton,
loading,
registerGiftCard,
} = useGiftCardPageContext()
</script>
@@ -0,0 +1,58 @@
import { computed, inject, provide, ref, type ComputedRef, type InjectionKey, type Ref } from 'vue'
import { loading } from '@/utils'
import { registerCodeGiftCard } from '@/api/giftCard'
import { message } from 'ant-design-vue'
export interface GiftCardPageContext {
form: Ref<{ code: string }>
disableButton: ComputedRef<boolean>
loading: typeof loading
registerGiftCard: () => Promise<void>
}
export const GIFT_CARD_PAGE_CONTEXT_KEY: InjectionKey<GiftCardPageContext> =
Symbol('giftCardPageContext')
export function useGiftCardPage() {
const form = ref<{ code: string }>({
code: '',
})
const disableButton = computed(() => {
if (!loading.value) {
return !form.value.code || form.value.code.trim().length === 0
}
return true
})
async function registerGiftCard() {
try {
loading.value = true
await registerCodeGiftCard(form.value.code)
message.success('کد کارت هدیه با موفقیت ثبت شد')
form.value.code = ''
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در ثبت کد کارت هدیه')
} finally {
loading.value = false
}
}
const context: GiftCardPageContext = {
form,
disableButton,
loading,
registerGiftCard,
}
provide(GIFT_CARD_PAGE_CONTEXT_KEY, context)
return context
}
export function useGiftCardPageContext() {
const context = inject(GIFT_CARD_PAGE_CONTEXT_KEY)
if (!context) {
throw new Error('useGiftCardPageContext must be used within a page that calls useGiftCardPage()')
}
return context
}
@@ -0,0 +1,50 @@
<template>
<div class="dgift">
<div class="dgift__container">
<header class="dgift-topbar">
<button type="button" class="dgift-topbar__back" @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
<span>بازگشت</span>
</button>
<div class="dgift-topbar__meta">
<p class="dgift-topbar__eyebrow">هدایا</p>
<h1 class="dgift-topbar__title">ثبت کارت هدیه</h1>
</div>
</header>
<div class="dgift-layout">
<section class="dgift-surface dgift-form">
<div class="dgift-form__header">
<div class="dgift-form__icon">
<BasicIcon icon="vuesax-linear:gift" size="28" />
</div>
<div>
<h2 class="dgift-form__title">ثبت کد کارت هدیه</h2>
<p class="dgift-form__subtitle">کد دریافتی خود را وارد کنید تا اعتبار به حساب شما اضافه شود</p>
</div>
</div>
<GiftCardForm variant="desktop" />
</section>
<aside class="dgift-surface dgift-guide">
<h3 class="dgift-guide__title">راهنمای استفاده</h3>
<ol class="dgift-guide__steps">
<li>کد کارت هدیه را از پیامک یا کارت فیزیکی دریافت کنید.</li>
<li>کد را بدون فاصله اضافی در فیلد مربوطه وارد کنید.</li>
<li>روی «ثبت کد» بزنید و پیام تأیید را بررسی کنید.</li>
</ol>
<div class="dgift-guide__note">
<BasicIcon icon="vuesax-linear:info-circle" size="18" />
<p>هر کد فقط یک‌بار قابل استفاده است و پس از ثبت موفق، موجودی کیف پول به‌روزرسانی می‌شود.</p>
</div>
</aside>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import GiftCardForm from '../components/GiftCardForm.vue'
</script>
@@ -0,0 +1,186 @@
/* Premium desktop gift card page */
.dgift {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.dgift__container {
max-width: 960px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.dgift-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__back {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
color: #6b7280;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
padding: 8px 0;
&:hover {
color: #111827;
}
}
&__meta {
flex: 1;
min-width: 0;
}
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin: 0 0 4px;
}
&__title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.03em;
color: #111827;
margin: 0;
line-height: 1.2;
}
}
.dgift-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 320px);
gap: 20px;
align-items: start;
}
.dgift-surface {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.dgift-form {
padding: 28px;
&__header {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #eef0f3;
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 14px;
background: rgba(9, 61, 81, 0.08);
color: @App-primary-color;
flex-shrink: 0;
}
&__title {
font-size: 18px;
font-weight: 700;
color: #111827;
margin: 0 0 6px;
}
&__subtitle {
font-size: 13px;
line-height: 1.6;
color: #6b7280;
margin: 0;
}
&__hint {
font-size: 13px;
line-height: 1.7;
color: #6b7280;
margin: 0 0 16px;
}
&__submit {
width: 100%;
min-height: 44px;
margin-top: 8px;
}
}
.dgift-guide {
padding: 24px;
position: sticky;
top: 24px;
&__title {
font-size: 15px;
font-weight: 600;
color: #111827;
margin: 0 0 16px;
}
&__steps {
margin: 0 0 20px;
padding: 0 20px 0 0;
display: flex;
flex-direction: column;
gap: 12px;
font-size: 13px;
line-height: 1.7;
color: #4b5563;
}
&__note {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 14px;
border-radius: 12px;
background: #f9fafb;
border: 1px solid #eef0f3;
color: #6b7280;
p {
margin: 0;
font-size: 12px;
line-height: 1.7;
}
svg,
.basic-icon {
color: @App-primary-color;
flex-shrink: 0;
margin-top: 2px;
}
}
}
@media (max-width: 1023.98px) {
.dgift-layout {
grid-template-columns: 1fr;
}
.dgift-guide {
position: static;
}
}
+13 -67
View File
@@ -1,78 +1,24 @@
<template> <template>
<div> <div class="gift-card-page-root">
<div class="bg-white py-3 px-3 text-black font-bold drop-shadow z-[1999] wrap-fix">کارت هدیه</div> <DesktopGiftCardView v-show="isDesktop" />
<div class="px-5 pt-26 pb-2"> <MobileGiftCardView v-show="!isDesktop" />
<a-form :model="form" class="!mt-5" name="basic" @finish="registerGiftCard">
<div class="border-gray-200 border-1 rounded-xl p-5 trans h-40 relative">
<bg_main />
<div class="z-100 relative">
<a-form-item :rules="[required()]" label="کد کارت هدیه" name="code">
<BasicInput
v-model:value="form.code"
place-holder="کد کارت هدیه را وارد کنید"
size="large"
type="text"
/>
</a-form-item>
<div class="font-bold text-xs mt-3">
کد کارت هدیه خود را در فیلد بالا وارد کنید و سپس دکمه ثبت را بزنید.
</div>
</div>
</div>
<a-form-item>
<BasicButton
:disabled="disableButton"
class="w-full py-2 mt-5"
html-type="submit"
type="primary"
>
<div>
<a-spin v-if="loading" size="small" />
<div v-else>ثبت کد</div>
</div>
</BasicButton>
</a-form-item>
</a-form>
</div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue' import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { loading } from '@/utils' import { useGiftCardPage } from './composables/useGiftCardPage'
import BasicButton from '@/components/BasicPacks/BasicButton.vue' import DesktopGiftCardView from './desktop/DesktopGiftCardView.vue'
import Bg_main from '@/components/bg_main.vue' import MobileGiftCardView from './mobile/MobileGiftCardView.vue'
import { registerCodeGiftCard } from '@/api/giftCard'
import { message } from 'ant-design-vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import { required } from '@/utils/inputRules'
const form = ref<{ code: string }>({ const isDesktop = useDesktopBreakpoint()
code: ''
})
const disableButton = computed(() => { useGiftCardPage()
if (!loading.value) {
return !form.value.code || form.value.code.trim().length === 0
} else {
return true
}
})
async function registerGiftCard() {
try {
loading.value = true
await registerCodeGiftCard(form.value.code)
message.success('کد کارت هدیه با موفقیت ثبت شد')
form.value.code = ''
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در ثبت کد کارت هدیه')
} finally {
loading.value = false
}
}
</script> </script>
<style scoped> <style scoped>
.gift-card-page-root {
min-height: 100%;
height: 100%;
}
</style> </style>
@@ -0,0 +1,18 @@
<template>
<div>
<div class="bg-white py-3 px-3 text-black font-bold drop-shadow z-[1999] wrap-fix">کارت هدیه</div>
<div class="px-5 pt-26 pb-2">
<div class="border-gray-200 border-1 rounded-xl p-5 trans h-40 relative">
<bg_main />
<div class="z-100 relative">
<GiftCardForm variant="mobile" />
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import Bg_main from '@/components/bg_main.vue'
import GiftCardForm from '../components/GiftCardForm.vue'
</script>
@@ -0,0 +1,79 @@
/* Desktop standalone gold trade page */
.dgold {
min-height: 100%;
background: #f6f7f9;
color: #111827;
padding: 28px 36px 40px;
}
.dgold-topbar {
display: flex;
align-items: flex-start;
gap: 16px;
max-width: 720px;
margin: 0 auto 24px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__back {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
color: #6b7280;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
padding: 8px 0;
flex-shrink: 0;
&:hover {
color: #111827;
}
}
&__meta {
flex: 1;
min-width: 0;
}
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin: 0 0 4px;
}
&__title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.03em;
color: #111827;
margin: 0;
line-height: 1.2;
}
}
.dgold__panel {
max-width: 720px;
margin: 0 auto;
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
overflow-y: auto;
max-height: calc(100vh - 180px);
padding-bottom: 16px;
}
.dgold__panel .mb-25vh {
margin-bottom: 2rem !important;
}
.dgold__panel .bottom-18 {
position: static !important;
margin-top: 1rem;
}
+64 -2
View File
@@ -1,5 +1,16 @@
<template> <template>
<div class="h-35rem overflow-y-auto"> <div :class="isStandaloneDesktop ? 'dgold' : 'h-35rem overflow-y-auto'">
<header v-if="isStandaloneDesktop" class="dgold-topbar">
<button type="button" class="dgold-topbar__back" @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
بازگشت
</button>
<div class="dgold-topbar__meta">
<p class="dgold-topbar__eyebrow">معامله</p>
<h1 class="dgold-topbar__title">خرید و فروش {{ tradeProductLabel }}</h1>
</div>
</header>
<div :class="isStandaloneDesktop ? 'dgold__panel' : ''">
<div class=" !overflow-y-scroll bg-white py-3 text-primary font-bold flex justify-evenly"> <div class=" !overflow-y-scroll bg-white py-3 text-primary font-bold flex justify-evenly">
<!-- <div @click="isBuy = false" class="w-full text-center" :class="!isBuy?'text-primary':'text-gray-400'">خرید طلا</div> <!-- <div @click="isBuy = false" class="w-full text-center" :class="!isBuy?'text-primary':'text-gray-400'">خرید طلا</div>
<div @click="isBuy = true" class="w-full text-center" :class="isBuy?'text-primary':'text-gray-400'">فروش طلا</div>--> <div @click="isBuy = true" class="w-full text-center" :class="isBuy?'text-primary':'text-gray-400'">فروش طلا</div>-->
@@ -150,7 +161,7 @@
</div>--> </div>-->
</div> </div>
<BasicActionSheet v-model:show="confirmAction"> <BasicActionSheet v-if="!isStandaloneDesktop" v-model:show="confirmAction">
<div class="font-bold text-lg"></div> <div class="font-bold text-lg"></div>
<div class="w-full rounded-lg bg-gray-50 p-3"> <div class="w-full rounded-lg bg-gray-50 p-3">
<div class="flex justify-between "> <div class="flex justify-between ">
@@ -185,6 +196,52 @@
</div> </div>
<BasicButton class="mb-20 mt-5 w-full" type="primary" @click="ok(); confirmAction= false">ثبت نهایی</BasicButton> <BasicButton class="mb-20 mt-5 w-full" type="primary" @click="ok(); confirmAction= false">ثبت نهایی</BasicButton>
</BasicActionSheet> </BasicActionSheet>
<BasicModal
v-else
v-model:visible="confirmAction"
title="تأیید سفارش"
width="480px"
destroy-on-close
custom-footer
>
<div class="w-full rounded-lg bg-gray-50 p-3">
<div class="flex justify-between ">
<div>وزن:</div>
<div class="flex items-center">
<div class="font-bold">{{ form.goldValue }}</div>
<div class="text-xs mr-1">{{ weightUnit }}</div>
</div>
</div>
<div class="flex justify-between mt-3">
<div>مظنه:</div>
<div class="flex items-center">
<div class="font-bold">{{ usePriceWithoutType(Math.round(goldPriceValue)) }}</div>
<div class="text-xs mr-1">ریال</div>
</div>
</div>
<div class="flex justify-between mt-3">
<div>کارمزد:</div>
<div class="flex items-center gap-2">
<div class="font-bold text-xl">{{ feeInGrams }}</div>
<div class="text-xs mr-1">{{ weightUnit }}</div>
</div>
</div>
<div>(معادل {{ equivalentPrice }} ریال)</div>
<div class="flex justify-between mt-3">
<div>{{ isBuy ? 'قیمت قابل پرداخت:' : 'مبلغ دریافتی:' }}</div>
<div class="flex items-center">
<div class="font-bold">{{ usePriceWithoutType(payablePrice.toFixed(0)) }}</div>
<div class="text-xs mr-1">ریال</div>
</div>
</div>
</div>
<BasicButton class="mt-5 w-full" type="primary" @click="ok(); confirmAction = false">
ثبت نهایی
</BasicButton>
<template #footer>
<div></div>
</template>
</BasicModal>
<van-dialog v-model:show="noCompleteVisible" class="completeButtonStyle" confirm-button-text="تکمیل اطلاعات" <van-dialog v-model:show="noCompleteVisible" class="completeButtonStyle" confirm-button-text="تکمیل اطلاعات"
show-confirm-button @confirm="router.push({name: 'user',query:{showEdit: true}})"> show-confirm-button @confirm="router.push({name: 'user',query:{showEdit: true}})">
<div class="flex flex-col items-center p-2"> <div class="flex flex-col items-center p-2">
@@ -197,6 +254,7 @@
</div> </div>
</div> </div>
</van-dialog> </van-dialog>
</div>
</div> </div>
</template> </template>
@@ -218,6 +276,8 @@ import { getLastPrice } from '@/api/home'
import Icon from '@/components/BasicPacks/BasicIcon.vue' import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { useUserStore } from '@/store/userStore' import { useUserStore } from '@/store/userStore'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue' import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { justNumber } from '@/utils/inputRules' import { justNumber } from '@/utils/inputRules'
import GoldSkeleton from '@/views/base/gold/components/GoldSkeleton.vue' import GoldSkeleton from '@/views/base/gold/components/GoldSkeleton.vue'
import GoldLoading from '@/components/GoldLoading.vue' import GoldLoading from '@/components/GoldLoading.vue'
@@ -270,6 +330,8 @@ interface Prop {
} }
const props = withDefaults(defineProps<Prop>(), {}) const props = withDefaults(defineProps<Prop>(), {})
const isDesktop = useDesktopBreakpoint()
const isStandaloneDesktop = computed(() => isDesktop.value && !props.ProductItem)
const isBuy = ref<boolean>(true) const isBuy = ref<boolean>(true)
const tradeProductLabel = computed(() => { const tradeProductLabel = computed(() => {
+59 -12
View File
@@ -1,13 +1,17 @@
<template> <template>
<div <div
:class="y > 50 ? 'bg-white shadow opacity-95' : ''" :class="y > 50 ? 'bg-white shadow opacity-95' : ''"
class="px-4 py-2 top-0 z-5 wrap-fix background-color-transition" class="px-4 py-2 top-0 z-5 wrap-fix background-color-transition"
> >
<div class="flex justify-between my-2"> <div class="flex justify-between my-2 items-center">
<div class="flex items-center gap-2" @click.stop="router.push({ name: 'user' })"> <div v-if="isBoursecuProject()" class="flex items-center gap-2">
<img :src="projectLogo" alt="" class="h-8 w-auto object-contain" />
<div class="color-transition font-bold text-primary">{{ appName }}</div>
</div>
<div v-else class="flex items-center gap-2" @click.stop="router.push({ name: 'user' })">
<div <div
class="z-100000 rounded-full border-1" class="z-100000 rounded-full border-1"
:class="y > 50 ? 'text-primary border-primary' : 'text-primary border-primary'" :class="y > 50 ? 'text-primary border-primary' : 'text-primary border-primary'"
> >
<Icon icon="vuesax-linear:user" size="20" /> <Icon icon="vuesax-linear:user" size="20" />
</div> </div>
@@ -15,26 +19,69 @@
{{ userStore.userInfo?.name }} {{ userStore.userInfo?.name }}
</div> </div>
</div> </div>
<Icon
:color="y > 50 ? '#0A1B37' : '#0A1B37'" <div class="flex items-center gap-2">
class="color-transition" <button
icon="vuesax-linear:logout" v-if="isBoursecuProject()"
type="button"
class="mhome-header-btn"
@click="router.push({ name: 'user' })"
>
<Icon icon="vuesax-linear:user" size="18" />
</button>
<button
type="button"
class="mhome-header-btn mhome-header-btn--logout"
@click="$emit('logout')" @click="$emit('logout')"
/> >
<Icon icon="vuesax-linear:logout" size="18" />
<span>خروج</span>
</button>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useWindowScroll } from '@vueuse/core' import { useWindowScroll } from '@vueuse/core'
import { useUserStore } from '@/store/userStore' import { useUserStore } from '@/store/userStore'
import { useConfigStore } from '@/store/configStore'
import router from '@/router' import router from '@/router'
import Icon from '@/components/BasicPacks/BasicIcon.vue' import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { getProjectNavLogo, isBoursecuProject } from '@/utils/config'
const { y } = useWindowScroll() const { y } = useWindowScroll()
const userStore = useUserStore() const userStore = useUserStore()
const config = useConfigStore()
const projectLogo = computed(() => getProjectNavLogo())
const appName = computed(() => config.config?.name_fa || config.config?.name || 'بورس مس')
defineEmits<{ defineEmits<{
logout: [] logout: []
}>() }>()
</script> </script>
<style scoped>
.mhome-header-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 10px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
color: #0a1b37;
font-size: 12px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
}
.mhome-header-btn--logout {
color: #dc2626;
border-color: #fecaca;
background: #fef2f2;
}
</style>
@@ -1,26 +1,31 @@
<template> <template>
<div class="bg-primary rounded-lg p-2 py-3 mt-2"> <div :class="plain ? 'py-2' : 'bg-primary rounded-lg p-2 py-3 mt-2'">
<div v-if="meta?.has_count_based_products" class="flex justify-center text-tertiary w-full z-20"> <div
v-if="meta?.has_count_based_products"
:class="rowClass"
class="flex justify-center w-full z-20"
>
محصولات: محصولات:
<div class="text-l mr-2 font-bold">{{ countBasedInventory }} عدد</div> <div class="text-l mr-2 font-bold">{{ countBasedInventory }} عدد</div>
</div> </div>
<template v-if="meta?.single_metal && primaryMetalTotal"> <template v-if="meta?.single_metal && primaryMetalTotal">
<div class="flex mt-2 justify-center text-tertiary w-full z-20"> <div :class="[rowClass, meta?.has_count_based_products ? 'mt-2' : '']" class="flex justify-center w-full z-20">
{{ primaryMetalTotal.label }}: {{ primaryMetalTotal.label }}:
<div class="text-l mr-2 font-bold">{{ formatMetalTotal(primaryMetalTotal) }}</div> <div class="text-l mr-2 font-bold">{{ formatMetalTotal(primaryMetalTotal) }}</div>
<BasicIcon <BasicIcon
icon="vuesax-linear:info-circle" icon="vuesax-linear:info-circle"
size="18" size="18"
color="tertiary" :color="iconColor"
@click="$emit('openGoldModal', primaryMetalTotal)" @click="$emit('openGoldModal', primaryMetalTotal)"
/> />
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div <div
v-for="metal in metalTotals" v-for="(metal, index) in metalTotals"
:key="metal.metal_id" :key="metal.metal_id"
class="flex mt-2 justify-center text-tertiary w-full z-20" :class="[rowClass, index > 0 || meta?.has_count_based_products ? 'mt-2' : '']"
class="flex justify-center w-full z-20"
> >
{{ metal.label }}: {{ metal.label }}:
<div class="text-l mr-2 font-bold">{{ formatMetalTotal(metal) }}</div> <div class="text-l mr-2 font-bold">{{ formatMetalTotal(metal) }}</div>
@@ -28,34 +33,50 @@
v-if="metal.metal_id === metalTotals[0]?.metal_id" v-if="metal.metal_id === metalTotals[0]?.metal_id"
icon="vuesax-linear:info-circle" icon="vuesax-linear:info-circle"
size="18" size="18"
color="tertiary" :color="iconColor"
@click="$emit('openGoldModal', metal)" @click="$emit('openGoldModal', metal)"
/> />
</div> </div>
</template> </template>
<div class="flex mt-2 justify-center text-tertiary w-full z-20"> <div :class="[rowClass, 'mt-2']" class="flex justify-center w-full z-20">
دارایی ریالی: دارایی ریالی:
<div class="text-l mr-2 font-bold">{{ usePriceWithoutType(totalRialValue) }} ریال</div> <div class="text-l mr-2 font-bold">{{ usePriceWithoutType(totalRialValue) }} ریال</div>
<BasicIcon @click="$emit('openRialModal')" icon="vuesax-linear:info-circle" size="18" color="tertiary" /> <BasicIcon
icon="vuesax-linear:info-circle"
size="18"
:color="iconColor"
@click="$emit('openRialModal')"
/>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from 'vue' import { computed } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue' import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { usePriceWithoutType } from '@/utils/usePrice' import { usePriceWithoutType } from '@/utils/usePrice'
import { useWalletAssets } from '@/composables/useWalletAssets' import { useWalletAssets } from '@/composables/useWalletAssets'
import type { WalletMetalTotal } from '@/models/walletAssets' import type { WalletMetalTotal } from '@/models/walletAssets'
import { resolveSummaryMetalWeight } from '@/utils/productAssets' import { resolveSummaryMetalWeight } from '@/utils/productAssets'
const { assetsSummary, loadWalletAssets } = useWalletAssets() const props = withDefaults(
defineProps<{
plain?: boolean
}>(),
{ plain: false }
)
/** Display-only — wallet assets are loaded once in useHomePageData.initHomePageData() */
const { assetsSummary } = useWalletAssets()
defineEmits<{ defineEmits<{
openGoldModal: [metal: WalletMetalTotal] openGoldModal: [metal: WalletMetalTotal]
openRialModal: [] openRialModal: []
}>() }>()
const rowClass = computed(() => (props.plain ? 'text-primary' : 'text-tertiary'))
const iconColor = computed(() => (props.plain ? 'primary' : 'tertiary'))
const meta = computed(() => assetsSummary.value?.meta) const meta = computed(() => assetsSummary.value?.meta)
const metalTotals = computed(() => assetsSummary.value?.metal_totals ?? []) const metalTotals = computed(() => assetsSummary.value?.metal_totals ?? [])
const primaryMetalTotal = computed(() => metalTotals.value[0]) const primaryMetalTotal = computed(() => metalTotals.value[0])
@@ -65,10 +86,6 @@ const countBasedInventory = computed(() =>
(assetsSummary.value?.count_products ?? []).reduce((sum, item) => sum + Number(item.quantity ?? 0), 0) (assetsSummary.value?.count_products ?? []).reduce((sum, item) => sum + Number(item.quantity ?? 0), 0)
) )
onMounted(() => {
void loadWalletAssets()
})
function formatMetalTotal(metal: WalletMetalTotal) { function formatMetalTotal(metal: WalletMetalTotal) {
return `${resolveSummaryMetalWeight(metal, inventoryCards.value)}${metal.unit}` return `${resolveSummaryMetalWeight(metal, inventoryCards.value)}${metal.unit}`
} }
@@ -50,7 +50,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue' import { computed, watch } from 'vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue' import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicInfoItem from '@/components/BasicPacks/BasicInfoItem.vue' import BasicInfoItem from '@/components/BasicPacks/BasicInfoItem.vue'
import { usePrice } from '@/utils/usePrice' import { usePrice } from '@/utils/usePrice'
@@ -81,10 +81,6 @@ const cashWallet = computed(() => assetsSummary.value?.cash_wallet ?? 0)
const assetsRialValue = computed(() => assetsSummary.value?.assets_rial_value ?? 0) const assetsRialValue = computed(() => assetsSummary.value?.assets_rial_value ?? 0)
const totalRialValue = computed(() => assetsSummary.value?.total_rial_value ?? 0) const totalRialValue = computed(() => assetsSummary.value?.total_rial_value ?? 0)
onMounted(() => {
void loadWalletAssets()
})
watch( watch(
() => props.show, () => props.show,
(open) => { (open) => {
@@ -0,0 +1,41 @@
<script setup lang="ts">
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { isBoursecuProject } from '@/utils/config'
import type { HomeQuickActionItem } from '../layouts/types'
defineProps<{
action: HomeQuickActionItem
}>()
defineEmits<{
click: []
}>()
const isBoursecu = isBoursecuProject()
</script>
<template>
<button
type="button"
class="home-quick-action-box flex flex-col items-center justify-center gap-2 rounded-xl border-2 p-3 min-h-[96px] w-full"
:class="{ 'home-quick-action-box--boursecu': isBoursecu }"
@click="$emit('click')"
>
<div class="flex h-11 w-11 shrink-0 items-center justify-center">
<Icon class="text-primary" :icon="action.icon" size="28" />
</div>
<span class="text-primary text-xs font-bold text-center leading-5">{{ action.label }}</span>
</button>
</template>
<style scoped>
.home-quick-action-box {
appearance: none;
cursor: pointer;
}
.home-quick-action-box--boursecu {
background-color: #eff5f3;
border-color: #d4e3de;
}
</style>
+46 -55
View File
@@ -1,63 +1,54 @@
<template> <template>
<div ref="skeletonRatio" class="h-fit"> <div ref="skeletonRatio" class="h-fit w-full min-h-[620px]">
<content-loader :width="skeletonSize.width" class="bg-white" height="100vh"> <content-loader
<rect :width="skeletonSize.width" height="5" rx="3" ry="3" x="0" y="0"/> :width="skeletonWidth"
<rect :width="skeletonSize.width" height="5" rx="3" ry="3" x="0" y="84"/> class="bg-white"
<rect :width="5" height="85" rx="3" ry="3" x="0" y="0"/> :height="skeletonHeight"
<rect :width="5" :x="skeletonSize.width-5" height="85" rx="3" ry="3" y="0"/> >
<rect :width="2" :x="skeletonSize.width/2" height="60" rx="3" ry="3" y="15"/> <rect :width="skeletonWidth" height="5" rx="3" ry="3" x="0" y="0" />
<rect :width="skeletonSize.width/2-20" :x="10" height="20" rx="10" ry="10" y="15"/> <rect :width="skeletonWidth" height="5" rx="3" ry="3" x="0" y="84" />
<rect :width="skeletonSize.width/2-20" :x="skeletonSize.width/2 + 10" height="20" rx="10" ry="10" y="15"/> <rect width="5" height="85" rx="3" ry="3" x="0" y="0" />
<rect :width="skeletonSize.width/2-40" :x="20" height="10" rx="5" ry="5" y="40"/> <rect width="5" :x="skeletonWidth - 5" height="85" rx="3" ry="3" y="0" />
<rect :width="skeletonSize.width/2-40" :x="skeletonSize.width/2 + 20" height="10" rx="5" ry="5" y="40"/> <rect width="2" :x="skeletonWidth / 2" height="60" rx="3" ry="3" y="15" />
<rect :width="skeletonSize.width/2-30" :x="15" height="18" rx="10" ry="10" y="60"/> <rect :width="halfWidth(20)" x="10" height="20" rx="10" ry="10" y="15" />
<rect :width="skeletonSize.width/2-30" :x="skeletonSize.width/2 + 15" height="18" rx="10" ry="10" y="60"/> <rect :width="halfWidth(20)" :x="skeletonWidth / 2 + 10" height="20" rx="10" ry="10" y="15" />
<circle :cx="skeletonSize.width/4-skeletonSize.width/8" cy="145" r="20"/> <rect :width="halfWidth(40)" x="20" height="10" rx="5" ry="5" y="40" />
<circle :cx="skeletonSize.width/4+skeletonSize.width/8" cy="145" r="20"/> <rect :width="halfWidth(40)" :x="skeletonWidth / 2 + 20" height="10" rx="5" ry="5" y="40" />
<circle :cx="skeletonSize.width/2+ skeletonSize.width/8" cy="145" r="20"/> <rect :width="halfWidth(30)" x="15" height="18" rx="10" ry="10" y="60" />
<circle :cx="skeletonSize.width - skeletonSize.width/8" cy="145" r="20"/> <rect :width="halfWidth(30)" :x="skeletonWidth / 2 + 15" height="18" rx="10" ry="10" y="60" />
<rect :width="50" :x="skeletonSize.width/4-skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/> <circle :cx="skeletonWidth / 4 - skeletonWidth / 8" cy="145" r="20" />
<rect :width="50" :x="skeletonSize.width/4+skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/> <circle :cx="skeletonWidth / 4 + skeletonWidth / 8" cy="145" r="20" />
<rect :width="50" :x="skeletonSize.width/2+ skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/> <circle :cx="skeletonWidth / 2 + skeletonWidth / 8" cy="145" r="20" />
<rect :width="50" :x="skeletonSize.width - skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/> <circle :cx="skeletonWidth - skeletonWidth / 8" cy="145" r="20" />
<rect :width="skeletonSize.width - 45" :x="45" height="20" rx="3" ry="3" y="230"/> <rect width="50" :x="skeletonWidth / 4 - skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
<rect :width="skeletonSize.width" :x="0" height="80" rx="3" ry="3" y="260"/> <rect width="50" :x="skeletonWidth / 4 + skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
<rect :width="skeletonSize.width/2 - 5" :x="0" height="50" rx="3" ry="3" y="360"/> <rect width="50" :x="skeletonWidth / 2 + skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
<rect :width="skeletonSize.width/2 - 5" :x="skeletonSize.width/2 + 5" height="50" rx="3" ry="3" y="360"/> <rect width="50" :x="skeletonWidth - skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
<rect :width="skeletonSize.width" :x="0" height="170" rx="3" ry="3" y="440"/> <rect :width="fullWidth(45)" x="45" height="20" rx="3" ry="3" y="230" />
<!-- <rect height="20" rx="3" ry="3" width="100" x="0" y="0"/> <rect :width="skeletonWidth" x="0" height="80" rx="3" ry="3" y="260" />
<rect :width="skeletonSize.width/4 + 40" :x="skeletonSize.width/2" height="20" rx="3" ry="3" y="0"/> <rect :width="halfWidth(10)" x="0" height="50" rx="3" ry="3" y="360" />
<rect height="20" rx="3" ry="3" width="80" x="0" y="30"/> <rect :width="halfWidth(10)" :x="skeletonWidth / 2 + 5" height="50" rx="3" ry="3" y="360" />
<rect :x="skeletonSize.width - 40" height="40" rx="12" ry="12" width="40" y="0"/> <rect :width="skeletonWidth" x="0" height="170" rx="3" ry="3" y="440" />
<rect :width="skeletonSize.width" :x="0" :y="skeletonSize.height-5" height="2" rx="12" ry="12"/>-->
<!-- <circle :cx="skeletonSize.width - 20" :cy="skeletonSize.height/2 - 10" r="20" />-->
<!-- <rect :x="10" y="20" rx="3" ry="3" :width="skeletonSize.width - 20" height="150"/>
<circle :cx="skeletonSize.width/2" cy="170" r="15" />
<rect :x="skeletonSize.width/2 - skeletonSize.width/3 -10" y="190" rx="3" ry="3" :width="skeletonSize.width/3 + skeletonSize.width/3 + 20" height="20"/>
<rect :x="skeletonSize.width/2 - skeletonSize.width/3 +20" y="220" rx="3" ry="3" :width="skeletonSize.width/3 + skeletonSize.width/3 - 40" height="20"/>-->
<!-- <rect x="90" y="0" rx="3" ry="3" width="80" height="20" />
<rect x="180" y="0" rx="3" ry="3" width="80" height="20" />
<rect x="270" y="0" rx="3" ry="3" width="80" height="20" ♥/>-->
</content-loader> </content-loader>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import {reactive, ref, watch} from "vue"; import { computed, ref } from 'vue'
import {useElementSize} from "@vueuse/core"; import { useElementSize } from '@vueuse/core'
import {ContentLoader} from "vue-content-loader"; import { ContentLoader } from 'vue-content-loader'
const skeletonRatio = ref(null) const skeletonHeight = 620
const skeletonSize = reactive( const skeletonRatio = ref<HTMLElement | null>(null)
useElementSize(skeletonRatio, {width: 0, height: 0}, {box: 'border-box'}) const { width } = useElementSize(skeletonRatio, { width: 0, height: 0 }, { box: 'border-box' })
)
/*watch(skeletonSize, () => { const skeletonWidth = computed(() => Math.max(width.value, 320))
console.log(skeletonSize)
})*/ function halfWidth(padding: number) {
return Math.max(skeletonWidth.value / 2 - padding, 0)
}
function fullWidth(padding: number) {
return Math.max(skeletonWidth.value - padding, 0)
}
</script> </script>
<style scoped>
</style>
+189 -70
View File
@@ -1,17 +1,45 @@
<template> <template>
<div class="bg-gray-50 w-full flex justify-evenly rounded-lg py-4 mt-4 h-[15rem]"> <div
class="bg-gray-50 w-full rounded-lg py-4"
:class="[
chartContainerClass,
variant === 'desktop' ? 'mt-0' : 'mt-4',
layout === 'boursecu' && variant !== 'desktop' ? 'price-chart-box--boursecu' : '',
]"
>
<div class="w-full text-center pt-2"> <div class="w-full text-center pt-2">
<a-radio-group v-model:value="selectedValue" button-style="solid"> <div v-if="showMetalSelector" class="chart-metal-segment mb-3">
<button
type="button"
class="chart-metal-segment__item"
:class="{ 'chart-metal-segment__item--active': selectedMetal === 'katod' }"
@click="emit('update:selectedMetal', 'katod')"
>
کاتد مس
</button>
<button
type="button"
class="chart-metal-segment__item"
:class="{ 'chart-metal-segment__item--active': selectedMetal === 'maftol' }"
@click="emit('update:selectedMetal', 'maftol')"
>
مفتول مس
</button>
</div>
<a-radio-group v-model:value="selectedValue" button-style="solid" class="chart-timeline-group">
<a-radio-button value="DAILY" @click="handleUpdate('DAILY')">24 ساعت اخیر</a-radio-button> <a-radio-button value="DAILY" @click="handleUpdate('DAILY')">24 ساعت اخیر</a-radio-button>
<a-radio-button value="WEEKLY" @click="handleUpdate('WEEKLY')">هفته گذشته</a-radio-button> <a-radio-button value="WEEKLY" @click="handleUpdate('WEEKLY')">هفته گذشته</a-radio-button>
<a-radio-button value="MONTHLY" @click="handleUpdate('MONTHLY')">ماه گذشته</a-radio-button> <a-radio-button value="MONTHLY" @click="handleUpdate('MONTHLY')">ماه گذشته</a-radio-button>
</a-radio-group> </a-radio-group>
<div id="chart-timeline"> <div ref="chartContainerRef" id="chart-timeline" class="w-full" :class="chartInnerClass">
<apexchart <apexchart
type="area" v-if="canRenderChart"
height="200" :key="`${layout}-${variant}-${selectedMetal}-${chartSelection}-${seriesLength}`"
:options="chartOptions" type="area"
:series="props.priceChartSeries" width="100%"
:height="chartHeight"
:options="chartOptions"
:series="props.priceChartSeries"
/> />
</div> </div>
</div> </div>
@@ -19,21 +47,71 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useConfigStore } from '@/store/configStore' import { useConfigStore } from '@/store/configStore'
import {usePrice} from "@/utils/usePrice"; import { usePrice } from '@/utils/usePrice'
import {getProjectFontFamily} from "@/utils/config"; import { getProjectFontFamily } from '@/utils/config'
import { useElementSize } from '@vueuse/core'
const props = defineProps<{ import type { ChartMetalType } from '../composables/usePriceChart'
priceChartSeries: any[]
}>() const props = withDefaults(
defineProps<{
priceChartSeries: any[]
layout?: 'default' | 'boursecu'
variant?: 'mobile' | 'desktop'
chartSelection?: string
selectedMetal?: ChartMetalType
}>(),
{
layout: 'default',
variant: 'mobile',
chartSelection: 'DAILY',
selectedMetal: 'katod',
}
)
const chartHeight = computed(() => (props.variant === 'desktop' ? 320 : 200))
const chartContainerClass = computed(() => {
if (props.variant === 'desktop') {
return 'min-h-[22rem]'
}
return props.layout === 'default' ? 'min-h-[15rem] h-[15rem]' : 'min-h-[15rem]'
})
const chartInnerClass = computed(() =>
props.variant === 'desktop' ? 'min-h-[320px]' : 'min-h-[200px]'
)
const showMetalSelector = computed(() => props.layout === 'boursecu')
const emit = defineEmits<{ const emit = defineEmits<{
updateData: [timeline: string] updateData: [timeline: string]
'update:selectedMetal': [metal: ChartMetalType]
}>() }>()
const config = useConfigStore() const config = useConfigStore()
const selectedValue = ref('DAILY') const chartContainerRef = ref<HTMLElement | null>(null)
const chartMounted = ref(false)
const selectedValue = ref(props.chartSelection)
const { width: chartWidth } = useElementSize(chartContainerRef)
const seriesLength = computed(() => props.priceChartSeries?.[0]?.data?.length ?? 0)
const canRenderChart = computed(
() => chartMounted.value && chartWidth.value > 0 && seriesLength.value > 0
)
watch(
() => props.chartSelection,
(timeline) => {
selectedValue.value = timeline
}
)
onMounted(async () => {
await nextTick()
chartMounted.value = true
})
const toPersianNumber = (num: number) => { const toPersianNumber = (num: number) => {
return num.toString().replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[d]) return num.toString().replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[d])
@@ -43,67 +121,108 @@ const chartOptions = computed(() => {
const fontFamily = getProjectFontFamily() const fontFamily = getProjectFontFamily()
return { return {
chart: { chart: {
id: 'نوسانات قیمت', id: 'نوسانات قیمت',
type: 'area', type: 'area',
height: 200, height: 200,
toolbar: { show: false }, toolbar: { show: false },
zoom: { autoScaleYaxis: true } zoom: { autoScaleYaxis: true },
}, redrawOnParentResize: true,
grid: { },
padding: { top: 0, right: 0, bottom: 0, left: 0 }, grid: {
yaxis: { lines: { show: false } } padding: { top: 0, right: 0, bottom: 0, left: 0 },
}, yaxis: { lines: { show: false } },
stroke: { width: 2 }, },
dataLabels: { stroke: { width: 2 },
enabled: false, dataLabels: {
// formatter: (val) => toPersianNumber(val), enabled: false,
style: { style: {
fontFamily fontFamily,
} },
}, },
yaxis: { yaxis: {
tickAmount: 8, tickAmount: 8,
show: false, show: false,
labels: { labels: {
formatter: (val: number) => toPersianNumber(usePrice(val, true, 'ه.ریال')),
style: { fontSize: '13px', fontFamily },
},
},
xaxis: {
show: false,
type: 'datetime',
axisTicks: { show: false },
tooltip: { enabled: false },
labels: { show: false },
},
tooltip: {
x: {
formatter: (value: number) => {
const date = new Date(value)
return `${date.toLocaleDateString('fa-IR')} - ${date.toLocaleTimeString('fa-IR', {
hour: '2-digit',
minute: '2-digit',
})}`
},
},
formatter: (val: number) => toPersianNumber(usePrice(val, true, 'ه.ریال')), formatter: (val: number) => toPersianNumber(usePrice(val, true, 'ه.ریال')),
style: { fontSize: '13px', fontFamily } style: { fontFamily },
} },
}, colors: [config.config.color],
xaxis: { fill: {
show: false, type: 'gradient',
type: 'datetime', gradient: {
axisTicks: { show: false }, shadeIntensity: 1,
tooltip: { enabled: false }, opacityFrom: 0.7,
labels: { show: false } opacityTo: 0.9,
}, stops: [0, 100],
tooltip: { },
x: {
formatter: (value: number) => {
const date = new Date(value)
return `${date.toLocaleDateString('fa-IR')} - ${date.toLocaleTimeString('fa-IR', {
hour: '2-digit',
minute: '2-digit'
})}`
}
}, },
formatter: (val: number) => toPersianNumber(usePrice(val, true, 'ه.ریال')),
style: { fontFamily }
},
colors: [config.config.color],
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.7,
opacityTo: 0.9,
stops: [0, 100]
}
}
} }
}) })
const handleUpdate = (timeline: string) => { const handleUpdate = (timeline: string) => {
emit('updateData', timeline) emit('updateData', timeline)
} }
</script> </script>
<style scoped lang="less">
@boursecu-surface-border: #d4e3de;
.price-chart-box--boursecu {
border: 2px solid @boursecu-surface-border;
}
.chart-metal-segment {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: #dfe8e5;
}
.chart-metal-segment__item {
appearance: none;
cursor: pointer;
min-width: 6.5rem;
padding: 0.4rem 0.75rem;
border: none;
border-radius: 8px;
background: transparent;
color: @App-primary-color;
opacity: 0.65;
font-size: 13px;
font-weight: 600;
line-height: 1.25;
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.chart-metal-segment__item--active {
background: #fff;
color: @App-primary-color;
opacity: 1;
font-weight: 700;
box-shadow: 0 1px 4px rgba(10, 27, 55, 0.12);
}
</style>
+18 -4
View File
@@ -1,5 +1,11 @@
<template> <template>
<div class="w-full flex justify-evenly rounded-lg py-2 home_appear_fade_in mt-6 pb-45"> <div
class="w-full flex justify-evenly rounded-lg py-2 home_appear_fade_in"
:class="[
layout === 'boursecu' ? 'mt-8 pt-2' : 'mt-6',
variant === 'mobile' ? 'pb-45' : 'mt-0 pb-0',
]"
>
<div class="text-center w-full"> <div class="text-center w-full">
<div>بالاترین قیمت امروز</div> <div>بالاترین قیمت امروز</div>
<div v-if="price" class="flex flex-col items-center mt-3"> <div v-if="price" class="flex flex-col items-center mt-3">
@@ -29,9 +35,17 @@ import { computed } from 'vue'
import { getTradeBuyPrice, getTradeSellPrice } from '@/utils/price' import { getTradeBuyPrice, getTradeSellPrice } from '@/utils/price'
import { usePriceWithoutType } from '../../../../utils/usePrice' import { usePriceWithoutType } from '../../../../utils/usePrice'
const props = defineProps<{ const props = withDefaults(
price: any defineProps<{
}>() price: any
layout?: 'default' | 'boursecu'
variant?: 'mobile' | 'desktop'
}>(),
{
layout: 'default',
variant: 'mobile',
}
)
const highPrice = computed(() => props.price?.up?.buy ?? getTradeBuyPrice(props.price)) const highPrice = computed(() => props.price?.up?.buy ?? getTradeBuyPrice(props.price))
const lowPrice = computed(() => props.price?.up?.sell ?? getTradeSellPrice(props.price)) const lowPrice = computed(() => props.price?.up?.sell ?? getTradeSellPrice(props.price))

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