diff --git a/src/app/demo/page.tsx b/src/app/demo/page.tsx index 2acaf67..58c4e1b 100644 --- a/src/app/demo/page.tsx +++ b/src/app/demo/page.tsx @@ -12,10 +12,10 @@ export default function DemoPage() { Interactive Demo -

Try Quadratic Proposal Ranking

+

Live Community Polls

- Experience how rVote works without creating an account. Click the vote - arrows to rank proposals—watch how quadratic costs scale in real-time. + These polls are synced in real-time across the entire r* ecosystem via rSpace. + Vote on options and watch tallies update live for everyone.

diff --git a/src/app/page.tsx b/src/app/page.tsx index 6a4c1bb..55aacc0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -124,11 +124,11 @@ export default function HomePage() {
- Interactive Demo + Live Demo

Try It Yourself

- Click the vote arrows to rank proposals. Watch how quadratic costs scale in real-time. + Vote on live polls synced across the r* ecosystem. Changes appear in real-time for everyone.

diff --git a/src/components/InteractiveDemo.tsx b/src/components/InteractiveDemo.tsx index 0b199ef..0ed9557 100644 --- a/src/components/InteractiveDemo.tsx +++ b/src/components/InteractiveDemo.tsx @@ -1,372 +1,239 @@ "use client"; -import { useState } from "react"; +import { useDemoSync, DemoShape } from "@/lib/demo-sync"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { - ChevronUp, - ChevronDown, - Check, - X, + Plus, Minus, RotateCcw, - Coins, - TrendingUp, + Vote, + Users, Clock, + Wifi, + WifiOff, + Loader2, } from "lucide-react"; -interface DemoProposal { - id: number; - title: string; - description: string; - score: number; - userVote: number; - pendingVote: number; - stage: "ranking" | "voting"; - yesVotes: number; - noVotes: number; +// ── Types ──────────────────────────────────────────────────────────── + +interface PollOption { + label: string; + votes: number; } -const initialProposals: DemoProposal[] = [ - { - id: 1, - title: "Add dark mode toggle to the dashboard", - description: "Implement a system-aware dark/light theme switch so users can choose their preferred viewing mode", - score: 72, userVote: 0, pendingVote: 0, stage: "ranking", yesVotes: 0, noVotes: 0, - }, - { - id: 2, - title: "Build mobile-responsive voting interface", - description: "Redesign the voting UI to work seamlessly on phones and tablets so members can vote on the go", - score: 58, userVote: 0, pendingVote: 0, stage: "ranking", yesVotes: 0, noVotes: 0, - }, - { - id: 3, - title: "Add email notifications for promoted proposals", - description: "Send members an email when a proposal they voted on advances to the pass/fail voting stage", - score: 41, userVote: 0, pendingVote: 0, stage: "ranking", yesVotes: 0, noVotes: 0, - }, - { - id: 4, - title: "Create public API for proposal data", - description: "Expose a read-only REST API so external tools and dashboards can display proposal rankings", - score: 35, userVote: 0, pendingVote: 0, stage: "ranking", yesVotes: 0, noVotes: 0, - }, - { - id: 5, - title: "Add proposal tagging and filtering", - description: "Let authors tag proposals by category (feature, bug, process) and allow users to filter the list", - score: 23, userVote: 0, pendingVote: 0, stage: "ranking", yesVotes: 0, noVotes: 0, - }, -]; +interface DemoPoll extends DemoShape { + question: string; + options: PollOption[]; + totalVoters: number; + status: "active" | "closed"; + endsAt: string; +} + +function isPoll(shape: DemoShape): shape is DemoPoll { + return shape.type === "demo-poll" && Array.isArray((shape as DemoPoll).options); +} + +// ── Component ──────────────────────────────────────────────────────── export function InteractiveDemo() { - const [credits, setCredits] = useState(100); - const [proposals, setProposals] = useState(initialProposals); + const { shapes, updateShape, connected, resetDemo } = useDemoSync({ + filter: ["demo-poll"], + }); - const maxWeight = Math.floor(Math.sqrt(credits)); + const polls = Object.values(shapes).filter(isPoll); + const loading = !connected && polls.length === 0; - function handleUpvote(proposalId: number) { - setProposals((prev) => - prev.map((p) => { - if (p.id !== proposalId) return p; - if (p.userVote > 0) { - const refund = p.userVote * p.userVote; - setCredits((c) => c + refund); - return { ...p, score: p.score - p.userVote, userVote: 0, pendingVote: 0 }; - } - if (p.userVote < 0) return p; - const newPending = p.pendingVote + 1; - const newCost = newPending * newPending; - if (newCost <= credits && newPending <= maxWeight) { - return { ...p, pendingVote: newPending }; - } - return p; - }) - ); + function handleVote(poll: DemoPoll, optionIndex: number, delta: number) { + const updatedOptions = poll.options.map((opt, i) => { + if (i !== optionIndex) return opt; + return { ...opt, votes: Math.max(0, opt.votes + delta) }; + }); + updateShape(poll.id, { options: updatedOptions }); } - function handleDownvote(proposalId: number) { - setProposals((prev) => - prev.map((p) => { - if (p.id !== proposalId) return p; - if (p.userVote < 0) { - const refund = p.userVote * p.userVote; - setCredits((c) => c + refund); - return { ...p, score: p.score - p.userVote, userVote: 0, pendingVote: 0 }; - } - if (p.userVote > 0) return p; - const newPending = p.pendingVote - 1; - const newCost = newPending * newPending; - if (newCost <= credits && Math.abs(newPending) <= maxWeight) { - return { ...p, pendingVote: newPending }; - } - return p; - }) - ); + async function handleReset() { + try { + await resetDemo(); + } catch (err) { + console.error("Reset failed:", err); + } } - function cancelPending(proposalId: number) { - setProposals((prev) => - prev.map((p) => (p.id === proposalId ? { ...p, pendingVote: 0 } : p)) - ); + // Total votes across all options in a poll + function totalVotes(poll: DemoPoll): number { + return poll.options.reduce((sum, opt) => sum + opt.votes, 0); } - function confirmVote(proposalId: number) { - setProposals((prev) => - prev.map((p) => { - if (p.id !== proposalId || p.pendingVote === 0) return p; - const cost = p.pendingVote * p.pendingVote; - const newScore = p.score + p.pendingVote; - const promoted = newScore >= 100 && p.stage === "ranking"; - setCredits((c) => c - cost); - return { - ...p, score: newScore, userVote: p.pendingVote, pendingVote: 0, - stage: promoted ? "voting" : p.stage, - yesVotes: promoted ? 8 : p.yesVotes, noVotes: promoted ? 3 : p.noVotes, - }; - }) - ); + // Format the deadline + function formatDeadline(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diff = date.getTime() - now.getTime(); + if (diff <= 0) return "Voting closed"; + const days = Math.ceil(diff / (1000 * 60 * 60 * 24)); + return `${days} day${days !== 1 ? "s" : ""} left`; } - function castFinalVote(proposalId: number, vote: "yes" | "no" | "abstain") { - setProposals((prev) => - prev.map((p) => { - if (p.id === proposalId) { - return { - ...p, - yesVotes: vote === "yes" ? p.yesVotes + 1 : p.yesVotes, - noVotes: vote === "no" ? p.noVotes + 1 : p.noVotes, - }; - } - return p; - }) - ); - } - - function resetDemo() { - setCredits(100); - setProposals(initialProposals); - } - - const rankingProposals = proposals.filter((p) => p.stage === "ranking").sort((a, b) => b.score - a.score); - const votingProposals = proposals.filter((p) => p.stage === "voting"); - return (
- {/* Credits display */} - - -
-
-
- - {credits} - credits -
- - Max vote: ±{maxWeight} - -
- -
-
-
- - {/* Quadratic cost explainer */} - - - - - Quadratic Voting Cost - - Each additional vote costs exponentially more credits - - -
- {[1, 2, 3, 4, 5].map((w) => ( -
-
+{w}
-
vote{w > 1 ? "s" : ""}
-
{w * w}¢
-
- ))} -
-
-
- - {/* Ranking stage */} -
+ {/* Connection status + Reset */} +
- Stage 1 -

Quadratic Ranking

- Score +100 to advance → + + {connected ? ( + + ) : ( + + )} + {connected ? "Connected" : "Disconnected"} + + + Live — synced across all r* demos +
+ +
-
- {rankingProposals.map((proposal) => { - const hasPending = proposal.pendingVote !== 0; - const hasVoted = proposal.userVote !== 0; - const pendingCost = proposal.pendingVote * proposal.pendingVote; - const displayScore = hasPending ? proposal.score + proposal.pendingVote : proposal.score; - const progressPercent = Math.min((displayScore / 100) * 100, 100); - const isUpvoted = (hasVoted && proposal.userVote > 0) || proposal.pendingVote > 0; - const isDownvoted = (hasVoted && proposal.userVote < 0) || proposal.pendingVote < 0; + {/* Loading state */} + {loading && ( + + + +

Connecting to rSpace...

+
+
+ )} - return ( -
0 ? "ring-2 ring-orange-500/50" : "ring-2 ring-blue-500/50" - : "" - }`} - > -
- - {displayScore} - + {/* No polls found */} + {!loading && polls.length === 0 && ( + + + +

No polls found. Try resetting the demo.

+
+
+ )} + + {/* Poll cards */} + {polls.map((poll) => { + const total = totalVotes(poll); + const maxVotes = Math.max(...poll.options.map((o) => o.votes), 1); + + return ( + + +
+
+ + + {poll.question} +
- -
-

{proposal.title}

-

{proposal.description}

-
-
- Progress to voting stage - - {displayScore}/100 - -
-
-
-
-
- {hasPending && ( -
- 0 - ? "border-orange-500/50 text-orange-600 bg-orange-500/10" - : "border-blue-500/50 text-blue-600 bg-blue-500/10" - }> - {proposal.pendingVote > 0 ? "+" : ""}{proposal.pendingVote} vote = {pendingCost} credits - - - -
- )} - {hasVoted && !hasPending && ( -
- 0 - ? "bg-orange-500/20 text-orange-600" : "bg-blue-500/20 text-blue-600" - }> - You voted: {proposal.userVote > 0 ? "+" : ""}{proposal.userVote} - -
- )} +
+ + {poll.status === "active" ? "Active" : "Closed"} +
- ); - })} -
-
+
+ + + {poll.totalVoters} voters + + + + {formatDeadline(poll.endsAt)} + + + {total} total vote{total !== 1 ? "s" : ""} + +
+ - {/* Voting stage */} - {votingProposals.length > 0 && ( -
-
- Stage 2 -

Pass/Fail Voting

- One member = one vote -
- {votingProposals.map((proposal) => { - const total = proposal.yesVotes + proposal.noVotes; - const yesPercent = total > 0 ? (proposal.yesVotes / total) * 100 : 50; - return ( - - -
- {proposal.title} -
- 6 days left + + {poll.options.map((option, idx) => { + const pct = total > 0 ? (option.votes / total) * 100 : 0; + const barWidth = maxVotes > 0 ? (option.votes / maxVotes) * 100 : 0; + + return ( +
+ {/* Vote buttons */} +
+ + + {option.votes} + + +
+ + {/* Option label + progress bar */} +
+
+ + {option.label} + + + {Math.round(pct)}% + +
+
+
+
- {proposal.description} - - -
-
-
-
-
-
- {proposal.yesVotes} Yes ({Math.round(yesPercent)}%) - {proposal.noVotes} No ({Math.round(100 - yesPercent)}%) -
-
-
- - - -
- - - ); - })} -
- )} + ); + })} + + + ); + })}
); } diff --git a/src/lib/demo-sync.ts b/src/lib/demo-sync.ts new file mode 100644 index 0000000..6487586 --- /dev/null +++ b/src/lib/demo-sync.ts @@ -0,0 +1,221 @@ +/** + * useDemoSync — lightweight React hook for real-time demo data via rSpace + * + * Connects to rSpace WebSocket in JSON mode (no Automerge bundle needed). + * All demo pages share the "demo" community, so changes in one app + * propagate to every other app viewing the same shapes. + * + * Usage: + * const { shapes, updateShape, deleteShape, connected, resetDemo } = useDemoSync({ + * filter: ['folk-note', 'folk-notebook'], // optional: only these shape types + * }); + */ + +import { useEffect, useRef, useState, useCallback } from 'react'; + +export interface DemoShape { + type: string; + id: string; + x: number; + y: number; + width: number; + height: number; + rotation: number; + [key: string]: unknown; +} + +interface UseDemoSyncOptions { + /** Community slug (default: 'demo') */ + slug?: string; + /** Only subscribe to these shape types */ + filter?: string[]; + /** rSpace server URL (default: auto-detect based on environment) */ + serverUrl?: string; +} + +interface UseDemoSyncReturn { + /** Current shapes (filtered if filter option set) */ + shapes: Record; + /** Update a shape by ID (partial update merged with existing) */ + updateShape: (id: string, data: Partial) => void; + /** Delete a shape by ID */ + deleteShape: (id: string) => void; + /** Whether WebSocket is connected */ + connected: boolean; + /** Reset demo to seed state */ + resetDemo: () => Promise; +} + +const DEFAULT_SLUG = 'demo'; +const RECONNECT_BASE_MS = 1000; +const RECONNECT_MAX_MS = 30000; +const PING_INTERVAL_MS = 30000; + +function getDefaultServerUrl(): string { + if (typeof window === 'undefined') return 'https://rspace.online'; + // In development, use localhost + if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { + return `http://${window.location.hostname}:3000`; + } + return 'https://rspace.online'; +} + +export function useDemoSync(options?: UseDemoSyncOptions): UseDemoSyncReturn { + const slug = options?.slug ?? DEFAULT_SLUG; + const filter = options?.filter; + const serverUrl = options?.serverUrl ?? getDefaultServerUrl(); + + const [shapes, setShapes] = useState>({}); + const [connected, setConnected] = useState(false); + + const wsRef = useRef(null); + const reconnectAttemptRef = useRef(0); + const reconnectTimerRef = useRef | null>(null); + const pingTimerRef = useRef | null>(null); + const mountedRef = useRef(true); + + // Stable filter reference for use in callbacks + const filterRef = useRef(filter); + filterRef.current = filter; + + const applyFilter = useCallback((allShapes: Record): Record => { + const f = filterRef.current; + if (!f || f.length === 0) return allShapes; + const filtered: Record = {}; + for (const [id, shape] of Object.entries(allShapes)) { + if (f.includes(shape.type)) { + filtered[id] = shape; + } + } + return filtered; + }, []); + + const connect = useCallback(() => { + if (!mountedRef.current) return; + + // Build WebSocket URL + const wsProtocol = serverUrl.startsWith('https') ? 'wss' : 'ws'; + const host = serverUrl.replace(/^https?:\/\//, ''); + const wsUrl = `${wsProtocol}://${host}/ws/${slug}?mode=json`; + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + if (!mountedRef.current) return; + setConnected(true); + reconnectAttemptRef.current = 0; + + // Start ping keepalive + pingTimerRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() })); + } + }, PING_INTERVAL_MS); + }; + + ws.onmessage = (event) => { + if (!mountedRef.current) return; + try { + const msg = JSON.parse(event.data); + if (msg.type === 'snapshot' && msg.shapes) { + setShapes(applyFilter(msg.shapes)); + } + // pong and error messages are silently handled + } catch { + // ignore parse errors + } + }; + + ws.onclose = () => { + if (!mountedRef.current) return; + setConnected(false); + cleanup(); + scheduleReconnect(); + }; + + ws.onerror = () => { + // onclose will fire after onerror, so reconnect is handled there + }; + }, [slug, serverUrl, applyFilter]); + + const cleanup = useCallback(() => { + if (pingTimerRef.current) { + clearInterval(pingTimerRef.current); + pingTimerRef.current = null; + } + }, []); + + const scheduleReconnect = useCallback(() => { + if (!mountedRef.current) return; + const attempt = reconnectAttemptRef.current; + const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, attempt), RECONNECT_MAX_MS); + reconnectAttemptRef.current = attempt + 1; + + reconnectTimerRef.current = setTimeout(() => { + if (mountedRef.current) connect(); + }, delay); + }, [connect]); + + // Connect on mount + useEffect(() => { + mountedRef.current = true; + connect(); + + return () => { + mountedRef.current = false; + cleanup(); + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + if (wsRef.current) { + wsRef.current.onclose = null; // prevent reconnect on unmount + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [connect, cleanup]); + + const updateShape = useCallback((id: string, data: Partial) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + // Optimistic local update + setShapes((prev) => { + const existing = prev[id]; + if (!existing) return prev; + const updated = { ...existing, ...data, id }; + const f = filterRef.current; + if (f && f.length > 0 && !f.includes(updated.type)) return prev; + return { ...prev, [id]: updated }; + }); + + // Send to server + ws.send(JSON.stringify({ type: 'update', id, data: { ...data, id } })); + }, []); + + const deleteShape = useCallback((id: string) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + // Optimistic local delete + setShapes((prev) => { + const { [id]: _, ...rest } = prev; + return rest; + }); + + ws.send(JSON.stringify({ type: 'delete', id })); + }, []); + + const resetDemo = useCallback(async () => { + const res = await fetch(`${serverUrl}/api/communities/demo/reset`, { method: 'POST' }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Reset failed: ${res.status} ${body}`); + } + // The server will broadcast new snapshot via WebSocket + }, [serverUrl]); + + return { shapes, updateShape, deleteShape, connected, resetDemo }; +}