feat(web): richer auth block in PublicLayout mobile menu + role-aware dashboard link
Some checks failed
E2E Tests / Playwright E2E (push) Failing after 22s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
Security Scanning / Trivy Scan — Web Image (push) Failing after 50s
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 3s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 47s
Deploy / Build API Image (push) Failing after 15s
Deploy / Build Web Image (push) Failing after 12s
Deploy / Build AI Services Image (push) Failing after 12s
Security Scanning / Trivy Scan — API Image (push) Failing after 57s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 46s
Security Scanning / Trivy Filesystem Scan (push) Failing after 28s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped

The mobile menu's logged-in footer previously showed just the user's
full name and a generic "Bảng điều khiển" button that always pointed
at /dashboard, even for ADMIN users whose real console lives at
/admin. The desktop header had the same ADMIN mis-route.

Mobile menu footer — now a compact account card:
- Avatar (avatarUrl) or initials fallback in a primary-tinted circle
  (getInitials handles single-word and multi-word names).
- Full name (truncated).
- Secondary line: email if present, otherwise phone.
- Role badge via ROLE_LABELS (Quản trị viên / Đại lý / Người bán /
  Người mua) — skipped when the role string isn't in the map.
- Primary CTA: routes to /admin for ADMIN, /dashboard otherwise.
  Button label flips to "Admin" vs "Bảng điều khiển" accordingly.
- Secondary CTA: /dashboard/profile with UserIcon.
- Tertiary: destructive-styled Đăng xuất button that calls the
  auth-store logout() action then router.push('/').

Desktop header: Dashboard button on the right now uses the same
dashboardHref + label computation so ADMIN users land on /admin.

i18n: added common.profile in both vi.json and en.json.

Verified at 375×812 (mobile preset): card + 3 buttons render within
viewport, initials bubble shows "HH" in red, role badge reads
"Quản trị viên".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-19 09:24:15 +07:00
parent b93c62372d
commit d5915b8655
3 changed files with 99 additions and 8 deletions

View File

@@ -1,23 +1,50 @@
'use client';
import { Menu, X } from 'lucide-react';
import { LogOut, Menu, User as UserIcon, X } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { CompareFloatingBar } from '@/components/comparison/compare-floating-bar';
import { NotificationBell } from '@/components/notifications/notification-bell';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { Link } from '@/i18n/navigation';
import { Link, useRouter } from '@/i18n/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { cn } from '@/lib/utils';
/** Map backend role strings to Vietnamese labels shown in the nav. */
const ROLE_LABELS: Record<string, string> = {
ADMIN: 'Quản trị viên',
AGENT: 'Đại lý',
SELLER: 'Người bán',
BUYER: 'Người mua',
};
/** First+last initials fallback for when avatarUrl is null. */
function getInitials(fullName: string): string {
const parts = fullName.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase();
}
export default function PublicLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { user } = useAuthStore();
const router = useRouter();
const { user, logout } = useAuthStore();
const t = useTranslations();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
/** Route the Dashboard CTA to the correct surface based on role. */
const dashboardHref = user?.role === 'ADMIN' ? '/admin' : '/dashboard';
const handleLogout = async () => {
setMobileMenuOpen(false);
await logout();
router.push('/');
};
const navLinks = [
{
href: '/' as const,
@@ -92,8 +119,10 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
{user.fullName}
</span>
{/* Dashboard button is desktop-only; mobile users reach it via the hamburger menu */}
<Link href="/dashboard" className="hidden sm:inline-flex">
<Button size="sm">{t('common.dashboard')}</Button>
<Link href={dashboardHref} className="hidden sm:inline-flex">
<Button size="sm">
{user.role === 'ADMIN' ? t('common.admin') : t('common.dashboard')}
</Button>
</Link>
</>
) : (
@@ -147,10 +176,70 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
<div className="mt-3 space-y-2 border-t pt-3">
{user ? (
<>
<p className="px-3 text-sm text-muted-foreground">{user.fullName}</p>
<Link href="/dashboard" onClick={() => setMobileMenuOpen(false)}>
<Button size="sm" className="w-full">{t('common.dashboard')}</Button>
{/* User card: avatar (or initials) + name + email/phone + role */}
<div className="flex items-center gap-3 px-2 py-1">
{user.avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={user.avatarUrl}
alt=""
className="h-10 w-10 shrink-0 rounded-full border object-cover"
/>
) : (
<div
aria-hidden="true"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
>
{getInitials(user.fullName)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user.fullName}</p>
<p className="truncate text-xs text-muted-foreground">
{user.email ?? user.phone}
</p>
</div>
{ROLE_LABELS[user.role] ? (
<Badge variant="outline" className="shrink-0 text-[10px]">
{ROLE_LABELS[user.role]}
</Badge>
) : null}
</div>
<Link
href={dashboardHref}
onClick={() => setMobileMenuOpen(false)}
className="block"
>
<Button size="sm" className="w-full">
{user.role === 'ADMIN' ? t('common.admin') : t('common.dashboard')}
</Button>
</Link>
<Link
href="/dashboard/profile"
onClick={() => setMobileMenuOpen(false)}
className="block"
>
<Button
size="sm"
variant="outline"
className="w-full justify-center gap-2"
>
<UserIcon className="h-4 w-4" aria-hidden="true" />
{t('common.profile')}
</Button>
</Link>
<Button
size="sm"
variant="ghost"
className="w-full justify-center gap-2 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={handleLogout}
>
<LogOut className="h-4 w-4" aria-hidden="true" />
{t('common.logout')}
</Button>
</>
) : (
<div className="flex gap-2">

View File

@@ -17,6 +17,7 @@
"logout": "Logout",
"admin": "Admin",
"dashboard": "Dashboard",
"profile": "Profile",
"errorCode": "Error code: {code}",
"retriedCount": "Retried {count} times",
"allRightsReserved": "© 2026 GoodGo. All rights reserved.",

View File

@@ -17,6 +17,7 @@
"logout": "Đăng xuất",
"admin": "Admin",
"dashboard": "Bảng điều khiển",
"profile": "Hồ sơ",
"errorCode": "Mã lỗi: {code}",
"retriedCount": "Đã thử lại {count} lần",
"allRightsReserved": "© 2026 GoodGo. Tất cả quyền được bảo lưu.",