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';
|
'use client';
|
||||||
|
|
||||||
import { Menu, X } from 'lucide-react';
|
import { LogOut, Menu, User as UserIcon, X } from 'lucide-react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { CompareFloatingBar } from '@/components/comparison/compare-floating-bar';
|
import { CompareFloatingBar } from '@/components/comparison/compare-floating-bar';
|
||||||
import { NotificationBell } from '@/components/notifications/notification-bell';
|
import { NotificationBell } from '@/components/notifications/notification-bell';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
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 { useAuthStore } from '@/lib/auth-store';
|
||||||
import { cn } from '@/lib/utils';
|
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 }) {
|
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { user } = useAuthStore();
|
const router = useRouter();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
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 = [
|
const navLinks = [
|
||||||
{
|
{
|
||||||
href: '/' as const,
|
href: '/' as const,
|
||||||
@@ -92,8 +119,10 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
|
|||||||
{user.fullName}
|
{user.fullName}
|
||||||
</span>
|
</span>
|
||||||
{/* Dashboard button is desktop-only; mobile users reach it via the hamburger menu */}
|
{/* Dashboard button is desktop-only; mobile users reach it via the hamburger menu */}
|
||||||
<Link href="/dashboard" className="hidden sm:inline-flex">
|
<Link href={dashboardHref} className="hidden sm:inline-flex">
|
||||||
<Button size="sm">{t('common.dashboard')}</Button>
|
<Button size="sm">
|
||||||
|
{user.role === 'ADMIN' ? t('common.admin') : t('common.dashboard')}
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -147,10 +176,70 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
|
|||||||
<div className="mt-3 space-y-2 border-t pt-3">
|
<div className="mt-3 space-y-2 border-t pt-3">
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
<p className="px-3 text-sm text-muted-foreground">{user.fullName}</p>
|
{/* User card: avatar (or initials) + name + email/phone + role */}
|
||||||
<Link href="/dashboard" onClick={() => setMobileMenuOpen(false)}>
|
<div className="flex items-center gap-3 px-2 py-1">
|
||||||
<Button size="sm" className="w-full">{t('common.dashboard')}</Button>
|
{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>
|
||||||
|
|
||||||
|
<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">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
|
"profile": "Profile",
|
||||||
"errorCode": "Error code: {code}",
|
"errorCode": "Error code: {code}",
|
||||||
"retriedCount": "Retried {count} times",
|
"retriedCount": "Retried {count} times",
|
||||||
"allRightsReserved": "© 2026 GoodGo. All rights reserved.",
|
"allRightsReserved": "© 2026 GoodGo. All rights reserved.",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"logout": "Đăng xuất",
|
"logout": "Đăng xuất",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"dashboard": "Bảng điều khiển",
|
"dashboard": "Bảng điều khiển",
|
||||||
|
"profile": "Hồ sơ",
|
||||||
"errorCode": "Mã lỗi: {code}",
|
"errorCode": "Mã lỗi: {code}",
|
||||||
"retriedCount": "Đã thử lại {count} lần",
|
"retriedCount": "Đã thử lại {count} lần",
|
||||||
"allRightsReserved": "© 2026 GoodGo. Tất cả quyền được bảo lưu.",
|
"allRightsReserved": "© 2026 GoodGo. Tất cả quyền được bảo lưu.",
|
||||||
|
|||||||
Reference in New Issue
Block a user