From 76acf8d1d3a61792165b8bf5dee0345ede6db4eb Mon Sep 17 00:00:00 2001 From: Mahdi Date: Mon, 14 Sep 2026 19:19:09 +0330 Subject: [PATCH] Creating a mobile menu at the bottom of the app --- apps/api/src/users/dto.ts | 9 +- apps/api/src/users/users.service.ts | 58 ++- apps/web/src/components/QuickCreate.vue | 9 +- apps/web/src/components/nav/NavHubPanel.vue | 2 +- apps/web/src/layouts/AppLayout.vue | 447 +++++++++++++++++--- apps/web/src/views/TeamSettingsView.vue | 48 ++- packages/shared/src/index.ts | 3 +- 7 files changed, 487 insertions(+), 89 deletions(-) diff --git a/apps/api/src/users/dto.ts b/apps/api/src/users/dto.ts index 950daa4..5aca532 100644 --- a/apps/api/src/users/dto.ts +++ b/apps/api/src/users/dto.ts @@ -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) diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 9bc88f0..36596b5 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -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), diff --git a/apps/web/src/components/QuickCreate.vue b/apps/web/src/components/QuickCreate.vue index bc2d1c7..73ef15f 100644 --- a/apps/web/src/components/QuickCreate.vue +++ b/apps/web/src/components/QuickCreate.vue @@ -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, + ), }; } diff --git a/apps/web/src/components/nav/NavHubPanel.vue b/apps/web/src/components/nav/NavHubPanel.vue index 926f393..2bd8c7d 100644 --- a/apps/web/src/components/nav/NavHubPanel.vue +++ b/apps/web/src/components/nav/NavHubPanel.vue @@ -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); } diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 0d28bd1..b00d83a 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -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(() => { 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([]); let pollTimer: ReturnType | 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); }); @@ -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; + } +} + + + diff --git a/apps/web/src/views/TeamSettingsView.vue b/apps/web/src/views/TeamSettingsView.vue index 1fd09c8..b49bc1e 100644 --- a/apps/web/src/views/TeamSettingsView.vue +++ b/apps/web/src/views/TeamSettingsView.vue @@ -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>('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);