feat: add multi-tenant spaces with Fungi Flows storefront

Add subdomain-based spaces system for branded storefronts. Each space
has its own theme, product catalog, and isolated cart via scoped
localStorage keys.

- Backend: SpaceService loads YAML configs, new /api/spaces endpoints,
  design filtering by space, CORS regex for *.rswag.online
- Frontend: Next.js middleware detects subdomain and sets space_id cookie,
  dynamic CSS variable injection for theming, space-aware API calls
- Spaces: _default (rSwag hub, cyan/orange) and fungiflows (gold/green/purple)
- Docker: Traefik wildcard HostRegexp for subdomain routing
- Designs: Placeholder Fungi Flows sticker and logo tee

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-18 12:20:19 -07:00
parent 57f4cf374b
commit 7be99d37d0
26 changed files with 555 additions and 57 deletions

View File

@ -21,25 +21,42 @@ E-commerce platform for rSpace ecosystem merchandise (stickers, shirts, prints)
| `backend/app/api/` | FastAPI route handlers |
| `backend/app/models/` | SQLAlchemy ORM models |
| `backend/app/schemas/` | Pydantic request/response schemas |
| `backend/app/services/` | Business logic (stripe, pod, orders) |
| `backend/app/services/` | Business logic (stripe, pod, orders, spaces) |
| `backend/app/pod/` | POD provider clients |
| `frontend/app/` | Next.js App Router pages |
| `frontend/components/` | React components |
| `frontend/lib/` | Utilities (spaces, cn) |
| `designs/` | Design assets (stickers, shirts, misc) |
| `spaces/` | Space configs (multi-tenant branding/theming) |
## Spaces (Multi-Tenant)
rSwag supports subdomain-based spaces. Each space has its own branding, theme, and product catalog.
- **Config**: `spaces/{space_id}/space.yaml` defines name, theme colors, design filter, tips
- **Subdomain routing**: `{space}.rswag.online` detected by Next.js middleware, sets `space_id` cookie
- **API filtering**: `GET /api/products?space=fungiflows` returns only that space's designs
- **Theme injection**: CSS variables overridden at runtime from space config
- **Cart isolation**: localStorage keys scoped by space (`cart_id_fungiflows`)
- **Current spaces**: `_default` (rSwag hub), `fungiflows` (Fungi Flows merch)
## Design Source
Designs are stored in-repo at `./designs/` and mounted into the backend container.
Each design has a `metadata.yaml` with name, description, products, variants, and pricing.
Each design has a `metadata.yaml` with name, description, products, variants, pricing, and `space` field.
## API Endpoints
### Spaces
- `GET /api/spaces` - List all spaces
- `GET /api/spaces/{id}` - Get space config (branding, theme, tips)
### Public
- `GET /api/designs` - List active designs
- `GET /api/designs` - List active designs (optional: `?space=X`)
- `GET /api/designs/{slug}` - Get design details
- `GET /api/designs/{slug}/image` - Serve design image
- `GET /api/products` - List products with variants
- `GET /api/products` - List products with variants (optional: `?space=X`)
- `POST /api/cart` - Create cart
- `GET/POST/DELETE /api/cart/{id}/items` - Cart operations
- `POST /api/checkout/session` - Create Stripe checkout
@ -62,7 +79,14 @@ Push to Gitea triggers webhook auto-deploy on Netcup at `/opt/apps/rswag/`.
## Branding
Default (rSwag):
- **Primary color**: Cyan (HSL 195 80% 45%)
- **Secondary color**: Orange (HSL 45 80% 55%)
- **Font**: Geist Sans + Geist Mono
- **Theme**: rSpace spatial web aesthetic
Fungi Flows space (`fungiflows.rswag.online`):
- **Primary**: Gold (#ffd700)
- **Secondary**: Bioluminescent green (#39ff14)
- **Background**: Deep purple (#08070d)
- **Theme**: Psychedelic mushroom hip-hop aesthetic

View File

@ -16,9 +16,10 @@ design_service = DesignService()
async def list_designs(
status: str = "active",
category: str | None = None,
space: str | None = None,
):
"""List all designs."""
designs = await design_service.list_designs(status=status, category=category)
designs = await design_service.list_designs(status=status, category=category, space=space)
return designs

View File

@ -13,11 +13,13 @@ design_service = DesignService()
async def list_products(
category: str | None = None,
product_type: str | None = None,
space: str | None = None,
):
"""List all products (designs with variants flattened for storefront)."""
products = await design_service.list_products(
category=category,
product_type=product_type,
space=space,
)
return products

23
backend/app/api/spaces.py Normal file
View File

@ -0,0 +1,23 @@
"""Spaces API endpoints."""
from fastapi import APIRouter, HTTPException
from app.services.space_service import SpaceService, Space
router = APIRouter()
space_service = SpaceService()
@router.get("", response_model=list[Space])
async def list_spaces():
"""List all available spaces."""
return space_service.list_spaces()
@router.get("/{space_id}", response_model=Space)
async def get_space(space_id: str):
"""Get a specific space by ID."""
space = space_service.get_space(space_id)
if not space:
raise HTTPException(status_code=404, detail="Space not found")
return space

View File

@ -42,6 +42,7 @@ class Settings(BaseSettings):
# Paths
designs_path: str = "/app/designs"
config_path: str = "/app/config"
spaces_path: str = "/app/spaces"
# App
app_name: str = "rSwag"
@ -55,6 +56,10 @@ class Settings(BaseSettings):
def config_dir(self) -> Path:
return Path(self.config_path)
@property
def spaces_dir(self) -> Path:
return Path(self.spaces_path)
@property
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",")]

View File

@ -6,7 +6,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import get_settings
from app.api import designs, products, cart, checkout, orders, webhooks, health, design_generator
from app.api import designs, products, cart, checkout, orders, webhooks, health, design_generator, spaces
from app.api.admin import router as admin_router
settings = get_settings()
@ -31,10 +31,11 @@ app = FastAPI(
lifespan=lifespan,
)
# CORS middleware
# CORS middleware - allow all rswag.online subdomains + configured origins
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_origin_regex=r"https?://([\w-]+\.)?rswag\.online",
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@ -49,6 +50,7 @@ app.include_router(checkout.router, prefix="/api/checkout", tags=["checkout"])
app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
app.include_router(webhooks.router, prefix="/api/webhooks", tags=["webhooks"])
app.include_router(design_generator.router, prefix="/api/design", tags=["design-generator"])
app.include_router(spaces.router, prefix="/api/spaces", tags=["spaces"])
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])

