feat: add Prodigi POD client + server-side product mockup previews

- Prodigi v4 API client for order fulfillment (create/get orders, quotes)
- Server-side mockup generation: Pillow composites designs onto product
  templates (shirt, sticker, print) at GET /api/designs/{slug}/mockup
- Product listing and detail pages now show designs ON products
- Mockup type switcher on product detail page (T-Shirt/Sticker/Art Print)
- Order service submits to Prodigi after successful payment
- POD webhook endpoint for fulfillment status updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-18 13:29:47 -07:00
parent b437709510
commit acfe6cc350
11 changed files with 372 additions and 102 deletions

View File

@ -1,15 +1,38 @@
"""Designs API endpoints."""
import io
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
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
router = APIRouter()
design_service = DesignService()
settings = get_settings()
# Mockup template configs: product_type → (template path, design bounding box)
MOCKUP_TEMPLATES = {
"shirt": {
"template": "shirt-template.png",
"design_box": (275, 300, 250, 250), # x, y, w, h on 800x800 canvas
},
"sticker": {
"template": "sticker-template.png",
"design_box": (130, 130, 540, 540),
},
"print": {
"template": "print-template.png",
"design_box": (160, 160, 480, 480),
},
}
# Cache generated mockups in memory: (slug, product_type) → PNG bytes
_mockup_cache: dict[tuple[str, str], bytes] = {}
@router.get("", response_model=list[Design])
@ -46,3 +69,74 @@ async def get_design_image(slug: str):
"Cache-Control": "public, max-age=86400", # Cache for 24 hours
},
)
@router.get("/{slug}/mockup")
async def get_design_mockup(slug: str, type: str = "shirt"):
"""Serve the design composited onto a product mockup template.
Composites the design image onto a product template (shirt, sticker, print)
using Pillow. Result is cached in memory for fast subsequent requests.
Query params:
type: Product type "shirt", "sticker", or "print" (default: shirt)
"""
cache_key = (slug, type)
if cache_key in _mockup_cache:
return StreamingResponse(
io.BytesIO(_mockup_cache[cache_key]),
media_type="image/png",
headers={"Cache-Control": "public, max-age=86400"},
)
template_config = MOCKUP_TEMPLATES.get(type)
if not template_config:
raise HTTPException(status_code=400, detail=f"Unknown product type: {type}")
# Load 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="Design image not found")
# Load template image from frontend/public/mockups/
template_dir = Path(__file__).resolve().parents[3] / "frontend" / "public" / "mockups"
template_path = template_dir / template_config["template"]
# Fallback: check if templates are mounted at /app/frontend/public/mockups/
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")
# Composite design onto product template
canvas = Image.new("RGBA", (800, 800), (0, 0, 0, 0))
design_img = Image.open(image_path).convert("RGBA")
template_img = Image.open(str(template_path)).convert("RGBA")
# 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)
# Draw design first (underneath), then template on top
canvas.paste(design_resized, (dx, dy), design_resized)
canvas.paste(template_img, (0, 0), template_img)
# Export to PNG bytes
buf = io.BytesIO()
canvas.save(buf, format="PNG", optimize=True)
png_bytes = buf.getvalue()
# Cache the result
_mockup_cache[cache_key] = png_bytes
return StreamingResponse(
io.BytesIO(png_bytes),
media_type="image/png",
headers={"Cache-Control": "public, max-age=86400"},
)

View File

View File

@ -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()

View File

