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}
-
-
-
-
- Reset
-
-
-
-
-
- {/* 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
+
+
+
+ Reset Demo
+
+
-
- {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"
- : ""
- }`}
- >
-
-
handleUpvote(proposal.id)}
- >
-
-
-
{displayScore}
-
0 ? "opacity-30 pointer-events-none" : ""}`}
- onClick={() => handleDownvote(proposal.id)}
- >
-
-
+ {/* 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
-
- cancelPending(proposal.id)}>
- Cancel
-
- 0 ? "bg-orange-500 hover:bg-orange-600" : "bg-blue-500 hover:bg-blue-600"}`}
- onClick={() => confirmVote(proposal.id)}
- >
- Confirm
-
-
- )}
- {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 */}
+
+
handleVote(poll, idx, -1)}
+ disabled={!connected || option.votes <= 0}
+ >
+
+
+
+ {option.votes}
+
+
handleVote(poll, idx, 1)}
+ disabled={!connected}
+ >
+
+
+
+
+ {/* Option label + progress bar */}
+
+
+
+ {option.label}
+
+
+ {Math.round(pct)}%
+
+
+
- {proposal.description}
-
-
-
-
-
- {proposal.yesVotes} Yes ({Math.round(yesPercent)}%)
- {proposal.noVotes} No ({Math.round(100 - yesPercent)}%)
-
-
-
- castFinalVote(proposal.id, "yes")}>
- Yes
-
- castFinalVote(proposal.id, "no")}>
- No
-
- castFinalVote(proposal.id, "abstain")}>
- Abstain
-
-
-
-
- );
- })}
-
- )}
+ );
+ })}
+
+
+ );
+ })}
);
}
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 };
+}