diff --git a/.env.example b/.env.example index 9d352ed..8a9d03a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/app/api/designs.py b/backend/app/api/designs.py index 5d3a0a7..0946c5b 100644 --- a/backend/app/api/designs.py +++ b/backend/app/api/designs.py @@ -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() diff --git a/backend/app/config.py b/backend/app/config.py index b9e84cb..cda632e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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) diff --git a/backend/app/pod/printful_client.py b/backend/app/pod/printful_client.py index 40531c3..5bb9d2d 100644 --- a/backend/app/pod/printful_client.py +++ b/backend/app/pod/printful_client.py @@ -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 ── diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index d167286..fc616ee 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -84,7 +84,10 @@ export default async function HomePage() {
-

Featured Products

+

Featured Products

+

+ Print-on-demand — fulfilled by Printful, shipped worldwide. +

{products.length === 0 ? (

No products available yet. Check back soon! @@ -95,22 +98,40 @@ export default async function HomePage() { -

+
{product.name} +
+ + {product.product_type} + +
-
-

{product.name}

-

- {product.product_type} +

+

+ {product.name} +

+

+ {product.description}

-

${product.base_price.toFixed(2)}

+
+ ${product.base_price.toFixed(2)} + + View Details → + +
diff --git a/frontend/app/products/[slug]/page.tsx b/frontend/app/products/[slug]/page.tsx index 93f9b9b..b40f0c8 100644 --- a/frontend/app/products/[slug]/page.tsx +++ b/frontend/app/products/[slug]/page.tsx @@ -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 ( -
-
-
+
+
+
+ {/* Image skeleton */} +
+ {/* Content skeleton */} +
+
+
+
+
+
+
+
); @@ -139,157 +151,218 @@ export default function ProductPage() { if (error || !product) { return ( -
-

{error || "Product not found"}

- Back to Products +
+
+

{error || "Product not found"}

+ Back to Products +
); } return ( -
- {/* Breadcrumb */} - +
+
+ {/* Breadcrumb */} + -
- {/* Product Mockup Image */} -
-
- {`${product.name} -
+
+ {/* Product Image Section */} +
+ {/* Main mockup image */} +
+ {imageLoading && ( +
+
+
+ Loading mockup... +
+
+ )} + {`${product.name} setImageLoading(false)} + onError={() => setImageLoading(false)} + /> +
- {/* Mockup type switcher — preview on different products */} -
- {MOCKUP_TYPES.map((mt) => ( - - ))} -
-
+ {/* Mockup type switcher */} +
+ {MOCKUP_TYPES.map((mt) => ( + + ))} +
- {/* Product Details */} -
-
- - {product.category} / {product.product_type} - -
- -

{product.name}

-

{product.description}

- -
- ${selectedVariant?.price.toFixed(2) || product.base_price.toFixed(2)} -
- - {/* Variant Selection */} - {product.variants && product.variants.length > 1 && ( -
- -
- {product.variants.map((variant) => ( - - ))} + {/* Raw design preview */} +
+

Original Design

+
+ {`${product.name}
- )} - - {/* Quantity */} -
- -
- - {quantity} - -
- {/* Add to Cart */} - + ))} +
+
)} - - {addedToCart && ( - - View Cart - - )} - - {/* Tags */} - {product.tags?.length > 0 && ( -
- Tags: -
- {product.tags.map((tag) => ( - {tag} - ))} + {/* Quantity */} +
+ +
+ + + {quantity} + +
- )} + + {/* Add to Cart */} + + + {addedToCart && ( + + View Cart → + + )} + + {/* Product info */} +
+
+ + + + Printed and shipped by Printful. Fulfilled on demand — no waste. +
+
+ + + + Standard shipping: 5–12 business days. Express available at checkout. +
+
+ + + + Bella + Canvas 3001 — premium 100% combed cotton, retail fit. +
+
+ + {/* Tags */} + {product.tags?.length > 0 && ( +
+
+ {product.tags.map((tag) => ( + + #{tag} + + ))} +
+
+ )} +
diff --git a/frontend/app/products/page.tsx b/frontend/app/products/page.tsx index 7a8d4e9..38b645b 100644 --- a/frontend/app/products/page.tsx +++ b/frontend/app/products/page.tsx @@ -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 ( -
-

Products

- - {products.length === 0 ? ( -
-

- No products available yet. Check back soon! +

+
+
+

Products

+

+ Print-on-demand merch — designed by the community, fulfilled by Printful.

- ) : ( -
- {products.map((product) => ( + + {products.length === 0 ? ( +
+
+ + + +
+

No products yet

+

+ New designs are being added. Check back soon or create your own. +

-
-
- {product.name} -
-
-

{product.name}

-

- {product.product_type} -

-

- ${product.base_price.toFixed(2)} -

-
-
+ Upload a Design - ))} -
- )} +
+ ) : ( +
+ {products.map((product) => ( + +
+ {/* Product image */} +
+ {product.name} + {/* Category badge */} +
+ + {product.product_type} + +
+
+ + {/* Product info */} +
+

+ {product.name} +

+

+ {product.description} +

+
+ + ${product.base_price.toFixed(2)} + + + View Details → + +
+
+
+ + ))} +
+ )} +
); } diff --git a/frontend/lib/mockups.ts b/frontend/lib/mockups.ts index f84ae4a..84534b4 100644 --- a/frontend/lib/mockups.ts +++ b/frontend/lib/mockups.ts @@ -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 { 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")); }; diff --git a/frontend/public/mockups/print-template.png b/frontend/public/mockups/print-template.png index 044e4ff..04ccd3a 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 22aa9e5..1ed1b5a 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 e5ccd84..aefd661 100644 Binary files a/frontend/public/mockups/sticker-template.png and b/frontend/public/mockups/sticker-template.png differ