@ -14,6 +14,7 @@ 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.prodigi_client import ProdigiClient
logger = logging.getLogger(__name__)
settings = get_settings()
@ -151,7 +152,9 @@ class OrderService:
# Route revenue margin to TBFF flow → bonding curve
await self._deposit_revenue_to_flow(order)
# TODO: Submit to POD providers
# Submit to POD providers
await self._submit_to_pod(order)
# TODO: Send confirmation email
async def update_pod_status(
@ -177,6 +180,73 @@ class OrderService:
)
await self.db.commit()
async def _submit_to_pod(self, order: Order):
"""Submit order items to Prodigi for fulfillment.
Groups items by POD provider and submits orders.
Design images are served via public URL for Prodigi to download.
"""
prodigi = ProdigiClient()
if not prodigi.enabled:
logger.info("Prodigi not configured, skipping POD submission")
return
# Need shipping address for POD — skip if not available
if not order.shipping_address_line1:
logger.info(f"Order {order.id} has no shipping address, skipping POD")
return
# Collect Prodigi items from order
prodigi_items = []
for item in order.items:
# Build public image URL for Prodigi to download
# TODO: Use CDN URL in production; for now use the API endpoint
image_url = f"https://fungiswag.jeffemmett.com/api/designs/{item.product_slug}/image"
prodigi_items.append({
"sku": item.variant or item.product_slug,
"copies": item.quantity,
"sizing": "fillPrintArea",
"assets": [{"printArea": "default", "url": image_url}],
})
if not prodigi_items:
return
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")
# Update order items with Prodigi order ID
for item in order.items:
item.pod_provider = "prodigi"
item.pod_order_id = pod_order_id
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.

View File

@ -17,6 +17,7 @@ services:
- ./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

View File

@ -56,6 +56,7 @@ services:
- ./designs:/app/designs
- ./config:/app/config:ro
- ./spaces:/app/spaces:ro
- ./frontend/public/mockups:/app/mockups:ro
depends_on:
db:
condition: service_healthy

View File

