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:
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ href: '/sync', label: 'Sync Sheets', icon: '🔄' },
|
{ href: '/sync', label: 'Sync Sheets', icon: '🔄' },
|
||||||
{ href: '/creation', label: 'Création', icon: '✨' },
|
{ href: '/creation', label: 'Création', icon: '✨' },
|
||||||
{ href: '/mockup', label: 'Mockups', icon: '🖼️' },
|
{ href: '/mockup', label: 'Mockups', icon: '🖼️' },
|
||||||
|
{ href: '/bannieres', label: 'Bannières', icon: '🎨' },
|
||||||
{ href: '/parametres', label: 'Paramètres', icon: '⚙️', adminOnly: true },
|
{ href: '/parametres', label: 'Paramètres', icon: '⚙️', adminOnly: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trouver le thème actif ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getActiveThemeId(): Promise<string> {
|
||||||
|
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<Record<string, unknown>> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, unknown>): SlideshowSection | null {
|
||||||
|
const sections = template.sections as Record<string, unknown>
|
||||||
|
if (!sections) return null
|
||||||
|
|
||||||
|
const entry = Object.entries(sections).find(([, v]) => {
|
||||||
|
const sec = v as Record<string, unknown>
|
||||||
|
return typeof sec.type === 'string' && sec.type.includes('slideshow-custom')
|
||||||
|
})
|
||||||
|
if (!entry) return null
|
||||||
|
|
||||||
|
const [sectionId, sec] = entry
|
||||||
|
const section = sec as Record<string, unknown>
|
||||||
|
const blocks = section.blocks as Record<string, { type: string; settings: SlideSettings }>
|
||||||
|
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<string, unknown>) ?? {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mettre à jour les slides dans le template ─────────────────────────────────
|
||||||
|
|
||||||
|
export function applySlideshow(
|
||||||
|
template: Record<string, unknown>,
|
||||||
|
slideshow: SlideshowSection,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const sections = { ...(template.sections as Record<string, unknown>) }
|
||||||
|
const existing = sections[slideshow.sectionId] as Record<string, unknown>
|
||||||
|
|
||||||
|
const blocks: Record<string, unknown> = {}
|
||||||
|
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<string> {
|
||||||
|
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}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user