Files
goodgo-platform/apps/web/app/[locale]/(dashboard)/layout.tsx
Ho Ngoc Hai d6d7584677 feat(web): wire TickerStrip + status bar role into DashboardLayout (TEC-3047)
- Import TickerStrip vào dashboard layout, truyền vào DashboardLayout.ticker
- Thêm placeholder top-8 quận với TODO comment chờ /analytics/districts API
- Thêm role="status" aria-live="polite" vào status bar div trong DashboardLayout
- 8 Vitest unit tests cho DashboardLayout: role=banner, role=status, ticker,
  sidebar collapse/expand width, main content (tất cả pass)

Note: listings.spec.tsx failure là pre-existing trên HEAD, không liên quan TEC-3047.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 01:47:25 +07:00

342 lines
11 KiB
TypeScript

'use client';
import {
BarChart3,
Bookmark,
Bot,
Building2,
CreditCard,
Factory,
FileText,
Gem,
Home,
List,
LogOut,
MessageSquare,
Moon,
Plus,
Search,
Sun,
Target,
User,
type LucideIcon,
} from 'lucide-react';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { DashboardLayout } from '@/components/design-system/dashboard-layout';
import { CompactHeader } from '@/components/design-system/compact-header';
import { TickerStrip } from '@/components/design-system/ticker-strip';
import type { TickerItem } from '@/components/design-system/ticker-strip';
import { NotificationBell } from '@/components/notifications/notification-bell';
import { useTheme } from '@/components/providers/theme-provider';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { Link } from '@/i18n/navigation';
import { useRouter } from '@/i18n/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { cn } from '@/lib/utils';
interface NavItem {
href: string;
label: string;
icon: LucideIcon;
}
interface NavGroup {
label: string;
items: NavItem[];
}
/** Icon-only sidebar button with tooltip. */
function SidebarNavItem({
item,
active,
onClick,
}: {
item: NavItem;
active: boolean;
onClick?: () => void;
}) {
return (
<Link
href={item.href}
onClick={onClick}
aria-label={item.label}
title={item.label}
className={cn(
'group relative flex h-10 w-10 items-center justify-center rounded-md transition-colors',
active
? 'bg-primary/10 text-primary'
: 'text-foreground-muted hover:bg-background-surface hover:text-foreground',
)}
>
<item.icon className="h-[18px] w-[18px] shrink-0" aria-hidden="true" />
{/* Tooltip */}
<span className="pointer-events-none absolute left-full ml-2 whitespace-nowrap rounded bg-background-elevated px-2 py-1 text-xs text-foreground opacity-0 shadow-elevation-2 transition-opacity group-hover:opacity-100">
{item.label}
</span>
</Link>
);
}
export default function AppDashboardLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const { user, isAuthenticated, isInitialized, logout } = useAuthStore();
const { theme, toggleTheme } = useTheme();
const t = useTranslations();
const [now, setNow] = useState<Date | null>(null);
// Auth guard
useEffect(() => {
if (isInitialized && !isAuthenticated) {
const next = encodeURIComponent(pathname);
router.replace(`/login?next=${next}`);
}
}, [isInitialized, isAuthenticated, pathname, router]);
// Live clock for status bar
useEffect(() => {
setNow(new Date());
const id = setInterval(() => setNow(new Date()), 30_000);
return () => clearInterval(id);
}, []);
if (!isInitialized || !isAuthenticated) {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-foreground-muted">
{t('common.loading')}
</div>
);
}
const role = user?.role;
const isDeveloper = role === 'DEVELOPER';
const isParkOperator = role === 'PARK_OPERATOR';
const showListings = !isDeveloper && !isParkOperator;
const showProjects = !isParkOperator;
const showParks = !isDeveloper;
const navGroups: NavGroup[] = [
{
label: t('dashboard.title'),
items: [
{ href: '/dashboard', label: t('dashboard.title'), icon: Home },
...(showListings
? [
{ href: '/listings', label: t('dashboard.listings'), icon: List },
{ href: '/listings/new', label: t('dashboard.createListing'), icon: Plus },
]
: []),
],
},
{
label: t('dashboard.catalogs'),
items: [
...(showProjects
? [
{
href: '/projects',
label: isDeveloper ? 'Dự án của tôi' : t('dashboard.manageProjects'),
icon: Building2,
},
]
: []),
...(showParks
? [
{
href: '/industrial-parks',
label: isParkOperator ? 'KCN của tôi' : t('dashboard.manageIndustrialParks'),
icon: Factory,
},
]
: []),
],
},
{
label: 'CRM',
items: [
{ href: '/inquiries', label: t('dashboard.inquiries'), icon: MessageSquare },
...(showListings
? [{ href: '/leads', label: t('dashboard.leads'), icon: Target }]
: []),
],
},
...(showListings
? [
{
label: t('dashboard.analytics'),
items: [
{ href: '/analytics', label: t('dashboard.analytics'), icon: BarChart3 },
{ href: '/dashboard/reports', label: t('dashboard.reports'), icon: FileText },
{
href: '/dashboard/saved-searches',
label: t('dashboard.savedSearches'),
icon: Bookmark,
},
{ href: '/dashboard/valuation', label: t('dashboard.aiValuation'), icon: Bot },
],
},
]
: []),
{
label: t('dashboard.profile'),
items: [
{ href: '/dashboard/profile', label: t('dashboard.profile'), icon: User },
...(showListings
? [
{
href: '/dashboard/subscription',
label: t('dashboard.subscription'),
icon: Gem,
},
{
href: '/dashboard/payments',
label: t('dashboard.payments'),
icon: CreditCard,
},
]
: []),
],
},
].filter((g) => g.items.length > 0);
const allNavItems = navGroups.flatMap((g) => g.items);
const isActive = (href: string) =>
pathname === href || (href !== '/dashboard' && pathname.startsWith(href));
// ── Sidebar (icon-only 56px, mobile uses sheet drawer) ──────────────────
const sidebar = (
<div className="flex h-full flex-col items-center gap-1 py-3">
{/* Logo mark */}
<Link
href="/dashboard"
className="mb-2 flex h-10 w-10 items-center justify-center rounded-md text-primary"
aria-label={t('common.goodgo')}
title={t('common.goodgo')}
>
<span className="text-sm font-bold leading-none">GG</span>
</Link>
<div className="h-px w-8 bg-border" />
{/* Nav items */}
<nav className="flex flex-1 flex-col items-center gap-1 pt-2" aria-label={t('nav.dashboardNav')}>
{allNavItems.map((item) => (
<SidebarNavItem key={item.href} item={item} active={isActive(item.href)} />
))}
</nav>
{/* Bottom: logout */}
<div className="flex flex-col items-center gap-1 pb-1">
<button
onClick={() => logout()}
aria-label={t('common.logout')}
title={t('common.logout')}
className="flex h-10 w-10 items-center justify-center rounded-md text-foreground-muted transition-colors hover:bg-background-surface hover:text-foreground"
>
<LogOut className="h-[18px] w-[18px]" aria-hidden="true" />
</button>
</div>
</div>
);
// ── CompactHeader ────────────────────────────────────────────────────────
const header = (
<CompactHeader
logo={
<span className="text-sm font-bold text-primary">{t('common.goodgo')}</span>
}
breadcrumb={
<span className="text-foreground-dim">/</span>
}
search={
<div className="relative">
<Search
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-foreground-dim"
aria-hidden="true"
/>
<Input
type="search"
placeholder="Tìm bất động sản..."
className="h-8 w-full bg-background-surface pl-8 text-sm placeholder:text-foreground-dim focus-visible:ring-primary"
aria-label="Tìm kiếm"
/>
</div>
}
actions={
<>
{user && <NotificationBell />}
<LanguageSwitcher />
<Button
variant="ghost"
size="sm"
onClick={toggleTheme}
aria-label={theme === 'light' ? t('dashboard.darkMode') : t('dashboard.lightMode')}
className="h-8 w-8 p-0 text-foreground-muted hover:text-foreground"
>
{theme === 'light' ? (
<Moon className="h-4 w-4" aria-hidden="true" />
) : (
<Sun className="h-4 w-4" aria-hidden="true" />
)}
</Button>
{user && (
<span
className="hidden max-w-[8rem] truncate text-xs text-foreground-muted lg:inline"
title={user.fullName}
>
{user.fullName}
</span>
)}
</>
}
/>
);
// ── Ticker strip (top 8 quận, placeholder → TODO: /analytics/districts) ───
// TODO: thay thế bằng dữ liệu thực từ /analytics/districts khi API sẵn sàng (TEC-3047)
const tickerItems: TickerItem[] = [
{ id: 'q1', label: 'Quận 1', changePercent: 2.4, direction: 'up' },
{ id: 'q2', label: 'Quận 2', changePercent: -0.8, direction: 'down' },
{ id: 'q3', label: 'Quận 3', changePercent: 1.1, direction: 'up' },
{ id: 'q7', label: 'Quận 7', changePercent: 3.2, direction: 'up' },
{ id: 'binhthanh', label: 'Bình Thạnh', changePercent: 0.0, direction: 'neutral' },
{ id: 'thuduc', label: 'Thủ Đức', changePercent: 1.7, direction: 'up' },
{ id: 'tanbinh', label: 'Tân Bình', changePercent: -1.3, direction: 'down' },
{ id: 'phuninh', label: 'Phú Nhuận', changePercent: 0.5, direction: 'up' },
];
const ticker = <TickerStrip items={tickerItems} />;
// ── Status bar ───────────────────────────────────────────────────────────
const statusBar = (
<>
<span className="inline-flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-signal-up" aria-hidden="true" />
<span>Đã kết nối</span>
</span>
{now && (
<span className="text-foreground-dim">
Cập nhật lúc {now.toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</>
);
return (
<DashboardLayout
sidebar={sidebar}
header={header}
ticker={ticker}
statusBar={statusBar}
sidebarCollapsed
>
{children}
</DashboardLayout>
);
}