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:
parent
b437709510
commit
acfe6cc350
|
|
@ -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"},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 |
Loading…
Reference in New Issue