feat: streamline KYC onboarding with required modal navigation, auto open edit sheet, and instant Jibit verification

This commit is contained in:
2026-11-10 14:28:32 +03:30
parent d6a2caffe0
commit 744065cd12
4 changed files with 99 additions and 315 deletions
+3 -2
View File
@@ -83,12 +83,13 @@ export function setupRouterGuards() {
return goToLogin(next)
}
// کاربر احراز هویت نشده نباید بتواند به صفحات داخلی دسترسی پیدا کند
// کاربر احراز هویت نشده نباید بتواند به صفحات داخلی به جز پروفایل و خانه دسترسی پیدا کند
if (
userStore.userInfo &&
!isCustomerIdentityVerified(userStore.userInfo.is_legal) &&
!isPublicRoute(to) &&
to.name !== 'home'
to.name !== 'home' &&
to.name !== 'user'
) {
return goToHome(next)
}
+59 -303
View File
@@ -12,241 +12,85 @@
class="required-kyc-modal"
wrap-class-name="required-kyc-modal-wrap"
>
<div class="kyc-modal-content text-right" dir="rtl">
<!-- State 1: Need to enter/edit KYC information -->
<template v-if="!isPendingApproval || isEditing">
<div class="text-center mb-5">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-amber-50 text-amber-700 border border-amber-200 mb-3 shadow-sm">
<BasicIcon icon="vuesax-bold:security-user" size="32" color="#b45309" />
</div>
<h2 class="text-lg font-bold text-slate-800 mb-1">
احراز هویت الزامی است
</h2>
<p class="text-xs text-slate-500 leading-6 max-w-sm mx-auto">
کاربر گرامی، جهت حفظ امنیت حساب و امکان انجام معاملات مس و واریز/برداشت وجه، لازم است مشخصات هویتی خود را ثبت نمایید.
</p>
</div>
<div class="kyc-modal-content text-center py-2" dir="rtl">
<!-- Icon with soft glowing background -->
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-amber-50 text-amber-600 border border-amber-200 mb-4 shadow-sm">
<BasicIcon icon="vuesax-bold:security-user" size="36" color="#d97706" />
</div>
<a-form
ref="formRef"
:model="formState"
layout="vertical"
class="kyc-form space-y-2"
@finish="onSubmit"
<!-- Title -->
<h2 class="text-base font-bold text-slate-800 mb-2">
احراز هویت الزامی است
</h2>
<!-- Description: شیک، کوتاه، مختصر و مفید -->
<p class="text-xs text-slate-600 leading-6 max-w-xs mx-auto mb-5">
کاربر گرامی، جهت استفاده از کلیه امکانات سامانه و شروع معاملات، لطفاً ابتدا فرایند احراز هویت خود را تکمیل فرمایید.
</p>
<!-- Tip info box -->
<div class="bg-amber-50/70 border border-amber-200/80 rounded-xl p-3 text-right text-xs text-amber-900 mb-5 space-y-1.5">
<div class="flex items-center gap-2">
<BasicIcon icon="vuesax-linear:tick-circle" size="16" color="#d97706" class="shrink-0" />
<span>استعلام و تایید آنی مشخصات از طریق سامانه جیبیت</span>
</div>
<div class="flex items-center gap-2">
<BasicIcon icon="vuesax-linear:tick-circle" size="16" color="#d97706" class="shrink-0" />
<span>کد ملی وارد شده باید متعلق به مالک شماره موبایل باشد</span>
</div>
</div>
<!-- Action buttons -->
<div class="flex flex-col gap-2.5">
<BasicButton
type="primary"
class="w-full h-11 text-sm font-semibold !rounded-xl flex items-center justify-center gap-2"
@click="goToKyc"
>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-0">
<a-form-item
label="نام"
name="fname"
:rules="[required('نام'), isValidPersian()]"
>
<BasicInput
v-model:value="formState.fname"
placeholder="مثال: علی"
:disabled="loading"
/>
</a-form-item>
<span>تکمیل و احراز هویت</span>
<BasicIcon icon="vuesax-linear:arrow-left" size="16" />
</BasicButton>
<a-form-item
label="نام خانوادگی"
name="lname"
:rules="[required('نام خانوادگی'), isValidPersian()]"
>
<BasicInput
v-model:value="formState.lname"
placeholder="مثال: محمدی"
:disabled="loading"
/>
</a-form-item>
</div>
<a-form-item
label="کد ملی (۱۰ رقم)"
name="n_code"
:rules="[requireNationalCode()]"
>
<BasicInput
v-model:value="formState.n_code"
type="text"
inputmode="numeric"
maxlength="10"
placeholder="مثال: ۰۰۱۲۳۴۵۶۷۸"
:disabled="loading"
/>
</a-form-item>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-0">
<a-form-item
label="تاریخ تولد"
name="birth_date"
:rules="[required('تاریخ تولد')]"
>
<BaseDatePicker
v-model:value="formState.birth_date"
class="w-full"
input-read-only
placeholder="انتخاب تاریخ تولد"
:disabled="loading"
/>
</a-form-item>
<a-form-item
label="شهر"
name="city"
:rules="[required('شهر')]"
>
<BasicInput
v-model:value="formState.city"
placeholder="مثال: تهران"
:disabled="loading"
/>
</a-form-item>
</div>
<div class="pt-4 flex flex-col gap-2.5">
<BasicButton
type="primary"
html-type="submit"
:disabled="loading"
class="w-full h-11 text-sm font-semibold !rounded-xl"
>
<a-spin v-if="loading" size="small" />
<span v-else>ثبت و استعلام هویت</span>
</BasicButton>
<div class="flex items-center justify-between gap-2 pt-1">
<button
v-if="isEditing && isPendingApproval"
type="button"
class="text-xs text-slate-500 hover:text-slate-700 py-1 px-2 cursor-pointer transition-colors"
@click="isEditing = false"
>
انصراف از ویرایش
</button>
<button
type="button"
class="text-xs text-red-600 hover:text-red-700 flex items-center gap-1 py-1 px-2 mr-auto cursor-pointer transition-colors"
@click="onLogout"
>
<BasicIcon icon="vuesax-linear:logout" size="14" color="#dc2626" />
<span>خروج از حساب کاربری</span>
</button>
</div>
</div>
</a-form>
</template>
<!-- State 2: Already submitted, waiting for Admin review -->
<template v-else>
<div class="text-center mb-5">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-amber-50 text-amber-700 border border-amber-200 mb-3 shadow-sm">
<BasicIcon icon="vuesax-bold:clock" size="32" color="#d97706" />
</div>
<h2 class="text-lg font-bold text-slate-800 mb-1">
در انتظار تأیید اطلاعات هویتی
</h2>
<p class="text-xs text-slate-600 leading-6 max-w-sm mx-auto">
اطلاعات شما با موفقیت در سامانه ثبت شده و در صف بررسی و تایید کارشناسان مدیریت قرار دارد. پس از تایید هویت، دسترسی به تمام بخش‌ها فعال خواهد شد.
</p>
</div>
<div class="bg-slate-50 rounded-xl p-3.5 border border-slate-200 text-xs text-slate-700 space-y-2 mb-5">
<div class="flex justify-between items-center py-1 border-b border-slate-200/60">
<span class="text-slate-500">نام و نام خانوادگی:</span>
<span class="font-medium text-slate-800">{{ formState.fname }} {{ formState.lname }}</span>
</div>
<div class="flex justify-between items-center py-1 border-b border-slate-200/60">
<span class="text-slate-500">کد ملی:</span>
<span class="font-medium text-slate-800 tracking-wider">{{ formState.n_code }}</span>
</div>
<div v-if="formState.birth_date" class="flex justify-between items-center py-1 border-b border-slate-200/60">
<span class="text-slate-500">تاریخ تولد:</span>
<span class="font-medium text-slate-800">{{ formState.birth_date }}</span>
</div>
<div v-if="formState.city" class="flex justify-between items-center py-1">
<span class="text-slate-500">شهر:</span>
<span class="font-medium text-slate-800">{{ formState.city }}</span>
</div>
</div>
<div class="flex flex-col gap-2.5">
<BasicButton
type="primary"
:disabled="checkingStatus"
class="w-full h-11 text-sm font-semibold !rounded-xl"
@click="onCheckStatus"
>
<a-spin v-if="checkingStatus" size="small" />
<span v-else>بررسی مجدد وضعیت تأیید</span>
</BasicButton>
<div class="flex items-center justify-between gap-2 pt-2">
<button
type="button"
class="text-xs text-slate-600 hover:text-slate-800 flex items-center gap-1 py-1 px-2 cursor-pointer transition-colors"
@click="isEditing = true"
>
<BasicIcon icon="vuesax-linear:edit-2" size="14" color="#475569" />
<span>ویرایش مجدد اطلاعات</span>
</button>
<button
type="button"
class="text-xs text-red-600 hover:text-red-700 flex items-center gap-1 py-1 px-2 mr-auto cursor-pointer transition-colors"
@click="onLogout"
>
<BasicIcon icon="vuesax-linear:logout" size="14" color="#dc2626" />
<span>خروج از حساب</span>
</button>
</div>
</div>
</template>
<button
type="button"
class="text-xs text-red-600 hover:text-red-700 flex items-center justify-center gap-1.5 py-1.5 cursor-pointer transition-colors"
@click="onLogout"
>
<BasicIcon icon="vuesax-linear:logout" size="14" color="#dc2626" />
<span>خروج از حساب کاربری</span>
</button>
</div>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import BaseDatePicker from '@/components/BasicPacks/BaseDatePicker'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useUserStore } from '@/store/userStore'
import { updateMe } from '@/api/me'
import { getToken } from '@/utils'
import { isValidPersian, requireNationalCode, required } from '@/utils/inputRules'
import { normalizeAuthFormFields } from '@/utils/persianDigits'
import { performUserLogout } from '@/utils/authSession'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { isCustomerIdentityVerified } from '@/utils/customerIdentity'
import { APP_Z_MODAL, getModalContainer } from '@/utils/zIndex'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isDesktop = useDesktopBreakpoint()
const formRef = ref<FormInstance>()
const loading = ref(false)
const checkingStatus = ref(false)
const isEditing = ref(false)
const formState = reactive({
fname: '',
lname: '',
n_code: '',
birth_date: '',
city: '',
})
const modalWidth = computed(() => (isDesktop.value ? '460px' : '92vw'))
const modalWidth = computed(() => (isDesktop.value ? '420px' : '90vw'))
const requiresKyc = computed(() => {
if (!getToken()) return false
if (route.name === 'login' || route.name === 'tvMonitor' || route.name === 'aboutSoftware') {
if (
route.name === 'login' ||
route.name === 'tvMonitor' ||
route.name === 'aboutSoftware' ||
route.name === 'user'
) {
return false
}
const info = userStore.userInfo
@@ -254,98 +98,20 @@ const requiresKyc = computed(() => {
return !isCustomerIdentityVerified(info.is_legal)
})
const isPendingApproval = computed(() => {
const info = userStore.userInfo
if (!info) return false
return Boolean(
String(info.n_code ?? '').trim() && !isCustomerIdentityVerified(info.is_legal),
)
})
function populateFormFromUser() {
const info = userStore.userInfo
if (!info) return
formState.fname = info.fname || ''
formState.lname = info.lname || ''
formState.n_code = info.n_code || ''
formState.birth_date = info.birth_date || ''
formState.city = info.city || ''
}
watch(
() => userStore.userInfo,
() => {
populateFormFromUser()
},
{ immediate: true },
)
async function onSubmit() {
try {
await formRef.value?.validateFields()
loading.value = true
normalizeAuthFormFields(formState)
const payload: any = {
...userStore.userInfo,
fname: formState.fname,
lname: formState.lname,
n_code: formState.n_code,
birth_date: formState.birth_date,
city: formState.city,
}
const res: any = await updateMe(payload)
await userStore.getMyInfo()
if (isCustomerIdentityVerified(userStore.userInfo?.is_legal)) {
message.success('احراز هویت شما با موفقیت انجام و تأیید شد.')
isEditing.value = false
} else {
isEditing.value = false
message.info(res?.message || 'اطلاعات هویتی شما ثبت شد و در انتظار تأیید مدیریت قرار گرفت.')
}
} catch (error: any) {
const errorMsg = error?.response?.data?.message || error?.message
if (errorMsg) {
message.error(errorMsg)
}
} finally {
loading.value = false
}
}
async function onCheckStatus() {
try {
checkingStatus.value = true
await userStore.getMyInfo()
if (isCustomerIdentityVerified(userStore.userInfo?.is_legal)) {
message.success('حساب کاربری شما تأیید شد!')
} else {
message.info('اطلاعات شما همچنان در صف بررسی و تایید مدیریت است.')
}
} catch (error) {
message.error('خطا در بررسی وضعیت. لطفاً مجدداً تلاش کنید.')
} finally {
checkingStatus.value = false
}
function goToKyc() {
void router.push({ name: 'user', query: { showEdit: 'true' } })
}
async function onLogout() {
await performUserLogout()
}
onMounted(() => {
if (getToken() && !userStore.userInfo) {
void userStore.getMyInfo()
}
})
</script>
<style lang="less">
.required-kyc-modal-wrap {
.ant-modal-content {
color: #1e293b;
border-radius: 20px;
padding: 24px 20px;
}
}
</style>
@@ -354,14 +120,4 @@ onMounted(() => {
.kyc-modal-content {
font-family: inherit;
}
:deep(.ant-form-item) {
margin-bottom: 12px;
}
:deep(.ant-form-item-label > label) {
font-size: 13px;
font-weight: 500;
color: #334155;
}
</style>
@@ -7,6 +7,16 @@
@finish="ok"
>
<div :class="scrollClass">
<div
v-if="userFormState.is_legal == 0"
class="mb-3 p-3 bg-amber-50/80 rounded-xl border border-amber-200/90 text-xs text-amber-900 flex items-start gap-2"
>
<BasicIcon icon="vuesax-linear:info-circle" size="18" color="#d97706" class="shrink-0 mt-0.5" />
<span class="leading-5">
جهت تکمیل احراز هویت، لطفاً کد ملی منطبق با شماره همراه خود را وارد فرمایید. اطلاعات شما به‌صورت آنی استعلام خواهد شد.
</span>
</div>
<a-form-item :rules="[isValidPersian()]" label="نام" name="fname">
<BasicInput v-model:value="userFormState.fname" />
</a-form-item>
@@ -45,6 +55,7 @@
<script lang="ts" setup>
import { computed } from 'vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import BaseDatePicker from '@/components/BasicPacks/BaseDatePicker'
import { useUserPageContext } from '../composables/useUserPage'
+26 -10
View File
@@ -8,6 +8,7 @@ import {
} from '@/utils/inputRules'
import type { Password, User } from '@/models/login'
import router from '@/router'
import { useRoute } from 'vue-router'
import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { updateMe, updatePass } from '@/api/me'
@@ -23,6 +24,7 @@ import { performUserLogout } from '@/utils/authSession'
import { useSettingsStore } from '@/store/settingsStore'
import { useTrackingSystemsFeature } from '@/composables/useTrackingSystemsFeature'
import { openGoldDayCalculator } from '@/composables/useGoldDayCalculator'
import { isCustomerIdentityVerified } from '@/utils/customerIdentity'
import { isUserSupportChatEnabled, unreadSupportCount } from '@/utils/chat'
import { resetHomePageData } from '../../home/composables/useHomePageData'
import { resetWalletPageData } from '../../wallet/composables/useWalletPageData'
@@ -256,6 +258,8 @@ export function useUserPage() {
void shareReferralText(val, getReferralShareTitle(nameProject))
}
const route = useRoute()
function ok() {
formRef.value?.validateFields().then(async () => {
try {
@@ -265,11 +269,17 @@ export function useUserPage() {
await userStore.getMyInfo()
localAvatarUrl.value = getUserAvatarUrl(userStore.userInfo)
editPassVisible.value = false
message.success(
res?.message || 'اطلاعات شما با موفقیت به روز رسانی شد',
)
} catch (e) {
console.log(e)
if (isCustomerIdentityVerified(userStore.userInfo?.is_legal)) {
message.success('احراز هویت شما با موفقیت انجام و تأیید شد.')
} else {
message.success(
res?.message || 'اطلاعات شما با موفقیت به روز رسانی شد',
)
}
} catch (e: any) {
console.error(e)
const errMsg = e?.response?.data?.message || 'خطا در ثبت اطلاعات'
message.error(errMsg)
} finally {
loading.value = false
}
@@ -303,18 +313,24 @@ export function useUserPage() {
await performUserLogout()
}
onMounted(() => {
setForm()
if (userStore.userInfo?.is_legal === 0) {
function checkOpenEditSheet() {
const isUnverified = !isCustomerIdentityVerified(userStore.userInfo?.is_legal)
if (isUnverified || route?.query?.showEdit) {
editPassVisible.value = true
}
}
onMounted(() => {
setForm()
checkOpenEditSheet()
})
watch(
() => userStore.userInfo,
(info) => {
() => [userStore.userInfo, route?.query?.showEdit],
([info]) => {
if (!info) return
setForm()
checkOpenEditSheet()
},
{ immediate: true },
)