Promote Leads to a top-level nav hub and expire login OTP after 90s.

Leads items sit under their own hub after Customers; login OTP shows a countdown and returns to the mobile step when time runs out.
This commit is contained in:
2026-09-27 18:00:59 +03:30
parent c079295ff4
commit ac3ffb8ab2
5 changed files with 4019 additions and 3915 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ import {
} from './dto'; } from './dto';
const OTP_LENGTH = 6; const OTP_LENGTH = 6;
const OTP_TTL_SECONDS = 5 * 60; const OTP_TTL_SECONDS = 90;
const AVATAR_MAX_BYTES = 2 * 1024 * 1024; const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
const AVATAR_EXT: Record<string, string> = { const AVATAR_EXT: Record<string, string> = {
'image/jpeg': 'jpg', 'image/jpeg': 'jpg',
+28 -3
View File
@@ -45,10 +45,12 @@ const dashboardActive = computed(() => route.path === '/');
const loyaltyActive = computed(() => pathIn(['/loyalty'])); const loyaltyActive = computed(() => pathIn(['/loyalty']));
const reportsActive = computed(() => pathIn(['/reports'])); const reportsActive = computed(() => pathIn(['/reports']));
const customersActive = computed(() => pathIn(['/contacts'])); const customersActive = computed(() => pathIn(['/contacts']));
const leadsActive = computed(() =>
pathIn(['/leads', '/settings/lead-stages', '/settings/lead-forms']),
);
const salesActive = computed(() => const salesActive = computed(() =>
pathIn([ pathIn([
'/pipeline', '/pipeline',
'/leads',
'/deals', '/deals',
'/quotes', '/quotes',
'/invoices', '/invoices',
@@ -68,7 +70,6 @@ const automationsActive = computed(
); );
const toolsActive = computed(() => const toolsActive = computed(() =>
pathIn([ pathIn([
'/settings/lead-forms',
'/projects', '/projects',
'/project-cards', '/project-cards',
'/inventory', '/inventory',
@@ -82,7 +83,6 @@ const settingsActive = computed(() =>
'/settings/team', '/settings/team',
'/settings/audit-logs', '/settings/audit-logs',
'/settings/public-api', '/settings/public-api',
'/settings/lead-stages',
'/settings/custom-fields', '/settings/custom-fields',
'/settings/currencies', '/settings/currencies',
'/settings/tahesab', '/settings/tahesab',
@@ -432,6 +432,21 @@ onUnmounted(() => {
</button> </button>
</div> </div>
<div class="nav-fold" v-if="hubVisible.leads" :class="{ active: leadsActive || openHubKey === 'leads' }">
<button
class="nav-item nav-fold-btn"
type="button"
title="لیدها"
:class="{ 'router-link-active': leadsActive || openHubKey === 'leads', 'hub-on': openHubKey === 'leads' }"
:aria-expanded="openHubKey === 'leads'"
@click="onHubClick('leads')"
>
<UiIcon name="user-plus" :size="collapsed ? 20 : 18" />
<span>لیدها</span>
<i class="chev" aria-hidden="true" />
</button>
</div>
<div class="nav-fold" v-if="hubVisible.sales" :class="{ active: salesActive || openHubKey === 'sales' }"> <div class="nav-fold" v-if="hubVisible.sales" :class="{ active: salesActive || openHubKey === 'sales' }">
<button <button
class="nav-item nav-fold-btn" class="nav-item nav-fold-btn"
@@ -725,6 +740,16 @@ onUnmounted(() => {
<UiIcon name="users" :size="20" /> <UiIcon name="users" :size="20" />
<span>مشتریان</span> <span>مشتریان</span>
</button> </button>
<button
class="tab"
type="button"
:class="{ on: !moreOpen && (leadsActive || openHubKey === 'leads') }"
:aria-expanded="openHubKey === 'leads'"
@click="openTabHub('leads')"
>
<UiIcon name="user-plus" :size="20" />
<span>لیدها</span>
</button>
<button <button
class="tab" class="tab"
type="button" type="button"
+50 -36
View File
@@ -2,6 +2,7 @@ import type { OrgModuleKey } from '@mento/shared';
export type NavHubKey = export type NavHubKey =
| 'customers' | 'customers'
| 'leads'
| 'sales' | 'sales'
| 'activities' | 'activities'
| 'support' | 'support'
@@ -97,6 +98,53 @@ export const NAV_HUBS: NavHubDef[] = [
}, },
], ],
}, },
{
key: 'leads',
title: 'لیدها',
desc: 'مخاطبین بالقوه قبل از تبدیل به مشتری',
icon: 'user-plus',
tint: 'sky',
shortcuts: [
{ to: '/leads', title: 'فهرست لیدها', icon: 'users', module: 'leads' },
{ to: '/leads/board', title: 'کاریز لیدها', icon: 'filter', module: 'leads' },
],
cards: [
{
to: '/leads',
title: 'فهرست لیدها',
desc: 'جستجو، فیلتر، تخصیص و ایمپورت اکسل',
icon: 'users',
tint: 'sky',
module: 'leads',
},
{
to: '/leads/board',
title: 'کاریز لیدها',
desc: 'نمای کانبان و حرکت بین مراحل',
icon: 'filter',
tint: 'violet',
module: 'leads',
},
{
to: '/settings/lead-stages',
title: 'مراحل و وضعیت‌ها',
desc: 'تعریف وضعیت‌ها و ستون‌های کاریز لید',
icon: 'layers',
tint: 'peach',
admin: true,
module: 'leads',
},
{
to: '/settings/lead-forms',
title: 'فرم‌ساز سرنخ',
desc: 'لینک عمومی، iframe و QR برای جذب سرنخ',
icon: 'document',
tint: 'mint',
admin: true,
module: 'lead_forms',
},
],
},
{ {
key: 'sales', key: 'sales',
title: 'فروش', title: 'فروش',
@@ -111,27 +159,11 @@ export const NAV_HUBS: NavHubDef[] = [
{ {
to: '/pipeline', to: '/pipeline',
title: 'کاریز فروش', title: 'کاریز فروش',
desc: 'سرنخ‌ها، معاملات و مراحل فروش', desc: 'معاملات و مراحل فروش',
icon: 'filter', icon: 'filter',
tint: 'mint', tint: 'mint',
module: 'pipeline', module: 'pipeline',
}, },
{
to: '/leads',
title: 'لیدها',
desc: 'فهرست سرنخ‌ها، تخصیص و پیگیری',
icon: 'user-plus',
tint: 'sky',
module: 'leads',
},
{
to: '/leads/board',
title: 'کاریز لیدها',
desc: 'نمای کانبان مراحل لید',
icon: 'filter',
tint: 'violet',
module: 'leads',
},
{ {
to: '/settings/products', to: '/settings/products',
title: 'محصولات و خدمات', title: 'محصولات و خدمات',
@@ -284,7 +316,7 @@ export const NAV_HUBS: NavHubDef[] = [
{ {
key: 'tools', key: 'tools',
title: 'ابزارها', title: 'ابزارها',
desc: 'فرم‌ها، پروژه، انبار و اسناد مالی', desc: 'پروژه، انبار و اسناد مالی',
icon: 'apps-plus', icon: 'apps-plus',
tint: 'violet', tint: 'violet',
shortcuts: [ shortcuts: [
@@ -293,15 +325,6 @@ export const NAV_HUBS: NavHubDef[] = [
{ to: '/reservations', title: 'نوبت جدید', icon: 'calendar', module: 'reservations' }, { to: '/reservations', title: 'نوبت جدید', icon: 'calendar', module: 'reservations' },
], ],
cards: [ cards: [
{
to: '/settings/lead-forms',
title: 'فرم‌ساز سرنخ',
desc: 'فرم عمومی برای جذب سرنخ تازه',
icon: 'layers',
tint: 'mint',
admin: true,
module: 'lead_forms',
},
{ {
to: '/projects', to: '/projects',
title: 'کاریز پروژه', title: 'کاریز پروژه',
@@ -403,15 +426,6 @@ export const NAV_HUBS: NavHubDef[] = [
module: 'public_api', module: 'public_api',
admin: true, admin: true,
}, },
{
to: '/settings/lead-stages',
title: 'تنظیمات مراحل لید',
desc: 'وضعیت‌ها و برچسب‌های کاریز لید',
icon: 'layers',
tint: 'violet',
admin: true,
module: 'leads',
},
{ {
to: '/settings/custom-fields', to: '/settings/custom-fields',
title: 'فیلدهای سفارشی', title: 'فیلدهای سفارشی',
+70 -6
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, onBeforeUnmount, ref } from 'vue';
import { useRouter, useRoute, RouterLink } from 'vue-router'; import { useRouter, useRoute, RouterLink } from 'vue-router';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import AuthBrandPanel from '@/components/auth/AuthBrandPanel.vue'; import AuthBrandPanel from '@/components/auth/AuthBrandPanel.vue';
@@ -10,6 +10,7 @@ import { USER_ROLE_LABELS } from '@/lib/roles';
import { brand } from '@mento/shared'; import { brand } from '@mento/shared';
import BrandName from '@/components/brand/BrandName.vue'; import BrandName from '@/components/brand/BrandName.vue';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import { toFaDigits } from '@/lib/jalali';
const auth = useAuthStore(); const auth = useAuthStore();
const router = useRouter(); const router = useRouter();
@@ -29,12 +30,42 @@ const challenge = ref<LoginChallengeResponse | null>(null);
const pendingChoice = ref<LoginOrganizationChoiceResponse | null>(null); const pendingChoice = ref<LoginOrganizationChoiceResponse | null>(null);
const error = ref(''); const error = ref('');
const loading = ref(false); const loading = ref(false);
const otpSecondsLeft = ref(0);
let otpTimer: ReturnType<typeof setInterval> | null = null;
const otpHint = computed(() => { const otpHint = computed(() => {
const dest = challenge.value?.maskedDestination ?? ''; const dest = challenge.value?.maskedDestination ?? '';
return dest ? `کد ارسال‌شده به ${dest} را وارد کنید` : 'کد یکبارمصرف را وارد کنید'; return dest ? `کد ارسال‌شده به ${dest} را وارد کنید` : 'کد یکبارمصرف را وارد کنید';
}); });
const otpTimerLabel = computed(() => {
const s = Math.max(0, otpSecondsLeft.value);
const mm = Math.floor(s / 60);
const ss = s % 60;
return `${toFaDigits(mm)}:${toFaDigits(String(ss).padStart(2, '0'))}`;
});
function clearOtpTimer() {
if (otpTimer) {
clearInterval(otpTimer);
otpTimer = null;
}
otpSecondsLeft.value = 0;
}
function startOtpTimer(seconds: number) {
clearOtpTimer();
otpSecondsLeft.value = Math.max(1, Math.round(seconds) || 90);
otpTimer = setInterval(() => {
if (otpSecondsLeft.value <= 1) {
clearOtpTimer();
backToOtpMobile('زمان کد به پایان رسید. دوباره درخواست دهید.');
return;
}
otpSecondsLeft.value -= 1;
}, 1000);
}
function apiError(e: unknown, fallback: string) { function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) { if (axios.isAxiosError(e)) {
if (!e.response) { if (!e.response) {
@@ -79,6 +110,7 @@ async function submitOtpMobile() {
challenge.value = await auth.requestLoginOtp(mobile.value.trim()); challenge.value = await auth.requestLoginOtp(mobile.value.trim());
if (challenge.value.devCode) otpCode.value = challenge.value.devCode; if (challenge.value.devCode) otpCode.value = challenge.value.devCode;
otpStep.value = 'code'; otpStep.value = 'code';
startOtpTimer(challenge.value.expiresInSeconds ?? 90);
} catch (e) { } catch (e) {
error.value = apiError(e, 'ارسال کد ناموفق بود'); error.value = apiError(e, 'ارسال کد ناموفق بود');
} finally { } finally {
@@ -88,10 +120,15 @@ async function submitOtpMobile() {
async function submitOtpCode() { async function submitOtpCode() {
if (!challenge.value) return; if (!challenge.value) return;
if (otpSecondsLeft.value <= 0) {
backToOtpMobile('زمان کد به پایان رسیده است.');
return;
}
error.value = ''; error.value = '';
loading.value = true; loading.value = true;
try { try {
const result = await auth.verifyLoginOtp(challenge.value.challengeId, otpCode.value.trim()); const result = await auth.verifyLoginOtp(challenge.value.challengeId, otpCode.value.trim());
clearOtpTimer();
await afterAuth(result); await afterAuth(result);
} catch (e) { } catch (e) {
error.value = apiError(e, 'تأیید کد ناموفق بود'); error.value = apiError(e, 'تأیید کد ناموفق بود');
@@ -102,17 +139,19 @@ async function submitOtpCode() {
function switchMode(next: 'password' | 'otp') { function switchMode(next: 'password' | 'otp') {
mode.value = next; mode.value = next;
clearOtpTimer();
otpStep.value = 'mobile'; otpStep.value = 'mobile';
challenge.value = null; challenge.value = null;
otpCode.value = ''; otpCode.value = '';
error.value = ''; error.value = '';
} }
function backToOtpMobile() { function backToOtpMobile(message?: string) {
clearOtpTimer();
otpStep.value = 'mobile'; otpStep.value = 'mobile';
otpCode.value = ''; otpCode.value = '';
challenge.value = null; challenge.value = null;
error.value = ''; error.value = message ?? '';
} }
async function pickOrganization(organizationId: string) { async function pickOrganization(organizationId: string) {
@@ -133,6 +172,10 @@ function cancelOrgChoice() {
pendingChoice.value = null; pendingChoice.value = null;
error.value = ''; error.value = '';
} }
onBeforeUnmount(() => {
clearOtpTimer();
});
</script> </script>
<template> <template>
@@ -286,8 +329,11 @@ function cancelOrgChoice() {
</template> </template>
<template v-else> <template v-else>
<button type="button" class="back" @click="backToOtpMobile">بازگشت</button> <button type="button" class="back" @click="backToOtpMobile()">بازگشت</button>
<p class="otp-lead">{{ otpHint }}</p> <p class="otp-lead">{{ otpHint }}</p>
<p class="otp-timer" :class="{ urgent: otpSecondsLeft <= 15 }" aria-live="polite">
اعتبار کد: <strong>{{ otpTimerLabel }}</strong>
</p>
<p v-if="challenge?.devCode" class="dev-hint"> <p v-if="challenge?.devCode" class="dev-hint">
کد توسعه: <strong>{{ challenge.devCode }}</strong> کد توسعه: <strong>{{ challenge.devCode }}</strong>
</p> </p>
@@ -309,7 +355,7 @@ function cancelOrgChoice() {
</div> </div>
</div> </div>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
<button class="btn submit" type="submit" :disabled="loading"> <button class="btn submit" type="submit" :disabled="loading || otpSecondsLeft <= 0">
{{ loading ? 'در حال تأیید…' : 'تأیید و ورود' }} {{ loading ? 'در حال تأیید…' : 'تأیید و ورود' }}
</button> </button>
</form> </form>
@@ -566,11 +612,29 @@ h1 {
} }
.otp-lead { .otp-lead {
margin: 0 0 1rem; margin: 0 0 0.5rem;
color: var(--muted); color: var(--muted);
line-height: 1.7; line-height: 1.7;
} }
.otp-timer {
margin: 0 0 1rem;
color: var(--text);
font-size: 0.92rem;
font-variant-numeric: tabular-nums;
direction: ltr;
text-align: right;
}
.otp-timer strong {
font-weight: 700;
letter-spacing: 0.04em;
}
.otp-timer.urgent {
color: #b91c1c;
}
.dev-hint { .dev-hint {
margin: -0.4rem 0 1rem; margin: -0.4rem 0 1rem;
padding: 0.65rem 0.8rem; padding: 0.65rem 0.8rem;
+4 -3
View File
@@ -204,11 +204,11 @@ export const ORG_MODULE_GROUPS: Array<{
keys: OrgModuleKey[]; keys: OrgModuleKey[];
}> = [ }> = [
{ title: "مشتریان", keys: ["customers"] }, { title: "مشتریان", keys: ["customers"] },
{ title: "لیدها", keys: ["leads", "lead_forms"] },
{ {
title: "فروش", title: "فروش",
keys: [ keys: [
"pipeline", "pipeline",
"leads",
"products", "products",
"invoices", "invoices",
"quotes", "quotes",
@@ -229,7 +229,6 @@ export const ORG_MODULE_GROUPS: Array<{
{ {
title: "ابزارها", title: "ابزارها",
keys: [ keys: [
"lead_forms",
"project_boards", "project_boards",
"inventory", "inventory",
"cheques", "cheques",
@@ -3662,7 +3661,9 @@ export interface ReservationListResponse {
total: number; total: number;
} }
/* ——— ãÏíÑíÊ áíÏåÇ ——— */
/* ——— مدیریت لیدها ——— */
export type LeadAssignRule = "round_robin" | "equal_split"; export type LeadAssignRule = "round_robin" | "equal_split";