View File

@ -35,6 +35,7 @@ class Design(BaseModel):
created: str = ""
source: DesignSource
products: list[DesignProduct] = []
space: str = "default"
status: str = "draft"
image_url: str = ""

View File

@ -30,6 +30,7 @@ class DesignService:
self,
status: str = "active",
category: str | None = None,
space: str | None = None,
) -> list[Design]:
"""List all designs from the designs directory."""
designs = []
@ -51,6 +52,10 @@ class DesignService:
design = await self._load_design(design_dir, category_dir.name)
if design and design.status == status:
# Filter by space if specified
if space and space != "all":
if design.space != space and design.space != "all":
continue
designs.append(design)
return designs
@ -156,6 +161,7 @@ class DesignService:
created=str(metadata.get("created", "")),
source=source,
products=products,
space=metadata.get("space", "default"),
status=metadata.get("status", "draft"),
image_url=f"/api/designs/{slug}/image",
)
@ -164,9 +170,10 @@ class DesignService:
self,
category: str | None = None,
product_type: str | None = None,
space: str | None = None,
) -> list[Product]:
"""List all products (designs formatted for storefront)."""
designs = await self.list_designs(status="active", category=category)
designs = await self.list_designs(status="active", category=category, space=space)
products = []
for design in designs:

View File

