Polish invite flow and improve mobile responsiveness

- Add Sheet UI component for mobile navigation drawer
- SpaceNav: hamburger menu on mobile, horizontal tabs on desktop
- Join page: specific error messages (expired/maxed/invalid), inline success
  state with credits info, already-member handling, skeleton loading
- InviteDialog: add expiry hours input, helper text for email field
- InviteList: new component for admin invite management (revoke, copy link)
- Mobile responsiveness sweep across all space pages, proposals, voting
  buttons, member list, settings, demo page, and final vote panel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-12 22:49:54 -07:00
parent 9456c6f73f
commit 90865039f5
18 changed files with 551 additions and 119 deletions

View File

@ -24,7 +24,7 @@ export default function HomePage() {
return (
<div className="space-y-16">
{/* Hero section */}
<section className="relative text-center py-16 space-y-6 overflow-hidden">
<section className="relative text-center py-8 sm:py-16 space-y-6 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 via-transparent to-accent/10 -z-10" />
<div className="absolute top-0 right-0 w-96 h-96 bg-primary/5 rounded-full blur-3xl -z-10" />
<div className="absolute bottom-0 left-0 w-96 h-96 bg-accent/5 rounded-full blur-3xl -z-10" />
@ -32,7 +32,7 @@ export default function HomePage() {
<Badge variant="secondary" className="text-sm px-4 py-1 bg-primary/10 text-primary border-primary/20">
Part of the rSpace Ecosystem
</Badge>
<h1 className="text-5xl md:text-6xl font-bold tracking-tight max-w-4xl mx-auto leading-tight">
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight max-w-4xl mx-auto leading-tight">
Democratic<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-accent">Backlog Prioritization</span>
</h1>
@ -196,11 +196,11 @@ export default function HomePage() {
<h3 className="text-lg font-semibold text-center mb-4">
Vote Cost Calculator
</h3>
<div className="grid grid-cols-5 gap-2 text-center">
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2 text-center">
{[1, 2, 3, 4, 5].map((votes) => (
<div
key={votes}
className="p-4 rounded-lg border-2 bg-card hover:border-primary/50 hover:bg-primary/5 transition-all duration-200"
className="p-3 sm:p-4 rounded-lg border-2 bg-card hover:border-primary/50 hover:bg-primary/5 transition-all duration-200"
>
<div className="text-2xl font-bold text-primary">{votes}</div>
<div className="text-sm text-muted-foreground">

View File

@ -1,52 +1,76 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
import { useParams, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { toast } from "sonner";
import { Users, LogIn } from "lucide-react";
import { Users, LogIn, CheckCircle2, AlertCircle, Clock, UserCheck } from "lucide-react";
import Link from "next/link";
interface InviteInfo {
space: { name: string; slug: string; description: string | null; startingCredits?: number };
expired: boolean;
maxedOut: boolean;
valid: boolean;
}
type PageState =
| { type: "loading" }
| { type: "error"; message: string; icon: typeof AlertCircle }
| { type: "invite"; info: InviteInfo }
| { type: "joined"; spaceName: string; startingCredits: number }
| { type: "already_member"; spaceName: string };
export default function JoinSpacePage() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const slug = params.slug as string;
const token = searchParams.get("token");
const [inviteInfo, setInviteInfo] = useState<{ spaceName: string; uses: number; maxUses?: number } | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [state, setState] = useState<PageState>({ type: "loading" });
const [joining, setJoining] = useState(false);
useEffect(() => {
if (token) {
fetch(`/api/spaces/join/${token}`)
.then((res) => res.json())
.then((data) => {
if (data.error) {
setError(data.error);
} else {
setInviteInfo(data);
}
})
.catch(() => setError("Failed to load invite"));
} else {
setError("No invite token provided");
if (!token) {
setState({ type: "error", message: "No invite token provided.", icon: AlertCircle });
return;
}
fetch(`/api/spaces/join/${token}`)
.then((res) => res.json())
.then((data) => {
if (data.error) {
setState({ type: "error", message: data.error, icon: AlertCircle });
} else if (data.expired) {
setState({ type: "error", message: "This invite link has expired.", icon: Clock });
} else if (data.maxedOut) {
setState({ type: "error", message: "This invite link has reached its maximum number of uses.", icon: Users });
} else if (data.valid) {
setState({ type: "invite", info: data });
} else {
setState({ type: "error", message: "This invite link is not valid.", icon: AlertCircle });
}
})
.catch(() => setState({ type: "error", message: "Failed to load invite. Please try again.", icon: AlertCircle }));
}, [token]);
async function acceptInvite() {
if (!token) return;
setLoading(true);
setJoining(true);
try {
const res = await fetch(`/api/spaces/join/${token}`, { method: "POST" });
const data = await res.json();
if (res.ok) {
toast.success("Welcome to the space!");
router.push("/");
if (res.ok && data.alreadyMember) {
setState({ type: "already_member", spaceName: data.space.name });
} else if (res.ok) {
setState({
type: "joined",
spaceName: data.space.name,
startingCredits: data.space.startingCredits ?? 0,
});
} else if (res.status === 401) {
// Not logged in - redirect to signin with invite token preserved
// Not logged in — redirect to signin with invite URL preserved
const rootDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN || "rvote.online";
const protocol = window.location.protocol;
window.location.href = `${protocol}//${rootDomain}/auth/signin?callbackUrl=${encodeURIComponent(window.location.href)}`;
@ -54,52 +78,94 @@ export default function JoinSpacePage() {
toast.error(data.error || "Failed to join space");
}
} finally {
setLoading(false);
setJoining(false);
}
}
if (error) {
return (
<div className="max-w-md mx-auto mt-16">
return (
<div className="max-w-md mx-auto mt-8 sm:mt-16 px-4">
{state.type === "loading" && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<div className="animate-pulse space-y-3">
<div className="mx-auto h-16 w-16 rounded-full bg-muted" />
<div className="h-6 bg-muted rounded w-48 mx-auto" />
<div className="h-4 bg-muted rounded w-64 mx-auto" />
</div>
</CardContent>
</Card>
)}
{state.type === "error" && (
<Card>
<CardContent className="py-8 text-center space-y-4">
<p className="text-lg text-muted-foreground">{error}</p>
<div className="mx-auto h-16 w-16 rounded-full bg-destructive/10 flex items-center justify-center">
<state.icon className="h-8 w-8 text-destructive" />
</div>
<p className="text-lg text-muted-foreground">{state.message}</p>
<Button asChild variant="outline">
<Link href="/">Go Home</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
)}
if (!inviteInfo) {
return (
<div className="max-w-md mx-auto mt-16 text-center text-muted-foreground">
Loading invite...
</div>
);
}
{state.type === "invite" && (
<Card>
<CardHeader className="text-center">
<div className="mx-auto h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Users className="h-8 w-8 text-primary" />
</div>
<CardTitle className="text-xl sm:text-2xl">Join {state.info.space.name}</CardTitle>
<CardDescription>
You&apos;ve been invited to join this space.
</CardDescription>
{state.info.space.description && (
<p className="text-sm text-muted-foreground mt-2">{state.info.space.description}</p>
)}
</CardHeader>
<CardContent className="text-center space-y-4">
<Button onClick={acceptInvite} disabled={joining} size="lg" className="w-full">
<LogIn className="h-4 w-4 mr-2" />
{joining ? "Joining..." : "Accept Invite"}
</Button>
</CardContent>
</Card>
)}
return (
<div className="max-w-md mx-auto mt-16">
<Card>
<CardHeader className="text-center">
<div className="mx-auto h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Users className="h-8 w-8 text-primary" />
</div>
<CardTitle className="text-2xl">Join {inviteInfo.spaceName}</CardTitle>
<CardDescription>
You&apos;ve been invited to join this space.
</CardDescription>
</CardHeader>
<CardContent className="text-center space-y-4">
<Button onClick={acceptInvite} disabled={loading} size="lg" className="w-full">
<LogIn className="h-4 w-4 mr-2" />
{loading ? "Joining..." : "Accept Invite"}
</Button>
</CardContent>
</Card>
{state.type === "joined" && (
<Card>
<CardContent className="py-8 text-center space-y-4">
<div className="mx-auto h-16 w-16 rounded-full bg-green-500/10 flex items-center justify-center">
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
<h2 className="text-xl sm:text-2xl font-bold">Welcome to {state.spaceName}!</h2>
<p className="text-muted-foreground">
You&apos;ve been given <span className="font-semibold text-orange-600">{state.startingCredits} credits</span> to start voting on proposals.
</p>
<Button asChild size="lg" className="w-full">
<Link href="/">Go to Dashboard</Link>
</Button>
</CardContent>
</Card>
)}
{state.type === "already_member" && (
<Card>
<CardContent className="py-8 text-center space-y-4">
<div className="mx-auto h-16 w-16 rounded-full bg-blue-500/10 flex items-center justify-center">
<UserCheck className="h-8 w-8 text-blue-600" />
</div>
<h2 className="text-xl sm:text-2xl font-bold">Already a member</h2>
<p className="text-muted-foreground">
You&apos;re already a member of {state.spaceName}.
</p>
<Button asChild size="lg" className="w-full">
<Link href="/">Go to Dashboard</Link>
</Button>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -52,14 +52,14 @@ export default async function SpaceLayout({
>
<div className="border-b bg-card">
<div className="container mx-auto px-4 py-3">
<h1 className="text-xl font-bold">{space.name}</h1>
<h1 className="text-lg sm:text-xl font-bold">{space.name}</h1>
{space.description && (
<p className="text-sm text-muted-foreground">{space.description}</p>
)}
</div>
</div>
<SpaceNav />
<div className="container mx-auto px-4 py-6">
<div className="container mx-auto px-4 py-4 sm:py-6">
{children}
</div>
</SpaceProvider>

View File

@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma";
import { notFound, redirect } from "next/navigation";
import { MemberList } from "@/components/MemberList";
import { InviteDialog } from "@/components/InviteDialog";
import { InviteList } from "@/components/InviteList";
import { Badge } from "@/components/ui/badge";
export default async function SpaceMembersPage({
@ -38,7 +39,7 @@ export default async function SpaceMembersPage({
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h2 className="text-2xl font-bold">Members</h2>
<p className="text-muted-foreground">
@ -54,6 +55,13 @@ export default async function SpaceMembersPage({
isAdmin={membership.role === "ADMIN"}
currentUserId={session.user.id}
/>
{membership.role === "ADMIN" && (
<div className="space-y-3">
<h3 className="text-lg font-semibold">Active Invites</h3>
<InviteList spaceSlug={slug} />
</div>
)}
</div>
);
}

View File

@ -36,25 +36,25 @@ export default async function SpaceDashboard({
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="bg-gradient-to-br from-orange-500/10 to-transparent border-orange-500/20">
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold text-orange-600">{rankingCount}</div>
<div className="text-2xl sm:text-3xl font-bold text-orange-600">{rankingCount}</div>
<p className="text-sm text-muted-foreground">Being Ranked</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold text-blue-600">{votingCount}</div>
<div className="text-2xl sm:text-3xl font-bold text-blue-600">{votingCount}</div>
<p className="text-sm text-muted-foreground">In Voting</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-green-500/10 to-transparent border-green-500/20">
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold text-green-600">{passedCount}</div>
<div className="text-2xl sm:text-3xl font-bold text-green-600">{passedCount}</div>
<p className="text-sm text-muted-foreground">Passed</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-purple-500/10 to-transparent border-purple-500/20">
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold text-purple-600">{memberCount}</div>
<div className="text-2xl sm:text-3xl font-bold text-purple-600">{memberCount}</div>
<p className="text-sm text-muted-foreground">Members</p>
</CardContent>
</Card>
@ -62,7 +62,7 @@ export default async function SpaceDashboard({
{/* Top proposals */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
<h2 className="text-xl font-bold">Top Proposals</h2>
<div className="flex gap-2">
<Button asChild variant="outline" size="sm">

View File

@ -78,7 +78,7 @@ export default async function SpaceProposalDetailPage({
return (
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-start gap-4">
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
{proposal.status === "RANKING" && (
<div className="pt-1">
<VoteButtons
@ -92,7 +92,7 @@ export default async function SpaceProposalDetailPage({
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<div className="flex flex-wrap items-center gap-2 mb-2">
<Badge className={statusColors[proposal.status]}>{proposal.status}</Badge>
{proposal.votingEndsAt && (
<span className="text-sm text-muted-foreground">
@ -100,7 +100,7 @@ export default async function SpaceProposalDetailPage({
</span>
)}
</div>
<h1 className="text-2xl font-bold">{proposal.title}</h1>
<h1 className="text-xl sm:text-2xl font-bold">{proposal.title}</h1>
<p className="text-sm text-muted-foreground mt-1">
by {proposal.author.name || proposal.author.email} &middot;{" "}
{formatDistanceToNow(new Date(proposal.createdAt), { addSuffix: true })}

View File

@ -70,9 +70,9 @@ export default async function SpaceProposalsPage({
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<h2 className="text-2xl font-bold">Proposals</h2>
<Button asChild>
<Button asChild size="sm">
<Link href="/proposals/new">
<Plus className="h-4 w-4 mr-2" />
New Proposal
@ -83,13 +83,13 @@ export default async function SpaceProposalsPage({
<Tabs defaultValue="ranking">
<TabsList>
<TabsTrigger value="ranking">
Ranking <Badge variant="secondary" className="ml-2 text-xs">{rankingProposals.length}</Badge>
Ranking <Badge variant="secondary" className="ml-2 text-xs hidden sm:inline">{rankingProposals.length}</Badge>
</TabsTrigger>
<TabsTrigger value="voting">
Voting <Badge variant="secondary" className="ml-2 text-xs">{votingProposals.length}</Badge>
Voting <Badge variant="secondary" className="ml-2 text-xs hidden sm:inline">{votingProposals.length}</Badge>
</TabsTrigger>
<TabsTrigger value="completed">
Completed <Badge variant="secondary" className="ml-2 text-xs">{completedProposals.length}</Badge>
Completed <Badge variant="secondary" className="ml-2 text-xs hidden sm:inline">{completedProposals.length}</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="ranking">

View File

@ -88,7 +88,7 @@ export default function SpaceSettingsPage() {
<CardTitle>Voting Configuration</CardTitle>
<CardDescription>Controls how proposals are ranked and promoted</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4">
<CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="threshold">Promotion Threshold</Label>
<Input id="threshold" type="number" value={promotionThreshold} onChange={(e) => setPromotionThreshold(e.target.value)} />
@ -107,7 +107,7 @@ export default function SpaceSettingsPage() {
<CardTitle>Credits</CardTitle>
<CardDescription>Controls credit allocation for members</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-3 gap-4">
<CardContent className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="cpd">Credits Per Day</Label>
<Input id="cpd" type="number" value={creditsPerDay} onChange={(e) => setCreditsPerDay(e.target.value)} />

View File

@ -30,9 +30,9 @@ export default async function SpacesPage() {
return (
<div className="max-w-4xl mx-auto space-y-8">
<div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 className="text-3xl font-bold">Your Spaces</h1>
<h1 className="text-2xl sm:text-3xl font-bold">Your Spaces</h1>
<p className="text-muted-foreground mt-1">
Communities you belong to
</p>

View File

@ -95,7 +95,7 @@ export function FinalVotePanel({
<CardContent className="space-y-4">
{/* Vote counts */}
<div className="grid grid-cols-3 gap-4 text-center">
<div className="grid grid-cols-3 gap-2 sm:gap-4 text-center">
<div>
<div className="text-2xl font-bold text-green-500">{votes.yes}</div>
<div className="text-sm text-muted-foreground">Yes</div>
@ -138,10 +138,10 @@ export function FinalVotePanel({
You voted: <Badge variant="outline">{userVote}</Badge>
</p>
)}
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
<Button
variant={userVote === "YES" ? "default" : "outline"}
className="flex-col h-auto py-3"
className="flex-col h-auto py-2 sm:py-3"
onClick={() => castVote("YES")}
disabled={isVoting}
>
@ -154,7 +154,7 @@ export function FinalVotePanel({
</Button>
<Button
variant={userVote === "NO" ? "destructive" : "outline"}
className="flex-col h-auto py-3"
className="flex-col h-auto py-2 sm:py-3"
onClick={() => castVote("NO")}
disabled={isVoting}
>
@ -167,7 +167,7 @@ export function FinalVotePanel({
</Button>
<Button
variant={userVote === "ABSTAIN" ? "secondary" : "outline"}
className="flex-col h-auto py-3"
className="flex-col h-auto py-2 sm:py-3"
onClick={() => castVote("ABSTAIN")}
disabled={isVoting}
>

View File

@ -159,11 +159,11 @@ export function InteractiveDemo() {
<Card className="border-2 border-orange-500/30 bg-gradient-to-r from-orange-500/10 to-amber-500/10">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 sm:gap-4">
<div className="flex items-center gap-2">
<Coins className="h-6 w-6 text-orange-500" />
<span className="font-bold text-2xl text-orange-600">{credits}</span>
<span className="text-muted-foreground">credits</span>
<Coins className="h-5 w-5 sm:h-6 sm:w-6 text-orange-500" />
<span className="font-bold text-xl sm:text-2xl text-orange-600">{credits}</span>
<span className="text-muted-foreground text-sm sm:text-base">credits</span>
</div>
<Badge variant="outline" className="border-orange-500/30 text-orange-600">
Max vote: &plusmn;{maxWeight}
@ -187,11 +187,11 @@ export function InteractiveDemo() {
<CardDescription>Each additional vote costs exponentially more credits</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-5 gap-2 text-center text-sm">
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2 text-center text-sm">
{[1, 2, 3, 4, 5].map((w) => (
<div
key={w}
className={`p-3 rounded-lg border-2 transition-all ${
className={`p-2 sm:p-3 rounded-lg border-2 transition-all ${
w <= maxWeight
? "bg-orange-500/10 border-orange-500/40 text-orange-700"
: "bg-muted/50 border-muted text-muted-foreground"

View File

@ -24,6 +24,7 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) {
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [maxUses, setMaxUses] = useState("");
const [expiresIn, setExpiresIn] = useState("");
const [inviteUrl, setInviteUrl] = useState("");
const [loading, setLoading] = useState(false);
@ -33,6 +34,10 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) {
const body: Record<string, unknown> = {};
if (email) body.email = email;
if (maxUses) body.maxUses = parseInt(maxUses);
if (expiresIn) {
const hours = parseInt(expiresIn);
body.expiresAt = new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
}
const res = await fetch(`/api/spaces/${spaceSlug}/invites`, {
method: "POST",
@ -61,6 +66,7 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) {
function reset() {
setEmail("");
setMaxUses("");
setExpiresIn("");
setInviteUrl("");
setOpen(false);
}
@ -68,7 +74,7 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) {
return (
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
<DialogTrigger asChild>
<Button>
<Button size="sm" className="sm:size-default">
<UserPlus className="h-4 w-4 mr-2" />
Create Invite
</Button>
@ -84,7 +90,7 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) {
{!inviteUrl ? (
<div className="space-y-4">
<div>
<Label htmlFor="email">Restrict to email (optional)</Label>
<Label htmlFor="email">Restrict to email</Label>
<Input
id="email"
type="email"
@ -92,16 +98,33 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) {
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-1">
If set, only this email address can use the invite link.
</p>
</div>
<div>
<Label htmlFor="maxUses">Max uses (optional)</Label>
<Input
id="maxUses"
type="number"
placeholder="Unlimited"
value={maxUses}
onChange={(e) => setMaxUses(e.target.value)}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label htmlFor="maxUses">Max uses</Label>
<Input
id="maxUses"
type="number"
min="1"
placeholder="Unlimited"
value={maxUses}
onChange={(e) => setMaxUses(e.target.value)}
/>
</div>
<div>
<Label htmlFor="expiresIn">Expires in (hours)</Label>
<Input
id="expiresIn"
type="number"
min="1"
placeholder="Never"
value={expiresIn}
onChange={(e) => setExpiresIn(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button onClick={createInvite} disabled={loading}>

View File

@ -0,0 +1,127 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { Copy, Trash2, Mail, Clock, Hash } from "lucide-react";
interface Invite {
id: string;
token: string;
email: string | null;
maxUses: number | null;
uses: number;
expiresAt: string | null;
createdAt: string;
}
interface InviteListProps {
spaceSlug: string;
}
export function InviteList({ spaceSlug }: InviteListProps) {
const [invites, setInvites] = useState<Invite[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/spaces/${spaceSlug}/invites`)
.then((res) => res.json())
.then((data) => {
if (Array.isArray(data)) setInvites(data);
})
.catch(() => toast.error("Failed to load invites"))
.finally(() => setLoading(false));
}, [spaceSlug]);
async function revokeInvite(id: string) {
const res = await fetch(`/api/spaces/${spaceSlug}/invites/${id}`, { method: "DELETE" });
if (res.ok) {
setInvites((prev) => prev.filter((i) => i.id !== id));
toast.success("Invite revoked");
} else {
toast.error("Failed to revoke invite");
}
}
function copyLink(token: string) {
const rootDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN || "rvote.online";
const protocol = window.location.protocol;
const url = `${protocol}//${spaceSlug}.${rootDomain}/join?token=${token}`;
navigator.clipboard.writeText(url);
toast.success("Copied to clipboard");
}
function isExpired(invite: Invite) {
return invite.expiresAt ? new Date(invite.expiresAt) < new Date() : false;
}
function isMaxedOut(invite: Invite) {
return invite.maxUses !== null ? invite.uses >= invite.maxUses : false;
}
if (loading) {
return <div className="text-sm text-muted-foreground animate-pulse">Loading invites...</div>;
}
if (invites.length === 0) {
return (
<p className="text-sm text-muted-foreground">
No active invites. Use the button above to create one.
</p>
);
}
return (
<div className="space-y-2">
{invites.map((invite) => {
const expired = isExpired(invite);
const maxed = isMaxedOut(invite);
const inactive = expired || maxed;
return (
<Card key={invite.id} className={inactive ? "opacity-60" : ""}>
<CardContent className="py-3 px-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div className="flex flex-wrap items-center gap-2 text-sm">
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">
{invite.token.slice(0, 8)}...
</code>
{invite.email && (
<span className="flex items-center gap-1 text-muted-foreground">
<Mail className="h-3 w-3" />
{invite.email}
</span>
)}
<span className="flex items-center gap-1 text-muted-foreground">
<Hash className="h-3 w-3" />
{invite.uses}{invite.maxUses !== null ? `/${invite.maxUses}` : ""} uses
</span>
{invite.expiresAt && (
<span className="flex items-center gap-1 text-muted-foreground">
<Clock className="h-3 w-3" />
{expired ? "Expired" : `Expires ${new Date(invite.expiresAt).toLocaleDateString()}`}
</span>
)}
{expired && <Badge variant="destructive" className="text-xs">Expired</Badge>}
{maxed && <Badge variant="secondary" className="text-xs">Max uses reached</Badge>}
</div>
<div className="flex items-center gap-1">
{!inactive && (
<Button size="sm" variant="ghost" onClick={() => copyLink(invite.token)}>
<Copy className="h-3.5 w-3.5" />
</Button>
)}
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive" onClick={() => revokeInvite(invite.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
);
}

View File

@ -92,7 +92,7 @@ export function MemberList({ members: initialMembers, spaceSlug, isAdmin, curren
{members.map((member) => (
<Card key={member.id}>
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarFallback>
@ -109,7 +109,7 @@ export function MemberList({ members: initialMembers, spaceSlug, isAdmin, curren
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground flex items-center gap-3">
<div className="text-sm text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1">
<span>{member.user.email}</span>
<span className="flex items-center gap-1">
<Coins className="h-3 w-3 text-orange-500" />
@ -120,7 +120,7 @@ export function MemberList({ members: initialMembers, spaceSlug, isAdmin, curren
</div>
{isAdmin && member.user.id !== currentUserId && (
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1">
<Input
type="number"

View File

@ -94,7 +94,7 @@ export function ProposalCard({
isUpvoted ? "ring-1 ring-orange-500/30" : isDownvoted ? "ring-1 ring-blue-500/30" : ""
}`}>
{showVoting && isRanking && (
<div className="flex items-center justify-center py-3 px-4 bg-muted/50 border-r min-w-[80px]">
<div className="flex items-center justify-center py-2 px-2 sm:py-3 sm:px-4 bg-muted/50 border-r min-w-[60px] sm:min-w-[80px]">
<VoteButtons
proposalId={proposal.id}
currentScore={score}
@ -113,7 +113,7 @@ export function ProposalCard({
href={`/proposals/${proposal.id}`}
className="hover:underline"
>
<h3 className="font-semibold text-lg leading-tight line-clamp-2">
<h3 className="font-semibold text-base sm:text-lg leading-tight line-clamp-2">
{proposal.title}
</h3>
</Link>
@ -159,7 +159,7 @@ export function ProposalCard({
</CardContent>
<CardFooter className="pt-2 pb-4 text-xs text-muted-foreground">
<div className="flex items-center gap-4">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<span className="flex items-center gap-1">
<User className="h-3 w-3" />
{proposal.author.name || proposal.author.email.split("@")[0]}

View File

@ -1,14 +1,22 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { useSpace } from "@/components/SpaceProvider";
import { FileText, Users, Settings, LayoutDashboard, Coins } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { FileText, Users, Settings, LayoutDashboard, Coins, Menu } from "lucide-react";
export function SpaceNav() {
const pathname = usePathname();
const { space, membership } = useSpace();
const [open, setOpen] = useState(false);
const links = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
@ -26,7 +34,8 @@ export function SpaceNav() {
<div className="border-b bg-card/50">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-12">
<div className="flex items-center gap-1">
{/* Desktop nav */}
<div className="hidden sm:flex items-center gap-1">
{links.map((link) => {
const isActive = cleanPath === link.href || (link.href !== "/" && cleanPath.startsWith(link.href));
return (
@ -45,13 +54,65 @@ export function SpaceNav() {
);
})}
</div>
{/* Mobile hamburger */}
<div className="sm:hidden">
<Sheet open={open} onOpenChange={setOpen}>
<Button variant="ghost" size="sm" onClick={() => setOpen(true)} className="gap-1.5">
<Menu className="h-4 w-4" />
<span className="text-sm font-medium">{space.name}</span>
</Button>
<SheetContent side="left" className="w-64">
<SheetHeader>
<SheetTitle>{space.name}</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-1 px-6">
{links.map((link) => {
const isActive = cleanPath === link.href || (link.href !== "/" && cleanPath.startsWith(link.href));
return (
<Link
key={link.href}
href={link.href}
onClick={() => setOpen(false)}
className={`flex items-center gap-3 px-3 py-3 rounded-md text-base transition-colors ${
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
<link.icon className="h-5 w-5" />
{link.label}
</Link>
);
})}
</nav>
{membership && (
<div className="mt-auto px-6 pb-6 flex items-center gap-2 text-sm text-muted-foreground">
<Coins className="h-4 w-4 text-orange-500" />
<span className="font-medium text-orange-600">{membership.credits}</span>
<span>credits</span>
</div>
)}
</SheetContent>
</Sheet>
</div>
{/* Desktop credits */}
{membership && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="hidden sm:flex items-center gap-2 text-sm text-muted-foreground">
<Coins className="h-4 w-4 text-orange-500" />
<span className="font-medium text-orange-600">{membership.credits}</span>
<span>credits</span>
</div>
)}
{/* Mobile credits (compact) */}
{membership && (
<div className="sm:hidden flex items-center gap-1.5 text-sm">
<Coins className="h-4 w-4 text-orange-500" />
<span className="font-medium text-orange-600">{membership.credits}</span>
</div>
)}
</div>
</div>
</div>

View File

@ -226,15 +226,15 @@ export function VoteButtons({
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
className="h-8 w-8 p-0"
onClick={cancelPending}
title="Cancel"
>
<X className="h-3 w-3" />
<X className="h-4 w-4" />
</Button>
<Button
size="sm"
className={`h-6 px-2 text-xs ${
className={`h-8 px-3 text-xs ${
pendingWeight > 0
? "bg-orange-500 hover:bg-orange-600"
: "bg-blue-500 hover:bg-blue-600"
@ -244,10 +244,10 @@ export function VoteButtons({
title="Confirm vote"
>
{isVoting ? (
<Loader2 className="h-3 w-3 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Check className="h-3 w-3 mr-1" />
<Check className="h-4 w-4 mr-1" />
Cast
</>
)}

147
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,147 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
side?: "top" | "bottom" | "left" | "right"
}) {
return (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background fixed z-50 flex flex-col gap-4 shadow-lg outline-none transition-transform duration-300 ease-in-out",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 max-w-sm border-l",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 max-w-sm border-r",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 border-t",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close
data-slot="sheet-close"
className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
>
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-2 p-6 pb-0", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-6 pt-0", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="sheet-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
}