From 3be79ef9f019d2cb0cd72181549eb22bdd864e2c Mon Sep 17 00:00:00 2001 From: safavimohadeseh78 Date: Tue, 21 Jul 2026 12:06:42 +0200 Subject: [PATCH 1/3] fix bug of WebOTP --- components.d.ts | 3 + src/components/SmsOtpField.vue | 295 ++++++++++++++++++ src/utils/webOtp.ts | 109 ++++++- src/views/login/components/Login.vue | 103 ++---- src/views/login/composables/useLoginForm.ts | 47 +-- .../desktop/components/DesktopLoginForm.vue | 43 +-- 6 files changed, 429 insertions(+), 171 deletions(-) create mode 100644 src/components/SmsOtpField.vue 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/login/components/Login.vue b/src/views/login/components/Login.vue index 2f45a5b..7d69733 100644 --- a/src/views/login/components/Login.vue +++ b/src/views/login/components/Login.vue @@ -118,31 +118,12 @@ name="token" style="position: relative" > - - - @@ -159,7 +140,7 @@
{{ userFormState.mobile }}
- ویرایش شماره + ویرایش شش2ماره
@@ -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" > - - From d5ff2624938f5bc76a3aa9c368e78195aa63dbf4 Mon Sep 17 00:00:00 2001 From: safavimohadeseh78 Date: Tue, 21 Jul 2026 15:39:03 +0200 Subject: [PATCH 2/3] change prices in chart --- .../base/home/composables/usePriceChart.ts | 127 ++++-------------- 1 file changed, 24 insertions(+), 103 deletions(-) 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() { From da66e6fef5ca01e44fdb8a0a8630915c1d7e37da Mon Sep 17 00:00:00 2001 From: safavimohadeseh78 Date: Wed, 22 Jul 2026 15:01:02 +0200 Subject: [PATCH 3/3] change text --- src/views/login/components/Login.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/login/components/Login.vue b/src/views/login/components/Login.vue index 7d69733..c8adc79 100644 --- a/src/views/login/components/Login.vue +++ b/src/views/login/components/Login.vue @@ -140,7 +140,7 @@
{{ userFormState.mobile }}
- ویرایش شش2ماره + ویرایش شماره