feat: add bannières page to manage Shopify homepage slideshow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 09:12:52 +02:00
parent d616f1d74c
commit 0a3f5042fc
5 changed files with 535 additions and 0 deletions
+275
View File
@@ -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<string> {
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<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="bg-slate-800 border border-slate-700 rounded-xl overflow-hidden">
{/* Image preview */}
<div className="relative h-44 bg-slate-900">
{previewUrl ? (
<img src={previewUrl} alt={`Slide ${index + 1}`} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-slate-600 text-sm">
Aucune image
</div>
)}
<div className="absolute top-2 left-2 bg-black/60 text-white text-xs px-2 py-0.5 rounded-full">
{index + 1} / {total}
</div>
</div>
{/* Controls */}
<div className="p-3 space-y-2">
{/* Link */}
<div>
<label className="block text-xs text-slate-400 mb-1">Lien (optionnel)</label>
<input
type="text"
value={slide.settings.link ?? ''}
onChange={e => 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"
/>
</div>
{error && <p className="text-xs text-red-400">{error}</p>}
{/* Actions */}
<div className="flex gap-1.5 flex-wrap">
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="flex-1 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded font-medium disabled:opacity-50"
>
{uploading ? '⬆️ Upload…' : '🖼️ Changer l\'image'}
</button>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
<button
onClick={onMoveUp}
disabled={index === 0}
className="py-1.5 px-2 text-xs bg-slate-700 hover:bg-slate-600 text-slate-300 rounded disabled:opacity-30"
title="Monter"
></button>
<button
onClick={onMoveDown}
disabled={index === total - 1}
className="py-1.5 px-2 text-xs bg-slate-700 hover:bg-slate-600 text-slate-300 rounded disabled:opacity-30"
title="Descendre"
></button>
<button
onClick={onRemove}
className="py-1.5 px-2 text-xs bg-slate-700 hover:bg-red-900 text-slate-400 hover:text-red-300 rounded"
title="Supprimer"
></button>
</div>
</div>
</div>
)
}
export default function BannièresPage() {
const [slideshow, setSlideshow] = useState<SlideshowSection | null>(null)
const [themeId, setThemeId] = useState<string>('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(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 (
<>
<Header
title="Bannières homepage"
actions={
<div className="flex items-center gap-3">
{saved && <span className="text-xs text-green-400"> Publié sur Shopify</span>}
{error && <span className="text-xs text-red-400">{error}</span>}
<button
onClick={addSlide}
disabled={!slideshow}
className="px-3 py-1.5 text-xs bg-slate-700 hover:bg-slate-600 text-white rounded font-medium disabled:opacity-40"
>
+ Ajouter une slide
</button>
<button
onClick={save}
disabled={!slideshow || saving}
className="px-4 py-1.5 text-xs bg-green-700 hover:bg-green-600 text-white rounded font-medium disabled:opacity-40"
>
{saving ? 'Publication…' : '🚀 Publier sur Shopify'}
</button>
</div>
}
/>
<div className="p-6">
{loading && (
<div className="text-center py-12 text-slate-500 text-sm">Chargement du thème</div>
)}
{!loading && !slideshow && (
<div className="text-center py-12 text-red-400 text-sm">
Section slideshow-custom introuvable dans le thème actif.
</div>
)}
{slideshow && (
<div className="space-y-4">
<p className="text-xs text-slate-500">
{slideshow.slides.length} slide{slideshow.slides.length > 1 ? 's' : ''} · Thème actif (Meka)
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{slideshow.slides.map((slide, i) => (
<SlideCard
key={slide.blockId}
slide={slide}
index={i}
total={slideshow.slides.length}
onMoveUp={() => moveSlide(i, -1)}
onMoveDown={() => moveSlide(i, 1)}
onRemove={() => removeSlide(i)}
onChangeImage={ref => changeImage(i, ref)}
onChangeLink={link => changeLink(i, link)}
/>
))}
</div>
</div>
)}
</div>
</>
)
}
+33
View File
@@ -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 })
}
}
+15
View File
@@ -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 })
}
}