fix bugs of physicalRecieve & login-code-input & odometer-digit

This commit is contained in:
2026-07-20 16:57:03 +03:30
parent cedd1ae586
commit a60f1fbc68
15 changed files with 545 additions and 73 deletions
-2
View File
@@ -22,7 +22,6 @@ 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']
@@ -70,6 +69,5 @@ 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']
}
}
+15
View File
@@ -236,6 +236,21 @@ body:has(.ant-modal-root .ant-modal-wrap:not([style*='display: none'])) {
}
}
/* Keep in DOM for Chrome/Safari WebOTP autofill (not display:none) */
.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;
}
.number-ltr {
@apply flex flex-row-reverse gap-1;
direction: ltr;
+4
View File
@@ -25,6 +25,8 @@ export interface Product {
order_limit_enabled?: boolean
min_order_cash?: number | null
max_order_cash?: number | null
has_physical_delivery?: boolean
min_physical_delivery_quantity?: number | null
direct_price: number | undefined
price_offset_percent?: number | null
use_last_price_if_zero?: boolean
@@ -60,6 +62,8 @@ export interface PInventory {
cash_fee_sell: number
cash_fee_buy_is_percent?: boolean
cash_fee_sell_is_percent?: boolean
has_physical_delivery?: boolean
min_physical_delivery_quantity?: number | null
reading_method: any
created_at: any
updated_at: any
+115
View File
@@ -0,0 +1,115 @@
import type { PInventory, Product } from '@/models/products'
import { getProductQuantityUnit, isCountBasedProduct } from '@/utils/productDealMethod'
import { formatWeightAmount, usePriceWithoutType } from '@/utils/usePrice'
export type PhysicalDeliveryStatus = 'none' | 'ready' | 'below_min'
/** برچسب کوتاه کارت محصول: فقط «دارد/ندارد» تحویل فیزیکی — مستقل از موجودی کاربر */
export function getProductDeliveryBadge(
product: { has_physical_delivery?: boolean } | null | undefined
): { has: boolean; text: string } {
const has = Boolean(product?.has_physical_delivery)
return {
has,
text: has ? 'تحویل فیزیکی دارد' : 'تحویل فیزیکی ندارد',
}
}
export interface PhysicalDeliveryState {
status: PhysicalDeliveryStatus
available: number
min: number
remaining: number
unit: string
message: string
}
type DeliveryProduct = (Partial<Product> | Partial<PInventory>) & {
has_physical_delivery?: boolean
min_physical_delivery_quantity?: number | null
amount?: any
unit?: string | null
group_type?: string | null
direct_price?: number | null
title?: string | null
wallet?: { gold?: number | null } | null
}
/**
* موجودی محصول به واحد خود محصول:
* - تعدادی → تعداد قلم (عدد)
* - وزنی → وزن به واحد محصول (گرم/کیلوگرم)
* همسان با inventory_cards سرور (wallet.gold مستقیماً به واحد محصول است).
*/
export function resolveProductQuantity(
product: DeliveryProduct | null | undefined,
quantity?: number | null
): number {
if (!product) {
return 0
}
const qty = quantity != null ? Number(quantity) : Number(product.wallet?.gold ?? 0)
if (!Number.isFinite(qty) || qty <= 0) {
return 0
}
return qty
}
/** قالب‌بندی مقدار بر اساس واحد: عدد بدون اعشار، وزن با فرمت وزنی */
function formatDeliveryQuantity(value: number, product: DeliveryProduct | null | undefined): string {
if (isCountBasedProduct(product as any)) {
return usePriceWithoutType(value, false)
}
return formatWeightAmount(value)
}
/**
* وضعیت تحویل فیزیکی یک محصول:
* - none: محصول اصلاً تحویل فیزیکی ندارد
* - ready: موجودی به حداقل رسیده و تحویل فعال است
* - below_min: تحویل دارد ولی هنوز به حداقل نرسیده — پیام «چند واحد مانده»
*/
export function resolvePhysicalDeliveryState(
product: DeliveryProduct | null | undefined,
quantity?: number | null
): PhysicalDeliveryState {
const title = product?.title?.trim() || 'این محصول'
const unit = getProductQuantityUnit(product as any)
if (!product?.has_physical_delivery) {
return {
status: 'none',
available: 0,
min: 0,
remaining: 0,
unit,
message: `${title} تحویل فیزیکی ندارد`,
}
}
const min = Math.max(0, Number(product.min_physical_delivery_quantity ?? 0))
const available = resolveProductQuantity(product, quantity)
if (min <= 0 || available + 0.0001 >= min) {
return {
status: 'ready',
available,
min,
remaining: 0,
unit,
message: 'امکان تحویل فیزیکی فعال است',
}
}
const remaining = Math.max(0, min - available)
return {
status: 'below_min',
available,
min,
remaining,
unit,
message: `${formatDeliveryQuantity(remaining, product)} ${unit} مانده تا رسیدن به حداقل تحویل فیزیکی`,
}
}
+39
View File
@@ -0,0 +1,39 @@
import { persianToEnglishDigits } from '@/utils/persianDigits'
/** استخراج کد عددی از نتیجه WebOTP / Credential */
export function extractOtpCode(
credential: Credential | null | undefined,
length = 4
): string {
const raw = (credential as { code?: string } | null | undefined)?.code ?? ''
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()
if (!('OTPCredential' in window) || !navigator.credentials?.get) {
return ac
}
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)
}
})
.catch(() => {
/* aborted or denied — ignore */
})
return ac
}
@@ -36,11 +36,10 @@
/>
<span class="price-change-badge__value">{{ formatSignedPercentChange(percentChange) }}</span>
</div>
<Vue3Odometer
:value="displayGoldVal"
<span
class="o-text !text-xl font-bold price-odometer--boursecu"
style="direction: ltr"
/>
>{{ usePriceWithoutType(displayGoldVal) }}</span>
<span class="price-unit--boursecu">ریال</span>
</div>
</div>
@@ -59,9 +58,7 @@ import {
getPriceDisplayState,
getStoreClosedHint,
} from '@/utils/storeHours'
import Vue3Odometer from 'vue3-odometer'
import 'odometer/themes/odometer-theme-default.css'
import { formatSignedPercentChange } from '@/utils/usePrice'
import { formatSignedPercentChange, usePriceWithoutType } from '@/utils/usePrice'
const userStore = useUserStore()
@@ -36,10 +36,10 @@
/>
<span class="dhome-hero-price__delta-value">{{ formatSignedPercentChange(percentChange) }}</span>
</span>
<Vue3Odometer
:value="displayGoldVal"
<span
class="dhome-hero-price__odometer o-text dhome-hero-price__odometer--boursecu"
/>
style="direction: ltr"
>{{ usePriceWithoutType(displayGoldVal) }}</span>
<span class="dhome-hero-price__unit dhome-hero-price__unit--boursecu">ریال</span>
</div>
</template>
@@ -48,8 +48,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import Vue3Odometer from 'vue3-odometer'
import 'odometer/themes/odometer-theme-default.css'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useUserStore } from '@/store/userStore'
import { getTradeBuyPrice, lastPrice, percentPriceChanged } from '@/utils/price'
@@ -59,7 +57,7 @@ import {
getPriceDisplayState,
getStoreClosedHint,
} from '@/utils/storeHours'
import { formatSignedPercentChange } from '@/utils/usePrice'
import { formatSignedPercentChange, usePriceWithoutType } from '@/utils/usePrice'
const userStore = useUserStore()
@@ -15,8 +15,8 @@
/>
<div class="pr-cart-row__stock text-xs">
<span>مقدار موجودی</span>
<span class="font-bold">{{ item.product.wallet.gold }}</span>
<span>{{ getProductQuantityUnit(item.product) }}</span>
<span class="font-bold">{{ displayGold }}</span>
<span>{{ quantityUnit }}</span>
<div
v-if="getProductUnitWeightLabel(item.product)"
class="mt-0.5 text-xs text-gray-500"
@@ -26,34 +26,42 @@
</div>
</div>
<div class="mt-2 flex justify-between items-center gap-2">
<div class="shrink-0">تعداد درخواستی:</div>
<div class="shrink-0">{{ isCountBased ? 'تعداد درخواستی:' : 'مقدار درخواستی:' }}</div>
<div
class="flex items-center gap-1 "
class="flex items-center gap-1"
:class="variant === 'desktop' ? 'dpr-cart-row__input' : 'pr-cart-row__input'"
>
<BasicInput
v-model:value="item.count"
:addon-before="getProductQuantityUnit(item.product)"
:addon-before="quantityUnit"
:addon-bg-color="config.config?.color"
addon-text-color="white"
type="text"
inputmode="numeric"
:inputmode="isCountBased ? 'numeric' : 'decimal'"
@update:value="onCountInput"
/>
</div>
</div>
<div v-if="countError" class="mt-1 text-xs text-red-600">{{ countError }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import { useConfigStore } from '@/store/configStore'
import type { CountPInventory } from '@/models/products'
import { getProductDisplayImage } from '@/utils/productAssets'
import { getProductQuantityUnit, getProductUnitWeightLabel } from '@/utils/productDealMethod'
import {
getProductQuantityUnit,
getProductUnitWeightLabel,
isCountBasedProduct,
} from '@/utils/productDealMethod'
import { formatGoldAmount, usePriceWithoutType } from '@/utils/usePrice'
import { persianToEnglishDigits } from '@/utils/persianDigits'
interface Props {
item: CountPInventory
@@ -64,6 +72,10 @@ const props = withDefaults(defineProps<Props>(), {
variant: 'mobile',
})
const countError = ref('')
const isCountBased = computed(() => isCountBasedProduct(props.item.product))
const quantityUnit = computed(() => getProductQuantityUnit(props.item.product))
const displayImage = computed(() =>
getProductDisplayImage({
group_type: props.item.product.group_type,
@@ -72,8 +84,49 @@ const displayImage = computed(() =>
})
)
const displayGold = computed(() => {
const gold = Number(props.item.product.wallet?.gold ?? 0)
return isCountBased.value ? usePriceWithoutType(gold, false) : formatGoldAmount(gold)
})
const config = useConfigStore()
function onCountInput(raw: string | number) {
countError.value = ''
const normalized = persianToEnglishDigits(String(raw ?? '').trim())
if (!normalized) {
props.item.count = 0 as any
return
}
const numeric = Number(normalized)
if (!Number.isFinite(numeric)) {
countError.value = 'مقدار معتبر وارد کنید'
return
}
if (isCountBased.value) {
if (numeric % 1 !== 0) {
countError.value = 'برای محصول تعدادی فقط عدد صحیح مجاز است'
props.item.count = Math.floor(numeric) as any
return
}
props.item.count = Math.floor(numeric) as any
}
}
watch(
() => props.item.count,
(value) => {
if (!isCountBased.value) return
const numeric = Number(value)
if (Number.isFinite(numeric) && numeric % 1 !== 0) {
props.item.count = Math.floor(numeric) as any
countError.value = 'برای محصول تعدادی فقط عدد صحیح مجاز است'
}
}
)
const emit = defineEmits<{
remove: [id: number]
}>()
@@ -8,13 +8,28 @@ import { physicalReceiveApi } from '@/api/physicalreceive'
import { tableAddress } from '@/api/address'
import type { CountPInventory, PInventory } from '@/models/products'
import type { Address } from '@/models/address'
import type { WalletAssetsSummary } from '@/models/walletAssets'
import { loading } from '@/utils'
import { enrichInventoryWithCatalog } from '@/utils/productAssets'
import {
enrichInventoryWithCatalog,
getProductInventoryQuantity,
} from '@/utils/productAssets'
import { resolvePhysicalDeliveryState } from '@/utils/physicalDelivery'
import { isCountBasedProduct } from '@/utils/productDealMethod'
import { useWalletAssets } from '@/composables/useWalletAssets'
import { useWalletStore } from '@/store/walletStore'
import { usePhysicalReceiveShellContext } from './usePhysicalReceiveShell'
export interface PendingDeliveryItem {
id: number
title: string
message: string
}
export interface NewPhysicalRequestContext {
isDesktop: Ref<boolean>
items: Ref<PInventory[] | undefined>
pendingDeliveryItems: Ref<PendingDeliveryItem[]>
selectedProduct: Ref<number | undefined>
selectedProducts: Ref<CountPInventory[]>
address: Ref<Address[] | undefined>
@@ -31,11 +46,37 @@ export interface NewPhysicalRequestContext {
export const NEW_PHYSICAL_REQUEST_KEY: InjectionKey<NewPhysicalRequestContext> =
Symbol('newPhysicalRequest')
function syncInventoryWithWallet(
products: PInventory[],
summary: WalletAssetsSummary | null | undefined,
mainWalletGold?: number | null
): PInventory[] {
const inventoryCards = summary?.inventory_cards ?? []
return products.map((product) => {
const gold = getProductInventoryQuantity(inventoryCards, product.id, mainWalletGold)
if (!product.wallet) {
return product
}
return {
...product,
wallet: {
...product.wallet,
gold,
},
}
})
}
export function useNewPhysicalRequestPage() {
const { isDesktop } = usePhysicalReceiveShellContext()
const { loadWalletAssets, assetsSummary } = useWalletAssets()
const walletStore = useWalletStore()
const items = ref<PInventory[]>()
const baseItems = ref<PInventory[]>()
const pendingDeliveryItems = ref<PendingDeliveryItem[]>([])
const selectedAddressID = ref<number>()
const selectedProduct = ref<number>()
const productsByWalletId = ref<Array<{ wallet_id: number; value: number }>>([])
@@ -76,10 +117,37 @@ export function useNewPhysicalRequestPage() {
const [inventory, catalog] = await Promise.all([
getProductInventory(),
getProductsCatalog(),
loadWalletAssets(),
])
const enriched = enrichInventoryWithCatalog(inventory.rows ?? [], catalog ?? [])
items.value = enriched
baseItems.value = cloneDeep(enriched)
const withBalance = syncInventoryWithWallet(
enrichInventoryWithCatalog(inventory.rows ?? [], catalog ?? []),
assetsSummary.value,
walletStore.walletInfo?.gold
).filter(
(product) =>
product.title !== 'آبشده نقدی' && Number(product.wallet?.gold ?? 0) > 0
)
// فقط محصولاتی که تحویل فیزیکی دارند و به حداقل گرم رسیده‌اند قابل انتخاب‌اند.
// محصولاتی که تحویل دارند ولی هنوز به حداقل نرسیده‌اند به‌صورت پیام نمایش داده می‌شوند.
const eligible: PInventory[] = []
const pending: PendingDeliveryItem[] = []
for (const product of withBalance) {
const state = resolvePhysicalDeliveryState(product as any)
if (state.status === 'ready') {
eligible.push(product)
} else if (state.status === 'below_min') {
pending.push({
id: product.id,
title: product.title ?? 'محصول',
message: `${product.title ?? 'محصول'}: ${state.message}`,
})
}
}
items.value = eligible
baseItems.value = cloneDeep(eligible)
pendingDeliveryItems.value = pending
} catch (e) {
console.log(e)
} finally {
@@ -91,10 +159,21 @@ export function useNewPhysicalRequestPage() {
try {
const zeroAmount = selectedProducts.value.map((item) => item.count <= 0)
if (zeroAmount.includes(true)) {
message.error('لطفاً تعداد درخواستی محصول را وارد کنید.')
} else {
showAddress.value = true
message.error('لطفاً مقدار درخواستی محصول را وارد کنید.')
return
}
const invalidCount = selectedProducts.value.find((item) => {
if (!isCountBasedProduct(item.product)) return false
const value = Number(item.count)
return !Number.isFinite(value) || value % 1 !== 0
})
if (invalidCount) {
message.error(`برای «${invalidCount.product.title}» فقط تعداد صحیح مجاز است`)
return
}
showAddress.value = true
} catch (e) {
console.log(e)
}
@@ -145,6 +224,7 @@ export function useNewPhysicalRequestPage() {
const context: NewPhysicalRequestContext = {
isDesktop,
items,
pendingDeliveryItems,
selectedProduct,
selectedProducts,
address,
@@ -22,6 +22,17 @@
<p v-if="items?.length" class="dpr-new__hint">
{{ items.length }} محصول با موجودی قابل تحویل
</p>
<div v-if="pendingDeliveryItems.length" class="dpr-new__pending">
<p class="dpr-new__pending-title">نزدیک به حد تحویل فیزیکی</p>
<p
v-for="pending in pendingDeliveryItems"
:key="pending.id"
class="dpr-new__pending-row"
>
{{ pending.message }}
</p>
</div>
</section>
<section class="dpr-surface dpr-new__cart">
@@ -104,6 +115,7 @@ import { useNewPhysicalRequestContext } from '../composables/useNewPhysicalReque
const {
items,
pendingDeliveryItems,
selectedProduct,
selectedProducts,
address,
@@ -117,3 +129,26 @@ const {
handleAddress,
} = useNewPhysicalRequestContext()
</script>
<style scoped>
.dpr-new__pending {
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: #fff7ed;
border: 1px solid #fed7aa;
}
.dpr-new__pending-title {
font-size: 0.8rem;
font-weight: 600;
color: #c2410c;
margin-bottom: 0.25rem;
}
.dpr-new__pending-row {
font-size: 0.78rem;
color: #9a3412;
line-height: 1.6;
}
</style>
@@ -17,6 +17,17 @@
افزودن
</BasicButton>
</div>
<div v-if="pendingDeliveryItems.length" class="physical-new-request__pending mx-3 mt-2">
<p class="physical-new-request__pending-title">نزدیک به حد تحویل فیزیکی</p>
<p
v-for="pending in pendingDeliveryItems"
:key="pending.id"
class="physical-new-request__pending-row"
>
{{ pending.message }}
</p>
</div>
</div>
<div class="physical-new-request__items px-3">
@@ -67,6 +78,7 @@ import { useNewPhysicalRequestContext } from '../composables/useNewPhysicalReque
const {
items,
pendingDeliveryItems,
selectedProduct,
selectedProducts,
address,
@@ -122,4 +134,24 @@ const {
background: #fff;
border-top: 1px solid #f3f4f6;
}
.physical-new-request__pending {
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: #fff7ed;
border: 1px solid #fed7aa;
}
.physical-new-request__pending-title {
font-size: 0.8rem;
font-weight: 600;
color: #c2410c;
margin-bottom: 0.25rem;
}
.physical-new-request__pending-row {
font-size: 0.78rem;
color: #9a3412;
line-height: 1.6;
}
</style>
@@ -118,6 +118,15 @@
<div>{{ productWalletBalanceText }}</div>
</div>
</Transition>
<Transition mode="out-in" name="fade">
<div
v-if="physicalDeliveryHint"
class="text-xs mt-1"
:class="physicalDeliveryState.status === 'ready' ? 'text-green-600' : 'text-orange-600'"
>
{{ physicalDeliveryHint }}
</div>
</Transition>
<Transition mode="out-in" name="fade">
<div v-if="priceErrorVisible" class="flex gap-1 items-center text-xs mt-[-15px] text-orange-600">
<BasicIcon icon="vuesax-linear:danger" />
@@ -321,6 +330,7 @@ import {
} from '@/utils/productOrderLimit'
import { useWalletAssets } from '@/composables/useWalletAssets'
import { formatProductWalletBalanceText, formatInventoryCardValue, getCashWalletBalance, getProductInventoryQuantity } from '@/utils/productAssets'
import { resolvePhysicalDeliveryState } from '@/utils/physicalDelivery'
import { isStoreClosed, resolveStoreClosedNotice } from '@/utils/storeHours'
const catalogProducts = getProductsCatalogRef()
@@ -371,6 +381,24 @@ const productWalletBalanceText = computed(() =>
)
)
const physicalDeliveryState = computed(() =>
resolvePhysicalDeliveryState(props?.ProductItem as any, productWalletBalance.value)
)
const physicalDeliveryHint = computed(() => {
if (!props?.ProductItem?.has_physical_delivery) {
return ''
}
const state = physicalDeliveryState.value
if (state.status === 'ready') {
return 'امکان تحویل فیزیکی این محصول فعال است'
}
if (state.status === 'below_min') {
return state.message
}
return ''
})
const infoVisible = ref(false)
const walletStore = useWalletStore()
const { assetsSummary, loadWalletAssets } = useWalletAssets()
+67 -25
View File
@@ -116,6 +116,7 @@
]"
class="error-token"
name="token"
style="position: relative"
>
<OtpInput
ref="refOtpInput"
@@ -127,10 +128,22 @@
input-classes="otp-input"
separator=""
style="direction: ltr"
value=""
: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>
<div
@@ -320,7 +333,7 @@ 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} from 'vue'
import {computed, reactive, ref, inject, unref, nextTick, onBeforeUnmount} from 'vue'
import OtpInput from 'vue3-otp-input'
import type { Login } from '@/models/login'
import type { FormInstance } from 'ant-design-vue'
@@ -344,7 +357,8 @@ import {
isTWA
} from "@/utils/config";
import BasicActionSheet from "@/components/BasicPacks/BasicActionSheet.vue";
import { normalizeAuthFormFields } from '@/utils/persianDigits'
import { normalizeAuthFormFields, persianToEnglishDigits } from '@/utils/persianDigits'
import { listenForSmsOtp } 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'
@@ -377,6 +391,7 @@ const setting=useSettingsStore()
const data = reactive(['ورود با پیامک', 'ورود با کلمه عبور'])
const value = ref(data[0])
const refOtpInput = ref<InstanceType<typeof OtpInput> | null>(null)
let otpAbort: AbortController | null = null
const userFormState = reactive<Login>({
provider: 'customer'
@@ -387,12 +402,18 @@ onFinish(() => {
enableSendAgainCode.value = true
})
onBeforeUnmount(() => {
otpAbort?.abort()
otpAbort = null
})
async function reSendCode() {
userFormState.token = undefined
// refOtpInput.value?.clearInput()
;(refOtpInput.value as { clearInput?: () => void } | null)?.clearInput?.()
enableSendAgainCode.value = false
normalizeAuthFormFields(userFormState)
await loginCustomer(userFormState)
getcode()
reset()
}
@@ -489,28 +510,35 @@ function phoneVerification() {
}
})
}
const getcode=()=>{
if ('OTPCredential' in window) {
console.log('run')
const ac = new AbortController();
navigator.credentials
.get({
otp: { transport: ['sms'] },
signal: ac.signal,
})
.then((otp) => {
console.log('otp recived');
userFormState.token = otp?.code;
refOtpInput.value.fillInput(otp?.code)
phoneVerification();
ac.abort();
})
.catch((err) => {
ac.abort();
console.log(err);
});
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 {
console.log('not supported');
userFormState.token = code
}
}
@@ -542,4 +570,18 @@ 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;
}
</style>
+30 -18
View File
@@ -1,4 +1,4 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, 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'
@@ -18,6 +18,7 @@ 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'
export function useLoginForm() {
const { time, onFinish, reset } = useCountdownTimer('00:02:00', 'mm:ss')
@@ -30,6 +31,7 @@ export function useLoginForm() {
const isSMS = ref(true)
const isRegister = ref<boolean | undefined>()
const showPWAInstallGuide = ref(false)
let otpAbort: AbortController | null = null
const userFormState = reactive<Login>({
provider: 'customer',
@@ -39,11 +41,18 @@ export function useLoginForm() {
enableSendAgainCode.value = true
})
onBeforeUnmount(() => {
otpAbort?.abort()
otpAbort = null
})
async function reSendCode() {
userFormState.token = undefined
;(refOtpInput.value as { clearInput?: () => void } | null)?.clearInput?.()
enableSendAgainCode.value = false
normalizeAuthFormFields(userFormState)
await loginCustomer(userFormState)
getcode()
reset()
}
@@ -83,24 +92,27 @@ export function useLoginForm() {
await completeLoginNavigation()
}
function getcode() {
if ('OTPCredential' in window) {
const ac = new AbortController()
navigator.credentials
.get({
otp: { transport: ['sms'] },
signal: ac.signal,
})
.then((otp) => {
userFormState.token = otp?.code
refOtpInput.value?.fillInput(otp?.code)
phoneVerification()
ac.abort()
})
.catch(() => {
ac.abort()
})
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() {
@@ -11,6 +11,7 @@ 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]
@@ -41,6 +42,17 @@ 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' })
}
@@ -123,6 +135,7 @@ function goToAboutUs() {
]"
class="error-token"
name="token"
style="position: relative"
>
<OtpInput
ref="refOtpInput"
@@ -134,10 +147,21 @@ function goToAboutUs() {
input-classes="otp-input"
separator=""
style="direction: ltr"
value=""
: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>
<div class="dauth-phone-edit">