feat: isolate all four portals with route groups + polished design system

Fix blank /org/login (and /admin/login): the guarded portal layout was
wrapping its own login route, rendering null when unauthenticated. Move
each portal's authed pages into a (authed)/(chapter) route group so login
pages live outside the guard.

Structure:
- app/(chapter)/* — chapter admin pages + guarded layout (was root-level)
- app/org/(authed)/* — org pages + guarded layout; /org/login now free
- app/admin/(authed)/* — admin pages + guarded layout; /admin/login free
- Root layout slimmed to providers only (no shared sidebar)
- Convert moved files' relative imports to @/app alias

Design system:
- PortalShell: shared sidebar shell (brand mark, themed active nav with
  accent bar, avatar dropdown user menu, loading skeletons)
- portal-theme.ts: per-portal theme tokens (violet/slate/blue/emerald)
- PortalLoginShell redesigned: two-column with gradient branding panel,
  dotted pattern, value-prop highlights, built-in back link
- New shadcn components: Avatar, DropdownMenu, Skeleton
- Move shared DraftCard to _components/draft-card.tsx (used by 2 portals)
- Portal selector: remove Super Admin card, icon tiles, hover lift
- All 4 login pages use react-hook-form + Zod + themed shell
- Member nav restored to full 11-section list with icons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 18:20:06 +05:30
parent d7b5988858
commit 205418fc4e
50 changed files with 619 additions and 360 deletions
@@ -0,0 +1,87 @@
'use client';
import { useState } from 'react';
type Status = 'ACTIVE' | 'DISCONNECTED' | 'BANNED' | 'PAIRING';
interface BotSummary {
id: string;
jid: string | null;
displayName: string | null;
status: Status;
}
export function BotSettingsCard({ initial }: { initial: { bot: BotSummary | null; shared: boolean; sharedBotId?: string } }) {
const [bot, setBot] = useState<BotSummary | null>(initial.bot);
const [revealedJid, setRevealedJid] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function onReveal() {
setError(null);
const res = await fetch('/api/bot/reveal', {
method: 'POST',
credentials: 'include',
});
if (res.ok) {
const data = (await res.json()) as { jid: string };
setRevealedJid(data.jid);
} else {
setError('Reveal failed');
}
}
if (!bot) {
return (
<div className="rounded-lg border border-gray-200 p-6 bg-white">
<h2 className="text-lg font-medium mb-2">Bot</h2>
<p className="text-sm text-gray-600">
No bot assigned yet. Contact your platform administrator to get one assigned.
</p>
</div>
);
}
return (
<div className="rounded-lg border border-gray-200 p-6 bg-white">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-medium">Bot</h2>
<span
className={`text-xs px-2 py-1 rounded ${
bot.status === 'ACTIVE'
? 'bg-green-100 text-green-800'
: bot.status === 'PAIRING'
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}
>
{bot.status}
</span>
</div>
<dl className="text-sm space-y-1 mb-4">
<div>
<dt className="inline font-medium">Number: </dt>
<dd className="inline">
{revealedJid ?? (bot.jid ? '••••••••••' : '—')}
{!revealedJid && bot.status === 'ACTIVE' && (
<button
type="button"
onClick={onReveal}
className="ml-2 text-xs text-blue-600 underline"
>
Reveal
</button>
)}
</dd>
</div>
<div>
<dt className="inline font-medium">Display name: </dt>
<dd className="inline">{bot.displayName ?? '—'}</dd>
</div>
</dl>
<p className="text-xs text-gray-400">
Bot is assigned by the platform administrator. Contact support to change or remove it.
</p>
{error && <p className="text-sm text-red-600 mt-2">{error}</p>}
</div>
);
}