2026-06-07 03:58:32 -04:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState } from "react";
|
|
|
|
|
import { useRouter } from "next/navigation";
|
2026-06-07 17:54:30 -04:00
|
|
|
import { Loader2, UserPlus, Building2, Save, Mic, Plus } from "lucide-react";
|
2026-06-07 03:58:32 -04:00
|
|
|
import { toast } from "sonner";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { Switch } from "@/components/ui/switch";
|
|
|
|
|
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";
|
2026-06-07 17:54:30 -04:00
|
|
|
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)}%`;
|
|
|
|
|
}
|
2026-06-07 03:58:32 -04:00
|
|
|
|
|
|
|
|
interface Member {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
email: string;
|
|
|
|
|
role: string;
|
|
|
|
|
}
|
|
|
|
|
interface Branding {
|
|
|
|
|
brandName: string | null;
|
|
|
|
|
primaryColor: string | null;
|
|
|
|
|
logoUrl: string | null;
|
|
|
|
|
removePoweredBy: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function TeamClient({
|
|
|
|
|
org,
|
|
|
|
|
members,
|
|
|
|
|
branding,
|
|
|
|
|
seats,
|
|
|
|
|
}: {
|
|
|
|
|
org: { id: string; name: string } | null;
|
|
|
|
|
members: Member[];
|
|
|
|
|
branding: Branding | null;
|
|
|
|
|
seats: number;
|
|
|
|
|
}) {
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
|
|
|
|
if (!org) return <CreateWorkspace />;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<MembersCard orgId={org.id} members={members} seats={seats} />
|
|
|
|
|
<BrandingCard orgId={org.id} branding={branding} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CreateWorkspace() {
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
const [name, setName] = useState("");
|
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
|
|
|
|
|
|
async function create(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setBusy(true);
|
|
|
|
|
const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
|
|
|
const { data, error } = await authClient.organization.create({ name: name.trim(), slug });
|
|
|
|
|
if (error) {
|
|
|
|
|
toast.error(error.message ?? "Could not create workspace");
|
|
|
|
|
setBusy(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (data?.id) await authClient.organization.setActive({ organizationId: data.id });
|
|
|
|
|
toast.success("Workspace created");
|
|
|
|
|
router.refresh();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Card className="max-w-md">
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
<Building2 className="h-5 w-5" /> Create your team workspace
|
|
|
|
|
</CardTitle>
|
|
|
|
|
<CardDescription>Invite up to your plan's seat limit and share a workspace.</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<form onSubmit={create} className="space-y-4">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="wsname">Workspace name</Label>
|
|
|
|
|
<Input id="wsname" value={name} onChange={(e) => setName(e.target.value)} required />
|
|
|
|
|
</div>
|
|
|
|
|
<Button type="submit" disabled={busy || !name.trim()}>
|
|
|
|
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
|
|
|
Create workspace
|
|
|
|
|
</Button>
|
|
|
|
|
</form>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function MembersCard({ orgId, members, seats }: { orgId: string; members: Member[]; seats: number }) {
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
const [email, setEmail] = useState("");
|
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
|
|
|
|
|
|
async function invite(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault();
|
2026-06-07 17:54:30 -04:00
|
|
|
// Fast UX guard only — the server action is the real seat-limit authority.
|
2026-06-07 03:58:32 -04:00
|
|
|
if (members.length >= seats) {
|
|
|
|
|
toast.error(`Your plan includes ${seats} seats.`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setBusy(true);
|
2026-06-07 17:54:30 -04:00
|
|
|
const res = await inviteMemberAction(orgId, email.trim());
|
2026-06-07 03:58:32 -04:00
|
|
|
setBusy(false);
|
2026-06-07 17:54:30 -04:00
|
|
|
if (!res.ok) {
|
|
|
|
|
toast.error(res.error ?? "Could not invite");
|
2026-06-07 03:58:32 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
toast.success(`Invitation sent to ${email}`);
|
|
|
|
|
setEmail("");
|
|
|
|
|
router.refresh();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="flex items-center justify-between">
|
|
|
|
|
<span>Members</span>
|
|
|
|
|
<Badge variant="secondary">
|
|
|
|
|
{members.length} / {seats} seats
|
|
|
|
|
</Badge>
|
|
|
|
|
</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-4">
|
2026-06-07 17:54:30 -04:00
|
|
|
<div className="divide-y rounded-2xl border">
|
2026-06-07 03:58:32 -04:00
|
|
|
{members.map((m) => (
|
|
|
|
|
<div key={m.id} className="flex items-center gap-3 p-3">
|
|
|
|
|
<Avatar className="h-8 w-8">
|
|
|
|
|
<AvatarFallback>{m.name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
|
|
|
</Avatar>
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<p className="truncate text-sm font-medium">{m.name}</p>
|
|
|
|
|
<p className="truncate text-xs text-muted-foreground">{m.email}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Badge variant="outline" className="capitalize">{m.role}</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<form onSubmit={invite} className="flex gap-2">
|
|
|
|
|
<Input
|
|
|
|
|
type="email"
|
|
|
|
|
placeholder="teammate@email.com"
|
|
|
|
|
value={email}
|
|
|
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
|
|
|
required
|
|
|
|
|
/>
|
|
|
|
|
<Button type="submit" disabled={busy}>
|
|
|
|
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />}
|
|
|
|
|
Invite
|
|
|
|
|
</Button>
|
|
|
|
|
</form>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function BrandingCard({ orgId, branding }: { orgId: string; branding: Branding | null }) {
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
const [brandName, setBrandName] = useState(branding?.brandName ?? "");
|
|
|
|
|
const [primaryColor, setPrimaryColor] = useState(branding?.primaryColor ?? "");
|
|
|
|
|
const [logoUrl, setLogoUrl] = useState(branding?.logoUrl ?? "");
|
|
|
|
|
const [removePoweredBy, setRemovePoweredBy] = useState(branding?.removePoweredBy ?? false);
|
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
|
|
|
|
|
|
async function save(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setBusy(true);
|
|
|
|
|
const res = await saveBrandingAction(orgId, { brandName, primaryColor, logoUrl, removePoweredBy });
|
|
|
|
|
setBusy(false);
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
toast.success("Branding saved");
|
|
|
|
|
router.refresh();
|
|
|
|
|
} else {
|
|
|
|
|
toast.error(res.error ?? "Could not save");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 17:54:30 -04:00
|
|
|
const previewHsl = hexToHslTriplet(primaryColor);
|
|
|
|
|
const previewName = brandName.trim() || "Your brand";
|
|
|
|
|
|
2026-06-07 03:58:32 -04:00
|
|
|
return (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle>White-label branding</CardTitle>
|
|
|
|
|
<CardDescription>Make the workspace your own.</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<form onSubmit={save} className="space-y-4">
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="brand">Brand name</Label>
|
|
|
|
|
<Input id="brand" value={brandName} onChange={(e) => setBrandName(e.target.value)} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="color">Primary colour (hex)</Label>
|
2026-06-07 17:54:30 -04:00
|
|
|
<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>
|
2026-06-07 03:58:32 -04:00
|
|
|
</div>
|
2026-06-07 17:54:30 -04:00
|
|
|
{primaryColor.trim() && !previewHsl && (
|
|
|
|
|
<p className="text-xs text-warning">Enter a 6-digit hex colour (e.g. #116DFF).</p>
|
|
|
|
|
)}
|
2026-06-07 03:58:32 -04:00
|
|
|
</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)} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium">Remove "Powered by PodcastYes"</p>
|
|
|
|
|
<p className="text-xs text-muted-foreground">Hide PodcastYes branding for your clients.</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Switch checked={removePoweredBy} onCheckedChange={setRemovePoweredBy} />
|
|
|
|
|
</div>
|
|
|
|
|
<Button type="submit" disabled={busy}>
|
|
|
|
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
|
|
|
|
Save branding
|
|
|
|
|
</Button>
|
|
|
|
|
</form>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|