feat: standardize AppSwitcher and EcosystemFooter across all rApps
- Update AppSwitcher with all 26 r*Apps in 8 categories - Add EcosystemFooter component with consistent ecosystem links - Categories: Creating, Planning, Communicating, Deciding, Funding & Commerce, Social & Media, Work & Productivity, Identity & Infrastructure Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ee6d57a245
commit
068d9a9368
Binary file not shown.
|
Before Width: | Height: | Size: 128 KiB |
|
|
@ -1,31 +0,0 @@
|
|||
name: "Fungi Flows Logo Tee"
|
||||
slug: fungi-logo-tee
|
||||
description: "The original Fungi Flows logo on a premium tee. Gold and bioluminescent green on deep purple — rep the mycelium movement. Revenue supports the Fungi Flows community."
|
||||
tags: [fungiflows, mushroom, logo, hip-hop, pittsburgh, tee, community]
|
||||
space: all
|
||||
category: shirts
|
||||
created: "2026-02-18"
|
||||
author: fungi-flows
|
||||
|
||||
source:
|
||||
file: fungi-logo-tee.png
|
||||
format: png
|
||||
dimensions:
|
||||
width: 3600
|
||||
height: 4800
|
||||
dpi: 300
|
||||
color_profile: sRGB
|
||||
|
||||
products:
|
||||
- type: shirt
|
||||
provider: printful
|
||||
sku: "71"
|
||||
variants: [S, M, L, XL, 2XL]
|
||||
retail_price: 29.99
|
||||
- type: hoodie
|
||||
provider: printful
|
||||
sku: "146"
|
||||
variants: [S, M, L, XL, 2XL]
|
||||
retail_price: 49.99
|
||||
|
||||
status: active
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
|
|
@ -1,26 +0,0 @@
|
|||
name: "Spore Print Sticker"
|
||||
slug: fungi-spore
|
||||
description: "Bioluminescent mushroom spore print design. Neon green on deep purple — the signature Fungi Flows aesthetic. Revenue supports the Fungi Flows community."
|
||||
tags: [fungiflows, mushroom, spore, psychedelic, sticker, community]
|
||||
space: all
|
||||
category: stickers
|
||||
created: "2026-02-18"
|
||||
author: fungi-flows
|
||||
|
||||
source:
|
||||
file: fungi-spore.png
|
||||
format: png
|
||||
dimensions:
|
||||
width: 1200
|
||||
height: 1200
|
||||
dpi: 300
|
||||
color_profile: sRGB
|
||||
|
||||
products:
|
||||
- type: sticker
|
||||
provider: prodigi
|
||||
sku: GLOBAL-STI-KIS-4X4
|
||||
variants: [matte, gloss]
|
||||
retail_price: 4.99
|
||||
|
||||
status: active
|
||||
|
|
@ -5,6 +5,7 @@ import "./globals.css";
|
|||
import type { SpaceConfig } from "@/lib/spaces";
|
||||
import { themeToCSS } from "@/lib/spaces";
|
||||
import { HeaderBar } from "@/components/HeaderBar";
|
||||
import { EcosystemFooter } from "@/components/EcosystemFooter";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
|
||||
|
||||
|
|
@ -31,26 +32,6 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||
};
|
||||
}
|
||||
|
||||
const ECOSYSTEM_LINKS = [
|
||||
{ name: "rSpace", href: "https://rspace.online" },
|
||||
{ name: "rSwag", href: "https://rswag.online", active: true },
|
||||
{ name: "rWork", href: "https://rwork.online" },
|
||||
{ name: "rMaps", href: "https://rmaps.online" },
|
||||
{ name: "rNotes", href: "https://rnotes.online" },
|
||||
{ name: "rVote", href: "https://rvote.online" },
|
||||
{ name: "rFunds", href: "https://rfunds.online" },
|
||||
{ name: "rTrips", href: "https://rtrips.online" },
|
||||
{ name: "rCart", href: "https://rcart.online" },
|
||||
{ name: "rWallet", href: "https://rwallet.online" },
|
||||
{ name: "rFiles", href: "https://rfiles.online" },
|
||||
{ name: "rTube", href: "https://rtube.online" },
|
||||
{ name: "rCal", href: "https://rcal.online" },
|
||||
{ name: "rNetwork", href: "https://rnetwork.online" },
|
||||
{ name: "rInbox", href: "https://rinbox.online" },
|
||||
{ name: "rStack", href: "https://rstack.online" },
|
||||
{ name: "rAuctions", href: "https://rauctions.online" },
|
||||
{ name: "rPubs", href: "https://rpubs.online" },
|
||||
];
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
|
|
@ -84,31 +65,7 @@ export default async function RootLayout({
|
|||
<main className="flex-1">{children}</main>
|
||||
|
||||
{/* ── Ecosystem Footer ────────────────────────────── */}
|
||||
<footer className="border-t py-8 mt-8">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 text-sm text-muted-foreground mb-4">
|
||||
<span className="font-medium text-foreground/70">
|
||||
r* Ecosystem
|
||||
</span>
|
||||
{ECOSYSTEM_LINKS.map((link) => (
|
||||
<a
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
className={`hover:text-foreground transition-colors ${
|
||||
link.active
|
||||
? "font-medium text-primary"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground/60">
|
||||
Part of the r* ecosystem — collaborative tools for communities.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
<EcosystemFooter current="rSwag" />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import Link from "next/link";
|
||||
import { cookies } from "next/headers";
|
||||
import type { SpaceConfig } from "@/lib/spaces";
|
||||
import { RevenueFlowSankey } from "@/components/RevenueFlowSankey";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
|
||||
|
||||
|
|
@ -248,107 +249,43 @@ export default async function HomePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Community Revenue Model ───────────────────────────── */}
|
||||
{/* ── Community Revenue Model — Interactive Sankey ─────── */}
|
||||
<section className="py-16 bg-muted/30">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold mb-4">
|
||||
<div className="text-center mb-10">
|
||||
<span className="inline-flex items-center rounded-full text-sm px-4 py-1.5 bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-medium">
|
||||
Revenue Model
|
||||
</span>
|
||||
<h2 className="mt-3 text-2xl sm:text-3xl font-bold">
|
||||
Merch That Funds Your Mission
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
<p className="mt-2 text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
Every purchase feeds revenue directly into your community's
|
||||
funding streams. No middlemen, no platform fees eating your margins.
|
||||
funding streams. Drag the sliders to explore how the money flows.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
||||
{/* Revenue Flow */}
|
||||
<div className="border border-primary/20 rounded-xl p-6 bg-card">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 flex items-center justify-center shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-bold text-lg">Direct Revenue Stream</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
Set your own margins. Printful handles production at cost, and
|
||||
the markup goes straight into your community's treasury, DAO,
|
||||
or project fund.
|
||||
</p>
|
||||
<div className="bg-muted/50 rounded-lg p-4 text-sm space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Production cost</span>
|
||||
<span>$9.25</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Your price</span>
|
||||
<span>$29.99</span>
|
||||
</div>
|
||||
<div className="border-t border-border pt-2 flex justify-between font-bold text-primary">
|
||||
<span>Community revenue</span>
|
||||
<span>$20.74 per sale</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-primary/20 rounded-xl p-6 sm:p-8 bg-card">
|
||||
<RevenueFlowSankey />
|
||||
</div>
|
||||
|
||||
{/* Community Benefits */}
|
||||
<div className="border border-primary/20 rounded-xl p-6 bg-card">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-bold text-lg">Built for Communities</h3>
|
||||
</div>
|
||||
<ul className="text-sm text-muted-foreground space-y-3">
|
||||
<li className="flex items-start gap-2">
|
||||
<svg className="h-5 w-5 text-primary shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span><strong className="text-foreground">Branded Spaces</strong> — each community gets their own storefront with custom theme, logo, and catalog</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg className="h-5 w-5 text-primary shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span><strong className="text-foreground">Zero inventory risk</strong> — items printed on demand, no upfront costs for your community</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg className="h-5 w-5 text-primary shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span><strong className="text-foreground">Revenue routing</strong> — connect to rFunds, DAOs, or any wallet to stream merch revenue directly into community funding</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg className="h-5 w-5 text-primary shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span><strong className="text-foreground">Local production</strong> — Printful fulfills from the nearest center, reducing shipping distance and carbon footprint</span>
|
||||
</li>
|
||||
</ul>
|
||||
{/* Key benefits row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
|
||||
<div className="text-center p-4 rounded-lg border border-primary/10 bg-card/50">
|
||||
<div className="text-2xl font-bold text-primary">$0</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">Platform Fees</div>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg border border-primary/10 bg-card/50">
|
||||
<div className="text-2xl font-bold text-emerald-400">100%</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">Margin to Community</div>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg border border-primary/10 bg-card/50">
|
||||
<div className="text-2xl font-bold text-purple-400">You</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">Set the Splits</div>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg border border-primary/10 bg-card/50">
|
||||
<div className="text-2xl font-bold text-blue-400">0</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">Inventory Risk</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface AppModule {
|
||||
export interface AppModule {
|
||||
id: string;
|
||||
name: string;
|
||||
badge: string;
|
||||
color: string;
|
||||
emoji: string;
|
||||
badge: string; // favicon-style abbreviation: rS, rN, rP, etc.
|
||||
color: string; // Tailwind bg class for the pastel badge
|
||||
emoji: string; // function emoji shown right of title
|
||||
description: string;
|
||||
domain?: string;
|
||||
}
|
||||
|
|
@ -16,27 +16,37 @@ const MODULES: AppModule[] = [
|
|||
// Creating
|
||||
{ id: 'space', name: 'rSpace', badge: 'rS', color: 'bg-teal-300', emoji: '🎨', description: 'Real-time collaborative canvas', domain: 'rspace.online' },
|
||||
{ id: 'notes', name: 'rNotes', badge: 'rN', color: 'bg-amber-300', emoji: '📝', description: 'Group note-taking & knowledge capture', domain: 'rnotes.online' },
|
||||
{ id: 'pubs', name: 'rPubs', badge: 'rP', color: 'bg-rose-300', emoji: '📰', description: 'Collaborative publishing platform', domain: 'rpubs.online' },
|
||||
{ id: 'pubs', name: 'rPubs', badge: 'rP', color: 'bg-rose-300', emoji: '📰', description: 'Collaborative publishing platform', domain: 'rpubs.online' },
|
||||
// Planning
|
||||
{ id: 'cal', name: 'rCal', badge: 'rC', color: 'bg-sky-300', emoji: '📅', description: 'Collaborative scheduling & events', domain: 'rcal.online' },
|
||||
{ id: 'trips', name: 'rTrips', badge: 'rT', color: 'bg-emerald-300', emoji: '✈️', description: 'Group travel planning in real time', domain: 'rtrips.online' },
|
||||
{ id: 'maps', name: 'rMaps', badge: 'rM', color: 'bg-green-300', emoji: '🗺️', description: 'Collaborative real-time mapping', domain: 'rmaps.online' },
|
||||
// Discussing & Deciding
|
||||
{ id: 'inbox', name: 'rInbox', badge: 'rI', color: 'bg-indigo-300', emoji: '📬', description: 'Private group messaging', domain: 'rinbox.online' },
|
||||
{ id: 'choices', name: 'rChoices', badge: 'rCh', color: 'bg-fuchsia-300', emoji: '🔀', description: 'Collaborative decision making', domain: 'rchoices.online' },
|
||||
// Communicating
|
||||
{ id: 'chats', name: 'rChats', badge: 'rCh', color: 'bg-emerald-200', emoji: '💬', description: 'Real-time encrypted messaging', domain: 'rchats.online' },
|
||||
{ id: 'inbox', name: 'rInbox', badge: 'rI', color: 'bg-indigo-300', emoji: '📬', description: 'Private group messaging', domain: 'rinbox.online' },
|
||||
{ id: 'mail', name: 'rMail', badge: 'rMa', color: 'bg-blue-200', emoji: '✉️', description: 'Community email & newsletters', domain: 'rmail.online' },
|
||||
{ id: 'forum', name: 'rForum', badge: 'rFo', color: 'bg-amber-200', emoji: '💭', description: 'Threaded community discussions', domain: 'rforum.online' },
|
||||
// Deciding
|
||||
{ id: 'choices', name: 'rChoices', badge: 'rCo', color: 'bg-fuchsia-300', emoji: '🔀', description: 'Collaborative decision making', domain: 'rchoices.online' },
|
||||
{ id: 'vote', name: 'rVote', badge: 'rV', color: 'bg-violet-300', emoji: '🗳️', description: 'Real-time polls & governance', domain: 'rvote.online' },
|
||||
// Funding & Commerce
|
||||
{ id: 'funds', name: 'rFunds', badge: 'rF', color: 'bg-lime-300', emoji: '💸', description: 'Collaborative fundraising & grants', domain: 'rfunds.online' },
|
||||
{ id: 'wallet', name: 'rWallet', badge: 'rW', color: 'bg-yellow-300', emoji: '💰', description: 'Multi-chain crypto wallet', domain: 'rwallet.online' },
|
||||
{ id: 'wallet', name: 'rWallet', badge: 'rW', color: 'bg-yellow-300', emoji: '💰', description: 'Multi-chain crypto wallet', domain: 'rwallet.online' },
|
||||
{ id: 'cart', name: 'rCart', badge: 'rCt', color: 'bg-orange-300', emoji: '🛒', description: 'Group commerce & shared shopping', domain: 'rcart.online' },
|
||||
{ id: 'swag', name: 'rSwag', badge: 'rSw', color: 'bg-cyan-300', emoji: '👕', description: 'Community merch & print-on-demand', domain: 'rswag.online' },
|
||||
{ id: 'auctions', name: 'rAuctions', badge: 'rA', color: 'bg-red-300', emoji: '🔨', description: 'Live auction platform', domain: 'rauctions.online' },
|
||||
// Social & Sharing
|
||||
{ id: 'network', name: 'rNetwork', badge: 'rNe', color: 'bg-blue-300', emoji: '🌐', description: 'Community network & social graph', domain: 'rnetwork.online' },
|
||||
{ id: 'files', name: 'rFiles', badge: 'rFi', color: 'bg-cyan-300', emoji: '📁', description: 'Collaborative file storage', domain: 'rfiles.online' },
|
||||
{ id: 'auctions', name: 'rAuctions', badge: 'rA', color: 'bg-red-300', emoji: '🔨', description: 'Live auction platform', domain: 'rauctions.online' },
|
||||
{ id: 'swag', name: 'rSwag', badge: 'rSw', color: 'bg-red-200', emoji: '👕', description: 'Community merch & swag store', domain: 'rswag.online' },
|
||||
// Social & Media
|
||||
{ id: 'photos', name: 'rPhotos', badge: 'rPh', color: 'bg-pink-200', emoji: '📸', description: 'Shared community photo albums', domain: 'rphotos.online' },
|
||||
{ id: 'tube', name: 'rTube', badge: 'rTu', color: 'bg-pink-300', emoji: '🎬', description: 'Group video platform', domain: 'rtube.online' },
|
||||
{ id: 'network', name: 'rNetwork', badge: 'rNe', color: 'bg-blue-300', emoji: '🌐', description: 'Community network & social graph', domain: 'rnetwork.online' },
|
||||
{ id: 'socials', name: 'rSocials', badge: 'rSo', color: 'bg-sky-200', emoji: '📢', description: 'Social media management', domain: 'rsocials.online' },
|
||||
{ id: 'files', name: 'rFiles', badge: 'rFi', color: 'bg-cyan-300', emoji: '📁', description: 'Collaborative file storage', domain: 'rfiles.online' },
|
||||
{ id: 'data', name: 'rData', badge: 'rD', color: 'bg-purple-300', emoji: '📊', description: 'Analytics & insights dashboard', domain: 'rdata.online' },
|
||||
{ id: 'work', name: 'rWork', badge: 'rWk', color: 'bg-slate-300', emoji: '📋', description: 'Collective task management', domain: 'rwork.online' },
|
||||
// Work & Productivity
|
||||
{ id: 'work', name: 'rWork', badge: 'rWo', color: 'bg-slate-300', emoji: '💼', description: 'Project & task management', domain: 'rwork.online' },
|
||||
// Identity & Infrastructure
|
||||
{ id: 'ids', name: 'rIDs', badge: 'rId', color: 'bg-emerald-300', emoji: '🔑', description: 'Passkey identity & zero-knowledge auth', domain: 'ridentity.online' },
|
||||
{ id: 'stack', name: 'rStack', badge: 'r*', color: 'bg-gradient-to-br from-cyan-300 via-violet-300 to-rose-300', emoji: '📦', description: 'Open-source community infrastructure', domain: 'rstack.online' },
|
||||
];
|
||||
|
||||
const MODULE_CATEGORIES: Record<string, string> = {
|
||||
|
|
@ -46,35 +56,56 @@ const MODULE_CATEGORIES: Record<string, string> = {
|
|||
cal: 'Planning',
|
||||
trips: 'Planning',
|
||||
maps: 'Planning',
|
||||
inbox: 'Discussing & Deciding',
|
||||
choices: 'Discussing & Deciding',
|
||||
vote: 'Discussing & Deciding',
|
||||
chats: 'Communicating',
|
||||
inbox: 'Communicating',
|
||||
mail: 'Communicating',
|
||||
forum: 'Communicating',
|
||||
choices: 'Deciding',
|
||||
vote: 'Deciding',
|
||||
funds: 'Funding & Commerce',
|
||||
wallet: 'Funding & Commerce',
|
||||
cart: 'Funding & Commerce',
|
||||
swag: 'Funding & Commerce',
|
||||
auctions: 'Funding & Commerce',
|
||||
network: 'Social & Sharing',
|
||||
files: 'Social & Sharing',
|
||||
tube: 'Social & Sharing',
|
||||
data: 'Social & Sharing',
|
||||
work: 'Social & Sharing',
|
||||
swag: 'Funding & Commerce',
|
||||
photos: 'Social & Media',
|
||||
tube: 'Social & Media',
|
||||
network: 'Social & Media',
|
||||
socials: 'Social & Media',
|
||||
files: 'Social & Media',
|
||||
data: 'Social & Media',
|
||||
work: 'Work & Productivity',
|
||||
ids: 'Identity & Infrastructure',
|
||||
stack: 'Identity & Infrastructure',
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
'Creating',
|
||||
'Planning',
|
||||
'Discussing & Deciding',
|
||||
'Communicating',
|
||||
'Deciding',
|
||||
'Funding & Commerce',
|
||||
'Social & Sharing',
|
||||
'Social & Media',
|
||||
'Work & Productivity',
|
||||
'Identity & Infrastructure',
|
||||
];
|
||||
|
||||
/** Build the URL for a module, using username subdomain if logged in */
|
||||
function getModuleUrl(m: AppModule, username: string | null): string {
|
||||
if (!m.domain) return '#';
|
||||
if (username) {
|
||||
// Generate <username>.<domain> URL
|
||||
return `https://${username}.${m.domain}`;
|
||||
}
|
||||
return `https://${m.domain}`;
|
||||
}
|
||||
|
||||
interface AppSwitcherProps {
|
||||
current?: string;
|
||||
}
|
||||
|
||||
export function AppSwitcher({ current = 'swag' }: AppSwitcherProps) {
|
||||
export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -87,8 +118,21 @@ export function AppSwitcher({ current = 'swag' }: AppSwitcherProps) {
|
|||
return () => document.removeEventListener('click', handleClick);
|
||||
}, []);
|
||||
|
||||
// Fetch current user's username for subdomain links
|
||||
useEffect(() => {
|
||||
fetch('/api/me')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.authenticated && data.user?.username) {
|
||||
setUsername(data.user.username);
|
||||
}
|
||||
})
|
||||
.catch(() => { /* not logged in */ });
|
||||
}, []);
|
||||
|
||||
const currentMod = MODULES.find((m) => m.id === current);
|
||||
|
||||
// Group modules by category
|
||||
const groups = new Map<string, AppModule[]>();
|
||||
for (const m of MODULES) {
|
||||
const cat = MODULE_CATEGORIES[m.id] || 'Other';
|
||||
|
|
@ -98,6 +142,7 @@ export function AppSwitcher({ current = 'swag' }: AppSwitcherProps) {
|
|||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm font-semibold bg-white/[0.08] hover:bg-white/[0.12] text-slate-200 transition-colors"
|
||||
|
|
@ -107,12 +152,14 @@ export function AppSwitcher({ current = 'swag' }: AppSwitcherProps) {
|
|||
{currentMod.badge}
|
||||
</span>
|
||||
)}
|
||||
<span className="hidden sm:inline">{currentMod?.name || 'rStack'}</span>
|
||||
<span>{currentMod?.name || 'rStack'}</span>
|
||||
<span className="text-[0.7em] opacity-60">▾</span>
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 mt-1.5 w-[300px] max-h-[70vh] overflow-y-auto rounded-xl bg-slate-800 border border-white/10 shadow-xl shadow-black/30 z-[200]">
|
||||
{/* rStack header */}
|
||||
<div className="px-3.5 py-3 border-b border-white/[0.08] flex items-center gap-2.5">
|
||||
<span className="w-7 h-7 rounded-lg bg-gradient-to-br from-cyan-300 via-violet-300 to-rose-300 flex items-center justify-center text-[11px] font-black text-slate-900 leading-none">
|
||||
r*
|
||||
|
|
@ -123,6 +170,7 @@ export function AppSwitcher({ current = 'swag' }: AppSwitcherProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
{CATEGORY_ORDER.map((cat) => {
|
||||
const items = groups.get(cat);
|
||||
if (!items || items.length === 0) return null;
|
||||
|
|
@ -139,13 +187,15 @@ export function AppSwitcher({ current = 'swag' }: AppSwitcherProps) {
|
|||
} transition-colors`}
|
||||
>
|
||||
<a
|
||||
href={m.domain ? `https://${m.domain}` : '#'}
|
||||
href={getModuleUrl(m, username)}
|
||||
className="flex items-center gap-2.5 flex-1 px-3.5 py-2 text-slate-200 no-underline min-w-0"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{/* Pastel favicon badge */}
|
||||
<span className={`w-7 h-7 rounded-md ${m.color} flex items-center justify-center text-[10px] font-black text-slate-900 leading-none flex-shrink-0`}>
|
||||
{m.badge}
|
||||
</span>
|
||||
{/* Name + description */}
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-semibold">{m.name}</span>
|
||||
|
|
@ -172,6 +222,7 @@ export function AppSwitcher({ current = 'swag' }: AppSwitcherProps) {
|
|||
);
|
||||
})}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-3.5 py-2.5 border-t border-white/[0.08] text-center">
|
||||
<a
|
||||
href="https://rstack.online"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
'use client';
|
||||
|
||||
const FOOTER_LINKS = [
|
||||
{ name: 'rSpace', href: 'https://rspace.online' },
|
||||
{ name: 'rNotes', href: 'https://rnotes.online' },
|
||||
{ name: 'rPubs', href: 'https://rpubs.online' },
|
||||
{ name: 'rCal', href: 'https://rcal.online' },
|
||||
{ name: 'rTrips', href: 'https://rtrips.online' },
|
||||
{ name: 'rMaps', href: 'https://rmaps.online' },
|
||||
{ name: 'rChats', href: 'https://rchats.online' },
|
||||
{ name: 'rInbox', href: 'https://rinbox.online' },
|
||||
{ name: 'rMail', href: 'https://rmail.online' },
|
||||
{ name: 'rForum', href: 'https://rforum.online' },
|
||||
{ name: 'rChoices', href: 'https://rchoices.online' },
|
||||
{ name: 'rVote', href: 'https://rvote.online' },
|
||||
{ name: 'rFunds', href: 'https://rfunds.online' },
|
||||
{ name: 'rWallet', href: 'https://rwallet.online' },
|
||||
{ name: 'rCart', href: 'https://rcart.online' },
|
||||
{ name: 'rAuctions', href: 'https://rauctions.online' },
|
||||
{ name: 'rSwag', href: 'https://rswag.online' },
|
||||
{ name: 'rPhotos', href: 'https://rphotos.online' },
|
||||
{ name: 'rTube', href: 'https://rtube.online' },
|
||||
{ name: 'rNetwork', href: 'https://rnetwork.online' },
|
||||
{ name: 'rSocials', href: 'https://rsocials.online' },
|
||||
{ name: 'rFiles', href: 'https://rfiles.online' },
|
||||
{ name: 'rData', href: 'https://rdata.online' },
|
||||
{ name: 'rWork', href: 'https://rwork.online' },
|
||||
{ name: 'rIDs', href: 'https://ridentity.online' },
|
||||
{ name: 'rStack', href: 'https://rstack.online' },
|
||||
];
|
||||
|
||||
interface EcosystemFooterProps {
|
||||
current?: string;
|
||||
}
|
||||
|
||||
export function EcosystemFooter({ current }: EcosystemFooterProps) {
|
||||
return (
|
||||
<footer className="border-t border-slate-800 py-8">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-sm text-slate-500 mb-4">
|
||||
<span className="font-medium text-slate-400">r* Ecosystem</span>
|
||||
{FOOTER_LINKS.map((link) => (
|
||||
<a
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
className={`hover:text-slate-300 transition-colors ${
|
||||
current && link.name.toLowerCase() === current.toLowerCase()
|
||||
? 'font-medium text-slate-300'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-center text-xs text-slate-600">
|
||||
Part of the <a href="https://rstack.online" className="hover:text-slate-400 transition-colors">r* ecosystem</a> — open source, self-hosted, community-owned
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
interface FlowSplits {
|
||||
printer: number;
|
||||
creator: number;
|
||||
community: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SALE_PRICE = 29.99;
|
||||
const DEFAULT_PRODUCTION_COST = 9.25;
|
||||
|
||||
// Min production cost as fraction of price (Printful cost floor)
|
||||
const MIN_PRINTER_FRACTION = 0.15;
|
||||
|
||||
export function RevenueFlowSankey() {
|
||||
const [salePrice] = useState(DEFAULT_SALE_PRICE);
|
||||
const [splits, setSplits] = useState<FlowSplits>(() => {
|
||||
const printer = DEFAULT_PRODUCTION_COST / DEFAULT_SALE_PRICE;
|
||||
const remaining = 1 - printer;
|
||||
return {
|
||||
printer,
|
||||
creator: remaining * 0.35,
|
||||
community: remaining * 0.65,
|
||||
};
|
||||
});
|
||||
|
||||
const handleSplitChange = useCallback(
|
||||
(key: keyof FlowSplits, newValue: number) => {
|
||||
setSplits((prev) => {
|
||||
const updated = { ...prev, [key]: newValue };
|
||||
|
||||
// Enforce minimum printer cost
|
||||
if (updated.printer < MIN_PRINTER_FRACTION) {
|
||||
updated.printer = MIN_PRINTER_FRACTION;
|
||||
}
|
||||
|
||||
// Normalize so all splits sum to 1
|
||||
const total = updated.printer + updated.creator + updated.community;
|
||||
if (total === 0) return prev;
|
||||
|
||||
return {
|
||||
printer: updated.printer / total,
|
||||
creator: updated.creator / total,
|
||||
community: updated.community / total,
|
||||
};
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const printerAmount = salePrice * splits.printer;
|
||||
const creatorAmount = salePrice * splits.creator;
|
||||
const communityAmount = salePrice * splits.community;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* SVG Sankey Diagram */}
|
||||
<div className="w-full overflow-hidden">
|
||||
<svg
|
||||
viewBox="0 0 700 320"
|
||||
className="w-full h-auto max-w-2xl mx-auto"
|
||||
role="img"
|
||||
aria-label="Revenue flow diagram showing how sale price splits between printer, creator, and community"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="flowPrinter" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.9" />
|
||||
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.7" />
|
||||
</linearGradient>
|
||||
<linearGradient id="flowCreator" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.9" />
|
||||
<stop offset="100%" stopColor="#a855f7" stopOpacity="0.7" />
|
||||
</linearGradient>
|
||||
<linearGradient id="flowCommunity" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.9" />
|
||||
<stop offset="100%" stopColor="#10b981" stopOpacity="0.7" />
|
||||
</linearGradient>
|
||||
{/* Glow filters */}
|
||||
<filter id="glowGreen">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* ── Source node: Sale ── */}
|
||||
<rect
|
||||
x="30"
|
||||
y="110"
|
||||
width="90"
|
||||
height="100"
|
||||
rx="8"
|
||||
className="fill-primary/20 stroke-primary"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
x="75"
|
||||
y="150"
|
||||
textAnchor="middle"
|
||||
className="fill-foreground text-sm font-bold"
|
||||
style={{ fontSize: "14px" }}
|
||||
>
|
||||
Sale
|
||||
</text>
|
||||
<text
|
||||
x="75"
|
||||
y="175"
|
||||
textAnchor="middle"
|
||||
className="fill-primary"
|
||||
style={{ fontSize: "18px", fontWeight: 700 }}
|
||||
>
|
||||
${salePrice.toFixed(2)}
|
||||
</text>
|
||||
|
||||
{/* ── Flow paths (Bezier curves) ── */}
|
||||
<SankeyFlow
|
||||
startX={120}
|
||||
startY={135}
|
||||
endX={480}
|
||||
endY={60}
|
||||
width={splits.printer * 80 + 4}
|
||||
gradient="url(#flowPrinter)"
|
||||
/>
|
||||
<SankeyFlow
|
||||
startX={120}
|
||||
startY={160}
|
||||
endX={480}
|
||||
endY={160}
|
||||
width={splits.creator * 80 + 4}
|
||||
gradient="url(#flowCreator)"
|
||||
/>
|
||||
<SankeyFlow
|
||||
startX={120}
|
||||
startY={185}
|
||||
endX={480}
|
||||
endY={260}
|
||||
width={splits.community * 80 + 4}
|
||||
gradient="url(#flowCommunity)"
|
||||
/>
|
||||
|
||||
{/* ── Target nodes ── */}
|
||||
{/* Printer */}
|
||||
<rect x="480" y="25" width="190" height="70" rx="8" fill="#3b82f620" stroke="#3b82f6" strokeWidth="1.5" />
|
||||
<circle cx="505" cy="50" r="10" fill="#3b82f6" />
|
||||
<text x="503" y="54" textAnchor="middle" fill="white" style={{ fontSize: "11px" }}>P</text>
|
||||
<text x="525" y="50" className="fill-foreground" style={{ fontSize: "13px", fontWeight: 600 }} dominantBaseline="middle">
|
||||
Printer
|
||||
</text>
|
||||
<text x="525" y="72" fill="#3b82f6" style={{ fontSize: "16px", fontWeight: 700 }}>
|
||||
${printerAmount.toFixed(2)}
|
||||
</text>
|
||||
<text x="605" y="72" className="fill-muted-foreground" style={{ fontSize: "11px" }}>
|
||||
({(splits.printer * 100).toFixed(0)}%)
|
||||
</text>
|
||||
|
||||
{/* Creator */}
|
||||
<rect x="480" y="125" width="190" height="70" rx="8" fill="#a855f720" stroke="#a855f7" strokeWidth="1.5" />
|
||||
<circle cx="505" cy="150" r="10" fill="#a855f7" />
|
||||
<text x="503" y="154" textAnchor="middle" fill="white" style={{ fontSize: "11px" }}>C</text>
|
||||
<text x="525" y="150" className="fill-foreground" style={{ fontSize: "13px", fontWeight: 600 }} dominantBaseline="middle">
|
||||
Creator
|
||||
</text>
|
||||
<text x="525" y="172" fill="#a855f7" style={{ fontSize: "16px", fontWeight: 700 }}>
|
||||
${creatorAmount.toFixed(2)}
|
||||
</text>
|
||||
<text x="605" y="172" className="fill-muted-foreground" style={{ fontSize: "11px" }}>
|
||||
({(splits.creator * 100).toFixed(0)}%)
|
||||
</text>
|
||||
|
||||
{/* Community */}
|
||||
<rect x="480" y="225" width="190" height="70" rx="8" fill="#10b98120" stroke="#10b981" strokeWidth="1.5" />
|
||||
<circle cx="505" cy="250" r="10" fill="#10b981" filter="url(#glowGreen)" />
|
||||
<text x="503" y="254" textAnchor="middle" fill="white" style={{ fontSize: "11px" }}>$</text>
|
||||
<text x="525" y="250" className="fill-foreground" style={{ fontSize: "13px", fontWeight: 600 }} dominantBaseline="middle">
|
||||
Community
|
||||
</text>
|
||||
<text x="525" y="272" fill="#10b981" style={{ fontSize: "16px", fontWeight: 700 }}>
|
||||
${communityAmount.toFixed(2)}
|
||||
</text>
|
||||
<text x="612" y="272" className="fill-muted-foreground" style={{ fontSize: "11px" }}>
|
||||
({(splits.community * 100).toFixed(0)}%)
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* ── Interactive Sliders ── */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 max-w-2xl mx-auto">
|
||||
<FlowSlider
|
||||
label="Printer"
|
||||
sublabel="Production"
|
||||
value={splits.printer}
|
||||
amount={printerAmount}
|
||||
color="#3b82f6"
|
||||
onChange={(v) => handleSplitChange("printer", v)}
|
||||
/>
|
||||
<FlowSlider
|
||||
label="Creator"
|
||||
sublabel="Design Margin"
|
||||
value={splits.creator}
|
||||
amount={creatorAmount}
|
||||
color="#a855f7"
|
||||
onChange={(v) => handleSplitChange("creator", v)}
|
||||
/>
|
||||
<FlowSlider
|
||||
label="Community"
|
||||
sublabel="Revenue Fund"
|
||||
value={splits.community}
|
||||
amount={communityAmount}
|
||||
color="#10b981"
|
||||
onChange={(v) => handleSplitChange("community", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground max-w-lg mx-auto">
|
||||
Drag the sliders to see how revenue flows between production, creator, and
|
||||
community. The community sets its own margin — every dollar above production
|
||||
cost funds collective work.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Bezier flow path ── */
|
||||
function SankeyFlow({
|
||||
startX,
|
||||
startY,
|
||||
endX,
|
||||
endY,
|
||||
width,
|
||||
gradient,
|
||||
}: {
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
width: number;
|
||||
gradient: string;
|
||||
}) {
|
||||
const midX = (startX + endX) / 2;
|
||||
const d = `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${endX} ${endY}`;
|
||||
|
||||
return (
|
||||
<path
|
||||
d={d}
|
||||
stroke={gradient}
|
||||
strokeWidth={Math.max(width, 2)}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
opacity="0.8"
|
||||
style={{ transition: "stroke-width 0.3s ease, d 0.3s ease" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Slider for a single flow channel ── */
|
||||
function FlowSlider({
|
||||
label,
|
||||
sublabel,
|
||||
value,
|
||||
amount,
|
||||
color,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
sublabel: string;
|
||||
value: number;
|
||||
amount: number;
|
||||
color: string;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-semibold" style={{ color }}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
{sublabel}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold tabular-nums" style={{ color }}>
|
||||
${amount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={Math.round(value * 100)}
|
||||
onChange={(e) => onChange(Number(e.target.value) / 100)}
|
||||
className="w-full h-2 rounded-full appearance-none cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${color} ${value * 100}%, hsl(var(--muted)) ${value * 100}%)`,
|
||||
accentColor: color,
|
||||
}}
|
||||
aria-label={`${label} share: ${(value * 100).toFixed(0)}%`}
|
||||
/>
|
||||
|
||||
<div className="text-center text-xs font-medium text-muted-foreground tabular-nums">
|
||||
{(value * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue