fix kashkool
This commit is contained in:
-36
@@ -123,39 +123,6 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
#whatsapp-button {
|
||||
position: fixed;
|
||||
bottom: 90px;
|
||||
right: 20px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
#whatsapp-button:hover:not(.is-dragging) {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 14px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
#whatsapp-button.is-dragging {
|
||||
cursor: grabbing;
|
||||
transform: none;
|
||||
box-shadow: 0 8px 18px rgba(0,0,0,0.22);
|
||||
}
|
||||
|
||||
#whatsapp-button img {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@@ -725,8 +692,5 @@
|
||||
</script>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<a href="#" target="_blank" id="whatsapp-button">
|
||||
<img class="p-2" src="/src/assets/img/rubika.webp" alt="" />
|
||||
</a>
|
||||
</body>
|
||||
</html>
|
||||
+160
-32
@@ -1,12 +1,5 @@
|
||||
<template>
|
||||
<div class="desktopConfig w-full right-0 left-0 absolute">
|
||||
<div v-if="popVisible" class="top-0 bottom-0 wrap-fix bg-primary z-100 opacity-60">
|
||||
<div class="absolute right-0 left-0 bottom-10vh flex flex-col items-center">
|
||||
<div><BasicButton>درباره ما</BasicButton></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="desktopConfig w-full right-0 left-0">
|
||||
<transition mode="out-in" name="fade">
|
||||
<AConfigProvider
|
||||
v-if="!showSplash"
|
||||
@@ -21,40 +14,55 @@
|
||||
direction="rtl"
|
||||
>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<!-- Use any custom transition and to `fade` -->
|
||||
<transition :name="route.meta.transition || 'fade'">
|
||||
<div>
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
<component :is="Component" :key="route.fullPath" class="app-main" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</AConfigProvider>
|
||||
|
||||
<div v-else class="flex bottom-0 wrap-fix top-0 bg-white z-1000000000000 justify-center">
|
||||
<div class="top-0 wrap-fix h-1/3 w-screen flex">
|
||||
<img :src="img_topGradiant" alt="" class="absolute z-10 w-screen h-full object-cover" />
|
||||
<img :src="img_pattern" alt="" class="absolute w-screen h-full object-none" />
|
||||
<div v-else class="app-splash-screen flex items-center justify-center bg-white">
|
||||
<div class="app-splash-screen__gradient app-splash-screen__gradient--top h-1/3 flex">
|
||||
<img :src="img_topGradiant" alt="" class="absolute z-10 w-full h-full object-cover" />
|
||||
<img :src="img_pattern" alt="" class="absolute w-full h-full object-none" />
|
||||
</div>
|
||||
<div class="wrap-fix bottom-0 h-1/3 w-screen">
|
||||
<div class="app-splash-screen__gradient app-splash-screen__gradient--bottom h-1/3">
|
||||
<img
|
||||
:src="img_bottomGradiant"
|
||||
alt=""
|
||||
class="absolute z-10 w-screen h-full object-cover"
|
||||
class="absolute z-10 w-full h-full object-cover"
|
||||
/>
|
||||
<img :src="img_pattern" alt="" class="absolute w-screen h-full object-none" />
|
||||
<img :src="img_pattern" alt="" class="absolute w-full h-full object-none" />
|
||||
</div>
|
||||
<div class="self-center splash_appear_fade_in">
|
||||
<img :src="logoFull" class="h-[20vh]">
|
||||
<!-- <img :src="logo" alt="" class="z-100 bg-primary rounded-full w-full aspect-square self-center h-40" />-->
|
||||
<div class="splash_appear_fade_in relative z-10">
|
||||
<img :src="logoFull" class="h-[20vh] max-h-[180px] w-auto object-contain" alt="" />
|
||||
</div>
|
||||
<div class="fixed w-full bottom-5 home_appear_fade_in_up">
|
||||
<div class="text-center text-xs">نسخه: {{ pkg.version }}</div>
|
||||
<div class="app-splash-screen__version home_appear_fade_in_up">
|
||||
<div class="text-center text-xs">نسخه: {{ displayVersion }}</div>
|
||||
</div>
|
||||
<div class="absolute bottom-10">
|
||||
<div class="app-splash-screen__spinner">
|
||||
<a-spin :indicator="indicator" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div
|
||||
v-if="showSupportButton && !showSplash"
|
||||
id="support-button"
|
||||
class="draggable-support-button"
|
||||
:class="{ 'is-dragging': supportDragging }"
|
||||
:style="supportButtonStyle"
|
||||
title="پشتیبانی"
|
||||
@pointerdown="onSupportPointerDown"
|
||||
@pointermove="onSupportPointerMove"
|
||||
@pointerup="handleSupportPointerUp"
|
||||
@pointercancel="onSupportPointerCancel"
|
||||
>
|
||||
<div class="support-button-inner" :style="{ '--support-accent': supportAccentColor }">
|
||||
<BasicIcon :icon="supportIcon" size="18" class="support-button-icon" />
|
||||
<span class="support-button-label">پشتیبانی</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VersionUpdateModal />
|
||||
</div>
|
||||
</template>
|
||||
@@ -62,14 +70,22 @@
|
||||
<script lang="ts" setup>
|
||||
import { colors } from './themeConfig'
|
||||
import pkg from '../package.json'
|
||||
import { defineAsyncComponent, h, onMounted, ref } from 'vue'
|
||||
import { getDisplayAppVersion } from '@/config/config-cache'
|
||||
import { computed, defineAsyncComponent, h, onMounted, ref } from 'vue'
|
||||
import img_topGradiant from '@/views/login/assets/img/gr_top_white.svg'
|
||||
import img_pattern from '@/views/login/assets/img/pattern.svg'
|
||||
import { LoadingOutlined } from '@ant-design/icons-vue'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import {
|
||||
buildSupportMessengerUrl,
|
||||
loadSupportMessengerFromSettings,
|
||||
supportMessengerSettings,
|
||||
} from '@/utils/supportMessenger'
|
||||
import { useDraggableFloatingButton } from '@/utils/useDraggableFloatingButton'
|
||||
import { useApplyDesktopLayoutClass } from '@/layouts/composables/useAppLayout'
|
||||
|
||||
import img_bottomGradiant from '@/views/login/assets/img/gr_bottom_white.svg'
|
||||
import logoFull from '@/assets/img/logo_full_.png'
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
|
||||
const VersionUpdateModal = defineAsyncComponent(
|
||||
() => import('@/modules/version/VersionUpdateModal.vue')
|
||||
@@ -81,9 +97,81 @@ const indicator = h(LoadingOutlined, {
|
||||
},
|
||||
spin: true
|
||||
})
|
||||
const displayVersion = getDisplayAppVersion(pkg.version)
|
||||
const showSplash = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
useApplyDesktopLayoutClass()
|
||||
|
||||
const supportLink = computed(() =>
|
||||
buildSupportMessengerUrl(
|
||||
supportMessengerSettings.value.messenger,
|
||||
supportMessengerSettings.value.messengerId
|
||||
)
|
||||
)
|
||||
|
||||
const showSupportButton = computed(() => !!supportLink.value)
|
||||
|
||||
const supportIcon = computed(() => {
|
||||
const messenger = (supportMessengerSettings.value.messenger ?? '').toLowerCase()
|
||||
switch (messenger) {
|
||||
case 'whatsapp':
|
||||
return 'vuesax-linear:whatsapp'
|
||||
case 'telegram':
|
||||
return 'vuesax-linear:send-2'
|
||||
case 'bale':
|
||||
return 'vuesax-linear:messages-2'
|
||||
default:
|
||||
return 'vuesax-linear:messages-2'
|
||||
}
|
||||
})
|
||||
|
||||
const supportAccentColor = computed(() => {
|
||||
const messenger = (supportMessengerSettings.value.messenger ?? '').toLowerCase()
|
||||
switch (messenger) {
|
||||
case 'whatsapp':
|
||||
return '#22c55e'
|
||||
case 'telegram':
|
||||
return '#0ea5e9'
|
||||
case 'bale':
|
||||
return '#16a34a'
|
||||
case 'rubika':
|
||||
return '#7c3aed'
|
||||
case 'eitaa':
|
||||
return '#f59e0b'
|
||||
case 'soroush':
|
||||
return '#2563eb'
|
||||
default:
|
||||
return colors.primary || '#334155'
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
dragging: supportDragging,
|
||||
fabStyle: supportButtonStyle,
|
||||
handlePointerDown: onSupportPointerDown,
|
||||
handlePointerMove: onSupportPointerMove,
|
||||
handlePointerUp: supportPointerUp,
|
||||
handlePointerCancel: onSupportPointerCancel,
|
||||
} = useDraggableFloatingButton('ghatreh_support_button_position_v1', {
|
||||
width: 118,
|
||||
height: 42,
|
||||
defaultBottomOffset: 100,
|
||||
defaultRightOffset: 16,
|
||||
})
|
||||
|
||||
function openSupportLink() {
|
||||
if (!supportLink.value) return
|
||||
window.open(supportLink.value, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function handleSupportPointerUp(event: PointerEvent) {
|
||||
if (supportPointerUp(event)) {
|
||||
openSupportLink()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSupportMessengerFromSettings()
|
||||
setTimeout(() => {
|
||||
showSplash.value = false
|
||||
}, 2000)
|
||||
@@ -98,10 +186,50 @@ onMounted(() => {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 426px) {
|
||||
.desktopConfig {
|
||||
width: 426px;
|
||||
position: relative !important;
|
||||
#support-button {
|
||||
position: fixed;
|
||||
z-index: 5100;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
border-radius: 9999px;
|
||||
|
||||
&.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.support-button-inner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 14px 9px 12px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.14);
|
||||
color: #1e293b;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
direction: rtl;
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||
|
||||
.support-button-icon {
|
||||
color: var(--support-accent, #334155);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.support-button-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
#support-button:not(.is-dragging):hover .support-button-inner {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
#support-button.is-dragging .support-button-inner {
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
</style>
|
||||
|
||||
+2
-2
@@ -4,10 +4,10 @@ import type { PWallet, Wallet } from '@/models/wallet'
|
||||
|
||||
|
||||
export const getProducts = async (): Promise<any> => {
|
||||
return await useAxios.get<Product[]>(`/customer/product/list`)
|
||||
return await useAxios.get<Product[]>(`/customer/product/list`, { timeout: 15000 })
|
||||
}
|
||||
export const getProductById = async ( id:number|null): Promise<any> => {
|
||||
return await useAxios.get<Product[]>(`/customer/product/list/${id}`)
|
||||
return await useAxios.get<Product[]>(`/customer/product/list/${id}`, { timeout: 15000 })
|
||||
}
|
||||
export const getProductInventory = async (): Promise<any> => {
|
||||
return await useAxios.post<PInventory[]>(`/customer/wallet/product/table`)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import useAxios from '@/utils/useAxios'
|
||||
import type { Card, Wallet } from '@/models/wallet'
|
||||
import type { WalletAssetsSummary } from '@/models/walletAssets'
|
||||
import type {Transaction} from '@/models/transaction'
|
||||
|
||||
export const getWallet = async () => await useAxios.get<Wallet>(`/customer/wallet/me`)
|
||||
|
||||
export const getWalletAssets = async () =>
|
||||
await useAxios.get<WalletAssetsSummary>(`/customer/wallet/assets`)
|
||||
|
||||
export const withdraw = async (obj: Transaction): Promise<any> => {
|
||||
return await useAxios.post<Transaction>(`/customer/wallet/withdraw`, obj)
|
||||
}
|
||||
|
||||
+13
-6
@@ -3,7 +3,6 @@ import router from '@/router'
|
||||
import { authenticateWithFingerprint, getToken, isIphone, removeToken } from '@/utils'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { useWalletStore } from '@/store/walletStore'
|
||||
import { useFundUserStore } from '@/store/fundStore'
|
||||
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
|
||||
import { useAuthStore } from '@/store/useAuthStore'
|
||||
import {
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
export function setupRouterGuards() {
|
||||
const userStore = useUserStore()
|
||||
const walletStore = useWalletStore()
|
||||
const fundStore = useFundUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isApple: boolean = isIphone()
|
||||
@@ -27,11 +25,20 @@ export function setupRouterGuards() {
|
||||
const currentRoute = computed(() => router.currentRoute.value.fullPath)
|
||||
|
||||
async function start() {
|
||||
if (getToken()) {
|
||||
await walletStore.getLimitedPrice()
|
||||
await walletStore.getLimitedBuyGold()
|
||||
await fundStore.getMyFund()
|
||||
if (!getToken()) {
|
||||
return
|
||||
}
|
||||
|
||||
const tasks: Promise<unknown>[] = [
|
||||
walletStore.getLimitedPrice(),
|
||||
walletStore.getLimitedBuyGold(),
|
||||
]
|
||||
|
||||
if (!userStore.userInfo) {
|
||||
tasks.push(userStore.getMyInfo())
|
||||
}
|
||||
|
||||
await Promise.all(tasks)
|
||||
}
|
||||
|
||||
function goToLogin(to: RouteLocationNormalized, next: NavigationGuardNext) {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
@import (less) './var.less';
|
||||
@import (less) './fonts.less';
|
||||
@import (less) '../../layouts/styles/desktop-shell.less';
|
||||
@import (less) '../../layouts/styles/mobile-frame.less';
|
||||
@import (less) '../../layouts/desktop/styles/desktop-page-common.less';
|
||||
@import (less) '../../views/base/fund/desktop/styles/desktop-fund.less';
|
||||
@import (less) '../../views/base/transactions/desktop/styles/desktop-transactions.less';
|
||||
@import (less) '../../views/base/wallet/desktop/styles/desktop-wallet.less';
|
||||
@import (less) '../../views/base/user/desktop/styles/desktop-user.less';
|
||||
@import (less) '../../views/base/home/desktop/styles/desktop-home.less';
|
||||
@import (less) '../../layouts/desktop/styles/desktop-inner-pages.less';
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
@@ -86,6 +95,11 @@ body {
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.background-color-transition {
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
@primary-color: #a72b77;
|
||||
@secondary-color: #a72b77;
|
||||
@App-primary-color: #a72b77;
|
||||
@prefixCls-font-family: 'iran-sans';
|
||||
@prefixCls-background-color: #fff;
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ref } from 'vue'
|
||||
import { getProducts } from '@/api/products'
|
||||
import type { Product } from '@/models/products'
|
||||
import { getLastStreamProductsList } from '@/utils/productStreamBridge'
|
||||
|
||||
/** کش مشترک لیست محصولات — dedupe درخواستهای polling */
|
||||
const catalogProducts = ref<Product[]>([])
|
||||
let catalogPromise: Promise<Product[]> | null = null
|
||||
|
||||
export function getProductsCatalogRef() {
|
||||
return catalogProducts
|
||||
}
|
||||
|
||||
export function findProductInCatalog(id: number): Product | undefined {
|
||||
const cached = catalogProducts.value.find((p) => p.id === id)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
return getLastStreamProductsList()?.find((p) => p.id === id)
|
||||
}
|
||||
|
||||
export function getProductsCatalog(force = false): Promise<Product[]> {
|
||||
if (!force) {
|
||||
const streamed = getLastStreamProductsList()
|
||||
if (streamed?.length) {
|
||||
catalogProducts.value = streamed
|
||||
return Promise.resolve(streamed)
|
||||
}
|
||||
|
||||
if (catalogProducts.value.length > 0) {
|
||||
return Promise.resolve(catalogProducts.value)
|
||||
}
|
||||
}
|
||||
|
||||
if (!force && catalogPromise) {
|
||||
return catalogPromise
|
||||
}
|
||||
|
||||
catalogPromise = getProducts()
|
||||
.then((products) => {
|
||||
catalogProducts.value = products ?? []
|
||||
return catalogProducts.value
|
||||
})
|
||||
.finally(() => {
|
||||
catalogPromise = null
|
||||
})
|
||||
|
||||
return catalogPromise
|
||||
}
|
||||
|
||||
export function clearProductsCatalog() {
|
||||
catalogProducts.value = []
|
||||
catalogPromise = null
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ref } from 'vue'
|
||||
import { getWalletAssets } from '@/api/wallet'
|
||||
import { getProductsCatalog, getProductsCatalogRef } from '@/composables/useProductsCatalog'
|
||||
import { useWalletStore } from '@/store/walletStore'
|
||||
import type { WalletAssetsSummary } from '@/models/walletAssets'
|
||||
import { enrichInventoryCardsWithCatalog } from '@/utils/productAssets'
|
||||
|
||||
const assetsSummary = ref<WalletAssetsSummary | null>(null)
|
||||
let assetsPromise: Promise<WalletAssetsSummary> | null = null
|
||||
|
||||
function enrichAssetsSummary(data: WalletAssetsSummary): WalletAssetsSummary {
|
||||
const catalog = getProductsCatalogRef().value
|
||||
|
||||
return {
|
||||
...data,
|
||||
inventory_cards: enrichInventoryCardsWithCatalog(data.inventory_cards ?? [], catalog ?? []),
|
||||
count_products: enrichInventoryCardsWithCatalog(data.count_products ?? [], catalog ?? []),
|
||||
}
|
||||
}
|
||||
|
||||
function syncWalletStoreFromAssets(data: WalletAssetsSummary) {
|
||||
const walletStore = useWalletStore()
|
||||
if (!walletStore.walletInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
const goldMetal = data.metal_totals?.find((metal) => metal.metal_id === 1) ?? data.metal_totals?.[0]
|
||||
|
||||
walletStore.walletInfo.cash = data.cash_wallet ?? walletStore.walletInfo.cash
|
||||
|
||||
if (goldMetal) {
|
||||
walletStore.walletInfo.gold = goldMetal.melted_weight ?? walletStore.walletInfo.gold
|
||||
walletStore.walletInfo.gold_sum = goldMetal.total_weight ?? walletStore.walletInfo.gold_sum
|
||||
}
|
||||
}
|
||||
|
||||
export function useWalletAssets() {
|
||||
async function loadWalletAssets(force = false) {
|
||||
if (!force && assetsSummary.value) {
|
||||
return assetsSummary.value
|
||||
}
|
||||
|
||||
if (!force && assetsPromise) {
|
||||
return assetsPromise
|
||||
}
|
||||
|
||||
assetsPromise = Promise.all([getWalletAssets(), getProductsCatalog(force)])
|
||||
.then(([data]) => {
|
||||
assetsSummary.value = enrichAssetsSummary(data)
|
||||
syncWalletStoreFromAssets(assetsSummary.value)
|
||||
return assetsSummary.value
|
||||
})
|
||||
.finally(() => {
|
||||
assetsPromise = null
|
||||
})
|
||||
|
||||
return assetsPromise
|
||||
}
|
||||
|
||||
function clearWalletAssets() {
|
||||
assetsSummary.value = null
|
||||
assetsPromise = null
|
||||
}
|
||||
|
||||
return {
|
||||
assetsSummary,
|
||||
loadWalletAssets,
|
||||
clearWalletAssets,
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,10 @@ export function getStoredAppVersion() {
|
||||
return localStorage.getItem(VERSION_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export function getDisplayAppVersion(fallbackVersion: string) {
|
||||
return getStoredAppVersion() || fallbackVersion
|
||||
}
|
||||
|
||||
export function writeStoredAppVersion(version: string) {
|
||||
localStorage.setItem(VERSION_STORAGE_KEY, String(version).trim())
|
||||
}
|
||||
|
||||
@@ -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,27 @@
|
||||
<template>
|
||||
<div class="dpage">
|
||||
<header v-if="title" class="dpage-topbar">
|
||||
<div>
|
||||
<h1 class="dpage-topbar__title">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="dpage-topbar__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<slot name="actions" />
|
||||
</header>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
title?: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dpage-topbar__subtitle {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<header class="dpage-topbar" :class="{ 'dpage-topbar--sticky': sticky }">
|
||||
<button
|
||||
v-if="showBack"
|
||||
type="button"
|
||||
class="dpage-topbar__back"
|
||||
@click="handleBack"
|
||||
>
|
||||
<BasicIcon icon="vuesax-linear:arrow-right-1" size="16" />
|
||||
<span>بازگشت</span>
|
||||
</button>
|
||||
<div class="dpage-topbar__meta">
|
||||
<p v-if="eyebrow" class="dpage-topbar__eyebrow">{{ eyebrow }}</p>
|
||||
<h1 class="dpage-topbar__title">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="dpage-topbar__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div v-if="$slots.trailing">
|
||||
<slot name="trailing" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import router from '@/router'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
eyebrow?: string
|
||||
subtitle?: string
|
||||
showBack?: boolean
|
||||
sticky?: boolean
|
||||
}>(),
|
||||
{
|
||||
showBack: false,
|
||||
sticky: true,
|
||||
},
|
||||
)
|
||||
|
||||
function handleBack() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="responsive-view-pair">
|
||||
<div v-show="!isDesktop" class="responsive-view-pair__pane">
|
||||
<slot name="mobile" />
|
||||
</div>
|
||||
<div v-show="isDesktop" class="responsive-view-pair__pane">
|
||||
<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: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.responsive-view-pair__pane {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
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)`)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -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 @@
|
||||
export { useDesktopBreakpoint, useApplyDesktopLayoutClass } from './useAppLayout'
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { AppNavItem } from '../types'
|
||||
|
||||
/** تبهای پایین موبایل — کاربر لاگینشده */
|
||||
export const AUTH_PRIMARY_NAV: AppNavItem[] = [
|
||||
{ id: 'home', routeName: 'home', label: 'صفحه اصلی', icon: 'vuesax-linear:home' },
|
||||
{ id: 'transactions', routeName: 'transactions', label: 'گزارش', icon: 'vuesax-linear:receipt-item' },
|
||||
{ id: 'wallet', routeName: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-1' },
|
||||
{ id: 'cart', routeName: 'cart', label: 'سبد خرید', icon: 'vuesax-linear:shopping-cart' },
|
||||
]
|
||||
|
||||
/** تبهای پایین موبایل — مهمان */
|
||||
export const GUEST_PRIMARY_NAV: AppNavItem[] = [
|
||||
{ id: 'buyProduct', routeName: 'buyProduct', label: 'فروشگاه', icon: 'vuesax-linear:coin' },
|
||||
{ id: 'about', routeName: 'about', label: 'درباره ما', icon: 'vuesax-linear:info-circle' },
|
||||
{ id: 'cart', routeName: 'cart', label: 'سبد خرید', icon: 'vuesax-linear:shopping-cart' },
|
||||
{ id: 'login', routeName: 'login', label: 'ورود', icon: 'vuesax-linear:login' },
|
||||
]
|
||||
|
||||
/** منوی FAB موبایل + بخش فرعی سایدبار دسکتاپ — کاربر لاگینشده */
|
||||
export const AUTH_SECONDARY_NAV: AppNavItem[] = [
|
||||
{ id: 'buyProduct', routeName: 'buyProduct', label: 'فروشگاه', icon: 'vuesax-linear:coin' },
|
||||
{ id: 'fund', routeName: 'fund', label: 'دارایی ها', icon: 'vuesax-linear:archive-1' },
|
||||
{ id: 'user', routeName: 'user', label: 'حساب کاربری', icon: 'vuesax-linear:user' },
|
||||
{ id: 'physicalReceive', routeName: 'physicalReceive', label: 'دریافت طلای فیزیکی', icon: 'vuesax-linear:direct-inbox' },
|
||||
{ id: 'list', routeName: 'list', label: 'لیست دریافتیها', icon: 'vuesax-linear:receipt-item' },
|
||||
{ id: 'giftCard', routeName: 'giftCard', label: 'ثبت کارت هدیه', icon: 'vuesax-linear:gift' },
|
||||
{ id: 'earnMoney', routeName: 'earnMoney', label: 'کسب درآمد', icon: 'vuesax-linear:dollar-circle' },
|
||||
{ id: 'about', routeName: 'about', label: 'درباره ما', icon: 'vuesax-linear:info-circle' },
|
||||
{ id: 'rules', routeName: 'rules', label: 'قوانین و مقررات', icon: 'vuesax-linear:task' },
|
||||
]
|
||||
|
||||
export function getPrimaryNavItems(isAuthenticated: boolean): AppNavItem[] {
|
||||
return isAuthenticated ? AUTH_PRIMARY_NAV : GUEST_PRIMARY_NAV
|
||||
}
|
||||
|
||||
export function getSecondaryNavItems(isAuthenticated: boolean): AppNavItem[] {
|
||||
return isAuthenticated ? AUTH_SECONDARY_NAV : []
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,38 @@
|
||||
<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 }">
|
||||
<div :key="route.fullPath" class="desktop-app-layout__content">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
</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>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="desktop-nav-item"
|
||||
:class="{ 'desktop-nav-item--active': active }"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<span class="desktop-nav-item__icon-wrap">
|
||||
<img
|
||||
v-if="iconSrc"
|
||||
:src="iconSrc"
|
||||
alt=""
|
||||
class="desktop-nav-item__icon desktop-nav-item__icon--image"
|
||||
/>
|
||||
<BasicIcon v-else :icon="icon" size="20" class="desktop-nav-item__icon" />
|
||||
<span v-if="badgeCount && badgeCount > 0" class="desktop-nav-item__badge">{{ badgeCount }}</span>
|
||||
</span>
|
||||
<span class="desktop-nav-item__label">{{ label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
|
||||
defineProps<{
|
||||
icon: string
|
||||
iconSrc?: string
|
||||
label: string
|
||||
active: boolean
|
||||
badgeCount?: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.desktop-nav-item__icon-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.desktop-nav-item__badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: #a72b77;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.desktop-nav-item__icon--image {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
transform: scale(1.75);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<aside class="desktop-sidebar">
|
||||
<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"
|
||||
:icon-src="item.iconSrc"
|
||||
:label="item.label"
|
||||
:active="isNavActive(item.routeName)"
|
||||
:badge-count="item.routeName === 'cart' && cartCount ? cartCount : undefined"
|
||||
@click="navigateTo(item.routeName)"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div v-if="secondaryItems.length" class="desktop-sidebar__divider" />
|
||||
|
||||
<nav
|
||||
v-if="secondaryItems.length"
|
||||
class="desktop-sidebar__section desktop-sidebar__section--secondary"
|
||||
aria-label="منوی فرعی"
|
||||
>
|
||||
<DesktopNavItem
|
||||
v-for="item in secondaryItems"
|
||||
:key="item.id"
|
||||
:icon="item.icon"
|
||||
:icon-src="item.iconSrc"
|
||||
:label="item.label"
|
||||
:active="isNavActive(item.routeName)"
|
||||
@click="navigateTo(item.routeName)"
|
||||
/>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import logoFull from '@/assets/img/logo_full_.png'
|
||||
import { isToken } from '@/utils'
|
||||
import { useCartStore } from '@/store/cartStore'
|
||||
import { getPrimaryNavItems, getSecondaryNavItems } from '../config/navigation'
|
||||
import { useAppNavigation } from '../composables/useAppNavigation'
|
||||
import DesktopNavItem from './DesktopNavItem.vue'
|
||||
|
||||
const cartStore = useCartStore()
|
||||
const authenticated = computed(() => isToken())
|
||||
const navLogo = logoFull
|
||||
const primaryItems = computed(() => getPrimaryNavItems(authenticated.value))
|
||||
const secondaryItems = computed(() => getSecondaryNavItems(authenticated.value))
|
||||
const cartCount = computed(() => cartStore.itemCount || 0)
|
||||
|
||||
const { isNavActive, navigateTo } = useAppNavigation()
|
||||
</script>
|
||||
@@ -0,0 +1,151 @@
|
||||
.desktop-app-layout {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
background: @prefixCls-background-color;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.desktop-app-layout__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
max-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
margin-right: var(--app-desktop-sidebar-width, 260px);
|
||||
}
|
||||
|
||||
.desktop-app-layout__main--full {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.desktop-app-layout__content {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
background: @prefixCls-background-color;
|
||||
}
|
||||
|
||||
.desktop-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: var(--app-desktop-sidebar-width, 260px);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
z-index: 100;
|
||||
padding: 20px 12px 16px;
|
||||
box-sizing: border-box;
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.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: 56px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.desktop-sidebar__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.desktop-sidebar__section--secondary {
|
||||
margin-top: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.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(167, 43, 119, 0.06);
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: rgba(167, 43, 119, 0.1);
|
||||
color: @primary-color;
|
||||
|
||||
.desktop-nav-item__icon {
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-nav-item__icon {
|
||||
flex-shrink: 0;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.desktop-nav-item__icon-wrap {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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,255 @@
|
||||
/* Shared desktop layouts for inner pages */
|
||||
|
||||
.dinner {
|
||||
min-height: 100%;
|
||||
background: #f6f7f9;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.dinner__container {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 36px 40px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dinner-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);
|
||||
}
|
||||
|
||||
/* Cart */
|
||||
.dcart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.dcart-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dcart-item {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
|
||||
&__image {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f9fafb;
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__price {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: @App-primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
.dcart-summary {
|
||||
position: sticky;
|
||||
top: 88px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
&__title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__total {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: @App-primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
/* Product detail */
|
||||
.dproduct-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 380px) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.dproduct-gallery {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 280px;
|
||||
background: #f9fafb;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.dproduct-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dproduct-info__price-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dproduct-info__price {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: @App-primary-color;
|
||||
}
|
||||
|
||||
.dproduct-info__desc {
|
||||
padding: 20px;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: #4b5563;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* Simple form pages (gift card, etc.) */
|
||||
.dform-page {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.dform-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Earn money */
|
||||
.dearn-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.dearn-stat {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
|
||||
&--blue {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
&--yellow {
|
||||
background: #fef9c3;
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.dearn-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.dearn-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
/* Physical receive */
|
||||
.dphysical-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dphysical-add {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: 1px dashed rgba(15, 23, 42, 0.15);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
html.app-layout--desktop {
|
||||
.dpage {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 36px 48px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dpage-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);
|
||||
|
||||
&--sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
margin: -4px 0 20px;
|
||||
padding: 12px 0 20px;
|
||||
background: #f6f7f9;
|
||||
}
|
||||
|
||||
&__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: 700;
|
||||
letter-spacing: -0.03em;
|
||||
color: #111827;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin: 6px 0 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide mobile fixed headers on desktop inner pages */
|
||||
.fund-info-page-root,
|
||||
.cart-page-root,
|
||||
.gift-card-page-root,
|
||||
.earn-money-page-root,
|
||||
.physical-receive-page-root,
|
||||
.product-detail-page-root,
|
||||
.dinner-page,
|
||||
.cart-page,
|
||||
.gift-card-page,
|
||||
.earn-money-page,
|
||||
.physical-receive-page,
|
||||
.product-detail-page,
|
||||
.about-page-root,
|
||||
.rules-page-root {
|
||||
> .wrap-fix,
|
||||
> div > .wrap-fix,
|
||||
.transaction-list {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.products-page .grid-cols-2 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1100px) {
|
||||
.products-page .grid-cols-2 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.dhome-topbar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 28px;
|
||||
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;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.dhome-btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
background: rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div>
|
||||
<MobileOverflowMenu
|
||||
:show="popVisible"
|
||||
@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"
|
||||
:nav-item-class="navItemClass"
|
||||
@navigate="navigateTo"
|
||||
@toggle-overflow="toggleOverflowMenu"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useAppNavigation } from '../composables/useAppNavigation'
|
||||
import MobileBottomNav from './MobileBottomNav.vue'
|
||||
import MobileOverflowMenu from './MobileOverflowMenu.vue'
|
||||
|
||||
const {
|
||||
popVisible,
|
||||
hideMenu,
|
||||
navItemClass,
|
||||
navigateTo,
|
||||
toggleOverflowMenu,
|
||||
closeOverflowMenu,
|
||||
} = useAppNavigation()
|
||||
</script>
|
||||
|
||||
<style lang="less" src="./mobile-layout.less"></style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="bottom-appbar w-auto mx-auto bg-white">
|
||||
<div class="tabs bottom-0">
|
||||
<template v-for="item in leftTabs" :key="item.id">
|
||||
<div :class="navItemClass(item.routeName)" class="tab" @click="$emit('navigate', item.routeName)">
|
||||
<a-badge v-if="item.routeName === 'cart'" :count="cartCount">
|
||||
<Icon :icon="item.icon" size="24" />
|
||||
</a-badge>
|
||||
<Icon v-else :icon="item.icon" size="24" />
|
||||
<div class="text-xs mt-1 font-bold">{{ item.label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="main_in">
|
||||
<div class="text-white circle_in">
|
||||
<div class="circle_into" @click="$emit('toggle-overflow')">
|
||||
<img class="p-2" :src="navLogo" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-for="item in rightTabs" :key="item.id">
|
||||
<div :class="navItemClass(item.routeName)" class="tab" @click="$emit('navigate', item.routeName)">
|
||||
<a-badge v-if="item.routeName === 'cart'" :count="cartCount">
|
||||
<Icon :icon="item.icon" size="24" />
|
||||
</a-badge>
|
||||
<Icon v-else :icon="item.icon" size="24" />
|
||||
<div class="text-xs mt-1 font-bold">{{ item.label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="circle" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import Icon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { isToken } from '@/utils'
|
||||
import { useCartStore } from '@/store/cartStore'
|
||||
import { getPrimaryNavItems } from '../config/navigation'
|
||||
import logo from '@/assets/img/logo.png'
|
||||
|
||||
defineProps<{
|
||||
navItemClass: (routeName: string) => string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
navigate: [routeName: string]
|
||||
'toggle-overflow': []
|
||||
}>()
|
||||
|
||||
const cartStore = useCartStore()
|
||||
const navLogo = logo
|
||||
const cartCount = computed(() => cartStore.itemCount || 0)
|
||||
|
||||
const primaryItems = computed(() => getPrimaryNavItems(isToken()))
|
||||
const leftTabs = computed(() => primaryItems.value.slice(0, 2))
|
||||
const rightTabs = computed(() => primaryItems.value.slice(2))
|
||||
</script>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<Transition>
|
||||
<div
|
||||
v-if="show"
|
||||
class="top-0 bottom-0 bg-black wrap-fix z-2000 opacity-60"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
</Transition>
|
||||
<Transition name="slide-fade">
|
||||
<div v-if="show" 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
|
||||
icon-size="16"
|
||||
class="!font-bold w-15rem"
|
||||
@click="go('user')"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<BasicIcon icon="vuesax-linear:user" size="16" class="absolute right-2" />
|
||||
<div>حساب کاربری</div>
|
||||
</div>
|
||||
</BasicButton>
|
||||
<BasicButton
|
||||
v-if="authenticated"
|
||||
icon-size="16"
|
||||
class="!font-bold w-15rem"
|
||||
@click="go('physicalReceive')"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<BasicIcon icon="vuesax-linear:direct-inbox" size="16" class="absolute right-2" />
|
||||
<div>دریافت طلای فیزیکی</div>
|
||||
</div>
|
||||
</BasicButton>
|
||||
<BasicButton
|
||||
v-if="authenticated"
|
||||
icon-size="16"
|
||||
class="!font-bold w-15rem"
|
||||
@click="go('buyProduct')"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<BasicIcon icon="vuesax-linear:coin" size="16" class="absolute right-2" />
|
||||
<div>خرید محصول</div>
|
||||
</div>
|
||||
</BasicButton>
|
||||
<BasicButton
|
||||
v-if="authenticated"
|
||||
icon-size="16"
|
||||
class="!font-bold w-15rem"
|
||||
@click="go('list')"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<BasicIcon icon="vuesax-linear:receipt-item" size="16" class="absolute right-2" />
|
||||
<div>لیست دریافتی ها</div>
|
||||
</div>
|
||||
</BasicButton>
|
||||
<BasicButton
|
||||
v-if="authenticated"
|
||||
icon-size="16"
|
||||
class="!font-bold w-15rem"
|
||||
@click="go('giftCard')"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<BasicIcon icon="vuesax-linear:gift" size="16" class="absolute right-2" />
|
||||
<div>ثبت کارت هدیه</div>
|
||||
</div>
|
||||
</BasicButton>
|
||||
<BasicButton icon-size="16" class="!font-bold w-15rem" @click="go('about')">
|
||||
<div class="flex gap-2 items-center">
|
||||
<BasicIcon icon="vuesax-linear:info-circle" size="16" class="absolute right-2" />
|
||||
<div>درباره ما</div>
|
||||
</div>
|
||||
</BasicButton>
|
||||
<BasicButton
|
||||
v-if="authenticated"
|
||||
icon-size="16"
|
||||
class="!font-bold w-15rem"
|
||||
@click="go('rules')"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<BasicIcon icon="vuesax-linear:task" size="16" class="absolute right-2" />
|
||||
<div>قوانین و مقررات</div>
|
||||
</div>
|
||||
</BasicButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { isToken } from '@/utils'
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
navigate: [routeName: string]
|
||||
}>()
|
||||
|
||||
const authenticated = computed(() => isToken())
|
||||
|
||||
function go(routeName: string) {
|
||||
emit('navigate', routeName)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,139 @@
|
||||
.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: 0;
|
||||
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;
|
||||
background-color: #a72b77;
|
||||
|
||||
.circle_into {
|
||||
overflow: hidden;
|
||||
background-color: @primary-color;
|
||||
border-radius: 50%;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: white 1px solid;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
min-height: calc(100vh - var(--app-bottom-chrome-height, 80px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-with-bottom-chrome-inner {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: min(100%, calc(100vh - var(--app-bottom-chrome-height, 80px)));
|
||||
}
|
||||
|
||||
.main-with-bottom-chrome--no-nav,
|
||||
.main-with-bottom-chrome--no-nav .main-with-bottom-chrome-inner {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
@desktop-layout-min: 768px;
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
.bottom-appbar {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
html.app-layout--desktop {
|
||||
@media screen and (min-width: @desktop-layout-min) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
body,
|
||||
#app,
|
||||
.desktopConfig {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-config-provider {
|
||||
display: block;
|
||||
height: 100%;
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
--app-desktop-sidebar-width: 260px;
|
||||
|
||||
.wrap-fix {
|
||||
right: var(--app-desktop-sidebar-width, 260px) !important;
|
||||
}
|
||||
|
||||
#goftino_w {
|
||||
right: 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.app-layout--desktop-no-sidebar .wrap-fix {
|
||||
right: 0 !important;
|
||||
}
|
||||
|
||||
.desktop-app-layout__content {
|
||||
.w-screen,
|
||||
.md\:w-auto,
|
||||
.md\:w-\[426px\] {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.mt-20 {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.dpage {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.fixed.bottom-20,
|
||||
.fixed.bottom-23 {
|
||||
left: var(--app-desktop-sidebar-width, 260px) !important;
|
||||
right: 0 !important;
|
||||
width: auto !important;
|
||||
max-width: none !important;
|
||||
margin: 0 !important;
|
||||
transform: none !important;
|
||||
|
||||
> div {
|
||||
max-width: 640px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.transaction-list {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Splash — full viewport, centered; 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%;
|
||||
pointer-events: none;
|
||||
|
||||
&--top {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&--bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__version {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
html.app-layout--desktop .app-splash-screen {
|
||||
right: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100vw !important;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
html:not(.app-layout--desktop) {
|
||||
@media screen and (max-width: 425.98px) {
|
||||
body {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#app,
|
||||
.desktopConfig,
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface AppNavItem {
|
||||
id: string
|
||||
routeName: string
|
||||
label: string
|
||||
icon: string
|
||||
iconSrc?: string
|
||||
authOnly?: boolean
|
||||
guestOnly?: boolean
|
||||
}
|
||||
+1
-4
@@ -4,7 +4,6 @@ import 'virtual:uno.css'
|
||||
|
||||
import { initPwaInstallCapture } from '@/modules/pwa/manifest'
|
||||
import { bootstrapApp } from '@/app/bootstrap'
|
||||
import { initWhatsappButtonDrag } from '@/utils/initWhatsappButtonDrag'
|
||||
|
||||
export {
|
||||
setTruth,
|
||||
@@ -16,6 +15,4 @@ export {
|
||||
import '@unocss/reset/tailwind-compat.css'
|
||||
|
||||
initPwaInstallCapture()
|
||||
bootstrapApp().then(() => {
|
||||
initWhatsappButtonDrag()
|
||||
})
|
||||
bootstrapApp()
|
||||
|
||||
+19
-4
@@ -12,8 +12,23 @@ export interface Series {
|
||||
}
|
||||
*/
|
||||
|
||||
export interface PriceUp {
|
||||
buy: number
|
||||
sell: number
|
||||
}
|
||||
|
||||
export type PriceStatus = 'open' | 'closed' | 'no_quote'
|
||||
|
||||
export interface Price {
|
||||
sell: number
|
||||
buy: number
|
||||
price_last: number
|
||||
}
|
||||
sell: number
|
||||
buy: number
|
||||
price: number
|
||||
price_last: number
|
||||
goldir?: number
|
||||
up?: PriceUp
|
||||
closed?: boolean
|
||||
status?: PriceStatus
|
||||
time?: string
|
||||
main_product?: number
|
||||
montzar?: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface WalletAssetsMeta {
|
||||
metal_ids: number[]
|
||||
metal_count: number
|
||||
has_count_based_products: boolean
|
||||
has_weight_based_products: boolean
|
||||
weight_unit: string
|
||||
single_metal: boolean
|
||||
}
|
||||
|
||||
export interface WalletInventoryCard {
|
||||
product_id: number
|
||||
title: string
|
||||
group_type: string
|
||||
metal_id: number
|
||||
quantity: number
|
||||
unit: string
|
||||
display_type: 'count' | 'weight'
|
||||
unit_weight?: string | null
|
||||
show_in_assets: boolean
|
||||
is_melted?: boolean
|
||||
is_main_wallet?: boolean
|
||||
file?: { src?: string | null } | null
|
||||
}
|
||||
|
||||
export interface WalletMetalTotal {
|
||||
metal_id: number
|
||||
metal_label: string
|
||||
label: string
|
||||
product_weight: number
|
||||
melted_weight: number
|
||||
total_weight: number
|
||||
unit: string
|
||||
}
|
||||
|
||||
export interface WalletAssetsSummary {
|
||||
meta: WalletAssetsMeta
|
||||
cash_wallet: number
|
||||
inventory_cards: WalletInventoryCard[]
|
||||
count_products: WalletInventoryCard[]
|
||||
metal_totals: WalletMetalTotal[]
|
||||
assets_rial_value: number
|
||||
total_rial_value: number
|
||||
profit_loss?: number
|
||||
}
|
||||
+12
-20
@@ -2,8 +2,8 @@ import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { CartItem } from '@/models/cart'
|
||||
import type { Product } from '@/models/products'
|
||||
import type { Price } from '@/models/home'
|
||||
import { getProducts } from '@/api/products'
|
||||
import { getProductsCatalog } from '@/composables/useProductsCatalog'
|
||||
import { getProductBuyPrice } from '@/utils/productPrice'
|
||||
|
||||
const CART_STORAGE_KEY = 'ghatre_cart'
|
||||
|
||||
@@ -113,23 +113,13 @@ export const useCartStore = defineStore('cart', () => {
|
||||
return beforeCount - cartItems.value.length
|
||||
}
|
||||
|
||||
function recalculateCartPrices(lastPrice: Price) {
|
||||
if (!lastPrice || cartItems.value.length === 0) return
|
||||
function updateCartItemPrices() {
|
||||
if (cartItems.value.length === 0) return
|
||||
|
||||
cartItems.value.forEach((item) => {
|
||||
const product = item.product
|
||||
let newPrice = 0
|
||||
|
||||
if (product.group_type == 'آبشده') {
|
||||
newPrice = lastPrice.price_last ?? 0
|
||||
} else {
|
||||
newPrice = product.amount * (lastPrice.price_last ?? 0)
|
||||
}
|
||||
|
||||
product.price = newPrice
|
||||
product.sellPrice = product.amount * (lastPrice.sell ?? 0)
|
||||
item.price = newPrice + (product.cash_fee_buy ?? 0)
|
||||
item.totalPrice = parseInt(String(item.price * item.quantity))
|
||||
const buyPrice = getProductBuyPrice(item.product)
|
||||
item.price = buyPrice
|
||||
item.totalPrice = parseInt(String(buyPrice * item.quantity))
|
||||
})
|
||||
|
||||
saveCartToStorage()
|
||||
@@ -139,8 +129,10 @@ export const useCartStore = defineStore('cart', () => {
|
||||
if (cartItems.value.length === 0) return 0
|
||||
|
||||
try {
|
||||
const serverProducts: Product[] = await getProducts()
|
||||
return syncProductsFromServer(serverProducts)
|
||||
const serverProducts: Product[] = await getProductsCatalog()
|
||||
const removed = syncProductsFromServer(serverProducts)
|
||||
updateCartItemPrices()
|
||||
return removed
|
||||
} catch (e) {
|
||||
console.error('Error refreshing cart from server:', e)
|
||||
return 0
|
||||
@@ -158,7 +150,7 @@ export const useCartStore = defineStore('cart', () => {
|
||||
loadCartFromStorage,
|
||||
saveCartToStorage,
|
||||
syncProductsFromServer,
|
||||
recalculateCartPrices,
|
||||
updateCartItemPrices,
|
||||
refreshCartFromServer
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import {
|
||||
buildSupportMessengerUrl,
|
||||
loadSupportMessengerFromSettings,
|
||||
supportMessengerSettings,
|
||||
} from '@/utils/supportMessenger'
|
||||
|
||||
const STORAGE_KEY = 'ghatreh_whatsapp_button_position'
|
||||
const DRAG_THRESHOLD = 6
|
||||
const WIDTH = 60
|
||||
const HEIGHT = 60
|
||||
const DEFAULT_BOTTOM_OFFSET = 90
|
||||
const DEFAULT_RIGHT_OFFSET = 20
|
||||
|
||||
type FabPosition = { x: number; y: number }
|
||||
|
||||
function getViewportWidth() {
|
||||
return Math.min(window.innerWidth, 426)
|
||||
}
|
||||
|
||||
function getHorizontalOffset() {
|
||||
const vw = window.innerWidth
|
||||
const contentWidth = getViewportWidth()
|
||||
return Math.max(0, (vw - contentWidth) / 2)
|
||||
}
|
||||
|
||||
function getDefaultPosition(): FabPosition {
|
||||
const vh = window.innerHeight
|
||||
const offsetX = getHorizontalOffset()
|
||||
const contentWidth = getViewportWidth()
|
||||
|
||||
return {
|
||||
x: offsetX + contentWidth - WIDTH - DEFAULT_RIGHT_OFFSET,
|
||||
y: vh - DEFAULT_BOTTOM_OFFSET - HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
function loadPosition(): FabPosition | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as FabPosition
|
||||
if (typeof parsed?.x === 'number' && typeof parsed?.y === 'number') {
|
||||
return parsed
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function savePosition(pos: FabPosition) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(pos))
|
||||
}
|
||||
|
||||
function clampPosition(pos: FabPosition): FabPosition {
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
|
||||
return {
|
||||
x: Math.max(0, Math.min(vw - WIDTH, pos.x)),
|
||||
y: Math.max(0, Math.min(vh - HEIGHT, pos.y)),
|
||||
}
|
||||
}
|
||||
|
||||
function applyPosition(button: HTMLElement, pos: FabPosition) {
|
||||
button.style.position = 'fixed'
|
||||
button.style.bottom = 'auto'
|
||||
button.style.right = 'auto'
|
||||
button.style.left = `${pos.x}px`
|
||||
button.style.top = `${pos.y}px`
|
||||
button.style.touchAction = 'none'
|
||||
}
|
||||
|
||||
function updateWhatsappHref(button: HTMLAnchorElement) {
|
||||
const link = buildSupportMessengerUrl(
|
||||
supportMessengerSettings.value.messenger,
|
||||
supportMessengerSettings.value.messengerId
|
||||
)
|
||||
|
||||
button.href = link || '#'
|
||||
button.style.display = 'flex'
|
||||
}
|
||||
|
||||
export function initWhatsappButtonDrag() {
|
||||
const button = document.getElementById('whatsapp-button') as HTMLAnchorElement | null
|
||||
if (!button) return
|
||||
|
||||
let pos = clampPosition(loadPosition() ?? getDefaultPosition())
|
||||
applyPosition(button, pos)
|
||||
|
||||
let pointerId: number | null = null
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let originX = 0
|
||||
let originY = 0
|
||||
let hasMoved = false
|
||||
let dragging = false
|
||||
|
||||
function onResize() {
|
||||
pos = clampPosition(pos)
|
||||
applyPosition(button!, pos)
|
||||
savePosition(pos)
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
dragging = true
|
||||
hasMoved = false
|
||||
pointerId = event.pointerId
|
||||
startX = event.clientX
|
||||
startY = event.clientY
|
||||
originX = pos.x
|
||||
originY = pos.y
|
||||
button!.classList.add('is-dragging')
|
||||
button!.setPointerCapture(event.pointerId)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (!dragging || pointerId !== event.pointerId) return
|
||||
|
||||
const dx = event.clientX - startX
|
||||
const dy = event.clientY - startY
|
||||
|
||||
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
|
||||
hasMoved = true
|
||||
}
|
||||
|
||||
pos = clampPosition({ x: originX + dx, y: originY + dy })
|
||||
applyPosition(button!, pos)
|
||||
}
|
||||
|
||||
function finishPointer(event: PointerEvent) {
|
||||
if (!dragging || pointerId !== event.pointerId) return
|
||||
|
||||
dragging = false
|
||||
pointerId = null
|
||||
button!.classList.remove('is-dragging')
|
||||
savePosition(pos)
|
||||
|
||||
const clicked = !hasMoved
|
||||
hasMoved = false
|
||||
|
||||
if (clicked && button!.href && button!.href !== '#') {
|
||||
window.open(button!.href, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
button.addEventListener('pointerdown', onPointerDown)
|
||||
button.addEventListener('pointermove', onPointerMove)
|
||||
button.addEventListener('pointerup', finishPointer)
|
||||
button.addEventListener('pointercancel', finishPointer)
|
||||
button.addEventListener('click', (event) => event.preventDefault())
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
void loadSupportMessengerFromSettings().then(() => {
|
||||
updateWhatsappHref(button)
|
||||
})
|
||||
}
|
||||
+25
-6
@@ -20,14 +20,28 @@ export const olderPrice = ref<number | undefined>(0)
|
||||
let lastPriceSourceAt = 0
|
||||
let consecutivePriceFetchFailures = 0
|
||||
|
||||
/** buy/sell نمایش و معامله — همان buy/sell/price_last از /api/price/last */
|
||||
export function getTradeBuyPrice(p?: Price | null) {
|
||||
return p?.buy ?? p?.price_last ?? p?.up?.buy ?? 0
|
||||
}
|
||||
|
||||
export function getTradeSellPrice(p?: Price | null) {
|
||||
return p?.sell ?? p?.up?.sell ?? 0
|
||||
}
|
||||
|
||||
/** up جدا نگه داشته میشود؛ buy/sell/price_last از lastPrice سرور دستنخورده میماند */
|
||||
export function normalizePriceResponse(data: Price): Price {
|
||||
return data
|
||||
}
|
||||
|
||||
function hasValidPricePayload(data?: Price | null): boolean {
|
||||
if (!data) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
Number(data.buy ?? data.up?.buy ?? 0) > 0
|
||||
|| Number(data.sell ?? data.up?.sell ?? 0) > 0
|
||||
getTradeBuyPrice(data) > 0
|
||||
|| getTradeSellPrice(data) > 0
|
||||
|| Number(data.price_last ?? data.price ?? 0) > 0
|
||||
)
|
||||
}
|
||||
@@ -147,13 +161,18 @@ export async function getLastPrices(): Promise<boolean> {
|
||||
}
|
||||
|
||||
export function applyLastPricePayload(dataLast: Price) {
|
||||
if (lastPrice.value?.price_last != dataLast.price_last) {
|
||||
const normalized = normalizePriceResponse(dataLast)
|
||||
if (lastPrice.value?.price_last != normalized.price_last) {
|
||||
olderPrice.value = lastPrice.value?.price_last
|
||||
}
|
||||
lastPrice.value = dataLast
|
||||
price.value = dataLast
|
||||
lastPrice.value = normalized
|
||||
price.value = {
|
||||
...normalized,
|
||||
buy: getTradeBuyPrice(normalized),
|
||||
sell: getTradeSellPrice(normalized),
|
||||
}
|
||||
|
||||
if (hasValidPricePayload(dataLast)) {
|
||||
if (hasValidPricePayload(normalized)) {
|
||||
markPriceSourceReceived()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,16 +189,14 @@ async function runFallbackPoll() {
|
||||
const { getLastPrices, hasRecentPriceFromSource, markPriceSourceFetchFailed } = await import(
|
||||
'@/utils/price'
|
||||
)
|
||||
const { getProducts } = await import('@/api/products')
|
||||
const { applyProductsListFromStream: applyList } = await import('@/utils/productStreamBridge')
|
||||
const [lastResult, products] = await Promise.allSettled([
|
||||
getLastPrices(),
|
||||
getProducts(),
|
||||
])
|
||||
const { refreshProductsPageData } = await import(
|
||||
'@/views/base/products/composables/useProductsPageData'
|
||||
)
|
||||
|
||||
if (products.status === 'fulfilled' && Array.isArray(products.value) && products.value.length) {
|
||||
applyList(products.value)
|
||||
}
|
||||
const [lastResult] = await Promise.allSettled([
|
||||
getLastPrices(),
|
||||
refreshProductsPageData(),
|
||||
])
|
||||
|
||||
const priceOk =
|
||||
lastResult.status === 'fulfilled'
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Product } from '@/models/products'
|
||||
import type { WalletInventoryCard, WalletMetalTotal } from '@/models/walletAssets'
|
||||
import { resolveProductImageSrc } from '@/utils/productImage'
|
||||
|
||||
export function fundPageInventoryCards<T extends { quantity?: number }>(cards: T[]): T[] {
|
||||
return cards.filter((card) => Number(card.quantity ?? 0) > 0)
|
||||
}
|
||||
|
||||
export function resolveSummaryMetalWeight(
|
||||
metal: { total_weight: number },
|
||||
_cards?: Array<{ metal_id?: number; quantity?: number }>
|
||||
): number {
|
||||
return Number(metal.total_weight ?? 0)
|
||||
}
|
||||
|
||||
export function enrichInventoryCardsWithCatalog<T extends WalletInventoryCard>(
|
||||
cards: T[],
|
||||
catalog: Product[]
|
||||
): T[] {
|
||||
return cards.map((card) => {
|
||||
const fromCatalog = catalog.find((product) => product.id === card.product_id)
|
||||
if (!fromCatalog) {
|
||||
return card
|
||||
}
|
||||
|
||||
return {
|
||||
...card,
|
||||
file: card.file ?? fromCatalog.file ?? null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getProductDisplayImage(product?: {
|
||||
group_type?: string | null
|
||||
title?: string | null
|
||||
file?: { src?: string | null } | null
|
||||
img?: string | null
|
||||
} | null): string {
|
||||
return resolveProductImageSrc(product)
|
||||
}
|
||||
|
||||
export function formatMetalTotal(
|
||||
metal: WalletMetalTotal,
|
||||
inventoryCards: WalletInventoryCard[]
|
||||
): string {
|
||||
return `${resolveSummaryMetalWeight(metal, inventoryCards)}${metal.unit}`
|
||||
}
|
||||
|
||||
export function resolveAssetsRialValue(
|
||||
summary: {
|
||||
total_rial_value?: number
|
||||
assets_rial_value?: number
|
||||
cash_wallet?: number
|
||||
} | null | undefined,
|
||||
liveGoldPrice = 0
|
||||
): number {
|
||||
const total = Number(summary?.total_rial_value ?? 0)
|
||||
if (total > 0) {
|
||||
return total
|
||||
}
|
||||
|
||||
const cash = Number(summary?.cash_wallet ?? 0)
|
||||
const assetsRial = Number(summary?.assets_rial_value ?? 0)
|
||||
if (assetsRial > 0) {
|
||||
return cash + assetsRial
|
||||
}
|
||||
|
||||
const metalWeight = (summary as { metal_totals?: Array<{ total_weight?: number }> } | null)
|
||||
?.metal_totals?.[0]?.total_weight
|
||||
return cash + Number(metalWeight ?? 0) * liveGoldPrice
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Product } from '@/models/products'
|
||||
import { getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price'
|
||||
|
||||
/**
|
||||
* قیمتگیری ماژولار محصول در حالت فروشگاهی (is_store=true).
|
||||
*
|
||||
* منبع حقیقت قیمت و وضعیت نمایش، سرور است: `/customer/product/list` و رویداد SSE
|
||||
* `list` هر دو قیمت هر محصول (`price`/`price_profit`) و `show` را محاسبهشده میفرستند.
|
||||
* این ماژول فقط همان قیمت سرور را میخواند و فقط برای آبشده وقتی قیمت سرور صفر است
|
||||
* به قیمت زندهٔ مرجع (`lastPrice`) fallback میکند — هیچجا قیمت سرور بازنویسی نمیشود.
|
||||
*/
|
||||
|
||||
const MELTED_GROUP = 'آبشده'
|
||||
|
||||
export function isMeltedProduct(product?: Product | null): boolean {
|
||||
return product?.group_type === MELTED_GROUP
|
||||
}
|
||||
|
||||
/** قیمت پایهٔ خرید (بدون کارمزد) — قیمت سرور، با fallback زندهٔ آبشده وقتی سرور صفر است */
|
||||
export function getProductBaseBuyPrice(product?: Product | null): number {
|
||||
if (!product) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const serverPrice = Number(product.price ?? 0)
|
||||
if (serverPrice > 0) {
|
||||
return serverPrice
|
||||
}
|
||||
|
||||
if (isMeltedProduct(product)) {
|
||||
const liveBuy = getTradeBuyPrice(lastPrice.value)
|
||||
if (liveBuy > 0) {
|
||||
const amount = Number(product.amount ?? 0)
|
||||
return amount > 0 ? liveBuy * amount : liveBuy
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/** قیمت خرید نمایشی محصول (همان مقداری که در لیست و صفحهٔ محصول نشان داده میشود) */
|
||||
export function getProductBuyPrice(product?: Product | null): number {
|
||||
const base = getProductBaseBuyPrice(product)
|
||||
if (base <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return base + Number(product?.cash_fee_buy ?? 0)
|
||||
}
|
||||
|
||||
/** قیمت پایهٔ فروش (بدون کارمزد) — قیمت فروش سرور، با fallback زندهٔ آبشده */
|
||||
export function getProductBaseSellPrice(product?: Product | null): number {
|
||||
if (!product) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const serverSell = Number(product.sellPrice ?? 0)
|
||||
if (serverSell > 0) {
|
||||
return serverSell
|
||||
}
|
||||
|
||||
if (isMeltedProduct(product)) {
|
||||
const liveSell = getTradeSellPrice(lastPrice.value)
|
||||
if (liveSell > 0) {
|
||||
const amount = Number(product.amount ?? 0)
|
||||
return amount > 0 ? liveSell * amount : liveSell
|
||||
}
|
||||
}
|
||||
|
||||
return getProductBaseBuyPrice(product)
|
||||
}
|
||||
|
||||
/** قیمت فروش نمایشی محصول */
|
||||
export function getProductSellPrice(product?: Product | null): number {
|
||||
const base = getProductBaseSellPrice(product)
|
||||
if (base <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return base - Number(product?.cash_fee_sell ?? 0)
|
||||
}
|
||||
|
||||
/** محصول وقتی قیمت پایهٔ معتبری ندارد، غیرفعال (غیرقابل معامله) است */
|
||||
export function isProductPriceInactive(product?: Product | null): boolean {
|
||||
return getProductBaseBuyPrice(product) <= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* نمایش محصول در فروشگاه — اولویت با `show` سرور.
|
||||
* فقط برای آبشدهای که قیمت سرورش هنوز نرسیده، با قیمت زنده تکمیل میکنیم.
|
||||
*/
|
||||
export function isProductVisible(product?: Product | null): boolean {
|
||||
if (!product || product.active === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (product.show) {
|
||||
return true
|
||||
}
|
||||
|
||||
// سازگاری: آبشدهای که سرور هنوز قیمت نداده ولی قیمت زنده موجود است
|
||||
return isMeltedProduct(product) && getProductBaseBuyPrice(product) > 0
|
||||
}
|
||||
@@ -3,10 +3,15 @@ import type { Product } from '@/models/products'
|
||||
type ProductListHandler = (products: Product[]) => void
|
||||
|
||||
const handlers = new Set<ProductListHandler>()
|
||||
let lastProductsList: Product[] | null = null
|
||||
|
||||
export function subscribeProductListStream(handler: ProductListHandler) {
|
||||
handlers.add(handler)
|
||||
|
||||
if (lastProductsList?.length) {
|
||||
handler(lastProductsList)
|
||||
}
|
||||
|
||||
return () => {
|
||||
handlers.delete(handler)
|
||||
}
|
||||
@@ -17,5 +22,10 @@ export function applyProductsListFromStream(products: Product[]) {
|
||||
return
|
||||
}
|
||||
|
||||
lastProductsList = products
|
||||
handlers.forEach((handler) => handler(products))
|
||||
}
|
||||
|
||||
export function getLastStreamProductsList(): Product[] | null {
|
||||
return lastProductsList
|
||||
}
|
||||
|
||||
+46
-1
@@ -1,6 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { lastPrice } from '@/utils/price'
|
||||
import { getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price'
|
||||
|
||||
export interface StoreTradingHours {
|
||||
start: string
|
||||
@@ -162,3 +162,48 @@ export function getStoreClosedMessage(): string {
|
||||
|
||||
return `فروشگاه در حال حاضر بسته است. ساعت کاری از ${open} تا ${close} میباشد.`
|
||||
}
|
||||
|
||||
export type PriceDisplayState = 'open' | 'closed' | 'no_quote'
|
||||
|
||||
export const NO_QUOTE_MESSAGE = 'مظنه در دسترس نیست'
|
||||
|
||||
/**
|
||||
* وضعیت نمایش قیمت در صفحه اصلی:
|
||||
* - closed → فروشگاه بسته است (خارج از ساعت کاری close_price یا بازه محصول)
|
||||
* - no_quote → مظنه در دسترس نیست (قیمت زنده نداریم و show_last_price خاموش است)
|
||||
* - open → نمایش عادی قیمت
|
||||
*
|
||||
* اولویت با status ارسالی از سرور است؛ اگر نبود به منطق سمت کلاینت برمیگردیم.
|
||||
*/
|
||||
export function getPriceDisplayState(): PriceDisplayState {
|
||||
void productTradingHoursVersion.value
|
||||
const p = lastPrice.value
|
||||
const status = p?.status
|
||||
|
||||
if (status === 'closed') {
|
||||
return 'closed'
|
||||
}
|
||||
if (status === 'no_quote') {
|
||||
return getTradeBuyPrice(p) > 0 ? 'open' : 'no_quote'
|
||||
}
|
||||
if (status === 'open') {
|
||||
return getTradeBuyPrice(p) > 0 || getTradeSellPrice(p) > 0 ? 'open' : 'no_quote'
|
||||
}
|
||||
|
||||
// سرور status نفرستاده (سازگاری با نسخه قدیمی) → استنتاج سمت کلاینت
|
||||
if (isStoreClosed()) {
|
||||
return 'closed'
|
||||
}
|
||||
|
||||
return getTradeBuyPrice(p) > 0 || getTradeSellPrice(p) > 0 ? 'open' : 'no_quote'
|
||||
}
|
||||
|
||||
export function getPriceDisplayMessage(state: PriceDisplayState = getPriceDisplayState()): string {
|
||||
if (state === 'closed') {
|
||||
return getStoreClosedMessage()
|
||||
}
|
||||
if (state === 'no_quote') {
|
||||
return NO_QUOTE_MESSAGE
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
+75
-43
@@ -55,61 +55,93 @@ _axios.interceptors.response.use(
|
||||
return response.data
|
||||
},
|
||||
async function (error) {
|
||||
if (error.response.data && error.response.data.message) {
|
||||
const response = error.response
|
||||
const config = error.config as MyAxiosRequestConfig | undefined
|
||||
|
||||
checkStatus(error.response.data.message, error.response.status, error.config)
|
||||
}
|
||||
|
||||
//console.log('this is error on interceptors.response', error.config.meta)
|
||||
/* if (error.response && (error.response.status == 401 || error.response.status == 400)) {
|
||||
//debugger
|
||||
removeToken()
|
||||
router.push({ name: 'login' })
|
||||
}*/
|
||||
if (error.response && error.response.status == 401) {
|
||||
///removeToken()
|
||||
console.log('error')
|
||||
if (response?.status === 401) {
|
||||
removeToken()
|
||||
// Don't redirect to login if user is on buyProduct page
|
||||
if (router.currentRoute.value.name !== 'buyProduct') {
|
||||
await router.push({name: 'login'})
|
||||
await router.push({name: 'login'})
|
||||
}
|
||||
} else if (error.response && error.response.status == 403) {
|
||||
message.error('شما دسترسی لازم برای انجام این عملیات را ندارید')
|
||||
} else if (error.response && error.response.status == 422) {
|
||||
// message.error(error.response.data?.message)
|
||||
} else if (error.response) {
|
||||
message.error('خطا در بارگذاری اطلاعات')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
// Any status codes that falls outside the range of 2xx cause this function to trigger
|
||||
// Do something with response error
|
||||
|
||||
//const { response, code, message, config } = error || {}
|
||||
//const errorMessageMode = config?.requestOptions?.errorMessageMode || 'none'
|
||||
//const msg: string = response?.data?.error?.message ?? ''
|
||||
//checkStatus(error?.response?.status, msg)
|
||||
if (!shouldShowApiError(config)) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const apiMessage = extractApiErrorMessage(response?.data)
|
||||
|
||||
if (response?.status === 403) {
|
||||
showApiError(apiMessage || 'شما دسترسی لازم برای انجام این عملیات را ندارید', config)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (response?.status === 422) {
|
||||
if (apiMessage) {
|
||||
showApiError(apiMessage, config)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (response) {
|
||||
showApiError(apiMessage || DEFAULT_LOAD_ERROR, config)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
showApiError(DEFAULT_LOAD_ERROR, config)
|
||||
return Promise.reject(error)
|
||||
})
|
||||
|
||||
function checkStatus(msg: string, code: number, config: any) {
|
||||
//const { code, data, message } = data
|
||||
const errorMessage = get(config, 'meta.errorMessage', 'message')
|
||||
/*if (config.meta) {
|
||||
if (config.meta.message) {
|
||||
errorMessage = config.meta.message
|
||||
}
|
||||
}*/
|
||||
// console.log('this is config', config)
|
||||
if (errorMessage == 'none') return
|
||||
switch (errorMessage) {
|
||||
case 'message':
|
||||
message.error(msg)
|
||||
break
|
||||
default:
|
||||
message.error(msg)
|
||||
const DEFAULT_LOAD_ERROR = 'خطا در بارگذاری اطلاعات'
|
||||
|
||||
function extractApiErrorMessage(data: unknown): string {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const record = data as Record<string, unknown>
|
||||
const rawMessage = record.message
|
||||
|
||||
if (typeof rawMessage === 'string' && rawMessage.trim()) {
|
||||
return rawMessage.trim()
|
||||
}
|
||||
|
||||
if (typeof rawMessage === 'number') {
|
||||
return String(rawMessage)
|
||||
}
|
||||
|
||||
const rawError = record.error
|
||||
if (typeof rawError === 'string' && rawError.trim()) {
|
||||
return rawError.trim()
|
||||
}
|
||||
|
||||
const errors = record.errors
|
||||
if (errors && typeof errors === 'object') {
|
||||
for (const value of Object.values(errors as Record<string, unknown>)) {
|
||||
if (Array.isArray(value) && typeof value[0] === 'string' && value[0].trim()) {
|
||||
return value[0].trim()
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function shouldShowApiError(config: MyAxiosRequestConfig | undefined): boolean {
|
||||
return get(config, 'meta.errorMessage', 'message') !== 'none'
|
||||
}
|
||||
|
||||
function showApiError(msg: string, config: MyAxiosRequestConfig | undefined) {
|
||||
if (!shouldShowApiError(config)) {
|
||||
return
|
||||
}
|
||||
|
||||
message.error(msg)
|
||||
}
|
||||
|
||||
const myAxios = {
|
||||
//AxiosResponse<T>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface AboutSection {
|
||||
title?: string
|
||||
paragraphs?: string[]
|
||||
items?: string[]
|
||||
}
|
||||
|
||||
export function getAboutSections(projectName: string): AboutSection[] {
|
||||
return [
|
||||
{
|
||||
paragraphs: [`${projectName} وبسایت رسمی است.`],
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div
|
||||
class="dabout-content dabout-content--default"
|
||||
:class="variant === 'desktop' ? 'dabout-content--default' : '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="variant === 'desktop' ? 'dabout-content__divider' : 'w-full h-1px my-3 bg-tertiary'"
|
||||
/>
|
||||
<div
|
||||
v-for="(paragraph, paragraphIndex) in section.paragraphs ?? []"
|
||||
:key="`p-${sectionIndex}-${paragraphIndex}`"
|
||||
:class="[variant === 'desktop' ? 'dabout-content__paragraph' : '', paragraphIndex > 0 ? 'mt-3' : '']"
|
||||
>
|
||||
{{ paragraph }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useAboutPageContext } from '../composables/useAboutPage'
|
||||
|
||||
withDefaults(defineProps<{ variant?: 'mobile' | 'desktop' }>(), { variant: 'mobile' })
|
||||
|
||||
const { aboutSections } = useAboutPageContext()
|
||||
</script>
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div :class="variant === 'desktop' ? 'dabout-support' : 'wrap-fix bottom-0 px-10 py-6 flex gap-2 bg-white items-center'">
|
||||
<a class="w-full" :href="supportHref" target="_blank" rel="noopener noreferrer">
|
||||
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
|
||||
</a>
|
||||
<a :href="supportHref" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src="@/assets/img/rubika.webp"
|
||||
alt=""
|
||||
:class="variant === 'desktop' ? 'dabout-support__bale' : 'w-12'"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
import { useAboutPageContext } from '../composables/useAboutPage'
|
||||
|
||||
withDefaults(defineProps<{ variant?: 'mobile' | 'desktop' }>(), { variant: 'mobile' })
|
||||
|
||||
const { supportHref } = useAboutPageContext()
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { computed, inject, provide, type InjectionKey, type ComputedRef } from 'vue'
|
||||
import { getConfig } from '@/config/app-config'
|
||||
import { getAboutSections, type AboutSection } from '../aboutContent'
|
||||
|
||||
export interface AboutPageContext {
|
||||
projectName: ComputedRef<string>
|
||||
aboutSections: ComputedRef<AboutSection[]>
|
||||
supportHref: ComputedRef<string>
|
||||
}
|
||||
|
||||
export const ABOUT_PAGE_CONTEXT_KEY: InjectionKey<AboutPageContext> = Symbol('aboutPageContext')
|
||||
|
||||
const SUPPORT_LINK = 'https://web.rubika.ir/#c=u0I8F1U0a939b0df5ba9f73618d33d7c'
|
||||
|
||||
export function useAboutPage() {
|
||||
const projectName = computed(() => getConfig('name_fa', 'کشکول گلد'))
|
||||
const aboutSections = computed(() => getAboutSections(projectName.value))
|
||||
const supportHref = computed(() => SUPPORT_LINK)
|
||||
|
||||
const context: AboutPageContext = { projectName, aboutSections, supportHref }
|
||||
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 useAboutPage')
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="dabout">
|
||||
<div class="dabout__container">
|
||||
<DesktopPageTopbar eyebrow="اطلاعات" title="درباره ما" show-back />
|
||||
|
||||
<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">{{ projectName }}</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 DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
|
||||
import AboutSectionsContent from '../components/AboutSectionsContent.vue'
|
||||
import AboutSupportActions from '../components/AboutSupportActions.vue'
|
||||
import { useAboutPageContext } from '../composables/useAboutPage'
|
||||
|
||||
const { projectName } = useAboutPageContext()
|
||||
</script>
|
||||
|
||||
<style lang="less" src="./styles/desktop-about.less"></style>
|
||||
@@ -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
-184
@@ -1,195 +1,23 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="bg-white wrap-fix z-200001 py-3 px-3 text-primary font-bold drop-shadow flex">
|
||||
<div @click="router.back()" class="cursor-pointer">
|
||||
<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">
|
||||
<div class="bg-primary text-white rounded-[12px] p-4 text-xs leading-5">
|
||||
<div class="w-full h-1px my-3 bg-tertiary"></div>
|
||||
<div>
|
||||
کشکول گلد وبسایت رسمی تست.
|
||||
</div>
|
||||
<!-- <div class="mt-3">-->
|
||||
<!-- این وبسایت با پشتوانه سه مجموعه معتبر نور هشتم فعالیت میکند و امکان خرید شمش طلا را از وزنهای خرد تا معاملات عمده، با قیمت شفاف و امنیت کامل فراهم میسازد.-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="mt-3">-->
|
||||
<!-- تمامی فرآیندهای خرید در کشکول گلد بر اساس استانداردهای حرفهای بازار طلا و با اتکا به اعتبار مجموعههای نور هشتم مشهد، نورهشتم فردوس و نورهشتم (داماس) بجستان انجام میشود تا کاربران با آرامش و اطمینان کامل اقدام به خرید کنند.-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="mt-3">-->
|
||||
<!-- کشکول گلدکشکول گلد، بانکی از طلا بساز...-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="mt-3">-->
|
||||
<!-- برای ارتباط حضوری، دریافت مشاوره و اطمینان بیشتر، کاربران میتوانند از طریق آدرسهای سه شعبه رسمی نور هشتم با مجموعه در ارتباط باشند و از پشتیبانی مستقیم مجموعه بهرهمند شوند.-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="w-full h-1px my-3 bg-tertiary"></div>-->
|
||||
<!-- <div class="mt-3 text-xl font-bold">چرا کشکول گلد؟</div>
|
||||
<div class="mt-3">
|
||||
۱. خرید بدون اجرت و مالیات
|
||||
طلایی که از اپلیکیشن کشکول گلد خریداری میکنید، بدون اجرت ساخت و مالیات محاسبه میشود و تنها ۰.۵٪ کارمزد از شما دریافت خواهد شد.
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
۲. خرید بدون محدودیت
|
||||
در کشکول گلد میتوانید از ۵۰ سوت تا چند کیلوگرم طلا را بهصورت آنلاین و بدون هیچگونه محدودیت مکانی خریداری کنید
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
۳. فعالیت در ساعات رسمی معاملات طلا
|
||||
برای رعایت قوانین صنف طلا و جلوگیری از خالیفروشی، فعالیت اپلیکیشن کشکول گلد منطبق با ساعات رسمی معاملات اتحادیه طلافروشان کشور است.
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
۴. امکان تحویل فیزیکی طلا
|
||||
کشکول گلد دارای شعب حضوری معتبر با مجوز خردهفروشی طلاست تا کاربران بتوانند در صورت تمایل، طلای خریداریشده خود را بهصورت فیزیکی تحویل بگیرند و از نزدیک از اصالت آن اطمینان حاصل کنند.
|
||||
</div>
|
||||
<div class="w-full h-1px my-3 bg-tertiary"></div>-->
|
||||
<!-- <div class="mt-3">-->
|
||||
<!-- آدرس شعب ما:-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="mt-3">-->
|
||||
<!-- مشهد : خیابان امام خمینی / برج طلا و جواهر مرمر / طبقه همکف / واحد 16 / طلا و سکه نورهشتم-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="mt-3">-->
|
||||
<!-- فردوس : خیابان امام خمینی / بعد از امام خمینی 11 / طلا و سکه نورهشتم-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="mt-3">-->
|
||||
<!-- بجستان : خیابان امام خمینی / چهارراه وحدت / طلا و جواهر داماس [طلا و سکه نورهشتم]-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="w-full h-1px my-3 bg-tertiary"></div>-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap-fix bottom-0 px-10 py-6 flex gap-2 bg-white items-center">
|
||||
<a class="w-full" href="https://web.rubika.ir/#">
|
||||
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
|
||||
</a>
|
||||
<a href="https://web.rubika.ir/#">
|
||||
<img src="@/assets/img/rubika.webp" alt="" class=" w-12"/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <ActionSheet v-model:show="editPassVisible">salam</ActionSheet>-->
|
||||
<div class="about-page-root">
|
||||
<DesktopAboutView v-show="isDesktop" />
|
||||
<MobileAboutView v-show="!isDesktop" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import img from '@/assets/img/user_ly1.svg'
|
||||
import gr_bottom from './assets/img/gr_bottom_white.svg'
|
||||
import { loading } from '@/utils'
|
||||
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 BaseDatePicker from '@/components/BasicPacks/BaseDatePicker'
|
||||
import img_pattern from "@/views/login/assets/img/pattern.svg";
|
||||
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||
import { useAboutPage } from './composables/useAboutPage'
|
||||
import DesktopAboutView from './desktop/DesktopAboutView.vue'
|
||||
import MobileAboutView from './mobile/MobileAboutView.vue'
|
||||
|
||||
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 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() {
|
||||
// nextTick(() => {
|
||||
const val = `⭐️الان طلا بخر؛ آخر ماه سودشو ببر!
|
||||
با خرید طلای آبشده در مقدارهای کم ولی مستمر، سود بیشتری ببر!
|
||||
|
||||
این دعوت نامه از طرف "${userStore.userInfo?.name}" عزیز برای شما ارسال شده تا شما هم از بازار طلای کشکول گلد جا نمونید.
|
||||
روی لینک زیر کلیک کنید و ثبت نام خود را تکمیل کنید.
|
||||
|
||||
kh.kashkolgold.ir/login?id=${userStore.userInfo?.referral_id}`
|
||||
navigator.share({
|
||||
title: 'کشکول گلد',
|
||||
text: 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
|
||||
}
|
||||
})
|
||||
}
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
useAboutPage()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/*.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
.about-page-root {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}*/
|
||||
/*.trans{
|
||||
transition: height 1s;
|
||||
}*/
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<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 pb-24">
|
||||
<div class="px-5">
|
||||
<AboutSectionsContent variant="mobile" />
|
||||
</div>
|
||||
<AboutSupportActions variant="mobile" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import router from '@/router'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import AboutSectionsContent from '../components/AboutSectionsContent.vue'
|
||||
import AboutSupportActions from '../components/AboutSupportActions.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,309 @@
|
||||
import { computed, inject, onMounted, onUnmounted, provide, ref, watch, type InjectionKey } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { useCartStore } from '@/store/cartStore'
|
||||
import { useWalletStore } from '@/store/walletStore'
|
||||
import { useWalletAssets } from '@/composables/useWalletAssets'
|
||||
import { checkoutCart } from '@/api/card'
|
||||
import { registerCodeDiscount } from '@/api/giftCard'
|
||||
import { getProductBaseBuyPrice } from '@/utils/productPrice'
|
||||
import { subscribeProductListStream } from '@/utils/productStreamBridge'
|
||||
import {
|
||||
getLastPrices,
|
||||
lastPrice,
|
||||
startLastPricePolling,
|
||||
stopPriceInterval,
|
||||
} from '@/utils/price'
|
||||
import {
|
||||
priceSocketConnected,
|
||||
priceStreamLifecycle,
|
||||
priceWssEnabled,
|
||||
shouldUsePricePolling,
|
||||
} from '@/utils/priceSocketState'
|
||||
import { getToken, loading } from '@/utils'
|
||||
import router from '@/router'
|
||||
import type { Price } from '@/models/home'
|
||||
import type { Order } from '@/models/order'
|
||||
import type { Product } from '@/models/products'
|
||||
|
||||
export interface CartPageContext {
|
||||
cartStore: ReturnType<typeof useCartStore>
|
||||
loading: typeof loading
|
||||
infoVisible: ReturnType<typeof ref<boolean>>
|
||||
showDiscountModal: ReturnType<typeof ref<boolean>>
|
||||
discountCode: ReturnType<typeof ref<string>>
|
||||
discountInfo: ReturnType<typeof ref<any>>
|
||||
price: ReturnType<typeof computed<Price>>
|
||||
totalPrice: ReturnType<typeof computed<number>>
|
||||
displaytotalPrice: ReturnType<typeof computed<number | string>>
|
||||
discountPercentLabel: ReturnType<typeof computed<string>>
|
||||
formatWeight: (weight?: number) => string
|
||||
handleRemoveItem: (itemId: string) => void
|
||||
handleCheckout: () => Promise<void>
|
||||
submitDiscount: () => Promise<void>
|
||||
removeDiscount: () => void
|
||||
}
|
||||
|
||||
export const CART_PAGE_CONTEXT_KEY: InjectionKey<CartPageContext> = Symbol('cartPageContext')
|
||||
|
||||
export function useCartPage() {
|
||||
const cartStore = useCartStore()
|
||||
const walletStore = useWalletStore()
|
||||
const { loadWalletAssets } = useWalletAssets()
|
||||
const infoVisible = ref(false)
|
||||
const showDiscountModal = ref(false)
|
||||
const discountCode = ref('')
|
||||
const discountInfo = ref<any>()
|
||||
|
||||
let cartRefreshInterval: ReturnType<typeof setInterval> | undefined
|
||||
let unsubscribeProductStream: (() => void) | undefined
|
||||
|
||||
const price = computed((): Price => <Price>lastPrice.value ?? 0)
|
||||
|
||||
function syncCartFromProducts(products: Product[]) {
|
||||
if (cartStore.itemCount === 0) return
|
||||
const removedCount = cartStore.syncProductsFromServer(products)
|
||||
cartStore.updateCartItemPrices()
|
||||
if (removedCount > 0) {
|
||||
message.warning(`${removedCount} محصول از سبد خرید حذف شد (دیگر در دسترس نیست)`)
|
||||
}
|
||||
}
|
||||
|
||||
watch(lastPrice, () => {
|
||||
if (lastPrice.value && cartStore.itemCount > 0) {
|
||||
cartStore.updateCartItemPrices()
|
||||
}
|
||||
}, { deep: true, immediate: false })
|
||||
|
||||
const totalPrice = computed(() =>
|
||||
parseInt(String(cartStore.cartItems.reduce((sum, item) => sum + item.totalPrice, 0)))
|
||||
)
|
||||
|
||||
const displaytotalPrice = computed(() => {
|
||||
if (discountInfo.value?.amount > 0) {
|
||||
return (totalPrice.value - totalPrice.value * discountInfo.value.amount).toFixed(0)
|
||||
}
|
||||
return totalPrice.value
|
||||
})
|
||||
|
||||
const discountPercentLabel = computed(() => {
|
||||
const amount = Number(discountInfo.value?.amount ?? 0)
|
||||
if (amount <= 0) return ''
|
||||
return `${Math.round(amount * 100)}٪`
|
||||
})
|
||||
|
||||
function removeDiscount() {
|
||||
discountInfo.value = undefined
|
||||
discountCode.value = ''
|
||||
message.success('کد تخفیف حذف شد')
|
||||
}
|
||||
|
||||
async function submitDiscount() {
|
||||
try {
|
||||
loading.value = true
|
||||
const temp = await registerCodeDiscount(discountCode.value)
|
||||
discountInfo.value = temp.giftcard
|
||||
message.success('کد تخفیف با موفقیت اعمال شد')
|
||||
showDiscountModal.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatWeight(weight?: number) {
|
||||
if (!weight) return ''
|
||||
if (weight < 1) {
|
||||
const sot = weight * 1000
|
||||
return sot < 1 ? '1 سوت' : `${Math.round(sot)} سوت`
|
||||
}
|
||||
return `${weight} گرم`
|
||||
}
|
||||
|
||||
function handleRemoveItem(itemId: string) {
|
||||
Modal.confirm({
|
||||
title: 'حذف از سبد خرید',
|
||||
content: 'آیا از حذف این آیتم از سبد خرید اطمینان دارید؟',
|
||||
okText: 'تایید',
|
||||
okType: 'danger',
|
||||
centered: true,
|
||||
cancelText: 'انصراف',
|
||||
onOk() {
|
||||
cartStore.removeFromCart(itemId)
|
||||
message.success('آیتم از سبد خرید حذف شد')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function handleCheckout() {
|
||||
if (!getToken()) {
|
||||
message.warning('برای تسویه حساب، لطفا ابتدا وارد حساب کاربری خود شوید')
|
||||
await router.push({ name: 'login' })
|
||||
return
|
||||
}
|
||||
if (cartStore.itemCount === 0) {
|
||||
message.warning('سبد خرید شما خالی است')
|
||||
return
|
||||
}
|
||||
if (!lastPrice.value) {
|
||||
message.error('لطفا منتظر بمانید تا قیمتها بارگذاری شوند')
|
||||
return
|
||||
}
|
||||
|
||||
let cashItemsTotal = cartStore.cartItems
|
||||
.filter((item) => item.method === 'cash' && !item.gateway)
|
||||
.reduce((sum, item) => parseInt(String(sum + item.totalPrice)), 0)
|
||||
|
||||
if (cashItemsTotal > 0) {
|
||||
if (discountInfo.value?.amount > 0) {
|
||||
cashItemsTotal -= cashItemsTotal * discountInfo.value.amount
|
||||
}
|
||||
if (!walletStore.walletInfo) await walletStore.getMyWallet()
|
||||
if (!walletStore.walletInfo?.cash || cashItemsTotal > walletStore.walletInfo.cash) {
|
||||
message.error('موجودی کیف پول شما ناکافی است')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const goldItemsTotal = cartStore.cartItems
|
||||
.filter((item) => item.method === 'gold')
|
||||
.reduce((sum, item) => sum + (item.goldVazn ? item.goldVazn * item.quantity : 0), 0)
|
||||
|
||||
if (goldItemsTotal > 0) {
|
||||
if (!walletStore.walletInfo) await walletStore.getMyWallet()
|
||||
if (!walletStore.walletInfo?.gold || goldItemsTotal > walletStore.walletInfo.gold) {
|
||||
message.error('موجودی کیف طلا شما ناکافی است')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
infoVisible.value = true
|
||||
const orders: Order[] = cartStore.cartItems.map((item) => ({
|
||||
direction: 1,
|
||||
price: getProductBaseBuyPrice(item.product),
|
||||
amount: item.product.amount,
|
||||
gateway: item.gateway,
|
||||
product_id: item.product.id,
|
||||
method: item.method,
|
||||
number: item.quantity,
|
||||
discount_code: discountInfo.value?.code,
|
||||
}))
|
||||
|
||||
const data = await checkoutCart(orders)
|
||||
let returnUrl: string | null = null
|
||||
if (Array.isArray(data)) {
|
||||
const itemWithReturnUrl = data.find((item) => item?.returnUrl)
|
||||
if (itemWithReturnUrl?.returnUrl) returnUrl = itemWithReturnUrl.returnUrl
|
||||
} else if (data?.returnUrl) {
|
||||
returnUrl = data.returnUrl
|
||||
}
|
||||
|
||||
if (returnUrl) {
|
||||
window.location.href = returnUrl
|
||||
return
|
||||
}
|
||||
|
||||
await walletStore.getMyWallet()
|
||||
cartStore.clearCart()
|
||||
await router.push({ name: 'transactions' })
|
||||
message.success('سفارشات شما با موفقیت ثبت شد')
|
||||
await loadWalletAssets(true)
|
||||
} catch (e) {
|
||||
console.error('Error in checkout:', e)
|
||||
message.error('خطا در ثبت سفارش. لطفا دوباره تلاش کنید.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
infoVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function initPrice() {
|
||||
try {
|
||||
if (shouldUsePricePolling()) await getLastPrices()
|
||||
startLastPricePolling()
|
||||
cartStore.updateCartItemPrices()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCart() {
|
||||
const removedCount = await cartStore.refreshCartFromServer()
|
||||
if (removedCount > 0) {
|
||||
message.warning(`${removedCount} محصول از سبد خرید حذف شد (دیگر در دسترس نیست)`)
|
||||
}
|
||||
}
|
||||
|
||||
function startCartRefreshPolling() {
|
||||
if (cartRefreshInterval || !shouldUsePricePolling()) return
|
||||
cartRefreshInterval = setInterval(() => {
|
||||
if (shouldUsePricePolling()) void refreshCart()
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
function stopCartRefreshPolling() {
|
||||
if (cartRefreshInterval) {
|
||||
clearInterval(cartRefreshInterval)
|
||||
cartRefreshInterval = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function syncCartRefreshPolling() {
|
||||
if (shouldUsePricePolling()) startCartRefreshPolling()
|
||||
else stopCartRefreshPolling()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [priceWssEnabled.value, priceSocketConnected.value, priceStreamLifecycle.value] as const,
|
||||
() => {
|
||||
syncCartRefreshPolling()
|
||||
if (shouldUsePricePolling() && cartStore.itemCount > 0) void refreshCart()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
cartStore.loadCartFromStorage()
|
||||
unsubscribeProductStream = subscribeProductListStream(syncCartFromProducts)
|
||||
loading.value = true
|
||||
try {
|
||||
await initPrice()
|
||||
if (shouldUsePricePolling()) await refreshCart()
|
||||
syncCartRefreshPolling()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unsubscribeProductStream?.()
|
||||
unsubscribeProductStream = undefined
|
||||
stopCartRefreshPolling()
|
||||
stopPriceInterval()
|
||||
})
|
||||
|
||||
const context: CartPageContext = {
|
||||
cartStore,
|
||||
loading,
|
||||
infoVisible,
|
||||
showDiscountModal,
|
||||
discountCode,
|
||||
discountInfo,
|
||||
price,
|
||||
totalPrice,
|
||||
displaytotalPrice,
|
||||
discountPercentLabel,
|
||||
formatWeight,
|
||||
handleRemoveItem,
|
||||
handleCheckout,
|
||||
submitDiscount,
|
||||
removeDiscount,
|
||||
}
|
||||
|
||||
provide(CART_PAGE_CONTEXT_KEY, context)
|
||||
return context
|
||||
}
|
||||
|
||||
export function useCartPageContext() {
|
||||
const context = inject(CART_PAGE_CONTEXT_KEY)
|
||||
if (!context) throw new Error('useCartPageContext must be used within useCartPage')
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="dinner cart-page">
|
||||
<div class="dinner__container">
|
||||
<DesktopPageTopbar title="سبد خرید" show-back>
|
||||
<template #trailing>
|
||||
<div v-if="price" class="dcart-header-price number-ltr">
|
||||
<span class="dcart-header-price__label">قیمت خرید از کشکول گلد:</span>
|
||||
<strong>{{ usePriceWithoutType(price?.price_last ?? 0) }}</strong>
|
||||
<span>ریال</span>
|
||||
</div>
|
||||
</template>
|
||||
</DesktopPageTopbar>
|
||||
|
||||
<GoldLoading v-model:show="infoVisible" title="" />
|
||||
|
||||
<template v-if="loading">
|
||||
<TransactionSkeleton v-for="i in 3" :key="i" class="mt-4" />
|
||||
</template>
|
||||
|
||||
<div v-else-if="cartStore.itemCount === 0" class="dinner-surface dcart-empty">
|
||||
<p>سبد خرید شما خالی است</p>
|
||||
<BasicButton type="primary" @click="router.push({ name: 'buyProduct' })">مشاهده محصولات</BasicButton>
|
||||
</div>
|
||||
|
||||
<div v-else class="dcart-grid">
|
||||
<div class="dcart-items">
|
||||
<article v-for="item in cartStore.cartItems" :key="item.id" class="dcart-item">
|
||||
<div class="dcart-item__image">
|
||||
<ProductImage :product="item.product" img-class="max-w-full max-h-full object-contain" />
|
||||
</div>
|
||||
<div class="dcart-item__main">
|
||||
<h3 class="dcart-item__title">{{ item.product.title }}</h3>
|
||||
<p class="dcart-item__meta">
|
||||
روش پرداخت: {{ item.method === 'cash' ? 'نقدی' : 'طلایی' }}
|
||||
<span v-if="item.method === 'cash' && item.gateway"> (با درگاه)</span>
|
||||
</p>
|
||||
<p class="dcart-item__meta">
|
||||
تعداد: {{ item.quantity }} عدد
|
||||
<span v-if="item.goldVazn"> | وزن: {{ formatWeight(item.goldVazn) }}</span>
|
||||
</p>
|
||||
<div class="dcart-item__footer">
|
||||
<span class="dcart-item__price">{{ usePrice(item.totalPrice) }}</span>
|
||||
<BasicIcon
|
||||
icon="vuesax-linear:trash"
|
||||
class="text-red-500 cursor-pointer"
|
||||
@click.stop="handleRemoveItem(item.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<aside class="dinner-surface dcart-summary">
|
||||
<h2 class="dcart-summary__title">خلاصه سفارش</h2>
|
||||
|
||||
<div
|
||||
v-if="discountInfo?.amount > 0"
|
||||
class="dcart-summary__discount"
|
||||
>
|
||||
<div>
|
||||
<span>کد تخفیف: </span>
|
||||
<strong>{{ discountInfo.code }}</strong>
|
||||
<div class="text-xs text-green-600">{{ discountPercentLabel }} تخفیف اعمال شد</div>
|
||||
</div>
|
||||
<button type="button" class="dcart-summary__remove-discount" @click="removeDiscount">
|
||||
<BasicIcon icon="vuesax-linear:close-circle" size="18" />
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dcart-summary__row">
|
||||
<span>مجموع:</span>
|
||||
<div class="text-left">
|
||||
<div class="dcart-summary__total">{{ usePrice(displaytotalPrice) }}</div>
|
||||
<div v-if="discountInfo?.amount > 0" class="text-xs text-gray-400 line-through">
|
||||
{{ usePrice(totalPrice) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!discountInfo?.amount"
|
||||
type="button"
|
||||
class="dcart-summary__discount-link"
|
||||
@click="showDiscountModal = true"
|
||||
>
|
||||
کد تخفیف دارم
|
||||
</button>
|
||||
|
||||
<BasicButton class="w-full" type="primary" size="large" @click="handleCheckout">
|
||||
تسویه حساب
|
||||
</BasicButton>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import router from '@/router'
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import GoldLoading from '@/components/GoldLoading.vue'
|
||||
import ProductImage from '@/components/ProductImage.vue'
|
||||
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
|
||||
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
|
||||
import { usePrice, usePriceWithoutType } from '@/utils/usePrice'
|
||||
import { useCartPageContext } from '../composables/useCartPage'
|
||||
|
||||
const {
|
||||
cartStore,
|
||||
loading,
|
||||
infoVisible,
|
||||
showDiscountModal,
|
||||
discountInfo,
|
||||
price,
|
||||
totalPrice,
|
||||
displaytotalPrice,
|
||||
discountPercentLabel,
|
||||
formatWeight,
|
||||
handleRemoveItem,
|
||||
handleCheckout,
|
||||
removeDiscount,
|
||||
} = useCartPageContext()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dcart-header-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
|
||||
strong {
|
||||
font-size: 16px;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
.dcart-empty {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.dcart-summary__discount {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(167, 43, 119, 0.06);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dcart-summary__remove-discount {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #ef4444;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.dcart-summary__discount-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: @App-primary-color;
|
||||
font-size: 13px;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
+26
-453
@@ -1,464 +1,37 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="absolute inset-0 bg-cover bg-center opacity-60 z--1" :style="{ backgroundImage: `url(${img})` }"></div>
|
||||
<div class="bg-white drop-shadow z-[1999] wrap-fix">
|
||||
<div class="py-3 px-3 font-bold flex items-center justify-between">
|
||||
<div>سبد خرید</div>
|
||||
<!-- Price Display in Header -->
|
||||
<div v-if="price" class="flex gap-1 items-center text-xs flex-1 justify-end">
|
||||
<span class="text-gray-600 text-xs">قیمت خرید از کشکول گلد:</span>
|
||||
<div class="font-bold text-base" style="font-family: iran-sans; direction: ltr">
|
||||
{{ usePriceWithoutType(price?.price_last ?? 0) }}
|
||||
</div>
|
||||
<span class="text-xs">ریال</span>
|
||||
</div>
|
||||
<div class="cart-page-root">
|
||||
<DesktopCartView v-show="isDesktop" />
|
||||
<MobileCartView v-show="!isDesktop" />
|
||||
|
||||
<AModal
|
||||
v-model:visible="showDiscountModal"
|
||||
ok-text="اعمال کد تخفیف"
|
||||
cancel-text="انصراف"
|
||||
@ok="submitDiscount"
|
||||
@cancel="showDiscountModal = false"
|
||||
>
|
||||
<div class="p-5">
|
||||
<div>کد تخفیف</div>
|
||||
<AInput v-model:value="discountCode" />
|
||||
<div class="font-bold text-xs mt-3">*حساس به حروف بزرگ و کوچک</div>
|
||||
</div>
|
||||
</div>
|
||||
<GoldLoading v-model:show="infoVisible" title=""/>
|
||||
<div class="px-5 pt-26 pb-30" :class="loading ? 'h-[60vh]' : ''">
|
||||
<template v-if="loading">
|
||||
<TransactionSkeleton v-for="i in 3" :key="i" class="mt-4" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Empty Cart Message -->
|
||||
<div v-if="cartStore.itemCount === 0" class="flex flex-col items-center justify-center h-[60vh]">
|
||||
<div class="text-gray-500 text-lg font-semibold mb-4">سبد خرید شما خالی است</div>
|
||||
<BasicButton type="primary" @click="$router.push({ name: 'buyProduct' })">
|
||||
مشاهده محصولات
|
||||
</BasicButton>
|
||||
</div>
|
||||
|
||||
<!-- Cart Items -->
|
||||
<div v-else>
|
||||
<div class="space-y-4 mb-20">
|
||||
<div
|
||||
v-for="item in cartStore.cartItems"
|
||||
:key="item.id"
|
||||
class="bg-white rounded-lg border border-gray-200 p-4 shadow-sm"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<!-- Product Image -->
|
||||
<div class="bg-gray-100 rounded-lg flex justify-center items-center p-1 min-w-[80px] h-[80px]">
|
||||
<ProductImage
|
||||
:product="item.product"
|
||||
img-class="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Product Info -->
|
||||
<div class="flex-1 flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="text-base font-semibold mb-1">{{ item.product.title }}</div>
|
||||
<!-- <div class="text-xs text-gray-500 mb-2">-->
|
||||
<!-- {{ getProductAmount(item.product.amount) }}-->
|
||||
<!-- </div>-->
|
||||
<div class="text-xs text-gray-500 mb-2">
|
||||
روش پرداخت: {{ item.method === 'cash' ? 'نقدی' : 'طلایی' }}
|
||||
<span v-if="item.method === 'cash' && item.gateway" class="mr-2">(با درگاه)</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
تعداد: {{ item.quantity }} عدد
|
||||
<span v-if="item.goldVazn" class="mr-2">| وزن: {{ formatWeight(item.goldVazn) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<div class="text-primary font-bold">
|
||||
{{ usePrice(item.totalPrice) }}
|
||||
</div>
|
||||
<BasicIcon
|
||||
icon="vuesax-linear:trash"
|
||||
class="text-red-500 cursor-pointer"
|
||||
@click.stop="handleRemoveItem(item.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total and Checkout -->
|
||||
<div class=" flex justify-center fixed bottom-20 left-0 right-0 z-1">
|
||||
<div class="md:w-[426px] w-full bg-white border-t border-gray-200 p-4 shadow-lg">
|
||||
<div
|
||||
v-if="discountInfo?.amount > 0"
|
||||
class="mb-3 flex items-center justify-between rounded-lg bg-purple-50 px-3 py-2"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-600">کد تخفیف:</span>
|
||||
<span class="font-semibold">{{ discountInfo.code }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-green-600">
|
||||
{{ discountPercentLabel }} تخفیف اعمال شد
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 text-xs text-red-500"
|
||||
@click="removeDiscount"
|
||||
>
|
||||
<BasicIcon icon="vuesax-linear:close-circle" size="18" />
|
||||
<span>حذف</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="text-lg font-semibold">مجموع:</div>
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<div class="text-primary font-bold text-xl">
|
||||
{{ usePrice(displaytotalPrice) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="discountInfo?.amount > 0"
|
||||
class="text-xs text-gray-400 line-through"
|
||||
>
|
||||
{{ usePrice(totalPrice) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!discountInfo?.amount"
|
||||
class="mb-3 text-center"
|
||||
>
|
||||
<span
|
||||
class="text-xs text-primary underline cursor-pointer"
|
||||
@click="showDiscountModal = true"
|
||||
>
|
||||
کد تخفیف دارم
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<BasicButton
|
||||
class="w-full py-3"
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="handleCheckout"
|
||||
>
|
||||
تسویه حساب
|
||||
</BasicButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</AModal>
|
||||
</div>
|
||||
<AModal v-model:visible="showDiscountModal" ok-text="اعمال کد تخفیف" cancel-text="انصراف" @ok="submitDiscount"
|
||||
@cancel="showDiscountModal=false">
|
||||
<div class="p-5">
|
||||
<div>کد تخفیف</div>
|
||||
<AInput v-model:value="discountCode"></AInput>
|
||||
<div class="font-bold text-xs mt-3">
|
||||
*حساس به حروف بزرگ و کوچک
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</AModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, onMounted, onUnmounted, watch, computed} from 'vue'
|
||||
import {useCartStore} from '@/store/cartStore'
|
||||
import {usePrice, usePriceWithoutType} from '@/utils/usePrice'
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import {message, Modal} from 'ant-design-vue'
|
||||
import img from '@/assets/img/user_ly1.svg'
|
||||
import {
|
||||
getLastPrices,
|
||||
lastPrice,
|
||||
stopPriceInterval,
|
||||
setIntervalGetLastPrice
|
||||
} from '@/utils/price'
|
||||
import {checkoutCart} from '@/api/card'
|
||||
import router from '@/router'
|
||||
import {useWalletStore} from '@/store/walletStore'
|
||||
import {useFundUserStore} from '@/store/fundStore'
|
||||
import type {Price} from '@/models/home'
|
||||
import type {Order} from '@/models/order'
|
||||
<script lang="ts" setup>
|
||||
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||
import { useCartPage } from './composables/useCartPage'
|
||||
import DesktopCartView from './desktop/DesktopCartView.vue'
|
||||
import MobileCartView from './mobile/MobileCartView.vue'
|
||||
|
||||
import {loading, getToken} from '@/utils'
|
||||
import GoldLoading from '@/components/GoldLoading.vue'
|
||||
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
|
||||
import ProductImage from '@/components/ProductImage.vue'
|
||||
import {registerCodeDiscount} from "@/api/giftCard";
|
||||
|
||||
const cartStore = useCartStore()
|
||||
const walletStore = useWalletStore()
|
||||
const fundStore = useFundUserStore()
|
||||
const infoVisible = ref(false)
|
||||
const showDiscountModal = ref(false)
|
||||
const discountCode = ref<string>('')
|
||||
const discountInfo = ref()
|
||||
let totalPriceWithDiscount = 0
|
||||
|
||||
const price = computed((): Price => <Price>lastPrice.value ?? 0)
|
||||
|
||||
// Watch for price updates and update cart items
|
||||
watch(lastPrice, () => {
|
||||
if (lastPrice.value) {
|
||||
cartStore.recalculateCartPrices(lastPrice.value)
|
||||
}
|
||||
}, {deep: true, immediate: false})
|
||||
|
||||
// Computed total price that updates with price changes
|
||||
const totalPrice = computed(() => {
|
||||
return parseInt(cartStore.cartItems.reduce((sum, item) => sum + item.totalPrice, 0))
|
||||
})
|
||||
const displaytotalPrice = computed(() => {
|
||||
if (discountInfo.value?.amount > 0) {
|
||||
return (totalPrice.value - (totalPrice.value * discountInfo.value?.amount)).toFixed(0)
|
||||
|
||||
} else
|
||||
return totalPrice.value
|
||||
})
|
||||
|
||||
const discountPercentLabel = computed(() => {
|
||||
const amount = Number(discountInfo.value?.amount ?? 0)
|
||||
if (amount <= 0) return ''
|
||||
return `${Math.round(amount * 100)}٪`
|
||||
})
|
||||
|
||||
function removeDiscount() {
|
||||
discountInfo.value = undefined
|
||||
discountCode.value = ''
|
||||
message.success('کد تخفیف حذف شد')
|
||||
}
|
||||
|
||||
async function submitDiscount() {
|
||||
try {
|
||||
loading.value = true
|
||||
const temp = await registerCodeDiscount(discountCode.value)
|
||||
discountInfo.value = temp.giftcard
|
||||
message.success('کد تخفیف با موفقیت اعمال شد')
|
||||
showDiscountModal.value = false
|
||||
} catch (e) {
|
||||
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
function getProductAmount(amount?: number) {
|
||||
if (!amount) return ''
|
||||
if (amount < 1) {
|
||||
// کمتر از 1 گرم را به سوت تبدیل کن (مشابه ProductItem)
|
||||
const sot = amount * 1000
|
||||
// اگر نتیجه کمتر از 1 شد، حداقل 1 نمایش بده
|
||||
if (sot < 1) {
|
||||
return `1 سوت`
|
||||
}
|
||||
// در غیر این صورت، عدد را به عدد صحیح گرد کن
|
||||
return `${Math.round(sot)} سوت`
|
||||
}
|
||||
return `${amount} گرم`
|
||||
}
|
||||
|
||||
function formatWeight(weight?: number) {
|
||||
if (!weight) return ''
|
||||
if (weight < 1) {
|
||||
// کمتر از 1 گرم را به سوت تبدیل کن
|
||||
const sot = weight * 1000
|
||||
// اگر نتیجه کمتر از 1 شد، حداقل 1 نمایش بده
|
||||
if (sot < 1) {
|
||||
return `1 سوت`
|
||||
}
|
||||
// در غیر این صورت، عدد را به عدد صحیح گرد کن
|
||||
return `${Math.round(sot)} سوت`
|
||||
}
|
||||
return `${weight} گرم`
|
||||
}
|
||||
|
||||
function handleRemoveItem(itemId: string) {
|
||||
Modal.confirm({
|
||||
title: 'حذف از سبد خرید',
|
||||
content: 'آیا از حذف این آیتم از سبد خرید اطمینان دارید؟',
|
||||
okText: 'تایید',
|
||||
okType: 'danger',
|
||||
centered: true,
|
||||
cancelText: 'انصراف',
|
||||
onOk() {
|
||||
cartStore.removeFromCart(itemId)
|
||||
message.success('آیتم از سبد خرید حذف شد')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleCheckout() {
|
||||
// Check if user is logged in
|
||||
if (!getToken()) {
|
||||
message.warning('برای تسویه حساب، لطفا ابتدا وارد حساب کاربری خود شوید')
|
||||
await router.push({name: 'login'})
|
||||
return
|
||||
}
|
||||
|
||||
if (cartStore.itemCount === 0) {
|
||||
message.warning('سبد خرید شما خالی است')
|
||||
return
|
||||
}
|
||||
|
||||
if (!lastPrice.value) {
|
||||
message.error('لطفا منتظر بمانید تا قیمتها بارگذاری شوند')
|
||||
return
|
||||
}
|
||||
|
||||
// Check wallet balance for cash method items (without gateway)
|
||||
let cashItemsTotal = cartStore.cartItems
|
||||
.filter(item => item.method === 'cash' && !item.gateway)
|
||||
.reduce((sum, item) => parseInt(sum + item.totalPrice), 0)
|
||||
|
||||
if (cashItemsTotal > 0) {
|
||||
// Ensure wallet info is loaded
|
||||
if (discountInfo.value?.amount && discountInfo.value?.amount>0){
|
||||
cashItemsTotal=cashItemsTotal-cashItemsTotal*discountInfo.value?.amount
|
||||
}
|
||||
if (!walletStore.walletInfo) {
|
||||
await walletStore.getMyWallet()
|
||||
}
|
||||
|
||||
if (!walletStore.walletInfo?.cash || cashItemsTotal > walletStore.walletInfo.cash) {
|
||||
message.error('موجودی کیف پول شما ناکافی است')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check gold balance for gold method items
|
||||
const goldItemsTotal = cartStore.cartItems
|
||||
.filter(item => item.method === 'gold')
|
||||
.reduce((sum, item) => {
|
||||
// Calculate total gold weight needed
|
||||
const goldWeight = item.goldVazn ? item.goldVazn * item.quantity : 0
|
||||
return sum + goldWeight
|
||||
}, 0)
|
||||
|
||||
if (goldItemsTotal > 0) {
|
||||
// Ensure wallet info is loaded
|
||||
if (!walletStore.walletInfo) {
|
||||
await walletStore.getMyWallet()
|
||||
}
|
||||
|
||||
if (!walletStore.walletInfo?.gold || goldItemsTotal > walletStore.walletInfo.gold) {
|
||||
message.error('موجودی کیف طلا شما ناکافی است')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
infoVisible.value = true
|
||||
|
||||
// Prepare orders array with same structure as createOrder for each product
|
||||
const orders: Order[] = cartStore.cartItems.map(item => {
|
||||
console.log(item)
|
||||
const product = item.product
|
||||
// Calculate price similar to createOrder (buy price)
|
||||
let productPrice = 0
|
||||
if (product.group_type == 'آبشده') {
|
||||
productPrice = lastPrice.value?.price_last ?? 0
|
||||
} else {
|
||||
productPrice = (product.amount ?? 0) * (lastPrice.value?.price_last ?? 0)
|
||||
}
|
||||
|
||||
return {
|
||||
direction: 1, // 1 for buy (cart items are always buy orders)
|
||||
price: productPrice,
|
||||
amount: product.amount,
|
||||
gateway: item.gateway,
|
||||
product_id: product.id,
|
||||
method: item.method,
|
||||
number: item.quantity,
|
||||
discount_code:discountInfo.value?.code
|
||||
}
|
||||
})
|
||||
|
||||
// Call checkoutCart API with orders array
|
||||
const data = await checkoutCart(orders)
|
||||
|
||||
// Check for returnUrl (gateway payment)
|
||||
let returnUrl = null
|
||||
if (Array.isArray(data)) {
|
||||
const itemWithReturnUrl = data.find(item => item?.returnUrl)
|
||||
if (itemWithReturnUrl?.returnUrl) {
|
||||
returnUrl = itemWithReturnUrl.returnUrl
|
||||
}
|
||||
} else if (data?.returnUrl) {
|
||||
returnUrl = data.returnUrl
|
||||
}
|
||||
|
||||
// If returnUrl exists, redirect to gateway and stop execution
|
||||
if (returnUrl) {
|
||||
window.location.href = returnUrl
|
||||
return // Stop execution here
|
||||
}
|
||||
|
||||
// If no gateway, continue with normal flow
|
||||
await walletStore.getMyWallet()
|
||||
cartStore.clearCart()
|
||||
await router.push({name: 'transactions'})
|
||||
message.success('سفارشات شما با موفقیت ثبت شد')
|
||||
await fundStore.getMyFund()
|
||||
} catch (e: any) {
|
||||
console.error('Error in checkout:', e)
|
||||
message.error('خطا در ثبت سفارش. لطفا دوباره تلاش کنید.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
infoVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function initPrice() {
|
||||
try {
|
||||
await getLastPrices()
|
||||
await setIntervalGetLastPrice()
|
||||
if (lastPrice.value) {
|
||||
cartStore.recalculateCartPrices(lastPrice.value)
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCart() {
|
||||
const removedCount = await cartStore.refreshCartFromServer()
|
||||
if (removedCount > 0) {
|
||||
message.warning(`${removedCount} محصول از سبد خرید حذف شد (دیگر در دسترس نیست)`)
|
||||
}
|
||||
if (lastPrice.value) {
|
||||
cartStore.recalculateCartPrices(lastPrice.value)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
cartStore.loadCartFromStorage()
|
||||
loading.value = true
|
||||
try {
|
||||
await initPrice()
|
||||
await refreshCart()
|
||||
const refreshCartInfo=setInterval(async () => {
|
||||
await refreshCart()
|
||||
// await getUpPrice()
|
||||
}, 10000)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPriceInterval()
|
||||
})
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
const { showDiscountModal, discountCode, submitDiscount } = useCartPage()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-primary {
|
||||
color: #d3a233;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 426px) {
|
||||
.wrap-fix {
|
||||
width: 426px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.cart-page-root {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="absolute inset-0 bg-cover bg-center opacity-60 z--1" :style="{ backgroundImage: `url(${img})` }" />
|
||||
<div class="bg-white drop-shadow z-[1999] wrap-fix">
|
||||
<div class="py-3 px-3 font-bold flex items-center justify-between">
|
||||
<div>سبد خرید</div>
|
||||
<div v-if="price" class="flex gap-1 items-center text-xs flex-1 justify-end">
|
||||
<span class="text-gray-600 text-xs">قیمت خرید از کشکول گلد:</span>
|
||||
<div class="font-bold text-base" style="font-family: iran-sans; direction: ltr">
|
||||
{{ usePriceWithoutType(price?.price_last ?? 0) }}
|
||||
</div>
|
||||
<span class="text-xs">ریال</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GoldLoading v-model:show="infoVisible" title="" />
|
||||
<div class="px-5 pt-26 pb-30" :class="loading ? 'h-[60vh]' : ''">
|
||||
<template v-if="loading">
|
||||
<TransactionSkeleton v-for="i in 3" :key="i" class="mt-4" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="cartStore.itemCount === 0" class="flex flex-col items-center justify-center h-[60vh]">
|
||||
<div class="text-gray-500 text-lg font-semibold mb-4">سبد خرید شما خالی است</div>
|
||||
<BasicButton type="primary" @click="router.push({ name: 'buyProduct' })">مشاهده محصولات</BasicButton>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="space-y-4 mb-20">
|
||||
<div
|
||||
v-for="item in cartStore.cartItems"
|
||||
:key="item.id"
|
||||
class="bg-white rounded-lg border border-gray-200 p-4 shadow-sm"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="bg-gray-100 rounded-lg flex justify-center items-center p-1 min-w-[80px] h-[80px]">
|
||||
<ProductImage :product="item.product" img-class="max-w-full max-h-full object-contain" />
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="text-base font-semibold mb-1">{{ item.product.title }}</div>
|
||||
<div class="text-xs text-gray-500 mb-2">
|
||||
روش پرداخت: {{ item.method === 'cash' ? 'نقدی' : 'طلایی' }}
|
||||
<span v-if="item.method === 'cash' && item.gateway" class="mr-2">(با درگاه)</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
تعداد: {{ item.quantity }} عدد
|
||||
<span v-if="item.goldVazn" class="mr-2">| وزن: {{ formatWeight(item.goldVazn) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<div class="text-primary font-bold">{{ usePrice(item.totalPrice) }}</div>
|
||||
<BasicIcon
|
||||
icon="vuesax-linear:trash"
|
||||
class="text-red-500 cursor-pointer"
|
||||
@click.stop="handleRemoveItem(item.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center fixed bottom-20 left-0 right-0 z-1">
|
||||
<div class="md:w-[426px] w-full bg-white border-t border-gray-200 p-4 shadow-lg">
|
||||
<div
|
||||
v-if="discountInfo?.amount > 0"
|
||||
class="mb-3 flex items-center justify-between rounded-lg bg-purple-50 px-3 py-2"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-600">کد تخفیف:</span>
|
||||
<span class="font-semibold">{{ discountInfo.code }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-green-600">{{ discountPercentLabel }} تخفیف اعمال شد</div>
|
||||
</div>
|
||||
<button type="button" class="flex items-center gap-1 text-xs text-red-500" @click="removeDiscount">
|
||||
<BasicIcon icon="vuesax-linear:close-circle" size="18" />
|
||||
<span>حذف</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="text-lg font-semibold">مجموع:</div>
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<div class="text-primary font-bold text-xl">{{ usePrice(displaytotalPrice) }}</div>
|
||||
<div v-if="discountInfo?.amount > 0" class="text-xs text-gray-400 line-through">
|
||||
{{ usePrice(totalPrice) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!discountInfo?.amount" class="mb-3 text-center">
|
||||
<span class="text-xs text-primary underline cursor-pointer" @click="showDiscountModal = true">
|
||||
کد تخفیف دارم
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<BasicButton class="w-full py-3" type="primary" size="large" @click="handleCheckout">
|
||||
تسویه حساب
|
||||
</BasicButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import router from '@/router'
|
||||
import img from '@/assets/img/user_ly1.svg'
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import GoldLoading from '@/components/GoldLoading.vue'
|
||||
import ProductImage from '@/components/ProductImage.vue'
|
||||
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
|
||||
import { usePrice, usePriceWithoutType } from '@/utils/usePrice'
|
||||
import { useCartPageContext } from '../composables/useCartPage'
|
||||
|
||||
const {
|
||||
cartStore,
|
||||
loading,
|
||||
infoVisible,
|
||||
showDiscountModal,
|
||||
discountInfo,
|
||||
price,
|
||||
totalPrice,
|
||||
displaytotalPrice,
|
||||
discountPercentLabel,
|
||||
formatWeight,
|
||||
handleRemoveItem,
|
||||
handleCheckout,
|
||||
removeDiscount,
|
||||
} = useCartPageContext()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-primary {
|
||||
color: #d3a233;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
import { inject, onMounted, provide, ref, type InjectionKey } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { getEarnMoney } from '@/api/earnMoney'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { loading } from '@/utils'
|
||||
import type { EarnMoney } from '@/models/earnMoney'
|
||||
|
||||
export interface EarnMoneyPageContext {
|
||||
data: ReturnType<typeof ref<EarnMoney | undefined>>
|
||||
userStore: ReturnType<typeof useUserStore>
|
||||
isTransaction: ReturnType<typeof ref<boolean>>
|
||||
setIsTransaction: (value: boolean) => void
|
||||
copyCode: () => void
|
||||
shareCode: () => void
|
||||
}
|
||||
|
||||
export const EARN_MONEY_PAGE_CONTEXT_KEY: InjectionKey<EarnMoneyPageContext> = Symbol('earnMoneyPageContext')
|
||||
|
||||
export function useEarnMoneyPage() {
|
||||
const data = ref<EarnMoney>()
|
||||
const userStore = useUserStore()
|
||||
const isTransaction = ref(false)
|
||||
|
||||
function setIsTransaction(value: boolean) {
|
||||
isTransaction.value = value
|
||||
}
|
||||
|
||||
function copyCode() {
|
||||
const val = userStore.userInfo?.referral_id
|
||||
if (val) {
|
||||
navigator.clipboard.writeText(val)
|
||||
message.info('کد رونوشت شد')
|
||||
}
|
||||
}
|
||||
|
||||
function shareCode() {
|
||||
const val = `⭐️الان طلا بخر؛ آخر ماه سودشو ببر!
|
||||
با خرید طلای آبشده در مقدارهای کم ولی مستمر، سود بیشتری ببر!
|
||||
|
||||
این دعوت نامه از طرف "${userStore.userInfo?.name}" عزیز برای شما ارسال شده تا شما هم از بازار طلای کشکول گلد جا نمونید.
|
||||
روی لینک زیر کلیک کنید و ثبت نام خود را تکمیل کنید.
|
||||
|
||||
kh.kashkolgold.ir/login?id=${userStore.userInfo?.referral_id}`
|
||||
navigator.share({ title: 'کشکول گلد', text: val })
|
||||
}
|
||||
|
||||
async function earnMoney() {
|
||||
try {
|
||||
loading.value = true
|
||||
data.value = await getEarnMoney()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void earnMoney()
|
||||
})
|
||||
|
||||
const context: EarnMoneyPageContext = {
|
||||
data,
|
||||
userStore,
|
||||
isTransaction,
|
||||
setIsTransaction,
|
||||
copyCode,
|
||||
shareCode,
|
||||
}
|
||||
|
||||
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 useEarnMoneyPage')
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="dinner earn-money-page">
|
||||
<div class="dinner__container">
|
||||
<DesktopPageTopbar title="کسب درآمد" show-back />
|
||||
|
||||
<section class="dinner-surface p-5 mb-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="bg-[#DBEAFE] h-10 w-10 rounded-full flex justify-center items-center flex-shrink-0">
|
||||
<img :src="coin" alt="" class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h2 class="font-bold mb-2">دعوت دوستان</h2>
|
||||
<p class="text-sm text-gray-600 leading-6">
|
||||
با دعوت دوستانتان به اپلیکیشن کشکول گلد، دوست شما اولین خرید خود را بدون کارمزد انجام میدهد
|
||||
و شما نیز ۱۰٪ از کارمزد ۱۰ معاملهی اول او را بهعنوان پاداش دریافت میکنید.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-2 mt-4">
|
||||
<span class="text-sm text-gray-500">کد بازاریابی</span>
|
||||
<strong class="text-lg tracking-wide">{{ userStore.userInfo?.referral_id }}</strong>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 mt-4 max-w-md mx-auto">
|
||||
<BasicButton icon="vuesax-linear:share" size="small" icon-size="18" @click="shareCode">اشتراک گذاری</BasicButton>
|
||||
<BasicButton icon="vuesax-linear:copy" size="small" icon-size="18" @click="copyCode">رونوشت</BasicButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="dearn-stats">
|
||||
<div class="dearn-stat dearn-stat--blue">
|
||||
<div class="dearn-stat__value">{{ data?.usersSum }} عدد</div>
|
||||
<div class="dearn-stat__label">تعداد زیر مجموعه</div>
|
||||
</div>
|
||||
<div class="dearn-stat dearn-stat--yellow">
|
||||
<div class="dearn-stat__value">{{ data?.giftsSum }} سوت</div>
|
||||
<div class="dearn-stat__label">میزان کل پاداش ها</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dinner-surface p-2 mb-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="dearn-tab"
|
||||
:class="{ 'dearn-tab--active': isTransaction }"
|
||||
@click="setIsTransaction(true)"
|
||||
>
|
||||
پاداش ها
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dearn-tab"
|
||||
:class="{ 'dearn-tab--active': !isTransaction }"
|
||||
@click="setIsTransaction(false)"
|
||||
>
|
||||
فهرست زیر مجموعه
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dearn-list">
|
||||
<template v-if="isTransaction">
|
||||
<GiftCard v-for="(val, i) in data?.gifts" :key="`gift-${i}`" :item="val" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<EarnCard v-for="(val, i) in data?.users" :key="`user-${i}`" :item="val" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import coin from '@/assets/img/coin.png'
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.dearn-tab {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
|
||||
&--active {
|
||||
background: #fff;
|
||||
color: @App-primary-color;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,163 +1,23 @@
|
||||
<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 class="earn-money-page-root">
|
||||
<DesktopEarnMoneyView v-show="isDesktop" />
|
||||
<MobileEarnMoneyView v-show="!isDesktop" />
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class=" p-3 border-solid border-1 rounded-[8px] border-[#a72b77] ">
|
||||
<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-2">
|
||||
<div class="flex">
|
||||
<div class="font-bold">دعوت دوستان</div>
|
||||
<!-- <div class="text-xs ">راهنمای کسب درآمد</div>-->
|
||||
</div>
|
||||
<div class=" mt-2 text-xs">با دعوت دوستانتان به اپلیکیشن کشکول گلد،
|
||||
دوست شما اولین خرید خود را بدون کارمزد انجام میدهد
|
||||
و شما نیز ۱۰٪ از کارمزد ۱۰ معاملهی اول او را بهعنوان پاداش دریافت میکنید. 💰</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-[#a72b77] p-2 rounded-[8px] w-full">-->
|
||||
<!-- <div class="text-white text-center">فهرست زیرمجموعه</div>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="bg-[#a72b77] 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 class="h-20rem overflow-y-scroll" v-if="isTransaction">
|
||||
<GiftCard v-for="(val,i) in data?.gifts" :key="i" :item="val"/>
|
||||
</div>
|
||||
<div v-else class="h-20rem overflow-y-scroll">
|
||||
<EarnCard v-for="(val,i) in data?.users" :key="i" :item="val"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<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'
|
||||
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'
|
||||
|
||||
const data = ref<EarnMoney>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
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() {
|
||||
// nextTick(() => {
|
||||
const val = `⭐️الان طلا بخر؛ آخر ماه سودشو ببر!
|
||||
با خرید طلای آبشده در مقدارهای کم ولی مستمر، سود بیشتری ببر!
|
||||
|
||||
این دعوت نامه از طرف "${userStore.userInfo?.name}" عزیز برای شما ارسال شده تا شما هم از بازار طلای کشکول گلد جا نمونید.
|
||||
روی لینک زیر کلیک کنید و ثبت نام خود را تکمیل کنید.
|
||||
|
||||
kh.kashkolgold.ir/login?id=${userStore.userInfo?.referral_id}`
|
||||
navigator.share({
|
||||
title: 'کشکول گلد',
|
||||
text: val
|
||||
})
|
||||
// message.info('کد رونوشت شد')
|
||||
// })
|
||||
}
|
||||
|
||||
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);'
|
||||
}
|
||||
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
useEarnMoneyPage()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
.earn-money-page-root {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<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 class="p-4">
|
||||
<div class="p-3 border-solid border-1 rounded-[8px] border-[#a72b77]">
|
||||
<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-2">
|
||||
<div class="font-bold">دعوت دوستان</div>
|
||||
<div class="mt-2 text-xs">
|
||||
با دعوت دوستانتان به اپلیکیشن کشکول گلد، دوست شما اولین خرید خود را بدون کارمزد انجام میدهد
|
||||
و شما نیز ۱۰٪ از کارمزد ۱۰ معاملهی اول او را بهعنوان پاداش دریافت میکنید. 💰
|
||||
</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="shareCode">
|
||||
اشتراک گذاری
|
||||
</BasicButton>
|
||||
<BasicButton icon="vuesax-linear:copy" size="small" class="flex-1" icon-size="18" @click="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="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="setIsTransaction(false)"
|
||||
>
|
||||
فهرست زیر مجموعه
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-solid border-1 border-[#E5E7EB] mt-6" />
|
||||
<div v-if="isTransaction" class="h-20rem overflow-y-scroll">
|
||||
<GiftCard v-for="(val, i) in data?.gifts" :key="i" :item="val" />
|
||||
</div>
|
||||
<div v-else class="h-20rem overflow-y-scroll">
|
||||
<EarnCard v-for="(val, i) in data?.users" :key="i" :item="val" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import router from '@/router'
|
||||
import coin from '@/assets/img/coin.png'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
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>
|
||||
@@ -1,69 +1,61 @@
|
||||
<template>
|
||||
<div v-if="item" class="bg-white flex flex-col items-center p-3 rounded-xl">
|
||||
<img v-lazy="{ src: itemImg, loading: logo,error: logo}" alt="" class="w-[87px] ">
|
||||
<div class="mt-3">{{props.item?.title?? '-'}}</div>
|
||||
<div class="mt-1 flex gap-1"><div class="font-bold">{{props.item?.wallet[0]?.gold?? '-'}}</div> گرم</div>
|
||||
<!-- {{props.item?.wallet[0]?.cash}}-->
|
||||
<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">
|
||||
<div class="mt-3 text-center line-clamp-2">{{ item.title ?? '-' }}</div>
|
||||
<div class="mt-1 flex flex-col items-center gap-0.5 text-center">
|
||||
<div class="flex gap-1">
|
||||
<div class="font-bold">{{ item.quantity ?? '-' }}</div>
|
||||
{{ item.unit }}
|
||||
</div>
|
||||
<div v-if="item.unit_weight && item.display_type === 'count'" class="text-xs text-gray-500">
|
||||
هر عدد {{ item.unit_weight }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 {computed} from "vue";
|
||||
import type {Fund} from "@/models/fund";
|
||||
import logo from "@/assets/img/logo_full_.png";
|
||||
/*function itemImg() {
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { WalletInventoryCard } from '@/models/walletAssets'
|
||||
import { getProductDisplayImage } from '@/utils/productAssets'
|
||||
|
||||
}*/
|
||||
interface Prop {
|
||||
item: Fund
|
||||
item: WalletInventoryCard
|
||||
variant?: 'mobile' | 'desktop'
|
||||
}
|
||||
|
||||
const props = defineProps<Prop>()
|
||||
|
||||
const itemImg = computed(() => {
|
||||
switch (props.item?.title) {
|
||||
case 'خرید لوازم منزل':
|
||||
return homeImg
|
||||
case 'هدیه':
|
||||
return giftImg
|
||||
case 'گردش':
|
||||
return circulationImg
|
||||
case 'ازدواج':
|
||||
return loveImg
|
||||
case 'تعمیرات':
|
||||
return repairsImg
|
||||
case 'پس انداز':
|
||||
return savingImg
|
||||
default: return savingImg
|
||||
}
|
||||
const props = withDefaults(defineProps<Prop>(), {
|
||||
variant: 'mobile',
|
||||
})
|
||||
const imageSrc = ref('')
|
||||
|
||||
/*const itemName = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'home':
|
||||
return 'خرید لوازم منزل'
|
||||
case 'gift':
|
||||
return 'هدیه'
|
||||
case 'circulation':
|
||||
return 'گردش'
|
||||
case 'love':
|
||||
return 'ازدواج'
|
||||
case 'repairs':
|
||||
return 'تعمیرات'
|
||||
case 'saving':
|
||||
return 'پس انداز'
|
||||
default: return null
|
||||
}
|
||||
})*/
|
||||
const displayImage = computed(() => imageSrc.value || resolveImage())
|
||||
|
||||
watch(
|
||||
() => props.item,
|
||||
() => {
|
||||
imageSrc.value = resolveImage()
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
function resolveImage() {
|
||||
return getProductDisplayImage({
|
||||
title: props.item.title,
|
||||
group_type: props.item.group_type,
|
||||
file: props.item.file,
|
||||
})
|
||||
}
|
||||
|
||||
function onImageError() {
|
||||
imageSrc.value = getProductDisplayImage({
|
||||
title: props.item.title,
|
||||
group_type: props.item.group_type,
|
||||
file: null,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
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 { useWalletAssets } from '@/composables/useWalletAssets'
|
||||
import type { WalletMetalTotal } from '@/models/walletAssets'
|
||||
import { fundPageInventoryCards, formatMetalTotal as buildMetalTotalLabel } from '@/utils/productAssets'
|
||||
import { getLastPrices } from '@/utils/price'
|
||||
import img from '@/assets/img/cash_img.svg'
|
||||
import empty from '@/assets/img/cash_empty.svg'
|
||||
|
||||
export interface FundPageContext {
|
||||
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
|
||||
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 { assetsSummary, loadWalletAssets } = useWalletAssets()
|
||||
|
||||
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 buildMetalTotalLabel(metal, inventoryCards.value)
|
||||
}
|
||||
|
||||
function openFundInfo(productId: number) {
|
||||
const id = Number(productId)
|
||||
if (!Number.isFinite(id) || id <= 0) return
|
||||
router.push({ name: 'fund_info', params: { id: String(id) } })
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
await Promise.all([loadWalletAssets(true), getLastPrices()])
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const context: FundPageContext = {
|
||||
meta,
|
||||
inventoryCards,
|
||||
metalTotals,
|
||||
primaryMetalTotal,
|
||||
cashWallet,
|
||||
totalRialValue,
|
||||
countBasedInventory,
|
||||
fundHeaderBackground: img,
|
||||
fundEmptyImage: empty,
|
||||
fundEmptyText: 'هنوز محصولی خریداری نکردهاید',
|
||||
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,114 @@
|
||||
<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 dfund-summary--ghatreh">
|
||||
<img :src="fundHeaderBackground" alt="" class="dfund-summary__bg" />
|
||||
<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="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" 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>{{ 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 Item from '../components/Item.vue'
|
||||
import { useFundPageContext } from '../composables/useFundPage'
|
||||
|
||||
const {
|
||||
meta,
|
||||
inventoryCards,
|
||||
metalTotals,
|
||||
primaryMetalTotal,
|
||||
cashWallet,
|
||||
totalRialValue,
|
||||
countBasedInventory,
|
||||
fundHeaderBackground,
|
||||
fundEmptyImage,
|
||||
fundEmptyText,
|
||||
formatMetalTotal,
|
||||
openFundInfo,
|
||||
goBack,
|
||||
} = useFundPageContext()
|
||||
</script>
|
||||
|
||||
<style lang="less" src="./styles/desktop-fund.less"></style>
|
||||
@@ -0,0 +1,506 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
&--ghatreh {
|
||||
background: #efced9;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
&__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;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.dfund-info__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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%;
|
||||
}
|
||||
+14
-113
@@ -1,122 +1,23 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="bg-white w-full py-3 px-3 text-black font-bold drop-shadow fixed md:absolute top-0 flex h-12 z-20">
|
||||
<div @click="router.back()">
|
||||
<BasicIcon icon="vuesax-linear:arrow-right-1"/>
|
||||
</div>
|
||||
<div class="mr-2">قلک</div>
|
||||
</div>
|
||||
<div class="bg-[#EFCED9] w-screen md:w-full h-[70vh] fixed md:absolute">
|
||||
<div class="pt-15 flex flex-col">
|
||||
<div class="text-gray-500 font-bold mr-5 z-20">موجودی قلک ها</div>
|
||||
<div class="flex items-baseline justify-center mt-2 w-full z-20">
|
||||
<div class="text-3xl font-bold">{{ inventory }}</div>
|
||||
/گرم
|
||||
</div>
|
||||
<img :src="img" alt="" class="mt-[-4rem] mr-10 max-w-[22rem] self-end">
|
||||
</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="items.length === 0 && !loading" class="w-full h-full flex justify-center items-center">
|
||||
<div>
|
||||
<img :src="empty" alt="" class="w-70 ">
|
||||
<div class="text-center text-lg">یک قلک جدید بساز!</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 items" :item="item"
|
||||
@click="router.push({name: 'fund_info', params:{id:item.id}})"/>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed w-full flex justify-center bottom-23 z-200 md:right-0">
|
||||
<div class="rounded-3xl bg-primary text-white px-5 py-2" @click="router.push({name: 'fund_add'})">افزودن قلک
|
||||
</div>
|
||||
</div>
|
||||
<div class="fund-page-root">
|
||||
<DesktopFundView v-show="isDesktop" />
|
||||
<MobileFundView v-show="!isDesktop" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||
import { useFundPage } from './composables/useFundPage'
|
||||
import DesktopFundView from './desktop/DesktopFundView.vue'
|
||||
import MobileFundView from './mobile/MobileFundView.vue'
|
||||
|
||||
import router from "@/router";
|
||||
import BasicIcon from "@/components/BasicPacks/BasicIcon.vue";
|
||||
import img from '@/assets/img/cash_img.svg'
|
||||
import empty from '@/assets/img/cash_empty.svg'
|
||||
import Item from './components/Item.vue'
|
||||
import {onMounted, ref} from "vue";
|
||||
import {loading} from "@/utils";
|
||||
import type {TableOutput} from "@/models/table";
|
||||
import type {Fund} from "@/models/fund";
|
||||
import {useFundUserStore} from "@/store/fundStore";
|
||||
|
||||
const items = ref<Fund[]>([])
|
||||
const tableOutput = ref<TableOutput<Fund> | null>(null)
|
||||
const inventory = ref<number>(0)
|
||||
const fundStore = useFundUserStore()
|
||||
|
||||
onMounted(() => {
|
||||
initTable()
|
||||
})
|
||||
|
||||
async function initTable() {
|
||||
try {
|
||||
loading.value = true
|
||||
tableOutput.value = fundStore.myFunds ?? await fundStore.getMyFund() ?? null
|
||||
// console.log(tableOutput.value)
|
||||
items.value.push(...tableOutput.value?.rows ?? [])
|
||||
items.value.forEach(x => {
|
||||
// console.log(x.wallet[0]?.gold)
|
||||
inventory.value += x?.wallet[0]?.gold ?? 0
|
||||
// console.log(inventory.value)
|
||||
})
|
||||
// console.log(tableOutput.value)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/*watch(()=> router.currentRoute.value.name, () =>{
|
||||
console.log('salammmmmmmmmmmmmmmmmmmmmmmmmmm')
|
||||
})*/
|
||||
/*
|
||||
const items = [
|
||||
{
|
||||
type: 'home',
|
||||
amount: 2.7
|
||||
},
|
||||
{
|
||||
type: 'gift',
|
||||
amount: 13.1
|
||||
},
|
||||
{
|
||||
type: 'saving',
|
||||
amount: 6.4
|
||||
},
|
||||
{
|
||||
type: 'repairs',
|
||||
amount: 16.6
|
||||
},
|
||||
{
|
||||
type: 'circulation',
|
||||
amount: 5.4
|
||||
},
|
||||
{
|
||||
type: 'love',
|
||||
amount: 3.7
|
||||
},
|
||||
]
|
||||
*/
|
||||
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
useFundPage(isDesktop)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
.fund-page-root {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,69 +1,63 @@
|
||||
<template>
|
||||
|
||||
<div @click="infoVisible = true">
|
||||
<div class="flex">
|
||||
<div :class="isUp? 'bg-green-50':'bg-red-50'"
|
||||
class="rounded-lg p-3 h-fit w-fit flex justify-center items-center ">
|
||||
<Icon :class="isUp? 'text-green-600':'text-red-600'"
|
||||
:icon="`vuesax-linear:${isUp? 'arrow-up-2':'arrow-down-2'}`"/>
|
||||
<div
|
||||
:class="isUp ? 'bg-green-50' : 'bg-red-50'"
|
||||
class="rounded-lg p-3 h-fit w-fit flex justify-center items-center"
|
||||
>
|
||||
<Icon
|
||||
:class="isUp ? 'text-green-600' : 'text-red-600'"
|
||||
:icon="`vuesax-linear:${isUp ? 'arrow-up-2' : 'arrow-down-2'}`"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full mr-3">
|
||||
<div class="flex justify-between w-full">
|
||||
<div>{{ item.type_str }}</div>
|
||||
<div>{{ item.title }}</div>
|
||||
<div>{{ toJalaliNotime(item.expire_at) }}</div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-2 ">
|
||||
<div :class="item.gold >= 0? 'text-green-600':'text-red-600'" class="text-gray-500 text-xs number-ltr">
|
||||
</div>
|
||||
<div class="flex justify-between mt-2">
|
||||
<div
|
||||
:class="item.gold >= 0 ? 'text-green-600' : 'text-red-600'"
|
||||
class="text-gray-500 text-xs number-ltr"
|
||||
/>
|
||||
<div class="text-gray-500 flex gap-1 text-xs items-center">
|
||||
<div :class="item.gold > 0 ? 'text-green-600':'text-red-600'" class="text-base number-ltr">
|
||||
{{ item.gold }}
|
||||
<div
|
||||
:class="item.gold > 0 ? 'text-green-600' : 'text-red-600'"
|
||||
class="text-base number-ltr"
|
||||
>
|
||||
{{ formatGoldAmount(item.gold) }}
|
||||
</div>
|
||||
گرم
|
||||
{{ item.unit }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div v-if="isGold" class="flex w-full justify-between mt-2">
|
||||
<div class="text-gray-500">قیمت معامله</div>
|
||||
<div class="text-gray-500 flex gap-1 text-xs items-center">
|
||||
<div class="text-base">{{ usePriceWithoutType(Math.abs(item.cash) / Math.abs(item.gold)) }}</div>
|
||||
ریال
|
||||
</div>
|
||||
</div>-->
|
||||
<!-- <div v-else class="h-8"></div>-->
|
||||
<a-divider class="!my-3"/>
|
||||
<a-divider class="!my-3" />
|
||||
<BasicActionSheet v-model:show="infoVisible">
|
||||
<div class="font-bold text-lg">{{ props.item.type_str }}</div>
|
||||
<div class="font-bold text-lg">{{ props.item.title }}</div>
|
||||
<div class="pb-20 mt-5">
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="text-black">مقدار طلا مبادله شده:</div>
|
||||
<div :class="item.gold >= 0? 'text-green-600' : 'text-red-600'" class="flex text-xs items-center">
|
||||
<div class="text-base ml-1 number-ltr">{{ item.gold }}</div>
|
||||
گرم
|
||||
<div class="text-black">مقدار {{ props.title }} مبادله شده:</div>
|
||||
<div
|
||||
:class="item.gold >= 0 ? 'text-green-600' : 'text-red-600'"
|
||||
class="flex text-xs items-center"
|
||||
>
|
||||
<div class="text-base ml-1 number-ltr">{{ formatGoldAmount(item.gold) }}</div>
|
||||
{{ item.unit }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="text-black">موجودی کیف طلا:</div>
|
||||
<div class="text-black">موجودی {{ props.title }}:</div>
|
||||
<div class="flex text-xs text-yellow-700 items-center">
|
||||
<div class="text-base ml-1">{{ item.balance_gold }}</div>
|
||||
گرم
|
||||
<div class="text-base ml-1">{{ formatGoldAmount(item.balance_gold) }}</div>
|
||||
{{ item.unit }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="flex justify-between mb-3">
|
||||
<div class="text-black">موجودی کیف پول:</div>
|
||||
<div class="flex text-xs items-center" :class="isUp? 'text-green-600' : 'text-red-600'"><div class="text-base">{{usePriceWithoutType(12000000)}}</div>ریال</div>
|
||||
</div>
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="text-black">موجودی کیف طلا:</div>
|
||||
<div class="flex items-center" :class="isUp? 'text-red-600' : 'text-green-600'">60 گرم</div>
|
||||
</div>-->
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="text-black">وضعیت:</div>
|
||||
<div class="flex text-xs items-center">
|
||||
<ATag :color="item.active? 'blue': 'red'" class="!py-1 !px-6 !ml-0">{{
|
||||
item.active ? 'موفق' : 'نا موفق'
|
||||
}}
|
||||
<ATag :color="item.active ? 'blue' : 'red'" class="!py-1 !px-6 !ml-0">
|
||||
{{ item.active ? 'موفق' : 'نا موفق' }}
|
||||
</ATag>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,43 +73,24 @@
|
||||
</div>
|
||||
</BasicActionSheet>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Icon from "@/components/BasicPacks/BasicIcon.vue";
|
||||
import BasicActionSheet from "@/components/BasicPacks/BasicActionSheet.vue";
|
||||
import {computed, ref} from "vue";
|
||||
import BasicButton from "@/components/BasicPacks/BasicButton.vue";
|
||||
import type {Transaction} from "@/models/transaction";
|
||||
import {toJalaliNotime, toTime} from "../../../../../utils/useDateTime";
|
||||
import Icon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
import type { Transaction } from '@/models/transaction'
|
||||
import { formatGoldAmount } from '@/utils/usePrice'
|
||||
import { toJalaliNotime, toTime } from '@/utils/useDateTime'
|
||||
|
||||
const infoVisible = ref<boolean>(false)
|
||||
|
||||
interface Prop {
|
||||
item: Transaction
|
||||
title?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Prop>(), {})
|
||||
const isUp = computed(() => props.item.type === 3 || props.item.type === 1)
|
||||
const isGold = computed(() => props.item.type === 1 || props.item.type === 2)
|
||||
/*const title = computed(() => {
|
||||
if (isUp.value) {
|
||||
if (isGold.value) {
|
||||
return 'واریز ریالی'
|
||||
} else {
|
||||
return 'خرید طلا'
|
||||
}
|
||||
} else {
|
||||
if (isGold.value) {
|
||||
return 'برداشت ریالی'
|
||||
} else {
|
||||
return 'فروش طلا'
|
||||
}
|
||||
}
|
||||
})*/
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
import { computed, inject, provide, ref, watch, type InjectionKey } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import router from '@/router'
|
||||
import { loading } from '@/utils'
|
||||
import { getFundTransactionTable } from '@/api/fund'
|
||||
import { getOneProductWallet } from '@/api/products'
|
||||
import type { Product } from '@/models/products'
|
||||
import type { TableInput, TableOutput } from '@/models/table'
|
||||
import type { Transaction } from '@/models/transaction'
|
||||
|
||||
export interface FundInfoPageContext {
|
||||
info: ReturnType<typeof ref<Product | undefined>>
|
||||
items: ReturnType<typeof ref<Transaction[]>>
|
||||
infoId: ReturnType<typeof computed<number | null>>
|
||||
goBack: () => void
|
||||
}
|
||||
|
||||
export const FUND_INFO_PAGE_KEY: InjectionKey<FundInfoPageContext> = Symbol('fundInfoPage')
|
||||
|
||||
function parseRouteProductId(raw: string | string[] | undefined): number | null {
|
||||
const value = Array.isArray(raw) ? raw[0] : raw
|
||||
if (value == null || value === '') return null
|
||||
const id = Number.parseInt(String(value), 10)
|
||||
return Number.isFinite(id) && id > 0 ? id : null
|
||||
}
|
||||
|
||||
export function useFundInfoPage() {
|
||||
const route = useRoute()
|
||||
const info = ref<Product>()
|
||||
const items = ref<Transaction[]>([])
|
||||
const tableOutput = ref<TableOutput<Transaction> | null>(null)
|
||||
const pagination = ref<TableInput>({})
|
||||
|
||||
const infoId = computed(() => parseRouteProductId(route.params.id))
|
||||
|
||||
async function initTable() {
|
||||
if (!info.value?.wallet?.id) return
|
||||
|
||||
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 initInfo() {
|
||||
const id = infoId.value
|
||||
if (id == null) {
|
||||
console.error('Invalid fund product id in route')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
info.value = await getOneProductWallet(id)
|
||||
await initTable()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(infoId, (id) => {
|
||||
if (id != null) void initInfo()
|
||||
}, { immediate: true })
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const context: FundInfoPageContext = {
|
||||
info,
|
||||
items,
|
||||
infoId,
|
||||
goBack,
|
||||
}
|
||||
|
||||
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,61 @@
|
||||
<template>
|
||||
<div class="dfund-info">
|
||||
<div class="dfund-info__container">
|
||||
<DesktopPageTopbar
|
||||
eyebrow="جزئیات دارایی"
|
||||
:title="info?.title ?? '—'"
|
||||
show-back
|
||||
sticky
|
||||
/>
|
||||
|
||||
<div class="dfund-info__layout">
|
||||
<section class="dfund-info__hero dfund-surface">
|
||||
<a-spin :spinning="loading">
|
||||
<div class="dfund-info__image">
|
||||
<ProductImage
|
||||
v-if="info"
|
||||
:product="info"
|
||||
img-class="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</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 DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
|
||||
import ProductImage from '@/components/ProductImage.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 { useFundInfoPageContext } from '../composables/useFundInfoPage'
|
||||
|
||||
const { info, items } = useFundInfoPageContext()
|
||||
</script>
|
||||
|
||||
<style lang="less" src="../../desktop/styles/desktop-fund.less"></style>
|
||||
@@ -1,291 +1,23 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="bg-white wrap-fix py-3 px-3 text-black font-bold drop-shadow flex h-12 z-20 justify-between">
|
||||
<div class="flex ">
|
||||
<div @click="router.back()" class="cursor-pointer">
|
||||
<BasicIcon icon="vuesax-linear:arrow-right-1" />
|
||||
</div>
|
||||
<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="">
|
||||
</div>
|
||||
</a-spin>
|
||||
<div class="mt-2 font-bold flex text-xs items-center text-gray-500">
|
||||
<div class="ml-1 text-base text-black">{{ info?.wallet[0]?.gold }}</div>
|
||||
گرم
|
||||
</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" />
|
||||
</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>موجودی کیف طلا: {{ walletStore?.walletInfo?.gold }} گرم</div>
|
||||
<div v-if="info?.wallet[0]?.gold !== 0">
|
||||
{{ `موجودی قابل برداشت قلک: ${info?.wallet[0]?.gold ?? 0} گرم` }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-2 text-xs font-bold">موجودی قلک {{ info.title }}: {{ info?.wallet[0]?.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 v-model:show="editShow">
|
||||
<div class="font-bold text-lg">قلک {{ info?.title }}</div>
|
||||
|
||||
<BasicButton class="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="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>
|
||||
<DialogVant v-model:show="showDeleteConfirm" :closeOnClickOverlay="true" :timeOut="-1">
|
||||
<!-- <template v-slot:img>
|
||||
<img :src="registerDelayImage" alt="" class="opacity-80">
|
||||
</template>-->
|
||||
|
||||
<template v-slot:title>
|
||||
<div class="p-5 text-sm">
|
||||
آیا از حذف قلک خود اطمینان دارید؟
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:button>
|
||||
<div class="text-center text-red" @click="deleteFundFunction">
|
||||
حذف
|
||||
</div>
|
||||
<div class="text-center" @click="showDeleteConfirm = false">
|
||||
انصراف
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</DialogVant>
|
||||
<div class="fund-info-page-root">
|
||||
<DesktopFundInfoView v-show="isDesktop" />
|
||||
<MobileFundInfoView v-show="!isDesktop" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||
import { useFundInfoPage } from './composables/useFundInfoPage'
|
||||
import DesktopFundInfoView from './desktop/DesktopFundInfoView.vue'
|
||||
import MobileFundInfoView from './mobile/MobileFundInfoView.vue'
|
||||
|
||||
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 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 { useFundUserStore } from '@/store/fundStore'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
|
||||
const info = ref<Fund>()
|
||||
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[0] === undefined || info.value?.wallet[0]?.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[0]?.gold
|
||||
}
|
||||
// return isDeposit.value ? userFormState.value > walletStore?.walletInfo?.gold : userFormState.value > info.value?.wallet[0]?.gold
|
||||
} else return true
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function initInfo() {
|
||||
try {
|
||||
loading.value = true
|
||||
info.value = await getFundById(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[0]?.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()
|
||||
useFundInfoPage()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.fund-info-page-root {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="bg-white wrap-fix py-3 px-3 text-black font-bold drop-shadow flex h-12 z-20">
|
||||
<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-gray-100 w-[120px] aspect-square flex justify-center items-center rounded-xl p-2">
|
||||
<ProductImage
|
||||
v-if="info"
|
||||
:product="info"
|
||||
img-class="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</a-spin>
|
||||
<div class="mt-2 font-bold flex text-xs items-center text-gray-500">
|
||||
<div class="ml-1 text-base text-black">{{ 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 ProductImage from '@/components/ProductImage.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 { useFundInfoPageContext } from '../composables/useFundInfoPage'
|
||||
|
||||
const { info, items, goBack } = useFundInfoPageContext()
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="bg-white w-full py-3 px-3 text-black 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="bg-[#EFCED9] w-screen md:w-full h-[70vh] fixed md:absolute overflow-hidden">
|
||||
<img
|
||||
:src="fundHeaderBackground"
|
||||
alt=""
|
||||
class="mt-[-4rem] mr-10 max-w-[22rem] self-end pointer-events-none absolute bottom-0 left-0 opacity-20"
|
||||
/>
|
||||
<div class="pt-15 flex flex-col relative z-10">
|
||||
<div class="text-gray-700 font-bold mr-5 z-20">موجودی دارایی ها</div>
|
||||
|
||||
<div
|
||||
v-if="meta?.has_count_based_products"
|
||||
class="flex justify-center mt-4 w-full z-20"
|
||||
>
|
||||
محصولات:
|
||||
<div class="text-l mr-4 font-bold">{{ countBasedInventory }} عدد</div>
|
||||
</div>
|
||||
|
||||
<template v-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"
|
||||
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="w-70" />
|
||||
<div 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 Item from '../components/Item.vue'
|
||||
import { useFundPageContext } from '../composables/useFundPage'
|
||||
|
||||
const {
|
||||
meta,
|
||||
inventoryCards,
|
||||
metalTotals,
|
||||
primaryMetalTotal,
|
||||
cashWallet,
|
||||
totalRialValue,
|
||||
countBasedInventory,
|
||||
fundHeaderBackground,
|
||||
fundEmptyImage,
|
||||
fundEmptyText,
|
||||
formatMetalTotal,
|
||||
openFundInfo,
|
||||
goBack,
|
||||
} = useFundPageContext()
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<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-1'">
|
||||
کد کارت هدیه خود را در فیلد بالا وارد کنید و سپس دکمه ثبت را بزنید.
|
||||
</p>
|
||||
<p :class="variant === 'desktop' ? 'dgift-form__case-note' : '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 { loading } from '@/utils'
|
||||
import { required } from '@/utils/inputRules'
|
||||
import { useGiftCardPageContext } from '../composables/useGiftCardPage'
|
||||
|
||||
withDefaults(defineProps<{ variant?: 'mobile' | 'desktop' }>(), { variant: 'mobile' })
|
||||
|
||||
const { form, disableButton, registerGiftCard } = useGiftCardPageContext()
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { computed, inject, provide, ref, type InjectionKey } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { registerCodeGiftCard } from '@/api/giftCard'
|
||||
import { loading } from '@/utils'
|
||||
|
||||
export interface GiftCardPageContext {
|
||||
form: ReturnType<typeof ref<{ code: string }>>
|
||||
disableButton: ReturnType<typeof computed<boolean>>
|
||||
registerGiftCard: () => Promise<void>
|
||||
}
|
||||
|
||||
export const GIFT_CARD_PAGE_CONTEXT_KEY: InjectionKey<GiftCardPageContext> = Symbol('giftCardPageContext')
|
||||
|
||||
export function useGiftCardPage() {
|
||||
const form = ref({ code: '' })
|
||||
|
||||
const disableButton = computed(() => {
|
||||
if (loading.value) return true
|
||||
return !form.value.code || form.value.code.trim().length === 0
|
||||
})
|
||||
|
||||
async function registerGiftCard() {
|
||||
try {
|
||||
loading.value = true
|
||||
await registerCodeGiftCard(form.value.code)
|
||||
message.success('کد کارت هدیه با موفقیت ثبت شد')
|
||||
form.value.code = ''
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const context: GiftCardPageContext = { form, disableButton, 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 useGiftCardPage')
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="dgift gift-card-page">
|
||||
<div class="dgift__container">
|
||||
<DesktopPageTopbar eyebrow="مالی" title="کارت هدیه" show-back sticky />
|
||||
|
||||
<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 DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
|
||||
import GiftCardForm from '../components/GiftCardForm.vue'
|
||||
</script>
|
||||
|
||||
<style lang="less" src="./styles/desktop-gift-card.less"></style>
|
||||
@@ -0,0 +1,144 @@
|
||||
/* 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;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dgift-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 300px);
|
||||
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(167, 43, 119, 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 8px;
|
||||
}
|
||||
|
||||
&__case-note {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
&__submit {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.dgift-guide {
|
||||
padding: 24px;
|
||||
position: sticky;
|
||||
top: 88px;
|
||||
|
||||
&__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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,81 +1,23 @@
|
||||
<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">
|
||||
<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-1">
|
||||
کد کارت هدیه خود را در فیلد بالا وارد کنید و سپس دکمه ثبت را بزنید.
|
||||
</div>
|
||||
<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 class="gift-card-page-root">
|
||||
<DesktopGiftCardView v-show="isDesktop" />
|
||||
<MobileGiftCardView v-show="!isDesktop" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, ref} from 'vue'
|
||||
import {loading} from '@/utils'
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
import Bg_main from '@/components/bg_main.vue'
|
||||
import {registerCodeGiftCard} from '@/api/giftCard'
|
||||
import {message} from 'ant-design-vue'
|
||||
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
|
||||
import {required} from '@/utils/inputRules'
|
||||
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||
import { useGiftCardPage } from './composables/useGiftCardPage'
|
||||
import DesktopGiftCardView from './desktop/DesktopGiftCardView.vue'
|
||||
import MobileGiftCardView from './mobile/MobileGiftCardView.vue'
|
||||
|
||||
const form = ref<{ code: string }>({
|
||||
code: ''
|
||||
})
|
||||
|
||||
const disableButton = computed(() => {
|
||||
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
|
||||
}
|
||||
}
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
useGiftCardPage()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.gift-card-page-root {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<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">
|
||||
<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-1">
|
||||
کد کارت هدیه خود را در فیلد بالا وارد کنید و سپس دکمه ثبت را بزنید.
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<a-spin v-if="loading" size="small" />
|
||||
<div v-else>ثبت کد</div>
|
||||
</BasicButton>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { loading } from '@/utils'
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
import Bg_main from '@/components/bg_main.vue'
|
||||
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
|
||||
import { required } from '@/utils/inputRules'
|
||||
import { useGiftCardPageContext } from '../composables/useGiftCardPage'
|
||||
|
||||
const { form, disableButton, registerGiftCard } = useGiftCardPageContext()
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<header class="dhome-topbar">
|
||||
<div>
|
||||
<p class="dhome-topbar__eyebrow">نمای کلی</p>
|
||||
<h1 class="dhome-topbar__title">{{ userStore.userInfo?.name ?? 'کاربر' }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="dhome-topbar__actions">
|
||||
<button type="button" class="dhome-btn-ghost" @click="router.push({ name: 'user' })">
|
||||
<BasicIcon icon="vuesax-linear:user" size="16" />
|
||||
<span>حساب کاربری</span>
|
||||
</button>
|
||||
<button type="button" class="dhome-btn-ghost" @click="$emit('logout')">
|
||||
<BasicIcon icon="vuesax-linear:logout" size="16" />
|
||||
<span>خروج</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import router from '@/router'
|
||||
|
||||
defineEmits<{
|
||||
logout: []
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
</script>
|
||||
@@ -1,63 +1,54 @@
|
||||
<template>
|
||||
<div ref="skeletonRatio" class="h-fit">
|
||||
<content-loader :width="skeletonSize.width" class="bg-white" height="100vh">
|
||||
<rect :width="skeletonSize.width" height="5" rx="3" ry="3" x="0" y="0"/>
|
||||
<rect :width="skeletonSize.width" height="5" rx="3" ry="3" x="0" y="84"/>
|
||||
<rect :width="5" height="85" rx="3" ry="3" x="0" y="0"/>
|
||||
<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="skeletonSize.width/2-20" :x="10" height="20" rx="10" ry="10" y="15"/>
|
||||
<rect :width="skeletonSize.width/2-20" :x="skeletonSize.width/2 + 10" height="20" rx="10" ry="10" y="15"/>
|
||||
<rect :width="skeletonSize.width/2-40" :x="20" height="10" rx="5" ry="5" y="40"/>
|
||||
<rect :width="skeletonSize.width/2-40" :x="skeletonSize.width/2 + 20" height="10" rx="5" ry="5" y="40"/>
|
||||
<rect :width="skeletonSize.width/2-30" :x="15" height="18" rx="10" ry="10" y="60"/>
|
||||
<rect :width="skeletonSize.width/2-30" :x="skeletonSize.width/2 + 15" height="18" rx="10" ry="10" y="60"/>
|
||||
<circle :cx="skeletonSize.width/4-skeletonSize.width/8" cy="145" r="20"/>
|
||||
<circle :cx="skeletonSize.width/4+skeletonSize.width/8" cy="145" r="20"/>
|
||||
<circle :cx="skeletonSize.width/2+ skeletonSize.width/8" cy="145" r="20"/>
|
||||
<circle :cx="skeletonSize.width - skeletonSize.width/8" cy="145" r="20"/>
|
||||
<rect :width="50" :x="skeletonSize.width/4-skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/>
|
||||
<rect :width="50" :x="skeletonSize.width/4+skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/>
|
||||
<rect :width="50" :x="skeletonSize.width/2+ skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/>
|
||||
<rect :width="50" :x="skeletonSize.width - skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/>
|
||||
<rect :width="skeletonSize.width - 45" :x="45" height="20" rx="3" ry="3" y="230"/>
|
||||
<rect :width="skeletonSize.width" :x="0" height="80" rx="3" ry="3" y="260"/>
|
||||
<rect :width="skeletonSize.width/2 - 5" :x="0" height="50" rx="3" ry="3" y="360"/>
|
||||
<rect :width="skeletonSize.width/2 - 5" :x="skeletonSize.width/2 + 5" height="50" rx="3" ry="3" y="360"/>
|
||||
<rect :width="skeletonSize.width" :x="0" height="170" rx="3" ry="3" y="440"/>
|
||||
<!-- <rect height="20" rx="3" ry="3" width="100" x="0" y="0"/>
|
||||
<rect :width="skeletonSize.width/4 + 40" :x="skeletonSize.width/2" height="20" rx="3" ry="3" y="0"/>
|
||||
<rect height="20" rx="3" ry="3" width="80" x="0" y="30"/>
|
||||
<rect :x="skeletonSize.width - 40" height="40" rx="12" ry="12" width="40" y="0"/>
|
||||
<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" ♥/>-->
|
||||
<div ref="skeletonRatio" class="h-fit w-full min-h-[620px]">
|
||||
<content-loader
|
||||
:width="skeletonWidth"
|
||||
class="bg-white"
|
||||
:height="skeletonHeight"
|
||||
>
|
||||
<rect :width="skeletonWidth" height="5" rx="3" ry="3" x="0" y="0" />
|
||||
<rect :width="skeletonWidth" height="5" rx="3" ry="3" x="0" y="84" />
|
||||
<rect width="5" height="85" rx="3" ry="3" x="0" y="0" />
|
||||
<rect width="5" :x="skeletonWidth - 5" height="85" rx="3" ry="3" y="0" />
|
||||
<rect width="2" :x="skeletonWidth / 2" height="60" rx="3" ry="3" y="15" />
|
||||
<rect :width="halfWidth(20)" x="10" height="20" rx="10" ry="10" y="15" />
|
||||
<rect :width="halfWidth(20)" :x="skeletonWidth / 2 + 10" height="20" rx="10" ry="10" y="15" />
|
||||
<rect :width="halfWidth(40)" x="20" height="10" rx="5" ry="5" y="40" />
|
||||
<rect :width="halfWidth(40)" :x="skeletonWidth / 2 + 20" height="10" rx="5" ry="5" y="40" />
|
||||
<rect :width="halfWidth(30)" x="15" height="18" rx="10" ry="10" y="60" />
|
||||
<rect :width="halfWidth(30)" :x="skeletonWidth / 2 + 15" height="18" rx="10" ry="10" y="60" />
|
||||
<circle :cx="skeletonWidth / 4 - skeletonWidth / 8" cy="145" r="20" />
|
||||
<circle :cx="skeletonWidth / 4 + skeletonWidth / 8" cy="145" r="20" />
|
||||
<circle :cx="skeletonWidth / 2 + skeletonWidth / 8" cy="145" r="20" />
|
||||
<circle :cx="skeletonWidth - skeletonWidth / 8" cy="145" r="20" />
|
||||
<rect width="50" :x="skeletonWidth / 4 - skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
|
||||
<rect width="50" :x="skeletonWidth / 4 + skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
|
||||
<rect width="50" :x="skeletonWidth / 2 + skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
|
||||
<rect width="50" :x="skeletonWidth - skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
|
||||
<rect :width="fullWidth(45)" x="45" height="20" rx="3" ry="3" y="230" />
|
||||
<rect :width="skeletonWidth" x="0" height="80" rx="3" ry="3" y="260" />
|
||||
<rect :width="halfWidth(10)" x="0" height="50" rx="3" ry="3" y="360" />
|
||||
<rect :width="halfWidth(10)" :x="skeletonWidth / 2 + 5" height="50" rx="3" ry="3" y="360" />
|
||||
<rect :width="skeletonWidth" x="0" height="170" rx="3" ry="3" y="440" />
|
||||
</content-loader>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {reactive, ref, watch} from "vue";
|
||||
import {useElementSize} from "@vueuse/core";
|
||||
import {ContentLoader} from "vue-content-loader";
|
||||
import { computed, ref } from 'vue'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { ContentLoader } from 'vue-content-loader'
|
||||
|
||||
const skeletonRatio = ref(null)
|
||||
const skeletonSize = reactive(
|
||||
useElementSize(skeletonRatio, {width: 0, height: 0}, {box: 'border-box'})
|
||||
)
|
||||
/*watch(skeletonSize, () => {
|
||||
console.log(skeletonSize)
|
||||
})*/
|
||||
const skeletonHeight = 620
|
||||
const skeletonRatio = ref<HTMLElement | null>(null)
|
||||
const { width } = useElementSize(skeletonRatio, { width: 0, height: 0 }, { box: 'border-box' })
|
||||
|
||||
const skeletonWidth = computed(() => Math.max(width.value, 320))
|
||||
|
||||
function halfWidth(padding: number) {
|
||||
return Math.max(skeletonWidth.value / 2 - padding, 0)
|
||||
}
|
||||
|
||||
function fullWidth(padding: number) {
|
||||
return Math.max(skeletonWidth.value - padding, 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ref } from 'vue'
|
||||
import { getConstByKey } from '@/api/const'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
|
||||
function parseJsonArray(raw: unknown): unknown[] {
|
||||
if (raw == null || raw === '') {
|
||||
return []
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
return raw
|
||||
}
|
||||
if (typeof raw !== 'string') {
|
||||
return []
|
||||
}
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Module-level banner state — shared by mobile and desktop home layouts. */
|
||||
const banners = ref<unknown[]>([])
|
||||
const bannersLoading = ref(false)
|
||||
let bannersPromise: Promise<void> | null = null
|
||||
let bannersLoaded = false
|
||||
|
||||
async function fetchBanners(force = false) {
|
||||
const settingsStore = useSettingsStore()
|
||||
if (!settingsStore.isSettingTruthy('banner_enabled')) {
|
||||
banners.value = []
|
||||
bannersLoaded = true
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && bannersLoaded) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && bannersPromise) {
|
||||
return bannersPromise
|
||||
}
|
||||
|
||||
bannersPromise = (async () => {
|
||||
try {
|
||||
bannersLoading.value = true
|
||||
const response = await getConstByKey('header_banner')
|
||||
if (!response) {
|
||||
banners.value = []
|
||||
return
|
||||
}
|
||||
|
||||
let list: unknown[] = []
|
||||
if (typeof response === 'string') {
|
||||
list = parseJsonArray(response)
|
||||
} else if (typeof response === 'object' && !Array.isArray(response) && 'value' in response) {
|
||||
list = parseJsonArray((response as { value?: unknown }).value)
|
||||
} else if (Array.isArray(response) && response.length > 0) {
|
||||
const first = response[0] as { value?: unknown; icon?: unknown }
|
||||
if (first.value != null) {
|
||||
list = parseJsonArray(first.value)
|
||||
} else if (first.icon) {
|
||||
list = response
|
||||
}
|
||||
}
|
||||
|
||||
banners.value = list.filter((banner) => (banner as { show?: boolean }).show === true)
|
||||
bannersLoaded = true
|
||||
} catch (error) {
|
||||
console.error('Error loading banners:', error)
|
||||
banners.value = []
|
||||
} finally {
|
||||
bannersLoading.value = false
|
||||
bannersPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return bannersPromise
|
||||
}
|
||||
|
||||
export function useBanners() {
|
||||
return {
|
||||
banners,
|
||||
loading: bannersLoading,
|
||||
loadBanners: fetchBanners,
|
||||
}
|
||||
}
|
||||
|
||||
export function loadHomeBanners(force = false) {
|
||||
return fetchBanners(force)
|
||||
}
|
||||
|
||||
export function resetHomeBanners() {
|
||||
banners.value = []
|
||||
bannersLoaded = false
|
||||
bannersPromise = null
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ref } from 'vue'
|
||||
import { getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
|
||||
import { useWalletAssets } from '@/composables/useWalletAssets'
|
||||
import { clearProductsCatalog } from '@/composables/useProductsCatalog'
|
||||
import { getHomeLayoutId } from '../layouts/registry'
|
||||
import { fetchHomeChartPrices, loadHomeMaftolOffset, resetHomePriceChart } from './usePriceChart'
|
||||
import { loadHomeBanners, resetHomeBanners } from './useBanners'
|
||||
import { loadHomeFunds, resetHomeFunds } from './useUserFunds'
|
||||
|
||||
/** فقط لودینگ صفحه اصلی — نه loading سراسری @/utils */
|
||||
const homePageLoading = ref(false)
|
||||
|
||||
let initPromise: Promise<void> | null = null
|
||||
const initialized = ref(false)
|
||||
|
||||
export function useHomePageLoading() {
|
||||
return { homePageLoading }
|
||||
}
|
||||
|
||||
export function isHomePageDataInitialized() {
|
||||
return initialized.value
|
||||
}
|
||||
|
||||
export async function initHomePageData(force = false) {
|
||||
if (!force && initialized.value) {
|
||||
startLastPricePolling()
|
||||
|
||||
if (getHomeLayoutId() === 'boursecu') {
|
||||
void loadHomeMaftolOffset(false)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && initPromise) {
|
||||
return initPromise
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
const { loadWalletAssets } = useWalletAssets()
|
||||
|
||||
await Promise.all([
|
||||
loadHomeFunds(force),
|
||||
loadWalletAssets(force),
|
||||
getLastPrices(),
|
||||
fetchHomeChartPrices(force),
|
||||
startLastPricePolling(),
|
||||
loadHomeBanners(force),
|
||||
])
|
||||
|
||||
if (getHomeLayoutId() === 'boursecu') {
|
||||
await loadHomeMaftolOffset(force)
|
||||
}
|
||||
|
||||
initialized.value = true
|
||||
} catch (error) {
|
||||
console.error('Home page initialization error:', error)
|
||||
} finally {
|
||||
initPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return initPromise
|
||||
}
|
||||
|
||||
/** Clears homepage caches — call on logout or when a full refresh is required. */
|
||||
export function resetHomePageData() {
|
||||
initialized.value = false
|
||||
initPromise = null
|
||||
homePageLoading.value = false
|
||||
stopLastPricePolling()
|
||||
resetHomeBanners()
|
||||
resetHomeFunds()
|
||||
resetHomePriceChart()
|
||||
clearProductsCatalog()
|
||||
}
|
||||
|
||||
export function useHomePageInitState() {
|
||||
return { initialized }
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getAllPrices } from '@/api/home'
|
||||
import type { Product } from '@/models/products'
|
||||
import { getProductsCatalog, getProductsCatalogRef } from '@/composables/useProductsCatalog'
|
||||
import { getHomeLayoutId } from '../layouts/registry'
|
||||
import { getProductBuyPrice, resolvePriceOffsetRatio } from '@/utils/productPrice'
|
||||
import { isKatodMesGroup, isMaftolMesGroup, isShamshMesGroup } from '@/utils/productDealMethod'
|
||||
import { getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price'
|
||||
|
||||
export type ChartMetalType = 'katod' | 'maftol' | 'shamsh'
|
||||
|
||||
type ChartPoint = [number, number]
|
||||
|
||||
/** Module-level chart state — one dataset shared by mobile and desktop PriceChart instances. */
|
||||
const priceChartSeries = ref<{ name: string; data: ChartPoint[] }[]>([
|
||||
{ name: 'قیمت', data: [] },
|
||||
])
|
||||
const rawChartPoints = ref<ChartPoint[]>([])
|
||||
const chartSelection = ref('DAILY')
|
||||
const chartLoading = ref(false)
|
||||
const selectedChartMetal = ref<ChartMetalType>('katod')
|
||||
const catalogProducts = getProductsCatalogRef()
|
||||
|
||||
function resolveChartProducts(products: Product[]) {
|
||||
return {
|
||||
katod: findKatodProduct(products),
|
||||
maftol: findMaftolProduct(products),
|
||||
shamsh: findShamshProduct(products),
|
||||
}
|
||||
}
|
||||
|
||||
/** نسبت مفتول به کاتد — reactive به قیمت لحظهای و کاتالوگ محصولات */
|
||||
const maftolChartRatio = computed(() => {
|
||||
if (getHomeLayoutId() !== 'boursecu') {
|
||||
return 0
|
||||
}
|
||||
|
||||
void lastPrice.value?.price_last
|
||||
|
||||
const { katod, maftol } = resolveChartProducts(catalogProducts.value)
|
||||
return deriveMetalChartRatio(maftol, katod)
|
||||
})
|
||||
|
||||
/** نسبت شمش مس به کاتد */
|
||||
const shamshChartRatio = computed(() => {
|
||||
if (getHomeLayoutId() !== 'boursecu') {
|
||||
return 0
|
||||
}
|
||||
|
||||
void lastPrice.value?.price_last
|
||||
|
||||
const { katod, shamsh } = resolveChartProducts(catalogProducts.value)
|
||||
return deriveMetalChartRatio(shamsh, katod)
|
||||
})
|
||||
|
||||
const selectedMetalChartRatio = computed(() => {
|
||||
if (selectedChartMetal.value === 'maftol') {
|
||||
return maftolChartRatio.value
|
||||
}
|
||||
if (selectedChartMetal.value === 'shamsh') {
|
||||
return shamshChartRatio.value
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
let chartFetchPromise: Promise<void> | null = null
|
||||
let chartInitialized = false
|
||||
let metalOffsetPromise: Promise<void> | null = null
|
||||
let metalOffsetLoaded = false
|
||||
|
||||
function findKatodProduct(products: Product[]) {
|
||||
return (
|
||||
products.find((product) => isKatodMesGroup(product.group_type))
|
||||
?? products.find((product) => (product.title ?? '').includes('کاتد'))
|
||||
)
|
||||
}
|
||||
|
||||
function findMaftolProduct(products: Product[]) {
|
||||
return (
|
||||
products.find((product) => isMaftolMesGroup(product.group_type))
|
||||
?? products.find((product) => (product.title ?? '').includes('مفتول'))
|
||||
)
|
||||
}
|
||||
|
||||
function findShamshProduct(products: Product[]) {
|
||||
return products.find((product) =>
|
||||
isShamshMesGroup(product.group_type, product.title)
|
||||
)
|
||||
}
|
||||
|
||||
function applyChartRatio(basePrice: number, ratio: number) {
|
||||
if (!ratio) {
|
||||
return basePrice
|
||||
}
|
||||
|
||||
return Math.round(basePrice + basePrice * ratio)
|
||||
}
|
||||
|
||||
/** بالاترین/پایینترین امروز — برای boursecu با نسبت مفتول/شمش نسبت به کاتد */
|
||||
export function resolveMetalDayRangePrices(options: {
|
||||
price: { up?: { buy?: number; sell?: number }; buy?: number; sell?: number; price_last?: number } | null | undefined
|
||||
metal: ChartMetalType
|
||||
isBoursecuLayout: boolean
|
||||
metalRatio: number
|
||||
}) {
|
||||
const baseHigh = options.price?.up?.buy ?? getTradeBuyPrice(options.price as Parameters<typeof getTradeBuyPrice>[0])
|
||||
const baseLow = options.price?.up?.sell ?? getTradeSellPrice(options.price as Parameters<typeof getTradeSellPrice>[0])
|
||||
|
||||
if (!options.isBoursecuLayout || options.metal === 'katod') {
|
||||
return { high: baseHigh, low: baseLow }
|
||||
}
|
||||
|
||||
return {
|
||||
high: applyChartRatio(baseHigh, options.metalRatio),
|
||||
low: applyChartRatio(baseLow, options.metalRatio),
|
||||
}
|
||||
}
|
||||
|
||||
function deriveMetalChartRatio(metalProduct?: Product, katodProduct?: Product) {
|
||||
const metalOffset = resolvePriceOffsetRatio(metalProduct?.price_offset_percent)
|
||||
if (metalOffset) {
|
||||
return metalOffset
|
||||
}
|
||||
|
||||
if (!metalProduct) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const liveBuy = getTradeBuyPrice(lastPrice.value)
|
||||
const katodLive = katodProduct ? getProductBuyPrice(katodProduct) : liveBuy
|
||||
const metalLive = getProductBuyPrice(metalProduct)
|
||||
|
||||
const katodStatic = Number(katodProduct?.price ?? 0) + Number(katodProduct?.price_profit ?? 0)
|
||||
const metalStatic = Number(metalProduct.price ?? 0) + Number(metalProduct.price_profit ?? 0)
|
||||
|
||||
const referencePrice = katodLive || liveBuy || katodStatic
|
||||
const derivedMetalPrice = metalLive || metalStatic
|
||||
|
||||
if (!referencePrice || !derivedMetalPrice || referencePrice === derivedMetalPrice) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return (derivedMetalPrice - referencePrice) / referencePrice
|
||||
}
|
||||
|
||||
function normalizeChartTimestamp(timestamp: number) {
|
||||
return timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp
|
||||
}
|
||||
|
||||
function resolvePricesMap(response: unknown): Record<string, Array<{ timestamp: number; value: number }>> | null {
|
||||
if (!response || typeof response !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const body = response as Record<string, unknown>
|
||||
const nested = body.data
|
||||
|
||||
if (nested && typeof nested === 'object' && 'prices' in nested) {
|
||||
return (nested as { prices: Record<string, Array<{ timestamp: number; value: number }>> }).prices
|
||||
}
|
||||
|
||||
if ('prices' in body) {
|
||||
return body.prices as Record<string, Array<{ timestamp: number; value: number }>>
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildChartPoints(items: Array<{ timestamp: number; value: number }>): ChartPoint[] {
|
||||
return items.map((item) => [
|
||||
normalizeChartTimestamp(item.timestamp),
|
||||
item.value - 1,
|
||||
])
|
||||
}
|
||||
|
||||
function syncChartSeries() {
|
||||
const ratio = selectedMetalChartRatio.value
|
||||
const data =
|
||||
getHomeLayoutId() === 'boursecu' && selectedChartMetal.value !== 'katod'
|
||||
? rawChartPoints.value.map(
|
||||
([timestamp, value]) =>
|
||||
[timestamp, applyChartRatio(value, ratio)] as ChartPoint
|
||||
)
|
||||
: rawChartPoints.value
|
||||
|
||||
priceChartSeries.value[0].data = data
|
||||
}
|
||||
|
||||
watch([selectedChartMetal, maftolChartRatio, shamshChartRatio], () => {
|
||||
syncChartSeries()
|
||||
})
|
||||
|
||||
async function fetchPrices(force = false) {
|
||||
if (!force && chartInitialized && rawChartPoints.value.length > 0) {
|
||||
syncChartSeries()
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && chartFetchPromise) {
|
||||
return chartFetchPromise
|
||||
}
|
||||
|
||||
chartFetchPromise = (async () => {
|
||||
try {
|
||||
chartLoading.value = true
|
||||
const response = await getAllPrices()
|
||||
const data = resolvePricesMap(response)
|
||||
const selectedData = data?.[chartSelection.value] ?? []
|
||||
rawChartPoints.value = buildChartPoints(selectedData)
|
||||
syncChartSeries()
|
||||
chartInitialized = true
|
||||
} catch (error) {
|
||||
console.error('Error fetching prices:', error)
|
||||
} finally {
|
||||
chartLoading.value = false
|
||||
chartFetchPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return chartFetchPromise
|
||||
}
|
||||
|
||||
async function loadMetalChartOffsets(force = false) {
|
||||
if (getHomeLayoutId() !== 'boursecu') {
|
||||
syncChartSeries()
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && metalOffsetLoaded && catalogProducts.value.length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && metalOffsetPromise) {
|
||||
return metalOffsetPromise
|
||||
}
|
||||
|
||||
metalOffsetPromise = (async () => {
|
||||
try {
|
||||
await getProductsCatalog(force)
|
||||
syncChartSeries()
|
||||
metalOffsetLoaded = true
|
||||
} catch (error) {
|
||||
console.error('Error loading boursecu chart metal ratios:', error)
|
||||
syncChartSeries()
|
||||
} finally {
|
||||
metalOffsetPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return metalOffsetPromise
|
||||
}
|
||||
|
||||
function updateChartData(timeline: string) {
|
||||
chartSelection.value = timeline
|
||||
void fetchPrices(true)
|
||||
}
|
||||
|
||||
function setChartMetal(metal: ChartMetalType) {
|
||||
selectedChartMetal.value = metal
|
||||
|
||||
if (metal !== 'katod' && getHomeLayoutId() === 'boursecu' && !catalogProducts.value.length) {
|
||||
void loadMetalChartOffsets(true)
|
||||
}
|
||||
}
|
||||
|
||||
export function usePriceChart() {
|
||||
return {
|
||||
priceChartSeries,
|
||||
chartSelection,
|
||||
chartLoading,
|
||||
selectedChartMetal,
|
||||
maftolChartRatio,
|
||||
shamshChartRatio,
|
||||
selectedMetalChartRatio,
|
||||
updateChartData,
|
||||
fetchPrices,
|
||||
loadMaftolOffset: loadMetalChartOffsets,
|
||||
loadMetalChartOffsets,
|
||||
setChartMetal,
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchHomeChartPrices(force = false) {
|
||||
return fetchPrices(force)
|
||||
}
|
||||
|
||||
export function loadHomeMaftolOffset(force = false) {
|
||||
return loadMetalChartOffsets(force)
|
||||
}
|
||||
|
||||
export function resetHomePriceChart() {
|
||||
priceChartSeries.value = [{ name: 'قیمت', data: [] }]
|
||||
rawChartPoints.value = []
|
||||
chartSelection.value = 'DAILY'
|
||||
selectedChartMetal.value = 'katod'
|
||||
chartInitialized = false
|
||||
chartFetchPromise = null
|
||||
metalOffsetLoaded = false
|
||||
metalOffsetPromise = null
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<div class="dhome">
|
||||
<div class="dhome__container">
|
||||
<DesktopHomeTopbar @logout="$emit('logout')" />
|
||||
|
||||
<HomeSkeleton v-if="loading && !userStore.userInfo" />
|
||||
|
||||
<template v-else-if="userStore.userInfo">
|
||||
<section class="dhome-hero-row">
|
||||
<div class="dhome-surface dhome-hero-price">
|
||||
<span class="dhome-label">قیمت خرید از کشکول گلد</span>
|
||||
<div class="dhome-hero-price__value-row">
|
||||
<span
|
||||
v-if="percentPriceChanged() !== 0"
|
||||
class="dhome-hero-price__delta"
|
||||
:class="percentPriceChanged() >= 0 ? 'dhome-hero-price__delta--up' : 'dhome-hero-price__delta--down'"
|
||||
>
|
||||
<BasicIcon
|
||||
:icon="percentPriceChanged() >= 0 ? 'vuesax-linear:arrow-up-2' : 'vuesax-linear:arrow-down-2'"
|
||||
size="14"
|
||||
/>
|
||||
<span class="dhome-hero-price__delta-value">{{ useTowDecimal(percentPriceChanged()) }}%</span>
|
||||
</span>
|
||||
<Vue3Odometer
|
||||
:value="goldVal ?? 0"
|
||||
class="dhome-hero-price__odometer o-text"
|
||||
/>
|
||||
<span class="dhome-hero-price__unit">ریال</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dhome-surface dhome-price-range">
|
||||
<article class="dhome-price-range__item">
|
||||
<span class="dhome-price-range__label">بیشترین امروز</span>
|
||||
<p class="dhome-price-range__value dhome-price-range__value--high">
|
||||
{{ usePriceWithoutType(price?.buy ?? 0) }} ریال
|
||||
</p>
|
||||
</article>
|
||||
<article class="dhome-price-range__item">
|
||||
<span class="dhome-price-range__label">کمترین امروز</span>
|
||||
<p class="dhome-price-range__value dhome-price-range__value--low">
|
||||
{{ usePriceWithoutType(price?.sell ?? 0) }} ریال
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="dhome-default-grid">
|
||||
<div class="dhome-default-grid__main">
|
||||
<section class="dhome-surface">
|
||||
<header class="dhome-section__header">
|
||||
<h2 class="dhome-section__title">عملیات</h2>
|
||||
</header>
|
||||
<div class="dhome-default-actions">
|
||||
<button
|
||||
v-for="action in mainActions"
|
||||
:key="action.name"
|
||||
type="button"
|
||||
class="dhome-default-action"
|
||||
@click="action.handler()"
|
||||
>
|
||||
<span class="dhome-default-action__icon">
|
||||
<BasicIcon :icon="action.icon" size="20" />
|
||||
</span>
|
||||
<span class="dhome-default-action__label">{{ action.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dhome-surface dhome-chart-banner-combined">
|
||||
<div class="dhome-chart-banner-combined__grid">
|
||||
<div class="dhome-chart-panel">
|
||||
<header class="dhome-section__header dhome-chart-panel__header">
|
||||
<h2 class="dhome-section__title">نمودار قیمت</h2>
|
||||
</header>
|
||||
<div class="dhome-chart-panel__tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="dhome-chart-panel__tab"
|
||||
:class="{ 'dhome-chart-panel__tab--active': selection === 'DAILY' }"
|
||||
@click="updateData('DAILY')"
|
||||
>
|
||||
24 ساعت اخیر
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dhome-chart-panel__tab"
|
||||
:class="{ 'dhome-chart-panel__tab--active': selection === 'WEEKLY' }"
|
||||
@click="updateData('WEEKLY')"
|
||||
>
|
||||
هفته گذشته
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dhome-chart-panel__tab"
|
||||
:class="{ 'dhome-chart-panel__tab--active': selection === 'MONTHLY' }"
|
||||
@click="updateData('MONTHLY')"
|
||||
>
|
||||
ماه گذشته
|
||||
</button>
|
||||
</div>
|
||||
<apexchart
|
||||
type="area"
|
||||
height="280"
|
||||
:options="priceChartOptions"
|
||||
:series="priceChartSeries"
|
||||
/>
|
||||
</div>
|
||||
<div class="dhome-banner-panel">
|
||||
<img :src="bannerImg" alt="" class="dhome-banner-panel__img" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="dhome-default-grid__side">
|
||||
<section class="dhome-surface dhome-assets-card">
|
||||
<h3 class="dhome-assets-card__title">داراییها</h3>
|
||||
<div class="dhome-assets-card__rows">
|
||||
<div class="dhome-assets-card__row">
|
||||
<span>دارایی طلایی</span>
|
||||
<strong>{{ balanceLabel }}</strong>
|
||||
</div>
|
||||
<div class="dhome-assets-card__row">
|
||||
<span>دارایی ریالی</span>
|
||||
<strong class="number-ltr">{{ usePriceWithoutType(totalRialDisplay) }} ریال</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="userStore.userInfo?.referral_id" class="dhome-surface dhome-referral">
|
||||
<h3 class="dhome-referral__title">دعوت از دوستان</h3>
|
||||
<p class="dhome-referral__code">{{ userStore.userInfo.referral_id }}</p>
|
||||
<div class="dhome-referral__actions">
|
||||
<button type="button" class="dhome-referral__btn" @click="shareReferral">
|
||||
<BasicIcon icon="vuesax-linear:share" size="16" />
|
||||
<span>دعوت</span>
|
||||
</button>
|
||||
<button type="button" class="dhome-referral__btn" @click="copyReferralCode">
|
||||
<BasicIcon icon="vuesax-linear:copy" size="16" />
|
||||
<span>رونوشت</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Vue3Odometer from 'vue3-odometer'
|
||||
import 'odometer/themes/odometer-theme-default.css'
|
||||
import router from '@/router'
|
||||
import { getAllPrices } from '@/api/home'
|
||||
import { loading } from '@/utils'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { useWalletStore } from '@/store/walletStore'
|
||||
import { useWalletAssets } from '@/composables/useWalletAssets'
|
||||
import { resolveAssetsRialValue } from '@/utils/productAssets'
|
||||
import { usePrice, usePriceWithoutType, useTowDecimal } from '@/utils/usePrice'
|
||||
import {
|
||||
getLastPrices,
|
||||
getTradeBuyPrice,
|
||||
getUpPrice,
|
||||
percentPriceChanged,
|
||||
price,
|
||||
setIntervalGetAllPrice,
|
||||
} from '@/utils/price'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import DesktopHomeTopbar from '@/views/base/home/components/DesktopHomeTopbar.vue'
|
||||
import HomeSkeleton from '@/views/base/home/components/HomeSkeleton.vue'
|
||||
import bannerImg from '@/assets/img/banner/2.png'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
const emit = defineEmits<{ logout: []; openBalance: [] }>()
|
||||
const userStore = useUserStore()
|
||||
const walletStore = useWalletStore()
|
||||
const { assetsSummary, loadWalletAssets } = useWalletAssets()
|
||||
|
||||
const goldVal = ref<number | undefined>()
|
||||
const selection = ref<'DAILY' | 'WEEKLY' | 'MONTHLY'>('DAILY')
|
||||
const priceChartSeries = ref([{ name: 'قیمت', data: [] as [number, number][] }])
|
||||
const priceChartOptions = ref({
|
||||
chart: { toolbar: { show: false }, zoom: { autoScaleYaxis: true } },
|
||||
stroke: { width: 2 },
|
||||
dataLabels: { enabled: false },
|
||||
yaxis: { show: false },
|
||||
xaxis: { type: 'datetime', labels: { show: false }, axisBorder: { show: false } },
|
||||
colors: ['#a72b77'],
|
||||
fill: {
|
||||
type: 'gradient',
|
||||
gradient: { shadeIntensity: 1, opacityFrom: 0.7, opacityTo: 0.9, stops: [0, 100] },
|
||||
},
|
||||
tooltip: { x: { format: 'dd/MM/yy HH:mm' } },
|
||||
})
|
||||
|
||||
const countBasedInventory = computed(() =>
|
||||
(assetsSummary.value?.count_products ?? []).reduce((sum, item) => sum + Number(item.quantity ?? 0), 0)
|
||||
)
|
||||
|
||||
const primaryMetalTotal = computed(() => assetsSummary.value?.metal_totals?.[0])
|
||||
|
||||
const balanceLabel = computed(() => {
|
||||
const meta = assetsSummary.value?.meta
|
||||
if (meta?.has_count_based_products && countBasedInventory.value > 0) {
|
||||
return `${countBasedInventory.value} عدد`
|
||||
}
|
||||
const metal = primaryMetalTotal.value
|
||||
if (metal && Number(metal.total_weight) > 0) {
|
||||
return `${Number(metal.total_weight).toFixed(3)} ${metal.unit}`
|
||||
}
|
||||
const fallback = walletStore.walletInfo?.gold_sum ?? userStore.userInfo?.wallet?.count_gold ?? 0
|
||||
return `${Number(fallback).toFixed(3)} گرم`
|
||||
})
|
||||
|
||||
const totalRialDisplay = computed(() =>
|
||||
resolveAssetsRialValue(assetsSummary.value, getTradeBuyPrice(price.value) || Number(goldVal.value ?? 0))
|
||||
)
|
||||
|
||||
const mainActions = [
|
||||
{ name: 'buySell', icon: 'vuesax-linear:money-receive', label: 'خرید و فروش', handler: () => router.push({ name: 'gold' }) },
|
||||
{ name: 'deposit', icon: 'vuesax-linear:card-add', label: 'واریز نقدی', handler: () => router.push({ name: 'wallet', query: { state: 'deposit' } }) },
|
||||
{ name: 'balance', icon: 'vuesax-linear:receipt-item', label: 'موجودی', handler: () => emitBalance() },
|
||||
{ name: 'fund', icon: 'vuesax-linear:archive-1', label: 'دارایی ها', handler: () => router.push({ name: 'fund' }) },
|
||||
{ name: 'earn', icon: 'vuesax-linear:dollar-circle', label: 'کسب درآمد', handler: () => router.push({ name: 'earnMoney' }) },
|
||||
]
|
||||
|
||||
function emitBalance() {
|
||||
emit('openBalance')
|
||||
}
|
||||
|
||||
async function fetchPrices() {
|
||||
try {
|
||||
const response = await getAllPrices()
|
||||
const data = response?.data?.prices ?? {}
|
||||
const selectedData = data[selection.value] ?? []
|
||||
priceChartSeries.value[0].data = selectedData.map((item: { timestamp: number; value: number }) => [
|
||||
item.timestamp,
|
||||
item.value - 1,
|
||||
])
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
function updateData(timeline: 'DAILY' | 'WEEKLY' | 'MONTHLY') {
|
||||
selection.value = timeline
|
||||
void fetchPrices()
|
||||
}
|
||||
|
||||
function copyReferralCode() {
|
||||
const code = userStore.userInfo?.referral_id
|
||||
if (code) {
|
||||
navigator.clipboard.writeText(code)
|
||||
message.info('کد رونوشت شد')
|
||||
}
|
||||
}
|
||||
|
||||
function shareReferral() {
|
||||
const text = `⭐️الان طلا بخر؛ آخر ماه سودشو ببر!
|
||||
این دعوت نامه از طرف "${userStore.userInfo?.name}" برای شما ارسال شده.
|
||||
kh.kashkolgold.ir/login?id=${userStore.userInfo?.referral_id}`
|
||||
navigator.share({ title: 'کشکول گلد', text })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
await Promise.all([
|
||||
getLastPrices(),
|
||||
getUpPrice(),
|
||||
setIntervalGetAllPrice(),
|
||||
fetchPrices(),
|
||||
loadWalletAssets(true),
|
||||
userStore.userInfo ? Promise.resolve() : userStore.getMyInfo(),
|
||||
])
|
||||
goldVal.value = price.value?.price_last
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" src="./styles/desktop-home.less"></style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<BoursecuDesktopHome v-if="layoutId === 'boursecu'" @logout="$emit('logout')" />
|
||||
<DefaultDesktopHome v-else @logout="$emit('logout')" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { getHomeLayoutId } from '../layouts/registry'
|
||||
import BoursecuDesktopHome from './layouts/BoursecuDesktopHome.vue'
|
||||
import DefaultDesktopHome from './layouts/DefaultDesktopHome.vue'
|
||||
|
||||
defineEmits<{
|
||||
logout: []
|
||||
}>()
|
||||
|
||||
const layoutId = computed(() => getHomeLayoutId())
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import AssetsSection from '../../components/AssetsSection.vue'
|
||||
import DesktopHomeCard from './DesktopHomeCard.vue'
|
||||
import type { WalletMetalTotal } from '@/models/walletAssets'
|
||||
|
||||
defineEmits<{
|
||||
openGoldModal: [metal: WalletMetalTotal]
|
||||
openRialModal: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DesktopHomeCard title="داراییهای من">
|
||||
<AssetsSection
|
||||
plain
|
||||
class="home-desktop-assets"
|
||||
@open-gold-modal="$emit('openGoldModal', $event)"
|
||||
@open-rial-modal="$emit('openRialModal')"
|
||||
/>
|
||||
</DesktopHomeCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.home-desktop-assets) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
:deep(.home-desktop-assets > div) {
|
||||
margin-top: 0 !important;
|
||||
justify-content: flex-start !important;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: #eff5f3;
|
||||
border: 1px solid #d4e3de;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import BoursecuAssetsSummary from '../../components/BoursecuAssetsSummary.vue'
|
||||
import { formatWeightAmount, usePriceWithoutType } from '@/utils/usePrice'
|
||||
import { useWalletAssets } from '@/composables/useWalletAssets'
|
||||
import type { WalletMetalTotal } from '@/models/walletAssets'
|
||||
import { resolveSummaryMetalWeight } from '@/utils/productAssets'
|
||||
import { useHomePageContext } from '../../composables/useHomePage'
|
||||
|
||||
const emit = defineEmits<{
|
||||
openGoldModal: [metal: WalletMetalTotal, modalText?: string, modalTitle?: string, modalContent?: string]
|
||||
openRialModal: []
|
||||
}>()
|
||||
|
||||
const { assetsSummary } = useWalletAssets()
|
||||
const ctx = useHomePageContext()
|
||||
const isBoursecuLayout = computed(() => ctx.isBoursecuHomeLayout.value)
|
||||
|
||||
const meta = computed(() => assetsSummary.value?.meta)
|
||||
const metalTotals = computed(() => assetsSummary.value?.metal_totals ?? [])
|
||||
const inventoryCards = computed(() => assetsSummary.value?.inventory_cards ?? [])
|
||||
const cashWallet = computed(() => assetsSummary.value?.cash_wallet ?? 0)
|
||||
const countBasedInventory = computed(() =>
|
||||
(assetsSummary.value?.count_products ?? []).reduce((sum, item) => sum + Number(item.quantity ?? 0), 0),
|
||||
)
|
||||
|
||||
type MetricItem = {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
metal?: WalletMetalTotal
|
||||
showInfo?: boolean
|
||||
onInfo?: 'gold' | 'rial'
|
||||
}
|
||||
|
||||
const metrics = computed<MetricItem[]>(() => {
|
||||
const items: MetricItem[] = []
|
||||
|
||||
if (meta.value?.has_count_based_products) {
|
||||
items.push({
|
||||
id: 'products',
|
||||
label: 'محصولات',
|
||||
value: `${usePriceWithoutType(countBasedInventory.value, false)} عدد`,
|
||||
})
|
||||
}
|
||||
|
||||
if (meta.value?.single_metal && metalTotals.value[0]) {
|
||||
const metal = metalTotals.value[0]
|
||||
items.push({
|
||||
id: `metal-${metal.metal_id}`,
|
||||
label: metal.label,
|
||||
value: formatMetalTotal(metal),
|
||||
metal,
|
||||
showInfo: true,
|
||||
onInfo: 'gold',
|
||||
})
|
||||
} else {
|
||||
metalTotals.value.forEach((metal, index) => {
|
||||
items.push({
|
||||
id: `metal-${metal.metal_id}`,
|
||||
label: metal.label,
|
||||
value: formatMetalTotal(metal),
|
||||
metal,
|
||||
showInfo: index === 0,
|
||||
onInfo: 'gold',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: 'rial',
|
||||
label: 'دارایی ریالی',
|
||||
value: `${usePriceWithoutType(cashWallet.value)} ریال`,
|
||||
showInfo: true,
|
||||
onInfo: 'rial',
|
||||
})
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
function formatMetalTotal(metal: WalletMetalTotal) {
|
||||
return `${formatWeightAmount(resolveSummaryMetalWeight(metal, inventoryCards.value))}${metal.unit}`
|
||||
}
|
||||
|
||||
function handleInfo(metric: MetricItem) {
|
||||
if (metric.onInfo === 'rial') {
|
||||
emit('openRialModal')
|
||||
return
|
||||
}
|
||||
if (metric.metal) {
|
||||
emit('openGoldModal', metric.metal)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BoursecuAssetsSummary
|
||||
v-if="isBoursecuLayout"
|
||||
variant="desktop"
|
||||
icon-color="tertiary"
|
||||
/>
|
||||
|
||||
<div v-else class="dhome-metrics">
|
||||
<article v-for="metric in metrics" :key="metric.id" class="dhome-metric-tile">
|
||||
<div class="dhome-metric-tile__head">
|
||||
<span class="dhome-metric-tile__label">{{ metric.label }}</span>
|
||||
<button
|
||||
v-if="metric.showInfo"
|
||||
type="button"
|
||||
class="dhome-metric-tile__info"
|
||||
aria-label="اطلاعات بیشتر"
|
||||
@click="handleInfo(metric)"
|
||||
>
|
||||
<BasicIcon icon="vuesax-linear:info-circle" size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="dhome-metric-tile__value">{{ metric.value }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import DesktopHomeSection from './DesktopHomeSection.vue'
|
||||
import BannerSlider from '../../components/BannerSlider.vue'
|
||||
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
embedded?: boolean
|
||||
}>(),
|
||||
{
|
||||
embedded: false,
|
||||
},
|
||||
)
|
||||
|
||||
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="embedded ? 'div' : DesktopHomeSection"
|
||||
v-if="ctx.bannerEnabled.value && ctx.banners.value?.length"
|
||||
:title="embedded ? undefined : 'بنرها'"
|
||||
class="dhome-banners"
|
||||
:class="{ 'dhome-banners--embedded': embedded }"
|
||||
>
|
||||
<BannerSlider
|
||||
:banners="ctx.banners.value"
|
||||
@bannerClick="ctx.handleBannerClick"
|
||||
/>
|
||||
</component>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toValue } from 'vue'
|
||||
import { useHomePageContext } from '../../composables/useHomePage'
|
||||
import PriceChart from '../../components/PriceChart.vue'
|
||||
import DesktopHomeCard from '../components/DesktopHomeCard.vue'
|
||||
|
||||
const ctx = useHomePageContext()
|
||||
|
||||
const chartLayout = computed(() => (toValue(ctx.isBoursecuHomeLayout) ? 'boursecu' : 'default'))
|
||||
const priceChartSeries = computed(() => toValue(ctx.priceChartSeries))
|
||||
const chartSelection = computed(() => toValue(ctx.chartSelection))
|
||||
const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DesktopHomeCard title="نمودار قیمت">
|
||||
<PriceChart
|
||||
variant="desktop"
|
||||
:price-chart-series="priceChartSeries"
|
||||
:layout="chartLayout"
|
||||
:chart-selection="chartSelection"
|
||||
:selected-metal="selectedChartMetal"
|
||||
@update-data="ctx.updateChartData"
|
||||
@update:selected-metal="ctx.setChartMetal"
|
||||
/>
|
||||
</DesktopHomeCard>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toValue } from 'vue'
|
||||
import { useHomePageContext } from '../../composables/useHomePage'
|
||||
import PriceChart from '../../components/PriceChart.vue'
|
||||
import DesktopHomeSection from './DesktopHomeSection.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
embedded?: boolean
|
||||
}>(),
|
||||
{
|
||||
embedded: false,
|
||||
},
|
||||
)
|
||||
|
||||
const ctx = useHomePageContext()
|
||||
|
||||
const chartLayout = computed(() => (toValue(ctx.isBoursecuHomeLayout) ? 'boursecu' : 'default'))
|
||||
const priceChartSeries = computed(() => toValue(ctx.priceChartSeries))
|
||||
const chartSelection = computed(() => toValue(ctx.chartSelection))
|
||||
const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="props.embedded ? 'div' : DesktopHomeSection"
|
||||
:title="props.embedded ? undefined : 'نمودار قیمت'"
|
||||
class="dhome-chart"
|
||||
>
|
||||
<PriceChart
|
||||
variant="desktop"
|
||||
:price-chart-series="priceChartSeries"
|
||||
:layout="chartLayout"
|
||||
:chart-selection="chartSelection"
|
||||
:selected-metal="selectedChartMetal"
|
||||
@update-data="ctx.updateChartData"
|
||||
@update:selected-metal="ctx.setChartMetal"
|
||||
/>
|
||||
</component>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { toValue } from 'vue'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import DesktopHomeSection from './DesktopHomeSection.vue'
|
||||
import { useHomePageContext } from '../../composables/useHomePage'
|
||||
import type { HomeActionItem } from '../../layouts/types'
|
||||
|
||||
const ctx = useHomePageContext()
|
||||
|
||||
function runAction(action: HomeActionItem) {
|
||||
action.handler()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DesktopHomeSection title="عملیات">
|
||||
<div class="dhome-default-actions">
|
||||
<button
|
||||
v-for="action in toValue(ctx.mainActions)"
|
||||
:key="action.name"
|
||||
type="button"
|
||||
class="dhome-default-action"
|
||||
@click="runAction(action)"
|
||||
>
|
||||
<span class="dhome-default-action__icon">
|
||||
<BasicIcon :icon="action.icon" size="20" />
|
||||
</span>
|
||||
<span class="dhome-default-action__label">{{ action.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</DesktopHomeSection>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { usePriceWithoutType } from '@/utils/usePrice'
|
||||
import DesktopHomeSection from './DesktopHomeSection.vue'
|
||||
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
|
||||
|
||||
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
|
||||
const userStore = useUserStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DesktopHomeSection v-if="ctx.isGaroosi.value" title="داراییها">
|
||||
<div class="dhome-garoosi-assets">
|
||||
<div class="dhome-garoosi-assets__item">
|
||||
<span class="dhome-garoosi-assets__label">دارایی طلایی</span>
|
||||
<span class="dhome-garoosi-assets__value">
|
||||
{{ (userStore?.userInfo?.wallet?.count_gold ?? 0).toFixed(3) }} گرم
|
||||
</span>
|
||||
</div>
|
||||
<div class="dhome-garoosi-assets__divider" />
|
||||
<div class="dhome-garoosi-assets__item">
|
||||
<span class="dhome-garoosi-assets__label">دارایی ریالی</span>
|
||||
<span class="dhome-garoosi-assets__value">
|
||||
{{ usePriceWithoutType(userStore?.userInfo?.wallet?.count_cash ?? 0) }} ریال
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DesktopHomeSection>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { usePriceWithoutType } from '@/utils/usePrice'
|
||||
import { isBoursecuProject } from '@/utils/config'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
|
||||
|
||||
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
|
||||
const userStore = useUserStore()
|
||||
const isBoursecu = isBoursecuProject()
|
||||
|
||||
const showLimitedPrice = computed(
|
||||
() =>
|
||||
ctx.limitedPrice.value !== undefined &&
|
||||
userStore.userInfo?.trade_active !== 1,
|
||||
)
|
||||
|
||||
const limitedPriceText = computed(() =>
|
||||
usePriceWithoutType(Number(ctx.limitedPrice.value?.value ?? 0)),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="showLimitedPrice || ctx.showClosedMessage.value" class="dhome-alerts">
|
||||
<div v-if="showLimitedPrice" class="dhome-alert dhome-alert--warning">
|
||||
<BasicIcon icon="vuesax-linear:info-circle" size="14" />
|
||||
<span>
|
||||
حداقل مبلغ اولیه برای ثبت سفارش {{ limitedPriceText }} ریال میباشد.
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isBoursecu && ctx.showClosedMessage.value"
|
||||
class="dhome-alert dhome-alert--info"
|
||||
v-html="ctx.closedMessage.value"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<section class="rounded-xl border border-[#d4e3de] bg-white p-5 shadow-sm">
|
||||
<h2 v-if="title" class="mb-4 text-base font-bold text-primary">{{ title }}</h2>
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
title?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<header class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">خوش آمدید</p>
|
||||
<h1 class="text-xl font-bold text-gray-900">
|
||||
{{ userStore.userInfo?.name ?? 'کاربر' }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 rounded-lg border border-[#d4e3de] bg-[#eff5f3] px-4 py-2 text-sm font-bold text-primary transition hover:bg-[#e4efea]"
|
||||
@click="router.push({ name: 'user' })"
|
||||
>
|
||||
<BasicIcon icon="vuesax-linear:user" size="18" />
|
||||
<span>حساب کاربری</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="showLogout"
|
||||
type="button"
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 px-4 py-2 text-sm font-bold text-gray-600 transition hover:bg-gray-50"
|
||||
@click="$emit('logout')"
|
||||
>
|
||||
<BasicIcon icon="vuesax-linear:logout" size="18" />
|
||||
<span>خروج</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import router from '@/router'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
showLogout?: boolean
|
||||
}>(),
|
||||
{ showLogout: true },
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
logout: []
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<section class="dhome-surface">
|
||||
<header v-if="title" class="dhome-section__header">
|
||||
<h2 class="dhome-section__title">{{ title }}</h2>
|
||||
<slot name="header-extra" />
|
||||
</header>
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
title?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="dhome" :class="{ 'dhome--boursecu': isBoursecu }">
|
||||
<div class="dhome__container">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { isBoursecuProject } from '@/utils/config'
|
||||
|
||||
const isBoursecu = isBoursecuProject()
|
||||
</script>
|
||||
|
||||
<style lang="less" src="../styles/desktop-home.less"></style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<header class="dhome-topbar">
|
||||
<div>
|
||||
<p class="dhome-topbar__eyebrow">نمای کلی</p>
|
||||
<h1 class="dhome-topbar__title">{{ userStore.userInfo?.name ?? 'کاربر' }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="dhome-topbar__actions">
|
||||
<button type="button" class="dhome-btn-ghost" @click="router.push({ name: 'user' })">
|
||||
<BasicIcon icon="vuesax-linear:user" size="16" />
|
||||
<span>حساب کاربری</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="showLogout"
|
||||
type="button"
|
||||
class="dhome-btn-ghost"
|
||||
@click="$emit('logout')"
|
||||
>
|
||||
<BasicIcon icon="vuesax-linear:logout" size="16" />
|
||||
<span>خروج</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import router from '@/router'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
showLogout?: boolean
|
||||
}>(),
|
||||
{ showLogout: true },
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
logout: []
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
</script>
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div
|
||||
class="dhome-surface dhome-hero-price"
|
||||
:class="{
|
||||
'dhome-hero-price--boursecu-open': isBoursecu && displayState === 'open',
|
||||
'dhome-hero-price--boursecu-closed': isBoursecu && displayState !== 'open',
|
||||
}"
|
||||
>
|
||||
<template v-if="displayState !== 'open'">
|
||||
<div class="dhome-hero-price__closed" :class="{ 'dhome-hero-price__closed--default': !isBoursecu }">
|
||||
<div class="dhome-hero-price__closed-icon">
|
||||
<BasicIcon
|
||||
:icon="displayState === 'closed' ? 'vuesax-linear:lock' : 'vuesax-linear:info-circle'"
|
||||
size="22"
|
||||
:color="isBoursecu ? '#ffffff' : '#64748b'"
|
||||
/>
|
||||
</div>
|
||||
<p class="dhome-hero-price__closed-title">{{ statusTitle }}</p>
|
||||
<p class="dhome-hero-price__closed-hint">{{ statusHint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<span class="dhome-label" :class="{ 'dhome-label--boursecu': isBoursecu }">
|
||||
{{ isBoursecu ? 'قیمت لحظهای کاتد مس' : 'قیمت لحظهای' }}
|
||||
</span>
|
||||
<p v-if="!isBoursecu" class="dhome-hero-price__brand">{{ config.config?.name_fa }}</p>
|
||||
<div class="dhome-hero-price__value-row">
|
||||
<span
|
||||
v-if="percentChange !== 0"
|
||||
class="dhome-hero-price__delta"
|
||||
:class="percentChange > 0 ? 'dhome-hero-price__delta--up' : 'dhome-hero-price__delta--down'"
|
||||
>
|
||||
<BasicIcon
|
||||
:icon="percentChange > 0 ? 'vuesax-linear:arrow-up-2' : 'vuesax-linear:arrow-down-2'"
|
||||
size="14"
|
||||
/>
|
||||
<span class="dhome-hero-price__delta-value">{{ formatSignedPercentChange(percentChange) }}</span>
|
||||
</span>
|
||||
<Vue3Odometer
|
||||
:value="displayGoldVal"
|
||||
class="dhome-hero-price__odometer o-text"
|
||||
:class="{ 'dhome-hero-price__odometer--boursecu': isBoursecu }"
|
||||
/>
|
||||
<span class="dhome-hero-price__unit" :class="{ 'dhome-hero-price__unit--boursecu': isBoursecu }">ریال</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Vue3Odometer from 'vue3-odometer'
|
||||
import 'odometer/themes/odometer-theme-default.css'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { useConfigStore } from '@/store/configStore'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { isBoursecuProject } from '@/utils/config'
|
||||
import { getTradeBuyPrice, lastPrice, percentPriceChanged } from '@/utils/price'
|
||||
import {
|
||||
NO_QUOTE_MESSAGE,
|
||||
getPriceDisplayState,
|
||||
getStoreClosedMessage,
|
||||
} from '@/utils/storeHours'
|
||||
import { formatSignedPercentChange } from '@/utils/usePrice'
|
||||
|
||||
const config = useConfigStore()
|
||||
const userStore = useUserStore()
|
||||
const isBoursecu = isBoursecuProject()
|
||||
|
||||
const displayState = computed(() => getPriceDisplayState())
|
||||
|
||||
const statusTitle = computed(() =>
|
||||
displayState.value === 'closed' ? 'فروشگاه بسته است' : NO_QUOTE_MESSAGE
|
||||
)
|
||||
|
||||
const statusHint = computed(() => {
|
||||
if (displayState.value === 'closed') {
|
||||
const custom = userStore.getValueByKey('closed_message')
|
||||
if (typeof custom === 'string' && custom.trim()) {
|
||||
return custom.replace(/<[^>]*>/g, '').trim()
|
||||
}
|
||||
return getStoreClosedMessage()
|
||||
}
|
||||
return 'در حال حاضر مظنهای برای نمایش وجود ندارد.'
|
||||
})
|
||||
|
||||
const displayGoldVal = computed(() => {
|
||||
if (displayState.value !== 'open') return 0
|
||||
return getTradeBuyPrice(lastPrice.value)
|
||||
})
|
||||
|
||||
const percentChange = computed(() => {
|
||||
if (!getTradeBuyPrice(lastPrice.value)) return 0
|
||||
return percentPriceChanged()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.dhome-hero-price--boursecu-open {
|
||||
background: linear-gradient(135deg, #c98a4a 0%, #a86528 100%);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 14px rgba(168, 101, 40, 0.28);
|
||||
}
|
||||
|
||||
.dhome-hero-price--boursecu-closed {
|
||||
background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 14px rgba(45, 55, 72, 0.22);
|
||||
}
|
||||
|
||||
.dhome-label--boursecu {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.dhome-hero-price__odometer--boursecu {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.dhome-hero-price__unit--boursecu {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.dhome-hero-price__closed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dhome-hero-price__closed-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dhome-hero-price__closed-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.dhome-hero-price__closed-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.dhome-hero-price__closed--default {
|
||||
.dhome-hero-price__closed-icon {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.dhome-hero-price__closed-title {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.dhome-hero-price__closed-hint {
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import CurrentPrice from '../../components/CurrentPrice.vue'
|
||||
import PriceStats from '../../components/PriceStats.vue'
|
||||
import DesktopHomeCard from './DesktopHomeCard.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<DesktopHomeCard title="قیمت لحظهای">
|
||||
<CurrentPrice class="home-desktop-current-price" />
|
||||
</DesktopHomeCard>
|
||||
|
||||
<DesktopHomeCard title="بازه قیمت امروز">
|
||||
<PriceStats variant="desktop" />
|
||||
</DesktopHomeCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.home-desktop-current-price) {
|
||||
margin-top: 0;
|
||||
border: none;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { price } from '@/utils/price'
|
||||
import { usePriceWithoutType } from '@/utils/usePrice'
|
||||
import { getHomeLayoutId } from '../../layouts/registry'
|
||||
import { resolveMetalDayRangePrices, usePriceChart } from '../../composables/usePriceChart'
|
||||
|
||||
const { selectedChartMetal, selectedMetalChartRatio } = usePriceChart()
|
||||
const isBoursecuLayout = computed(() => getHomeLayoutId() === 'boursecu')
|
||||
|
||||
const rangePrices = computed(() =>
|
||||
resolveMetalDayRangePrices({
|
||||
price: price.value,
|
||||
metal: selectedChartMetal.value,
|
||||
isBoursecuLayout: isBoursecuLayout.value,
|
||||
metalRatio: selectedMetalChartRatio.value,
|
||||
})
|
||||
)
|
||||
|
||||
const highPrice = computed(() => rangePrices.value.high)
|
||||
const lowPrice = computed(() => rangePrices.value.low)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dhome-surface dhome-price-range">
|
||||
<div class="dhome-price-range__item">
|
||||
<span class="dhome-label">بیشترین امروز</span>
|
||||
<span class="dhome-price-range__value dhome-price-range__value--high">
|
||||
{{ usePriceWithoutType(highPrice) }}
|
||||
<span class="text-xs font-medium text-gray-400 mr-1">ریال</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="dhome-price-range__divider" />
|
||||
<div class="dhome-price-range__item">
|
||||
<span class="dhome-label">کمترین امروز</span>
|
||||
<span class="dhome-price-range__value dhome-price-range__value--low">
|
||||
{{ usePriceWithoutType(lowPrice) }}
|
||||
<span class="text-xs font-medium text-gray-400 mr-1">ریال</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user