diff --git a/components.d.ts b/components.d.ts
index 206c37c..4ea8046 100644
--- a/components.d.ts
+++ b/components.d.ts
@@ -22,6 +22,7 @@ declare module 'vue' {
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASlider: typeof import('ant-design-vue/es')['Slider']
ASpin: typeof import('ant-design-vue/es')['Spin']
+ ASwitch: typeof import('ant-design-vue/es')['Switch']
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
@@ -60,6 +61,7 @@ declare module 'vue' {
RouterView: typeof import('vue-router')['RouterView']
SelectInRange: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/select-in-range.vue')['default']
Size: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/size.vue')['default']
+ SmsOtpField: typeof import('./src/components/SmsOtpField.vue')['default']
StartEnd: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/start-end.vue')['default']
Suffix: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/suffix.vue')['default']
SupportContactBar: typeof import('./src/components/SupportContactBar.vue')['default']
@@ -69,5 +71,6 @@ declare module 'vue' {
TransactionSkeleton: typeof import('./src/components/TransactionSkeleton.vue')['default']
VanActionSheet: typeof import('vant/es')['ActionSheet']
VanDialog: typeof import('vant/es')['Dialog']
+ VanField: typeof import('vant/es')['Field']
}
}
diff --git a/src/components/SmsOtpField.vue b/src/components/SmsOtpField.vue
new file mode 100644
index 0000000..a268be4
--- /dev/null
+++ b/src/components/SmsOtpField.vue
@@ -0,0 +1,295 @@
+
+
+
+
+
+
+
diff --git a/src/utils/webOtp.ts b/src/utils/webOtp.ts
index 0228f00..f08bd99 100644
--- a/src/utils/webOtp.ts
+++ b/src/utils/webOtp.ts
@@ -1,39 +1,116 @@
import { persianToEnglishDigits } from '@/utils/persianDigits'
-/** استخراج کد عددی از نتیجه WebOTP / Credential */
+type OtpHandler = (code: string) => void
+
+let activeController: AbortController | null = null
+let listenGeneration = 0
+let pendingCode: string | null = null
+const handlers = new Set()
+
+/** استخراج کد عددی از نتیجه WebOTP / Credential / رشته خام */
export function extractOtpCode(
- credential: Credential | null | undefined,
- length = 4
+ credential: Credential | null | undefined | string,
+ length = 4,
): string {
- const raw = (credential as { code?: string } | null | undefined)?.code ?? ''
+ let raw = ''
+ if (typeof credential === 'string') {
+ raw = credential
+ } else if (credential && typeof credential === 'object') {
+ const c = credential as { code?: string; id?: string }
+ raw = c.code ?? c.id ?? ''
+ }
return persianToEnglishDigits(raw).replace(/\D/g, '').slice(0, length)
}
-/**
- * گوش دادن به SMS از طریق WebOTP API (Chrome Android).
- * با کلیک Allow روی دیالوگ مرورگر، onCode با کد ۴ رقمی صدا زده میشود.
- */
-export function listenForSmsOtp(onCode: (code: string) => void): AbortController {
- const ac = new AbortController()
+function emitCode(code: string) {
+ pendingCode = code
+ handlers.forEach((handler) => {
+ try {
+ handler(code)
+ } catch {
+ /* ignore subscriber errors */
+ }
+ })
+}
- if (!('OTPCredential' in window) || !navigator.credentials?.get) {
- return ac
+/**
+ * شروع زودهنگام WebOTP — باید قبل از ارسال SMS / همزمان با درخواست کد صدا زده شود.
+ * اگر از قبل در حال listen باشد، دوباره abort نمیکند (دیالوگ Allow را خراب نکند).
+ */
+export function beginSmsOtpCapture(length = 4): void {
+ if (typeof window === 'undefined' || !navigator.credentials?.get) {
+ return
}
+ if (activeController && !activeController.signal.aborted) {
+ return
+ }
+
+ const ac = new AbortController()
+ activeController = ac
+ const generation = ++listenGeneration
+
void navigator.credentials
.get({
otp: { transport: ['sms'] },
signal: ac.signal,
} as CredentialRequestOptions)
.then((credential) => {
- const code = extractOtpCode(credential, 4)
- if (code.length === 4) {
- onCode(code)
+ if (generation !== listenGeneration) return
+ const code = extractOtpCode(credential, length)
+ if (code.length === length) {
+ emitCode(code)
}
})
.catch(() => {
- /* aborted or denied — ignore */
+ /* Deny / Abort / unsupported */
})
+ .finally(() => {
+ if (activeController === ac) {
+ activeController = null
+ }
+ })
+}
+/** عضویت برای دریافت کد — اگر کد از قبل آمده باشد همان لحظه میدهد */
+export function subscribeSmsOtp(handler: OtpHandler): () => void {
+ handlers.add(handler)
+ if (pendingCode) {
+ try {
+ handler(pendingCode)
+ } catch {
+ /* ignore */
+ }
+ }
+ return () => {
+ handlers.delete(handler)
+ }
+}
+
+export function clearPendingSmsOtp(): void {
+ pendingCode = null
+}
+
+export function stopSmsOtpCapture(): void {
+ listenGeneration += 1
+ activeController?.abort()
+ activeController = null
+}
+
+/**
+ * سازگاری با کد قبلی — subscribe + begin
+ * توجه: abort کردن این controller دیگر listener سراسری را قطع نمیکند.
+ */
+export function listenForSmsOtp(onCode: (code: string) => void): AbortController {
+ const ac = new AbortController()
+ const unsubscribe = subscribeSmsOtp((code) => {
+ if (!ac.signal.aborted) {
+ onCode(code)
+ }
+ })
+ beginSmsOtpCapture()
+ ac.signal.addEventListener('abort', () => {
+ unsubscribe()
+ })
return ac
}
diff --git a/src/views/base/home/composables/usePriceChart.ts b/src/views/base/home/composables/usePriceChart.ts
index 60dd1b9..03e62c5 100644
--- a/src/views/base/home/composables/usePriceChart.ts
+++ b/src/views/base/home/composables/usePriceChart.ts
@@ -1,15 +1,22 @@
import { computed, ref, watch } from 'vue'
import { getAllPrices } from '@/api/home'
-import type { Product } from '@/models/products'
import { getProductsCatalog, getProductsCatalogRef } from '@/composables/useProductsCatalog'
-import { getProductBuyPrice, resolvePriceOffsetRatio } from '@/utils/productPrice'
-import { isKatodMesGroup, isMaftolMesGroup, isShamshMesGroup } from '@/utils/productDealMethod'
-import { getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price'
+import { getTradeBuyPrice, getTradeSellPrice } from '@/utils/price'
export type ChartMetalType = 'katod' | 'maftol' | 'shamsh'
type ChartPoint = [number, number]
+/**
+ * مارکآپ ثابت روی قیمت بورس کالا برای نمودار:
+ * کاتد +۳٪ ، مفتول +۶٪ ، شمش +۱۲٪
+ */
+export const CHART_METAL_MARKUP_RATIO: Record = {
+ katod: 0.03,
+ maftol: 0.06,
+ shamsh: 0.12,
+}
+
/** Module-level chart state — one dataset shared by mobile and desktop PriceChart instances. */
const priceChartSeries = ref<{ name: string; data: ChartPoint[] }[]>([
{ name: 'قیمت', data: [] },
@@ -20,65 +27,18 @@ const chartLoading = ref(false)
const selectedChartMetal = ref('katod')
const catalogProducts = getProductsCatalogRef()
-function resolveChartProducts(products: Product[]) {
- return {
- katod: findKatodProduct(products),
- maftol: findMaftolProduct(products),
- shamsh: findShamshProduct(products),
- }
-}
+const maftolChartRatio = computed(() => CHART_METAL_MARKUP_RATIO.maftol)
+const shamshChartRatio = computed(() => CHART_METAL_MARKUP_RATIO.shamsh)
-/** نسبت مفتول به کاتد — reactive به قیمت لحظهای و کاتالوگ محصولات */
-const maftolChartRatio = computed(() => {
- void lastPrice.value?.price_last
-
- const { katod, maftol } = resolveChartProducts(catalogProducts.value)
- return deriveMetalChartRatio(maftol, katod)
-})
-
-/** نسبت شمش مس به کاتد */
-const shamshChartRatio = computed(() => {
- void lastPrice.value?.price_last
-
- const { katod, shamsh } = resolveChartProducts(catalogProducts.value)
- return deriveMetalChartRatio(shamsh, katod)
-})
-
-const selectedMetalChartRatio = computed(() => {
- if (selectedChartMetal.value === 'maftol') {
- return maftolChartRatio.value
- }
- if (selectedChartMetal.value === 'shamsh') {
- return shamshChartRatio.value
- }
- return 0
-})
+const selectedMetalChartRatio = computed(
+ () => CHART_METAL_MARKUP_RATIO[selectedChartMetal.value],
+)
let chartFetchPromise: Promise | null = null
let chartInitialized = false
let metalOffsetPromise: Promise | null = null
let metalOffsetLoaded = false
-function findKatodProduct(products: Product[]) {
- return (
- products.find((product) => isKatodMesGroup(product.group_type))
- ?? products.find((product) => (product.title ?? '').includes('کاتد'))
- )
-}
-
-function findMaftolProduct(products: Product[]) {
- return (
- products.find((product) => isMaftolMesGroup(product.group_type))
- ?? products.find((product) => (product.title ?? '').includes('مفتول'))
- )
-}
-
-function findShamshProduct(products: Product[]) {
- return products.find((product) =>
- isShamshMesGroup(product.group_type, product.title)
- )
-}
-
function applyChartRatio(basePrice: number, ratio: number) {
if (!ratio) {
return basePrice
@@ -87,7 +47,7 @@ function applyChartRatio(basePrice: number, ratio: number) {
return Math.round(basePrice + basePrice * ratio)
}
-/** بالاترین/پایینترین امروز — برای boursecu با نسبت مفتول/شمش نسبت به کاتد */
+/** بالاترین/پایینترین امروز — همان مارکآپ ثابت فلز روی قیمت بورس کالا */
export function resolveMetalDayRangePrices(options: {
price: { up?: { buy?: number; sell?: number }; buy?: number; sell?: number; price_last?: number } | null | undefined
metal: ChartMetalType
@@ -95,44 +55,14 @@ export function resolveMetalDayRangePrices(options: {
}) {
const baseHigh = options.price?.up?.buy ?? getTradeBuyPrice(options.price as Parameters[0])
const baseLow = options.price?.up?.sell ?? getTradeSellPrice(options.price as Parameters[0])
-
- if (options.metal === 'katod') {
- return { high: baseHigh, low: baseLow }
- }
+ const ratio = options.metalRatio || CHART_METAL_MARKUP_RATIO[options.metal]
return {
- high: applyChartRatio(baseHigh, options.metalRatio),
- low: applyChartRatio(baseLow, options.metalRatio),
+ high: applyChartRatio(baseHigh, ratio),
+ low: applyChartRatio(baseLow, ratio),
}
}
-function deriveMetalChartRatio(metalProduct?: Product, katodProduct?: Product) {
- const metalOffset = resolvePriceOffsetRatio(metalProduct?.price_offset_percent)
- if (metalOffset) {
- return metalOffset
- }
-
- if (!metalProduct) {
- return 0
- }
-
- const liveBuy = getTradeBuyPrice(lastPrice.value)
- const katodLive = katodProduct ? getProductBuyPrice(katodProduct) : liveBuy
- const metalLive = getProductBuyPrice(metalProduct)
-
- const katodStatic = Number(katodProduct?.price ?? 0) + Number(katodProduct?.price_profit ?? 0)
- const metalStatic = Number(metalProduct.price ?? 0) + Number(metalProduct.price_profit ?? 0)
-
- const referencePrice = katodLive || liveBuy || katodStatic
- const derivedMetalPrice = metalLive || metalStatic
-
- if (!referencePrice || !derivedMetalPrice || referencePrice === derivedMetalPrice) {
- return 0
- }
-
- return (derivedMetalPrice - referencePrice) / referencePrice
-}
-
function normalizeChartTimestamp(timestamp: number) {
return timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp
}
@@ -165,15 +95,9 @@ function buildChartPoints(items: Array<{ timestamp: number; value: number }>): C
function syncChartSeries() {
const ratio = selectedMetalChartRatio.value
- const data =
- selectedChartMetal.value !== 'katod'
- ? rawChartPoints.value.map(
- ([timestamp, value]) =>
- [timestamp, applyChartRatio(value, ratio)] as ChartPoint
- )
- : rawChartPoints.value
-
- priceChartSeries.value[0].data = data
+ priceChartSeries.value[0].data = rawChartPoints.value.map(
+ ([timestamp, value]) => [timestamp, applyChartRatio(value, ratio)] as ChartPoint,
+ )
}
watch([selectedChartMetal, maftolChartRatio, shamshChartRatio], () => {
@@ -242,10 +166,7 @@ function updateChartData(timeline: string) {
function setChartMetal(metal: ChartMetalType) {
selectedChartMetal.value = metal
-
- if (metal !== 'katod' && !catalogProducts.value.length) {
- void loadMetalChartOffsets(true)
- }
+ syncChartSeries()
}
export function usePriceChart() {
diff --git a/src/views/login/components/Login.vue b/src/views/login/components/Login.vue
index 2f45a5b..c8adc79 100644
--- a/src/views/login/components/Login.vue
+++ b/src/views/login/components/Login.vue
@@ -118,31 +118,12 @@
name="token"
style="position: relative"
>
-
-
-
@@ -333,8 +314,8 @@ import { mobileValidation, required } from '@/utils/inputRules'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { authenticateWithFingerprint, getToken, loading, removeToken, setToken, URLUserType } from '@/utils'
-import {computed, reactive, ref, inject, unref, nextTick, onBeforeUnmount} from 'vue'
-import OtpInput from 'vue3-otp-input'
+import {computed, reactive, ref, inject, unref} from 'vue'
+import SmsOtpField from '@/components/SmsOtpField.vue'
import type { Login } from '@/models/login'
import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
@@ -357,8 +338,8 @@ import {
isTWA
} from "@/utils/config";
import BasicActionSheet from "@/components/BasicPacks/BasicActionSheet.vue";
-import { normalizeAuthFormFields, persianToEnglishDigits } from '@/utils/persianDigits'
-import { listenForSmsOtp } from '@/utils/webOtp'
+import { normalizeAuthFormFields } from '@/utils/persianDigits'
+import { beginSmsOtpCapture, clearPendingSmsOtp } from '@/utils/webOtp'
import {getConfig} from "@/api/config";
import shareAppleIcon from '@/assets/img/share-apple.png'
import addToHomeScreenIcon from '@/assets/img/Add-to-homeScreen.png'
@@ -390,8 +371,7 @@ const walletStore = useWalletStore()
const setting=useSettingsStore()
const data = reactive(['ورود با پیامک', 'ورود با کلمه عبور'])
const value = ref(data[0])
-const refOtpInput = ref | null>(null)
-let otpAbort: AbortController | null = null
+const refOtpInput = ref | null>(null)
const userFormState = reactive({
provider: 'customer'
@@ -402,18 +382,16 @@ onFinish(() => {
enableSendAgainCode.value = true
})
-onBeforeUnmount(() => {
- otpAbort?.abort()
- otpAbort = null
-})
-
async function reSendCode() {
userFormState.token = undefined
- ;(refOtpInput.value as { clearInput?: () => void } | null)?.clearInput?.()
+ clearPendingSmsOtp()
+ refOtpInput.value?.clearInput?.()
enableSendAgainCode.value = false
normalizeAuthFormFields(userFormState)
+ beginSmsOtpCapture()
await loginCustomer(userFormState)
- getcode()
+ refOtpInput.value?.startWebOtp?.()
+ refOtpInput.value?.focusInput?.()
reset()
}
@@ -489,10 +467,11 @@ function phoneVerification() {
userFormState.token = undefined
userFormState.password = undefined
userFormState.provider = 'customer'
+ // مهم: قبل از ارسال SMS گوش بده تا دیالوگ Allow به listener زنده وصل باشد
+ beginSmsOtpCapture()
const { id } = await loginCustomer(userFormState)
userFormState.id = id
stepTwoVerify.value = true
- getcode()
reset()
} else {
const { access_token } = await verifyToken(userFormState)
@@ -510,37 +489,6 @@ function phoneVerification() {
}
})
}
-const getcode = () => {
- otpAbort?.abort()
- otpAbort = listenForSmsOtp((code) => {
- void applySmsOtpCode(code)
- })
-}
-
-async function applySmsOtpCode(code: string) {
- userFormState.token = code
- await nextTick()
- const input = refOtpInput.value as { fillInput?: (v: string) => void } | null
- if (input?.fillInput) {
- try {
- input.fillInput(code)
- return
- } catch {
- /* fall through */
- }
- }
- await phoneVerification()
-}
-
-function onWebOtpAssistInput(event: Event) {
- const raw = (event.target as HTMLInputElement | null)?.value ?? ''
- const code = persianToEnglishDigits(raw).replace(/\D/g, '').slice(0, 4)
- if (code.length === 4) {
- void applySmsOtpCode(code)
- } else {
- userFormState.token = code
- }
-}
const goToEducate = () => {
router.push({ name: "educate" })
@@ -570,18 +518,7 @@ async function proceedWithInstall() {
transform: translate(100%, 0);
}
-/* Must stay in DOM (not display:none) so Chrome/Safari can autofill OTP */
-.web-otp-assist {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip: rect(0, 0, 0, 0);
- white-space: nowrap;
- border: 0;
- opacity: 0;
- pointer-events: none;
+.error-token {
+ position: relative;
}
\ No newline at end of file
diff --git a/src/views/login/composables/useLoginForm.ts b/src/views/login/composables/useLoginForm.ts
index ecee1bd..95b54b2 100644
--- a/src/views/login/composables/useLoginForm.ts
+++ b/src/views/login/composables/useLoginForm.ts
@@ -1,7 +1,7 @@
-import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
+import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
-import OtpInput from 'vue3-otp-input'
+import type SmsOtpField from '@/components/SmsOtpField.vue'
import type { Login } from '@/models/login'
import {
authenticateWithFingerprint,
@@ -11,14 +11,13 @@ import {
} from '@/utils'
import useCountdownTimer from '@/utils/useCountdownTimer'
import { loginCustomer, verifyToken } from '@/api/login'
-import router from '@/router'
import { useUserStore } from '@/store/userStore'
import { useWalletStore } from '@/store/walletStore'
import { useSettingsStore } from '@/store/settingsStore'
import { setTruth } from '@/app/authBoot'
import { completeLoginNavigation } from '@/layouts/composables/useMobileScrollReset'
import { normalizeAuthFormFields } from '@/utils/persianDigits'
-import { listenForSmsOtp } from '@/utils/webOtp'
+import { beginSmsOtpCapture, clearPendingSmsOtp } from '@/utils/webOtp'
export function useLoginForm() {
const { time, onFinish, reset } = useCountdownTimer('00:02:00', 'mm:ss')
@@ -27,11 +26,10 @@ export function useLoginForm() {
const formRef = ref()
const userStore = useUserStore()
const walletStore = useWalletStore()
- const refOtpInput = ref | null>(null)
+ const refOtpInput = ref | null>(null)
const isSMS = ref(true)
const isRegister = ref()
const showPWAInstallGuide = ref(false)
- let otpAbort: AbortController | null = null
const userFormState = reactive({
provider: 'customer',
@@ -42,17 +40,19 @@ export function useLoginForm() {
})
onBeforeUnmount(() => {
- otpAbort?.abort()
- otpAbort = null
+ // SmsOtpField cleans its own listener
})
async function reSendCode() {
userFormState.token = undefined
- ;(refOtpInput.value as { clearInput?: () => void } | null)?.clearInput?.()
+ clearPendingSmsOtp()
+ refOtpInput.value?.clearInput?.()
enableSendAgainCode.value = false
normalizeAuthFormFields(userFormState)
+ beginSmsOtpCapture()
await loginCustomer(userFormState)
- getcode()
+ refOtpInput.value?.startWebOtp?.()
+ refOtpInput.value?.focusInput?.()
reset()
}
@@ -92,31 +92,8 @@ export function useLoginForm() {
await completeLoginNavigation()
}
- async function applySmsOtpCode(code: string) {
- userFormState.token = code
- await nextTick()
- const input = refOtpInput.value as { fillInput?: (v: string) => void } | null
- if (input?.fillInput) {
- try {
- input.fillInput(code)
- // fillInput → on-complete → handleOnComplete → phoneVerification
- return
- } catch {
- /* fall through */
- }
- }
- await phoneVerification()
- }
-
- function getcode() {
- otpAbort?.abort()
- otpAbort = listenForSmsOtp((code) => {
- void applySmsOtpCode(code)
- })
- }
-
function phoneVerification() {
- formRef.value?.validateFields().then(async () => {
+ return formRef.value?.validateFields().then(async () => {
try {
loading.value = true
normalizeAuthFormFields(userFormState)
@@ -125,10 +102,10 @@ export function useLoginForm() {
userFormState.token = undefined
userFormState.password = undefined
userFormState.provider = 'customer'
+ beginSmsOtpCapture()
const { id } = await loginCustomer(userFormState)
userFormState.id = id
stepTwoVerify.value = true
- getcode()
reset()
} else {
const { access_token } = await verifyToken(userFormState)
diff --git a/src/views/login/desktop/components/DesktopLoginForm.vue b/src/views/login/desktop/components/DesktopLoginForm.vue
index afdb0d2..86e81e7 100644
--- a/src/views/login/desktop/components/DesktopLoginForm.vue
+++ b/src/views/login/desktop/components/DesktopLoginForm.vue
@@ -3,15 +3,13 @@ import { mobileValidation, required } from '@/utils/inputRules'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
-import OtpInput from 'vue3-otp-input'
+import SmsOtpField from '@/components/SmsOtpField.vue'
import { useConfigStore } from '@/store/configStore'
import { useLoginPageContext } from '../../composables/useLoginPage'
import DesktopPwaGuideModal from './DesktopPwaGuideModal.vue'
import router from '@/router'
import {isTWA} from "@/utils/config";
-import {useUserStore} from "@/store/userStore";
import {useSettingsStore} from "@/store/settingsStore";
-import { persianToEnglishDigits } from '@/utils/persianDigits'
const emit = defineEmits<{
register: [val: boolean]
@@ -42,17 +40,6 @@ const {
proceedWithInstall,
} = useLoginPageContext().loginForm
-function onWebOtpAssistInput(event: Event) {
- const raw = (event.target as HTMLInputElement | null)?.value ?? ''
- const code = persianToEnglishDigits(raw).replace(/\D/g, '').slice(0, 4)
- if (code.length === 4) {
- userFormState.token = code
- handleOnComplete(code)
- } else {
- handleOnChange(code)
- }
-}
-
function goToEducate() {
router.push({ name: 'educate' })
}
@@ -137,30 +124,12 @@ function goToAboutUs() {
name="token"
style="position: relative"
>
-
-