@ -0,0 +1,104 @@
"""Space (tenant) service for multi-subdomain support."""
from pathlib import Path
import yaml
from pydantic import BaseModel
from app.config import get_settings
settings = get_settings()
class SpaceTheme(BaseModel):
"""Theme configuration for a space."""
primary: str = "195 80% 45%"
primary_foreground: str = "0 0% 100%"
secondary: str = "45 80% 55%"
secondary_foreground: str = "222.2 47.4% 11.2%"
background: str = "0 0% 100%"
foreground: str = "222.2 84% 4.9%"
card: str = "0 0% 100%"
card_foreground: str = "222.2 84% 4.9%"
popover: str = "0 0% 100%"
popover_foreground: str = "222.2 84% 4.9%"
muted: str = "210 40% 96.1%"
muted_foreground: str = "215.4 16.3% 46.9%"
accent: str = "210 40% 96.1%"
accent_foreground: str = "222.2 47.4% 11.2%"
destructive: str = "0 84.2% 60.2%"
destructive_foreground: str = "210 40% 98%"
border: str = "214.3 31.8% 91.4%"
input: str = "214.3 31.8% 91.4%"
ring: str = "195 80% 45%"
class Space(BaseModel):
"""Space configuration."""
id: str
name: str
tagline: str = ""
description: str = ""
domain: str = ""
footer_text: str = ""
theme: SpaceTheme = SpaceTheme()
design_filter: str = "all"
logo_url: str | None = None
design_tips: list[str] = []
class SpaceService:
"""Service for loading and resolving spaces."""
def __init__(self):
self.spaces_path = Path(settings.spaces_path)
self._cache: dict[str, Space] = {}
self._loaded = False
def _ensure_loaded(self):
if self._loaded:
return
self._load_all()
self._loaded = True
def _load_all(self):
if not self.spaces_path.exists():
return
for space_dir in self.spaces_path.iterdir():
if not space_dir.is_dir():
continue
config_path = space_dir / "space.yaml"
if not config_path.exists():
continue
try:
with open(config_path) as f:
data = yaml.safe_load(f)
space = Space(**data)
self._cache[space.id] = space
except Exception:
continue
def get_space(self, space_id: str) -> Space | None:
"""Get a space by its ID."""
self._ensure_loaded()
return self._cache.get(space_id)
def get_default(self) -> Space:
"""Get the default space."""
self._ensure_loaded()
return self._cache.get(
"default",
Space(id="default", name="rSwag", domain="rswag.online"),
)
def list_spaces(self) -> list[Space]:
"""List all spaces."""
self._ensure_loaded()
return list(self._cache.values())
def clear_cache(self):
"""Clear the cache to force reload."""
self._cache.clear()
self._loaded = False

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

View File

@ -0,0 +1,26 @@
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."
tags: [fungiflows, mushroom, logo, hip-hop, pittsburgh, tee]
space: fungiflows
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
status: active

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,26 @@
name: "Spore Print Sticker"
slug: fungi-spore
description: "Bioluminescent mushroom spore print design. Neon green on deep purple — the signature Fungi Flows aesthetic."
tags: [fungiflows, mushroom, spore, psychedelic, sticker]
space: fungiflows
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

View File

@ -16,6 +16,7 @@ services:
# Mount designs from in-repo designs dir
- ./designs:/app/designs:ro
- ./config:/app/config:ro
- ./spaces:/app/spaces:ro
environment:
- DEBUG=true
- POD_SANDBOX_MODE=true

View File

@ -3,9 +3,11 @@ services:
volumes:
- /opt/apps/rswag/designs:/app/designs
- /opt/apps/rswag/config:/app/config:ro
- /opt/apps/rswag/spaces:/app/spaces:ro
environment:
- DESIGNS_PATH=/app/designs
- CONFIG_PATH=/app/config
- SPACES_PATH=/app/spaces
frontend:
build:

View File

@ -47,10 +47,12 @@ services:
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
- DESIGNS_PATH=/app/designs
- CONFIG_PATH=/app/config
- SPACES_PATH=/app/spaces
- GEMINI_API_KEY=${GEMINI_API_KEY}
volumes:
- ./designs:/app/designs
- ./config:/app/config:ro
- ./spaces:/app/spaces:ro
depends_on:
db:
condition: service_healthy
@ -59,7 +61,7 @@ services:
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.rswag-api.rule=Host(`rswag.online`) && PathPrefix(`/api`)"
- "traefik.http.routers.rswag-api.rule=(Host(`rswag.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rswag.online`)) && PathPrefix(`/api`)"
- "traefik.http.routers.rswag-api.entrypoints=web"
- "traefik.http.services.rswag-api.loadbalancer.server.port=8000"
- "traefik.docker.network=traefik-public"
@ -83,7 +85,7 @@ services:
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.rswag-web.rule=Host(`rswag.online`)"
- "traefik.http.routers.rswag-web.rule=Host(`rswag.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rswag.online`)"
- "traefik.http.routers.rswag-web.entrypoints=web"
- "traefik.http.services.rswag-web.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik-public"

