diff --git a/components.d.ts b/components.d.ts index 751269f..206c37c 100644 --- a/components.d.ts +++ b/components.d.ts @@ -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'] } } diff --git a/src/assets/css/main.less b/src/assets/css/main.less index f27c1e8..23df791 100644 --- a/src/assets/css/main.less +++ b/src/assets/css/main.less @@ -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; diff --git a/src/models/products.ts b/src/models/products.ts index 70c5aac..280c1bf 100644 --- a/src/models/products.ts +++ b/src/models/products.ts @@ -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 diff --git a/src/utils/physicalDelivery.ts b/src/utils/physicalDelivery.ts new file mode 100644 index 0000000..2fd7d45 --- /dev/null +++ b/src/utils/physicalDelivery.ts @@ -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 | Partial) & { + 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} مانده تا رسیدن به حداقل تحویل فیزیکی`, + } +} diff --git a/src/utils/webOtp.ts b/src/utils/webOtp.ts new file mode 100644 index 0000000..0228f00 --- /dev/null +++ b/src/utils/webOtp.ts @@ -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 +} diff --git a/src/views/base/home/components/CurrentPrice.vue b/src/views/base/home/components/CurrentPrice.vue index 46c41d8..69309ba 100644 --- a/src/views/base/home/components/CurrentPrice.vue +++ b/src/views/base/home/components/CurrentPrice.vue @@ -36,11 +36,10 @@ /> {{ formatSignedPercentChange(percentChange) }} - + >{{ usePriceWithoutType(displayGoldVal) }} ریال @@ -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() diff --git a/src/views/base/home/desktop/components/DesktopLivePrice.vue b/src/views/base/home/desktop/components/DesktopLivePrice.vue index 657d7f1..569dcfe 100644 --- a/src/views/base/home/desktop/components/DesktopLivePrice.vue +++ b/src/views/base/home/desktop/components/DesktopLivePrice.vue @@ -36,10 +36,10 @@ /> {{ formatSignedPercentChange(percentChange) }} - + style="direction: ltr" + >{{ usePriceWithoutType(displayGoldVal) }} ریال @@ -48,8 +48,6 @@ + + diff --git a/src/views/base/physicalReceive/mobile/MobileNewRequestView.vue b/src/views/base/physicalReceive/mobile/MobileNewRequestView.vue index 1007487..8103ee4 100644 --- a/src/views/base/physicalReceive/mobile/MobileNewRequestView.vue +++ b/src/views/base/physicalReceive/mobile/MobileNewRequestView.vue @@ -17,6 +17,17 @@ افزودن + +
+

نزدیک به حد تحویل فیزیکی

+

+ {{ pending.message }} +

+
@@ -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; +} diff --git a/src/views/base/products/components/DealProduct.vue b/src/views/base/products/components/DealProduct.vue index 526b0e8..c8f7fdb 100644 --- a/src/views/base/products/components/DealProduct.vue +++ b/src/views/base/products/components/DealProduct.vue @@ -118,6 +118,15 @@
{{ productWalletBalanceText }}
+ +
+ {{ physicalDeliveryHint }} +
+
@@ -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() diff --git a/src/views/login/components/Login.vue b/src/views/login/components/Login.vue index c2f49f7..2f45a5b 100644 --- a/src/views/login/components/Login.vue +++ b/src/views/login/components/Login.vue @@ -116,6 +116,7 @@ ]" class="error-token" name="token" + style="position: relative" > + +
| null>(null) +let otpAbort: AbortController | null = null const userFormState = reactive({ 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; +} \ No newline at end of file diff --git a/src/views/login/composables/useLoginForm.ts b/src/views/login/composables/useLoginForm.ts index a8017cc..ecee1bd 100644 --- a/src/views/login/composables/useLoginForm.ts +++ b/src/views/login/composables/useLoginForm.ts @@ -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() const showPWAInstallGuide = ref(false) + let otpAbort: AbortController | null = null const userFormState = reactive({ 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() { diff --git a/src/views/login/desktop/components/DesktopLoginForm.vue b/src/views/login/desktop/components/DesktopLoginForm.vue index a601de5..afdb0d2 100644 --- a/src/views/login/desktop/components/DesktopLoginForm.vue +++ b/src/views/login/desktop/components/DesktopLoginForm.vue @@ -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" > +