Creating a mobile menu at the bottom of the app

This commit is contained in:
2026-09-14 19:19:09 +03:30
parent f176fde1eb
commit 76acf8d1d3
7 changed files with 487 additions and 89 deletions
+6 -3
View File
@@ -1,9 +1,9 @@
import { Type } from 'class-transformer';
import {
IsEmail,
IsIn,
IsNumber,
IsString,
Matches,
Max,
MaxLength,
Min,
@@ -14,8 +14,11 @@ const INVITE_ROLES = ['admin', 'sales', 'support'] as const;
const ALL_ROLES = ['owner', 'admin', 'sales', 'support'] as const;
export class InviteUserDto {
@IsEmail()
email!: string;
@IsString()
@Matches(/^09\d{9}$|^\+98\d{10}$|^98\d{10}$|^9\d{9}$/, {
message: 'شماره موبایل معتبر نیست',
})
mobile!: string;
@IsString()
@MinLength(2)
+39 -19
View File
@@ -8,8 +8,13 @@ import {
import type { InviteUserRequest, OrgUserDto, UserRole } from '@mento/shared';
import * as bcrypt from 'bcryptjs';
import { Prisma } from '@prisma/client';
import { normalizeMobile } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
function inviteEmailFromMobile(mobile: string): string {
return `${mobile}@users.mento.local`;
}
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
@@ -31,30 +36,43 @@ export class UsersService {
throw new ForbiddenException('فقط مالک یا مدیر می‌تواند کاربر اضافه کند');
}
const email = dto.email.trim().toLowerCase();
const existing = await this.prisma.user.findFirst({
where: { organizationId, email },
});
if (existing) {
throw new ConflictException('این ایمیل در سازمان شما وجود دارد');
const mobile = normalizeMobile(dto.mobile);
if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const global = await this.prisma.user.findFirst({ where: { email } });
if (global) {
throw new ConflictException('این ایمیل قبلاً در سیستم ثبت شده است');
const existingMobile = await this.prisma.user.findFirst({ where: { mobile } });
if (existingMobile) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
const email = inviteEmailFromMobile(mobile);
const existingEmail = await this.prisma.user.findFirst({
where: { organizationId, email },
});
if (existingEmail) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
const passwordHash = await bcrypt.hash(dto.password, 10);
const row = await this.prisma.user.create({
data: {
organizationId,
email,
fullName: dto.fullName.trim(),
passwordHash,
role: dto.role,
},
});
return this.toDto(row);
try {
const row = await this.prisma.user.create({
data: {
organizationId,
email,
mobile,
fullName: dto.fullName.trim(),
passwordHash,
role: dto.role,
},
});
return this.toDto(row);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
throw e;
}
}
async updateCommission(
@@ -122,6 +140,7 @@ export class UsersService {
private toDto(row: {
id: string;
email: string;
mobile?: string | null;
fullName: string;
role: UserRole;
commissionPercent?: { toString(): string } | number;
@@ -130,6 +149,7 @@ export class UsersService {
return {
id: row.id,
email: row.email,
mobile: row.mobile ?? null,
fullName: row.fullName,
role: row.role,
commissionPercent: Number(row.commissionPercent ?? 0),
+7 -2
View File
@@ -75,17 +75,22 @@ let dragOffsetX = 0;
let dragOffsetY = 0;
function defaultPos(): FabPos {
const bottomGap = viewport.value.w <= 900 ? FAB_MARGIN + 64 : FAB_MARGIN;
return {
left: FAB_MARGIN,
top: Math.max(FAB_MARGIN, viewport.value.h - FAB_MARGIN - FAB_SIZE),
top: Math.max(FAB_MARGIN, viewport.value.h - bottomGap - FAB_SIZE),
};
}
function clampPos(pos: FabPos): FabPos {
const margin = 8;
const bottomGap = viewport.value.w <= 900 ? 64 : 0;
return {
left: Math.min(Math.max(margin, pos.left), viewport.value.w - FAB_SIZE - margin),
top: Math.min(Math.max(margin, pos.top), viewport.value.h - FAB_SIZE - margin),
top: Math.min(
Math.max(margin, pos.top),
viewport.value.h - FAB_SIZE - margin - bottomGap,
),
};
}
+1 -1
View File
@@ -262,7 +262,7 @@ onUnmounted(() => {
width: 100%;
max-height: min(86dvh, calc(100dvh - 3.5rem));
overflow: auto;
padding: 0.85rem 0.9rem calc(0.9rem + env(safe-area-inset-bottom));
padding: 0.85rem 0.9rem 0.9rem;
border-radius: 24px 24px 0 0;
box-shadow: 0 -12px 40px rgba(15, 23, 42, 0.16);
}
+396 -51
View File
@@ -41,6 +41,9 @@ function pathIn(prefixes: string[]) {
);
}
const dashboardActive = computed(() => route.path === '/');
const loyaltyActive = computed(() => pathIn(['/loyalty']));
const reportsActive = computed(() => pathIn(['/reports']));
const customersActive = computed(() => pathIn(['/contacts']));
const salesActive = computed(() =>
pathIn(['/pipeline', '/deals', '/quotes', '/invoices', '/commissions', '/sales/forecast', '/settings/products']),
@@ -65,6 +68,17 @@ const settingsActive = computed(() =>
'/settings/faq',
]),
);
const moreSectionActive = computed(
() =>
notesActive.value ||
supportActive.value ||
loyaltyActive.value ||
reportsActive.value ||
automationsActive.value ||
toolsActive.value ||
settingsActive.value,
);
const openHub = computed<NavHubDef | null>(() => {
if (!openHubKey.value) return null;
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
@@ -118,7 +132,17 @@ function closeHub() {
function onHubClick(key: NavHubKey) {
openHubKey.value = openHubKey.value === key ? null : key;
closeMenu();
}
function openTabHub(key: NavHubKey) {
moreOpen.value = false;
notifOpen.value = false;
onHubClick(key);
}
function openMoreHub(key: NavHubKey) {
notifOpen.value = false;
onHubClick(key);
}
function goHub(to: string) {
@@ -127,17 +151,23 @@ function goHub(to: string) {
void router.push(to);
}
const menuOpen = ref(false);
const moreOpen = ref(false);
const notifOpen = ref(false);
const loading = ref(false);
const unreadCount = ref(0);
const items = ref<NotificationDto[]>([]);
let pollTimer: ReturnType<typeof setInterval> | null = null;
let mobileMq: MediaQueryList | null = null;
const hasUnread = computed(() => unreadCount.value > 0);
const moreTabActive = computed(() => moreOpen.value || moreSectionActive.value);
function closeMore() {
moreOpen.value = false;
}
function closeMenu() {
menuOpen.value = false;
closeMore();
}
function toggleCollapsed() {
@@ -153,6 +183,10 @@ function closeNotif() {
notifOpen.value = false;
}
function onMobileMqChange(event: MediaQueryListEvent) {
if (!event.matches) closeMore();
}
function onLogout() {
closeHub();
closeMenu();
@@ -206,13 +240,18 @@ async function markAll() {
unreadCount.value = 0;
}
function toggleMenu() {
function toggleMore() {
notifOpen.value = false;
menuOpen.value = !menuOpen.value;
if (moreOpen.value) {
moreOpen.value = false;
return;
}
closeHub();
moreOpen.value = true;
}
function toggleNotif() {
menuOpen.value = false;
moreOpen.value = false;
notifOpen.value = !notifOpen.value;
if (notifOpen.value) void refreshNotifs();
}
@@ -235,10 +274,12 @@ function submitSearch() {
}
function syncBodyLock() {
const overlay = moreOpen.value || Boolean(openHubKey.value);
document.body.style.overflow =
menuOpen.value || notifOpen.value || Boolean(openHubKey.value) ? 'hidden' : '';
overlay || notifOpen.value ? 'hidden' : '';
document.body.classList.toggle('mobile-nav-overlay', overlay);
}
watch(menuOpen, syncBodyLock);
watch(moreOpen, syncBodyLock);
watch(notifOpen, syncBodyLock);
watch(openHubKey, syncBodyLock);
watch(
@@ -267,20 +308,23 @@ onMounted(() => {
const meta = profileNameEl.value?.parentElement;
if (meta) profileNameObserver.observe(meta);
}
mobileMq = window.matchMedia('(max-width: 900px)');
mobileMq.addEventListener('change', onMobileMqChange);
});
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer);
document.body.style.overflow = '';
document.body.classList.remove('mobile-nav-overlay');
profileNameObserver?.disconnect();
mobileMq?.removeEventListener('change', onMobileMqChange);
});
</script>
<template>
<div class="shell" :class="{ 'menu-open': menuOpen, collapsed, 'hub-open': Boolean(openHub) }">
<div v-if="menuOpen" class="backdrop" aria-hidden="true" @click="closeMenu" />
<div class="shell" :class="{ collapsed, 'hub-open': Boolean(openHub), 'more-open': moreOpen }">
<aside id="app-sidebar" class="sidebar" :class="{ open: menuOpen, collapsed }">
<aside id="app-sidebar" class="sidebar" :class="{ collapsed }">
<div class="brand-row">
<div class="brand-cluster">
<MentoLogo class="sidebar-logo" :size="32" />
@@ -499,24 +543,34 @@ onUnmounted(() => {
<div class="workspace">
<header class="mobile-topbar mobile-only">
<button
class="round-btn"
type="button"
:aria-expanded="menuOpen"
aria-controls="app-sidebar"
aria-label="منو"
@click="toggleMenu"
>
<span class="burger" :class="{ open: menuOpen }" />
</button>
<div class="top-brand">
<MentoLogo :size="32" :show-text="false" variant="mark" />
<BrandName lang="en" />
</div>
<button class="round-btn" type="button" aria-label="اعلان‌ها" @click="toggleNotif">
<UiIcon name="bell" :size="18" />
<span v-if="hasUnread" class="badge">{{ toFaDigits(unreadCount) }}</span>
</button>
<div class="top-tools">
<button class="round-btn" type="button" aria-label="اعلان‌ها" @click="toggleNotif">
<UiIcon name="bell" :size="18" />
<span v-if="hasUnread" class="badge">{{ toFaDigits(unreadCount) }}</span>
</button>
<RouterLink
class="top-avatar"
:to="{ name: 'profile' }"
:title="auth.user?.fullName || 'پروفایل'"
:aria-label="auth.user?.fullName || 'پروفایل'"
>
<span class="avatar-wrap">
<span class="avatar">
<img
src="/brand/user-avatar.png"
:alt="auth.user?.fullName || 'کاربر'"
width="36"
height="36"
/>
</span>
<i class="online-dot" title="آنلاین" aria-hidden="true" />
</span>
</RouterLink>
</div>
</header>
<header class="desktop-topbar desktop-only">
@@ -587,6 +641,153 @@ onUnmounted(() => {
<button class="hub-dim" type="button" aria-label="بستن منو" @click="closeHub" />
<NavHubPanel :hub="openHub" @close="closeHub" @go="goHub" />
</div>
<nav class="mobile-tabbar mobile-only" aria-label="منوی اصلی">
<RouterLink
to="/"
class="tab"
:class="{ on: dashboardActive && !moreOpen }"
@click="closeHub(); closeMore()"
>
<UiIcon name="home" :size="20" />
<span>داشبورد</span>
</RouterLink>
<button
class="tab"
type="button"
:class="{ on: !moreOpen && (customersActive || openHubKey === 'customers') }"
:aria-expanded="openHubKey === 'customers'"
@click="openTabHub('customers')"
>
<UiIcon name="users" :size="20" />
<span>مشتریان</span>
</button>
<button
class="tab"
type="button"
:class="{ on: !moreOpen && (salesActive || openHubKey === 'sales') }"
:aria-expanded="openHubKey === 'sales'"
@click="openTabHub('sales')"
>
<UiIcon name="briefcase" :size="20" />
<span>فروش</span>
</button>
<button
class="tab"
type="button"
:class="{ on: !moreOpen && (activitiesActive || openHubKey === 'activities') }"
:aria-expanded="openHubKey === 'activities'"
@click="openTabHub('activities')"
>
<span class="tab-ico">
<UiIcon name="check" :size="20" />
<span v-if="activityBadge" class="tab-badge">{{ toFaDigits(activityBadge) }}</span>
</span>
<span>فعالیت‌ها</span>
</button>
<button
class="tab"
type="button"
:class="{ on: moreTabActive }"
:aria-expanded="moreOpen"
aria-controls="mobile-more"
@click="toggleMore"
>
<UiIcon name="grid" :size="20" />
<span>بیشتر</span>
</button>
</nav>
<div
v-if="moreOpen"
id="mobile-more"
class="more-layer mobile-only"
role="dialog"
aria-modal="true"
aria-label="منو"
>
<header class="more-head">
<h2>منو</h2>
<button class="round-btn sm" type="button" aria-label="بستن" @click="closeMore">✕</button>
</header>
<nav class="more-body">
<RouterLink to="/notes" class="nav-item" @click="closeHub(); closeMore()">
<UiIcon name="document" :size="18" />
<span>یادداشت‌ها</span>
</RouterLink>
<button
class="nav-item nav-fold-btn"
type="button"
:class="{
'router-link-active': supportActive || openHubKey === 'support',
'hub-on': openHubKey === 'support',
}"
:aria-expanded="openHubKey === 'support'"
@click="openMoreHub('support')"
>
<UiIcon name="chat" :size="18" />
<span>پشتیبانی</span>
<i class="chev" aria-hidden="true" />
</button>
<RouterLink to="/loyalty" class="nav-item" @click="closeHub(); closeMore()">
<UiIcon name="gift" :size="18" />
<span>باشگاه مشتریان</span>
</RouterLink>
<RouterLink to="/reports" class="nav-item" @click="closeHub(); closeMore()">
<UiIcon name="chart" :size="18" />
<span>گزارش‌ها</span>
</RouterLink>
<button
v-if="canSettings"
class="nav-item nav-fold-btn"
type="button"
:class="{
'router-link-active': automationsActive || openHubKey === 'automations',
'hub-on': openHubKey === 'automations',
}"
:aria-expanded="openHubKey === 'automations'"
@click="openMoreHub('automations')"
>
<UiIcon name="layers" :size="18" />
<span>اتوماسیون</span>
<i class="chev" aria-hidden="true" />
</button>
<button
class="nav-item nav-fold-btn"
type="button"
:class="{
'router-link-active': toolsActive || openHubKey === 'tools',
'hub-on': openHubKey === 'tools',
}"
:aria-expanded="openHubKey === 'tools'"
@click="openMoreHub('tools')"
>
<UiIcon name="apps-plus" :size="18" />
<span>ابزارها</span>
<i class="chev" aria-hidden="true" />
</button>
<button
v-if="canSettings"
class="nav-item nav-fold-btn"
type="button"
:class="{
'router-link-active': settingsActive || openHubKey === 'settings',
'hub-on': openHubKey === 'settings',
}"
:aria-expanded="openHubKey === 'settings'"
@click="openMoreHub('settings')"
>
<UiIcon name="settings" :size="18" />
<span>تنظیمات</span>
<i class="chev" aria-hidden="true" />
</button>
</nav>
<button class="logout-link more-logout" type="button" @click="onLogout">
<UiIcon name="logout" :size="18" />
<span>خروج از حساب</span>
</button>
</div>
<QuickCreate />
</div>
</template>
@@ -1584,8 +1785,35 @@ onUnmounted(() => {
gap: 0.5rem;
}
.top-tools {
display: flex;
align-items: center;
gap: 0.45rem;
}
.top-avatar {
display: grid;
place-items: center;
width: var(--control-h);
height: var(--control-h);
text-decoration: none;
flex-shrink: 0;
}
.top-avatar .avatar-wrap,
.top-avatar .avatar {
width: 36px;
height: 36px;
}
.mobile-tabbar,
.more-layer {
display: none;
}
@media (max-width: 900px) {
.shell {
--mobile-tabbar-h: calc(3.65rem + env(safe-area-inset-bottom, 0px));
grid-template-columns: minmax(0, 1fr);
}
@@ -1597,35 +1825,13 @@ onUnmounted(() => {
display: flex;
}
.backdrop {
display: block;
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
z-index: 34;
}
.sidebar {
position: fixed;
top: 0;
bottom: 0;
inset-inline-start: 0;
width: min(300px, 86vw);
height: 100dvh;
margin: 0;
border-radius: 0;
z-index: 35;
transform: translateX(110%);
transition: transform 0.2s ease;
}
.sidebar.open {
transform: translateX(0);
box-shadow: 0 0 40px rgba(0, 0, 0, 0.15);
display: none;
}
.hub-layer {
z-index: 80;
bottom: var(--mobile-tabbar-h);
}
.hub-layer :deep(.hub) {
@@ -1634,6 +1840,8 @@ onUnmounted(() => {
inset-inline: 0;
width: 100%;
margin: 0;
padding-bottom: 0.9rem;
max-height: min(86dvh, calc(100dvh - var(--mobile-tabbar-h) - 2rem));
animation: hub-sheet-up 0.28s ease;
}
@@ -1647,16 +1855,153 @@ onUnmounted(() => {
}
.content {
padding: 1rem 0.85rem 1.4rem;
padding: 1rem 0.85rem calc(1.25rem + var(--mobile-tabbar-h));
}
.notif-layer {
padding: 4.25rem 0.65rem 1rem;
padding: 4.25rem 0.65rem calc(1rem + var(--mobile-tabbar-h));
place-items: start stretch;
}
.notif-panel {
width: 100%;
}
.mobile-tabbar {
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 45;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
align-items: stretch;
height: var(--mobile-tabbar-h);
min-height: var(--mobile-tabbar-h);
padding: 0.28rem 0.2rem calc(0.28rem + env(safe-area-inset-bottom, 0px));
background: #fff;
border-top: 1px solid #e8eef3;
box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.06);
}
.tab {
appearance: none;
border: 0;
background: transparent;
color: #334155;
display: grid;
justify-items: center;
align-content: center;
gap: 0.12rem;
min-width: 0;
margin: 0.12rem;
padding: 0.28rem 0.1rem;
border-radius: 14px;
font: inherit;
font-size: 0.68rem;
font-weight: 600;
line-height: 1.15;
text-decoration: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s ease, color 0.15s ease;
}
.tab > span:not(.tab-ico) {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab:hover {
background: #f8fafc;
color: #0f172a;
}
.tab:active,
.tab.on {
background: #d8faf3;
color: #0f766e;
}
.tab:active :deep(.ico),
.tab.on :deep(.ico) {
color: #0f766e;
}
.tab-ico {
position: relative;
display: grid;
place-items: center;
}
.tab-badge {
position: absolute;
top: -0.35rem;
inset-inline-start: 0.85rem;
min-width: 1rem;
height: 1rem;
padding: 0 0.2rem;
border-radius: 999px;
background: #14b8a6;
color: #fff;
font-size: 0.62rem;
font-weight: 800;
display: grid;
place-items: center;
border: 2px solid #fff;
}
.more-layer {
position: fixed;
inset: 0 0 var(--mobile-tabbar-h) 0;
z-index: 40;
display: flex;
flex-direction: column;
background: #fff;
}
.more-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: calc(0.75rem + env(safe-area-inset-top, 0px)) 1rem 0.85rem;
background: #fff;
border-bottom: 1px solid #e8eef3;
}
.more-head h2 {
margin: 0;
font-size: 1.05rem;
font-weight: 800;
color: #0f172a;
}
.more-body {
flex: 1;
overflow: auto;
display: grid;
align-content: start;
gap: 0.35rem;
padding: 0.45rem 0.55rem 0.5rem;
-webkit-tap-highlight-color: transparent;
}
.more-body .nav-item:active {
background: #d8faf3;
color: #0f766e;
}
.more-logout {
justify-self: start;
margin: 0.15rem 0.85rem 0.85rem;
}
}
</style>
<style>
body.mobile-nav-overlay .qc {
display: none;
}
</style>
+36 -12
View File
@@ -24,7 +24,7 @@ const businessField = ref('');
const orgSaving = ref(false);
const orgOk = ref('');
const email = ref('');
const mobile = ref('');
const fullName = ref('');
const password = ref('');
const role = ref<Exclude<UserRole, 'owner'>>('sales');
@@ -100,21 +100,21 @@ async function saveOrg() {
}
async function invite() {
if (!email.value.trim() || !fullName.value.trim() || password.value.length < 8) {
error.value = 'نام، ایمیل و رمز حداقل ۸ کاراکتر لازم است.';
if (!mobile.value.trim() || !fullName.value.trim() || password.value.length < 8) {
error.value = 'نام، شماره موبایل و رمز حداقل ۸ کاراکتر لازم است.';
return;
}
saving.value = true;
error.value = '';
try {
const payload: InviteUserRequest = {
email: email.value.trim(),
mobile: mobile.value.trim(),
fullName: fullName.value.trim(),
password: password.value,
role: role.value,
};
await api.post('/users', payload);
email.value = '';
mobile.value = '';
fullName.value = '';
password.value = '';
role.value = 'sales';
@@ -214,8 +214,16 @@ onMounted(load);
<input v-model="fullName" required maxlength="120" />
</label>
<label>
ایمیل
<input v-model="email" type="email" required />
شماره موبایل
<input
v-model="mobile"
type="tel"
inputmode="numeric"
autocomplete="tel"
dir="ltr"
placeholder="0912xxxxxxx"
required
/>
</label>
<label>
رمز عبور موقت
@@ -239,7 +247,7 @@ onMounted(load);
<article v-for="user in users" :key="user.id" class="row">
<div>
<strong>{{ user.fullName }}</strong>
<p class="muted">{{ user.email }}</p>
<p class="muted">{{ user.mobile || user.email }}</p>
</div>
<div class="role-cell">
<label class="comm-field">
@@ -405,22 +413,38 @@ select {
.role-cell {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.55rem;
gap: 0.65rem;
flex-wrap: nowrap;
}
.comm-field {
display: grid;
gap: 0.15rem;
font-size: 0.75rem;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.4rem;
margin: 0;
font-size: 0.85rem;
color: #64748b;
}
.comm-field input,
.role-cell select {
height: var(--control-h);
min-height: var(--control-h);
}
.comm-field input {
width: 4.8rem;
padding: 0 0.45rem;
}
.role-cell select {
width: auto;
min-width: 6.6rem;
}
.role-pill {
background: #f0fdfa;
color: #0f766e;
+2 -1
View File
@@ -40,6 +40,7 @@ export interface AuthUserDto {
export interface OrgUserDto {
id: string;
email: string;
mobile?: string | null;
fullName: string;
role: UserRole;
commissionPercent?: number;
@@ -51,7 +52,7 @@ export interface UpdateUserCommissionRequest {
}
export interface InviteUserRequest {
email: string;
mobile: string;
fullName: string;
password: string;
role: Exclude<UserRole, "owner">;