fix bugs of physicalRecieve & login-code-input & odometer-digit
This commit is contained in:
Vendored
-2
@@ -22,7 +22,6 @@ 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']
|
||||||
@@ -70,6 +69,5 @@ 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']
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
.number-ltr {
|
||||||
@apply flex flex-row-reverse gap-1;
|
@apply flex flex-row-reverse gap-1;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export interface Product {
|
|||||||
order_limit_enabled?: boolean
|
order_limit_enabled?: boolean
|
||||||
min_order_cash?: number | null
|
min_order_cash?: number | null
|
||||||
max_order_cash?: number | null
|
max_order_cash?: number | null
|
||||||
|
has_physical_delivery?: boolean
|
||||||
|
min_physical_delivery_quantity?: number | null
|
||||||
direct_price: number | undefined
|
direct_price: number | undefined
|
||||||
price_offset_percent?: number | null
|
price_offset_percent?: number | null
|
||||||
use_last_price_if_zero?: boolean
|
use_last_price_if_zero?: boolean
|
||||||
@@ -60,6 +62,8 @@ export interface PInventory {
|
|||||||
cash_fee_sell: number
|
cash_fee_sell: number
|
||||||
cash_fee_buy_is_percent?: boolean
|
cash_fee_buy_is_percent?: boolean
|
||||||
cash_fee_sell_is_percent?: boolean
|
cash_fee_sell_is_percent?: boolean
|
||||||
|
has_physical_delivery?: boolean
|
||||||
|
min_physical_delivery_quantity?: number | null
|
||||||
reading_method: any
|
reading_method: any
|
||||||
created_at: any
|
created_at: any
|
||||||
updated_at: any
|
updated_at: any
|
||||||
|
|||||||
@@ -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} مانده تا رسیدن به حداقل تحویل فیزیکی`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
<span class="price-change-badge__value">{{ formatSignedPercentChange(percentChange) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<Vue3Odometer
|
<span
|
||||||
:value="displayGoldVal"
|
|
||||||
class="o-text !text-xl font-bold price-odometer--boursecu"
|
class="o-text !text-xl font-bold price-odometer--boursecu"
|
||||||
style="direction: ltr"
|
style="direction: ltr"
|
||||||
/>
|
>{{ usePriceWithoutType(displayGoldVal) }}</span>
|
||||||
<span class="price-unit--boursecu">ریال</span>
|
<span class="price-unit--boursecu">ریال</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,9 +58,7 @@ import {
|
|||||||
getPriceDisplayState,
|
getPriceDisplayState,
|
||||||
getStoreClosedHint,
|
getStoreClosedHint,
|
||||||
} from '@/utils/storeHours'
|
} from '@/utils/storeHours'
|
||||||
import Vue3Odometer from 'vue3-odometer'
|
import { formatSignedPercentChange, usePriceWithoutType } from '@/utils/usePrice'
|
||||||
import 'odometer/themes/odometer-theme-default.css'
|
|
||||||
import { formatSignedPercentChange } from '@/utils/usePrice'
|
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
|||||||
@@ -36,10 +36,10 @@
|
|||||||
/>
|
/>
|
||||||
<span class="dhome-hero-price__delta-value">{{ formatSignedPercentChange(percentChange) }}</span>
|
<span class="dhome-hero-price__delta-value">{{ formatSignedPercentChange(percentChange) }}</span>
|
||||||
</span>
|
</span>
|
||||||
<Vue3Odometer
|
<span
|
||||||
:value="displayGoldVal"
|
|
||||||
class="dhome-hero-price__odometer o-text dhome-hero-price__odometer--boursecu"
|
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>
|
<span class="dhome-hero-price__unit dhome-hero-price__unit--boursecu">ریال</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -48,8 +48,6 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import Vue3Odometer from 'vue3-odometer'
|
|
||||||
import 'odometer/themes/odometer-theme-default.css'
|
|
||||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||||
import { useUserStore } from '@/store/userStore'
|
import { useUserStore } from '@/store/userStore'
|
||||||
import { getTradeBuyPrice, lastPrice, percentPriceChanged } from '@/utils/price'
|
import { getTradeBuyPrice, lastPrice, percentPriceChanged } from '@/utils/price'
|
||||||
@@ -59,7 +57,7 @@ import {
|
|||||||
getPriceDisplayState,
|
getPriceDisplayState,
|
||||||
getStoreClosedHint,
|
getStoreClosedHint,
|
||||||
} from '@/utils/storeHours'
|
} from '@/utils/storeHours'
|
||||||
import { formatSignedPercentChange } from '@/utils/usePrice'
|
import { formatSignedPercentChange, usePriceWithoutType } from '@/utils/usePrice'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
/>
|
/>
|
||||||
<div class="pr-cart-row__stock text-xs">
|
<div class="pr-cart-row__stock text-xs">
|
||||||
<span>مقدار موجودی</span>
|
<span>مقدار موجودی</span>
|
||||||
<span class="font-bold">{{ item.product.wallet.gold }}</span>
|
<span class="font-bold">{{ displayGold }}</span>
|
||||||
<span>{{ getProductQuantityUnit(item.product) }}</span>
|
<span>{{ quantityUnit }}</span>
|
||||||
<div
|
<div
|
||||||
v-if="getProductUnitWeightLabel(item.product)"
|
v-if="getProductUnitWeightLabel(item.product)"
|
||||||
class="mt-0.5 text-xs text-gray-500"
|
class="mt-0.5 text-xs text-gray-500"
|
||||||
@@ -26,34 +26,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex justify-between items-center gap-2">
|
<div class="mt-2 flex justify-between items-center gap-2">
|
||||||
<div class="shrink-0">تعداد درخواستی:</div>
|
<div class="shrink-0">{{ isCountBased ? 'تعداد درخواستی:' : 'مقدار درخواستی:' }}</div>
|
||||||
<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'"
|
:class="variant === 'desktop' ? 'dpr-cart-row__input' : 'pr-cart-row__input'"
|
||||||
>
|
>
|
||||||
<BasicInput
|
<BasicInput
|
||||||
v-model:value="item.count"
|
v-model:value="item.count"
|
||||||
:addon-before="getProductQuantityUnit(item.product)"
|
:addon-before="quantityUnit"
|
||||||
:addon-bg-color="config.config?.color"
|
:addon-bg-color="config.config?.color"
|
||||||
addon-text-color="white"
|
addon-text-color="white"
|
||||||
type="text"
|
type="text"
|
||||||
inputmode="numeric"
|
:inputmode="isCountBased ? 'numeric' : 'decimal'"
|
||||||
|
@update:value="onCountInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="countError" class="mt-1 text-xs text-red-600">{{ countError }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||||
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
|
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
|
||||||
import { useConfigStore } from '@/store/configStore'
|
import { useConfigStore } from '@/store/configStore'
|
||||||
import type { CountPInventory } from '@/models/products'
|
import type { CountPInventory } from '@/models/products'
|
||||||
import { getProductDisplayImage } from '@/utils/productAssets'
|
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 {
|
interface Props {
|
||||||
item: CountPInventory
|
item: CountPInventory
|
||||||
@@ -64,6 +72,10 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
variant: 'mobile',
|
variant: 'mobile',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const countError = ref('')
|
||||||
|
const isCountBased = computed(() => isCountBasedProduct(props.item.product))
|
||||||
|
const quantityUnit = computed(() => getProductQuantityUnit(props.item.product))
|
||||||
|
|
||||||
const displayImage = computed(() =>
|
const displayImage = computed(() =>
|
||||||
getProductDisplayImage({
|
getProductDisplayImage({
|
||||||
group_type: props.item.product.group_type,
|
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()
|
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<{
|
const emit = defineEmits<{
|
||||||
remove: [id: number]
|
remove: [id: number]
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -8,13 +8,28 @@ import { physicalReceiveApi } from '@/api/physicalreceive'
|
|||||||
import { tableAddress } from '@/api/address'
|
import { tableAddress } from '@/api/address'
|
||||||
import type { CountPInventory, PInventory } from '@/models/products'
|
import type { CountPInventory, PInventory } from '@/models/products'
|
||||||
import type { Address } from '@/models/address'
|
import type { Address } from '@/models/address'
|
||||||
|
import type { WalletAssetsSummary } from '@/models/walletAssets'
|
||||||
import { loading } from '@/utils'
|
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'
|
import { usePhysicalReceiveShellContext } from './usePhysicalReceiveShell'
|
||||||
|
|
||||||
|
export interface PendingDeliveryItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface NewPhysicalRequestContext {
|
export interface NewPhysicalRequestContext {
|
||||||
isDesktop: Ref<boolean>
|
isDesktop: Ref<boolean>
|
||||||
items: Ref<PInventory[] | undefined>
|
items: Ref<PInventory[] | undefined>
|
||||||
|
pendingDeliveryItems: Ref<PendingDeliveryItem[]>
|
||||||
selectedProduct: Ref<number | undefined>
|
selectedProduct: Ref<number | undefined>
|
||||||
selectedProducts: Ref<CountPInventory[]>
|
selectedProducts: Ref<CountPInventory[]>
|
||||||
address: Ref<Address[] | undefined>
|
address: Ref<Address[] | undefined>
|
||||||
@@ -31,11 +46,37 @@ export interface NewPhysicalRequestContext {
|
|||||||
export const NEW_PHYSICAL_REQUEST_KEY: InjectionKey<NewPhysicalRequestContext> =
|
export const NEW_PHYSICAL_REQUEST_KEY: InjectionKey<NewPhysicalRequestContext> =
|
||||||
Symbol('newPhysicalRequest')
|
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() {
|
export function useNewPhysicalRequestPage() {
|
||||||
const { isDesktop } = usePhysicalReceiveShellContext()
|
const { isDesktop } = usePhysicalReceiveShellContext()
|
||||||
|
const { loadWalletAssets, assetsSummary } = useWalletAssets()
|
||||||
|
const walletStore = useWalletStore()
|
||||||
|
|
||||||
const items = ref<PInventory[]>()
|
const items = ref<PInventory[]>()
|
||||||
const baseItems = ref<PInventory[]>()
|
const baseItems = ref<PInventory[]>()
|
||||||
|
const pendingDeliveryItems = ref<PendingDeliveryItem[]>([])
|
||||||
const selectedAddressID = ref<number>()
|
const selectedAddressID = ref<number>()
|
||||||
const selectedProduct = ref<number>()
|
const selectedProduct = ref<number>()
|
||||||
const productsByWalletId = ref<Array<{ wallet_id: number; value: number }>>([])
|
const productsByWalletId = ref<Array<{ wallet_id: number; value: number }>>([])
|
||||||
@@ -76,10 +117,37 @@ export function useNewPhysicalRequestPage() {
|
|||||||
const [inventory, catalog] = await Promise.all([
|
const [inventory, catalog] = await Promise.all([
|
||||||
getProductInventory(),
|
getProductInventory(),
|
||||||
getProductsCatalog(),
|
getProductsCatalog(),
|
||||||
|
loadWalletAssets(),
|
||||||
])
|
])
|
||||||
const enriched = enrichInventoryWithCatalog(inventory.rows ?? [], catalog ?? [])
|
const withBalance = syncInventoryWithWallet(
|
||||||
items.value = enriched
|
enrichInventoryWithCatalog(inventory.rows ?? [], catalog ?? []),
|
||||||
baseItems.value = cloneDeep(enriched)
|
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) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -91,10 +159,21 @@ export function useNewPhysicalRequestPage() {
|
|||||||
try {
|
try {
|
||||||
const zeroAmount = selectedProducts.value.map((item) => item.count <= 0)
|
const zeroAmount = selectedProducts.value.map((item) => item.count <= 0)
|
||||||
if (zeroAmount.includes(true)) {
|
if (zeroAmount.includes(true)) {
|
||||||
message.error('لطفاً تعداد درخواستی محصول را وارد کنید.')
|
message.error('لطفاً مقدار درخواستی محصول را وارد کنید.')
|
||||||
} else {
|
return
|
||||||
showAddress.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
@@ -145,6 +224,7 @@ export function useNewPhysicalRequestPage() {
|
|||||||
const context: NewPhysicalRequestContext = {
|
const context: NewPhysicalRequestContext = {
|
||||||
isDesktop,
|
isDesktop,
|
||||||
items,
|
items,
|
||||||
|
pendingDeliveryItems,
|
||||||
selectedProduct,
|
selectedProduct,
|
||||||
selectedProducts,
|
selectedProducts,
|
||||||
address,
|
address,
|
||||||
|
|||||||
@@ -22,6 +22,17 @@
|
|||||||
<p v-if="items?.length" class="dpr-new__hint">
|
<p v-if="items?.length" class="dpr-new__hint">
|
||||||
{{ items.length }} محصول با موجودی قابل تحویل
|
{{ items.length }} محصول با موجودی قابل تحویل
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<section class="dpr-surface dpr-new__cart">
|
<section class="dpr-surface dpr-new__cart">
|
||||||
@@ -104,6 +115,7 @@ import { useNewPhysicalRequestContext } from '../composables/useNewPhysicalReque
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
items,
|
items,
|
||||||
|
pendingDeliveryItems,
|
||||||
selectedProduct,
|
selectedProduct,
|
||||||
selectedProducts,
|
selectedProducts,
|
||||||
address,
|
address,
|
||||||
@@ -117,3 +129,26 @@ const {
|
|||||||
handleAddress,
|
handleAddress,
|
||||||
} = useNewPhysicalRequestContext()
|
} = useNewPhysicalRequestContext()
|
||||||
</script>
|
</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>
|
</BasicButton>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="physical-new-request__items px-3">
|
<div class="physical-new-request__items px-3">
|
||||||
@@ -67,6 +78,7 @@ import { useNewPhysicalRequestContext } from '../composables/useNewPhysicalReque
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
items,
|
items,
|
||||||
|
pendingDeliveryItems,
|
||||||
selectedProduct,
|
selectedProduct,
|
||||||
selectedProducts,
|
selectedProducts,
|
||||||
address,
|
address,
|
||||||
@@ -122,4 +134,24 @@ const {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border-top: 1px solid #f3f4f6;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -118,6 +118,15 @@
|
|||||||
<div>{{ productWalletBalanceText }}</div>
|
<div>{{ productWalletBalanceText }}</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</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">
|
<Transition mode="out-in" name="fade">
|
||||||
<div v-if="priceErrorVisible" class="flex gap-1 items-center text-xs mt-[-15px] text-orange-600">
|
<div v-if="priceErrorVisible" class="flex gap-1 items-center text-xs mt-[-15px] text-orange-600">
|
||||||
<BasicIcon icon="vuesax-linear:danger" />
|
<BasicIcon icon="vuesax-linear:danger" />
|
||||||
@@ -321,6 +330,7 @@ import {
|
|||||||
} from '@/utils/productOrderLimit'
|
} from '@/utils/productOrderLimit'
|
||||||
import { useWalletAssets } from '@/composables/useWalletAssets'
|
import { useWalletAssets } from '@/composables/useWalletAssets'
|
||||||
import { formatProductWalletBalanceText, formatInventoryCardValue, getCashWalletBalance, getProductInventoryQuantity } from '@/utils/productAssets'
|
import { formatProductWalletBalanceText, formatInventoryCardValue, getCashWalletBalance, getProductInventoryQuantity } from '@/utils/productAssets'
|
||||||
|
import { resolvePhysicalDeliveryState } from '@/utils/physicalDelivery'
|
||||||
import { isStoreClosed, resolveStoreClosedNotice } from '@/utils/storeHours'
|
import { isStoreClosed, resolveStoreClosedNotice } from '@/utils/storeHours'
|
||||||
|
|
||||||
const catalogProducts = getProductsCatalogRef()
|
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 infoVisible = ref(false)
|
||||||
const walletStore = useWalletStore()
|
const walletStore = useWalletStore()
|
||||||
const { assetsSummary, loadWalletAssets } = useWalletAssets()
|
const { assetsSummary, loadWalletAssets } = useWalletAssets()
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
]"
|
]"
|
||||||
class="error-token"
|
class="error-token"
|
||||||
name="token"
|
name="token"
|
||||||
|
style="position: relative"
|
||||||
>
|
>
|
||||||
<OtpInput
|
<OtpInput
|
||||||
ref="refOtpInput"
|
ref="refOtpInput"
|
||||||
@@ -127,10 +128,22 @@
|
|||||||
input-classes="otp-input"
|
input-classes="otp-input"
|
||||||
separator=""
|
separator=""
|
||||||
style="direction: ltr"
|
style="direction: ltr"
|
||||||
value=""
|
:value="userFormState.token || ''"
|
||||||
@on-change="handleOnChange"
|
@on-change="handleOnChange"
|
||||||
@on-complete="handleOnComplete"
|
@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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -320,7 +333,7 @@ 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} from 'vue'
|
import {computed, reactive, ref, inject, unref, nextTick, onBeforeUnmount} from 'vue'
|
||||||
import OtpInput from 'vue3-otp-input'
|
import OtpInput from 'vue3-otp-input'
|
||||||
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'
|
||||||
@@ -344,7 +357,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 } from '@/utils/persianDigits'
|
import { normalizeAuthFormFields, persianToEnglishDigits } from '@/utils/persianDigits'
|
||||||
|
import { listenForSmsOtp } 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'
|
||||||
@@ -377,6 +391,7 @@ 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 OtpInput> | null>(null)
|
||||||
|
let otpAbort: AbortController | null = null
|
||||||
|
|
||||||
const userFormState = reactive<Login>({
|
const userFormState = reactive<Login>({
|
||||||
provider: 'customer'
|
provider: 'customer'
|
||||||
@@ -387,12 +402,18 @@ 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?.clearInput()
|
;(refOtpInput.value as { clearInput?: () => void } | null)?.clearInput?.()
|
||||||
enableSendAgainCode.value = false
|
enableSendAgainCode.value = false
|
||||||
normalizeAuthFormFields(userFormState)
|
normalizeAuthFormFields(userFormState)
|
||||||
await loginCustomer(userFormState)
|
await loginCustomer(userFormState)
|
||||||
|
getcode()
|
||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,28 +510,35 @@ function phoneVerification() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const getcode=()=>{
|
const getcode = () => {
|
||||||
if ('OTPCredential' in window) {
|
otpAbort?.abort()
|
||||||
console.log('run')
|
otpAbort = listenForSmsOtp((code) => {
|
||||||
const ac = new AbortController();
|
void applySmsOtpCode(code)
|
||||||
navigator.credentials
|
})
|
||||||
.get({
|
}
|
||||||
otp: { transport: ['sms'] },
|
|
||||||
signal: ac.signal,
|
async function applySmsOtpCode(code: string) {
|
||||||
})
|
userFormState.token = code
|
||||||
.then((otp) => {
|
await nextTick()
|
||||||
console.log('otp recived');
|
const input = refOtpInput.value as { fillInput?: (v: string) => void } | null
|
||||||
userFormState.token = otp?.code;
|
if (input?.fillInput) {
|
||||||
refOtpInput.value.fillInput(otp?.code)
|
try {
|
||||||
phoneVerification();
|
input.fillInput(code)
|
||||||
ac.abort();
|
return
|
||||||
})
|
} catch {
|
||||||
.catch((err) => {
|
/* fall through */
|
||||||
ac.abort();
|
}
|
||||||
console.log(err);
|
}
|
||||||
});
|
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 {
|
} else {
|
||||||
console.log('not supported');
|
userFormState.token = code
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,4 +570,18 @@ async function proceedWithInstall() {
|
|||||||
transform: translate(100%, 0);
|
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>
|
</style>
|
||||||
@@ -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 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 OtpInput from 'vue3-otp-input'
|
||||||
@@ -18,6 +18,7 @@ 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'
|
||||||
|
|
||||||
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')
|
||||||
@@ -30,6 +31,7 @@ export function useLoginForm() {
|
|||||||
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',
|
||||||
@@ -39,11 +41,18 @@ export function useLoginForm() {
|
|||||||
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?.()
|
||||||
enableSendAgainCode.value = false
|
enableSendAgainCode.value = false
|
||||||
normalizeAuthFormFields(userFormState)
|
normalizeAuthFormFields(userFormState)
|
||||||
await loginCustomer(userFormState)
|
await loginCustomer(userFormState)
|
||||||
|
getcode()
|
||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,24 +92,27 @@ export function useLoginForm() {
|
|||||||
await completeLoginNavigation()
|
await completeLoginNavigation()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getcode() {
|
async function applySmsOtpCode(code: string) {
|
||||||
if ('OTPCredential' in window) {
|
userFormState.token = code
|
||||||
const ac = new AbortController()
|
await nextTick()
|
||||||
navigator.credentials
|
const input = refOtpInput.value as { fillInput?: (v: string) => void } | null
|
||||||
.get({
|
if (input?.fillInput) {
|
||||||
otp: { transport: ['sms'] },
|
try {
|
||||||
signal: ac.signal,
|
input.fillInput(code)
|
||||||
})
|
// fillInput → on-complete → handleOnComplete → phoneVerification
|
||||||
.then((otp) => {
|
return
|
||||||
userFormState.token = otp?.code
|
} catch {
|
||||||
refOtpInput.value?.fillInput(otp?.code)
|
/* fall through */
|
||||||
phoneVerification()
|
}
|
||||||
ac.abort()
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
ac.abort()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
await phoneVerification()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getcode() {
|
||||||
|
otpAbort?.abort()
|
||||||
|
otpAbort = listenForSmsOtp((code) => {
|
||||||
|
void applySmsOtpCode(code)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function phoneVerification() {
|
function phoneVerification() {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import router from '@/router'
|
|||||||
import {isTWA} from "@/utils/config";
|
import {isTWA} from "@/utils/config";
|
||||||
import {useUserStore} from "@/store/userStore";
|
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]
|
||||||
@@ -41,6 +42,17 @@ 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' })
|
||||||
}
|
}
|
||||||
@@ -123,6 +135,7 @@ function goToAboutUs() {
|
|||||||
]"
|
]"
|
||||||
class="error-token"
|
class="error-token"
|
||||||
name="token"
|
name="token"
|
||||||
|
style="position: relative"
|
||||||
>
|
>
|
||||||
<OtpInput
|
<OtpInput
|
||||||
ref="refOtpInput"
|
ref="refOtpInput"
|
||||||
@@ -134,10 +147,21 @@ function goToAboutUs() {
|
|||||||
input-classes="otp-input"
|
input-classes="otp-input"
|
||||||
separator=""
|
separator=""
|
||||||
style="direction: ltr"
|
style="direction: ltr"
|
||||||
value=""
|
:value="userFormState.token || ''"
|
||||||
@on-change="handleOnChange"
|
@on-change="handleOnChange"
|
||||||
@on-complete="handleOnComplete"
|
@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>
|
||||||
|
|
||||||
<div class="dauth-phone-edit">
|
<div class="dauth-phone-edit">
|
||||||
|
|||||||
Reference in New Issue
Block a user