implement

This commit is contained in:
Arjun Patel
2026-06-11 09:05:09 -07:00
parent cf94003b3f
commit b46df94124
3 changed files with 343 additions and 164 deletions
+165 -163
View File
@@ -1,27 +1,37 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, CreditCard, Mail, Shield, Users, X } from 'lucide-react';
import {
ArrowLeft,
CreditCard,
Mail,
Shield,
UserPlus,
Users,
X,
} from 'lucide-react';
import { toast } from 'sonner';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Muted } from '@/components/ui/typography';
import { WindowControls } from '@/components/window-controls';
import { useNetworks } from '@/hooks/use-networks';
import {
useNetworkInvitations,
useInviteMembers,
useRevokeInvitation,
useRemoveMember,
} from '@/hooks/use-member-management';
import { useAuthStore } from '@/stores/auth-store';
import { BillingSection } from '@/features/network-billing';
import { AddMembersDialog } from '@/features/network-settings/add-members-dialog';
import { ConfirmDestructiveOverlay } from '@/components/confirm-destructive-overlay';
import type { Human } from '@/api/types';
type Section = 'members' | 'billing';
function MemberRow({
human,
isAdmin,
@@ -65,43 +75,6 @@ function MemberRow({
);
}
function InviteForm({ networkId }: { networkId: string }) {
const [email, setEmail] = useState('');
const inviteMembers = useInviteMembers(networkId);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = email.trim();
if (!trimmed) return;
inviteMembers.mutate([trimmed], {
onSuccess: () => {
toast.success(`Invitation sent to ${trimmed}`);
setEmail('');
},
});
};
return (
<form onSubmit={handleSubmit} className="flex items-center gap-2 px-4 py-3">
<Input
type="email"
placeholder="[email protected]"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1"
/>
<Button
type="submit"
size="sm"
disabled={!email.trim() || inviteMembers.isPending}
>
{inviteMembers.isPending ? 'Sending...' : 'Invite'}
</Button>
</form>
);
}
function PendingInvitationRow({
email,
networkId,
@@ -141,36 +114,38 @@ function PendingInvitationRow({
);
}
function SectionHeader({
icon,
function SectionHeading({
title,
description,
trailing,
count,
action,
}: {
icon: React.ReactNode;
title: string;
description?: string;
trailing?: React.ReactNode;
count?: number;
action?: React.ReactNode;
}) {
return (
<div className="flex items-start gap-3 px-4 pb-2 pt-6">
<span className="text-muted-foreground mt-0.5 flex size-4 items-center justify-center">
{icon}
</span>
<div className="min-w-0 flex-1">
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
{trailing}
<h2 className="text-base font-semibold tracking-tight">{title}</h2>
{count != null && (
<Badge variant="secondary" className="tabular-nums">
{count}
</Badge>
)}
</div>
{description && <Muted className="text-xs">{description}</Muted>}
{description && <Muted className="mt-0.5 text-xs">{description}</Muted>}
</div>
{action}
</div>
);
}
function Section({ children }: { children: React.ReactNode }) {
function Panel({ children }: { children: React.ReactNode }) {
return (
<section className="bg-card/40 mx-4 mb-2 overflow-hidden rounded-lg border">
<section className="bg-card/40 overflow-hidden rounded-lg border">
{children}
</section>
);
@@ -181,7 +156,7 @@ export default function NetworkSettingsPage() {
const { networkId } = useParams<{ networkId: string }>();
if (!networkId)
throw new Error('NetworkSettingsPage requires a :networkId route param');
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
const { data: invitations, error: invitationsError } =
@@ -189,18 +164,30 @@ export default function NetworkSettingsPage() {
const currentUser = useAuthStore((s) => s.user);
const isAdmin = currentUser?.id === network?.admin_human.id;
const [memberToRemove, setMemberToRemove] = useState<Human | null>(null);
// Onboarding: opening settings with `?add=1` (e.g. right after creating a
// network) starts with the Add members dialog open. Non-admins never render
// the dialog, so the initial value is harmless for them.
const [addOpen, setAddOpen] = useState(() => searchParams.get('add') === '1');
const removeMember = useRemoveMember(networkId);
const billingRef = useRef<HTMLDivElement>(null);
const section: Section =
searchParams.get('section') === 'billing' ? 'billing' : 'members';
const setSection = (value: string) => {
const next = new URLSearchParams(searchParams);
if (value === 'billing') next.set('section', value);
else next.delete('section');
setSearchParams(next, { replace: true });
};
// Strip the one-shot `add` param so the dialog doesn't reopen on refresh or
// back navigation. The initial open state was already captured above.
useEffect(() => {
if (searchParams.get('section') === 'billing') {
billingRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
}, [searchParams]);
if (searchParams.get('add') !== '1') return;
const next = new URLSearchParams(searchParams);
next.delete('add');
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
const networkName = network?.name ?? 'Network';
const memberCount = network?.humans.length ?? 0;
@@ -223,121 +210,136 @@ export default function NetworkSettingsPage() {
<div className="flex-1" />
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="flex items-center gap-3 px-4 pb-4 pt-6">
<Avatar size="lg">
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{networkInitials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-base font-semibold">{networkName}</p>
<Muted className="text-xs">
{memberCount} {memberCount === 1 ? 'member' : 'members'}
{isAdmin ? " · You're an admin" : ''}
</Muted>
<Tabs
value={section}
onValueChange={setSection}
orientation="vertical"
className="min-h-0 flex-1 gap-0"
>
<aside className="flex w-52 shrink-0 flex-col gap-4 border-r p-3">
<div className="flex items-center gap-3 px-1 pt-1">
<Avatar>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{networkInitials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{networkName}</p>
<Muted className="text-xs">
{memberCount} {memberCount === 1 ? 'member' : 'members'}
</Muted>
</div>
</div>
</div>
<TabsList variant="line" className="w-full gap-1">
<TabsTrigger value="members">
<Users />
Members
</TabsTrigger>
<TabsTrigger value="billing">
<CreditCard />
Plan & Billing
</TabsTrigger>
</TabsList>
</aside>
<Section>
<SectionHeader
icon={<Users className="size-4" />}
title="Members"
description="People with access to this network."
trailing={
<Badge variant="secondary" className="tabular-nums">
{memberCount}
</Badge>
}
/>
<Separator />
{network?.humans.map((human, index) => {
const isRowAdmin = human.id === network.admin_human.id;
const canRemove =
isAdmin && !isRowAdmin && human.id !== currentUser?.id;
return (
<div key={human.id}>
<MemberRow
human={human}
isAdmin={isRowAdmin}
onRemove={
canRemove ? () => setMemberToRemove(human) : undefined
}
/>
{index < network.humans.length - 1 && (
<Separator className="mx-4" />
)}
</div>
);
})}
</Section>
{isAdmin && network && (
<Section>
<SectionHeader
icon={<Mail className="size-4" />}
title="Invitations"
description="Invite teammates by email. They'll get a link to join."
trailing={
pendingCount > 0 ? (
<Badge variant="secondary" className="tabular-nums">
{pendingCount} pending
</Badge>
<ScrollArea className="min-h-0 flex-1">
<TabsContent value="members" className="p-4">
<SectionHeading
title="Members"
description="People with access to this network."
count={memberCount}
action={
isAdmin ? (
<Button
size="sm"
className="shrink-0"
onClick={() => setAddOpen(true)}
>
<UserPlus className="mr-1 size-3.5" />
Add members
</Button>
) : undefined
}
/>
<Separator />
<InviteForm networkId={networkId} />
{invitationsError && (
<>
<Separator />
<p className="text-muted-foreground px-4 py-3 text-xs">
Couldn't load pending invitations.
</p>
</>
)}
{invitations && invitations.length > 0 && (
<>
<Separator />
<div className="px-4 pb-1 pt-3">
<Muted className="text-xs font-medium uppercase tracking-wider">
Pending
</Muted>
</div>
{invitations.map((inv, index) => (
<div key={inv.email}>
<PendingInvitationRow
email={inv.email}
networkId={networkId}
<Panel>
{network?.humans.map((human, index) => {
const isRowAdmin = human.id === network.admin_human.id;
const canRemove =
isAdmin && !isRowAdmin && human.id !== currentUser?.id;
return (
<div key={human.id}>
<MemberRow
human={human}
isAdmin={isRowAdmin}
onRemove={
canRemove ? () => setMemberToRemove(human) : undefined
}
/>
{index < invitations.length - 1 && (
{index < network.humans.length - 1 && (
<Separator className="mx-4" />
)}
</div>
))}
</>
)}
</Section>
)}
);
})}
</Panel>
<div ref={billingRef}>
<Section>
<SectionHeader
icon={<CreditCard className="size-4" />}
title="Billing"
{isAdmin && (
<div className="mt-6">
<SectionHeading
title="Pending invitations"
description="Invites that haven't been accepted yet."
count={pendingCount}
/>
{invitationsError ? (
<Panel>
<p className="text-muted-foreground px-4 py-3 text-xs">
Couldn't load pending invitations.
</p>
</Panel>
) : invitations && invitations.length > 0 ? (
<Panel>
{invitations.map((inv, index) => (
<div key={inv.email}>
<PendingInvitationRow
email={inv.email}
networkId={networkId}
/>
{index < invitations.length - 1 && (
<Separator className="mx-4" />
)}
</div>
))}
</Panel>
) : (
<Muted className="text-xs">No pending invitations.</Muted>
)}
</div>
)}
</TabsContent>
<TabsContent value="billing" className="p-4">
<SectionHeading
title="Plan & Billing"
description={
isAdmin
? 'Manage your plan, seats, and payment.'
: "Your network's current plan and usage."
}
/>
<Separator />
<BillingSection networkId={networkId} />
</Section>
</div>
<Panel>
<BillingSection networkId={networkId} />
</Panel>
</TabsContent>
</ScrollArea>
</Tabs>
<div className="h-6" />
</ScrollArea>
{isAdmin && (
<AddMembersDialog
networkId={networkId}
open={addOpen}
onOpenChange={setAddOpen}
/>
)}
{memberToRemove && (
<ConfirmDestructiveOverlay