Compare commits

..

No commits in common. "75b148e77223568d827b039050210ee08f73b242" and "8895c0fb759bfddf357ff26e50ff8c1beb9a6062" have entirely different histories.

147 changed files with 251 additions and 4530 deletions

2
.gitignore vendored
View File

@ -6,7 +6,7 @@ dist/
# Data storage
data/
!modules/rdata/
!modules/data/
# IDE
.vscode/

View File

@ -35,7 +35,7 @@ services:
rbooks-standalone:
<<: *standalone-base
container_name: rbooks-standalone
command: ["bun", "run", "modules/rbooks/standalone.ts"]
command: ["bun", "run", "modules/books/standalone.ts"]
volumes:
- rspace-books:/data/books
environment:
@ -51,7 +51,7 @@ services:
rpubs-standalone:
<<: *standalone-base
container_name: rpubs-standalone
command: ["bun", "run", "modules/rpubs/standalone.ts"]
command: ["bun", "run", "modules/pubs/standalone.ts"]
labels:
<<: *traefik-enabled
traefik.http.routers.rpubs-sa.rule: Host(`rpubs.online`)
@ -62,7 +62,7 @@ services:
rcart-standalone:
<<: *standalone-base
container_name: rcart-standalone
command: ["bun", "run", "modules/rcart/standalone.ts"]
command: ["bun", "run", "modules/cart/standalone.ts"]
environment:
<<: *base-env
FLOW_SERVICE_URL: http://payment-flow:3010
@ -86,7 +86,7 @@ services:
rswag-standalone:
<<: *standalone-base
container_name: rswag-standalone
command: ["bun", "run", "modules/rswag/standalone.ts"]
command: ["bun", "run", "modules/swag/standalone.ts"]
volumes:
- rspace-swag:/data/swag-artifacts
environment:
@ -102,7 +102,7 @@ services:
rchoices-standalone:
<<: *standalone-base
container_name: rchoices-standalone
command: ["bun", "run", "modules/rchoices/standalone.ts"]
command: ["bun", "run", "modules/choices/standalone.ts"]
labels:
<<: *traefik-enabled
traefik.http.routers.rchoices-sa.rule: Host(`rchoices.online`)
@ -113,7 +113,7 @@ services:
rfunds-standalone:
<<: *standalone-base
container_name: rfunds-standalone
command: ["bun", "run", "modules/rfunds/standalone.ts"]
command: ["bun", "run", "modules/funds/standalone.ts"]
environment:
<<: *base-env
FLOW_SERVICE_URL: http://payment-flow:3010
@ -133,7 +133,7 @@ services:
rfiles-standalone:
<<: *standalone-base
container_name: rfiles-standalone
command: ["bun", "run", "modules/rfiles/standalone.ts"]
command: ["bun", "run", "modules/files/standalone.ts"]
volumes:
- rspace-files:/data/files
environment:
@ -149,7 +149,7 @@ services:
rforum-standalone:
<<: *standalone-base
container_name: rforum-standalone
command: ["bun", "run", "modules/rforum/standalone.ts"]
command: ["bun", "run", "modules/forum/standalone.ts"]
environment:
<<: *base-env
HETZNER_API_TOKEN: ${HETZNER_API_TOKEN}
@ -165,7 +165,7 @@ services:
rvote-standalone:
<<: *standalone-base
container_name: rvote-standalone
command: ["bun", "run", "modules/rvote/standalone.ts"]
command: ["bun", "run", "modules/vote/standalone.ts"]
labels:
<<: *traefik-enabled
traefik.http.routers.rvote-sa.rule: Host(`rvote.online`)
@ -176,7 +176,7 @@ services:
rnotes-standalone:
<<: *standalone-base
container_name: rnotes-standalone
command: ["bun", "run", "modules/rnotes/standalone.ts"]
command: ["bun", "run", "modules/notes/standalone.ts"]
labels:
<<: *traefik-enabled
traefik.http.routers.rnotes-sa.rule: Host(`rnotes.online`)
@ -187,7 +187,7 @@ services:
rmaps-standalone:
<<: *standalone-base
container_name: rmaps-standalone
command: ["bun", "run", "modules/rmaps/standalone.ts"]
command: ["bun", "run", "modules/maps/standalone.ts"]
environment:
<<: *base-env
MAPS_SYNC_URL: wss://sync.rmaps.online
@ -201,7 +201,7 @@ services:
rwork-standalone:
<<: *standalone-base
container_name: rwork-standalone
command: ["bun", "run", "modules/rwork/standalone.ts"]
command: ["bun", "run", "modules/work/standalone.ts"]
labels:
<<: *traefik-enabled
traefik.http.routers.rwork-sa.rule: Host(`rwork.online`)
@ -212,7 +212,7 @@ services:
rtrips-standalone:
<<: *standalone-base
container_name: rtrips-standalone
command: ["bun", "run", "modules/rtrips/standalone.ts"]
command: ["bun", "run", "modules/trips/standalone.ts"]
environment:
<<: *base-env
OSRM_URL: http://osrm-backend:5000
@ -226,7 +226,7 @@ services:
rcal-standalone:
<<: *standalone-base
container_name: rcal-standalone
command: ["bun", "run", "modules/rcal/standalone.ts"]
command: ["bun", "run", "modules/cal/standalone.ts"]
labels:
<<: *traefik-enabled
traefik.http.routers.rcal-sa.rule: Host(`rcal.online`)
@ -237,7 +237,7 @@ services:
rnetwork-standalone:
<<: *standalone-base
container_name: rnetwork-standalone
command: ["bun", "run", "modules/rnetwork/standalone.ts"]
command: ["bun", "run", "modules/network/standalone.ts"]
environment:
<<: *base-env
TWENTY_API_URL: https://rnetwork.online
@ -252,7 +252,7 @@ services:
rtube-standalone:
<<: *standalone-base
container_name: rtube-standalone
command: ["bun", "run", "modules/rtube/standalone.ts"]
command: ["bun", "run", "modules/tube/standalone.ts"]
environment:
<<: *base-env
R2_ENDPOINT: ${R2_ENDPOINT}
@ -270,7 +270,7 @@ services:
rinbox-standalone:
<<: *standalone-base
container_name: rinbox-standalone
command: ["bun", "run", "modules/rinbox/standalone.ts"]
command: ["bun", "run", "modules/inbox/standalone.ts"]
environment:
<<: *base-env
IMAP_HOST: mail.rmail.online
@ -290,7 +290,7 @@ services:
rdata-standalone:
<<: *standalone-base
container_name: rdata-standalone
command: ["bun", "run", "modules/rdata/standalone.ts"]
command: ["bun", "run", "modules/data/standalone.ts"]
environment:
<<: *base-env
UMAMI_URL: https://analytics.rspace.online
@ -305,7 +305,7 @@ services:
rwallet-standalone:
<<: *standalone-base
container_name: rwallet-standalone
command: ["bun", "run", "modules/rwallet/standalone.ts"]
command: ["bun", "run", "modules/wallet/standalone.ts"]
labels:
<<: *traefik-enabled
traefik.http.routers.rwallet-sa.rule: Host(`rwallet.online`)

View File

@ -1,210 +0,0 @@
/**
* DemoSync vanilla JS class for real-time demo data via rSpace WebSocket.
*
* Mirrors the React useDemoSync hook but uses EventTarget for framework-free
* operation. Designed for module demo pages served as server-rendered HTML.
*
* Events:
* - "snapshot" detail: { shapes: Record<string, DemoShape> }
* - "connected" WebSocket opened
* - "disconnected" WebSocket closed
*
* Usage:
* const sync = new DemoSync({ filter: ['demo-poll'] });
* sync.addEventListener('snapshot', (e) => renderUI(e.detail.shapes));
* sync.connect();
*/
export interface DemoShape {
type: string;
id: string;
x: number;
y: number;
width: number;
height: number;
rotation: number;
[key: string]: unknown;
}
export interface DemoSyncOptions {
/** Community slug (default: 'demo') */
slug?: string;
/** Only subscribe to these shape types */
filter?: string[];
/** rSpace server URL (default: auto-detect) */
serverUrl?: string;
}
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";
const host = window.location.hostname;
if (host === "localhost" || host === "127.0.0.1") {
return `http://${host}:3000`;
}
return "https://rspace.online";
}
export class DemoSync extends EventTarget {
private slug: string;
private filter: string[] | undefined;
private serverUrl: string;
private ws: WebSocket | null = null;
private reconnectAttempt = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private pingTimer: ReturnType<typeof setInterval> | null = null;
private destroyed = false;
shapes: Record<string, DemoShape> = {};
connected = false;
constructor(options?: DemoSyncOptions) {
super();
this.slug = options?.slug ?? DEFAULT_SLUG;
this.filter = options?.filter;
this.serverUrl = options?.serverUrl ?? getDefaultServerUrl();
}
connect(): void {
if (this.destroyed) return;
const wsProtocol = this.serverUrl.startsWith("https") ? "wss" : "ws";
const host = this.serverUrl.replace(/^https?:\/\//, "");
const wsUrl = `${wsProtocol}://${host}/ws/${this.slug}?mode=json`;
const ws = new WebSocket(wsUrl);
this.ws = ws;
ws.onopen = () => {
if (this.destroyed) return;
this.connected = true;
this.reconnectAttempt = 0;
this.dispatchEvent(new Event("connected"));
this.pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
}
}, PING_INTERVAL_MS);
};
ws.onmessage = (event) => {
if (this.destroyed) return;
try {
const msg = JSON.parse(event.data);
if (msg.type === "snapshot" && msg.shapes) {
this.shapes = this.applyFilter(msg.shapes);
this.dispatchEvent(
new CustomEvent("snapshot", {
detail: { shapes: this.shapes },
}),
);
}
} catch {
// ignore parse errors
}
};
ws.onclose = () => {
if (this.destroyed) return;
this.connected = false;
this.cleanup();
this.dispatchEvent(new Event("disconnected"));
this.scheduleReconnect();
};
ws.onerror = () => {
// onclose fires after onerror
};
}
updateShape(id: string, data: Partial<DemoShape>): void {
const ws = this.ws;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
// Optimistic local update
const existing = this.shapes[id];
if (existing) {
const updated = { ...existing, ...data, id };
if (this.filter && this.filter.length > 0 && !this.filter.includes(updated.type)) return;
this.shapes = { ...this.shapes, [id]: updated as DemoShape };
this.dispatchEvent(
new CustomEvent("snapshot", {
detail: { shapes: this.shapes },
}),
);
}
ws.send(JSON.stringify({ type: "update", id, data: { ...data, id } }));
}
deleteShape(id: string): void {
const ws = this.ws;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const { [id]: _, ...rest } = this.shapes;
this.shapes = rest;
this.dispatchEvent(
new CustomEvent("snapshot", { detail: { shapes: this.shapes } }),
);
ws.send(JSON.stringify({ type: "delete", id }));
}
async resetDemo(): Promise<void> {
const res = await fetch(`${this.serverUrl}/api/communities/demo/reset`, {
method: "POST",
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Reset failed: ${res.status} ${body}`);
}
}
destroy(): void {
this.destroyed = true;
this.cleanup();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.onclose = null;
this.ws.close();
this.ws = null;
}
}
private applyFilter(allShapes: Record<string, DemoShape>): Record<string, DemoShape> {
if (!this.filter || this.filter.length === 0) return allShapes;
const filtered: Record<string, DemoShape> = {};
for (const [id, shape] of Object.entries(allShapes)) {
if (this.filter.includes(shape.type)) {
filtered[id] = shape;
}
}
return filtered;
}
private cleanup(): void {
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
private scheduleReconnect(): void {
if (this.destroyed) return;
const delay = Math.min(
RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempt),
RECONNECT_MAX_MS,
);
this.reconnectAttempt++;
this.reconnectTimer = setTimeout(() => {
if (!this.destroyed) this.connect();
}, delay);
}
}

View File

@ -212,8 +212,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-book-shelf space-slug="${spaceSlug}"></folk-book-shelf>`,
scripts: `<script type="module" src="/modules/rbooks/folk-book-shelf.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rbooks/books.css">`,
scripts: `<script type="module" src="/modules/books/folk-book-shelf.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/books/books.css">`,
}));
});
@ -264,10 +264,10 @@ routes.get("/read/:id", async (c) => {
`,
modules: getModuleInfoList(),
theme: "dark",
head: `<link rel="stylesheet" href="/modules/rbooks/books.css">`,
head: `<link rel="stylesheet" href="/modules/books/books.css">`,
scripts: `
<script type="module">
import { FolkBookReader } from '/modules/rbooks/folk-book-reader.js';
import { FolkBookReader } from '/modules/books/folk-book-reader.js';
</script>
`,
});

View File

@ -9,12 +9,11 @@ import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell, renderDemoShell } from "../../server/shell";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
import { renderLanding } from "./landing";
import { renderDemo } from "./demo";
const routes = new Hono();
@ -377,18 +376,6 @@ routes.get("/api/context/:tool", async (c) => {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
if (space === "demo") {
return c.html(renderDemoShell({
title: "rCal Demo — rSpace",
moduleId: "rcal",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: renderDemo(),
scripts: `<script type="module" src="/modules/rcal/cal-demo.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcal/cal.css">`,
}));
}
return c.html(renderShell({
title: `${space} — Calendar | rSpace`,
moduleId: "rcal",
@ -396,8 +383,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-calendar-view space="${space}"></folk-calendar-view>`,
scripts: `<script type="module" src="/modules/rcal/folk-calendar-view.js?v=3"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcal/cal.css?v=2">`,
scripts: `<script type="module" src="/modules/cal/folk-calendar-view.js?v=3"></script>`,
styles: `<link rel="stylesheet" href="/modules/cal/cal.css?v=2">`,
}));
});
@ -409,7 +396,6 @@ export const calModule: RSpaceModule = {
routes,
standaloneDomain: "rcal.online",
landingPage: renderLanding,
demoPage: renderDemo,
feeds: [
{
id: "events",

View File

@ -10,13 +10,12 @@ import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell, renderDemoShell } from "../../server/shell";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import { depositOrderRevenue } from "./flow";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
import { renderLanding } from "./landing";
import { renderDemo } from "./demo";
const routes = new Hono();
@ -443,18 +442,6 @@ routes.post("/api/fulfill/resolve", async (c) => {
// ── Page route: shop ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
if (space === "demo") {
return c.html(renderDemoShell({
title: "rCart Demo — rSpace",
moduleId: "rcart",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: renderDemo(),
scripts: `<script type="module" src="/modules/rcart/cart-demo.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
}));
}
return c.html(renderShell({
title: `Shop | rSpace`,
moduleId: "rcart",
@ -462,8 +449,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-cart-shop space="${space}"></folk-cart-shop>`,
scripts: `<script type="module" src="/modules/rcart/folk-cart-shop.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
scripts: `<script type="module" src="/modules/cart/folk-cart-shop.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/cart/cart.css">`,
}));
});
@ -475,7 +462,6 @@ export const cartModule: RSpaceModule = {
routes,
standaloneDomain: "rcart.online",
landingPage: renderLanding,
demoPage: renderDemo,
feeds: [
{
id: "orders",

View File

@ -56,8 +56,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-choices-dashboard space="${spaceSlug}"></folk-choices-dashboard>`,
scripts: `<script type="module" src="/modules/rchoices/folk-choices-dashboard.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rchoices/choices.css">`,
scripts: `<script type="module" src="/modules/choices/folk-choices-dashboard.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/choices/choices.css">`,
}));
});

View File

@ -128,8 +128,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-analytics-view space="${space}"></folk-analytics-view>`,
scripts: `<script type="module" src="/modules/rdata/folk-analytics-view.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rdata/data.css">`,
scripts: `<script type="module" src="/modules/data/folk-analytics-view.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/data/data.css">`,
}));
});

View File

@ -389,8 +389,8 @@ routes.get("/", (c) => {
theme: "dark",
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
<folk-file-browser space="${spaceSlug}"></folk-file-browser>`,
scripts: `<script type="module" src="/modules/rfiles/folk-file-browser.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rfiles/files.css">`,
scripts: `<script type="module" src="/modules/files/folk-file-browser.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/files/files.css">`,
}));
});

View File

