From 744d16c2c5ea798023d685e32c92573f72f0e084 Mon Sep 17 00:00:00 2001 From: gilles Date: Sun, 18 Jan 2026 19:30:23 +0100 Subject: [PATCH] feat: add Zustand store, product list and add product modal (Steps 2-3) - Create useProductStore with Zustand for state management - Enrich client.js with all API functions (CRUD, scrape) - Connect HomePage to store with loading/error states - Add ProductCard with scrape/delete actions - Add ProductGrid component - Add AddProductModal with URL extraction - Update Header with refresh and scrape all buttons - Add comprehensive styles for cards and modal Co-Authored-By: Claude Opus 4.5 --- frontend/src/App.jsx | 48 ++- frontend/src/api/client.js | 81 ++++- frontend/src/components/ProductCard.jsx | 14 - .../components/products/AddProductModal.jsx | 123 +++++++ .../src/components/products/ProductCard.jsx | 94 ++++++ .../src/components/products/ProductGrid.jsx | 24 ++ frontend/src/pages/HomePage.jsx | 40 ++- frontend/src/stores/useProductStore.js | 89 +++++ frontend/src/styles/global.scss | 309 +++++++++++++++++- 9 files changed, 783 insertions(+), 39 deletions(-) delete mode 100644 frontend/src/components/ProductCard.jsx create mode 100644 frontend/src/components/products/AddProductModal.jsx create mode 100644 frontend/src/components/products/ProductCard.jsx create mode 100644 frontend/src/components/products/ProductGrid.jsx create mode 100644 frontend/src/stores/useProductStore.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0c32a9a..6a315de 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,17 +1,35 @@ -import React from "react"; +import React, { useState } from "react"; import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom"; import HomePage from "./pages/HomePage"; import DebugPage from "./pages/DebugPage"; +import useProductStore from "./stores/useProductStore"; +import AddProductModal from "./components/products/AddProductModal"; -const App = () => ( - -
+const Header = () => { + const { fetchProducts, scrapeAll, loading } = useProductStore(); + const [showAddModal, setShowAddModal] = useState(false); + + const handleRefresh = () => { + fetchProducts(); + }; + + const handleScrapeAll = async () => { + if (!confirm("Lancer le scraping de tous les produits ?")) return; + try { + await scrapeAll(); + } catch (err) { + console.error("Erreur scrape all:", err); + } + }; + + return ( + <>
suivi_produits
- - +
+ {showAddModal && ( + setShowAddModal(false)} /> + )} + + ); +}; + +const App = () => ( + +
+
} /> } /> diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 052a8ed..ec30cbe 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -1,7 +1,82 @@ const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8008"; -export const fetchProducts = async () => { - // point d'entrée simple vers l'API FastAPI - const response = await fetch(`${BASE_URL}/products`); +// Helper pour gérer les erreurs +const handleResponse = async (response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: "Erreur réseau" })); + throw new Error(error.detail || `Erreur ${response.status}`); + } return response.json(); }; + +// Products +export const fetchProducts = async () => { + const response = await fetch(`${BASE_URL}/products`); + return handleResponse(response); +}; + +export const fetchProduct = async (id) => { + const response = await fetch(`${BASE_URL}/products/${id}`); + return handleResponse(response); +}; + +export const createProduct = async (data) => { + const response = await fetch(`${BASE_URL}/products`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return handleResponse(response); +}; + +export const updateProduct = async (id, data) => { + const response = await fetch(`${BASE_URL}/products/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return handleResponse(response); +}; + +export const deleteProduct = async (id) => { + const response = await fetch(`${BASE_URL}/products/${id}`, { + method: "DELETE", + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: "Erreur réseau" })); + throw new Error(error.detail || `Erreur ${response.status}`); + } + return true; +}; + +// Scraping +export const scrapeProduct = async (id) => { + const response = await fetch(`${BASE_URL}/scrape/product/${id}`, { + method: "POST", + }); + return handleResponse(response); +}; + +export const scrapeAll = async () => { + const response = await fetch(`${BASE_URL}/scrape/all`, { + method: "POST", + }); + return handleResponse(response); +}; + +// Snapshots +export const fetchSnapshots = async (productId, limit = 30) => { + const response = await fetch(`${BASE_URL}/products/${productId}/snapshots?limit=${limit}`); + return handleResponse(response); +}; + +// Debug +export const fetchDebugTables = async (limit = 50) => { + const response = await fetch(`${BASE_URL}/debug/tables?limit=${limit}`); + return handleResponse(response); +}; + +export const fetchDebugLogs = async (lines = 100) => { + const response = await fetch(`${BASE_URL}/debug/logs?lines=${lines}`); + return handleResponse(response); +}; diff --git a/frontend/src/components/ProductCard.jsx b/frontend/src/components/ProductCard.jsx deleted file mode 100644 index cf4fe5d..0000000 --- a/frontend/src/components/ProductCard.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from "react"; - -const ProductCard = ({ product }) => ( -
- {/* vignette produit : image + infos principales */} -
-
-

