From b924e41ce8502b228eddefb8fbb938ad6fd5aa08 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 29 Jan 2026 16:59:45 +0000 Subject: [PATCH] feat: Initialize mycopunk-swag-store e-commerce platform Full-stack e-commerce platform for mycopunk merchandise: Backend (FastAPI): - Design service reading from mycopunk-swag repo - Cart, checkout, and order management - Stripe payment integration - POD webhook handlers (Prodigi, Printful) - Admin API with JWT auth - PostgreSQL with Alembic migrations Frontend (Next.js 15): - Product catalog with SSR - Shopping cart with localStorage persistence - Stripe checkout redirect flow - Tailwind CSS + shadcn/ui theming Infrastructure: - Docker Compose with PostgreSQL, Redis - Traefik labels for swag.mycofi.earth - Multi-stage Dockerfiles Co-Authored-By: Claude Opus 4.5 --- .env.example | 25 ++ .gitignore | 67 ++++++ CLAUDE.md | 114 +++++++++ README.md | 105 ++++++++ backend/Dockerfile | 47 ++++ backend/alembic.ini | 42 ++++ backend/alembic/env.py | 83 +++++++ backend/alembic/script.py.mako | 26 ++ backend/alembic/versions/001_initial.py | 142 +++++++++++ backend/app/__init__.py | 1 + backend/app/api/__init__.py | 3 + backend/app/api/admin/__init__.py | 12 + backend/app/api/admin/analytics.py | 39 +++ backend/app/api/admin/auth.py | 36 +++ backend/app/api/admin/orders.py | 57 +++++ backend/app/api/admin/products.py | 57 +++++ backend/app/api/cart.py | 83 +++++++ backend/app/api/checkout.py | 47 ++++ backend/app/api/designs.py | 47 ++++ backend/app/api/health.py | 11 + backend/app/api/orders.py | 53 +++++ backend/app/api/products.py | 31 +++ backend/app/api/webhooks.py | 97 ++++++++ backend/app/config.py | 66 ++++++ backend/app/database.py | 46 ++++ backend/app/main.py | 61 +++++ backend/app/models/__init__.py | 17 ++ backend/app/models/admin.py | 24 ++ backend/app/models/cart.py | 60 +++++ backend/app/models/customer.py | 27 +++ backend/app/models/order.py | 102 ++++++++ backend/app/models/product.py | 21 ++ backend/app/schemas/__init__.py | 28 +++ backend/app/schemas/cart.py | 57 +++++ backend/app/schemas/design.py | 42 ++++ backend/app/schemas/order.py | 76 ++++++ backend/app/schemas/product.py | 30 +++ backend/app/services/__init__.py | 1 + backend/app/services/analytics_service.py | 94 ++++++++ backend/app/services/auth_service.py | 90 +++++++ backend/app/services/cart_service.py | 142 +++++++++++ backend/app/services/design_service.py | 277 ++++++++++++++++++++++ backend/app/services/order_service.py | 229 ++++++++++++++++++ backend/app/services/stripe_service.py | 91 +++++++ backend/pyproject.toml | 54 +++++ backend/requirements.txt | 32 +++ docker-compose.yml | 109 +++++++++ frontend/Dockerfile | 45 ++++ frontend/app/cart/page.tsx | 147 ++++++++++++ frontend/app/globals.css | 59 +++++ frontend/app/layout.tsx | 46 ++++ frontend/app/page.tsx | 42 ++++ frontend/app/products/page.tsx | 71 ++++++ frontend/next.config.mjs | 18 ++ frontend/package.json | 40 ++++ frontend/postcss.config.mjs | 9 + frontend/tailwind.config.ts | 54 +++++ frontend/tsconfig.json | 27 +++ 58 files changed, 3559 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/001_initial.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/admin/__init__.py create mode 100644 backend/app/api/admin/analytics.py create mode 100644 backend/app/api/admin/auth.py create mode 100644 backend/app/api/admin/orders.py create mode 100644 backend/app/api/admin/products.py create mode 100644 backend/app/api/cart.py create mode 100644 backend/app/api/checkout.py create mode 100644 backend/app/api/designs.py create mode 100644 backend/app/api/health.py create mode 100644 backend/app/api/orders.py create mode 100644 backend/app/api/products.py create mode 100644 backend/app/api/webhooks.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/admin.py create mode 100644 backend/app/models/cart.py create mode 100644 backend/app/models/customer.py create mode 100644 backend/app/models/order.py create mode 100644 backend/app/models/product.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/cart.py create mode 100644 backend/app/schemas/design.py create mode 100644 backend/app/schemas/order.py create mode 100644 backend/app/schemas/product.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/analytics_service.py create mode 100644 backend/app/services/auth_service.py create mode 100644 backend/app/services/cart_service.py create mode 100644 backend/app/services/design_service.py create mode 100644 backend/app/services/order_service.py create mode 100644 backend/app/services/stripe_service.py create mode 100644 backend/pyproject.toml create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/app/cart/page.tsx create mode 100644 frontend/app/globals.css create mode 100644 frontend/app/layout.tsx create mode 100644 frontend/app/page.tsx create mode 100644 frontend/app/products/page.tsx create mode 100644 frontend/next.config.mjs create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.mjs create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..81e7a49 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Database +DB_PASSWORD=change_me_in_production + +# Stripe +STRIPE_SECRET_KEY=sk_test_xxx +STRIPE_PUBLISHABLE_KEY=pk_test_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx + +# POD Providers +PRODIGI_API_KEY=xxx +PRINTFUL_API_TOKEN=xxx +POD_SANDBOX_MODE=true + +# Auth +JWT_SECRET=generate_a_strong_secret_here + +# App +CORS_ORIGINS=http://localhost:3000 + +# Email (Resend) +RESEND_API_KEY=re_xxx + +# Frontend +NEXT_PUBLIC_API_URL=http://localhost:8000/api +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3322782 --- /dev/null +++ b/.gitignore @@ -0,0 +1,67 @@ +# Environment +.env +.env.local +.env.*.local + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +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..8749119 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,114 @@ +# Mycopunk Swag Store - AI Assistant Context + +## Project Overview + +E-commerce platform for mycopunk merchandise (stickers, shirts, prints) with Stripe payments and print-on-demand fulfillment via Printful and Prodigi. + +## Architecture + +- **Frontend**: Next.js 15 App Router, shadcn/ui, Tailwind CSS +- **Backend**: FastAPI, SQLAlchemy, Alembic +- **Database**: PostgreSQL +- **Payments**: Stripe Checkout (redirect flow) +- **Fulfillment**: Printful (apparel), Prodigi (stickers/prints) +- **Deployment**: Docker on Netcup RS 8000, Traefik routing + +## Key Directories + +| Directory | Purpose | +|-----------|---------| +| `backend/app/api/` | FastAPI route handlers | +| `backend/app/models/` | SQLAlchemy ORM models | +| `backend/app/schemas/` | Pydantic request/response schemas | +| `backend/app/services/` | Business logic (stripe, pod, orders) | +| `backend/app/pod/` | POD provider clients (from mycopunk-swag) | +| `frontend/app/` | Next.js App Router pages | +| `frontend/components/` | React components | + +## Design Source + +Designs are read from the mycopunk-swag repo at runtime: +- **Local**: `/home/jeffe/Github/mycopunk-swag/designs/` +- **Docker**: Volume mounted from `/opt/mycopunk-swag/designs` + +Each design has a `metadata.yaml` with name, description, products, variants, and pricing. + +## API Endpoints + +### Public +- `GET /api/designs` - List active designs +- `GET /api/designs/{slug}` - Get design details +- `GET /api/designs/{slug}/image` - Serve design image +- `GET /api/products` - List products with variants +- `POST /api/cart` - Create cart +- `GET/POST/DELETE /api/cart/{id}/items` - Cart operations +- `POST /api/checkout/session` - Create Stripe checkout +- `GET /api/orders/{id}` - Order status (requires email) + +### Webhooks +- `POST /api/webhooks/stripe` - Stripe payment events +- `POST /api/webhooks/prodigi` - Prodigi fulfillment updates +- `POST /api/webhooks/printful` - Printful fulfillment updates + +### Admin (JWT required) +- `POST /api/admin/auth/login` - Admin login +- `GET /api/admin/orders` - List orders +- `GET /api/admin/analytics/*` - Sales metrics + +## Order Flow + +1. Customer adds items to cart (cart_id in localStorage) +2. Checkout creates Stripe session, redirects to Stripe +3. Stripe webhook fires on payment success +4. Backend creates order, submits to POD provider +5. POD webhook updates order status +6. Customer receives email notifications + +## Common Tasks + +### Run locally +```bash +docker compose up -d +``` + +### Run migrations +```bash +cd backend +alembic upgrade head +``` + +### Add a new API endpoint +1. Create route in `backend/app/api/` +2. Add Pydantic schemas in `backend/app/schemas/` +3. Register router in `backend/app/main.py` + +### Add a new component +```bash +cd frontend +npx shadcn@latest add button # or other component +``` + +## Environment Variables + +See `.env.example` for all required variables. + +## Testing + +```bash +# Backend +cd backend +pytest + +# Frontend +cd frontend +pnpm test +``` + +## Deployment + +Push to Gitea triggers webhook → auto-deploy on Netcup. + +Manual deploy: +```bash +ssh netcup "cd /opt/mycopunk-swag-store && git pull && docker compose up -d --build" +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..06359f1 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# Mycopunk Swag Store + +E-commerce platform for mycopunk merchandise at **swag.mycofi.earth** + +## Stack + +- **Frontend**: Next.js 15 + shadcn/ui + Tailwind CSS +- **Backend**: FastAPI + SQLAlchemy + Alembic +- **Database**: PostgreSQL +- **Payments**: Stripe Checkout +- **Fulfillment**: Printful (apparel) + Prodigi (stickers/prints) + +## Architecture + +``` +swag.mycofi.earth + │ + ▼ + Cloudflare Tunnel → Traefik + │ │ + ▼ ▼ + Next.js (3000) FastAPI (8000) + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + PostgreSQL Stripe POD APIs +``` + +## Development + +### Prerequisites + +- Docker & Docker Compose +- Python 3.12+ +- Node.js 20+ +- pnpm + +### Quick Start + +```bash +# Clone and setup +cd /home/jeffe/Github/mycopunk-swag-store +cp .env.example .env +# Edit .env with your API keys + +# Start services +docker compose up -d + +# Backend available at http://localhost:8000 +# Frontend available at 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 + +``` +mycopunk-swag-store/ +├── 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 +``` + +## Environment Variables + +See `.env.example` for required configuration. + +## Deployment + +Deployed on Netcup RS 8000 via Docker Compose with Traefik reverse proxy. + +```bash +# On server +cd /opt/mycopunk-swag-store +git pull +docker compose up -d --build +``` + +## Links + +- [Stripe Dashboard](https://dashboard.stripe.com) +- [Printful API Docs](https://developers.printful.com/docs/) +- [Prodigi API Docs](https://www.prodigi.com/print-api/docs/) diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4705ea9 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,47 @@ +# Stage 1: Build +FROM python:3.12-slim AS builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt + +# Stage 2: Runtime +FROM python:3.12-slim AS runtime + +WORKDIR /app + +# Install runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN addgroup --system --gid 1001 appgroup && \ + adduser --system --uid 1001 --ingroup appgroup appuser + +# Install wheels +COPY --from=builder /app/wheels /wheels +RUN pip install --no-cache /wheels/* + +# Copy application code +COPY --chown=appuser:appgroup app/ ./app/ +COPY --chown=appuser:appgroup alembic/ ./alembic/ +COPY --chown=appuser:appgroup alembic.ini ./ + +# Create directories for mounted volumes +RUN mkdir -p /app/designs /app/config && \ + chown -R appuser:appgroup /app + +USER appuser + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] 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/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..755fb4e --- /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 mycopunk-swag repo (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..da2aba9 --- /dev/null +++ b/backend/app/api/checkout.py @@ -0,0 +1,47 @@ +"""Checkout API endpoints.""" + +from fastapi import APIRouter, HTTPException, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.schemas.order import CheckoutRequest, CheckoutResponse +from app.services.stripe_service import StripeService +from app.services.cart_service import CartService + +router = APIRouter() + + +def get_stripe_service() -> StripeService: + return StripeService() + + +def get_cart_service(db: AsyncSession = Depends(get_db)) -> CartService: + return CartService(db) + + +@router.post("/session", response_model=CheckoutResponse) +async def create_checkout_session( + request: CheckoutRequest, + stripe_service: StripeService = Depends(get_stripe_service), + cart_service: CartService = Depends(get_cart_service), +): + """Create a Stripe checkout session.""" + # Get cart + cart = await cart_service.get_cart(request.cart_id) + if not cart: + raise HTTPException(status_code=404, detail="Cart not found") + + if not cart.items: + raise HTTPException(status_code=400, detail="Cart is empty") + + # Create Stripe session + result = await stripe_service.create_checkout_session( + cart=cart, + success_url=request.success_url, + cancel_url=request.cancel_url, + ) + + return CheckoutResponse( + checkout_url=result["url"], + session_id=result["session_id"], + ) diff --git a/backend/app/api/designs.py b/backend/app/api/designs.py new file mode 100644 index 0000000..8a20cf6 --- /dev/null +++ b/backend/app/api/designs.py @@ -0,0 +1,47 @@ +"""Designs API endpoints.""" + +from pathlib import Path + +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse + +from app.schemas.design import Design +from app.services.design_service import DesignService + +router = APIRouter() +design_service = DesignService() + + +@router.get("", response_model=list[Design]) +async def list_designs( + status: str = "active", + category: str | None = None, +): + """List all designs.""" + designs = await design_service.list_designs(status=status, category=category) + return designs + + +@router.get("/{slug}", response_model=Design) +async def get_design(slug: str): + """Get a single design by slug.""" + design = await design_service.get_design(slug) + if not design: + raise HTTPException(status_code=404, detail="Design not found") + return design + + +@router.get("/{slug}/image") +async def get_design_image(slug: str): + """Serve the design image.""" + image_path = await design_service.get_design_image_path(slug) + if not image_path or not Path(image_path).exists(): + raise HTTPException(status_code=404, detail="Image not found") + + return FileResponse( + image_path, + media_type="image/png", + headers={ + "Cache-Control": "public, max-age=86400", # Cache for 24 hours + }, + ) diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000..19ce57a --- /dev/null +++ b/backend/app/api/health.py @@ -0,0 +1,11 @@ +"""Health check endpoint.""" + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy"} 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..b8795a8 --- /dev/null +++ b/backend/app/api/products.py @@ -0,0 +1,31 @@ +"""Products API endpoints.""" + +from fastapi import APIRouter, HTTPException + +from app.schemas.product import Product +from app.services.design_service import DesignService + +router = APIRouter() +design_service = DesignService() + + +@router.get("", response_model=list[Product]) +async def list_products( + category: str | None = None, + product_type: str | None = None, +): + """List all products (designs with variants flattened for storefront).""" + products = await design_service.list_products( + category=category, + product_type=product_type, + ) + return products + + +@router.get("/{slug}", response_model=Product) +async def get_product(slug: str): + """Get a single product by slug.""" + product = await design_service.get_product(slug) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + return product diff --git a/backend/app/api/webhooks.py b/backend/app/api/webhooks.py new file mode 100644 index 0000000..17f3aaa --- /dev/null +++ b/backend/app/api/webhooks.py @@ -0,0 +1,97 @@ +"""Webhook endpoints for Stripe and POD providers.""" + +from fastapi import APIRouter, Request, HTTPException, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.config import get_settings +from app.services.stripe_service import StripeService +from app.services.order_service import OrderService + +router = APIRouter() +settings = get_settings() + + +def get_stripe_service() -> StripeService: + return StripeService() + + +def get_order_service(db: AsyncSession = Depends(get_db)) -> OrderService: + return OrderService(db) + + +@router.post("/stripe") +async def stripe_webhook( + request: Request, + stripe_service: StripeService = Depends(get_stripe_service), + order_service: OrderService = Depends(get_order_service), +): + """Handle Stripe webhook events.""" + payload = await request.body() + sig_header = request.headers.get("stripe-signature") + + if not sig_header: + raise HTTPException(status_code=400, detail="Missing signature") + + try: + event = stripe_service.verify_webhook(payload, sig_header) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # Handle events + if event["type"] == "checkout.session.completed": + session = event["data"]["object"] + await order_service.handle_successful_payment(session) + + elif event["type"] == "payment_intent.payment_failed": + # Log failure, maybe send notification + pass + + return {"status": "ok"} + + +@router.post("/prodigi") +async def prodigi_webhook( + request: Request, + order_service: OrderService = Depends(get_order_service), +): + """Handle Prodigi webhook events.""" + payload = await request.json() + + event_type = payload.get("event") + order_data = payload.get("order", {}) + + if event_type in ["order.shipped", "order.complete"]: + await order_service.update_pod_status( + pod_provider="prodigi", + pod_order_id=order_data.get("id"), + status=event_type.replace("order.", ""), + tracking_number=order_data.get("shipments", [{}])[0].get("trackingNumber"), + tracking_url=order_data.get("shipments", [{}])[0].get("trackingUrl"), + ) + + return {"status": "ok"} + + +@router.post("/printful") +async def printful_webhook( + request: Request, + order_service: OrderService = Depends(get_order_service), +): + """Handle Printful webhook events.""" + payload = await request.json() + + event_type = payload.get("type") + order_data = payload.get("data", {}).get("order", {}) + + if event_type in ["package_shipped", "order_fulfilled"]: + shipment = payload.get("data", {}).get("shipment", {}) + await order_service.update_pod_status( + pod_provider="printful", + pod_order_id=str(order_data.get("id")), + status="shipped" if event_type == "package_shipped" else "fulfilled", + tracking_number=shipment.get("tracking_number"), + tracking_url=shipment.get("tracking_url"), + ) + + return {"status": "ok"} diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..e7a12c6 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,66 @@ +"""Application configuration.""" + +from functools import lru_cache +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + # Database + database_url: str = "postgresql://swag:devpassword@localhost:5432/swag" + + # Redis + redis_url: str = "redis://localhost:6379" + + # Stripe + stripe_secret_key: str = "" + stripe_publishable_key: str = "" + stripe_webhook_secret: str = "" + + # POD Providers + prodigi_api_key: str = "" + printful_api_token: str = "" + pod_sandbox_mode: bool = True + + # Auth + jwt_secret: str = "dev-secret-change-in-production" + jwt_algorithm: str = "HS256" + jwt_expire_hours: int = 24 + + # CORS + cors_origins: str = "http://localhost:3000" + + # Paths + designs_path: str = "/app/designs" + config_path: str = "/app/config" + + # App + app_name: str = "Mycopunk Swag Store" + debug: bool = False + + @property + def designs_dir(self) -> Path: + return Path(self.designs_path) + + @property + def config_dir(self) -> Path: + return Path(self.config_path) + + @property + def cors_origins_list(self) -> list[str]: + return [origin.strip() for origin in self.cors_origins.split(",")] + + +@lru_cache +def get_settings() -> Settings: + """Get cached settings instance.""" + return Settings() 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..4f7c92e --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,61 @@ +"""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 +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 mycopunk merchandise", + version="0.1.0", + lifespan=lifespan, +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(health.router, prefix="/api", tags=["health"]) +app.include_router(designs.router, prefix="/api/designs", tags=["designs"]) +app.include_router(products.router, prefix="/api/products", tags=["products"]) +app.include_router(cart.router, prefix="/api/cart", tags=["cart"]) +app.include_router(checkout.router, prefix="/api/checkout", tags=["checkout"]) +app.include_router(orders.router, prefix="/api/orders", tags=["orders"]) +app.include_router(webhooks.router, prefix="/api/webhooks", tags=["webhooks"]) +app.include_router(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..a003d65 --- /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) + stripe_customer_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + # Relationships + carts: Mapped[list["Cart"]] = relationship("Cart", back_populates="customer") + orders: Mapped[list["Order"]] = relationship("Order", back_populates="customer") diff --git a/backend/app/models/order.py b/backend/app/models/order.py new file mode 100644 index 0000000..16915ff --- /dev/null +++ b/backend/app/models/order.py @@ -0,0 +1,102 @@ +"""Order models.""" + +import uuid +from datetime import datetime +from enum import Enum + +from sqlalchemy import String, Integer, Numeric, DateTime, ForeignKey, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class OrderStatus(str, Enum): + """Order status enum.""" + + PENDING = "pending" + PAID = "paid" + PROCESSING = "processing" + PRINTING = "printing" + SHIPPED = "shipped" + DELIVERED = "delivered" + CANCELLED = "cancelled" + REFUNDED = "refunded" + + +class Order(Base): + """Order model.""" + + __tablename__ = "orders" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + customer_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("customers.id"), nullable=True + ) + stripe_session_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + stripe_payment_intent_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[str] = mapped_column(String(50), default=OrderStatus.PENDING.value) + + # Shipping info + shipping_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + shipping_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + shipping_address_line1: Mapped[str | None] = mapped_column(String(255), nullable=True) + shipping_address_line2: Mapped[str | None] = mapped_column(String(255), nullable=True) + shipping_city: Mapped[str | None] = mapped_column(String(100), nullable=True) + shipping_state: Mapped[str | None] = mapped_column(String(100), nullable=True) + shipping_postal_code: Mapped[str | None] = mapped_column(String(20), nullable=True) + shipping_country: Mapped[str | None] = mapped_column(String(2), nullable=True) + + # Financials + subtotal: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True) + shipping_cost: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True) + tax: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True) + total: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True) + currency: Mapped[str] = mapped_column(String(3), default="USD") + + # Timestamps + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow + ) + paid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + shipped_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + delivered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + # Relationships + customer: Mapped["Customer | None"] = relationship("Customer", back_populates="orders") + items: Mapped[list["OrderItem"]] = relationship( + "OrderItem", back_populates="order", cascade="all, delete-orphan" + ) + + +class OrderItem(Base): + """Order item model.""" + + __tablename__ = "order_items" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + order_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("orders.id", ondelete="CASCADE"), nullable=False + ) + product_slug: Mapped[str] = mapped_column(String(100), nullable=False) + product_name: Mapped[str] = mapped_column(String(255), nullable=False) + variant: Mapped[str | None] = mapped_column(String(50), nullable=True) + quantity: Mapped[int] = mapped_column(Integer, nullable=False) + unit_price: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) + + # POD fulfillment + pod_provider: Mapped[str | None] = mapped_column(String(50), nullable=True) + pod_order_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + pod_status: Mapped[str | None] = mapped_column(String(50), nullable=True) + pod_tracking_number: Mapped[str | None] = mapped_column(String(100), nullable=True) + pod_tracking_url: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + # Relationships + order: Mapped["Order"] = relationship("Order", back_populates="items") 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/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..3dda835 --- /dev/null +++ b/backend/app/schemas/design.py @@ -0,0 +1,42 @@ +"""Design schemas.""" + +from pydantic import BaseModel + + +class DesignSource(BaseModel): + """Design source file information.""" + + file: str + format: str + dimensions: dict[str, int] + dpi: int + color_profile: str = "sRGB" + + +class DesignProduct(BaseModel): + """Product configuration for a design.""" + + type: str + provider: str + sku: str + variants: list[str] = [] + retail_price: float + + +class Design(BaseModel): + """Design information from metadata.yaml.""" + + slug: str + name: str + description: str + tags: list[str] = [] + category: str + author: str = "" + created: str = "" + source: DesignSource + products: list[DesignProduct] = [] + status: str = "draft" + image_url: str = "" + + class Config: + from_attributes = True 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..048295c --- /dev/null +++ b/backend/app/services/cart_service.py @@ -0,0 +1,142 @@ +"""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() + await self.db.refresh(cart) + 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() + await self.db.refresh(cart) + return self._cart_to_response(cart) + + # 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() + await self.db.refresh(cart) + return self._cart_to_response(cart) + + 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..1164dd9 --- /dev/null +++ b/backend/app/services/design_service.py @@ -0,0 +1,277 @@ +"""Design service for reading designs from mycopunk-swag repo.""" + +from pathlib import Path +from functools import lru_cache + +import yaml +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.schemas.design import Design, DesignSource, DesignProduct +from app.schemas.product import Product, ProductVariant +from app.models.product import ProductOverride + +settings = get_settings() + + +class DesignService: + """Service for reading and managing designs.""" + + def __init__(self): + self.designs_path = settings.designs_dir + self._cache: dict[str, Design] = {} + + def clear_cache(self): + """Clear the design cache.""" + self._cache.clear() + + async def list_designs( + self, + status: str = "active", + category: str | None = None, + ) -> list[Design]: + """List all designs from the designs directory.""" + designs = [] + + if not self.designs_path.exists(): + return designs + + for category_dir in self.designs_path.iterdir(): + if not category_dir.is_dir(): + continue + + # Filter by category if specified + if category and category_dir.name != category: + continue + + for design_dir in category_dir.iterdir(): + if not design_dir.is_dir(): + continue + + design = await self._load_design(design_dir, category_dir.name) + if design and design.status == status: + designs.append(design) + + return designs + + async def get_design(self, slug: str) -> Design | None: + """Get a single design by slug.""" + # Check cache + if slug in self._cache: + return self._cache[slug] + + # Search for the design + for category_dir in self.designs_path.iterdir(): + if not category_dir.is_dir(): + continue + + design_dir = category_dir / slug + if design_dir.exists(): + design = await self._load_design(design_dir, category_dir.name) + if design: + self._cache[slug] = design + return design + + return None + + async def get_design_image_path(self, slug: str) -> str | None: + """Get the path to the design image file.""" + design = await self.get_design(slug) + if not design: + return None + + # Look for exported PNG first + for category_dir in self.designs_path.iterdir(): + if not category_dir.is_dir(): + continue + + design_dir = category_dir / slug + if not design_dir.exists(): + continue + + # Check exports/300dpi first + export_path = design_dir / "exports" / "300dpi" / f"{slug}.png" + if export_path.exists(): + return str(export_path) + + # Check for source PNG + source_path = design_dir / design.source.file + if source_path.exists() and source_path.suffix.lower() == ".png": + return str(source_path) + + # Check for any PNG in the directory + for png_file in design_dir.glob("*.png"): + return str(png_file) + + return None + + async def _load_design(self, design_dir: Path, category: str) -> Design | None: + """Load a design from its directory.""" + metadata_path = design_dir / "metadata.yaml" + if not metadata_path.exists(): + return None + + try: + with open(metadata_path) as f: + metadata = yaml.safe_load(f) + except Exception: + return None + + if not metadata: + return None + + slug = metadata.get("slug", design_dir.name) + + # Parse source info + source_data = metadata.get("source", {}) + source = DesignSource( + file=source_data.get("file", f"{slug}.svg"), + format=source_data.get("format", "svg"), + dimensions=source_data.get("dimensions", {"width": 0, "height": 0}), + dpi=source_data.get("dpi", 300), + color_profile=source_data.get("color_profile", "sRGB"), + ) + + # Parse products + products = [] + for p in metadata.get("products", []): + products.append( + DesignProduct( + type=p.get("type", ""), + provider=p.get("provider", ""), + sku=p.get("sku", ""), + variants=p.get("variants", []), + retail_price=float(p.get("retail_price", 0)), + ) + ) + + return Design( + slug=slug, + name=metadata.get("name", slug), + description=metadata.get("description", ""), + tags=metadata.get("tags", []), + category=category, + author=metadata.get("author", ""), + created=str(metadata.get("created", "")), + source=source, + products=products, + status=metadata.get("status", "draft"), + image_url=f"/api/designs/{slug}/image", + ) + + async def list_products( + self, + category: str | None = None, + product_type: str | None = None, + ) -> list[Product]: + """List all products (designs formatted for storefront).""" + designs = await self.list_designs(status="active", category=category) + products = [] + + for design in designs: + for dp in design.products: + # Filter by product type if specified + if product_type and dp.type != product_type: + continue + + 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, + ) + ] + + 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=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/order_service.py b/backend/app/services/order_service.py new file mode 100644 index 0000000..5a22249 --- /dev/null +++ b/backend/app/services/order_service.py @@ -0,0 +1,229 @@ +"""Order management service.""" + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.order import Order, OrderItem, OrderStatus +from app.models.customer import Customer +from app.models.cart import Cart +from app.schemas.order import OrderResponse, OrderItemResponse + + +class OrderService: + """Service for order operations.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def get_order_by_id(self, order_id: UUID) -> OrderResponse | None: + """Get order by ID.""" + result = await self.db.execute( + select(Order) + .where(Order.id == order_id) + .options(selectinload(Order.items)) + ) + order = result.scalar_one_or_none() + if not order: + return None + return self._order_to_response(order) + + async def get_order_by_id_and_email( + self, + order_id: UUID, + email: str, + ) -> OrderResponse | None: + """Get order by ID with email verification.""" + result = await self.db.execute( + select(Order) + .where(Order.id == order_id, Order.shipping_email == email) + .options(selectinload(Order.items)) + ) + order = result.scalar_one_or_none() + if not order: + return None + return self._order_to_response(order) + + async def list_orders( + self, + status: OrderStatus | None = None, + limit: int = 50, + offset: int = 0, + ) -> list[OrderResponse]: + """List orders with optional status filter.""" + query = select(Order).options(selectinload(Order.items)) + if status: + query = query.where(Order.status == status.value) + query = query.order_by(Order.created_at.desc()).limit(limit).offset(offset) + + result = await self.db.execute(query) + orders = result.scalars().all() + return [self._order_to_response(o) for o in orders] + + async def update_status( + self, + order_id: UUID, + status: OrderStatus, + ) -> OrderResponse | None: + """Update order status.""" + result = await self.db.execute( + select(Order) + .where(Order.id == order_id) + .options(selectinload(Order.items)) + ) + order = result.scalar_one_or_none() + if not order: + return None + + order.status = status.value + if status == OrderStatus.SHIPPED: + order.shipped_at = datetime.utcnow() + elif status == OrderStatus.DELIVERED: + order.delivered_at = datetime.utcnow() + + await self.db.commit() + return self._order_to_response(order) + + async def handle_successful_payment(self, session: dict): + """Handle successful Stripe payment.""" + cart_id = session.get("metadata", {}).get("cart_id") + if not cart_id: + return + + # Get cart + result = await self.db.execute( + select(Cart) + .where(Cart.id == UUID(cart_id)) + .options(selectinload(Cart.items)) + ) + cart = result.scalar_one_or_none() + if not cart or not cart.items: + return + + # Get or create customer + email = session.get("customer_details", {}).get("email", "") + customer = await self._get_or_create_customer(email) + + # Get shipping details + shipping = session.get("shipping_details", {}) or {} + address = shipping.get("address", {}) or {} + + # Create order + order = Order( + customer_id=customer.id if customer else None, + stripe_session_id=session.get("id"), + stripe_payment_intent_id=session.get("payment_intent"), + status=OrderStatus.PAID.value, + shipping_name=shipping.get("name"), + shipping_email=email, + shipping_address_line1=address.get("line1"), + shipping_address_line2=address.get("line2"), + shipping_city=address.get("city"), + shipping_state=address.get("state"), + shipping_postal_code=address.get("postal_code"), + shipping_country=address.get("country"), + subtotal=float(session.get("amount_subtotal", 0)) / 100, + shipping_cost=float(session.get("shipping_cost", {}).get("amount_total", 0)) / 100, + total=float(session.get("amount_total", 0)) / 100, + currency=session.get("currency", "usd").upper(), + paid_at=datetime.utcnow(), + ) + self.db.add(order) + await self.db.flush() + + # Create order items + for cart_item in cart.items: + order_item = OrderItem( + order_id=order.id, + product_slug=cart_item.product_slug, + product_name=cart_item.product_name, + variant=cart_item.variant, + quantity=cart_item.quantity, + unit_price=float(cart_item.unit_price), + pod_status="pending", + ) + self.db.add(order_item) + + await self.db.commit() + + # TODO: Submit to POD providers + # TODO: Send confirmation email + + async def update_pod_status( + self, + pod_provider: str, + pod_order_id: str, + status: str, + tracking_number: str | None = None, + tracking_url: str | None = None, + ): + """Update POD status for order items.""" + await self.db.execute( + update(OrderItem) + .where( + OrderItem.pod_provider == pod_provider, + OrderItem.pod_order_id == pod_order_id, + ) + .values( + pod_status=status, + pod_tracking_number=tracking_number, + pod_tracking_url=tracking_url, + ) + ) + await self.db.commit() + + async def _get_or_create_customer(self, email: str) -> Customer | None: + """Get or create customer by email.""" + if not email: + return None + + result = await self.db.execute( + select(Customer).where(Customer.email == email) + ) + customer = result.scalar_one_or_none() + if customer: + return customer + + customer = Customer(email=email) + self.db.add(customer) + await self.db.flush() + return customer + + def _order_to_response(self, order: Order) -> OrderResponse: + """Convert Order model to response schema.""" + items = [ + OrderItemResponse( + id=item.id, + product_slug=item.product_slug, + product_name=item.product_name, + variant=item.variant, + quantity=item.quantity, + unit_price=float(item.unit_price), + pod_provider=item.pod_provider, + pod_status=item.pod_status, + pod_tracking_number=item.pod_tracking_number, + pod_tracking_url=item.pod_tracking_url, + ) + for item in order.items + ] + + return OrderResponse( + id=order.id, + status=order.status, + shipping_name=order.shipping_name, + shipping_email=order.shipping_email, + shipping_city=order.shipping_city, + shipping_country=order.shipping_country, + subtotal=float(order.subtotal) if order.subtotal else None, + shipping_cost=float(order.shipping_cost) if order.shipping_cost else None, + tax=float(order.tax) if order.tax else None, + total=float(order.total) if order.total else None, + currency=order.currency, + items=items, + created_at=order.created_at, + paid_at=order.paid_at, + shipped_at=order.shipped_at, + ) diff --git a/backend/app/services/stripe_service.py b/backend/app/services/stripe_service.py new file mode 100644 index 0000000..7cd1403 --- /dev/null +++ b/backend/app/services/stripe_service.py @@ -0,0 +1,91 @@ +"""Stripe payment service.""" + +import stripe + +from app.config import get_settings +from app.schemas.cart import CartResponse + +settings = get_settings() + +# Configure Stripe +stripe.api_key = settings.stripe_secret_key + + +class StripeService: + """Service for Stripe operations.""" + + async def create_checkout_session( + self, + cart: CartResponse, + success_url: str, + cancel_url: str, + ) -> dict: + """Create a Stripe checkout session.""" + line_items = [] + for item in cart.items: + line_items.append({ + "price_data": { + "currency": "usd", + "product_data": { + "name": item.product_name, + "description": f"Variant: {item.variant}" if item.variant else None, + }, + "unit_amount": int(item.unit_price * 100), # Convert to cents + }, + "quantity": item.quantity, + }) + + session = stripe.checkout.Session.create( + mode="payment", + line_items=line_items, + success_url=success_url + "?session_id={CHECKOUT_SESSION_ID}", + cancel_url=cancel_url, + shipping_address_collection={ + "allowed_countries": [ + "US", "CA", "GB", "AU", "DE", "FR", "NL", "BE", "AT", "CH", + "ES", "IT", "PT", "IE", "DK", "SE", "NO", "FI", "PL", "CZ", + ], + }, + metadata={ + "cart_id": str(cart.id), + }, + ) + + return { + "url": session.url, + "session_id": session.id, + } + + def verify_webhook(self, payload: bytes, sig_header: str) -> dict: + """Verify and parse Stripe webhook.""" + try: + event = stripe.Webhook.construct_event( + payload, + sig_header, + settings.stripe_webhook_secret, + ) + return event + except stripe.error.SignatureVerificationError as e: + raise ValueError(f"Invalid signature: {e}") + except Exception as e: + raise ValueError(f"Webhook error: {e}") + + async def get_session(self, session_id: str) -> dict: + """Get Stripe checkout session details.""" + session = stripe.checkout.Session.retrieve( + session_id, + expand=["line_items", "customer"], + ) + return session + + async def create_refund( + self, + payment_intent_id: str, + amount: int | None = None, + ) -> dict: + """Create a refund for a payment.""" + refund = stripe.Refund.create( + payment_intent=payment_intent_id, + amount=amount, # None = full refund + ) + return refund diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..a880827 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,54 @@ +[project] +name = "mycopunk-swag-store" +version = "0.1.0" +description = "E-commerce backend for mycopunk merchandise" +authors = [{ name = "Jeff Emmett", email = "jeff@mycofi.earth" }] +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.109.0", + "uvicorn[standard]>=0.27.0", + "sqlalchemy>=2.0.0", + "alembic>=1.13.0", + "asyncpg>=0.29.0", + "psycopg2-binary>=2.9.0", + "pydantic>=2.5.0", + "pydantic-settings>=2.1.0", + "python-jose[cryptography]>=3.3.0", + "passlib[bcrypt]>=1.7.4", + "httpx>=0.26.0", + "stripe>=7.0.0", + "pyyaml>=6.0.0", + "pillow>=10.0.0", + "python-multipart>=0.0.6", + "redis>=5.0.0", + "aiofiles>=23.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.23.0", + "httpx>=0.26.0", + "black>=24.0.0", + "ruff>=0.1.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] +ignore = ["E501"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..8315e90 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,32 @@ +# Core +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 + +# Database +sqlalchemy>=2.0.0 +alembic>=1.13.0 +asyncpg>=0.29.0 +psycopg2-binary>=2.9.0 + +# Validation +pydantic>=2.5.0 +pydantic-settings>=2.1.0 + +# Auth +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 + +# HTTP Client +httpx>=0.26.0 + +# Stripe +stripe>=7.0.0 + +# Config & Utils +pyyaml>=6.0.0 +pillow>=10.0.0 +python-multipart>=0.0.6 +aiofiles>=23.0.0 + +# Cache +redis>=5.0.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f355dc6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,109 @@ +version: "3.8" + +services: + # PostgreSQL Database + db: + image: postgres:16-alpine + container_name: swag-db + restart: unless-stopped + environment: + POSTGRES_USER: swag + POSTGRES_PASSWORD: ${DB_PASSWORD:-devpassword} + POSTGRES_DB: swag + volumes: + - swag-db-data:/var/lib/postgresql/data + networks: + - swag-internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U swag"] + interval: 5s + timeout: 5s + retries: 5 + ports: + - "5432:5432" # Expose for local dev, remove in production + + # Redis for sessions/cache (optional) + redis: + image: redis:7-alpine + container_name: swag-redis + restart: unless-stopped + volumes: + - swag-redis-data:/data + networks: + - swag-internal + ports: + - "6379:6379" # Expose for local dev + + # FastAPI Backend + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: swag-backend + restart: unless-stopped + environment: + - DATABASE_URL=postgresql://swag:${DB_PASSWORD:-devpassword}@db:5432/swag + - REDIS_URL=redis://redis:6379 + - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} + - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET} + - PRODIGI_API_KEY=${PRODIGI_API_KEY} + - PRINTFUL_API_TOKEN=${PRINTFUL_API_TOKEN} + - POD_SANDBOX_MODE=${POD_SANDBOX_MODE:-true} + - JWT_SECRET=${JWT_SECRET:-dev-secret-change-me} + - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000} + - DESIGNS_PATH=/app/designs + - CONFIG_PATH=/app/config + volumes: + # Mount designs from mycopunk-swag repo + - ${DESIGNS_PATH:-../mycopunk-swag/designs}:/app/designs:ro + - ${CONFIG_PATH:-../mycopunk-swag/config}:/app/config:ro + depends_on: + db: + condition: service_healthy + networks: + - swag-internal + - traefik-public + ports: + - "8000:8000" # Expose for local dev + labels: + - "traefik.enable=true" + - "traefik.http.routers.swag-api.rule=Host(`swag.mycofi.earth`) && PathPrefix(`/api`)" + - "traefik.http.routers.swag-api.entrypoints=web" + - "traefik.http.services.swag-api.loadbalancer.server.port=8000" + - "traefik.docker.network=traefik-public" + + # Next.js Frontend + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api} + - NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY} + container_name: swag-frontend + restart: unless-stopped + environment: + - NODE_ENV=production + depends_on: + - backend + networks: + - swag-internal + - traefik-public + ports: + - "3000:3000" # Expose for local dev + labels: + - "traefik.enable=true" + - "traefik.http.routers.swag-web.rule=Host(`swag.mycofi.earth`)" + - "traefik.http.routers.swag-web.entrypoints=web" + - "traefik.http.services.swag-web.loadbalancer.server.port=3000" + - "traefik.docker.network=traefik-public" + +volumes: + swag-db-data: + swag-redis-data: + +networks: + swag-internal: + driver: bridge + traefik-public: + external: true diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ac81c11 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,45 @@ +# Stage 1: Dependencies +FROM node:20-alpine AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY package.json pnpm-lock.yaml* ./ +RUN corepack enable pnpm && pnpm i --frozen-lockfile || npm install + +# Stage 2: Builder +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NEXT_TELEMETRY_DISABLED=1 + +ARG NEXT_PUBLIC_API_URL +ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY + +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..d4b4098 --- /dev/null +++ b/frontend/app/cart/page.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useState, useEffect } from "react"; + +interface CartItem { + id: string; + product_slug: string; + product_name: string; + variant: 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); + + useEffect(() => { + const cartId = localStorage.getItem("cart_id"); + if (cartId) { + fetch( + `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/cart/${cartId}` + ) + .then((res) => (res.ok ? res.json() : null)) + .then(setCart) + .finally(() => setLoading(false)); + } else { + setLoading(false); + } + }, []); + + const handleCheckout = async () => { + if (!cart) return; + setCheckingOut(true); + + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/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; + } + } catch (error) { + console.error("Checkout error:", error); + } finally { + setCheckingOut(false); + } + }; + + if (loading) { + return ( +
+