@ -180,8 +180,8 @@ routes.get("/", (c) => {
theme: "dark",
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
<folk-forum-dashboard space="${spaceSlug}"></folk-forum-dashboard>`,
scripts: `<script type="module" src="/modules/rforum/folk-forum-dashboard.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rforum/forum.css">`,
scripts: `<script type="module" src="/modules/forum/folk-forum-dashboard.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/forum/forum.css">`,
}));
});

View File

@ -160,9 +160,9 @@ class FolkFundsApp extends HTMLElement {
}
private getCssPath(): string {
// In rSpace: /modules/rfunds/funds.css | Standalone: /modules/rfunds/funds.css
// The shell always serves from /modules/rfunds/ in both modes
return "/modules/rfunds/funds.css";
// In rSpace: /modules/funds/funds.css | Standalone: /modules/funds/funds.css
// The shell always serves from /modules/funds/ in both modes
return "/modules/funds/funds.css";
}
private render() {

View File

@ -8,12 +8,11 @@ import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell, renderDemoShell } from "../../server/shell";
import { renderShell } from "../../server/shell";
import type { RSpaceModule } from "../../shared/module";
import { getModuleInfoList } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
import { renderLanding } from "./landing";
import { renderDemo } from "./demo";
const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010";
@ -190,27 +189,14 @@ routes.delete("/api/space-flows/:flowId", async (c) => {
// ─── Page routes ────────────────────────────────────────
const fundsScripts = `
<script type="module" src="/modules/rfunds/folk-funds-app.js"></script>
<script type="module" src="/modules/rfunds/folk-budget-river.js"></script>`;
<script type="module" src="/modules/funds/folk-funds-app.js"></script>
<script type="module" src="/modules/funds/folk-budget-river.js"></script>`;
const fundsStyles = `<link rel="stylesheet" href="/modules/rfunds/funds.css">`;
const fundsStyles = `<link rel="stylesheet" href="/modules/funds/funds.css">`;
// Landing page
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
if (spaceSlug === "demo") {
return c.html(renderDemoShell({
title: "rFunds Demo — rSpace",
moduleId: "rfunds",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
body: renderDemo(),
demoScripts: `<script type="module" src="/lib/demo-sync.js"></script>
<script type="module" src="/modules/rfunds/funds-demo.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rfunds/funds.css">`,
}));
}
return c.html(renderShell({
title: `rFunds — TBFF Flow Funding | rSpace`,
moduleId: "rfunds",
@ -218,8 +204,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-funds-app space="${spaceSlug}"></folk-funds-app>`,
scripts: `<script type="module" src="/modules/rfunds/folk-funds-app.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rfunds/funds.css">`,
scripts: `<script type="module" src="/modules/funds/folk-funds-app.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/funds/funds.css">`,
}));
});
@ -261,7 +247,6 @@ export const fundsModule: RSpaceModule = {
description: "Budget flows, river visualization, and treasury management",
routes,
landingPage: renderLanding,
demoPage: renderDemo,
standaloneDomain: "rfunds.online",
feeds: [
{

View File

@ -589,8 +589,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-inbox-client space="${space}"></folk-inbox-client>`,
scripts: `<script type="module" src="/modules/rinbox/folk-inbox-client.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rinbox/inbox.css">`,
scripts: `<script type="module" src="/modules/inbox/folk-inbox-client.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/inbox/inbox.css">`,
}));
});

View File

@ -141,8 +141,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-map-viewer space="${space}"></folk-map-viewer>`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=3"></script>`,
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css">`,
scripts: `<script type="module" src="/modules/maps/folk-map-viewer.js?v=3"></script>`,
styles: `<link rel="stylesheet" href="/modules/maps/maps.css">`,
}));
});
@ -156,9 +156,9 @@ routes.get("/:room", (c) => {
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css">`,
styles: `<link rel="stylesheet" href="/modules/maps/maps.css">`,
body: `<folk-map-viewer space="${space}" room="${room}"></folk-map-viewer>`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=3"></script>`,
scripts: `<script type="module" src="/modules/maps/folk-map-viewer.js?v=3"></script>`,
}));
});

View File

@ -239,8 +239,8 @@ routes.get("/", (c) => {
theme: "dark",
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
<folk-graph-viewer space="${space}"></folk-graph-viewer>`,
scripts: `<script type="module" src="/modules/rnetwork/folk-graph-viewer.js?v=3"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
scripts: `<script type="module" src="/modules/network/folk-graph-viewer.js?v=3"></script>`,
styles: `<link rel="stylesheet" href="/modules/network/network.css">`,
}));
});

View File

@ -9,12 +9,11 @@ import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell, renderDemoShell } from "../../server/shell";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
import { renderLanding } from "./landing";
import { renderDemo } from "./demo";
const routes = new Hono();
@ -363,19 +362,6 @@ routes.delete("/api/notes/:id", async (c) => {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
if (space === "demo") {
return c.html(renderDemoShell({
title: "rNotes Demo — rSpace",
moduleId: "rnotes",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: renderDemo(),
demoScripts: `<script type="module" src="/lib/demo-sync.js"></script>
<script type="module" src="/modules/rnotes/notes-demo.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css">`,
}));
}
return c.html(renderShell({
title: `${space} — Notes | rSpace`,
moduleId: "rnotes",
@ -383,8 +369,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-notes-app space="${space}"></folk-notes-app>`,
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css">`,
scripts: `<script type="module" src="/modules/notes/folk-notes-app.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/notes/notes.css">`,
}));
});
@ -395,7 +381,6 @@ export const notesModule: RSpaceModule = {
description: "Notebooks with rich-text notes, voice transcription, and collaboration",
routes,
landingPage: renderLanding,
demoPage: renderDemo,
standaloneDomain: "rnotes.online",
feeds: [
{

View File

@ -116,8 +116,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-photo-gallery space="${spaceSlug}"></folk-photo-gallery>`,
scripts: `<script type="module" src="/modules/rphotos/folk-photo-gallery.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rphotos/photos.css">`,
scripts: `<script type="module" src="/modules/photos/folk-photo-gallery.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/photos/photos.css">`,
}));
});

View File

@ -329,8 +329,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-pubs-editor space="${spaceSlug}"></folk-pubs-editor>`,
scripts: `<script type="module" src="/modules/rpubs/folk-pubs-editor.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rpubs/pubs.css">`,
scripts: `<script type="module" src="/modules/pubs/folk-pubs-editor.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/pubs/pubs.css">`,
}));
});

View File

@ -1,21 +0,0 @@
/**
* rCal demo tab switching and zoom controls (local state only, no WebSocket).
*
* Highlights the active tab when clicked. All tabs show the same
* calendar grid for now; this is purely visual feedback.
*/
const tabs = document.querySelectorAll<HTMLElement>("[data-cal-tab]");
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
tabs.forEach((t) => {
t.style.background = "transparent";
t.style.color = "#94a3b8";
});
tab.style.background = "rgba(99,102,241,0.15)";
tab.style.color = "#818cf8";
});
});
export {};

View File

