Merge remote main with unrelated histories
Accept remote docker-compose.yml (full rswag app stack) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
d33d4183cf
|
|
@ -0,0 +1,37 @@
|
|||
# Database
|
||||
DB_PASSWORD=change_me_in_production
|
||||
|
||||
# Mollie Payments (https://my.mollie.com/dashboard)
|
||||
MOLLIE_API_KEY=test_xxx
|
||||
|
||||
# POD Providers
|
||||
PRODIGI_API_KEY=xxx
|
||||
PRINTFUL_API_TOKEN=xxx
|
||||
PRINTFUL_STORE_ID=
|
||||
POD_SANDBOX_MODE=true
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=generate_a_strong_secret_here
|
||||
|
||||
# App
|
||||
CORS_ORIGINS=https://rswag.online
|
||||
PUBLIC_URL=https://rswag.online
|
||||
|
||||
# AI Design Generation
|
||||
GEMINI_API_KEY=xxx
|
||||
|
||||
# TBFF Revenue Split → Bonding Curve
|
||||
# Leave FLOW_SERVICE_URL empty to disable flow routing
|
||||
FLOW_SERVICE_URL=http://flow-service:3010
|
||||
FLOW_ID=xxx
|
||||
FLOW_FUNNEL_ID=xxx
|
||||
FLOW_REVENUE_SPLIT=0.5
|
||||
|
||||
# SMTP Email
|
||||
SMTP_HOST=mail.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=noreply@example.com
|
||||
SMTP_PASS=changeme
|
||||
|
||||
# Frontend
|
||||
NEXT_PUBLIC_API_URL=https://rswag.online/api
|
||||
|
|
@ -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,92 @@
|
|||
# rSwag - AI Assistant Context
|
||||
|
||||
## Project Overview
|
||||
|
||||
E-commerce platform for rSpace ecosystem merchandise (stickers, shirts, prints) with Mollie payments and print-on-demand fulfillment via Printful and Prodigi. Part of the rSpace ecosystem (rspace.online).
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Frontend**: Next.js 15 App Router, shadcn/ui, Tailwind CSS, Geist font
|
||||
- **Backend**: FastAPI, SQLAlchemy, Alembic
|
||||
- **Database**: PostgreSQL
|
||||
- **Payments**: Mollie (redirect flow, Dutch data residency)
|
||||
- **Fulfillment**: Printful (apparel), Prodigi (stickers/prints)
|
||||
- **AI Design**: Gemini API for design generation
|
||||
- **Deployment**: Docker on Netcup RS 8000, Traefik routing
|
||||
|
||||
## Key Directories
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `backend/app/api/` | FastAPI route handlers |
|
||||
| `backend/app/models/` | SQLAlchemy ORM models |
|
||||
| `backend/app/schemas/` | Pydantic request/response schemas |
|
||||
| `backend/app/services/` | Business logic (mollie, pod, orders, spaces) |
|
||||
| `backend/app/pod/` | POD provider clients |
|
||||
| `frontend/app/` | Next.js App Router pages |
|
||||
| `frontend/components/` | React components |
|
||||
| `frontend/lib/` | Utilities (spaces, cn) |
|
||||
| `designs/` | Design assets (stickers, shirts, misc) |
|
||||
| `spaces/` | Space configs (multi-tenant branding/theming) |
|
||||
|
||||
## Spaces (Multi-Tenant)
|
||||
|
||||
rSwag supports subdomain-based spaces. Each space has its own branding, theme, and product catalog.
|
||||
|
||||
- **Config**: `spaces/{space_id}/space.yaml` defines name, theme colors, design filter, tips
|
||||
- **Subdomain routing**: `{space}.rswag.online` detected by Next.js middleware, sets `space_id` cookie
|
||||
- **API filtering**: `GET /api/products?space=fungiflows` returns only that space's designs
|
||||
- **Theme injection**: CSS variables overridden at runtime from space config
|
||||
- **Cart isolation**: localStorage keys scoped by space (`cart_id_fungiflows`)
|
||||
- **Current spaces**: `_default` (rSwag hub), `fungiflows` (Fungi Flows merch)
|
||||
|
||||
## Design Source
|
||||
|
||||
Designs are stored in-repo at `./designs/` and mounted into the backend container.
|
||||
|
||||
Each design has a `metadata.yaml` with name, description, products, variants, pricing, and `space` field.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Spaces
|
||||
- `GET /api/spaces` - List all spaces
|
||||
- `GET /api/spaces/{id}` - Get space config (branding, theme, tips)
|
||||
|
||||
### Public
|
||||
- `GET /api/designs` - List active designs (optional: `?space=X`)
|
||||
- `GET /api/designs/{slug}` - Get design details
|
||||
- `GET /api/designs/{slug}/image` - Serve design image
|
||||
- `GET /api/products` - List products with variants (optional: `?space=X`)
|
||||
- `POST /api/cart` - Create cart
|
||||
- `GET/POST/DELETE /api/cart/{id}/items` - Cart operations
|
||||
- `POST /api/checkout/session` - Create Mollie payment
|
||||
- `GET /api/orders/{id}` - Order status (requires email)
|
||||
- `POST /api/design/generate` - AI design generation
|
||||
|
||||
### Webhooks
|
||||
- `POST /api/webhooks/mollie` - Mollie payment events
|
||||
- `POST /api/webhooks/prodigi` - Prodigi fulfillment updates
|
||||
- `POST /api/webhooks/printful` - Printful fulfillment updates
|
||||
|
||||
### Admin (JWT required)
|
||||
- `POST /api/admin/auth/login` - Admin login
|
||||
- `GET /api/admin/orders` - List orders
|
||||
- `GET /api/admin/analytics/*` - Sales metrics
|
||||
|
||||
## Deployment
|
||||
|
||||
Push to Gitea triggers webhook auto-deploy on Netcup at `/opt/apps/rswag/`.
|
||||
|
||||
## Branding
|
||||
|
||||
Default (rSwag):
|
||||
- **Primary color**: Cyan (HSL 195 80% 45%)
|
||||
- **Secondary color**: Orange (HSL 45 80% 55%)
|
||||
- **Font**: Geist Sans + Geist Mono
|
||||
- **Theme**: rSpace spatial web aesthetic
|
||||
|
||||
Fungi Flows space (`fungiflows.rswag.online`):
|
||||
- **Primary**: Gold (#ffd700)
|
||||
- **Secondary**: Bioluminescent green (#39ff14)
|
||||
- **Background**: Deep purple (#08070d)
|
||||
- **Theme**: Psychedelic mushroom hip-hop aesthetic
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
# rSwag
|
||||
|
||||
Merchandise store for the **rSpace ecosystem** at **rswag.online**
|
||||
|
||||
## Stack
|
||||
|
||||
- **Frontend**: Next.js 15 + shadcn/ui + Tailwind CSS + Geist font
|
||||
- **Backend**: FastAPI + SQLAlchemy + Alembic
|
||||
- **Database**: PostgreSQL
|
||||
- **Payments**: Mollie (EU data residency)
|
||||
- **Fulfillment**: Printful (apparel) + Prodigi (stickers/prints)
|
||||
- **AI Design**: Gemini API for on-demand design generation
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
rswag.online
|
||||
│
|
||||
▼
|
||||
Cloudflare Tunnel → Traefik
|
||||
│ │
|
||||
▼ ▼
|
||||
Next.js (3000) FastAPI (8000)
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
PostgreSQL Mollie POD APIs
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your API keys
|
||||
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
|
||||
# Backend: http://localhost:8000
|
||||
# Frontend: http://localhost:3000
|
||||
```
|
||||
|
||||
### Local Development (without Docker)
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
pip install -e .
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
# Frontend
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
rswag/
|
||||
├── backend/ # FastAPI Python backend
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # Route handlers
|
||||
│ │ ├── models/ # SQLAlchemy ORM
|
||||
│ │ ├── schemas/ # Pydantic models
|
||||
│ │ ├── services/ # Business logic
|
||||
│ │ └── pod/ # POD provider clients
|
||||
│ └── alembic/ # Database migrations
|
||||
├── frontend/ # Next.js 15 frontend
|
||||
│ ├── app/ # App Router pages
|
||||
│ ├── components/ # React components
|
||||
│ └── lib/ # Utilities
|
||||
├── designs/ # Design assets (in-repo)
|
||||
│ ├── stickers/
|
||||
│ ├── shirts/
|
||||
│ └── misc/
|
||||
└── config/ # POD provider config
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Deployed on Netcup RS 8000 via Docker Compose with Traefik reverse proxy.
|
||||
|
||||
```bash
|
||||
ssh netcup "cd /opt/apps/rswag && git pull && docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build"
|
||||
```
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
# Stage 1: Build
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 appgroup && \
|
||||
adduser --system --uid 1001 --ingroup appgroup appuser
|
||||
|
||||
# Install wheels
|
||||
COPY --from=builder /app/wheels /wheels
|
||||
RUN pip install --no-cache /wheels/*
|
||||
|
||||
# Copy application code
|
||||
COPY --chown=appuser:appgroup app/ ./app/
|
||||
COPY --chown=appuser:appgroup alembic/ ./alembic/
|
||||
COPY --chown=appuser:appgroup alembic.ini ./
|
||||
|
||||
# Copy Infisical entrypoint
|
||||
COPY --chown=appuser:appgroup entrypoint.sh ./entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# Create directories for mounted volumes
|
||||
RUN mkdir -p /app/designs /app/config && \
|
||||
chown -R appuser:appgroup /app
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
@ -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,36 @@
|
|||
"""Migrate from Stripe to Mollie payment provider
|
||||
|
||||
Revision ID: 002_stripe_to_mollie
|
||||
Revises: 001_initial
|
||||
Create Date: 2026-02-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "002_stripe_to_mollie"
|
||||
down_revision: Union[str, None] = "001_initial"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Rename Stripe-specific columns to generic payment columns
|
||||
op.alter_column("orders", "stripe_session_id", new_column_name="payment_id")
|
||||
op.alter_column("orders", "stripe_payment_intent_id", new_column_name="payment_method")
|
||||
|
||||
# Add payment_provider column
|
||||
op.add_column("orders", sa.Column("payment_provider", sa.String(50), nullable=True))
|
||||
|
||||
# Rename stripe_customer_id on customers table
|
||||
op.alter_column("customers", "stripe_customer_id", new_column_name="external_id")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column("customers", "external_id", new_column_name="stripe_customer_id")
|
||||
op.drop_column("orders", "payment_provider")
|
||||
op.alter_column("orders", "payment_method", new_column_name="stripe_payment_intent_id")
|
||||
op.alter_column("orders", "payment_id", new_column_name="stripe_session_id")
|
||||
|
|
@ -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,53 @@
|
|||
"""Checkout API endpoints."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas.order import CheckoutRequest, CheckoutResponse
|
||||
from app.services.mollie_service import MollieService
|
||||
from app.services.cart_service import CartService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_mollie_service() -> MollieService:
|
||||
return MollieService()
|
||||
|
||||
|
||||
def get_cart_service(db: AsyncSession = Depends(get_db)) -> CartService:
|
||||
return CartService(db)
|
||||
|
||||
|
||||
@router.post("/session", response_model=CheckoutResponse)
|
||||
async def create_checkout_session(
|
||||
checkout_request: CheckoutRequest,
|
||||
request: Request,
|
||||
mollie_service: MollieService = Depends(get_mollie_service),
|
||||
cart_service: CartService = Depends(get_cart_service),
|
||||
):
|
||||
"""Create a Mollie payment session."""
|
||||
# Get cart
|
||||
cart = await cart_service.get_cart(checkout_request.cart_id)
|
||||
if not cart:
|
||||
raise HTTPException(status_code=404, detail="Cart not found")
|
||||
|
||||
if not cart.items:
|
||||
raise HTTPException(status_code=400, detail="Cart is empty")
|
||||
|
||||
# Build webhook URL from request origin
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
webhook_url = f"{base_url}/api/webhooks/mollie"
|
||||
|
||||
# Create Mollie payment
|
||||
result = await mollie_service.create_payment(
|
||||
cart=cart,
|
||||
success_url=checkout_request.success_url,
|
||||
cancel_url=checkout_request.cancel_url,
|
||||
webhook_url=webhook_url,
|
||||
)
|
||||
|
||||
return CheckoutResponse(
|
||||
checkout_url=result["url"],
|
||||
session_id=result["payment_id"],
|
||||
)
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
"""AI design generation API."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import get_settings
|
||||
from app.api.designs import design_service
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class DesignRequest(BaseModel):
|
||||
"""Request to generate a new design."""
|
||||
concept: str
|
||||
name: str
|
||||
tags: list[str] = []
|
||||
product_type: str = "sticker"
|
||||
|
||||
|
||||
class DesignResponse(BaseModel):
|
||||
"""Response with generated design info."""
|
||||
slug: str
|
||||
name: str
|
||||
image_url: str
|
||||
status: str
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
"""Convert text to URL-friendly slug."""
|
||||
text = text.lower().strip()
|
||||
text = re.sub(r'[^\w\s-]', '', text)
|
||||
text = re.sub(r'[\s_-]+', '-', text)
|
||||
text = re.sub(r'^-+|-+$', '', text)
|
||||
return text
|
||||
|
||||
|
||||
@router.post("/generate", response_model=DesignResponse)
|
||||
async def generate_design(request: DesignRequest):
|
||||
"""Generate a new design using AI."""
|
||||
|
||||
gemini_api_key = os.environ.get("GEMINI_API_KEY", "")
|
||||
if not gemini_api_key:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="AI generation not configured. Set GEMINI_API_KEY."
|
||||
)
|
||||
|
||||
# Create slug from name
|
||||
slug = slugify(request.name)
|
||||
if not slug:
|
||||
slug = f"design-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Check if design already exists
|
||||
design_dir = settings.designs_dir / "stickers" / slug
|
||||
if design_dir.exists():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Design '{slug}' already exists"
|
||||
)
|
||||
|
||||
# Build the image generation prompt
|
||||
style_prompt = f"""A striking sticker design for "{request.name}".
|
||||
{request.concept}
|
||||
The design should have a clean, modern spatial-web aesthetic with interconnected
|
||||
nodes, network patterns, and a collaborative/commons feel.
|
||||
Colors: vibrant cyan, warm orange accents on dark background.
|
||||
High contrast, suitable for vinyl sticker printing.
|
||||
Square format, clean edges for die-cut sticker."""
|
||||
|
||||
# Call Gemini API for image generation
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
# Use gemini-3-pro-image-preview for image generation
|
||||
response = await client.post(
|
||||
f"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent?key={gemini_api_key}",
|
||||
json={
|
||||
"contents": [{
|
||||
"parts": [{
|
||||
"text": style_prompt
|
||||
}]
|
||||
}],
|
||||
"generationConfig": {
|
||||
"responseModalities": ["image", "text"]
|
||||
}
|
||||
},
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
error_detail = response.text[:500] if response.text else "Unknown error"
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"AI generation failed ({response.status_code}): {error_detail}"
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
|
||||
# Extract image data from response
|
||||
image_data = None
|
||||
for candidate in result.get("candidates", []):
|
||||
for part in candidate.get("content", {}).get("parts", []):
|
||||
if "inlineData" in part:
|
||||
image_data = part["inlineData"]["data"]
|
||||
break
|
||||
if image_data:
|
||||
break
|
||||
|
||||
if not image_data:
|
||||
# Log what we got for debugging
|
||||
import json
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"AI did not return an image. Response: {json.dumps(result)[:500]}"
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail="AI generation timed out"
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"AI generation error: {str(e)}"
|
||||
)
|
||||
|
||||
# Create design directory
|
||||
design_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save image
|
||||
import base64
|
||||
image_path = design_dir / f"{slug}.png"
|
||||
image_bytes = base64.b64decode(image_data)
|
||||
image_path.write_bytes(image_bytes)
|
||||
|
||||
# Create metadata.yaml
|
||||
# Escape quotes in user-provided strings to prevent YAML parsing errors
|
||||
safe_name = request.name.replace('"', '\\"')
|
||||
safe_concept = request.concept.replace('"', '\\"')
|
||||
tags_str = ", ".join(request.tags) if request.tags else "rspace, sticker, ai-generated"
|
||||
metadata_content = f"""name: "{safe_name}"
|
||||
slug: {slug}
|
||||
description: "{safe_concept}"
|
||||
tags: [{tags_str}]
|
||||
created: {date.today().isoformat()}
|
||||
author: ai-generated
|
||||
|
||||
source:
|
||||
file: {slug}.png
|
||||
format: png
|
||||
dimensions:
|
||||
width: 1024
|
||||
height: 1024
|
||||
dpi: 300
|
||||
color_profile: sRGB
|
||||
|
||||
products:
|
||||
- type: sticker
|
||||
provider: prodigi
|
||||
sku: GLOBAL-STI-KIS-3X3
|
||||
variants: [matte, gloss]
|
||||
retail_price: 3.50
|
||||
|
||||
status: draft
|
||||
"""
|
||||
|
||||
metadata_path = design_dir / "metadata.yaml"
|
||||
metadata_path.write_text(metadata_content)
|
||||
|
||||
return DesignResponse(
|
||||
slug=slug,
|
||||
name=request.name,
|
||||
image_url=f"/api/designs/{slug}/image",
|
||||
status="draft"
|
||||
)
|
||||
|
||||
|
||||
def find_design_dir(slug: str) -> Path | None:
|
||||
"""Find a design directory by slug, searching all categories."""
|
||||
for category_dir in settings.designs_dir.iterdir():
|
||||
if not category_dir.is_dir():
|
||||
continue
|
||||
design_dir = category_dir / slug
|
||||
if design_dir.exists() and (design_dir / "metadata.yaml").exists():
|
||||
return design_dir
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/{slug}/activate")
|
||||
async def activate_design(slug: str):
|
||||
"""Activate a draft design to make it visible in the store."""
|
||||
|
||||
design_dir = find_design_dir(slug)
|
||||
if not design_dir:
|
||||
raise HTTPException(status_code=404, detail="Design not found")
|
||||
|
||||
metadata_path = design_dir / "metadata.yaml"
|
||||
|
||||
# Read and update metadata
|
||||
content = metadata_path.read_text()
|
||||
content = content.replace("status: draft", "status: active")
|
||||
metadata_path.write_text(content)
|
||||
|
||||
# Clear the design service cache so the new status is picked up
|
||||
design_service.clear_cache()
|
||||
|
||||
return {"status": "activated", "slug": slug}
|
||||
|
||||
|
||||
@router.delete("/{slug}")
|
||||
async def delete_design(slug: str):
|
||||
"""Delete a design (only drafts can be deleted)."""
|
||||
import shutil
|
||||
|
||||
design_dir = find_design_dir(slug)
|
||||
if not design_dir:
|
||||
raise HTTPException(status_code=404, detail="Design not found")
|
||||
|
||||
metadata_path = design_dir / "metadata.yaml"
|
||||
|
||||
# Check if draft
|
||||
content = metadata_path.read_text()
|
||||
if "status: active" in content:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete active designs. Set to draft first."
|
||||
)
|
||||
|
||||
# Delete directory
|
||||
shutil.rmtree(design_dir)
|
||||
|
||||
return {"status": "deleted", "slug": slug}
|
||||
|
|
@ -0,0 +1,275 @@
|
|||
"""Designs API endpoints."""
|
||||
|
||||
import io
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from PIL import Image
|
||||
|
||||
from app.config import get_settings
|
||||
from app.schemas.design import Design
|
||||
from app.services.design_service import DesignService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
design_service = DesignService()
|
||||
settings = get_settings()
|
||||
|
||||
# Mockup template configs: product_type → (template path, design bounding box, blend mode)
|
||||
# Coordinates are for 1024x1024 photorealistic templates
|
||||
MOCKUP_TEMPLATES = {
|
||||
"shirt": {
|
||||
"template": "shirt-template.png",
|
||||
"design_box": (262, 230, 500, 450), # x, y, w, h — chest area on black tee
|
||||
"blend": "screen", # screen blend for light designs on dark fabric
|
||||
},
|
||||
"sticker": {
|
||||
"template": "sticker-template.png",
|
||||
"design_box": (270, 210, 470, 530), # inside the white sticker area
|
||||
"blend": "paste",
|
||||
},
|
||||
"print": {
|
||||
"template": "print-template.png",
|
||||
"design_box": (225, 225, 575, 500), # inside the black frame
|
||||
"blend": "paste",
|
||||
},
|
||||
}
|
||||
|
||||
# Map mockup type → matching product types from metadata
|
||||
_TYPE_MAP = {
|
||||
"shirt": ("shirt", "tshirt", "tee", "hoodie"),
|
||||
"sticker": ("sticker",),
|
||||
"print": ("print",),
|
||||
}
|
||||
|
||||
# Cache generated mockups in memory: (slug, product_type) → PNG bytes
|
||||
_mockup_cache: dict[tuple[str, str], bytes] = {}
|
||||
|
||||
|
||||
@router.get("", response_model=list[Design])
|
||||
async def list_designs(
|
||||
status: str = "active",
|
||||
category: str | None = None,
|
||||
space: str | None = None,
|
||||
):
|
||||
"""List all designs."""
|
||||
designs = await design_service.list_designs(status=status, category=category, space=space)
|
||||
return designs
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=Design)
|
||||
async def get_design(slug: str):
|
||||
"""Get a single design by slug."""
|
||||
design = await design_service.get_design(slug)
|
||||
if not design:
|
||||
raise HTTPException(status_code=404, detail="Design not found")
|
||||
return design
|
||||
|
||||
|
||||
@router.get("/{slug}/image")
|
||||
async def get_design_image(slug: str):
|
||||
"""Serve the design image."""
|
||||
image_path = await design_service.get_design_image_path(slug)
|
||||
if not image_path or not Path(image_path).exists():
|
||||
raise HTTPException(status_code=404, detail="Image not found")
|
||||
|
||||
return FileResponse(
|
||||
image_path,
|
||||
media_type="image/png",
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{slug}/mockup")
|
||||
async def get_design_mockup(slug: str, type: str = "shirt", fresh: bool = False):
|
||||
"""Serve the design composited onto a product mockup template.
|
||||
|
||||
For Printful-provider designs: fetches photorealistic mockup from
|
||||
Printful's mockup generator API (cached after first generation).
|
||||
For other designs: composites with Pillow using local templates.
|
||||
|
||||
Query params:
|
||||
type: Product type — "shirt", "sticker", or "print" (default: shirt)
|
||||
fresh: If true, bypass cache and regenerate mockup
|
||||
"""
|
||||
cache_key = (slug, type)
|
||||
if not fresh and cache_key in _mockup_cache:
|
||||
return StreamingResponse(
|
||||
io.BytesIO(_mockup_cache[cache_key]),
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "public, max-age=86400"},
|
||||
)
|
||||
|
||||
# Load design to check provider
|
||||
design = await design_service.get_design(slug)
|
||||
if not design:
|
||||
raise HTTPException(status_code=404, detail="Design not found")
|
||||
|
||||
# Find a Printful-provider product matching the requested mockup type
|
||||
printful_product = None
|
||||
accepted_types = _TYPE_MAP.get(type, (type,))
|
||||
for p in design.products:
|
||||
if p.provider == "printful" and p.type in accepted_types:
|
||||
printful_product = p
|
||||
break
|
||||
|
||||
# Try Printful mockup API for Printful-provider designs
|
||||
if printful_product and settings.printful_api_token:
|
||||
png_bytes = await _get_printful_mockup(slug, printful_product)
|
||||
if png_bytes:
|
||||
_mockup_cache[cache_key] = png_bytes
|
||||
return StreamingResponse(
|
||||
io.BytesIO(png_bytes),
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "public, max-age=86400"},
|
||||
)
|
||||
|
||||
# Fallback: Pillow compositing with local templates
|
||||
return await _pillow_mockup(slug, type)
|
||||
|
||||
|
||||
async def _get_printful_mockup(slug: str, product) -> bytes | None:
|
||||
"""Fetch mockup from Printful API. Returns PNG bytes or None."""
|
||||
from app.pod.printful_client import PrintfulClient
|
||||
|
||||
printful = PrintfulClient()
|
||||
if not printful.enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
product_id = int(product.sku)
|
||||
|
||||
# Get first variant for mockup preview
|
||||
variants = await printful.get_catalog_variants(product_id)
|
||||
if not variants:
|
||||
logger.warning(f"No Printful variants for product {product_id}")
|
||||
return None
|
||||
variant_ids = [variants[0]["id"]]
|
||||
|
||||
# Public image URL for Printful to download
|
||||
image_url = f"{settings.public_url}/api/designs/{slug}/image"
|
||||
|
||||
# Generate mockup (blocks up to ~60s on first call)
|
||||
mockups = await printful.generate_mockup_and_wait(
|
||||
product_id=product_id,
|
||||
variant_ids=variant_ids,
|
||||
image_url=image_url,
|
||||
placement="front",
|
||||
technique="dtg",
|
||||
)
|
||||
|
||||
if not mockups:
|
||||
return None
|
||||
|
||||
# v2 response: catalog_variant_mockups[] → .mockups[] → .mockup_url
|
||||
mockup_url = None
|
||||
for variant_mockup in mockups:
|
||||
for mockup in variant_mockup.get("mockups", []):
|
||||
mockup_url = mockup.get("mockup_url") or mockup.get("url")
|
||||
if mockup_url:
|
||||
break
|
||||
if mockup_url:
|
||||
break
|
||||
|
||||
if not mockup_url:
|
||||
logger.warning(f"No mockup URL in Printful response for {slug}")
|
||||
return None
|
||||
|
||||
# Download the mockup image
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.get(mockup_url)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Printful mockup failed for {slug}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _pillow_mockup(slug: str, type: str) -> StreamingResponse:
|
||||
"""Generate photorealistic mockup using Pillow compositing."""
|
||||
from PIL import ImageChops
|
||||
|
||||
template_config = MOCKUP_TEMPLATES.get(type)
|
||||
if not template_config:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown product type: {type}")
|
||||
|
||||
image_path = await design_service.get_design_image_path(slug)
|
||||
if not image_path or not Path(image_path).exists():
|
||||
raise HTTPException(status_code=404, detail="Design image not found")
|
||||
|
||||
# Load template from frontend/public/mockups/ or /app/mockups/ (Docker mount)
|
||||
template_dir = Path(__file__).resolve().parents[3] / "frontend" / "public" / "mockups"
|
||||
template_path = template_dir / template_config["template"]
|
||||
if not template_path.exists():
|
||||
template_path = Path("/app/mockups") / template_config["template"]
|
||||
if not template_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Mockup template not found")
|
||||
|
||||
# Load images
|
||||
template_img = Image.open(str(template_path)).convert("RGB")
|
||||
design_img = Image.open(image_path).convert("RGBA")
|
||||
|
||||
# Start with the photorealistic template as the base
|
||||
canvas = template_img.copy()
|
||||
|
||||
# Scale design to fit bounding box while maintaining aspect ratio
|
||||
bx, by, bw, bh = template_config["design_box"]
|
||||
scale = min(bw / design_img.width, bh / design_img.height)
|
||||
dw = int(design_img.width * scale)
|
||||
dh = int(design_img.height * scale)
|
||||
dx = bx + (bw - dw) // 2
|
||||
dy = by + (bh - dh) // 2
|
||||
|
||||
design_resized = design_img.resize((dw, dh), Image.LANCZOS)
|
||||
|
||||
blend_mode = template_config.get("blend", "paste")
|
||||
|
||||
if blend_mode == "screen":
|
||||
# Screen blend for light designs on dark fabric.
|
||||
# We use a brightness-based mask so only non-dark pixels from
|
||||
# the design show through, preventing a visible dark rectangle
|
||||
# when the design has its own dark background.
|
||||
design_rgb = design_resized.convert("RGB")
|
||||
|
||||
# Extract the region under the design
|
||||
region = canvas.crop((dx, dy, dx + dw, dy + dh))
|
||||
|
||||
# Screen blend the design onto the fabric region
|
||||
blended = ImageChops.screen(region, design_rgb)
|
||||
|
||||
# Create a luminance mask from the design — only bright pixels blend in.
|
||||
# This prevents the design's dark background from creating a visible box.
|
||||
lum = design_rgb.convert("L")
|
||||
# Boost contrast so only clearly visible parts of the design show
|
||||
lum = lum.point(lambda p: min(255, int(p * 1.5)))
|
||||
|
||||
# Composite: use luminance as mask (bright pixels = show blended, dark = keep original)
|
||||
result = Image.composite(blended, region, lum)
|
||||
canvas.paste(result, (dx, dy))
|
||||
else:
|
||||
# Direct paste — for stickers/prints where design goes on a light surface
|
||||
if design_resized.mode == "RGBA":
|
||||
canvas.paste(design_resized, (dx, dy), design_resized)
|
||||
else:
|
||||
canvas.paste(design_resized, (dx, dy))
|
||||
|
||||
# Export to high-quality PNG
|
||||
buf = io.BytesIO()
|
||||
canvas.save(buf, format="PNG", optimize=True)
|
||||
png_bytes = buf.getvalue()
|
||||
|
||||
# Cache the result
|
||||
cache_key = (slug, type)
|
||||
_mockup_cache[cache_key] = png_bytes
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(png_bytes),
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "public, max-age=86400"},
|
||||
)
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
"""Health check endpoint."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.config import get_settings
|
||||
from app.services.flow_service import FlowService
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
flow_service = FlowService()
|
||||
return {
|
||||
"status": "healthy",
|
||||
"payment_provider": "mollie",
|
||||
"flow_enabled": flow_service.enabled,
|
||||
"flow_revenue_split": settings.flow_revenue_split if flow_service.enabled else None,
|
||||
}
|
||||
|
|
@ -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,33 @@
|
|||
"""Products API endpoints."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.schemas.product import Product
|
||||
from app.services.design_service import DesignService
|
||||
|
||||
router = APIRouter()
|
||||
design_service = DesignService()
|
||||
|
||||
|
||||
@router.get("", response_model=list[Product])
|
||||
async def list_products(
|
||||
category: str | None = None,
|
||||
product_type: str | None = None,
|
||||
space: str | None = None,
|
||||
):
|
||||
"""List all products (designs with variants flattened for storefront)."""
|
||||
products = await design_service.list_products(
|
||||
category=category,
|
||||
product_type=product_type,
|
||||
space=space,
|
||||
)
|
||||
return products
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=Product)
|
||||
async def get_product(slug: str):
|
||||
"""Get a single product by slug."""
|
||||
product = await design_service.get_product(slug)
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
return product
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
"""Spaces API endpoints."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.services.space_service import SpaceService, Space
|
||||
|
||||
router = APIRouter()
|
||||
space_service = SpaceService()
|
||||
|
||||
|
||||
@router.get("", response_model=list[Space])
|
||||
async def list_spaces():
|
||||
"""List all available spaces."""
|
||||
return space_service.list_spaces()
|
||||
|
||||
|
||||
@router.get("/{space_id}", response_model=Space)
|
||||
async def get_space(space_id: str):
|
||||
"""Get a specific space by ID."""
|
||||
space = space_service.get_space(space_id)
|
||||
if not space:
|
||||
raise HTTPException(status_code=404, detail="Space not found")
|
||||
return space
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
"""Design upload API — users upload their own artwork."""
|
||||
|
||||
import io
|
||||
import re
|
||||
import uuid
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, UploadFile
|
||||
from PIL import Image
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import get_settings
|
||||
from app.api.designs import design_service
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
ALLOWED_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
MIN_DIMENSION = 500
|
||||
|
||||
|
||||
class UploadResponse(BaseModel):
|
||||
slug: str
|
||||
name: str
|
||||
image_url: str
|
||||
status: str
|
||||
products: list[dict]
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
text = text.lower().strip()
|
||||
text = re.sub(r'[^\w\s-]', '', text)
|
||||
text = re.sub(r'[\s_-]+', '-', text)
|
||||
text = re.sub(r'^-+|-+$', '', text)
|
||||
return text
|
||||
|
||||
|
||||
@router.post("/upload", response_model=UploadResponse)
|
||||
async def upload_design(
|
||||
file: UploadFile,
|
||||
name: str = Form(...),
|
||||
space: str = Form("default"),
|
||||
tags: str = Form(""),
|
||||
):
|
||||
"""Upload a custom design image."""
|
||||
|
||||
# Validate content type
|
||||
if file.content_type not in ALLOWED_TYPES:
|
||||
raise HTTPException(400, "Only PNG, JPEG, and WebP files are accepted")
|
||||
|
||||
# Read file and check size
|
||||
contents = await file.read()
|
||||
if len(contents) > MAX_FILE_SIZE:
|
||||
raise HTTPException(400, "File size must be under 10 MB")
|
||||
|
||||
# Open with Pillow and validate dimensions
|
||||
try:
|
||||
img = Image.open(io.BytesIO(contents))
|
||||
except Exception:
|
||||
raise HTTPException(400, "Could not read image file")
|
||||
|
||||
if img.width < MIN_DIMENSION or img.height < MIN_DIMENSION:
|
||||
raise HTTPException(400, f"Image must be at least {MIN_DIMENSION}x{MIN_DIMENSION} pixels")
|
||||
|
||||
# Create slug
|
||||
slug = slugify(name)
|
||||
if not slug:
|
||||
slug = f"upload-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Check for existing design
|
||||
design_dir = settings.designs_dir / "uploads" / slug
|
||||
if design_dir.exists():
|
||||
slug = f"{slug}-{uuid.uuid4().hex[:6]}"
|
||||
design_dir = settings.designs_dir / "uploads" / slug
|
||||
|
||||
# Save image as PNG
|
||||
design_dir.mkdir(parents=True, exist_ok=True)
|
||||
img = img.convert("RGBA")
|
||||
image_path = design_dir / f"{slug}.png"
|
||||
img.save(str(image_path), "PNG")
|
||||
|
||||
# Build metadata
|
||||
safe_name = name.replace('"', '\\"')
|
||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else ["custom", "upload"]
|
||||
tags_str = ", ".join(tag_list)
|
||||
space_field = space if space != "default" else "all"
|
||||
|
||||
metadata_content = f"""name: "{safe_name}"
|
||||
slug: {slug}
|
||||
description: "Custom uploaded design"
|
||||
tags: [{tags_str}]
|
||||
space: {space_field}
|
||||
category: uploads
|
||||
created: "{date.today().isoformat()}"
|
||||
author: user-upload
|
||||
|
||||
source:
|
||||
file: {slug}.png
|
||||
format: png
|
||||
dimensions:
|
||||
width: {img.width}
|
||||
height: {img.height}
|
||||
dpi: 300
|
||||
color_profile: sRGB
|
||||
|
||||
products:
|
||||
- type: sticker
|
||||
provider: prodigi
|
||||
sku: GLOBAL-STI-KIS-3X3
|
||||
variants: [matte, gloss]
|
||||
retail_price: 3.50
|
||||
- type: shirt
|
||||
provider: printful
|
||||
sku: "71"
|
||||
variants: [S, M, L, XL, 2XL]
|
||||
retail_price: 29.99
|
||||
- type: print
|
||||
provider: prodigi
|
||||
sku: GLOBAL-FAP-A4
|
||||
variants: [matte, lustre]
|
||||
retail_price: 12.99
|
||||
|
||||
status: draft
|
||||
"""
|
||||
metadata_path = design_dir / "metadata.yaml"
|
||||
metadata_path.write_text(metadata_content)
|
||||
|
||||
# Clear design cache so the new upload is discoverable
|
||||
design_service.clear_cache()
|
||||
|
||||
products = [
|
||||
{"type": "sticker", "price": 3.50},
|
||||
{"type": "shirt", "price": 29.99},
|
||||
{"type": "print", "price": 12.99},
|
||||
]
|
||||
|
||||
return UploadResponse(
|
||||
slug=slug,
|
||||
name=name,
|
||||
image_url=f"/api/designs/{slug}/image",
|
||||
status="draft",
|
||||
products=products,
|
||||
)
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
"""Webhook endpoints for Mollie and POD providers."""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.services.mollie_service import MollieService
|
||||
from app.services.order_service import OrderService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_mollie_service() -> MollieService:
|
||||
return MollieService()
|
||||
|
||||
|
||||
def get_order_service(db: AsyncSession = Depends(get_db)) -> OrderService:
|
||||
return OrderService(db)
|
||||
|
||||
|
||||
@router.post("/mollie")
|
||||
async def mollie_webhook(
|
||||
request: Request,
|
||||
mollie_service: MollieService = Depends(get_mollie_service),
|
||||
order_service: OrderService = Depends(get_order_service),
|
||||
):
|
||||
"""Handle Mollie webhook events.
|
||||
|
||||
Mollie sends a POST with form data containing just the payment ID.
|
||||
We then fetch the full payment details from Mollie's API to verify status.
|
||||
"""
|
||||
form = await request.form()
|
||||
payment_id = form.get("id")
|
||||
|
||||
if not payment_id:
|
||||
raise HTTPException(status_code=400, detail="Missing payment id")
|
||||
|
||||
# Fetch payment from Mollie API (this IS the verification — no signature needed)
|
||||
payment = await mollie_service.get_payment(payment_id)
|
||||
|
||||
status = payment.get("status")
|
||||
if status == "paid":
|
||||
await order_service.handle_successful_payment(payment)
|
||||
elif status in ("failed", "canceled", "expired"):
|
||||
# Log but no action needed
|
||||
print(f"Mollie payment {payment_id} status: {status}")
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/prodigi")
|
||||
async def prodigi_webhook(
|
||||
request: Request,
|
||||
order_service: OrderService = Depends(get_order_service),
|
||||
):
|
||||
"""Handle Prodigi webhook events."""
|
||||
payload = await request.json()
|
||||
|
||||
event_type = payload.get("event")
|
||||
order_data = payload.get("order", {})
|
||||
|
||||
if event_type in ["order.shipped", "order.complete"]:
|
||||
await order_service.update_pod_status(
|
||||
pod_provider="prodigi",
|
||||
pod_order_id=order_data.get("id"),
|
||||
status=event_type.replace("order.", ""),
|
||||
tracking_number=order_data.get("shipments", [{}])[0].get("trackingNumber"),
|
||||
tracking_url=order_data.get("shipments", [{}])[0].get("trackingUrl"),
|
||||
)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/printful")
|
||||
async def printful_webhook(
|
||||
request: Request,
|
||||
order_service: OrderService = Depends(get_order_service),
|
||||
):
|
||||
"""Handle Printful webhook events."""
|
||||
payload = await request.json()
|
||||
|
||||
event_type = payload.get("type")
|
||||
order_data = payload.get("data", {}).get("order", {})
|
||||
|
||||
if event_type in ["package_shipped", "order_fulfilled"]:
|
||||
shipment = payload.get("data", {}).get("shipment", {})
|
||||
await order_service.update_pod_status(
|
||||
pod_provider="printful",
|
||||
pod_order_id=str(order_data.get("id")),
|
||||
status="shipped" if event_type == "package_shipped" else "fulfilled",
|
||||
tracking_number=shipment.get("tracking_number"),
|
||||
tracking_url=shipment.get("tracking_url"),
|
||||
)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
"""Application configuration."""
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Database
|
||||
database_url: str = "postgresql://swag:devpassword@localhost:5432/swag"
|
||||
|
||||
# Redis
|
||||
redis_url: str = "redis://localhost:6379"
|
||||
|
||||
# Mollie
|
||||
mollie_api_key: str = ""
|
||||
|
||||
# POD Providers
|
||||
prodigi_api_key: str = ""
|
||||
printful_api_token: str = ""
|
||||
printful_store_id: str = ""
|
||||
pod_sandbox_mode: bool = True
|
||||
|
||||
# Flow Service (TBFF revenue split → bonding curve)
|
||||
flow_service_url: str = ""
|
||||
flow_id: str = ""
|
||||
flow_funnel_id: str = ""
|
||||
flow_revenue_split: float = 0.5 # fraction of margin routed to flow (0.0-1.0)
|
||||
|
||||
# Auth
|
||||
jwt_secret: str = "dev-secret-change-in-production"
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_expire_hours: int = 24
|
||||
|
||||
# Email (SMTP via Mailcow)
|
||||
smtp_host: str = "mail.rmail.online"
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = Field(default="", validation_alias=AliasChoices("smtp_password", "SMTP_PASSWORD", "SMTP_PASS"))
|
||||
smtp_from_email: str = "noreply@rswag.online"
|
||||
smtp_from_name: str = "rSwag"
|
||||
|
||||
# CORS
|
||||
cors_origins: str = "http://localhost:3000"
|
||||
|
||||
# Paths
|
||||
designs_path: str = "/app/designs"
|
||||
config_path: str = "/app/config"
|
||||
spaces_path: str = "/app/spaces"
|
||||
|
||||
# App
|
||||
app_name: str = "rSwag"
|
||||
public_url: str = "https://rswag.online"
|
||||
debug: bool = False
|
||||
|
||||
@property
|
||||
def designs_dir(self) -> Path:
|
||||
return Path(self.designs_path)
|
||||
|
||||
@property
|
||||
def config_dir(self) -> Path:
|
||||
return Path(self.config_path)
|
||||
|
||||
@property
|
||||
def spaces_dir(self) -> Path:
|
||||
return Path(self.spaces_path)
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.cors_origins.split(",")]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance."""
|
||||
return Settings()
|
||||
|
|
@ -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,65 @@
|
|||
"""FastAPI application entry point."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import get_settings
|
||||
from app.api import designs, products, cart, checkout, orders, webhooks, health, design_generator, upload, spaces
|
||||
from app.api.admin import router as admin_router
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan handler."""
|
||||
# Startup
|
||||
print(f"Starting {settings.app_name}...")
|
||||
print(f"Designs path: {settings.designs_path}")
|
||||
print(f"POD sandbox mode: {settings.pod_sandbox_mode}")
|
||||
yield
|
||||
# Shutdown
|
||||
print("Shutting down...")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
description="E-commerce API for rSpace ecosystem merchandise",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS middleware - allow all rswag.online subdomains + configured origins
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_origin_regex=r"https?://(([\w-]+\.)?rswag\.online|fungiswag\.jeffemmett\.com)",
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(health.router, prefix="/api", tags=["health"])
|
||||
app.include_router(designs.router, prefix="/api/designs", tags=["designs"])
|
||||
app.include_router(products.router, prefix="/api/products", tags=["products"])
|
||||
app.include_router(cart.router, prefix="/api/cart", tags=["cart"])
|
||||
app.include_router(checkout.router, prefix="/api/checkout", tags=["checkout"])
|
||||
app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
|
||||
app.include_router(webhooks.router, prefix="/api/webhooks", tags=["webhooks"])
|
||||
app.include_router(design_generator.router, prefix="/api/design", tags=["design-generator"])
|
||||
app.include_router(upload.router, prefix="/api/design", tags=["upload"])
|
||||
app.include_router(spaces.router, prefix="/api/spaces", tags=["spaces"])
|
||||
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint."""
|
||||
return {
|
||||
"name": settings.app_name,
|
||||
"version": "0.1.0",
|
||||
"docs": "/docs",
|
||||
}
|
||||
|
|
@ -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)
|
||||
external_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
carts: Mapped[list["Cart"]] = relationship("Cart", back_populates="customer")
|
||||
orders: Mapped[list["Order"]] = relationship("Order", back_populates="customer")
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
"""Order models."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import String, Integer, Numeric, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class OrderStatus(str, Enum):
|
||||
"""Order status enum."""
|
||||
|
||||
PENDING = "pending"
|
||||
PAID = "paid"
|
||||
PROCESSING = "processing"
|
||||
PRINTING = "printing"
|
||||
SHIPPED = "shipped"
|
||||
DELIVERED = "delivered"
|
||||
CANCELLED = "cancelled"
|
||||
REFUNDED = "refunded"
|
||||
|
||||
|
||||
class Order(Base):
|
||||
"""Order model."""
|
||||
|
||||
__tablename__ = "orders"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
customer_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("customers.id"), nullable=True
|
||||
)
|
||||
|
||||
# Payment provider info (provider-agnostic)
|
||||
payment_provider: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
payment_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
payment_method: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(50), default=OrderStatus.PENDING.value)
|
||||
|
||||
# Shipping info
|
||||
shipping_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
shipping_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
shipping_address_line1: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
shipping_address_line2: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
shipping_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
shipping_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
shipping_postal_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
shipping_country: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||
|
||||
# Financials
|
||||
subtotal: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
shipping_cost: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
tax: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
total: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="USD")
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
paid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
shipped_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
delivered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
customer: Mapped["Customer | None"] = relationship("Customer", back_populates="orders")
|
||||
items: Mapped[list["OrderItem"]] = relationship(
|
||||
"OrderItem", back_populates="order", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class OrderItem(Base):
|
||||
"""Order item model."""
|
||||
|
||||
__tablename__ = "order_items"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
order_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("orders.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
product_slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
product_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
variant: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
quantity: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
unit_price: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
|
||||
# POD fulfillment
|
||||
pod_provider: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
pod_order_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
pod_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
pod_tracking_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
pod_tracking_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
order: Mapped["Order"] = relationship("Order", back_populates="items")
|
||||
|
|
@ -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,273 @@
|
|||
"""Printful Print-on-Demand API client (v2).
|
||||
|
||||
Handles catalog lookup, mockup generation, and order submission.
|
||||
API v2 docs: https://developers.printful.com/docs/v2-beta/
|
||||
Rate limit: 120 req/60s (leaky bucket), lower for mockups.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
BASE_URL = "https://api.printful.com/v2"
|
||||
|
||||
# In-memory cache for catalog variants: {product_id: {"variants": [...], "ts": float}}
|
||||
_variant_cache: dict[int, dict] = {}
|
||||
_VARIANT_CACHE_TTL = 86400 # 24 hours
|
||||
|
||||
|
||||
class PrintfulClient:
|
||||
"""Client for the Printful v2 API."""
|
||||
|
||||
def __init__(self):
|
||||
self.api_token = settings.printful_api_token
|
||||
self.sandbox = settings.pod_sandbox_mode
|
||||
self.enabled = bool(self.api_token)
|
||||
|
||||
@property
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if settings.printful_store_id:
|
||||
headers["X-PF-Store-Id"] = settings.printful_store_id
|
||||
return headers
|
||||
|
||||
# ── Catalog ──
|
||||
|
||||
async def get_catalog_variants(self, product_id: int) -> list[dict]:
|
||||
"""Get variants for a catalog product (cached 24h).
|
||||
|
||||
Each variant has: id (int), size (str), color (str), color_code (str).
|
||||
"""
|
||||
cached = _variant_cache.get(product_id)
|
||||
if cached and (time.time() - cached["ts"]) < _VARIANT_CACHE_TTL:
|
||||
return cached["variants"]
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(
|
||||
f"{BASE_URL}/catalog-products/{product_id}/catalog-variants",
|
||||
headers=self._headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
variants = resp.json().get("data", [])
|
||||
|
||||
_variant_cache[product_id] = {"variants": variants, "ts": time.time()}
|
||||
return variants
|
||||
|
||||
async def resolve_variant_id(
|
||||
self,
|
||||
product_id: int,
|
||||
size: str,
|
||||
color: str = "Black",
|
||||
) -> int | None:
|
||||
"""Resolve (product_id, size, color) → Printful catalog_variant_id.
|
||||
|
||||
Our metadata uses SKU "71" + variants ["S","M","L",...].
|
||||
Printful orders require numeric catalog_variant_id.
|
||||
"""
|
||||
variants = await self.get_catalog_variants(product_id)
|
||||
|
||||
# Try exact match on size + color
|
||||
for v in variants:
|
||||
if (
|
||||
v.get("size", "").upper() == size.upper()
|
||||
and color.lower() in v.get("color", "").lower()
|
||||
):
|
||||
return v.get("id")
|
||||
|
||||
# Fallback: match size only
|
||||
for v in variants:
|
||||
if v.get("size", "").upper() == size.upper():
|
||||
return v.get("id")
|
||||
|
||||
return None
|
||||
|
||||
# ── Mockup Generation ──
|
||||
|
||||
async def create_mockup_task(
|
||||
self,
|
||||
product_id: int,
|
||||
variant_ids: list[int],
|
||||
image_url: str,
|
||||
placement: str = "front",
|
||||
technique: str = "dtg",
|
||||
) -> str:
|
||||
"""Start async mockup generation task (v2 format).
|
||||
|
||||
Returns task_id to poll with get_mockup_task().
|
||||
|
||||
v2 payload uses products array with catalog source, and layers
|
||||
inside placements instead of flat image_url.
|
||||
"""
|
||||
payload = {
|
||||
"products": [
|
||||
{
|
||||
"source": "catalog",
|
||||
"catalog_product_id": product_id,
|
||||
"catalog_variant_ids": variant_ids,
|
||||
"placements": [
|
||||
{
|
||||
"placement": placement,
|
||||
"technique": technique,
|
||||
"layers": [
|
||||
{
|
||||
"type": "file",
|
||||
"url": image_url,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"format": "png",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
f"{BASE_URL}/mockup-tasks",
|
||||
headers=self._headers,
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
# v2 returns {"data": [{ ... }]} — data is a list
|
||||
raw_data = resp.json().get("data", [])
|
||||
data = raw_data[0] if isinstance(raw_data, list) and raw_data else raw_data
|
||||
task_id = data.get("id") or data.get("task_key") or data.get("task_id")
|
||||
logger.info(f"Printful mockup task created: {task_id}")
|
||||
return str(task_id)
|
||||
|
||||
async def get_mockup_task(self, task_id: str) -> dict:
|
||||
"""Poll mockup task status (v2 format).
|
||||
|
||||
Returns dict with "status" (pending/completed/failed) and
|
||||
"catalog_variant_mockups" list when completed.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(
|
||||
f"{BASE_URL}/mockup-tasks",
|
||||
headers=self._headers,
|
||||
params={"id": task_id},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
# v2 returns {"data": [{ ... }]} — data is a list
|
||||
raw_data = resp.json().get("data", [])
|
||||
if isinstance(raw_data, list) and raw_data:
|
||||
return raw_data[0]
|
||||
return raw_data if isinstance(raw_data, dict) else {}
|
||||
|
||||
async def generate_mockup_and_wait(
|
||||
self,
|
||||
product_id: int,
|
||||
variant_ids: list[int],
|
||||
image_url: str,
|
||||
placement: str = "front",
|
||||
technique: str = "dtg",
|
||||
max_polls: int = 20,
|
||||
poll_interval: float = 3.0,
|
||||
) -> list[dict] | None:
|
||||
"""Create mockup task and poll until complete.
|
||||
|
||||
Returns list of mockup dicts with "mockup_url" fields,
|
||||
or None on failure/timeout.
|
||||
"""
|
||||
task_id = await self.create_mockup_task(
|
||||
product_id, variant_ids, image_url, placement, technique
|
||||
)
|
||||
|
||||
for _ in range(max_polls):
|
||||
await asyncio.sleep(poll_interval)
|
||||
result = await self.get_mockup_task(task_id)
|
||||
status = result.get("status", "")
|
||||
|
||||
if status == "completed":
|
||||
return (
|
||||
result.get("mockups", [])
|
||||
or result.get("catalog_variant_mockups", [])
|
||||
)
|
||||
elif status == "failed":
|
||||
reasons = result.get("failure_reasons", [])
|
||||
logger.error(f"Mockup task {task_id} failed: {reasons}")
|
||||
return None
|
||||
|
||||
logger.warning(f"Mockup task {task_id} timed out after {max_polls} polls")
|
||||
return None
|
||||
|
||||
# ── Orders ──
|
||||
|
||||
async def create_order(
|
||||
self,
|
||||
items: list[dict],
|
||||
recipient: dict,
|
||||
) -> dict:
|
||||
"""Create a fulfillment order.
|
||||
|
||||
Args:
|
||||
items: List of dicts with:
|
||||
- catalog_variant_id (int)
|
||||
- quantity (int)
|
||||
- image_url (str) — public URL to design
|
||||
- placement (str, default "front")
|
||||
recipient: dict with name, address1, city, state_code,
|
||||
country_code, zip, email (optional)
|
||||
"""
|
||||
if not self.enabled:
|
||||
raise ValueError("Printful API token not configured")
|
||||
|
||||
order_items = []
|
||||
for item in items:
|
||||
order_items.append({
|
||||
"source": "catalog",
|
||||
"catalog_variant_id": item["catalog_variant_id"],
|
||||
"quantity": item.get("quantity", 1),
|
||||
"placements": [
|
||||
{
|
||||
"placement": item.get("placement", "front"),
|
||||
"technique": "dtg",
|
||||
"layers": [
|
||||
{
|
||||
"type": "file",
|
||||
"url": item["image_url"],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
payload = {
|
||||
"recipient": recipient,
|
||||
"items": order_items,
|
||||
}
|
||||
|
||||
# Sandbox mode: create as draft (not sent to production)
|
||||
if self.sandbox:
|
||||
payload["draft"] = True
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
f"{BASE_URL}/orders",
|
||||
headers=self._headers,
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json().get("data", {})
|
||||
logger.info(f"Printful order created: {result.get('id')}")
|
||||
return result
|
||||
|
||||
async def get_order(self, order_id: str) -> dict:
|
||||
"""Get order details by Printful order ID."""
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(
|
||||
f"{BASE_URL}/orders/{order_id}",
|
||||
headers=self._headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("data", {})
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
"""Prodigi Print-on-Demand API client (v4).
|
||||
|
||||
Handles order submission, product specs, and quotes.
|
||||
Sandbox: https://api.sandbox.prodigi.com/v4.0/
|
||||
Production: https://api.prodigi.com/v4.0/
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
SANDBOX_URL = "https://api.sandbox.prodigi.com/v4.0"
|
||||
PRODUCTION_URL = "https://api.prodigi.com/v4.0"
|
||||
|
||||
|
||||
class ProdigiClient:
|
||||
"""Client for the Prodigi v4 Print API."""
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = settings.prodigi_api_key
|
||||
self.base_url = SANDBOX_URL if settings.pod_sandbox_mode else PRODUCTION_URL
|
||||
self.enabled = bool(self.api_key)
|
||||
|
||||
@property
|
||||
def _headers(self) -> dict:
|
||||
return {
|
||||
"X-API-Key": self.api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def create_order(
|
||||
self,
|
||||
items: list[dict],
|
||||
recipient: dict,
|
||||
shipping_method: str = "Budget",
|
||||
metadata: dict | None = None,
|
||||
) -> dict:
|
||||
"""Create a Prodigi print order.
|
||||
|
||||
Args:
|
||||
items: List of items, each with:
|
||||
- sku: Prodigi SKU (e.g., "GLOBAL-STI-KIS-4X4")
|
||||
- copies: Number of copies
|
||||
- sizing: "fillPrintArea" | "fitPrintArea" | "stretchToPrintArea"
|
||||
- assets: [{"printArea": "default", "url": "https://..."}]
|
||||
recipient: Shipping address with:
|
||||
- name: Recipient name
|
||||
- email: Email (optional)
|
||||
- address: {line1, line2, townOrCity, stateOrCounty, postalOrZipCode, countryCode}
|
||||
shipping_method: "Budget" | "Standard" | "Express"
|
||||
metadata: Optional key/value metadata
|
||||
"""
|
||||
if not self.enabled:
|
||||
raise ValueError("Prodigi API key not configured")
|
||||
|
||||
payload = {
|
||||
"shippingMethod": shipping_method,
|
||||
"recipient": recipient,
|
||||
"items": items,
|
||||
}
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/Orders",
|
||||
headers=self._headers,
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
logger.info(f"Prodigi order created: {result.get('id')}")
|
||||
return result
|
||||
|
||||
async def get_order(self, order_id: str) -> dict:
|
||||
"""Get order details by Prodigi order ID."""
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(
|
||||
f"{self.base_url}/Orders/{order_id}",
|
||||
headers=self._headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_product(self, sku: str) -> dict:
|
||||
"""Get product specifications (dimensions, print areas, etc.)."""
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(
|
||||
f"{self.base_url}/products/{sku}",
|
||||
headers=self._headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_quote(
|
||||
self,
|
||||
items: list[dict],
|
||||
shipping_method: str = "Budget",
|
||||
destination_country: str = "US",
|
||||
) -> dict:
|
||||
"""Get a pricing quote before ordering.
|
||||
|
||||
Args:
|
||||
items: List with sku, copies, sizing, assets
|
||||
shipping_method: Shipping tier
|
||||
destination_country: 2-letter country code
|
||||
"""
|
||||
payload = {
|
||||
"shippingMethod": shipping_method,
|
||||
"destinationCountryCode": destination_country,
|
||||
"items": [
|
||||
{"sku": item["sku"], "copies": item.get("copies", 1)}
|
||||
for item in items
|
||||
],
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/quotes",
|
||||
headers=self._headers,
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
|
@ -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,43 @@
|
|||
"""Design schemas."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DesignSource(BaseModel):
|
||||
"""Design source file information."""
|
||||
|
||||
file: str
|
||||
format: str
|
||||
dimensions: dict[str, int]
|
||||
dpi: int
|
||||
color_profile: str = "sRGB"
|
||||
|
||||
|
||||
class DesignProduct(BaseModel):
|
||||
"""Product configuration for a design."""
|
||||
|
||||
type: str
|
||||
provider: str
|
||||
sku: str
|
||||
variants: list[str] = []
|
||||
retail_price: float
|
||||
|
||||
|
||||
class Design(BaseModel):
|
||||
"""Design information from metadata.yaml."""
|
||||
|
||||
slug: str
|
||||
name: str
|
||||
description: str
|
||||
tags: list[str] = []
|
||||
category: str
|
||||
author: str = ""
|
||||
created: str = ""
|
||||
source: DesignSource
|
||||
products: list[DesignProduct] = []
|
||||
space: str = "default"
|
||||
status: str = "draft"
|
||||
image_url: str = ""
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -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,300 @@
|
|||
"""Design service for reading designs from the designs directory."""
|
||||
|
||||
from pathlib import Path
|
||||
from functools import lru_cache
|
||||
|
||||
import yaml
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.schemas.design import Design, DesignSource, DesignProduct
|
||||
from app.schemas.product import Product, ProductVariant
|
||||
from app.models.product import ProductOverride
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class DesignService:
|
||||
"""Service for reading and managing designs."""
|
||||
|
||||
def __init__(self):
|
||||
self.designs_path = settings.designs_dir
|
||||
self._cache: dict[str, Design] = {}
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the design cache."""
|
||||
self._cache.clear()
|
||||
|
||||
async def list_designs(
|
||||
self,
|
||||
status: str = "active",
|
||||
category: str | None = None,
|
||||
space: str | None = None,
|
||||
) -> list[Design]:
|
||||
"""List all designs from the designs directory."""
|
||||
designs = []
|
||||
|
||||
if not self.designs_path.exists():
|
||||
return designs
|
||||
|
||||
for category_dir in self.designs_path.iterdir():
|
||||
if not category_dir.is_dir():
|
||||
continue
|
||||
|
||||
# Filter by category if specified
|
||||
if category and category_dir.name != category:
|
||||
continue
|
||||
|
||||
for design_dir in category_dir.iterdir():
|
||||
if not design_dir.is_dir():
|
||||
continue
|
||||
|
||||
design = await self._load_design(design_dir, category_dir.name)
|
||||
if design and design.status == status:
|
||||
# Filter by space if specified
|
||||
if space and space != "all":
|
||||
if design.space != space and design.space != "all":
|
||||
continue
|
||||
designs.append(design)
|
||||
|
||||
return designs
|
||||
|
||||
async def get_design(self, slug: str) -> Design | None:
|
||||
"""Get a single design by slug."""
|
||||
# Check cache
|
||||
if slug in self._cache:
|
||||
return self._cache[slug]
|
||||
|
||||
# Search for the design
|
||||
for category_dir in self.designs_path.iterdir():
|
||||
if not category_dir.is_dir():
|
||||
continue
|
||||
|
||||
design_dir = category_dir / slug
|
||||
if design_dir.exists():
|
||||
design = await self._load_design(design_dir, category_dir.name)
|
||||
if design:
|
||||
self._cache[slug] = design
|
||||
return design
|
||||
|
||||
return None
|
||||
|
||||
async def get_design_image_path(self, slug: str) -> str | None:
|
||||
"""Get the path to the design image file."""
|
||||
design = await self.get_design(slug)
|
||||
if not design:
|
||||
return None
|
||||
|
||||
# Look for exported PNG first
|
||||
for category_dir in self.designs_path.iterdir():
|
||||
if not category_dir.is_dir():
|
||||
continue
|
||||
|
||||
design_dir = category_dir / slug
|
||||
if not design_dir.exists():
|
||||
continue
|
||||
|
||||
# Check exports/300dpi first
|
||||
export_path = design_dir / "exports" / "300dpi" / f"{slug}.png"
|
||||
if export_path.exists():
|
||||
return str(export_path)
|
||||
|
||||
# Check for source PNG
|
||||
source_path = design_dir / design.source.file
|
||||
if source_path.exists() and source_path.suffix.lower() == ".png":
|
||||
return str(source_path)
|
||||
|
||||
# Check for any PNG in the directory
|
||||
for png_file in design_dir.glob("*.png"):
|
||||
return str(png_file)
|
||||
|
||||
return None
|
||||
|
||||
async def _load_design(self, design_dir: Path, category: str) -> Design | None:
|
||||
"""Load a design from its directory."""
|
||||
metadata_path = design_dir / "metadata.yaml"
|
||||
if not metadata_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(metadata_path) as f:
|
||||
metadata = yaml.safe_load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not metadata:
|
||||
return None
|
||||
|
||||
slug = metadata.get("slug", design_dir.name)
|
||||
|
||||
# Parse source info
|
||||
source_data = metadata.get("source", {})
|
||||
source = DesignSource(
|
||||
file=source_data.get("file", f"{slug}.svg"),
|
||||
format=source_data.get("format", "svg"),
|
||||
dimensions=source_data.get("dimensions", {"width": 0, "height": 0}),
|
||||
dpi=source_data.get("dpi", 300),
|
||||
color_profile=source_data.get("color_profile", "sRGB"),
|
||||
)
|
||||
|
||||
# Parse products
|
||||
products = []
|
||||
for p in metadata.get("products", []):
|
||||
products.append(
|
||||
DesignProduct(
|
||||
type=p.get("type", ""),
|
||||
provider=p.get("provider", ""),
|
||||
sku=str(p.get("sku", "")), # Convert to string (some SKUs are integers)
|
||||
variants=p.get("variants", []),
|
||||
retail_price=float(p.get("retail_price", 0)),
|
||||
)
|
||||
)
|
||||
|
||||
return Design(
|
||||
slug=slug,
|
||||
name=metadata.get("name", slug),
|
||||
description=metadata.get("description", ""),
|
||||
tags=metadata.get("tags", []),
|
||||
category=category,
|
||||
author=metadata.get("author", ""),
|
||||
created=str(metadata.get("created", "")),
|
||||
source=source,
|
||||
products=products,
|
||||
space=metadata.get("space", "default"),
|
||||
status=metadata.get("status", "draft"),
|
||||
image_url=f"/api/designs/{slug}/image",
|
||||
)
|
||||
|
||||
async def list_products(
|
||||
self,
|
||||
category: str | None = None,
|
||||
product_type: str | None = None,
|
||||
space: str | None = None,
|
||||
) -> list[Product]:
|
||||
"""List all products (designs formatted for storefront)."""
|
||||
designs = await self.list_designs(status="active", category=category, space=space)
|
||||
products = []
|
||||
|
||||
for design in designs:
|
||||
# Skip designs with no products
|
||||
if not design.products:
|
||||
continue
|
||||
|
||||
# Filter by product type if specified
|
||||
matching_products = [
|
||||
dp for dp in design.products
|
||||
if not product_type or dp.type == product_type
|
||||
]
|
||||
|
||||
if not matching_products:
|
||||
continue
|
||||
|
||||
# Use the first matching product for base info, combine all variants
|
||||
dp = matching_products[0]
|
||||
all_variants = []
|
||||
|
||||
for mp in matching_products:
|
||||
if mp.variants:
|
||||
for v in mp.variants:
|
||||
all_variants.append(
|
||||
ProductVariant(
|
||||
name=f"{v} ({mp.provider})",
|
||||
sku=f"{mp.sku}-{v}",
|
||||
provider=mp.provider,
|
||||
price=mp.retail_price,
|
||||
)
|
||||
)
|
||||
else:
|
||||
all_variants.append(
|
||||
ProductVariant(
|
||||
name=f"default ({mp.provider})",
|
||||
sku=mp.sku,
|
||||
provider=mp.provider,
|
||||
price=mp.retail_price,
|
||||
)
|
||||
)
|
||||
|
||||
products.append(
|
||||
Product(
|
||||
slug=design.slug,
|
||||
name=design.name,
|
||||
description=design.description,
|
||||
category=design.category,
|
||||
product_type=dp.type,
|
||||
tags=design.tags,
|
||||
image_url=design.image_url,
|
||||
base_price=dp.retail_price,
|
||||
variants=all_variants,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
|
||||
return products
|
||||
|
||||
async def get_product(self, slug: str) -> Product | None:
|
||||
"""Get a single product by slug."""
|
||||
design = await self.get_design(slug)
|
||||
if not design or not design.products:
|
||||
return None
|
||||
|
||||
# Use the first product configuration
|
||||
dp = design.products[0]
|
||||
variants = [
|
||||
ProductVariant(
|
||||
name=v,
|
||||
sku=f"{dp.sku}-{v}",
|
||||
provider=dp.provider,
|
||||
price=dp.retail_price,
|
||||
)
|
||||
for v in dp.variants
|
||||
] if dp.variants else [
|
||||
ProductVariant(
|
||||
name="default",
|
||||
sku=dp.sku,
|
||||
provider=dp.provider,
|
||||
price=dp.retail_price,
|
||||
)
|
||||
]
|
||||
|
||||
return Product(
|
||||
slug=design.slug,
|
||||
name=design.name,
|
||||
description=design.description,
|
||||
category=design.category,
|
||||
product_type=dp.type,
|
||||
tags=design.tags,
|
||||
image_url=design.image_url,
|
||||
base_price=dp.retail_price,
|
||||
variants=variants,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
async def set_product_override(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
slug: str,
|
||||
is_active: bool | None = None,
|
||||
price_override: float | None = None,
|
||||
):
|
||||
"""Set a product override in the database."""
|
||||
# Check if override exists
|
||||
result = await db.execute(
|
||||
select(ProductOverride).where(ProductOverride.slug == slug)
|
||||
)
|
||||
override = result.scalar_one_or_none()
|
||||
|
||||
if override:
|
||||
if is_active is not None:
|
||||
override.is_active = is_active
|
||||
if price_override is not None:
|
||||
override.price_override = price_override
|
||||
else:
|
||||
override = ProductOverride(
|
||||
slug=slug,
|
||||
is_active=is_active if is_active is not None else True,
|
||||
price_override=price_override,
|
||||
)
|
||||
db.add(override)
|
||||
|
||||
await db.commit()
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
"""Email service for order confirmations and shipping notifications."""
|
||||
|
||||
import logging
|
||||
import ssl
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import aiosmtplib
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""Async email sender via SMTP (Mailcow)."""
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(settings.smtp_user and settings.smtp_password)
|
||||
|
||||
async def send_order_confirmation(
|
||||
self,
|
||||
*,
|
||||
to_email: str,
|
||||
to_name: str | None,
|
||||
order_id: str,
|
||||
items: list[dict],
|
||||
total: float,
|
||||
currency: str = "USD",
|
||||
):
|
||||
"""Send order confirmation email after successful payment."""
|
||||
if not self.enabled:
|
||||
logger.info("SMTP not configured, skipping order confirmation email")
|
||||
return
|
||||
|
||||
subject = f"Order Confirmed — {settings.app_name} #{order_id[:8]}"
|
||||
html = self._render_confirmation_html(
|
||||
to_name=to_name,
|
||||
order_id=order_id,
|
||||
items=items,
|
||||
total=total,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
await self._send(to_email=to_email, subject=subject, html=html)
|
||||
|
||||
async def send_shipping_notification(
|
||||
self,
|
||||
*,
|
||||
to_email: str,
|
||||
to_name: str | None,
|
||||
order_id: str,
|
||||
tracking_number: str | None = None,
|
||||
tracking_url: str | None = None,
|
||||
):
|
||||
"""Send shipping notification when POD provider ships the order."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
subject = f"Your Order Has Shipped — {settings.app_name}"
|
||||
html = self._render_shipping_html(
|
||||
to_name=to_name,
|
||||
order_id=order_id,
|
||||
tracking_number=tracking_number,
|
||||
tracking_url=tracking_url,
|
||||
)
|
||||
|
||||
await self._send(to_email=to_email, subject=subject, html=html)
|
||||
|
||||
async def _send(self, *, to_email: str, subject: str, html: str):
|
||||
"""Send an HTML email via SMTP."""
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_from_email}>"
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = subject
|
||||
|
||||
# Plain-text fallback
|
||||
plain = html.replace("<br>", "\n").replace("</p>", "\n")
|
||||
# Strip remaining tags
|
||||
import re
|
||||
plain = re.sub(r"<[^>]+>", "", plain)
|
||||
msg.attach(MIMEText(plain, "plain"))
|
||||
msg.attach(MIMEText(html, "html"))
|
||||
|
||||
tls_context = ssl.create_default_context()
|
||||
tls_context.check_hostname = False
|
||||
tls_context.verify_mode = ssl.CERT_NONE # self-signed cert on Mailcow
|
||||
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=settings.smtp_host,
|
||||
port=settings.smtp_port,
|
||||
username=settings.smtp_user,
|
||||
password=settings.smtp_password,
|
||||
start_tls=True,
|
||||
tls_context=tls_context,
|
||||
)
|
||||
logger.info(f"Sent email to {to_email}: {subject}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {to_email}: {e}")
|
||||
|
||||
def _render_confirmation_html(
|
||||
self,
|
||||
*,
|
||||
to_name: str | None,
|
||||
order_id: str,
|
||||
items: list[dict],
|
||||
total: float,
|
||||
currency: str,
|
||||
) -> str:
|
||||
greeting = f"Hi {to_name}," if to_name else "Hi there,"
|
||||
order_url = f"{settings.public_url}/checkout/success?order_id={order_id}"
|
||||
currency_symbol = "$" if currency == "USD" else currency + " "
|
||||
|
||||
items_html = ""
|
||||
for item in items:
|
||||
qty = item.get("quantity", 1)
|
||||
name = item.get("product_name", "Item")
|
||||
variant = item.get("variant", "")
|
||||
price = item.get("unit_price", 0)
|
||||
variant_str = f" ({variant})" if variant else ""
|
||||
items_html += f"""
|
||||
<tr>
|
||||
<td style="padding:8px 0;border-bottom:1px solid #222;">{name}{variant_str}</td>
|
||||
<td style="padding:8px 0;border-bottom:1px solid #222;text-align:center;">{qty}</td>
|
||||
<td style="padding:8px 0;border-bottom:1px solid #222;text-align:right;">{currency_symbol}{price:.2f}</td>
|
||||
</tr>"""
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="margin:0;padding:0;background:#0a0a0a;color:#e5e5e5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<div style="max-width:560px;margin:0 auto;padding:32px 24px;">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="text-align:center;padding-bottom:24px;border-bottom:1px solid #222;">
|
||||
<div style="display:inline-block;background:linear-gradient(135deg,#22d3ee,#f59e0b);border-radius:10px;width:40px;height:40px;line-height:40px;font-size:12px;font-weight:900;color:#0a0a0a;text-align:center;">rSw</div>
|
||||
<h1 style="margin:12px 0 0;font-size:22px;color:#fff;">Order Confirmed</h1>
|
||||
</div>
|
||||
|
||||
<!-- Greeting -->
|
||||
<p style="margin:24px 0 8px;font-size:15px;">{greeting}</p>
|
||||
<p style="margin:0 0 24px;font-size:15px;">
|
||||
Thank you for your order! Your items are being prepared for production.
|
||||
Print-on-demand means each piece is made just for you at the nearest fulfillment center.
|
||||
</p>
|
||||
|
||||
<!-- Order Summary -->
|
||||
<div style="background:#111;border:1px solid #222;border-radius:12px;padding:20px;margin-bottom:24px;">
|
||||
<div style="font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#888;margin-bottom:12px;">Order Summary</div>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:14px;">
|
||||
<tr style="color:#888;font-size:12px;">
|
||||
<td style="padding-bottom:8px;">Item</td>
|
||||
<td style="padding-bottom:8px;text-align:center;">Qty</td>
|
||||
<td style="padding-bottom:8px;text-align:right;">Price</td>
|
||||
</tr>
|
||||
{items_html}
|
||||
<tr>
|
||||
<td style="padding:12px 0 0;font-weight:700;color:#22d3ee;" colspan="2">Total</td>
|
||||
<td style="padding:12px 0 0;font-weight:700;color:#22d3ee;text-align:right;">{currency_symbol}{total:.2f}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Status Link -->
|
||||
<div style="text-align:center;margin-bottom:24px;">
|
||||
<a href="{order_url}" style="display:inline-block;background:linear-gradient(135deg,#22d3ee,#0891b2);color:#fff;text-decoration:none;padding:12px 32px;border-radius:8px;font-weight:600;font-size:14px;">
|
||||
View Order Status
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- What happens next -->
|
||||
<div style="background:#111;border:1px solid #222;border-radius:12px;padding:20px;margin-bottom:24px;">
|
||||
<div style="font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#888;margin-bottom:12px;">What Happens Next</div>
|
||||
<ol style="margin:0;padding-left:20px;font-size:14px;line-height:1.8;">
|
||||
<li>Your design is sent to the nearest print facility</li>
|
||||
<li>Each item is printed on demand — just for you</li>
|
||||
<li>You'll get a shipping email with tracking info</li>
|
||||
<li>Revenue from your purchase supports the community</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="text-align:center;padding-top:24px;border-top:1px solid #222;font-size:12px;color:#555;">
|
||||
<p style="margin:0;">Order #{order_id[:8]}</p>
|
||||
<p style="margin:8px 0 0;">{settings.app_name} — Community merch, on demand.</p>
|
||||
<p style="margin:4px 0 0;">Part of the <a href="https://rstack.online" style="color:#22d3ee;text-decoration:none;">rStack</a> ecosystem.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
def _render_shipping_html(
|
||||
self,
|
||||
*,
|
||||
to_name: str | None,
|
||||
order_id: str,
|
||||
tracking_number: str | None,
|
||||
tracking_url: str | None,
|
||||
) -> str:
|
||||
greeting = f"Hi {to_name}," if to_name else "Hi there,"
|
||||
order_url = f"{settings.public_url}/checkout/success?order_id={order_id}"
|
||||
|
||||
tracking_html = ""
|
||||
if tracking_number:
|
||||
track_link = tracking_url or "#"
|
||||
tracking_html = f"""
|
||||
<div style="background:#111;border:1px solid #222;border-radius:12px;padding:20px;margin-bottom:24px;text-align:center;">
|
||||
<div style="font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#888;margin-bottom:8px;">Tracking Number</div>
|
||||
<a href="{track_link}" style="font-size:18px;font-weight:700;color:#22d3ee;text-decoration:none;letter-spacing:1px;">{tracking_number}</a>
|
||||
</div>"""
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="margin:0;padding:0;background:#0a0a0a;color:#e5e5e5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<div style="max-width:560px;margin:0 auto;padding:32px 24px;">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="text-align:center;padding-bottom:24px;border-bottom:1px solid #222;">
|
||||
<div style="display:inline-block;background:linear-gradient(135deg,#22d3ee,#f59e0b);border-radius:10px;width:40px;height:40px;line-height:40px;font-size:12px;font-weight:900;color:#0a0a0a;text-align:center;">rSw</div>
|
||||
<h1 style="margin:12px 0 0;font-size:22px;color:#fff;">Your Order Has Shipped!</h1>
|
||||
</div>
|
||||
|
||||
<p style="margin:24px 0 8px;font-size:15px;">{greeting}</p>
|
||||
<p style="margin:0 0 24px;font-size:15px;">
|
||||
Great news — your order is on its way! It was printed at the nearest fulfillment center and is now heading to you.
|
||||
</p>
|
||||
|
||||
{tracking_html}
|
||||
|
||||
<div style="text-align:center;margin-bottom:24px;">
|
||||
<a href="{order_url}" style="display:inline-block;background:linear-gradient(135deg,#22d3ee,#0891b2);color:#fff;text-decoration:none;padding:12px 32px;border-radius:8px;font-weight:600;font-size:14px;">
|
||||
View Order
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;padding-top:24px;border-top:1px solid #222;font-size:12px;color:#555;">
|
||||
<p style="margin:0;">Order #{order_id[:8]}</p>
|
||||
<p style="margin:8px 0 0;">{settings.app_name} — Community merch, on demand.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
"""Flow service client for TBFF revenue routing.
|
||||
|
||||
After a swag sale, the margin (sale price minus POD fulfillment cost)
|
||||
gets deposited into a TBFF funnel via the flow-service. The flow-service
|
||||
manages threshold-based distribution, and when the funnel overflows its
|
||||
MAX threshold, excess funds route to the bonding curve.
|
||||
|
||||
Revenue split flow:
|
||||
Mollie payment → calculate margin → deposit to flow-service funnel
|
||||
↓
|
||||
TBFF thresholds
|
||||
↓ overflow
|
||||
bonding curve ($MYCO)
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class FlowService:
|
||||
"""Client for the payment-infra flow-service."""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.flow_service_url.rstrip("/")
|
||||
self.flow_id = settings.flow_id
|
||||
self.funnel_id = settings.flow_funnel_id
|
||||
self.enabled = bool(self.base_url and self.flow_id and self.funnel_id)
|
||||
|
||||
async def deposit_revenue(
|
||||
self,
|
||||
amount: float,
|
||||
currency: str = "USD",
|
||||
order_id: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> dict | None:
|
||||
"""Deposit revenue margin into the TBFF funnel.
|
||||
|
||||
Args:
|
||||
amount: Fiat amount to deposit (post-split margin)
|
||||
currency: Currency code (default USD)
|
||||
order_id: rSwag order ID for traceability
|
||||
description: Human-readable note
|
||||
"""
|
||||
if not self.enabled:
|
||||
logger.info("Flow service not configured, skipping revenue deposit")
|
||||
return None
|
||||
|
||||
if amount <= 0:
|
||||
return None
|
||||
|
||||
payload = {
|
||||
"funnelId": self.funnel_id,
|
||||
"amount": round(amount, 2),
|
||||
"currency": currency,
|
||||
"source": "rswag",
|
||||
"metadata": {},
|
||||
}
|
||||
if order_id:
|
||||
payload["metadata"]["order_id"] = order_id
|
||||
if description:
|
||||
payload["metadata"]["description"] = description
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/api/flows/{self.flow_id}/deposit",
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
logger.info(
|
||||
f"Revenue deposited to flow: ${amount:.2f} {currency} "
|
||||
f"(order={order_id})"
|
||||
)
|
||||
return result
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Failed to deposit revenue to flow service: {e}")
|
||||
return None
|
||||
|
||||
async def get_flow_stats(self) -> dict | None:
|
||||
"""Get current flow stats (balance, thresholds, etc.)."""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
f"{self.base_url}/api/flows/{self.flow_id}",
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Failed to get flow stats: {e}")
|
||||
return None
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
"""Mollie payment service."""
|
||||
|
||||
from mollie.api.client import Client
|
||||
|
||||
from app.config import get_settings
|
||||
from app.schemas.cart import CartResponse
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class MollieService:
|
||||
"""Service for Mollie payment operations."""
|
||||
|
||||
def __init__(self):
|
||||
self.client = Client()
|
||||
if settings.mollie_api_key:
|
||||
self.client.set_api_key(settings.mollie_api_key)
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
cart: CartResponse,
|
||||
success_url: str,
|
||||
cancel_url: str,
|
||||
webhook_url: str,
|
||||
) -> dict:
|
||||
"""Create a Mollie payment.
|
||||
|
||||
Mollie uses a redirect flow: create payment → redirect to hosted page →
|
||||
webhook callback on completion → redirect to success URL.
|
||||
"""
|
||||
# Build description from cart items
|
||||
item_names = [item.product_name for item in cart.items]
|
||||
description = f"rSwag order: {', '.join(item_names[:3])}"
|
||||
if len(item_names) > 3:
|
||||
description += f" (+{len(item_names) - 3} more)"
|
||||
|
||||
# Calculate total from cart
|
||||
total = sum(item.unit_price * item.quantity for item in cart.items)
|
||||
|
||||
payment = self.client.payments.create({
|
||||
"amount": {
|
||||
"currency": "USD",
|
||||
"value": f"{total:.2f}",
|
||||
},
|
||||
"description": description,
|
||||
"redirectUrl": f"{success_url}?payment_id={{paymentId}}",
|
||||
"cancelUrl": cancel_url,
|
||||
"webhookUrl": webhook_url,
|
||||
"metadata": {
|
||||
"cart_id": str(cart.id),
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
"url": payment["_links"]["checkout"]["href"],
|
||||
"payment_id": payment["id"],
|
||||
}
|
||||
|
||||
async def get_payment(self, payment_id: str) -> dict:
|
||||
"""Get Mollie payment details."""
|
||||
payment = self.client.payments.get(payment_id)
|
||||
return payment
|
||||
|
||||
async def create_refund(
|
||||
self,
|
||||
payment_id: str,
|
||||
amount: float | None = None,
|
||||
currency: str = "USD",
|
||||
) -> dict:
|
||||
"""Create a refund for a Mollie payment."""
|
||||
payment = self.client.payments.get(payment_id)
|
||||
refund_data = {}
|
||||
if amount is not None:
|
||||
refund_data["amount"] = {
|
||||
"currency": currency,
|
||||
"value": f"{amount:.2f}",
|
||||
}
|
||||
refund = self.client.payment_refunds.with_parent_id(payment_id).create(refund_data)
|
||||
return refund
|
||||
|
|
@ -0,0 +1,495 @@
|
|||
"""Order management service."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.order import Order, OrderItem, OrderStatus
|
||||
from app.models.customer import Customer
|
||||
from app.models.cart import Cart
|
||||
from app.schemas.order import OrderResponse, OrderItemResponse
|
||||
from app.services.flow_service import FlowService
|
||||
from app.pod.printful_client import PrintfulClient
|
||||
from app.pod.prodigi_client import ProdigiClient
|
||||
from app.services.design_service import DesignService
|
||||
from app.services.email_service import EmailService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class OrderService:
|
||||
"""Service for order operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_order_by_id(self, order_id: UUID) -> OrderResponse | None:
|
||||
"""Get order by ID."""
|
||||
result = await self.db.execute(
|
||||
select(Order)
|
||||
.where(Order.id == order_id)
|
||||
.options(selectinload(Order.items))
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
return None
|
||||
return self._order_to_response(order)
|
||||
|
||||
async def get_order_by_id_and_email(
|
||||
self,
|
||||
order_id: UUID,
|
||||
email: str,
|
||||
) -> OrderResponse | None:
|
||||
"""Get order by ID with email verification."""
|
||||
result = await self.db.execute(
|
||||
select(Order)
|
||||
.where(Order.id == order_id, Order.shipping_email == email)
|
||||
.options(selectinload(Order.items))
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
return None
|
||||
return self._order_to_response(order)
|
||||
|
||||
async def list_orders(
|
||||
self,
|
||||
status: OrderStatus | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[OrderResponse]:
|
||||
"""List orders with optional status filter."""
|
||||
query = select(Order).options(selectinload(Order.items))
|
||||
if status:
|
||||
query = query.where(Order.status == status.value)
|
||||
query = query.order_by(Order.created_at.desc()).limit(limit).offset(offset)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
orders = result.scalars().all()
|
||||
return [self._order_to_response(o) for o in orders]
|
||||
|
||||
async def update_status(
|
||||
self,
|
||||
order_id: UUID,
|
||||
status: OrderStatus,
|
||||
) -> OrderResponse | None:
|
||||
"""Update order status."""
|
||||
result = await self.db.execute(
|
||||
select(Order)
|
||||
.where(Order.id == order_id)
|
||||
.options(selectinload(Order.items))
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
return None
|
||||
|
||||
order.status = status.value
|
||||
if status == OrderStatus.SHIPPED:
|
||||
order.shipped_at = datetime.utcnow()
|
||||
elif status == OrderStatus.DELIVERED:
|
||||
order.delivered_at = datetime.utcnow()
|
||||
|
||||
await self.db.commit()
|
||||
return self._order_to_response(order)
|
||||
|
||||
async def handle_successful_payment(self, payment: dict):
|
||||
"""Handle successful Mollie payment.
|
||||
|
||||
Called by the Mollie webhook when payment status is 'paid'.
|
||||
Mollie payment object contains metadata with cart_id.
|
||||
"""
|
||||
cart_id = payment.get("metadata", {}).get("cart_id")
|
||||
if not cart_id:
|
||||
return
|
||||
|
||||
# Get cart
|
||||
result = await self.db.execute(
|
||||
select(Cart)
|
||||
.where(Cart.id == UUID(cart_id))
|
||||
.options(selectinload(Cart.items))
|
||||
)
|
||||
cart = result.scalar_one_or_none()
|
||||
if not cart or not cart.items:
|
||||
return
|
||||
|
||||
# Extract amount from Mollie payment
|
||||
amount = payment.get("amount", {})
|
||||
total = float(amount.get("value", "0"))
|
||||
currency = amount.get("currency", "USD")
|
||||
|
||||
# Create order
|
||||
order = Order(
|
||||
payment_provider="mollie",
|
||||
payment_id=payment.get("id"),
|
||||
payment_method=payment.get("method"),
|
||||
status=OrderStatus.PAID.value,
|
||||
shipping_email=payment.get("metadata", {}).get("email", ""),
|
||||
subtotal=total,
|
||||
total=total,
|
||||
currency=currency,
|
||||
paid_at=datetime.utcnow(),
|
||||
)
|
||||
self.db.add(order)
|
||||
await self.db.flush()
|
||||
|
||||
# Create order items
|
||||
for cart_item in cart.items:
|
||||
order_item = OrderItem(
|
||||
order_id=order.id,
|
||||
product_slug=cart_item.product_slug,
|
||||
product_name=cart_item.product_name,
|
||||
variant=cart_item.variant,
|
||||
quantity=cart_item.quantity,
|
||||
unit_price=float(cart_item.unit_price),
|
||||
pod_status="pending",
|
||||
)
|
||||
self.db.add(order_item)
|
||||
|
||||
await self.db.commit()
|
||||
|
||||
# Route revenue margin to TBFF flow → bonding curve
|
||||
await self._deposit_revenue_to_flow(order)
|
||||
|
||||
# Submit to POD providers
|
||||
await self._submit_to_pod(order)
|
||||
|
||||
# Send confirmation email (non-blocking — don't fail the order if email fails)
|
||||
try:
|
||||
email_service = EmailService()
|
||||
await email_service.send_order_confirmation(
|
||||
to_email=order.shipping_email or "",
|
||||
to_name=order.shipping_name,
|
||||
order_id=str(order.id),
|
||||
items=[
|
||||
{
|
||||
"product_name": item.product_name,
|
||||
"variant": item.variant,
|
||||
"quantity": item.quantity,
|
||||
"unit_price": float(item.unit_price),
|
||||
}
|
||||
for item in order.items
|
||||
],
|
||||
total=float(order.total),
|
||||
currency=order.currency or "USD",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send confirmation email for order {order.id}: {e}")
|
||||
|
||||
async def update_pod_status(
|
||||
self,
|
||||
pod_provider: str,
|
||||
pod_order_id: str,
|
||||
status: str,
|
||||
tracking_number: str | None = None,
|
||||
tracking_url: str | None = None,
|
||||
):
|
||||
"""Update POD status for order items."""
|
||||
await self.db.execute(
|
||||
update(OrderItem)
|
||||
.where(
|
||||
OrderItem.pod_provider == pod_provider,
|
||||
OrderItem.pod_order_id == pod_order_id,
|
||||
)
|
||||
.values(
|
||||
pod_status=status,
|
||||
pod_tracking_number=tracking_number,
|
||||
pod_tracking_url=tracking_url,
|
||||
)
|
||||
)
|
||||
await self.db.commit()
|
||||
|
||||
# Send shipping notification when items ship
|
||||
if status in ("shipped", "in_transit") and tracking_number:
|
||||
await self._send_shipping_email(
|
||||
pod_provider=pod_provider,
|
||||
pod_order_id=pod_order_id,
|
||||
tracking_number=tracking_number,
|
||||
tracking_url=tracking_url,
|
||||
)
|
||||
|
||||
async def _send_shipping_email(
|
||||
self,
|
||||
pod_provider: str,
|
||||
pod_order_id: str,
|
||||
tracking_number: str | None,
|
||||
tracking_url: str | None,
|
||||
):
|
||||
"""Send shipping notification for an order."""
|
||||
try:
|
||||
# Find the order via its items
|
||||
result = await self.db.execute(
|
||||
select(OrderItem)
|
||||
.where(
|
||||
OrderItem.pod_provider == pod_provider,
|
||||
OrderItem.pod_order_id == pod_order_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
return
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Order).where(Order.id == item.order_id)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order or not order.shipping_email:
|
||||
return
|
||||
|
||||
email_service = EmailService()
|
||||
await email_service.send_shipping_notification(
|
||||
to_email=order.shipping_email,
|
||||
to_name=order.shipping_name,
|
||||
order_id=str(order.id),
|
||||
tracking_number=tracking_number,
|
||||
tracking_url=tracking_url,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send shipping email: {e}")
|
||||
|
||||
async def _submit_to_pod(self, order: Order):
|
||||
"""Route order items to the correct POD provider for fulfillment.
|
||||
|
||||
Reads each item's design metadata to determine provider (printful/prodigi),
|
||||
groups items, and submits separate orders per provider.
|
||||
"""
|
||||
if not order.shipping_address_line1:
|
||||
logger.info(f"Order {order.id} has no shipping address, skipping POD")
|
||||
return
|
||||
|
||||
design_service = DesignService()
|
||||
printful_items = []
|
||||
prodigi_items = []
|
||||
|
||||
for item in order.items:
|
||||
image_url = f"https://fungiswag.jeffemmett.com/api/designs/{item.product_slug}/image"
|
||||
|
||||
design = await design_service.get_design(item.product_slug)
|
||||
provider = "prodigi" # default
|
||||
product_sku = item.variant or item.product_slug
|
||||
|
||||
if design and design.products:
|
||||
product_config = design.products[0]
|
||||
provider = product_config.provider
|
||||
product_sku = product_config.sku
|
||||
|
||||
if provider == "printful":
|
||||
# Extract size from variant string (e.g. "71-M" → "M", or just "M")
|
||||
size = item.variant or "M"
|
||||
if "-" in size:
|
||||
size = size.split("-", 1)[1]
|
||||
|
||||
printful_items.append({
|
||||
"order_item": item,
|
||||
"product_id": int(product_sku),
|
||||
"size": size,
|
||||
"quantity": item.quantity,
|
||||
"image_url": image_url,
|
||||
})
|
||||
else:
|
||||
prodigi_items.append({
|
||||
"order_item": item,
|
||||
"sku": item.variant or item.product_slug,
|
||||
"quantity": item.quantity,
|
||||
"image_url": image_url,
|
||||
})
|
||||
|
||||
if printful_items:
|
||||
await self._submit_to_printful(order, printful_items)
|
||||
if prodigi_items:
|
||||
await self._submit_to_prodigi(order, prodigi_items)
|
||||
|
||||
async def _submit_to_printful(self, order: Order, items: list[dict]):
|
||||
"""Submit items to Printful for fulfillment."""
|
||||
printful = PrintfulClient()
|
||||
if not printful.enabled:
|
||||
logger.info("Printful not configured, skipping")
|
||||
return
|
||||
|
||||
order_items = []
|
||||
for item_data in items:
|
||||
variant_id = await printful.resolve_variant_id(
|
||||
product_id=item_data["product_id"],
|
||||
size=item_data["size"],
|
||||
)
|
||||
if not variant_id:
|
||||
logger.error(
|
||||
f"Could not resolve Printful variant for product "
|
||||
f"{item_data['product_id']} size {item_data['size']}"
|
||||
)
|
||||
continue
|
||||
|
||||
order_items.append({
|
||||
"catalog_variant_id": variant_id,
|
||||
"quantity": item_data["quantity"],
|
||||
"image_url": item_data["image_url"],
|
||||
"placement": "front_large",
|
||||
})
|
||||
|
||||
if not order_items:
|
||||
return
|
||||
|
||||
recipient = {
|
||||
"name": order.shipping_name or "",
|
||||
"address1": order.shipping_address_line1 or "",
|
||||
"address2": order.shipping_address_line2 or "",
|
||||
"city": order.shipping_city or "",
|
||||
"state_code": order.shipping_state or "",
|
||||
"country_code": order.shipping_country or "",
|
||||
"zip": order.shipping_postal_code or "",
|
||||
"email": order.shipping_email or "",
|
||||
}
|
||||
|
||||
try:
|
||||
result = await printful.create_order(
|
||||
items=order_items,
|
||||
recipient=recipient,
|
||||
)
|
||||
pod_order_id = str(result.get("id", ""))
|
||||
|
||||
for item_data in items:
|
||||
item_data["order_item"].pod_provider = "printful"
|
||||
item_data["order_item"].pod_order_id = pod_order_id
|
||||
item_data["order_item"].pod_status = "submitted"
|
||||
|
||||
order.status = OrderStatus.PROCESSING.value
|
||||
await self.db.commit()
|
||||
logger.info(f"Submitted order {order.id} to Printful: {pod_order_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to submit order {order.id} to Printful: {e}")
|
||||
|
||||
async def _submit_to_prodigi(self, order: Order, items: list[dict]):
|
||||
"""Submit items to Prodigi for fulfillment."""
|
||||
prodigi = ProdigiClient()
|
||||
if not prodigi.enabled:
|
||||
logger.info("Prodigi not configured, skipping")
|
||||
return
|
||||
|
||||
prodigi_items = []
|
||||
for item_data in items:
|
||||
prodigi_items.append({
|
||||
"sku": item_data["sku"],
|
||||
"copies": item_data["quantity"],
|
||||
"sizing": "fillPrintArea",
|
||||
"assets": [{"printArea": "default", "url": item_data["image_url"]}],
|
||||
})
|
||||
|
||||
recipient = {
|
||||
"name": order.shipping_name or "",
|
||||
"email": order.shipping_email or "",
|
||||
"address": {
|
||||
"line1": order.shipping_address_line1 or "",
|
||||
"line2": order.shipping_address_line2 or "",
|
||||
"townOrCity": order.shipping_city or "",
|
||||
"stateOrCounty": order.shipping_state or "",
|
||||
"postalOrZipCode": order.shipping_postal_code or "",
|
||||
"countryCode": order.shipping_country or "",
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
result = await prodigi.create_order(
|
||||
items=prodigi_items,
|
||||
recipient=recipient,
|
||||
metadata={"rswag_order_id": str(order.id)},
|
||||
)
|
||||
pod_order_id = result.get("id")
|
||||
|
||||
for item_data in items:
|
||||
item_data["order_item"].pod_provider = "prodigi"
|
||||
item_data["order_item"].pod_order_id = pod_order_id
|
||||
item_data["order_item"].pod_status = "submitted"
|
||||
|
||||
order.status = OrderStatus.PROCESSING.value
|
||||
await self.db.commit()
|
||||
logger.info(f"Submitted order {order.id} to Prodigi: {pod_order_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to submit order {order.id} to Prodigi: {e}")
|
||||
|
||||
async def _deposit_revenue_to_flow(self, order: Order):
|
||||
"""Calculate margin and deposit to TBFF flow for bonding curve funding.
|
||||
|
||||
Revenue split:
|
||||
total sale - POD cost estimate = margin
|
||||
margin × flow_revenue_split = amount deposited to flow
|
||||
flow → Transak on-ramp → USDC → bonding curve → $MYCO
|
||||
"""
|
||||
split = settings.flow_revenue_split
|
||||
if split <= 0:
|
||||
return
|
||||
|
||||
total = float(order.total) if order.total else 0
|
||||
if total <= 0:
|
||||
return
|
||||
|
||||
# Revenue split: configurable fraction of total goes to flow
|
||||
# (POD costs + operational expenses kept as fiat remainder)
|
||||
flow_amount = round(total * split, 2)
|
||||
|
||||
flow_service = FlowService()
|
||||
await flow_service.deposit_revenue(
|
||||
amount=flow_amount,
|
||||
currency=order.currency or "USD",
|
||||
order_id=str(order.id),
|
||||
description=f"rSwag sale revenue split ({split:.0%} of ${total:.2f})",
|
||||
)
|
||||
|
||||
async def _get_or_create_customer(self, email: str) -> Customer | None:
|
||||
"""Get or create customer by email."""
|
||||
if not email:
|
||||
return None
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Customer).where(Customer.email == email)
|
||||
)
|
||||
customer = result.scalar_one_or_none()
|
||||
if customer:
|
||||
return customer
|
||||
|
||||
customer = Customer(email=email)
|
||||
self.db.add(customer)
|
||||
await self.db.flush()
|
||||
return customer
|
||||
|
||||
def _order_to_response(self, order: Order) -> OrderResponse:
|
||||
"""Convert Order model to response schema."""
|
||||
items = [
|
||||
OrderItemResponse(
|
||||
id=item.id,
|
||||
product_slug=item.product_slug,
|
||||
product_name=item.product_name,
|
||||
variant=item.variant,
|
||||
quantity=item.quantity,
|
||||
unit_price=float(item.unit_price),
|
||||
pod_provider=item.pod_provider,
|
||||
pod_status=item.pod_status,
|
||||
pod_tracking_number=item.pod_tracking_number,
|
||||
pod_tracking_url=item.pod_tracking_url,
|
||||
)
|
||||
for item in order.items
|
||||
]
|
||||
|
||||
return OrderResponse(
|
||||
id=order.id,
|
||||
status=order.status,
|
||||
shipping_name=order.shipping_name,
|
||||
shipping_email=order.shipping_email,
|
||||
shipping_city=order.shipping_city,
|
||||
shipping_country=order.shipping_country,
|
||||
subtotal=float(order.subtotal) if order.subtotal else None,
|
||||
shipping_cost=float(order.shipping_cost) if order.shipping_cost else None,
|
||||
tax=float(order.tax) if order.tax else None,
|
||||
total=float(order.total) if order.total else None,
|
||||
currency=order.currency,
|
||||
items=items,
|
||||
created_at=order.created_at,
|
||||
paid_at=order.paid_at,
|
||||
shipped_at=order.shipped_at,
|
||||
)
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
"""Space (tenant) service for multi-subdomain support."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class SpaceTheme(BaseModel):
|
||||
"""Theme configuration for a space."""
|
||||
|
||||
primary: str = "195 80% 45%"
|
||||
primary_foreground: str = "0 0% 100%"
|
||||
secondary: str = "45 80% 55%"
|
||||
secondary_foreground: str = "222.2 47.4% 11.2%"
|
||||
background: str = "0 0% 100%"
|
||||
foreground: str = "222.2 84% 4.9%"
|
||||
card: str = "0 0% 100%"
|
||||
card_foreground: str = "222.2 84% 4.9%"
|
||||
popover: str = "0 0% 100%"
|
||||
popover_foreground: str = "222.2 84% 4.9%"
|
||||
muted: str = "210 40% 96.1%"
|
||||
muted_foreground: str = "215.4 16.3% 46.9%"
|
||||
accent: str = "210 40% 96.1%"
|
||||
accent_foreground: str = "222.2 47.4% 11.2%"
|
||||
destructive: str = "0 84.2% 60.2%"
|
||||
destructive_foreground: str = "210 40% 98%"
|
||||
border: str = "214.3 31.8% 91.4%"
|
||||
input: str = "214.3 31.8% 91.4%"
|
||||
ring: str = "195 80% 45%"
|
||||
|
||||
|
||||
class Space(BaseModel):
|
||||
"""Space configuration."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
tagline: str = ""
|
||||
description: str = ""
|
||||
domain: str = ""
|
||||
footer_text: str = ""
|
||||
theme: SpaceTheme = SpaceTheme()
|
||||
design_filter: str = "all"
|
||||
logo_url: str | None = None
|
||||
design_tips: list[str] = []
|
||||
|
||||
|
||||
class SpaceService:
|
||||
"""Service for loading and resolving spaces."""
|
||||
|
||||
def __init__(self):
|
||||
self.spaces_path = Path(settings.spaces_path)
|
||||
self._cache: dict[str, Space] = {}
|
||||
self._loaded = False
|
||||
|
||||
def _ensure_loaded(self):
|
||||
if self._loaded:
|
||||
return
|
||||
self._load_all()
|
||||
self._loaded = True
|
||||
|
||||
def _load_all(self):
|
||||
if not self.spaces_path.exists():
|
||||
return
|
||||
for space_dir in self.spaces_path.iterdir():
|
||||
if not space_dir.is_dir():
|
||||
continue
|
||||
config_path = space_dir / "space.yaml"
|
||||
if not config_path.exists():
|
||||
continue
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
space = Space(**data)
|
||||
self._cache[space.id] = space
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def get_space(self, space_id: str) -> Space | None:
|
||||
"""Get a space by its ID."""
|
||||
self._ensure_loaded()
|
||||
return self._cache.get(space_id)
|
||||
|
||||
def get_default(self) -> Space:
|
||||
"""Get the default space."""
|
||||
self._ensure_loaded()
|
||||
return self._cache.get(
|
||||
"default",
|
||||
Space(id="default", name="rSwag", domain="rswag.online"),
|
||||
)
|
||||
|
||||
def list_spaces(self) -> list[Space]:
|
||||
"""List all spaces."""
|
||||
self._ensure_loaded()
|
||||
return list(self._cache.values())
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the cache to force reload."""
|
||||
self._cache.clear()
|
||||
self._loaded = False
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
#!/bin/sh
|
||||
# Infisical secret injection entrypoint (Python version)
|
||||
set -e
|
||||
|
||||
INFISICAL_URL="${INFISICAL_URL:-http://infisical:8080}"
|
||||
INFISICAL_ENV="${INFISICAL_ENV:-prod}"
|
||||
INFISICAL_PROJECT_SLUG="${INFISICAL_PROJECT_SLUG:-rswag}"
|
||||
|
||||
if [ -z "$INFISICAL_CLIENT_ID" ] || [ -z "$INFISICAL_CLIENT_SECRET" ]; then
|
||||
echo "[infisical] No credentials set, starting without secret injection"
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
echo "[infisical] Fetching secrets from ${INFISICAL_PROJECT_SLUG}/${INFISICAL_ENV}..."
|
||||
|
||||
EXPORTS=$(python3 -c "
|
||||
import urllib.request, json, os, sys
|
||||
|
||||
base = os.environ['INFISICAL_URL']
|
||||
slug = os.environ['INFISICAL_PROJECT_SLUG']
|
||||
env = os.environ['INFISICAL_ENV']
|
||||
|
||||
try:
|
||||
data = json.dumps({'clientId': os.environ['INFISICAL_CLIENT_ID'], 'clientSecret': os.environ['INFISICAL_CLIENT_SECRET']}).encode()
|
||||
req = urllib.request.Request(f'{base}/api/v1/auth/universal-auth/login', data=data, headers={'Content-Type': 'application/json'})
|
||||
auth = json.loads(urllib.request.urlopen(req).read())
|
||||
token = auth.get('accessToken')
|
||||
if not token:
|
||||
print('[infisical] Auth failed', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
req = urllib.request.Request(f'{base}/api/v3/secrets/raw?workspaceSlug={slug}&environment={env}&secretPath=/&recursive=true')
|
||||
req.add_header('Authorization', f'Bearer {token}')
|
||||
secrets = json.loads(urllib.request.urlopen(req).read())
|
||||
|
||||
if 'secrets' not in secrets:
|
||||
print('[infisical] No secrets returned', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
for s in secrets['secrets']:
|
||||
key = s['secretKey']
|
||||
val = s['secretValue'].replace(\"'\", \"'\\\\'\")
|
||||
existing = os.environ.get(key, '')
|
||||
if existing and existing != val:
|
||||
print(f'[infisical] Keeping explicit env var for {key}', file=sys.stderr)
|
||||
continue
|
||||
print(f\"export {key}='{val}'\")
|
||||
except Exception as e:
|
||||
print(f'[infisical] Error: {e}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" 2>&1) || {
|
||||
echo "[infisical] WARNING: Failed to fetch secrets, starting with existing env vars"
|
||||
exec "$@"
|
||||
}
|
||||
|
||||
if echo "$EXPORTS" | grep -q "^export "; then
|
||||
COUNT=$(echo "$EXPORTS" | grep -c "^export ")
|
||||
eval "$EXPORTS"
|
||||
echo "[infisical] Injected ${COUNT} secrets"
|
||||
else
|
||||
echo "[infisical] WARNING: $EXPORTS"
|
||||
echo "[infisical] Starting with existing env vars"
|
||||
fi
|
||||
|
||||
# Fetch SMTP config from claude-ops /mail (authoritative source for rSwag email)
|
||||
SMTP_OVERRIDES=$(python3 -c "
|
||||
import urllib.request, json, os, sys
|
||||
base = os.environ.get('INFISICAL_URL', 'http://infisical:8080')
|
||||
try:
|
||||
data = json.dumps({'clientId': os.environ['INFISICAL_CLIENT_ID'], 'clientSecret': os.environ['INFISICAL_CLIENT_SECRET']}).encode()
|
||||
req = urllib.request.Request(f'{base}/api/v1/auth/universal-auth/login', data=data, headers={'Content-Type': 'application/json'})
|
||||
token = json.loads(urllib.request.urlopen(req).read()).get('accessToken','')
|
||||
req = urllib.request.Request(f'{base}/api/v3/secrets/raw?workspaceSlug=claude-ops&environment=prod&secretPath=/mail')
|
||||
req.add_header('Authorization', f'Bearer {token}')
|
||||
secrets = json.loads(urllib.request.urlopen(req).read())
|
||||
mapping = {'RSWAG_SMTP_HOST': 'SMTP_HOST', 'RSWAG_SMTP_USER': 'SMTP_USER', 'RSWAG_SMTP_PASSWORD': 'SMTP_PASSWORD'}
|
||||
for s in secrets.get('secrets',[]):
|
||||
env_key = mapping.get(s['secretKey'])
|
||||
if env_key:
|
||||
val = s['secretValue'].replace(\"'\", \"'\\\\'\")
|
||||
print(f\"export {env_key}='{val}'\")
|
||||
except Exception as e:
|
||||
print(f'[smtp] Could not fetch from claude-ops: {e}', file=sys.stderr)
|
||||
" 2>&1) || true
|
||||
if echo "$SMTP_OVERRIDES" | grep -q "^export "; then
|
||||
eval "$SMTP_OVERRIDES"
|
||||
echo "[infisical] Loaded SMTP config from claude-ops/mail"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
[project]
|
||||
name = "rswag"
|
||||
version = "0.1.0"
|
||||
description = "E-commerce backend for rSpace ecosystem merchandise"
|
||||
authors = [{ name = "Jeff Emmett", email = "jeff@rspace.online" }]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.109.0",
|
||||
"uvicorn[standard]>=0.27.0",
|
||||
"sqlalchemy>=2.0.0",
|
||||
"alembic>=1.13.0",
|
||||
"asyncpg>=0.29.0",
|
||||
"psycopg2-binary>=2.9.0",
|
||||
"pydantic>=2.5.0",
|
||||
"pydantic-settings>=2.1.0",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"httpx>=0.26.0",
|
||||
"mollie-api-python>=3.0.0",
|
||||
"pyyaml>=6.0.0",
|
||||
"pillow>=10.0.0",
|
||||
"python-multipart>=0.0.6",
|
||||
"redis>=5.0.0",
|
||||
"aiofiles>=23.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"httpx>=0.26.0",
|
||||
"black>=24.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "W"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
# Core
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
|
||||
# Database
|
||||
sqlalchemy>=2.0.0
|
||||
alembic>=1.13.0
|
||||
asyncpg>=0.29.0
|
||||
psycopg2-binary>=2.9.0
|
||||
|
||||
# Validation
|
||||
pydantic>=2.5.0
|
||||
pydantic-settings>=2.1.0
|
||||
|
||||
# Auth
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
|
||||
# HTTP Client
|
||||
httpx>=0.26.0
|
||||
|
||||
# Payments (Mollie)
|
||||
mollie-api-python>=3.0.0
|
||||
|
||||
# Config & Utils
|
||||
pyyaml>=6.0.0
|
||||
pillow>=10.0.0
|
||||
python-multipart>=0.0.6
|
||||
aiofiles>=23.0.0
|
||||
|
||||
# Email
|
||||
aiosmtplib>=3.0.0
|
||||
|
||||
# Cache
|
||||
redis>=5.0.0
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
project_name: "rSwag"
|
||||
default_status: "To Do"
|
||||
statuses: ["To Do", "In Progress", "Done"]
|
||||
labels: []
|
||||
milestones: []
|
||||
date_format: yyyy-mm-dd
|
||||
max_column_width: 20
|
||||
default_editor: "nvim"
|
||||
auto_open_browser: true
|
||||
default_port: 6420
|
||||
remote_operations: true
|
||||
auto_commit: false
|
||||
bypass_git_hooks: false
|
||||
check_active_branches: true
|
||||
active_branch_days: 30
|
||||
task_prefix: "task"
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
id: TASK-1
|
||||
title: Configure Mollie API key for production payments
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:51'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Sign up at my.mollie.com, get live API key, add MOLLIE_API_KEY to /opt/apps/rswag/.env on Netcup. Configure webhook URL in Mollie dashboard pointing to https://rswag.online/api/webhooks/mollie (or fungiswag.jeffemmett.com equivalent). Test mode key starts with test_, live key starts with live_.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
id: TASK-10
|
||||
title: Consistent rApp header bar with AppSwitcher + SpaceSwitcher
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-25 07:28'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
rSwag header now matches standard rApp pattern: [AppSwitcher | SpaceSwitcher | Logo ... Nav ... Auth Cart]. Removed custom rSwag-specific SpaceSwitcher, adopted the ecosystem-wide component. EcosystemFooter also standardized.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
id: TASK-11
|
||||
title: Migrate all SMTP references to mail.rmail.online
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-25 08:00'
|
||||
updated_date: '2026-02-25 08:25'
|
||||
labels: [infrastructure, email]
|
||||
dependencies: [TASK-6]
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Replace all references to mx.jeffemmett.com with mail.rmail.online across all repositories. Set up rswag.online domain in Mailcow with noreply@ mailbox, DNS records, and SMTP credentials wired into rSwag backend via Infisical.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Cross-repo cleanup: Updated 10 backlog/doc files across 6 repos (rinbox-online, rmail-online, dev-ops, payment-infra, cadcad-discourse-forum, configuration dotfiles). Updated live CLAUDE.md. Only 2 intentional references remain (ADDITIONAL_SAN backward compat in task-11 migration notes).
|
||||
|
||||
Mailcow setup: Created rswag.online domain (2048-bit DKIM), noreply@rswag.online mailbox. Cloudflare DNS: MX (mail.rmail.online, priority 10), SPF (v=spf1 ip4:159.195.32.209 ~all), DKIM (dkim._domainkey), DMARC (p=quarantine).
|
||||
|
||||
Infisical wiring: Stored RSWAG_SMTP_HOST, RSWAG_SMTP_USER, RSWAG_SMTP_PASSWORD in claude-ops /mail folder. Added rswag-container identity as viewer on claude-ops project. Entrypoint.sh fetches SMTP config from claude-ops at startup, overriding stale values from .env and rSwag Infisical project. config.py AliasChoices accepts both SMTP_PASSWORD and SMTP_PASS.
|
||||
|
||||
Deploy: Triggered via webhook with correct HMAC secret. Container logs confirm: "[infisical] Loaded SMTP config from claude-ops/mail".
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
id: TASK-2
|
||||
title: Configure Printful and Prodigi API keys
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:51'
|
||||
updated_date: '2026-02-25 07:34'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add PRINTFUL_API_TOKEN and PRODIGI_API_KEY to .env. Currently empty — orders will be created but not submitted to POD providers. Also implement the POD client code in backend/app/pod/ to actually submit orders after Stripe payment.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Printful API token configured. Prodigi API key also set up. POD client code in backend/app/pod/ submits orders after Mollie payment. Printful mockup generation working.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
id: TASK-3
|
||||
title: Replace placeholder Fungi Flows design assets
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:51'
|
||||
updated_date: '2026-02-25 07:28'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Current fungi-logo-tee.png and fungi-spore.png are Pillow-generated placeholders. Replace with real artwork from Darren/Fungi Flows team. Designs at designs/shirts/fungi-logo-tee/ and designs/stickers/fungi-spore/.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Fungi Flows placeholder designs deleted. Only DefectFi 'Don't Abuse the Holes' design remains with real Printful products (shirt SKU 71, hoodie SKU 146).
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
id: TASK-4
|
||||
title: Integrate EncryptID authentication for rSwag
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:51'
|
||||
updated_date: '2026-02-25 07:28'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Replace email/password admin auth with EncryptID passkeys to be consistent with other rApps (rWork, rFiles, rNotes). Use @encryptid/sdk, WebAuthn flow, DID-based user identity, space role checking. See /home/jeffe/Github/encryptid-sdk/ and rwork-online for patterns.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
EncryptID auth integrated with passkey sign-in via vendored @encryptid/sdk. AuthButton + Zustand auth store matching rMaps pattern.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
id: TASK-5
|
||||
title: Add real Printful mockup API integration
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:51'
|
||||
updated_date: '2026-02-25 07:28'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Current upload page uses client-side Canvas compositing with simple template images. When Printful API token is configured, enhance with real Printful Mockup Generator API (POST /mockup-generator/create-task) for photorealistic product previews showing actual garment colors and fabric texture.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
2026-02-21: Printful client code is DONE and deployed. Blocking issue: API token not scoped to store.
|
||||
|
||||
What's done:
|
||||
- backend/app/pod/printful_client.py created (catalog, mockups, orders)
|
||||
- designs.py updated (Printful mockup path + Pillow fallback)
|
||||
- order_service.py refactored (provider-aware routing: printful vs prodigi)
|
||||
- Token stored at ~/.secrets/printful_api_token and in Netcup .env
|
||||
- Deployed to fungiswag.jeffemmett.com (Pillow fallback working)
|
||||
|
||||
Blocking:
|
||||
- Token u5WU...R2d returns "This endpoint requires store_id" on mockup/order APIs
|
||||
- Need to create a NEW token on developers.printful.com scoped to "Fungi Flows" store
|
||||
- Select the store in the "Access" dropdown (not "Account (all stores)")
|
||||
|
||||
Once new token is set, just update ~/.secrets/printful_api_token and Netcup .env, rebuild, done.
|
||||
|
||||
Printful mockup API v2 integrated. Falls back to Pillow compositing with local templates. Old fungi designs removed, only defectfi-dont-abuse-holes remains.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
id: TASK-6
|
||||
title: Add order confirmation emails
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:51'
|
||||
updated_date: '2026-02-25 07:34'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
OrderService has TODO for sending confirmation emails after payment. Connect to Mailcow SMTP (mail.rmail.online:587) or email-relay API. Send order confirmation with items, total, and tracking link.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
EmailService created with aiosmtplib. Order confirmation email sent after successful Mollie payment. Shipping notification email sent when POD provider reports shipped status with tracking info. HTML templates with rSwag dark theme branding. SMTP via Mailcow (mail.rmail.online:587 STARTTLS). Non-blocking: failures logged but don't break order flow.
|
||||
|
||||
Mailcow setup (2026-02-25): Created rswag.online domain with 2048-bit DKIM. Created noreply@rswag.online mailbox. DNS records (MX, SPF, DKIM, DMARC) added to Cloudflare. SMTP credentials stored in claude-ops /mail folder (RSWAG_SMTP_HOST, RSWAG_SMTP_USER, RSWAG_SMTP_PASSWORD). Entrypoint fetches from claude-ops at startup, overriding stale rSwag Infisical values. config.py uses AliasChoices to accept both SMTP_PASSWORD and SMTP_PASS env var names.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
id: TASK-7
|
||||
title: Set up auto-deploy webhook for rSwag
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:51'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add rswag entry to /opt/deploy-webhook/webhook.py REPOS dict and create Gitea webhook so pushes to main auto-deploy. Currently requires manual git pull + docker compose rebuild.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
id: TASK-8
|
||||
title: Standardize SpaceSwitcher across all rApps
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-25 07:28'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Deploy consistent SpaceSwitcher component across all 16 rApp repos. Space dropdown links now use subdomain URLs (<space>.<app>.online) instead of rspace.online/<space> paths. Domain auto-derived from window.location.hostname.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
id: TASK-9
|
||||
title: Interactive Sankey fund flow visualization
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-25 07:28'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Built interactive SVG Sankey diagram for rSwag landing page showing revenue flow from sale price through printer (production), creator (design margin), and community (revenue fund). Drag sliders to adjust splits dynamically. Pure React+SVG, zero dependencies.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
|
@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 MiB |
|
|
@ -0,0 +1,31 @@
|
|||
name: "Don't Abuse the Holes!"
|
||||
slug: defectfi-dont-abuse-holes
|
||||
description: "Defensive bug hunting meets punk resistance. Distressed typography with beetles crawling through the design — because the best defense is finding the vulnerabilities before they find you. Revenue from this design supports the DefectFi community. #DefectFi"
|
||||
tags: [defectfi, whistleblower, bug-bounty, punk, resistance, tee, community]
|
||||
space: all
|
||||
category: shirts
|
||||
created: "2026-02-24"
|
||||
author: defectfi
|
||||
|
||||
source:
|
||||
file: dont-abuse-the-holes.png
|
||||
format: png
|
||||
dimensions:
|
||||
width: 1743
|
||||
height: 1786
|
||||
dpi: 300
|
||||
color_profile: sRGB
|
||||
|
||||
products:
|
||||
- type: shirt
|
||||
provider: printful
|
||||
sku: "71"
|
||||
variants: [S, M, L, XL, 2XL, 3XL]
|
||||
retail_price: 29.99
|
||||
- type: hoodie
|
||||
provider: printful
|
||||
sku: "146"
|
||||
variants: [S, M, L, XL, 2XL]
|
||||
retail_price: 49.99
|
||||
|
||||
status: active
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Development overrides - use with: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||
|
||||
services:
|
||||
db:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
redis:
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
backend:
|
||||
volumes:
|
||||
# Mount source for hot reload
|
||||
- ./backend/app:/app/app:ro
|
||||
# Mount designs from in-repo designs dir
|
||||
- ./designs:/app/designs:ro
|
||||
- ./config:/app/config:ro
|
||||
- ./spaces:/app/spaces:ro
|
||||
- ./frontend/public/mockups:/app/mockups:ro
|
||||
environment:
|
||||
- DEBUG=true
|
||||
- POD_SANDBOX_MODE=true
|
||||
ports:
|
||||
- "8000:8000"
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
frontend:
|
||||
build:
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=http://localhost:8000/api
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
networks:
|
||||
rswag-internal:
|
||||
driver: bridge
|
||||
traefik-public:
|
||||
driver: bridge # Override external for local dev
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
services:
|
||||
backend:
|
||||
volumes:
|
||||
- /opt/apps/rswag/designs:/app/designs
|
||||
- /opt/apps/rswag/config:/app/config:ro
|
||||
- /opt/apps/rswag/spaces:/app/spaces:ro
|
||||
environment:
|
||||
- DESIGNS_PATH=/app/designs
|
||||
- CONFIG_PATH=/app/config
|
||||
- SPACES_PATH=/app/spaces
|
||||
|
||||
frontend:
|
||||
build:
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=https://rswag.online/api
|
||||
|
||||
networks:
|
||||
rswag-internal:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 10.200.1.0/24
|
||||
|
|
@ -1,20 +1,106 @@
|
|||
services:
|
||||
rswag:
|
||||
build: .
|
||||
# PostgreSQL Database
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: rswag-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: rswag
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-devpassword}
|
||||
POSTGRES_DB: rswag
|
||||
volumes:
|
||||
- rswag-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- rswag-internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U rswag"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Redis for sessions/cache
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: rswag-redis
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- rswag-redis-data:/data
|
||||
networks:
|
||||
- rswag-internal
|
||||
|
||||
# FastAPI Backend
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: rswag-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- INFISICAL_CLIENT_ID=${INFISICAL_CLIENT_ID}
|
||||
- INFISICAL_CLIENT_SECRET=${INFISICAL_CLIENT_SECRET}
|
||||
- INFISICAL_PROJECT_SLUG=rswag
|
||||
- INFISICAL_ENV=prod
|
||||
- INFISICAL_URL=http://infisical:8080
|
||||
- DATABASE_URL=postgresql://rswag:${DB_PASSWORD:-devpassword}@db:5432/rswag
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- DESIGNS_PATH=/app/designs
|
||||
- CONFIG_PATH=/app/config
|
||||
- SPACES_PATH=/app/spaces
|
||||
- PRINTFUL_STORE_ID=${PRINTFUL_STORE_ID:-}
|
||||
- PUBLIC_URL=${PUBLIC_URL:-https://rswag.online}
|
||||
- SMTP_HOST=${SMTP_HOST:-mail.rmail.online}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER:-noreply@rswag.online}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
|
||||
- SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL:-noreply@rswag.online}
|
||||
- SMTP_FROM_NAME=${SMTP_FROM_NAME:-rSwag}
|
||||
volumes:
|
||||
- ./designs:/app/designs
|
||||
- ./config:/app/config:ro
|
||||
- ./spaces:/app/spaces:ro
|
||||
- ./frontend/public/mockups:/app/mockups:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- rswag-internal
|
||||
- traefik-public
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.rswag.rule=Host(`rswag.online`) || Host(`www.rswag.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rswag.online`)"
|
||||
- "traefik.http.services.rswag.loadbalancer.server.port=80"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:80/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
- "traefik.http.routers.rswag-api.rule=(Host(`rswag.online`) || Host(`fungiswag.jeffemmett.com`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rswag.online`)) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.rswag-api.entrypoints=web"
|
||||
- "traefik.http.services.rswag-api.loadbalancer.server.port=8000"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
|
||||
# Next.js Frontend
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api}
|
||||
container_name: rswag-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- rswag-internal
|
||||
- traefik-public
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.rswag-web.rule=Host(`rswag.online`) || Host(`fungiswag.jeffemmett.com`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rswag.online`)"
|
||||
- "traefik.http.routers.rswag-web.entrypoints=web"
|
||||
- "traefik.http.services.rswag-web.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
|
||||
volumes:
|
||||
rswag-db-data:
|
||||
rswag-redis-data:
|
||||
|
||||
networks:
|
||||
rswag-internal:
|
||||
driver: bridge
|
||||
traefik-public:
|
||||
external: true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
# Stage 1: Dependencies
|
||||
FROM node:20-alpine AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
COPY vendor/ ./vendor/
|
||||
RUN corepack enable pnpm && pnpm i --frozen-lockfile || npm install
|
||||
|
||||
# Stage 2: Builder
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
|
||||
# Ensure public directory exists
|
||||
RUN mkdir -p public
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Runner
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { getSpaceIdFromCookie, getCartKey } from "@/lib/spaces";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
|
||||
|
||||
interface CartItem {
|
||||
id: string;
|
||||
product_slug: string;
|
||||
product_name: string;
|
||||
variant_sku: string;
|
||||
variant_name: string | null;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
subtotal: number;
|
||||
}
|
||||
|
||||
interface Cart {
|
||||
id: string;
|
||||
items: CartItem[];
|
||||
item_count: number;
|
||||
subtotal: number;
|
||||
}
|
||||
|
||||
export default function CartPage() {
|
||||
const [cart, setCart] = useState<Cart | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [checkingOut, setCheckingOut] = useState(false);
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
|
||||
const fetchCart = async () => {
|
||||
const cartKey = getCartKey(getSpaceIdFromCookie());
|
||||
const cartId = localStorage.getItem(cartKey);
|
||||
if (cartId) {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/cart/${cartId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCart(data);
|
||||
} else {
|
||||
// Cart expired or deleted
|
||||
localStorage.removeItem(cartKey);
|
||||
setCart(null);
|
||||
}
|
||||
} catch {
|
||||
setCart(null);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCart();
|
||||
}, []);
|
||||
|
||||
const updateQuantity = async (itemId: string, newQuantity: number) => {
|
||||
if (!cart || newQuantity < 1) return;
|
||||
|
||||
setUpdating(itemId);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/cart/${cart.id}/items/${itemId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ quantity: newQuantity }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const updatedCart = await res.json();
|
||||
setCart(updatedCart);
|
||||
}
|
||||
} catch {
|
||||
console.error("Failed to update quantity");
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const removeItem = async (itemId: string) => {
|
||||
if (!cart) return;
|
||||
|
||||
setUpdating(itemId);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/cart/${cart.id}/items/${itemId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const updatedCart = await res.json();
|
||||
setCart(updatedCart);
|
||||
}
|
||||
} catch {
|
||||
console.error("Failed to remove item");
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckout = async () => {
|
||||
if (!cart) return;
|
||||
setCheckingOut(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/checkout/session`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
cart_id: cart.id,
|
||||
success_url: `${window.location.origin}/checkout/success`,
|
||||
cancel_url: `${window.location.origin}/cart`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const { checkout_url } = await res.json();
|
||||
window.location.href = checkout_url;
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.detail || "Failed to start checkout");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Checkout error:", error);
|
||||
alert("Failed to start checkout");
|
||||
} finally {
|
||||
setCheckingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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,46 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { getSpaceIdFromCookie, getCartKey } from "@/lib/spaces";
|
||||
|
||||
export default function CheckoutSuccessPage() {
|
||||
useEffect(() => {
|
||||
// Clear cart after successful payment
|
||||
const cartKey = getCartKey(getSpaceIdFromCookie());
|
||||
localStorage.removeItem(cartKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-16 text-center">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg
|
||||
className="w-8 h-8 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mb-4">Order Confirmed!</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Thank you for your purchase. Your order is being processed and
|
||||
you'll receive a confirmation email shortly.
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,338 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { getSpaceIdFromCookie } from "@/lib/spaces";
|
||||
import type { SpaceConfig } from "@/lib/spaces";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
|
||||
|
||||
interface GeneratedDesign {
|
||||
slug: string;
|
||||
name: string;
|
||||
image_url: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function DesignPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [concept, setConcept] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [generatedDesign, setGeneratedDesign] = useState<GeneratedDesign | null>(null);
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
const [spaceConfig, setSpaceConfig] = useState<SpaceConfig | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const spaceId = getSpaceIdFromCookie();
|
||||
fetch(`${API_URL}/spaces/${spaceId}`)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then(setSpaceConfig)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleGenerate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
setGeneratedDesign(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/design/generate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
concept,
|
||||
tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
|
||||
product_type: "sticker",
|
||||
space: getSpaceIdFromCookie(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || "Failed to generate design");
|
||||
}
|
||||
|
||||
const design = await response.json();
|
||||
setGeneratedDesign(design);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async () => {
|
||||
if (!generatedDesign) return;
|
||||
|
||||
setIsActivating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/design/${generatedDesign.slug}/activate`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || "Failed to activate design");
|
||||
}
|
||||
|
||||
setGeneratedDesign({ ...generatedDesign, status: "active" });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setIsActivating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!generatedDesign) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/design/${generatedDesign.slug}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || "Failed to delete design");
|
||||
}
|
||||
|
||||
setGeneratedDesign(null);
|
||||
setName("");
|
||||
setConcept("");
|
||||
setTags("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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 {spaceConfig?.name || "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-destructive/10 border border-destructive/30 rounded-md text-red-400">
|
||||
{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-emerald-400"
|
||||
: "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-500/30 text-red-400 rounded-md font-medium hover:bg-red-500/10 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">
|
||||
{(spaceConfig?.design_tips || [
|
||||
"Be specific about text you want included",
|
||||
"Mention colors, mood, and style preferences",
|
||||
"Generated designs start as drafts — preview before adding to the store",
|
||||
]).map((tip, i) => (
|
||||
<li key={i}>• {tip}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 222 30% 6%;
|
||||
--foreground: 180 10% 96%;
|
||||
--card: 222 25% 8%;
|
||||
--card-foreground: 180 10% 96%;
|
||||
--popover: 222 25% 8%;
|
||||
--popover-foreground: 180 10% 96%;
|
||||
--primary: 195 80% 50%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 45 80% 55%;
|
||||
--secondary-foreground: 180 10% 96%;
|
||||
--muted: 222 20% 14%;
|
||||
--muted-foreground: 215 20% 65%;
|
||||
--accent: 222 20% 14%;
|
||||
--accent-foreground: 180 10% 96%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 222 20% 16%;
|
||||
--input: 222 20% 16%;
|
||||
--ring: 195 80% 50%;
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import type { Metadata } from "next";
|
||||
import { GeistSans } from "geist/font";
|
||||
import { cookies } from "next/headers";
|
||||
import "./globals.css";
|
||||
import type { SpaceConfig } from "@/lib/spaces";
|
||||
import { themeToCSS } from "@/lib/spaces";
|
||||
import { HeaderBar } from "@/components/HeaderBar";
|
||||
import { EcosystemFooter } from "@/components/EcosystemFooter";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
|
||||
|
||||
async function getSpaceConfig(spaceId: string): Promise<SpaceConfig | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/spaces/${spaceId}`, {
|
||||
next: { revalidate: 300 },
|
||||
});
|
||||
if (res.ok) return res.json();
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const cookieStore = await cookies();
|
||||
const spaceId = cookieStore.get("space_id")?.value || "default";
|
||||
const space = await getSpaceConfig(spaceId);
|
||||
|
||||
const name = space?.name || "rSwag";
|
||||
const tagline = space?.tagline || "Merch for the rSpace Ecosystem";
|
||||
return {
|
||||
title: `${name} — ${tagline}`,
|
||||
description: space?.description || "Design and order custom merchandise.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
const spaceId = cookieStore.get("space_id")?.value || "default";
|
||||
const space = await getSpaceConfig(spaceId);
|
||||
|
||||
const name = space?.name || "rSwag";
|
||||
const logoUrl = space?.logo_url;
|
||||
const themeCSS = space?.theme ? themeToCSS(space.theme) : "";
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
{themeCSS && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `:root {\n ${themeCSS}\n }`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body className={GeistSans.className}>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<HeaderBar name={name} logoUrl={logoUrl ?? null} />
|
||||
|
||||
{/* ── Main Content ────────────────────────────────── */}
|
||||
<main className="flex-1">{children}</main>
|
||||
|
||||
{/* ── Ecosystem Footer ────────────────────────────── */}
|
||||
<EcosystemFooter current="rSwag" />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,565 @@
|
|||
import Link from "next/link";
|
||||
import { cookies } from "next/headers";
|
||||
import type { SpaceConfig } from "@/lib/spaces";
|
||||
import { RevenueFlowSankey } from "@/components/RevenueFlowSankey";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
|
||||
|
||||
interface Product {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
product_type: string;
|
||||
image_url: string;
|
||||
base_price: number;
|
||||
}
|
||||
|
||||
async function getProducts(spaceId: string): Promise<Product[]> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (spaceId && spaceId !== "default") {
|
||||
params.set("space", spaceId);
|
||||
}
|
||||
const url = `${API_URL}/products${params.toString() ? `?${params}` : ""}`;
|
||||
const res = await fetch(url, { next: { revalidate: 60 } });
|
||||
if (!res.ok) return [];
|
||||
return res.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getSpaceConfig(spaceId: string): Promise<SpaceConfig | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/spaces/${spaceId}`, {
|
||||
next: { revalidate: 300 },
|
||||
});
|
||||
if (res.ok) return res.json();
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getMockupType(productType: string): string {
|
||||
if (
|
||||
productType.includes("shirt") ||
|
||||
productType.includes("tee") ||
|
||||
productType.includes("hoodie")
|
||||
)
|
||||
return "shirt";
|
||||
if (productType.includes("sticker")) return "sticker";
|
||||
if (productType.includes("print")) return "print";
|
||||
return "shirt";
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
const cookieStore = await cookies();
|
||||
const spaceId = cookieStore.get("space_id")?.value || "default";
|
||||
const [products, space] = await Promise.all([
|
||||
getProducts(spaceId),
|
||||
getSpaceConfig(spaceId),
|
||||
]);
|
||||
|
||||
const name = space?.name || "rSwag";
|
||||
const isCustomSpace = spaceId !== "default" && !!space;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ── Hero Section ──────────────────────────────────────── */}
|
||||
<section className="relative text-center py-16 sm:py-24 space-y-6 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 via-transparent to-secondary/10 -z-10" />
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-primary/5 rounded-full blur-3xl -z-10" />
|
||||
<div className="absolute bottom-0 left-0 w-96 h-96 bg-secondary/5 rounded-full blur-3xl -z-10" />
|
||||
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
<span className="inline-flex items-center rounded-full text-sm px-4 py-1.5 bg-primary/10 text-primary border border-primary/20 font-medium">
|
||||
Part of the rSpace Ecosystem
|
||||
</span>
|
||||
|
||||
<h1 className="mt-6 text-4xl sm:text-5xl md:text-6xl font-bold tracking-tight max-w-4xl mx-auto leading-tight">
|
||||
{isCustomSpace ? (
|
||||
name
|
||||
) : (
|
||||
<>
|
||||
Get Your Community{" "}
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-secondary">
|
||||
Noticed
|
||||
</span>
|
||||
<br />
|
||||
with{" "}
|
||||
<span className="text-white">(you)</span>
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-secondary">
|
||||
rMerch
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 text-lg sm:text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
{isCustomSpace ? (
|
||||
space?.description ||
|
||||
"Custom merchandise for your community."
|
||||
) : (
|
||||
<>
|
||||
A self-provisioned local design & print protocol that
|
||||
generates{" "}
|
||||
<strong className="text-foreground">revenue</strong> (and
|
||||
attention!) for your community.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col sm:flex-row justify-center gap-4">
|
||||
<Link
|
||||
href="/products"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-gradient-to-r from-primary to-primary/80 hover:from-primary/90 hover:to-primary/70 px-8 py-3.5 text-lg font-medium text-primary-foreground transition-all shadow-lg shadow-primary/20"
|
||||
>
|
||||
Browse the Shop
|
||||
<svg
|
||||
className="ml-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href="/upload"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-primary/30 hover:bg-primary/5 px-8 py-3.5 text-lg font-medium transition-colors"
|
||||
>
|
||||
Upload a Design
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── How It Works ──────────────────────────────────────── */}
|
||||
<section className="py-16 max-w-5xl mx-auto px-4">
|
||||
<div className="text-center mb-10">
|
||||
<span className="inline-flex items-center rounded-full text-sm px-4 py-1.5 bg-muted text-muted-foreground font-medium">
|
||||
How It Works
|
||||
</span>
|
||||
<h2 className="mt-3 text-2xl sm:text-3xl font-bold">
|
||||
From Design to Revenue in Minutes
|
||||
</h2>
|
||||
<p className="mt-2 text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
<strong className="text-primary">Create</strong> your community's
|
||||
merch, <strong className="text-secondary">sell</strong> it on demand,
|
||||
and <strong className="text-foreground">fund</strong> your
|
||||
community's work — no inventory, no risk.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Step 1 */}
|
||||
<div className="border-2 border-primary/40 bg-gradient-to-br from-primary/10 to-primary/5 rounded-xl p-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="h-10 w-10 rounded-full bg-primary flex items-center justify-center shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-primary-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-bold text-lg">1. Upload or Create a Design</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Upload your own artwork or generate a unique design with our AI
|
||||
studio. Logos, slogans, art — anything that represents your
|
||||
community.
|
||||
<strong className="text-foreground block mt-2">
|
||||
Your community's identity, on merch.
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div className="border-2 border-secondary/40 bg-gradient-to-br from-secondary/10 to-secondary/5 rounded-xl p-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="h-10 w-10 rounded-full bg-secondary flex items-center justify-center shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-secondary-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM8.625 10.5a.375.375 0 11-.75 0 .375.375 0 01.75 0zm7.5 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-bold text-lg">2. Pick Products & Set Prices</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Choose from t-shirts, hoodies, stickers, posters, mugs, and more.
|
||||
Set your markup — every dollar above cost goes directly into your
|
||||
community's funding stream.
|
||||
<strong className="text-foreground block mt-2">
|
||||
You set the margin, you keep the revenue.
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 3 */}
|
||||
<div className="border-2 border-primary/30 bg-gradient-to-br from-primary/5 to-secondary/5 rounded-xl p-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="h-10 w-10 rounded-full bg-gradient-to-r from-primary to-secondary flex items-center justify-center shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-primary-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12.75 3.03v.568c0 .334.148.65.405.864l1.068.89c.442.369.535 1.01.216 1.49l-.51.766a2.25 2.25 0 01-1.161.886l-.143.048a1.107 1.107 0 00-.57 1.664c.369.555.169 1.307-.427 1.605L9 13.125l.423 1.059a.956.956 0 01-1.652.928l-.679-.906a1.125 1.125 0 00-1.906.172L4.5 15.75l-.612.153M12.75 3.031a9 9 0 00-8.862 12.872M12.75 3.031a9 9 0 016.69 14.036m0 0l-.177-.529A2.25 2.25 0 0017.128 15H16.5l-.324-.324a1.453 1.453 0 00-2.328.377l-.036.073a1.586 1.586 0 01-.982.816l-.99.282c-.55.157-.894.702-.8 1.267l.073.438a2.253 2.253 0 01-1.699 2.608l-.425.108A9.012 9.012 0 013.888 15.9"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-bold text-lg">3. Ship Locally — Worldwide</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Printful prints locally from the nearest fulfillment center — less
|
||||
shipping, less carbon. Delivered to your community members anywhere
|
||||
in the world.
|
||||
<strong className="text-foreground block mt-2">
|
||||
Local production, global reach.
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Community Revenue Model — Interactive Sankey ─────── */}
|
||||
<section className="py-16 bg-muted/30">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
<div className="text-center mb-10">
|
||||
<span className="inline-flex items-center rounded-full text-sm px-4 py-1.5 bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-medium">
|
||||
Revenue Model
|
||||
</span>
|
||||
<h2 className="mt-3 text-2xl sm:text-3xl font-bold">
|
||||
Merch That Funds Your Mission
|
||||
</h2>
|
||||
<p className="mt-2 text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
Every purchase feeds revenue directly into your community's
|
||||
funding streams. Drag the sliders to explore how the money flows.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-primary/20 rounded-xl p-6 sm:p-8 bg-card">
|
||||
<RevenueFlowSankey />
|
||||
</div>
|
||||
|
||||
{/* Key benefits row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
|
||||
<div className="text-center p-4 rounded-lg border border-primary/10 bg-card/50">
|
||||
<div className="text-2xl font-bold text-primary">$0</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">Platform Fees</div>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg border border-primary/10 bg-card/50">
|
||||
<div className="text-2xl font-bold text-emerald-400">100%</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">Margin to Community</div>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg border border-primary/10 bg-card/50">
|
||||
<div className="text-2xl font-bold text-purple-400">You</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">Set the Splits</div>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg border border-primary/10 bg-card/50">
|
||||
<div className="text-2xl font-bold text-blue-400">0</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">Inventory Risk</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Features Grid ─────────────────────────────────────── */}
|
||||
<section className="py-16 max-w-5xl mx-auto px-4">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold mb-4">
|
||||
Everything Your Community Needs
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Tools to design, sell, and ship merch that funds your collective work
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="border border-primary/20 hover:border-primary/40 transition-colors rounded-xl p-6 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2">AI Design Studio</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generate unique designs with AI or upload your own. Instant
|
||||
photorealistic product mockups.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-primary/20 hover:border-primary/40 transition-colors rounded-xl p-6 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-gradient-to-br from-secondary to-secondary/60 flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2">Community Spaces</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Each community gets its own branded storefront — custom domain,
|
||||
theme, and product catalog.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-primary/20 hover:border-primary/40 transition-colors rounded-xl p-6 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2">Revenue Streams</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Merch revenue flows directly to your community — connect to rFunds,
|
||||
DAOs, treasuries, or any funding channel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-primary/20 hover:border-primary/40 transition-colors rounded-xl p-6 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-gradient-to-br from-purple-500 to-violet-600 flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21zM8.25 8.625a1.125 1.125 0 100-2.25 1.125 1.125 0 000 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2">Realistic Mockups</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
See your design on real products via Printful's mockup engine
|
||||
before you ever commit to an order.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-primary/20 hover:border-primary/40 transition-colors rounded-xl p-6 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-gradient-to-br from-rose-500 to-pink-600 flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2">Local Fulfillment</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Printed at the nearest Printful facility — shorter shipping
|
||||
distances, less carbon, faster delivery.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-primary/20 hover:border-primary/40 transition-colors rounded-xl p-6 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-gradient-to-br from-teal-500 to-cyan-600 flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 002.25-2.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v2.25A2.25 2.25 0 006 10.5zm0 9.75h2.25A2.25 2.25 0 0010.5 18v-2.25a2.25 2.25 0 00-2.25-2.25H6a2.25 2.25 0 00-2.25 2.25V18A2.25 2.25 0 006 20.25zm9.75-9.75H18a2.25 2.25 0 002.25-2.25V6A2.25 2.25 0 0018 3.75h-2.25A2.25 2.25 0 0013.5 6v2.25a2.25 2.25 0 002.25 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2">rSpace Ecosystem</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Part of the r* suite — integrates with rFunds for treasury, rVote
|
||||
for design governance, and more.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Featured Products ─────────────────────────────────── */}
|
||||
{products.length > 0 && (
|
||||
<section className="py-16">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold mb-2">
|
||||
Community Merch
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Locally produced, print-on-demand — every sale supports the
|
||||
community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{products.slice(0, 6).map((product) => (
|
||||
<Link
|
||||
key={product.slug}
|
||||
href={`/products/${product.slug}`}
|
||||
className="group block"
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-xl border border-border/50 bg-card shadow-sm transition-all duration-300 hover:shadow-xl hover:shadow-primary/5 hover:-translate-y-1 hover:border-primary/30">
|
||||
<div className="aspect-square bg-muted relative overflow-hidden">
|
||||
<img
|
||||
src={`${API_URL}/designs/${product.slug}/mockup?type=${getMockupType(product.product_type)}`}
|
||||
alt={product.name}
|
||||
className="object-cover w-full h-full transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute top-3 left-3">
|
||||
<span className="inline-flex items-center rounded-full bg-black/60 backdrop-blur-sm px-3 py-1 text-xs font-medium text-white capitalize">
|
||||
{product.product_type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<h3 className="font-semibold text-lg leading-tight group-hover:text-primary transition-colors">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground line-clamp-2">
|
||||
{product.description}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<span className="text-xl font-bold">
|
||||
${product.base_price.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-primary opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
View Details →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{products.length > 6 && (
|
||||
<div className="text-center mt-8">
|
||||
<Link
|
||||
href="/products"
|
||||
className="inline-flex items-center text-primary font-medium hover:underline"
|
||||
>
|
||||
View all {products.length} products →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── CTA Section ───────────────────────────────────────── */}
|
||||
<section className="py-16 max-w-5xl mx-auto px-4">
|
||||
<div className="border-2 border-primary/30 bg-gradient-to-br from-primary/10 via-secondary/5 to-primary/10 rounded-2xl overflow-hidden relative">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 w-64 h-64 bg-secondary/10 rounded-full blur-3xl" />
|
||||
<div className="py-12 sm:py-16 text-center space-y-6 relative px-6">
|
||||
<span className="inline-flex items-center rounded-full text-sm px-4 py-1.5 bg-primary/10 text-primary border border-primary/20 font-medium">
|
||||
Fund Your Community
|
||||
</span>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold">
|
||||
Turn your community's identity into a revenue stream
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-xl mx-auto">
|
||||
Create a Space, upload your designs, and start selling merch that
|
||||
funds your community's work. Every sale flows directly into your
|
||||
community's funding channels.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row justify-center gap-4">
|
||||
<Link
|
||||
href="/upload"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-gradient-to-r from-primary to-secondary hover:opacity-90 px-8 py-3.5 text-lg font-medium text-primary-foreground transition-all"
|
||||
>
|
||||
Get Started
|
||||
<svg
|
||||
className="ml-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href="/products"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-primary/30 hover:bg-primary/5 px-8 py-3.5 text-lg font-medium transition-colors"
|
||||
>
|
||||
Browse the Shop
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,370 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getSpaceIdFromCookie, getCartKey } from "@/lib/spaces";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
|
||||
|
||||
interface 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;
|
||||
}
|
||||
|
||||
const MOCKUP_TYPES = [
|
||||
{ type: "shirt", label: "T-Shirt", icon: "👕" },
|
||||
{ type: "sticker", label: "Sticker", icon: "🏷️" },
|
||||
{ type: "print", label: "Art Print", icon: "🖼️" },
|
||||
];
|
||||
|
||||
function getMockupType(productType: string): string {
|
||||
if (productType.includes("shirt") || productType.includes("tee") || productType.includes("hoodie")) return "shirt";
|
||||
if (productType.includes("sticker")) return "sticker";
|
||||
if (productType.includes("print")) return "print";
|
||||
return "shirt";
|
||||
}
|
||||
|
||||
export default function ProductPage() {
|
||||
const params = useParams();
|
||||
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 [selectedMockup, setSelectedMockup] = useState<string>("shirt");
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [addingToCart, setAddingToCart] = useState(false);
|
||||
const [addedToCart, setAddedToCart] = useState(false);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchProduct() {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/products/${slug}`);
|
||||
if (!res.ok) {
|
||||
setError(res.status === 404 ? "Product not found" : "Failed to load product");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setProduct(data);
|
||||
if (data.variants?.length > 0) {
|
||||
setSelectedVariant(data.variants[0]);
|
||||
}
|
||||
setSelectedMockup(getMockupType(data.product_type));
|
||||
} catch {
|
||||
setError("Failed to load product");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
if (slug) fetchProduct();
|
||||
}, [slug]);
|
||||
|
||||
const getOrCreateCart = async (): Promise<string | null> => {
|
||||
let cartId = localStorage.getItem(getCartKey(getSpaceIdFromCookie()));
|
||||
if (cartId) {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/cart/${cartId}`);
|
||||
if (res.ok) return cartId;
|
||||
} catch { /* cart expired */ }
|
||||
}
|
||||
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(getCartKey(getSpaceIdFromCookie()), 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,
|
||||
}),
|
||||
});
|
||||
|
||||
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="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="grid md:grid-cols-2 gap-12">
|
||||
{/* Image skeleton */}
|
||||
<div className="aspect-square rounded-xl bg-muted animate-pulse" />
|
||||
{/* Content skeleton */}
|
||||
<div className="space-y-4">
|
||||
<div className="h-4 w-24 bg-muted rounded animate-pulse" />
|
||||
<div className="h-10 w-3/4 bg-muted rounded animate-pulse" />
|
||||
<div className="h-20 w-full bg-muted rounded animate-pulse" />
|
||||
<div className="h-10 w-32 bg-muted rounded animate-pulse" />
|
||||
<div className="h-12 w-full bg-muted rounded animate-pulse mt-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !product) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<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="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-8 text-sm flex items-center gap-2">
|
||||
<Link href="/" className="text-muted-foreground hover:text-primary transition-colors">Home</Link>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<Link href="/products" className="text-muted-foreground hover:text-primary transition-colors">Products</Link>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<span className="text-foreground font-medium">{product.name}</span>
|
||||
</nav>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12 lg:gap-16">
|
||||
{/* Product Image Section */}
|
||||
<div className="space-y-4">
|
||||
{/* Main mockup image */}
|
||||
<div className="relative aspect-square rounded-xl overflow-hidden bg-muted border shadow-sm">
|
||||
{imageLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-muted z-10">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
|
||||
<span className="text-xs text-muted-foreground">Loading mockup...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={`${API_URL}/designs/${product.slug}/mockup?type=${selectedMockup}`}
|
||||
alt={`${product.name} — ${selectedMockup} mockup`}
|
||||
className={`w-full h-full object-cover transition-opacity duration-300 ${imageLoading ? 'opacity-0' : 'opacity-100'}`}
|
||||
onLoad={() => setImageLoading(false)}
|
||||
onError={() => setImageLoading(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mockup type switcher */}
|
||||
<div className="flex gap-2">
|
||||
{MOCKUP_TYPES.map((mt) => (
|
||||
<button
|
||||
key={mt.type}
|
||||
onClick={() => {
|
||||
setSelectedMockup(mt.type);
|
||||
setImageLoading(true);
|
||||
}}
|
||||
className={`flex-1 py-2.5 px-3 rounded-lg border text-sm font-medium transition-all duration-200 ${
|
||||
selectedMockup === mt.type
|
||||
? "border-primary bg-primary/10 text-primary shadow-sm"
|
||||
: "border-border hover:border-primary/50 text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1.5">{mt.icon}</span>
|
||||
{mt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Raw design preview */}
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground mb-2 font-medium uppercase tracking-wide">Original Design</p>
|
||||
<div className="aspect-video rounded-md overflow-hidden bg-muted">
|
||||
<img
|
||||
src={`${API_URL}/designs/${product.slug}/image`}
|
||||
alt={`${product.name} — raw design`}
|
||||
className="w-full h-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Details Section */}
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-3">
|
||||
<span className="inline-flex items-center rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary capitalize">
|
||||
{product.category} — {product.product_type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl lg:text-4xl font-bold tracking-tight mb-4">{product.name}</h1>
|
||||
<p className="text-muted-foreground leading-relaxed mb-8">{product.description}</p>
|
||||
|
||||
<div className="text-4xl font-bold mb-8">
|
||||
${selectedVariant?.price.toFixed(2) || product.base_price.toFixed(2)}
|
||||
<span className="text-sm font-normal text-muted-foreground ml-2">+ shipping</span>
|
||||
</div>
|
||||
|
||||
{/* Variant Selection */}
|
||||
{product.variants && product.variants.length > 1 && (
|
||||
<div className="mb-8">
|
||||
<label className="block text-sm font-semibold mb-3">Size</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{product.variants.map((variant) => (
|
||||
<button
|
||||
key={variant.sku}
|
||||
onClick={() => setSelectedVariant(variant)}
|
||||
className={`min-w-[3.5rem] px-4 py-2.5 rounded-lg border text-sm font-medium transition-all duration-200 ${
|
||||
selectedVariant?.sku === variant.sku
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-border hover:border-primary/50 text-foreground"
|
||||
}`}
|
||||
>
|
||||
{variant.name.replace(/ \(printful\)$/i, "")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantity */}
|
||||
<div className="mb-8">
|
||||
<label className="block text-sm font-semibold mb-3">Quantity</label>
|
||||
<div className="inline-flex items-center rounded-lg border overflow-hidden">
|
||||
<button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
className="w-12 h-12 flex items-center justify-center hover:bg-muted transition-colors text-lg font-medium"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-14 h-12 flex items-center justify-center font-semibold border-x text-lg">
|
||||
{quantity}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
className="w-12 h-12 flex items-center justify-center hover:bg-muted transition-colors text-lg font-medium"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add to Cart */}
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={addingToCart || !selectedVariant}
|
||||
className={`w-full py-4 rounded-xl font-semibold text-lg transition-all duration-200 ${
|
||||
addedToCart
|
||||
? "bg-green-600 text-white shadow-lg shadow-green-600/20"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30"
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none`}
|
||||
>
|
||||
{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 to cart...
|
||||
</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 — $${((selectedVariant?.price || product.base_price) * quantity).toFixed(2)}`
|
||||
)}
|
||||
</button>
|
||||
|
||||
{addedToCart && (
|
||||
<Link
|
||||
href="/cart"
|
||||
className="block text-center mt-4 py-3 rounded-xl border border-primary text-primary font-medium hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
View Cart →
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Product info */}
|
||||
<div className="mt-10 space-y-4 text-sm text-muted-foreground border-t pt-8">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="h-5 w-5 mt-0.5 flex-shrink-0 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
<span>Printed and shipped by Printful. Fulfilled on demand — no waste.</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="h-5 w-5 mt-0.5 flex-shrink-0 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Standard shipping: 5–12 business days. Express available at checkout.</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="h-5 w-5 mt-0.5 flex-shrink-0 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span>Bella + Canvas 3001 — premium 100% combed cotton, retail fit.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{product.tags?.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{product.tags.map((tag) => (
|
||||
<span key={tag} className="px-3 py-1 text-xs font-medium bg-muted rounded-full text-muted-foreground">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import Link from "next/link";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
|
||||
|
||||
interface Product {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
product_type: string;
|
||||
image_url: string;
|
||||
base_price: number;
|
||||
}
|
||||
|
||||
function getMockupType(productType: string): string {
|
||||
if (productType.includes("shirt") || productType.includes("tee") || productType.includes("hoodie")) return "shirt";
|
||||
if (productType.includes("sticker")) return "sticker";
|
||||
if (productType.includes("print")) return "print";
|
||||
return "shirt";
|
||||
}
|
||||
|
||||
async function getProducts(spaceId: string): Promise<Product[]> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (spaceId && spaceId !== "default") {
|
||||
params.set("space", spaceId);
|
||||
}
|
||||
const url = `${API_URL}/products${params.toString() ? `?${params}` : ""}`;
|
||||
const res = await fetch(url, { next: { revalidate: 3600 } });
|
||||
if (!res.ok) return [];
|
||||
return res.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProductsPage() {
|
||||
const cookieStore = await cookies();
|
||||
const spaceId = cookieStore.get("space_id")?.value || "default";
|
||||
const products = await getProducts(spaceId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl font-bold tracking-tight">Products</h1>
|
||||
<p className="mt-2 text-lg text-muted-foreground">
|
||||
Print-on-demand merch — designed by the community, fulfilled by Printful.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="rounded-full bg-muted p-6 mb-6">
|
||||
<svg className="h-12 w-12 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">No products yet</h2>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
New designs are being added. Check back soon or create your own.
|
||||
</p>
|
||||
<Link
|
||||
href="/upload"
|
||||
className="mt-6 inline-flex items-center rounded-md bg-primary px-6 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Upload a Design
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{products.map((product) => (
|
||||
<Link
|
||||
key={product.slug}
|
||||
href={`/products/${product.slug}`}
|
||||
className="group block"
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-xl border bg-card shadow-sm transition-all duration-300 hover:shadow-xl hover:-translate-y-1">
|
||||
{/* Product image */}
|
||||
<div className="aspect-square bg-muted relative overflow-hidden">
|
||||
<img
|
||||
src={`${API_URL}/designs/${product.slug}/mockup?type=${getMockupType(product.product_type)}`}
|
||||
alt={product.name}
|
||||
className="object-cover w-full h-full transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/* Category badge */}
|
||||
<div className="absolute top-3 left-3">
|
||||
<span className="inline-flex items-center rounded-full bg-black/60 backdrop-blur-sm px-3 py-1 text-xs font-medium text-white capitalize">
|
||||
{product.product_type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product info */}
|
||||
<div className="p-5">
|
||||
<h3 className="font-semibold text-lg leading-tight group-hover:text-primary transition-colors">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground line-clamp-2">
|
||||
{product.description}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<span className="text-xl font-bold">
|
||||
${product.base_price.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-primary opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
View Details →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,470 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { getSpaceIdFromCookie } from "@/lib/spaces";
|
||||
import { MOCKUP_CONFIGS, generateMockup } from "@/lib/mockups";
|
||||
import type { SpaceConfig } from "@/lib/spaces";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
|
||||
|
||||
interface UploadedDesign {
|
||||
slug: string;
|
||||
name: string;
|
||||
image_url: string;
|
||||
status: string;
|
||||
products: { type: string; price: number }[];
|
||||
}
|
||||
|
||||
export default function UploadPage() {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [mockups, setMockups] = useState<Record<string, string>>({});
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [uploadedDesign, setUploadedDesign] = useState<UploadedDesign | null>(null);
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
const [spaceConfig, setSpaceConfig] = useState<SpaceConfig | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const spaceId = getSpaceIdFromCookie();
|
||||
fetch(`${API_URL}/spaces/${spaceId}`)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then(setSpaceConfig)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Generate mockups when preview changes
|
||||
useEffect(() => {
|
||||
if (!preview) {
|
||||
setMockups({});
|
||||
return;
|
||||
}
|
||||
MOCKUP_CONFIGS.forEach(async (config) => {
|
||||
try {
|
||||
const result = await generateMockup(preview, config);
|
||||
setMockups((prev) => ({ ...prev, [config.productType]: result }));
|
||||
} catch {
|
||||
// Template load failure — fallback handled in render
|
||||
}
|
||||
});
|
||||
}, [preview]);
|
||||
|
||||
const handleFile = useCallback((f: File) => {
|
||||
if (!f.type.startsWith("image/")) {
|
||||
setError("Please select an image file (PNG, JPEG, or WebP)");
|
||||
return;
|
||||
}
|
||||
if (f.size > 10 * 1024 * 1024) {
|
||||
setError("File must be under 10 MB");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setFile(f);
|
||||
setPreview(URL.createObjectURL(f));
|
||||
if (!name) {
|
||||
// Auto-fill name from filename
|
||||
const baseName = f.name.replace(/\.[^.]+$/, "").replace(/[-_]/g, " ");
|
||||
setName(baseName.charAt(0).toUpperCase() + baseName.slice(1));
|
||||
}
|
||||
}, [name]);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const droppedFile = e.dataTransfer.files[0];
|
||||
if (droppedFile) handleFile(droppedFile);
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
const handleUpload = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!file || !name) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("name", name);
|
||||
formData.append("space", getSpaceIdFromCookie());
|
||||
if (tags) formData.append("tags", tags);
|
||||
|
||||
const response = await fetch(`${API_URL}/design/upload`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || "Upload failed");
|
||||
}
|
||||
|
||||
const design = await response.json();
|
||||
setUploadedDesign(design);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async () => {
|
||||
if (!uploadedDesign) return;
|
||||
setIsActivating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/design/${uploadedDesign.slug}/activate`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || "Failed to activate design");
|
||||
}
|
||||
setUploadedDesign({ ...uploadedDesign, status: "active" });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setIsActivating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!uploadedDesign) return;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/design/${uploadedDesign.slug}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || "Failed to delete design");
|
||||
}
|
||||
resetForm();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
setName("");
|
||||
setTags("");
|
||||
setMockups({});
|
||||
setUploadedDesign(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-2">Upload Swag</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Upload your own design and preview it on{" "}
|
||||
{spaceConfig?.name || "rSwag"} merchandise. See how it looks on
|
||||
shirts, stickers, and prints before ordering.
|
||||
</p>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Left: Upload Form */}
|
||||
<div>
|
||||
<form onSubmit={handleUpload} 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., My Custom Logo"
|
||||
className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
disabled={isUploading || !!uploadedDesign}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Drag & Drop Zone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Design Image
|
||||
</label>
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() =>
|
||||
!uploadedDesign && fileInputRef.current?.click()
|
||||
}
|
||||
className={`relative border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
|
||||
isDragging
|
||||
? "border-primary bg-primary/5"
|
||||
: preview
|
||||
? "border-primary/50"
|
||||
: "border-muted-foreground/30 hover:border-primary/50"
|
||||
} ${uploadedDesign ? "pointer-events-none opacity-60" : ""}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleFile(f);
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
{preview ? (
|
||||
<div className="space-y-3">
|
||||
<img
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
className="max-h-48 mx-auto rounded-md"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{file?.name} (
|
||||
{((file?.size || 0) / 1024 / 1024).toFixed(1)} MB)
|
||||
</p>
|
||||
{!uploadedDesign && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
setMockups({});
|
||||
}}
|
||||
className="text-sm text-red-500 hover:text-red-700"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-muted-foreground/50"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-muted-foreground">
|
||||
Drag & drop your design here, or{" "}
|
||||
<span className="text-primary font-medium">browse</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
PNG, JPEG, or WebP. Max 10 MB. Min 500x500px.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="tags"
|
||||
className="block text-sm font-medium mb-2"
|
||||
>
|
||||
Tags (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tags"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="logo, custom, brand"
|
||||
className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isUploading || !!uploadedDesign}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-destructive/10 border border-destructive/30 rounded-md text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!uploadedDesign && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isUploading || !file || !name}
|
||||
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"
|
||||
>
|
||||
{isUploading ? (
|
||||
<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>
|
||||
Uploading...
|
||||
</span>
|
||||
) : (
|
||||
"Upload & Save Design"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Post-upload actions */}
|
||||
{uploadedDesign && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<p className="font-medium">{uploadedDesign.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Status:{" "}
|
||||
<span
|
||||
className={
|
||||
uploadedDesign.status === "active"
|
||||
? "text-emerald-400"
|
||||
: "text-yellow-600"
|
||||
}
|
||||
>
|
||||
{uploadedDesign.status}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{uploadedDesign.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-500/30 text-red-400 rounded-md font-medium hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href={`/products/${uploadedDesign.slug}`}
|
||||
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>
|
||||
<button
|
||||
onClick={resetForm}
|
||||
className="px-4 py-2 border border-muted-foreground/30 text-muted-foreground rounded-md font-medium hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
Upload Another
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Mockup Previews */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4">Product Previews</h2>
|
||||
{preview ? (
|
||||
<div className="space-y-4">
|
||||
{MOCKUP_CONFIGS.map((config) => (
|
||||
<div
|
||||
key={config.productType}
|
||||
className="border rounded-lg overflow-hidden"
|
||||
>
|
||||
<div className="aspect-square bg-muted/20 relative">
|
||||
{mockups[config.productType] ? (
|
||||
<img
|
||||
src={mockups[config.productType]}
|
||||
alt={`${config.label} preview`}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 flex items-center justify-between">
|
||||
<span className="font-medium">{config.label}</span>
|
||||
<span className="text-muted-foreground">
|
||||
from ${config.price.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-square border-2 border-dashed rounded-lg flex items-center justify-center bg-muted/30">
|
||||
<p className="text-muted-foreground text-center px-8">
|
||||
Upload a design to see product previews
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="mt-12 p-6 bg-muted/30 rounded-lg">
|
||||
<h3 className="font-semibold mb-3">Upload Tips</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>
|
||||
• Use a high-resolution image (at least 2000x2000px for best
|
||||
print quality)
|
||||
</li>
|
||||
<li>
|
||||
• PNG with transparency works best for stickers and shirts
|
||||
</li>
|
||||
<li>
|
||||
• Keep important elements centered — edges may be cropped on
|
||||
some products
|
||||
</li>
|
||||
<li>
|
||||
• Designs start as drafts — preview before adding to the
|
||||
store
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
export interface AppModule {
|
||||
id: string;
|
||||
name: string;
|
||||
badge: string; // favicon-style abbreviation: rS, rN, rP, etc.
|
||||
color: string; // Tailwind bg class for the pastel badge
|
||||
emoji: string; // function emoji shown right of title
|
||||
description: string;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
const MODULES: AppModule[] = [
|
||||
// Creating
|
||||
{ id: 'space', name: 'rSpace', badge: 'rS', color: 'bg-teal-300', emoji: '🎨', description: 'Real-time collaborative canvas', domain: 'rspace.online' },
|
||||
{ id: 'notes', name: 'rNotes', badge: 'rN', color: 'bg-amber-300', emoji: '📝', description: 'Group note-taking & knowledge capture', domain: 'rnotes.online' },
|
||||
{ id: 'pubs', name: 'rPubs', badge: 'rP', color: 'bg-rose-300', emoji: '📰', description: 'Collaborative publishing platform', domain: 'rpubs.online' },
|
||||
// Planning
|
||||
{ id: 'cal', name: 'rCal', badge: 'rC', color: 'bg-sky-300', emoji: '📅', description: 'Collaborative scheduling & events', domain: 'rcal.online' },
|
||||
{ id: 'trips', name: 'rTrips', badge: 'rT', color: 'bg-emerald-300', emoji: '✈️', description: 'Group travel planning in real time', domain: 'rtrips.online' },
|
||||
{ id: 'maps', name: 'rMaps', badge: 'rM', color: 'bg-green-300', emoji: '🗺️', description: 'Collaborative real-time mapping', domain: 'rmaps.online' },
|
||||
// Communicating
|
||||
{ id: 'chats', name: 'rChats', badge: 'rCh', color: 'bg-emerald-200', emoji: '💬', description: 'Real-time encrypted messaging', domain: 'rchats.online' },
|
||||
{ id: 'inbox', name: 'rInbox', badge: 'rI', color: 'bg-indigo-300', emoji: '📬', description: 'Private group messaging', domain: 'rinbox.online' },
|
||||
{ id: 'mail', name: 'rMail', badge: 'rMa', color: 'bg-blue-200', emoji: '✉️', description: 'Community email & newsletters', domain: 'rmail.online' },
|
||||
{ id: 'forum', name: 'rForum', badge: 'rFo', color: 'bg-amber-200', emoji: '💭', description: 'Threaded community discussions', domain: 'rforum.online' },
|
||||
// Deciding
|
||||
{ id: 'choices', name: 'rChoices', badge: 'rCo', color: 'bg-fuchsia-300', emoji: '🔀', description: 'Collaborative decision making', domain: 'rchoices.online' },
|
||||
{ id: 'vote', name: 'rVote', badge: 'rV', color: 'bg-violet-300', emoji: '🗳️', description: 'Real-time polls & governance', domain: 'rvote.online' },
|
||||
// Funding & Commerce
|
||||
{ id: 'funds', name: 'rFunds', badge: 'rF', color: 'bg-lime-300', emoji: '💸', description: 'Collaborative fundraising & grants', domain: 'rfunds.online' },
|
||||
{ id: 'wallet', name: 'rWallet', badge: 'rW', color: 'bg-yellow-300', emoji: '💰', description: 'Multi-chain crypto wallet', domain: 'rwallet.online' },
|
||||
{ id: 'cart', name: 'rCart', badge: 'rCt', color: 'bg-orange-300', emoji: '🛒', description: 'Group commerce & shared shopping', domain: 'rcart.online' },
|
||||
{ id: 'auctions', name: 'rAuctions', badge: 'rA', color: 'bg-red-300', emoji: '🔨', description: 'Live auction platform', domain: 'rauctions.online' },
|
||||
{ id: 'swag', name: 'rSwag', badge: 'rSw', color: 'bg-red-200', emoji: '👕', description: 'Community merch & swag store', domain: 'rswag.online' },
|
||||
// Social & Media
|
||||
{ id: 'photos', name: 'rPhotos', badge: 'rPh', color: 'bg-pink-200', emoji: '📸', description: 'Shared community photo albums', domain: 'rphotos.online' },
|
||||
{ id: 'tube', name: 'rTube', badge: 'rTu', color: 'bg-pink-300', emoji: '🎬', description: 'Group video platform', domain: 'rtube.online' },
|
||||
{ id: 'network', name: 'rNetwork', badge: 'rNe', color: 'bg-blue-300', emoji: '🌐', description: 'Community network & social graph', domain: 'rnetwork.online' },
|
||||
{ id: 'socials', name: 'rSocials', badge: 'rSo', color: 'bg-sky-200', emoji: '📢', description: 'Social media management', domain: 'rsocials.online' },
|
||||
{ id: 'files', name: 'rFiles', badge: 'rFi', color: 'bg-cyan-300', emoji: '📁', description: 'Collaborative file storage', domain: 'rfiles.online' },
|
||||
{ id: 'data', name: 'rData', badge: 'rD', color: 'bg-purple-300', emoji: '📊', description: 'Analytics & insights dashboard', domain: 'rdata.online' },
|
||||
// Work & Productivity
|
||||
{ id: 'work', name: 'rWork', badge: 'rWo', color: 'bg-slate-300', emoji: '💼', description: 'Project & task management', domain: 'rwork.online' },
|
||||
// Identity & Infrastructure
|
||||
{ id: 'ids', name: 'rIDs', badge: 'rId', color: 'bg-emerald-300', emoji: '🔑', description: 'Passkey identity & zero-knowledge auth', domain: 'ridentity.online' },
|
||||
{ id: 'stack', name: 'rStack', badge: 'r*', color: 'bg-gradient-to-br from-cyan-300 via-violet-300 to-rose-300', emoji: '📦', description: 'Open-source community infrastructure', domain: 'rstack.online' },
|
||||
];
|
||||
|
||||
const MODULE_CATEGORIES: Record<string, string> = {
|
||||
space: 'Creating',
|
||||
notes: 'Creating',
|
||||
pubs: 'Creating',
|
||||
cal: 'Planning',
|
||||
trips: 'Planning',
|
||||
maps: 'Planning',
|
||||
chats: 'Communicating',
|
||||
inbox: 'Communicating',
|
||||
mail: 'Communicating',
|
||||
forum: 'Communicating',
|
||||
choices: 'Deciding',
|
||||
vote: 'Deciding',
|
||||
funds: 'Funding & Commerce',
|
||||
wallet: 'Funding & Commerce',
|
||||
cart: 'Funding & Commerce',
|
||||
auctions: 'Funding & Commerce',
|
||||
swag: 'Funding & Commerce',
|
||||
photos: 'Social & Media',
|
||||
tube: 'Social & Media',
|
||||
network: 'Social & Media',
|
||||
socials: 'Social & Media',
|
||||
files: 'Social & Media',
|
||||
data: 'Social & Media',
|
||||
work: 'Work & Productivity',
|
||||
ids: 'Identity & Infrastructure',
|
||||
stack: 'Identity & Infrastructure',
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
'Creating',
|
||||
'Planning',
|
||||
'Communicating',
|
||||
'Deciding',
|
||||
'Funding & Commerce',
|
||||
'Social & Media',
|
||||
'Work & Productivity',
|
||||
'Identity & Infrastructure',
|
||||
];
|
||||
|
||||
/** Build the URL for a module, using username subdomain if logged in */
|
||||
function getModuleUrl(m: AppModule, username: string | null): string {
|
||||
if (!m.domain) return '#';
|
||||
if (username) {
|
||||
// Generate <username>.<domain> URL
|
||||
return `https://${username}.${m.domain}`;
|
||||
}
|
||||
return `https://${m.domain}`;
|
||||
}
|
||||
|
||||
interface AppSwitcherProps {
|
||||
current?: string;
|
||||
}
|
||||
|
||||
export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', handleClick);
|
||||
return () => document.removeEventListener('click', handleClick);
|
||||
}, []);
|
||||
|
||||
// Fetch current user's username for subdomain links
|
||||
useEffect(() => {
|
||||
fetch('/api/me')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.authenticated && data.user?.username) {
|
||||
setUsername(data.user.username);
|
||||
}
|
||||
})
|
||||
.catch(() => { /* not logged in */ });
|
||||
}, []);
|
||||
|
||||
const currentMod = MODULES.find((m) => m.id === current);
|
||||
|
||||
// Group modules by category
|
||||
const groups = new Map<string, AppModule[]>();
|
||||
for (const m of MODULES) {
|
||||
const cat = MODULE_CATEGORIES[m.id] || 'Other';
|
||||
if (!groups.has(cat)) groups.set(cat, []);
|
||||
groups.get(cat)!.push(m);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm font-semibold bg-white/[0.08] hover:bg-white/[0.12] text-slate-200 transition-colors"
|
||||
>
|
||||
{currentMod && (
|
||||
<span className={`w-6 h-6 rounded-md ${currentMod.color} flex items-center justify-center text-[10px] font-black text-slate-900 leading-none flex-shrink-0`}>
|
||||
{currentMod.badge}
|
||||
</span>
|
||||
)}
|
||||
<span>{currentMod?.name || 'rStack'}</span>
|
||||
<span className="text-[0.7em] opacity-60">▾</span>
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 mt-1.5 w-[300px] max-h-[70vh] overflow-y-auto rounded-xl bg-slate-800 border border-white/10 shadow-xl shadow-black/30 z-[200]">
|
||||
{/* rStack header */}
|
||||
<div className="px-3.5 py-3 border-b border-white/[0.08] flex items-center gap-2.5">
|
||||
<span className="w-7 h-7 rounded-lg bg-gradient-to-br from-cyan-300 via-violet-300 to-rose-300 flex items-center justify-center text-[11px] font-black text-slate-900 leading-none">
|
||||
r*
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-white">rStack</div>
|
||||
<div className="text-[10px] text-slate-400">Self-hosted community app suite</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
{CATEGORY_ORDER.map((cat) => {
|
||||
const items = groups.get(cat);
|
||||
if (!items || items.length === 0) return null;
|
||||
return (
|
||||
<div key={cat}>
|
||||
<div className="px-3.5 pt-3 pb-1 text-[0.6rem] font-bold uppercase tracking-widest text-slate-500 select-none">
|
||||
{cat}
|
||||
</div>
|
||||
{items.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`flex items-center group ${
|
||||
m.id === current ? 'bg-white/[0.07]' : 'hover:bg-white/[0.04]'
|
||||
} transition-colors`}
|
||||
>
|
||||
<a
|
||||
href={getModuleUrl(m, username)}
|
||||
className="flex items-center gap-2.5 flex-1 px-3.5 py-2 text-slate-200 no-underline min-w-0"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{/* Pastel favicon badge */}
|
||||
<span className={`w-7 h-7 rounded-md ${m.color} flex items-center justify-center text-[10px] font-black text-slate-900 leading-none flex-shrink-0`}>
|
||||
{m.badge}
|
||||
</span>
|
||||
{/* Name + description */}
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-semibold">{m.name}</span>
|
||||
<span className="text-sm flex-shrink-0">{m.emoji}</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400 truncate">{m.description}</span>
|
||||
</div>
|
||||
</a>
|
||||
{m.domain && (
|
||||
<a
|
||||
href={`https://${m.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-8 flex items-center justify-center text-xs text-cyan-400 opacity-0 group-hover:opacity-50 hover:!opacity-100 transition-opacity flex-shrink-0"
|
||||
title={m.domain}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-3.5 py-2.5 border-t border-white/[0.08] text-center">
|
||||
<a
|
||||
href="https://rstack.online"
|
||||
className="text-[11px] text-slate-500 hover:text-cyan-400 transition-colors no-underline"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
rstack.online — self-hosted, community-run
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
export function AuthButton() {
|
||||
const { isAuthenticated, username, did, loading, login, register, logout } = useAuthStore();
|
||||
const [showRegister, setShowRegister] = useState(false);
|
||||
const [regUsername, setRegUsername] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm">
|
||||
<span className="text-white/60">Signed in as </span>
|
||||
<span className="text-primary font-medium">{username || did?.slice(0, 16) + '...'}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-xs text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showRegister) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={regUsername}
|
||||
onChange={(e) => setRegUsername(e.target.value)}
|
||||
placeholder="Choose a username"
|
||||
className="text-sm py-1 px-2 w-36 rounded-md bg-white/10 border border-white/10 text-white placeholder:text-white/30 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
maxLength={20}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && regUsername.trim()) {
|
||||
setError('');
|
||||
register(regUsername.trim()).catch((err: Error) => {
|
||||
setError(err.message || 'Registration failed');
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!regUsername.trim()) return;
|
||||
setError('');
|
||||
try {
|
||||
await register(regUsername.trim());
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Registration failed');
|
||||
}
|
||||
}}
|
||||
disabled={loading || !regUsername.trim()}
|
||||
className="text-sm py-1 px-3 bg-primary text-primary-foreground rounded-md font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? '...' : 'Register'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRegister(false)}
|
||||
className="text-xs text-white/40 hover:text-white/60"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{error && <span className="text-xs text-red-400">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
setError('');
|
||||
try {
|
||||
await login();
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'SecurityError' || e.name === 'AbortError')) {
|
||||
setShowRegister(true);
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : 'Sign in failed');
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
className="text-sm text-white/60 hover:text-primary transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-4 h-4">
|
||||
<circle cx={12} cy={10} r={3} />
|
||||
<path d="M12 13v8" />
|
||||
<path d="M9 18h6" />
|
||||
<circle cx={12} cy={10} r={7} />
|
||||
</svg>
|
||||
{loading ? 'Signing in...' : 'Sign in with Passkey'}
|
||||
</button>
|
||||
{error && <span className="text-xs text-red-400">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
'use client';
|
||||
|
||||
const FOOTER_LINKS = [
|
||||
{ name: 'rSpace', href: 'https://rspace.online' },
|
||||
{ name: 'rNotes', href: 'https://rnotes.online' },
|
||||
{ name: 'rPubs', href: 'https://rpubs.online' },
|
||||
{ name: 'rCal', href: 'https://rcal.online' },
|
||||
{ name: 'rTrips', href: 'https://rtrips.online' },
|
||||
{ name: 'rMaps', href: 'https://rmaps.online' },
|
||||
{ name: 'rChats', href: 'https://rchats.online' },
|
||||
{ name: 'rInbox', href: 'https://rinbox.online' },
|
||||
{ name: 'rMail', href: 'https://rmail.online' },
|
||||
{ name: 'rForum', href: 'https://rforum.online' },
|
||||
{ name: 'rChoices', href: 'https://rchoices.online' },
|
||||
{ name: 'rVote', href: 'https://rvote.online' },
|
||||
{ name: 'rFunds', href: 'https://rfunds.online' },
|
||||
{ name: 'rWallet', href: 'https://rwallet.online' },
|
||||
{ name: 'rCart', href: 'https://rcart.online' },
|
||||
{ name: 'rAuctions', href: 'https://rauctions.online' },
|
||||
{ name: 'rSwag', href: 'https://rswag.online' },
|
||||
{ name: 'rPhotos', href: 'https://rphotos.online' },
|
||||
{ name: 'rTube', href: 'https://rtube.online' },
|
||||
{ name: 'rNetwork', href: 'https://rnetwork.online' },
|
||||
{ name: 'rSocials', href: 'https://rsocials.online' },
|
||||
{ name: 'rFiles', href: 'https://rfiles.online' },
|
||||
{ name: 'rData', href: 'https://rdata.online' },
|
||||
{ name: 'rWork', href: 'https://rwork.online' },
|
||||
{ name: 'rIDs', href: 'https://ridentity.online' },
|
||||
{ name: 'rStack', href: 'https://rstack.online' },
|
||||
];
|
||||
|
||||
interface EcosystemFooterProps {
|
||||
current?: string;
|
||||
}
|
||||
|
||||
export function EcosystemFooter({ current }: EcosystemFooterProps) {
|
||||
return (
|
||||
<footer className="border-t border-slate-800 py-8">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-sm text-slate-500 mb-4">
|
||||
<span className="font-medium text-slate-400">r* Ecosystem</span>
|
||||
{FOOTER_LINKS.map((link) => (
|
||||
<a
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
className={`hover:text-slate-300 transition-colors ${
|
||||
current && link.name.toLowerCase() === current.toLowerCase()
|
||||
? 'font-medium text-slate-300'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-center text-xs text-slate-600">
|
||||
Part of the <a href="https://rstack.online" className="hover:text-slate-400 transition-colors">r* ecosystem</a> — open source, self-hosted, community-owned
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { AppSwitcher } from '@/components/AppSwitcher';
|
||||
import { SpaceSwitcher } from '@/components/SpaceSwitcher';
|
||||
import { AuthButton } from '@/components/AuthButton';
|
||||
|
||||
interface HeaderBarProps {
|
||||
name: string;
|
||||
logoUrl: string | null;
|
||||
}
|
||||
|
||||
export function HeaderBar({ name, logoUrl }: HeaderBarProps) {
|
||||
return (
|
||||
<header className="border-b border-slate-800 sticky top-0 z-50 bg-background/90 backdrop-blur-sm">
|
||||
<div className="max-w-6xl mx-auto px-4 py-2.5 flex items-center justify-between gap-3">
|
||||
{/* Left: App switcher + Space switcher + Logo */}
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<AppSwitcher current="swag" />
|
||||
<SpaceSwitcher />
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 font-bold text-lg ml-1"
|
||||
>
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="h-7 w-7 rounded" />
|
||||
) : (
|
||||
<div className="h-7 w-7 bg-gradient-to-br from-cyan-300 to-amber-300 rounded-lg flex items-center justify-center text-slate-900 text-[10px] font-black leading-none">
|
||||
rSw
|
||||
</div>
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
<span className="text-primary">r</span>
|
||||
{name === 'rSwag' ? 'Swag' : name}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Center: Nav links */}
|
||||
<nav className="flex items-center gap-1 sm:gap-2">
|
||||
<Link
|
||||
href="/design"
|
||||
className="text-sm text-slate-300 hover:text-white transition-colors px-2.5 py-1.5 rounded-md hover:bg-white/[0.06] hidden sm:inline-flex"
|
||||
>
|
||||
Design
|
||||
</Link>
|
||||
<Link
|
||||
href="/upload"
|
||||
className="text-sm text-slate-300 hover:text-white transition-colors px-2.5 py-1.5 rounded-md hover:bg-white/[0.06]"
|
||||
>
|
||||
Upload
|
||||
</Link>
|
||||
<Link
|
||||
href="/products"
|
||||
className="text-sm px-4 py-1.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Shop
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Right: Auth + Cart */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden sm:block">
|
||||
<AuthButton />
|
||||
</div>
|
||||
<Link
|
||||
href="/cart"
|
||||
className="text-slate-300 hover:text-white transition-colors p-2 rounded-md hover:bg-white/[0.06]"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 00-16.536-1.84M7.5 14.25L5.106 5.272M6 20.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm12.75 0a.75.75 0 11-1.5 0 .75.75 0 011.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
interface FlowSplits {
|
||||
printer: number;
|
||||
creator: number;
|
||||
community: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SALE_PRICE = 29.99;
|
||||
const DEFAULT_PRODUCTION_COST = 9.25;
|
||||
|
||||
// Min production cost as fraction of price (Printful cost floor)
|
||||
const MIN_PRINTER_FRACTION = 0.15;
|
||||
|
||||
export function RevenueFlowSankey() {
|
||||
const [salePrice] = useState(DEFAULT_SALE_PRICE);
|
||||
const [splits, setSplits] = useState<FlowSplits>(() => {
|
||||
const printer = DEFAULT_PRODUCTION_COST / DEFAULT_SALE_PRICE;
|
||||
const remaining = 1 - printer;
|
||||
return {
|
||||
printer,
|
||||
creator: remaining * 0.35,
|
||||
community: remaining * 0.65,
|
||||
};
|
||||
});
|
||||
|
||||
const handleSplitChange = useCallback(
|
||||
(key: keyof FlowSplits, newValue: number) => {
|
||||
setSplits((prev) => {
|
||||
const updated = { ...prev, [key]: newValue };
|
||||
|
||||
// Enforce minimum printer cost
|
||||
if (updated.printer < MIN_PRINTER_FRACTION) {
|
||||
updated.printer = MIN_PRINTER_FRACTION;
|
||||
}
|
||||
|
||||
// Normalize so all splits sum to 1
|
||||
const total = updated.printer + updated.creator + updated.community;
|
||||
if (total === 0) return prev;
|
||||
|
||||
return {
|
||||
printer: updated.printer / total,
|
||||
creator: updated.creator / total,
|
||||
community: updated.community / total,
|
||||
};
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const printerAmount = salePrice * splits.printer;
|
||||
const creatorAmount = salePrice * splits.creator;
|
||||
const communityAmount = salePrice * splits.community;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* SVG Sankey Diagram */}
|
||||
<div className="w-full overflow-hidden">
|
||||
<svg
|
||||
viewBox="0 0 700 320"
|
||||
className="w-full h-auto max-w-2xl mx-auto"
|
||||
role="img"
|
||||
aria-label="Revenue flow diagram showing how sale price splits between printer, creator, and community"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="flowPrinter" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.9" />
|
||||
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.7" />
|
||||
</linearGradient>
|
||||
<linearGradient id="flowCreator" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.9" />
|
||||
<stop offset="100%" stopColor="#a855f7" stopOpacity="0.7" />
|
||||
</linearGradient>
|
||||
<linearGradient id="flowCommunity" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.9" />
|
||||
<stop offset="100%" stopColor="#10b981" stopOpacity="0.7" />
|
||||
</linearGradient>
|
||||
{/* Glow filters */}
|
||||
<filter id="glowGreen">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* ── Source node: Sale ── */}
|
||||
<rect
|
||||
x="30"
|
||||
y="110"
|
||||
width="90"
|
||||
height="100"
|
||||
rx="8"
|
||||
className="fill-primary/20 stroke-primary"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
x="75"
|
||||
y="150"
|
||||
textAnchor="middle"
|
||||
className="fill-foreground text-sm font-bold"
|
||||
style={{ fontSize: "14px" }}
|
||||
>
|
||||
Sale
|
||||
</text>
|
||||
<text
|
||||
x="75"
|
||||
y="175"
|
||||
textAnchor="middle"
|
||||
className="fill-primary"
|
||||
style={{ fontSize: "18px", fontWeight: 700 }}
|
||||
>
|
||||
${salePrice.toFixed(2)}
|
||||
</text>
|
||||
|
||||
{/* ── Flow paths (Bezier curves) ── */}
|
||||
<SankeyFlow
|
||||
startX={120}
|
||||
startY={135}
|
||||
endX={480}
|
||||
endY={60}
|
||||
width={splits.printer * 80 + 4}
|
||||
gradient="url(#flowPrinter)"
|
||||
/>
|
||||
<SankeyFlow
|
||||
startX={120}
|
||||
startY={160}
|
||||
endX={480}
|
||||
endY={160}
|
||||
width={splits.creator * 80 + 4}
|
||||
gradient="url(#flowCreator)"
|
||||
/>
|
||||
<SankeyFlow
|
||||
startX={120}
|
||||
startY={185}
|
||||
endX={480}
|
||||
endY={260}
|
||||
width={splits.community * 80 + 4}
|
||||
gradient="url(#flowCommunity)"
|
||||
/>
|
||||
|
||||
{/* ── Target nodes ── */}
|
||||
{/* Printer */}
|
||||
<rect x="480" y="25" width="190" height="70" rx="8" fill="#3b82f620" stroke="#3b82f6" strokeWidth="1.5" />
|
||||
<circle cx="505" cy="50" r="10" fill="#3b82f6" />
|
||||
<text x="503" y="54" textAnchor="middle" fill="white" style={{ fontSize: "11px" }}>P</text>
|
||||
<text x="525" y="50" className="fill-foreground" style={{ fontSize: "13px", fontWeight: 600 }} dominantBaseline="middle">
|
||||
Printer
|
||||
</text>
|
||||
<text x="525" y="72" fill="#3b82f6" style={{ fontSize: "16px", fontWeight: 700 }}>
|
||||
${printerAmount.toFixed(2)}
|
||||
</text>
|
||||
<text x="605" y="72" className="fill-muted-foreground" style={{ fontSize: "11px" }}>
|
||||
({(splits.printer * 100).toFixed(0)}%)
|
||||
</text>
|
||||
|
||||
{/* Creator */}
|
||||
<rect x="480" y="125" width="190" height="70" rx="8" fill="#a855f720" stroke="#a855f7" strokeWidth="1.5" />
|
||||
<circle cx="505" cy="150" r="10" fill="#a855f7" />
|
||||
<text x="503" y="154" textAnchor="middle" fill="white" style={{ fontSize: "11px" }}>C</text>
|
||||
<text x="525" y="150" className="fill-foreground" style={{ fontSize: "13px", fontWeight: 600 }} dominantBaseline="middle">
|
||||
Creator
|
||||
</text>
|
||||
<text x="525" y="172" fill="#a855f7" style={{ fontSize: "16px", fontWeight: 700 }}>
|
||||
${creatorAmount.toFixed(2)}
|
||||
</text>
|
||||
<text x="605" y="172" className="fill-muted-foreground" style={{ fontSize: "11px" }}>
|
||||
({(splits.creator * 100).toFixed(0)}%)
|
||||
</text>
|
||||
|
||||
{/* Community */}
|
||||
<rect x="480" y="225" width="190" height="70" rx="8" fill="#10b98120" stroke="#10b981" strokeWidth="1.5" />
|
||||
<circle cx="505" cy="250" r="10" fill="#10b981" filter="url(#glowGreen)" />
|
||||
<text x="503" y="254" textAnchor="middle" fill="white" style={{ fontSize: "11px" }}>$</text>
|
||||
<text x="525" y="250" className="fill-foreground" style={{ fontSize: "13px", fontWeight: 600 }} dominantBaseline="middle">
|
||||
Community
|
||||
</text>
|
||||
<text x="525" y="272" fill="#10b981" style={{ fontSize: "16px", fontWeight: 700 }}>
|
||||
${communityAmount.toFixed(2)}
|
||||
</text>
|
||||
<text x="612" y="272" className="fill-muted-foreground" style={{ fontSize: "11px" }}>
|
||||
({(splits.community * 100).toFixed(0)}%)
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* ── Interactive Sliders ── */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 max-w-2xl mx-auto">
|
||||
<FlowSlider
|
||||
label="Printer"
|
||||
sublabel="Production"
|
||||
value={splits.printer}
|
||||
amount={printerAmount}
|
||||
color="#3b82f6"
|
||||
onChange={(v) => handleSplitChange("printer", v)}
|
||||
/>
|
||||
<FlowSlider
|
||||
label="Creator"
|
||||
sublabel="Design Margin"
|
||||
value={splits.creator}
|
||||
amount={creatorAmount}
|
||||
color="#a855f7"
|
||||
onChange={(v) => handleSplitChange("creator", v)}
|
||||
/>
|
||||
<FlowSlider
|
||||
label="Community"
|
||||
sublabel="Revenue Fund"
|
||||
value={splits.community}
|
||||
amount={communityAmount}
|
||||
color="#10b981"
|
||||
onChange={(v) => handleSplitChange("community", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground max-w-lg mx-auto">
|
||||
Drag the sliders to see how revenue flows between production, creator, and
|
||||
community. The community sets its own margin — every dollar above production
|
||||
cost funds collective work.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Bezier flow path ── */
|
||||
function SankeyFlow({
|
||||
startX,
|
||||
startY,
|
||||
endX,
|
||||
endY,
|
||||
width,
|
||||
gradient,
|
||||
}: {
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
width: number;
|
||||
gradient: string;
|
||||
}) {
|
||||
const midX = (startX + endX) / 2;
|
||||
const d = `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${endX} ${endY}`;
|
||||
|
||||
return (
|
||||
<path
|
||||
d={d}
|
||||
stroke={gradient}
|
||||
strokeWidth={Math.max(width, 2)}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
opacity="0.8"
|
||||
style={{ transition: "stroke-width 0.3s ease, d 0.3s ease" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Slider for a single flow channel ── */
|
||||
function FlowSlider({
|
||||
label,
|
||||
sublabel,
|
||||
value,
|
||||
amount,
|
||||
color,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
sublabel: string;
|
||||
value: number;
|
||||
amount: number;
|
||||
color: string;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-semibold" style={{ color }}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
{sublabel}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold tabular-nums" style={{ color }}>
|
||||
${amount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={Math.round(value * 100)}
|
||||
onChange={(e) => onChange(Number(e.target.value) / 100)}
|
||||
className="w-full h-2 rounded-full appearance-none cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${color} ${value * 100}%, hsl(var(--muted)) ${value * 100}%)`,
|
||||
accentColor: color,
|
||||
}}
|
||||
aria-label={`${label} share: ${(value * 100).toFixed(0)}%`}
|
||||
/>
|
||||
|
||||
<div className="text-center text-xs font-medium text-muted-foreground tabular-nums">
|
||||
{(value * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface SpaceInfo {
|
||||
slug: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface SpaceSwitcherProps {
|
||||
/** Current app domain, e.g. 'rswag.online'. Space links become <space>.<domain> */
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
export function SpaceSwitcher({ domain }: SpaceSwitcherProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [spaces, setSpaces] = useState<SpaceInfo[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Derive domain from window.location if not provided
|
||||
const appDomain = domain || (typeof window !== 'undefined'
|
||||
? window.location.hostname.split('.').slice(-2).join('.')
|
||||
: 'rspace.online');
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', handleClick);
|
||||
return () => document.removeEventListener('click', handleClick);
|
||||
}, []);
|
||||
|
||||
// Check auth status on mount
|
||||
useEffect(() => {
|
||||
fetch('/api/me')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.authenticated) setIsAuthenticated(true);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const loadSpaces = async () => {
|
||||
if (loaded) return;
|
||||
try {
|
||||
const res = await fetch('/api/spaces');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSpaces(data.spaces || []);
|
||||
}
|
||||
} catch {
|
||||
// API not available
|
||||
}
|
||||
setLoaded(true);
|
||||
};
|
||||
|
||||
const handleOpen = async () => {
|
||||
const nowOpen = !open;
|
||||
setOpen(nowOpen);
|
||||
if (nowOpen && !loaded) {
|
||||
await loadSpaces();
|
||||
}
|
||||
};
|
||||
|
||||
/** Build URL for a space: <space>.<current-app-domain> */
|
||||
const spaceUrl = (slug: string) => `https://${slug}.${appDomain}`;
|
||||
|
||||
const mySpaces = spaces.filter((s) => s.role);
|
||||
const publicSpaces = spaces.filter((s) => !s.role);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleOpen(); }}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium text-slate-400 hover:bg-white/[0.05] transition-colors"
|
||||
>
|
||||
<span className="opacity-40 font-light mr-0.5">/</span>
|
||||
<span className="max-w-[160px] truncate">personal</span>
|
||||
<span className="text-[0.7em] opacity-50">▾</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 mt-1.5 min-w-[240px] max-h-[400px] overflow-y-auto rounded-xl bg-slate-800 border border-white/10 shadow-xl shadow-black/30 z-[200]">
|
||||
{!loaded ? (
|
||||
<div className="px-4 py-4 text-center text-sm text-slate-400">Loading spaces...</div>
|
||||
) : spaces.length === 0 ? (
|
||||
<>
|
||||
<div className="px-4 py-4 text-center text-sm text-slate-400">
|
||||
{isAuthenticated ? 'No spaces yet' : 'Sign in to see your spaces'}
|
||||
</div>
|
||||
<a
|
||||
href="https://rspace.online/new"
|
||||
className="flex items-center px-3.5 py-2.5 text-sm font-semibold text-cyan-400 hover:bg-cyan-500/[0.08] transition-colors no-underline"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
+ Create new space
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{mySpaces.length > 0 && (
|
||||
<>
|
||||
<div className="px-3.5 pt-2.5 pb-1 text-[0.65rem] font-bold uppercase tracking-wider text-slate-500 select-none">
|
||||
Your spaces
|
||||
</div>
|
||||
{mySpaces.map((s) => (
|
||||
<a
|
||||
key={s.slug}
|
||||
href={spaceUrl(s.slug)}
|
||||
className="flex items-center gap-2.5 px-3.5 py-2.5 text-slate-200 no-underline transition-colors hover:bg-white/[0.05]"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<span className="text-base">{s.icon || '🌐'}</span>
|
||||
<span className="text-sm font-medium flex-1">{s.name}</span>
|
||||
{s.role && (
|
||||
<span className="text-[0.6rem] font-bold uppercase bg-cyan-500/15 text-cyan-300 px-1.5 py-0.5 rounded tracking-wide">
|
||||
{s.role}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{publicSpaces.length > 0 && (
|
||||
<>
|
||||
{mySpaces.length > 0 && <div className="h-px bg-white/[0.08] my-1" />}
|
||||
<div className="px-3.5 pt-2.5 pb-1 text-[0.65rem] font-bold uppercase tracking-wider text-slate-500 select-none">
|
||||
Public spaces
|
||||
</div>
|
||||
{publicSpaces.map((s) => (
|
||||
<a
|
||||
key={s.slug}
|
||||
href={spaceUrl(s.slug)}
|
||||
className="flex items-center gap-2.5 px-3.5 py-2.5 text-slate-200 no-underline transition-colors hover:bg-white/[0.05]"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<span className="text-base">{s.icon || '🌐'}</span>
|
||||
<span className="text-sm font-medium flex-1">{s.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="h-px bg-white/[0.08] my-1" />
|
||||
<a
|
||||
href="https://rspace.online/new"
|
||||
className="flex items-center px-3.5 py-2.5 text-sm font-semibold text-cyan-400 hover:bg-cyan-500/[0.08] transition-colors no-underline"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
+ Create new space
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
/** Client-side Canvas mockup compositing for design previews. */
|
||||
|
||||
export interface MockupConfig {
|
||||
template: string;
|
||||
designArea: { x: number; y: number; width: number; height: number };
|
||||
label: string;
|
||||
productType: string;
|
||||
price: number;
|
||||
blend?: "screen" | "normal";
|
||||
}
|
||||
|
||||
export const MOCKUP_CONFIGS: MockupConfig[] = [
|
||||
{
|
||||
template: "/mockups/shirt-template.png",
|
||||
designArea: { x: 262, y: 230, width: 500, height: 450 },
|
||||
label: "T-Shirt",
|
||||
productType: "shirt",
|
||||
price: 29.99,
|
||||
blend: "screen",
|
||||
},
|
||||
{
|
||||
template: "/mockups/sticker-template.png",
|
||||
designArea: { x: 270, y: 210, width: 470, height: 530 },
|
||||
label: "Sticker",
|
||||
productType: "sticker",
|
||||
price: 3.50,
|
||||
blend: "normal",
|
||||
},
|
||||
{
|
||||
template: "/mockups/print-template.png",
|
||||
designArea: { x: 225, y: 225, width: 575, height: 500 },
|
||||
label: "Art Print",
|
||||
productType: "print",
|
||||
price: 12.99,
|
||||
blend: "normal",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Composite a design image onto a photorealistic product template.
|
||||
* For shirts: uses screen blending so designs look printed on fabric.
|
||||
* For stickers/prints: direct paste into the blank area.
|
||||
*/
|
||||
export function generateMockup(
|
||||
designDataUrl: string,
|
||||
config: MockupConfig
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 1024;
|
||||
canvas.height = 1024;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return reject(new Error("Canvas not supported"));
|
||||
|
||||
const templateImg = new window.Image();
|
||||
const designImg = new window.Image();
|
||||
|
||||
templateImg.crossOrigin = "anonymous";
|
||||
designImg.crossOrigin = "anonymous";
|
||||
|
||||
let loaded = 0;
|
||||
const onBothLoaded = () => {
|
||||
loaded++;
|
||||
if (loaded < 2) return;
|
||||
|
||||
// Draw photorealistic template as base
|
||||
ctx.drawImage(templateImg, 0, 0, 1024, 1024);
|
||||
|
||||
const { x, y, width, height } = config.designArea;
|
||||
|
||||
// Maintain aspect ratio within the bounding box
|
||||
const scale = Math.min(width / designImg.width, height / designImg.height);
|
||||
const dw = designImg.width * scale;
|
||||
const dh = designImg.height * scale;
|
||||
const dx = x + (width - dw) / 2;
|
||||
const dy = y + (height - dh) / 2;
|
||||
|
||||
if (config.blend === "screen") {
|
||||
// Screen blend: makes light colors on dark fabric look printed
|
||||
ctx.globalCompositeOperation = "screen";
|
||||
}
|
||||
|
||||
ctx.drawImage(designImg, dx, dy, dw, dh);
|
||||
|
||||
// Reset composite operation
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
|
||||
resolve(canvas.toDataURL("image/png"));
|
||||
};
|
||||
|
||||
templateImg.onload = onBothLoaded;
|
||||
designImg.onload = onBothLoaded;
|
||||
templateImg.onerror = () => reject(new Error(`Failed to load template: ${config.template}`));
|
||||
designImg.onerror = () => reject(new Error("Failed to load design image"));
|
||||
|
||||
templateImg.src = config.template;
|
||||
designImg.src = designDataUrl;
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
export interface SpaceTheme {
|
||||
primary: string;
|
||||
primary_foreground: string;
|
||||
secondary: string;
|
||||
secondary_foreground: string;
|
||||
background: string;
|
||||
foreground: string;
|
||||
card: string;
|
||||
card_foreground: string;
|
||||
popover: string;
|
||||
popover_foreground: string;
|
||||
muted: string;
|
||||
muted_foreground: string;
|
||||
accent: string;
|
||||
accent_foreground: string;
|
||||
destructive: string;
|
||||
destructive_foreground: string;
|
||||
border: string;
|
||||
input: string;
|
||||
ring: string;
|
||||
}
|
||||
|
||||
export interface SpaceConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
tagline: string;
|
||||
description: string;
|
||||
domain: string;
|
||||
footer_text: string;
|
||||
theme: SpaceTheme;
|
||||
design_filter: string;
|
||||
logo_url: string | null;
|
||||
design_tips: string[];
|
||||
}
|
||||
|
||||
const THEME_VAR_MAP: Record<keyof SpaceTheme, string> = {
|
||||
primary: "--primary",
|
||||
primary_foreground: "--primary-foreground",
|
||||
secondary: "--secondary",
|
||||
secondary_foreground: "--secondary-foreground",
|
||||
background: "--background",
|
||||
foreground: "--foreground",
|
||||
card: "--card",
|
||||
card_foreground: "--card-foreground",
|
||||
popover: "--popover",
|
||||
popover_foreground: "--popover-foreground",
|
||||
muted: "--muted",
|
||||
muted_foreground: "--muted-foreground",
|
||||
accent: "--accent",
|
||||
accent_foreground: "--accent-foreground",
|
||||
destructive: "--destructive",
|
||||
destructive_foreground: "--destructive-foreground",
|
||||
border: "--border",
|
||||
input: "--input",
|
||||
ring: "--ring",
|
||||
};
|
||||
|
||||
export function themeToCSS(theme: SpaceTheme): string {
|
||||
return Object.entries(THEME_VAR_MAP)
|
||||
.filter(([key]) => theme[key as keyof SpaceTheme])
|
||||
.map(([key, cssVar]) => `${cssVar}: ${theme[key as keyof SpaceTheme]};`)
|
||||
.join("\n ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the localStorage key for the cart, scoped by space.
|
||||
*/
|
||||
export function getCartKey(spaceId?: string): string {
|
||||
if (!spaceId || spaceId === "default") return "cart_id";
|
||||
return `cart_id_${spaceId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read space_id from document cookie (client-side only).
|
||||
*/
|
||||
export function getSpaceIdFromCookie(): string {
|
||||
if (typeof document === "undefined") return "default";
|
||||
const match = document.cookie.match(/(?:^|;\s*)space_id=([^;]*)/);
|
||||
return match ? match[1] : "default";
|
||||
}
|
||||
|
|
@ -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,39 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const host = request.headers.get("host") || "";
|
||||
const hostname = host.split(":")[0].toLowerCase();
|
||||
|
||||
// Determine space from subdomain or custom domain
|
||||
let spaceId = "default";
|
||||
if (hostname.endsWith(".rswag.online")) {
|
||||
spaceId = hostname.replace(".rswag.online", "");
|
||||
} else if (hostname === "fungiswag.jeffemmett.com") {
|
||||
spaceId = "fungiflows";
|
||||
}
|
||||
// Local dev: check for space query param as override
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1") {
|
||||
const url = new URL(request.url);
|
||||
const spaceParam = url.searchParams.get("_space");
|
||||
if (spaceParam) {
|
||||
spaceId = spaceParam;
|
||||
}
|
||||
}
|
||||
|
||||
const response = NextResponse.next();
|
||||
|
||||
// Set cookie so both server and client components can read the space
|
||||
response.cookies.set("space_id", spaceId, {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
httpOnly: false,
|
||||
maxAge: 86400,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue