diff --git a/src/app/page.tsx b/src/app/page.tsx index 0c7567e..6a4c1bb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -24,7 +24,7 @@ export default function HomePage() { return (
{/* Hero section */} -
+
@@ -32,7 +32,7 @@ export default function HomePage() { Part of the rSpace Ecosystem -

+

Democratic
Backlog Prioritization

@@ -196,11 +196,11 @@ export default function HomePage() {

Vote Cost Calculator

-
+
{[1, 2, 3, 4, 5].map((votes) => (
{votes}
diff --git a/src/app/s/[slug]/join/page.tsx b/src/app/s/[slug]/join/page.tsx index 0b21935..bbe9680 100644 --- a/src/app/s/[slug]/join/page.tsx +++ b/src/app/s/[slug]/join/page.tsx @@ -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({ 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 ( -
+ return ( +
+ {state.type === "loading" && ( + + +
+
+
+
+
+ + + )} + + {state.type === "error" && ( -

{error}

+
+ +
+

{state.message}

-
- ); - } + )} - if (!inviteInfo) { - return ( -
- Loading invite... -
- ); - } + {state.type === "invite" && ( + + +
+ +
+ Join {state.info.space.name} + + You've been invited to join this space. + + {state.info.space.description && ( +

{state.info.space.description}

+ )} +
+ + + +
+ )} - return ( -
- - -
- -
- Join {inviteInfo.spaceName} - - You've been invited to join this space. - -
- - - -
+ {state.type === "joined" && ( + + +
+ +
+

Welcome to {state.spaceName}!

+

+ You've been given {state.startingCredits} credits to start voting on proposals. +

+ +
+
+ )} + + {state.type === "already_member" && ( + + +
+ +
+

Already a member

+

+ You're already a member of {state.spaceName}. +

+ +
+
+ )}
); } diff --git a/src/app/s/[slug]/layout.tsx b/src/app/s/[slug]/layout.tsx index ea60688..d579fbc 100644 --- a/src/app/s/[slug]/layout.tsx +++ b/src/app/s/[slug]/layout.tsx @@ -52,14 +52,14 @@ export default async function SpaceLayout({ >
-

{space.name}

+

{space.name}

{space.description && (

{space.description}

)}
-
+
{children}
diff --git a/src/app/s/[slug]/members/page.tsx b/src/app/s/[slug]/members/page.tsx index 2822733..127bab9 100644 --- a/src/app/s/[slug]/members/page.tsx +++ b/src/app/s/[slug]/members/page.tsx @@ -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 (
-
+

Members

@@ -54,6 +55,13 @@ export default async function SpaceMembersPage({ isAdmin={membership.role === "ADMIN"} currentUserId={session.user.id} /> + + {membership.role === "ADMIN" && ( +

+

Active Invites

+ +
+ )}
); } diff --git a/src/app/s/[slug]/page.tsx b/src/app/s/[slug]/page.tsx index f5cb063..61e1ce0 100644 --- a/src/app/s/[slug]/page.tsx +++ b/src/app/s/[slug]/page.tsx @@ -36,25 +36,25 @@ export default async function SpaceDashboard({
-
{rankingCount}
+
{rankingCount}

Being Ranked

-
{votingCount}
+
{votingCount}

In Voting

-
{passedCount}
+
{passedCount}

Passed

-
{memberCount}
+
{memberCount}

Members

@@ -62,7 +62,7 @@ export default async function SpaceDashboard({ {/* Top proposals */}
-
+

Top Proposals

@@ -84,7 +90,7 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) { {!inviteUrl ? (
- + setEmail(e.target.value)} /> +

+ If set, only this email address can use the invite link. +

-
- - setMaxUses(e.target.value)} - /> +
+
+ + setMaxUses(e.target.value)} + /> +
+
+ + setExpiresIn(e.target.value)} + /> +
+ )} + +
+
+ + + ); + })} +
+ ); +} diff --git a/src/components/MemberList.tsx b/src/components/MemberList.tsx index 7e4fd94..fcfcca5 100644 --- a/src/components/MemberList.tsx +++ b/src/components/MemberList.tsx @@ -92,7 +92,7 @@ export function MemberList({ members: initialMembers, spaceSlug, isAdmin, curren {members.map((member) => ( -
+
@@ -109,7 +109,7 @@ export function MemberList({ members: initialMembers, spaceSlug, isAdmin, curren )}
-
+
{member.user.email} @@ -120,7 +120,7 @@ export function MemberList({ members: initialMembers, spaceSlug, isAdmin, curren
{isAdmin && member.user.id !== currentUserId && ( -
+
{showVoting && isRanking && ( -
+
-

+

{proposal.title}

@@ -159,7 +159,7 @@ export function ProposalCard({ -
+
{proposal.author.name || proposal.author.email.split("@")[0]} diff --git a/src/components/SpaceNav.tsx b/src/components/SpaceNav.tsx index fc9fe6e..89596cb 100644 --- a/src/components/SpaceNav.tsx +++ b/src/components/SpaceNav.tsx @@ -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() {
-
+ {/* Desktop nav */} +
{links.map((link) => { const isActive = cleanPath === link.href || (link.href !== "/" && cleanPath.startsWith(link.href)); return ( @@ -45,13 +54,65 @@ export function SpaceNav() { ); })}
+ + {/* Mobile hamburger */} +
+ + + + + {space.name} + + + {membership && ( +
+ + {membership.credits} + credits +
+ )} +
+
+
+ + {/* Desktop credits */} {membership && ( -
+
{membership.credits} credits
)} + + {/* Mobile credits (compact) */} + {membership && ( +
+ + {membership.credits} +
+ )}
diff --git a/src/components/VoteButtons.tsx b/src/components/VoteButtons.tsx index 67f7121..43be344 100644 --- a/src/components/VoteButtons.tsx +++ b/src/components/VoteButtons.tsx @@ -226,15 +226,15 @@ export function VoteButtons({