Initial commit: rSwag - merch store for the rSpace ecosystem

Forked from mycopunk-swag-store and rebranded for rSpace:
- Next.js 15 + FastAPI + PostgreSQL + Stripe
- Printful + Prodigi POD integration
- AI design generation via Gemini API
- rSpace color scheme (cyan/orange) and branding
- In-repo designs directory (stickers, shirts, misc)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-18 03:24:22 -07:00
commit 72f072f233
71 changed files with 4910 additions and 0 deletions

31
.env.example Normal file
View File

@ -0,0 +1,31 @@
# Database
DB_PASSWORD=change_me_in_production
# Stripe
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# POD Providers
PRODIGI_API_KEY=xxx
PRINTFUL_API_TOKEN=xxx
POD_SANDBOX_MODE=true
# Auth
JWT_SECRET=generate_a_strong_secret_here
# App
CORS_ORIGINS=https://rswag.online
# AI Design Generation
GEMINI_API_KEY=xxx
# SMTP Email (Mailcow)
SMTP_HOST=mx.jeffemmett.com
SMTP_PORT=587
SMTP_USER=noreply@jeffemmett.com
SMTP_PASS=changeme
# Frontend
NEXT_PUBLIC_API_URL=https://rswag.online/api
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx

70
.gitignore vendored Normal file
View File

@ -0,0 +1,70 @@
# Environment
.env
.env.local
.env.*.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
# Python lib directories (not frontend/lib)
/lib/
/lib64/
backend/lib/
backend/lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.venv/
venv/
ENV/
# Node
node_modules/
.next/
out/
.pnpm-store/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Database
*.db
*.sqlite3
# Test
.coverage
htmlcov/
.pytest_cache/
.tox/
# Build
*.pyc
.cache/
# Docker
docker-compose.override.yml

68
CLAUDE.md Normal file
View File

@ -0,0 +1,68 @@
# rSwag - AI Assistant Context
## Project Overview
E-commerce platform for rSpace ecosystem merchandise (stickers, shirts, prints) with Stripe payments and print-on-demand fulfillment via Printful and Prodigi. Part of the rSpace ecosystem (rspace.online).
## Architecture
- **Frontend**: Next.js 15 App Router, shadcn/ui, Tailwind CSS, Geist font
- **Backend**: FastAPI, SQLAlchemy, Alembic
- **Database**: PostgreSQL
- **Payments**: Stripe Checkout (redirect flow)
- **Fulfillment**: Printful (apparel), Prodigi (stickers/prints)
- **AI Design**: Gemini API for design generation
- **Deployment**: Docker on Netcup RS 8000, Traefik routing
## Key Directories
| Directory | Purpose |
|-----------|---------|
| `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/pod/` | POD provider clients |
| `frontend/app/` | Next.js App Router pages |
| `frontend/components/` | React components |
| `designs/` | Design assets (stickers, shirts, misc) |
## 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.
## API Endpoints
### Public
- `GET /api/designs` - List active designs
- `GET /api/designs/{slug}` - Get design details
- `GET /api/designs/{slug}/image` - Serve design image
- `GET /api/products` - List products with variants
- `POST /api/cart` - Create cart
- `GET/POST/DELETE /api/cart/{id}/items` - Cart operations
- `POST /api/checkout/session` - Create Stripe checkout
- `GET /api/orders/{id}` - Order status (requires email)
- `POST /api/design/generate` - AI design generation
### Webhooks
- `POST /api/webhooks/stripe` - Stripe payment events
- `POST /api/webhooks/prodigi` - Prodigi fulfillment updates
- `POST /api/webhooks/printful` - Printful fulfillment updates
### Admin (JWT required)
- `POST /api/admin/auth/login` - Admin login
- `GET /api/admin/orders` - List orders
- `GET /api/admin/analytics/*` - Sales metrics
## Deployment
Push to Gitea triggers webhook auto-deploy on Netcup at `/opt/apps/rswag/`.
## Branding
- **Primary color**: Cyan (HSL 195 80% 45%)
- **Secondary color**: Orange (HSL 45 80% 55%)
- **Font**: Geist Sans + Geist Mono
- **Theme**: rSpace spatial web aesthetic

87
README.md Normal file
View File

@ -0,0 +1,87 @@
# rSwag
Merchandise store for the **rSpace ecosystem** at **rswag.online**
## Stack
- **Frontend**: Next.js 15 + shadcn/ui + Tailwind CSS + Geist font
- **Backend**: FastAPI + SQLAlchemy + Alembic
- **Database**: PostgreSQL
- **Payments**: Stripe Checkout
- **Fulfillment**: Printful (apparel) + Prodigi (stickers/prints)
- **AI Design**: Gemini API for on-demand design generation
## Architecture
```
rswag.online
Cloudflare Tunnel → Traefik
│ │
▼ ▼
Next.js (3000) FastAPI (8000)
┌───────────────┼───────────────┐
▼ ▼ ▼
PostgreSQL Stripe POD APIs
```
## Development
### Quick Start
```bash
cp .env.example .env
# Edit .env with your API keys
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# Backend: http://localhost:8000
# Frontend: http://localhost:3000
```
### Local Development (without Docker)
```bash
# Backend
cd backend
pip install -e .
uvicorn app.main:app --reload
# Frontend
cd frontend
pnpm install
pnpm dev
```
## Project Structure
```
rswag/
├── backend/ # FastAPI Python backend
│ ├── app/
│ │ ├── api/ # Route handlers
│ │ ├── models/ # SQLAlchemy ORM
│ │ ├── schemas/ # Pydantic models
│ │ ├── services/ # Business logic
│ │ └── pod/ # POD provider clients
│ └── alembic/ # Database migrations
├── frontend/ # Next.js 15 frontend
│ ├── app/ # App Router pages
│ ├── components/ # React components
│ └── lib/ # Utilities
├── designs/ # Design assets (in-repo)
│ ├── stickers/
│ ├── shirts/
│ └── misc/
└── config/ # POD provider config
```
## Deployment
Deployed on Netcup RS 8000 via Docker Compose with Traefik reverse proxy.
```bash
ssh netcup "cd /opt/apps/rswag && git pull && docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build"
```

47
backend/Dockerfile Normal file
View File

