feat: photorealistic mockups and professional storefront

Backend:
- Replace tiny placeholder templates with photorealistic 1024x1024 product
  photos (blank t-shirt, sticker with peeling corner, framed print)
- Rewrite Pillow compositing: screen blend for dark garments (design looks
  printed on fabric), direct paste for stickers/prints
- Add PRINTFUL_STORE_ID config + X-PF-Store-Id header to Printful client
  (unblocks existing account-level tokens)

Frontend:
- Product listing: rounded cards with shadows, category badges, hover
  animations, lazy loading, empty state
- Product detail: skeleton loading, mockup type switcher with loading
  indicator, raw design preview, inline price in Add to Cart button,
  shipping/quality info section
- Homepage: featured products now show mockups instead of raw designs,
  professional card layout matching products page
- Client-side mockups: updated coordinates for new templates, screen
  blend support via Canvas globalCompositeOperation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-24 15:43:49 -08:00
parent 0624cdec6f
commit 067e14ed0c
11 changed files with 387 additions and 209 deletions

View File

@ -7,6 +7,7 @@ MOLLIE_API_KEY=test_xxx
# POD Providers
PRODIGI_API_KEY=xxx
PRINTFUL_API_TOKEN=xxx
PRINTFUL_STORE_ID=
POD_SANDBOX_MODE=true
# Auth

View File

