diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..53c1b82 --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# Database +DB_PASSWORD=change_me_in_production + +# Mollie Payments (https://my.mollie.com/dashboard) +MOLLIE_API_KEY=test_xxx + +# POD Providers +PRODIGI_API_KEY=xxx +PRINTFUL_API_TOKEN=xxx +PRINTFUL_STORE_ID= +POD_SANDBOX_MODE=true + +# Auth +JWT_SECRET=generate_a_strong_secret_here + +# App +CORS_ORIGINS=https://rswag.online +PUBLIC_URL=https://rswag.online + +# AI Design Generation +GEMINI_API_KEY=xxx + +# TBFF Revenue Split → Bonding Curve +# Leave FLOW_SERVICE_URL empty to disable flow routing +FLOW_SERVICE_URL=http://flow-service:3010 +FLOW_ID=xxx +FLOW_FUNNEL_ID=xxx +FLOW_REVENUE_SPLIT=0.5 + +# SMTP Email +SMTP_HOST=mail.example.com +SMTP_PORT=587 +SMTP_USER=noreply@example.com +SMTP_PASS=changeme + +# Frontend +NEXT_PUBLIC_API_URL=https://rswag.online/api diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f731256 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..73df230 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# rSwag - AI Assistant Context + +## Project Overview + +E-commerce platform for rSpace ecosystem merchandise (stickers, shirts, prints) with Mollie 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**: Mollie (redirect flow, Dutch data residency) +- **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 (mollie, pod, orders, spaces) | +| `backend/app/pod/` | POD provider clients | +| `frontend/app/` | Next.js App Router pages | +| `frontend/components/` | React components | +| `frontend/lib/` | Utilities (spaces, cn) | +| `designs/` | Design assets (stickers, shirts, misc) | +| `spaces/` | Space configs (multi-tenant branding/theming) | + +## Spaces (Multi-Tenant) + +rSwag supports subdomain-based spaces. Each space has its own branding, theme, and product catalog. + +- **Config**: `spaces/{space_id}/space.yaml` defines name, theme colors, design filter, tips +- **Subdomain routing**: `{space}.rswag.online` detected by Next.js middleware, sets `space_id` cookie +- **API filtering**: `GET /api/products?space=fungiflows` returns only that space's designs +- **Theme injection**: CSS variables overridden at runtime from space config +- **Cart isolation**: localStorage keys scoped by space (`cart_id_fungiflows`) +- **Current spaces**: `_default` (rSwag hub), `fungiflows` (Fungi Flows merch) + +## Design Source + +Designs are stored in-repo at `./designs/` and mounted into the backend container. + +Each design has a `metadata.yaml` with name, description, products, variants, pricing, and `space` field. + +## API Endpoints + +### Spaces +- `GET /api/spaces` - List all spaces +- `GET /api/spaces/{id}` - Get space config (branding, theme, tips) + +### Public +- `GET /api/designs` - List active designs (optional: `?space=X`) +- `GET /api/designs/{slug}` - Get design details +- `GET /api/designs/{slug}/image` - Serve design image +- `GET /api/products` - List products with variants (optional: `?space=X`) +- `POST /api/cart` - Create cart +- `GET/POST/DELETE /api/cart/{id}/items` - Cart operations +- `POST /api/checkout/session` - Create Mollie payment +- `GET /api/orders/{id}` - Order status (requires email) +- `POST /api/design/generate` - AI design generation + +### Webhooks +- `POST /api/webhooks/mollie` - Mollie 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 + +Default (rSwag): +- **Primary color**: Cyan (HSL 195 80% 45%) +- **Secondary color**: Orange (HSL 45 80% 55%) +- **Font**: Geist Sans + Geist Mono +- **Theme**: rSpace spatial web aesthetic + +Fungi Flows space (`fungiflows.rswag.online`): +- **Primary**: Gold (#ffd700) +- **Secondary**: Bioluminescent green (#39ff14) +- **Background**: Deep purple (#08070d) +- **Theme**: Psychedelic mushroom hip-hop aesthetic diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e9b829 --- /dev/null +++ b/README.md @@ -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**: Mollie (EU data residency) +- **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 Mollie 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" +``` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..113c77f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,52 @@ +# 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 ./ + +# Copy Infisical entrypoint +COPY --chown=appuser:appgroup entrypoint.sh ./entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +# Create directories for mounted volumes +RUN mkdir -p /app/designs /app/config && \ + chown -R appuser:appgroup /app + +USER appuser + +EXPOSE 8000 + +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..534cbc5 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..948d318 --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/001_initial.py b/backend/alembic/versions/001_initial.py new file mode 100644 index 0000000..24f7582 --- /dev/null +++ b/backend/alembic/versions/001_initial.py @@ -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") diff --git a/backend/alembic/versions/002_stripe_to_mollie.py b/backend/alembic/versions/002_stripe_to_mollie.py new file mode 100644 index 0000000..9a3de22 --- /dev/null +++ b/backend/alembic/versions/002_stripe_to_mollie.py @@ -0,0 +1,36 @@ +"""Migrate from Stripe to Mollie payment provider + +Revision ID: 002_stripe_to_mollie +Revises: 001_initial +Create Date: 2026-02-18 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "002_stripe_to_mollie" +down_revision: Union[str, None] = "001_initial" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Rename Stripe-specific columns to generic payment columns + op.alter_column("orders", "stripe_session_id", new_column_name="payment_id") + op.alter_column("orders", "stripe_payment_intent_id", new_column_name="payment_method") + + # Add payment_provider column + op.add_column("orders", sa.Column("payment_provider", sa.String(50), nullable=True)) + + # Rename stripe_customer_id on customers table + op.alter_column("customers", "stripe_customer_id", new_column_name="external_id") + + +def downgrade() -> None: + op.alter_column("customers", "external_id", new_column_name="stripe_customer_id") + op.drop_column("orders", "payment_provider") + op.alter_column("orders", "payment_method", new_column_name="stripe_payment_intent_id") + op.alter_column("orders", "payment_id", new_column_name="stripe_session_id") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..f7826c0 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# Mycopunk Swag Store Backend diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..a8efdec --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,3 @@ +"""API routes.""" + +from app.api import designs, products, cart, checkout, orders, webhooks, health diff --git a/backend/app/api/admin/__init__.py b/backend/app/api/admin/__init__.py new file mode 100644 index 0000000..9d44e3b --- /dev/null +++ b/backend/app/api/admin/__init__.py @@ -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"]) diff --git a/backend/app/api/admin/analytics.py b/backend/app/api/admin/analytics.py new file mode 100644 index 0000000..d757357 --- /dev/null +++ b/backend/app/api/admin/analytics.py @@ -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) diff --git a/backend/app/api/admin/auth.py b/backend/app/api/admin/auth.py new file mode 100644 index 0000000..7304a40 --- /dev/null +++ b/backend/app/api/admin/auth.py @@ -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) diff --git a/backend/app/api/admin/orders.py b/backend/app/api/admin/orders.py new file mode 100644 index 0000000..2d465b7 --- /dev/null +++ b/backend/app/api/admin/orders.py @@ -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} diff --git a/backend/app/api/admin/products.py b/backend/app/api/admin/products.py new file mode 100644 index 0000000..3ffd966 --- /dev/null +++ b/backend/app/api/admin/products.py @@ -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)} diff --git a/backend/app/api/cart.py b/backend/app/api/cart.py new file mode 100644 index 0000000..2b74b82 --- /dev/null +++ b/backend/app/api/cart.py @@ -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 diff --git a/backend/app/api/checkout.py b/backend/app/api/checkout.py new file mode 100644 index 0000000..1baecec --- /dev/null +++ b/backend/app/api/checkout.py @@ -0,0 +1,53 @@ +"""Checkout API endpoints.""" + +from fastapi import APIRouter, HTTPException, Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.schemas.order import CheckoutRequest, CheckoutResponse +from app.services.mollie_service import MollieService +from app.services.cart_service import CartService + +router = APIRouter() + + +def get_mollie_service() -> MollieService: + return MollieService() + + +def get_cart_service(db: AsyncSession = Depends(get_db)) -> CartService: + return CartService(db) + + +@router.post("/session", response_model=CheckoutResponse) +async def create_checkout_session( + checkout_request: CheckoutRequest, + request: Request, + mollie_service: MollieService = Depends(get_mollie_service), + cart_service: CartService = Depends(get_cart_service), +): + """Create a Mollie payment session.""" + # Get cart + cart = await cart_service.get_cart(checkout_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") + + # Build webhook URL from request origin + base_url = str(request.base_url).rstrip("/") + webhook_url = f"{base_url}/api/webhooks/mollie" + + # Create Mollie payment + result = await mollie_service.create_payment( + cart=cart, + success_url=checkout_request.success_url, + cancel_url=checkout_request.cancel_url, + webhook_url=webhook_url, + ) + + return CheckoutResponse( + checkout_url=result["url"], + session_id=result["payment_id"], + ) diff --git a/backend/app/api/design_generator.py b/backend/app/api/design_generator.py new file mode 100644 index 0000000..e8304e4 --- /dev/null +++ b/backend/app/api/design_generator.py @@ -0,0 +1,240 @@ +"""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" + ) + + +def find_design_dir(slug: str) -> Path | None: + """Find a design directory by slug, searching all categories.""" + for category_dir in settings.designs_dir.iterdir(): + if not category_dir.is_dir(): + continue + design_dir = category_dir / slug + if design_dir.exists() and (design_dir / "metadata.yaml").exists(): + return design_dir + return None + + +@router.post("/{slug}/activate") +async def activate_design(slug: str): + """Activate a draft design to make it visible in the store.""" + + design_dir = find_design_dir(slug) + if not design_dir: + raise HTTPException(status_code=404, detail="Design not found") + + metadata_path = design_dir / "metadata.yaml" + + # 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 = find_design_dir(slug) + if not design_dir: + raise HTTPException(status_code=404, detail="Design not found") + + metadata_path = design_dir / "metadata.yaml" + + # 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} diff --git a/backend/app/api/designs.py b/backend/app/api/designs.py new file mode 100644 index 0000000..4c6f88d --- /dev/null +++ b/backend/app/api/designs.py @@ -0,0 +1,275 @@ +"""Designs API endpoints.""" + +import io +import logging +from pathlib import Path + +import httpx +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse, StreamingResponse +from PIL import Image + +from app.config import get_settings +from app.schemas.design import Design +from app.services.design_service import DesignService + +logger = logging.getLogger(__name__) +router = APIRouter() +design_service = DesignService() +settings = get_settings() + +# Mockup template configs: product_type → (template path, design bounding box, blend mode) +# Coordinates are for 1024x1024 photorealistic templates +MOCKUP_TEMPLATES = { + "shirt": { + "template": "shirt-template.png", + "design_box": (262, 230, 500, 450), # x, y, w, h — chest area on black tee + "blend": "screen", # screen blend for light designs on dark fabric + }, + "sticker": { + "template": "sticker-template.png", + "design_box": (270, 210, 470, 530), # inside the white sticker area + "blend": "paste", + }, + "print": { + "template": "print-template.png", + "design_box": (225, 225, 575, 500), # inside the black frame + "blend": "paste", + }, +} + +# Map mockup type → matching product types from metadata +_TYPE_MAP = { + "shirt": ("shirt", "tshirt", "tee", "hoodie"), + "sticker": ("sticker",), + "print": ("print",), +} + +# Cache generated mockups in memory: (slug, product_type) → PNG bytes +_mockup_cache: dict[tuple[str, str], bytes] = {} + + +@router.get("", response_model=list[Design]) +async def list_designs( + status: str = "active", + category: str | None = None, + space: str | None = None, +): + """List all designs.""" + designs = await design_service.list_designs(status=status, category=category, space=space) + 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", + }, + ) + + +@router.get("/{slug}/mockup") +async def get_design_mockup(slug: str, type: str = "shirt", fresh: bool = False): + """Serve the design composited onto a product mockup template. + + For Printful-provider designs: fetches photorealistic mockup from + Printful's mockup generator API (cached after first generation). + For other designs: composites with Pillow using local templates. + + Query params: + type: Product type — "shirt", "sticker", or "print" (default: shirt) + fresh: If true, bypass cache and regenerate mockup + """ + cache_key = (slug, type) + if not fresh and cache_key in _mockup_cache: + return StreamingResponse( + io.BytesIO(_mockup_cache[cache_key]), + media_type="image/png", + headers={"Cache-Control": "public, max-age=86400"}, + ) + + # Load design to check provider + design = await design_service.get_design(slug) + if not design: + raise HTTPException(status_code=404, detail="Design not found") + + # Find a Printful-provider product matching the requested mockup type + printful_product = None + accepted_types = _TYPE_MAP.get(type, (type,)) + for p in design.products: + if p.provider == "printful" and p.type in accepted_types: + printful_product = p + break + + # Try Printful mockup API for Printful-provider designs + if printful_product and settings.printful_api_token: + png_bytes = await _get_printful_mockup(slug, printful_product) + if png_bytes: + _mockup_cache[cache_key] = png_bytes + return StreamingResponse( + io.BytesIO(png_bytes), + media_type="image/png", + headers={"Cache-Control": "public, max-age=86400"}, + ) + + # Fallback: Pillow compositing with local templates + return await _pillow_mockup(slug, type) + + +async def _get_printful_mockup(slug: str, product) -> bytes | None: + """Fetch mockup from Printful API. Returns PNG bytes or None.""" + from app.pod.printful_client import PrintfulClient + + printful = PrintfulClient() + if not printful.enabled: + return None + + try: + product_id = int(product.sku) + + # Get first variant for mockup preview + variants = await printful.get_catalog_variants(product_id) + if not variants: + logger.warning(f"No Printful variants for product {product_id}") + return None + variant_ids = [variants[0]["id"]] + + # Public image URL for Printful to download + image_url = f"{settings.public_url}/api/designs/{slug}/image" + + # Generate mockup (blocks up to ~60s on first call) + mockups = await printful.generate_mockup_and_wait( + product_id=product_id, + variant_ids=variant_ids, + image_url=image_url, + placement="front", + technique="dtg", + ) + + if not mockups: + return None + + # v2 response: catalog_variant_mockups[] → .mockups[] → .mockup_url + mockup_url = None + for variant_mockup in mockups: + for mockup in variant_mockup.get("mockups", []): + mockup_url = mockup.get("mockup_url") or mockup.get("url") + if mockup_url: + break + if mockup_url: + break + + if not mockup_url: + logger.warning(f"No mockup URL in Printful response for {slug}") + return None + + # Download the mockup image + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.get(mockup_url) + resp.raise_for_status() + return resp.content + + except Exception as e: + logger.warning(f"Printful mockup failed for {slug}: {e}") + return None + + +async def _pillow_mockup(slug: str, type: str) -> StreamingResponse: + """Generate photorealistic mockup using Pillow compositing.""" + from PIL import ImageChops + + template_config = MOCKUP_TEMPLATES.get(type) + if not template_config: + raise HTTPException(status_code=400, detail=f"Unknown product type: {type}") + + 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="Design image not found") + + # Load template from frontend/public/mockups/ or /app/mockups/ (Docker mount) + template_dir = Path(__file__).resolve().parents[3] / "frontend" / "public" / "mockups" + template_path = template_dir / template_config["template"] + if not template_path.exists(): + template_path = Path("/app/mockups") / template_config["template"] + if not template_path.exists(): + raise HTTPException(status_code=404, detail="Mockup template not found") + + # Load images + template_img = Image.open(str(template_path)).convert("RGB") + design_img = Image.open(image_path).convert("RGBA") + + # Start with the photorealistic template as the base + canvas = template_img.copy() + + # Scale design to fit bounding box while maintaining aspect ratio + bx, by, bw, bh = template_config["design_box"] + scale = min(bw / design_img.width, bh / design_img.height) + dw = int(design_img.width * scale) + dh = int(design_img.height * scale) + dx = bx + (bw - dw) // 2 + dy = by + (bh - dh) // 2 + + design_resized = design_img.resize((dw, dh), Image.LANCZOS) + + blend_mode = template_config.get("blend", "paste") + + if blend_mode == "screen": + # Screen blend for light designs on dark fabric. + # We use a brightness-based mask so only non-dark pixels from + # the design show through, preventing a visible dark rectangle + # when the design has its own dark background. + design_rgb = design_resized.convert("RGB") + + # Extract the region under the design + region = canvas.crop((dx, dy, dx + dw, dy + dh)) + + # Screen blend the design onto the fabric region + blended = ImageChops.screen(region, design_rgb) + + # Create a luminance mask from the design — only bright pixels blend in. + # This prevents the design's dark background from creating a visible box. + lum = design_rgb.convert("L") + # Boost contrast so only clearly visible parts of the design show + lum = lum.point(lambda p: min(255, int(p * 1.5))) + + # Composite: use luminance as mask (bright pixels = show blended, dark = keep original) + result = Image.composite(blended, region, lum) + canvas.paste(result, (dx, dy)) + else: + # Direct paste — for stickers/prints where design goes on a light surface + if design_resized.mode == "RGBA": + canvas.paste(design_resized, (dx, dy), design_resized) + else: + canvas.paste(design_resized, (dx, dy)) + + # Export to high-quality PNG + buf = io.BytesIO() + canvas.save(buf, format="PNG", optimize=True) + png_bytes = buf.getvalue() + + # Cache the result + cache_key = (slug, type) + _mockup_cache[cache_key] = png_bytes + + return StreamingResponse( + io.BytesIO(png_bytes), + media_type="image/png", + headers={"Cache-Control": "public, max-age=86400"}, + ) diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000..9630460 --- /dev/null +++ b/backend/app/api/health.py @@ -0,0 +1,21 @@ +"""Health check endpoint.""" + +from fastapi import APIRouter + +from app.config import get_settings +from app.services.flow_service import FlowService + +router = APIRouter() +settings = get_settings() + + +@router.get("/health") +async def health_check(): + """Health check endpoint.""" + flow_service = FlowService() + return { + "status": "healthy", + "payment_provider": "mollie", + "flow_enabled": flow_service.enabled, + "flow_revenue_split": settings.flow_revenue_split if flow_service.enabled else None, + } diff --git a/backend/app/api/orders.py b/backend/app/api/orders.py new file mode 100644 index 0000000..8ebf3dc --- /dev/null +++ b/backend/app/api/orders.py @@ -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} diff --git a/backend/app/api/products.py b/backend/app/api/products.py new file mode 100644 index 0000000..b29eb36 --- /dev/null +++ b/backend/app/api/products.py @@ -0,0 +1,33 @@ +"""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, + space: str | None = None, +): + """List all products (designs with variants flattened for storefront).""" + products = await design_service.list_products( + category=category, + product_type=product_type, + space=space, + ) + return products + + +@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 diff --git a/backend/app/api/spaces.py b/backend/app/api/spaces.py new file mode 100644 index 0000000..382fa03 --- /dev/null +++ b/backend/app/api/spaces.py @@ -0,0 +1,23 @@ +"""Spaces API endpoints.""" + +from fastapi import APIRouter, HTTPException + +from app.services.space_service import SpaceService, Space + +router = APIRouter() +space_service = SpaceService() + + +@router.get("", response_model=list[Space]) +async def list_spaces(): + """List all available spaces.""" + return space_service.list_spaces() + + +@router.get("/{space_id}", response_model=Space) +async def get_space(space_id: str): + """Get a specific space by ID.""" + space = space_service.get_space(space_id) + if not space: + raise HTTPException(status_code=404, detail="Space not found") + return space diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py new file mode 100644 index 0000000..b233eb7 --- /dev/null +++ b/backend/app/api/upload.py @@ -0,0 +1,145 @@ +"""Design upload API — users upload their own artwork.""" + +import io +import re +import uuid +from datetime import date +from pathlib import Path + +from fastapi import APIRouter, Form, HTTPException, UploadFile +from PIL import Image +from pydantic import BaseModel + +from app.config import get_settings +from app.api.designs import design_service + +router = APIRouter() +settings = get_settings() + +ALLOWED_TYPES = {"image/png", "image/jpeg", "image/webp"} +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB +MIN_DIMENSION = 500 + + +class UploadResponse(BaseModel): + slug: str + name: str + image_url: str + status: str + products: list[dict] + + +def slugify(text: str) -> str: + 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("/upload", response_model=UploadResponse) +async def upload_design( + file: UploadFile, + name: str = Form(...), + space: str = Form("default"), + tags: str = Form(""), +): + """Upload a custom design image.""" + + # Validate content type + if file.content_type not in ALLOWED_TYPES: + raise HTTPException(400, "Only PNG, JPEG, and WebP files are accepted") + + # Read file and check size + contents = await file.read() + if len(contents) > MAX_FILE_SIZE: + raise HTTPException(400, "File size must be under 10 MB") + + # Open with Pillow and validate dimensions + try: + img = Image.open(io.BytesIO(contents)) + except Exception: + raise HTTPException(400, "Could not read image file") + + if img.width < MIN_DIMENSION or img.height < MIN_DIMENSION: + raise HTTPException(400, f"Image must be at least {MIN_DIMENSION}x{MIN_DIMENSION} pixels") + + # Create slug + slug = slugify(name) + if not slug: + slug = f"upload-{uuid.uuid4().hex[:8]}" + + # Check for existing design + design_dir = settings.designs_dir / "uploads" / slug + if design_dir.exists(): + slug = f"{slug}-{uuid.uuid4().hex[:6]}" + design_dir = settings.designs_dir / "uploads" / slug + + # Save image as PNG + design_dir.mkdir(parents=True, exist_ok=True) + img = img.convert("RGBA") + image_path = design_dir / f"{slug}.png" + img.save(str(image_path), "PNG") + + # Build metadata + safe_name = name.replace('"', '\\"') + tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else ["custom", "upload"] + tags_str = ", ".join(tag_list) + space_field = space if space != "default" else "all" + + metadata_content = f"""name: "{safe_name}" +slug: {slug} +description: "Custom uploaded design" +tags: [{tags_str}] +space: {space_field} +category: uploads +created: "{date.today().isoformat()}" +author: user-upload + +source: + file: {slug}.png + format: png + dimensions: + width: {img.width} + height: {img.height} + dpi: 300 + color_profile: sRGB + +products: + - type: sticker + provider: prodigi + sku: GLOBAL-STI-KIS-3X3 + variants: [matte, gloss] + retail_price: 3.50 + - type: shirt + provider: printful + sku: "71" + variants: [S, M, L, XL, 2XL] + retail_price: 29.99 + - type: print + provider: prodigi + sku: GLOBAL-FAP-A4 + variants: [matte, lustre] + retail_price: 12.99 + +status: draft +""" + metadata_path = design_dir / "metadata.yaml" + metadata_path.write_text(metadata_content) + + # Clear design cache so the new upload is discoverable + design_service.clear_cache() + + products = [ + {"type": "sticker", "price": 3.50}, + {"type": "shirt", "price": 29.99}, + {"type": "print", "price": 12.99}, + ] + + return UploadResponse( + slug=slug, + name=name, + image_url=f"/api/designs/{slug}/image", + status="draft", + products=products, + ) diff --git a/backend/app/api/webhooks.py b/backend/app/api/webhooks.py new file mode 100644 index 0000000..8006740 --- /dev/null +++ b/backend/app/api/webhooks.py @@ -0,0 +1,95 @@ +"""Webhook endpoints for Mollie and POD providers.""" + +from fastapi import APIRouter, Request, HTTPException, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.services.mollie_service import MollieService +from app.services.order_service import OrderService + +router = APIRouter() + + +def get_mollie_service() -> MollieService: + return MollieService() + + +def get_order_service(db: AsyncSession = Depends(get_db)) -> OrderService: + return OrderService(db) + + +@router.post("/mollie") +async def mollie_webhook( + request: Request, + mollie_service: MollieService = Depends(get_mollie_service), + order_service: OrderService = Depends(get_order_service), +): + """Handle Mollie webhook events. + + Mollie sends a POST with form data containing just the payment ID. + We then fetch the full payment details from Mollie's API to verify status. + """ + form = await request.form() + payment_id = form.get("id") + + if not payment_id: + raise HTTPException(status_code=400, detail="Missing payment id") + + # Fetch payment from Mollie API (this IS the verification — no signature needed) + payment = await mollie_service.get_payment(payment_id) + + status = payment.get("status") + if status == "paid": + await order_service.handle_successful_payment(payment) + elif status in ("failed", "canceled", "expired"): + # Log but no action needed + print(f"Mollie payment {payment_id} status: {status}") + + 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"} diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..8c9498a --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,86 @@ +"""Application configuration.""" + +from functools import lru_cache +from pathlib import Path + +from pydantic import AliasChoices, Field +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" + + # Mollie + mollie_api_key: str = "" + + # POD Providers + prodigi_api_key: str = "" + printful_api_token: str = "" + printful_store_id: str = "" + pod_sandbox_mode: bool = True + + # Flow Service (TBFF revenue split → bonding curve) + flow_service_url: str = "" + flow_id: str = "" + flow_funnel_id: str = "" + flow_revenue_split: float = 0.5 # fraction of margin routed to flow (0.0-1.0) + + # Auth + jwt_secret: str = "dev-secret-change-in-production" + jwt_algorithm: str = "HS256" + jwt_expire_hours: int = 24 + + # Email (SMTP via Mailcow) + smtp_host: str = "mail.rmail.online" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = Field(default="", validation_alias=AliasChoices("smtp_password", "SMTP_PASSWORD", "SMTP_PASS")) + smtp_from_email: str = "noreply@rswag.online" + smtp_from_name: str = "rSwag" + + # CORS + cors_origins: str = "http://localhost:3000" + + # Paths + designs_path: str = "/app/designs" + config_path: str = "/app/config" + spaces_path: str = "/app/spaces" + + # App + app_name: str = "rSwag" + public_url: str = "https://rswag.online" + 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 spaces_dir(self) -> Path: + return Path(self.spaces_path) + + @property + def cors_origins_list(self) -> list[str]: + return [origin.strip() for origin in self.cors_origins.split(",")] + + +@lru_cache +def get_settings() -> Settings: + """Get cached settings instance.""" + return Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..ede7747 --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..96bb77e --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,65 @@ +"""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, upload, spaces +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 - allow all rswag.online subdomains + configured origins +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_origin_regex=r"https?://(([\w-]+\.)?rswag\.online|fungiswag\.jeffemmett\.com)", + 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(upload.router, prefix="/api/design", tags=["upload"]) +app.include_router(spaces.router, prefix="/api/spaces", tags=["spaces"]) +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", + } diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e5f7844 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/admin.py b/backend/app/models/admin.py new file mode 100644 index 0000000..cf856cc --- /dev/null +++ b/backend/app/models/admin.py @@ -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) diff --git a/backend/app/models/cart.py b/backend/app/models/cart.py new file mode 100644 index 0000000..6436308 --- /dev/null +++ b/backend/app/models/cart.py @@ -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") diff --git a/backend/app/models/customer.py b/backend/app/models/customer.py new file mode 100644 index 0000000..10a1578 --- /dev/null +++ b/backend/app/models/customer.py @@ -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) + external_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") diff --git a/backend/app/models/order.py b/backend/app/models/order.py new file mode 100644 index 0000000..c8cbcf3 --- /dev/null +++ b/backend/app/models/order.py @@ -0,0 +1,106 @@ +"""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 + ) + + # Payment provider info (provider-agnostic) + payment_provider: Mapped[str | None] = mapped_column(String(50), nullable=True) + payment_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + payment_method: Mapped[str | None] = mapped_column(String(100), 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") diff --git a/backend/app/models/product.py b/backend/app/models/product.py new file mode 100644 index 0000000..c1f4c6f --- /dev/null +++ b/backend/app/models/product.py @@ -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 + ) diff --git a/backend/app/pod/__init__.py b/backend/app/pod/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/pod/printful_client.py b/backend/app/pod/printful_client.py new file mode 100644 index 0000000..e9031b2 --- /dev/null +++ b/backend/app/pod/printful_client.py @@ -0,0 +1,273 @@ +"""Printful Print-on-Demand API client (v2). + +Handles catalog lookup, mockup generation, and order submission. +API v2 docs: https://developers.printful.com/docs/v2-beta/ +Rate limit: 120 req/60s (leaky bucket), lower for mockups. +""" + +import asyncio +import logging +import time + +import httpx + +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +BASE_URL = "https://api.printful.com/v2" + +# In-memory cache for catalog variants: {product_id: {"variants": [...], "ts": float}} +_variant_cache: dict[int, dict] = {} +_VARIANT_CACHE_TTL = 86400 # 24 hours + + +class PrintfulClient: + """Client for the Printful v2 API.""" + + def __init__(self): + self.api_token = settings.printful_api_token + self.sandbox = settings.pod_sandbox_mode + self.enabled = bool(self.api_token) + + @property + def _headers(self) -> dict[str, str]: + headers = { + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json", + } + if settings.printful_store_id: + headers["X-PF-Store-Id"] = settings.printful_store_id + return headers + + # ── Catalog ── + + async def get_catalog_variants(self, product_id: int) -> list[dict]: + """Get variants for a catalog product (cached 24h). + + Each variant has: id (int), size (str), color (str), color_code (str). + """ + cached = _variant_cache.get(product_id) + if cached and (time.time() - cached["ts"]) < _VARIANT_CACHE_TTL: + return cached["variants"] + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get( + f"{BASE_URL}/catalog-products/{product_id}/catalog-variants", + headers=self._headers, + ) + resp.raise_for_status() + variants = resp.json().get("data", []) + + _variant_cache[product_id] = {"variants": variants, "ts": time.time()} + return variants + + async def resolve_variant_id( + self, + product_id: int, + size: str, + color: str = "Black", + ) -> int | None: + """Resolve (product_id, size, color) → Printful catalog_variant_id. + + Our metadata uses SKU "71" + variants ["S","M","L",...]. + Printful orders require numeric catalog_variant_id. + """ + variants = await self.get_catalog_variants(product_id) + + # Try exact match on size + color + for v in variants: + if ( + v.get("size", "").upper() == size.upper() + and color.lower() in v.get("color", "").lower() + ): + return v.get("id") + + # Fallback: match size only + for v in variants: + if v.get("size", "").upper() == size.upper(): + return v.get("id") + + return None + + # ── Mockup Generation ── + + async def create_mockup_task( + self, + product_id: int, + variant_ids: list[int], + image_url: str, + placement: str = "front", + technique: str = "dtg", + ) -> str: + """Start async mockup generation task (v2 format). + + Returns task_id to poll with get_mockup_task(). + + v2 payload uses products array with catalog source, and layers + inside placements instead of flat image_url. + """ + payload = { + "products": [ + { + "source": "catalog", + "catalog_product_id": product_id, + "catalog_variant_ids": variant_ids, + "placements": [ + { + "placement": placement, + "technique": technique, + "layers": [ + { + "type": "file", + "url": image_url, + } + ], + } + ], + } + ], + "format": "png", + } + + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{BASE_URL}/mockup-tasks", + headers=self._headers, + json=payload, + ) + resp.raise_for_status() + # v2 returns {"data": [{ ... }]} — data is a list + raw_data = resp.json().get("data", []) + data = raw_data[0] if isinstance(raw_data, list) and raw_data else raw_data + task_id = data.get("id") or data.get("task_key") or data.get("task_id") + logger.info(f"Printful mockup task created: {task_id}") + return str(task_id) + + async def get_mockup_task(self, task_id: str) -> dict: + """Poll mockup task status (v2 format). + + Returns dict with "status" (pending/completed/failed) and + "catalog_variant_mockups" list when completed. + """ + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get( + f"{BASE_URL}/mockup-tasks", + headers=self._headers, + params={"id": task_id}, + ) + resp.raise_for_status() + # v2 returns {"data": [{ ... }]} — data is a list + raw_data = resp.json().get("data", []) + if isinstance(raw_data, list) and raw_data: + return raw_data[0] + return raw_data if isinstance(raw_data, dict) else {} + + async def generate_mockup_and_wait( + self, + product_id: int, + variant_ids: list[int], + image_url: str, + placement: str = "front", + technique: str = "dtg", + max_polls: int = 20, + poll_interval: float = 3.0, + ) -> list[dict] | None: + """Create mockup task and poll until complete. + + Returns list of mockup dicts with "mockup_url" fields, + or None on failure/timeout. + """ + task_id = await self.create_mockup_task( + product_id, variant_ids, image_url, placement, technique + ) + + for _ in range(max_polls): + await asyncio.sleep(poll_interval) + result = await self.get_mockup_task(task_id) + status = result.get("status", "") + + if status == "completed": + return ( + result.get("mockups", []) + or result.get("catalog_variant_mockups", []) + ) + elif status == "failed": + reasons = result.get("failure_reasons", []) + logger.error(f"Mockup task {task_id} failed: {reasons}") + return None + + logger.warning(f"Mockup task {task_id} timed out after {max_polls} polls") + return None + + # ── Orders ── + + async def create_order( + self, + items: list[dict], + recipient: dict, + ) -> dict: + """Create a fulfillment order. + + Args: + items: List of dicts with: + - catalog_variant_id (int) + - quantity (int) + - image_url (str) — public URL to design + - placement (str, default "front") + recipient: dict with name, address1, city, state_code, + country_code, zip, email (optional) + """ + if not self.enabled: + raise ValueError("Printful API token not configured") + + order_items = [] + for item in items: + order_items.append({ + "source": "catalog", + "catalog_variant_id": item["catalog_variant_id"], + "quantity": item.get("quantity", 1), + "placements": [ + { + "placement": item.get("placement", "front"), + "technique": "dtg", + "layers": [ + { + "type": "file", + "url": item["image_url"], + } + ], + } + ], + }) + + payload = { + "recipient": recipient, + "items": order_items, + } + + # Sandbox mode: create as draft (not sent to production) + if self.sandbox: + payload["draft"] = True + + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{BASE_URL}/orders", + headers=self._headers, + json=payload, + ) + resp.raise_for_status() + result = resp.json().get("data", {}) + logger.info(f"Printful order created: {result.get('id')}") + return result + + async def get_order(self, order_id: str) -> dict: + """Get order details by Printful order ID.""" + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get( + f"{BASE_URL}/orders/{order_id}", + headers=self._headers, + ) + resp.raise_for_status() + return resp.json().get("data", {}) diff --git a/backend/app/pod/prodigi_client.py b/backend/app/pod/prodigi_client.py new file mode 100644 index 0000000..46ad01a --- /dev/null +++ b/backend/app/pod/prodigi_client.py @@ -0,0 +1,129 @@ +"""Prodigi Print-on-Demand API client (v4). + +Handles order submission, product specs, and quotes. +Sandbox: https://api.sandbox.prodigi.com/v4.0/ +Production: https://api.prodigi.com/v4.0/ +""" + +import logging + +import httpx + +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +SANDBOX_URL = "https://api.sandbox.prodigi.com/v4.0" +PRODUCTION_URL = "https://api.prodigi.com/v4.0" + + +class ProdigiClient: + """Client for the Prodigi v4 Print API.""" + + def __init__(self): + self.api_key = settings.prodigi_api_key + self.base_url = SANDBOX_URL if settings.pod_sandbox_mode else PRODUCTION_URL + self.enabled = bool(self.api_key) + + @property + def _headers(self) -> dict: + return { + "X-API-Key": self.api_key, + "Content-Type": "application/json", + } + + async def create_order( + self, + items: list[dict], + recipient: dict, + shipping_method: str = "Budget", + metadata: dict | None = None, + ) -> dict: + """Create a Prodigi print order. + + Args: + items: List of items, each with: + - sku: Prodigi SKU (e.g., "GLOBAL-STI-KIS-4X4") + - copies: Number of copies + - sizing: "fillPrintArea" | "fitPrintArea" | "stretchToPrintArea" + - assets: [{"printArea": "default", "url": "https://..."}] + recipient: Shipping address with: + - name: Recipient name + - email: Email (optional) + - address: {line1, line2, townOrCity, stateOrCounty, postalOrZipCode, countryCode} + shipping_method: "Budget" | "Standard" | "Express" + metadata: Optional key/value metadata + """ + if not self.enabled: + raise ValueError("Prodigi API key not configured") + + payload = { + "shippingMethod": shipping_method, + "recipient": recipient, + "items": items, + } + if metadata: + payload["metadata"] = metadata + + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{self.base_url}/Orders", + headers=self._headers, + json=payload, + ) + resp.raise_for_status() + result = resp.json() + logger.info(f"Prodigi order created: {result.get('id')}") + return result + + async def get_order(self, order_id: str) -> dict: + """Get order details by Prodigi order ID.""" + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get( + f"{self.base_url}/Orders/{order_id}", + headers=self._headers, + ) + resp.raise_for_status() + return resp.json() + + async def get_product(self, sku: str) -> dict: + """Get product specifications (dimensions, print areas, etc.).""" + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get( + f"{self.base_url}/products/{sku}", + headers=self._headers, + ) + resp.raise_for_status() + return resp.json() + + async def get_quote( + self, + items: list[dict], + shipping_method: str = "Budget", + destination_country: str = "US", + ) -> dict: + """Get a pricing quote before ordering. + + Args: + items: List with sku, copies, sizing, assets + shipping_method: Shipping tier + destination_country: 2-letter country code + """ + payload = { + "shippingMethod": shipping_method, + "destinationCountryCode": destination_country, + "items": [ + {"sku": item["sku"], "copies": item.get("copies", 1)} + for item in items + ], + } + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post( + f"{self.base_url}/quotes", + headers=self._headers, + json=payload, + ) + resp.raise_for_status() + return resp.json() diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..8e92624 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -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", +] diff --git a/backend/app/schemas/cart.py b/backend/app/schemas/cart.py new file mode 100644 index 0000000..c7af0c6 --- /dev/null +++ b/backend/app/schemas/cart.py @@ -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 diff --git a/backend/app/schemas/design.py b/backend/app/schemas/design.py new file mode 100644 index 0000000..caf129f --- /dev/null +++ b/backend/app/schemas/design.py @@ -0,0 +1,43 @@ +"""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] = [] + space: str = "default" + status: str = "draft" + image_url: str = "" + + class Config: + from_attributes = True diff --git a/backend/app/schemas/order.py b/backend/app/schemas/order.py new file mode 100644 index 0000000..28731ce --- /dev/null +++ b/backend/app/schemas/order.py @@ -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 diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py new file mode 100644 index 0000000..b3399a6 --- /dev/null +++ b/backend/app/schemas/product.py @@ -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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..de2060f --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""Business logic services.""" diff --git a/backend/app/services/analytics_service.py b/backend/app/services/analytics_service.py new file mode 100644 index 0000000..ac4210b --- /dev/null +++ b/backend/app/services/analytics_service.py @@ -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 diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..0f4dcd6 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -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 diff --git a/backend/app/services/cart_service.py b/backend/app/services/cart_service.py new file mode 100644 index 0000000..4132005 --- /dev/null +++ b/backend/app/services/cart_service.py @@ -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, + ) diff --git a/backend/app/services/design_service.py b/backend/app/services/design_service.py new file mode 100644 index 0000000..65e16cc --- /dev/null +++ b/backend/app/services/design_service.py @@ -0,0 +1,300 @@ +"""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, + space: 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: + # Filter by space if specified + if space and space != "all": + if design.space != space and design.space != "all": + continue + 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, + space=metadata.get("space", "default"), + 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, + space: str | None = None, + ) -> list[Product]: + """List all products (designs formatted for storefront).""" + designs = await self.list_designs(status="active", category=category, space=space) + 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() diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..609bc2b --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,249 @@ +"""Email service for order confirmations and shipping notifications.""" + +import logging +import ssl +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import aiosmtplib + +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + + +class EmailService: + """Async email sender via SMTP (Mailcow).""" + + @property + def enabled(self) -> bool: + return bool(settings.smtp_user and settings.smtp_password) + + async def send_order_confirmation( + self, + *, + to_email: str, + to_name: str | None, + order_id: str, + items: list[dict], + total: float, + currency: str = "USD", + ): + """Send order confirmation email after successful payment.""" + if not self.enabled: + logger.info("SMTP not configured, skipping order confirmation email") + return + + subject = f"Order Confirmed — {settings.app_name} #{order_id[:8]}" + html = self._render_confirmation_html( + to_name=to_name, + order_id=order_id, + items=items, + total=total, + currency=currency, + ) + + await self._send(to_email=to_email, subject=subject, html=html) + + async def send_shipping_notification( + self, + *, + to_email: str, + to_name: str | None, + order_id: str, + tracking_number: str | None = None, + tracking_url: str | None = None, + ): + """Send shipping notification when POD provider ships the order.""" + if not self.enabled: + return + + subject = f"Your Order Has Shipped — {settings.app_name}" + html = self._render_shipping_html( + to_name=to_name, + order_id=order_id, + tracking_number=tracking_number, + tracking_url=tracking_url, + ) + + await self._send(to_email=to_email, subject=subject, html=html) + + async def _send(self, *, to_email: str, subject: str, html: str): + """Send an HTML email via SMTP.""" + msg = MIMEMultipart("alternative") + msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_from_email}>" + msg["To"] = to_email + msg["Subject"] = subject + + # Plain-text fallback + plain = html.replace("
", "\n").replace("

