diff --git a/src/app/api/images/proxy/route.ts b/src/app/api/images/proxy/route.ts new file mode 100644 index 0000000..31dd51a --- /dev/null +++ b/src/app/api/images/proxy/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' + +export async function GET(req: NextRequest) { + const session = await getServerSession(authOptions) + if (!session) return new Response('Non authentifié', { status: 401 }) + + const url = req.nextUrl.searchParams.get('url') + if (!url) return new Response('url requis', { status: 400 }) + + const res = await fetch(url) + if (!res.ok) return new Response('Fetch failed', { status: 502 }) + + const buffer = await res.arrayBuffer() + const contentType = res.headers.get('content-type') ?? 'image/jpeg' + return new Response(buffer, { headers: { 'Content-Type': contentType } }) +} diff --git a/src/app/api/mockup/product-meta/[shopifyId]/route.ts b/src/app/api/mockup/product-meta/[shopifyId]/route.ts new file mode 100644 index 0000000..f16477f --- /dev/null +++ b/src/app/api/mockup/product-meta/[shopifyId]/route.ts @@ -0,0 +1,30 @@ +import { NextRequest } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' + +const API_VERSION = '2024-01' + +export async function GET(_req: NextRequest, { params }: { params: { shopifyId: string } }) { + const session = await getServerSession(authOptions) + if (!session) return Response.json({ error: 'Non authentifié' }, { status: 401 }) + + const domain = process.env.SHOPIFY_STORE_DOMAIN + const token = process.env.SHOPIFY_ADMIN_API_TOKEN + if (!domain || !token) return Response.json({ error: 'Config Shopify manquante' }, { status: 500 }) + + const res = await fetch( + `https://${domain}/admin/api/${API_VERSION}/products/${params.shopifyId}/metafields.json`, + { headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' } }, + ) + if (!res.ok) return Response.json({ error: `Shopify error: ${res.status}` }, { status: 502 }) + + const data = await res.json() + const metafields = (data.metafields ?? []) as Array<{ + namespace: string + key: string + value: string + type: string + }> + + return Response.json({ metafields }) +} diff --git a/src/components/mockup/MockupModal.tsx b/src/components/mockup/MockupModal.tsx index 5216615..9f74d65 100644 --- a/src/components/mockup/MockupModal.tsx +++ b/src/components/mockup/MockupModal.tsx @@ -8,35 +8,96 @@ interface Props { onClose: () => void } -const FAMILIES = ['Menuiserie', 'Baies vitrées', 'Carrelage', 'Tous'] +interface Metafield { + namespace: string + key: string + value: string + type: string +} + +async function urlToBase64(url: string): Promise { + const res = await fetch(`/api/images/proxy?url=${encodeURIComponent(url)}`) + if (!res.ok) throw new Error('Impossible de charger l\'image') + const blob = await res.blob() + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve((reader.result as string).split(',')[1]) + reader.onerror = reject + reader.readAsDataURL(blob) + }) +} export function MockupModal({ product, onClose }: Props) { const [templates, setTemplates] = useState([]) + const [families, setFamilies] = useState([]) + const [metafields, setMetafields] = useState([]) + const [loadingMeta, setLoadingMeta] = useState(true) + const [selectedTemplate, setSelectedTemplate] = useState(null) - const [family, setFamily] = useState('Menuiserie') - const [dimensions, setDimensions] = useState('') + const [family, setFamily] = useState('') + const [selectedMeta, setSelectedMeta] = useState([]) const [cutoutB64, setCutoutB64] = useState(null) + const [selectedImageUrl, setSelectedImageUrl] = useState(null) + const [loadingImage, setLoadingImage] = useState(false) + const [instruction, setInstruction] = useState('') const [generating, setGenerating] = useState(false) const [resultB64, setResultB64] = useState(null) const [uploading, setUploading] = useState(false) const [uploadedUrl, setUploadedUrl] = useState(null) const [error, setError] = useState(null) + const fileRef = useRef(null) const templateFileRef = useRef(null) const [uploadingTemplate, setUploadingTemplate] = useState(false) useEffect(() => { - fetch('/api/mockup/templates') - .then(r => r.json()) - .then((data: MockupTemplate[]) => setTemplates(data)) - }, []) + Promise.all([ + fetch('/api/mockup/templates').then(r => r.json()), + fetch('/api/sheets/tabs').then(r => r.json()), + fetch(`/api/mockup/product-meta/${product.shopifyId}`).then(r => r.json()), + ]).then(([tplData, tabsData, metaData]) => { + setTemplates(tplData as MockupTemplate[]) + const tabs: string[] = (tabsData.tabs ?? []).filter((t: string) => + !['COLLECTIONS', 'LIVRAISONS', 'ECOPART', 'RECAP'].some(x => t.toUpperCase().includes(x)) + ) + setFamilies(tabs) + setFamily(tabs[0] ?? '') + setMetafields(metaData.metafields ?? []) + }).finally(() => setLoadingMeta(false)) + }, [product.shopifyId]) - const filtered = templates.filter(t => t.family === family || t.family === 'Tous') + const filteredTemplates = templates.filter(t => + !family || t.family === family || t.family === 'Tous' + ) - const handleCutout = (e: React.ChangeEvent) => { + const dimensionStr = selectedMeta.length > 0 + ? selectedMeta.map(key => { + const mf = metafields.find(m => `${m.namespace}.${m.key}` === key) + return mf ? `${mf.key}: ${mf.value}` : '' + }).filter(Boolean).join(', ') + : undefined + + const selectExistingImage = async (url: string) => { + setSelectedImageUrl(url) + setCutoutB64(null) + setLoadingImage(true) + setError(null) + try { + const b64 = await urlToBase64(url) + setCutoutB64(b64) + } catch { + setError('Impossible de charger cette image') + setSelectedImageUrl(null) + } finally { + setLoadingImage(false) + } + } + + const handleFileUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return + setSelectedImageUrl(null) const reader = new FileReader() reader.onload = ev => setCutoutB64((ev.target?.result as string).split(',')[1]) reader.readAsDataURL(file) @@ -45,11 +106,10 @@ export function MockupModal({ product, onClose }: Props) { const uploadTemplate = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return - const name = file.name.replace(/\.[^.]+$/, '') setUploadingTemplate(true) const form = new FormData() - form.append('name', name) - form.append('family', family) + form.append('name', file.name.replace(/\.[^.]+$/, '')) + form.append('family', family || 'Tous') form.append('description', '') form.append('image', file) const res = await fetch('/api/mockup/templates', { method: 'POST', body: form }) @@ -59,6 +119,10 @@ export function MockupModal({ product, onClose }: Props) { setUploadingTemplate(false) } + const toggleMeta = (key: string) => { + setSelectedMeta(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]) + } + const generate = async () => { if (!selectedTemplate || !cutoutB64) return setGenerating(true) @@ -72,8 +136,8 @@ export function MockupModal({ product, onClose }: Props) { templateId: selectedTemplate, productImageBase64: cutoutB64, productName: product.title, - family, - dimensions: dimensions || undefined, + family: family || 'Tous', + dimensions: dimensionStr, instruction: instruction || undefined, }), }) @@ -91,11 +155,10 @@ export function MockupModal({ product, onClose }: Props) { if (!resultB64) return setUploading(true) try { - const filename = `${product.ref}-mockup.jpg` const res = await fetch('/api/images/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ shopifyProductId: product.shopifyId, imageBase64: resultB64, filename }), + body: JSON.stringify({ shopifyProductId: product.shopifyId, imageBase64: resultB64, filename: `${product.ref}-mockup.jpg` }), }) const data = await res.json() if (!res.ok) throw new Error(data.error) @@ -108,72 +171,124 @@ export function MockupModal({ product, onClose }: Props) { } return ( -
{ if (e.target === e.currentTarget) onClose() }}> +
{ if (e.target === e.currentTarget) onClose() }}>
+ {/* Header */} -
+

Créer un mockup

-

{product.title} {product.ref}

+

+ {product.title} {product.ref} +

-
- {/* Family + dimensions */} -
+
+ + {/* 1. Image de base */} +
+

1. Image du produit

+ {product.images.length > 0 && ( +
+

Images existantes

+
+ {product.images.map((url, i) => ( + + ))} +
+
+ )}
- +

Ou uploader une image découpée (PNG)

+ +
+ {cutoutB64 && !loadingImage && ( +

✓ Image prête

+ )} +
+ + {/* 2. Famille */} +
+

2. Famille

+ {families.length > 0 ? ( -
-
- - setDimensions(e.target.value)} placeholder="optionnel" - className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" /> -
-
+ ) : ( +

{loadingMeta ? 'Chargement…' : 'Aucune famille disponible'}

+ )} + - {/* Template selection */} -
+ {/* 3. Caractéristiques (metafields) */} + {metafields.length > 0 && ( +
+

3. Caractéristiques à inclure dans le prompt

+
+ {metafields.map(mf => { + const key = `${mf.namespace}.${mf.key}` + return ( + + ) + })} +
+ {dimensionStr && ( +

Inclus dans le prompt : {dimensionStr}

+ )} +
+ )} + + {/* 4. Template */} +
- +

+ {metafields.length > 0 ? '4.' : '3.'} Image de référence (template) +

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

Aucun template pour cette famille. Uploadez-en un ci-dessus.

+ {filteredTemplates.length === 0 ? ( +

Aucun template pour cette famille. Uploadez-en un ci-dessus.

) : ( -
- {filtered.map(t => ( +
+ {filteredTemplates.map(t => ( ))}
)} -
- - {/* Product cutout */} -
- - - {cutoutB64 &&

✓ Image chargée

} -
+
{/* Generate */}