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
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:
@@ -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">
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user