View File

@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import Link from "next/link";
import { getSpaceIdFromCookie, getCartKey } from "@/lib/spaces";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
@ -30,7 +31,8 @@ export default function CartPage() {
const [updating, setUpdating] = useState<string | null>(null);
const fetchCart = async () => {
const cartId = localStorage.getItem("cart_id");
const cartKey = getCartKey(getSpaceIdFromCookie());
const cartId = localStorage.getItem(cartKey);
if (cartId) {
try {
const res = await fetch(`${API_URL}/cart/${cartId}`);
@ -39,7 +41,7 @@ export default function CartPage() {
setCart(data);
} else {
// Cart expired or deleted
localStorage.removeItem("cart_id");
localStorage.removeItem(cartKey);
setCart(null);
}
} catch {

View File

@ -1,7 +1,9 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import Link from "next/link";
import { getSpaceIdFromCookie } from "@/lib/spaces";
import type { SpaceConfig } from "@/lib/spaces";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
@ -20,6 +22,15 @@ export default function DesignPage() {
const [error, setError] = useState<string | null>(null);
const [generatedDesign, setGeneratedDesign] = useState<GeneratedDesign | null>(null);
const [isActivating, setIsActivating] = useState(false);
const [spaceConfig, setSpaceConfig] = useState<SpaceConfig | null>(null);
useEffect(() => {
const spaceId = getSpaceIdFromCookie();
fetch(`${API_URL}/spaces/${spaceId}`)
.then((res) => (res.ok ? res.json() : null))
.then(setSpaceConfig)
.catch(() => {});
}, []);
const handleGenerate = async (e: React.FormEvent) => {
e.preventDefault();
@ -38,6 +49,7 @@ export default function DesignPage() {
concept,
tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
product_type: "sticker",
space: getSpaceIdFromCookie(),
}),
});
@ -112,7 +124,7 @@ export default function DesignPage() {
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-2">Design Swag</h1>
<p className="text-muted-foreground mb-8">
Create custom rSpace merchandise using AI. Describe your vision and
Create custom {spaceConfig?.name || "rSpace"} merchandise using AI. Describe your vision and
we&apos;ll generate a unique design.
</p>
@ -311,21 +323,13 @@ export default function DesignPage() {
<div className="mt-12 p-6 bg-muted/30 rounded-lg">
<h3 className="font-semibold mb-3">Design Tips</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
Be specific about text you want included - the AI will try to
render it in the design
</li>
<li>
Mention colors, mood, and style preferences in your concept
</li>
<li>
rSpace themes work great: spatial webs, interconnected nodes,
commons, collaboration, community tools
</li>
<li>
Generated designs start as drafts - preview before adding to the
store
</li>
{(spaceConfig?.design_tips || [
"Be specific about text you want included",
"Mention colors, mood, and style preferences",
"Generated designs start as drafts — preview before adding to the store",
]).map((tip, i) => (
<li key={i}> {tip}</li>
))}
</ul>
</div>
</div>

View File

@ -1,25 +1,69 @@
import type { Metadata } from "next";
import { GeistSans, GeistMono } from "geist/font";
import { GeistSans } from "geist/font";
import { cookies } from "next/headers";
import "./globals.css";
import type { SpaceConfig } from "@/lib/spaces";
import { themeToCSS } from "@/lib/spaces";
export const metadata: Metadata = {
title: "rSwag — Merch for the rSpace Ecosystem",
description: "Design and order custom merchandise for rSpace communities. Stickers, shirts, and more.",
};
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
export default function RootLayout({
async function getSpaceConfig(spaceId: string): Promise<SpaceConfig | null> {
try {
const res = await fetch(`${API_URL}/spaces/${spaceId}`, {
next: { revalidate: 300 },
});
if (res.ok) return res.json();
} catch {}
return null;
}
export async function generateMetadata(): Promise<Metadata> {
const cookieStore = await cookies();
const spaceId = cookieStore.get("space_id")?.value || "default";
const space = await getSpaceConfig(spaceId);
const name = space?.name || "rSwag";
const tagline = space?.tagline || "Merch for the rSpace Ecosystem";
return {
title: `${name}${tagline}`,
description: space?.description || "Design and order custom merchandise.",
};
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const cookieStore = await cookies();
const spaceId = cookieStore.get("space_id")?.value || "default";
const space = await getSpaceConfig(spaceId);
const name = space?.name || "rSwag";
const footerText = space?.footer_text || "rSpace. Infrastructure for the commons.";
const logoUrl = space?.logo_url;
const themeCSS = space?.theme ? themeToCSS(space.theme) : "";
return (
<html lang="en">
<head>
{themeCSS && (
<style
dangerouslySetInnerHTML={{
__html: `:root {\n ${themeCSS}\n }`,
}}
/>
)}
</head>
<body className={GeistSans.className}>
<div className="min-h-screen flex flex-col">
<header className="border-b">
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<a href="/" className="text-xl font-bold text-primary">
rSwag
<a href="/" className="flex items-center gap-2 text-xl font-bold text-primary">
{logoUrl && (
<img src={logoUrl} alt="" className="h-8 w-8 rounded" />
)}
{name}
</a>
<nav className="flex items-center gap-6">
<a href="/products" className="hover:text-primary">
@ -37,7 +81,7 @@ export default function RootLayout({
<main className="flex-1">{children}</main>
<footer className="border-t py-6">
<div className="container mx-auto px-4 text-center text-muted-foreground">
<p>&copy; 2026 rSpace. Infrastructure for the commons.</p>
<p>&copy; 2026 {footerText}</p>
</div>
</footer>
</div>

View File

@ -1,4 +1,8 @@
import Link from "next/link";
import { cookies } from "next/headers";
import type { SpaceConfig } from "@/lib/spaces";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
interface Product {
slug: string;
@ -10,12 +14,14 @@ interface Product {
base_price: number;
}
async function getProducts(): Promise<Product[]> {
async function getProducts(spaceId: string): Promise<Product[]> {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/products`,
{ next: { revalidate: 60 } } // Revalidate every minute
);
const params = new URLSearchParams();
if (spaceId && spaceId !== "default") {
params.set("space", spaceId);
}
const url = `${API_URL}/products${params.toString() ? `?${params}` : ""}`;
const res = await fetch(url, { next: { revalidate: 60 } });
if (!res.ok) return [];
return res.json();
} catch {
@ -23,18 +29,37 @@ async function getProducts(): Promise<Product[]> {
}
}
async function getSpaceConfig(spaceId: string): Promise<SpaceConfig | null> {
try {
const res = await fetch(`${API_URL}/spaces/${spaceId}`, {
next: { revalidate: 300 },
});
if (res.ok) return res.json();
} catch {}
return null;
}
export default async function HomePage() {
const products = await getProducts();
const cookieStore = await cookies();
const spaceId = cookieStore.get("space_id")?.value || "default";
const [products, space] = await Promise.all([
getProducts(spaceId),
getSpaceConfig(spaceId),
]);
const name = space?.name || "rSwag";
const description =
space?.description ||
"Merch for the rSpace ecosystem. Stickers, shirts, and more — designed by the community, printed on demand.";
return (
<div className="container mx-auto px-4 py-16">
<div className="max-w-3xl mx-auto text-center">
<h1 className="text-4xl md:text-6xl font-bold mb-6">
rSwag
{name}
</h1>
<p className="text-xl text-muted-foreground mb-8">
Merch for the rSpace ecosystem. Stickers, shirts,
and more designed by the community, printed on demand.
{description}
</p>
<div className="flex gap-4 justify-center">
<Link
@ -69,7 +94,7 @@ export default async function HomePage() {
<div className="border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
<div className="aspect-square bg-muted relative overflow-hidden">
<img
src={`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/designs/${product.slug}/image`}
src={`${API_URL}/designs/${product.slug}/image`}
alt={product.name}
className="object-cover w-full h-full group-hover:scale-105 transition-transform"
/>

View File

@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { getSpaceIdFromCookie, getCartKey } from "@/lib/spaces";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
@ -70,7 +71,7 @@ export default function ProductPage() {
const getOrCreateCart = async (): Promise<string | null> => {
// Check for existing cart in localStorage
let cartId = localStorage.getItem("cart_id");
let cartId = localStorage.getItem(getCartKey(getSpaceIdFromCookie()));
if (cartId) {
// Verify cart still exists
@ -93,7 +94,7 @@ export default function ProductPage() {
if (res.ok) {
const data = await res.json();
cartId = data.id;
localStorage.setItem("cart_id", cartId!);
localStorage.setItem(getCartKey(getSpaceIdFromCookie()), cartId!);
return cartId;
}
} catch {

View File

@ -1,4 +1,7 @@
import Link from "next/link";
import { cookies } from "next/headers";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
interface Product {
slug: string;
@ -10,12 +13,14 @@ interface Product {
base_price: number;
}
async function getProducts(): Promise<Product[]> {
async function getProducts(spaceId: string): Promise<Product[]> {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/products`,
{ next: { revalidate: 3600 } }
);
const params = new URLSearchParams();
if (spaceId && spaceId !== "default") {
params.set("space", spaceId);
}
const url = `${API_URL}/products${params.toString() ? `?${params}` : ""}`;
const res = await fetch(url, { next: { revalidate: 3600 } });
if (!res.ok) return [];
return res.json();
} catch {
@ -24,7 +29,9 @@ async function getProducts(): Promise<Product[]> {
}
export default async function ProductsPage() {
const products = await getProducts();
const cookieStore = await cookies();
const spaceId = cookieStore.get("space_id")?.value || "default";
const products = await getProducts(spaceId);
return (
<div className="container mx-auto px-4 py-8">
@ -47,7 +54,7 @@ export default async function ProductsPage() {
<div className="border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
<div className="aspect-square bg-muted relative overflow-hidden">
<img
src={`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/designs/${product.slug}/image`}
src={`${API_URL}/designs/${product.slug}/image`}
alt={product.name}
className="object-cover w-full h-full group-hover:scale-105 transition-transform"
/>

80
frontend/lib/spaces.ts Normal file
View File

@ -0,0 +1,80 @@
export interface SpaceTheme {
primary: string;
primary_foreground: string;
secondary: string;
secondary_foreground: string;
background: string;
foreground: string;
card: string;
card_foreground: string;
popover: string;
popover_foreground: string;
muted: string;
muted_foreground: string;
accent: string;
accent_foreground: string;
destructive: string;
destructive_foreground: string;
border: string;
input: string;
ring: string;
}
export interface SpaceConfig {
id: string;
name: string;
tagline: string;
description: string;
domain: string;
footer_text: string;
theme: SpaceTheme;
design_filter: string;
logo_url: string | null;
design_tips: string[];
}
const THEME_VAR_MAP: Record<keyof SpaceTheme, string> = {
primary: "--primary",
primary_foreground: "--primary-foreground",
secondary: "--secondary",
secondary_foreground: "--secondary-foreground",
background: "--background",
foreground: "--foreground",
card: "--card",
card_foreground: "--card-foreground",
popover: "--popover",
popover_foreground: "--popover-foreground",
muted: "--muted",
muted_foreground: "--muted-foreground",
accent: "--accent",
accent_foreground: "--accent-foreground",
destructive: "--destructive",
destructive_foreground: "--destructive-foreground",
border: "--border",
input: "--input",
ring: "--ring",
};
export function themeToCSS(theme: SpaceTheme): string {
return Object.entries(THEME_VAR_MAP)
.filter(([key]) => theme[key as keyof SpaceTheme])
.map(([key, cssVar]) => `${cssVar}: ${theme[key as keyof SpaceTheme]};`)
.join("\n ");
}
/**
* Get the localStorage key for the cart, scoped by space.
*/
export function getCartKey(spaceId?: string): string {
if (!spaceId || spaceId === "default") return "cart_id";
return `cart_id_${spaceId}`;
}
/**
* Read space_id from document cookie (client-side only).
*/
export function getSpaceIdFromCookie(): string {
if (typeof document === "undefined") return "default";
const match = document.cookie.match(/(?:^|;\s*)space_id=([^;]*)/);
return match ? match[1] : "default";
}

37
frontend/middleware.ts Normal file
View File

@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const host = request.headers.get("host") || "";
const hostname = host.split(":")[0].toLowerCase();
// Determine space from subdomain
let spaceId = "default";
if (hostname.endsWith(".rswag.online")) {
spaceId = hostname.replace(".rswag.online", "");
}
// Local dev: check for space query param as override
if (hostname === "localhost" || hostname === "127.0.0.1") {
const url = new URL(request.url);
const spaceParam = url.searchParams.get("_space");
if (spaceParam) {
spaceId = spaceParam;
}
}
const response = NextResponse.next();
// Set cookie so both server and client components can read the space
response.cookies.set("space_id", spaceId, {
path: "/",
sameSite: "lax",
httpOnly: false,
maxAge: 86400,
});
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

View File

@ -0,0 +1,36 @@
id: default
name: "rSwag"
tagline: "Merch for the rSpace ecosystem"
description: "Stickers, shirts, and more — designed by the community, printed on demand."
domain: "rswag.online"
footer_text: "rSpace. Infrastructure for the commons."
theme:
primary: "195 80% 45%"
primary_foreground: "0 0% 100%"
secondary: "45 80% 55%"
secondary_foreground: "222.2 47.4% 11.2%"
background: "0 0% 100%"
foreground: "222.2 84% 4.9%"
card: "0 0% 100%"
card_foreground: "222.2 84% 4.9%"
popover: "0 0% 100%"
popover_foreground: "222.2 84% 4.9%"
muted: "210 40% 96.1%"
muted_foreground: "215.4 16.3% 46.9%"
accent: "210 40% 96.1%"
accent_foreground: "222.2 47.4% 11.2%"
destructive: "0 84.2% 60.2%"
destructive_foreground: "210 40% 98%"
border: "214.3 31.8% 91.4%"
input: "214.3 31.8% 91.4%"
ring: "195 80% 45%"
design_filter: "all"
logo_url: null
design_tips:
- "Be specific about text you want included"
- "Mention colors, mood, and style preferences"
- "rSpace themes work great: spatial webs, nodes, commons"
- "Generated designs start as drafts — preview before adding to the store"

View File

@ -0,0 +1,36 @@
id: fungiflows
name: "Fungi Flows"
tagline: "If Hip Hop Ate Magic Mushrooms"
description: "Psychedelic mushroom merch for the Fungi Flows movement. Hoodies, tees, stickers, and prints — spreading spores of consciousness."
domain: "fungiflows.rswag.online"
footer_text: "Fungi Flows. Spreading spores of consciousness."
theme:
primary: "51 100% 50%"
primary_foreground: "264 30% 6%"
secondary: "110 100% 54%"
secondary_foreground: "264 30% 6%"
background: "264 30% 4%"
foreground: "40 30% 92%"
card: "264 30% 6%"
card_foreground: "40 30% 92%"
popover: "264 30% 6%"
popover_foreground: "40 30% 92%"
muted: "264 20% 12%"
muted_foreground: "40 10% 60%"
accent: "264 20% 12%"
accent_foreground: "40 30% 92%"
destructive: "0 62.8% 30.6%"
destructive_foreground: "210 40% 98%"
border: "264 15% 18%"
input: "264 15% 18%"
ring: "51 100% 50%"
design_filter: "fungiflows"
logo_url: null
design_tips:
- "Mushroom and mycological imagery works perfectly"
- "Include psychedelic, bioluminescent, and fractal elements"
- "Pittsburgh hip-hop and music culture references welcome"
- "Deep purple, gold, and neon green color palette"