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:
parent
57f4cf374b
commit
7be99d37d0
32
CLAUDE.md
32
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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(",")]
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ class Design(BaseModel):
|
|||
created: str = ""
|
||||
source: DesignSource
|
||||
products: list[DesignProduct] = []
|
||||
space: str = "default"
|
||||
status: str = "draft"
|
||||
image_url: str = ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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>© 2026 rSpace. Infrastructure for the commons.</p>
|
||||
<p>© 2026 {footerText}</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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).*)"],
|
||||
};
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
Loading…
Reference in New Issue