{product.titre}

-

Prix actuel : {product.prix_actuel ?? "-"} €

-
-
-); - -export default ProductCard; diff --git a/frontend/src/components/products/AddProductModal.jsx b/frontend/src/components/products/AddProductModal.jsx new file mode 100644 index 0000000..de7ed78 --- /dev/null +++ b/frontend/src/components/products/AddProductModal.jsx @@ -0,0 +1,123 @@ +import React, { useState } from "react"; +import useProductStore from "../../stores/useProductStore"; + +const AddProductModal = ({ onClose }) => { + const { addProduct } = useProductStore(); + const [url, setUrl] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Extrait l'ASIN d'une URL Amazon + const extractAsin = (amazonUrl) => { + const patterns = [ + /\/dp\/([A-Z0-9]{10})/i, + /\/gp\/product\/([A-Z0-9]{10})/i, + /\/ASIN\/([A-Z0-9]{10})/i, + /\?asin=([A-Z0-9]{10})/i, + ]; + + for (const pattern of patterns) { + const match = amazonUrl.match(pattern); + if (match) return match[1].toUpperCase(); + } + return null; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(null); + + // Validation URL Amazon + if (!url.includes("amazon.fr")) { + setError("L'URL doit être une URL Amazon.fr"); + return; + } + + const asin = extractAsin(url); + if (!asin) { + setError("Impossible d'extraire l'ASIN de cette URL"); + return; + } + + setLoading(true); + try { + // Construire l'URL canonique + const canonicalUrl = `https://www.amazon.fr/dp/${asin}`; + + await addProduct({ + boutique: "amazon", + url: canonicalUrl, + asin: asin, + titre: null, + url_image: null, + categorie: null, + type: null, + actif: true, + }); + + onClose(); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleBackdropClick = (e) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+
+
+

Ajouter un produit

+ +
+ +
+ {error &&
{error}
} + +
+ + setUrl(e.target.value)} + placeholder="https://www.amazon.fr/dp/B0..." + required + autoFocus + /> +

+ Collez l'URL complète du produit Amazon.fr +

+
+ +
+ + +
+
+
+
+ ); +}; + +export default AddProductModal; diff --git a/frontend/src/components/products/ProductCard.jsx b/frontend/src/components/products/ProductCard.jsx new file mode 100644 index 0000000..439f49c --- /dev/null +++ b/frontend/src/components/products/ProductCard.jsx @@ -0,0 +1,94 @@ +import React from "react"; +import useProductStore from "../../stores/useProductStore"; + +const ProductCard = ({ product }) => { + const { scrapeProduct, deleteProduct, scraping } = useProductStore(); + const isScraping = scraping[product.id]; + + const handleScrape = async () => { + try { + await scrapeProduct(product.id); + } catch (err) { + console.error("Erreur scraping:", err); + } + }; + + const handleDelete = async () => { + if (!confirm(`Supprimer "${product.titre || product.asin}" ?`)) return; + try { + await deleteProduct(product.id); + } catch (err) { + console.error("Erreur suppression:", err); + } + }; + + return ( +
+
+ {product.boutique} + {product.actif ? ( + Actif + ) : ( + Inactif + )} +
+ +
+
+ {product.url_image ? ( + {product.titre} + ) : ( +
+ +
+ )} +
+ +
+

+ {product.titre || "Titre non disponible"} +

+ +
+ ASIN: {product.asin} + {product.categorie && ( + {product.categorie} + )} +
+ + + Voir sur Amazon + +
+
+ +
+ + +
+
+ ); +}; + +export default ProductCard; diff --git a/frontend/src/components/products/ProductGrid.jsx b/frontend/src/components/products/ProductGrid.jsx new file mode 100644 index 0000000..b3d2a78 --- /dev/null +++ b/frontend/src/components/products/ProductGrid.jsx @@ -0,0 +1,24 @@ +import React from "react"; +import ProductCard from "./ProductCard"; + +const ProductGrid = ({ products }) => { + if (!products || products.length === 0) { + return ( +
+ +

Aucun produit pour l'instant

+

Ajoutez un lien Amazon.fr pour commencer !

+
+ ); + } + + return ( +
+ {products.map((product) => ( + + ))} +
+ ); +}; + +export default ProductGrid; diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx index 01fca96..db030e0 100644 --- a/frontend/src/pages/HomePage.jsx +++ b/frontend/src/pages/HomePage.jsx @@ -1,11 +1,35 @@ -import React from "react"; +import React, { useEffect } from "react"; +import useProductStore from "../stores/useProductStore"; +import ProductGrid from "../components/products/ProductGrid"; -const HomePage = () => ( -
-
-

Aucun produit pour l'instant, ajoutez un lien Amazon.fr !

-
-
-); +const HomePage = () => { + const { products, loading, error, fetchProducts, clearError } = useProductStore(); + + useEffect(() => { + fetchProducts(); + }, [fetchProducts]); + + return ( +
+ {error && ( +
+ {error} + +
+ )} + + {loading && products.length === 0 ? ( +
+ +

Chargement des produits...

+
+ ) : ( + + )} +
+ ); +}; export default HomePage; diff --git a/frontend/src/stores/useProductStore.js b/frontend/src/stores/useProductStore.js new file mode 100644 index 0000000..1896216 --- /dev/null +++ b/frontend/src/stores/useProductStore.js @@ -0,0 +1,89 @@ +import { create } from "zustand"; +import * as api from "../api/client"; + +const useProductStore = create((set, get) => ({ + // State + products: [], + loading: false, + error: null, + scraping: {}, // { [productId]: true } pour les produits en cours de scraping + + // Actions + fetchProducts: async () => { + set({ loading: true, error: null }); + try { + const products = await api.fetchProducts(); + set({ products, loading: false }); + } catch (err) { + set({ error: err.message, loading: false }); + } + }, + + addProduct: async (data) => { + set({ loading: true, error: null }); + try { + const newProduct = await api.createProduct(data); + set((state) => ({ + products: [newProduct, ...state.products], + loading: false, + })); + return newProduct; + } catch (err) { + set({ error: err.message, loading: false }); + throw err; + } + }, + + deleteProduct: async (id) => { + set({ error: null }); + try { + await api.deleteProduct(id); + set((state) => ({ + products: state.products.filter((p) => p.id !== id), + })); + } catch (err) { + set({ error: err.message }); + throw err; + } + }, + + scrapeProduct: async (id) => { + set((state) => ({ + scraping: { ...state.scraping, [id]: true }, + error: null, + })); + try { + const result = await api.scrapeProduct(id); + // Refresh la liste pour avoir les nouvelles données + await get().fetchProducts(); + set((state) => { + const { [id]: _, ...rest } = state.scraping; + return { scraping: rest }; + }); + return result; + } catch (err) { + set((state) => { + const { [id]: _, ...rest } = state.scraping; + return { scraping: rest, error: err.message }; + }); + throw err; + } + }, + + scrapeAll: async () => { + set({ loading: true, error: null }); + try { + const result = await api.scrapeAll(); + await get().fetchProducts(); + set({ loading: false }); + return result; + } catch (err) { + set({ error: err.message, loading: false }); + throw err; + } + }, + + clearError: () => set({ error: null }), +})); + +export default useProductStore; diff --git a/frontend/src/styles/global.scss b/frontend/src/styles/global.scss index 1e3c371..9141644 100644 --- a/frontend/src/styles/global.scss +++ b/frontend/src/styles/global.scss @@ -121,24 +121,321 @@ a { } // Home Page -.app-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); - gap: 20px; +.home-page { padding: 24px; } +.product-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 20px; +} + .empty-state { - grid-column: 1 / -1; background: $card; border-radius: 12px; - padding: 48px 32px; + padding: 64px 32px; text-align: center; + .empty-icon { + font-size: 3rem; + color: $text-muted; + margin-bottom: 16px; + } + p { color: $text-muted; font-size: 1.1rem; + margin: 8px 0; } + + .hint { + font-size: 0.9rem; + } +} + +.loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px; + color: $text-muted; + + i { + font-size: 2rem; + margin-bottom: 16px; + } +} + +.error-banner { + display: flex; + align-items: center; + justify-content: space-between; + background: rgba($accent-red, 0.15); + color: $accent-red; + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 20px; + + .btn-close { + background: none; + border: none; + color: $accent-red; + cursor: pointer; + padding: 4px 8px; + + &:hover { + opacity: 0.7; + } + } +} + +// Product Card +.product-card { + background: $card; + border-radius: 12px; + overflow: hidden; + transition: transform 0.2s, box-shadow 0.2s; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + } +} + +.product-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: $bg-soft; + border-bottom: 1px solid $bg; + + .boutique { + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + color: $accent; + } + + .status { + font-size: 0.75rem; + padding: 2px 8px; + border-radius: 4px; + + &.active { + background: rgba($accent-green, 0.2); + color: $accent-green; + } + + &.inactive { + background: rgba($text-muted, 0.2); + color: $text-muted; + } + } +} + +.product-body { + display: flex; + gap: 16px; + padding: 16px; +} + +.product-image { + flex-shrink: 0; + width: 100px; + height: 100px; + background: $bg; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + + .no-image { + color: $text-muted; + font-size: 2rem; + } +} + +.product-info { + flex: 1; + min-width: 0; +} + +.product-title { + font-size: 0.95rem; + font-weight: 500; + margin: 0 0 8px 0; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.product-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; + + .asin { + font-size: 0.75rem; + color: $text-muted; + font-family: monospace; + } + + .category { + font-size: 0.75rem; + background: $bg; + padding: 2px 8px; + border-radius: 4px; + color: $accent-yellow; + } +} + +.product-link { + font-size: 0.8rem; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.product-actions { + display: flex; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid $bg; + background: $bg-soft; + + .btn-scrape { + flex: 1; + } + + .btn-delete { + background: rgba($accent-red, 0.15); + color: $accent-red; + + &:hover { + background: rgba($accent-red, 0.25); + } + } +} + +// Modal +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + padding: 20px; +} + +.modal { + background: $card; + border-radius: 12px; + width: 100%; + max-width: 500px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid $bg; + + h2 { + margin: 0; + font-size: 1.2rem; + } + + .btn-close { + background: none; + border: none; + color: $text-muted; + cursor: pointer; + padding: 8px; + font-size: 1.1rem; + + &:hover { + color: $text; + } + } +} + +.modal-body { + padding: 24px; +} + +.form-group { + margin-bottom: 20px; + + label { + display: block; + margin-bottom: 8px; + font-weight: 500; + color: $text; + } + + input, select, textarea { + width: 100%; + padding: 12px 16px; + background: $bg; + border: 1px solid $card-hover; + border-radius: 8px; + color: $text; + font-size: 0.95rem; + + &:focus { + outline: none; + border-color: $accent; + } + + &::placeholder { + color: $text-muted; + } + } + + .form-hint { + margin-top: 6px; + font-size: 0.8rem; + color: $text-muted; + } +} + +.form-error { + background: rgba($accent-red, 0.15); + color: $accent-red; + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 16px; + font-size: 0.9rem; +} + +.modal-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 24px; +} + +// Button states +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; } // Debug Page