Initial commit: rSwag - merch store for the rSpace ecosystem
Forked from mycopunk-swag-store and rebranded for rSpace: - Next.js 15 + FastAPI + PostgreSQL + Stripe - Printful + Prodigi POD integration - AI design generation via Gemini API - rSpace color scheme (cyan/orange) and branding - In-repo designs directory (stickers, shirts, misc) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
72f072f233
|
|
@ -0,0 +1,31 @@
|
|||
# Database
|
||||
DB_PASSWORD=change_me_in_production
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY=sk_test_xxx
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_xxx
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
|
||||
# POD Providers
|
||||
PRODIGI_API_KEY=xxx
|
||||
PRINTFUL_API_TOKEN=xxx
|
||||
POD_SANDBOX_MODE=true
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=generate_a_strong_secret_here
|
||||
|
||||
# App
|
||||
CORS_ORIGINS=https://rswag.online
|
||||
|
||||
# AI Design Generation
|
||||
GEMINI_API_KEY=xxx
|
||||
|
||||
# SMTP Email (Mailcow)
|
||||
SMTP_HOST=mx.jeffemmett.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=noreply@jeffemmett.com
|
||||
SMTP_PASS=changeme
|
||||
|
||||
# Frontend
|
||||
NEXT_PUBLIC_API_URL=https://rswag.online/api
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
# Python lib directories (not frontend/lib)
|
||||
/lib/
|
||||
/lib64/
|
||||
backend/lib/
|
||||
backend/lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
.pnpm-store/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# Test
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.tox/
|
||||
|
||||
# Build
|
||||
*.pyc
|
||||
.cache/
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
# rSwag - AI Assistant Context
|
||||
|
||||
## Project Overview
|
||||
|
||||
E-commerce platform for rSpace ecosystem merchandise (stickers, shirts, prints) with Stripe payments and print-on-demand fulfillment via Printful and Prodigi. Part of the rSpace ecosystem (rspace.online).
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Frontend**: Next.js 15 App Router, shadcn/ui, Tailwind CSS, Geist font
|
||||
- **Backend**: FastAPI, SQLAlchemy, Alembic
|
||||
- **Database**: PostgreSQL
|
||||
- **Payments**: Stripe Checkout (redirect flow)
|
||||
- **Fulfillment**: Printful (apparel), Prodigi (stickers/prints)
|
||||
- **AI Design**: Gemini API for design generation
|
||||
- **Deployment**: Docker on Netcup RS 8000, Traefik routing
|
||||
|
||||
## Key Directories
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `backend/app/api/` | FastAPI route handlers |
|
||||
| `backend/app/models/` | SQLAlchemy ORM models |
|
||||
| `backend/app/schemas/` | Pydantic request/response schemas |
|
||||
| `backend/app/services/` | Business logic (stripe, pod, orders) |
|
||||
| `backend/app/pod/` | POD provider clients |
|
||||
| `frontend/app/` | Next.js App Router pages |
|
||||
| `frontend/components/` | React components |
|
||||
| `designs/` | Design assets (stickers, shirts, misc) |
|
||||
|
||||
## Design Source
|
||||
|
||||
Designs are stored in-repo at `./designs/` and mounted into the backend container.
|
||||
|
||||
Each design has a `metadata.yaml` with name, description, products, variants, and pricing.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Public
|
||||
- `GET /api/designs` - List active designs
|
||||
- `GET /api/designs/{slug}` - Get design details
|
||||
- `GET /api/designs/{slug}/image` - Serve design image
|
||||
- `GET /api/products` - List products with variants
|
||||
- `POST /api/cart` - Create cart
|
||||
- `GET/POST/DELETE /api/cart/{id}/items` - Cart operations
|
||||
- `POST /api/checkout/session` - Create Stripe checkout
|
||||
- `GET /api/orders/{id}` - Order status (requires email)
|
||||
- `POST /api/design/generate` - AI design generation
|
||||
|
||||
### Webhooks
|
||||
- `POST /api/webhooks/stripe` - Stripe payment events
|
||||
- `POST /api/webhooks/prodigi` - Prodigi fulfillment updates
|
||||
- `POST /api/webhooks/printful` - Printful fulfillment updates
|
||||
|
||||
### Admin (JWT required)
|
||||
- `POST /api/admin/auth/login` - Admin login
|
||||
- `GET /api/admin/orders` - List orders
|
||||
- `GET /api/admin/analytics/*` - Sales metrics
|
||||
|
||||
## Deployment
|
||||
|
||||
Push to Gitea triggers webhook auto-deploy on Netcup at `/opt/apps/rswag/`.
|
||||
|
||||
## Branding
|
||||
|
||||
- **Primary color**: Cyan (HSL 195 80% 45%)
|
||||
- **Secondary color**: Orange (HSL 45 80% 55%)
|
||||
- **Font**: Geist Sans + Geist Mono
|
||||
- **Theme**: rSpace spatial web aesthetic
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
# rSwag
|
||||
|
||||
Merchandise store for the **rSpace ecosystem** at **rswag.online**
|
||||
|
||||
## Stack
|
||||
|
||||
- **Frontend**: Next.js 15 + shadcn/ui + Tailwind CSS + Geist font
|
||||
- **Backend**: FastAPI + SQLAlchemy + Alembic
|
||||
- **Database**: PostgreSQL
|
||||
- **Payments**: Stripe Checkout
|
||||
- **Fulfillment**: Printful (apparel) + Prodigi (stickers/prints)
|
||||
- **AI Design**: Gemini API for on-demand design generation
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
rswag.online
|
||||
│
|
||||
▼
|
||||
Cloudflare Tunnel → Traefik
|
||||
│ │
|
||||
▼ ▼
|
||||
Next.js (3000) FastAPI (8000)
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
PostgreSQL Stripe POD APIs
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your API keys
|
||||
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
|
||||
# Backend: http://localhost:8000
|
||||
# Frontend: http://localhost:3000
|
||||
```
|
||||
|
||||
### Local Development (without Docker)
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
pip install -e .
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
# Frontend
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
rswag/
|
||||
├── backend/ # FastAPI Python backend
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # Route handlers
|
||||
│ │ ├── models/ # SQLAlchemy ORM
|
||||
│ │ ├── schemas/ # Pydantic models
|
||||
│ │ ├── services/ # Business logic
|
||||
│ │ └── pod/ # POD provider clients
|
||||
│ └── alembic/ # Database migrations
|
||||
├── frontend/ # Next.js 15 frontend
|
||||
│ ├── app/ # App Router pages
|
||||
│ ├── components/ # React components
|
||||
│ └── lib/ # Utilities
|
||||
├── designs/ # Design assets (in-repo)
|
||||
│ ├── stickers/
|
||||
│ ├── shirts/
|
||||
│ └── misc/
|
||||
└── config/ # POD provider config
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Deployed on Netcup RS 8000 via Docker Compose with Traefik reverse proxy.
|
||||
|
||||
```bash
|
||||
ssh netcup "cd /opt/apps/rswag && git pull && docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build"
|
||||
```
|
||||
|
|
@ -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 the designs directory (admin only)."""
|
||||
# Clear any caches and reload
|
||||
design_service.clear_cache()
|
||||
designs = await design_service.list_designs()
|
||||
return {"status": "synced", "count": len(designs)}
|
||||
|
|
@ -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,229 @@
|
|||
"""AI design generation API."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import get_settings
|
||||
from app.api.designs import design_service
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class DesignRequest(BaseModel):
|
||||
"""Request to generate a new design."""
|
||||
concept: str
|
||||
name: str
|
||||
tags: list[str] = []
|
||||
product_type: str = "sticker"
|
||||
|
||||
|
||||
class DesignResponse(BaseModel):
|
||||
"""Response with generated design info."""
|
||||
slug: str
|
||||
name: str
|
||||
image_url: str
|
||||
status: str
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
"""Convert text to URL-friendly slug."""
|
||||
text = text.lower().strip()
|
||||
text = re.sub(r'[^\w\s-]', '', text)
|
||||
text = re.sub(r'[\s_-]+', '-', text)
|
||||
text = re.sub(r'^-+|-+$', '', text)
|
||||
return text
|
||||
|
||||
|
||||
@router.post("/generate", response_model=DesignResponse)
|
||||
async def generate_design(request: DesignRequest):
|
||||
"""Generate a new design using AI."""
|
||||
|
||||
gemini_api_key = os.environ.get("GEMINI_API_KEY", "")
|
||||
if not gemini_api_key:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="AI generation not configured. Set GEMINI_API_KEY."
|
||||
)
|
||||
|
||||
# Create slug from name
|
||||
slug = slugify(request.name)
|
||||
if not slug:
|
||||
slug = f"design-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Check if design already exists
|
||||
design_dir = settings.designs_dir / "stickers" / slug
|
||||
if design_dir.exists():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Design '{slug}' already exists"
|
||||
)
|
||||
|
||||
# Build the image generation prompt
|
||||
style_prompt = f"""A striking sticker design for "{request.name}".
|
||||
{request.concept}
|
||||
The design should have a clean, modern spatial-web aesthetic with interconnected
|
||||
nodes, network patterns, and a collaborative/commons feel.
|
||||
Colors: vibrant cyan, warm orange accents on dark background.
|
||||
High contrast, suitable for vinyl sticker printing.
|
||||
Square format, clean edges for die-cut sticker."""
|
||||
|
||||
# Call Gemini API for image generation
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
# Use gemini-3-pro-image-preview for image generation
|
||||
response = await client.post(
|
||||
f"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent?key={gemini_api_key}",
|
||||
json={
|
||||
"contents": [{
|
||||
"parts": [{
|
||||
"text": style_prompt
|
||||
}]
|
||||
}],
|
||||
"generationConfig": {
|
||||
"responseModalities": ["image", "text"]
|
||||
}
|
||||
},
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
error_detail = response.text[:500] if response.text else "Unknown error"
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"AI generation failed ({response.status_code}): {error_detail}"
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
|
||||
# Extract image data from response
|
||||
image_data = None
|
||||
for candidate in result.get("candidates", []):
|
||||
for part in candidate.get("content", {}).get("parts", []):
|
||||
if "inlineData" in part:
|
||||
image_data = part["inlineData"]["data"]
|
||||
break
|
||||
if image_data:
|
||||
break
|
||||
|
||||
if not image_data:
|
||||
# Log what we got for debugging
|
||||
import json
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"AI did not return an image. Response: {json.dumps(result)[:500]}"
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail="AI generation timed out"
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"AI generation error: {str(e)}"
|
||||
)
|
||||
|
||||
# Create design directory
|
||||
design_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save image
|
||||
import base64
|
||||
image_path = design_dir / f"{slug}.png"
|
||||
image_bytes = base64.b64decode(image_data)
|
||||
image_path.write_bytes(image_bytes)
|
||||
|
||||
# Create metadata.yaml
|
||||
# Escape quotes in user-provided strings to prevent YAML parsing errors
|
||||
safe_name = request.name.replace('"', '\\"')
|
||||
safe_concept = request.concept.replace('"', '\\"')
|
||||
tags_str = ", ".join(request.tags) if request.tags else "rspace, sticker, ai-generated"
|
||||
metadata_content = f"""name: "{safe_name}"
|
||||
slug: {slug}
|
||||
description: "{safe_concept}"
|
||||
tags: [{tags_str}]
|
||||
created: {date.today().isoformat()}
|
||||
author: ai-generated
|
||||
|
||||
source:
|
||||
file: {slug}.png
|
||||
format: png
|
||||
dimensions:
|
||||
width: 1024
|
||||
height: 1024
|
||||
dpi: 300
|
||||
color_profile: sRGB
|
||||
|
||||
products:
|
||||
- type: sticker
|
||||
provider: prodigi
|
||||
sku: GLOBAL-STI-KIS-3X3
|
||||
variants: [matte, gloss]
|
||||
retail_price: 3.50
|
||||
|
||||
status: draft
|
||||
"""
|
||||
|
||||
metadata_path = design_dir / "metadata.yaml"
|
||||
metadata_path.write_text(metadata_content)
|
||||
|
||||
return DesignResponse(
|
||||
slug=slug,
|
||||
name=request.name,
|
||||
image_url=f"/api/designs/{slug}/image",
|
||||
status="draft"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{slug}/activate")
|
||||
async def activate_design(slug: str):
|
||||
"""Activate a draft design to make it visible in the store."""
|
||||
|
||||
design_dir = settings.designs_dir / "stickers" / slug
|
||||
metadata_path = design_dir / "metadata.yaml"
|
||||
|
||||
if not metadata_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Design not found")
|
||||
|
||||
# Read and update metadata
|
||||
content = metadata_path.read_text()
|
||||
content = content.replace("status: draft", "status: active")
|
||||
metadata_path.write_text(content)
|
||||
|
||||
# Clear the design service cache so the new status is picked up
|
||||
design_service.clear_cache()
|
||||
|
||||
return {"status": "activated", "slug": slug}
|
||||
|
||||
|
||||
@router.delete("/{slug}")
|
||||
async def delete_design(slug: str):
|
||||
"""Delete a design (only drafts can be deleted)."""
|
||||
import shutil
|
||||
|
||||
design_dir = settings.designs_dir / "stickers" / slug
|
||||
metadata_path = design_dir / "metadata.yaml"
|
||||
|
||||
if not metadata_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Design not found")
|
||||
|
||||
# Check if draft
|
||||
content = metadata_path.read_text()
|
||||
if "status: active" in content:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete active designs. Set to draft first."
|
||||
)
|
||||
|
||||
# Delete directory
|
||||
shutil.rmtree(design_dir)
|
||||
|
||||
return {"status": "deleted", "slug": slug}
|
||||
|
|
@ -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 = "rSwag"
|
||||
debug: bool = False
|
||||
|
||||
@property
|
||||
def designs_dir(self) -> Path:
|
||||
return Path(self.designs_path)
|
||||
|
||||
@property
|
||||
def config_dir(self) -> Path:
|
||||
return Path(self.config_path)
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.cors_origins.split(",")]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance."""
|
||||
return Settings()
|
||||
|
|
@ -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,62 @@
|
|||
"""FastAPI application entry point."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import get_settings
|
||||
from app.api import designs, products, cart, checkout, orders, webhooks, health, design_generator
|
||||
from app.api.admin import router as admin_router
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan handler."""
|
||||
# Startup
|
||||
print(f"Starting {settings.app_name}...")
|
||||
print(f"Designs path: {settings.designs_path}")
|
||||
print(f"POD sandbox mode: {settings.pod_sandbox_mode}")
|
||||
yield
|
||||
# Shutdown
|
||||
print("Shutting down...")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
description="E-commerce API for rSpace ecosystem merchandise",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(health.router, prefix="/api", tags=["health"])
|
||||
app.include_router(designs.router, prefix="/api/designs", tags=["designs"])
|
||||
app.include_router(products.router, prefix="/api/products", tags=["products"])
|
||||
app.include_router(cart.router, prefix="/api/cart", tags=["cart"])
|
||||
app.include_router(checkout.router, prefix="/api/checkout", tags=["checkout"])
|
||||
app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
|
||||
app.include_router(webhooks.router, prefix="/api/webhooks", tags=["webhooks"])
|
||||
app.include_router(design_generator.router, prefix="/api/design", tags=["design-generator"])
|
||||
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint."""
|
||||
return {
|
||||
"name": settings.app_name,
|
||||
"version": "0.1.0",
|
||||
"docs": "/docs",
|
||||
}
|
||||
|
|
@ -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,146 @@
|
|||
"""Cart service for managing shopping carts."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.cart import Cart, CartItem
|
||||
from app.schemas.cart import CartItemCreate, CartResponse, CartItemResponse
|
||||
|
||||
|
||||
class CartService:
|
||||
"""Service for cart operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def create_cart(self) -> CartResponse:
|
||||
"""Create a new shopping cart."""
|
||||
cart = Cart()
|
||||
self.db.add(cart)
|
||||
await self.db.commit()
|
||||
# Re-fetch with items loaded to avoid lazy loading issues
|
||||
result = await self.db.execute(
|
||||
select(Cart)
|
||||
.where(Cart.id == cart.id)
|
||||
.options(selectinload(Cart.items))
|
||||
)
|
||||
cart = result.scalar_one()
|
||||
return self._cart_to_response(cart)
|
||||
|
||||
async def get_cart(self, cart_id: UUID) -> CartResponse | None:
|
||||
"""Get cart by ID."""
|
||||
result = await self.db.execute(
|
||||
select(Cart)
|
||||
.where(Cart.id == cart_id)
|
||||
.options(selectinload(Cart.items))
|
||||
)
|
||||
cart = result.scalar_one_or_none()
|
||||
if not cart:
|
||||
return None
|
||||
return self._cart_to_response(cart)
|
||||
|
||||
async def add_item(
|
||||
self,
|
||||
cart_id: UUID,
|
||||
item: CartItemCreate,
|
||||
) -> CartResponse | None:
|
||||
"""Add item to cart."""
|
||||
result = await self.db.execute(
|
||||
select(Cart)
|
||||
.where(Cart.id == cart_id)
|
||||
.options(selectinload(Cart.items))
|
||||
)
|
||||
cart = result.scalar_one_or_none()
|
||||
if not cart:
|
||||
return None
|
||||
|
||||
# Check if item already exists (same product + variant)
|
||||
for existing in cart.items:
|
||||
if (
|
||||
existing.product_slug == item.product_slug
|
||||
and existing.variant == item.variant
|
||||
):
|
||||
existing.quantity += item.quantity
|
||||
await self.db.commit()
|
||||
return await self.get_cart(cart_id)
|
||||
|
||||
# Add new item
|
||||
cart_item = CartItem(
|
||||
cart_id=cart_id,
|
||||
product_slug=item.product_slug,
|
||||
product_name=item.product_name,
|
||||
variant=item.variant,
|
||||
quantity=item.quantity,
|
||||
unit_price=item.unit_price,
|
||||
)
|
||||
self.db.add(cart_item)
|
||||
await self.db.commit()
|
||||
return await self.get_cart(cart_id)
|
||||
|
||||
async def update_item(
|
||||
self,
|
||||
cart_id: UUID,
|
||||
item_id: UUID,
|
||||
quantity: int,
|
||||
) -> CartResponse | None:
|
||||
"""Update cart item quantity."""
|
||||
result = await self.db.execute(
|
||||
select(CartItem)
|
||||
.where(CartItem.id == item_id, CartItem.cart_id == cart_id)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
return None
|
||||
|
||||
if quantity <= 0:
|
||||
await self.db.delete(item)
|
||||
else:
|
||||
item.quantity = quantity
|
||||
|
||||
await self.db.commit()
|
||||
return await self.get_cart(cart_id)
|
||||
|
||||
async def remove_item(
|
||||
self,
|
||||
cart_id: UUID,
|
||||
item_id: UUID,
|
||||
) -> CartResponse | None:
|
||||
"""Remove item from cart."""
|
||||
result = await self.db.execute(
|
||||
select(CartItem)
|
||||
.where(CartItem.id == item_id, CartItem.cart_id == cart_id)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
return None
|
||||
|
||||
await self.db.delete(item)
|
||||
await self.db.commit()
|
||||
return await self.get_cart(cart_id)
|
||||
|
||||
def _cart_to_response(self, cart: Cart) -> CartResponse:
|
||||
"""Convert Cart model to response schema."""
|
||||
items = [
|
||||
CartItemResponse(
|
||||
id=item.id,
|
||||
product_slug=item.product_slug,
|
||||
product_name=item.product_name,
|
||||
variant=item.variant,
|
||||
quantity=item.quantity,
|
||||
unit_price=float(item.unit_price),
|
||||
subtotal=float(item.unit_price) * item.quantity,
|
||||
)
|
||||
for item in cart.items
|
||||
]
|
||||
|
||||
return CartResponse(
|
||||
id=cart.id,
|
||||
items=items,
|
||||
item_count=sum(item.quantity for item in items),
|
||||
subtotal=sum(item.subtotal for item in items),
|
||||
created_at=cart.created_at,
|
||||
expires_at=cart.expires_at,
|
||||
)
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
"""Design service for reading designs from the designs directory."""
|
||||
|
||||
from pathlib import Path
|
||||
from functools import lru_cache
|
||||
|
||||
import yaml
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.schemas.design import Design, DesignSource, DesignProduct
|
||||
from app.schemas.product import Product, ProductVariant
|
||||
from app.models.product import ProductOverride
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class DesignService:
|
||||
"""Service for reading and managing designs."""
|
||||
|
||||
def __init__(self):
|
||||
self.designs_path = settings.designs_dir
|
||||
self._cache: dict[str, Design] = {}
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the design cache."""
|
||||
self._cache.clear()
|
||||
|
||||
async def list_designs(
|
||||
self,
|
||||
status: str = "active",
|
||||
category: str | None = None,
|
||||
) -> list[Design]:
|
||||
"""List all designs from the designs directory."""
|
||||
designs = []
|
||||
|
||||
if not self.designs_path.exists():
|
||||
return designs
|
||||
|
||||
for category_dir in self.designs_path.iterdir():
|
||||
if not category_dir.is_dir():
|
||||
continue
|
||||
|
||||
# Filter by category if specified
|
||||
if category and category_dir.name != category:
|
||||
continue
|
||||
|
||||
for design_dir in category_dir.iterdir():
|
||||
if not design_dir.is_dir():
|
||||
continue
|
||||
|
||||
design = await self._load_design(design_dir, category_dir.name)
|
||||
if design and design.status == status:
|
||||
designs.append(design)
|
||||
|
||||
return designs
|
||||
|
||||
async def get_design(self, slug: str) -> Design | None:
|
||||
"""Get a single design by slug."""
|
||||
# Check cache
|
||||
if slug in self._cache:
|
||||
return self._cache[slug]
|
||||
|
||||
# Search for the design
|
||||
for category_dir in self.designs_path.iterdir():
|
||||
if not category_dir.is_dir():
|
||||
continue
|
||||
|
||||
design_dir = category_dir / slug
|
||||
if design_dir.exists():
|
||||
design = await self._load_design(design_dir, category_dir.name)
|
||||
if design:
|
||||
self._cache[slug] = design
|
||||
return design
|
||||
|
||||
return None
|
||||
|
||||
async def get_design_image_path(self, slug: str) -> str | None:
|
||||
"""Get the path to the design image file."""
|
||||
design = await self.get_design(slug)
|
||||
if not design:
|
||||
return None
|
||||
|
||||
# Look for exported PNG first
|
||||
for category_dir in self.designs_path.iterdir():
|
||||
if not category_dir.is_dir():
|
||||
continue
|
||||
|
||||
design_dir = category_dir / slug
|
||||
if not design_dir.exists():
|
||||
continue
|
||||
|
||||
# Check exports/300dpi first
|
||||
export_path = design_dir / "exports" / "300dpi" / f"{slug}.png"
|
||||
if export_path.exists():
|
||||
return str(export_path)
|
||||
|
||||
# Check for source PNG
|
||||
source_path = design_dir / design.source.file
|
||||
if source_path.exists() and source_path.suffix.lower() == ".png":
|
||||
return str(source_path)
|
||||
|
||||
# Check for any PNG in the directory
|
||||
for png_file in design_dir.glob("*.png"):
|
||||
return str(png_file)
|
||||
|
||||
return None
|
||||
|
||||
async def _load_design(self, design_dir: Path, category: str) -> Design | None:
|
||||
"""Load a design from its directory."""
|
||||
metadata_path = design_dir / "metadata.yaml"
|
||||
if not metadata_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(metadata_path) as f:
|
||||
metadata = yaml.safe_load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not metadata:
|
||||
return None
|
||||
|
||||
slug = metadata.get("slug", design_dir.name)
|
||||
|
||||
# Parse source info
|
||||
source_data = metadata.get("source", {})
|
||||
source = DesignSource(
|
||||
file=source_data.get("file", f"{slug}.svg"),
|
||||
format=source_data.get("format", "svg"),
|
||||
dimensions=source_data.get("dimensions", {"width": 0, "height": 0}),
|
||||
dpi=source_data.get("dpi", 300),
|
||||
color_profile=source_data.get("color_profile", "sRGB"),
|
||||
)
|
||||
|
||||
# Parse products
|
||||
products = []
|
||||
for p in metadata.get("products", []):
|
||||
products.append(
|
||||
DesignProduct(
|
||||
type=p.get("type", ""),
|
||||
provider=p.get("provider", ""),
|
||||
sku=str(p.get("sku", "")), # Convert to string (some SKUs are integers)
|
||||
variants=p.get("variants", []),
|
||||
retail_price=float(p.get("retail_price", 0)),
|
||||
)
|
||||
)
|
||||
|
||||
return Design(
|
||||
slug=slug,
|
||||
name=metadata.get("name", slug),
|
||||
description=metadata.get("description", ""),
|
||||
tags=metadata.get("tags", []),
|
||||
category=category,
|
||||
author=metadata.get("author", ""),
|
||||
created=str(metadata.get("created", "")),
|
||||
source=source,
|
||||
products=products,
|
||||
status=metadata.get("status", "draft"),
|
||||
image_url=f"/api/designs/{slug}/image",
|
||||
)
|
||||
|
||||
async def list_products(
|
||||
self,
|
||||
category: str | None = None,
|
||||
product_type: str | None = None,
|
||||
) -> list[Product]:
|
||||
"""List all products (designs formatted for storefront)."""
|
||||
designs = await self.list_designs(status="active", category=category)
|
||||
products = []
|
||||
|
||||
for design in designs:
|
||||
# Skip designs with no products
|
||||
if not design.products:
|
||||
continue
|
||||
|
||||
# Filter by product type if specified
|
||||
matching_products = [
|
||||
dp for dp in design.products
|
||||
if not product_type or dp.type == product_type
|
||||
]
|
||||
|
||||
if not matching_products:
|
||||
continue
|
||||
|
||||
# Use the first matching product for base info, combine all variants
|
||||
dp = matching_products[0]
|
||||
all_variants = []
|
||||
|
||||
for mp in matching_products:
|
||||
if mp.variants:
|
||||
for v in mp.variants:
|
||||
all_variants.append(
|
||||
ProductVariant(
|
||||
name=f"{v} ({mp.provider})",
|
||||
sku=f"{mp.sku}-{v}",
|
||||
provider=mp.provider,
|
||||
price=mp.retail_price,
|
||||
)
|
||||
)
|
||||
else:
|
||||
all_variants.append(
|
||||
ProductVariant(
|
||||
name=f"default ({mp.provider})",
|
||||
sku=mp.sku,
|
||||
provider=mp.provider,
|
||||
price=mp.retail_price,
|
||||
)
|
||||
)
|
||||
|
||||
products.append(
|
||||
Product(
|
||||
slug=design.slug,
|
||||
name=design.name,
|
||||
description=design.description,
|
||||
category=design.category,
|
||||
product_type=dp.type,
|
||||
tags=design.tags,
|
||||
image_url=design.image_url,
|
||||
base_price=dp.retail_price,
|
||||
variants=all_variants,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
|
||||
return products
|
||||
|
||||
async def get_product(self, slug: str) -> Product | None:
|
||||
"""Get a single product by slug."""
|
||||
design = await self.get_design(slug)
|
||||
if not design or not design.products:
|
||||
return None
|
||||
|
||||
# Use the first product configuration
|
||||
dp = design.products[0]
|
||||
variants = [
|
||||
ProductVariant(
|
||||
name=v,
|
||||
sku=f"{dp.sku}-{v}",
|
||||
provider=dp.provider,
|
||||
price=dp.retail_price,
|
||||
)
|
||||
for v in dp.variants
|
||||
] if dp.variants else [
|
||||
ProductVariant(
|
||||
name="default",
|
||||
sku=dp.sku,
|
||||
provider=dp.provider,
|
||||
price=dp.retail_price,
|
||||
)
|
||||
]
|
||||
|
||||
return Product(
|
||||
slug=design.slug,
|
||||
name=design.name,
|
||||
description=design.description,
|
||||
category=design.category,
|
||||
product_type=dp.type,
|
||||
tags=design.tags,
|
||||
image_url=design.image_url,
|
||||
base_price=dp.retail_price,
|
||||
variants=variants,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
async def set_product_override(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
slug: str,
|
||||
is_active: bool | None = None,
|
||||
price_override: float | None = None,
|
||||
):
|
||||
"""Set a product override in the database."""
|
||||
# Check if override exists
|
||||
result = await db.execute(
|
||||
select(ProductOverride).where(ProductOverride.slug == slug)
|
||||
)
|
||||
override = result.scalar_one_or_none()
|
||||
|
||||
if override:
|
||||
if is_active is not None:
|
||||
override.is_active = is_active
|
||||
if price_override is not None:
|
||||
override.price_override = price_override
|
||||
else:
|
||||
override = ProductOverride(
|
||||
slug=slug,
|
||||
is_active=is_active if is_active is not None else True,
|
||||
price_override=price_override,
|
||||
)
|
||||
db.add(override)
|
||||
|
||||
await db.commit()
|
||||
|
|
@ -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 = "rswag"
|
||||
version = "0.1.0"
|
||||
description = "E-commerce backend for rSpace ecosystem merchandise"
|
||||
authors = [{ name = "Jeff Emmett", email = "jeff@rspace.online" }]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.109.0",
|
||||
"uvicorn[standard]>=0.27.0",
|
||||
"sqlalchemy>=2.0.0",
|
||||
"alembic>=1.13.0",
|
||||
"asyncpg>=0.29.0",
|
||||
"psycopg2-binary>=2.9.0",
|
||||
"pydantic>=2.5.0",
|
||||
"pydantic-settings>=2.1.0",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"httpx>=0.26.0",
|
||||
"stripe>=7.0.0",
|
||||
"pyyaml>=6.0.0",
|
||||
"pillow>=10.0.0",
|
||||
"python-multipart>=0.0.6",
|
||||
"redis>=5.0.0",
|
||||
"aiofiles>=23.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"httpx>=0.26.0",
|
||||
"black>=24.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "W"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
|
@ -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,232 @@
|
|||
# rSwag Product Catalog Configuration
|
||||
# Maps design types to POD provider products
|
||||
|
||||
defaults:
|
||||
color_profile: sRGB
|
||||
resolution: 300
|
||||
format: png
|
||||
|
||||
# Sticker Products
|
||||
stickers:
|
||||
small:
|
||||
name: "3×3 Vinyl Sticker"
|
||||
dimensions:
|
||||
width: 3
|
||||
height: 3
|
||||
unit: inches
|
||||
pixels:
|
||||
width: 900
|
||||
height: 900
|
||||
providers:
|
||||
prodigi:
|
||||
sku: "GLOBAL-STI-KIS-3X3"
|
||||
variants:
|
||||
- id: matte
|
||||
name: "Matte Finish"
|
||||
- id: gloss
|
||||
name: "Gloss Finish"
|
||||
base_cost: 1.20
|
||||
printful:
|
||||
sku: 358 # Kiss-cut stickers
|
||||
variants:
|
||||
- id: white
|
||||
name: "White"
|
||||
base_cost: 1.50
|
||||
|
||||
medium:
|
||||
name: "4×4 Vinyl Sticker"
|
||||
dimensions:
|
||||
width: 4
|
||||
height: 4
|
||||
unit: inches
|
||||
pixels:
|
||||
width: 1200
|
||||
height: 1200
|
||||
providers:
|
||||
prodigi:
|
||||
sku: "GLOBAL-STI-KIS-4X4"
|
||||
variants:
|
||||
- id: matte
|
||||
name: "Matte Finish"
|
||||
- id: gloss
|
||||
name: "Gloss Finish"
|
||||
base_cost: 1.80
|
||||
|
||||
large:
|
||||
name: "6×6 Vinyl Sticker"
|
||||
dimensions:
|
||||
width: 6
|
||||
height: 6
|
||||
unit: inches
|
||||
pixels:
|
||||
width: 1800
|
||||
height: 1800
|
||||
providers:
|
||||
prodigi:
|
||||
sku: "GLOBAL-STI-KIS-6X6"
|
||||
base_cost: 2.50
|
||||
|
||||
# Apparel Products
|
||||
apparel:
|
||||
tshirt:
|
||||
name: "Unisex T-Shirt"
|
||||
print_areas:
|
||||
front:
|
||||
dimensions:
|
||||
width: 12
|
||||
height: 16
|
||||
unit: inches
|
||||
pixels:
|
||||
width: 3600
|
||||
height: 4800
|
||||
chest:
|
||||
dimensions:
|
||||
width: 4
|
||||
height: 4
|
||||
unit: inches
|
||||
pixels:
|
||||
width: 1200
|
||||
height: 1200
|
||||
providers:
|
||||
printful:
|
||||
sku: 71 # Bella + Canvas 3001
|
||||
sizes: [S, M, L, XL, 2XL, 3XL]
|
||||
colors:
|
||||
- id: black
|
||||
name: "Black"
|
||||
hex: "#0a0a0a"
|
||||
- id: white
|
||||
name: "White"
|
||||
hex: "#ffffff"
|
||||
- id: heather_charcoal
|
||||
name: "Heather Charcoal"
|
||||
hex: "#4a4a4a"
|
||||
- id: forest_green
|
||||
name: "Forest Green"
|
||||
hex: "#2d4a3e"
|
||||
- id: maroon
|
||||
name: "Maroon"
|
||||
hex: "#5a2d2d"
|
||||
base_cost:
|
||||
S: 9.25
|
||||
M: 9.25
|
||||
L: 9.25
|
||||
XL: 9.25
|
||||
2XL: 11.25
|
||||
3XL: 13.25
|
||||
|
||||
hoodie:
|
||||
name: "Unisex Hoodie"
|
||||
print_areas:
|
||||
front:
|
||||
dimensions:
|
||||
width: 14
|
||||
height: 16
|
||||
unit: inches
|
||||
pixels:
|
||||
width: 4200
|
||||
height: 4800
|
||||
providers:
|
||||
printful:
|
||||
sku: 146 # Bella + Canvas 3719
|
||||
sizes: [S, M, L, XL, 2XL]
|
||||
colors:
|
||||
- id: black
|
||||
name: "Black"
|
||||
- id: dark_grey_heather
|
||||
name: "Dark Grey Heather"
|
||||
base_cost:
|
||||
S: 23.95
|
||||
M: 23.95
|
||||
L: 23.95
|
||||
XL: 23.95
|
||||
2XL: 27.95
|
||||
|
||||
# Art Prints
|
||||
prints:
|
||||
small:
|
||||
name: "8×10 Art Print"
|
||||
dimensions:
|
||||
width: 8
|
||||
height: 10
|
||||
unit: inches
|
||||
pixels:
|
||||
width: 2400
|
||||
height: 3000
|
||||
providers:
|
||||
prodigi:
|
||||
sku: "GLOBAL-FAP-8X10"
|
||||
variants:
|
||||
- id: matte
|
||||
name: "Matte"
|
||||
- id: lustre
|
||||
name: "Lustre"
|
||||
base_cost: 4.50
|
||||
|
||||
medium:
|
||||
name: "11×14 Art Print"
|
||||
dimensions:
|
||||
width: 11
|
||||
height: 14
|
||||
unit: inches
|
||||
pixels:
|
||||
width: 3300
|
||||
height: 4200
|
||||
providers:
|
||||
prodigi:
|
||||
sku: "GLOBAL-FAP-11X14"
|
||||
base_cost: 7.00
|
||||
|
||||
large:
|
||||
name: "18×24 Art Print"
|
||||
dimensions:
|
||||
width: 18
|
||||
height: 24
|
||||
unit: inches
|
||||
pixels:
|
||||
width: 5400
|
||||
height: 7200
|
||||
providers:
|
||||
prodigi:
|
||||
sku: "GLOBAL-FAP-18X24"
|
||||
base_cost: 12.00
|
||||
|
||||
# Pricing Rules
|
||||
pricing:
|
||||
default_markup: 2.0 # 100% markup (double cost)
|
||||
|
||||
rules:
|
||||
stickers:
|
||||
markup: 2.5 # Higher margin on low-cost items
|
||||
minimum_price: 3.00
|
||||
|
||||
apparel:
|
||||
markup: 1.8
|
||||
minimum_price: 20.00
|
||||
|
||||
prints:
|
||||
markup: 2.0
|
||||
minimum_price: 15.00
|
||||
|
||||
# Round to nearest .99 or .50
|
||||
rounding: nearest_99
|
||||
|
||||
# Shipping Profiles
|
||||
shipping:
|
||||
prodigi:
|
||||
standard:
|
||||
name: "Standard"
|
||||
days: "5-10"
|
||||
express:
|
||||
name: "Express"
|
||||
days: "2-5"
|
||||
additional_cost: 5.00
|
||||
|
||||
printful:
|
||||
standard:
|
||||
name: "Standard"
|
||||
days: "5-12"
|
||||
express:
|
||||
name: "Express"
|
||||
days: "3-5"
|
||||
additional_cost: 7.00
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# Development overrides - use with: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||
|
||||
services:
|
||||
db:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
redis:
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
backend:
|
||||
volumes:
|
||||
# Mount source for hot reload
|
||||
- ./backend/app:/app/app:ro
|
||||
# Mount designs from in-repo designs dir
|
||||
- ./designs:/app/designs:ro
|
||||
- ./config:/app/config:ro
|
||||
environment:
|
||||
- DEBUG=true
|
||||
- POD_SANDBOX_MODE=true
|
||||
ports:
|
||||
- "8000:8000"
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
frontend:
|
||||
build:
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=http://localhost:8000/api
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
networks:
|
||||
rswag-internal:
|
||||
driver: bridge
|
||||
traefik-public:
|
||||
driver: bridge # Override external for local dev
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
services:
|
||||
backend:
|
||||
volumes:
|
||||
- /opt/apps/rswag/designs:/app/designs
|
||||
- /opt/apps/rswag/config:/app/config:ro
|
||||
environment:
|
||||
- DESIGNS_PATH=/app/designs
|
||||
- CONFIG_PATH=/app/config
|
||||
|
||||
frontend:
|
||||
build:
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=https://rswag.online/api
|
||||
|
||||
networks:
|
||||
rswag-internal:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 10.200.1.0/24
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
services:
|
||||
# PostgreSQL Database
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: rswag-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: rswag
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-devpassword}
|
||||
POSTGRES_DB: rswag
|
||||
volumes:
|
||||
- rswag-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- rswag-internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U rswag"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Redis for sessions/cache
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: rswag-redis
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- rswag-redis-data:/data
|
||||
networks:
|
||||
- rswag-internal
|
||||
|
||||
# FastAPI Backend
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: rswag-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://rswag:${DB_PASSWORD:-devpassword}@db:5432/rswag
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||
- PRODIGI_API_KEY=${PRODIGI_API_KEY}
|
||||
- PRINTFUL_API_TOKEN=${PRINTFUL_API_TOKEN}
|
||||
- POD_SANDBOX_MODE=${POD_SANDBOX_MODE:-true}
|
||||
- JWT_SECRET=${JWT_SECRET:-dev-secret-change-me}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
|
||||
- DESIGNS_PATH=/app/designs
|
||||
- CONFIG_PATH=/app/config
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||
volumes:
|
||||
- ./designs:/app/designs
|
||||
- ./config:/app/config:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- rswag-internal
|
||||
- traefik-public
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.rswag-api.rule=Host(`rswag.online`) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.rswag-api.entrypoints=web"
|
||||
- "traefik.http.services.rswag-api.loadbalancer.server.port=8000"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
|
||||
# Next.js Frontend
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api}
|
||||
- NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
|
||||
container_name: rswag-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- rswag-internal
|
||||
- traefik-public
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.rswag-web.rule=Host(`rswag.online`)"
|
||||
- "traefik.http.routers.rswag-web.entrypoints=web"
|
||||
- "traefik.http.services.rswag-web.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
|
||||
volumes:
|
||||
rswag-db-data:
|
||||
rswag-redis-data:
|
||||
|
||||
networks:
|
||||
rswag-internal:
|
||||
driver: bridge
|
||||
traefik-public:
|
||||
external: true
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# Stage 1: Dependencies
|
||||
FROM node:20-alpine AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN corepack enable pnpm && pnpm i --frozen-lockfile || npm install
|
||||
|
||||
# Stage 2: Builder
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
|
||||
# Ensure public directory exists
|
||||
RUN mkdir -p public
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Runner
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
|
||||
|
||||
interface CartItem {
|
||||
id: string;
|
||||
product_slug: string;
|
||||
product_name: string;
|
||||
variant_sku: string;
|
||||
variant_name: string | null;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
subtotal: number;
|
||||
}
|
||||
|
||||
interface Cart {
|
||||
id: string;
|
||||
items: CartItem[];
|
||||
item_count: number;
|
||||
subtotal: number;
|
||||
}
|
||||
|
||||
export default function CartPage() {
|
||||
const [cart, setCart] = useState<Cart | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [checkingOut, setCheckingOut] = useState(false);
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
|
||||
const fetchCart = async () => {
|
||||
const cartId = localStorage.getItem("cart_id");
|
||||
if (cartId) {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/cart/${cartId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCart(data);
|
||||
} else {
|
||||
// Cart expired or deleted
|
||||
localStorage.removeItem("cart_id");
|
||||
setCart(null);
|
||||
}
|
||||
} catch {
|
||||
setCart(null);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCart();
|
||||
}, []);
|
||||
|
||||
const updateQuantity = async (itemId: string, newQuantity: number) => {
|
||||
if (!cart || newQuantity < 1) return;
|
||||
|
||||
setUpdating(itemId);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/cart/${cart.id}/items/${itemId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ quantity: newQuantity }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const updatedCart = await res.json();
|
||||
setCart(updatedCart);
|
||||
}
|
||||
} catch {
|
||||
console.error("Failed to update quantity");
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const removeItem = async (itemId: string) => {
|
||||
if (!cart) return;
|
||||
|
||||
setUpdating(itemId);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/cart/${cart.id}/items/${itemId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const updatedCart = await res.json();
|
||||
setCart(updatedCart);
|
||||
}
|
||||
} catch {
|
||||
console.error("Failed to remove item");
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckout = async () => {
|
||||
if (!cart) return;
|
||||
setCheckingOut(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/checkout/session`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
cart_id: cart.id,
|
||||
success_url: `${window.location.origin}/checkout/success`,
|
||||
cancel_url: `${window.location.origin}/cart`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const { checkout_url } = await res.json();
|
||||
window.location.href = checkout_url;
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.detail || "Failed to start checkout");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Checkout error:", error);
|
||||
alert("Failed to start checkout");
|
||||
} finally {
|
||||
setCheckingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-16">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cart || cart.items.length === 0) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-16 text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Your Cart</h1>
|
||||
<p className="text-muted-foreground mb-8">Your cart is empty.</p>
|
||||
<Link
|
||||
href="/products"
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Continue Shopping
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-8">Your Cart</h1>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{cart.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`flex items-center gap-4 p-4 border rounded-lg ${
|
||||
updating === item.id ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Product Image */}
|
||||
<Link href={`/products/${item.product_slug}`}>
|
||||
<div className="w-24 h-24 bg-muted rounded-lg overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
src={`${API_URL}/designs/${item.product_slug}/image`}
|
||||
alt={item.product_name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Product Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href={`/products/${item.product_slug}`}
|
||||
className="font-semibold hover:text-primary transition-colors"
|
||||
>
|
||||
{item.product_name}
|
||||
</Link>
|
||||
{item.variant_name && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.variant_name}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
${item.unit_price.toFixed(2)} each
|
||||
</p>
|
||||
|
||||
{/* Quantity Controls */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, item.quantity - 1)}
|
||||
disabled={updating === item.id || item.quantity <= 1}
|
||||
className="w-8 h-8 rounded border flex items-center justify-center hover:bg-muted transition-colors disabled:opacity-50"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="w-8 text-center">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, item.quantity + 1)}
|
||||
disabled={updating === item.id}
|
||||
className="w-8 h-8 rounded border flex items-center justify-center hover:bg-muted transition-colors disabled:opacity-50"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeItem(item.id)}
|
||||
disabled={updating === item.id}
|
||||
className="ml-4 text-sm text-red-600 hover:text-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtotal */}
|
||||
<div className="text-right">
|
||||
<p className="font-bold">${item.subtotal.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="border rounded-lg p-6 sticky top-4">
|
||||
<h2 className="font-bold text-lg mb-4">Order Summary</h2>
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
Subtotal ({cart.item_count} item{cart.item_count !== 1 ? "s" : ""})
|
||||
</span>
|
||||
<span>${cart.subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Shipping</span>
|
||||
<span>Calculated at checkout</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-4 mb-6">
|
||||
<div className="flex justify-between font-bold text-lg">
|
||||
<span>Total</span>
|
||||
<span>${cart.subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCheckout}
|
||||
disabled={checkingOut}
|
||||
className="w-full bg-primary text-primary-foreground py-3 rounded-md font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{checkingOut ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
) : (
|
||||
"Proceed to Checkout"
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
href="/products"
|
||||
className="block text-center mt-4 text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
Continue Shopping
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
|
||||
|
||||
interface GeneratedDesign {
|
||||
slug: string;
|
||||
name: string;
|
||||
image_url: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function DesignPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [concept, setConcept] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [generatedDesign, setGeneratedDesign] = useState<GeneratedDesign | null>(null);
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
|
||||
const handleGenerate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
setGeneratedDesign(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/design/generate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
concept,
|
||||
tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
|
||||
product_type: "sticker",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || "Failed to generate design");
|
||||
}
|
||||
|
||||
const design = await response.json();
|
||||
setGeneratedDesign(design);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async () => {
|
||||
if (!generatedDesign) return;
|
||||
|
||||
setIsActivating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/design/${generatedDesign.slug}/activate`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || "Failed to activate design");
|
||||
}
|
||||
|
||||
setGeneratedDesign({ ...generatedDesign, status: "active" });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setIsActivating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!generatedDesign) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/design/${generatedDesign.slug}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || "Failed to delete design");
|
||||
}
|
||||
|
||||
setGeneratedDesign(null);
|
||||
setName("");
|
||||
setConcept("");
|
||||
setTags("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-2">Design Swag</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Create custom rSpace merchandise using AI. Describe your vision and
|
||||
we'll generate a unique design.
|
||||
</p>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Form */}
|
||||
<div>
|
||||
<form onSubmit={handleGenerate} className="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium mb-2"
|
||||
>
|
||||
Design Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Commons Builder"
|
||||
className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="concept"
|
||||
className="block text-sm font-medium mb-2"
|
||||
>
|
||||
Design Concept
|
||||
</label>
|
||||
<textarea
|
||||
id="concept"
|
||||
value={concept}
|
||||
onChange={(e) => setConcept(e.target.value)}
|
||||
placeholder="Describe your design idea... e.g., Interconnected nodes forming a spatial web, symbolizing collaborative infrastructure for the commons. Include the phrase 'BUILD TOGETHER' in bold letters."
|
||||
rows={4}
|
||||
className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
required
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="tags"
|
||||
className="block text-sm font-medium mb-2"
|
||||
>
|
||||
Tags (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tags"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="rspace, commons, spatial, collaboration"
|
||||
className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-md text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isGenerating || !name || !concept}
|
||||
className="w-full px-6 py-3 bg-primary text-primary-foreground rounded-md font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Generating Design...
|
||||
</span>
|
||||
) : (
|
||||
"Generate Design"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4">Preview</h2>
|
||||
<div className="aspect-square border-2 border-dashed rounded-lg flex items-center justify-center bg-muted/30">
|
||||
{generatedDesign ? (
|
||||
<img
|
||||
src={`${API_URL.replace("/api", "")}${generatedDesign.image_url}`}
|
||||
alt={generatedDesign.name}
|
||||
className="w-full h-full object-contain rounded-lg"
|
||||
/>
|
||||
) : isGenerating ? (
|
||||
<div className="text-center text-muted-foreground">
|
||||
<svg
|
||||
className="animate-spin h-12 w-12 mx-auto mb-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<p>Creating your design...</p>
|
||||
<p className="text-sm">This may take a moment</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
Your design will appear here
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{generatedDesign && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<p className="font-medium">{generatedDesign.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Status:{" "}
|
||||
<span
|
||||
className={
|
||||
generatedDesign.status === "active"
|
||||
? "text-green-600"
|
||||
: "text-yellow-600"
|
||||
}
|
||||
>
|
||||
{generatedDesign.status}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{generatedDesign.status === "draft" ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleActivate}
|
||||
disabled={isActivating}
|
||||
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-md font-medium hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isActivating ? "Activating..." : "Add to Store"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-4 py-2 border border-red-300 text-red-600 rounded-md font-medium hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
href="/products"
|
||||
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-md font-medium hover:bg-primary/90 transition-colors text-center"
|
||||
>
|
||||
View in Store
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="mt-12 p-6 bg-muted/30 rounded-lg">
|
||||
<h3 className="font-semibold mb-3">Design Tips</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>
|
||||
• Be specific about text you want included - the AI will try to
|
||||
render it in the design
|
||||
</li>
|
||||
<li>
|
||||
• Mention colors, mood, and style preferences in your concept
|
||||
</li>
|
||||
<li>
|
||||
• rSpace themes work great: spatial webs, interconnected nodes,
|
||||
commons, collaboration, community tools
|
||||
</li>
|
||||
<li>
|
||||
• Generated designs start as drafts - preview before adding to the
|
||||
store
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 195 80% 45%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 45 80% 55%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 195 80% 45%;
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 264 30% 6%;
|
||||
--foreground: 180 10% 96%;
|
||||
--card: 264 30% 6%;
|
||||
--card-foreground: 180 10% 96%;
|
||||
--popover: 264 30% 6%;
|
||||
--popover-foreground: 180 10% 96%;
|
||||
--primary: 195 80% 50%;
|
||||
--primary-foreground: 264 30% 6%;
|
||||
--secondary: 45 80% 55%;
|
||||
--secondary-foreground: 180 10% 96%;
|
||||
--muted: 264 20% 15%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 264 20% 15%;
|
||||
--accent-foreground: 180 10% 96%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 264 20% 15%;
|
||||
--input: 264 20% 15%;
|
||||
--ring: 195 80% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import type { Metadata } from "next";
|
||||
import { GeistSans, GeistMono } from "geist/font";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "rSwag — Merch for the rSpace Ecosystem",
|
||||
description: "Design and order custom merchandise for rSpace communities. Stickers, shirts, and more.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={GeistSans.className}>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<a href="/" className="text-xl font-bold text-primary">
|
||||
rSwag
|
||||
</a>
|
||||
<nav className="flex items-center gap-6">
|
||||
<a href="/products" className="hover:text-primary">
|
||||
Products
|
||||
</a>
|
||||
<a href="/design" className="px-3 py-1.5 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors">
|
||||
Design Swag
|
||||
</a>
|
||||
<a href="/cart" className="hover:text-primary">
|
||||
Cart
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1">{children}</main>
|
||||
<footer className="border-t py-6">
|
||||
<div className="container mx-auto px-4 text-center text-muted-foreground">
|
||||
<p>© 2026 rSpace. Infrastructure for the commons.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import Link from "next/link";
|
||||
|
||||
interface Product {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
product_type: string;
|
||||
image_url: string;
|
||||
base_price: number;
|
||||
}
|
||||
|
||||
async function getProducts(): Promise<Product[]> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/products`,
|
||||
{ next: { revalidate: 60 } } // Revalidate every minute
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
return res.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
const products = await getProducts();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-16">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<h1 className="text-4xl md:text-6xl font-bold mb-6">
|
||||
rSwag
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground mb-8">
|
||||
Merch for the rSpace ecosystem. Stickers, shirts,
|
||||
and more — designed by the community, printed on demand.
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Link
|
||||
href="/products"
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary px-8 py-3 text-lg font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Browse Products
|
||||
</Link>
|
||||
<Link
|
||||
href="/design"
|
||||
className="inline-flex items-center justify-center rounded-md border border-primary px-8 py-3 text-lg font-medium text-primary hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
Design Your Own
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-24">
|
||||
<h2 className="text-2xl font-bold text-center mb-12">Featured Products</h2>
|
||||
{products.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground">
|
||||
No products available yet. Check back soon!
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-5xl mx-auto">
|
||||
{products.map((product) => (
|
||||
<Link
|
||||
key={product.slug}
|
||||
href={`/products/${product.slug}`}
|
||||
className="group"
|
||||
>
|
||||
<div className="border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<div className="aspect-square bg-muted relative overflow-hidden">
|
||||
<img
|
||||
src={`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/designs/${product.slug}/image`}
|
||||
alt={product.name}
|
||||
className="object-cover w-full h-full group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold">{product.name}</h3>
|
||||
<p className="text-sm text-muted-foreground capitalize">
|
||||
{product.product_type}
|
||||
</p>
|
||||
<p className="font-bold mt-2">${product.base_price.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,329 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
|
||||
|
||||
interface ProductVariant {
|
||||
name: string;
|
||||
sku: string;
|
||||
provider: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
interface Product {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
product_type: string;
|
||||
tags: string[];
|
||||
image_url: string;
|
||||
base_price: number;
|
||||
variants: ProductVariant[];
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export default function ProductPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const slug = params.slug as string;
|
||||
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [addingToCart, setAddingToCart] = useState(false);
|
||||
const [addedToCart, setAddedToCart] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchProduct() {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/products/${slug}`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
setError("Product not found");
|
||||
} else {
|
||||
setError("Failed to load product");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setProduct(data);
|
||||
if (data.variants && data.variants.length > 0) {
|
||||
setSelectedVariant(data.variants[0]);
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load product");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (slug) {
|
||||
fetchProduct();
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
const getOrCreateCart = async (): Promise<string | null> => {
|
||||
// Check for existing cart in localStorage
|
||||
let cartId = localStorage.getItem("cart_id");
|
||||
|
||||
if (cartId) {
|
||||
// Verify cart still exists
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/cart/${cartId}`);
|
||||
if (res.ok) {
|
||||
return cartId;
|
||||
}
|
||||
} catch {
|
||||
// Cart doesn't exist, create new one
|
||||
}
|
||||
}
|
||||
|
||||
// Create new cart
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/cart`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
cartId = data.id;
|
||||
localStorage.setItem("cart_id", cartId!);
|
||||
return cartId;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
if (!product || !selectedVariant) return;
|
||||
|
||||
setAddingToCart(true);
|
||||
try {
|
||||
const cartId = await getOrCreateCart();
|
||||
if (!cartId) {
|
||||
alert("Failed to create cart");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_URL}/cart/${cartId}/items`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
product_slug: product.slug,
|
||||
variant_sku: selectedVariant.sku,
|
||||
quantity: quantity,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setAddedToCart(true);
|
||||
setTimeout(() => setAddedToCart(false), 3000);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.detail || "Failed to add to cart");
|
||||
}
|
||||
} catch {
|
||||
alert("Failed to add to cart");
|
||||
} finally {
|
||||
setAddingToCart(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-16">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !product) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-16">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">{error || "Product not found"}</h1>
|
||||
<Link
|
||||
href="/products"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Back to Products
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-8 text-sm">
|
||||
<Link href="/" className="text-muted-foreground hover:text-primary">
|
||||
Home
|
||||
</Link>
|
||||
<span className="mx-2 text-muted-foreground">/</span>
|
||||
<Link href="/products" className="text-muted-foreground hover:text-primary">
|
||||
Products
|
||||
</Link>
|
||||
<span className="mx-2 text-muted-foreground">/</span>
|
||||
<span className="text-foreground">{product.name}</span>
|
||||
</nav>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12">
|
||||
{/* Product Image */}
|
||||
<div className="aspect-square bg-muted rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={`${API_URL}/designs/${product.slug}/image`}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Product Details */}
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<span className="text-sm text-muted-foreground capitalize">
|
||||
{product.category} / {product.product_type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold mb-4">{product.name}</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-6">{product.description}</p>
|
||||
|
||||
<div className="text-3xl font-bold mb-6">
|
||||
${selectedVariant?.price.toFixed(2) || product.base_price.toFixed(2)}
|
||||
</div>
|
||||
|
||||
{/* Variant Selection */}
|
||||
{product.variants && product.variants.length > 1 && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Select Option
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{product.variants.map((variant) => (
|
||||
<button
|
||||
key={variant.sku}
|
||||
onClick={() => setSelectedVariant(variant)}
|
||||
className={`px-4 py-2 rounded-md border transition-colors ${
|
||||
selectedVariant?.sku === variant.sku
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-muted-foreground/30 hover:border-primary"
|
||||
}`}
|
||||
>
|
||||
{variant.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantity */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium mb-2">Quantity</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
className="w-10 h-10 rounded-md border flex items-center justify-center hover:bg-muted transition-colors"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="w-12 text-center font-medium">{quantity}</span>
|
||||
<button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
className="w-10 h-10 rounded-md border flex items-center justify-center hover:bg-muted transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add to Cart Button */}
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={addingToCart || !selectedVariant}
|
||||
className={`w-full py-4 rounded-md font-medium transition-colors ${
|
||||
addedToCart
|
||||
? "bg-green-600 text-white"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{addingToCart ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Adding...
|
||||
</span>
|
||||
) : addedToCart ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Added to Cart!
|
||||
</span>
|
||||
) : (
|
||||
"Add to Cart"
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* View Cart Link */}
|
||||
{addedToCart && (
|
||||
<Link
|
||||
href="/cart"
|
||||
className="block text-center mt-4 text-primary hover:underline"
|
||||
>
|
||||
View Cart
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{product.tags && product.tags.length > 0 && (
|
||||
<div className="mt-8 pt-6 border-t">
|
||||
<span className="text-sm text-muted-foreground">Tags: </span>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{product.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-1 text-xs bg-muted rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,6 @@
|
|||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'rswag.online',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"name": "rswag-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"geist": "^1.3.0",
|
||||
"next": "^15.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@stripe/stripe-js": "^2.4.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -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