Files
boursecu_user/src/components/SmsOtpField.vue
T

296 lines
5.9 KiB
Vue
Raw Normal View History

2026-07-21 12:06:42 +02:00
<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>