feat: add Upload Swag page with product mockup previews
Users can upload their own logo/design, see instant mockup previews on shirts, stickers, and art prints via client-side Canvas compositing, then save and activate the design to the store for ordering. - Backend: POST /api/design/upload with file validation (type, size, dimensions), Pillow processing, saves to designs/uploads/ - Frontend: /upload page with drag-and-drop, real-time mockup gallery, activate/discard flow matching existing Design Swag pattern - Fix: activate/delete endpoints now scan all category dirs instead of hardcoding stickers/ - Nav: "Upload Swag" button added to header and homepage CTAs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7be99d37d0
commit
54ccbbc350
|
|
@ -183,16 +183,27 @@ status: draft
|
|||
)
|
||||
|
||||
|
||||
def find_design_dir(slug: str) -> Path | None:
|
||||
"""Find a design directory by slug, searching all categories."""
|
||||
for category_dir in settings.designs_dir.iterdir():
|
||||
if not category_dir.is_dir():
|
||||
continue
|
||||
design_dir = category_dir / slug
|
||||
if design_dir.exists() and (design_dir / "metadata.yaml").exists():
|
||||
return design_dir
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/{slug}/activate")
|
||||
async def activate_design(slug: str):
|
||||
"""Activate a draft design to make it visible in the store."""
|
||||
|
||||
design_dir = settings.designs_dir / "stickers" / slug
|
||||
metadata_path = design_dir / "metadata.yaml"
|
||||
|
||||
if not metadata_path.exists():
|
||||
design_dir = find_design_dir(slug)
|
||||
if not design_dir:
|
||||
raise HTTPException(status_code=404, detail="Design not found")
|
||||
|
||||
metadata_path = design_dir / "metadata.yaml"
|
||||
|
||||
# Read and update metadata
|
||||
content = metadata_path.read_text()
|
||||
content = content.replace("status: draft", "status: active")
|
||||
|
|
@ -209,12 +220,12 @@ async def delete_design(slug: str):
|
|||
"""Delete a design (only drafts can be deleted)."""
|
||||
import shutil
|
||||
|
||||
design_dir = settings.designs_dir / "stickers" / slug
|
||||
metadata_path = design_dir / "metadata.yaml"
|
||||
|
||||
if not metadata_path.exists():
|
||||
design_dir = find_design_dir(slug)
|
||||
if not design_dir:
|
||||
raise HTTPException(status_code=404, detail="Design not found")
|
||||
|
||||
metadata_path = design_dir / "metadata.yaml"
|
||||
|
||||
# Check if draft
|
||||
content = metadata_path.read_text()
|
||||
if "status: active" in content:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
"""Design upload API — users upload their own artwork."""
|
||||
|
||||
import io
|
||||
import re
|
||||
import uuid
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, UploadFile
|
||||
from PIL import Image
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import get_settings
|
||||
from app.api.designs import design_service
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
ALLOWED_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
MIN_DIMENSION = 500
|
||||
|
||||
|
||||
class UploadResponse(BaseModel):
|
||||
slug: str
|
||||
name: str
|
||||
image_url: str
|
||||
status: str
|
||||
products: list[dict]
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
text = text.lower().strip()
|
||||
text = re.sub(r'[^\w\s-]', '', text)
|
||||
text = re.sub(r'[\s_-]+', '-', text)
|
||||
text = re.sub(r'^-+|-+$', '', text)
|
||||
return text
|
||||
|
||||
|
||||
@router.post("/upload", response_model=UploadResponse)
|
||||
async def upload_design(
|
||||
file: UploadFile,
|
||||
name: str = Form(...),
|
||||
space: str = Form("default"),
|
||||
tags: str = Form(""),
|
||||
):
|
||||
"""Upload a custom design image."""
|
||||
|
||||
# Validate content type
|
||||
if file.content_type not in ALLOWED_TYPES:
|
||||
raise HTTPException(400, "Only PNG, JPEG, and WebP files are accepted")
|
||||
|
||||
# Read file and check size
|
||||
contents = await file.read()
|
||||
if len(contents) > MAX_FILE_SIZE:
|
||||
raise HTTPException(400, "File size must be under 10 MB")
|
||||
|
||||
# Open with Pillow and validate dimensions
|
||||
try:
|
||||
img = Image.open(io.BytesIO(contents))
|
||||
except Exception:
|
||||
raise HTTPException(400, "Could not read image file")
|
||||
|
||||
if img.width < MIN_DIMENSION or img.height < MIN_DIMENSION:
|
||||
raise HTTPException(400, f"Image must be at least {MIN_DIMENSION}x{MIN_DIMENSION} pixels")
|
||||
|
||||
# Create slug
|
||||
slug = slugify(name)
|
||||
if not slug:
|
||||
slug = f"upload-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Check for existing design
|
||||
design_dir = settings.designs_dir / "uploads" / slug
|
||||
if design_dir.exists():
|
||||
slug = f"{slug}-{uuid.uuid4().hex[:6]}"
|
||||
design_dir = settings.designs_dir / "uploads" / slug
|
||||
|
||||
# Save image as PNG
|
||||
design_dir.mkdir(parents=True, exist_ok=True)
|
||||
img = img.convert("RGBA")
|
||||
image_path = design_dir / f"{slug}.png"
|
||||
img.save(str(image_path), "PNG")
|
||||
|
||||
# Build metadata
|
||||
safe_name = name.replace('"', '\\"')
|
||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else ["custom", "upload"]
|
||||
tags_str = ", ".join(tag_list)
|
||||
space_field = space if space != "default" else "all"
|
||||
|
||||
metadata_content = f"""name: "{safe_name}"
|
||||
slug: {slug}
|
||||
description: "Custom uploaded design"
|
||||
tags: [{tags_str}]
|
||||
space: {space_field}
|
||||
category: uploads
|
||||
created: "{date.today().isoformat()}"
|
||||
author: user-upload
|
||||
|
||||
source:
|
||||
file: {slug}.png
|
||||
format: png
|
||||
dimensions:
|
||||
width: {img.width}
|
||||
height: {img.height}
|
||||
dpi: 300
|
||||
color_profile: sRGB
|
||||
|
||||
products:
|
||||
- type: sticker
|
||||
provider: prodigi
|
||||
sku: GLOBAL-STI-KIS-3X3
|
||||
variants: [matte, gloss]
|
||||
retail_price: 3.50
|
||||
- type: shirt
|
||||
provider: printful
|
||||
sku: "71"
|
||||
variants: [S, M, L, XL, 2XL]
|
||||
retail_price: 29.99
|
||||
- type: print
|
||||
provider: prodigi
|
||||
sku: GLOBAL-FAP-A4
|
||||
variants: [matte, lustre]
|
||||
retail_price: 12.99
|
||||
|
||||
status: draft
|
||||
"""
|
||||
metadata_path = design_dir / "metadata.yaml"
|
||||
metadata_path.write_text(metadata_content)
|
||||
|
||||
# Clear design cache so the new upload is discoverable
|
||||
design_service.clear_cache()
|
||||
|
||||
products = [
|
||||
{"type": "sticker", "price": 3.50},
|
||||
{"type": "shirt", "price": 29.99},
|
||||
{"type": "print", "price": 12.99},
|
||||
]
|
||||
|
||||
return UploadResponse(
|
||||
slug=slug,
|
||||
name=name,
|
||||
image_url=f"/api/designs/{slug}/image",
|
||||
status="draft",
|
||||
products=products,
|
||||
)
|
||||
|
|
@ -6,7 +6,7 @@ from fastapi import FastAPI
|
|||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import get_settings
|
||||
from app.api import designs, products, cart, checkout, orders, webhooks, health, design_generator, spaces
|
||||
from app.api import designs, products, cart, checkout, orders, webhooks, health, design_generator, upload, spaces
|
||||
from app.api.admin import router as admin_router
|
||||
|
||||
settings = get_settings()
|
||||
|
|
@ -50,6 +50,7 @@ app.include_router(checkout.router, prefix="/api/checkout", tags=["checkout"])
|
|||
app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
|
||||
app.include_router(webhooks.router, prefix="/api/webhooks", tags=["webhooks"])
|
||||
app.include_router(design_generator.router, prefix="/api/design", tags=["design-generator"])
|
||||
app.include_router(upload.router, prefix="/api/design", tags=["upload"])
|
||||
app.include_router(spaces.router, prefix="/api/spaces", tags=["spaces"])
|
||||
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,9 @@ export default async function RootLayout({
|
|||
<a href="/design" className="px-3 py-1.5 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors">
|
||||
Design Swag
|
||||
</a>
|
||||
<a href="/upload" className="px-3 py-1.5 border border-primary text-primary rounded-md text-sm font-medium hover:bg-primary/10 transition-colors">
|
||||
Upload Swag
|
||||
</a>
|
||||
<a href="/cart" className="hover:text-primary">
|
||||
Cart
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -74,6 +74,12 @@ export default async function HomePage() {
|
|||
>
|
||||
Design Your Own
|
||||
</Link>
|
||||
<Link
|
||||
href="/upload"
|
||||
className="inline-flex items-center justify-center rounded-md border border-primary px-8 py-3 text-lg font-medium text-primary hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
Upload Your Own
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,470 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { getSpaceIdFromCookie } from "@/lib/spaces";
|
||||
import { MOCKUP_CONFIGS, generateMockup } from "@/lib/mockups";
|
||||
import type { SpaceConfig } from "@/lib/spaces";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
|
||||
|
||||
interface UploadedDesign {
|
||||
slug: string;
|
||||
name: string;
|
||||
image_url: string;
|
||||
status: string;
|
||||
products: { type: string; price: number }[];
|
||||
}
|
||||
|
||||
export default function UploadPage() {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [mockups, setMockups] = useState<Record<string, string>>({});
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [uploadedDesign, setUploadedDesign] = useState<UploadedDesign | null>(null);
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
const [spaceConfig, setSpaceConfig] = useState<SpaceConfig | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const spaceId = getSpaceIdFromCookie();
|
||||
fetch(`${API_URL}/spaces/${spaceId}`)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then(setSpaceConfig)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Generate mockups when preview changes
|
||||
useEffect(() => {
|
||||
if (!preview) {
|
||||
setMockups({});
|
||||
return;
|
||||
}
|
||||
MOCKUP_CONFIGS.forEach(async (config) => {
|
||||
try {
|
||||
const result = await generateMockup(preview, config);
|
||||
setMockups((prev) => ({ ...prev, [config.productType]: result }));
|
||||
} catch {
|
||||
// Template load failure — fallback handled in render
|
||||
}
|
||||
});
|
||||
}, [preview]);
|
||||
|
||||
const handleFile = useCallback((f: File) => {
|
||||
if (!f.type.startsWith("image/")) {
|
||||
setError("Please select an image file (PNG, JPEG, or WebP)");
|
||||
return;
|
||||
}
|
||||
if (f.size > 10 * 1024 * 1024) {
|
||||
setError("File must be under 10 MB");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setFile(f);
|
||||
setPreview(URL.createObjectURL(f));
|
||||
if (!name) {
|
||||
// Auto-fill name from filename
|
||||
const baseName = f.name.replace(/\.[^.]+$/, "").replace(/[-_]/g, " ");
|
||||
setName(baseName.charAt(0).toUpperCase() + baseName.slice(1));
|
||||
}
|
||||
}, [name]);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const droppedFile = e.dataTransfer.files[0];
|
||||
if (droppedFile) handleFile(droppedFile);
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
const handleUpload = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!file || !name) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("name", name);
|
||||
formData.append("space", getSpaceIdFromCookie());
|
||||
if (tags) formData.append("tags", tags);
|
||||
|
||||
const response = await fetch(`${API_URL}/design/upload`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || "Upload failed");
|
||||
}
|
||||
|
||||
const design = await response.json();
|
||||
setUploadedDesign(design);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async () => {
|
||||
if (!uploadedDesign) return;
|
||||
setIsActivating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/design/${uploadedDesign.slug}/activate`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || "Failed to activate design");
|
||||
}
|
||||
setUploadedDesign({ ...uploadedDesign, status: "active" });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setIsActivating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!uploadedDesign) return;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/design/${uploadedDesign.slug}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || "Failed to delete design");
|
||||
}
|
||||
resetForm();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
setName("");
|
||||
setTags("");
|
||||
setMockups({});
|
||||
setUploadedDesign(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-2">Upload Swag</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Upload your own design and preview it on{" "}
|
||||
{spaceConfig?.name || "rSwag"} merchandise. See how it looks on
|
||||
shirts, stickers, and prints before ordering.
|
||||
</p>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Left: Upload Form */}
|
||||
<div>
|
||||
<form onSubmit={handleUpload} className="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium mb-2"
|
||||
>
|
||||
Design Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., My Custom Logo"
|
||||
className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
disabled={isUploading || !!uploadedDesign}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Drag & Drop Zone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Design Image
|
||||
</label>
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() =>
|
||||
!uploadedDesign && fileInputRef.current?.click()
|
||||
}
|
||||
className={`relative border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
|
||||
isDragging
|
||||
? "border-primary bg-primary/5"
|
||||
: preview
|
||||
? "border-primary/50"
|
||||
: "border-muted-foreground/30 hover:border-primary/50"
|
||||
} ${uploadedDesign ? "pointer-events-none opacity-60" : ""}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleFile(f);
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
{preview ? (
|
||||
<div className="space-y-3">
|
||||
<img
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
className="max-h-48 mx-auto rounded-md"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{file?.name} (
|
||||
{((file?.size || 0) / 1024 / 1024).toFixed(1)} MB)
|
||||
</p>
|
||||
{!uploadedDesign && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
setMockups({});
|
||||
}}
|
||||
className="text-sm text-red-500 hover:text-red-700"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-muted-foreground/50"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-muted-foreground">
|
||||
Drag & drop your design here, or{" "}
|
||||
<span className="text-primary font-medium">browse</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
PNG, JPEG, or WebP. Max 10 MB. Min 500x500px.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="tags"
|
||||
className="block text-sm font-medium mb-2"
|
||||
>
|
||||
Tags (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tags"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="logo, custom, brand"
|
||||
className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isUploading || !!uploadedDesign}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-md text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!uploadedDesign && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isUploading || !file || !name}
|
||||
className="w-full px-6 py-3 bg-primary text-primary-foreground rounded-md font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isUploading ? (
|
||||
<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>
|
||||
Uploading...
|
||||
</span>
|
||||
) : (
|
||||
"Upload & Save Design"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Post-upload actions */}
|
||||
{uploadedDesign && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<p className="font-medium">{uploadedDesign.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Status:{" "}
|
||||
<span
|
||||
className={
|
||||
uploadedDesign.status === "active"
|
||||
? "text-green-600"
|
||||
: "text-yellow-600"
|
||||
}
|
||||
>
|
||||
{uploadedDesign.status}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{uploadedDesign.status === "draft" ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleActivate}
|
||||
disabled={isActivating}
|
||||
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-md font-medium hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isActivating ? "Activating..." : "Add to Store"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-4 py-2 border border-red-300 text-red-600 rounded-md font-medium hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href={`/products/${uploadedDesign.slug}`}
|
||||
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-md font-medium hover:bg-primary/90 transition-colors text-center"
|
||||
>
|
||||
View in Store
|
||||
</Link>
|
||||
<button
|
||||
onClick={resetForm}
|
||||
className="px-4 py-2 border border-muted-foreground/30 text-muted-foreground rounded-md font-medium hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
Upload Another
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Mockup Previews */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4">Product Previews</h2>
|
||||
{preview ? (
|
||||
<div className="space-y-4">
|
||||
{MOCKUP_CONFIGS.map((config) => (
|
||||
<div
|
||||
key={config.productType}
|
||||
className="border rounded-lg overflow-hidden"
|
||||
>
|
||||
<div className="aspect-square bg-muted/20 relative">
|
||||
{mockups[config.productType] ? (
|
||||
<img
|
||||
src={mockups[config.productType]}
|
||||
alt={`${config.label} preview`}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 flex items-center justify-between">
|
||||
<span className="font-medium">{config.label}</span>
|
||||
<span className="text-muted-foreground">
|
||||
from ${config.price.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-square border-2 border-dashed rounded-lg flex items-center justify-center bg-muted/30">
|
||||
<p className="text-muted-foreground text-center px-8">
|
||||
Upload a design to see product previews
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="mt-12 p-6 bg-muted/30 rounded-lg">
|
||||
<h3 className="font-semibold mb-3">Upload Tips</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>
|
||||
• Use a high-resolution image (at least 2000x2000px for best
|
||||
print quality)
|
||||
</li>
|
||||
<li>
|
||||
• PNG with transparency works best for stickers and shirts
|
||||
</li>
|
||||
<li>
|
||||
• Keep important elements centered — edges may be cropped on
|
||||
some products
|
||||
</li>
|
||||
<li>
|
||||
• Designs start as drafts — preview before adding to the
|
||||
store
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/** Client-side Canvas mockup compositing for design previews. */
|
||||
|
||||
export interface MockupConfig {
|
||||
template: string;
|
||||
designArea: { x: number; y: number; width: number; height: number };
|
||||
label: string;
|
||||
productType: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export const MOCKUP_CONFIGS: MockupConfig[] = [
|
||||
{
|
||||
template: "/mockups/shirt-template.png",
|
||||
designArea: { x: 275, y: 300, width: 250, height: 250 },
|
||||
label: "T-Shirt",
|
||||
productType: "shirt",
|
||||
price: 29.99,
|
||||
},
|
||||
{
|
||||
template: "/mockups/sticker-template.png",
|
||||
designArea: { x: 130, y: 130, width: 540, height: 540 },
|
||||
label: "Sticker",
|
||||
productType: "sticker",
|
||||
price: 3.50,
|
||||
},
|
||||
{
|
||||
template: "/mockups/print-template.png",
|
||||
designArea: { x: 160, y: 160, width: 480, height: 480 },
|
||||
label: "Art Print",
|
||||
productType: "print",
|
||||
price: 12.99,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function generateMockup(
|
||||
designDataUrl: string,
|
||||
config: MockupConfig
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 800;
|
||||
canvas.height = 800;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return reject(new Error("Canvas not supported"));
|
||||
|
||||
const templateImg = new window.Image();
|
||||
const designImg = new window.Image();
|
||||
|
||||
templateImg.crossOrigin = "anonymous";
|
||||
designImg.crossOrigin = "anonymous";
|
||||
|
||||
let loaded = 0;
|
||||
const onBothLoaded = () => {
|
||||
loaded++;
|
||||
if (loaded < 2) return;
|
||||
|
||||
// Draw design first (underneath template)
|
||||
const { x, y, width, height } = config.designArea;
|
||||
|
||||
// Maintain aspect ratio within the bounding box
|
||||
const scale = Math.min(width / designImg.width, height / designImg.height);
|
||||
const dw = designImg.width * scale;
|
||||
const dh = designImg.height * scale;
|
||||
const dx = x + (width - dw) / 2;
|
||||
const dy = y + (height - dh) / 2;
|
||||
|
||||
ctx.drawImage(designImg, dx, dy, dw, dh);
|
||||
|
||||
// Draw template on top (transparent areas show design through)
|
||||
ctx.drawImage(templateImg, 0, 0, 800, 800);
|
||||
|
||||
resolve(canvas.toDataURL("image/png"));
|
||||
};
|
||||
|
||||
templateImg.onload = onBothLoaded;
|
||||
designImg.onload = onBothLoaded;
|
||||
templateImg.onerror = () => reject(new Error(`Failed to load template: ${config.template}`));
|
||||
designImg.onerror = () => reject(new Error("Failed to load design image"));
|
||||
|
||||
templateImg.src = config.template;
|
||||
designImg.src = designDataUrl;
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Loading…
Reference in New Issue