fix bug of WebOTP
This commit is contained in:
Vendored
+3
@@ -22,6 +22,7 @@ declare module 'vue' {
|
|||||||
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
|
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
|
||||||
ASlider: typeof import('ant-design-vue/es')['Slider']
|
ASlider: typeof import('ant-design-vue/es')['Slider']
|
||||||
ASpin: typeof import('ant-design-vue/es')['Spin']
|
ASpin: typeof import('ant-design-vue/es')['Spin']
|
||||||
|
ASwitch: typeof import('ant-design-vue/es')['Switch']
|
||||||
ATag: typeof import('ant-design-vue/es')['Tag']
|
ATag: typeof import('ant-design-vue/es')['Tag']
|
||||||
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||||
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
||||||
@@ -60,6 +61,7 @@ declare module 'vue' {
|
|||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
SelectInRange: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/select-in-range.vue')['default']
|
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']
|
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']
|
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']
|
Suffix: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/suffix.vue')['default']
|
||||||
SupportContactBar: typeof import('./src/components/SupportContactBar.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']
|
TransactionSkeleton: typeof import('./src/components/TransactionSkeleton.vue')['default']
|
||||||
VanActionSheet: typeof import('vant/es')['ActionSheet']
|
VanActionSheet: typeof import('vant/es')['ActionSheet']
|
||||||
VanDialog: typeof import('vant/es')['Dialog']
|
VanDialog: typeof import('vant/es')['Dialog']
|
||||||
|
VanField: typeof import('vant/es')['Field']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { persianToEnglishDigits } from '@/utils/persianDigits'
|
||||||
|
import {
|
||||||
|
beginSmsOtpCapture,
|
||||||
|
clearPendingSmsOtp,
|
||||||
|
subscribeSmsOtp,
|
||||||
|
} from '@/utils/webOtp'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: string
|
||||||
|
length?: number
|
||||||
|
autofocus?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: '',
|
||||||
|
length: 4,
|
||||||
|
autofocus: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
change: [value: string]
|
||||||
|
complete: [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const localValue = ref('')
|
||||||
|
let completedFor = ''
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let unsubscribeOtp: (() => void) | null = null
|
||||||
|
|
||||||
|
const digits = computed(() => {
|
||||||
|
const chars = localValue.value.padEnd(props.length, ' ').slice(0, props.length).split('')
|
||||||
|
return chars.map((ch) => (ch === ' ' ? '' : ch))
|
||||||
|
})
|
||||||
|
|
||||||
|
function normalize(raw: string) {
|
||||||
|
return persianToEnglishDigits(raw).replace(/\D/g, '').slice(0, props.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCode(raw: string) {
|
||||||
|
const code = normalize(raw)
|
||||||
|
if (!code || code === localValue.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
localValue.value = code
|
||||||
|
if (inputRef.value && inputRef.value.value !== code) {
|
||||||
|
inputRef.value.value = code
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:modelValue', code)
|
||||||
|
emit('change', code)
|
||||||
|
|
||||||
|
if (code.length === props.length && completedFor !== code) {
|
||||||
|
completedFor = code
|
||||||
|
clearPendingSmsOtp()
|
||||||
|
emit('complete', code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code.length < props.length) {
|
||||||
|
completedFor = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFromDom() {
|
||||||
|
const direct = inputRef.value?.value ?? ''
|
||||||
|
if (direct) {
|
||||||
|
applyCode(direct)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// اگر Chrome جای دیگری با autocomplete پر کرده
|
||||||
|
const nodes = document.querySelectorAll<HTMLInputElement>(
|
||||||
|
'input[autocomplete="one-time-code"]',
|
||||||
|
)
|
||||||
|
for (const node of nodes) {
|
||||||
|
const value = node.value ?? ''
|
||||||
|
if (normalize(value).length === props.length) {
|
||||||
|
applyCode(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput(event: Event) {
|
||||||
|
applyCode((event.target as HTMLInputElement).value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChange(event: Event) {
|
||||||
|
applyCode((event.target as HTMLInputElement).value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAnimationStart(event: AnimationEvent) {
|
||||||
|
if (!String(event.animationName || '').includes('onAutoFillStart')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncFromDom()
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusInput() {
|
||||||
|
const el = inputRef.value
|
||||||
|
if (!el) return
|
||||||
|
el.focus({ preventScroll: true })
|
||||||
|
try {
|
||||||
|
el.setSelectionRange(el.value.length, el.value.length)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearInput() {
|
||||||
|
completedFor = ''
|
||||||
|
localValue.value = ''
|
||||||
|
clearPendingSmsOtp()
|
||||||
|
if (inputRef.value) {
|
||||||
|
inputRef.value.value = ''
|
||||||
|
}
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
emit('change', '')
|
||||||
|
focusInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillInput(value: string) {
|
||||||
|
applyCode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startWebOtp() {
|
||||||
|
beginSmsOtpCapture(props.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
stopPolling()
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
syncFromDom()
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(val) => {
|
||||||
|
const next = normalize(val ?? '')
|
||||||
|
if (next !== localValue.value) {
|
||||||
|
localValue.value = next
|
||||||
|
if (inputRef.value) {
|
||||||
|
inputRef.value.value = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
beginSmsOtpCapture(props.length)
|
||||||
|
unsubscribeOtp = subscribeSmsOtp((code) => {
|
||||||
|
applyCode(code)
|
||||||
|
})
|
||||||
|
if (props.autofocus) {
|
||||||
|
focusInput()
|
||||||
|
}
|
||||||
|
startPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
unsubscribeOtp?.()
|
||||||
|
unsubscribeOtp = null
|
||||||
|
stopPolling()
|
||||||
|
// listener سراسری را abort نکن — دیالوگ Allow هنوز ممکن است باز باشد
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
focusInput,
|
||||||
|
clearInput,
|
||||||
|
fillInput,
|
||||||
|
startWebOtp,
|
||||||
|
inputRef,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sms-otp" dir="ltr" @click="focusInput">
|
||||||
|
<div class="sms-otp__boxes" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
v-for="(digit, index) in digits"
|
||||||
|
:key="index"
|
||||||
|
class="sms-otp__box otp-input"
|
||||||
|
:class="{
|
||||||
|
'sms-otp__box--filled': !!digit,
|
||||||
|
'sms-otp__box--active': localValue.length === index,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ digit }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
class="sms-otp__input"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
name="one-time-code"
|
||||||
|
:maxlength="length"
|
||||||
|
enterkeyhint="done"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
aria-label="کد تایید پیامک"
|
||||||
|
@input="onInput"
|
||||||
|
@change="onChange"
|
||||||
|
@keyup="onInput"
|
||||||
|
@paste="onInput"
|
||||||
|
@animationstart="onAnimationStart"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sms-otp {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-otp__boxes {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-otp__box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
margin: 0 8px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-otp__box--active {
|
||||||
|
border-color: var(--App-primary-color, #0a1b37);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-otp__input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
/* کمتر از این را Chrome گاهی autofill نمیکند */
|
||||||
|
opacity: 0.15;
|
||||||
|
color: transparent;
|
||||||
|
caret-color: var(--App-primary-color, #0a1b37);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 48px;
|
||||||
|
text-align: left;
|
||||||
|
direction: ltr;
|
||||||
|
animation-name: onAutoFillStart;
|
||||||
|
animation-duration: 0.001s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes onAutoFillStart {
|
||||||
|
from {
|
||||||
|
/**/
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
/**/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+93
-16
@@ -1,39 +1,116 @@
|
|||||||
import { persianToEnglishDigits } from '@/utils/persianDigits'
|
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<OtpHandler>()
|
||||||
|
|
||||||
|
/** استخراج کد عددی از نتیجه WebOTP / Credential / رشته خام */
|
||||||
export function extractOtpCode(
|
export function extractOtpCode(
|
||||||
credential: Credential | null | undefined,
|
credential: Credential | null | undefined | string,
|
||||||
length = 4
|
length = 4,
|
||||||
): string {
|
): 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)
|
return persianToEnglishDigits(raw).replace(/\D/g, '').slice(0, length)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function emitCode(code: string) {
|
||||||
* گوش دادن به SMS از طریق WebOTP API (Chrome Android).
|
pendingCode = code
|
||||||
* با کلیک Allow روی دیالوگ مرورگر، onCode با کد ۴ رقمی صدا زده میشود.
|
handlers.forEach((handler) => {
|
||||||
*/
|
try {
|
||||||
export function listenForSmsOtp(onCode: (code: string) => void): AbortController {
|
handler(code)
|
||||||
const ac = new AbortController()
|
} 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
|
void navigator.credentials
|
||||||
.get({
|
.get({
|
||||||
otp: { transport: ['sms'] },
|
otp: { transport: ['sms'] },
|
||||||
signal: ac.signal,
|
signal: ac.signal,
|
||||||
} as CredentialRequestOptions)
|
} as CredentialRequestOptions)
|
||||||
.then((credential) => {
|
.then((credential) => {
|
||||||
const code = extractOtpCode(credential, 4)
|
if (generation !== listenGeneration) return
|
||||||
if (code.length === 4) {
|
const code = extractOtpCode(credential, length)
|
||||||
onCode(code)
|
if (code.length === length) {
|
||||||
|
emitCode(code)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.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
|
return ac
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,31 +118,12 @@
|
|||||||
name="token"
|
name="token"
|
||||||
style="position: relative"
|
style="position: relative"
|
||||||
>
|
>
|
||||||
<OtpInput
|
<SmsOtpField
|
||||||
ref="refOtpInput"
|
ref="refOtpInput"
|
||||||
:is-input-num="true"
|
v-model="userFormState.token"
|
||||||
:num-inputs="4"
|
:class="variant === 'desktop' ? 'dauth-otp' : undefined"
|
||||||
:should-auto-focus="true"
|
@change="handleOnChange"
|
||||||
autocomplete="one-time-code"
|
@complete="handleOnComplete"
|
||||||
:class="variant === 'desktop' ? 'dauth-otp justify-center' : 'mt-5 justify-center'"
|
|
||||||
input-classes="otp-input"
|
|
||||||
separator=""
|
|
||||||
style="direction: ltr"
|
|
||||||
:value="userFormState.token || ''"
|
|
||||||
@on-change="handleOnChange"
|
|
||||||
@on-complete="handleOnComplete"
|
|
||||||
/>
|
|
||||||
<!-- برای WebOTP / پیشنهاد کیبورد: autocomplete باید روی input واقعی باشد -->
|
|
||||||
<input
|
|
||||||
class="web-otp-assist"
|
|
||||||
type="text"
|
|
||||||
inputmode="numeric"
|
|
||||||
autocomplete="one-time-code"
|
|
||||||
maxlength="4"
|
|
||||||
tabindex="-1"
|
|
||||||
aria-hidden="true"
|
|
||||||
:value="userFormState.token || ''"
|
|
||||||
@input="onWebOtpAssistInput"
|
|
||||||
/>
|
/>
|
||||||
</AFormItem>
|
</AFormItem>
|
||||||
|
|
||||||
@@ -159,7 +140,7 @@
|
|||||||
<div v-else class="flex items-center my-[23px] justify-center">
|
<div v-else class="flex items-center my-[23px] justify-center">
|
||||||
<span class="text-gray-400 mt-1">{{ userFormState.mobile }}</span>
|
<span class="text-gray-400 mt-1">{{ userFormState.mobile }}</span>
|
||||||
<div class="flex items-center text-gray-700 mr-2" @click="stepTwoVerify = false">
|
<div class="flex items-center text-gray-700 mr-2" @click="stepTwoVerify = false">
|
||||||
<span class="ml-1">ویرایش شماره</span>
|
<span class="ml-1">ویرایش شش2ماره</span>
|
||||||
<BasicIcon icon="vuesax-linear:edit" size="20" />
|
<BasicIcon icon="vuesax-linear:edit" size="20" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,8 +314,8 @@ import { mobileValidation, required } from '@/utils/inputRules'
|
|||||||
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
|
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
|
||||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||||
import { authenticateWithFingerprint, getToken, loading, removeToken, setToken, URLUserType } from '@/utils'
|
import { authenticateWithFingerprint, getToken, loading, removeToken, setToken, URLUserType } from '@/utils'
|
||||||
import {computed, reactive, ref, inject, unref, nextTick, onBeforeUnmount} from 'vue'
|
import {computed, reactive, ref, inject, unref} from 'vue'
|
||||||
import OtpInput from 'vue3-otp-input'
|
import SmsOtpField from '@/components/SmsOtpField.vue'
|
||||||
import type { Login } from '@/models/login'
|
import type { Login } from '@/models/login'
|
||||||
import type { FormInstance } from 'ant-design-vue'
|
import type { FormInstance } from 'ant-design-vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
@@ -357,8 +338,8 @@ import {
|
|||||||
isTWA
|
isTWA
|
||||||
} from "@/utils/config";
|
} from "@/utils/config";
|
||||||
import BasicActionSheet from "@/components/BasicPacks/BasicActionSheet.vue";
|
import BasicActionSheet from "@/components/BasicPacks/BasicActionSheet.vue";
|
||||||
import { normalizeAuthFormFields, persianToEnglishDigits } from '@/utils/persianDigits'
|
import { normalizeAuthFormFields } from '@/utils/persianDigits'
|
||||||
import { listenForSmsOtp } from '@/utils/webOtp'
|
import { beginSmsOtpCapture, clearPendingSmsOtp } from '@/utils/webOtp'
|
||||||
import {getConfig} from "@/api/config";
|
import {getConfig} from "@/api/config";
|
||||||
import shareAppleIcon from '@/assets/img/share-apple.png'
|
import shareAppleIcon from '@/assets/img/share-apple.png'
|
||||||
import addToHomeScreenIcon from '@/assets/img/Add-to-homeScreen.png'
|
import addToHomeScreenIcon from '@/assets/img/Add-to-homeScreen.png'
|
||||||
@@ -390,8 +371,7 @@ const walletStore = useWalletStore()
|
|||||||
const setting=useSettingsStore()
|
const setting=useSettingsStore()
|
||||||
const data = reactive(['ورود با پیامک', 'ورود با کلمه عبور'])
|
const data = reactive(['ورود با پیامک', 'ورود با کلمه عبور'])
|
||||||
const value = ref(data[0])
|
const value = ref(data[0])
|
||||||
const refOtpInput = ref<InstanceType<typeof OtpInput> | null>(null)
|
const refOtpInput = ref<InstanceType<typeof SmsOtpField> | null>(null)
|
||||||
let otpAbort: AbortController | null = null
|
|
||||||
|
|
||||||
const userFormState = reactive<Login>({
|
const userFormState = reactive<Login>({
|
||||||
provider: 'customer'
|
provider: 'customer'
|
||||||
@@ -402,18 +382,16 @@ onFinish(() => {
|
|||||||
enableSendAgainCode.value = true
|
enableSendAgainCode.value = true
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
otpAbort?.abort()
|
|
||||||
otpAbort = null
|
|
||||||
})
|
|
||||||
|
|
||||||
async function reSendCode() {
|
async function reSendCode() {
|
||||||
userFormState.token = undefined
|
userFormState.token = undefined
|
||||||
;(refOtpInput.value as { clearInput?: () => void } | null)?.clearInput?.()
|
clearPendingSmsOtp()
|
||||||
|
refOtpInput.value?.clearInput?.()
|
||||||
enableSendAgainCode.value = false
|
enableSendAgainCode.value = false
|
||||||
normalizeAuthFormFields(userFormState)
|
normalizeAuthFormFields(userFormState)
|
||||||
|
beginSmsOtpCapture()
|
||||||
await loginCustomer(userFormState)
|
await loginCustomer(userFormState)
|
||||||
getcode()
|
refOtpInput.value?.startWebOtp?.()
|
||||||
|
refOtpInput.value?.focusInput?.()
|
||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,10 +467,11 @@ function phoneVerification() {
|
|||||||
userFormState.token = undefined
|
userFormState.token = undefined
|
||||||
userFormState.password = undefined
|
userFormState.password = undefined
|
||||||
userFormState.provider = 'customer'
|
userFormState.provider = 'customer'
|
||||||
|
// مهم: قبل از ارسال SMS گوش بده تا دیالوگ Allow به listener زنده وصل باشد
|
||||||
|
beginSmsOtpCapture()
|
||||||
const { id } = await loginCustomer(userFormState)
|
const { id } = await loginCustomer(userFormState)
|
||||||
userFormState.id = id
|
userFormState.id = id
|
||||||
stepTwoVerify.value = true
|
stepTwoVerify.value = true
|
||||||
getcode()
|
|
||||||
reset()
|
reset()
|
||||||
} else {
|
} else {
|
||||||
const { access_token } = await verifyToken(userFormState)
|
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 = () => {
|
const goToEducate = () => {
|
||||||
router.push({ name: "educate" })
|
router.push({ name: "educate" })
|
||||||
@@ -570,18 +518,7 @@ async function proceedWithInstall() {
|
|||||||
transform: translate(100%, 0);
|
transform: translate(100%, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Must stay in DOM (not display:none) so Chrome/Safari can autofill OTP */
|
.error-token {
|
||||||
.web-otp-assist {
|
position: relative;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -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 type { FormInstance } from 'ant-design-vue'
|
||||||
import { message } 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 type { Login } from '@/models/login'
|
||||||
import {
|
import {
|
||||||
authenticateWithFingerprint,
|
authenticateWithFingerprint,
|
||||||
@@ -11,14 +11,13 @@ import {
|
|||||||
} from '@/utils'
|
} from '@/utils'
|
||||||
import useCountdownTimer from '@/utils/useCountdownTimer'
|
import useCountdownTimer from '@/utils/useCountdownTimer'
|
||||||
import { loginCustomer, verifyToken } from '@/api/login'
|
import { loginCustomer, verifyToken } from '@/api/login'
|
||||||
import router from '@/router'
|
|
||||||
import { useUserStore } from '@/store/userStore'
|
import { useUserStore } from '@/store/userStore'
|
||||||
import { useWalletStore } from '@/store/walletStore'
|
import { useWalletStore } from '@/store/walletStore'
|
||||||
import { useSettingsStore } from '@/store/settingsStore'
|
import { useSettingsStore } from '@/store/settingsStore'
|
||||||
import { setTruth } from '@/app/authBoot'
|
import { setTruth } from '@/app/authBoot'
|
||||||
import { completeLoginNavigation } from '@/layouts/composables/useMobileScrollReset'
|
import { completeLoginNavigation } from '@/layouts/composables/useMobileScrollReset'
|
||||||
import { normalizeAuthFormFields } from '@/utils/persianDigits'
|
import { normalizeAuthFormFields } from '@/utils/persianDigits'
|
||||||
import { listenForSmsOtp } from '@/utils/webOtp'
|
import { beginSmsOtpCapture, clearPendingSmsOtp } from '@/utils/webOtp'
|
||||||
|
|
||||||
export function useLoginForm() {
|
export function useLoginForm() {
|
||||||
const { time, onFinish, reset } = useCountdownTimer('00:02:00', 'mm:ss')
|
const { time, onFinish, reset } = useCountdownTimer('00:02:00', 'mm:ss')
|
||||||
@@ -27,11 +26,10 @@ export function useLoginForm() {
|
|||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const walletStore = useWalletStore()
|
const walletStore = useWalletStore()
|
||||||
const refOtpInput = ref<InstanceType<typeof OtpInput> | null>(null)
|
const refOtpInput = ref<InstanceType<typeof SmsOtpField> | null>(null)
|
||||||
const isSMS = ref(true)
|
const isSMS = ref(true)
|
||||||
const isRegister = ref<boolean | undefined>()
|
const isRegister = ref<boolean | undefined>()
|
||||||
const showPWAInstallGuide = ref(false)
|
const showPWAInstallGuide = ref(false)
|
||||||
let otpAbort: AbortController | null = null
|
|
||||||
|
|
||||||
const userFormState = reactive<Login>({
|
const userFormState = reactive<Login>({
|
||||||
provider: 'customer',
|
provider: 'customer',
|
||||||
@@ -42,17 +40,19 @@ export function useLoginForm() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
otpAbort?.abort()
|
// SmsOtpField cleans its own listener
|
||||||
otpAbort = null
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function reSendCode() {
|
async function reSendCode() {
|
||||||
userFormState.token = undefined
|
userFormState.token = undefined
|
||||||
;(refOtpInput.value as { clearInput?: () => void } | null)?.clearInput?.()
|
clearPendingSmsOtp()
|
||||||
|
refOtpInput.value?.clearInput?.()
|
||||||
enableSendAgainCode.value = false
|
enableSendAgainCode.value = false
|
||||||
normalizeAuthFormFields(userFormState)
|
normalizeAuthFormFields(userFormState)
|
||||||
|
beginSmsOtpCapture()
|
||||||
await loginCustomer(userFormState)
|
await loginCustomer(userFormState)
|
||||||
getcode()
|
refOtpInput.value?.startWebOtp?.()
|
||||||
|
refOtpInput.value?.focusInput?.()
|
||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,31 +92,8 @@ export function useLoginForm() {
|
|||||||
await completeLoginNavigation()
|
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() {
|
function phoneVerification() {
|
||||||
formRef.value?.validateFields().then(async () => {
|
return formRef.value?.validateFields().then(async () => {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
normalizeAuthFormFields(userFormState)
|
normalizeAuthFormFields(userFormState)
|
||||||
@@ -125,10 +102,10 @@ export function useLoginForm() {
|
|||||||
userFormState.token = undefined
|
userFormState.token = undefined
|
||||||
userFormState.password = undefined
|
userFormState.password = undefined
|
||||||
userFormState.provider = 'customer'
|
userFormState.provider = 'customer'
|
||||||
|
beginSmsOtpCapture()
|
||||||
const { id } = await loginCustomer(userFormState)
|
const { id } = await loginCustomer(userFormState)
|
||||||
userFormState.id = id
|
userFormState.id = id
|
||||||
stepTwoVerify.value = true
|
stepTwoVerify.value = true
|
||||||
getcode()
|
|
||||||
reset()
|
reset()
|
||||||
} else {
|
} else {
|
||||||
const { access_token } = await verifyToken(userFormState)
|
const { access_token } = await verifyToken(userFormState)
|
||||||
|
|||||||
@@ -3,15 +3,13 @@ import { mobileValidation, required } from '@/utils/inputRules'
|
|||||||
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
|
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
|
||||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.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 { useConfigStore } from '@/store/configStore'
|
||||||
import { useLoginPageContext } from '../../composables/useLoginPage'
|
import { useLoginPageContext } from '../../composables/useLoginPage'
|
||||||
import DesktopPwaGuideModal from './DesktopPwaGuideModal.vue'
|
import DesktopPwaGuideModal from './DesktopPwaGuideModal.vue'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import {isTWA} from "@/utils/config";
|
import {isTWA} from "@/utils/config";
|
||||||
import {useUserStore} from "@/store/userStore";
|
|
||||||
import {useSettingsStore} from "@/store/settingsStore";
|
import {useSettingsStore} from "@/store/settingsStore";
|
||||||
import { persianToEnglishDigits } from '@/utils/persianDigits'
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
register: [val: boolean]
|
register: [val: boolean]
|
||||||
@@ -42,17 +40,6 @@ const {
|
|||||||
proceedWithInstall,
|
proceedWithInstall,
|
||||||
} = useLoginPageContext().loginForm
|
} = 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() {
|
function goToEducate() {
|
||||||
router.push({ name: 'educate' })
|
router.push({ name: 'educate' })
|
||||||
}
|
}
|
||||||
@@ -137,30 +124,12 @@ function goToAboutUs() {
|
|||||||
name="token"
|
name="token"
|
||||||
style="position: relative"
|
style="position: relative"
|
||||||
>
|
>
|
||||||
<OtpInput
|
<SmsOtpField
|
||||||
ref="refOtpInput"
|
ref="refOtpInput"
|
||||||
:is-input-num="true"
|
v-model="userFormState.token"
|
||||||
:num-inputs="4"
|
class="dauth-otp"
|
||||||
:should-auto-focus="true"
|
@change="handleOnChange"
|
||||||
autocomplete="one-time-code"
|
@complete="handleOnComplete"
|
||||||
class="dauth-otp justify-center"
|
|
||||||
input-classes="otp-input"
|
|
||||||
separator=""
|
|
||||||
style="direction: ltr"
|
|
||||||
:value="userFormState.token || ''"
|
|
||||||
@on-change="handleOnChange"
|
|
||||||
@on-complete="handleOnComplete"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
class="web-otp-assist"
|
|
||||||
type="text"
|
|
||||||
inputmode="numeric"
|
|
||||||
autocomplete="one-time-code"
|
|
||||||
maxlength="4"
|
|
||||||
tabindex="-1"
|
|
||||||
aria-hidden="true"
|
|
||||||
:value="userFormState.token || ''"
|
|
||||||
@input="onWebOtpAssistInput"
|
|
||||||
/>
|
/>
|
||||||
</AFormItem>
|
</AFormItem>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user