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" > - -