@ -18,19 +18,23 @@ router = APIRouter()
design_service = DesignService()
settings = get_settings()
# Mockup template configs: product_type → (template path, design bounding box)
# 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": (275, 300, 250, 250), # x, y, w, h on 800x800 canvas
"design_box": (330, 310, 370, 370), # 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": (130, 130, 540, 540),
"design_box": (270, 210, 470, 530), # inside the white sticker area
"blend": "paste",
},
"print": {
"template": "print-template.png",
"design_box": (160, 160, 480, 480),
"design_box": (225, 225, 575, 500), # inside the black frame
"blend": "paste",
},
}
@ -182,7 +186,9 @@ async def _get_printful_mockup(slug: str, product) -> bytes | None:
async def _pillow_mockup(slug: str, type: str) -> StreamingResponse:
"""Generate mockup using Pillow compositing with local templates."""
"""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}")
@ -199,10 +205,12 @@ async def _pillow_mockup(slug: str, type: str) -> StreamingResponse:
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))
# Load images
template_img = Image.open(str(template_path)).convert("RGB")
design_img = Image.open(image_path).convert("RGBA")
template_img = Image.open(str(template_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"]
@ -214,11 +222,36 @@ async def _pillow_mockup(slug: str, type: str) -> StreamingResponse:
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)
blend_mode = template_config.get("blend", "paste")
# Export to PNG bytes
if blend_mode == "screen":
# Screen blend: makes light designs appear printed on dark fabric.
# Formula: 1 - (1-base)(1-overlay). Preserves fabric texture.
design_rgb = design_resized.convert("RGB")
alpha = design_resized.split()[3] if design_resized.mode == "RGBA" else None
# 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)
# If design has alpha, composite using it as mask
if alpha:
# Convert alpha to 3-channel for masking
region_arr = region.copy()
blended_with_alpha = Image.composite(blended, region_arr, alpha)
canvas.paste(blended_with_alpha, (dx, dy))
else:
canvas.paste(blended, (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()

View File

@ -27,6 +27,7 @@ class Settings(BaseSettings):
# 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)

View File

@ -33,10 +33,13 @@ class PrintfulClient:
@property
def _headers(self) -> dict[str, str]:
return {
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 ──

View File

@ -84,7 +84,10 @@ export default async function HomePage() {
</div>
<div className="mt-24">
<h2 className="text-2xl font-bold text-center mb-12">Featured Products</h2>
<h2 className="text-2xl font-bold text-center mb-4">Featured Products</h2>
<p className="text-center text-muted-foreground mb-12">
Print-on-demand fulfilled by Printful, shipped worldwide.
</p>
{products.length === 0 ? (
<p className="text-center text-muted-foreground">
No products available yet. Check back soon!
@ -95,22 +98,40 @@ export default async function HomePage() {
<Link
key={product.slug}
href={`/products/${product.slug}`}
className="group"
className="group block"
>
<div className="border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
<div className="relative overflow-hidden rounded-xl border bg-card shadow-sm transition-all duration-300 hover:shadow-xl hover:-translate-y-1">
<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=${
product.product_type.includes("shirt") || product.product_type.includes("tee") ? "shirt"
: product.product_type.includes("sticker") ? "sticker"
: product.product_type.includes("print") ? "print"
: "shirt"
}`}
alt={product.name}
className="object-cover w-full h-full group-hover:scale-105 transition-transform"
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-4">
<h3 className="font-semibold">{product.name}</h3>
<p className="text-sm text-muted-foreground capitalize">
{product.product_type}
<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>
<p className="font-bold mt-2">${product.base_price.toFixed(2)}</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>

View File

@ -28,9 +28,9 @@ interface Product {
}
const MOCKUP_TYPES = [
{ type: "shirt", label: "T-Shirt" },
{ type: "sticker", label: "Sticker" },
{ type: "print", label: "Art Print" },
{ type: "shirt", label: "T-Shirt", icon: "👕" },
{ type: "sticker", label: "Sticker", icon: "🏷️" },
{ type: "print", label: "Art Print", icon: "🖼️" },
];
function getMockupType(productType: string): string {
@ -52,6 +52,7 @@ export default function ProductPage() {
const [quantity, setQuantity] = useState(1);
const [addingToCart, setAddingToCart] = useState(false);
const [addedToCart, setAddedToCart] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
useEffect(() => {
async function fetchProduct() {
@ -129,9 +130,20 @@ export default function ProductPage() {
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 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>
);
@ -139,157 +151,218 @@ export default function ProductPage() {
if (error || !product) {
return (
<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 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="container mx-auto px-4 py-8">
{/* Breadcrumb */}
<nav className="mb-8 text-sm">
<Link href="/" className="text-muted-foreground hover:text-primary">Home</Link>
<span className="mx-2 text-muted-foreground">/</span>
<Link href="/products" className="text-muted-foreground hover:text-primary">Products</Link>
<span className="mx-2 text-muted-foreground">/</span>
<span className="text-foreground">{product.name}</span>
</nav>
<div className="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">
{/* 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>
<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 — 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>
{/* 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>
{/* Product Details */}
<div>
<div className="mb-2">
<span className="text-sm text-muted-foreground capitalize">
{product.category} / {product.product_type}
</span>
</div>
<h1 className="text-3xl font-bold mb-4">{product.name}</h1>
<p className="text-muted-foreground mb-6">{product.description}</p>
<div className="text-3xl font-bold mb-6">
${selectedVariant?.price.toFixed(2) || product.base_price.toFixed(2)}
</div>
{/* Variant Selection */}
{product.variants && product.variants.length > 1 && (
<div className="mb-6">
<label className="block text-sm font-medium mb-2">Select Option</label>
<div className="flex flex-wrap gap-2">
{product.variants.map((variant) => (
<button
key={variant.sku}
onClick={() => setSelectedVariant(variant)}
className={`px-4 py-2 rounded-md border transition-colors ${
selectedVariant?.sku === variant.sku
? "border-primary bg-primary/10 text-primary"
: "border-muted-foreground/30 hover:border-primary"
}`}
>
{variant.name}
</button>
))}
{/* 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>
)}
{/* Quantity */}
<div className="mb-6">
<label className="block text-sm font-medium mb-2">Quantity</label>
<div className="flex items-center gap-2">
<button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
className="w-10 h-10 rounded-md border flex items-center justify-center hover:bg-muted transition-colors"
>
-
</button>
<span className="w-12 text-center font-medium">{quantity}</span>
<button
onClick={() => setQuantity(quantity + 1)}
className="w-10 h-10 rounded-md border flex items-center justify-center hover:bg-muted transition-colors"
>
+
</button>
</div>
</div>
{/* Add to Cart */}
<button
onClick={handleAddToCart}
disabled={addingToCart || !selectedVariant}
className={`w-full py-4 rounded-md font-medium transition-colors ${
addedToCart
? "bg-green-600 text-white"
: "bg-primary text-primary-foreground hover:bg-primary/90"
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{addingToCart ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Adding...
{/* 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>
) : 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"
</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>
)}
</button>
{addedToCart && (
<Link href="/cart" className="block text-center mt-4 text-primary hover:underline">
View Cart
</Link>
)}
{/* Tags */}
{product.tags?.length > 0 && (
<div className="mt-8 pt-6 border-t">
<span className="text-sm text-muted-foreground">Tags: </span>
<div className="flex flex-wrap gap-2 mt-2">
{product.tags.map((tag) => (
<span key={tag} className="px-2 py-1 text-xs bg-muted rounded-full">{tag}</span>
))}
{/* 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: 512 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>

View File

@ -13,7 +13,6 @@ 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";
@ -42,45 +41,81 @@ export default async function ProductsPage() {
const products = await getProducts(spaceId);
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Products</h1>
{products.length === 0 ? (
<div className="text-center py-16">
<p className="text-muted-foreground">
No products available yet. Check back soon!
<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>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{products.map((product) => (
{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
key={product.slug}
href={`/products/${product.slug}`}
className="group"
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"
>
<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}/mockup?type=${getMockupType(product.product_type)}`}
alt={product.name}
className="object-cover w-full h-full group-hover:scale-105 transition-transform"
/>
</div>
<div className="p-4">
<h3 className="font-semibold truncate">{product.name}</h3>
<p className="text-sm text-muted-foreground capitalize">
{product.product_type}
</p>
<p className="font-bold mt-2">
${product.base_price.toFixed(2)}
</p>
</div>
</div>
Upload a Design
</Link>
))}
</div>
)}
</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>
);
}

View File

@ -6,36 +6,40 @@ export interface MockupConfig {
label: string;
productType: string;
price: number;
blend?: "screen" | "normal";
}
export const MOCKUP_CONFIGS: MockupConfig[] = [
{
template: "/mockups/shirt-template.png",
designArea: { x: 275, y: 300, width: 250, height: 250 },
designArea: { x: 330, y: 310, width: 370, height: 370 },
label: "T-Shirt",
productType: "shirt",
price: 29.99,
blend: "screen",
},
{
template: "/mockups/sticker-template.png",
designArea: { x: 130, y: 130, width: 540, height: 540 },
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: 160, y: 160, width: 480, height: 480 },
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 product template using Canvas API.
* Draws the design into the bounding box first, then overlays the template
* so transparent regions in the template show the design through.
* 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,
@ -43,8 +47,8 @@ export function generateMockup(
): Promise<string> {
return new Promise((resolve, reject) => {
const canvas = document.createElement("canvas");
canvas.width = 800;
canvas.height = 800;
canvas.width = 1024;
canvas.height = 1024;
const ctx = canvas.getContext("2d");
if (!ctx) return reject(new Error("Canvas not supported"));
@ -59,7 +63,9 @@ export function generateMockup(
loaded++;
if (loaded < 2) return;
// Draw design first (underneath template)
// 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
@ -69,10 +75,15 @@ export function generateMockup(
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);
// Draw template on top (transparent areas show design through)
ctx.drawImage(templateImg, 0, 0, 800, 800);
// Reset composite operation
ctx.globalCompositeOperation = "source-over";
resolve(canvas.toDataURL("image/png"));
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 507 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 582 KiB