diff --git a/components.d.ts b/components.d.ts index 471e08b..2dab5e3 100644 --- a/components.d.ts +++ b/components.d.ts @@ -14,7 +14,6 @@ declare module 'vue' { AFormItem: typeof import('ant-design-vue/es')['FormItem'] AImage: typeof import('ant-design-vue/es')['Image'] AInput: typeof import('ant-design-vue/es')['Input'] - AInputNumber: typeof import('ant-design-vue/es')['InputNumber'] AInputPassword: typeof import('ant-design-vue/es')['InputPassword'] AModal: typeof import('ant-design-vue/es')['Modal'] AppCreditLink: typeof import('./src/components/AppCreditLink.vue')['default'] @@ -45,6 +44,7 @@ declare module 'vue' { BasicToggleTwoItem: typeof import('./src/components/BasicPacks/BasicToggleTwoItem.vue')['default'] Bg_main: typeof import('./src/components/bg_main.vue')['default'] Bordered: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/bordered.vue')['default'] + CopperConvertModal: typeof import('./src/components/CopperConvertModal.vue')['default'] DateRender: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/date-render.vue')['default'] Demo: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/index.vue')['default'] DialogVant: typeof import('./src/components/DialogVant.vue')['default'] diff --git a/src/App.vue b/src/App.vue index 62c5602..9995b6e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -110,6 +110,7 @@ /> + - - diff --git a/src/components/CopperConvertModal.vue b/src/components/CopperConvertModal.vue new file mode 100644 index 0000000..8ea3f93 --- /dev/null +++ b/src/components/CopperConvertModal.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/components/GoldDayCalculatorModal.vue b/src/components/GoldDayCalculatorModal.vue index 5b61457..71b752f 100644 --- a/src/components/GoldDayCalculatorModal.vue +++ b/src/components/GoldDayCalculatorModal.vue @@ -4,11 +4,11 @@ width="400px" destroy-on-close custom-footer - wrap-class-name="gold-day-calc-modal" + wrap-class-name="app-premium-modal--scroll-tall" > @@ -75,7 +75,7 @@ @@ -219,99 +219,3 @@ watch(visible, async (open) => { display: inline-block; } - - diff --git a/src/composables/useCopperConvertModal.ts b/src/composables/useCopperConvertModal.ts new file mode 100644 index 0000000..fedf87f --- /dev/null +++ b/src/composables/useCopperConvertModal.ts @@ -0,0 +1,11 @@ +import { ref } from 'vue' + +export const copperConvertModalVisible = ref(false) + +export function openCopperConvert() { + copperConvertModalVisible.value = true +} + +export function closeCopperConvert() { + copperConvertModalVisible.value = false +} diff --git a/src/styles/app-premium-modal.less b/src/styles/app-premium-modal.less new file mode 100644 index 0000000..247392e --- /dev/null +++ b/src/styles/app-premium-modal.less @@ -0,0 +1,198 @@ +/** تم یکسان مدال‌های boursecu user — لوکس / پریمیوم */ + +.app-premium-modal.ant-modal-wrap { + backdrop-filter: blur(2px); +} + +.app-premium-modal { + .ant-modal-content { + direction: rtl; + display: flex; + flex-direction: column; + max-height: min(92dvh, 720px); + overflow: hidden; + padding: 0 !important; + border-radius: 1.125rem; + border: 1px solid rgba(201, 160, 98, 0.35); + box-shadow: + 0 24px 48px rgba(15, 39, 68, 0.28), + 0 0 0 1px rgba(255, 255, 255, 0.04) inset; + background: #fff; + position: relative; + } + + .ant-modal-header { + flex-shrink: 0; + position: relative; + z-index: 1; + margin: 0; + min-height: 3.25rem; + display: flex; + align-items: center; + justify-content: flex-start; + padding: 0.875rem 1rem 0.875rem 3rem; + direction: rtl; + text-align: right; + background: linear-gradient(180deg, #faf8f5 0%, #fff 100%); + border-bottom: 1px solid rgba(201, 160, 98, 0.2); + border-radius: 1.125rem 1.125rem 0 0; + } + + .ant-modal-title { + flex: 1 1 auto; + min-width: 0; + margin: 0; + padding: 0; + line-height: 1.45; + text-align: right; + font-size: 1.0625rem; + font-weight: 800; + color: #0f2744; + letter-spacing: -0.01em; + } + + .app-premium-modal__title { + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; + direction: rtl; + width: 100%; + } + + .app-premium-modal__title-icon { + flex-shrink: 0; + color: var(--App-secondary-color, #c9a062); + } + + .ant-modal-close { + position: absolute; + inset-inline-start: 0.75rem !important; + inset-inline-end: auto !important; + left: 0.75rem !important; + right: auto !important; + top: 0.875rem !important; + margin: 0; + width: 2rem; + height: 2rem; + border-radius: 999px; + background: rgba(15, 39, 68, 0.06); + color: #0f2744; + transition: background 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; + + &:hover { + background: rgba(15, 39, 68, 0.1); + } + + .ant-modal-close-x { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + line-height: 1; + font-size: 0.875rem; + } + } + + .ant-modal-body { + flex: 1 1 auto; + min-height: 0; + padding: 0.75rem 1.125rem 0.5rem; + background: #fff; + } + + #body { + max-height: min(56dvh, 440px); + overflow-y: auto !important; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + padding: 0.35rem 0.125rem 0.5rem !important; + } + + .ant-modal-footer { + flex-shrink: 0; + margin: 0; + padding: 0.75rem 1.125rem 1rem; + border-top: 1px solid rgba(15, 39, 68, 0.08); + background: #faf8f5; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; + } + + .ant-modal-footer .ant-btn { + min-height: 2.5rem; + border-radius: 0.75rem !important; + font-weight: 700; + padding-inline: 1.25rem; + } + + .ant-modal-footer .ant-btn-primary, + .app-premium-modal__btn-primary { + border: none !important; + background: linear-gradient(135deg, #0f2744 0%, #1a365d 55%, #234876 100%) !important; + box-shadow: 0 8px 20px rgba(15, 39, 68, 0.22); + } + + .ant-modal-footer .ant-btn-default { + border-color: rgba(15, 39, 68, 0.15) !important; + color: #0f2744 !important; + background: #fff !important; + } + + &.app-premium-modal--no-header { + .ant-modal-header { + display: none; + } + + .ant-modal-close { + display: none; + } + + .ant-modal-body { + padding-top: 1rem; + } + } + + &.app-premium-modal--scroll-tall #body { + max-height: min(58dvh, 480px) !important; + } + + &.app-premium-modal--fluid-body #body { + max-height: none !important; + overflow: visible !important; + padding-top: 0 !important; + padding-bottom: 0.25rem !important; + } + + &.app-premium-modal--wide-panel { + .ant-modal-body { + padding-inline: 1.5rem; + padding-bottom: 1rem; + } + + #body { + max-height: calc(100dvh - 12rem); + } + } +} + +@media screen and (max-width: 767.98px) { + .app-premium-modal { + .ant-modal { + max-width: calc(100vw - 1rem); + margin: 0 auto; + top: 1.5vh; + } + + &.app-premium-modal--scroll-tall #body { + max-height: min(52dvh, 420px) !important; + } + } +} diff --git a/src/utils/copperWireConvert.ts b/src/utils/copperWireConvert.ts new file mode 100644 index 0000000..676ea22 --- /dev/null +++ b/src/utils/copperWireConvert.ts @@ -0,0 +1,78 @@ +/** فرمول تبدیل کارفرما — اکسل «تبدیل کاتد و مفتول به سیم ۱.۵–۲.۵» */ + +export type CopperConvertSource = 'katod' | 'maftoul' | 'shamsh' + +export interface CopperConvertPrices { + katodPerKg: number + maftoulPerKg: number + shamshPerKg: number + /** قیمت هر حلقه سیم ۱.۵ (ریال) */ + wireRing15: number + /** قیمت هر حلقه سیم ۲.۵ (ریال) */ + wireRing25: number + /** وزن هر حلقه ۱.۵ — گرم (ستون اکسل) */ + wireWeightGrams15: number + /** وزن هر حلقه ۲.۵ — گرم؛ اگر صفر باشد فقط محاسبه ارزشی */ + wireWeightGrams25: number +} + +export interface CopperConvertResult { + totalValueRial: number + equivalentMaftoulKg: number + /** ارزش ÷ قیمت حلقه (شیت مفتول→سیم) */ + rings15ByValue: number + rings25ByValue: number + /** مفتول معادل ÷ وزن حلقه */ + rings15ByWeight: number | null + rings25ByWeight: number | null +} + +/** مقادیر نمونه اکسل — وقتی محصول سیم در لیست نیست */ +export const COPPER_CONVERT_WIRE_FALLBACK = { + wireRing15: 36_000_000, + wireRing25: 38_000_000, + wireWeightGrams15: 2777, + wireWeightGrams25: 0, +} as const + +function sourcePricePerKg(source: CopperConvertSource, prices: CopperConvertPrices): number { + if (source === 'katod') return prices.katodPerKg + if (source === 'maftoul') return prices.maftoulPerKg + return prices.shamshPerKg +} + +function ringsByWeight(maftoulKg: number, gramsPerRing: number): number | null { + if (maftoulKg <= 0 || gramsPerRing <= 0) return null + return (maftoulKg * 1000) / gramsPerRing +} + +export function calcCopperWireConvert( + inputKg: number, + source: CopperConvertSource, + prices: CopperConvertPrices, +): CopperConvertResult | null { + if (!Number.isFinite(inputKg) || inputKg <= 0) { + return null + } + + const perKg = sourcePricePerKg(source, prices) + if (perKg <= 0) { + return null + } + + const totalValueRial = inputKg * perKg + const equivalentMaftoulKg = + prices.maftoulPerKg > 0 ? totalValueRial / prices.maftoulPerKg : 0 + + const rings15ByValue = prices.wireRing15 > 0 ? totalValueRial / prices.wireRing15 : 0 + const rings25ByValue = prices.wireRing25 > 0 ? totalValueRial / prices.wireRing25 : 0 + + return { + totalValueRial, + equivalentMaftoulKg, + rings15ByValue, + rings25ByValue, + rings15ByWeight: ringsByWeight(equivalentMaftoulKg, prices.wireWeightGrams15), + rings25ByWeight: ringsByWeight(equivalentMaftoulKg, prices.wireWeightGrams25), + } +} diff --git a/src/views/base/convert/components/CopperConvertCalculator.vue b/src/views/base/convert/components/CopperConvertCalculator.vue new file mode 100644 index 0000000..364bc32 --- /dev/null +++ b/src/views/base/convert/components/CopperConvertCalculator.vue @@ -0,0 +1,507 @@ + + + + + diff --git a/src/views/base/convert/composables/useCopperConvertPage.ts b/src/views/base/convert/composables/useCopperConvertPage.ts new file mode 100644 index 0000000..034cc71 --- /dev/null +++ b/src/views/base/convert/composables/useCopperConvertPage.ts @@ -0,0 +1,175 @@ +import { + computed, + inject, + provide, + ref, + type ComputedRef, + type InjectionKey, + type Ref, +} from 'vue' +import { getProducts } from '@/api/products' +import type { Product } from '@/models/products' +import { + COPPER_CONVERT_WIRE_FALLBACK, + calcCopperWireConvert, + type CopperConvertPrices, + type CopperConvertResult, + type CopperConvertSource, +} from '@/utils/copperWireConvert' +import { LEGACY_GROUP_KATOD, LEGACY_GROUP_MAFTOL, normalizeProductGroupType } from '@/utils/productCategories' +import { getProductBuyPrice } from '@/utils/productPrice' +import { getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price' +import { persianToEnglishDigits } from '@/utils/persianDigits' + +export const COPPER_CONVERT_PAGE_KEY: InjectionKey = + Symbol('copperConvertPageContext') + +export interface CopperConvertPageContext { + loading: Ref + source: Ref + inputKg: Ref + prices: ComputedRef + result: ComputedRef + priceRows: ComputedRef<{ label: string; value: string; missing?: boolean }[]> +} + +function parseKg(raw: string): number { + const s = persianToEnglishDigits(String(raw ?? '')) + .replace(/,/g, '') + .replace(/[^\d.]/g, '') + const n = Number.parseFloat(s) + return Number.isFinite(n) ? n : 0 +} + +function titleIncludes(product: Product, part: string) { + return (product.title ?? '').includes(part) +} + +function isKatodProduct(product: Product) { + const group = normalizeProductGroupType(product.group_type) + return group === LEGACY_GROUP_KATOD || titleIncludes(product, 'کاتد') +} + +function isMaftolProduct(product: Product) { + const group = normalizeProductGroupType(product.group_type) + return group === LEGACY_GROUP_MAFTOL || titleIncludes(product, 'مفتول') +} + +function isShamshProduct(product: Product) { + const group = normalizeProductGroupType(product.group_type) + return group.includes('شمش') || titleIncludes(product, 'شمش') +} + +function wireCrossSection(product: Product): '1.5' | '2.5' | null { + const t = product.title ?? '' + if (/1\.5|۱\.۵/.test(t)) return '1.5' + if (/2\.5|۲\.۵/.test(t)) return '2.5' + return null +} + +function isWireProduct(product: Product) { + const t = `${product.group_type ?? ''} ${product.title ?? ''}` + return t.includes('سیم') || wireCrossSection(product) != null +} + +function pickProduct( + products: Product[], + matcher: (p: Product) => boolean, +): Product | undefined { + return products.find((p) => p.active && matcher(p)) +} + +function resolveWireRingPrice(product: Product | undefined, fallback: number): number { + if (!product) return fallback + const price = getProductBuyPrice(product) + return price > 0 ? price : fallback +} + +export function useCopperConvertPage() { + const loading = ref(true) + const source = ref('katod') + const inputKg = ref('') + const catalog = ref([]) + + const katodProduct = computed(() => pickProduct(catalog.value, isKatodProduct)) + const maftolProduct = computed(() => pickProduct(catalog.value, isMaftolProduct)) + const shamshProduct = computed(() => pickProduct(catalog.value, isShamshProduct)) + const wire15Product = computed(() => + pickProduct(catalog.value, (p) => isWireProduct(p) && wireCrossSection(p) === '1.5'), + ) + const wire25Product = computed(() => + pickProduct(catalog.value, (p) => isWireProduct(p) && wireCrossSection(p) === '2.5'), + ) + + const prices = computed(() => ({ + katodPerKg: katodProduct.value ? getProductBuyPrice(katodProduct.value) : 0, + maftoulPerKg: maftolProduct.value ? getProductBuyPrice(maftolProduct.value) : 0, + shamshPerKg: shamshProduct.value ? getProductBuyPrice(shamshProduct.value) : 0, + wireRing15: resolveWireRingPrice(wire15Product.value, COPPER_CONVERT_WIRE_FALLBACK.wireRing15), + wireRing25: resolveWireRingPrice(wire25Product.value, COPPER_CONVERT_WIRE_FALLBACK.wireRing25), + wireWeightGrams15: COPPER_CONVERT_WIRE_FALLBACK.wireWeightGrams15, + wireWeightGrams25: COPPER_CONVERT_WIRE_FALLBACK.wireWeightGrams25, + })) + + const result = computed(() => + calcCopperWireConvert(parseKg(inputKg.value), source.value, prices.value), + ) + + const priceRows = computed(() => { + const p = prices.value + const row = (label: string, amount: number) => ({ + label, + value: amount > 0 ? `${Math.round(amount).toLocaleString('en-US')} ریال` : '—', + missing: amount <= 0, + }) + return [ + row('قیمت کاتد (هر کیلو)', p.katodPerKg), + row('قیمت مفتول (هر کیلو)', p.maftoulPerKg), + row('قیمت شمش (هر کیلو)', p.shamshPerKg), + row('قیمت حلقه سیم ۱.۵', p.wireRing15), + row('قیمت حلقه سیم ۲.۵', p.wireRing25), + ] + }) + + async function loadCatalog() { + loading.value = true + try { + await getLastPrices() + const list = await getProducts() + catalog.value = Array.isArray(list) ? list : [] + } catch { + catalog.value = [] + } finally { + loading.value = false + } + } + + async function refreshLivePrices() { + startLastPricePolling() + await loadCatalog() + } + + function stopLivePrices() { + stopLastPricePolling() + } + + const ctx: CopperConvertPageContext = { + loading, + source, + inputKg, + prices, + result, + priceRows, + } + + provide(COPPER_CONVERT_PAGE_KEY, ctx) + return { ...ctx, refreshLivePrices, stopLivePrices, loadCatalog } +} + +export function useCopperConvertPageContext() { + const ctx = inject(COPPER_CONVERT_PAGE_KEY) + if (!ctx) { + throw new Error('useCopperConvertPageContext requires useCopperConvertPage') + } + return ctx +} diff --git a/src/views/base/home/layouts/configs/boursecuQuickActions.ts b/src/views/base/home/layouts/configs/boursecuQuickActions.ts index e6e1ab0..49690c0 100644 --- a/src/views/base/home/layouts/configs/boursecuQuickActions.ts +++ b/src/views/base/home/layouts/configs/boursecuQuickActions.ts @@ -1,3 +1,4 @@ +import { openCopperConvert } from '@/composables/useCopperConvertModal' import { getBoursecuActionIconSrc } from '@/utils/config' import type { HomeQuickActionItem } from '../types' @@ -42,6 +43,6 @@ export const boursecuQuickActions: HomeQuickActionItem[] = [ label: 'تبدیل', icon: 'vuesax-linear:refresh-circle', iconSrc: getBoursecuActionIconSrc('convert'), - route: { name: 'buyProduct' }, + handler: () => openCopperConvert(), }, ] \ No newline at end of file diff --git a/src/views/base/products/desktop/styles/desktop-products.less b/src/views/base/products/desktop/styles/desktop-products.less index ba2b465..ab02750 100644 --- a/src/views/base/products/desktop/styles/desktop-products.less +++ b/src/views/base/products/desktop/styles/desktop-products.less @@ -138,14 +138,8 @@ } .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 7e09c13..6abb1a0 100644 --- a/src/views/base/products/index.vue +++ b/src/views/base/products/index.vue @@ -22,7 +22,7 @@
@@ -112,36 +112,6 @@ const { color: #16a34a; } -.user-edit-modal { - .ant-modal-content { - border-radius: 16px; - padding-top: 0.75rem; - } - - .ant-modal-header { - margin-bottom: 0; - padding-bottom: 0.75rem; - border-bottom: 1px solid #f1f5f9; - } - - .ant-modal-title { - font-size: 1rem; - font-weight: 700; - color: #0f172a; - } - - .ant-modal-body { - padding-top: 1rem; - } - - #body { - max-height: none; - overflow: visible; - padding-top: 0; - padding-bottom: 0.25rem; - } -} - .user-edit-modal__status { display: inline-flex; align-items: center; diff --git a/src/views/base/wallet/components/WalletDepositGuideModal.vue b/src/views/base/wallet/components/WalletDepositGuideModal.vue index b66384e..3265d43 100644 --- a/src/views/base/wallet/components/WalletDepositGuideModal.vue +++ b/src/views/base/wallet/components/WalletDepositGuideModal.vue @@ -3,7 +3,7 @@ v-model:visible="localVisible" custom-footer :closable="false" - wrap-class-name="deposit-guide-modal-wrap" + wrap-class-name="app-premium-modal--no-header" width="min(92vw, 380px)" >
@@ -102,7 +102,11 @@
@@ -305,20 +309,3 @@ watch( } -