diff --git a/src/app/(dashboard)/bannieres/page.tsx b/src/app/(dashboard)/bannieres/page.tsx new file mode 100644 index 0000000..68b7b8b --- /dev/null +++ b/src/app/(dashboard)/bannieres/page.tsx @@ -0,0 +1,275 @@ +'use client' +import { useState, useEffect, useRef, useCallback } from 'react' +import { Header } from '@/components/layout/Header' +import type { SlideshowSection, Slide } from '@/lib/shopifyTheme' +import { randomUUID } from 'crypto' + +// Convertit "shopify://shop_images/foo.jpg" → URL CDN prévisualisable via proxy +function shopifyRefToProxyUrl(ref: string): string { + if (!ref) return '' + const filename = ref.replace('shopify://shop_images/', '') + return `/api/images/proxy?url=${encodeURIComponent(`https://cdn.shopify.com/s/files/1/0897/7373/6263/files/${filename}`)}` +} + +function blobToBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve((reader.result as string).split(',')[1]) + reader.onerror = reject + reader.readAsDataURL(blob) + }) +} + +interface SlideCardProps { + slide: Slide + index: number + total: number + onMoveUp: () => void + onMoveDown: () => void + onRemove: () => void + onChangeImage: (shopifyRef: string) => void + onChangeLink: (link: string) => void +} + +function SlideCard({ slide, index, total, onMoveUp, onMoveDown, onRemove, onChangeImage, onChangeLink }: SlideCardProps) { + const fileRef = useRef(null) + const [uploading, setUploading] = useState(false) + const [error, setError] = useState(null) + + const handleFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + setUploading(true) + setError(null) + try { + const base64 = await blobToBase64(file) + const res = await fetch('/api/theme/upload-file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ imageBase64: base64, filename: file.name }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error) + onChangeImage(data.shopifyRef) + } catch (err) { + setError((err as Error).message) + } finally { + setUploading(false) + e.target.value = '' + } + } + + const previewUrl = shopifyRefToProxyUrl(slide.settings.image) + + return ( +
+ {/* Image preview */} +
+ {previewUrl ? ( + {`Slide + ) : ( +
+ Aucune image +
+ )} +
+ {index + 1} / {total} +
+
+ + {/* Controls */} +
+ {/* Link */} +
+ + onChangeLink(e.target.value)} + placeholder="https://…" + className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" + /> +
+ + {error &&

{error}

} + + {/* Actions */} +
+ + + + + +
+
+
+ ) +} + +export default function BannièresPage() { + const [slideshow, setSlideshow] = useState(null) + const [themeId, setThemeId] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + fetch('/api/theme/slideshow') + .then(r => r.json()) + .then(d => { + if (d.error) throw new Error(d.error) + setSlideshow(d.slideshow) + setThemeId(d.themeId) + }) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)) + }, []) + + const update = useCallback((fn: (s: SlideshowSection) => SlideshowSection) => { + setSlideshow(prev => prev ? fn(prev) : prev) + setSaved(false) + }, []) + + const moveSlide = (index: number, dir: -1 | 1) => { + update(s => { + const slides = [...s.slides] + const tmp = slides[index] + slides[index] = slides[index + dir] + slides[index + dir] = tmp + return { ...s, slides } + }) + } + + const removeSlide = (index: number) => { + update(s => ({ ...s, slides: s.slides.filter((_, i) => i !== index) })) + } + + const addSlide = () => { + update(s => ({ + ...s, + slides: [...s.slides, { + blockId: `slide_${Math.random().toString(36).slice(2, 8)}`, + settings: { image: '', link: '' }, + }], + })) + } + + const changeImage = (index: number, shopifyRef: string) => { + update(s => { + const slides = s.slides.map((sl, i) => + i === index ? { ...sl, settings: { ...sl.settings, image: shopifyRef } } : sl + ) + return { ...s, slides } + }) + } + + const changeLink = (index: number, link: string) => { + update(s => { + const slides = s.slides.map((sl, i) => + i === index ? { ...sl, settings: { ...sl.settings, link } } : sl + ) + return { ...s, slides } + }) + } + + const save = async () => { + if (!slideshow) return + setSaving(true) + setError(null) + try { + const res = await fetch('/api/theme/slideshow', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slideshow, themeId }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error) + setSaved(true) + } catch (err) { + setError((err as Error).message) + } finally { + setSaving(false) + } + } + + return ( + <> +
+ {saved && ✓ Publié sur Shopify} + {error && {error}} + + + + } + /> +
+ {loading && ( +
Chargement du thème…
+ )} + {!loading && !slideshow && ( +
+ Section slideshow-custom introuvable dans le thème actif. +
+ )} + {slideshow && ( +
+

+ {slideshow.slides.length} slide{slideshow.slides.length > 1 ? 's' : ''} · Thème actif (Meka) +

+
+ {slideshow.slides.map((slide, i) => ( + moveSlide(i, -1)} + onMoveDown={() => moveSlide(i, 1)} + onRemove={() => removeSlide(i)} + onChangeImage={ref => changeImage(i, ref)} + onChangeLink={link => changeLink(i, link)} + /> + ))} +
+
+ )} +
+ + ) +} diff --git a/src/app/api/theme/slideshow/route.ts b/src/app/api/theme/slideshow/route.ts new file mode 100644 index 0000000..66192a2 --- /dev/null +++ b/src/app/api/theme/slideshow/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server' +import { + getActiveThemeId, + getHomepageTemplate, + extractSlideshow, + applySlideshow, + saveHomepageTemplate, +} from '@/lib/shopifyTheme' +import type { SlideshowSection } from '@/lib/shopifyTheme' + +export async function GET() { + try { + const themeId = await getActiveThemeId() + const template = await getHomepageTemplate(themeId) + const slideshow = extractSlideshow(template) + if (!slideshow) return NextResponse.json({ error: 'Section slideshow-custom introuvable' }, { status: 404 }) + return NextResponse.json({ slideshow, themeId }) + } catch (err) { + return NextResponse.json({ error: (err as Error).message }, { status: 500 }) + } +} + +export async function PUT(req: NextRequest) { + try { + const body = await req.json() as { slideshow: SlideshowSection; themeId: string } + const template = await getHomepageTemplate(body.themeId) + const updated = applySlideshow(template, body.slideshow) + await saveHomepageTemplate(body.themeId, updated) + return NextResponse.json({ ok: true }) + } catch (err) { + return NextResponse.json({ error: (err as Error).message }, { status: 500 }) + } +} diff --git a/src/app/api/theme/upload-file/route.ts b/src/app/api/theme/upload-file/route.ts new file mode 100644 index 0000000..0591bdf --- /dev/null +++ b/src/app/api/theme/upload-file/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from 'next/server' +import { uploadFileToShopify } from '@/lib/shopifyTheme' + +export async function POST(req: NextRequest) { + try { + const { imageBase64, filename } = await req.json() as { imageBase64: string; filename: string } + if (!imageBase64 || !filename) { + return NextResponse.json({ error: 'imageBase64 et filename requis' }, { status: 400 }) + } + const shopifyRef = await uploadFileToShopify(imageBase64, filename) + return NextResponse.json({ shopifyRef }) + } catch (err) { + return NextResponse.json({ error: (err as Error).message }, { status: 500 }) + } +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index b698544..81de96e 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -17,6 +17,7 @@ const NAV_ITEMS: NavItem[] = [ { href: '/sync', label: 'Sync Sheets', icon: '🔄' }, { href: '/creation', label: 'Création', icon: '✨' }, { href: '/mockup', label: 'Mockups', icon: '🖼️' }, + { href: '/bannieres', label: 'Bannières', icon: '🎨' }, { href: '/parametres', label: 'Paramètres', icon: '⚙️', adminOnly: true }, ] diff --git a/src/lib/shopifyTheme.ts b/src/lib/shopifyTheme.ts new file mode 100644 index 0000000..3e63cc7 --- /dev/null +++ b/src/lib/shopifyTheme.ts @@ -0,0 +1,211 @@ +const API_VERSION = '2024-01' + +function getConfig() { + const domain = process.env.SHOPIFY_STORE_DOMAIN + const token = process.env.SHOPIFY_ADMIN_API_TOKEN + if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis') + return { + baseUrl: `https://${domain}/admin/api/${API_VERSION}`, + graphqlUrl: `https://${domain}/admin/api/${API_VERSION}/graphql.json`, + headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' }, + } +} + +export interface SlideSettings { + image: string // "shopify://shop_images/filename.jpg" + link?: string +} + +export interface Slide { + blockId: string + settings: SlideSettings +} + +export interface SlideshowSection { + sectionId: string + slides: Slide[] + blockOrder: string[] + sectionSettings: Record +} + +// ── Trouver le thème actif ──────────────────────────────────────────────────── + +export async function getActiveThemeId(): Promise { + const { baseUrl, headers } = getConfig() + const res = await fetch(`${baseUrl}/themes.json`, { headers }) + const data = await res.json() + const main = (data.themes as Array<{ id: number; role: string }>).find(t => t.role === 'main') + if (!main) throw new Error('Aucun thème actif trouvé') + return String(main.id) +} + +// ── Lire le template homepage ───────────────────────────────────────────────── + +export async function getHomepageTemplate(themeId: string): Promise> { + const { baseUrl, headers } = getConfig() + const url = `${baseUrl}/themes/${themeId}/assets.json?asset%5Bkey%5D=templates%2Findex.json` + const res = await fetch(url, { headers }) + const data = await res.json() + const value = (data.asset as { value: string }).value + return JSON.parse(value) +} + +// ── Écrire le template homepage ─────────────────────────────────────────────── + +export async function saveHomepageTemplate( + themeId: string, + template: Record, +): Promise { + const { baseUrl, headers } = getConfig() + const res = await fetch(`${baseUrl}/themes/${themeId}/assets.json`, { + method: 'PUT', + headers, + body: JSON.stringify({ asset: { key: 'templates/index.json', value: JSON.stringify(template, null, 2) } }), + }) + if (!res.ok) { + const body = await res.text() + throw new Error(`Shopify theme write error: ${res.status} — ${body}`) + } +} + +// ── Extraire le slideshow depuis le template ────────────────────────────────── + +export function extractSlideshow(template: Record): SlideshowSection | null { + const sections = template.sections as Record + if (!sections) return null + + const entry = Object.entries(sections).find(([, v]) => { + const sec = v as Record + return typeof sec.type === 'string' && sec.type.includes('slideshow-custom') + }) + if (!entry) return null + + const [sectionId, sec] = entry + const section = sec as Record + const blocks = section.blocks as Record + const blockOrder = section.block_order as string[] + + const slides: Slide[] = (blockOrder ?? []) + .filter(id => blocks[id]?.type === 'slide') + .map(id => ({ blockId: id, settings: blocks[id].settings })) + + return { + sectionId, + slides, + blockOrder: blockOrder ?? [], + sectionSettings: (section.settings as Record) ?? {}, + } +} + +// ── Mettre à jour les slides dans le template ───────────────────────────────── + +export function applySlideshow( + template: Record, + slideshow: SlideshowSection, +): Record { + const sections = { ...(template.sections as Record) } + const existing = sections[slideshow.sectionId] as Record + + const blocks: Record = {} + for (const slide of slideshow.slides) { + blocks[slide.blockId] = { type: 'slide', settings: slide.settings } + } + + sections[slideshow.sectionId] = { + ...existing, + blocks, + block_order: slideshow.slides.map(s => s.blockId), + settings: slideshow.sectionSettings, + } + + return { ...template, sections } +} + +// ── Uploader une image dans Shopify Files et retourner la ref shopify:// ────── + +export async function uploadFileToShopify( + imageBase64: string, + filename: string, +): Promise { + const { graphqlUrl, headers } = getConfig() + + // 1. Créer un staged upload + const stageRes = await fetch(graphqlUrl, { + method: 'POST', + headers, + body: JSON.stringify({ + query: `mutation stagedUploadsCreate($input: [StagedUploadInput!]!) { + stagedUploadsCreate(input: $input) { + stagedTargets { + url + resourceUrl + parameters { name value } + } + userErrors { field message } + } + }`, + variables: { + input: [{ + filename, + mimeType: 'image/jpeg', + resource: 'FILE', + fileSize: String(Math.ceil(imageBase64.length * 0.75)), + httpMethod: 'POST', + }], + }, + }), + }) + const stageData = await stageRes.json() + const target = stageData?.data?.stagedUploadsCreate?.stagedTargets?.[0] + if (!target) throw new Error('Staged upload échoué') + + // 2. Uploader le fichier vers l'URL staging + const binaryStr = atob(imageBase64) + const bytes = new Uint8Array(binaryStr.length) + for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i) + const blob = new Blob([bytes], { type: 'image/jpeg' }) + + const form = new FormData() + for (const { name, value } of target.parameters as Array<{ name: string; value: string }>) { + form.append(name, value) + } + form.append('file', blob, filename) + + const uploadRes = await fetch(target.url, { method: 'POST', body: form }) + if (!uploadRes.ok) throw new Error(`Upload staging échoué: ${uploadRes.status}`) + + // 3. Enregistrer le fichier dans Shopify Files + const createRes = await fetch(graphqlUrl, { + method: 'POST', + headers, + body: JSON.stringify({ + query: `mutation fileCreate($files: [FileCreateInput!]!) { + fileCreate(files: $files) { + files { + ... on MediaImage { + id + image { url } + } + } + userErrors { field message } + } + }`, + variables: { + files: [{ originalSource: target.resourceUrl, contentType: 'IMAGE' }], + }, + }), + }) + const createData = await createRes.json() + const fileUrl = createData?.data?.fileCreate?.files?.[0]?.image?.url as string | undefined + + if (!fileUrl) { + // Fallback: construire la ref depuis le resourceUrl + const resourceUrl = target.resourceUrl as string + const fname = resourceUrl.split('/').pop()?.split('?')[0] ?? filename + return `shopify://shop_images/${fname}` + } + + // Extraire le nom de fichier depuis l'URL CDN + const cdnFilename = fileUrl.split('/files/')[1]?.split('?')[0] ?? filename + return `shopify://shop_images/${cdnFilename}` +}