diff --git a/index.html b/index.html index 82618a8..fe0d5f2 100644 --- a/index.html +++ b/index.html @@ -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; - } - - - \ No newline at end of file diff --git a/src/App.vue b/src/App.vue index 80f3f2c..08e3eea 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,12 +1,5 @@ @@ -62,14 +70,22 @@ diff --git a/src/layouts/components/DesktopPageFrame.vue b/src/layouts/components/DesktopPageFrame.vue new file mode 100644 index 0000000..e0e316e --- /dev/null +++ b/src/layouts/components/DesktopPageFrame.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/src/layouts/components/DesktopPageTopbar.vue b/src/layouts/components/DesktopPageTopbar.vue new file mode 100644 index 0000000..c0561c2 --- /dev/null +++ b/src/layouts/components/DesktopPageTopbar.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/layouts/components/ResponsiveViewPair.vue b/src/layouts/components/ResponsiveViewPair.vue new file mode 100644 index 0000000..bc0cb15 --- /dev/null +++ b/src/layouts/components/ResponsiveViewPair.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/src/layouts/composables/useAppLayout.ts b/src/layouts/composables/useAppLayout.ts new file mode 100644 index 0000000..3e34fea --- /dev/null +++ b/src/layouts/composables/useAppLayout.ts @@ -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) + }) +} diff --git a/src/layouts/composables/useAppNavigation.ts b/src/layouts/composables/useAppNavigation.ts new file mode 100644 index 0000000..7a65a9d --- /dev/null +++ b/src/layouts/composables/useAppNavigation.ts @@ -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, + } +} diff --git a/src/layouts/composables/useResponsiveLayout.ts b/src/layouts/composables/useResponsiveLayout.ts new file mode 100644 index 0000000..a66e140 --- /dev/null +++ b/src/layouts/composables/useResponsiveLayout.ts @@ -0,0 +1 @@ +export { useDesktopBreakpoint, useApplyDesktopLayoutClass } from './useAppLayout' diff --git a/src/layouts/config/navigation.ts b/src/layouts/config/navigation.ts new file mode 100644 index 0000000..1b86cb7 --- /dev/null +++ b/src/layouts/config/navigation.ts @@ -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 : [] +} diff --git a/src/layouts/constants.ts b/src/layouts/constants.ts new file mode 100644 index 0000000..409b42f --- /dev/null +++ b/src/layouts/constants.ts @@ -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 diff --git a/src/layouts/desktop/DesktopAppLayout.vue b/src/layouts/desktop/DesktopAppLayout.vue new file mode 100644 index 0000000..f9c1711 --- /dev/null +++ b/src/layouts/desktop/DesktopAppLayout.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/src/layouts/desktop/DesktopNavItem.vue b/src/layouts/desktop/DesktopNavItem.vue new file mode 100644 index 0000000..1a7b55b --- /dev/null +++ b/src/layouts/desktop/DesktopNavItem.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/src/layouts/desktop/DesktopSidebar.vue b/src/layouts/desktop/DesktopSidebar.vue new file mode 100644 index 0000000..5ab1232 --- /dev/null +++ b/src/layouts/desktop/DesktopSidebar.vue @@ -0,0 +1,57 @@ + + + diff --git a/src/layouts/desktop/desktop-layout.less b/src/layouts/desktop/desktop-layout.less new file mode 100644 index 0000000..93b4d48 --- /dev/null +++ b/src/layouts/desktop/desktop-layout.less @@ -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; +} diff --git a/src/layouts/desktop/styles/desktop-inner-pages.less b/src/layouts/desktop/styles/desktop-inner-pages.less new file mode 100644 index 0000000..810af9f --- /dev/null +++ b/src/layouts/desktop/styles/desktop-inner-pages.less @@ -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; + } +} diff --git a/src/layouts/desktop/styles/desktop-page-common.less b/src/layouts/desktop/styles/desktop-page-common.less new file mode 100644 index 0000000..ccd26ea --- /dev/null +++ b/src/layouts/desktop/styles/desktop-page-common.less @@ -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); + } + } +} diff --git a/src/layouts/mobile/MobileAppLayout.vue b/src/layouts/mobile/MobileAppLayout.vue new file mode 100644 index 0000000..cc433e3 --- /dev/null +++ b/src/layouts/mobile/MobileAppLayout.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/src/layouts/mobile/MobileBottomNav.vue b/src/layouts/mobile/MobileBottomNav.vue new file mode 100644 index 0000000..29ada63 --- /dev/null +++ b/src/layouts/mobile/MobileBottomNav.vue @@ -0,0 +1,63 @@ + + + diff --git a/src/layouts/mobile/MobileOverflowMenu.vue b/src/layouts/mobile/MobileOverflowMenu.vue new file mode 100644 index 0000000..14eda1a --- /dev/null +++ b/src/layouts/mobile/MobileOverflowMenu.vue @@ -0,0 +1,117 @@ + + + diff --git a/src/layouts/mobile/mobile-layout.less b/src/layouts/mobile/mobile-layout.less new file mode 100644 index 0000000..b7ead45 --- /dev/null +++ b/src/layouts/mobile/mobile-layout.less @@ -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; +} diff --git a/src/layouts/styles/desktop-shell.less b/src/layouts/styles/desktop-shell.less new file mode 100644 index 0000000..467e24d --- /dev/null +++ b/src/layouts/styles/desktop-shell.less @@ -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; +} diff --git a/src/layouts/styles/mobile-frame.less b/src/layouts/styles/mobile-frame.less new file mode 100644 index 0000000..d07de8c --- /dev/null +++ b/src/layouts/styles/mobile-frame.less @@ -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; + } + } +} diff --git a/src/layouts/types.ts b/src/layouts/types.ts new file mode 100644 index 0000000..53a439c --- /dev/null +++ b/src/layouts/types.ts @@ -0,0 +1,9 @@ +export interface AppNavItem { + id: string + routeName: string + label: string + icon: string + iconSrc?: string + authOnly?: boolean + guestOnly?: boolean +} diff --git a/src/main.ts b/src/main.ts index 2c38f22..ff60a88 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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() diff --git a/src/models/home.ts b/src/models/home.ts index 0d8cf40..0617c94 100644 --- a/src/models/home.ts +++ b/src/models/home.ts @@ -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 -} \ No newline at end of file + sell: number + buy: number + price: number + price_last: number + goldir?: number + up?: PriceUp + closed?: boolean + status?: PriceStatus + time?: string + main_product?: number + montzar?: number +} diff --git a/src/models/walletAssets.ts b/src/models/walletAssets.ts new file mode 100644 index 0000000..6185fd5 --- /dev/null +++ b/src/models/walletAssets.ts @@ -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 +} diff --git a/src/store/cartStore.ts b/src/store/cartStore.ts index fb2e463..58e02e8 100644 --- a/src/store/cartStore.ts +++ b/src/store/cartStore.ts @@ -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 } }) diff --git a/src/utils/initWhatsappButtonDrag.ts b/src/utils/initWhatsappButtonDrag.ts deleted file mode 100644 index 18aeaac..0000000 --- a/src/utils/initWhatsappButtonDrag.ts +++ /dev/null @@ -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) - }) -} diff --git a/src/utils/price.ts b/src/utils/price.ts index e96a25e..3863515 100644 --- a/src/utils/price.ts +++ b/src/utils/price.ts @@ -20,14 +20,28 @@ export const olderPrice = ref(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 { } 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() } } diff --git a/src/utils/priceSocket.ts b/src/utils/priceSocket.ts index 54a462f..0589c98 100644 --- a/src/utils/priceSocket.ts +++ b/src/utils/priceSocket.ts @@ -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' diff --git a/src/utils/productAssets.ts b/src/utils/productAssets.ts new file mode 100644 index 0000000..4533e04 --- /dev/null +++ b/src/utils/productAssets.ts @@ -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(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( + 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 +} diff --git a/src/utils/productPrice.ts b/src/utils/productPrice.ts new file mode 100644 index 0000000..3d6b7e2 --- /dev/null +++ b/src/utils/productPrice.ts @@ -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 +} diff --git a/src/utils/productStreamBridge.ts b/src/utils/productStreamBridge.ts index 7b93c79..cb1c450 100644 --- a/src/utils/productStreamBridge.ts +++ b/src/utils/productStreamBridge.ts @@ -3,10 +3,15 @@ import type { Product } from '@/models/products' type ProductListHandler = (products: Product[]) => void const handlers = new Set() +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 +} diff --git a/src/utils/storeHours.ts b/src/utils/storeHours.ts index 5b74c8f..5232cc3 100644 --- a/src/utils/storeHours.ts +++ b/src/utils/storeHours.ts @@ -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 '' +} diff --git a/src/utils/useAxios.ts b/src/utils/useAxios.ts index 2e612a5..012b0bf 100644 --- a/src/utils/useAxios.ts +++ b/src/utils/useAxios.ts @@ -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 + 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)) { + 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 diff --git a/src/views/base/about/aboutContent.ts b/src/views/base/about/aboutContent.ts new file mode 100644 index 0000000..a3fc4eb --- /dev/null +++ b/src/views/base/about/aboutContent.ts @@ -0,0 +1,13 @@ +export interface AboutSection { + title?: string + paragraphs?: string[] + items?: string[] +} + +export function getAboutSections(projectName: string): AboutSection[] { + return [ + { + paragraphs: [`${projectName} وبسایت رسمی است.`], + }, + ] +} diff --git a/src/views/base/about/components/AboutSectionsContent.vue b/src/views/base/about/components/AboutSectionsContent.vue new file mode 100644 index 0000000..2a24f23 --- /dev/null +++ b/src/views/base/about/components/AboutSectionsContent.vue @@ -0,0 +1,28 @@ + + + diff --git a/src/views/base/about/components/AboutSupportActions.vue b/src/views/base/about/components/AboutSupportActions.vue new file mode 100644 index 0000000..32ea3ad --- /dev/null +++ b/src/views/base/about/components/AboutSupportActions.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/views/base/about/composables/useAboutPage.ts b/src/views/base/about/composables/useAboutPage.ts new file mode 100644 index 0000000..6775722 --- /dev/null +++ b/src/views/base/about/composables/useAboutPage.ts @@ -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 + aboutSections: ComputedRef + supportHref: ComputedRef +} + +export const ABOUT_PAGE_CONTEXT_KEY: InjectionKey = 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 +} diff --git a/src/views/base/about/desktop/DesktopAboutView.vue b/src/views/base/about/desktop/DesktopAboutView.vue new file mode 100644 index 0000000..d9857e7 --- /dev/null +++ b/src/views/base/about/desktop/DesktopAboutView.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/src/views/base/about/desktop/styles/desktop-about.less b/src/views/base/about/desktop/styles/desktop-about.less new file mode 100644 index 0000000..8f940a0 --- /dev/null +++ b/src/views/base/about/desktop/styles/desktop-about.less @@ -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; + } +} diff --git a/src/views/base/about/index.vue b/src/views/base/about/index.vue index d2c097c..99e4560 100644 --- a/src/views/base/about/index.vue +++ b/src/views/base/about/index.vue @@ -1,195 +1,23 @@ diff --git a/src/views/base/about/mobile/MobileAboutView.vue b/src/views/base/about/mobile/MobileAboutView.vue new file mode 100644 index 0000000..c747a62 --- /dev/null +++ b/src/views/base/about/mobile/MobileAboutView.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/views/base/cart/composables/useCartPage.ts b/src/views/base/cart/composables/useCartPage.ts new file mode 100644 index 0000000..41219cc --- /dev/null +++ b/src/views/base/cart/composables/useCartPage.ts @@ -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 + loading: typeof loading + infoVisible: ReturnType> + showDiscountModal: ReturnType> + discountCode: ReturnType> + discountInfo: ReturnType> + price: ReturnType> + totalPrice: ReturnType> + displaytotalPrice: ReturnType> + discountPercentLabel: ReturnType> + formatWeight: (weight?: number) => string + handleRemoveItem: (itemId: string) => void + handleCheckout: () => Promise + submitDiscount: () => Promise + removeDiscount: () => void +} + +export const CART_PAGE_CONTEXT_KEY: InjectionKey = 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() + + let cartRefreshInterval: ReturnType | undefined + let unsubscribeProductStream: (() => void) | undefined + + const price = computed((): 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 +} diff --git a/src/views/base/cart/desktop/DesktopCartView.vue b/src/views/base/cart/desktop/DesktopCartView.vue new file mode 100644 index 0000000..45c9ceb --- /dev/null +++ b/src/views/base/cart/desktop/DesktopCartView.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/src/views/base/cart/index.vue b/src/views/base/cart/index.vue index 06fe89a..b8ac4f7 100644 --- a/src/views/base/cart/index.vue +++ b/src/views/base/cart/index.vue @@ -1,464 +1,37 @@ - diff --git a/src/views/base/cart/mobile/MobileCartView.vue b/src/views/base/cart/mobile/MobileCartView.vue new file mode 100644 index 0000000..761d043 --- /dev/null +++ b/src/views/base/cart/mobile/MobileCartView.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/src/views/base/earnMoney/composables/useEarnMoneyPage.ts b/src/views/base/earnMoney/composables/useEarnMoneyPage.ts new file mode 100644 index 0000000..f4a3556 --- /dev/null +++ b/src/views/base/earnMoney/composables/useEarnMoneyPage.ts @@ -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> + userStore: ReturnType + isTransaction: ReturnType> + setIsTransaction: (value: boolean) => void + copyCode: () => void + shareCode: () => void +} + +export const EARN_MONEY_PAGE_CONTEXT_KEY: InjectionKey = Symbol('earnMoneyPageContext') + +export function useEarnMoneyPage() { + const data = ref() + 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 +} diff --git a/src/views/base/earnMoney/desktop/DesktopEarnMoneyView.vue b/src/views/base/earnMoney/desktop/DesktopEarnMoneyView.vue new file mode 100644 index 0000000..ab246e0 --- /dev/null +++ b/src/views/base/earnMoney/desktop/DesktopEarnMoneyView.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/src/views/base/earnMoney/index.vue b/src/views/base/earnMoney/index.vue index 685aa7d..c41b0e0 100644 --- a/src/views/base/earnMoney/index.vue +++ b/src/views/base/earnMoney/index.vue @@ -1,163 +1,23 @@ \ No newline at end of file +.earn-money-page-root { + min-height: 100%; + height: 100%; +} + diff --git a/src/views/base/earnMoney/mobile/MobileEarnMoneyView.vue b/src/views/base/earnMoney/mobile/MobileEarnMoneyView.vue new file mode 100644 index 0000000..049bea7 --- /dev/null +++ b/src/views/base/earnMoney/mobile/MobileEarnMoneyView.vue @@ -0,0 +1,86 @@ + + + diff --git a/src/views/base/fund/components/Item.vue b/src/views/base/fund/components/Item.vue index 85c9322..f51126f 100644 --- a/src/views/base/fund/components/Item.vue +++ b/src/views/base/fund/components/Item.vue @@ -1,69 +1,61 @@ - - \ No newline at end of file diff --git a/src/views/base/fund/composables/useFundPage.ts b/src/views/base/fund/composables/useFundPage.ts new file mode 100644 index 0000000..a993974 --- /dev/null +++ b/src/views/base/fund/composables/useFundPage.ts @@ -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 + inventoryCards: ReturnType + metalTotals: ReturnType + primaryMetalTotal: ReturnType + cashWallet: ReturnType + totalRialValue: ReturnType + countBasedInventory: ReturnType + fundHeaderBackground: string + fundEmptyImage: string + fundEmptyText: string + formatMetalTotal: (metal: WalletMetalTotal) => string + openFundInfo: (productId: number) => void + goBack: () => void +} + +export const FUND_PAGE_KEY: InjectionKey = Symbol('fundPage') + +export function useFundPage(_isDesktop: Ref) { + 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 +} diff --git a/src/views/base/fund/desktop/DesktopFundView.vue b/src/views/base/fund/desktop/DesktopFundView.vue new file mode 100644 index 0000000..b12a8b1 --- /dev/null +++ b/src/views/base/fund/desktop/DesktopFundView.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/src/views/base/fund/desktop/styles/desktop-fund.less b/src/views/base/fund/desktop/styles/desktop-fund.less new file mode 100644 index 0000000..f9c4f54 --- /dev/null +++ b/src/views/base/fund/desktop/styles/desktop-fund.less @@ -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%; +} diff --git a/src/views/base/fund/index.vue b/src/views/base/fund/index.vue index bdef709..ce9b24f 100644 --- a/src/views/base/fund/index.vue +++ b/src/views/base/fund/index.vue @@ -1,122 +1,23 @@ \ No newline at end of file +.fund-page-root { + min-height: 100%; + height: 100%; +} + diff --git a/src/views/base/fund/info/components/Item.vue b/src/views/base/fund/info/components/Item.vue index b922120..d7d4f28 100644 --- a/src/views/base/fund/info/components/Item.vue +++ b/src/views/base/fund/info/components/Item.vue @@ -1,69 +1,63 @@ - - \ No newline at end of file diff --git a/src/views/base/fund/info/composables/useFundInfoPage.ts b/src/views/base/fund/info/composables/useFundInfoPage.ts new file mode 100644 index 0000000..bf8c01e --- /dev/null +++ b/src/views/base/fund/info/composables/useFundInfoPage.ts @@ -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> + items: ReturnType> + infoId: ReturnType> + goBack: () => void +} + +export const FUND_INFO_PAGE_KEY: InjectionKey = 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() + const items = ref([]) + const tableOutput = ref | null>(null) + const pagination = ref({}) + + 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 +} diff --git a/src/views/base/fund/info/desktop/DesktopFundInfoView.vue b/src/views/base/fund/info/desktop/DesktopFundInfoView.vue new file mode 100644 index 0000000..3f7eb6c --- /dev/null +++ b/src/views/base/fund/info/desktop/DesktopFundInfoView.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/src/views/base/fund/info/index.vue b/src/views/base/fund/info/index.vue index fb9eff8..bd9bdb3 100644 --- a/src/views/base/fund/info/index.vue +++ b/src/views/base/fund/info/index.vue @@ -1,291 +1,23 @@ diff --git a/src/views/base/fund/info/mobile/MobileFundInfoView.vue b/src/views/base/fund/info/mobile/MobileFundInfoView.vue new file mode 100644 index 0000000..a683ef3 --- /dev/null +++ b/src/views/base/fund/info/mobile/MobileFundInfoView.vue @@ -0,0 +1,51 @@ + + + diff --git a/src/views/base/fund/mobile/MobileFundView.vue b/src/views/base/fund/mobile/MobileFundView.vue new file mode 100644 index 0000000..aafe639 --- /dev/null +++ b/src/views/base/fund/mobile/MobileFundView.vue @@ -0,0 +1,114 @@ + + + diff --git a/src/views/base/giftCard/components/GiftCardForm.vue b/src/views/base/giftCard/components/GiftCardForm.vue new file mode 100644 index 0000000..007007d --- /dev/null +++ b/src/views/base/giftCard/components/GiftCardForm.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/views/base/giftCard/composables/useGiftCardPage.ts b/src/views/base/giftCard/composables/useGiftCardPage.ts new file mode 100644 index 0000000..0efd536 --- /dev/null +++ b/src/views/base/giftCard/composables/useGiftCardPage.ts @@ -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> + disableButton: ReturnType> + registerGiftCard: () => Promise +} + +export const GIFT_CARD_PAGE_CONTEXT_KEY: InjectionKey = 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 +} diff --git a/src/views/base/giftCard/desktop/DesktopGiftCardView.vue b/src/views/base/giftCard/desktop/DesktopGiftCardView.vue new file mode 100644 index 0000000..7df5e48 --- /dev/null +++ b/src/views/base/giftCard/desktop/DesktopGiftCardView.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/src/views/base/giftCard/desktop/styles/desktop-gift-card.less b/src/views/base/giftCard/desktop/styles/desktop-gift-card.less new file mode 100644 index 0000000..c5c3c84 --- /dev/null +++ b/src/views/base/giftCard/desktop/styles/desktop-gift-card.less @@ -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; + } +} diff --git a/src/views/base/giftCard/index.vue b/src/views/base/giftCard/index.vue index 76a2ae6..14e6f34 100644 --- a/src/views/base/giftCard/index.vue +++ b/src/views/base/giftCard/index.vue @@ -1,81 +1,23 @@ diff --git a/src/views/base/giftCard/mobile/MobileGiftCardView.vue b/src/views/base/giftCard/mobile/MobileGiftCardView.vue new file mode 100644 index 0000000..8c04cf6 --- /dev/null +++ b/src/views/base/giftCard/mobile/MobileGiftCardView.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/views/base/home/components/DesktopHomeTopbar.vue b/src/views/base/home/components/DesktopHomeTopbar.vue new file mode 100644 index 0000000..c632cd3 --- /dev/null +++ b/src/views/base/home/components/DesktopHomeTopbar.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/views/base/home/components/HomeSkeleton.vue b/src/views/base/home/components/HomeSkeleton.vue index 1e4b861..5311191 100644 --- a/src/views/base/home/components/HomeSkeleton.vue +++ b/src/views/base/home/components/HomeSkeleton.vue @@ -1,63 +1,54 @@ - - - - \ No newline at end of file diff --git a/src/views/base/home/composables/useBanners.ts b/src/views/base/home/composables/useBanners.ts new file mode 100644 index 0000000..40cc833 --- /dev/null +++ b/src/views/base/home/composables/useBanners.ts @@ -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([]) +const bannersLoading = ref(false) +let bannersPromise: Promise | 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 +} diff --git a/src/views/base/home/composables/useHomePageData.ts b/src/views/base/home/composables/useHomePageData.ts new file mode 100644 index 0000000..44a26cc --- /dev/null +++ b/src/views/base/home/composables/useHomePageData.ts @@ -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 | 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 } +} diff --git a/src/views/base/home/composables/usePriceChart.ts b/src/views/base/home/composables/usePriceChart.ts new file mode 100644 index 0000000..01e5076 --- /dev/null +++ b/src/views/base/home/composables/usePriceChart.ts @@ -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([]) +const chartSelection = ref('DAILY') +const chartLoading = ref(false) +const selectedChartMetal = ref('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 | null = null +let chartInitialized = false +let metalOffsetPromise: Promise | 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[0]) + const baseLow = options.price?.up?.sell ?? getTradeSellPrice(options.price as Parameters[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> | null { + if (!response || typeof response !== 'object') { + return null + } + + const body = response as Record + const nested = body.data + + if (nested && typeof nested === 'object' && 'prices' in nested) { + return (nested as { prices: Record> }).prices + } + + if ('prices' in body) { + return body.prices as Record> + } + + 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 +} diff --git a/src/views/base/home/desktop/GhatrehHomeDesktopView.vue b/src/views/base/home/desktop/GhatrehHomeDesktopView.vue new file mode 100644 index 0000000..b607d65 --- /dev/null +++ b/src/views/base/home/desktop/GhatrehHomeDesktopView.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/src/views/base/home/desktop/HomeDesktopView.vue b/src/views/base/home/desktop/HomeDesktopView.vue new file mode 100644 index 0000000..09045a1 --- /dev/null +++ b/src/views/base/home/desktop/HomeDesktopView.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/views/base/home/desktop/components/DesktopAssetsCard.vue b/src/views/base/home/desktop/components/DesktopAssetsCard.vue new file mode 100644 index 0000000..0a58640 --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopAssetsCard.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/src/views/base/home/desktop/components/DesktopAssetsMetrics.vue b/src/views/base/home/desktop/components/DesktopAssetsMetrics.vue new file mode 100644 index 0000000..02c9827 --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopAssetsMetrics.vue @@ -0,0 +1,121 @@ + + + diff --git a/src/views/base/home/desktop/components/DesktopBannersPanel.vue b/src/views/base/home/desktop/components/DesktopBannersPanel.vue new file mode 100644 index 0000000..fe2b46b --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopBannersPanel.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/views/base/home/desktop/components/DesktopChartPanel.vue b/src/views/base/home/desktop/components/DesktopChartPanel.vue new file mode 100644 index 0000000..6dd270c --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopChartPanel.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/views/base/home/desktop/components/DesktopChartSection.vue b/src/views/base/home/desktop/components/DesktopChartSection.vue new file mode 100644 index 0000000..92bbdb6 --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopChartSection.vue @@ -0,0 +1,40 @@ + + + diff --git a/src/views/base/home/desktop/components/DesktopDefaultActions.vue b/src/views/base/home/desktop/components/DesktopDefaultActions.vue new file mode 100644 index 0000000..4577d18 --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopDefaultActions.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/views/base/home/desktop/components/DesktopGaroosiAssets.vue b/src/views/base/home/desktop/components/DesktopGaroosiAssets.vue new file mode 100644 index 0000000..48326ec --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopGaroosiAssets.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/views/base/home/desktop/components/DesktopHomeAlerts.vue b/src/views/base/home/desktop/components/DesktopHomeAlerts.vue new file mode 100644 index 0000000..3925c5c --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopHomeAlerts.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/views/base/home/desktop/components/DesktopHomeCard.vue b/src/views/base/home/desktop/components/DesktopHomeCard.vue new file mode 100644 index 0000000..f95ac1a --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopHomeCard.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/views/base/home/desktop/components/DesktopHomeHeader.vue b/src/views/base/home/desktop/components/DesktopHomeHeader.vue new file mode 100644 index 0000000..445dda1 --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopHomeHeader.vue @@ -0,0 +1,49 @@ + + + diff --git a/src/views/base/home/desktop/components/DesktopHomeSection.vue b/src/views/base/home/desktop/components/DesktopHomeSection.vue new file mode 100644 index 0000000..88f4d9a --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopHomeSection.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/views/base/home/desktop/components/DesktopHomeShell.vue b/src/views/base/home/desktop/components/DesktopHomeShell.vue new file mode 100644 index 0000000..7d615ac --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopHomeShell.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/src/views/base/home/desktop/components/DesktopHomeTopbar.vue b/src/views/base/home/desktop/components/DesktopHomeTopbar.vue new file mode 100644 index 0000000..30183b4 --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopHomeTopbar.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/views/base/home/desktop/components/DesktopLivePrice.vue b/src/views/base/home/desktop/components/DesktopLivePrice.vue new file mode 100644 index 0000000..b4f9bfd --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopLivePrice.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/src/views/base/home/desktop/components/DesktopPriceOverview.vue b/src/views/base/home/desktop/components/DesktopPriceOverview.vue new file mode 100644 index 0000000..13de215 --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopPriceOverview.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/src/views/base/home/desktop/components/DesktopPriceRange.vue b/src/views/base/home/desktop/components/DesktopPriceRange.vue new file mode 100644 index 0000000..a2a73cb --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopPriceRange.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/views/base/home/desktop/components/DesktopQuickActionsGrid.vue b/src/views/base/home/desktop/components/DesktopQuickActionsGrid.vue new file mode 100644 index 0000000..f1f7524 --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopQuickActionsGrid.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/src/views/base/home/desktop/components/DesktopQuickActionsPanel.vue b/src/views/base/home/desktop/components/DesktopQuickActionsPanel.vue new file mode 100644 index 0000000..3de4961 --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopQuickActionsPanel.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/views/base/home/desktop/components/DesktopReferralPanel.vue b/src/views/base/home/desktop/components/DesktopReferralPanel.vue new file mode 100644 index 0000000..4ce38fa --- /dev/null +++ b/src/views/base/home/desktop/components/DesktopReferralPanel.vue @@ -0,0 +1,14 @@ + + + diff --git a/src/views/base/home/desktop/layouts/BoursecuDesktopHome.vue b/src/views/base/home/desktop/layouts/BoursecuDesktopHome.vue new file mode 100644 index 0000000..09775dc --- /dev/null +++ b/src/views/base/home/desktop/layouts/BoursecuDesktopHome.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/views/base/home/desktop/layouts/DefaultDesktopHome.vue b/src/views/base/home/desktop/layouts/DefaultDesktopHome.vue new file mode 100644 index 0000000..2f5ce61 --- /dev/null +++ b/src/views/base/home/desktop/layouts/DefaultDesktopHome.vue @@ -0,0 +1,93 @@ + + + diff --git a/src/views/base/home/desktop/styles/desktop-home.less b/src/views/base/home/desktop/styles/desktop-home.less new file mode 100644 index 0000000..8d8327a --- /dev/null +++ b/src/views/base/home/desktop/styles/desktop-home.less @@ -0,0 +1,714 @@ +/* Premium desktop home — SaaS dashboard design tokens */ + +.dhome { + min-height: 100%; + background: #f6f7f9; + color: #111827; + font-feature-settings: 'ss01' on; +} + +.dhome__container { + max-width: 1180px; + margin: 0 auto; + padding: 28px 36px 40px; +} + +.dhome-label { + display: block; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + color: #9ca3af; + margin-bottom: 6px; +} + +.dhome-surface { + background: #ffffff; + border-radius: 14px; + padding: 22px 24px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); + min-width: 0; + overflow: hidden; + + .home-referral { + margin-top: 0; + } +} + +.dhome-section { + &__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + } + + &__title { + font-size: 13px; + font-weight: 600; + color: #374151; + letter-spacing: -0.01em; + } +} + +/* Topbar */ +.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-bottom: 4px; + } + + &__title { + font-size: 26px; + font-weight: 600; + letter-spacing: -0.03em; + color: #111827; + line-height: 1.2; + } + + &__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-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); + } +} + +/* Alerts */ +.dhome-alerts { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 20px; +} + +.dhome-alert { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-radius: 10px; + font-size: 12px; + font-weight: 500; + line-height: 1.5; + + &--warning { + background: #fef3f2; + color: #b42318; + } + + &--info { + background: #f0f4ff; + color: #344054; + } +} + +/* Hero price */ +.dhome-hero-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 16px; + margin-bottom: 16px; +} + +.dhome-hero-price { + &__brand { + font-size: 13px; + color: #6b7280; + margin: 0 0 12px; + } + + &__value-row { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 10px; + } + + &__odometer { + font-size: 32px !important; + font-weight: 600 !important; + letter-spacing: -0.03em; + color: #111827; + direction: ltr; + line-height: 1; + } + + &__unit { + font-size: 14px; + font-weight: 500; + color: #9ca3af; + } + + &__delta { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + + &-value { + direction: ltr; + unicode-bidi: isolate; + font-variant-numeric: tabular-nums; + } + + &--up { + background: #ecfdf3; + color: #027a48; + } + + &--down { + background: #fef3f2; + color: #b42318; + } + } +} + +.dhome-price-range { + display: flex; + flex-direction: column; + justify-content: center; + gap: 16px; + height: 100%; + + &__item { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__value { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.02em; + direction: ltr; + text-align: right; + } + + &__value--high { + color: #027a48; + } + + &__value--low { + color: #b42318; + } + + &__divider { + height: 1px; + background: rgba(15, 23, 42, 0.06); + } +} + +/* Asset metrics */ +.dhome-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.dhome-metric-tile { + background: #ffffff; + border-radius: 12px; + padding: 16px 18px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); + transition: box-shadow 0.15s ease; + + &:hover { + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06); + } + + &__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; + } + + &__label { + font-size: 12px; + font-weight: 500; + color: #6b7280; + } + + &__info { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: 6px; + background: transparent; + color: #9ca3af; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + + &:hover { + background: #f3f4f6; + color: #374151; + } + } + + &__value { + font-size: 20px; + font-weight: 600; + letter-spacing: -0.02em; + color: #111827; + line-height: 1.2; + } +} + +/* Main dashboard grid */ +.dhome-main-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 272px; + gap: 16px; + align-items: start; +} + +/* Chart + banners in one card */ +.dhome-chart-banner-combined { + &__grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(320px, 38%); + gap: 20px; + align-items: start; + } +} + +.dhome-banners { + min-width: 0; + + &--embedded { + align-self: center; + border-radius: 12px; + overflow: hidden; + background: #f9fafb; + } + + :deep(.home_appear_fade_in) { + margin-top: 0; + } + + :deep(.banner-swiper) { + width: 100%; + aspect-ratio: 2.8 / 1; + max-height: 13rem; + border-radius: 12px; + overflow: hidden; + } + + :deep(.swiper-slide), + :deep(.banner-img) { + width: 100%; + height: 100%; + } + + :deep(.banner-img) { + object-fit: cover; + } +} + +.dhome-chart { + :deep(.bg-gray-50) { + background: transparent !important; + border-radius: 0; + padding-top: 0; + padding-bottom: 0; + } + + :deep(.w-full.text-center.pt-2) { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + } + + :deep(.chart-metal-segment) { + display: inline-flex; + background: #f3f4f6; + border-radius: 8px; + padding: 3px; + margin-bottom: 0 !important; + flex-shrink: 0; + } + + :deep(.chart-metal-segment__item) { + border-radius: 6px; + font-size: 12px; + font-weight: 500; + } + + :deep(.chart-timeline-group) { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 6px; + width: 100%; + margin-bottom: 4px; + } + + :deep(.chart-timeline-group .ant-radio-button-wrapper) { + margin: 0; + } +} + +/* Quick actions */ +.dhome-actions { + display: flex; + flex-direction: column; + gap: 2px; +} + +.dhome-action { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 11px 10px; + border: none; + border-radius: 10px; + background: transparent; + cursor: pointer; + text-align: right; + font-family: inherit; + transition: background 0.15s ease; + + &:hover { + background: #f9fafb; + } + + &__icon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 10px; + background: #f3f4f6; + color: @App-primary-color; + flex-shrink: 0; + transition: background 0.15s ease; + overflow: visible; + } + + &__icon-image { + width: 18px; + height: 18px; + object-fit: contain; + transform: scale(2.2); + } + + &:hover &__icon { + background: #eef2f6; + } + + &__label { + flex: 1; + font-size: 13px; + font-weight: 600; + color: #374151; + } + + &__chevron { + color: #d1d5db; + flex-shrink: 0; + } +} + +/* Default layout grid */ +.dhome-default-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 16px; + align-items: start; +} + +.dhome-default-grid__main, +.dhome-default-grid__side { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +.dhome-default-actions { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; +} + +.dhome-default-action { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 18px 12px; + border: none; + border-radius: 12px; + background: #f9fafb; + cursor: pointer; + font-family: inherit; + transition: background 0.15s ease, transform 0.15s ease; + + &:hover { + background: #f3f4f6; + transform: translateY(-1px); + } + + &__icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 12px; + background: #ffffff; + color: @App-primary-color; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); + } + + &__label { + font-size: 12px; + font-weight: 600; + color: #374151; + text-align: center; + line-height: 1.4; + } +} + +/* Sidebar: assets + referral */ +.dhome-assets-card { + padding: 20px; + + &__title { + font-size: 14px; + font-weight: 600; + color: #111827; + margin: 0 0 16px; + } + + &__rows { + display: flex; + flex-direction: column; + gap: 14px; + } + + &__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 13px; + color: #6b7280; + + strong { + font-size: 15px; + font-weight: 600; + color: #111827; + text-align: left; + } + } +} + +.dhome-referral { + padding: 20px; + + &__title { + font-size: 14px; + font-weight: 600; + color: #111827; + margin: 0 0 12px; + } + + &__code { + display: block; + font-size: 22px; + font-weight: 700; + letter-spacing: 0.06em; + text-align: center; + color: #111827; + margin: 0 0 16px; + direction: ltr; + } + + &__actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + &__btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 0; + padding: 10px 8px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 10px; + background: #f9fafb; + color: #374151; + font-family: inherit; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease; + + &:hover { + background: #f3f4f6; + } + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +} + +.dhome-chart-panel { + min-width: 0; + + &__tabs { + display: inline-flex; + flex-wrap: wrap; + gap: 4px; + background: #f3f4f6; + border-radius: 8px; + padding: 3px; + margin-bottom: 14px; + } + + &__tab { + border: none; + background: transparent; + padding: 8px 14px; + border-radius: 6px; + font-family: inherit; + font-size: 12px; + font-weight: 500; + color: #6b7280; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s ease, color 0.15s ease; + + &--active { + background: @App-primary-color; + color: #fff; + font-weight: 600; + } + } +} + +.dhome-banner-panel { + align-self: stretch; + border-radius: 12px; + overflow: hidden; + background: #f9fafb; + min-height: 220px; + + &__img { + display: block; + width: 100%; + height: 100%; + min-height: 220px; + object-fit: cover; + } +} + +/* Garoosi assets strip */ +.dhome-garoosi-assets { + display: flex; + flex-direction: column; + gap: 14px; + + &__item { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__label { + font-size: 12px; + font-weight: 500; + color: #6b7280; + } + + &__value { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.02em; + color: #111827; + direction: ltr; + text-align: right; + } + + &__divider { + height: 1px; + background: rgba(15, 23, 42, 0.06); + } +} + +/* Boursecu subtle accent */ +.dhome--boursecu { + .dhome-action__icon { + background: rgba(19, 48, 80, 0.06); + color: #133050; + } + + .dhome-hero-price__odometer { + color: #133050; + } +} + +@media (max-width: 1024px) { + .dhome__container { + padding: 24px 28px 36px; + } + + .dhome-hero-row, + .dhome-main-grid, + .dhome-chart-banner-combined__grid, + .dhome-default-grid { + grid-template-columns: 1fr; + } + + .dhome-default-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/src/views/base/home/index.vue b/src/views/base/home/index.vue index b8c500d..0c641f0 100644 --- a/src/views/base/home/index.vue +++ b/src/views/base/home/index.vue @@ -1,12 +1,14 @@ -
@@ -428,30 +423,22 @@ class="flex justify-between" title="مجموعه کل دارایی طلایی" is-line - :info="`در این قسمت مجموع کل وزن طلای آبشده (${(walletStore.walletInfo?.gold ?? 0)?.toFixed(3)}) + ارزش دارایی ها (${(walletStore.walletInfo?.treasure_sum ?? 0)?.toFixed(3)} ) را مشاهده میفرمایید.`" - infoHead="مجموع کل دارایی طلایی" - :value="parseFloat((walletStore.walletInfo?.gold_sum ?? 0).toFixed(2)) + ' گرم'" + :value="balanceLabel" /> - + +
@@ -502,7 +482,7 @@ - + \ No newline at end of file + \ No newline at end of file diff --git a/src/views/base/home/layouts/registry.ts b/src/views/base/home/layouts/registry.ts new file mode 100644 index 0000000..9427add --- /dev/null +++ b/src/views/base/home/layouts/registry.ts @@ -0,0 +1,51 @@ +import { defineAsyncComponent, type Component } from 'vue' +import { getConfigApp, isBoursecuProject } from '@/utils/config' +import type { HomeLayoutConfig, HomeLayoutId } from './types' +import { defaultHomeLayoutConfig } from './configs/default' +import { boursecuHomeLayoutConfig } from './configs/boursecu' + +export const DEFAULT_HOME_LAYOUT_ID: HomeLayoutId = 'default' + +const HOME_LAYOUT_CONFIGS: Record = { + default: defaultHomeLayoutConfig, + boursecu: boursecuHomeLayoutConfig, +} + +let cachedLayoutRenderer: Component | null = null + +function getLayoutRenderer(): Component { + if (!cachedLayoutRenderer) { + cachedLayoutRenderer = defineAsyncComponent(() => import('./HomeLayoutRenderer.vue')) + } + + return cachedLayoutRenderer +} + +export function isHomeLayoutId(value: unknown): value is HomeLayoutId { + return typeof value === 'string' && value in HOME_LAYOUT_CONFIGS +} + +export function getHomeLayoutId(): HomeLayoutId { + const adminLayout = getConfigApp('home_layout') + if (isHomeLayoutId(adminLayout)) { + return adminLayout + } + + if (isBoursecuProject()) { + return 'boursecu' + } + + return DEFAULT_HOME_LAYOUT_ID +} + +export function getHomeLayoutConfig(layoutId = getHomeLayoutId()): HomeLayoutConfig { + return HOME_LAYOUT_CONFIGS[layoutId] ?? HOME_LAYOUT_CONFIGS[DEFAULT_HOME_LAYOUT_ID] +} + +export function resolveHomeLayoutComponent(_layoutId = getHomeLayoutId()): Component { + return getLayoutRenderer() +} + +export function registerHomeLayoutConfig(layoutId: HomeLayoutId, config: HomeLayoutConfig) { + HOME_LAYOUT_CONFIGS[layoutId] = config +} diff --git a/src/views/base/home/layouts/types.ts b/src/views/base/home/layouts/types.ts new file mode 100644 index 0000000..9e18e05 --- /dev/null +++ b/src/views/base/home/layouts/types.ts @@ -0,0 +1,42 @@ +/** چیدمان‌های صفحه اصلی — هر پروژه/ادمین می‌تواند یکی را انتخاب کند */ +export type HomeLayoutId = 'default' | 'boursecu' + +export type HomeSectionId = + | 'limitedPriceAlert' + | 'garoosiAssets' + | 'mainActions' + | 'boursecuQuickActions' + | 'referral' + | 'assets' + | 'marketClosed' + | 'currentPrice' + | 'banners' + | 'priceChart' + | 'priceStats' + +export interface HomeQuickActionItem { + id: string + label: string + icon: string + iconSrc?: string + route: { name: string; query?: Record } +} + +export interface HomeLayoutSectionConfig { + id: HomeSectionId + visible?: boolean +} + +export interface HomeLayoutConfig { + id: HomeLayoutId + wrapperClass?: string + outerSections?: HomeLayoutSectionConfig[] + sections: HomeLayoutSectionConfig[] +} + +export interface HomeActionItem { + name: string + icon: string + label: string + handler: () => void +} diff --git a/src/views/base/home/shell/HomePageShell.vue b/src/views/base/home/shell/HomePageShell.vue new file mode 100644 index 0000000..2e1887d --- /dev/null +++ b/src/views/base/home/shell/HomePageShell.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/views/base/index.vue b/src/views/base/index.vue index 60b495c..cdf0e24 100644 --- a/src/views/base/index.vue +++ b/src/views/base/index.vue @@ -1,351 +1,7 @@ - - - \ No newline at end of file diff --git a/src/views/base/physicalReceive/components/RequestList.vue b/src/views/base/physicalReceive/components/RequestList.vue index 27b25a3..5af4e50 100644 --- a/src/views/base/physicalReceive/components/RequestList.vue +++ b/src/views/base/physicalReceive/components/RequestList.vue @@ -1,44 +1,59 @@ \ No newline at end of file +.physical-receive-page-root { + min-height: 100%; + height: 100%; +} + diff --git a/src/views/base/physicalReceive/desktop/DesktopPhysicalReceiveListView.vue b/src/views/base/physicalReceive/desktop/DesktopPhysicalReceiveListView.vue new file mode 100644 index 0000000..ebc9b7b --- /dev/null +++ b/src/views/base/physicalReceive/desktop/DesktopPhysicalReceiveListView.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/views/base/products/components/DealProduct.vue b/src/views/base/products/components/DealProduct.vue index 04c60e5..6425d33 100644 --- a/src/views/base/products/components/DealProduct.vue +++ b/src/views/base/products/components/DealProduct.vue @@ -262,11 +262,12 @@ import type { Transaction } from '@/models/transaction' import type { TableEngineInput, TableOutput } from '@/models/table' import { getTransactionTable } from '@/api/transaction' import { getLastPrices, lastPrice } from '@/utils/price' +import { getProductBaseBuyPrice, getProductBaseSellPrice } from '@/utils/productPrice' import { Product } from '@/models/products' import BasicSelect from '@/components/BasicPacks/BasicSelect.vue' import { SelectItem } from '@/models/SelectItem' import Bg_main from '@/components/bg_main.vue' -import { useFundUserStore } from '@/store/fundStore' +import { useWalletAssets } from '@/composables/useWalletAssets' import { useCartStore } from '@/store/cartStore' const typeDeal: SelectItem[] = [ @@ -295,7 +296,7 @@ const sellItems = ref([]) const priceErrorVisible = ref(false) const buyTableOutput = ref | null>(null) const sellTableOutput = ref | null>(null) -const fundStore = useFundUserStore() +const { loadWalletAssets } = useWalletAssets() const refInput = ref() const el = ref() @@ -653,18 +654,9 @@ const price = computed((): Price => lastPrice.value ?? 0) async function initPrice() { try { - // chartLoading.value = true await getLastPrices() - // await setIntervalGetPrice() - loading.value = true - price.value = lastPrice.value - props.ProductItem.price = lastPrice.value?.buy * props.ProductItem?.amount - props.ProductItem.sellPrice = lastPrice.value?.sell * props.ProductItem?.amount } catch (e) { console.log(e) - } finally { - loading.value = false - // chartLoading.value = false } } @@ -715,7 +707,9 @@ async function ok() { infoVisible.value = true const data = await createOrder({ direction: isBuy.value ? 1 : 2, - price: isBuy.value ? price.value?.buy : price.value?.sell, + price: isBuy.value + ? getProductBaseBuyPrice(props.ProductItem) + : getProductBaseSellPrice(props.ProductItem), amount: form.value.goldValue ?? 0, gateway: form.value.gateway, product_id: props.ProductItem.id, @@ -753,7 +747,7 @@ async function ok() { } // refreshTable() message.success('سفارش شما با موفقیت انجام شد') - await fundStore.getMyFund() + await loadWalletAssets(true) } catch (e) { // console.log(e) // await initPrice() diff --git a/src/views/base/products/components/ProductItem.vue b/src/views/base/products/components/ProductItem.vue index e221e5b..d40a28a 100644 --- a/src/views/base/products/components/ProductItem.vue +++ b/src/views/base/products/components/ProductItem.vue @@ -94,7 +94,7 @@
- {{ usePrice((props.ProductItem.price ?? 0) + (props.ProductItem.cash_fee_buy ?? 0)) }} + {{ usePrice(getProductBuyPrice(props.ProductItem)) }}
@@ -104,6 +104,7 @@ import { computed } from 'vue' import type { Product } from '@/models/products' import { usePrice } from '@/utils/usePrice' +import { getProductBuyPrice } from '@/utils/productPrice' import ProductImage from '@/components/ProductImage.vue' interface Props { diff --git a/src/views/base/products/components/ProductItemVertical.vue b/src/views/base/products/components/ProductItemVertical.vue index 8f0f3c6..e929aaf 100644 --- a/src/views/base/products/components/ProductItemVertical.vue +++ b/src/views/base/products/components/ProductItemVertical.vue @@ -27,7 +27,7 @@
- {{ usePrice((props.ProductItem.price ?? 0) + (props.ProductItem.cash_fee_buy ?? 0)) }} + {{ usePrice(getProductBuyPrice(props.ProductItem)) }}
@@ -36,6 +36,7 @@ import { computed } from 'vue' import type { Product } from '@/models/products' import { usePrice } from '@/utils/usePrice' +import { getProductBuyPrice } from '@/utils/productPrice' import ProductImage from '@/components/ProductImage.vue' interface Props { diff --git a/src/views/base/products/composables/useProductsPageData.ts b/src/views/base/products/composables/useProductsPageData.ts new file mode 100644 index 0000000..acf58c7 --- /dev/null +++ b/src/views/base/products/composables/useProductsPageData.ts @@ -0,0 +1,193 @@ +import { ref, watch } from 'vue' +import { getProductsCatalog, getProductsCatalogRef } from '@/composables/useProductsCatalog' +import type { Product } from '@/models/products' +import { loadProductCategoryTabNames } from '@/utils/categoryTabs' +import { getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price' +import { + priceSocketConnected, + priceStreamLifecycle, + priceWssEnabled, + shouldUsePricePolling, +} from '@/utils/priceSocketState' +import { subscribeProductListStream } from '@/utils/productStreamBridge' + +let initPromise: Promise | null = null +let productListInterval: ReturnType | undefined +let providerMountCount = 0 +let pricePollingMountCount = 0 +let unsubscribeProductStream: (() => void) | undefined + +const initialized = ref(false) + +export const productsActiveKey = ref('همه') +export const productsTypeItems = ref([]) +export const productsFirstItems = ref() +export const productsItems = ref() +export const productsPageLoading = ref(true) + +function sortProductsByInventory(products: Product[]) { + return [...products].sort((a, b) => { + const aInStock = Number(a.inventory ?? 0) > 0 ? 0 : 1 + const bInStock = Number(b.inventory ?? 0) > 0 ? 0 : 1 + if (aInStock !== bInStock) return aInStock - bInStock + return Number(b.inventory ?? 0) - Number(a.inventory ?? 0) + }) +} + +export function changeProductsTab() { + if (productsActiveKey.value !== 'همه') { + productsItems.value = productsFirstItems.value?.filter( + (val) => val.group_type === productsActiveKey.value, + ) + } else { + productsItems.value = productsFirstItems.value + } +} + +async function loadCategoryTabs() { + try { + productsTypeItems.value = await loadProductCategoryTabNames() + } catch (error) { + console.error('Product category tabs error:', error) + productsTypeItems.value = [] + } +} + +function applyProductsList(products: Product[]) { + const sorted = sortProductsByInventory(products) + productsFirstItems.value = sorted + changeProductsTab() + productsPageLoading.value = false +} + +export async function refreshProductsPageData() { + try { + const fresh = await getProductsCatalog(true) + if (!fresh?.length) return + + applyProductsList(fresh) + } catch (error) { + console.error('Products refresh error:', error) + } +} + +export function applyProductsListFromSocket(products: Product[]) { + if (!products?.length) { + return + } + + getProductsCatalogRef().value = products + applyProductsList(products) +} + +function startProductListPolling() { + if (productListInterval || !shouldUsePricePolling()) { + return + } + + productListInterval = setInterval(() => { + if (shouldUsePricePolling()) { + void refreshProductsPageData() + } + }, 10000) +} + +function stopProductListPolling() { + if (productListInterval) { + clearInterval(productListInterval) + productListInterval = undefined + } +} + +function syncProductListPolling() { + if (shouldUsePricePolling()) { + if (initialized.value) { + startProductListPolling() + } + return + } + + stopProductListPolling() +} + +watch( + () => [priceWssEnabled.value, priceSocketConnected.value, priceStreamLifecycle.value] as const, + () => { + syncProductListPolling() + if (shouldUsePricePolling() && initialized.value && !productsFirstItems.value?.length) { + void refreshProductsPageData() + } + }, +) + +export function onProductsPageProviderMounted() { + providerMountCount += 1 + pricePollingMountCount += 1 + + unsubscribeProductStream = subscribeProductListStream(applyProductsListFromSocket) + + if (pricePollingMountCount === 1) { + if (shouldUsePricePolling()) { + void getLastPrices() + } + startLastPricePolling() + } +} + +export function onProductsPageProviderUnmounted() { + unsubscribeProductStream?.() + unsubscribeProductStream = undefined + + providerMountCount = Math.max(0, providerMountCount - 1) + pricePollingMountCount = Math.max(0, pricePollingMountCount - 1) + + if (pricePollingMountCount === 0) { + stopLastPricePolling() + } + + if (providerMountCount === 0) { + stopProductListPolling() + } +} + +export async function initProductsPageData(force = false) { + if (!force && initialized.value) { + syncProductListPolling() + if (productsFirstItems.value?.length) { + productsPageLoading.value = false + } + return + } + + if (!force && initPromise) { + return initPromise + } + + initPromise = (async () => { + try { + if (!productsFirstItems.value?.length) { + productsPageLoading.value = true + } + await loadCategoryTabs() + + // stream-first: polling فقط وقتی SSE قطع/غیرفعال است + if (shouldUsePricePolling()) { + await refreshProductsPageData() + } + + initialized.value = true + syncProductListPolling() + } catch (error) { + console.error('Products page initialization error:', error) + } finally { + // اگر استریم قبل از init لیست را داده، loading را دوباره true نکرده‌ایم؛ + // اگر API/polling تمام شد یا محصول داریم، اسپینر را قطع کن. + if (productsFirstItems.value?.length || shouldUsePricePolling()) { + productsPageLoading.value = false + } + initPromise = null + } + })() + + return initPromise +} diff --git a/src/views/base/products/index.vue b/src/views/base/products/index.vue index a02fb18..e345849 100644 --- a/src/views/base/products/index.vue +++ b/src/views/base/products/index.vue @@ -1,15 +1,16 @@