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 <noreply@anthropic.com>
This commit is contained in:
commit
b924e41ce8
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
```
|
||||||
|
|
@ -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/)
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# Mycopunk Swag Store Backend
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""API routes."""
|
||||||
|
|
||||||
|
from app.api import designs, products, cart, checkout, orders, webhooks, health
|
||||||
|
|
@ -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"])
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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)}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"],
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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",
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Business logic services."""
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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<Cart | null>(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 (
|
||||||
|
<div className="container mx-auto px-4 py-16 text-center">
|
||||||
|
<p>Loading cart...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cart || cart.items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-16 text-center">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Your Cart</h1>
|
||||||
|
<p className="text-muted-foreground mb-8">Your cart is empty.</p>
|
||||||
|
<a href="/products" className="text-primary hover:underline">
|
||||||
|
Continue Shopping
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-8">Your Cart</h1>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-8">
|
||||||
|
<div className="md:col-span-2 space-y-4">
|
||||||
|
{cart.items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-center gap-4 p-4 border rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="w-20 h-20 bg-muted rounded" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold">{item.product_name}</h3>
|
||||||
|
{item.variant && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Variant: {item.variant}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm">Qty: {item.quantity}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-bold">${item.subtotal.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg p-6 h-fit">
|
||||||
|
<h2 className="font-bold mb-4">Order Summary</h2>
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Subtotal</span>
|
||||||
|
<span>${cart.subtotal.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-muted-foreground">
|
||||||
|
<span>Shipping</span>
|
||||||
|
<span>Calculated at checkout</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t pt-4 mb-4">
|
||||||
|
<div className="flex justify-between font-bold">
|
||||||
|
<span>Total</span>
|
||||||
|
<span>${cart.subtotal.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCheckout}
|
||||||
|
disabled={checkingOut}
|
||||||
|
className="w-full bg-primary text-primary-foreground py-3 rounded-md font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{checkingOut ? "Redirecting..." : "Checkout"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<html lang="en">
|
||||||
|
<body className={inter.className}>
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<header className="border-b">
|
||||||
|
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
|
<a href="/" className="text-xl font-bold text-primary">
|
||||||
|
Mycopunk Swag
|
||||||
|
</a>
|
||||||
|
<nav className="flex items-center gap-6">
|
||||||
|
<a href="/products" className="hover:text-primary">
|
||||||
|
Products
|
||||||
|
</a>
|
||||||
|
<a href="/cart" className="hover:text-primary">
|
||||||
|
Cart
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
<footer className="border-t py-6">
|
||||||
|
<div className="container mx-auto px-4 text-center text-muted-foreground">
|
||||||
|
<p>© 2026 Mycopunk. Build tools, not empires.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-16">
|
||||||
|
<div className="max-w-3xl mx-auto text-center">
|
||||||
|
<h1 className="text-4xl md:text-6xl font-bold mb-6">
|
||||||
|
Mycopunk Swag Store
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-muted-foreground mb-8">
|
||||||
|
Mycelial merchandise for the decentralized future. Stickers, shirts,
|
||||||
|
and more featuring designs from the mycopunk movement.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4 justify-center">
|
||||||
|
<Link
|
||||||
|
href="/products"
|
||||||
|
className="inline-flex items-center justify-center rounded-md bg-primary px-8 py-3 text-lg font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Browse Products
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-24">
|
||||||
|
<h2 className="text-2xl font-bold text-center mb-12">Featured</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
<div className="border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
|
||||||
|
<div className="aspect-square bg-muted flex items-center justify-center">
|
||||||
|
<span className="text-muted-foreground">Coming Soon</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="font-semibold">Build Tools Not Empires</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Sticker</p>
|
||||||
|
<p className="font-bold mt-2">$3.50</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* More products will be loaded from API */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
product_type: string;
|
||||||
|
image_url: string;
|
||||||
|
base_price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProducts(): Promise<Product[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/products`,
|
||||||
|
{ next: { revalidate: 3600 } }
|
||||||
|
);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return res.json();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductsPage() {
|
||||||
|
const products = await getProducts();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-8">Products</h1>
|
||||||
|
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
No products available yet. Check back soon!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||||
|
{products.map((product) => (
|
||||||
|
<Link
|
||||||
|
key={product.slug}
|
||||||
|
href={`/products/${product.slug}`}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<div className="border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
|
||||||
|
<div className="aspect-square bg-muted relative overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/designs/${product.slug}/image`}
|
||||||
|
alt={product.name}
|
||||||
|
className="object-cover w-full h-full group-hover:scale-105 transition-transform"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="font-semibold truncate">{product.name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground capitalize">
|
||||||
|
{product.product_type}
|
||||||
|
</p>
|
||||||
|
<p className="font-bold mt-2">
|
||||||
|
${product.base_price.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue