feat: détection et suppression des doublons produits Shopify

- deleteShopifyProduct() dans shopifySync (DELETE REST API)
- GET /api/products/duplicates : groupe par ref/SKU, trie par ID (ancien en premier)
- DELETE /api/products/[id] : suppression unitaire avec auth
- Page /produits/doublons : liste des groupes, radio pour choisir lequel garder,
  confirmation avant suppression, état par groupe (idle/confirm/deleting/done)
- Lien 'Vérifier les doublons' dans l'en-tête de la page Produits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 23:57:53 +02:00
parent a2c0789625
commit 297d47bd19
5 changed files with 364 additions and 1 deletions
@@ -0,0 +1,261 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import Link from 'next/link'
import { Header } from '@/components/layout/Header'
import type { DuplicateGroup } from '@/app/api/products/duplicates/route'
type DeleteState = 'idle' | 'confirm' | 'deleting' | 'done'
interface GroupState {
group: DuplicateGroup
keepId: string
deleteState: DeleteState
deleted: string[] // IDs supprimés
error: string | null
}
export default function DoublonsPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [groupStates, setGroupStates] = useState<GroupState[]>([])
useEffect(() => {
fetch('/api/products/duplicates')
.then((r) => r.json())
.then((data) => {
if (data.error) throw new Error(data.error)
setGroupStates(
(data.groups as DuplicateGroup[]).map((group) => ({
group,
keepId: group.products[0].shopifyId, // garder le plus ancien par défaut
deleteState: 'idle',
deleted: [],
error: null,
}))
)
})
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false))
}, [])
const setKeep = useCallback((groupIdx: number, id: string) => {
setGroupStates((prev) =>
prev.map((gs, i) => i === groupIdx ? { ...gs, keepId: id } : gs)
)
}, [])
const askConfirm = useCallback((groupIdx: number) => {
setGroupStates((prev) =>
prev.map((gs, i) => i === groupIdx ? { ...gs, deleteState: 'confirm', error: null } : gs)
)
}, [])
const cancelConfirm = useCallback((groupIdx: number) => {
setGroupStates((prev) =>
prev.map((gs, i) => i === groupIdx ? { ...gs, deleteState: 'idle' } : gs)
)
}, [])
const deleteGroup = useCallback(async (groupIdx: number) => {
const gs = groupStates[groupIdx]
const toDelete = gs.group.products
.map((p) => p.shopifyId)
.filter((id) => id !== gs.keepId && !gs.deleted.includes(id))
setGroupStates((prev) =>
prev.map((s, i) => i === groupIdx ? { ...s, deleteState: 'deleting', error: null } : s)
)
const newDeleted = [...gs.deleted]
let lastError: string | null = null
for (const id of toDelete) {
const res = await fetch(`/api/products/${id}`, { method: 'DELETE' })
if (res.ok) {
newDeleted.push(id)
} else {
const data = await res.json()
lastError = data.error ?? `Erreur HTTP ${res.status}`
break
}
}
setGroupStates((prev) =>
prev.map((s, i) =>
i === groupIdx
? { ...s, deleted: newDeleted, deleteState: lastError ? 'idle' : 'done', error: lastError }
: s
)
)
}, [groupStates])
const remaining = groupStates.filter((gs) => gs.deleteState !== 'done').length
return (
<>
<Header
title="🔍 Doublons produits"
actions={
<Link href="/produits" className="text-xs text-slate-400 hover:text-slate-200">
Retour au catalogue
</Link>
}
/>
<div className="p-6 flex flex-col gap-6 flex-1 overflow-y-auto">
{loading && (
<div className="flex items-center gap-2 text-slate-400 text-sm py-12 justify-center">
<span className="animate-spin"></span> Analyse du catalogue Shopify
</div>
)}
{error && (
<div className="p-4 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">
{error}
</div>
)}
{!loading && !error && groupStates.length === 0 && (
<div className="text-center py-16">
<p className="text-4xl mb-3"></p>
<p className="text-white font-semibold">Aucun doublon détecté</p>
<p className="text-slate-400 text-sm mt-1">Tous les produits ont une référence unique.</p>
</div>
)}
{!loading && groupStates.length > 0 && (
<>
<div className="flex items-center justify-between">
<p className="text-sm text-slate-400">
<span className="text-white font-semibold">{groupStates.length}</span> groupe{groupStates.length > 1 ? 's' : ''} de doublons détectés
{remaining < groupStates.length && (
<span className="ml-2 text-green-400">· {groupStates.length - remaining} résolu{groupStates.length - remaining > 1 ? 's' : ''}</span>
)}
</p>
</div>
<div className="flex flex-col gap-4">
{groupStates.map((gs, groupIdx) => {
const { group, keepId, deleteState, deleted, error: groupError } = gs
const isDone = deleteState === 'done'
const activeProducts = group.products.filter((p) => !deleted.includes(p.shopifyId))
return (
<div
key={group.ref}
className={`border rounded-xl overflow-hidden transition-all ${
isDone
? 'border-green-700/50 bg-green-950/20 opacity-60'
: 'border-slate-700 bg-slate-800/50'
}`}
>
{/* En-tête groupe */}
<div className="px-4 py-3 border-b border-slate-700 flex items-center justify-between">
<div>
<span className="font-mono text-sm text-indigo-300 font-semibold">{group.ref}</span>
<span className="ml-3 text-xs text-slate-500">
{isDone ? '✅ Résolu' : `${activeProducts.length} produits`}
</span>
</div>
{!isDone && deleteState === 'idle' && (
<button
onClick={() => askConfirm(groupIdx)}
className="text-xs px-3 py-1.5 bg-red-900/40 hover:bg-red-800/60 border border-red-700/50 text-red-300 rounded-lg transition-colors"
>
Supprimer les doublons
</button>
)}
</div>
{/* Liste des produits */}
{!isDone && (
<div className="divide-y divide-slate-700/50">
{activeProducts.map((product) => {
const isKeep = product.shopifyId === keepId
return (
<div
key={product.shopifyId}
className={`px-4 py-3 flex items-center gap-3 ${isKeep ? 'bg-green-950/20' : ''}`}
>
<input
type="radio"
name={`keep-${groupIdx}`}
checked={isKeep}
onChange={() => setKeep(groupIdx, product.shopifyId)}
className="accent-green-500 cursor-pointer"
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{product.title}</p>
<p className="text-xs text-slate-500 mt-0.5">
ID {product.shopifyId} ·{' '}
<span className={product.status === 'active' ? 'text-green-400' : 'text-slate-400'}>
{product.status}
</span>
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{isKeep && (
<span className="text-xs text-green-400 font-medium">À garder</span>
)}
<a
href={product.shopifyUrl}
target="_blank"
rel="noreferrer"
className="text-xs text-slate-500 hover:text-slate-300"
>
Shopify
</a>
</div>
</div>
)
})}
</div>
)}
{/* Zone de confirmation */}
{deleteState === 'confirm' && (
<div className="px-4 py-3 bg-red-950/30 border-t border-red-800/40 flex items-center justify-between gap-4">
<p className="text-sm text-red-300">
Supprimer définitivement{' '}
<strong>{activeProducts.length - 1} produit{activeProducts.length - 1 > 1 ? 's' : ''}</strong>{' '}
de Shopify. Cette action est irréversible.
</p>
<div className="flex gap-2 shrink-0">
<button
onClick={() => cancelConfirm(groupIdx)}
className="text-xs px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-lg"
>
Annuler
</button>
<button
onClick={() => deleteGroup(groupIdx)}
className="text-xs px-3 py-1.5 bg-red-700 hover:bg-red-600 text-white font-semibold rounded-lg"
>
Confirmer la suppression
</button>
</div>
</div>
)}
{deleteState === 'deleting' && (
<div className="px-4 py-3 border-t border-slate-700 text-sm text-slate-400 flex items-center gap-2">
<span className="animate-spin"></span> Suppression en cours
</div>
)}
{groupError && (
<div className="px-4 py-2 border-t border-red-800/40 text-xs text-red-400">
Erreur : {groupError}
</div>
)}
</div>
)
})}
</div>
</>
)}
</div>
</>
)
}
+9 -1
View File
@@ -1,5 +1,6 @@
'use client' 'use client'
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { Header } from '@/components/layout/Header' import { Header } from '@/components/layout/Header'
import { ProductFiltersBar } from '@/components/products/ProductFiltersBar' import { ProductFiltersBar } from '@/components/products/ProductFiltersBar'
import { ProductTable } from '@/components/products/ProductTable' import { ProductTable } from '@/components/products/ProductTable'
@@ -35,7 +36,14 @@ export default function ProduitsPage() {
return ( return (
<> <>
<Header title="Produits" /> <Header
title="Produits"
actions={
<Link href="/produits/doublons" className="text-xs px-3 py-1.5 bg-slate-700 hover:bg-slate-600 border border-slate-600 text-slate-300 rounded-lg transition-colors">
🔍 Vérifier les doublons
</Link>
}
/>
<div className="p-6"> <div className="p-6">
<div className="bg-slate-800 rounded-xl p-6"> <div className="bg-slate-800 rounded-xl p-6">
<ProductFiltersBar <ProductFiltersBar
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { deleteShopifyProduct } from '@/lib/shopifySync'
export async function DELETE(
_req: NextRequest,
{ params }: { params: { id: string } },
) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
try {
await deleteShopifyProduct(params.id)
return NextResponse.json({ ok: true })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+59
View File
@@ -0,0 +1,59 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
import type { ShopifyProductFull } from '@/types/sync'
export interface DuplicateGroup {
ref: string
products: Array<{
shopifyId: string
title: string
status: string
handle: string
shopifyUrl: string
}>
}
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
try {
const domain = process.env.SHOPIFY_STORE_DOMAIN ?? ''
const all = await fetchAllShopifyProducts()
// Grouper par ref (SKU)
const byRef = new Map<string, ShopifyProductFull[]>()
for (const p of all) {
const key = p.ref.trim().toLowerCase()
if (!byRef.has(key)) byRef.set(key, [])
byRef.get(key)!.push(p)
}
const groups: DuplicateGroup[] = []
for (const products of Array.from(byRef.values())) {
if (products.length < 2) continue
// Trier : le plus ancien (ID numérique le plus petit) en premier
products.sort((a: ShopifyProductFull, b: ShopifyProductFull) => Number(a.shopifyId) - Number(b.shopifyId))
groups.push({
ref: products[0].ref,
products: products.map((p: ShopifyProductFull) => ({
shopifyId: p.shopifyId,
title: p.title,
status: p.status,
handle: p.handle,
shopifyUrl: `https://${domain}/admin/products/${p.shopifyId}`,
})),
})
}
// Trier les groupes par ref
groups.sort((a, b) => a.ref.localeCompare(b.ref))
return NextResponse.json({ groups, total: groups.length })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+15
View File
@@ -164,6 +164,21 @@ export async function deactivateShopifyProduct(shopifyId: string): Promise<void>
} }
} }
/**
* Supprime définitivement un produit Shopify.
*/
export async function deleteShopifyProduct(shopifyId: string): Promise<void> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/products/${shopifyId}.json`, {
method: 'DELETE',
headers,
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Shopify deleteProduct error: ${res.status}${err}`)
}
}
/** /**
* Récupère l'ID de la première variante d'un produit Shopify. * Récupère l'ID de la première variante d'un produit Shopify.
* Nécessaire pour mettre à jour le prix (update variant). * Nécessaire pour mettre à jour le prix (update variant).