fix kashkool

This commit is contained in:
2026-06-29 22:43:28 +03:30
parent be17631109
commit 8b855e5ee4
150 changed files with 11974 additions and 4445 deletions
-36
View File
@@ -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
View File
@@ -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
View File
@@ -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`)
+4
View File
@@ -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
View File
@@ -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) {
+14
View File
@@ -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;
}
+1
View File
@@ -2,6 +2,7 @@
@primary-color: #a72b77;
@secondary-color: #a72b77;
@App-primary-color: #a72b77;
@prefixCls-font-family: 'iran-sans';
@prefixCls-background-color: #fff;
+55
View File
@@ -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
}
+70
View File
@@ -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,
}
}
+4
View File
@@ -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())
}
+12
View File
@@ -0,0 +1,12 @@
<template>
<DesktopAppLayout v-if="isDesktopLayout" />
<MobileAppLayout v-else />
</template>
<script lang="ts" setup>
import { useDesktopBreakpoint } from './composables/useAppLayout'
import DesktopAppLayout from './desktop/DesktopAppLayout.vue'
import MobileAppLayout from './mobile/MobileAppLayout.vue'
const isDesktopLayout = useDesktopBreakpoint()
</script>
@@ -0,0 +1,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>
+26
View File
@@ -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'
+38
View File
@@ -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 : []
}
+4
View File
@@ -0,0 +1,4 @@
/** Minimum viewport width for desktop app shell (sidebar layout). */
export const DESKTOP_LAYOUT_MIN_WIDTH = 768
export const DESKTOP_SIDEBAR_WIDTH = 260
+38
View File
@@ -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>
+65
View File
@@ -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>
+57
View File
@@ -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>
+151
View File
@@ -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);
}
}
}
+41
View File
@@ -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>
+63
View File
@@ -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>
+117
View File
@@ -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>
+139
View File
@@ -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;
}
+174
View File
@@ -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;
}
+51
View File
@@ -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;
}
}
}
+9
View File
@@ -0,0 +1,9 @@
export interface AppNavItem {
id: string
routeName: string
label: string
icon: string
iconSrc?: string
authOnly?: boolean
guestOnly?: boolean
}
+1 -4
View File
@@ -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
View File
@@ -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
}
+44
View File
@@ -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
View File
@@ -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
}
})
-158
View File
@@ -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
View File
@@ -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()
}
}
+7 -9
View File
@@ -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'
+71
View File
@@ -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
}
+103
View File
@@ -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
}
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+14 -154
View File
@@ -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>
+48 -56
View File
@@ -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
View File
@@ -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>
+42 -67
View File
@@ -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>
+13 -281
View File
@@ -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;
}
}
+13 -71
View File
@@ -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>
+46 -55
View File
@@ -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