@ -1,344 +0,0 @@
/**
* rCal demo page server-rendered HTML body.
*
* Static July 2026 calendar grid with Alpine Explorer trip events,
* tab switching (Temporal/Spatial/Lunar/Context), zoom panel,
* and feature cards. Entirely local state, no WebSocket.
*/
/* ─── Event Data ──────────────────────────────────────────── */
interface CalEvent {
day: number;
emoji: string;
label: string;
color: string;
bg: string;
}
const TRIP_EVENTS: CalEvent[] = [
{ day: 6, emoji: "\u2708\uFE0F", label: "Arrive Chamonix", color: "#2dd4bf", bg: "rgba(20,184,166,0.15)" },
{ day: 7, emoji: "\u{1F97E}", label: "Lac Blanc Hike", color: "#34d399", bg: "rgba(16,185,129,0.15)" },
{ day: 8, emoji: "\u{1F9D7}", label: "Aiguille du Midi", color: "#fbbf24", bg: "rgba(245,158,11,0.15)" },
{ day: 9, emoji: "\u{1F97E}", label: "Mer de Glace", color: "#34d399", bg: "rgba(16,185,129,0.15)" },
{ day: 10, emoji: "\u{1F682}", label: "Train to Zermatt", color: "#22d3ee", bg: "rgba(6,182,212,0.15)" },
{ day: 11, emoji: "\u{1F97E}", label: "Five Lakes Walk", color: "#34d399", bg: "rgba(16,185,129,0.15)" },
{ day: 12, emoji: "\u26F7", label: "Glacier Paradise", color: "#fbbf24", bg: "rgba(245,158,11,0.15)" },
{ day: 13, emoji: "\u{1F3DB}", label: "Alpine Museum", color: "#a78bfa", bg: "rgba(139,92,246,0.15)" },
{ day: 14, emoji: "\u{1F68C}", label: "Bus to Dolomites", color: "#22d3ee", bg: "rgba(6,182,212,0.15)" },
{ day: 15, emoji: "\u{1F97E}", label: "Tre Cime Circuit", color: "#34d399", bg: "rgba(16,185,129,0.15)" },
{ day: 16, emoji: "\u{1FA82}", label: "Paragliding", color: "#fbbf24", bg: "rgba(245,158,11,0.15)" },
{ day: 17, emoji: "\u{1F6F6}", label: "Lago di Braies", color: "#fbbf24", bg: "rgba(245,158,11,0.15)" },
{ day: 18, emoji: "\u{1F97E}", label: "Seceda Ridge", color: "#34d399", bg: "rgba(16,185,129,0.15)" },
{ day: 19, emoji: "\u{1F4F8}", label: "Rest & Photos", color: "#94a3b8", bg: "rgba(100,116,139,0.15)" },
{ day: 20, emoji: "\u2708\uFE0F", label: "Depart", color: "#2dd4bf", bg: "rgba(20,184,166,0.15)" },
];
const TABS = ["Temporal", "Spatial", "Lunar", "Context"];
const ZOOM_LEVELS = [
"Era", "Century", "Decade", "Year", "Quarter",
"Month", "Week", "Day", "Hour", "Minute",
];
const FEATURES = [
{
icon: "\u{1F50D}",
title: "Temporal Zoom",
desc: "Navigate seamlessly from geological eras down to individual minutes. The calendar adapts its grid density and label fidelity at every level.",
},
{
icon: "\u{1F30D}",
title: "Spatial Context",
desc: "Events are location-aware. Zoom the map and the calendar filters to show only events within the visible region.",
},
{
icon: "\u{1F319}",
title: "Lunar Cycles",
desc: "Overlay moon phases, tidal patterns, and seasonal markers. Useful for agriculture, ceremony, and natural rhythm tracking.",
},
{
icon: "\u{1F4C5}",
title: "Multi-Calendar",
desc: "Layer Gregorian, Islamic, Hebrew, Chinese, and custom community calendars. Cross-reference events across time systems.",
},
];
const LEGEND = [
{ color: "#2dd4bf", label: "Travel" },
{ color: "#34d399", label: "Hike" },
{ color: "#fbbf24", label: "Adventure" },
{ color: "#22d3ee", label: "Transit" },
{ color: "#a78bfa", label: "Culture" },
{ color: "#94a3b8", label: "Rest" },
];
/* ─── Helpers ─────────────────────────────────────────────── */
function eventForDay(day: number): CalEvent | undefined {
return TRIP_EVENTS.find((e) => e.day === day);
}
function isTripDay(day: number): boolean {
return day >= 6 && day <= 20;
}
/* ─── Render ──────────────────────────────────────────────── */
export function renderDemo(): string {
// July 2026: starts on Wednesday (offset 2 for Mon-based grid), 31 days
const firstDayOffset = 2; // Mon=0, Tue=1, Wed=2
const totalDays = 31;
const dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
// Build calendar cells
const calendarCells: string[] = [];
// Empty offset cells
for (let i = 0; i < firstDayOffset; i++) {
calendarCells.push(`<div class="rcal-cell rcal-cell--empty"></div>`);
}
// Day cells
for (let d = 1; d <= totalDays; d++) {
const ev = eventForDay(d);
const trip = isTripDay(d);
const todayClass = d === 15 ? " rcal-cell--today" : "";
const tripClass = trip ? " rcal-cell--trip" : "";
let pill = "";
if (ev) {
pill = `<div class="rcal-pill" style="background:${ev.bg};color:${ev.color};border:1px solid ${ev.color}22;">
<span class="rcal-pill__emoji">${ev.emoji}</span>
<span class="rcal-pill__label">${ev.label}</span>
</div>`;
}
calendarCells.push(`<div class="rcal-cell${tripClass}${todayClass}">
<span class="rcal-cell__num${trip ? " rcal-cell__num--trip" : ""}">${d}</span>
${pill}
</div>`);
}
return `
<div class="rd-root" style="--rd-accent-from:#6366f1; --rd-accent-to:#a78bfa;">
<!-- Hero -->
<section class="rd-hero">
<div style="display:inline-block;padding:0.375rem 1rem;background:rgba(99,102,241,0.1);border:1px solid rgba(99,102,241,0.2);border-radius:9999px;font-size:0.875rem;color:#a5b4fc;font-weight:500;margin-bottom:1.5rem;">
Multi-Dimensional Calendar
</div>
<h1>rCal Demo</h1>
<p class="rd-subtitle">Multi-dimensional calendar with temporal zoom</p>
<div class="rd-meta">
<span>\u{1F50D} Temporal Zoom</span>
<span style="color:#475569">|</span>
<span>\u{1F30D} Spatial Context</span>
<span style="color:#475569">|</span>
<span>\u{1F319} Lunar Cycles</span>
<span style="color:#475569">|</span>
<span>\u{1F4C5} Multi-Calendar</span>
</div>
</section>
<!-- Calendar Section -->
<section class="rd-section rd-section--narrow">
<!-- Header bar -->
<div class="rd-card" style="margin-bottom:0;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid rgba(51,65,85,0.3);flex-wrap:wrap;gap:0.75rem;">
<h2 style="font-size:1.25rem;font-weight:700;color:#f1f5f9;margin:0;display:flex;align-items:center;gap:0.5rem;">
\u{1F4C5} July 2026
</h2>
<div style="display:flex;gap:0.25rem;" id="rcal-tabs">
${TABS.map(
(tab, i) => `<button data-cal-tab="${tab.toLowerCase()}" style="
padding:0.375rem 0.875rem;
border-radius:0.5rem;
font-size:0.8rem;
font-weight:500;
border:none;
cursor:pointer;
transition:all 0.15s;
${i === 0 ? "background:rgba(99,102,241,0.15);color:#818cf8;" : "background:transparent;color:#94a3b8;"}
">${tab}</button>`,
).join("\n ")}
</div>
</div>
<!-- Day header row -->
<div class="rcal-grid rcal-grid--header">
${dayNames.map((d) => `<div class="rcal-day-header">${d}</div>`).join("\n ")}
</div>
<!-- Calendar grid -->
<div class="rcal-grid">
${calendarCells.join("\n ")}
</div>
<!-- Legend -->
<div style="display:flex;align-items:center;gap:1rem;padding:0.75rem 1.25rem;border-top:1px solid rgba(51,65,85,0.3);flex-wrap:wrap;">
<span style="font-size:0.75rem;color:#64748b;font-weight:500;">Legend:</span>
${LEGEND.map(
(l) => `<span style="display:flex;align-items:center;gap:0.375rem;font-size:0.75rem;color:#94a3b8;">
<span style="width:0.5rem;height:0.5rem;border-radius:9999px;background:${l.color};display:inline-block;"></span>
${l.label}
</span>`,
).join("\n ")}
</div>
</div>
</section>
<!-- Zoom Panel -->
<section class="rd-section rd-section--narrow">
<div class="rd-card" style="padding:1.5rem;">
<h2 style="font-size:1.125rem;font-weight:600;color:#f1f5f9;margin:0 0 1rem;display:flex;align-items:center;gap:0.5rem;">
\u{1F50D} Temporal Zoom
</h2>
<p style="font-size:0.875rem;color:#94a3b8;margin:0 0 1rem;">
Navigate across temporal granularities. The calendar grid adapts at each zoom level.
</p>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">
${ZOOM_LEVELS.map(
(level) => {
const isActive = level === "Month";
return `<div style="
padding:0.5rem 1rem;
border-radius:0.5rem;
font-size:0.8rem;
font-weight:500;
border:1px solid ${isActive ? "rgba(99,102,241,0.4)" : "rgba(51,65,85,0.4)"};
background:${isActive ? "rgba(99,102,241,0.15)" : "rgba(30,41,59,0.5)"};
color:${isActive ? "#818cf8" : "#64748b"};
${isActive ? "box-shadow:0 0 12px rgba(99,102,241,0.2);" : ""}
">${level}${isActive ? " \u25C0" : ""}</div>`;
},
).join("\n ")}
</div>
</div>
</section>
<!-- Features -->
<section class="rd-section">
<div class="rd-grid rd-grid--2">
${FEATURES.map(
(f) => `
<div class="rd-card" style="padding:1.5rem;">
<div style="font-size:1.75rem;margin-bottom:0.75rem;">${f.icon}</div>
<h3 style="font-size:1rem;font-weight:600;color:#e2e8f0;margin:0 0 0.5rem;">${f.title}</h3>
<p style="font-size:0.875rem;color:#94a3b8;margin:0;line-height:1.5;">${f.desc}</p>
</div>`,
).join("")}
</div>
</section>
<!-- CTA -->
<section class="rd-section rd-section--narrow">
<div class="rd-cta">
<h2>Coordinate in Time & Space</h2>
<p>
rCal layers temporal zoom, spatial context, and lunar cycles into a single calendar.
Plan events that respect natural rhythms and local conditions.
</p>
<a href="/create-space" style="background:linear-gradient(135deg,#6366f1,#a78bfa);box-shadow:0 8px 24px rgba(99,102,241,0.25);">
Create Your Space
</a>
</div>
</section>
</div>
<style>
/* ── rCal demo grid ── */
.rcal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: rgba(51,65,85,0.2);
padding: 0 1px 1px;
}
.rcal-grid--header {
gap: 0;
padding: 0;
background: transparent;
border-bottom: 1px solid rgba(51,65,85,0.3);
}
.rcal-day-header {
padding: 0.5rem 0;
text-align: center;
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rcal-cell {
min-height: 3.5rem;
padding: 0.375rem;
background: rgba(15,23,42,0.6);
display: flex;
flex-direction: column;
gap: 0.25rem;
transition: background 0.15s;
}
.rcal-cell:hover {
background: rgba(30,41,59,0.8);
}
.rcal-cell--empty {
background: rgba(15,23,42,0.3);
}
.rcal-cell--trip {
background: rgba(99,102,241,0.04);
}
.rcal-cell--today {
outline: 2px solid rgba(99,102,241,0.5);
outline-offset: -2px;
background: rgba(99,102,241,0.08);
}
.rcal-cell__num {
font-size: 0.75rem;
font-weight: 500;
color: #64748b;
line-height: 1;
}
.rcal-cell__num--trip {
color: #a5b4fc;
font-weight: 600;
}
.rcal-pill {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.625rem;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
}
.rcal-pill__emoji {
flex-shrink: 0;
font-size: 0.7rem;
}
.rcal-pill__label {
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
/* Responsive: stack pill text on small screens */
@media (max-width: 640px) {
.rcal-cell {
min-height: 2.75rem;
padding: 0.25rem;
}
.rcal-pill__label {
display: none;
}
.rcal-pill {
justify-content: center;
padding: 0.125rem;
}
}
</style>`;
}

View File

@ -1,2 +0,0 @@
// rCart demo — static display, no client interactivity needed
export {};

View File

@ -1,234 +0,0 @@
/**
* rCart demo page static community garden shopping cart.
*
* Renders a fully server-side demo with 8 cart items, funding progress bars,
* member activity, and summary stats. No WebSocket needed (all static data).
*/
/* ─── Mock Data ─────────────────────────────────────────────── */
const members = [
{ name: "Alice", color: "#10b981" },
{ name: "Bob", color: "#0ea5e9" },
{ name: "Carol", color: "#f59e0b" },
{ name: "Dave", color: "#8b5cf6" },
];
interface CartItem {
name: string;
price: number;
requestedBy: string;
funded: number;
status: "Funded" | "In Cart" | "Needs Funding";
}
const cartItems: CartItem[] = [
{ name: "Raised Garden Bed Kit (4x8 ft)", price: 89.99, requestedBy: "Alice", funded: 89.99, status: "Funded" },
{ name: "Organic Seed Variety Pack (30 types)", price: 34.5, requestedBy: "Carol", funded: 34.5, status: "Funded" },
{ name: "Premium Potting Soil (40 qt, 3-pack)", price: 47.99, requestedBy: "Bob", funded: 32.0, status: "In Cart" },
{ name: "Stainless Steel Garden Tool Set", price: 62.0, requestedBy: "Dave", funded: 62.0, status: "Funded" },
{ name: "Drip Irrigation Kit (100 ft)", price: 54.95, requestedBy: "Alice", funded: 20.0, status: "Needs Funding" },
{ name: "Compost Tumbler (45 gal)", price: 109.0, requestedBy: "Bob", funded: 109.0, status: "Funded" },
{ name: "Garden Kneeling Pad &amp; Gloves Set", price: 28.5, requestedBy: "Carol", funded: 12.0, status: "Needs Funding" },
{ name: "Solar-Powered Pest Repeller (4-pack)", price: 39.99, requestedBy: "Dave", funded: 39.99, status: "In Cart" },
];
/* ─── Helpers ──────────────────────────────────────────────── */
function getMemberColor(name: string): string {
return members.find((m) => m.name === name)?.color || "#64748b";
}
function statusBadgeClass(status: CartItem["status"]): string {
switch (status) {
case "Funded":
return "rd-badge--emerald";
case "In Cart":
return "rd-badge--sky";
case "Needs Funding":
return "rd-badge--amber";
}
}
function progressFillClass(pct: number): string {
if (pct >= 100) return "rd-progress__fill--emerald";
if (pct >= 50) return "rd-progress__fill--sky";
return "rd-progress__fill--amber";
}
/* ─── Render ─────────────────────────────────────────────── */
export function renderDemo(): string {
const totalCost = cartItems.reduce((sum, item) => sum + item.price, 0);
const totalFunded = cartItems.reduce((sum, item) => sum + item.funded, 0);
const perPerson = totalCost / members.length;
const fundedCount = cartItems.filter((i) => i.status === "Funded").length;
const overallPct = Math.round((totalFunded / totalCost) * 100);
const uniqueRequesters = new Set(cartItems.map((i) => i.requestedBy)).size;
return `
<div class="rd-root" style="--rd-accent-from: #10b981; --rd-accent-to: #2dd4bf;">
<!-- Hero -->
<section class="rd-hero">
<div style="display:inline-block;padding:0.375rem 1rem;background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.2);border-radius:9999px;font-size:0.875rem;color:#6ee7b7;font-weight:500;margin-bottom:1.5rem;">
Group Shopping, Together
</div>
<h1>See how rCart works</h1>
<p class="rd-subtitle">
A community garden project where neighbors pool resources to buy everything they need together.
</p>
<div class="rd-meta">
<span>8 items</span>
<span style="color:#475569">|</span>
<span>$${totalCost.toFixed(2)} total</span>
<span style="color:#475569">|</span>
<span>${fundedCount}/${cartItems.length} funded</span>
</div>
<div class="rd-avatars">
${members
.map(
(m) =>
`<div class="rd-avatar" style="background:${m.color}" title="${m.name}">${m.name[0]}</div>`,
)
.join("\n ")}
<span class="rd-count">${members.length} members</span>
</div>
</section>
<!-- Overall Funding Progress -->
<section class="rd-section rd-section--narrow">
<div class="rd-card" style="padding:1.5rem;margin-bottom:1.5rem;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
<div>
<h2 style="font-size:1.125rem;font-weight:600;color:#f1f5f9;margin:0 0 0.25rem;">Community Garden Project</h2>
<p class="rd-text-xs rd-text-muted" style="margin:0;">Shared cart for our neighborhood garden setup</p>
</div>
<div style="text-align:right">
<p style="font-size:1.5rem;font-weight:700;color:white;margin:0;">$${totalFunded.toFixed(2)}</p>
<p class="rd-text-xs rd-text-muted" style="margin:0;">of $${totalCost.toFixed(2)} funded</p>
</div>
</div>
<div class="rd-progress" style="margin-bottom:0.5rem;">
<div class="rd-progress__fill" style="width:${overallPct}%"></div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;">
<span class="rd-text-xs rd-text-muted">${overallPct}% funded</span>
<span class="rd-text-xs rd-text-muted">$${(totalCost - totalFunded).toFixed(2)} remaining</span>
</div>
</div>
<!-- Cart Items -->
<div class="rd-card" style="margin-bottom:1.5rem;">
<div class="rd-card-header">
<div class="rd-card-title"><span class="rd-icon">&#128722;</span> Cart Items</div>
<div style="display:flex;align-items:center;gap:0.75rem;font-size:0.75rem;color:#94a3b8;">
<span style="display:flex;align-items:center;gap:0.25rem;">
<span style="width:0.5rem;height:0.5rem;border-radius:9999px;background:#10b981;display:inline-block;"></span> Funded
</span>
<span style="display:flex;align-items:center;gap:0.25rem;">
<span style="width:0.5rem;height:0.5rem;border-radius:9999px;background:#0ea5e9;display:inline-block;"></span> In Cart
</span>
<span style="display:flex;align-items:center;gap:0.25rem;">
<span style="width:0.5rem;height:0.5rem;border-radius:9999px;background:#f59e0b;display:inline-block;"></span> Needs Funding
</span>
</div>
</div>
${cartItems
.map((item) => {
const pct = Math.round((item.funded / item.price) * 100);
const memberColor = getMemberColor(item.requestedBy);
return `
<div style="padding:1rem 1.25rem;${cartItems.indexOf(item) > 0 ? "border-top:1px solid rgba(51,65,85,0.3);" : ""}">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;margin-bottom:0.5rem;">
<div style="min-width:0;flex:1;">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem;">
<span class="rd-text-sm rd-font-medium" style="color:#e2e8f0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${item.name}</span>
<span class="rd-badge ${statusBadgeClass(item.status)}">${item.status}</span>
</div>
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.75rem;color:#94a3b8;">
<span style="display:flex;align-items:center;gap:0.375rem;">
<span style="width:1rem;height:1rem;background:${memberColor};border-radius:9999px;display:inline-flex;align-items:center;justify-content:center;font-size:0.625rem;font-weight:700;color:white;">${item.requestedBy[0]}</span>
${item.requestedBy}
</span>
<span style="color:#475569;">requested this</span>
</div>
</div>
<div style="text-align:right;flex-shrink:0;">
<p class="rd-text-sm rd-font-semibold" style="color:#e2e8f0;margin:0;">$${item.price.toFixed(2)}</p>
${item.status !== "Funded" ? `<p class="rd-text-xs" style="color:#64748b;margin:0;">$${item.funded.toFixed(2)} funded</p>` : ""}
</div>
</div>
<div class="rd-progress rd-progress--sm">
<div class="rd-progress__fill ${progressFillClass(pct)}" style="width:${pct}%"></div>
</div>
</div>`;
})
.join("")}
</div>
<!-- Summary Grid -->
<div class="rd-grid rd-grid--3" style="margin-bottom:1.5rem;">
<div class="rd-stat">
<p class="rd-text-xs rd-font-semibold rd-text-muted" style="text-transform:uppercase;letter-spacing:0.05em;margin:0 0 0.5rem;">Total Cost</p>
<p class="rd-stat__value">$${totalCost.toFixed(2)}</p>
<p class="rd-stat__sub">${cartItems.length} items across ${uniqueRequesters} requesters</p>
</div>
<div class="rd-stat">
<p class="rd-text-xs rd-font-semibold rd-text-muted" style="text-transform:uppercase;letter-spacing:0.05em;margin:0 0 0.5rem;">Amount Funded</p>
<p class="rd-stat__value" style="color:#34d399;">$${totalFunded.toFixed(2)}</p>
<p class="rd-stat__sub">${fundedCount} of ${cartItems.length} items fully funded</p>
</div>
<div class="rd-stat">
<p class="rd-text-xs rd-font-semibold rd-text-muted" style="text-transform:uppercase;letter-spacing:0.05em;margin:0 0 0.5rem;">Per-Person Split</p>
<p class="rd-stat__value" style="color:#2dd4bf;">$${perPerson.toFixed(2)}</p>
<p class="rd-stat__sub">split equally among ${members.length} members</p>
</div>
</div>
<!-- Member Activity -->
<div class="rd-card" style="padding:1.5rem;">
<h3 class="rd-text-sm rd-font-semibold" style="color:#cbd5e1;margin:0 0 1rem;">Member Activity</h3>
<div class="rd-grid rd-grid--2">
${members
.map((member) => {
const requested = cartItems.filter((i) => i.requestedBy === member.name);
const requestedTotal = requested.reduce((sum, i) => sum + i.price, 0);
return `
<div style="display:flex;align-items:center;gap:0.75rem;background:rgba(51,65,85,0.2);border-radius:0.75rem;padding:0.75rem;">
<div class="rd-avatar" style="background:${member.color};flex-shrink:0;width:2.5rem;height:2.5rem;font-size:0.875rem;">${member.name[0]}</div>
<div style="min-width:0;flex:1;">
<p class="rd-text-sm rd-font-medium" style="color:#e2e8f0;margin:0;">${member.name}</p>
<p class="rd-text-xs rd-text-muted" style="margin:0;">
${requested.length} item${requested.length !== 1 ? "s" : ""} requested
<span style="color:#475569;margin:0 0.375rem;">&middot;</span>
$${requestedTotal.toFixed(2)} total
</p>
</div>
<div style="text-align:right;flex-shrink:0;">
<p class="rd-text-sm rd-font-medium" style="color:#cbd5e1;margin:0;">$${perPerson.toFixed(2)}</p>
<p class="rd-text-xs" style="color:#64748b;margin:0;">share</p>
</div>
</div>`;
})
.join("")}
</div>
</div>
</section>
<!-- CTA -->
<section class="rd-section rd-section--narrow">
<div class="rd-cta">
<h2>Ready to shop together?</h2>
<p>
Create a shared cart for your group, community, or team. Add items from any store,
split costs fairly, and check out together.
</p>
<a href="/create-space" style="background:linear-gradient(135deg, #10b981, #059669);box-shadow:0 8px 24px rgba(16,185,129,0.25);">
Create Your First Cart
</a>
</div>
</section>
</div>`;
}

View File

@ -1,526 +0,0 @@
/**
* rFunds demo client-side WebSocket controller.
*
* Connects via DemoSync, extracts expenses and budget from shapes,
* renders/updates budget overview, expense list, balances, settlements,
* category breakdown, and per-person stats. Supports inline expense editing.
*/
import { DemoSync } from "@lib/demo-sync-vanilla";
import type { DemoShape } from "@lib/demo-sync-vanilla";
// ── Types ──
interface ExpenseShape extends DemoShape {
type: "demo-expense";
description: string;
amount: number;
currency: string;
paidBy: string;
split: "equal" | "custom";
category: "transport" | "accommodation" | "activity" | "food";
date: string;
}
interface BudgetShape extends DemoShape {
type: "folk-budget";
budgetTitle: string;
currency: string;
budgetTotal: number;
spent: number;
categories: { name: string; budget: number; spent: number }[];
}
function isExpense(shape: DemoShape): shape is ExpenseShape {
return shape.type === "demo-expense" && typeof (shape as ExpenseShape).amount === "number";
}
function isBudget(shape: DemoShape): shape is BudgetShape {
return shape.type === "folk-budget";
}
// ── Constants ──
const MEMBERS = ["Maya", "Liam", "Priya", "Omar"];
const MEMBER_COLORS: Record<string, string> = {
Maya: "#10b981",
Liam: "#06b6d4",
Priya: "#8b5cf6",
Omar: "#f59e0b",
};
const MEMBER_BG: Record<string, string> = {
Maya: "rd-bg-emerald",
Liam: "rd-bg-cyan",
Priya: "rd-bg-violet",
Omar: "rd-bg-amber",
};
const CATEGORY_ICONS: Record<string, string> = {
transport: "\u{1F682}",
accommodation: "\u{1F3E8}",
activity: "\u26F7",
food: "\u{1F372}",
};
const CATEGORY_LABELS: Record<string, string> = {
transport: "Transport",
accommodation: "Accommodation",
activity: "Activities",
food: "Food & Drink",
};
const CATEGORY_TEXT_CLASS: Record<string, string> = {
transport: "rd-cyan",
accommodation: "rd-violet",
activity: "rd-amber",
food: "rd-rose",
};
// ── Helpers ──
function fmt(amount: number): string {
return `\u20AC${amount.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
}
function formatDate(dateStr: string): string {
try {
const d = new Date(dateStr);
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
} catch {
return dateStr;
}
}
function esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ── Balance computation ──
interface BalanceEntry {
name: string;
paid: number;
owes: number;
balance: number;
}
function computeBalances(expenses: ExpenseShape[]): BalanceEntry[] {
const total = expenses.reduce((s, e) => s + e.amount, 0);
const perPerson = total / MEMBERS.length;
const paid: Record<string, number> = {};
MEMBERS.forEach((m) => (paid[m] = 0));
expenses.forEach((e) => (paid[e.paidBy] = (paid[e.paidBy] || 0) + e.amount));
return MEMBERS.map((name) => ({
name,
paid: paid[name] || 0,
owes: perPerson,
balance: (paid[name] || 0) - perPerson,
}));
}
// ── Settlement computation (greedy) ──
interface Settlement {
from: string;
to: string;
amount: number;
}
function computeSettlements(balances: BalanceEntry[]): Settlement[] {
const debtors = balances
.filter((b) => b.balance < -0.01)
.map((b) => ({ ...b, balance: -b.balance }));
const creditors = balances
.filter((b) => b.balance > 0.01)
.map((b) => ({ ...b }));
debtors.sort((a, b) => b.balance - a.balance);
creditors.sort((a, b) => b.balance - a.balance);
const settlements: Settlement[] = [];
let di = 0,
ci = 0;
while (di < debtors.length && ci < creditors.length) {
const amount = Math.min(debtors[di].balance, creditors[ci].balance);
if (amount > 0.01) {
settlements.push({
from: debtors[di].name,
to: creditors[ci].name,
amount: Math.round(amount * 100) / 100,
});
}
debtors[di].balance -= amount;
creditors[ci].balance -= amount;
if (debtors[di].balance < 0.01) di++;
if (creditors[ci].balance < 0.01) ci++;
}
return settlements;
}
// ── DOM refs ──
const connBadge = document.getElementById("rd-conn-badge") as HTMLElement;
const resetBtn = document.getElementById("rd-reset-btn") as HTMLButtonElement;
const loadingEl = document.getElementById("rd-loading") as HTMLElement;
const emptyEl = document.getElementById("rd-empty") as HTMLElement;
const budgetSection = document.getElementById("rd-budget-section") as HTMLElement;
const expensesSection = document.getElementById("rd-expenses-section") as HTMLElement;
const spendingSection = document.getElementById("rd-spending-section") as HTMLElement;
const personSection = document.getElementById("rd-person-section") as HTMLElement;
const budgetTotalEl = document.getElementById("rd-budget-total") as HTMLElement;
const budgetSpentEl = document.getElementById("rd-budget-spent") as HTMLElement;
const budgetRemainingEl = document.getElementById("rd-budget-remaining") as HTMLElement;
const budgetRemainingLabel = document.getElementById("rd-budget-remaining-label") as HTMLElement;
const budgetPctLabel = document.getElementById("rd-budget-pct-label") as HTMLElement;
const budgetLeftLabel = document.getElementById("rd-budget-left-label") as HTMLElement;
const budgetBar = document.getElementById("rd-budget-bar") as HTMLElement;
const expenseList = document.getElementById("rd-expense-list") as HTMLElement;
const expenseCount = document.getElementById("rd-expense-count") as HTMLElement;
const expenseTotal = document.getElementById("rd-expense-total") as HTMLElement;
const balancesBody = document.getElementById("rd-balances-body") as HTMLElement;
const settlementsBody = document.getElementById("rd-settlements-body") as HTMLElement;
// ── DemoSync ──
const sync = new DemoSync({ filter: ["demo-expense", "folk-budget"] });
// Editing state
let editingExpenseId: string | null = null;
// Show loading spinner immediately
loadingEl.style.display = "";
// ── Connection events ──
sync.addEventListener("connected", () => {
connBadge.className = "rd-status rd-status--connected";
connBadge.textContent = "Connected";
resetBtn.disabled = false;
});
sync.addEventListener("disconnected", () => {
connBadge.className = "rd-status rd-status--disconnected";
connBadge.textContent = "Disconnected";
resetBtn.disabled = true;
});
// ── Snapshot -> render ──
sync.addEventListener("snapshot", ((e: CustomEvent) => {
const shapes: Record<string, DemoShape> = e.detail.shapes;
const expenses = Object.values(shapes)
.filter(isExpense)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const budget = Object.values(shapes).find(isBudget) ?? null;
// Hide loading
loadingEl.style.display = "none";
const hasData = expenses.length > 0 || budget !== null;
if (!hasData) {
emptyEl.style.display = "";
budgetSection.style.display = "none";
expensesSection.style.display = "none";
spendingSection.style.display = "none";
personSection.style.display = "none";
return;
}
emptyEl.style.display = "none";
// ── Computed values ──
const totalSpent = expenses.reduce((s, ex) => s + ex.amount, 0);
const budgetTotal = budget?.budgetTotal ?? 4000;
const budgetSpent = budget?.spent ?? totalSpent;
const budgetRemaining = budgetTotal - budgetSpent;
const budgetPct = budgetTotal > 0 ? Math.min(100, Math.round((budgetSpent / budgetTotal) * 100)) : 0;
const balances = computeBalances(expenses);
const settlements = computeSettlements(balances);
// Category breakdown from budget shape or computed from expenses
let categoryBreakdown: { name: string; budget: number; spent: number }[];
if (budget?.categories && budget.categories.length > 0) {
categoryBreakdown = budget.categories;
} else {
const cats: Record<string, number> = {};
expenses.forEach((ex) => (cats[ex.category] = (cats[ex.category] || 0) + ex.amount));
categoryBreakdown = Object.entries(cats).map(([name, spent]) => ({
name,
budget: Math.round(budgetTotal / 4),
spent,
}));
}
// ── Update Budget Overview ──
budgetSection.style.display = "";
budgetTotalEl.textContent = fmt(budgetTotal);
budgetSpentEl.textContent = fmt(budgetSpent);
budgetRemainingEl.textContent = fmt(Math.abs(budgetRemaining));
budgetRemainingEl.className = `rd-stat__value ${budgetRemaining >= 0 ? "rd-cyan" : "rd-rose"}`;
budgetRemainingLabel.textContent = budgetRemaining >= 0 ? "Remaining" : "Over Budget";
budgetPctLabel.textContent = `${budgetPct}% used`;
budgetLeftLabel.textContent = `${fmt(Math.abs(budgetRemaining))} ${budgetRemaining >= 0 ? "left" : "over"}`;
budgetBar.style.width = `${budgetPct}%`;
budgetBar.className = `rd-progress__fill ${budgetPct >= 90 ? "rd-progress__fill--rose" : budgetPct >= 70 ? "rd-progress__fill--amber" : "rd-progress__fill--emerald"}`;
// Category breakdown
const catSection = document.getElementById("rd-category-breakdown")!;
const catCards = catSection.querySelectorAll<HTMLElement>("[data-category]");
catCards.forEach((card) => {
const key = card.dataset.category!;
const catData = categoryBreakdown.find(
(c) => c.name.toLowerCase() === key,
);
const spent = catData?.spent ?? 0;
const catBudget = catData?.budget ?? Math.round(budgetTotal / 4);
const catPct = catBudget > 0 ? Math.min(100, Math.round((spent / catBudget) * 100)) : 0;
const amountsEl = card.querySelector("[data-cat-amounts]") as HTMLElement;
const barEl = card.querySelector("[data-cat-bar]") as HTMLElement;
const pctEl = card.querySelector("[data-cat-pct]") as HTMLElement;
if (amountsEl) amountsEl.textContent = `${fmt(spent)} / ${fmt(catBudget)}`;
if (barEl) barEl.style.width = `${catPct}%`;
if (pctEl) pctEl.textContent = `${catPct}% used`;
});
// ── Update Expenses ──
if (expenses.length > 0) {
expensesSection.style.display = "";
expenseCount.textContent = `Expenses (${expenses.length})`;
expenseTotal.textContent = fmt(totalSpent);
expenseList.innerHTML = expenses
.map((ex) => {
const catIcon = CATEGORY_ICONS[ex.category] || "\u{1F4B0}";
const catLabel = CATEGORY_LABELS[ex.category] || ex.category;
const catTextClass = CATEGORY_TEXT_CLASS[ex.category] || "rd-text-muted";
const perPerson = fmt(Math.round((ex.amount / MEMBERS.length) * 100) / 100);
return `<div class="rd-item" data-expense-id="${esc(ex.id)}">
<div style="width:2.5rem; height:2.5rem; border-radius:0.75rem; display:flex; align-items:center; justify-content:center; font-size:1.125rem; background:rgba(51,65,85,0.4); flex-shrink:0;">
${catIcon}
</div>
<div style="flex:1; min-width:0;">
<p style="font-size:0.875rem; color:#e2e8f0; font-weight:500; margin:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${esc(ex.description)}</p>
<div style="display:flex; align-items:center; gap:0.375rem; font-size:0.75rem; color:#64748b; margin-top:0.125rem; flex-wrap:wrap;">
<span class="${catTextClass}">${catLabel}</span>
<span>\u00B7</span>
<span>Paid by ${esc(ex.paidBy)}</span>
<span>\u00B7</span>
<span>${ex.split === "equal" ? `Split ${MEMBERS.length} ways` : "Custom split"}</span>
<span>\u00B7</span>
<span>${formatDate(ex.date)}</span>
</div>
</div>
<button class="rd-expense-amount" data-edit-expense="${esc(ex.id)}" data-amount="${ex.amount}" style="background:none; border:none; cursor:pointer; text-align:right; padding:0.25rem 0.5rem; border-radius:0.5rem; transition:background 0.15s; flex-shrink:0;" title="Click to edit amount">
<span style="font-size:0.875rem; font-weight:600; color:#e2e8f0; display:block;">${fmt(ex.amount)}</span>
<span style="font-size:0.75rem; color:#64748b; display:block;">${perPerson}/person</span>
</button>
</div>`;
})
.join("");
} else {
expensesSection.style.display = "none";
}
// ── Update Balances ──
balancesBody.innerHTML = balances
.map((b) => {
const bgClass = MEMBER_BG[b.name] || "rd-bg-slate";
const initial = b.name[0];
const balanceColor = b.balance >= 0 ? "rd-emerald" : "rd-rose";
const balanceStr = `${b.balance >= 0 ? "+" : ""}${fmt(Math.round(b.balance * 100) / 100)}`;
return `<div style="display:flex; align-items:center; gap:0.75rem; margin-bottom:0.75rem;">
<div class="rd-avatar ${bgClass}" style="width:2rem; height:2rem; font-size:0.75rem;">${initial}</div>
<div style="flex:1; min-width:0;">
<p style="font-size:0.875rem; color:#e2e8f0; margin:0;">${esc(b.name)}</p>
<p style="font-size:0.75rem; color:#64748b; margin:0;">Paid ${fmt(b.paid)}</p>
</div>
<span style="font-size:0.875rem; font-weight:600;" class="${balanceColor}">${balanceStr}</span>
</div>`;
})
.join("");
// ── Update Settlements ──
if (settlements.length > 0) {
settlementsBody.innerHTML =
settlements
.map(
(s) => `<div style="display:flex; align-items:center; gap:0.5rem; background:rgba(51,65,85,0.3); border-radius:0.75rem; padding:0.75rem; margin-bottom:0.5rem;">
<span style="font-size:0.875rem; font-weight:500; color:#fb7185;">${esc(s.from)}</span>
<span style="flex:1; text-align:center;">
<span style="font-size:0.75rem; color:#64748b;">\u2192</span>
<span style="display:block; font-size:0.875rem; font-weight:600; color:white;">${fmt(s.amount)}</span>
</span>
<span style="font-size:0.875rem; font-weight:500; color:#34d399;">${esc(s.to)}</span>
</div>`,
)
.join("") +
`<p style="font-size:0.75rem; color:#64748b; text-align:center; margin:0.5rem 0 0;">
${settlements.length} payment${settlements.length !== 1 ? "s" : ""} to settle all debts
</p>`;
} else {
settlementsBody.innerHTML = `<p style="font-size:0.875rem; color:#64748b; text-align:center;">All settled up!</p>`;
}
// ── Update Spending by Category ──
if (expenses.length > 0) {
spendingSection.style.display = "";
const catKeys = ["transport", "accommodation", "activity", "food"] as const;
catKeys.forEach((cat) => {
const catExpenses = expenses.filter((e) => e.category === cat);
const catTotal = catExpenses.reduce((s, e) => s + e.amount, 0);
const catPct = totalSpent > 0 ? Math.round((catTotal / totalSpent) * 100) : 0;
const card = document.querySelector<HTMLElement>(`[data-spending-cat="${cat}"]`);
if (!card) return;
const amountEl = card.querySelector("[data-spending-amount]") as HTMLElement;
const barEl = card.querySelector("[data-spending-bar]") as HTMLElement;
const pctEl = card.querySelector("[data-spending-pct]") as HTMLElement;
if (amountEl) amountEl.textContent = fmt(catTotal);
if (barEl) barEl.style.width = `${catPct}%`;
if (pctEl) pctEl.textContent = `${catPct}% of total`;
});
} else {
spendingSection.style.display = "none";
}
// ── Update Per Person ──
if (expenses.length > 0) {
personSection.style.display = "";
balances.forEach((b) => {
const card = document.querySelector<HTMLElement>(`[data-person="${b.name}"]`);
if (!card) return;
const paidPct = totalSpent > 0 ? Math.round((b.paid / totalSpent) * 100) : 0;
const paidEl = card.querySelector("[data-person-paid]") as HTMLElement;
const pctEl = card.querySelector("[data-person-pct]") as HTMLElement;
const balanceEl = card.querySelector("[data-person-balance]") as HTMLElement;
if (paidEl) paidEl.textContent = fmt(b.paid);
if (pctEl) pctEl.textContent = `paid (${paidPct}%)`;
if (balanceEl) {
const roundedBalance = Math.round(b.balance * 100) / 100;
const label = b.balance >= 0 ? "Gets back " : "Owes ";
balanceEl.textContent = `${label}${fmt(Math.abs(roundedBalance))}`;
balanceEl.className = b.balance >= 0 ? "rd-emerald" : "rd-rose";
balanceEl.style.fontSize = "0.875rem";
balanceEl.style.fontWeight = "600";
balanceEl.style.margin = "0.25rem 0 0";
}
});
} else {
personSection.style.display = "none";
}
}) as EventListener);
// ── Inline expense editing via event delegation ──
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
// Click on amount button to start editing
const amountBtn = target.closest<HTMLElement>("[data-edit-expense]");
if (amountBtn && !editingExpenseId) {
const expenseId = amountBtn.dataset.editExpense!;
const currentAmount = parseFloat(amountBtn.dataset.amount!);
editingExpenseId = expenseId;
amountBtn.innerHTML = `
<div style="display:flex; align-items:center; gap:0.25rem;">
<span style="font-size:0.875rem; color:#94a3b8;">\u20AC</span>
<input type="number" value="${currentAmount}" step="0.01" min="0"
style="width:5rem; background:#334155; border:1px solid rgba(16,185,129,0.5); border-radius:0.5rem; padding:0.25rem 0.5rem; font-size:0.875rem; color:white; text-align:right; outline:none;"
id="rd-edit-input" autofocus>
<button id="rd-edit-save" style="background:rgba(16,185,129,0.1); color:#34d399; border:none; cursor:pointer; font-size:0.75rem; padding:0.125rem 0.375rem; border-radius:0.25rem;">\u2713</button>
<button id="rd-edit-cancel" style="background:rgba(51,65,85,0.5); color:#94a3b8; border:none; cursor:pointer; font-size:0.75rem; padding:0.125rem 0.375rem; border-radius:0.25rem;">\u2717</button>
</div>`;
const input = document.getElementById("rd-edit-input") as HTMLInputElement;
if (input) {
input.focus();
input.select();
}
return;
}
// Save edit
if (target.id === "rd-edit-save" || target.closest("#rd-edit-save")) {
saveEdit();
return;
}
// Cancel edit
if (target.id === "rd-edit-cancel" || target.closest("#rd-edit-cancel")) {
cancelEdit();
return;
}
});
// Handle keyboard events on edit input
document.addEventListener("keydown", (e) => {
if (!editingExpenseId) return;
const input = document.getElementById("rd-edit-input") as HTMLInputElement;
if (!input || e.target !== input) return;
if (e.key === "Enter") {
e.preventDefault();
saveEdit();
} else if (e.key === "Escape") {
e.preventDefault();
cancelEdit();
}
});
function saveEdit(): void {
if (!editingExpenseId) return;
const input = document.getElementById("rd-edit-input") as HTMLInputElement;
if (!input) return;
const newAmount = parseFloat(input.value);
if (!isNaN(newAmount) && newAmount >= 0) {
sync.updateShape(editingExpenseId, {
amount: Math.round(newAmount * 100) / 100,
});
}
editingExpenseId = null;
}
function cancelEdit(): void {
editingExpenseId = null;
// Re-render will happen on next snapshot; force it by dispatching current state
sync.dispatchEvent(
new CustomEvent("snapshot", {
detail: { shapes: sync.shapes },
}),
);
}
// ── Reset button ──
resetBtn.addEventListener("click", async () => {
resetBtn.disabled = true;
try {
await sync.resetDemo();
} catch (err) {
console.error("Reset failed:", err);
} finally {
if (sync.connected) resetBtn.disabled = false;
}
});
// ── Connect ──
sync.connect();

