Product Conversion

This commit is contained in:
2026-10-22 10:36:43 +03:30
parent 2048680d7a
commit 819e007e60
16 changed files with 1044 additions and 168 deletions
+1 -1
View File
@@ -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']
+2
View File
@@ -110,6 +110,7 @@
/>
<GoldDayCalculatorModal />
<CopperConvertModal />
</template>
<script lang="ts" setup>
@@ -132,6 +133,7 @@ import { usePwaInstall } from '@/composables/usePwaInstall'
import PwaInstallBanner from '@/components/pwa/PwaInstallBanner.vue'
import PwaInstallGuideModal from '@/components/pwa/PwaInstallGuideModal.vue'
import GoldDayCalculatorModal from '@/components/GoldDayCalculatorModal.vue'
import CopperConvertModal from '@/components/CopperConvertModal.vue'
import { useUserStore } from '@/store/userStore'
import { useSettingsStore } from '@/store/settingsStore'
import {
+1
View File
@@ -25,6 +25,7 @@
@import (less) '../../views/base/fund/desktop/styles/desktop-fund.less';
@import (less) '../../views/base/gold/desktop/styles/desktop-gold.less';
@import (less) './mobile-keyboard-scroll.less';
@import (less) '../../styles/app-premium-modal.less';
html {
scroll-behavior: smooth;
+10 -9
View File
@@ -13,10 +13,13 @@
:width="props.width"
:ok-text="props.okText"
:cancel-text="props.cancelText"
:wrapClassName="props.wrapClassName"
:wrapClassName="mergedWrapClassName"
:closable="props.closable"
:destroy-on-close="props.destroyOnClose"
>
<template v-if="$slots.title" #title>
<slot name="title" />
</template>
<template v-if="props.customFooter" #footer> <slot name="footer" /></template>
<a-spin :spinning="props.loading">
<div class="overflow-y-auto py-2 px-1 relative max-h-[83vh]" id="body">
@@ -60,6 +63,11 @@ const props = withDefaults(defineProps<Prop>(), {
zIndex: APP_Z_MODAL,
})
const mergedWrapClassName = computed(() => {
const extra = (props.wrapClassName ?? '').trim()
return extra ? `app-premium-modal ${extra}` : 'app-premium-modal'
})
const localValue = computed({
get() {
return props.visible
@@ -67,13 +75,6 @@ const localValue = computed({
set(value) {
emits('update:visible', value)
}
},
})
</script>
<style>
.ant-modal-content {
border-radius: 0.6rem;
padding-top: 0.5rem;
}
</style>
+47
View File
@@ -0,0 +1,47 @@
<template>
<BasicModal
v-model:visible="visible"
width="460px"
destroy-on-close
custom-footer
title="تبدیل محصول"
wrap-class-name="app-premium-modal--scroll-tall"
@close="closeCopperConvert"
>
<CopperConvertCalculator />
<template #footer>
<BasicButton
type="primary"
class="app-premium-modal__btn-primary !w-full"
size="middle"
@click="closeCopperConvert"
>
بستن
</BasicButton>
</template>
</BasicModal>
</template>
<script setup lang="ts">
import { watch } from 'vue'
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import CopperConvertCalculator from '@/views/base/convert/components/CopperConvertCalculator.vue'
import {
closeCopperConvert,
copperConvertModalVisible,
} from '@/composables/useCopperConvertModal'
import { useCopperConvertPage } from '@/views/base/convert/composables/useCopperConvertPage'
const visible = copperConvertModalVisible
const { refreshLivePrices, stopLivePrices } = useCopperConvertPage()
watch(visible, async (open) => {
if (open) {
await refreshLivePrices()
} else {
stopLivePrices()
}
})
</script>
+4 -100
View File
@@ -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"
>
<template #title>
<div class="gold-day-calc-modal__title">
<BasicIcon icon="vuesax-linear:calculator" size="20" class="gold-day-calc-modal__title-icon" />
<div class="app-premium-modal__title">
<BasicIcon icon="vuesax-linear:calculator" size="20" class="app-premium-modal__title-icon" />
<span>ماشین حساب</span>
</div>
</template>
@@ -75,7 +75,7 @@
</div>
<template #footer>
<BasicButton type="primary" class="!w-full" size="middle" @click="recalculate">
<BasicButton type="primary" class="app-premium-modal__btn-primary !w-full" size="middle" @click="recalculate">
محاسبه مجدد
</BasicButton>
</template>
@@ -219,99 +219,3 @@ watch(visible, async (open) => {
display: inline-block;
}
</style>
<style lang="less">
.gold-day-calc-modal {
.ant-modal-content {
direction: rtl;
display: flex;
flex-direction: column;
max-height: min(88dvh, 640px);
overflow: hidden;
}
.ant-modal-header {
flex-shrink: 0;
direction: rtl;
text-align: right;
padding-left: 2.75rem;
padding-right: 1rem;
}
.ant-modal-title {
text-align: right;
width: 100%;
}
.gold-day-calc-modal__title {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
direction: rtl;
width: 100%;
}
.gold-day-calc-modal__title-icon {
flex-shrink: 0;
color: var(--App-secondary-color, #c9a062);
}
.ant-modal-close {
inset-inline-start: 0 !important;
inset-inline-end: auto !important;
right: auto !important;
left: 0 !important;
}
.ant-modal-body {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
padding-bottom: 0.25rem;
max-height: none;
}
#body {
max-height: min(48dvh, 360px) !important;
overflow-y: auto !important;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.ant-modal-footer {
flex-shrink: 0;
margin-top: 0;
padding-top: 0.75rem;
border-top: 1px solid rgba(148, 163, 184, 0.25);
}
}
@media screen and (max-width: 767.98px) {
.gold-day-calc-modal {
.ant-modal {
max-width: calc(100vw - 1.5rem);
margin: 0 auto;
}
.ant-modal-content {
max-height: min(86dvh, 100%);
}
#body {
max-height: min(42dvh, 280px) !important;
}
.ant-modal-body {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
}
}
@media screen and (max-width: 400px) {
.gold-day-calc-modal #body {
max-height: min(38dvh, 240px) !important;
}
}
</style>
+11
View File
@@ -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
}
+198
View File
@@ -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;
}
}
}
+78
View File
@@ -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),
}
}
@@ -0,0 +1,507 @@
<template>
<div class="copper-convert" dir="rtl">
<p class="copper-convert__lead">
وزن دارایی را وارد کنید؛ معادل مفتول و تعداد حلقه سیم ۱.۵ و ۲.۵ با قیمت روز محاسبه می‌شود.
</p>
<a-spin :spinning="loading">
<section class="copper-convert__panel" aria-label="قیمت‌های مبنا">
<div class="copper-convert__panel-head">
<span class="copper-convert__panel-kicker">قیمت لحظه‌ای</span>
<span class="copper-convert__panel-title">مبنای محاسبه</span>
</div>
<div class="copper-convert__price-group">
<span class="copper-convert__group-label">مواد اولیه (هر کیلوگرم)</span>
<ul class="copper-convert__price-grid m-0 p-0 list-none">
<li
v-for="row in metalPriceRows"
:key="row.label"
class="copper-convert__chip"
:class="{ 'copper-convert__chip--muted': row.missing }"
>
<span class="copper-convert__chip-label">{{ row.label }}</span>
<span class="copper-convert__chip-value" dir="ltr">{{ row.value }}</span>
</li>
</ul>
</div>
<div class="copper-convert__price-group copper-convert__price-group--wire">
<span class="copper-convert__group-label">سیم (هر حلقه)</span>
<ul class="copper-convert__price-grid copper-convert__price-grid--wire m-0 p-0 list-none">
<li
v-for="row in wirePriceRows"
:key="row.label"
class="copper-convert__chip copper-convert__chip--accent"
>
<span class="copper-convert__chip-label">{{ row.label }}</span>
<span class="copper-convert__chip-value" dir="ltr">{{ row.value }}</span>
</li>
</ul>
</div>
</section>
<section class="copper-convert__form">
<label class="copper-convert__field-label">نوع دارایی</label>
<div class="copper-convert__pills" role="tablist">
<button
v-for="opt in sourceOptions"
:key="opt.value"
type="button"
role="tab"
class="copper-convert__pill"
:class="{ 'copper-convert__pill--active': source === opt.value }"
:aria-selected="source === opt.value"
@click="source = opt.value"
>
{{ opt.label }}
</button>
</div>
<div class="copper-convert__weight">
<label class="copper-convert__field-label" for="copper-convert-kg">مقدار</label>
<div class="copper-convert__weight-wrap">
<input
id="copper-convert-kg"
v-model="inputKg"
class="copper-convert__weight-input"
type="text"
inputmode="decimal"
placeholder="مثلاً ۱۰"
autocomplete="off"
/>
<span class="copper-convert__weight-unit">کیلوگرم</span>
</div>
</div>
</section>
<section v-if="result" class="copper-convert__result">
<div class="copper-convert__result-head">
<span class="copper-convert__result-kicker">خروجی</span>
<span class="copper-convert__result-title">نتیجه تبدیل</span>
</div>
<div class="copper-convert__rings">
<article class="copper-convert__ring-card">
<span class="copper-convert__ring-badge">۱.۵ mm²</span>
<p class="copper-convert__ring-count" dir="ltr">{{ formatRings(result.rings15ByValue) }}</p>
<p class="copper-convert__ring-caption">حلقه سیم</p>
<p
v-if="result.rings15ByWeight != null"
class="copper-convert__ring-sub"
dir="ltr"
>
≈ {{ formatRings(result.rings15ByWeight) }} بر اساس وزن حلقه
</p>
</article>
<article class="copper-convert__ring-card">
<span class="copper-convert__ring-badge">۲.۵ mm²</span>
<p class="copper-convert__ring-count" dir="ltr">{{ formatRings(result.rings25ByValue) }}</p>
<p class="copper-convert__ring-caption">حلقه سیم</p>
<p
v-if="result.rings25ByWeight != null"
class="copper-convert__ring-sub"
dir="ltr"
>
≈ {{ formatRings(result.rings25ByWeight) }} بر اساس وزن حلقه
</p>
</article>
</div>
<div class="copper-convert__summary">
<div class="copper-convert__summary-row">
<span>ارزش معادل</span>
<strong dir="ltr">{{ formatRial(result.totalValueRial) }} <small>ریال</small></strong>
</div>
<div class="copper-convert__summary-row">
<span>معادل مفتول</span>
<strong dir="ltr">{{ formatKg(result.equivalentMaftoulKg) }} <small>کیلو</small></strong>
</div>
</div>
</section>
<p v-else-if="inputKg && !loading" class="copper-convert__warn">
مقدار معتبر وارد کنید و از فعال بودن قیمت کاتد/مفتول در سامانه مطمئن شوید.
</p>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useCopperConvertPageContext } from '../composables/useCopperConvertPage'
import type { CopperConvertSource } from '@/utils/copperWireConvert'
const { loading, source, inputKg, result, priceRows } = useCopperConvertPageContext()
const sourceOptions = computed(() => [
{ label: 'کاتد', value: 'katod' as CopperConvertSource },
{ label: 'مفتول', value: 'maftoul' as CopperConvertSource },
{ label: 'شمش', value: 'shamsh' as CopperConvertSource },
])
const metalPriceRows = computed(() => priceRows.value.slice(0, 3))
const wirePriceRows = computed(() => priceRows.value.slice(3))
function formatRial(value: number) {
return Math.round(value).toLocaleString('en-US')
}
function formatKg(value: number) {
if (value >= 100) return value.toFixed(1)
if (value >= 10) return value.toFixed(2)
return value.toFixed(3)
}
function formatRings(value: number) {
if (value >= 100) return value.toFixed(1)
if (value >= 10) return value.toFixed(2)
return value.toFixed(3)
}
</script>
<style scoped>
.copper-convert {
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: right;
}
.copper-convert__lead {
margin: 0 0 0.125rem;
font-size: 0.8125rem;
line-height: 1.65;
color: #64748b;
}
.copper-convert__panel {
position: relative;
padding: 0.75rem 0.875rem 0.7rem;
border-radius: 1rem;
background: linear-gradient(145deg, #0f2744 0%, #152f4f 48%, #1a365d 100%);
border: 1px solid rgba(201, 160, 98, 0.35);
box-shadow:
0 12px 28px rgba(15, 39, 68, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.copper-convert__panel::before {
content: '';
position: absolute;
top: -40px;
inset-inline-end: -30px;
width: 120px;
height: 120px;
border-radius: 50%;
background: radial-gradient(circle, rgba(232, 180, 140, 0.28) 0%, transparent 70%);
pointer-events: none;
}
.copper-convert__panel-head {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
margin-bottom: 0.75rem;
}
.copper-convert__panel-kicker {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(201, 160, 98, 0.95);
}
.copper-convert__panel-title {
font-size: 0.9375rem;
font-weight: 800;
color: #f8fafc;
}
.copper-convert__price-group {
position: relative;
z-index: 1;
margin-bottom: 0.65rem;
}
.copper-convert__price-group--wire {
margin-bottom: 0;
padding-top: 0.65rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.copper-convert__group-label {
display: block;
margin-bottom: 0.45rem;
font-size: 0.6875rem;
font-weight: 600;
color: rgba(226, 232, 240, 0.75);
}
.copper-convert__price-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.4rem;
}
.copper-convert__price-grid--wire {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.copper-convert__chip {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
padding: 0.45rem 0.5rem;
border-radius: 0.65rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.copper-convert__chip--accent {
background: rgba(201, 160, 98, 0.12);
border-color: rgba(201, 160, 98, 0.35);
}
.copper-convert__chip--muted .copper-convert__chip-value {
color: rgba(248, 250, 252, 0.45);
}
.copper-convert__chip-label {
font-size: 0.625rem;
line-height: 1.45;
color: rgba(226, 232, 240, 0.85);
}
.copper-convert__chip-value {
font-size: 0.6875rem;
font-weight: 700;
color: #f8fafc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copper-convert__form {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-top: 0.125rem;
padding: 0.75rem 0.875rem;
border-radius: 1rem;
background: #faf8f5;
border: 1px solid rgba(201, 160, 98, 0.22);
}
.copper-convert__field-label {
font-size: 0.75rem;
font-weight: 700;
color: #334155;
}
.copper-convert__pills {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.35rem;
padding: 0.25rem;
border-radius: 999px;
background: #eef2f6;
}
.copper-convert__pill {
appearance: none;
border: none;
border-radius: 999px;
padding: 0.5rem 0.35rem;
font-family: inherit;
font-size: 0.75rem;
font-weight: 700;
color: #64748b;
background: transparent;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.copper-convert__pill--active {
color: #0f2744;
background: #fff;
box-shadow: 0 2px 8px rgba(15, 39, 68, 0.12);
}
.copper-convert__weight-wrap {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.35rem;
padding: 0.55rem 0.75rem;
border-radius: 0.75rem;
background: #fff;
border: 1px solid rgba(15, 39, 68, 0.12);
box-shadow: inset 0 1px 2px rgba(15, 39, 68, 0.04);
}
.copper-convert__weight-input {
flex: 1 1 auto;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-size: 1.125rem;
font-weight: 800;
color: #0f2744;
text-align: right;
direction: ltr;
}
.copper-convert__weight-input::placeholder {
color: #94a3b8;
font-weight: 500;
font-size: 0.875rem;
}
.copper-convert__weight-unit {
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 800;
color: #c9a062;
}
.copper-convert__result {
margin-top: 0.125rem;
padding: 0.875rem;
border-radius: 1rem;
background: linear-gradient(180deg, #fff 0%, #faf8f5 100%);
border: 1px solid rgba(201, 160, 98, 0.35);
box-shadow: 0 8px 24px rgba(15, 39, 68, 0.08);
}
.copper-convert__result-head {
display: flex;
flex-direction: column;
gap: 0.1rem;
margin-bottom: 0.75rem;
}
.copper-convert__result-kicker {
font-size: 0.6875rem;
font-weight: 700;
color: #c9a062;
}
.copper-convert__result-title {
font-size: 0.9375rem;
font-weight: 800;
color: #0f2744;
}
.copper-convert__rings {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.copper-convert__ring-card {
position: relative;
padding: 0.75rem 0.5rem;
border-radius: 0.85rem;
text-align: center;
background: linear-gradient(160deg, #0f2744 0%, #1a365d 100%);
border: 1px solid rgba(201, 160, 98, 0.4);
overflow: hidden;
}
.copper-convert__ring-card::after {
content: '';
position: absolute;
inset-inline-start: 50%;
bottom: -24px;
width: 80px;
height: 48px;
transform: translateX(-50%);
background: radial-gradient(ellipse, rgba(201, 160, 98, 0.35) 0%, transparent 70%);
pointer-events: none;
}
.copper-convert__ring-badge {
display: inline-block;
margin-bottom: 0.35rem;
padding: 0.15rem 0.45rem;
border-radius: 999px;
font-size: 0.625rem;
font-weight: 700;
color: #0f2744;
background: rgba(201, 160, 98, 0.95);
}
.copper-convert__ring-count {
margin: 0;
font-size: 1.5rem;
font-weight: 800;
line-height: 1.2;
color: #f8fafc;
}
.copper-convert__ring-caption {
margin: 0.15rem 0 0;
font-size: 0.6875rem;
font-weight: 600;
color: rgba(226, 232, 240, 0.85);
}
.copper-convert__ring-sub {
margin: 0.35rem 0 0;
font-size: 0.625rem;
color: rgba(201, 160, 98, 0.9);
}
.copper-convert__summary {
display: flex;
flex-direction: column;
gap: 0.45rem;
padding-top: 0.65rem;
border-top: 1px dashed rgba(15, 39, 68, 0.15);
}
.copper-convert__summary-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.75rem;
color: #64748b;
}
.copper-convert__summary-row strong {
font-size: 0.8125rem;
font-weight: 800;
color: #0f2744;
}
.copper-convert__summary-row strong small {
font-size: 0.6875rem;
font-weight: 600;
color: #94a3b8;
}
.copper-convert__warn {
margin: 0;
padding: 0.65rem 0.75rem;
border-radius: 0.75rem;
font-size: 0.75rem;
line-height: 1.6;
color: #9a3412;
background: #fff7ed;
border: 1px solid rgba(234, 88, 12, 0.2);
}
@media (max-width: 380px) {
.copper-convert__price-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -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<CopperConvertPageContext> =
Symbol('copperConvertPageContext')
export interface CopperConvertPageContext {
loading: Ref<boolean>
source: Ref<CopperConvertSource>
inputKg: Ref<string>
prices: ComputedRef<CopperConvertPrices>
result: ComputedRef<CopperConvertResult | null>
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<CopperConvertSource>('katod')
const inputKg = ref('')
const catalog = ref<Product[]>([])
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<CopperConvertPrices>(() => ({
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
}
@@ -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(),
},
]
@@ -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;
}
}
+1 -1
View File
@@ -22,7 +22,7 @@
<BasicModal
v-else
v-model:visible="showFilter"
wrap-class-name="dproducts-order-panel"
wrap-class-name="dproducts-order-panel app-premium-modal--wide-panel"
:title="selectedProduct?.title ?? 'ثبت سفارش'"
width="560px"
destroy-on-close
+1 -31
View File
@@ -25,7 +25,7 @@
width="440px"
destroy-on-close
custom-footer
wrap-class-name="user-edit-modal"
wrap-class-name="app-premium-modal--fluid-body"
:loading="loading"
>
<div class="user-edit-modal__status" :class="isLegal.color">
@@ -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;
@@ -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)"
>
<div class="deposit-guide">
@@ -102,7 +102,11 @@
</div>
<template #footer>
<BasicButton type="primary" class="deposit-guide__confirm w-full" @click="localVisible = false">
<BasicButton
type="primary"
class="app-premium-modal__btn-primary deposit-guide__confirm w-full"
@click="localVisible = false"
>
متوجه شدم
</BasicButton>
</template>
@@ -305,20 +309,3 @@ watch(
}
</style>
<style lang="less">
.deposit-guide-modal-wrap {
.ant-modal-content {
border-radius: 1rem;
padding-top: 1rem;
}
.ant-modal-header {
display: none;
}
.ant-modal-footer {
border-top: none;
padding-top: 0;
}
}
</style>