296 lines
5.9 KiB
Vue
296 lines
5.9 KiB
Vue
<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>
|