View File

@ -1,256 +0,0 @@
/**
* rFunds demo page group expense tracking for "Alpine Explorer 2026".
*
* Renders server-side HTML skeleton with budget overview, expense list,
* balances, settlements, category breakdown, and per-person stats.
* The client-side funds-demo.ts hydrates via WebSocket (DemoSync).
*/
/* ─── Constants ─────────────────────────────────────────────── */
const MEMBERS = [
{ name: "Maya", initial: "M", color: "#10b981", bgClass: "rd-bg-emerald" },
{ name: "Liam", initial: "L", color: "#06b6d4", bgClass: "rd-bg-cyan" },
{ name: "Priya", initial: "P", color: "#8b5cf6", bgClass: "rd-bg-violet" },
{ name: "Omar", initial: "O", color: "#f59e0b", bgClass: "rd-bg-amber" },
];
const CATEGORIES = [
{ key: "transport", icon: "\u{1F682}", label: "Transport", colorClass: "rd-progress__fill--cyan", badgeClass: "rd-badge--sky", textClass: "rd-cyan" },
{ key: "accommodation", icon: "\u{1F3E8}", label: "Accommodation", colorClass: "rd-progress__fill--violet", badgeClass: "rd-badge--teal", textClass: "rd-violet" },
{ key: "activity", icon: "\u26F7", label: "Activities", colorClass: "rd-progress__fill--amber", badgeClass: "rd-badge--amber", textClass: "rd-amber" },
{ key: "food", icon: "\u{1F372}", label: "Food & Drink", colorClass: "rd-progress__fill--rose", badgeClass: "rd-badge--rose", textClass: "rd-rose" },
];
/* ─── Render ─────────────────────────────────────────────── */
export function renderDemo(): string {
return `
<div class="rd-root" style="--rd-accent-from: #f59e0b; --rd-accent-to: #10b981;">
<!-- Hero -->
<section class="rd-hero">
<h1>Alpine Explorer 2026</h1>
<p class="rd-subtitle">Group Expenses</p>
<div class="rd-meta">
<span>\u{1F4C5} Jul 6-20, 2026</span>
<span style="color:#475569">|</span>
<span>\u{1F465} ${MEMBERS.length} travelers</span>
<span style="color:#475569">|</span>
<span>\u{1F3D4} Chamonix \u2192 Zermatt \u2192 Dolomites</span>
</div>
<div class="rd-avatars">
${MEMBERS.map(
(m) =>
`<div class="rd-avatar ${m.bgClass}" title="${m.name}">${m.initial}</div>`,
).join("\n ")}
<span class="rd-count">${MEMBERS.length} members</span>
</div>
</section>
<!-- Status bar -->
<div class="rd-section rd-section--narrow">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.5rem; flex-wrap:wrap; gap:0.75rem">
<div style="display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap">
<span id="rd-conn-badge" class="rd-status rd-status--disconnected">Disconnected</span>
<span class="rd-badge rd-badge--amber" style="font-size:0.7rem">Live &mdash; synced across all r* demos</span>
</div>
<button id="rd-reset-btn" class="rd-btn rd-btn--ghost" disabled>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
Reset Demo
</button>
</div>
</div>
<!-- Loading state -->
<div class="rd-section rd-section--narrow">
<div id="rd-loading" class="rd-card" style="border:2px dashed rgba(100,116,139,0.4); display:none">
<div class="rd-card-body" style="padding:3rem; text-align:center">
<div style="width:2rem; height:2rem; margin:0 auto 0.75rem; border:3px solid rgba(245,158,11,0.2); border-top-color:#f59e0b; border-radius:50%; animation:rd-spin 0.8s linear infinite"></div>
<p class="rd-text-muted">Connecting to rSpace...</p>
</div>
</div>
<style>@keyframes rd-spin { to { transform: rotate(360deg); } }</style>
<!-- Empty state -->
<div id="rd-empty" class="rd-card" style="border:2px dashed rgba(100,116,139,0.4); display:none">
<div class="rd-card-body" style="padding:3rem; text-align:center">
<div style="font-size:2rem; margin-bottom:0.75rem">\u{1F4B0}</div>
<p class="rd-text-muted">No expense data found. Try resetting the demo.</p>
</div>
</div>
</div>
<!-- Budget Overview -->
<section id="rd-budget-section" class="rd-section rd-section--narrow" style="display:none">
<div class="rd-card" style="padding:1.5rem; margin-bottom:1.5rem;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1rem;">
<h2 style="font-size:1.125rem; font-weight:600; color:#f1f5f9; margin:0; display:flex; align-items:center; gap:0.5rem;">
\u{1F4CA} Trip Budget
</h2>
<span class="rd-live">live</span>
</div>
<!-- Budget totals: 3-column stat grid -->
<div class="rd-grid rd-grid--3" style="margin-bottom:1rem;">
<div class="rd-stat">
<p class="rd-stat__value" id="rd-budget-total">\u20AC4,000</p>
<p class="rd-stat__label">Total Budget</p>
</div>
<div class="rd-stat">
<p class="rd-stat__value rd-emerald" id="rd-budget-spent">\u20AC0</p>
<p class="rd-stat__label">Spent</p>
</div>
<div class="rd-stat">
<p class="rd-stat__value rd-cyan" id="rd-budget-remaining">\u20AC4,000</p>
<p class="rd-stat__label" id="rd-budget-remaining-label">Remaining</p>
</div>
</div>
<!-- Progress bar -->
<div style="margin-bottom:1rem;">
<div style="display:flex; align-items:center; justify-content:space-between; font-size:0.75rem; color:#94a3b8; margin-bottom:0.375rem;">
<span id="rd-budget-pct-label">0% used</span>
<span id="rd-budget-left-label">\u20AC4,000 left</span>
</div>
<div class="rd-progress">
<div class="rd-progress__fill rd-progress__fill--emerald" id="rd-budget-bar" style="width:0%"></div>
</div>
</div>
<!-- Category breakdown -->
<div>
<h3 style="font-size:0.875rem; font-weight:600; color:#cbd5e1; margin:0 0 0.75rem;">Budget by Category</h3>
<div class="rd-grid rd-grid--2" id="rd-category-breakdown">
${CATEGORIES.map(
(cat) => `
<div class="rd-stat" style="padding:0.75rem;" data-category="${cat.key}">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:0.5rem;">
<span style="display:flex; align-items:center; gap:0.5rem; font-size:0.875rem; color:#e2e8f0;">
${cat.icon} ${cat.label}
</span>
<span class="rd-text-xs rd-text-muted" data-cat-amounts>\u20AC0 / \u20AC1,000</span>
</div>
<div class="rd-progress rd-progress--sm">
<div class="${cat.colorClass}" style="height:100%; border-radius:9999px; width:0%; transition:width 0.3s" data-cat-bar></div>
</div>
<p class="rd-text-xs rd-text-dim" style="margin:0.25rem 0 0;" data-cat-pct>0% used</p>
</div>`,
).join("")}
</div>
</div>
</div>
</section>
<!-- Expenses + Balances grid -->
<section id="rd-expenses-section" class="rd-section" style="display:none">
<div style="display:grid; grid-template-columns:1fr; gap:1rem;">
<!-- Expense list (left 2/3 on desktop) -->
<div class="rd-card" id="rd-expense-card" style="grid-column:1;">
<div class="rd-card-header">
<div class="rd-card-title"><span class="rd-icon">\u{1F4DD}</span> <span id="rd-expense-count">Expenses (0)</span></div>
<span class="rd-text-xs rd-text-muted">Click amount to edit</span>
</div>
<div id="rd-expense-list">
<!-- Populated by funds-demo.ts -->
</div>
<!-- Total row -->
<div style="display:flex; align-items:center; justify-content:space-between; padding:0.75rem 1.25rem; border-top:1px solid rgba(51,65,85,0.5); background:rgba(51,65,85,0.2);">
<span style="font-size:0.875rem; font-weight:600; color:#cbd5e1;">Total</span>
<span style="font-size:1.125rem; font-weight:700; color:white;" id="rd-expense-total">\u20AC0</span>
</div>
</div>
<!-- Balances (right 1/3 on desktop) -->
<div style="display:flex; flex-direction:column; gap:1rem;">
<div class="rd-card" id="rd-balances">
<div class="rd-card-header">
<div class="rd-card-title"><span class="rd-icon">\u2696\uFE0F</span> Balances</div>
</div>
<div class="rd-card-body" id="rd-balances-body">
<!-- Populated by funds-demo.ts -->
</div>
</div>
<div class="rd-card" id="rd-settlements">
<div class="rd-card-header">
<div class="rd-card-title"><span class="rd-icon">\u{1F4B8}</span> Settle Up</div>
</div>
<div class="rd-card-body" id="rd-settlements-body">
<!-- Populated by funds-demo.ts -->
</div>
</div>
</div>
</div>
</section>
<!-- Spending by Category -->
<section id="rd-spending-section" class="rd-section" style="display:none">
<div class="rd-card" style="padding:1.5rem;">
<h2 style="font-size:1.125rem; font-weight:600; margin:0 0 1rem; display:flex; align-items:center; gap:0.5rem;">
\u{1F4CA} Spending by Category
</h2>
<div class="rd-grid rd-grid--4" id="rd-spending-grid">
${CATEGORIES.map(
(cat) => `
<div class="rd-stat" data-spending-cat="${cat.key}">
<div style="font-size:1.5rem; margin-bottom:0.5rem;">${cat.icon}</div>
<p style="font-size:0.875rem; color:#cbd5e1; font-weight:500; margin:0;">${cat.label}</p>
<p style="font-size:1.125rem; font-weight:700; color:white; margin:0.25rem 0;" data-spending-amount>\u20AC0</p>
<div class="rd-progress rd-progress--xs" style="margin-bottom:0.25rem;">
<div class="${cat.colorClass}" style="height:100%; border-radius:9999px; width:0%; transition:width 0.3s" data-spending-bar></div>
</div>
<p class="rd-text-xs rd-text-dim" style="margin:0;" data-spending-pct>0% of total</p>
</div>`,
).join("")}
</div>
</div>
</section>
<!-- Per Person -->
<section id="rd-person-section" class="rd-section" style="display:none">
<div class="rd-card" style="padding:1.5rem;">
<h2 style="font-size:1.125rem; font-weight:600; margin:0 0 1rem; display:flex; align-items:center; gap:0.5rem;">
\u{1F464} Per Person
</h2>
<div class="rd-grid rd-grid--4" id="rd-person-grid">
${MEMBERS.map(
(m) => `
<div class="rd-stat" data-person="${m.name}">
<div class="rd-avatar ${m.bgClass}" style="width:3rem; height:3rem; font-size:1.125rem; margin:0 auto 0.5rem; box-shadow:0 0 0 2px #1e293b;">${m.initial}</div>
<p style="font-size:0.875rem; color:#cbd5e1; font-weight:500; margin:0;">${m.name}</p>
<p style="font-size:1.125rem; font-weight:700; color:white; margin:0.25rem 0;" data-person-paid>\u20AC0</p>
<p class="rd-text-xs rd-text-dim" style="margin:0;" data-person-pct>paid (0%)</p>
<p style="font-size:0.875rem; font-weight:600; margin:0.25rem 0 0;" data-person-balance>\u20AC0</p>
</div>`,
).join("")}
</div>
</div>
</section>
<!-- CTA -->
<section class="rd-section rd-section--narrow">
<div class="rd-cta">
<h2>Track Your Group Expenses</h2>
<p>
rFunds makes it easy to manage shared costs, track budgets, and settle up.
Design custom funding flows with threshold-based mechanisms.
</p>
<a href="/create-space" style="background:linear-gradient(135deg, #f59e0b, #10b981); box-shadow:0 8px 24px rgba(245,158,11,0.25);">
Create Your Space
</a>
</div>
</section>
</div>
<style>
/* Responsive grid for expenses + balances on desktop */
@media (min-width: 768px) {
#rd-expenses-section > div {
grid-template-columns: 2fr 1fr;
}
}
</style>`;
}

