From 8c9bce7aa5a00dad0e45957592f0d81ecee0e907 Mon Sep 17 00:00:00 2001 From: Mathieu Date: Wed, 24 Jun 2026 16:12:54 +0200 Subject: [PATCH] feat: collection picker in product creation flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API /api/creation/collections liste toutes les collections Shopify - Détection automatique depuis la colonne ID FAMILLE du Sheets - Sélecteur avec recherche si absente ou pour modifier - Collection affichée dans l'écran de confirmation - collectionOverride passé à l'exécution comme fallback Co-Authored-By: Claude Sonnet 4.6 --- src/app/(dashboard)/creation/page.tsx | 100 +++++++++++++++++++++- src/app/api/creation/collections/route.ts | 32 +++++++ src/app/api/creation/execute/route.ts | 6 +- 3 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 src/app/api/creation/collections/route.ts diff --git a/src/app/(dashboard)/creation/page.tsx b/src/app/(dashboard)/creation/page.tsx index b675364..d0d528d 100644 --- a/src/app/(dashboard)/creation/page.tsx +++ b/src/app/(dashboard)/creation/page.tsx @@ -1,5 +1,5 @@ 'use client' -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect } from 'react' import { Header } from '@/components/layout/Header' import { PendingRowsTable } from '@/components/creation/PendingRowsTable' import { MetafieldResolver } from '@/components/creation/MetafieldResolver' @@ -9,6 +9,8 @@ import type { PendingProduct, MetafieldResolution, CreationStreamEvent } from '@ type Step = 'famille' | 'analyse' | 'metafields' | 'confirmation' | 'execution' +interface ShopifyCollection { id: string; title: string } + export default function CreationPage() { const [step, setStep] = useState('famille') const [selectedTab, setSelectedTab] = useState(null) @@ -23,9 +25,28 @@ export default function CreationPage() { const [events, setEvents] = useState([]) const [done, setDone] = useState(false) + // Collection + const [collections, setCollections] = useState([]) + const [detectedCollectionId, setDetectedCollectionId] = useState('') + const [collectionOverride, setCollectionOverride] = useState('') + const [collectionSearch, setCollectionSearch] = useState('') + const [loadingCollections, setLoadingCollections] = useState(false) + + useEffect(() => { + setLoadingCollections(true) + fetch('/api/creation/collections') + .then(r => r.json()) + .then(d => setCollections(d.collections ?? [])) + .finally(() => setLoadingCollections(false)) + }, []) + const handleSelectTab = (tab: string) => { setSelectedTab(tab) setStep('analyse') + setDetectedCollectionId('') + setCollectionOverride('') + setCollectionSearch('') + setClearResult(null) } const analyse = async () => { @@ -41,6 +62,17 @@ export default function CreationPage() { setByTab(data.byTab) setTotal(data.total) setSpecificColumns(data.specificColumns) + + // Détecter la collection depuis les produits analysés + const products: PendingProduct[] = Object.values(data.byTab as Record).flat() + const ids = Array.from(new Set(products.map(p => p.collectionId).filter(Boolean))) + if (ids.length === 1) { + setDetectedCollectionId(ids[0]) + setCollectionOverride(ids[0]) + } else { + setDetectedCollectionId('') + setCollectionOverride('') + } setStep('metafields') } catch (e) { setError(e instanceof Error ? e.message : 'Erreur') @@ -58,7 +90,7 @@ export default function CreationPage() { const res = await fetch('/api/creation/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ resolutions, tab: selectedTab }), + body: JSON.stringify({ resolutions, tab: selectedTab, collectionOverride: collectionOverride || undefined }), }) if (!res.body) return const reader = res.body.getReader() @@ -114,8 +146,16 @@ export default function CreationPage() { setDone(false) setByTab({}) setTotal(0) + setDetectedCollectionId('') + setCollectionOverride('') } + const filteredCollections = collectionSearch + ? collections.filter(c => c.title.toLowerCase().includes(collectionSearch.toLowerCase())) + : collections + + const selectedCollection = collections.find(c => c.id === collectionOverride) + return ( <>
@@ -171,6 +211,57 @@ export default function CreationPage() { {total > 0 && ( <> +
+ + {/* Collection Shopify */} +
+

Collection Shopify

+ {detectedCollectionId ? ( +
+ ✓ Détectée depuis le Sheets : + + {collections.find(c => c.id === detectedCollectionId)?.title ?? `ID ${detectedCollectionId}`} + + +
+ ) : ( +

⚠ Aucune collection détectée dans le Sheets — choisissez-en une :

+ )} + {(!detectedCollectionId || collectionOverride !== detectedCollectionId) && ( +
+ {selectedCollection && ( +
+ → {selectedCollection.title} + +
+ )} + setCollectionSearch(e.target.value)} + placeholder={loadingCollections ? 'Chargement…' : 'Rechercher une collection Shopify…'} + className="w-full max-w-sm 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" + /> + {collectionSearch && filteredCollections.length > 0 && ( +
+ {filteredCollections.map(c => ( + + ))} +
+ )} + {collectionSearch && filteredCollections.length === 0 && ( +

Aucune collection trouvée

+ )} +
+ )} +
+

Résolution des métachamps

@@ -189,6 +280,11 @@ export default function CreationPage() {

{total} produit{total > 1 ? 's' : ''} à créer dans Shopify

+

+ Collection : + {collections.find(c => c.id === collectionOverride)?.title ?? (collectionOverride ? `ID ${collectionOverride}` : Aucune)} + +

{resolutions.filter(r => r.action !== 'skip').length} type{resolutions.filter(r => r.action !== 'skip').length > 1 ? 's' : ''} de métachamps à assigner

{resolutions.filter(r => r.action === 'create').length} nouvelle{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} définition{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} à créer dans Shopify

⚠ Les descriptions seront générées par OpenAI GPT-4o (~0.01$ par produit)

diff --git a/src/app/api/creation/collections/route.ts b/src/app/api/creation/collections/route.ts new file mode 100644 index 0000000..c3fd0d7 --- /dev/null +++ b/src/app/api/creation/collections/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' + +const API_VERSION = '2024-01' + +export async function GET() { + const session = await getServerSession(authOptions) + if (!session) return NextResponse.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 NextResponse.json({ error: 'Config Shopify manquante' }, { status: 500 }) + + const baseUrl = `https://${domain}/admin/api/${API_VERSION}` + const headers = { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' } + + // Récupère custom collections + smart collections + const [customRes, smartRes] = await Promise.all([ + fetch(`${baseUrl}/custom_collections.json?limit=250&fields=id,title`, { headers }), + fetch(`${baseUrl}/smart_collections.json?limit=250&fields=id,title`, { headers }), + ]) + + const custom = customRes.ok ? (await customRes.json()).custom_collections as { id: number; title: string }[] : [] + const smart = smartRes.ok ? (await smartRes.json()).smart_collections as { id: number; title: string }[] : [] + + const collections = [...custom, ...smart] + .map(c => ({ id: String(c.id), title: c.title })) + .sort((a, b) => a.title.localeCompare(b.title)) + + return NextResponse.json({ collections }) +} diff --git a/src/app/api/creation/execute/route.ts b/src/app/api/creation/execute/route.ts index 7bf8a01..a8f469c 100644 --- a/src/app/api/creation/execute/route.ts +++ b/src/app/api/creation/execute/route.ts @@ -20,9 +20,10 @@ export async function POST(req: NextRequest) { } const userId = (session.user as { id: string }).id - const body = await req.json().catch(() => ({})) as { resolutions?: MetafieldResolution[]; tab?: string } + const body = await req.json().catch(() => ({})) as { resolutions?: MetafieldResolution[]; tab?: string; collectionOverride?: string } const resolutions: MetafieldResolution[] = body.resolutions ?? [] const tabFilter: string | undefined = body.tab ?? undefined + const collectionOverride: string | undefined = body.collectionOverride ?? undefined const resolutionMap = new Map(resolutions.map(r => [r.columnHeader, r])) const stream = new ReadableStream({ @@ -83,7 +84,8 @@ export async function POST(req: NextRequest) { weightKg: product.weightKg, status: 'active', }) - if (product.collectionId) await addProductToCollection(shopifyId, product.collectionId) + const effectiveCollectionId = product.collectionId || collectionOverride || '' + if (effectiveCollectionId) await addProductToCollection(shopifyId, effectiveCollectionId) if (product.subCollectionId) await addProductToCollection(shopifyId, product.subCollectionId) send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Métachamps…' }) for (const [colHeader, value] of Object.entries(product.specificFields)) {