feat: portal redesign — shadcn/ui, React Query, Zod, isolated portals
Foundation: - CLAUDE.md with full coding rules (tech stack, patterns, redirect rules) - Install @tanstack/react-query, zod, react-hook-form, shadcn/ui deps - CSS variables design system (Tailwind v4 + tw-animate-css) - Shared components: Button, Input, Label, Card, Badge, Separator, Form - PortalLoginShell: two-column login layout (branding left, form right) - PortalNav: shared sidebar nav used by all portals - ReactQueryProvider in root layout - api-client.ts: typed fetch wrapper (no raw fetch in components) Portal isolation: - Sidebar now returns null for all non-chapter routes; portals own their layout - Admin layout: auth guard + PortalNav (slate accent) - Org layout: auth guard + PortalNav (violet accent) - Member layout: server-side cookie check → MemberNav client component - Chapter admin sidebar: PortalNav (blue accent) Login pages (all use react-hook-form + Zod + PortalLoginShell): - /login → defaults next to /search (was /) — fixes redirect bug - /admin/login → replaces /admin - /org/login → replaces /org - /member-login → two-step phone+OTP with proper form validation Portal selector (/) redesigned with accent border-left cards Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
async function handleResponse<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ message: res.statusText })) as { message?: string };
|
||||
throw new Error(body.message ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(url: string) =>
|
||||
fetch(url, { cache: 'no-store' }).then((r) => handleResponse<T>(r)),
|
||||
|
||||
post: <T>(url: string, body?: unknown) =>
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}).then((r) => handleResponse<T>(r)),
|
||||
|
||||
patch: <T>(url: string, body: unknown) =>
|
||||
fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then((r) => handleResponse<T>(r)),
|
||||
|
||||
del: (url: string) =>
|
||||
fetch(url, { method: 'DELETE' }).then((r) => handleResponse<void>(r)),
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function ReactQueryProvider({ children }: { children: React.ReactNode }) {
|
||||
const [client] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 30_000, retry: 1 },
|
||||
mutations: { retry: 0 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
+34
-114
@@ -1,137 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSuperAdmin } from './super-admin-context';
|
||||
import { useEffect } from 'react';
|
||||
import { useAuth } from './auth-context';
|
||||
import { PortalNav } from '../_components/portal-nav';
|
||||
import {
|
||||
Search, Users, MessageSquare, FileText, Settings, Bot,
|
||||
LayoutDashboard, Building2, Server, Inbox,
|
||||
} from 'lucide-react';
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ href: '/search', label: 'Search' },
|
||||
{ href: '/groups', label: 'Groups & Routes' },
|
||||
{ href: '/messages/pending', label: 'Pending messages' },
|
||||
{ href: '/drafts', label: 'AI Drafts' },
|
||||
{ href: '/settings/rules', label: 'Rules' },
|
||||
{ href: '/settings/bot', label: 'Bot' },
|
||||
// Paths that render their own full-screen layout (no sidebar)
|
||||
const FULLSCREEN_PATHS = ['/', '/login', '/member-login', '/onboard', '/admin/login', '/org/login', '/org', '/admin', '/my'];
|
||||
|
||||
const CHAPTER_NAV = [
|
||||
{ href: '/search', label: 'Search', icon: <Search /> },
|
||||
{ href: '/groups', label: 'Groups & Routes', icon: <Users /> },
|
||||
{ href: '/messages/pending', label: 'Pending', icon: <Inbox /> },
|
||||
{ href: '/drafts', label: 'AI Drafts', icon: <FileText /> },
|
||||
{ href: '/settings/rules', label: 'Rules', icon: <Settings /> },
|
||||
{ href: '/settings/bot', label: 'Bot', icon: <Bot /> },
|
||||
];
|
||||
|
||||
const SUPER_ADMIN_LINKS = [
|
||||
{ href: '/admin', label: 'Dashboard' },
|
||||
{ href: '/admin/tenants', label: 'Tenants' },
|
||||
{ href: '/admin/bots', label: 'Bot Pool' },
|
||||
{ href: '/admin/drafts', label: 'AI Drafts' },
|
||||
];
|
||||
|
||||
const PUBLIC_PATHS = ['/login', '/onboard', '/member-login'];
|
||||
const ADMIN_PATHS = ['/admin'];
|
||||
const MEMBER_PATHS = ['/my'];
|
||||
const ORG_PATHS = ['/org'];
|
||||
|
||||
export function Sidebar() {
|
||||
const { admin, loading, logout } = useAuth();
|
||||
const { admin: superAdmin, logout: superLogout } = useSuperAdmin();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [pendingCount, setPendingCount] = useState<number | null>(null);
|
||||
|
||||
const isFullscreen = FULLSCREEN_PATHS.some((p) =>
|
||||
p === '/' ? pathname === '/' : pathname === p || pathname.startsWith(p + '/'),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/messages/pending/count')
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => setPendingCount(data?.count ?? null))
|
||||
.catch(() => setPendingCount(null));
|
||||
}, []);
|
||||
if (loading || isFullscreen) return;
|
||||
if (!admin) router.replace(`/login?next=${encodeURIComponent(pathname)}`);
|
||||
}, [loading, admin, pathname, router, isFullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (pathname === '/') return;
|
||||
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) return;
|
||||
if (ADMIN_PATHS.some((p) => pathname.startsWith(p))) return;
|
||||
if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) return;
|
||||
if (ORG_PATHS.some((p) => pathname.startsWith(p))) return;
|
||||
if (!admin) {
|
||||
router.replace(`/login?next=${encodeURIComponent(pathname)}`);
|
||||
}
|
||||
}, [loading, admin, pathname, router]);
|
||||
if (isFullscreen) return null;
|
||||
|
||||
if (pathname === '/' || PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
|
||||
if (loading) {
|
||||
return (
|
||||
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4">
|
||||
<span className="font-bold text-base">TOWER</span>
|
||||
</nav>
|
||||
<div className="w-56 shrink-0 border-r bg-sidebar animate-pulse" />
|
||||
);
|
||||
}
|
||||
|
||||
if (ADMIN_PATHS.some((p) => pathname.startsWith(p))) {
|
||||
return (
|
||||
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col">
|
||||
<span className="font-bold text-base mb-4">TOWER Admin</span>
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
{SUPER_ADMIN_LINKS.map((link) => (
|
||||
<Link key={link.href} href={link.href} className="rounded px-3 py-2 text-sm hover:bg-gray-100">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-3 mt-3 flex flex-col gap-2">
|
||||
<div className="px-3 text-xs text-gray-500">
|
||||
<div className="font-medium text-gray-700 truncate">{superAdmin?.email}</div>
|
||||
<div className="uppercase tracking-wide">Super Admin</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void superLogout()}
|
||||
className="text-left rounded px-3 py-2 text-sm hover:bg-gray-100"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ORG_PATHS.some((p) => pathname.startsWith(p))) {
|
||||
return null;
|
||||
}
|
||||
if (!admin) return null;
|
||||
|
||||
return (
|
||||
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col">
|
||||
<span className="font-bold text-base mb-4">TOWER</span>
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="rounded px-3 py-2 text-sm hover:bg-gray-100 flex items-center justify-between"
|
||||
>
|
||||
<span>{link.label}</span>
|
||||
{link.href === '/messages/pending' && pendingCount !== null && pendingCount > 0 && (
|
||||
<span className="bg-blue-600 text-white text-[11px] font-bold rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{pendingCount > 99 ? '99+' : pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{admin && (
|
||||
<div className="border-t border-gray-200 pt-3 mt-3 flex flex-col gap-2">
|
||||
<div className="px-3 text-xs text-gray-500">
|
||||
<div className="font-medium text-gray-700 truncate">{admin.name ?? admin.email}</div>
|
||||
<div className="truncate">{admin.tenantName}</div>
|
||||
<div className="uppercase tracking-wide">{admin.role}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void logout()}
|
||||
className="text-left rounded px-3 py-2 text-sm hover:bg-gray-100"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
<PortalNav
|
||||
portalName="Chapter"
|
||||
navItems={CHAPTER_NAV}
|
||||
accentClass="text-blue-600"
|
||||
user={{ email: admin.email, name: admin.name ?? undefined, role: admin.role }}
|
||||
onLogout={() => { void logout(); router.replace('/login'); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user