View File

@ -1,353 +0,0 @@
/**
* rNotes demo client-side WebSocket controller.
*
* Connects to rSpace via DemoSync, populates note cards,
* packing list checkboxes, sidebar, and notebook header.
*/
import { DemoSync, type DemoShape } from "../../../lib/demo-sync-vanilla";
// ── Helpers ──
function shapesByType(shapes: Record<string, DemoShape>, type: string): DemoShape[] {
return Object.values(shapes).filter((s) => s.type === type);
}
function shapeByType(shapes: Record<string, DemoShape>, type: string): DemoShape | undefined {
return Object.values(shapes).find((s) => s.type === type);
}
function $(id: string): HTMLElement | null {
return document.getElementById(id);
}
// ── Simple markdown renderer ──
function renderMarkdown(text: string): string {
if (!text) return "";
const lines = text.split("\n");
const out: string[] = [];
let inCodeBlock = false;
let codeLang = "";
let codeLines: string[] = [];
let inList: "ul" | "ol" | null = null;
function flushList() {
if (inList) { out.push(inList === "ul" ? "</ul>" : "</ol>"); inList = null; }
}
function flushCode() {
if (inCodeBlock) {
const escaped = codeLines.join("\n").replace(/</g, "&lt;").replace(/>/g, "&gt;");
out.push(`<div class="rd-md-codeblock">${codeLang ? `<div class="rd-md-codeblock-lang"><span>${codeLang}</span></div>` : ""}<pre>${escaped}</pre></div>`);
inCodeBlock = false;
codeLines = [];
codeLang = "";
}
}
for (const raw of lines) {
const line = raw;
// Code fence
if (line.startsWith("```")) {
if (inCodeBlock) { flushCode(); } else { flushList(); inCodeBlock = true; codeLang = line.slice(3).trim(); }
continue;
}
if (inCodeBlock) { codeLines.push(line); continue; }
// Blank line
if (!line.trim()) { flushList(); continue; }
// Headings
if (line.startsWith("### ")) { flushList(); out.push(`<h3>${inlineFormat(line.slice(4))}</h3>`); continue; }
if (line.startsWith("## ")) { flushList(); out.push(`<h3>${inlineFormat(line.slice(3))}</h3>`); continue; }
if (line.startsWith("# ")) { flushList(); out.push(`<h3>${inlineFormat(line.slice(2))}</h3>`); continue; }
if (line.startsWith("#### ")) { flushList(); out.push(`<h4>${inlineFormat(line.slice(5))}</h4>`); continue; }
if (line.startsWith("##### ")) { flushList(); out.push(`<h5>${inlineFormat(line.slice(6))}</h5>`); continue; }
// Blockquote
if (line.startsWith("> ")) { flushList(); out.push(`<div class="rd-md-quote"><p>${inlineFormat(line.slice(2))}</p></div>`); continue; }
// Unordered list
const ulMatch = line.match(/^[-*]\s+(.+)/);
if (ulMatch) {
if (inList !== "ul") { flushList(); out.push("<ul>"); inList = "ul"; }
out.push(`<li>${inlineFormat(ulMatch[1])}</li>`);
continue;
}
// Ordered list
const olMatch = line.match(/^(\d+)\.\s+(.+)/);
if (olMatch) {
if (inList !== "ol") { flushList(); out.push("<ol>"); inList = "ol"; }
out.push(`<li><span class="rd-md-num">${olMatch[1]}.</span>${inlineFormat(olMatch[2])}</li>`);
continue;
}
// Paragraph
flushList();
out.push(`<p>${inlineFormat(line)}</p>`);
}
flushCode();
flushList();
return out.join("\n");
}
function inlineFormat(text: string): string {
return text
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>");
}
// ── Note card rendering ──
const TAG_COLORS: Record<string, string> = {
planning: "rgba(245,158,11,0.15)",
travel: "rgba(20,184,166,0.15)",
food: "rgba(251,146,60,0.15)",
gear: "rgba(168,85,247,0.15)",
safety: "rgba(239,68,68,0.15)",
accommodation: "rgba(59,130,246,0.15)",
};
function renderNoteCard(note: DemoShape, expanded: boolean): string {
const title = (note.title as string) || "Untitled";
const content = (note.content as string) || "";
const tags = (note.tags as string[]) || [];
const lastEdited = note.lastEdited as string;
const synced = note.synced !== false;
const preview = content.split("\n").slice(0, 3).join(" ").slice(0, 120);
const previewText = preview.replace(/[#*>`\-]/g, "").trim();
return `
<div class="rd-card rd-note-card ${expanded ? "rd-note-card--expanded" : ""}" data-note-id="${note.id}" style="cursor:pointer;">
<div style="padding:1rem 1.25rem;">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;margin-bottom:0.5rem;">
<h3 style="font-size:0.9375rem;font-weight:600;color:white;margin:0;">${escHtml(title)}</h3>
${synced ? `<span class="rd-synced-badge"><span style="width:6px;height:6px;border-radius:50%;background:#2dd4bf;"></span>synced</span>` : ""}
</div>
${expanded
? `<div class="rd-md" style="margin-top:0.75rem;">${renderMarkdown(content)}</div>`
: `<p style="font-size:0.8125rem;color:#94a3b8;margin:0 0 0.75rem;line-height:1.5;">${escHtml(previewText)}${content.length > 120 ? "..." : ""}</p>`
}
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:0.75rem;">
<div style="display:flex;flex-wrap:wrap;gap:0.375rem;">
${tags.map((t) => `<span class="rd-note-tag" style="background:${TAG_COLORS[t] || "rgba(51,65,85,0.5)"}">${escHtml(t)}</span>`).join("")}
</div>
${lastEdited ? `<span style="font-size:0.6875rem;color:#64748b;">${formatRelative(lastEdited)}</span>` : ""}
</div>
</div>
</div>`;
}
function escHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function formatRelative(iso: string): string {
try {
const d = new Date(iso);
const now = Date.now();
const diff = now - d.getTime();
if (diff < 60_000) return "just now";
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`;
return d.toLocaleDateString("en", { month: "short", day: "numeric" });
} catch { return ""; }
}
// ── Packing list rendering ──
function renderPackingList(packingList: DemoShape): string {
const items = (packingList.items as Array<{ name: string; packed: boolean; category: string }>) || [];
if (items.length === 0) return "";
// Group by category
const groups: Record<string, typeof items> = {};
for (const item of items) {
const cat = item.category || "General";
if (!groups[cat]) groups[cat] = [];
groups[cat].push(item);
}
const checked = items.filter((i) => i.packed).length;
const pct = Math.round((checked / items.length) * 100);
let html = `
<div class="rd-card" style="overflow:hidden;">
<div style="padding:0.75rem 1rem;border-bottom:1px solid rgba(51,65,85,0.5);display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:0.8125rem;font-weight:600;color:white;">Packing Checklist</span>
<span style="font-size:0.75rem;color:#94a3b8;">${checked}/${items.length} packed (${pct}%)</span>
</div>
<div style="padding:0.75rem 1rem 0.25rem;">
<div style="height:0.375rem;background:rgba(51,65,85,0.5);border-radius:9999px;overflow:hidden;margin-bottom:0.75rem;">
<div style="height:100%;width:${pct}%;background:linear-gradient(90deg,#f59e0b,#fb923c);border-radius:9999px;transition:width 0.3s;"></div>
</div>
</div>`;
for (const [cat, catItems] of Object.entries(groups)) {
html += `<div style="padding:0 0.75rem 0.75rem;">
<h4 style="font-size:0.6875rem;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:0.05em;margin:0.5rem 0 0.25rem;">${escHtml(cat)}</h4>`;
for (let i = 0; i < catItems.length; i++) {
const item = catItems[i];
const globalIdx = items.indexOf(item);
html += `
<div class="rd-pack-item" data-pack-idx="${globalIdx}">
<div class="rd-pack-check ${item.packed ? "rd-pack-check--checked" : ""}">
${item.packed ? `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7"/></svg>` : ""}
</div>
<span style="font-size:0.8125rem;${item.packed ? "color:#64748b;text-decoration:line-through;" : "color:#e2e8f0;"}">${escHtml(item.name)}</span>
</div>`;
}
html += `</div>`;
}
html += `</div>`;
return html;
}
// ── Avatars ──
const AVATAR_COLORS = ["#14b8a6", "#06b6d4", "#8b5cf6", "#f59e0b", "#f43f5e"];
function renderAvatars(members: string[]): string {
if (!members.length) return "";
return members.map((name, i) =>
`<div class="rd-avatar" style="background:${AVATAR_COLORS[i % AVATAR_COLORS.length]}" title="${escHtml(name)}">${name[0]}</div>`
).join("") + `<span style="font-size:0.75rem;color:#94a3b8;margin-left:0.25rem;">${members.length} collaborators</span>`;
}
// ── Main ──
const expandedNotes = new Set<string>();
const sync = new DemoSync({ filter: ["folk-notebook", "folk-note", "folk-packing-list"] });
function render(shapes: Record<string, DemoShape>) {
const notebook = shapeByType(shapes, "folk-notebook");
const notes = shapesByType(shapes, "folk-note").sort((a, b) => {
const aTime = a.lastEdited ? new Date(a.lastEdited as string).getTime() : 0;
const bTime = b.lastEdited ? new Date(b.lastEdited as string).getTime() : 0;
return bTime - aTime;
});
const packingList = shapeByType(shapes, "folk-packing-list");
// Hide loading skeleton
const loading = $("rd-loading");
if (loading) loading.style.display = "none";
// Notebook header
if (notebook) {
const nbTitle = $("rd-nb-title");
const nbCount = $("rd-nb-count");
const nbDesc = $("rd-nb-desc");
const sbTitle = $("rd-sb-nb-title");
const sbCount = $("rd-sb-note-count");
const sbNum = $("rd-sb-notes-num");
if (nbTitle) nbTitle.textContent = (notebook.name as string) || "Trip Notebook";
if (nbCount) nbCount.textContent = `${notes.length} notes`;
if (nbDesc) nbDesc.textContent = (notebook.description as string) || "";
if (sbTitle) sbTitle.textContent = (notebook.name as string) || "Trip Notebook";
if (sbCount) sbCount.textContent = `${notes.length} note${notes.length !== 1 ? "s" : ""}`;
if (sbNum) sbNum.textContent = String(notes.length);
}
// Notes count
const notesCount = $("rd-notes-count");
if (notesCount) notesCount.textContent = `${notes.length} note${notes.length !== 1 ? "s" : ""}`;
// Notes container
const container = $("rd-notes-container");
const empty = $("rd-notes-empty");
if (container) {
if (notes.length === 0) {
container.innerHTML = "";
if (empty) empty.style.display = "block";
} else {
if (empty) empty.style.display = "none";
container.innerHTML = notes.map((n) => renderNoteCard(n, expandedNotes.has(n.id))).join("");
}
}
// Packing list
const packSection = $("rd-packing-section");
const packContainer = $("rd-packing-container");
if (packingList && packSection && packContainer) {
packSection.style.display = "block";
packContainer.innerHTML = renderPackingList(packingList);
}
// Avatars — extract from notebook members or note authors
const members = (notebook?.members as string[]) || [];
const avatarsEl = $("rd-avatars");
if (avatarsEl && members.length > 0) {
avatarsEl.innerHTML = renderAvatars(members);
}
}
// ── Event listeners ──
sync.addEventListener("snapshot", ((e: CustomEvent) => {
render(e.detail.shapes);
}) as EventListener);
sync.addEventListener("connected", () => {
const dot = $("rd-hero-dot");
const label = $("rd-hero-label");
if (dot) dot.style.background = "#10b981";
if (label) label.textContent = "Live — Connected to rSpace";
const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null;
if (resetBtn) resetBtn.disabled = false;
});
sync.addEventListener("disconnected", () => {
const dot = $("rd-hero-dot");
const label = $("rd-hero-label");
if (dot) dot.style.background = "#64748b";
if (label) label.textContent = "Reconnecting...";
const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null;
if (resetBtn) resetBtn.disabled = true;
});
// ── Event delegation ──
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
// Note card expand/collapse
const noteCard = target.closest<HTMLElement>("[data-note-id]");
if (noteCard) {
const id = noteCard.dataset.noteId!;
if (expandedNotes.has(id)) expandedNotes.delete(id);
else expandedNotes.add(id);
render(sync.shapes);
return;
}
// Packing checkbox toggle
const packItem = target.closest<HTMLElement>("[data-pack-idx]");
if (packItem) {
const idx = parseInt(packItem.dataset.packIdx!, 10);
const packingList = shapeByType(sync.shapes, "folk-packing-list");
if (packingList) {
const items = [...(packingList.items as Array<{ name: string; packed: boolean; category: string }>)];
items[idx] = { ...items[idx], packed: !items[idx].packed };
sync.updateShape(packingList.id, { items });
}
return;
}
// Reset button
if (target.closest("#rd-reset-btn")) {
sync.resetDemo().catch((err) => console.error("[Notes] Reset failed:", err));
}
});
// ── Start ──
sync.connect();

View File

@ -1,360 +0,0 @@
/**
* rNotes demo page server-rendered HTML body.
*
* Returns the static HTML skeleton for the interactive notes demo.
* The client-side notes-demo.ts populates note cards, packing list,
* sidebar, and notebook header via WebSocket snapshots.
*/
export function renderDemo(): string {
return `
<div class="rd-root" style="--rd-accent-from:#f59e0b; --rd-accent-to:#fb923c">
<!-- Hero -->
<section class="rd-hero">
<div style="display:inline-flex;align-items:center;gap:0.5rem;padding:0.375rem 1rem;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.2);border-radius:9999px;font-size:0.875rem;color:#fbbf24;font-weight:500;margin-bottom:1.5rem;">
<span id="rd-hero-dot" style="width:0.5rem;height:0.5rem;border-radius:9999px;background:#f59e0b;animation:rd-pulse 2s ease-in-out infinite;"></span>
<span id="rd-hero-label">Interactive Demo</span>
</div>
<h1>See how rNotes works</h1>
<p class="rd-subtitle">A collaborative knowledge base for your team</p>
<div class="rd-meta">
<span>Live transcription</span>
<span>Audio &amp; video</span>
<span>Organized notebooks</span>
<span>Canvas sync</span>
<span>Real-time collaboration</span>
</div>
<div class="rd-avatars" id="rd-avatars">
<div class="rd-avatar" style="background:#14b8a6" title="...">...</div>
</div>
</section>
<!-- Context bar + Reset -->
<section class="rd-section rd-section--narrow">
<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:1rem;margin-bottom:1.5rem;">
<p style="text-align:center;font-size:0.875rem;color:#94a3b8;max-width:40rem;margin:0;">
This demo shows a <span style="color:#e2e8f0;font-weight:500">Trip Planning Notebook</span> scenario
with notes, a packing list, tags, and canvas sync &mdash; all powered by the
<span style="color:#e2e8f0;font-weight:500">r* ecosystem</span> with live data from
<span style="color:#e2e8f0;font-weight:500">rSpace</span>.
</p>
<button id="rd-reset-btn" class="rd-btn rd-btn--ghost" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
Reset Demo
</button>
</div>
</section>
<!-- Notebook header card -->
<section class="rd-section rd-section--narrow">
<div class="rd-card" id="rd-notebook-header" style="margin-bottom:1.5rem;">
<div class="rd-card-header">
<div class="rd-card-title">
<span class="rd-icon" style="font-size:1.25rem;">&#128211;</span>
<span id="rd-nb-title">Loading...</span>
<span id="rd-nb-count" class="rd-text-xs rd-text-muted" style="margin-left:0.5rem;"></span>
</div>
<a href="https://rnotes.online" target="_blank" rel="noopener noreferrer" class="rd-card-header rd-open-link">Open in rNotes</a>
</div>
<div class="rd-card-body" style="padding:0.75rem 1.25rem;">
<p id="rd-nb-desc" class="rd-text-sm rd-text-muted" style="margin:0;">Loading notebook data...</p>
</div>
</div>
<!-- Main layout: sidebar + content -->
<div style="display:grid;grid-template-columns:1fr;gap:1.5rem;" class="rd-notes-layout">
<!-- Sidebar -->
<div id="rd-sidebar">
<div class="rd-card" style="overflow:hidden;">
<!-- Sidebar header -->
<div style="padding:0.75rem 1rem;border-bottom:1px solid rgba(51,65,85,0.5);display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:0.75rem;font-weight:600;color:#94a3b8;text-transform:uppercase;letter-spacing:0.05em;">Notebook</span>
<span id="rd-sb-note-count" style="font-size:0.75rem;color:#64748b;">0 notes</span>
</div>
<!-- Active notebook tree -->
<div style="padding:0.5rem;">
<div style="margin-bottom:0.25rem;">
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0.75rem;border-radius:0.5rem;font-size:0.875rem;background:rgba(245,158,11,0.1);color:#fcd34d;">
<span>&#128211;</span>
<span id="rd-sb-nb-title" style="font-weight:500;">Loading...</span>
</div>
<div style="margin-left:1rem;margin-top:0.125rem;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:0.375rem 0.75rem;border-radius:0.375rem;font-size:0.75rem;background:rgba(51,65,85,0.4);color:white;">
<span>Notes</span>
<span id="rd-sb-notes-num" style="color:#475569">0</span>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:0.375rem 0.75rem;border-radius:0.375rem;font-size:0.75rem;color:#64748b;margin-top:0.125rem;cursor:pointer;" onmouseover="this.style.background='rgba(51,65,85,0.2)';this.style.color='#cbd5e1'" onmouseout="this.style.background='';this.style.color='#64748b'">
<span>Packing List</span>
<span style="color:#475569">1</span>
</div>
</div>
</div>
</div>
<!-- Quick info links -->
<div style="padding:0.75rem 1rem;border-top:1px solid rgba(51,65,85,0.5);display:flex;flex-direction:column;gap:0.5rem;">
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.75rem;color:#64748b;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<span>Search notes...</span>
</div>
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.75rem;color:#64748b;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
<span>Browse tags</span>
</div>
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.75rem;color:#64748b;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span>Recent edits</span>
</div>
</div>
</div>
</div>
<!-- Notes + Packing list -->
<div>
<!-- Notes section -->
<div id="rd-notes-section">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;">
<div style="display:flex;align-items:center;gap:0.5rem;">
<h2 style="font-size:0.875rem;font-weight:600;color:#cbd5e1;margin:0;">Notes</h2>
<span id="rd-notes-count" class="rd-text-xs rd-text-muted">0 notes</span>
</div>
<span class="rd-text-xs rd-text-muted">Sort: Recently edited</span>
</div>
<!-- Loading skeleton -->
<div id="rd-loading" style="display:flex;flex-direction:column;gap:1rem;">
${[1, 2, 3]
.map(
() => `
<div class="rd-card" style="padding:1rem;">
<div style="height:1rem;background:rgba(51,65,85,0.5);border-radius:0.25rem;width:66%;margin-bottom:0.75rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="height:0.75rem;background:rgba(51,65,85,0.3);border-radius:0.25rem;width:100%;margin-bottom:0.5rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="height:0.75rem;background:rgba(51,65,85,0.3);border-radius:0.25rem;width:80%;margin-bottom:0.75rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="display:flex;gap:0.5rem;">
<div style="height:1.25rem;background:rgba(51,65,85,0.3);border-radius:0.25rem;width:4rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="height:1.25rem;background:rgba(51,65,85,0.3);border-radius:0.25rem;width:5rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
</div>
</div>`,
)
.join("")}
</div>
<style>
@keyframes rd-skeleton-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
</style>
<!-- Note cards container (populated by notes-demo.ts) -->
<div id="rd-notes-container" style="display:flex;flex-direction:column;gap:1rem;"></div>
<!-- Empty state -->
<div id="rd-notes-empty" class="rd-card" style="display:none;padding:2rem;text-align:center;">
<p class="rd-text-muted rd-text-sm">No notes found. Try resetting the demo.</p>
</div>
</div>
<!-- Packing list section -->
<div id="rd-packing-section" style="margin-top:1.5rem;display:none;">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:1rem;">
<h2 style="font-size:0.875rem;font-weight:600;color:#cbd5e1;margin:0;">Packing List</h2>
</div>
<div id="rd-packing-container"></div>
</div>
</div>
</div>
</section>
<!-- Features showcase -->
<section class="rd-section" style="margin-top:2rem;">
<h2 style="font-size:1.5rem;font-weight:700;color:white;text-align:center;margin-bottom:2rem;">Everything you need to capture knowledge</h2>
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:1rem;" class="rd-features-grid">
${[
{
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>`,
title: "Live Transcription",
desc: "Record and transcribe in real time. Stream audio via WebSocket or transcribe offline with Parakeet.js.",
},
{
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
title: "Rich Editing",
desc: "Headings, lists, code blocks, highlights, images, and file attachments in every note.",
},
{
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>`,
title: "Notebooks",
desc: "Organize notes into notebooks with sections. Nest as deep as you need.",
},
{
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>`,
title: "Flexible Tags",
desc: "Cross-cutting tags let you find notes across all notebooks instantly.",
},
{
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
title: "Canvas Sync",
desc: "Pin any note to your rSpace canvas for visual collaboration with your team.",
},
]
.map(
(f) => `
<div class="rd-card" style="padding:1.25rem;">
<div style="width:2.5rem;height:2.5rem;background:rgba(245,158,11,0.1);border-radius:0.5rem;display:flex;align-items:center;justify-content:center;margin-bottom:0.75rem;">
${f.icon}
</div>
<h3 style="font-size:0.875rem;font-weight:600;color:white;margin:0 0 0.25rem;">${f.title}</h3>
<p style="font-size:0.75rem;color:#94a3b8;margin:0;line-height:1.5;">${f.desc}</p>
</div>`,
)
.join("")}
</div>
</section>
<!-- CTA -->
<section class="rd-section rd-section--narrow">
<div class="rd-cta">
<h2>Ready to capture everything?</h2>
<p>
rNotes gives your team a shared knowledge base with rich editing, flexible organization,
and deep integration with the r* ecosystem &mdash; all on a collaborative canvas.
</p>
<a href="/create-space" style="background:linear-gradient(135deg,#f59e0b,#f97316);box-shadow:0 8px 24px rgba(245,158,11,0.25);">
Start Taking Notes
</a>
</div>
</section>
</div>
<style>
/* ── Notes-specific layout ── */
@media (min-width: 1024px) {
.rd-notes-layout {
grid-template-columns: 16rem 1fr !important;
}
}
@media (min-width: 640px) {
.rd-features-grid {
grid-template-columns: repeat(3, 1fr) !important;
}
}
@media (min-width: 1024px) {
.rd-features-grid {
grid-template-columns: repeat(5, 1fr) !important;
}
}
/* Note card styles */
.rd-note-card {
cursor: pointer;
transition: border-color 0.15s;
}
.rd-note-card:hover {
border-color: rgba(100,116,139,0.6);
}
.rd-note-card--expanded {
cursor: default;
border-color: rgba(245,158,11,0.3) !important;
box-shadow: 0 0 0 1px rgba(245,158,11,0.15);
}
/* Synced badge */
.rd-synced-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
padding: 0.25rem 0.625rem;
background: rgba(20,184,166,0.1);
border: 1px solid rgba(20,184,166,0.2);
color: #2dd4bf;
border-radius: 9999px;
white-space: nowrap;
flex-shrink: 0;
}
/* Markdown rendered content */
.rd-md h3 { font-size: 1.125rem; font-weight: 700; color: white; margin: 1rem 0 0.5rem; }
.rd-md h4 { font-size: 1rem; font-weight: 600; color: #e2e8f0; margin: 1rem 0 0.5rem; }
.rd-md h5 { font-size: 0.875rem; font-weight: 600; color: #cbd5e1; margin: 0.75rem 0 0.25rem; }
.rd-md p { font-size: 0.875rem; color: #cbd5e1; margin: 0.25rem 0; line-height: 1.6; }
.rd-md strong { color: white; font-weight: 500; }
.rd-md em { color: #cbd5e1; font-style: italic; }
.rd-md code {
color: #fcd34d;
background: rgba(30,41,59,1);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-family: monospace;
}
.rd-md .rd-md-quote {
background: rgba(245,158,11,0.1);
border-left: 2px solid #f59e0b;
padding: 0.5rem 1rem;
border-radius: 0 0.5rem 0.5rem 0;
margin: 0.5rem 0;
}
.rd-md .rd-md-quote p { color: #fcd34d; }
.rd-md ul, .rd-md ol { margin: 0.5rem 0; padding: 0; list-style: none; }
.rd-md ul li, .rd-md ol li {
display: flex; align-items: flex-start; gap: 0.5rem;
font-size: 0.875rem; color: #cbd5e1; padding: 0.125rem 0;
}
.rd-md ul li::before { content: "\\2022"; color: #f59e0b; margin-top: 0.1rem; flex-shrink: 0; }
.rd-md ol li .rd-md-num { color: #f59e0b; font-weight: 500; min-width: 1.2em; text-align: right; flex-shrink: 0; }
.rd-md .rd-md-codeblock {
background: rgba(2,6,23,1);
border-radius: 0.5rem;
border: 1px solid rgba(51,65,85,0.5);
overflow: hidden;
margin: 0.5rem 0;
}
.rd-md .rd-md-codeblock-lang {
display: flex; align-items: center; justify-content: space-between;
padding: 0.5rem 1rem;
background: rgba(30,41,59,0.5);
border-bottom: 1px solid rgba(51,65,85,0.5);
font-size: 0.75rem; color: #94a3b8; font-family: monospace;
}
.rd-md .rd-md-codeblock pre {
padding: 0.75rem 1rem;
font-size: 0.75rem; color: #cbd5e1;
font-family: monospace;
overflow-x: auto;
line-height: 1.5;
margin: 0;
}
/* Tag pill */
.rd-note-tag {
display: inline-block;
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
background: rgba(51,65,85,0.5);
color: #94a3b8;
border-radius: 0.375rem;
border: 1px solid rgba(51,65,85,0.3);
}
/* Packing list checkbox */
.rd-pack-check {
width: 1.25rem; height: 1.25rem; border-radius: 0.25rem; flex-shrink: 0;
border: 2px solid #475569;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 0.15s;
}
.rd-pack-check--checked {
background: #f59e0b; border-color: #f59e0b;
}
.rd-pack-check:hover { border-color: #64748b; }
/* Packing item label */
.rd-pack-item {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
cursor: pointer;
transition: background 0.1s;
}
.rd-pack-item:hover { background: rgba(51,65,85,0.3); }
</style>`;
}

View File

@ -1,459 +0,0 @@
/**
* rTrips demo client-side WebSocket controller.
*
* Connects to rSpace (no filter needs all shape types) and
* populates the 6-card trip dashboard: maps, notes/packing,
* calendar, polls, funds, cart.
*/
import { DemoSync, type DemoShape } from "../../../lib/demo-sync-vanilla";
// ── Helpers ──
function shapesByType(shapes: Record<string, DemoShape>, type: string): DemoShape[] {
return Object.values(shapes).filter((s) => s.type === type);
}
function shapeByType(shapes: Record<string, DemoShape>, type: string): DemoShape | undefined {
return Object.values(shapes).find((s) => s.type === type);
}
function $(id: string): HTMLElement | null {
return document.getElementById(id);
}
function escHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ── Constants ──
const MEMBER_COLORS = ["#14b8a6", "#06b6d4", "#3b82f6", "#8b5cf6", "#f59e0b", "#f43f5e"];
const POLL_COLORS = ["#f59e0b", "#3b82f6", "#10b981", "#f43f5e"];
const CATEGORY_COLORS: Record<string, string> = {
travel: "#14b8a6",
hike: "#10b981",
adventure: "#f59e0b",
rest: "#64748b",
culture: "#8b5cf6",
FLIGHT: "#14b8a6",
TRANSPORT: "#06b6d4",
ACCOMMODATION: "#14b8a6",
ACTIVITY: "#10b981",
MEAL: "#f59e0b",
FREE_TIME: "#64748b",
OTHER: "#64748b",
};
// ── DemoSync (no filter — needs all shape types) ──
const sync = new DemoSync();
// ── Render functions ──
function render(shapes: Record<string, DemoShape>) {
const itinerary = shapeByType(shapes, "folk-itinerary");
const destinations = shapesByType(shapes, "folk-destination");
const packingList = shapeByType(shapes, "folk-packing-list");
const pollShapes = shapesByType(shapes, "demo-poll");
const expenseShapes = shapesByType(shapes, "demo-expense");
const cartShapes = shapesByType(shapes, "demo-cart-item");
const budgetShape = shapeByType(shapes, "folk-budget");
const hasShapes = Object.keys(shapes).length > 0;
// Members from itinerary
const travelers = (itinerary?.travelers ?? []) as string[];
const members = travelers.map((name, i) => ({ name, color: MEMBER_COLORS[i % MEMBER_COLORS.length] }));
// Trip header
renderHeader(itinerary, destinations, budgetShape, members, hasShapes);
// Show live badges
for (const card of ["maps", "notes", "cal", "polls", "funds", "cart"]) {
const el = $(`rd-${card}-live`);
if (el) el.style.display = hasShapes ? "inline-flex" : "none";
}
// Cards
renderMap(destinations);
renderNotes(packingList);
renderCalendar(itinerary);
renderPolls(pollShapes);
renderFunds(expenseShapes, members);
renderCart(cartShapes);
}
// ── Header ──
function renderHeader(
itinerary: DemoShape | undefined,
destinations: DemoShape[],
budgetShape: DemoShape | undefined,
members: { name: string; color: string }[],
hasShapes: boolean,
) {
const title = $("rd-trip-title");
const route = $("rd-trip-route");
const meta = $("rd-trip-meta");
const avatars = $("rd-avatars");
if (title && itinerary?.tripTitle) {
title.textContent = itinerary.tripTitle as string;
}
if (route && destinations.length > 0) {
route.textContent = destinations.map((d) => d.destName as string).join(" → ");
}
if (meta && itinerary) {
const start = itinerary.startDate as string;
const end = itinerary.endDate as string;
const budgetTotal = (budgetShape?.budgetTotal as number) || 4000;
const dateRange = start && end
? `${new Date(start).toLocaleDateString("en", { month: "short", day: "numeric" })}${new Date(end).toLocaleDateString("en", { month: "short", day: "numeric" })}, 2026`
: "Jul 620, 2026";
const countries = destinations.length > 0
? new Set(destinations.map((d) => d.country as string)).size
: 3;
meta.innerHTML = `
<span>📅 ${dateRange}</span>
<span>💶 ~${budgetTotal.toLocaleString()} budget</span>
<span>🏔 ${countries} countr${countries !== 1 ? "ies" : "y"}</span>
${hasShapes ? `<span style="display:flex;align-items:center;gap:0.25rem;color:#34d399;"><span style="width:6px;height:6px;border-radius:50%;background:#34d399;animation:rd-pulse 2s ease-in-out infinite;"></span> Live data</span>` : ""}
`;
}
if (avatars && members.length > 0) {
avatars.innerHTML = members.map((m) =>
`<div class="rd-avatar" style="background:${m.color}" title="${escHtml(m.name)}">${m.name[0]}</div>`
).join("") + `<span style="font-size:0.75rem;color:#94a3b8;margin-left:0.25rem;">${members.length} explorers</span>`;
}
}
// ── Map card ──
function renderMap(destinations: DemoShape[]) {
if (destinations.length === 0) return;
const pins = destinations.map((d, i) => ({
name: d.destName as string,
cx: 160 + i * 245,
cy: 180 - i * 20,
color: ["#14b8a6", "#06b6d4", "#8b5cf6"][i] || "#94a3b8",
stroke: ["#0d9488", "#0891b2", "#7c3aed"][i] || "#64748b",
dates: d.arrivalDate && d.departureDate
? `${new Date(d.arrivalDate as string).toLocaleDateString("en", { month: "short", day: "numeric" })}${new Date(d.departureDate as string).getUTCDate()}`
: "",
}));
const pinsEl = $("rd-map-pins");
if (pinsEl) {
pinsEl.innerHTML = pins.map((p) => `
<g>
<circle cx="${p.cx}" cy="${p.cy}" r="8" fill="${p.color}" stroke="${p.stroke}" stroke-width="2"/>
<text x="${p.cx}" y="${p.cy + 30}" text-anchor="middle" fill="#94a3b8" font-size="12" font-weight="600">${escHtml(p.name)}</text>
<text x="${p.cx}" y="${p.cy + 44}" text-anchor="middle" fill="#64748b" font-size="10">${p.dates}</text>
</g>
`).join("");
}
if (pins.length >= 3) {
const routeEl = $("rd-route-path");
if (routeEl) {
routeEl.setAttribute("d",
`M${pins[0].cx} ${pins[0].cy} C${pins[0].cx + 90} ${pins[0].cy - 20}, ${pins[1].cx - 80} ${pins[1].cy + 50}, ${pins[1].cx} ${pins[1].cy} C${pins[1].cx + 80} ${pins[1].cy - 50}, ${pins[2].cx - 90} ${pins[2].cy + 20}, ${pins[2].cx} ${pins[2].cy}`
);
}
}
}
// ── Notes card (packing checklist) ──
function renderNotes(packingList: DemoShape | undefined) {
const container = $("rd-packing-list");
if (!container || !packingList) return;
const items = (packingList.items as Array<{ name: string; packed: boolean; category: string }>) || [];
if (items.length === 0) return;
container.innerHTML = `<ul style="list-style:none;margin:0;padding:0;">
${items.map((item, idx) => `
<li class="rd-trips-pack-item" data-pack-idx="${idx}">
<div class="rd-trips-pack-check ${item.packed ? "rd-trips-pack-check--checked" : ""}">
${item.packed ? `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7"/></svg>` : ""}
</div>
<span style="${item.packed ? "color:#64748b;text-decoration:line-through;" : "color:#e2e8f0;"}">${escHtml(item.name)}</span>
</li>
`).join("")}
</ul>`;
}
// ── Calendar card ──
function renderCalendar(itinerary: DemoShape | undefined) {
const grid = $("rd-cal-grid");
if (!grid) return;
const items = (itinerary?.items ?? []) as Array<{ date: string; activity: string; category: string }>;
// Build calendar events map
const calEvents: Record<number, { label: string; color: string }[]> = {};
for (const item of items) {
if (!item.date) continue;
const match = item.date.match(/(\d+)/);
if (!match) continue;
const day = parseInt(match[1], 10);
if (!calEvents[day]) calEvents[day] = [];
calEvents[day].push({
label: item.activity,
color: CATEGORY_COLORS[item.category] || "#64748b",
});
}
const eventDays = Object.keys(calEvents).map(Number);
const tripStart = eventDays.length > 0 ? Math.min(...eventDays) : 6;
const tripEnd = eventDays.length > 0 ? Math.max(...eventDays) : 20;
const daysLabel = $("rd-cal-days");
if (daysLabel) daysLabel.textContent = `${tripEnd - tripStart + 1} days`;
const dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const offset = 2; // July 1 2026 = Wednesday (0-indexed Mon grid)
const daysInJuly = 31;
let html = `<div class="rd-cal-grid">`;
// Day name headers
for (const d of dayNames) {
html += `<div class="rd-cal-day-name">${d}</div>`;
}
// Empty cells for offset
for (let i = 0; i < offset; i++) {
html += `<div class="rd-cal-cell" style="min-height:3.5rem;"></div>`;
}
// Day cells
for (let day = 1; day <= daysInJuly; day++) {
const isTrip = day >= tripStart && day <= tripEnd;
const events = calEvents[day];
html += `<div class="rd-cal-cell ${isTrip ? "rd-cal-cell--trip" : "rd-cal-cell--empty"}">
<span class="rd-cal-cell-num ${isTrip ? "rd-cal-cell-num--trip" : "rd-cal-cell-num--off"}">${day}</span>`;
if (events) {
for (const ev of events) {
html += `<span class="rd-cal-event" style="background:${ev.color};">${escHtml(ev.label)}</span>`;
}
}
html += `</div>`;
}
html += `</div>`;
grid.innerHTML = html;
}
// ── Polls card ──
function renderPolls(pollShapes: DemoShape[]) {
const body = $("rd-polls-body");
if (!body) return;
if (pollShapes.length === 0) return;
let html = "";
for (const shape of pollShapes) {
const question = shape.question as string;
const options = (shape.options ?? []) as Array<{ label: string; votes: number }>;
const totalVotes = options.reduce((s, o) => s + o.votes, 0);
html += `<div style="margin-bottom:1.25rem;">
<h4 style="font-size:0.8125rem;font-weight:500;color:#e2e8f0;margin:0 0 0.5rem;">${escHtml(question)}</h4>`;
for (let i = 0; i < options.length; i++) {
const opt = options[i];
const pct = totalVotes > 0 ? Math.round((opt.votes / totalVotes) * 100) : 0;
const color = POLL_COLORS[i % POLL_COLORS.length];
html += `<div style="margin-bottom:0.5rem;">
<div style="display:flex;align-items:center;justify-content:space-between;font-size:0.75rem;margin-bottom:0.25rem;">
<span style="color:#cbd5e1;">${escHtml(opt.label)}</span>
<span style="color:#94a3b8;">${opt.votes} vote${opt.votes !== 1 ? "s" : ""} (${pct}%)</span>
</div>
<div class="rd-trips-poll-bar-bg">
<div class="rd-trips-poll-bar" style="width:${pct}%;background:${color};"></div>
</div>
</div>`;
}
html += `<p style="font-size:0.6875rem;color:#64748b;margin:0.25rem 0 0;">${totalVotes} votes cast</p></div>`;
}
body.innerHTML = html;
}
// ── Funds card ──
function renderFunds(expenseShapes: DemoShape[], members: { name: string; color: string }[]) {
const totalEl = $("rd-funds-total");
const skeleton = $("rd-funds-skeleton");
const expensesEl = $("rd-funds-expenses");
const balancesEl = $("rd-funds-balances");
if (!totalEl || !expensesEl || !balancesEl) return;
if (expenseShapes.length === 0) return;
const expenses = expenseShapes.map((s) => ({
desc: s.description as string,
who: s.paidBy as string,
amount: s.amount as number,
split: members.length || 4,
}));
const totalSpent = expenses.reduce((s, e) => s + e.amount, 0);
totalEl.textContent = `${totalSpent.toLocaleString()}`;
if (skeleton) skeleton.style.display = "none";
expensesEl.style.display = "block";
balancesEl.style.display = "block";
// Recent expenses
let expHtml = `<h4 class="rd-trips-sub-heading" style="margin-top:0.75rem;">Recent</h4>`;
for (const e of expenses.slice(0, 4)) {
expHtml += `<div style="display:flex;align-items:center;justify-content:space-between;font-size:0.8125rem;margin-bottom:0.5rem;">
<div style="min-width:0;">
<p style="color:#e2e8f0;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escHtml(e.desc)}</p>
<p style="font-size:0.6875rem;color:#64748b;margin:0;">${escHtml(e.who)} · split ${e.split} ways</p>
</div>
<span style="color:#cbd5e1;font-weight:500;margin-left:0.5rem;flex-shrink:0;">${e.amount}</span>
</div>`;
}
expensesEl.innerHTML = expHtml;
// Balances
const balances: Record<string, number> = {};
for (const m of members) balances[m.name] = 0;
for (const e of expenses) {
const share = e.amount / e.split;
balances[e.who] = (balances[e.who] || 0) + e.amount;
for (const m of members.slice(0, e.split)) {
balances[m.name] = (balances[m.name] || 0) - share;
}
}
let balHtml = `<h4 class="rd-trips-sub-heading" style="margin-top:0.75rem;">Balances</h4>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.375rem;">`;
for (const m of members) {
const bal = balances[m.name] || 0;
balHtml += `<div style="display:flex;align-items:center;justify-content:space-between;font-size:0.75rem;">
<span style="color:#cbd5e1;">${escHtml(m.name)}</span>
<span style="color:${bal >= 0 ? "#34d399" : "#fb7185"};">${bal >= 0 ? "+" : ""}${Math.round(bal)}</span>
</div>`;
}
balHtml += `</div>`;
balancesEl.innerHTML = balHtml;
}
// ── Cart card ──
function renderCart(cartShapes: DemoShape[]) {
const skeleton = $("rd-cart-skeleton");
const content = $("rd-cart-content");
if (!content) return;
if (cartShapes.length === 0) return;
if (skeleton) skeleton.style.display = "none";
content.style.display = "block";
const items = cartShapes.map((s) => ({
name: s.name as string,
target: s.price as number,
funded: s.funded as number,
status: ((s.funded as number) >= (s.price as number)) ? "Purchased" : "Funding",
}));
const totalFunded = items.reduce((s, i) => s + i.funded, 0);
const totalTarget = items.reduce((s, i) => s + i.target, 0);
const purchased = items.filter((i) => i.status === "Purchased").length;
const overallPct = totalTarget > 0 ? Math.round((totalFunded / totalTarget) * 100) : 0;
let html = `
<div style="display:flex;align-items:center;justify-content:space-between;font-size:0.8125rem;margin-bottom:0.25rem;">
<span style="color:#cbd5e1;">${totalFunded} / ${totalTarget} funded</span>
<span style="font-size:0.75rem;color:#94a3b8;">${purchased}/${items.length} purchased</span>
</div>
<div style="height:0.5rem;background:rgba(51,65,85,0.7);border-radius:9999px;overflow:hidden;margin-bottom:1rem;">
<div style="height:100%;width:${overallPct}%;background:#14b8a6;border-radius:9999px;transition:width 0.3s;"></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;">`;
for (const item of items) {
const pct = item.target > 0 ? Math.round((item.funded / item.target) * 100) : 0;
html += `<div class="rd-trips-cart-item">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.375rem;">
<span style="font-size:0.8125rem;color:#e2e8f0;">${escHtml(item.name)}</span>
${item.status === "Purchased"
? `<span style="font-size:0.6875rem;padding:0.125rem 0.5rem;background:rgba(16,185,129,0.2);color:#34d399;border-radius:9999px;">✓ Bought</span>`
: `<span style="font-size:0.6875rem;color:#94a3b8;">€${item.funded}/€${item.target}</span>`
}
</div>
<div class="rd-trips-cart-bar-bg">
<div class="rd-trips-cart-bar" style="width:${pct}%;background:${pct === 100 ? "#10b981" : "#14b8a6"};"></div>
</div>
</div>`;
}
html += `</div>`;
content.innerHTML = html;
}
// ── Event listeners ──
sync.addEventListener("snapshot", ((e: CustomEvent) => {
render(e.detail.shapes);
}) as EventListener);
sync.addEventListener("connected", () => {
const dot = $("rd-hero-dot");
const label = $("rd-hero-label");
if (dot) dot.style.background = "#10b981";
if (label) label.textContent = "Live — Connected to rSpace";
const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null;
if (resetBtn) resetBtn.disabled = false;
});
sync.addEventListener("disconnected", () => {
const dot = $("rd-hero-dot");
const label = $("rd-hero-label");
if (dot) dot.style.background = "#64748b";
if (label) label.textContent = "Reconnecting...";
const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null;
if (resetBtn) resetBtn.disabled = true;
});
// ── Event delegation ──
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
// Packing checkbox toggle
const packItem = target.closest<HTMLElement>("[data-pack-idx]");
if (packItem) {
const idx = parseInt(packItem.dataset.packIdx!, 10);
const packingList = shapeByType(sync.shapes, "folk-packing-list");
if (packingList) {
const items = [...(packingList.items as Array<{ name: string; packed: boolean; category: string }>)];
items[idx] = { ...items[idx], packed: !items[idx].packed };
sync.updateShape(packingList.id, { items });
}
return;
}
// Reset button
if (target.closest("#rd-reset-btn")) {
sync.resetDemo().catch((err) => console.error("[Trips] Reset failed:", err));
}
});
// ── Start ──
sync.connect();

View File

@ -1,414 +0,0 @@
/**
* rTrips demo page server-rendered HTML body.
*
* "Alpine Explorer 2026" dashboard with 6 cards powered by the rStack:
* Maps (SVG), Notes (packing checklist), Calendar (grid),
* Polls (bars), Funds (expenses), Cart (gear progress).
*
* Client-side trips-demo.ts populates all cards via WebSocket snapshots.
*/
export function renderDemo(): string {
return `
<div class="rd-root" style="--rd-accent-from:#14b8a6; --rd-accent-to:#06b6d4">
<!-- Trip Header -->
<section class="rd-hero">
<div style="display:inline-flex;align-items:center;gap:0.5rem;padding:0.375rem 1rem;background:rgba(20,184,166,0.1);border:1px solid rgba(20,184,166,0.2);border-radius:9999px;font-size:0.875rem;color:#5eead4;font-weight:500;margin-bottom:1.5rem;">
<span id="rd-hero-dot" style="width:0.5rem;height:0.5rem;border-radius:9999px;background:#14b8a6;animation:rd-pulse 2s ease-in-out infinite;"></span>
<span id="rd-hero-label">Interactive Demo</span>
</div>
<h1 id="rd-trip-title" style="background:linear-gradient(135deg,#5eead4,#67e8f9,#93c5fd);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;">Alpine Explorer 2026</h1>
<p class="rd-subtitle" id="rd-trip-route">Chamonix Zermatt Dolomites</p>
<div class="rd-meta" id="rd-trip-meta">
<span>📅 Jul 620, 2026</span>
<span>💶 ~4,000 budget</span>
<span>🏔 3 countries</span>
</div>
<div class="rd-avatars" id="rd-avatars">
<div class="rd-avatar" style="background:#64748b" title="Loading"></div>
</div>
</section>
<!-- Context bar -->
<section class="rd-section rd-section--narrow">
<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:1rem;margin-bottom:1.5rem;">
<p style="text-align:center;font-size:0.875rem;color:#94a3b8;max-width:40rem;margin:0;">
Every trip is powered by the <span style="color:#e2e8f0;font-weight:500">rStack</span>
a suite of collaborative tools that handle routes, notes, schedules, voting, expenses,
and shared purchases. Each card below shows live data with a link to the full tool.
</p>
<button id="rd-reset-btn" class="rd-btn rd-btn--ghost" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
Reset Demo
</button>
</div>
</section>
<!-- 6-Card Dashboard Grid -->
<section class="rd-section">
<div class="rd-trips-grid">
<!-- 1. Route Map (span 2) -->
<div class="rd-card rd-trips-card rd-trips-card--span2" id="rd-card-maps">
<div class="rd-trips-card-header">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span style="font-size:1.125rem;">🗺</span>
<span style="font-weight:600;font-size:0.875rem;">Route Map</span>
<span class="rd-trips-live" id="rd-maps-live" style="display:none;">
<span style="width:6px;height:6px;border-radius:50%;background:#34d399;animation:rd-pulse 2s ease-in-out infinite;"></span>
live
</span>
</div>
<a href="https://rmaps.online" target="_blank" rel="noopener noreferrer" class="rd-trips-open-link">Open in rMaps </a>
</div>
<div class="rd-trips-card-body">
<div style="position:relative;width:100%;border-radius:0.75rem;background:rgba(15,23,42,0.6);overflow:hidden;">
<svg id="rd-map-svg" viewBox="0 0 800 300" style="width:100%;height:auto;display:block;" xmlns="http://www.w3.org/2000/svg">
<!-- Mountain silhouettes -->
<path d="M0 280 L60 200 L100 240 L160 160 L200 210 L260 130 L320 180 L380 100 L420 150 L480 80 L540 140 L600 110 L660 160 L720 120 L800 180 L800 300 L0 300 Z" fill="rgba(30,41,59,0.8)"/>
<path d="M0 280 L80 220 L140 250 L200 190 L280 230 L340 170 L400 200 L460 150 L520 190 L580 160 L640 200 L700 170 L800 220 L800 300 L0 300 Z" fill="rgba(51,65,85,0.6)"/>
<path d="M370 100 L380 100 L390 108 L375 105 Z" fill="rgba(255,255,255,0.4)"/>
<path d="M470 80 L480 80 L492 90 L476 86 Z" fill="rgba(255,255,255,0.5)"/>
<path d="M590 110 L600 110 L612 120 L598 116 Z" fill="rgba(255,255,255,0.4)"/>
<!-- Route line (default) -->
<path id="rd-route-path" d="M160 180 C250 160, 350 200, 430 150 C510 100, 560 160, 650 140" fill="none" stroke="rgba(94,234,212,0.7)" stroke-width="3" stroke-dasharray="10 6"/>
<!-- Default destination pins -->
<g id="rd-map-pins">
<g><circle cx="160" cy="180" r="8" fill="#14b8a6" stroke="#0d9488" stroke-width="2"/><text x="160" y="210" text-anchor="middle" fill="#94a3b8" font-size="12" font-weight="600">Chamonix</text><text x="160" y="224" text-anchor="middle" fill="#64748b" font-size="10">Jul 610</text></g>
<g><circle cx="430" cy="150" r="8" fill="#06b6d4" stroke="#0891b2" stroke-width="2"/><text x="430" y="180" text-anchor="middle" fill="#94a3b8" font-size="12" font-weight="600">Zermatt</text><text x="430" y="194" text-anchor="middle" fill="#64748b" font-size="10">Jul 1014</text></g>
<g><circle cx="650" cy="140" r="8" fill="#8b5cf6" stroke="#7c3aed" stroke-width="2"/><text x="650" y="170" text-anchor="middle" fill="#94a3b8" font-size="12" font-weight="600">Dolomites</text><text x="650" y="184" text-anchor="middle" fill="#64748b" font-size="10">Jul 1420</text></g>
</g>
<!-- Activity icons -->
<text x="280" y="168" font-size="16">🥾</text>
<text x="350" y="188" font-size="16">🧗</text>
<text x="500" y="128" font-size="16">🚵</text>
<text x="560" y="148" font-size="16">🪂</text>
<text x="620" y="158" font-size="16">🛶</text>
</svg>
<div style="position:absolute;bottom:0.75rem;left:0.75rem;display:flex;gap:0.75rem;font-size:0.75rem;color:#94a3b8;">
<span style="display:flex;align-items:center;gap:0.25rem;"><span style="width:8px;height:8px;border-radius:50%;background:#14b8a6;"></span> France</span>
<span style="display:flex;align-items:center;gap:0.25rem;"><span style="width:8px;height:8px;border-radius:50%;background:#06b6d4;"></span> Switzerland</span>
<span style="display:flex;align-items:center;gap:0.25rem;"><span style="width:8px;height:8px;border-radius:50%;background:#8b5cf6;"></span> Italy</span>
</div>
</div>
</div>
</div>
<!-- 2. Trip Notes (packing checklist) -->
<div class="rd-card rd-trips-card" id="rd-card-notes">
<div class="rd-trips-card-header">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span style="font-size:1.125rem;">📝</span>
<span style="font-weight:600;font-size:0.875rem;">Trip Notes</span>
<span class="rd-trips-live" id="rd-notes-live" style="display:none;">
<span style="width:6px;height:6px;border-radius:50%;background:#34d399;animation:rd-pulse 2s ease-in-out infinite;"></span>
live
</span>
</div>
<a href="https://rnotes.online" target="_blank" rel="noopener noreferrer" class="rd-trips-open-link">Open in rNotes </a>
</div>
<div class="rd-trips-card-body" id="rd-notes-body">
<div>
<h4 class="rd-trips-sub-heading">Packing Checklist</h4>
<div id="rd-packing-list" class="rd-trips-skeleton">
<div style="height:0.875rem;background:rgba(51,65,85,0.5);border-radius:0.25rem;width:75%;margin-bottom:0.5rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="height:0.875rem;background:rgba(51,65,85,0.5);border-radius:0.25rem;width:50%;margin-bottom:0.5rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="height:0.875rem;background:rgba(51,65,85,0.5);border-radius:0.25rem;width:66%;margin-bottom:0.5rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="height:0.875rem;background:rgba(51,65,85,0.5);border-radius:0.25rem;width:33%;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
</div>
</div>
<div style="margin-top:1rem;">
<h4 class="rd-trips-sub-heading">Trip Rules</h4>
<ol style="margin:0;padding:0 0 0 1.25rem;font-size:0.8125rem;color:#cbd5e1;line-height:1.75;">
<li>Majority vote on daily activities</li>
<li>Shared expenses split equally</li>
<li>Quiet hours after 10pm in huts</li>
<li>Everyone carries their own pack</li>
</ol>
</div>
</div>
</div>
<!-- 3. Group Calendar (span 2) -->
<div class="rd-card rd-trips-card rd-trips-card--span2" id="rd-card-cal">
<div class="rd-trips-card-header">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span style="font-size:1.125rem;">📅</span>
<span style="font-weight:600;font-size:0.875rem;">Group Calendar</span>
<span class="rd-trips-live" id="rd-cal-live" style="display:none;">
<span style="width:6px;height:6px;border-radius:50%;background:#34d399;animation:rd-pulse 2s ease-in-out infinite;"></span>
live
</span>
</div>
<a href="https://rcal.online" target="_blank" rel="noopener noreferrer" class="rd-trips-open-link">Open in rCal </a>
</div>
<div class="rd-trips-card-body">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
<h4 style="font-size:0.875rem;font-weight:600;color:#e2e8f0;margin:0;">July 2026</h4>
<span id="rd-cal-days" style="font-size:0.75rem;color:#94a3b8;">15 days</span>
</div>
<div id="rd-cal-grid"></div>
<div style="display:flex;flex-wrap:wrap;gap:0.75rem;margin-top:0.75rem;font-size:0.75rem;color:#94a3b8;">
<span style="display:flex;align-items:center;gap:0.25rem;"><span style="width:8px;height:8px;border-radius:50%;background:#14b8a6;"></span> Travel</span>
<span style="display:flex;align-items:center;gap:0.25rem;"><span style="width:8px;height:8px;border-radius:50%;background:#10b981;"></span> Hiking</span>
<span style="display:flex;align-items:center;gap:0.25rem;"><span style="width:8px;height:8px;border-radius:50%;background:#f59e0b;"></span> Adventure</span>
<span style="display:flex;align-items:center;gap:0.25rem;"><span style="width:8px;height:8px;border-radius:50%;background:#8b5cf6;"></span> Culture</span>
<span style="display:flex;align-items:center;gap:0.25rem;"><span style="width:8px;height:8px;border-radius:50%;background:#64748b;"></span> Rest</span>
<span style="display:flex;align-items:center;gap:0.25rem;"><span style="width:8px;height:8px;border-radius:50%;background:#06b6d4;"></span> Transit</span>
</div>
</div>
</div>
<!-- 4. Group Polls -->
<div class="rd-card rd-trips-card" id="rd-card-polls">
<div class="rd-trips-card-header">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span style="font-size:1.125rem;">🗳</span>
<span style="font-weight:600;font-size:0.875rem;">Group Polls</span>
<span class="rd-trips-live" id="rd-polls-live" style="display:none;">
<span style="width:6px;height:6px;border-radius:50%;background:#34d399;animation:rd-pulse 2s ease-in-out infinite;"></span>
live
</span>
</div>
<a href="https://rvote.online" target="_blank" rel="noopener noreferrer" class="rd-trips-open-link">Open in rVote </a>
</div>
<div class="rd-trips-card-body" id="rd-polls-body">
<div class="rd-trips-skeleton">
<div style="height:0.875rem;background:rgba(51,65,85,0.5);border-radius:0.25rem;width:75%;margin-bottom:0.75rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="height:0.5rem;background:rgba(51,65,85,0.3);border-radius:0.25rem;width:100%;margin-bottom:0.5rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="height:0.5rem;background:rgba(51,65,85,0.3);border-radius:0.25rem;width:66%;margin-bottom:0.5rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="height:0.5rem;background:rgba(51,65,85,0.3);border-radius:0.25rem;width:33%;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
</div>
</div>
</div>
<!-- 5. Group Expenses -->
<div class="rd-card rd-trips-card" id="rd-card-funds">
<div class="rd-trips-card-header">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span style="font-size:1.125rem;">💰</span>
<span style="font-weight:600;font-size:0.875rem;">Group Expenses</span>
<span class="rd-trips-live" id="rd-funds-live" style="display:none;">
<span style="width:6px;height:6px;border-radius:50%;background:#34d399;animation:rd-pulse 2s ease-in-out infinite;"></span>
live
</span>
</div>
<a href="https://rfunds.online" target="_blank" rel="noopener noreferrer" class="rd-trips-open-link">Open in rFunds </a>
</div>
<div class="rd-trips-card-body" id="rd-funds-body">
<div style="text-align:center;padding:0.5rem 0;">
<p id="rd-funds-total" style="font-size:1.5rem;font-weight:700;color:white;margin:0;">...</p>
<p style="font-size:0.75rem;color:#94a3b8;margin:0.25rem 0 0;">Total group spending</p>
</div>
<div class="rd-trips-skeleton" id="rd-funds-skeleton">
<div style="height:0.875rem;background:rgba(51,65,85,0.5);border-radius:0.25rem;width:75%;margin-bottom:0.5rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="height:0.875rem;background:rgba(51,65,85,0.5);border-radius:0.25rem;width:50%;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
</div>
<div id="rd-funds-expenses" style="display:none;"></div>
<div id="rd-funds-balances" style="display:none;"></div>
</div>
</div>
<!-- 6. Shared Gear (span 2) -->
<div class="rd-card rd-trips-card rd-trips-card--span2" id="rd-card-cart">
<div class="rd-trips-card-header">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span style="font-size:1.125rem;">🛒</span>
<span style="font-weight:600;font-size:0.875rem;">Shared Gear</span>
<span class="rd-trips-live" id="rd-cart-live" style="display:none;">
<span style="width:6px;height:6px;border-radius:50%;background:#34d399;animation:rd-pulse 2s ease-in-out infinite;"></span>
live
</span>
</div>
<a href="https://rcart.online" target="_blank" rel="noopener noreferrer" class="rd-trips-open-link">Open in rCart </a>
</div>
<div class="rd-trips-card-body" id="rd-cart-body">
<div class="rd-trips-skeleton" id="rd-cart-skeleton">
<div style="height:0.5rem;background:rgba(51,65,85,0.5);border-radius:0.25rem;width:100%;margin-bottom:0.75rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;">
<div style="height:3rem;background:rgba(51,65,85,0.3);border-radius:0.5rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="height:3rem;background:rgba(51,65,85,0.3);border-radius:0.5rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="height:3rem;background:rgba(51,65,85,0.3);border-radius:0.5rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="height:3rem;background:rgba(51,65,85,0.3);border-radius:0.5rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
</div>
</div>
<div id="rd-cart-content" style="display:none;"></div>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="rd-section rd-section--narrow">
<div class="rd-cta">
<h2>Plan Your Own Group Adventure</h2>
<p>
The rStack gives your group everything you need &mdash; routes, schedules, polls,
shared expenses, and gear lists &mdash; all connected in one trip canvas.
</p>
<a href="/create-space" style="background:linear-gradient(135deg,#14b8a6,#06b6d4);box-shadow:0 8px 24px rgba(20,184,166,0.25);">
Start Planning
</a>
</div>
</section>
</div>
<style>
@keyframes rd-skeleton-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.rd-trips-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 768px) {
.rd-trips-grid {
grid-template-columns: repeat(3, 1fr);
}
.rd-trips-card--span2 {
grid-column: span 2;
}
}
.rd-trips-card {
overflow: hidden;
}
.rd-trips-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid rgba(51,65,85,0.5);
}
.rd-trips-card-body {
padding: 1.25rem;
}
.rd-trips-open-link {
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
background: rgba(51,65,85,0.6);
border-radius: 0.5rem;
color: #cbd5e1;
text-decoration: none;
transition: all 0.15s;
white-space: nowrap;
}
.rd-trips-open-link:hover {
background: rgba(71,85,105,0.6);
color: white;
}
.rd-trips-live {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: #34d399;
}
.rd-trips-sub-heading {
font-size: 0.6875rem;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.5rem;
}
.rd-trips-skeleton {
animation: rd-skeleton-pulse 1.5s ease-in-out infinite;
}
/* Packing item */
.rd-trips-pack-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
padding: 0.25rem 0;
cursor: pointer;
}
.rd-trips-pack-item:hover { opacity: 0.85; }
.rd-trips-pack-check {
width: 1rem; height: 1rem; border-radius: 0.25rem; flex-shrink: 0;
border: 2px solid #475569;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.rd-trips-pack-check--checked {
background: #14b8a6; border-color: #14b8a6;
}
/* Calendar grid */
.rd-cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.25rem;
}
.rd-cal-day-name {
text-align: center;
font-size: 0.6875rem;
color: #64748b;
font-weight: 500;
padding: 0.25rem 0;
}
.rd-cal-cell {
min-height: 3.5rem;
border-radius: 0.5rem;
padding: 0.25rem;
font-size: 0.75rem;
}
.rd-cal-cell--trip {
background: rgba(51,65,85,0.4);
border: 1px solid rgba(71,85,105,0.4);
}
.rd-cal-cell--empty {
background: rgba(30,41,59,0.3);
}
.rd-cal-cell-num { display: block; margin-bottom: 0.125rem; }
.rd-cal-cell-num--trip { color: #e2e8f0; font-weight: 500; }
.rd-cal-cell-num--off { color: #475569; }
.rd-cal-event {
display: block;
border-radius: 0.25rem;
padding: 0 0.25rem;
color: white;
font-size: 9px;
margin-top: 0.125rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Poll bar */
.rd-trips-poll-bar-bg {
height: 0.5rem;
background: rgba(51,65,85,0.7);
border-radius: 9999px;
overflow: hidden;
}
.rd-trips-poll-bar {
height: 100%;
border-radius: 9999px;
transition: width 0.3s;
}
/* Cart item */
.rd-trips-cart-item {
background: rgba(51,65,85,0.3);
border-radius: 0.5rem;
padding: 0.75rem;
}
.rd-trips-cart-bar-bg {
height: 0.375rem;
background: rgba(51,65,85,0.6);
border-radius: 9999px;
overflow: hidden;
}
.rd-trips-cart-bar {
height: 100%;
border-radius: 9999px;
transition: width 0.3s;
}
</style>`;
}

Some files were not shown because too many files have changed in this diff Show More