diff --git a/CLAUDE.md b/CLAUDE.md index 23f4c34..80ba8b6 100644 --- a/CLAUDE.md +++ b/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 diff --git a/backend/app/api/designs.py b/backend/app/api/designs.py index 8a20cf6..49e77ee 100644 --- a/backend/app/api/designs.py +++ b/backend/app/api/designs.py @@ -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 diff --git a/backend/app/api/products.py b/backend/app/api/products.py index b8795a8..b29eb36 100644 --- a/backend/app/api/products.py +++ b/backend/app/api/products.py @@ -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 diff --git a/backend/app/api/spaces.py b/backend/app/api/spaces.py new file mode 100644 index 0000000..382fa03 --- /dev/null +++ b/backend/app/api/spaces.py @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py index 70e7ea5..efabcc9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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(",")] diff --git a/backend/app/main.py b/backend/app/main.py index 87219ed..79b2e02 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/schemas/design.py b/backend/app/schemas/design.py index 3dda835..caf129f 100644 --- a/backend/app/schemas/design.py +++ b/backend/app/schemas/design.py @@ -35,6 +35,7 @@ class Design(BaseModel): created: str = "" source: DesignSource products: list[DesignProduct] = [] + space: str = "default" status: str = "draft" image_url: str = "" diff --git a/backend/app/services/design_service.py b/backend/app/services/design_service.py index 26c9088..65e16cc 100644 --- a/backend/app/services/design_service.py +++ b/backend/app/services/design_service.py @@ -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: diff --git a/backend/app/services/space_service.py b/backend/app/services/space_service.py new file mode 100644 index 0000000..14acbaa --- /dev/null +++ b/backend/app/services/space_service.py @@ -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 diff --git a/designs/shirts/fungi-logo-tee/fungi-logo-tee.png b/designs/shirts/fungi-logo-tee/fungi-logo-tee.png new file mode 100644 index 0000000..1d917d2 Binary files /dev/null and b/designs/shirts/fungi-logo-tee/fungi-logo-tee.png differ diff --git a/designs/shirts/fungi-logo-tee/metadata.yaml b/designs/shirts/fungi-logo-tee/metadata.yaml new file mode 100644 index 0000000..47278c1 --- /dev/null +++ b/designs/shirts/fungi-logo-tee/metadata.yaml @@ -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 diff --git a/designs/stickers/fungi-spore/fungi-spore.png b/designs/stickers/fungi-spore/fungi-spore.png new file mode 100644 index 0000000..cff14af Binary files /dev/null and b/designs/stickers/fungi-spore/fungi-spore.png differ diff --git a/designs/stickers/fungi-spore/metadata.yaml b/designs/stickers/fungi-spore/metadata.yaml new file mode 100644 index 0000000..31ec0b1 --- /dev/null +++ b/designs/stickers/fungi-spore/metadata.yaml @@ -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 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6fda3dd..1c53a5e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f98a267..afbff7a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 7cf3ba7..05d135e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/frontend/app/cart/page.tsx b/frontend/app/cart/page.tsx index b751a69..7e92cd6 100644 --- a/frontend/app/cart/page.tsx +++ b/frontend/app/cart/page.tsx @@ -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(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 { diff --git a/frontend/app/design/page.tsx b/frontend/app/design/page.tsx index fac8a06..ab1cf67 100644 --- a/frontend/app/design/page.tsx +++ b/frontend/app/design/page.tsx @@ -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(null); const [generatedDesign, setGeneratedDesign] = useState(null); const [isActivating, setIsActivating] = useState(false); + const [spaceConfig, setSpaceConfig] = useState(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() {

Design Swag

- 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.

@@ -311,21 +323,13 @@ export default function DesignPage() {

Design Tips

    -
  • - • Be specific about text you want included - the AI will try to - render it in the design -
  • -
  • - • Mention colors, mood, and style preferences in your concept -
  • -
  • - • rSpace themes work great: spatial webs, interconnected nodes, - commons, collaboration, community tools -
  • -
  • - • Generated designs start as drafts - preview before adding to the - store -
  • + {(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) => ( +
  • • {tip}
  • + ))}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index b13cf54..2000083 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -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 { + 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 { + 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 ( + + {themeCSS && ( +