Loading cart...

+
+ ); + } + + if (!cart || cart.items.length === 0) { + return ( +
+

Your Cart

+

Your cart is empty.

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

Your Cart

+ +
+
+ {cart.items.map((item) => ( +
+
+
+

{item.product_name}

+ {item.variant && ( +

+ Variant: {item.variant} +

+ )} +

Qty: {item.quantity}

+
+
+

${item.subtotal.toFixed(2)}

+
+
+ ))} +
+ +
+

Order Summary

+
+
+ Subtotal + ${cart.subtotal.toFixed(2)} +
+
+ Shipping + Calculated at checkout +
+
+
+
+ Total + ${cart.subtotal.toFixed(2)} +
+
+ +
+
+
+ ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..7f0b1d3 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,59 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 142 76% 36%; + --primary-foreground: 355.7 100% 97.3%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 142 76% 36%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 142 70% 45%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 142 76% 36%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..c620c3a --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Mycopunk Swag Store", + description: "Mycelial merchandise for the decentralized future", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + +
+
+ +
+
{children}
+
+
+

© 2026 Mycopunk. Build tools, not empires.

+
+
+
+ + + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..b9edb5f --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,42 @@ +import Link from "next/link"; + +export default function HomePage() { + return ( +
+
+

+ Mycopunk Swag Store +

+

+ Mycelial merchandise for the decentralized future. Stickers, shirts, + and more featuring designs from the mycopunk movement. +

+
+ + Browse Products + +
+
+ +
+

Featured

+
+
+
+ Coming Soon +
+
+

Build Tools Not Empires

+

Sticker

+

$3.50

+
+
+ {/* More products will be loaded from API */} +
+
+
+ ); +} diff --git a/frontend/app/products/page.tsx b/frontend/app/products/page.tsx new file mode 100644 index 0000000..9236fbf --- /dev/null +++ b/frontend/app/products/page.tsx @@ -0,0 +1,71 @@ +import Link from "next/link"; + +interface Product { + slug: string; + name: string; + description: string; + category: string; + product_type: string; + image_url: string; + base_price: number; +} + +async function getProducts(): Promise { + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/products`, + { next: { revalidate: 3600 } } + ); + if (!res.ok) return []; + return res.json(); + } catch { + return []; + } +} + +export default async function ProductsPage() { + const products = await getProducts(); + + return ( +
+

Products

+ + {products.length === 0 ? ( +
+

+ No products available yet. Check back soon! +

+
+ ) : ( +
+ {products.map((product) => ( + +
+
+ {product.name} +
+
+

{product.name}

+

+ {product.product_type} +

+

+ ${product.base_price.toFixed(2)} +

+
+
+ + ))} +
+ )} +
+ ); +} diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs new file mode 100644 index 0000000..15ccb97 --- /dev/null +++ b/frontend/next.config.mjs @@ -0,0 +1,18 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'swag.mycofi.earth', + }, + { + protocol: 'http', + hostname: 'localhost', + }, + ], + }, +}; + +export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b4554f9 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,40 @@ +{ + "name": "mycopunk-swag-store-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-radio-group": "^1.2.2", + "@radix-ui/react-select": "^2.1.4", + "@radix-ui/react-separator": "^1.1.1", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.2", + "@radix-ui/react-toast": "^1.2.4", + "@stripe/stripe-js": "^2.4.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.469.0", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.0" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..2ef30fc --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +export default config; diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..5607f96 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,54 @@ +import type { Config } from "tailwindcss"; + +export default { + darkMode: ["class"], + content: [ + "./app/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + colors: { + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +} satisfies Config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..d8b9323 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}