Comprehensive admin + user dashboards (production-ready)

This commit is contained in:
Leon Serfaty
2026-06-07 17:54:30 -04:00
parent 155507f21a
commit f033f00379
122 changed files with 7878 additions and 805 deletions
+101 -8
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, UserPlus, Building2, Save } from "lucide-react";
import { Loader2, UserPlus, Building2, Save, Mic, Plus } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -12,7 +12,42 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { authClient } from "@/lib/auth/auth-client";
import { saveBrandingAction } from "@/app/(app)/team/actions";
import { inviteMemberAction, saveBrandingAction } from "@/app/(app)/team/actions";
/**
* Pure client-side #rrggbb → "H S% L%" converter for the live branding preview.
* Mirrors `hexToHslTriplet` in lib/branding.ts (which is server-only). Returns
* null for invalid hex so the preview falls back to the default brand token.
*/
function hexToHslTriplet(hex: string): string | null {
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
if (!m) return null;
const int = parseInt(m[1], 16);
const r = ((int >> 16) & 255) / 255;
const g = ((int >> 8) & 255) / 255;
const b = (int & 255) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
default:
h = (r - g) / d + 4;
}
h /= 6;
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
}
interface Member {
id: string;
@@ -101,16 +136,16 @@ function MembersCard({ orgId, members, seats }: { orgId: string; members: Member
async function invite(e: React.FormEvent) {
e.preventDefault();
// Fast UX guard only — the server action is the real seat-limit authority.
if (members.length >= seats) {
toast.error(`Your plan includes ${seats} seats.`);
return;
}
setBusy(true);
await authClient.organization.setActive({ organizationId: orgId });
const { error } = await authClient.organization.inviteMember({ email: email.trim(), role: "member" });
const res = await inviteMemberAction(orgId, email.trim());
setBusy(false);
if (error) {
toast.error(error.message ?? "Could not invite");
if (!res.ok) {
toast.error(res.error ?? "Could not invite");
return;
}
toast.success(`Invitation sent to ${email}`);
@@ -129,7 +164,7 @@ function MembersCard({ orgId, members, seats }: { orgId: string; members: Member
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="divide-y rounded-lg border">
<div className="divide-y rounded-2xl border">
{members.map((m) => (
<div key={m.id} className="flex items-center gap-3 p-3">
<Avatar className="h-8 w-8">
@@ -182,6 +217,9 @@ function BrandingCard({ orgId, branding }: { orgId: string; branding: Branding |
}
}
const previewHsl = hexToHslTriplet(primaryColor);
const previewName = brandName.trim() || "Your brand";
return (
<Card>
<CardHeader>
@@ -197,9 +235,64 @@ function BrandingCard({ orgId, branding }: { orgId: string; branding: Branding |
</div>
<div className="space-y-2">
<Label htmlFor="color">Primary colour (hex)</Label>
<Input id="color" placeholder="#7c3aed" value={primaryColor} onChange={(e) => setPrimaryColor(e.target.value)} />
<div className="flex items-center gap-2">
<span
aria-hidden
className="h-9 w-9 shrink-0 rounded-xl border border-border"
style={{ backgroundColor: previewHsl ? `hsl(${previewHsl})` : "hsl(var(--brand))" }}
/>
<Input id="color" placeholder="#7c3aed" value={primaryColor} onChange={(e) => setPrimaryColor(e.target.value)} />
</div>
</div>
</div>
{/* Live white-label preview — applies the chosen colour to a mini app mock. */}
<div className="space-y-2">
<Label>Live preview</Label>
<div
className="overflow-hidden rounded-2xl border border-border bg-background"
style={previewHsl ? ({ "--brand": previewHsl, "--ring": previewHsl } as React.CSSProperties) : undefined}
>
<div className="flex items-center justify-between gap-2 border-b border-border bg-background/80 px-3 py-2.5">
<div className="flex items-center gap-2 font-display text-sm font-bold tracking-tight">
{logoUrl.trim() ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoUrl} alt={previewName} className="h-6 w-auto max-w-[120px] object-contain" />
) : (
<>
<span className="flex h-6 w-6 items-center justify-center rounded-lg bg-brand text-brand-foreground">
<Mic className="h-3.5 w-3.5" />
</span>
<span className="truncate">{previewName}</span>
</>
)}
</div>
<span className="inline-flex items-center gap-1 rounded-full bg-brand px-2.5 py-1 text-xs font-semibold text-brand-foreground">
<Plus className="h-3 w-3" /> New
</span>
</div>
<div className="flex gap-3 p-3">
<div className="hidden w-28 shrink-0 flex-col gap-1 sm:flex">
<span className="rounded-full bg-brand/10 px-2.5 py-1.5 text-xs font-semibold text-brand">Dashboard</span>
<span className="rounded-full px-2.5 py-1.5 text-xs font-medium text-muted-foreground">Episodes</span>
<span className="rounded-full px-2.5 py-1.5 text-xs font-medium text-muted-foreground">Billing</span>
</div>
<div className="flex-1 space-y-2">
<p className="font-display text-sm font-bold tracking-tight">Welcome back</p>
<div className="rounded-xl border border-border p-2.5">
<div className="h-1.5 w-2/3 rounded-full bg-brand" />
<div className="mt-1.5 h-1.5 w-1/3 rounded-full bg-secondary" />
</div>
<span className="inline-block text-xs font-semibold text-brand underline-offset-2">
View usage
</span>
</div>
</div>
</div>
{primaryColor.trim() && !previewHsl && (
<p className="text-xs text-warning">Enter a 6-digit hex colour (e.g. #116DFF).</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="logo">Logo URL</Label>
<Input id="logo" placeholder="https://…" value={logoUrl} onChange={(e) => setLogoUrl(e.target.value)} />