", "\n") + # Strip remaining tags + import re + plain = re.sub(r"<[^>]+>", "", plain) + msg.attach(MIMEText(plain, "plain")) + msg.attach(MIMEText(html, "html")) + + tls_context = ssl.create_default_context() + tls_context.check_hostname = False + tls_context.verify_mode = ssl.CERT_NONE # self-signed cert on Mailcow + + try: + await aiosmtplib.send( + msg, + hostname=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_user, + password=settings.smtp_password, + start_tls=True, + tls_context=tls_context, + ) + logger.info(f"Sent email to {to_email}: {subject}") + except Exception as e: + logger.error(f"Failed to send email to {to_email}: {e}") + + def _render_confirmation_html( + self, + *, + to_name: str | None, + order_id: str, + items: list[dict], + total: float, + currency: str, + ) -> str: + greeting = f"Hi {to_name}," if to_name else "Hi there," + order_url = f"{settings.public_url}/checkout/success?order_id={order_id}" + currency_symbol = "$" if currency == "USD" else currency + " " + + items_html = "" + for item in items: + qty = item.get("quantity", 1) + name = item.get("product_name", "Item") + variant = item.get("variant", "") + price = item.get("unit_price", 0) + variant_str = f" ({variant})" if variant else "" + items_html += f""" + + {name}{variant_str} + {qty} + {currency_symbol}{price:.2f} + """ + + return f""" + + + +
+ + +
+
rSw
+

