-
-
-
-
- حداقل مبلغ اولیه برای ثبت سفارش {{ usePriceWithoutType(limitedPrice.value as number ?? 0) }} ریال می باشد.
-
+
+
-
-
-
-
+
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
+
+
+
\ No newline at end of file
+
diff --git a/src/views/base/home/layouts/HomeLayoutRenderer.vue b/src/views/base/home/layouts/HomeLayoutRenderer.vue
new file mode 100644
index 0000000..d695353
--- /dev/null
+++ b/src/views/base/home/layouts/HomeLayoutRenderer.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
diff --git a/src/views/base/home/layouts/configs/boursecu.ts b/src/views/base/home/layouts/configs/boursecu.ts
new file mode 100644
index 0000000..ca3fdb7
--- /dev/null
+++ b/src/views/base/home/layouts/configs/boursecu.ts
@@ -0,0 +1,21 @@
+import type { HomeLayoutConfig } from '../types'
+
+/**
+ * چیدمان اختصاصی boursecu — فقط وقتی home_layout=boursecu یا پروژه boursecu باشد.
+ * شامل: دارایی بدون بکگراند، گرید ۶ باکس، نمودار کاتد/مفتول، و ...
+ */
+export const boursecuHomeLayoutConfig: HomeLayoutConfig = {
+ id: 'boursecu',
+ wrapperClass: 'rounded-lg mt-5',
+ outerSections: [
+ { id: 'assets' },
+ { id: 'boursecuQuickActions' },
+ { id: 'limitedPriceAlert' },
+ ],
+ sections: [
+ { id: 'marketClosed' },
+ { id: 'currentPrice' },
+ { id: 'priceChart' },
+ { id: 'priceStats' },
+ ],
+}
diff --git a/src/views/base/home/layouts/configs/boursecuQuickActions.ts b/src/views/base/home/layouts/configs/boursecuQuickActions.ts
new file mode 100644
index 0000000..32c33fb
--- /dev/null
+++ b/src/views/base/home/layouts/configs/boursecuQuickActions.ts
@@ -0,0 +1,40 @@
+import type { HomeQuickActionItem } from '../types'
+
+export const boursecuQuickActions: HomeQuickActionItem[] = [
+ {
+ id: 'buySell',
+ label: 'خرید و فروش',
+ icon: 'vuesax-linear:money-receive',
+ route: { name: 'buyProduct' },
+ },
+ {
+ id: 'wallet',
+ label: 'واریز و برداشت',
+ icon: 'vuesax-linear:wallet-1',
+ route: { name: 'wallet' },
+ },
+ {
+ id: 'assets',
+ label: 'دارایی ها',
+ icon: 'vuesax-linear:cup',
+ route: { name: 'fund' },
+ },
+ {
+ id: 'transactions',
+ label: 'تراکنش ها',
+ icon: 'vuesax-linear:receipt-item',
+ route: { name: 'transactions' },
+ },
+ {
+ id: 'physicalReceive',
+ label: 'تحویل فیزیکی',
+ icon: 'vuesax-linear:direct-inbox',
+ route: { name: 'physicalReceive' },
+ },
+ {
+ id: 'convert',
+ label: 'تبدیل',
+ icon: 'vuesax-linear:refresh-circle',
+ route: { name: 'buyProduct' },
+ },
+]
diff --git a/src/views/base/home/layouts/configs/default.ts b/src/views/base/home/layouts/configs/default.ts
new file mode 100644
index 0000000..a5cfc52
--- /dev/null
+++ b/src/views/base/home/layouts/configs/default.ts
@@ -0,0 +1,19 @@
+import type { HomeLayoutConfig } from '../types'
+
+/** چیدمان پیشفرض — همه پروژهها بهجز boursecu */
+export const defaultHomeLayoutConfig: HomeLayoutConfig = {
+ id: 'default',
+ wrapperClass: 'p-1 from-primary rounded-lg p-px to-transparent mt-5',
+ outerSections: [{ id: 'limitedPriceAlert' }],
+ sections: [
+ { id: 'garoosiAssets' },
+ { id: 'mainActions' },
+ { id: 'referral' },
+ { id: 'assets' },
+ { id: 'marketClosed' },
+ { id: 'currentPrice' },
+ { id: 'banners' },
+ { id: 'priceChart' },
+ { id: 'priceStats' },
+ ],
+}
diff --git a/src/views/base/home/layouts/registry.ts b/src/views/base/home/layouts/registry.ts
new file mode 100644
index 0000000..29416b8
--- /dev/null
+++ b/src/views/base/home/layouts/registry.ts
@@ -0,0 +1,47 @@
+import 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'
+import HomeLayoutRenderer from './HomeLayoutRenderer.vue'
+
+const HOME_LAYOUT_CONFIGS: Record
= {
+ default: defaultHomeLayoutConfig,
+ boursecu: boursecuHomeLayoutConfig,
+}
+
+const HOME_LAYOUT_COMPONENTS: Record = {
+ default: HomeLayoutRenderer,
+ boursecu: HomeLayoutRenderer,
+}
+
+export const DEFAULT_HOME_LAYOUT_ID: HomeLayoutId = 'default'
+
+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 HOME_LAYOUT_COMPONENTS[layoutId] ?? HOME_LAYOUT_COMPONENTS[DEFAULT_HOME_LAYOUT_ID]
+}
+
+export function registerHomeLayoutConfig(layoutId: HomeLayoutId, config: HomeLayoutConfig) {
+ HOME_LAYOUT_CONFIGS[layoutId] = config
+}
diff --git a/src/views/base/home/layouts/sections/AssetsSectionBlock.vue b/src/views/base/home/layouts/sections/AssetsSectionBlock.vue
new file mode 100644
index 0000000..62f6b91
--- /dev/null
+++ b/src/views/base/home/layouts/sections/AssetsSectionBlock.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/src/views/base/home/layouts/sections/BannersSection.vue b/src/views/base/home/layouts/sections/BannersSection.vue
new file mode 100644
index 0000000..a1e2354
--- /dev/null
+++ b/src/views/base/home/layouts/sections/BannersSection.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/src/views/base/home/layouts/sections/BoursecuQuickActionsSection.vue b/src/views/base/home/layouts/sections/BoursecuQuickActionsSection.vue
new file mode 100644
index 0000000..3c85a30
--- /dev/null
+++ b/src/views/base/home/layouts/sections/BoursecuQuickActionsSection.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
diff --git a/src/views/base/home/layouts/sections/CurrentPriceSection.vue b/src/views/base/home/layouts/sections/CurrentPriceSection.vue
new file mode 100644
index 0000000..f27215d
--- /dev/null
+++ b/src/views/base/home/layouts/sections/CurrentPriceSection.vue
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/src/views/base/home/layouts/sections/GaroosiAssetsSection.vue b/src/views/base/home/layouts/sections/GaroosiAssetsSection.vue
new file mode 100644
index 0000000..a36b931
--- /dev/null
+++ b/src/views/base/home/layouts/sections/GaroosiAssetsSection.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/src/views/base/home/layouts/sections/LimitedPriceAlertSection.vue b/src/views/base/home/layouts/sections/LimitedPriceAlertSection.vue
new file mode 100644
index 0000000..48cb9aa
--- /dev/null
+++ b/src/views/base/home/layouts/sections/LimitedPriceAlertSection.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+ حداقل مبلغ اولیه برای ثبت سفارش
+ {{ usePriceWithoutType(Number(ctx.limitedPrice.value?.value ?? 0)) }}
+ ریال می باشد.
+
+
diff --git a/src/views/base/home/layouts/sections/MainActionsSection.vue b/src/views/base/home/layouts/sections/MainActionsSection.vue
new file mode 100644
index 0000000..2572a0b
--- /dev/null
+++ b/src/views/base/home/layouts/sections/MainActionsSection.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/src/views/base/home/layouts/sections/MarketClosedSection.vue b/src/views/base/home/layouts/sections/MarketClosedSection.vue
new file mode 100644
index 0000000..6372e2c
--- /dev/null
+++ b/src/views/base/home/layouts/sections/MarketClosedSection.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/src/views/base/home/layouts/sections/PriceChartSection.vue b/src/views/base/home/layouts/sections/PriceChartSection.vue
new file mode 100644
index 0000000..b46b54d
--- /dev/null
+++ b/src/views/base/home/layouts/sections/PriceChartSection.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
diff --git a/src/views/base/home/layouts/sections/PriceStatsSection.vue b/src/views/base/home/layouts/sections/PriceStatsSection.vue
new file mode 100644
index 0000000..5d17187
--- /dev/null
+++ b/src/views/base/home/layouts/sections/PriceStatsSection.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/src/views/base/home/layouts/sections/ReferralSectionBlock.vue b/src/views/base/home/layouts/sections/ReferralSectionBlock.vue
new file mode 100644
index 0000000..8d75fa4
--- /dev/null
+++ b/src/views/base/home/layouts/sections/ReferralSectionBlock.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/src/views/base/home/layouts/sections/index.ts b/src/views/base/home/layouts/sections/index.ts
new file mode 100644
index 0000000..fcf4562
--- /dev/null
+++ b/src/views/base/home/layouts/sections/index.ts
@@ -0,0 +1,31 @@
+import type { Component } from 'vue'
+import type { HomeSectionId } from '../types'
+import LimitedPriceAlertSection from './LimitedPriceAlertSection.vue'
+import GaroosiAssetsSection from './GaroosiAssetsSection.vue'
+import MainActionsSection from './MainActionsSection.vue'
+import BoursecuQuickActionsSection from './BoursecuQuickActionsSection.vue'
+import ReferralSectionBlock from './ReferralSectionBlock.vue'
+import AssetsSectionBlock from './AssetsSectionBlock.vue'
+import MarketClosedSection from './MarketClosedSection.vue'
+import CurrentPriceSection from './CurrentPriceSection.vue'
+import BannersSection from './BannersSection.vue'
+import PriceChartSection from './PriceChartSection.vue'
+import PriceStatsSection from './PriceStatsSection.vue'
+
+export const HOME_SECTION_COMPONENTS: Record = {
+ limitedPriceAlert: LimitedPriceAlertSection,
+ garoosiAssets: GaroosiAssetsSection,
+ mainActions: MainActionsSection,
+ boursecuQuickActions: BoursecuQuickActionsSection,
+ referral: ReferralSectionBlock,
+ assets: AssetsSectionBlock,
+ marketClosed: MarketClosedSection,
+ currentPrice: CurrentPriceSection,
+ banners: BannersSection,
+ priceChart: PriceChartSection,
+ priceStats: PriceStatsSection,
+}
+
+export function registerHomeSection(id: HomeSectionId, component: Component) {
+ HOME_SECTION_COMPONENTS[id] = component
+}
diff --git a/src/views/base/home/layouts/types.ts b/src/views/base/home/layouts/types.ts
new file mode 100644
index 0000000..28f964c
--- /dev/null
+++ b/src/views/base/home/layouts/types.ts
@@ -0,0 +1,41 @@
+/** چیدمانهای صفحه اصلی — هر پروژه/ادمین میتواند یکی را انتخاب کند */
+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
+ 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/mobile/MobileHomeView.vue b/src/views/base/home/mobile/MobileHomeView.vue
new file mode 100644
index 0000000..68608c4
--- /dev/null
+++ b/src/views/base/home/mobile/MobileHomeView.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
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 fde1d30..cdf0e24 100644
--- a/src/views/base/index.vue
+++ b/src/views/base/index.vue
@@ -1,440 +1,7 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
\ No newline at end of file
diff --git a/src/views/base/physicalReceive/components/NewRequest.vue b/src/views/base/physicalReceive/components/NewRequest.vue
index 8ba6de5..6f560ae 100644
--- a/src/views/base/physicalReceive/components/NewRequest.vue
+++ b/src/views/base/physicalReceive/components/NewRequest.vue
@@ -1,224 +1,14 @@
-
-
-
-
- افزودن
-
-
-
-
-
-
-
-
- {{ val.product.title }}
-
-
-
-
-
تعداد موجودی:
-
-
-
{{ val.product.wallet.gold }}
-
{{ getProductQuantityUnit(val.product) }}
-
-
- هر عدد {{ getProductUnitWeightLabel(val.product) }}
-
-
-
-
-
تعداد درخواستی:
-
-
-
-
-
-
-
-
-
ثبت
-
-
-
-
-
-
-
- انتخاب آدرس
-
-
-
-
- افزودن آدرس +
- ثبت نهایی
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
diff --git a/src/views/base/physicalReceive/components/PhysicalReceiveAddressPicker.vue b/src/views/base/physicalReceive/components/PhysicalReceiveAddressPicker.vue
new file mode 100644
index 0000000..3f7b8ad
--- /dev/null
+++ b/src/views/base/physicalReceive/components/PhysicalReceiveAddressPicker.vue
@@ -0,0 +1,51 @@
+
+
+
انتخاب آدرس
+
+
+
+
+
+ {{ addr.province }} / {{ addr.city }} / {{ addr.address }}
+
+
+
+
+
+
+ افزودن آدرس +
+
+ ثبت نهایی
+
+
+
+
+
+
diff --git a/src/views/base/physicalReceive/components/PhysicalReceiveCartRow.vue b/src/views/base/physicalReceive/components/PhysicalReceiveCartRow.vue
new file mode 100644
index 0000000..478ae3a
--- /dev/null
+++ b/src/views/base/physicalReceive/components/PhysicalReceiveCartRow.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
{{ item.product.title }}
+
+
+
+
تعداد موجودی:
+
+
+
{{ item.product.wallet.gold }}
+
{{ getProductQuantityUnit(item.product) }}
+
+
+ هر عدد {{ getProductUnitWeightLabel(item.product) }}
+
+
+
+
+
تعداد درخواستی:
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/base/physicalReceive/components/RequestItem.vue b/src/views/base/physicalReceive/components/RequestItem.vue
index 6e33dce..1874b4f 100644
--- a/src/views/base/physicalReceive/components/RequestItem.vue
+++ b/src/views/base/physicalReceive/components/RequestItem.vue
@@ -1,26 +1,33 @@
-
-
-
+
+
+
شماره سند: {{ props.item.id }}
{{ toJalali(props.item.created_at) }}
-
-
-
-
-
-
+
+
+
+
+
-
{{ item.title }}
-
-
-
+
{{ tx.title }}
-
{{ item.gold * -1 }} عدد
+
{{ tx.gold * -1 }} عدد
-
@@ -28,30 +35,30 @@
{{ props.item.sts_str }}
-
-
+
آدرس:
-
{{ props.item.address.province }}/{{ props.item.address.city }}/{{ props.item.address.address }}
+
+ {{ props.item.address.province }}/{{ props.item.address.city }}/{{ props.item.address.address }}
+
-
-
-
\ No newline at end of file
+const props = withDefaults(defineProps
(), {
+ variant: 'mobile',
+})
+
diff --git a/src/views/base/physicalReceive/components/RequestList.vue b/src/views/base/physicalReceive/components/RequestList.vue
index c3557b1..12ee934 100644
--- a/src/views/base/physicalReceive/components/RequestList.vue
+++ b/src/views/base/physicalReceive/components/RequestList.vue
@@ -1,36 +1,14 @@
-
-
-
-
-
-
-
افزودن درخواست جدید
-
-
-
-
+
+
-
-
\ No newline at end of file
diff --git a/src/views/base/physicalReceive/composables/useNewPhysicalRequestPage.ts b/src/views/base/physicalReceive/composables/useNewPhysicalRequestPage.ts
new file mode 100644
index 0000000..9cdf1af
--- /dev/null
+++ b/src/views/base/physicalReceive/composables/useNewPhysicalRequestPage.ts
@@ -0,0 +1,153 @@
+import { inject, onMounted, provide, ref, type InjectionKey, type Ref } from 'vue'
+import { cloneDeep } from 'lodash'
+import { message } from 'ant-design-vue'
+import { getProductInventory } from '@/api/products'
+import { physicalReceiveApi } from '@/api/physicalreceive'
+import { tableAddress } from '@/api/address'
+import type { CountPInventory, PInventory } from '@/models/products'
+import type { Address } from '@/models/address'
+import { loading } from '@/utils'
+import { usePhysicalReceiveShellContext } from './usePhysicalReceiveShell'
+
+export interface NewPhysicalRequestContext {
+ isDesktop: Ref
+ items: Ref
+ selectedProduct: Ref
+ selectedProducts: Ref
+ address: Ref
+ selectedAddressID: Ref
+ showAddress: Ref
+ addAddressVisible: Ref
+ handleAdd: () => void
+ handleRemove: (id: number) => void
+ handelFirstSubmit: () => Promise
+ handelsubmit: () => Promise
+ handleAddress: () => void
+}
+
+export const NEW_PHYSICAL_REQUEST_KEY: InjectionKey =
+ Symbol('newPhysicalRequest')
+
+export function useNewPhysicalRequestPage() {
+ const { isDesktop } = usePhysicalReceiveShellContext()
+
+ const items = ref()
+ const baseItems = ref()
+ const selectedAddressID = ref()
+ const selectedProduct = ref()
+ const productsByWalletId = ref>([])
+ const address = ref()
+ const selectedProducts = ref([])
+ const showAddress = ref(false)
+ const addAddressVisible = ref(false)
+
+ function handleAdd() {
+ selectedProducts.value?.push({
+ count: 0,
+ product: items.value?.find((x) => x.id === selectedProduct.value)!,
+ })
+ items.value?.splice(items.value?.findIndex((x) => x.id === selectedProduct.value)!, 1)
+ selectedProduct.value = undefined
+ }
+
+ function handleAddress() {
+ getAddress()
+ addAddressVisible.value = false
+ showAddress.value = true
+ }
+
+ function handleRemove(id: number) {
+ const findItem = baseItems.value?.find((x) => x.id === id)
+ if (findItem) {
+ items.value?.push(findItem)
+ }
+ selectedProducts.value?.splice(
+ selectedProducts.value?.findIndex((x) => x.product.id === id),
+ 1
+ )
+ }
+
+ async function initProductWallet() {
+ try {
+ loading.value = true
+ items.value = (await getProductInventory()).rows
+ baseItems.value = cloneDeep(items.value)
+ } catch (e) {
+ console.log(e)
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function handelFirstSubmit() {
+ try {
+ const zeroAmount = selectedProducts.value.map((item) => item.count <= 0)
+ if (zeroAmount.includes(true)) {
+ message.error('لطفاً تعداد درخواستی محصول را وارد کنید.')
+ } else {
+ showAddress.value = true
+ }
+ } catch (e) {
+ console.log(e)
+ }
+ }
+
+ async function handelsubmit() {
+ try {
+ loading.value = true
+ productsByWalletId.value = selectedProducts.value.map((val) => ({
+ wallet_id: val.product.wallet.id,
+ value: val.count,
+ }))
+ await physicalReceiveApi({
+ address_id: selectedAddressID.value,
+ wallets: productsByWalletId.value,
+ })
+ await initProductWallet()
+ message.success('درخواست شما با موفقیت ارسال شد')
+ selectedProducts.value = []
+ } catch (e) {
+ console.log(e)
+ } finally {
+ loading.value = false
+ showAddress.value = false
+ }
+ }
+
+ async function getAddress() {
+ const { rows } = await tableAddress({ page: 0, limit: 150 })
+ address.value = rows
+ }
+
+ onMounted(async () => {
+ await initProductWallet()
+ await getAddress()
+ })
+
+ const context: NewPhysicalRequestContext = {
+ isDesktop,
+ items,
+ selectedProduct,
+ selectedProducts,
+ address,
+ selectedAddressID,
+ showAddress,
+ addAddressVisible,
+ handleAdd,
+ handleRemove,
+ handelFirstSubmit,
+ handelsubmit,
+ handleAddress,
+ }
+
+ provide(NEW_PHYSICAL_REQUEST_KEY, context)
+ return context
+}
+
+export function useNewPhysicalRequestContext() {
+ const context = inject(NEW_PHYSICAL_REQUEST_KEY)
+ if (!context) {
+ throw new Error('useNewPhysicalRequestContext must be used within useNewPhysicalRequestPage')
+ }
+ return context
+}
diff --git a/src/views/base/physicalReceive/composables/usePhysicalReceiveList.ts b/src/views/base/physicalReceive/composables/usePhysicalReceiveList.ts
new file mode 100644
index 0000000..de296e0
--- /dev/null
+++ b/src/views/base/physicalReceive/composables/usePhysicalReceiveList.ts
@@ -0,0 +1,37 @@
+import { inject, onMounted, provide, ref, type InjectionKey, type Ref } from 'vue'
+import { getPhysicalReceiveList } from '@/api/physicalreceive'
+import type { Physicalreceive } from '@/models/physicalreceive'
+import router from '@/router'
+
+export interface PhysicalReceiveListContext {
+ requestItems: Ref
+ openAdd: () => void
+}
+
+export const PHYSICAL_RECEIVE_LIST_KEY: InjectionKey =
+ Symbol('physicalReceiveList')
+
+export function usePhysicalReceiveList() {
+ const requestItems = ref([])
+
+ function openAdd() {
+ router.push('physicalReceive/new')
+ }
+
+ onMounted(async () => {
+ const { rows } = await getPhysicalReceiveList()
+ requestItems.value = rows
+ })
+
+ const context: PhysicalReceiveListContext = { requestItems, openAdd }
+ provide(PHYSICAL_RECEIVE_LIST_KEY, context)
+ return context
+}
+
+export function usePhysicalReceiveListContext() {
+ const context = inject(PHYSICAL_RECEIVE_LIST_KEY)
+ if (!context) {
+ throw new Error('usePhysicalReceiveListContext must be used within usePhysicalReceiveList')
+ }
+ return context
+}
diff --git a/src/views/base/physicalReceive/composables/usePhysicalReceiveShell.ts b/src/views/base/physicalReceive/composables/usePhysicalReceiveShell.ts
new file mode 100644
index 0000000..9ff147f
--- /dev/null
+++ b/src/views/base/physicalReceive/composables/usePhysicalReceiveShell.ts
@@ -0,0 +1,23 @@
+import { inject, provide, type InjectionKey } from 'vue'
+import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
+
+export interface PhysicalReceiveShellContext {
+ isDesktop: ReturnType
+}
+
+export const PHYSICAL_RECEIVE_SHELL_KEY: InjectionKey =
+ Symbol('physicalReceiveShell')
+
+export function usePhysicalReceiveShell() {
+ const isDesktop = useDesktopBreakpoint()
+ provide(PHYSICAL_RECEIVE_SHELL_KEY, { isDesktop })
+ return { isDesktop }
+}
+
+export function usePhysicalReceiveShellContext() {
+ const context = inject(PHYSICAL_RECEIVE_SHELL_KEY)
+ if (!context) {
+ throw new Error('usePhysicalReceiveShellContext must be used within physical receive shell')
+ }
+ return context
+}
diff --git a/src/views/base/physicalReceive/desktop/DesktopNewRequestView.vue b/src/views/base/physicalReceive/desktop/DesktopNewRequestView.vue
new file mode 100644
index 0000000..afadd11
--- /dev/null
+++ b/src/views/base/physicalReceive/desktop/DesktopNewRequestView.vue
@@ -0,0 +1,119 @@
+
+
+
+
+ انتخاب محصول
+
+
+
+ افزودن
+
+
+
+ {{ items.length }} محصول با موجودی قابل تحویل
+
+
+
+
+
+
+
+
+
+
+
محصولی به سبد اضافه نشده است.
+
+
+
+
+ ادامه و انتخاب آدرس
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/base/physicalReceive/desktop/DesktopPhysicalReceiveChrome.vue b/src/views/base/physicalReceive/desktop/DesktopPhysicalReceiveChrome.vue
new file mode 100644
index 0000000..743d2f0
--- /dev/null
+++ b/src/views/base/physicalReceive/desktop/DesktopPhysicalReceiveChrome.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
diff --git a/src/views/base/physicalReceive/desktop/DesktopRequestListView.vue b/src/views/base/physicalReceive/desktop/DesktopRequestListView.vue
new file mode 100644
index 0000000..23f9af0
--- /dev/null
+++ b/src/views/base/physicalReceive/desktop/DesktopRequestListView.vue
@@ -0,0 +1,36 @@
+
+
+
+ {{ requestItems.length }} درخواست
+
+
+ درخواست جدید
+
+
+
+
+
+
+
+
+
+
هنوز درخواستی ثبت نشده است.
+
+ ثبت اولین درخواست
+
+
+
+
+
+
diff --git a/src/views/base/physicalReceive/desktop/styles/desktop-physical-receive.less b/src/views/base/physicalReceive/desktop/styles/desktop-physical-receive.less
new file mode 100644
index 0000000..8dc8e4d
--- /dev/null
+++ b/src/views/base/physicalReceive/desktop/styles/desktop-physical-receive.less
@@ -0,0 +1,301 @@
+/* Premium desktop physical receive */
+
+.dpr {
+ min-height: 100%;
+ background: #f6f7f9;
+ color: #111827;
+}
+
+.dpr__container {
+ max-width: 1180px;
+ margin: 0 auto;
+ padding: 28px 36px 40px;
+}
+
+.dpr-topbar {
+ display: flex;
+ align-items: flex-start;
+ 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;
+ 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;
+ }
+}
+
+.dpr-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);
+}
+
+.dpr-list {
+ padding: 24px;
+ 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);
+
+ &__toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 20px;
+ }
+
+ &__count {
+ font-size: 14px;
+ color: #6b7280;
+ }
+
+ &__add {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 18px;
+ border: none;
+ border-radius: 10px;
+ background: @App-primary-color;
+ color: #fff;
+ font-size: 14px;
+ font-weight: 600;
+ font-family: inherit;
+ cursor: pointer;
+
+ &:hover {
+ filter: brightness(0.95);
+ }
+ }
+
+ &__items {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+ }
+
+ &__empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ min-height: 240px;
+ padding: 32px 16px;
+ color: #6b7280;
+ text-align: center;
+
+ p {
+ margin: 0;
+ font-size: 15px;
+ }
+ }
+
+ &__empty-btn {
+ margin-top: 8px;
+ padding: 10px 20px;
+ border: none;
+ border-radius: 10px;
+ background: @App-primary-color;
+ color: #fff;
+ font-size: 14px;
+ font-weight: 600;
+ font-family: inherit;
+ cursor: pointer;
+ }
+}
+
+.dpr-item {
+ margin: 0 !important;
+ border-radius: 16px !important;
+}
+
+.dpr-new {
+ &__grid {
+ display: grid;
+ grid-template-columns: minmax(0, 380px) minmax(0, 1fr);
+ gap: 20px;
+ align-items: start;
+ }
+
+ &__picker,
+ &__cart {
+ padding: 24px;
+ }
+
+ &__section-title {
+ font-size: 16px;
+ font-weight: 600;
+ color: #111827;
+ margin: 0 0 16px;
+ }
+
+ &__picker-row {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+ }
+
+ &__hint {
+ margin: 12px 0 0;
+ font-size: 13px;
+ color: #9ca3af;
+ }
+
+ &__cart-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 16px;
+ }
+
+ &__cart-count {
+ font-size: 13px;
+ color: #9ca3af;
+ }
+
+ &__cart-items {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ max-height: 420px;
+ overflow-y: auto;
+ margin-bottom: 20px;
+ }
+
+ &__cart-item {
+ padding: 14px;
+ border-radius: 12px;
+ background: #f9fafb;
+ border: 1px solid rgba(15, 23, 42, 0.05);
+ }
+
+ &__cart-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ min-height: 200px;
+ color: #9ca3af;
+ text-align: center;
+
+ p {
+ margin: 0;
+ font-size: 14px;
+ }
+ }
+
+ &__actions {
+ padding-top: 8px;
+ border-top: 1px solid rgba(15, 23, 42, 0.06);
+ }
+}
+
+.dpr-cart-row__input {
+ width: min(100%, 240px);
+ min-width: 220px;
+ flex-shrink: 0;
+
+ .noname-input,
+ .ant-input-group-wrapper {
+ width: 100%;
+ }
+
+ .ant-input-group-addon {
+ min-width: 4.75rem;
+ padding-inline: 10px;
+ white-space: nowrap;
+ font-size: 12px;
+ font-weight: 600;
+ }
+}
+
+.dpr-address-picker {
+ &--desktop {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+
+ &__list {
+ max-height: 320px;
+ overflow-y: auto;
+ }
+
+ &__item {
+ padding: 14px 16px;
+ margin-bottom: 10px;
+ border-radius: 12px;
+ border: 1px solid rgba(15, 23, 42, 0.08);
+ background: #f9fafb;
+ font-size: 14px;
+ }
+
+ &__actions {
+ display: flex;
+ gap: 10px;
+ padding-top: 8px;
+
+ > button:last-child {
+ flex: 1;
+ }
+ }
+}
+
+@media (max-width: 900px) {
+ .dpr-list__items {
+ grid-template-columns: 1fr;
+ }
+
+ .dpr-new__grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/views/base/physicalReceive/index.vue b/src/views/base/physicalReceive/index.vue
index 6508706..7366885 100644
--- a/src/views/base/physicalReceive/index.vue
+++ b/src/views/base/physicalReceive/index.vue
@@ -1,43 +1,36 @@
-
-
-
-
-
-
-
دریافت فیزیکی
-
دریافت فیزیکی-درخواست جدید
+
diff --git a/src/views/base/physicalReceive/mobile/MobileNewRequestView.vue b/src/views/base/physicalReceive/mobile/MobileNewRequestView.vue
new file mode 100644
index 0000000..c682b32
--- /dev/null
+++ b/src/views/base/physicalReceive/mobile/MobileNewRequestView.vue
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+ افزودن
+
+
+
+
+ ثبت
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/base/physicalReceive/mobile/MobilePhysicalReceiveChrome.vue b/src/views/base/physicalReceive/mobile/MobilePhysicalReceiveChrome.vue
new file mode 100644
index 0000000..cde7446
--- /dev/null
+++ b/src/views/base/physicalReceive/mobile/MobilePhysicalReceiveChrome.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
دریافت فیزیکی
+
دریافت فیزیکی-درخواست جدید
+
+
+
+
+
+
diff --git a/src/views/base/physicalReceive/mobile/MobileRequestListView.vue b/src/views/base/physicalReceive/mobile/MobileRequestListView.vue
new file mode 100644
index 0000000..6024e94
--- /dev/null
+++ b/src/views/base/physicalReceive/mobile/MobileRequestListView.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
افزودن درخواست جدید
+
+
+
+
+
+
+
diff --git a/src/views/base/priceTrigger/components/Item.vue b/src/views/base/priceTrigger/components/Item.vue
index d07fa2e..512581f 100644
--- a/src/views/base/priceTrigger/components/Item.vue
+++ b/src/views/base/priceTrigger/components/Item.vue
@@ -1,90 +1,145 @@
-
+
+
{{ usePrice(item.target_price) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
آیا از حذف هشدار اطمینان دارید؟
+
+
+
+
+ انصراف
+
+
+
+
+
-
{{usePrice(item.target_price) }}
+
{{ usePrice(item.target_price) }}
-
-
-
-
-
+
+
-
-
{{toJalali(item.created_at)}}
-
+
{{ toJalali(item.created_at) }}
+
-
-
-
-
-
-
-
+
+
-
- آیا از حذف هشدار اطمینان دارید؟
-
+
آیا از حذف هشدار اطمینان دارید؟
-
-
+
-
+ انصراف
-
-
-
+
-
-
diff --git a/src/views/base/priceTrigger/components/PriceTriggerForm.vue b/src/views/base/priceTrigger/components/PriceTriggerForm.vue
new file mode 100644
index 0000000..5042b3d
--- /dev/null
+++ b/src/views/base/priceTrigger/components/PriceTriggerForm.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+ ارسال پیامک
+ ارسال ایمیل
+
+
+
+
+
+ {{ editId ? 'ویرایش هشدار' : 'ثبت هشدار' }}
+
+
+
+
+
+
+
+
diff --git a/src/views/base/priceTrigger/composables/usePriceTriggerPage.ts b/src/views/base/priceTrigger/composables/usePriceTriggerPage.ts
new file mode 100644
index 0000000..3252d5e
--- /dev/null
+++ b/src/views/base/priceTrigger/composables/usePriceTriggerPage.ts
@@ -0,0 +1,135 @@
+import { inject, onMounted, provide, reactive, ref, type InjectionKey, type Ref } from 'vue'
+import { cloneDeep } from 'lodash'
+import { type FormInstance, message } from 'ant-design-vue'
+import { createPriceTrigger, getPriceTriggers, updatePriceTrigger } from '@/api/triggerPrice'
+import type { SelectItem } from '@/models/SelectItem'
+import type { PriceTrigger } from '@/models/PriceTrigger'
+import { loading } from '@/utils'
+
+export interface PriceTriggerPageContext {
+ items: Ref
+ addTriggerVisible: Ref
+ editId: Ref
+ form: Ref
+ formRef: Ref
+ loading: typeof loading
+ radioStyle: ReturnType>
+ typeItems: SelectItem[]
+ resetFields: () => void
+ openAdd: () => void
+ openEdit: (val: PriceTrigger) => void
+ initItems: () => Promise
+ ok: () => Promise
+}
+
+export const PRICE_TRIGGER_PAGE_CONTEXT_KEY: InjectionKey =
+ Symbol('priceTriggerPageContext')
+
+export function usePriceTriggerPage() {
+ const items = ref([])
+ const addTriggerVisible = ref(false)
+ const editId = ref(undefined)
+ const formRef = ref()
+
+ const radioStyle = reactive({
+ display: '',
+ height: '30px',
+ lineHeight: '30px',
+ })
+
+ const form = ref({
+ price_condition: '',
+ is_active: true,
+ action: '',
+ target_price: '',
+ })
+
+ const typeItems: SelectItem[] = [
+ { value: 'بزرگتر', key: '>' },
+ { value: 'کوچکتر', key: '<' },
+ { value: 'مساوی', key: '==' },
+ ]
+
+ function resetFields() {
+ form.value = {
+ price_condition: '',
+ is_active: true,
+ action: '',
+ target_price: '',
+ }
+ }
+
+ function openAdd() {
+ resetFields()
+ editId.value = undefined
+ addTriggerVisible.value = true
+ }
+
+ function openEdit(val: PriceTrigger) {
+ form.value = cloneDeep(val)
+ editId.value = form.value.id
+ addTriggerVisible.value = true
+ }
+
+ async function initItems() {
+ try {
+ items.value = []
+ items.value = await getPriceTriggers()
+ } catch (e) {
+ console.log(e)
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function ok() {
+ await formRef.value?.validateFields().then(async () => {
+ try {
+ loading.value = true
+ if (editId.value) {
+ await updatePriceTrigger(editId.value, form.value)
+ } else {
+ await createPriceTrigger(form.value)
+ }
+ message.success('هشدار با موفقیت ایجاد شد')
+ await initItems()
+ addTriggerVisible.value = false
+ } catch (e) {
+ console.log(e)
+ } finally {
+ loading.value = false
+ }
+ })
+ }
+
+ onMounted(() => {
+ initItems()
+ })
+
+ const context: PriceTriggerPageContext = {
+ items,
+ addTriggerVisible,
+ editId,
+ form,
+ formRef,
+ loading,
+ radioStyle,
+ typeItems,
+ resetFields,
+ openAdd,
+ openEdit,
+ initItems,
+ ok,
+ }
+
+ provide(PRICE_TRIGGER_PAGE_CONTEXT_KEY, context)
+ return context
+}
+
+export function usePriceTriggerPageContext() {
+ const context = inject(PRICE_TRIGGER_PAGE_CONTEXT_KEY)
+ if (!context) {
+ throw new Error('usePriceTriggerPageContext must be used within a page that calls usePriceTriggerPage()')
+ }
+ return context
+}
diff --git a/src/views/base/priceTrigger/desktop/DesktopPriceTriggerView.vue b/src/views/base/priceTrigger/desktop/DesktopPriceTriggerView.vue
new file mode 100644
index 0000000..06eaaab
--- /dev/null
+++ b/src/views/base/priceTrigger/desktop/DesktopPriceTriggerView.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
هنوز هشداری ثبت نشده است.
+
+ افزودن اولین هشدار
+
+
+
+
+
+
+
+
+
diff --git a/src/views/base/priceTrigger/desktop/styles/desktop-price-trigger.less b/src/views/base/priceTrigger/desktop/styles/desktop-price-trigger.less
new file mode 100644
index 0000000..3faf1a5
--- /dev/null
+++ b/src/views/base/priceTrigger/desktop/styles/desktop-price-trigger.less
@@ -0,0 +1,273 @@
+/* Premium desktop price trigger page */
+
+.dptr {
+ min-height: 100%;
+ background: #f6f7f9;
+ color: #111827;
+}
+
+.dptr__container {
+ max-width: 1180px;
+ margin: 0 auto;
+ padding: 28px 36px 40px;
+}
+
+.dptr-topbar {
+ margin-bottom: 24px;
+ padding-bottom: 24px;
+ border-bottom: 1px solid rgba(15, 23, 42, 0.06);
+
+ &__eyebrow {
+ font-size: 12px;
+ font-weight: 500;
+ color: #9ca3af;
+ margin: 0 0 4px;
+ }
+
+ &__title {
+ font-size: 26px;
+ font-weight: 600;
+ letter-spacing: -0.03em;
+ color: #111827;
+ margin: 0 0 6px;
+ line-height: 1.2;
+ }
+
+ &__subtitle {
+ font-size: 14px;
+ color: #6b7280;
+ margin: 0;
+ max-width: 520px;
+ line-height: 1.6;
+ }
+}
+
+.dptr-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 380px) minmax(0, 1fr);
+ gap: 20px;
+ align-items: start;
+}
+
+.dptr-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);
+}
+
+.dptr-form {
+ padding: 24px;
+
+ &__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 20px;
+ }
+
+ &__title {
+ font-size: 16px;
+ font-weight: 600;
+ color: #111827;
+ margin: 0;
+ }
+
+ &__reset {
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ background: #fff;
+ color: #6b7280;
+ font-size: 12px;
+ font-weight: 600;
+ font-family: inherit;
+ padding: 6px 10px;
+ cursor: pointer;
+ transition: border-color 0.15s ease, color 0.15s ease;
+
+ &:hover {
+ border-color: @App-primary-color;
+ color: @App-primary-color;
+ }
+ }
+}
+
+.dptr-list {
+ display: flex;
+ flex-direction: column;
+ min-height: 420px;
+
+ &__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 20px 24px 0;
+ }
+
+ &__title {
+ font-size: 16px;
+ font-weight: 600;
+ color: #111827;
+ margin: 0;
+ }
+
+ &__count {
+ font-size: 12px;
+ font-weight: 600;
+ color: #6b7280;
+ background: #f3f4f6;
+ border-radius: 999px;
+ padding: 4px 10px;
+ }
+
+ &__items {
+ padding: 16px 24px 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ }
+
+ &__skeletons {
+ padding: 16px 24px 24px;
+ }
+}
+
+.dptr-card {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 12px 16px;
+ padding: 16px 18px;
+ border: 1px solid #eef0f3;
+ border-radius: 12px;
+ background: #fafbfc;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+
+ &:hover {
+ border-color: #dbe0e6;
+ box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
+ }
+
+ &__price {
+ grid-column: 1 / -1;
+ font-size: 18px;
+ font-weight: 700;
+ color: #111827;
+ margin: 0;
+ }
+
+ &__meta {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 0;
+ }
+
+ &__row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ color: #6b7280;
+ }
+
+ &__label {
+ color: #9ca3af;
+ flex-shrink: 0;
+ }
+
+ &__value {
+ color: #374151;
+ font-weight: 600;
+ }
+
+ &__actions {
+ display: flex;
+ align-items: flex-start;
+ gap: 4px;
+ }
+
+ &__icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 34px;
+ height: 34px;
+ border: 1px solid #e5e7eb;
+ border-radius: 10px;
+ background: #fff;
+ cursor: pointer;
+ transition: border-color 0.15s ease, background 0.15s ease;
+
+ &:hover {
+ background: #f9fafb;
+ }
+
+ &--danger:hover {
+ border-color: #fecaca;
+ background: #fef2f2;
+ }
+
+ &--edit:hover {
+ border-color: #bbf7d0;
+ background: #f0fdf4;
+ }
+ }
+
+ &__footer {
+ grid-column: 1 / -1;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding-top: 10px;
+ border-top: 1px solid #eef0f3;
+ }
+
+ &__date {
+ font-size: 12px;
+ color: #9ca3af;
+ }
+}
+
+.dptr-empty {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 48px 24px;
+ color: #6b7280;
+ text-align: center;
+
+ p {
+ margin: 0;
+ font-size: 14px;
+ }
+
+ &__btn {
+ margin-top: 4px;
+ border: none;
+ border-radius: 10px;
+ background: @App-primary-color;
+ color: #fff;
+ font-size: 13px;
+ font-weight: 600;
+ font-family: inherit;
+ padding: 10px 16px;
+ cursor: pointer;
+ transition: opacity 0.15s ease;
+
+ &:hover {
+ opacity: 0.92;
+ }
+ }
+}
+
+@media (max-width: 1023.98px) {
+ .dptr-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/views/base/priceTrigger/index.vue b/src/views/base/priceTrigger/index.vue
index 97f1fd6..e512426 100644
--- a/src/views/base/priceTrigger/index.vue
+++ b/src/views/base/priceTrigger/index.vue
@@ -1,198 +1,31 @@
-
-
-
-
\ No newline at end of file
+.price-trigger-page-root {
+ min-height: 100%;
+ height: 100%;
+}
+
diff --git a/src/views/base/priceTrigger/mobile/MobilePriceTriggerView.vue b/src/views/base/priceTrigger/mobile/MobilePriceTriggerView.vue
new file mode 100644
index 0000000..8b97615
--- /dev/null
+++ b/src/views/base/priceTrigger/mobile/MobilePriceTriggerView.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
diff --git a/src/views/base/products/composables/useProductsPage.ts b/src/views/base/products/composables/useProductsPage.ts
new file mode 100644
index 0000000..3b57e92
--- /dev/null
+++ b/src/views/base/products/composables/useProductsPage.ts
@@ -0,0 +1,168 @@
+import {
+ inject,
+ onMounted,
+ onUnmounted,
+ provide,
+ ref,
+ watch,
+ computed,
+ type InjectionKey,
+} from 'vue'
+import router from '@/router'
+import { loading } from '@/utils'
+import type { Product } from '@/models/products'
+import { cloneDeep } from 'lodash'
+import { isProductPriceInactive } from '@/utils/productPrice'
+import { getStoreClosedMessage, isStoreClosed } from '@/utils/storeHours'
+import { lastPrice } from '@/utils/price'
+import {
+ changeProductsTab,
+ initProductsPageData,
+ onProductsPageProviderMounted,
+ onProductsPageProviderUnmounted,
+ productsActiveKey,
+ productsCloseTime,
+ productsItems,
+ productsOpenTime,
+ productsTypeItems,
+} from './useProductsPageData'
+
+export interface ProductsPageContext {
+ activekey: typeof productsActiveKey
+ typeItems: typeof productsTypeItems
+ items: typeof productsItems
+ loading: typeof loading
+ showFilter: ReturnType
>
+ orderSheetKey: ReturnType>
+ selectedProduct: ReturnType>
+ isProductInactive: ReturnType>
+ isAfterCloseTime: ReturnType>
+ storeClosedMessage: ReturnType>
+ openTime: typeof productsOpenTime
+ closeTime: typeof productsCloseTime
+ changeTab: typeof changeProductsTab
+ clickProduct: (item?: Product) => void
+ onProductOrdered: () => void
+ isProductPriceInactive: typeof isProductPriceInactive
+}
+
+export const PRODUCTS_PAGE_CONTEXT_KEY: InjectionKey =
+ Symbol('productsPageContext')
+
+export function useProductsPageProvider() {
+ const showFilter = ref(false)
+ const orderSheetKey = ref(0)
+ const selectedProduct = ref()
+ const isAfterCloseTime = ref(false)
+ const isProductInactive = ref(false)
+ const storeClosedMessage = computed(() => getStoreClosedMessage())
+
+ function syncStoreClosedState() {
+ if (isStoreClosed()) {
+ showFilter.value = false
+ isAfterCloseTime.value = true
+ }
+ }
+
+ watch([productsOpenTime, productsCloseTime], () => {
+ syncStoreClosedState()
+ })
+
+ watch(() => lastPrice.value?.closed, syncStoreClosedState)
+
+ function clickProduct(item?: Product) {
+ if (!item?.show) return
+
+ if (isStoreClosed()) {
+ showFilter.value = false
+ isAfterCloseTime.value = true
+ return
+ }
+
+ if (isProductPriceInactive(item)) {
+ showFilter.value = false
+ isProductInactive.value = true
+ return
+ }
+
+ orderSheetKey.value += 1
+ selectedProduct.value = cloneDeep(item)
+ showFilter.value = true
+ }
+
+ function onProductOrdered() {
+ showFilter.value = false
+ }
+
+ watch(showFilter, (open) => {
+ if (open && isStoreClosed()) {
+ showFilter.value = false
+ isAfterCloseTime.value = true
+ return
+ }
+ if (!open) {
+ orderSheetKey.value += 1
+ }
+ })
+
+ onMounted(async () => {
+ onProductsPageProviderMounted()
+ await initProductsPageData()
+
+ if (router.currentRoute.value.query.cat === 'coin') {
+ productsActiveKey.value = 'سکه'
+ changeProductsTab()
+ }
+
+ if (router.currentRoute.value.query.state) {
+ if (isStoreClosed()) {
+ isAfterCloseTime.value = true
+ } else if (router.currentRoute.value.query.product) {
+ clickProduct(productsItems.value?.find((x) => x.title === 'سکه'))
+ } else {
+ clickProduct(productsItems.value?.find((x) => x.title === 'آبشده نقدی'))
+ }
+ }
+ })
+
+ onUnmounted(() => {
+ onProductsPageProviderUnmounted()
+ })
+
+ const context: ProductsPageContext = {
+ activekey: productsActiveKey,
+ typeItems: productsTypeItems,
+ items: productsItems,
+ loading,
+ showFilter,
+ orderSheetKey,
+ selectedProduct,
+ isProductInactive,
+ isAfterCloseTime,
+ storeClosedMessage,
+ openTime: productsOpenTime,
+ closeTime: productsCloseTime,
+ changeTab: changeProductsTab,
+ clickProduct,
+ onProductOrdered,
+ isProductPriceInactive,
+ }
+
+ provide(PRODUCTS_PAGE_CONTEXT_KEY, context)
+ return context
+}
+
+export function useProductsPageContext() {
+ const context = inject(PRODUCTS_PAGE_CONTEXT_KEY)
+ if (!context) {
+ throw new Error(
+ 'useProductsPageContext must be used within a page that calls useProductsPageProvider()',
+ )
+ }
+ return context
+}
+
+/** @deprecated Use useProductsPageProvider in index + useProductsPageContext in views. */
+export function useProductsPage() {
+ return useProductsPageProvider()
+}
diff --git a/src/views/base/products/composables/useProductsPageData.ts b/src/views/base/products/composables/useProductsPageData.ts
new file mode 100644
index 0000000..9ebbe86
--- /dev/null
+++ b/src/views/base/products/composables/useProductsPageData.ts
@@ -0,0 +1,180 @@
+import { ref } from 'vue'
+import { getProductsCatalog } from '@/composables/useProductsCatalog'
+import { loading } from '@/utils'
+import type { Product } from '@/models/products'
+import { loadServiceFeeFromSettings } from '@/utils/serviceFee'
+import { loadProductCategoryTabNames } from '@/utils/categoryTabs'
+import { getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
+
+/** Shared products page datasets — mobile/desktop views read this module state only. */
+let initPromise: Promise | null = null
+let productListInterval: ReturnType | undefined
+let providerMountCount = 0
+let pricePollingMountCount = 0
+
+const initialized = ref(false)
+
+export const productsActiveKey = ref('همه')
+export const productsTypeItems = ref([])
+export const productsFirstItems = ref()
+export const productsItems = ref()
+export const productsOpenTime = ref('')
+export const productsCloseTime = ref('')
+
+export function isProductsPageDataInitialized() {
+ return initialized.value
+}
+
+export function changeProductsTab() {
+ if (productsActiveKey.value !== 'همه') {
+ productsItems.value = productsFirstItems.value?.filter(
+ (val) => val.group_type === productsActiveKey.value,
+ )
+ } else {
+ productsItems.value = productsFirstItems.value
+ }
+}
+
+function syncTypeItems(products: Product[]) {
+ products.forEach((val) => {
+ if (!productsTypeItems.value.includes(val.group_type)) {
+ productsTypeItems.value.push(val.group_type)
+ }
+ })
+}
+
+async function loadCategoryTabs() {
+ try {
+ const tabs = await loadProductCategoryTabNames()
+ if (tabs.length) {
+ productsTypeItems.value = tabs
+ }
+ } catch (error) {
+ console.error('Product category tabs error:', error)
+ }
+}
+
+function applyProductsList(products: Product[]) {
+ if (products.length) {
+ productsCloseTime.value = products[0].auto_close_at
+ productsOpenTime.value = products[0].auto_open_at
+ }
+
+ productsFirstItems.value = products
+ syncTypeItems(products)
+ changeProductsTab()
+}
+
+export async function refreshProductsPageData() {
+ try {
+ const fresh = await getProductsCatalog(true)
+ if (!fresh?.length) return
+
+ syncTypeItems(fresh)
+
+ const freshById = new Map(fresh.map((p) => [p.id, p]))
+ const current = productsFirstItems.value
+
+ if (!current?.length) {
+ applyProductsList(fresh)
+ return
+ }
+
+ current.forEach((item) => {
+ const updated = freshById.get(item.id)
+ if (updated) {
+ Object.assign(item, updated)
+ }
+ })
+
+ const currentIds = new Set(current.map((p) => p.id))
+ const hasAdded = fresh.some((p) => !currentIds.has(p.id))
+ const hasRemoved = current.some((p) => !freshById.has(p.id))
+ if (hasAdded || hasRemoved) {
+ applyProductsList(fresh)
+ }
+ } catch (error) {
+ console.error('Products refresh error:', error)
+ }
+}
+
+function startProductListPolling() {
+ if (productListInterval) return
+ productListInterval = setInterval(() => {
+ void refreshProductsPageData()
+ }, 10000)
+}
+
+function stopProductListPolling() {
+ if (productListInterval) {
+ clearInterval(productListInterval)
+ productListInterval = undefined
+ }
+}
+
+export function onProductsPageProviderMounted() {
+ providerMountCount += 1
+ pricePollingMountCount += 1
+ if (pricePollingMountCount === 1) {
+ void getLastPrices()
+ startLastPricePolling()
+ }
+}
+
+export function onProductsPageProviderUnmounted() {
+ 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) {
+ startProductListPolling()
+ return
+ }
+
+ if (!force && initPromise) {
+ return initPromise
+ }
+
+ initPromise = (async () => {
+ try {
+ loading.value = true
+ const products = await getProductsCatalog(force)
+ applyProductsList(products ?? [])
+ await Promise.all([
+ loadServiceFeeFromSettings(),
+ loadCategoryTabs(),
+ ])
+ initialized.value = true
+ startProductListPolling()
+ } catch (error) {
+ console.error('Products page initialization error:', error)
+ } finally {
+ loading.value = false
+ initPromise = null
+ }
+ })()
+
+ return initPromise
+}
+
+export function resetProductsPageData() {
+ initialized.value = false
+ initPromise = null
+ providerMountCount = 0
+ pricePollingMountCount = 0
+ stopLastPricePolling()
+ stopProductListPolling()
+ productsActiveKey.value = 'همه'
+ productsTypeItems.value = []
+ productsFirstItems.value = undefined
+ productsItems.value = undefined
+ productsOpenTime.value = ''
+ productsCloseTime.value = ''
+}
diff --git a/src/views/base/products/desktop/DesktopProductsView.vue b/src/views/base/products/desktop/DesktopProductsView.vue
new file mode 100644
index 0000000..7548447
--- /dev/null
+++ b/src/views/base/products/desktop/DesktopProductsView.vue
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+ همه
+
+
+ {{ item }}
+
+
+
+
+
+
+
+ محصولی برای نمایش وجود ندارد.
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/base/products/desktop/components/DesktopProductCard.vue b/src/views/base/products/desktop/components/DesktopProductCard.vue
new file mode 100644
index 0000000..f0c13df
--- /dev/null
+++ b/src/views/base/products/desktop/components/DesktopProductCard.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
{{ product.group_type }}
+
{{ product.title }}
+
+
+ {{ usePrice(goldPriceValue) }}
+
+
+ {{ usePrice(price) }}
+
+
+
+
+
+
+
diff --git a/src/views/base/products/desktop/styles/desktop-products.less b/src/views/base/products/desktop/styles/desktop-products.less
new file mode 100644
index 0000000..a01b804
--- /dev/null
+++ b/src/views/base/products/desktop/styles/desktop-products.less
@@ -0,0 +1,173 @@
+/* Premium desktop products / orders page */
+
+.dproducts {
+ min-height: 100%;
+ background: #f6f7f9;
+ color: #111827;
+}
+
+.dproducts__container {
+ max-width: 1180px;
+ margin: 0 auto;
+ padding: 28px 36px 40px;
+}
+
+.dproducts-topbar {
+ margin-bottom: 24px;
+ padding-bottom: 24px;
+ border-bottom: 1px solid rgba(15, 23, 42, 0.06);
+
+ &__eyebrow {
+ font-size: 12px;
+ font-weight: 500;
+ color: #9ca3af;
+ margin: 0 0 4px;
+ }
+
+ &__title {
+ font-size: 26px;
+ font-weight: 600;
+ letter-spacing: -0.03em;
+ color: #111827;
+ margin: 0;
+ line-height: 1.2;
+ }
+
+ &__subtitle {
+ font-size: 13px;
+ color: #6b7280;
+ margin: 8px 0 0;
+ }
+}
+
+.dproducts-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 24px;
+
+ &__btn {
+ padding: 8px 14px;
+ border: 1px solid #e5e7eb;
+ border-radius: 999px;
+ background: #fff;
+ font-size: 13px;
+ font-weight: 600;
+ color: #6b7280;
+ font-family: inherit;
+ cursor: pointer;
+ transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
+
+ &:hover {
+ border-color: #d1d5db;
+ color: #374151;
+ }
+
+ &--active {
+ background: @App-primary-color;
+ border-color: @App-primary-color;
+ color: #fff;
+ }
+ }
+}
+
+.dproducts-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 16px;
+}
+
+.dproducts-card {
+ display: flex;
+ flex-direction: column;
+ background: #fff;
+ border: 1px solid #f3f4f6;
+ border-radius: 14px;
+ overflow: hidden;
+ cursor: pointer;
+ text-align: right;
+ font-family: inherit;
+ transition: box-shadow 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
+
+ &:hover:not(&--disabled) {
+ border-color: #e5e7eb;
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
+ transform: translateY(-1px);
+ }
+
+ &--disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+ }
+
+ &__image-wrap {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 160px;
+ background: #f9fafb;
+ padding: 20px;
+ }
+
+ &__image {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ }
+
+ &__body {
+ padding: 16px 18px 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ &__title {
+ font-size: 15px;
+ font-weight: 600;
+ color: #111827;
+ margin: 0;
+ line-height: 1.4;
+ }
+
+ &__price {
+ font-size: 18px;
+ font-weight: 600;
+ color: @App-primary-color;
+ margin: 0;
+ direction: ltr;
+ text-align: right;
+ }
+
+ &__badge {
+ display: inline-flex;
+ align-self: flex-start;
+ padding: 4px 8px;
+ border-radius: 6px;
+ background: #f3f4f6;
+ font-size: 11px;
+ font-weight: 600;
+ color: #6b7280;
+ }
+}
+
+.dproducts-empty {
+ grid-column: 1 / -1;
+ padding: 48px 24px;
+ text-align: center;
+ color: #6b7280;
+ font-size: 14px;
+}
+
+.dproducts-order-panel {
+ :deep(.ant-modal-content) {
+ border-radius: 16px;
+ overflow: hidden;
+ }
+
+ :deep(.ant-modal-body) {
+ max-height: calc(100vh - 180px);
+ overflow-y: auto;
+ padding: 0 24px 24px;
+ }
+}
diff --git a/src/views/base/products/index.vue b/src/views/base/products/index.vue
index 541dfc4..804c775 100644
--- a/src/views/base/products/index.vue
+++ b/src/views/base/products/index.vue
@@ -1,325 +1,110 @@
-
-
-
-
-