diff --git a/backend/app/api/designs.py b/backend/app/api/designs.py index 49e77ee..b3f421e 100644 --- a/backend/app/api/designs.py +++ b/backend/app/api/designs.py @@ -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"}, + ) diff --git a/backend/app/pod/__init__.py b/backend/app/pod/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/pod/prodigi_client.py b/backend/app/pod/prodigi_client.py new file mode 100644 index 0000000..46ad01a --- /dev/null +++ b/backend/app/pod/prodigi_client.py @@ -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() diff --git a/backend/app/services/order_service.py b/backend/app/services/order_service.py index 090f98a..dfd2a40 100644 --- a/backend/app/services/order_service.py +++ b/backend/app/services/order_service.py @@ -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. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 1c53a5e..839933f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index c2e8e4f..fabba59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/app/products/[slug]/page.tsx b/frontend/app/products/[slug]/page.tsx index e86734b..93f9b9b 100644 --- a/frontend/app/products/[slug]/page.tsx +++ b/frontend/app/products/[slug]/page.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedVariant, setSelectedVariant] = useState(null); + const [selectedMockup, setSelectedMockup] = useState("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 => { - // 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 ( -
-
-

{error || "Product not found"}

- - Back to Products - -
+
+

{error || "Product not found"}

+ Back to Products
); } @@ -171,25 +150,40 @@ export default function ProductPage() {
{/* Breadcrumb */}
- {/* Product Image */} -
- {product.name} + {/* Product Mockup Image */} +
+
+ {`${product.name} +
+ + {/* Mockup type switcher — preview on different products */} +
+ {MOCKUP_TYPES.map((mt) => ( + + ))} +
{/* Product Details */} @@ -201,7 +195,6 @@ export default function ProductPage() {

{product.name}

-

{product.description}

@@ -211,9 +204,7 @@ export default function ProductPage() { {/* Variant Selection */} {product.variants && product.variants.length > 1 && (
- +
{product.variants.map((variant) => (
- {/* Add to Cart Button */} + {/* Add to Cart */} - {/* View Cart Link */} {addedToCart && ( - + View Cart )} {/* Tags */} - {product.tags && product.tags.length > 0 && ( + {product.tags?.length > 0 && (
Tags:
{product.tags.map((tag) => ( - - {tag} - + {tag} ))}
diff --git a/frontend/app/products/page.tsx b/frontend/app/products/page.tsx index 5168e2f..7a8d4e9 100644 --- a/frontend/app/products/page.tsx +++ b/frontend/app/products/page.tsx @@ -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 { try { const params = new URLSearchParams(); @@ -54,7 +62,7 @@ export default async function ProductsPage() {
{product.name} diff --git a/frontend/public/mockups/print-template.png b/frontend/public/mockups/print-template.png index 1c5dc6b..044e4ff 100644 Binary files a/frontend/public/mockups/print-template.png and b/frontend/public/mockups/print-template.png differ diff --git a/frontend/public/mockups/shirt-template.png b/frontend/public/mockups/shirt-template.png index 9141640..22aa9e5 100644 Binary files a/frontend/public/mockups/shirt-template.png and b/frontend/public/mockups/shirt-template.png differ diff --git a/frontend/public/mockups/sticker-template.png b/frontend/public/mockups/sticker-template.png index 331961a..e5ccd84 100644 Binary files a/frontend/public/mockups/sticker-template.png and b/frontend/public/mockups/sticker-template.png differ