Order Confirmed

+
+ + +

{greeting}

+

+ Thank you for your order! Your items are being prepared for production. + Print-on-demand means each piece is made just for you at the nearest fulfillment center. +

+ + +
+
Order Summary
+ + + + + + + {items_html} + + + + +
ItemQtyPrice
Total{currency_symbol}{total:.2f}
+
+ + +
+ + View Order Status + +
+ + +
+
What Happens Next
+
    +
  1. Your design is sent to the nearest print facility
  2. +
  3. Each item is printed on demand — just for you
  4. +
  5. You'll get a shipping email with tracking info
  6. +
  7. Revenue from your purchase supports the community
  8. +
+
+ + +
+

Order #{order_id[:8]}

+

{settings.app_name} — Community merch, on demand.

+

Part of the rStack ecosystem.

+
+ +
+ +""" + + def _render_shipping_html( + self, + *, + to_name: str | None, + order_id: str, + tracking_number: str | None, + tracking_url: str | None, + ) -> str: + greeting = f"Hi {to_name}," if to_name else "Hi there," + order_url = f"{settings.public_url}/checkout/success?order_id={order_id}" + + tracking_html = "" + if tracking_number: + track_link = tracking_url or "#" + tracking_html = f""" +
+
Tracking Number
+ {tracking_number} +
""" + + return f""" + + + +
+ + +
+
rSw
+

