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']
|
||||
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']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user