@ -0,0 +1,47 @@
# Stage 1: Build
FROM python:3.12-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt
# Stage 2: Runtime
FROM python:3.12-slim AS runtime
WORKDIR /app
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN addgroup --system --gid 1001 appgroup && \
adduser --system --uid 1001 --ingroup appgroup appuser
# Install wheels
COPY --from=builder /app/wheels /wheels
RUN pip install --no-cache /wheels/*
# Copy application code
COPY --chown=appuser:appgroup app/ ./app/
COPY --chown=appuser:appgroup alembic/ ./alembic/
COPY --chown=appuser:appgroup alembic.ini ./
# Create directories for mounted volumes
RUN mkdir -p /app/designs /app/config && \
chown -R appuser:appgroup /app
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

42
backend/alembic.ini Normal file
View File

@ -0,0 +1,42 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql://swag:devpassword@localhost:5432/swag
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

83
backend/alembic/env.py Normal file
View File

@ -0,0 +1,83 @@
"""Alembic environment configuration."""
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Import models and base
from app.database import Base
from app.models import * # noqa: F401, F403
# this is the Alembic Config object
config = context.config
# Interpret the config file for Python logging.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Model metadata for autogenerate
target_metadata = Base.metadata
def get_url():
"""Get database URL from environment or config."""
import os
return os.environ.get("DATABASE_URL", config.get_main_option("sqlalchemy.url"))
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = get_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in async mode."""
url = get_url()
if url.startswith("postgresql://"):
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = url
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,142 @@
"""Initial database schema
Revision ID: 001_initial
Revises:
Create Date: 2026-01-29
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Customers table
op.create_table(
"customers",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("stripe_customer_id", sa.String(255), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_customers_email", "customers", ["email"], unique=True)
# Carts table
op.create_table(
"carts",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("customer_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.Column("expires_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["customer_id"], ["customers.id"]),
sa.PrimaryKeyConstraint("id"),
)
# Cart items table
op.create_table(
"cart_items",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("cart_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("product_slug", sa.String(100), nullable=False),
sa.Column("product_name", sa.String(255), nullable=False),
sa.Column("variant", sa.String(50), nullable=True),
sa.Column("quantity", sa.Integer(), nullable=False),
sa.Column("unit_price", sa.Numeric(10, 2), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["cart_id"], ["carts.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
# Orders table
op.create_table(
"orders",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("customer_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("stripe_session_id", sa.String(255), nullable=True),
sa.Column("stripe_payment_intent_id", sa.String(255), nullable=True),
sa.Column("status", sa.String(50), nullable=True),
sa.Column("shipping_name", sa.String(255), nullable=True),
sa.Column("shipping_email", sa.String(255), nullable=True),
sa.Column("shipping_address_line1", sa.String(255), nullable=True),
sa.Column("shipping_address_line2", sa.String(255), nullable=True),
sa.Column("shipping_city", sa.String(100), nullable=True),
sa.Column("shipping_state", sa.String(100), nullable=True),
sa.Column("shipping_postal_code", sa.String(20), nullable=True),
sa.Column("shipping_country", sa.String(2), nullable=True),
sa.Column("subtotal", sa.Numeric(10, 2), nullable=True),
sa.Column("shipping_cost", sa.Numeric(10, 2), nullable=True),
sa.Column("tax", sa.Numeric(10, 2), nullable=True),
sa.Column("total", sa.Numeric(10, 2), nullable=True),
sa.Column("currency", sa.String(3), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.Column("paid_at", sa.DateTime(), nullable=True),
sa.Column("shipped_at", sa.DateTime(), nullable=True),
sa.Column("delivered_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["customer_id"], ["customers.id"]),
sa.PrimaryKeyConstraint("id"),
)
# Order items table
op.create_table(
"order_items",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("order_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("product_slug", sa.String(100), nullable=False),
sa.Column("product_name", sa.String(255), nullable=False),
sa.Column("variant", sa.String(50), nullable=True),
sa.Column("quantity", sa.Integer(), nullable=False),
sa.Column("unit_price", sa.Numeric(10, 2), nullable=False),
sa.Column("pod_provider", sa.String(50), nullable=True),
sa.Column("pod_order_id", sa.String(255), nullable=True),
sa.Column("pod_status", sa.String(50), nullable=True),
sa.Column("pod_tracking_number", sa.String(100), nullable=True),
sa.Column("pod_tracking_url", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["order_id"], ["orders.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
# Admin users table
op.create_table(
"admin_users",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_admin_users_email", "admin_users", ["email"], unique=True)
# Product overrides table
op.create_table(
"product_overrides",
sa.Column("slug", sa.String(100), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=True),
sa.Column("price_override", sa.Numeric(10, 2), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("slug"),
)
def downgrade() -> None:
op.drop_table("product_overrides")
op.drop_index("ix_admin_users_email", table_name="admin_users")
op.drop_table("admin_users")
op.drop_table("order_items")
op.drop_table("orders")
op.drop_table("cart_items")
op.drop_table("carts")
op.drop_index("ix_customers_email", table_name="customers")
op.drop_table("customers")

1
backend/app/__init__.py Normal file
View File

@ -0,0 +1 @@
# Mycopunk Swag Store Backend

View File

@ -0,0 +1,3 @@
"""API routes."""
from app.api import designs, products, cart, checkout, orders, webhooks, health

View File

@ -0,0 +1,12 @@
"""Admin API routes."""
from fastapi import APIRouter
from app.api.admin import auth, orders, analytics, products
router = APIRouter()
router.include_router(auth.router, prefix="/auth", tags=["admin-auth"])
router.include_router(orders.router, prefix="/orders", tags=["admin-orders"])
router.include_router(analytics.router, prefix="/analytics", tags=["admin-analytics"])
router.include_router(products.router, prefix="/products", tags=["admin-products"])

View File

@ -0,0 +1,39 @@
"""Admin analytics endpoints."""
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.analytics_service import AnalyticsService
from app.services.auth_service import get_current_admin
router = APIRouter()
def get_analytics_service(db: AsyncSession = Depends(get_db)) -> AnalyticsService:
return AnalyticsService(db)
@router.get("/sales")
async def get_sales_analytics(
days: int = Query(default=30, le=365),
service: AnalyticsService = Depends(get_analytics_service),
_admin=Depends(get_current_admin),
):
"""Get sales analytics (admin only)."""
start_date = datetime.utcnow() - timedelta(days=days)
return await service.get_sales_summary(start_date)
@router.get("/products")
async def get_product_analytics(
days: int = Query(default=30, le=365),
limit: int = Query(default=10, le=50),
service: AnalyticsService = Depends(get_analytics_service),
_admin=Depends(get_current_admin),
):
"""Get product performance analytics (admin only)."""
start_date = datetime.utcnow() - timedelta(days=days)
return await service.get_product_performance(start_date, limit)

View File

@ -0,0 +1,36 @@
"""Admin authentication endpoints."""
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.auth_service import AuthService
router = APIRouter()
class LoginRequest(BaseModel):
email: str
password: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
def get_auth_service(db: AsyncSession = Depends(get_db)) -> AuthService:
return AuthService(db)
@router.post("/login", response_model=LoginResponse)
async def login(
request: LoginRequest,
auth_service: AuthService = Depends(get_auth_service),
):
"""Admin login."""
token = await auth_service.authenticate(request.email, request.password)
if not token:
raise HTTPException(status_code=401, detail="Invalid credentials")
return LoginResponse(access_token=token)

View File

@ -0,0 +1,57 @@
"""Admin order management endpoints."""
from uuid import UUID
from fastapi import APIRouter, HTTPException, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.order import OrderResponse, OrderStatus
from app.services.order_service import OrderService
from app.services.auth_service import get_current_admin
router = APIRouter()
def get_order_service(db: AsyncSession = Depends(get_db)) -> OrderService:
return OrderService(db)
@router.get("", response_model=list[OrderResponse])
async def list_orders(
status: OrderStatus | None = None,
limit: int = Query(default=50, le=100),
offset: int = Query(default=0, ge=0),
service: OrderService = Depends(get_order_service),
_admin=Depends(get_current_admin),
):
"""List all orders (admin only)."""
orders = await service.list_orders(status=status, limit=limit, offset=offset)
return orders
@router.get("/{order_id}", response_model=OrderResponse)
async def get_order(
order_id: UUID,
service: OrderService = Depends(get_order_service),
_admin=Depends(get_current_admin),
):
"""Get order details (admin only)."""
order = await service.get_order_by_id(order_id)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return order
@router.put("/{order_id}/status")
async def update_order_status(
order_id: UUID,
status: OrderStatus,
service: OrderService = Depends(get_order_service),
_admin=Depends(get_current_admin),
):
"""Update order status (admin only)."""
order = await service.update_status(order_id, status)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return {"status": "updated", "order_id": order_id, "new_status": status}

View File

@ -0,0 +1,57 @@
"""Admin product management endpoints."""
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.design_service import DesignService
from app.services.auth_service import get_current_admin
router = APIRouter()
class ProductOverrideRequest(BaseModel):
is_active: bool | None = None
price_override: float | None = None
def get_design_service() -> DesignService:
return DesignService()
@router.put("/{slug}/override")
async def update_product_override(
slug: str,
override: ProductOverrideRequest,
db: AsyncSession = Depends(get_db),
design_service: DesignService = Depends(get_design_service),
_admin=Depends(get_current_admin),
):
"""Update product visibility or price override (admin only)."""
# Verify product exists
product = await design_service.get_product(slug)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
# Update override in database
await design_service.set_product_override(
db=db,
slug=slug,
is_active=override.is_active,
price_override=override.price_override,
)
return {"status": "updated", "slug": slug}
@router.post("/sync")
async def sync_designs(
design_service: DesignService = Depends(get_design_service),
_admin=Depends(get_current_admin),
):
"""Force sync designs from the designs directory (admin only)."""
# Clear any caches and reload
design_service.clear_cache()
designs = await design_service.list_designs()
return {"status": "synced", "count": len(designs)}

83
backend/app/api/cart.py Normal file
View File

@ -0,0 +1,83 @@
"""Cart API endpoints."""
from uuid import UUID
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.cart import (
CartCreate,
CartResponse,
CartItemCreate,
CartItemUpdate,
CartItemResponse,
)
from app.services.cart_service import CartService
router = APIRouter()
def get_cart_service(db: AsyncSession = Depends(get_db)) -> CartService:
return CartService(db)
@router.post("", response_model=CartResponse)
async def create_cart(
service: CartService = Depends(get_cart_service),
):
"""Create a new shopping cart."""
cart = await service.create_cart()
return cart
@router.get("/{cart_id}", response_model=CartResponse)
async def get_cart(
cart_id: UUID,
service: CartService = Depends(get_cart_service),
):
"""Get cart by ID."""
cart = await service.get_cart(cart_id)
if not cart:
raise HTTPException(status_code=404, detail="Cart not found")
return cart
@router.post("/{cart_id}/items", response_model=CartResponse)
async def add_item(
cart_id: UUID,
item: CartItemCreate,
service: CartService = Depends(get_cart_service),
):
"""Add item to cart."""
cart = await service.add_item(cart_id, item)
if not cart:
raise HTTPException(status_code=404, detail="Cart not found")
return cart
@router.put("/{cart_id}/items/{item_id}", response_model=CartResponse)
async def update_item(
cart_id: UUID,
item_id: UUID,
update: CartItemUpdate,
service: CartService = Depends(get_cart_service),
):
"""Update cart item quantity."""
cart = await service.update_item(cart_id, item_id, update.quantity)
if not cart:
raise HTTPException(status_code=404, detail="Cart or item not found")
return cart
@router.delete("/{cart_id}/items/{item_id}", response_model=CartResponse)
async def remove_item(
cart_id: UUID,
item_id: UUID,
service: CartService = Depends(get_cart_service),
):
"""Remove item from cart."""
cart = await service.remove_item(cart_id, item_id)
if not cart:
raise HTTPException(status_code=404, detail="Cart or item not found")
return cart

View File

@ -0,0 +1,47 @@
"""Checkout API endpoints."""
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.order import CheckoutRequest, CheckoutResponse
from app.services.stripe_service import StripeService
from app.services.cart_service import CartService
router = APIRouter()
def get_stripe_service() -> StripeService:
return StripeService()
def get_cart_service(db: AsyncSession = Depends(get_db)) -> CartService:
return CartService(db)
@router.post("/session", response_model=CheckoutResponse)
async def create_checkout_session(
request: CheckoutRequest,
stripe_service: StripeService = Depends(get_stripe_service),
cart_service: CartService = Depends(get_cart_service),
):
"""Create a Stripe checkout session."""
# Get cart
cart = await cart_service.get_cart(request.cart_id)
if not cart:
raise HTTPException(status_code=404, detail="Cart not found")
if not cart.items:
raise HTTPException(status_code=400, detail="Cart is empty")
# Create Stripe session
result = await stripe_service.create_checkout_session(
cart=cart,
success_url=request.success_url,
cancel_url=request.cancel_url,
)
return CheckoutResponse(
checkout_url=result["url"],
session_id=result["session_id"],
)

View File

@ -0,0 +1,229 @@
"""AI design generation API."""
import os
import re
import uuid
from datetime import date
from pathlib import Path
import httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.config import get_settings
from app.api.designs import design_service
router = APIRouter()
settings = get_settings()
class DesignRequest(BaseModel):
"""Request to generate a new design."""
concept: str
name: str
tags: list[str] = []
product_type: str = "sticker"
class DesignResponse(BaseModel):
"""Response with generated design info."""
slug: str
name: str
image_url: str
status: str
def slugify(text: str) -> str:
"""Convert text to URL-friendly slug."""
text = text.lower().strip()
text = re.sub(r'[^\w\s-]', '', text)
text = re.sub(r'[\s_-]+', '-', text)
text = re.sub(r'^-+|-+$', '', text)
return text
@router.post("/generate", response_model=DesignResponse)
async def generate_design(request: DesignRequest):
"""Generate a new design using AI."""
gemini_api_key = os.environ.get("GEMINI_API_KEY", "")
if not gemini_api_key:
raise HTTPException(
status_code=503,
detail="AI generation not configured. Set GEMINI_API_KEY."
)
# Create slug from name
slug = slugify(request.name)
if not slug:
slug = f"design-{uuid.uuid4().hex[:8]}"
# Check if design already exists
design_dir = settings.designs_dir / "stickers" / slug
if design_dir.exists():
raise HTTPException(
status_code=409,
detail=f"Design '{slug}' already exists"
)
# Build the image generation prompt
style_prompt = f"""A striking sticker design for "{request.name}".
{request.concept}
The design should have a clean, modern spatial-web aesthetic with interconnected
nodes, network patterns, and a collaborative/commons feel.
Colors: vibrant cyan, warm orange accents on dark background.
High contrast, suitable for vinyl sticker printing.
Square format, clean edges for die-cut sticker."""
# Call Gemini API for image generation
try:
async with httpx.AsyncClient(timeout=120.0) as client:
# Use gemini-3-pro-image-preview for image generation
response = await client.post(
f"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent?key={gemini_api_key}",
json={
"contents": [{
"parts": [{
"text": style_prompt
}]
}],
"generationConfig": {
"responseModalities": ["image", "text"]
}
},
headers={"Content-Type": "application/json"}
)
if response.status_code != 200:
error_detail = response.text[:500] if response.text else "Unknown error"
raise HTTPException(
status_code=502,
detail=f"AI generation failed ({response.status_code}): {error_detail}"
)
result = response.json()
# Extract image data from response
image_data = None
for candidate in result.get("candidates", []):
for part in candidate.get("content", {}).get("parts", []):
if "inlineData" in part:
image_data = part["inlineData"]["data"]
break
if image_data:
break
if not image_data:
# Log what we got for debugging
import json
raise HTTPException(
status_code=502,
detail=f"AI did not return an image. Response: {json.dumps(result)[:500]}"
)
except httpx.TimeoutException:
raise HTTPException(
status_code=504,
detail="AI generation timed out"
)
except Exception as e:
raise HTTPException(
status_code=502,
detail=f"AI generation error: {str(e)}"
)
# Create design directory
design_dir.mkdir(parents=True, exist_ok=True)
# Save image
import base64
image_path = design_dir / f"{slug}.png"
image_bytes = base64.b64decode(image_data)
image_path.write_bytes(image_bytes)
# Create metadata.yaml
# Escape quotes in user-provided strings to prevent YAML parsing errors
safe_name = request.name.replace('"', '\\"')
safe_concept = request.concept.replace('"', '\\"')
tags_str = ", ".join(request.tags) if request.tags else "rspace, sticker, ai-generated"
metadata_content = f"""name: "{safe_name}"
slug: {slug}
description: "{safe_concept}"
tags: [{tags_str}]
created: {date.today().isoformat()}
author: ai-generated
source:
file: {slug}.png
format: png
dimensions:
width: 1024
height: 1024
dpi: 300
color_profile: sRGB
products:
- type: sticker
provider: prodigi
sku: GLOBAL-STI-KIS-3X3
variants: [matte, gloss]
retail_price: 3.50
status: draft
"""
metadata_path = design_dir / "metadata.yaml"
metadata_path.write_text(metadata_content)
return DesignResponse(
slug=slug,
name=request.name,
image_url=f"/api/designs/{slug}/image",
status="draft"
)
@router.post("/{slug}/activate")
async def activate_design(slug: str):
"""Activate a draft design to make it visible in the store."""
design_dir = settings.designs_dir / "stickers" / slug
metadata_path = design_dir / "metadata.yaml"
if not metadata_path.exists():
raise HTTPException(status_code=404, detail="Design not found")
# Read and update metadata
content = metadata_path.read_text()
content = content.replace("status: draft", "status: active")
metadata_path.write_text(content)
# Clear the design service cache so the new status is picked up
design_service.clear_cache()
return {"status": "activated", "slug": slug}
@router.delete("/{slug}")
async def delete_design(slug: str):
"""Delete a design (only drafts can be deleted)."""
import shutil
design_dir = settings.designs_dir / "stickers" / slug
metadata_path = design_dir / "metadata.yaml"
if not metadata_path.exists():
raise HTTPException(status_code=404, detail="Design not found")
# Check if draft
content = metadata_path.read_text()
if "status: active" in content:
raise HTTPException(
status_code=400,
detail="Cannot delete active designs. Set to draft first."
)
# Delete directory
shutil.rmtree(design_dir)
return {"status": "deleted", "slug": slug}

View File

@ -0,0 +1,47 @@
"""Designs API endpoints."""
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from app.schemas.design import Design
from app.services.design_service import DesignService
router = APIRouter()
design_service = DesignService()
@router.get("", response_model=list[Design])
async def list_designs(
status: str = "active",
category: str | None = None,
):
"""List all designs."""
designs = await design_service.list_designs(status=status, category=category)
return designs
@router.get("/{slug}", response_model=Design)
async def get_design(slug: str):
"""Get a single design by slug."""
design = await design_service.get_design(slug)
if not design:
raise HTTPException(status_code=404, detail="Design not found")
return design
@router.get("/{slug}/image")
async def get_design_image(slug: str):
"""Serve the design image."""
image_path = await design_service.get_design_image_path(slug)
if not image_path or not Path(image_path).exists():
raise HTTPException(status_code=404, detail="Image not found")
return FileResponse(
image_path,
media_type="image/png",
headers={
"Cache-Control": "public, max-age=86400", # Cache for 24 hours
},
)

11
backend/app/api/health.py Normal file
View File

@ -0,0 +1,11 @@
"""Health check endpoint."""
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy"}

53
backend/app/api/orders.py Normal file
View File

@ -0,0 +1,53 @@
"""Orders API endpoints."""
from uuid import UUID
from fastapi import APIRouter, HTTPException, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.order import OrderResponse
from app.services.order_service import OrderService
router = APIRouter()
def get_order_service(db: AsyncSession = Depends(get_db)) -> OrderService:
return OrderService(db)
@router.get("/{order_id}", response_model=OrderResponse)
async def get_order(
order_id: UUID,
email: str = Query(..., description="Email used for the order"),
service: OrderService = Depends(get_order_service),
):
"""Get order by ID (requires email verification)."""
order = await service.get_order_by_id_and_email(order_id, email)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return order
@router.get("/{order_id}/tracking")
async def get_order_tracking(
order_id: UUID,
email: str = Query(..., description="Email used for the order"),
service: OrderService = Depends(get_order_service),
):
"""Get tracking information for an order."""
order = await service.get_order_by_id_and_email(order_id, email)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
tracking = []
for item in order.items:
if item.pod_tracking_number:
tracking.append({
"product": item.product_name,
"tracking_number": item.pod_tracking_number,
"tracking_url": item.pod_tracking_url,
"status": item.pod_status,
})
return {"order_id": order_id, "tracking": tracking}

View File

@ -0,0 +1,31 @@
"""Products API endpoints."""
from fastapi import APIRouter, HTTPException
from app.schemas.product import Product
from app.services.design_service import DesignService
router = APIRouter()
design_service = DesignService()
@router.get("", response_model=list[Product])
async def list_products(
category: str | None = None,
product_type: str | None = None,
):
"""List all products (designs with variants flattened for storefront)."""
products = await design_service.list_products(
category=category,
product_type=product_type,
)
return products
@router.get("/{slug}", response_model=Product)
async def get_product(slug: str):
"""Get a single product by slug."""
product = await design_service.get_product(slug)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
return product

View File

@ -0,0 +1,97 @@
"""Webhook endpoints for Stripe and POD providers."""
from fastapi import APIRouter, Request, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.config import get_settings
from app.services.stripe_service import StripeService
from app.services.order_service import OrderService
router = APIRouter()
settings = get_settings()
def get_stripe_service() -> StripeService:
return StripeService()
def get_order_service(db: AsyncSession = Depends(get_db)) -> OrderService:
return OrderService(db)
@router.post("/stripe")
async def stripe_webhook(
request: Request,
stripe_service: StripeService = Depends(get_stripe_service),
order_service: OrderService = Depends(get_order_service),
):
"""Handle Stripe webhook events."""
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
if not sig_header:
raise HTTPException(status_code=400, detail="Missing signature")
try:
event = stripe_service.verify_webhook(payload, sig_header)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Handle events
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
await order_service.handle_successful_payment(session)
elif event["type"] == "payment_intent.payment_failed":
# Log failure, maybe send notification
pass
return {"status": "ok"}
@router.post("/prodigi")
async def prodigi_webhook(
request: Request,
order_service: OrderService = Depends(get_order_service),
):
"""Handle Prodigi webhook events."""
payload = await request.json()
event_type = payload.get("event")
order_data = payload.get("order", {})
if event_type in ["order.shipped", "order.complete"]:
await order_service.update_pod_status(
pod_provider="prodigi",
pod_order_id=order_data.get("id"),
status=event_type.replace("order.", ""),
tracking_number=order_data.get("shipments", [{}])[0].get("trackingNumber"),
tracking_url=order_data.get("shipments", [{}])[0].get("trackingUrl"),
)
return {"status": "ok"}
@router.post("/printful")
async def printful_webhook(
request: Request,
order_service: OrderService = Depends(get_order_service),
):
"""Handle Printful webhook events."""
payload = await request.json()
event_type = payload.get("type")
order_data = payload.get("data", {}).get("order", {})
if event_type in ["package_shipped", "order_fulfilled"]:
shipment = payload.get("data", {}).get("shipment", {})
await order_service.update_pod_status(
pod_provider="printful",
pod_order_id=str(order_data.get("id")),
status="shipped" if event_type == "package_shipped" else "fulfilled",
tracking_number=shipment.get("tracking_number"),
tracking_url=shipment.get("tracking_url"),
)
return {"status": "ok"}

66
backend/app/config.py Normal file
View File

@ -0,0 +1,66 @@
"""Application configuration."""
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# Database
database_url: str = "postgresql://swag:devpassword@localhost:5432/swag"
# Redis
redis_url: str = "redis://localhost:6379"
# Stripe
stripe_secret_key: str = ""
stripe_publishable_key: str = ""
stripe_webhook_secret: str = ""
# POD Providers
prodigi_api_key: str = ""
printful_api_token: str = ""
pod_sandbox_mode: bool = True
# Auth
jwt_secret: str = "dev-secret-change-in-production"
jwt_algorithm: str = "HS256"
jwt_expire_hours: int = 24
# CORS
cors_origins: str = "http://localhost:3000"
# Paths
designs_path: str = "/app/designs"
config_path: str = "/app/config"
# App
app_name: str = "rSwag"
debug: bool = False
@property
def designs_dir(self) -> Path:
return Path(self.designs_path)
@property
def config_dir(self) -> Path:
return Path(self.config_path)
@property
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",")]
@lru_cache
def get_settings() -> Settings:
"""Get cached settings instance."""
return Settings()

46
backend/app/database.py Normal file
View File

@ -0,0 +1,46 @@
"""Database configuration and session management."""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
settings = get_settings()
# Convert postgresql:// to postgresql+asyncpg:// for async
database_url = settings.database_url
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(
database_url,
echo=settings.debug,
pool_pre_ping=True,
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
"""Base class for SQLAlchemy models."""
pass
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Dependency for getting database session."""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()

62
backend/app/main.py Normal file
View File

@ -0,0 +1,62 @@
"""FastAPI application entry point."""
from contextlib import asynccontextmanager
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.admin import router as admin_router
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler."""
# Startup
print(f"Starting {settings.app_name}...")
print(f"Designs path: {settings.designs_path}")
print(f"POD sandbox mode: {settings.pod_sandbox_mode}")
yield
# Shutdown
print("Shutting down...")
app = FastAPI(
title=settings.app_name,
description="E-commerce API for rSpace ecosystem merchandise",
version="0.1.0",
lifespan=lifespan,
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(health.router, prefix="/api", tags=["health"])
app.include_router(designs.router, prefix="/api/designs", tags=["designs"])
app.include_router(products.router, prefix="/api/products", tags=["products"])
app.include_router(cart.router, prefix="/api/cart", tags=["cart"])
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(admin_router, prefix="/api/admin", tags=["admin"])
@app.get("/")
async def root():
"""Root endpoint."""
return {
"name": settings.app_name,
"version": "0.1.0",
"docs": "/docs",
}

View File

@ -0,0 +1,17 @@
"""SQLAlchemy ORM models."""
from app.models.customer import Customer
from app.models.cart import Cart, CartItem
from app.models.order import Order, OrderItem
from app.models.admin import AdminUser
from app.models.product import ProductOverride
__all__ = [
"Customer",
"Cart",
"CartItem",
"Order",
"OrderItem",
"AdminUser",
"ProductOverride",
]

View File

@ -0,0 +1,24 @@
"""Admin user model."""
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class AdminUser(Base):
"""Admin user model for authentication."""
__tablename__ = "admin_users"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@ -0,0 +1,60 @@
"""Cart models."""
import uuid
from datetime import datetime, timedelta
from sqlalchemy import String, Integer, Numeric, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def default_expiry():
return datetime.utcnow() + timedelta(days=7)
class Cart(Base):
"""Shopping cart model."""
__tablename__ = "carts"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
customer_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("customers.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
expires_at: Mapped[datetime] = mapped_column(DateTime, default=default_expiry)
# Relationships
customer: Mapped["Customer | None"] = relationship("Customer", back_populates="carts")
items: Mapped[list["CartItem"]] = relationship(
"CartItem", back_populates="cart", cascade="all, delete-orphan"
)
class CartItem(Base):
"""Cart item model."""
__tablename__ = "cart_items"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
cart_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("carts.id", ondelete="CASCADE"), nullable=False
)
product_slug: Mapped[str] = mapped_column(String(100), nullable=False)
product_name: Mapped[str] = mapped_column(String(255), nullable=False)
variant: Mapped[str | None] = mapped_column(String(50), nullable=True)
quantity: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
unit_price: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
cart: Mapped["Cart"] = relationship("Cart", back_populates="items")

View File

@ -0,0 +1,27 @@
"""Customer model."""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Customer(Base):
"""Customer model for storing customer information."""
__tablename__ = "customers"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
stripe_customer_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
carts: Mapped[list["Cart"]] = relationship("Cart", back_populates="customer")
orders: Mapped[list["Order"]] = relationship("Order", back_populates="customer")

102
backend/app/models/order.py Normal file
View File

@ -0,0 +1,102 @@
"""Order models."""
import uuid
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Integer, Numeric, DateTime, ForeignKey, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class OrderStatus(str, Enum):
"""Order status enum."""
PENDING = "pending"
PAID = "paid"
PROCESSING = "processing"
PRINTING = "printing"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
REFUNDED = "refunded"
class Order(Base):
"""Order model."""
__tablename__ = "orders"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
customer_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("customers.id"), nullable=True
)
stripe_session_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
stripe_payment_intent_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[str] = mapped_column(String(50), default=OrderStatus.PENDING.value)
# Shipping info
shipping_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
shipping_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
shipping_address_line1: Mapped[str | None] = mapped_column(String(255), nullable=True)
shipping_address_line2: Mapped[str | None] = mapped_column(String(255), nullable=True)
shipping_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
shipping_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
shipping_postal_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
shipping_country: Mapped[str | None] = mapped_column(String(2), nullable=True)
# Financials
subtotal: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
shipping_cost: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
tax: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
total: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
currency: Mapped[str] = mapped_column(String(3), default="USD")
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
paid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
shipped_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
delivered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Relationships
customer: Mapped["Customer | None"] = relationship("Customer", back_populates="orders")
items: Mapped[list["OrderItem"]] = relationship(
"OrderItem", back_populates="order", cascade="all, delete-orphan"
)
class OrderItem(Base):
"""Order item model."""
__tablename__ = "order_items"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
order_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("orders.id", ondelete="CASCADE"), nullable=False
)
product_slug: Mapped[str] = mapped_column(String(100), nullable=False)
product_name: Mapped[str] = mapped_column(String(255), nullable=False)
variant: Mapped[str | None] = mapped_column(String(50), nullable=True)
quantity: Mapped[int] = mapped_column(Integer, nullable=False)
unit_price: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
# POD fulfillment
pod_provider: Mapped[str | None] = mapped_column(String(50), nullable=True)
pod_order_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
pod_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
pod_tracking_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
pod_tracking_url: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
order: Mapped["Order"] = relationship("Order", back_populates="items")

View File

@ -0,0 +1,21 @@
"""Product override model."""
from datetime import datetime
from sqlalchemy import String, Boolean, Numeric, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class ProductOverride(Base):
"""Product override model for visibility and price overrides."""
__tablename__ = "product_overrides"
slug: Mapped[str] = mapped_column(String(100), primary_key=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
price_override: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)

View File

@ -0,0 +1,28 @@
"""Pydantic schemas for API request/response models."""
from app.schemas.design import Design, DesignProduct, DesignSource
from app.schemas.product import Product, ProductVariant
from app.schemas.cart import (
CartCreate,
CartResponse,
CartItemCreate,
CartItemUpdate,
CartItemResponse,
)
from app.schemas.order import OrderResponse, OrderItemResponse, OrderStatus
__all__ = [
"Design",
"DesignProduct",
"DesignSource",
"Product",
"ProductVariant",
"CartCreate",
"CartResponse",
"CartItemCreate",
"CartItemUpdate",
"CartItemResponse",
"OrderResponse",
"OrderItemResponse",
"OrderStatus",
]

View File

@ -0,0 +1,57 @@
"""Cart schemas."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
class CartItemCreate(BaseModel):
"""Request to add item to cart."""
product_slug: str
product_name: str
variant: str | None = None
quantity: int = 1
unit_price: float
class CartItemUpdate(BaseModel):
"""Request to update cart item."""
quantity: int
class CartItemResponse(BaseModel):
"""Cart item in response."""
id: UUID
product_slug: str
product_name: str
variant: str | None
quantity: int
unit_price: float
subtotal: float
class Config:
from_attributes = True
class CartCreate(BaseModel):
"""Request to create a new cart."""
pass
class CartResponse(BaseModel):
"""Cart response."""
id: UUID
items: list[CartItemResponse]
item_count: int
subtotal: float
created_at: datetime
expires_at: datetime
class Config:
from_attributes = True

View File

@ -0,0 +1,42 @@
"""Design schemas."""
from pydantic import BaseModel
class DesignSource(BaseModel):
"""Design source file information."""
file: str
format: str
dimensions: dict[str, int]
dpi: int
color_profile: str = "sRGB"
class DesignProduct(BaseModel):
"""Product configuration for a design."""
type: str
provider: str
sku: str
variants: list[str] = []
retail_price: float
class Design(BaseModel):
"""Design information from metadata.yaml."""
slug: str
name: str
description: str
tags: list[str] = []
category: str
author: str = ""
created: str = ""
source: DesignSource
products: list[DesignProduct] = []
status: str = "draft"
image_url: str = ""
class Config:
from_attributes = True

View File

@ -0,0 +1,76 @@
"""Order schemas."""
from datetime import datetime
from enum import Enum
from uuid import UUID
from pydantic import BaseModel
class OrderStatus(str, Enum):
"""Order status enum."""
PENDING = "pending"
PAID = "paid"
PROCESSING = "processing"
PRINTING = "printing"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
REFUNDED = "refunded"
class OrderItemResponse(BaseModel):
"""Order item in response."""
id: UUID
product_slug: str
product_name: str
variant: str | None
quantity: int
unit_price: float
pod_provider: str | None
pod_status: str | None
pod_tracking_number: str | None
pod_tracking_url: str | None
class Config:
from_attributes = True
class OrderResponse(BaseModel):
"""Order response."""
id: UUID
status: str
shipping_name: str | None
shipping_email: str | None
shipping_city: str | None
shipping_country: str | None
subtotal: float | None
shipping_cost: float | None
tax: float | None
total: float | None
currency: str
items: list[OrderItemResponse]
created_at: datetime
paid_at: datetime | None
shipped_at: datetime | None
class Config:
from_attributes = True
class CheckoutRequest(BaseModel):
"""Request to create checkout session."""
cart_id: UUID
success_url: str
cancel_url: str
class CheckoutResponse(BaseModel):
"""Checkout session response."""
checkout_url: str
session_id: str

View File

@ -0,0 +1,30 @@
"""Product schemas."""
from pydantic import BaseModel
class ProductVariant(BaseModel):
"""Product variant information."""
name: str
sku: str
provider: str
price: float
class Product(BaseModel):
"""Product for display in storefront."""
slug: str
name: str
description: str
category: str
product_type: str # sticker, shirt, print
tags: list[str] = []
image_url: str
base_price: float
variants: list[ProductVariant] = []
is_active: bool = True
class Config:
from_attributes = True

View File

@ -0,0 +1 @@
"""Business logic services."""

View File

@ -0,0 +1,94 @@
"""Analytics service for admin dashboard."""
from datetime import datetime
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.order import Order, OrderItem, OrderStatus
class AnalyticsService:
"""Service for analytics and reporting."""
def __init__(self, db: AsyncSession):
self.db = db
async def get_sales_summary(self, start_date: datetime) -> dict:
"""Get sales summary for the given period."""
# Total revenue
revenue_result = await self.db.execute(
select(func.sum(Order.total))
.where(
Order.created_at >= start_date,
Order.status.in_([
OrderStatus.PAID.value,
OrderStatus.PROCESSING.value,
OrderStatus.SHIPPED.value,
OrderStatus.DELIVERED.value,
]),
)
)
total_revenue = revenue_result.scalar() or 0
# Total orders
orders_result = await self.db.execute(
select(func.count(Order.id))
.where(Order.created_at >= start_date)
)
total_orders = orders_result.scalar() or 0
# Completed orders
completed_result = await self.db.execute(
select(func.count(Order.id))
.where(
Order.created_at >= start_date,
Order.status.in_([
OrderStatus.SHIPPED.value,
OrderStatus.DELIVERED.value,
]),
)
)
completed_orders = completed_result.scalar() or 0
# Average order value
avg_order = total_revenue / total_orders if total_orders > 0 else 0
return {
"total_revenue": float(total_revenue),
"total_orders": total_orders,
"completed_orders": completed_orders,
"average_order_value": float(avg_order),
"period_start": start_date.isoformat(),
}
async def get_product_performance(
self,
start_date: datetime,
limit: int = 10,
) -> list[dict]:
"""Get top performing products."""
result = await self.db.execute(
select(
OrderItem.product_slug,
OrderItem.product_name,
func.sum(OrderItem.quantity).label("total_quantity"),
func.sum(OrderItem.quantity * OrderItem.unit_price).label("total_revenue"),
)
.join(Order)
.where(Order.created_at >= start_date)
.group_by(OrderItem.product_slug, OrderItem.product_name)
.order_by(func.sum(OrderItem.quantity * OrderItem.unit_price).desc())
.limit(limit)
)
products = []
for row in result:
products.append({
"slug": row.product_slug,
"name": row.product_name,
"total_quantity": row.total_quantity,
"total_revenue": float(row.total_revenue),
})
return products

View File

@ -0,0 +1,90 @@
"""Authentication service for admin users."""
from datetime import datetime, timedelta
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from passlib.hash import bcrypt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.database import get_db
from app.models.admin import AdminUser
settings = get_settings()
security = HTTPBearer()
class AuthService:
"""Service for admin authentication."""
def __init__(self, db: AsyncSession):
self.db = db
async def authenticate(self, email: str, password: str) -> str | None:
"""Authenticate admin user and return JWT token."""
result = await self.db.execute(
select(AdminUser).where(AdminUser.email == email)
)
admin = result.scalar_one_or_none()
if not admin or not admin.is_active:
return None
if not bcrypt.verify(password, admin.password_hash):
return None
# Create JWT token
expire = datetime.utcnow() + timedelta(hours=settings.jwt_expire_hours)
payload = {
"sub": str(admin.id),
"email": admin.email,
"exp": expire,
}
token = jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
return token
async def verify_token(self, token: str) -> AdminUser | None:
"""Verify JWT token and return admin user."""
try:
payload = jwt.decode(
token,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
admin_id = payload.get("sub")
if not admin_id:
return None
result = await self.db.execute(
select(AdminUser).where(AdminUser.id == admin_id)
)
admin = result.scalar_one_or_none()
if not admin or not admin.is_active:
return None
return admin
except JWTError:
return None
@staticmethod
def hash_password(password: str) -> str:
"""Hash a password."""
return bcrypt.hash(password)
async def get_current_admin(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> AdminUser:
"""Dependency to get current authenticated admin."""
auth_service = AuthService(db)
admin = await auth_service.verify_token(credentials.credentials)
if not admin:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)
return admin

View File

@ -0,0 +1,146 @@
"""Cart service for managing shopping carts."""
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.cart import Cart, CartItem
from app.schemas.cart import CartItemCreate, CartResponse, CartItemResponse
class CartService:
"""Service for cart operations."""
def __init__(self, db: AsyncSession):
self.db = db
async def create_cart(self) -> CartResponse:
"""Create a new shopping cart."""
cart = Cart()
self.db.add(cart)
await self.db.commit()
# Re-fetch with items loaded to avoid lazy loading issues
result = await self.db.execute(
select(Cart)
.where(Cart.id == cart.id)
.options(selectinload(Cart.items))
)
cart = result.scalar_one()
return self._cart_to_response(cart)
async def get_cart(self, cart_id: UUID) -> CartResponse | None:
"""Get cart by ID."""
result = await self.db.execute(
select(Cart)
.where(Cart.id == cart_id)
.options(selectinload(Cart.items))
)
cart = result.scalar_one_or_none()
if not cart:
return None
return self._cart_to_response(cart)
async def add_item(
self,
cart_id: UUID,
item: CartItemCreate,
) -> CartResponse | None:
"""Add item to cart."""
result = await self.db.execute(
select(Cart)
.where(Cart.id == cart_id)
.options(selectinload(Cart.items))
)
cart = result.scalar_one_or_none()
if not cart:
return None
# Check if item already exists (same product + variant)
for existing in cart.items:
if (
existing.product_slug == item.product_slug
and existing.variant == item.variant
):
existing.quantity += item.quantity
await self.db.commit()
return await self.get_cart(cart_id)
# Add new item
cart_item = CartItem(
cart_id=cart_id,
product_slug=item.product_slug,
product_name=item.product_name,
variant=item.variant,
quantity=item.quantity,
unit_price=item.unit_price,
)
self.db.add(cart_item)
await self.db.commit()
return await self.get_cart(cart_id)
async def update_item(
self,
cart_id: UUID,
item_id: UUID,
quantity: int,
) -> CartResponse | None:
"""Update cart item quantity."""
result = await self.db.execute(
select(CartItem)
.where(CartItem.id == item_id, CartItem.cart_id == cart_id)
)
item = result.scalar_one_or_none()
if not item:
return None
if quantity <= 0:
await self.db.delete(item)
else:
item.quantity = quantity
await self.db.commit()
return await self.get_cart(cart_id)
async def remove_item(
self,
cart_id: UUID,
item_id: UUID,
) -> CartResponse | None:
"""Remove item from cart."""
result = await self.db.execute(
select(CartItem)
.where(CartItem.id == item_id, CartItem.cart_id == cart_id)
)
item = result.scalar_one_or_none()
if not item:
return None
await self.db.delete(item)
await self.db.commit()
return await self.get_cart(cart_id)
def _cart_to_response(self, cart: Cart) -> CartResponse:
"""Convert Cart model to response schema."""
items = [
CartItemResponse(
id=item.id,
product_slug=item.product_slug,
product_name=item.product_name,
variant=item.variant,
quantity=item.quantity,
unit_price=float(item.unit_price),
subtotal=float(item.unit_price) * item.quantity,
)
for item in cart.items
]
return CartResponse(
id=cart.id,
items=items,
item_count=sum(item.quantity for item in items),
subtotal=sum(item.subtotal for item in items),
created_at=cart.created_at,
expires_at=cart.expires_at,
)

View File

@ -0,0 +1,293 @@
"""Design service for reading designs from the designs directory."""
from pathlib import Path
from functools import lru_cache
import yaml
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.schemas.design import Design, DesignSource, DesignProduct
from app.schemas.product import Product, ProductVariant
from app.models.product import ProductOverride
settings = get_settings()
class DesignService:
"""Service for reading and managing designs."""
def __init__(self):
self.designs_path = settings.designs_dir
self._cache: dict[str, Design] = {}
def clear_cache(self):
"""Clear the design cache."""
self._cache.clear()
async def list_designs(
self,
status: str = "active",
category: str | None = None,
) -> list[Design]:
"""List all designs from the designs directory."""
designs = []
if not self.designs_path.exists():
return designs
for category_dir in self.designs_path.iterdir():
if not category_dir.is_dir():
continue
# Filter by category if specified
if category and category_dir.name != category:
continue
for design_dir in category_dir.iterdir():
if not design_dir.is_dir():
continue
design = await self._load_design(design_dir, category_dir.name)
if design and design.status == status:
designs.append(design)
return designs
async def get_design(self, slug: str) -> Design | None:
"""Get a single design by slug."""
# Check cache
if slug in self._cache:
return self._cache[slug]
# Search for the design
for category_dir in self.designs_path.iterdir():
if not category_dir.is_dir():
continue
design_dir = category_dir / slug
if design_dir.exists():
design = await self._load_design(design_dir, category_dir.name)
if design:
self._cache[slug] = design
return design
return None
async def get_design_image_path(self, slug: str) -> str | None:
"""Get the path to the design image file."""
design = await self.get_design(slug)
if not design:
return None
# Look for exported PNG first
for category_dir in self.designs_path.iterdir():
if not category_dir.is_dir():
continue
design_dir = category_dir / slug
if not design_dir.exists():
continue
# Check exports/300dpi first
export_path = design_dir / "exports" / "300dpi" / f"{slug}.png"
if export_path.exists():
return str(export_path)
# Check for source PNG
source_path = design_dir / design.source.file
if source_path.exists() and source_path.suffix.lower() == ".png":
return str(source_path)
# Check for any PNG in the directory
for png_file in design_dir.glob("*.png"):
return str(png_file)
return None
async def _load_design(self, design_dir: Path, category: str) -> Design | None:
"""Load a design from its directory."""
metadata_path = design_dir / "metadata.yaml"
if not metadata_path.exists():
return None
try:
with open(metadata_path) as f:
metadata = yaml.safe_load(f)
except Exception:
return None
if not metadata:
return None
slug = metadata.get("slug", design_dir.name)
# Parse source info
source_data = metadata.get("source", {})
source = DesignSource(
file=source_data.get("file", f"{slug}.svg"),
format=source_data.get("format", "svg"),
dimensions=source_data.get("dimensions", {"width": 0, "height": 0}),
dpi=source_data.get("dpi", 300),
color_profile=source_data.get("color_profile", "sRGB"),
)
# Parse products
products = []
for p in metadata.get("products", []):
products.append(
DesignProduct(
type=p.get("type", ""),
provider=p.get("provider", ""),
sku=str(p.get("sku", "")), # Convert to string (some SKUs are integers)
variants=p.get("variants", []),
retail_price=float(p.get("retail_price", 0)),
)
)
return Design(
slug=slug,
name=metadata.get("name", slug),
description=metadata.get("description", ""),
tags=metadata.get("tags", []),
category=category,
author=metadata.get("author", ""),
created=str(metadata.get("created", "")),
source=source,
products=products,
status=metadata.get("status", "draft"),
image_url=f"/api/designs/{slug}/image",
)
async def list_products(
self,
category: str | None = None,
product_type: str | None = None,
) -> list[Product]:
"""List all products (designs formatted for storefront)."""
designs = await self.list_designs(status="active", category=category)
products = []
for design in designs:
# Skip designs with no products
if not design.products:
continue
# Filter by product type if specified
matching_products = [
dp for dp in design.products
if not product_type or dp.type == product_type
]
if not matching_products:
continue
# Use the first matching product for base info, combine all variants
dp = matching_products[0]
all_variants = []
for mp in matching_products:
if mp.variants:
for v in mp.variants:
all_variants.append(
ProductVariant(
name=f"{v} ({mp.provider})",
sku=f"{mp.sku}-{v}",
provider=mp.provider,
price=mp.retail_price,
)
)
else:
all_variants.append(
ProductVariant(
name=f"default ({mp.provider})",
sku=mp.sku,
provider=mp.provider,
price=mp.retail_price,
)
)
products.append(
Product(
slug=design.slug,
name=design.name,
description=design.description,
category=design.category,
product_type=dp.type,
tags=design.tags,
image_url=design.image_url,
base_price=dp.retail_price,
variants=all_variants,
is_active=True,
)
)
return products
async def get_product(self, slug: str) -> Product | None:
"""Get a single product by slug."""
design = await self.get_design(slug)
if not design or not design.products:
return None
# Use the first product configuration
dp = design.products[0]
variants = [
ProductVariant(
name=v,
sku=f"{dp.sku}-{v}",
provider=dp.provider,
price=dp.retail_price,
)
for v in dp.variants
] if dp.variants else [
ProductVariant(
name="default",
sku=dp.sku,
provider=dp.provider,
price=dp.retail_price,
)
]
return Product(
slug=design.slug,
name=design.name,
description=design.description,
category=design.category,
product_type=dp.type,
tags=design.tags,
image_url=design.image_url,
base_price=dp.retail_price,
variants=variants,
is_active=True,
)
async def set_product_override(
self,
db: AsyncSession,
slug: str,
is_active: bool | None = None,
price_override: float | None = None,
):
"""Set a product override in the database."""
# Check if override exists
result = await db.execute(
select(ProductOverride).where(ProductOverride.slug == slug)
)
override = result.scalar_one_or_none()
if override:
if is_active is not None:
override.is_active = is_active
if price_override is not None:
override.price_override = price_override
else:
override = ProductOverride(
slug=slug,
is_active=is_active if is_active is not None else True,
price_override=price_override,
)
db.add(override)
await db.commit()

View File

@ -0,0 +1,229 @@
"""Order management service."""
from datetime import datetime
from uuid import UUID
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.order import Order, OrderItem, OrderStatus
from app.models.customer import Customer
from app.models.cart import Cart
from app.schemas.order import OrderResponse, OrderItemResponse
class OrderService:
"""Service for order operations."""
def __init__(self, db: AsyncSession):
self.db = db
async def get_order_by_id(self, order_id: UUID) -> OrderResponse | None:
"""Get order by ID."""
result = await self.db.execute(
select(Order)
.where(Order.id == order_id)
.options(selectinload(Order.items))
)
order = result.scalar_one_or_none()
if not order:
return None
return self._order_to_response(order)
async def get_order_by_id_and_email(
self,
order_id: UUID,
email: str,
) -> OrderResponse | None:
"""Get order by ID with email verification."""
result = await self.db.execute(
select(Order)
.where(Order.id == order_id, Order.shipping_email == email)
.options(selectinload(Order.items))
)
order = result.scalar_one_or_none()
if not order:
return None
return self._order_to_response(order)
async def list_orders(
self,
status: OrderStatus | None = None,
limit: int = 50,
offset: int = 0,
) -> list[OrderResponse]:
"""List orders with optional status filter."""
query = select(Order).options(selectinload(Order.items))
if status:
query = query.where(Order.status == status.value)
query = query.order_by(Order.created_at.desc()).limit(limit).offset(offset)
result = await self.db.execute(query)
orders = result.scalars().all()
return [self._order_to_response(o) for o in orders]
async def update_status(
self,
order_id: UUID,
status: OrderStatus,
) -> OrderResponse | None:
"""Update order status."""
result = await self.db.execute(
select(Order)
.where(Order.id == order_id)
.options(selectinload(Order.items))
)
order = result.scalar_one_or_none()
if not order:
return None
order.status = status.value
if status == OrderStatus.SHIPPED:
order.shipped_at = datetime.utcnow()
elif status == OrderStatus.DELIVERED:
order.delivered_at = datetime.utcnow()
await self.db.commit()
return self._order_to_response(order)
async def handle_successful_payment(self, session: dict):
"""Handle successful Stripe payment."""
cart_id = session.get("metadata", {}).get("cart_id")
if not cart_id:
return
# Get cart
result = await self.db.execute(
select(Cart)
.where(Cart.id == UUID(cart_id))
.options(selectinload(Cart.items))
)
cart = result.scalar_one_or_none()
if not cart or not cart.items:
return
# Get or create customer
email = session.get("customer_details", {}).get("email", "")
customer = await self._get_or_create_customer(email)
# Get shipping details
shipping = session.get("shipping_details", {}) or {}
address = shipping.get("address", {}) or {}
# Create order
order = Order(
customer_id=customer.id if customer else None,
stripe_session_id=session.get("id"),
stripe_payment_intent_id=session.get("payment_intent"),
status=OrderStatus.PAID.value,
shipping_name=shipping.get("name"),
shipping_email=email,
shipping_address_line1=address.get("line1"),
shipping_address_line2=address.get("line2"),
shipping_city=address.get("city"),
shipping_state=address.get("state"),
shipping_postal_code=address.get("postal_code"),
shipping_country=address.get("country"),
subtotal=float(session.get("amount_subtotal", 0)) / 100,
shipping_cost=float(session.get("shipping_cost", {}).get("amount_total", 0)) / 100,
total=float(session.get("amount_total", 0)) / 100,
currency=session.get("currency", "usd").upper(),
paid_at=datetime.utcnow(),
)
self.db.add(order)
await self.db.flush()
# Create order items
for cart_item in cart.items:
order_item = OrderItem(
order_id=order.id,
product_slug=cart_item.product_slug,
product_name=cart_item.product_name,
variant=cart_item.variant,
quantity=cart_item.quantity,
unit_price=float(cart_item.unit_price),
pod_status="pending",
)
self.db.add(order_item)
await self.db.commit()
# TODO: Submit to POD providers
# TODO: Send confirmation email
async def update_pod_status(
self,
pod_provider: str,
pod_order_id: str,
status: str,
tracking_number: str | None = None,
tracking_url: str | None = None,
):
"""Update POD status for order items."""
await self.db.execute(
update(OrderItem)
.where(
OrderItem.pod_provider == pod_provider,
OrderItem.pod_order_id == pod_order_id,
)
.values(
pod_status=status,
pod_tracking_number=tracking_number,
pod_tracking_url=tracking_url,
)
)
await self.db.commit()
async def _get_or_create_customer(self, email: str) -> Customer | None:
"""Get or create customer by email."""
if not email:
return None
result = await self.db.execute(
select(Customer).where(Customer.email == email)
)
customer = result.scalar_one_or_none()
if customer:
return customer
customer = Customer(email=email)
self.db.add(customer)
await self.db.flush()
return customer
def _order_to_response(self, order: Order) -> OrderResponse:
"""Convert Order model to response schema."""
items = [
OrderItemResponse(
id=item.id,
product_slug=item.product_slug,
product_name=item.product_name,
variant=item.variant,
quantity=item.quantity,
unit_price=float(item.unit_price),
pod_provider=item.pod_provider,
pod_status=item.pod_status,
pod_tracking_number=item.pod_tracking_number,
pod_tracking_url=item.pod_tracking_url,
)
for item in order.items
]
return OrderResponse(
id=order.id,
status=order.status,
shipping_name=order.shipping_name,
shipping_email=order.shipping_email,
shipping_city=order.shipping_city,
shipping_country=order.shipping_country,
subtotal=float(order.subtotal) if order.subtotal else None,
shipping_cost=float(order.shipping_cost) if order.shipping_cost else None,
tax=float(order.tax) if order.tax else None,
total=float(order.total) if order.total else None,
currency=order.currency,
items=items,
created_at=order.created_at,
paid_at=order.paid_at,
shipped_at=order.shipped_at,
)

View File

@ -0,0 +1,91 @@
"""Stripe payment service."""
import stripe
from app.config import get_settings
from app.schemas.cart import CartResponse
settings = get_settings()
# Configure Stripe
stripe.api_key = settings.stripe_secret_key
class StripeService:
"""Service for Stripe operations."""
async def create_checkout_session(
self,
cart: CartResponse,
success_url: str,
cancel_url: str,
) -> dict:
"""Create a Stripe checkout session."""
line_items = []
for item in cart.items:
line_items.append({
"price_data": {
"currency": "usd",
"product_data": {
"name": item.product_name,
"description": f"Variant: {item.variant}" if item.variant else None,
},
"unit_amount": int(item.unit_price * 100), # Convert to cents
},
"quantity": item.quantity,
})
session = stripe.checkout.Session.create(
mode="payment",
line_items=line_items,
success_url=success_url + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url=cancel_url,
shipping_address_collection={
"allowed_countries": [
"US", "CA", "GB", "AU", "DE", "FR", "NL", "BE", "AT", "CH",
"ES", "IT", "PT", "IE", "DK", "SE", "NO", "FI", "PL", "CZ",
],
},
metadata={
"cart_id": str(cart.id),
},
)
return {
"url": session.url,
"session_id": session.id,
}
def verify_webhook(self, payload: bytes, sig_header: str) -> dict:
"""Verify and parse Stripe webhook."""
try:
event = stripe.Webhook.construct_event(
payload,
sig_header,
settings.stripe_webhook_secret,
)
return event
except stripe.error.SignatureVerificationError as e:
raise ValueError(f"Invalid signature: {e}")
except Exception as e:
raise ValueError(f"Webhook error: {e}")
async def get_session(self, session_id: str) -> dict:
"""Get Stripe checkout session details."""
session = stripe.checkout.Session.retrieve(
session_id,
expand=["line_items", "customer"],
)
return session
async def create_refund(
self,
payment_intent_id: str,
amount: int | None = None,
) -> dict:
"""Create a refund for a payment."""
refund = stripe.Refund.create(
payment_intent=payment_intent_id,
amount=amount, # None = full refund
)
return refund

54
backend/pyproject.toml Normal file
View File

@ -0,0 +1,54 @@
[project]
name = "rswag"
version = "0.1.0"
description = "E-commerce backend for rSpace ecosystem merchandise"
authors = [{ name = "Jeff Emmett", email = "jeff@rspace.online" }]
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.109.0",
"uvicorn[standard]>=0.27.0",
"sqlalchemy>=2.0.0",
"alembic>=1.13.0",
"asyncpg>=0.29.0",
"psycopg2-binary>=2.9.0",
"pydantic>=2.5.0",
"pydantic-settings>=2.1.0",
"python-jose[cryptography]>=3.3.0",
"passlib[bcrypt]>=1.7.4",
"httpx>=0.26.0",
"stripe>=7.0.0",
"pyyaml>=6.0.0",
"pillow>=10.0.0",
"python-multipart>=0.0.6",
"redis>=5.0.0",
"aiofiles>=23.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.26.0",
"black>=24.0.0",
"ruff>=0.1.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "W"]
ignore = ["E501"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

32
backend/requirements.txt Normal file
View File

@ -0,0 +1,32 @@
# Core
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
# Database
sqlalchemy>=2.0.0
alembic>=1.13.0
asyncpg>=0.29.0
psycopg2-binary>=2.9.0
# Validation
pydantic>=2.5.0
pydantic-settings>=2.1.0
# Auth
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
# HTTP Client
httpx>=0.26.0
# Stripe
stripe>=7.0.0
# Config & Utils
pyyaml>=6.0.0
pillow>=10.0.0
python-multipart>=0.0.6
aiofiles>=23.0.0
# Cache
redis>=5.0.0

232
config/products.yaml Normal file
View File

@ -0,0 +1,232 @@
# rSwag Product Catalog Configuration
# Maps design types to POD provider products
defaults:
color_profile: sRGB
resolution: 300
format: png
# Sticker Products
stickers:
small:
name: "3×3 Vinyl Sticker"
dimensions:
width: 3
height: 3
unit: inches
pixels:
width: 900
height: 900
providers:
prodigi:
sku: "GLOBAL-STI-KIS-3X3"
variants:
- id: matte
name: "Matte Finish"
- id: gloss
name: "Gloss Finish"
base_cost: 1.20
printful:
sku: 358 # Kiss-cut stickers
variants:
- id: white
name: "White"
base_cost: 1.50
medium:
name: "4×4 Vinyl Sticker"
dimensions:
width: 4
height: 4
unit: inches
pixels:
width: 1200
height: 1200
providers:
prodigi:
sku: "GLOBAL-STI-KIS-4X4"
variants:
- id: matte
name: "Matte Finish"
- id: gloss
name: "Gloss Finish"
base_cost: 1.80
large:
name: "6×6 Vinyl Sticker"
dimensions:
width: 6
height: 6
unit: inches
pixels:
width: 1800
height: 1800
providers:
prodigi:
sku: "GLOBAL-STI-KIS-6X6"
base_cost: 2.50
# Apparel Products
apparel:
tshirt:
name: "Unisex T-Shirt"
print_areas:
front:
dimensions:
width: 12
height: 16
unit: inches
pixels:
width: 3600
height: 4800
chest:
dimensions:
width: 4
height: 4
unit: inches
pixels:
width: 1200
height: 1200
providers:
printful:
sku: 71 # Bella + Canvas 3001
sizes: [S, M, L, XL, 2XL, 3XL]
colors:
- id: black
name: "Black"
hex: "#0a0a0a"
- id: white
name: "White"
hex: "#ffffff"
- id: heather_charcoal
name: "Heather Charcoal"
hex: "#4a4a4a"
- id: forest_green
name: "Forest Green"
hex: "#2d4a3e"
- id: maroon
name: "Maroon"
hex: "#5a2d2d"
base_cost:
S: 9.25
M: 9.25
L: 9.25
XL: 9.25
2XL: 11.25
3XL: 13.25
hoodie:
name: "Unisex Hoodie"
print_areas:
front:
dimensions:
width: 14
height: 16
unit: inches
pixels:
width: 4200
height: 4800
providers:
printful:
sku: 146 # Bella + Canvas 3719
sizes: [S, M, L, XL, 2XL]
colors:
- id: black
name: "Black"
- id: dark_grey_heather
name: "Dark Grey Heather"
base_cost:
S: 23.95
M: 23.95
L: 23.95
XL: 23.95
2XL: 27.95
# Art Prints
prints:
small:
name: "8×10 Art Print"
dimensions:
width: 8
height: 10
unit: inches
pixels:
width: 2400
height: 3000
providers:
prodigi:
sku: "GLOBAL-FAP-8X10"
variants:
- id: matte
name: "Matte"
- id: lustre
name: "Lustre"
base_cost: 4.50
medium:
name: "11×14 Art Print"
dimensions:
width: 11
height: 14
unit: inches
pixels:
width: 3300
height: 4200
providers:
prodigi:
sku: "GLOBAL-FAP-11X14"
base_cost: 7.00
large:
name: "18×24 Art Print"
dimensions:
width: 18
height: 24
unit: inches
pixels:
width: 5400
height: 7200
providers:
prodigi:
sku: "GLOBAL-FAP-18X24"
base_cost: 12.00
# Pricing Rules
pricing:
default_markup: 2.0 # 100% markup (double cost)
rules:
stickers:
markup: 2.5 # Higher margin on low-cost items
minimum_price: 3.00
apparel:
markup: 1.8
minimum_price: 20.00
prints:
markup: 2.0
minimum_price: 15.00
# Round to nearest .99 or .50
rounding: nearest_99
# Shipping Profiles
shipping:
prodigi:
standard:
name: "Standard"
days: "5-10"
express:
name: "Express"
days: "2-5"
additional_cost: 5.00
printful:
standard:
name: "Standard"
days: "5-12"
express:
name: "Express"
days: "3-5"
additional_cost: 7.00

0
designs/.gitkeep Normal file
View File

0
designs/misc/.gitkeep Normal file
View File

0
designs/shirts/.gitkeep Normal file
View File

View File

37
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,37 @@
# Development overrides - use with: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
services:
db:
ports:
- "5432:5432"
redis:
ports:
- "6379:6379"
backend:
volumes:
# Mount source for hot reload
- ./backend/app:/app/app:ro
# Mount designs from in-repo designs dir
- ./designs:/app/designs:ro
- ./config:/app/config:ro
environment:
- DEBUG=true
- POD_SANDBOX_MODE=true
ports:
- "8000:8000"
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
frontend:
build:
args:
- NEXT_PUBLIC_API_URL=http://localhost:8000/api
ports:
- "3000:3000"
networks:
rswag-internal:
driver: bridge
traefik-public:
driver: bridge # Override external for local dev

20
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,20 @@
services:
backend:
volumes:
- /opt/apps/rswag/designs:/app/designs
- /opt/apps/rswag/config:/app/config:ro
environment:
- DESIGNS_PATH=/app/designs
- CONFIG_PATH=/app/config
frontend:
build:
args:
- NEXT_PUBLIC_API_URL=https://rswag.online/api
networks:
rswag-internal:
driver: bridge
ipam:
config:
- subnet: 10.200.1.0/24

99
docker-compose.yml Normal file
View File

@ -0,0 +1,99 @@
services:
# PostgreSQL Database
db:
image: postgres:16-alpine
container_name: rswag-db
restart: unless-stopped
environment:
POSTGRES_USER: rswag
POSTGRES_PASSWORD: ${DB_PASSWORD:-devpassword}
POSTGRES_DB: rswag
volumes:
- rswag-db-data:/var/lib/postgresql/data
networks:
- rswag-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rswag"]
interval: 5s
timeout: 5s
retries: 5
# Redis for sessions/cache
redis:
image: redis:7-alpine
container_name: rswag-redis
restart: unless-stopped
volumes:
- rswag-redis-data:/data
networks:
- rswag-internal
# FastAPI Backend
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: rswag-backend
restart: unless-stopped
environment:
- DATABASE_URL=postgresql://rswag:${DB_PASSWORD:-devpassword}@db:5432/rswag
- REDIS_URL=redis://redis:6379
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- PRODIGI_API_KEY=${PRODIGI_API_KEY}
- PRINTFUL_API_TOKEN=${PRINTFUL_API_TOKEN}
- POD_SANDBOX_MODE=${POD_SANDBOX_MODE:-true}
- JWT_SECRET=${JWT_SECRET:-dev-secret-change-me}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
- DESIGNS_PATH=/app/designs
- CONFIG_PATH=/app/config
- GEMINI_API_KEY=${GEMINI_API_KEY}
volumes:
- ./designs:/app/designs
- ./config:/app/config:ro
depends_on:
db:
condition: service_healthy
networks:
- rswag-internal
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.rswag-api.rule=Host(`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"
# Next.js Frontend
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api}
- NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
container_name: rswag-frontend
restart: unless-stopped
environment:
- NODE_ENV=production
depends_on:
- backend
networks:
- rswag-internal
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.rswag-web.rule=Host(`rswag.online`)"
- "traefik.http.routers.rswag-web.entrypoints=web"
- "traefik.http.services.rswag-web.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik-public"
volumes:
rswag-db-data:
rswag-redis-data:
networks:
rswag-internal:
driver: bridge
traefik-public:
external: true

48
frontend/Dockerfile Normal file
View File

@ -0,0 +1,48 @@
# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable pnpm && pnpm i --frozen-lockfile || npm install
# Stage 2: Builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
# Ensure public directory exists
RUN mkdir -p public
RUN npm run build
# Stage 3: Runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

294
frontend/app/cart/page.tsx Normal file
View File

@ -0,0 +1,294 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
interface CartItem {
id: string;
product_slug: string;
product_name: string;
variant_sku: string;
variant_name: string | null;
quantity: number;
unit_price: number;
subtotal: number;
}
interface Cart {
id: string;
items: CartItem[];
item_count: number;
subtotal: number;
}
export default function CartPage() {
const [cart, setCart] = useState<Cart | null>(null);
const [loading, setLoading] = useState(true);
const [checkingOut, setCheckingOut] = useState(false);
const [updating, setUpdating] = useState<string | null>(null);
const fetchCart = async () => {
const cartId = localStorage.getItem("cart_id");
if (cartId) {
try {
const res = await fetch(`${API_URL}/cart/${cartId}`);
if (res.ok) {
const data = await res.json();
setCart(data);
} else {
// Cart expired or deleted
localStorage.removeItem("cart_id");
setCart(null);
}
} catch {
setCart(null);
}
}
setLoading(false);
};
useEffect(() => {
fetchCart();
}, []);
const updateQuantity = async (itemId: string, newQuantity: number) => {
if (!cart || newQuantity < 1) return;
setUpdating(itemId);
try {
const res = await fetch(`${API_URL}/cart/${cart.id}/items/${itemId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quantity: newQuantity }),
});
if (res.ok) {
const updatedCart = await res.json();
setCart(updatedCart);
}
} catch {
console.error("Failed to update quantity");
} finally {
setUpdating(null);
}
};
const removeItem = async (itemId: string) => {
if (!cart) return;
setUpdating(itemId);
try {
const res = await fetch(`${API_URL}/cart/${cart.id}/items/${itemId}`, {
method: "DELETE",
});
if (res.ok) {
const updatedCart = await res.json();
setCart(updatedCart);
}
} catch {
console.error("Failed to remove item");
} finally {
setUpdating(null);
}
};
const handleCheckout = async () => {
if (!cart) return;
setCheckingOut(true);
try {
const res = await fetch(`${API_URL}/checkout/session`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
cart_id: cart.id,
success_url: `${window.location.origin}/checkout/success`,
cancel_url: `${window.location.origin}/cart`,
}),
});
if (res.ok) {
const { checkout_url } = await res.json();
window.location.href = checkout_url;
} else {
const data = await res.json();
alert(data.detail || "Failed to start checkout");
}
} catch (error) {
console.error("Checkout error:", error);
alert("Failed to start checkout");
} finally {
setCheckingOut(false);
}
};
if (loading) {
return (
<div className="container mx-auto px-4 py-16">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
</div>
);
}
if (!cart || cart.items.length === 0) {
return (
<div className="container mx-auto px-4 py-16 text-center">
<h1 className="text-2xl font-bold mb-4">Your Cart</h1>
<p className="text-muted-foreground mb-8">Your cart is empty.</p>
<Link
href="/products"
className="inline-flex items-center justify-center rounded-md bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Continue Shopping
</Link>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-8">Your Cart</h1>
<div className="grid lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-4">
{cart.items.map((item) => (
<div
key={item.id}
className={`flex items-center gap-4 p-4 border rounded-lg ${
updating === item.id ? "opacity-50" : ""
}`}
>
{/* Product Image */}
<Link href={`/products/${item.product_slug}`}>
<div className="w-24 h-24 bg-muted rounded-lg overflow-hidden flex-shrink-0">
<img
src={`${API_URL}/designs/${item.product_slug}/image`}
alt={item.product_name}
className="w-full h-full object-cover"
/>
</div>
</Link>
{/* Product Info */}
<div className="flex-1 min-w-0">
<Link
href={`/products/${item.product_slug}`}
className="font-semibold hover:text-primary transition-colors"
>
{item.product_name}
</Link>
{item.variant_name && (
<p className="text-sm text-muted-foreground">
{item.variant_name}
</p>
)}
<p className="text-sm text-muted-foreground">
${item.unit_price.toFixed(2)} each
</p>
{/* Quantity Controls */}
<div className="flex items-center gap-2 mt-2">
<button
onClick={() => updateQuantity(item.id, item.quantity - 1)}
disabled={updating === item.id || item.quantity <= 1}
className="w-8 h-8 rounded border flex items-center justify-center hover:bg-muted transition-colors disabled:opacity-50"
>
-
</button>
<span className="w-8 text-center">{item.quantity}</span>
<button
onClick={() => updateQuantity(item.id, item.quantity + 1)}
disabled={updating === item.id}
className="w-8 h-8 rounded border flex items-center justify-center hover:bg-muted transition-colors disabled:opacity-50"
>
+
</button>
<button
onClick={() => removeItem(item.id)}
disabled={updating === item.id}
className="ml-4 text-sm text-red-600 hover:text-red-700 transition-colors disabled:opacity-50"
>
Remove
</button>
</div>
</div>
{/* Subtotal */}
<div className="text-right">
<p className="font-bold">${item.subtotal.toFixed(2)}</p>
</div>
</div>
))}
</div>
{/* Order Summary */}
<div className="lg:col-span-1">
<div className="border rounded-lg p-6 sticky top-4">
<h2 className="font-bold text-lg mb-4">Order Summary</h2>
<div className="space-y-3 mb-4">
<div className="flex justify-between">
<span className="text-muted-foreground">
Subtotal ({cart.item_count} item{cart.item_count !== 1 ? "s" : ""})
</span>
<span>${cart.subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Shipping</span>
<span>Calculated at checkout</span>
</div>
</div>
<div className="border-t pt-4 mb-6">
<div className="flex justify-between font-bold text-lg">
<span>Total</span>
<span>${cart.subtotal.toFixed(2)}</span>
</div>
</div>
<button
onClick={handleCheckout}
disabled={checkingOut}
className="w-full bg-primary text-primary-foreground py-3 rounded-md font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{checkingOut ? (
<span className="flex items-center justify-center gap-2">
<svg
className="animate-spin h-5 w-5"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Processing...
</span>
) : (
"Proceed to Checkout"
)}
</button>
<Link
href="/products"
className="block text-center mt-4 text-sm text-muted-foreground hover:text-primary transition-colors"
>
Continue Shopping
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,334 @@
"use client";
import { useState } from "react";
import Link from "next/link";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
interface GeneratedDesign {
slug: string;
name: string;
image_url: string;
status: string;
}
export default function DesignPage() {
const [name, setName] = useState("");
const [concept, setConcept] = useState("");
const [tags, setTags] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [generatedDesign, setGeneratedDesign] = useState<GeneratedDesign | null>(null);
const [isActivating, setIsActivating] = useState(false);
const handleGenerate = async (e: React.FormEvent) => {
e.preventDefault();
setIsGenerating(true);
setError(null);
setGeneratedDesign(null);
try {
const response = await fetch(`${API_URL}/design/generate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
concept,
tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
product_type: "sticker",
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || "Failed to generate design");
}
const design = await response.json();
setGeneratedDesign(design);
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsGenerating(false);
}
};
const handleActivate = async () => {
if (!generatedDesign) return;
setIsActivating(true);
setError(null);
try {
const response = await fetch(
`${API_URL}/design/${generatedDesign.slug}/activate`,
{
method: "POST",
}
);
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || "Failed to activate design");
}
setGeneratedDesign({ ...generatedDesign, status: "active" });
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsActivating(false);
}
};
const handleDelete = async () => {
if (!generatedDesign) return;
try {
const response = await fetch(
`${API_URL}/design/${generatedDesign.slug}`,
{
method: "DELETE",
}
);
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || "Failed to delete design");
}
setGeneratedDesign(null);
setName("");
setConcept("");
setTags("");
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
}
};
return (
<div className="container mx-auto px-4 py-8">
<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
we&apos;ll generate a unique design.
</p>
<div className="grid md:grid-cols-2 gap-8">
{/* Form */}
<div>
<form onSubmit={handleGenerate} className="space-y-6">
<div>
<label
htmlFor="name"
className="block text-sm font-medium mb-2"
>
Design Name
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Commons Builder"
className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
required
disabled={isGenerating}
/>
</div>
<div>
<label
htmlFor="concept"
className="block text-sm font-medium mb-2"
>
Design Concept
</label>
<textarea
id="concept"
value={concept}
onChange={(e) => setConcept(e.target.value)}
placeholder="Describe your design idea... e.g., Interconnected nodes forming a spatial web, symbolizing collaborative infrastructure for the commons. Include the phrase 'BUILD TOGETHER' in bold letters."
rows={4}
className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary resize-none"
required
disabled={isGenerating}
/>
</div>
<div>
<label
htmlFor="tags"
className="block text-sm font-medium mb-2"
>
Tags (comma-separated)
</label>
<input
type="text"
id="tags"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="rspace, commons, spatial, collaboration"
className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
disabled={isGenerating}
/>
</div>
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-md text-red-700">
{error}
</div>
)}
<button
type="submit"
disabled={isGenerating || !name || !concept}
className="w-full px-6 py-3 bg-primary text-primary-foreground rounded-md font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGenerating ? (
<span className="flex items-center justify-center gap-2">
<svg
className="animate-spin h-5 w-5"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Generating Design...
</span>
) : (
"Generate Design"
)}
</button>
</form>
</div>
{/* Preview */}
<div>
<h2 className="text-lg font-semibold mb-4">Preview</h2>
<div className="aspect-square border-2 border-dashed rounded-lg flex items-center justify-center bg-muted/30">
{generatedDesign ? (
<img
src={`${API_URL.replace("/api", "")}${generatedDesign.image_url}`}
alt={generatedDesign.name}
className="w-full h-full object-contain rounded-lg"
/>
) : isGenerating ? (
<div className="text-center text-muted-foreground">
<svg
className="animate-spin h-12 w-12 mx-auto mb-4"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p>Creating your design...</p>
<p className="text-sm">This may take a moment</p>
</div>
) : (
<p className="text-muted-foreground">
Your design will appear here
</p>
)}
</div>
{generatedDesign && (
<div className="mt-4 space-y-4">
<div className="p-4 bg-muted/50 rounded-lg">
<p className="font-medium">{generatedDesign.name}</p>
<p className="text-sm text-muted-foreground">
Status:{" "}
<span
className={
generatedDesign.status === "active"
? "text-green-600"
: "text-yellow-600"
}
>
{generatedDesign.status}
</span>
</p>
</div>
<div className="flex gap-2">
{generatedDesign.status === "draft" ? (
<>
<button
onClick={handleActivate}
disabled={isActivating}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-md font-medium hover:bg-green-700 transition-colors disabled:opacity-50"
>
{isActivating ? "Activating..." : "Add to Store"}
</button>
<button
onClick={handleDelete}
className="px-4 py-2 border border-red-300 text-red-600 rounded-md font-medium hover:bg-red-50 transition-colors"
>
Discard
</button>
</>
) : (
<Link
href="/products"
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-md font-medium hover:bg-primary/90 transition-colors text-center"
>
View in Store
</Link>
)}
</div>
</div>
)}
</div>
</div>
{/* Tips */}
<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>
</ul>
</div>
</div>
</div>
);
}

59
frontend/app/globals.css Normal file
View File

@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--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%;
--primary: 195 80% 45%;
--primary-foreground: 0 0% 100%;
--secondary: 45 80% 55%;
--secondary-foreground: 222.2 47.4% 11.2%;
--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%;
--radius: 0.625rem;
}
.dark {
--background: 264 30% 6%;
--foreground: 180 10% 96%;
--card: 264 30% 6%;
--card-foreground: 180 10% 96%;
--popover: 264 30% 6%;
--popover-foreground: 180 10% 96%;
--primary: 195 80% 50%;
--primary-foreground: 264 30% 6%;
--secondary: 45 80% 55%;
--secondary-foreground: 180 10% 96%;
--muted: 264 20% 15%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 264 20% 15%;
--accent-foreground: 180 10% 96%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 264 20% 15%;
--input: 264 20% 15%;
--ring: 195 80% 50%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

47
frontend/app/layout.tsx Normal file
View File

@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { GeistSans, GeistMono } from "geist/font";
import "./globals.css";
export const metadata: Metadata = {
title: "rSwag — Merch for the rSpace Ecosystem",
description: "Design and order custom merchandise for rSpace communities. Stickers, shirts, and more.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<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>
<nav className="flex items-center gap-6">
<a href="/products" className="hover:text-primary">
Products
</a>
<a href="/design" className="px-3 py-1.5 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors">
Design Swag
</a>
<a href="/cart" className="hover:text-primary">
Cart
</a>
</nav>
</div>
</header>
<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>
</div>
</footer>
</div>
</body>
</html>
);
}

92
frontend/app/page.tsx Normal file
View File

@ -0,0 +1,92 @@
import Link from "next/link";
interface Product {
slug: string;
name: string;
description: string;
category: string;
product_type: string;
image_url: string;
base_price: number;
}
async function getProducts(): Promise<Product[]> {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/products`,
{ next: { revalidate: 60 } } // Revalidate every minute
);
if (!res.ok) return [];
return res.json();
} catch {
return [];
}
}
export default async function HomePage() {
const products = await getProducts();
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
</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.
</p>
<div className="flex gap-4 justify-center">
<Link
href="/products"
className="inline-flex items-center justify-center rounded-md bg-primary px-8 py-3 text-lg font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Browse Products
</Link>
<Link
href="/design"
className="inline-flex items-center justify-center rounded-md border border-primary px-8 py-3 text-lg font-medium text-primary hover:bg-primary/10 transition-colors"
>
Design Your Own
</Link>
</div>
</div>
<div className="mt-24">
<h2 className="text-2xl font-bold text-center mb-12">Featured Products</h2>
{products.length === 0 ? (
<p className="text-center text-muted-foreground">
No products available yet. Check back soon!
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-5xl mx-auto">
{products.map((product) => (
<Link
key={product.slug}
href={`/products/${product.slug}`}
className="group"
>
<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`}
alt={product.name}
className="object-cover w-full h-full group-hover:scale-105 transition-transform"
/>
</div>
<div className="p-4">
<h3 className="font-semibold">{product.name}</h3>
<p className="text-sm text-muted-foreground capitalize">
{product.product_type}
</p>
<p className="font-bold mt-2">${product.base_price.toFixed(2)}</p>
</div>
</div>
</Link>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,329 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
interface ProductVariant {
name: string;
sku: string;
provider: string;
price: number;
}
interface Product {
slug: string;
name: string;
description: string;
category: string;
product_type: string;
tags: string[];
image_url: string;
base_price: number;
variants: ProductVariant[];
is_active: boolean;
}
export default function ProductPage() {
const params = useParams();
const router = useRouter();
const slug = params.slug as string;
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
const [quantity, setQuantity] = useState(1);
const [addingToCart, setAddingToCart] = useState(false);
const [addedToCart, setAddedToCart] = useState(false);
useEffect(() => {
async function fetchProduct() {
try {
const res = await fetch(`${API_URL}/products/${slug}`);
if (!res.ok) {
if (res.status === 404) {
setError("Product not found");
} else {
setError("Failed to load product");
}
return;
}
const data = await res.json();
setProduct(data);
if (data.variants && data.variants.length > 0) {
setSelectedVariant(data.variants[0]);
}
} catch {
setError("Failed to load product");
} finally {
setLoading(false);
}
}
if (slug) {
fetchProduct();
}
}, [slug]);
const getOrCreateCart = async (): Promise<string | null> => {
// Check for existing cart in localStorage
let cartId = localStorage.getItem("cart_id");
if (cartId) {
// Verify cart still exists
try {
const res = await fetch(`${API_URL}/cart/${cartId}`);
if (res.ok) {
return cartId;
}
} catch {
// Cart doesn't exist, create new one
}
}
// Create new cart
try {
const res = await fetch(`${API_URL}/cart`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
if (res.ok) {
const data = await res.json();
cartId = data.id;
localStorage.setItem("cart_id", cartId!);
return cartId;
}
} catch {
return null;
}
return null;
};
const handleAddToCart = async () => {
if (!product || !selectedVariant) return;
setAddingToCart(true);
try {
const cartId = await getOrCreateCart();
if (!cartId) {
alert("Failed to create cart");
return;
}
const res = await fetch(`${API_URL}/cart/${cartId}/items`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
product_slug: product.slug,
variant_sku: selectedVariant.sku,
quantity: quantity,
}),
});
if (res.ok) {
setAddedToCart(true);
setTimeout(() => setAddedToCart(false), 3000);
} else {
const data = await res.json();
alert(data.detail || "Failed to add to cart");
}
} catch {
alert("Failed to add to cart");
} finally {
setAddingToCart(false);
}
};
if (loading) {
return (
<div className="container mx-auto px-4 py-16">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
</div>
);
}
if (error || !product) {
return (
<div className="container mx-auto px-4 py-16">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">{error || "Product not found"}</h1>
<Link
href="/products"
className="text-primary hover:underline"
>
Back to Products
</Link>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
{/* Breadcrumb */}
<nav className="mb-8 text-sm">
<Link href="/" className="text-muted-foreground hover:text-primary">
Home
</Link>
<span className="mx-2 text-muted-foreground">/</span>
<Link href="/products" className="text-muted-foreground hover:text-primary">
Products
</Link>
<span className="mx-2 text-muted-foreground">/</span>
<span className="text-foreground">{product.name}</span>
</nav>
<div className="grid md:grid-cols-2 gap-12">
{/* Product Image */}
<div className="aspect-square bg-muted rounded-lg overflow-hidden">
<img
src={`${API_URL}/designs/${product.slug}/image`}
alt={product.name}
className="w-full h-full object-cover"
/>
</div>
{/* Product Details */}
<div>
<div className="mb-2">
<span className="text-sm text-muted-foreground capitalize">
{product.category} / {product.product_type}
</span>
</div>
<h1 className="text-3xl font-bold mb-4">{product.name}</h1>
<p className="text-muted-foreground mb-6">{product.description}</p>
<div className="text-3xl font-bold mb-6">
${selectedVariant?.price.toFixed(2) || product.base_price.toFixed(2)}
</div>
{/* Variant Selection */}
{product.variants && product.variants.length > 1 && (
<div className="mb-6">
<label className="block text-sm font-medium mb-2">
Select Option
</label>
<div className="flex flex-wrap gap-2">
{product.variants.map((variant) => (
<button
key={variant.sku}
onClick={() => setSelectedVariant(variant)}
className={`px-4 py-2 rounded-md border transition-colors ${
selectedVariant?.sku === variant.sku
? "border-primary bg-primary/10 text-primary"
: "border-muted-foreground/30 hover:border-primary"
}`}
>
{variant.name}
</button>
))}
</div>
</div>
)}
{/* Quantity */}
<div className="mb-6">
<label className="block text-sm font-medium mb-2">Quantity</label>
<div className="flex items-center gap-2">
<button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
className="w-10 h-10 rounded-md border flex items-center justify-center hover:bg-muted transition-colors"
>
-
</button>
<span className="w-12 text-center font-medium">{quantity}</span>
<button
onClick={() => setQuantity(quantity + 1)}
className="w-10 h-10 rounded-md border flex items-center justify-center hover:bg-muted transition-colors"
>
+
</button>
</div>
</div>
{/* Add to Cart Button */}
<button
onClick={handleAddToCart}
disabled={addingToCart || !selectedVariant}
className={`w-full py-4 rounded-md font-medium transition-colors ${
addedToCart
? "bg-green-600 text-white"
: "bg-primary text-primary-foreground hover:bg-primary/90"
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{addingToCart ? (
<span className="flex items-center justify-center gap-2">
<svg
className="animate-spin h-5 w-5"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Adding...
</span>
) : addedToCart ? (
<span className="flex items-center justify-center gap-2">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Added to Cart!
</span>
) : (
"Add to Cart"
)}
</button>
{/* View Cart Link */}
{addedToCart && (
<Link
href="/cart"
className="block text-center mt-4 text-primary hover:underline"
>
View Cart
</Link>
)}
{/* Tags */}
{product.tags && product.tags.length > 0 && (
<div className="mt-8 pt-6 border-t">
<span className="text-sm text-muted-foreground">Tags: </span>
<div className="flex flex-wrap gap-2 mt-2">
{product.tags.map((tag) => (
<span
key={tag}
className="px-2 py-1 text-xs bg-muted rounded-full"
>
{tag}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,71 @@
import Link from "next/link";
interface Product {
slug: string;
name: string;
description: string;
category: string;
product_type: string;
image_url: string;
base_price: number;
}
async function getProducts(): Promise<Product[]> {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/products`,
{ next: { revalidate: 3600 } }
);
if (!res.ok) return [];
return res.json();
} catch {
return [];
}
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Products</h1>
{products.length === 0 ? (
<div className="text-center py-16">
<p className="text-muted-foreground">
No products available yet. Check back soon!
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{products.map((product) => (
<Link
key={product.slug}
href={`/products/${product.slug}`}
className="group"
>
<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`}
alt={product.name}
className="object-cover w-full h-full group-hover:scale-105 transition-transform"
/>
</div>
<div className="p-4">
<h3 className="font-semibold truncate">{product.name}</h3>
<p className="text-sm text-muted-foreground capitalize">
{product.product_type}
</p>
<p className="font-bold mt-2">
${product.base_price.toFixed(2)}
</p>
</div>
</div>
</Link>
))}
</div>
)}
</div>
);
}

6
frontend/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

6
frontend/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

18
frontend/next.config.mjs Normal file
View File

@ -0,0 +1,18 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'rswag.online',
},
{
protocol: 'http',
hostname: 'localhost',
},
],
},
};
export default nextConfig;

41
frontend/package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "rswag-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"geist": "^1.3.0",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-radio-group": "^1.2.2",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@stripe/stripe-js": "^2.4.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.0"
}
}

View File

@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

0
frontend/public/.gitkeep Normal file
View File

View File

@ -0,0 +1,54 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;

27
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}