Your Order Has Shipped!

+
+ +

{greeting}

+

+ Great news — your order is on its way! It was printed at the nearest fulfillment center and is now heading to you. +

+ + {tracking_html} + +
+ + View Order + +
+ +
+

Order #{order_id[:8]}

+

{settings.app_name} — Community merch, on demand.

+
+ +
+ +""" diff --git a/backend/app/services/flow_service.py b/backend/app/services/flow_service.py new file mode 100644 index 0000000..d92a200 --- /dev/null +++ b/backend/app/services/flow_service.py @@ -0,0 +1,100 @@ +"""Flow service client for TBFF revenue routing. + +After a swag sale, the margin (sale price minus POD fulfillment cost) +gets deposited into a TBFF funnel via the flow-service. The flow-service +manages threshold-based distribution, and when the funnel overflows its +MAX threshold, excess funds route to the bonding curve. + +Revenue split flow: + Mollie payment → calculate margin → deposit to flow-service funnel + ↓ + TBFF thresholds + ↓ overflow + bonding curve ($MYCO) +""" + +import logging + +import httpx + +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + + +class FlowService: + """Client for the payment-infra flow-service.""" + + def __init__(self): + self.base_url = settings.flow_service_url.rstrip("/") + self.flow_id = settings.flow_id + self.funnel_id = settings.flow_funnel_id + self.enabled = bool(self.base_url and self.flow_id and self.funnel_id) + + async def deposit_revenue( + self, + amount: float, + currency: str = "USD", + order_id: str | None = None, + description: str | None = None, + ) -> dict | None: + """Deposit revenue margin into the TBFF funnel. + + Args: + amount: Fiat amount to deposit (post-split margin) + currency: Currency code (default USD) + order_id: rSwag order ID for traceability + description: Human-readable note + """ + if not self.enabled: + logger.info("Flow service not configured, skipping revenue deposit") + return None + + if amount <= 0: + return None + + payload = { + "funnelId": self.funnel_id, + "amount": round(amount, 2), + "currency": currency, + "source": "rswag", + "metadata": {}, + } + if order_id: + payload["metadata"]["order_id"] = order_id + if description: + payload["metadata"]["description"] = description + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + f"{self.base_url}/api/flows/{self.flow_id}/deposit", + json=payload, + ) + resp.raise_for_status() + result = resp.json() + logger.info( + f"Revenue deposited to flow: ${amount:.2f} {currency} " + f"(order={order_id})" + ) + return result + except httpx.HTTPError as e: + logger.error(f"Failed to deposit revenue to flow service: {e}") + return None + + async def get_flow_stats(self) -> dict | None: + """Get current flow stats (balance, thresholds, etc.).""" + if not self.enabled: + return None + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get( + f"{self.base_url}/api/flows/{self.flow_id}", + ) + resp.raise_for_status() + return resp.json() + except httpx.HTTPError as e: + logger.error(f"Failed to get flow stats: {e}") + return None diff --git a/backend/app/services/mollie_service.py b/backend/app/services/mollie_service.py new file mode 100644 index 0000000..9a63c99 --- /dev/null +++ b/backend/app/services/mollie_service.py @@ -0,0 +1,79 @@ +"""Mollie payment service.""" + +from mollie.api.client import Client + +from app.config import get_settings +from app.schemas.cart import CartResponse + +settings = get_settings() + + +class MollieService: + """Service for Mollie payment operations.""" + + def __init__(self): + self.client = Client() + if settings.mollie_api_key: + self.client.set_api_key(settings.mollie_api_key) + + async def create_payment( + self, + cart: CartResponse, + success_url: str, + cancel_url: str, + webhook_url: str, + ) -> dict: + """Create a Mollie payment. + + Mollie uses a redirect flow: create payment → redirect to hosted page → + webhook callback on completion → redirect to success URL. + """ + # Build description from cart items + item_names = [item.product_name for item in cart.items] + description = f"rSwag order: {', '.join(item_names[:3])}" + if len(item_names) > 3: + description += f" (+{len(item_names) - 3} more)" + + # Calculate total from cart + total = sum(item.unit_price * item.quantity for item in cart.items) + + payment = self.client.payments.create({ + "amount": { + "currency": "USD", + "value": f"{total:.2f}", + }, + "description": description, + "redirectUrl": f"{success_url}?payment_id={{paymentId}}", + "cancelUrl": cancel_url, + "webhookUrl": webhook_url, + "metadata": { + "cart_id": str(cart.id), + }, + }) + + return { + "url": payment["_links"]["checkout"]["href"], + "payment_id": payment["id"], + } + + async def get_payment(self, payment_id: str) -> dict: + """Get Mollie payment details.""" + payment = self.client.payments.get(payment_id) + return payment + + async def create_refund( + self, + payment_id: str, + amount: float | None = None, + currency: str = "USD", + ) -> dict: + """Create a refund for a Mollie payment.""" + payment = self.client.payments.get(payment_id) + refund_data = {} + if amount is not None: + refund_data["amount"] = { + "currency": currency, + "value": f"{amount:.2f}", + } + refund = self.client.payment_refunds.with_parent_id(payment_id).create(refund_data) + return refund diff --git a/backend/app/services/order_service.py b/backend/app/services/order_service.py new file mode 100644 index 0000000..3cf3d21 --- /dev/null +++ b/backend/app/services/order_service.py @@ -0,0 +1,495 @@ +"""Order management service.""" + +import logging +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.config import get_settings +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 +from app.services.flow_service import FlowService +from app.pod.printful_client import PrintfulClient +from app.pod.prodigi_client import ProdigiClient +from app.services.design_service import DesignService +from app.services.email_service import EmailService + +logger = logging.getLogger(__name__) +settings = get_settings() + + +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, payment: dict): + """Handle successful Mollie payment. + + Called by the Mollie webhook when payment status is 'paid'. + Mollie payment object contains metadata with cart_id. + """ + cart_id = payment.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 + + # Extract amount from Mollie payment + amount = payment.get("amount", {}) + total = float(amount.get("value", "0")) + currency = amount.get("currency", "USD") + + # Create order + order = Order( + payment_provider="mollie", + payment_id=payment.get("id"), + payment_method=payment.get("method"), + status=OrderStatus.PAID.value, + shipping_email=payment.get("metadata", {}).get("email", ""), + subtotal=total, + total=total, + currency=currency, + 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() + + # Route revenue margin to TBFF flow → bonding curve + await self._deposit_revenue_to_flow(order) + + # Submit to POD providers + await self._submit_to_pod(order) + + # Send confirmation email (non-blocking — don't fail the order if email fails) + try: + email_service = EmailService() + await email_service.send_order_confirmation( + to_email=order.shipping_email or "", + to_name=order.shipping_name, + order_id=str(order.id), + items=[ + { + "product_name": item.product_name, + "variant": item.variant, + "quantity": item.quantity, + "unit_price": float(item.unit_price), + } + for item in order.items + ], + total=float(order.total), + currency=order.currency or "USD", + ) + except Exception as e: + logger.error(f"Failed to send confirmation email for order {order.id}: {e}") + + 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() + + # Send shipping notification when items ship + if status in ("shipped", "in_transit") and tracking_number: + await self._send_shipping_email( + pod_provider=pod_provider, + pod_order_id=pod_order_id, + tracking_number=tracking_number, + tracking_url=tracking_url, + ) + + async def _send_shipping_email( + self, + pod_provider: str, + pod_order_id: str, + tracking_number: str | None, + tracking_url: str | None, + ): + """Send shipping notification for an order.""" + try: + # Find the order via its items + result = await self.db.execute( + select(OrderItem) + .where( + OrderItem.pod_provider == pod_provider, + OrderItem.pod_order_id == pod_order_id, + ) + .limit(1) + ) + item = result.scalar_one_or_none() + if not item: + return + + result = await self.db.execute( + select(Order).where(Order.id == item.order_id) + ) + order = result.scalar_one_or_none() + if not order or not order.shipping_email: + return + + email_service = EmailService() + await email_service.send_shipping_notification( + to_email=order.shipping_email, + to_name=order.shipping_name, + order_id=str(order.id), + tracking_number=tracking_number, + tracking_url=tracking_url, + ) + except Exception as e: + logger.error(f"Failed to send shipping email: {e}") + + async def _submit_to_pod(self, order: Order): + """Route order items to the correct POD provider for fulfillment. + + Reads each item's design metadata to determine provider (printful/prodigi), + groups items, and submits separate orders per provider. + """ + if not order.shipping_address_line1: + logger.info(f"Order {order.id} has no shipping address, skipping POD") + return + + design_service = DesignService() + printful_items = [] + prodigi_items = [] + + for item in order.items: + image_url = f"https://fungiswag.jeffemmett.com/api/designs/{item.product_slug}/image" + + design = await design_service.get_design(item.product_slug) + provider = "prodigi" # default + product_sku = item.variant or item.product_slug + + if design and design.products: + product_config = design.products[0] + provider = product_config.provider + product_sku = product_config.sku + + if provider == "printful": + # Extract size from variant string (e.g. "71-M" → "M", or just "M") + size = item.variant or "M" + if "-" in size: + size = size.split("-", 1)[1] + + printful_items.append({ + "order_item": item, + "product_id": int(product_sku), + "size": size, + "quantity": item.quantity, + "image_url": image_url, + }) + else: + prodigi_items.append({ + "order_item": item, + "sku": item.variant or item.product_slug, + "quantity": item.quantity, + "image_url": image_url, + }) + + if printful_items: + await self._submit_to_printful(order, printful_items) + if prodigi_items: + await self._submit_to_prodigi(order, prodigi_items) + + async def _submit_to_printful(self, order: Order, items: list[dict]): + """Submit items to Printful for fulfillment.""" + printful = PrintfulClient() + if not printful.enabled: + logger.info("Printful not configured, skipping") + return + + order_items = [] + for item_data in items: + variant_id = await printful.resolve_variant_id( + product_id=item_data["product_id"], + size=item_data["size"], + ) + if not variant_id: + logger.error( + f"Could not resolve Printful variant for product " + f"{item_data['product_id']} size {item_data['size']}" + ) + continue + + order_items.append({ + "catalog_variant_id": variant_id, + "quantity": item_data["quantity"], + "image_url": item_data["image_url"], + "placement": "front_large", + }) + + if not order_items: + return + + recipient = { + "name": order.shipping_name or "", + "address1": order.shipping_address_line1 or "", + "address2": order.shipping_address_line2 or "", + "city": order.shipping_city or "", + "state_code": order.shipping_state or "", + "country_code": order.shipping_country or "", + "zip": order.shipping_postal_code or "", + "email": order.shipping_email or "", + } + + try: + result = await printful.create_order( + items=order_items, + recipient=recipient, + ) + pod_order_id = str(result.get("id", "")) + + for item_data in items: + item_data["order_item"].pod_provider = "printful" + item_data["order_item"].pod_order_id = pod_order_id + item_data["order_item"].pod_status = "submitted" + + order.status = OrderStatus.PROCESSING.value + await self.db.commit() + logger.info(f"Submitted order {order.id} to Printful: {pod_order_id}") + + except Exception as e: + logger.error(f"Failed to submit order {order.id} to Printful: {e}") + + async def _submit_to_prodigi(self, order: Order, items: list[dict]): + """Submit items to Prodigi for fulfillment.""" + prodigi = ProdigiClient() + if not prodigi.enabled: + logger.info("Prodigi not configured, skipping") + return + + prodigi_items = [] + for item_data in items: + prodigi_items.append({ + "sku": item_data["sku"], + "copies": item_data["quantity"], + "sizing": "fillPrintArea", + "assets": [{"printArea": "default", "url": item_data["image_url"]}], + }) + + recipient = { + "name": order.shipping_name or "", + "email": order.shipping_email or "", + "address": { + "line1": order.shipping_address_line1 or "", + "line2": order.shipping_address_line2 or "", + "townOrCity": order.shipping_city or "", + "stateOrCounty": order.shipping_state or "", + "postalOrZipCode": order.shipping_postal_code or "", + "countryCode": order.shipping_country or "", + }, + } + + try: + result = await prodigi.create_order( + items=prodigi_items, + recipient=recipient, + metadata={"rswag_order_id": str(order.id)}, + ) + pod_order_id = result.get("id") + + for item_data in items: + item_data["order_item"].pod_provider = "prodigi" + item_data["order_item"].pod_order_id = pod_order_id + item_data["order_item"].pod_status = "submitted" + + order.status = OrderStatus.PROCESSING.value + await self.db.commit() + logger.info(f"Submitted order {order.id} to Prodigi: {pod_order_id}") + + except Exception as e: + logger.error(f"Failed to submit order {order.id} to Prodigi: {e}") + + async def _deposit_revenue_to_flow(self, order: Order): + """Calculate margin and deposit to TBFF flow for bonding curve funding. + + Revenue split: + total sale - POD cost estimate = margin + margin × flow_revenue_split = amount deposited to flow + flow → Transak on-ramp → USDC → bonding curve → $MYCO + """ + split = settings.flow_revenue_split + if split <= 0: + return + + total = float(order.total) if order.total else 0 + if total <= 0: + return + + # Revenue split: configurable fraction of total goes to flow + # (POD costs + operational expenses kept as fiat remainder) + flow_amount = round(total * split, 2) + + flow_service = FlowService() + await flow_service.deposit_revenue( + amount=flow_amount, + currency=order.currency or "USD", + order_id=str(order.id), + description=f"rSwag sale revenue split ({split:.0%} of ${total:.2f})", + ) + + 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, + ) diff --git a/backend/app/services/space_service.py b/backend/app/services/space_service.py new file mode 100644 index 0000000..14acbaa --- /dev/null +++ b/backend/app/services/space_service.py @@ -0,0 +1,104 @@ +"""Space (tenant) service for multi-subdomain support.""" + +from pathlib import Path + +import yaml +from pydantic import BaseModel + +from app.config import get_settings + +settings = get_settings() + + +class SpaceTheme(BaseModel): + """Theme configuration for a space.""" + + primary: str = "195 80% 45%" + primary_foreground: str = "0 0% 100%" + secondary: str = "45 80% 55%" + secondary_foreground: str = "222.2 47.4% 11.2%" + background: str = "0 0% 100%" + foreground: str = "222.2 84% 4.9%" + card: str = "0 0% 100%" + card_foreground: str = "222.2 84% 4.9%" + popover: str = "0 0% 100%" + popover_foreground: str = "222.2 84% 4.9%" + muted: str = "210 40% 96.1%" + muted_foreground: str = "215.4 16.3% 46.9%" + accent: str = "210 40% 96.1%" + accent_foreground: str = "222.2 47.4% 11.2%" + destructive: str = "0 84.2% 60.2%" + destructive_foreground: str = "210 40% 98%" + border: str = "214.3 31.8% 91.4%" + input: str = "214.3 31.8% 91.4%" + ring: str = "195 80% 45%" + + +class Space(BaseModel): + """Space configuration.""" + + id: str + name: str + tagline: str = "" + description: str = "" + domain: str = "" + footer_text: str = "" + theme: SpaceTheme = SpaceTheme() + design_filter: str = "all" + logo_url: str | None = None + design_tips: list[str] = [] + + +class SpaceService: + """Service for loading and resolving spaces.""" + + def __init__(self): + self.spaces_path = Path(settings.spaces_path) + self._cache: dict[str, Space] = {} + self._loaded = False + + def _ensure_loaded(self): + if self._loaded: + return + self._load_all() + self._loaded = True + + def _load_all(self): + if not self.spaces_path.exists(): + return + for space_dir in self.spaces_path.iterdir(): + if not space_dir.is_dir(): + continue + config_path = space_dir / "space.yaml" + if not config_path.exists(): + continue + try: + with open(config_path) as f: + data = yaml.safe_load(f) + space = Space(**data) + self._cache[space.id] = space + except Exception: + continue + + def get_space(self, space_id: str) -> Space | None: + """Get a space by its ID.""" + self._ensure_loaded() + return self._cache.get(space_id) + + def get_default(self) -> Space: + """Get the default space.""" + self._ensure_loaded() + return self._cache.get( + "default", + Space(id="default", name="rSwag", domain="rswag.online"), + ) + + def list_spaces(self) -> list[Space]: + """List all spaces.""" + self._ensure_loaded() + return list(self._cache.values()) + + def clear_cache(self): + """Clear the cache to force reload.""" + self._cache.clear() + self._loaded = False diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100755 index 0000000..983dda3 --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,90 @@ +#!/bin/sh +# Infisical secret injection entrypoint (Python version) +set -e + +INFISICAL_URL="${INFISICAL_URL:-http://infisical:8080}" +INFISICAL_ENV="${INFISICAL_ENV:-prod}" +INFISICAL_PROJECT_SLUG="${INFISICAL_PROJECT_SLUG:-rswag}" + +if [ -z "$INFISICAL_CLIENT_ID" ] || [ -z "$INFISICAL_CLIENT_SECRET" ]; then + echo "[infisical] No credentials set, starting without secret injection" + exec "$@" +fi + +echo "[infisical] Fetching secrets from ${INFISICAL_PROJECT_SLUG}/${INFISICAL_ENV}..." + +EXPORTS=$(python3 -c " +import urllib.request, json, os, sys + +base = os.environ['INFISICAL_URL'] +slug = os.environ['INFISICAL_PROJECT_SLUG'] +env = os.environ['INFISICAL_ENV'] + +try: + data = json.dumps({'clientId': os.environ['INFISICAL_CLIENT_ID'], 'clientSecret': os.environ['INFISICAL_CLIENT_SECRET']}).encode() + req = urllib.request.Request(f'{base}/api/v1/auth/universal-auth/login', data=data, headers={'Content-Type': 'application/json'}) + auth = json.loads(urllib.request.urlopen(req).read()) + token = auth.get('accessToken') + if not token: + print('[infisical] Auth failed', file=sys.stderr) + sys.exit(1) + + req = urllib.request.Request(f'{base}/api/v3/secrets/raw?workspaceSlug={slug}&environment={env}&secretPath=/&recursive=true') + req.add_header('Authorization', f'Bearer {token}') + secrets = json.loads(urllib.request.urlopen(req).read()) + + if 'secrets' not in secrets: + print('[infisical] No secrets returned', file=sys.stderr) + sys.exit(1) + + for s in secrets['secrets']: + key = s['secretKey'] + val = s['secretValue'].replace(\"'\", \"'\\\\'\") + existing = os.environ.get(key, '') + if existing and existing != val: + print(f'[infisical] Keeping explicit env var for {key}', file=sys.stderr) + continue + print(f\"export {key}='{val}'\") +except Exception as e: + print(f'[infisical] Error: {e}', file=sys.stderr) + sys.exit(1) +" 2>&1) || { + echo "[infisical] WARNING: Failed to fetch secrets, starting with existing env vars" + exec "$@" +} + +if echo "$EXPORTS" | grep -q "^export "; then + COUNT=$(echo "$EXPORTS" | grep -c "^export ") + eval "$EXPORTS" + echo "[infisical] Injected ${COUNT} secrets" +else + echo "[infisical] WARNING: $EXPORTS" + echo "[infisical] Starting with existing env vars" +fi + +# Fetch SMTP config from claude-ops /mail (authoritative source for rSwag email) +SMTP_OVERRIDES=$(python3 -c " +import urllib.request, json, os, sys +base = os.environ.get('INFISICAL_URL', 'http://infisical:8080') +try: + data = json.dumps({'clientId': os.environ['INFISICAL_CLIENT_ID'], 'clientSecret': os.environ['INFISICAL_CLIENT_SECRET']}).encode() + req = urllib.request.Request(f'{base}/api/v1/auth/universal-auth/login', data=data, headers={'Content-Type': 'application/json'}) + token = json.loads(urllib.request.urlopen(req).read()).get('accessToken','') + req = urllib.request.Request(f'{base}/api/v3/secrets/raw?workspaceSlug=claude-ops&environment=prod&secretPath=/mail') + req.add_header('Authorization', f'Bearer {token}') + secrets = json.loads(urllib.request.urlopen(req).read()) + mapping = {'RSWAG_SMTP_HOST': 'SMTP_HOST', 'RSWAG_SMTP_USER': 'SMTP_USER', 'RSWAG_SMTP_PASSWORD': 'SMTP_PASSWORD'} + for s in secrets.get('secrets',[]): + env_key = mapping.get(s['secretKey']) + if env_key: + val = s['secretValue'].replace(\"'\", \"'\\\\'\") + print(f\"export {env_key}='{val}'\") +except Exception as e: + print(f'[smtp] Could not fetch from claude-ops: {e}', file=sys.stderr) +" 2>&1) || true +if echo "$SMTP_OVERRIDES" | grep -q "^export "; then + eval "$SMTP_OVERRIDES" + echo "[infisical] Loaded SMTP config from claude-ops/mail" +fi + +exec "$@" diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..53fcd12 --- /dev/null +++ b/backend/pyproject.toml @@ -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", + "mollie-api-python>=3.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"] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2dd93db --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,35 @@ +# 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 + +# Payments (Mollie) +mollie-api-python>=3.0.0 + +# Config & Utils +pyyaml>=6.0.0 +pillow>=10.0.0 +python-multipart>=0.0.6 +aiofiles>=23.0.0 + +# Email +aiosmtplib>=3.0.0 + +# Cache +redis>=5.0.0 diff --git a/backlog/config.yml b/backlog/config.yml new file mode 100644 index 0000000..2f83faf --- /dev/null +++ b/backlog/config.yml @@ -0,0 +1,16 @@ +project_name: "rSwag" +default_status: "To Do" +statuses: ["To Do", "In Progress", "Done"] +labels: [] +milestones: [] +date_format: yyyy-mm-dd +max_column_width: 20 +default_editor: "nvim" +auto_open_browser: true +default_port: 6420 +remote_operations: true +auto_commit: false +bypass_git_hooks: false +check_active_branches: true +active_branch_days: 30 +task_prefix: "task" diff --git a/backlog/tasks/task-1 - Configure-Mollie-API-key-for-production-payments.md b/backlog/tasks/task-1 - Configure-Mollie-API-key-for-production-payments.md new file mode 100644 index 0000000..cb5c5c8 --- /dev/null +++ b/backlog/tasks/task-1 - Configure-Mollie-API-key-for-production-payments.md @@ -0,0 +1,16 @@ +--- +id: TASK-1 +title: Configure Mollie API key for production payments +status: To Do +assignee: [] +created_date: '2026-02-18 19:51' +labels: [] +dependencies: [] +priority: high +--- + +## Description + + +Sign up at my.mollie.com, get live API key, add MOLLIE_API_KEY to /opt/apps/rswag/.env on Netcup. Configure webhook URL in Mollie dashboard pointing to https://rswag.online/api/webhooks/mollie (or fungiswag.jeffemmett.com equivalent). Test mode key starts with test_, live key starts with live_. + diff --git a/backlog/tasks/task-10 - Consistent-rApp-header-bar-with-AppSwitcher-SpaceSwitcher.md b/backlog/tasks/task-10 - Consistent-rApp-header-bar-with-AppSwitcher-SpaceSwitcher.md new file mode 100644 index 0000000..1bd1964 --- /dev/null +++ b/backlog/tasks/task-10 - Consistent-rApp-header-bar-with-AppSwitcher-SpaceSwitcher.md @@ -0,0 +1,16 @@ +--- +id: TASK-10 +title: Consistent rApp header bar with AppSwitcher + SpaceSwitcher +status: Done +assignee: [] +created_date: '2026-02-25 07:28' +labels: [] +dependencies: [] +priority: high +--- + +## Description + + +rSwag header now matches standard rApp pattern: [AppSwitcher | SpaceSwitcher | Logo ... Nav ... Auth Cart]. Removed custom rSwag-specific SpaceSwitcher, adopted the ecosystem-wide component. EcosystemFooter also standardized. + diff --git a/backlog/tasks/task-11 - Migrate-all-SMTP-references-to-mail.rmail.online.md b/backlog/tasks/task-11 - Migrate-all-SMTP-references-to-mail.rmail.online.md new file mode 100644 index 0000000..c60a170 --- /dev/null +++ b/backlog/tasks/task-11 - Migrate-all-SMTP-references-to-mail.rmail.online.md @@ -0,0 +1,29 @@ +--- +id: TASK-11 +title: Migrate all SMTP references to mail.rmail.online +status: Done +assignee: [] +created_date: '2026-02-25 08:00' +updated_date: '2026-02-25 08:25' +labels: [infrastructure, email] +dependencies: [TASK-6] +priority: medium +--- + +## Description + + +Replace all references to mx.jeffemmett.com with mail.rmail.online across all repositories. Set up rswag.online domain in Mailcow with noreply@ mailbox, DNS records, and SMTP credentials wired into rSwag backend via Infisical. + + +## Implementation Notes + + +Cross-repo cleanup: Updated 10 backlog/doc files across 6 repos (rinbox-online, rmail-online, dev-ops, payment-infra, cadcad-discourse-forum, configuration dotfiles). Updated live CLAUDE.md. Only 2 intentional references remain (ADDITIONAL_SAN backward compat in task-11 migration notes). + +Mailcow setup: Created rswag.online domain (2048-bit DKIM), noreply@rswag.online mailbox. Cloudflare DNS: MX (mail.rmail.online, priority 10), SPF (v=spf1 ip4:159.195.32.209 ~all), DKIM (dkim._domainkey), DMARC (p=quarantine). + +Infisical wiring: Stored RSWAG_SMTP_HOST, RSWAG_SMTP_USER, RSWAG_SMTP_PASSWORD in claude-ops /mail folder. Added rswag-container identity as viewer on claude-ops project. Entrypoint.sh fetches SMTP config from claude-ops at startup, overriding stale values from .env and rSwag Infisical project. config.py AliasChoices accepts both SMTP_PASSWORD and SMTP_PASS. + +Deploy: Triggered via webhook with correct HMAC secret. Container logs confirm: "[infisical] Loaded SMTP config from claude-ops/mail". + diff --git a/backlog/tasks/task-2 - Configure-Printful-and-Prodigi-API-keys.md b/backlog/tasks/task-2 - Configure-Printful-and-Prodigi-API-keys.md new file mode 100644 index 0000000..60b41ef --- /dev/null +++ b/backlog/tasks/task-2 - Configure-Printful-and-Prodigi-API-keys.md @@ -0,0 +1,23 @@ +--- +id: TASK-2 +title: Configure Printful and Prodigi API keys +status: Done +assignee: [] +created_date: '2026-02-18 19:51' +updated_date: '2026-02-25 07:34' +labels: [] +dependencies: [] +priority: high +--- + +## Description + + +Add PRINTFUL_API_TOKEN and PRODIGI_API_KEY to .env. Currently empty — orders will be created but not submitted to POD providers. Also implement the POD client code in backend/app/pod/ to actually submit orders after Stripe payment. + + +## Implementation Notes + + +Printful API token configured. Prodigi API key also set up. POD client code in backend/app/pod/ submits orders after Mollie payment. Printful mockup generation working. + diff --git a/backlog/tasks/task-3 - Replace-placeholder-Fungi-Flows-design-assets.md b/backlog/tasks/task-3 - Replace-placeholder-Fungi-Flows-design-assets.md new file mode 100644 index 0000000..964ce57 --- /dev/null +++ b/backlog/tasks/task-3 - Replace-placeholder-Fungi-Flows-design-assets.md @@ -0,0 +1,23 @@ +--- +id: TASK-3 +title: Replace placeholder Fungi Flows design assets +status: Done +assignee: [] +created_date: '2026-02-18 19:51' +updated_date: '2026-02-25 07:28' +labels: [] +dependencies: [] +priority: medium +--- + +## Description + + +Current fungi-logo-tee.png and fungi-spore.png are Pillow-generated placeholders. Replace with real artwork from Darren/Fungi Flows team. Designs at designs/shirts/fungi-logo-tee/ and designs/stickers/fungi-spore/. + + +## Implementation Notes + + +Fungi Flows placeholder designs deleted. Only DefectFi 'Don't Abuse the Holes' design remains with real Printful products (shirt SKU 71, hoodie SKU 146). + diff --git a/backlog/tasks/task-4 - Integrate-EncryptID-authentication-for-rSwag.md b/backlog/tasks/task-4 - Integrate-EncryptID-authentication-for-rSwag.md new file mode 100644 index 0000000..c29b4d0 --- /dev/null +++ b/backlog/tasks/task-4 - Integrate-EncryptID-authentication-for-rSwag.md @@ -0,0 +1,23 @@ +--- +id: TASK-4 +title: Integrate EncryptID authentication for rSwag +status: Done +assignee: [] +created_date: '2026-02-18 19:51' +updated_date: '2026-02-25 07:28' +labels: [] +dependencies: [] +priority: medium +--- + +## Description + + +Replace email/password admin auth with EncryptID passkeys to be consistent with other rApps (rWork, rFiles, rNotes). Use @encryptid/sdk, WebAuthn flow, DID-based user identity, space role checking. See /home/jeffe/Github/encryptid-sdk/ and rwork-online for patterns. + + +## Implementation Notes + + +EncryptID auth integrated with passkey sign-in via vendored @encryptid/sdk. AuthButton + Zustand auth store matching rMaps pattern. + diff --git a/backlog/tasks/task-5 - Add-real-Printful-mockup-API-integration.md b/backlog/tasks/task-5 - Add-real-Printful-mockup-API-integration.md new file mode 100644 index 0000000..0ce268c --- /dev/null +++ b/backlog/tasks/task-5 - Add-real-Printful-mockup-API-integration.md @@ -0,0 +1,39 @@ +--- +id: TASK-5 +title: Add real Printful mockup API integration +status: Done +assignee: [] +created_date: '2026-02-18 19:51' +updated_date: '2026-02-25 07:28' +labels: [] +dependencies: [] +priority: high +--- + +## Description + + +Current upload page uses client-side Canvas compositing with simple template images. When Printful API token is configured, enhance with real Printful Mockup Generator API (POST /mockup-generator/create-task) for photorealistic product previews showing actual garment colors and fabric texture. + + +## Implementation Notes + + +2026-02-21: Printful client code is DONE and deployed. Blocking issue: API token not scoped to store. + +What's done: +- backend/app/pod/printful_client.py created (catalog, mockups, orders) +- designs.py updated (Printful mockup path + Pillow fallback) +- order_service.py refactored (provider-aware routing: printful vs prodigi) +- Token stored at ~/.secrets/printful_api_token and in Netcup .env +- Deployed to fungiswag.jeffemmett.com (Pillow fallback working) + +Blocking: +- Token u5WU...R2d returns "This endpoint requires store_id" on mockup/order APIs +- Need to create a NEW token on developers.printful.com scoped to "Fungi Flows" store +- Select the store in the "Access" dropdown (not "Account (all stores)") + +Once new token is set, just update ~/.secrets/printful_api_token and Netcup .env, rebuild, done. + +Printful mockup API v2 integrated. Falls back to Pillow compositing with local templates. Old fungi designs removed, only defectfi-dont-abuse-holes remains. + diff --git a/backlog/tasks/task-6 - Add-order-confirmation-emails.md b/backlog/tasks/task-6 - Add-order-confirmation-emails.md new file mode 100644 index 0000000..ba58a5b --- /dev/null +++ b/backlog/tasks/task-6 - Add-order-confirmation-emails.md @@ -0,0 +1,25 @@ +--- +id: TASK-6 +title: Add order confirmation emails +status: Done +assignee: [] +created_date: '2026-02-18 19:51' +updated_date: '2026-02-25 07:34' +labels: [] +dependencies: [] +priority: medium +--- + +## Description + + +OrderService has TODO for sending confirmation emails after payment. Connect to Mailcow SMTP (mail.rmail.online:587) or email-relay API. Send order confirmation with items, total, and tracking link. + + +## Implementation Notes + + +EmailService created with aiosmtplib. Order confirmation email sent after successful Mollie payment. Shipping notification email sent when POD provider reports shipped status with tracking info. HTML templates with rSwag dark theme branding. SMTP via Mailcow (mail.rmail.online:587 STARTTLS). Non-blocking: failures logged but don't break order flow. + +Mailcow setup (2026-02-25): Created rswag.online domain with 2048-bit DKIM. Created noreply@rswag.online mailbox. DNS records (MX, SPF, DKIM, DMARC) added to Cloudflare. SMTP credentials stored in claude-ops /mail folder (RSWAG_SMTP_HOST, RSWAG_SMTP_USER, RSWAG_SMTP_PASSWORD). Entrypoint fetches from claude-ops at startup, overriding stale rSwag Infisical values. config.py uses AliasChoices to accept both SMTP_PASSWORD and SMTP_PASS env var names. + diff --git a/backlog/tasks/task-7 - Set-up-auto-deploy-webhook-for-rSwag.md b/backlog/tasks/task-7 - Set-up-auto-deploy-webhook-for-rSwag.md new file mode 100644 index 0000000..e008a47 --- /dev/null +++ b/backlog/tasks/task-7 - Set-up-auto-deploy-webhook-for-rSwag.md @@ -0,0 +1,16 @@ +--- +id: TASK-7 +title: Set up auto-deploy webhook for rSwag +status: Done +assignee: [] +created_date: '2026-02-18 19:51' +labels: [] +dependencies: [] +priority: medium +--- + +## Description + + +Add rswag entry to /opt/deploy-webhook/webhook.py REPOS dict and create Gitea webhook so pushes to main auto-deploy. Currently requires manual git pull + docker compose rebuild. + diff --git a/backlog/tasks/task-8 - Standardize-SpaceSwitcher-across-all-rApps.md b/backlog/tasks/task-8 - Standardize-SpaceSwitcher-across-all-rApps.md new file mode 100644 index 0000000..1a06038 --- /dev/null +++ b/backlog/tasks/task-8 - Standardize-SpaceSwitcher-across-all-rApps.md @@ -0,0 +1,16 @@ +--- +id: TASK-8 +title: Standardize SpaceSwitcher across all rApps +status: Done +assignee: [] +created_date: '2026-02-25 07:28' +labels: [] +dependencies: [] +priority: high +--- + +## Description + + +Deploy consistent SpaceSwitcher component across all 16 rApp repos. Space dropdown links now use subdomain URLs (..online) instead of rspace.online/ paths. Domain auto-derived from window.location.hostname. + diff --git a/backlog/tasks/task-9 - Interactive-Sankey-fund-flow-visualization.md b/backlog/tasks/task-9 - Interactive-Sankey-fund-flow-visualization.md new file mode 100644 index 0000000..2038541 --- /dev/null +++ b/backlog/tasks/task-9 - Interactive-Sankey-fund-flow-visualization.md @@ -0,0 +1,16 @@ +--- +id: TASK-9 +title: Interactive Sankey fund flow visualization +status: Done +assignee: [] +created_date: '2026-02-25 07:28' +labels: [] +dependencies: [] +priority: medium +--- + +## Description + + +Built interactive SVG Sankey diagram for rSwag landing page showing revenue flow from sale price through printer (production), creator (design margin), and community (revenue fund). Drag sliders to adjust splits dynamically. Pure React+SVG, zero dependencies. + diff --git a/config/products.yaml b/config/products.yaml new file mode 100644 index 0000000..570e974 --- /dev/null +++ b/config/products.yaml @@ -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 diff --git a/designs/.gitkeep b/designs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/designs/misc/.gitkeep b/designs/misc/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/designs/shirts/.gitkeep b/designs/shirts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/designs/shirts/defectfi-dont-abuse-holes/dont-abuse-the-holes.png b/designs/shirts/defectfi-dont-abuse-holes/dont-abuse-the-holes.png new file mode 100644 index 0000000..aa2259e Binary files /dev/null and b/designs/shirts/defectfi-dont-abuse-holes/dont-abuse-the-holes.png differ diff --git a/designs/shirts/defectfi-dont-abuse-holes/metadata.yaml b/designs/shirts/defectfi-dont-abuse-holes/metadata.yaml new file mode 100644 index 0000000..42bda84 --- /dev/null +++ b/designs/shirts/defectfi-dont-abuse-holes/metadata.yaml @@ -0,0 +1,31 @@ +name: "Don't Abuse the Holes!" +slug: defectfi-dont-abuse-holes +description: "Defensive bug hunting meets punk resistance. Distressed typography with beetles crawling through the design — because the best defense is finding the vulnerabilities before they find you. Revenue from this design supports the DefectFi community. #DefectFi" +tags: [defectfi, whistleblower, bug-bounty, punk, resistance, tee, community] +space: all +category: shirts +created: "2026-02-24" +author: defectfi + +source: + file: dont-abuse-the-holes.png + format: png + dimensions: + width: 1743 + height: 1786 + dpi: 300 + color_profile: sRGB + +products: + - type: shirt + provider: printful + sku: "71" + variants: [S, M, L, XL, 2XL, 3XL] + retail_price: 29.99 + - type: hoodie + provider: printful + sku: "146" + variants: [S, M, L, XL, 2XL] + retail_price: 49.99 + +status: active diff --git a/designs/stickers/.gitkeep b/designs/stickers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/designs/uploads/.gitkeep b/designs/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..839933f --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,39 @@ +# 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 + - ./spaces:/app/spaces:ro + - ./frontend/public/mockups:/app/mockups: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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..afbff7a --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,22 @@ +services: + backend: + volumes: + - /opt/apps/rswag/designs:/app/designs + - /opt/apps/rswag/config:/app/config:ro + - /opt/apps/rswag/spaces:/app/spaces:ro + environment: + - DESIGNS_PATH=/app/designs + - CONFIG_PATH=/app/config + - SPACES_PATH=/app/spaces + + frontend: + build: + args: + - NEXT_PUBLIC_API_URL=https://rswag.online/api + +networks: + rswag-internal: + driver: bridge + ipam: + config: + - subnet: 10.200.1.0/24 diff --git a/docker-compose.yml b/docker-compose.yml index b437cfc..63ff96c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,106 @@ services: - rswag: - build: . + # 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: + - INFISICAL_CLIENT_ID=${INFISICAL_CLIENT_ID} + - INFISICAL_CLIENT_SECRET=${INFISICAL_CLIENT_SECRET} + - INFISICAL_PROJECT_SLUG=rswag + - INFISICAL_ENV=prod + - INFISICAL_URL=http://infisical:8080 + - DATABASE_URL=postgresql://rswag:${DB_PASSWORD:-devpassword}@db:5432/rswag + - REDIS_URL=redis://redis:6379 + - DESIGNS_PATH=/app/designs + - CONFIG_PATH=/app/config + - SPACES_PATH=/app/spaces + - PRINTFUL_STORE_ID=${PRINTFUL_STORE_ID:-} + - PUBLIC_URL=${PUBLIC_URL:-https://rswag.online} + - SMTP_HOST=${SMTP_HOST:-mail.rmail.online} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-noreply@rswag.online} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL:-noreply@rswag.online} + - SMTP_FROM_NAME=${SMTP_FROM_NAME:-rSwag} + volumes: + - ./designs:/app/designs + - ./config:/app/config:ro + - ./spaces:/app/spaces:ro + - ./frontend/public/mockups:/app/mockups:ro + depends_on: + db: + condition: service_healthy + networks: + - rswag-internal + - traefik-public labels: - "traefik.enable=true" - - "traefik.http.routers.rswag.rule=Host(`rswag.online`) || Host(`www.rswag.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rswag.online`)" - - "traefik.http.services.rswag.loadbalancer.server.port=80" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:80/"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 15s + - "traefik.http.routers.rswag-api.rule=(Host(`rswag.online`) || Host(`fungiswag.jeffemmett.com`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rswag.online`)) && PathPrefix(`/api`)" + - "traefik.http.routers.rswag-api.entrypoints=web" + - "traefik.http.services.rswag-api.loadbalancer.server.port=8000" + - "traefik.docker.network=traefik-public" + + # Next.js Frontend + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api} + 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`) || Host(`fungiswag.jeffemmett.com`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rswag.online`)" + - "traefik.http.routers.rswag-web.entrypoints=web" + - "traefik.http.services.rswag-web.loadbalancer.server.port=3000" + - "traefik.docker.network=traefik-public" + +volumes: + rswag-db-data: + rswag-redis-data: networks: + rswag-internal: + driver: bridge traefik-public: external: true diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..dec4f54 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,47 @@ +# Stage 1: Dependencies +FROM node:20-alpine AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY package.json pnpm-lock.yaml* ./ +COPY vendor/ ./vendor/ +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 +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL + +# 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"] diff --git a/frontend/app/cart/page.tsx b/frontend/app/cart/page.tsx new file mode 100644 index 0000000..7e92cd6 --- /dev/null +++ b/frontend/app/cart/page.tsx @@ -0,0 +1,296 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { getSpaceIdFromCookie, getCartKey } from "@/lib/spaces"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"; + +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(null); + const [loading, setLoading] = useState(true); + const [checkingOut, setCheckingOut] = useState(false); + const [updating, setUpdating] = useState(null); + + const fetchCart = async () => { + const cartKey = getCartKey(getSpaceIdFromCookie()); + const cartId = localStorage.getItem(cartKey); + 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(cartKey); + 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 ( +
+
+
+
+
+ ); + } + + if (!cart || cart.items.length === 0) { + return ( +
+

Your Cart

+

Your cart is empty.

+ + Continue Shopping + +
+ ); + } + + return ( +
+

Your Cart

+ +
+
+ {cart.items.map((item) => ( +
+ {/* Product Image */} + +
+ {item.product_name} +
+ + + {/* Product Info */} +
+ + {item.product_name} + + {item.variant_name && ( +

+ {item.variant_name} +

+ )} +

+ ${item.unit_price.toFixed(2)} each +

+ + {/* Quantity Controls */} +
+ + {item.quantity} + + +
+
+ + {/* Subtotal */} +
+

${item.subtotal.toFixed(2)}

+
+
+ ))} +
+ + {/* Order Summary */} +
+
+

Order Summary

+
+
+ + Subtotal ({cart.item_count} item{cart.item_count !== 1 ? "s" : ""}) + + ${cart.subtotal.toFixed(2)} +
+
+ Shipping + Calculated at checkout +
+
+
+
+ Total + ${cart.subtotal.toFixed(2)} +
+
+ + + Continue Shopping + +
+
+
+
+ ); +} diff --git a/frontend/app/checkout/success/page.tsx b/frontend/app/checkout/success/page.tsx new file mode 100644 index 0000000..991ad41 --- /dev/null +++ b/frontend/app/checkout/success/page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; +import { getSpaceIdFromCookie, getCartKey } from "@/lib/spaces"; + +export default function CheckoutSuccessPage() { + useEffect(() => { + // Clear cart after successful payment + const cartKey = getCartKey(getSpaceIdFromCookie()); + localStorage.removeItem(cartKey); + }, []); + + return ( +
+
+
+ + + +
+

Order Confirmed!

+

+ Thank you for your purchase. Your order is being processed and + you'll receive a confirmation email shortly. +

+ + Continue Shopping + +
+
+ ); +} diff --git a/frontend/app/design/page.tsx b/frontend/app/design/page.tsx new file mode 100644 index 0000000..b74d420 --- /dev/null +++ b/frontend/app/design/page.tsx @@ -0,0 +1,338 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { getSpaceIdFromCookie } from "@/lib/spaces"; +import type { SpaceConfig } from "@/lib/spaces"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"; + +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(null); + const [generatedDesign, setGeneratedDesign] = useState(null); + const [isActivating, setIsActivating] = useState(false); + const [spaceConfig, setSpaceConfig] = useState(null); + + useEffect(() => { + const spaceId = getSpaceIdFromCookie(); + fetch(`${API_URL}/spaces/${spaceId}`) + .then((res) => (res.ok ? res.json() : null)) + .then(setSpaceConfig) + .catch(() => {}); + }, []); + + const handleGenerate = async (e: React.FormEvent) => { + e.preventDefault(); + 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", + space: getSpaceIdFromCookie(), + }), + }); + + 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 ( +
+
+

Design Swag

+

+ Create custom {spaceConfig?.name || "rSpace"} merchandise using AI. Describe your vision and + we'll generate a unique design. +

+ +
+ {/* Form */} +
+
+
+ + 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} + /> +
+ +
+ +