@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useParams } from "next/navigation";
import Link from "next/link";
import { getSpaceIdFromCookie, getCartKey } from "@/lib/spaces";
@ -27,15 +27,28 @@ interface Product {
is_active: boolean;
}
const MOCKUP_TYPES = [
{ type: "shirt", label: "T-Shirt" },
{ type: "sticker", label: "Sticker" },
{ type: "print", label: "Art Print" },
];
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 router = useRouter();
const slug = params.slug as string;
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
const [selectedMockup, setSelectedMockup] = useState<string>("shirt");
const [quantity, setQuantity] = useState(1);
const [addingToCart, setAddingToCart] = useState(false);
const [addedToCart, setAddedToCart] = useState(false);
@ -45,47 +58,32 @@ export default function ProductPage() {
try {
const res = await fetch(`${API_URL}/products/${slug}`);
if (!res.ok) {
if (res.status === 404) {
setError("Product not found");
} else {
setError("Failed to load product");
}
setError(res.status === 404 ? "Product not found" : "Failed to load product");
return;
}
const data = await res.json();
setProduct(data);
if (data.variants && data.variants.length > 0) {
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();
}
if (slug) fetchProduct();
}, [slug]);
const getOrCreateCart = async (): Promise<string | null> => {
// Check for existing cart in localStorage
let cartId = localStorage.getItem(getCartKey(getSpaceIdFromCookie()));
if (cartId) {
// Verify cart still exists
try {
const res = await fetch(`${API_URL}/cart/${cartId}`);
if (res.ok) {
return cartId;
}
} catch {
// Cart doesn't exist, create new one
}
if (res.ok) return cartId;
} catch { /* cart expired */ }
}
// Create new cart
try {
const res = await fetch(`${API_URL}/cart`, {
method: "POST",
@ -97,33 +95,24 @@ export default function ProductPage() {
localStorage.setItem(getCartKey(getSpaceIdFromCookie()), cartId!);
return cartId;
}
} catch {
return null;
}
} 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;
}
if (!cartId) { alert("Failed to create cart"); return; }
const res = await fetch(`${API_URL}/cart/${cartId}/items`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
product_slug: product.slug,
variant_sku: selectedVariant.sku,
quantity: quantity,
quantity,
}),
});
@ -134,11 +123,8 @@ export default function ProductPage() {
const data = await res.json();
alert(data.detail || "Failed to add to cart");
}
} catch {
alert("Failed to add to cart");
} finally {
setAddingToCart(false);
}
} catch { alert("Failed to add to cart"); }
finally { setAddingToCart(false); }
};
if (loading) {
@ -153,16 +139,9 @@ export default function ProductPage() {
if (error || !product) {
return (
<div className="container mx-auto px-4 py-16">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">{error || "Product not found"}</h1>
<Link
href="/products"
className="text-primary hover:underline"
>
Back to Products
</Link>
</div>
<div className="container mx-auto px-4 py-16 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>
);
}
@ -171,25 +150,40 @@ export default function ProductPage() {
<div className="container mx-auto px-4 py-8">
{/* Breadcrumb */}
<nav className="mb-8 text-sm">
<Link href="/" className="text-muted-foreground hover:text-primary">
Home
</Link>
<Link href="/" className="text-muted-foreground hover:text-primary">Home</Link>
<span className="mx-2 text-muted-foreground">/</span>
<Link href="/products" className="text-muted-foreground hover:text-primary">
Products
</Link>
<Link href="/products" className="text-muted-foreground hover:text-primary">Products</Link>
<span className="mx-2 text-muted-foreground">/</span>
<span className="text-foreground">{product.name}</span>
</nav>
<div className="grid md:grid-cols-2 gap-12">
{/* Product Image */}
<div className="aspect-square bg-muted rounded-lg overflow-hidden">
<img
src={`${API_URL}/designs/${product.slug}/image`}
alt={product.name}
className="w-full h-full object-cover"
/>
{/* Product Mockup Image */}
<div>
<div className="aspect-square bg-muted rounded-lg overflow-hidden mb-4">
<img
src={`${API_URL}/designs/${product.slug}/mockup?type=${selectedMockup}`}
alt={`${product.name} on ${selectedMockup}`}
className="w-full h-full object-cover"
/>
</div>
{/* Mockup type switcher — preview on different products */}
<div className="flex gap-2">
{MOCKUP_TYPES.map((mt) => (
<button
key={mt.type}
onClick={() => setSelectedMockup(mt.type)}
className={`flex-1 py-2 px-3 rounded-md border text-sm font-medium transition-colors ${
selectedMockup === mt.type
? "border-primary bg-primary/10 text-primary"
: "border-muted-foreground/30 hover:border-primary text-muted-foreground"
}`}
>
{mt.label}
</button>
))}
</div>
</div>
{/* Product Details */}
@ -201,7 +195,6 @@ export default function ProductPage() {
</div>
<h1 className="text-3xl font-bold mb-4">{product.name}</h1>
<p className="text-muted-foreground mb-6">{product.description}</p>
<div className="text-3xl font-bold mb-6">
@ -211,9 +204,7 @@ export default function ProductPage() {
{/* Variant Selection */}
{product.variants && product.variants.length > 1 && (
<div className="mb-6">
<label className="block text-sm font-medium mb-2">
Select Option
</label>
<label className="block text-sm font-medium mb-2">Select Option</label>
<div className="flex flex-wrap gap-2">
{product.variants.map((variant) => (
<button
@ -252,7 +243,7 @@ export default function ProductPage() {
</div>
</div>
{/* Add to Cart Button */}
{/* Add to Cart */}
<button
onClick={handleAddToCart}
disabled={addingToCart || !selectedVariant}
@ -264,24 +255,9 @@ export default function ProductPage() {
>
{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 className="animate-spin h-5 w-5" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Adding...
</span>
@ -297,28 +273,19 @@ export default function ProductPage() {
)}
</button>
{/* View Cart Link */}
{addedToCart && (
<Link
href="/cart"
className="block text-center mt-4 text-primary hover:underline"
>
<Link href="/cart" className="block text-center mt-4 text-primary hover:underline">
View Cart
</Link>
)}
{/* Tags */}
{product.tags && product.tags.length > 0 && (
{product.tags?.length > 0 && (
<div className="mt-8 pt-6 border-t">
<span className="text-sm text-muted-foreground">Tags: </span>
<div className="flex flex-wrap gap-2 mt-2">
{product.tags.map((tag) => (
<span
key={tag}
className="px-2 py-1 text-xs bg-muted rounded-full"
>
{tag}
</span>
<span key={tag} className="px-2 py-1 text-xs bg-muted rounded-full">{tag}</span>
))}
</div>
</div>

View File

@ -13,6 +13,14 @@ interface Product {
base_price: number;
}
// Map product types to mockup types
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();
@ -54,7 +62,7 @@ export default async function ProductsPage() {
<div className="border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
<div className="aspect-square bg-muted relative overflow-hidden">
<img
src={`${API_URL}/designs/${product.slug}/image`}
src={`${API_URL}/designs/${product.slug}/mockup?type=${getMockupType(product.product_type)}`}
alt={product.name}
className="object-cover w-full h-full group-hover:scale-105 transition-transform"
/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB