From 8d124bf0f8b0c9f6dbdfb26cf0fdb23390694d70 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 23 Nov 2025 14:37:35 +0100 Subject: [PATCH] feat: Add initial database migration and UI label component, alongside frontend dependency installation. --- app/alembic/versions/add_category_field.py | 26 ++ app/alembic/versions/add_is_available.py | 26 ++ app/models.py | 2 + app/routers/items.py | 30 +- app/services/direct_search_service.py | 72 +++-- app/services/scheduler_service.py | 11 +- app/services/scraper_service.py | 60 +++- app/services/search_service.py | 2 +- frontend/src/App.jsx | 2 + .../src/components/dashboard/ItemCard.jsx | 41 ++- .../dashboard/PriceHistoryModal.jsx | 227 +++++++++++++++ frontend/src/components/layout/Layout.jsx | 3 +- frontend/src/pages/Dashboard.jsx | 61 +++- frontend/src/pages/MultiSearch.jsx | 271 ++++++++++++++++++ frontend/src/pages/Search.jsx | 32 ++- frontend/src/pages/Settings.jsx | 31 +- init.sql | 2 + 17 files changed, 859 insertions(+), 40 deletions(-) create mode 100644 app/alembic/versions/add_category_field.py create mode 100644 app/alembic/versions/add_is_available.py create mode 100644 frontend/src/components/dashboard/PriceHistoryModal.jsx create mode 100644 frontend/src/pages/MultiSearch.jsx diff --git a/app/alembic/versions/add_category_field.py b/app/alembic/versions/add_category_field.py new file mode 100644 index 0000000..5d165cb --- /dev/null +++ b/app/alembic/versions/add_category_field.py @@ -0,0 +1,26 @@ +"""add_category_to_items + +Revision ID: add_category_field +Revises: add_is_available +Create Date: 2025-11-23 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'add_category_field' +down_revision = 'add_is_available' +branch_labels = None +depends_on = None + + +def upgrade(): + # Add category column to items table + op.add_column('items', sa.Column('category', sa.String(255), nullable=True)) + + +def downgrade(): + # Remove category column from items table + op.drop_column('items', 'category') diff --git a/app/alembic/versions/add_is_available.py b/app/alembic/versions/add_is_available.py new file mode 100644 index 0000000..7aa01c2 --- /dev/null +++ b/app/alembic/versions/add_is_available.py @@ -0,0 +1,26 @@ +"""add_is_available_to_items + +Revision ID: add_is_available +Revises: +Create Date: 2025-11-23 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'add_is_available' +down_revision = None # Update this if there are previous migrations +branch_labels = None +depends_on = None + + +def upgrade(): + # Add is_available column to items table + op.add_column('items', sa.Column('is_available', sa.Boolean(), nullable=True, server_default='true')) + + +def downgrade(): + # Remove is_available column from items table + op.drop_column('items', 'is_available') diff --git a/app/models.py b/app/models.py index 5fee7ac..9f3803d 100644 --- a/app/models.py +++ b/app/models.py @@ -47,6 +47,8 @@ class Item(Base): # New fields current_price: float | None = Column(Float, nullable=True) # type: ignore in_stock: bool | None = Column(Boolean, nullable=True) # type: ignore + is_available: bool | None = Column(Boolean, nullable=True, default=True) # type: ignore # Product still exists + category: str | None = Column(String, nullable=True) # type: ignore # User-defined category tags: str | None = Column(String, nullable=True) # type: ignore description: str | None = Column(String, nullable=True) # type: ignore diff --git a/app/routers/items.py b/app/routers/items.py index e048c0b..2c2964b 100644 --- a/app/routers/items.py +++ b/app/routers/items.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from sqlalchemy.orm import Session -from app import database, schemas +from app import database, models, schemas from app.limiter import limiter from app.services.item_service import ItemService from app.services.scheduler_service import process_item_check @@ -10,8 +10,12 @@ router = APIRouter(prefix="/items", tags=["items"]) @router.get("", response_model=list[schemas.ItemResponse]) -def get_items(db: Session = Depends(database.get_db)): - return ItemService.get_items(db) +def get_items(category: str | None = None, db: Session = Depends(database.get_db)): + """Get all items, optionally filtered by category""" + items = ItemService.get_items(db) + if category: + items = [item for item in items if item.category == category] + return items @router.post("", response_model=schemas.ItemResponse) @@ -29,6 +33,26 @@ def delete_item(item_id: int, db: Session = Depends(database.get_db)): return ItemService.delete_item(db, item_id) +@router.patch("/{item_id}/category") +def update_item_category(item_id: int, category: str | None, db: Session = Depends(database.get_db)): + """Update the category of an item""" + item = db.query(models.Item).filter(models.Item.id == item_id).first() + if not item: + raise HTTPException(status_code=404, detail="Item not found") + + item.category = category + db.commit() + db.refresh(item) + return {"message": "Category updated", "category": category} + + +@router.get("/categories/list") +def get_categories(db: Session = Depends(database.get_db)): + """Get list of distinct categories used in items""" + categories = db.query(models.Item.category).distinct().filter(models.Item.category.isnot(None)).all() + return [cat[0] for cat in categories if cat[0]] + + @router.post("/{item_id}/check") @limiter.limit("10/minute") def check_item( diff --git a/app/services/direct_search_service.py b/app/services/direct_search_service.py index b94ba5a..4821dac 100644 --- a/app/services/direct_search_service.py +++ b/app/services/direct_search_service.py @@ -70,8 +70,10 @@ DEFAULT_SITE_CONFIGS = { "name": "B&M", "search_url": "https://bmstores.fr/module/ambjolisearch/jolisearch?s={query}", "product_selector": ( - ".product-miniature a, .js-product-miniature a, a.product-thumbnail, " - ".products article a, a[href*='/produits/']" + # Cibler uniquement les liens produits, pas les catégories + "a.product-thumbnail[href*='/produits/']:not([href*='/c/']):not([href*='/categorie']), " + ".product-miniature a[href*='/produits/']:not([href*='/c/']), " + ".js-product-miniature a[href*='/produits/']" ), "wait_selector": ".products, .product-miniature, #js-product-list, #search_results", "category": "Discount", @@ -190,10 +192,12 @@ DEFAULT_SITE_CONFIGS = { "name": "Cdiscount", "search_url": "https://www.cdiscount.com/search/10/{query}.html", "product_selector": ( - "a.prdtBImg, .prdtBILDetails a, .product-item a, " - "a[href*='/f-']" + # Sélecteurs spécifiques Cdiscount - produits uniquement + "a.prdtBImg[href*='/f-'][href*='.html'], " + ".prdtBILDetails a[href*='/f-'][href*='.html'], " + "a[href*='/f-'][href*='.html']:not([href*='/l-']):not([href*='/rayon'])" ), - "wait_selector": ".prdtBILDetails, .product-list", + "wait_selector": ".prdtBILDetails, .product-list, #lpBloc", "category": "E-commerce", "requires_js": True, "priority": 12, @@ -203,10 +207,12 @@ DEFAULT_SITE_CONFIGS = { "name": "Darty", "search_url": "https://www.darty.com/nav/recherche?text={query}", "product_selector": ( - ".product-card a, a[href*='/product'], " - ".product-list-item a" + # Cibler les fiches produits, exclure navigation et catégories + "a[href*='/nav/achat/'][href*='.html']:not([href*='/rayon']):not([href*='/promo']), " + ".product-card a[href*='/nav/achat/'], " + "a.product-link[href*='/nav/achat/']" ), - "wait_selector": ".product-card, .product-list", + "wait_selector": ".product-card, .product-list, .product-item", "category": "Électronique", "requires_js": True, "priority": 13, @@ -215,10 +221,12 @@ DEFAULT_SITE_CONFIGS = { "name": "Boulanger", "search_url": "https://www.boulanger.com/resultats?tr={query}", "product_selector": ( - ".product-list__item a, a[href*='/ref/'], " - ".product-card a" + # Cibler uniquement les pages produits avec /ref/ + "a[href*='/ref/'][href*='_']:not([href*='/c/']):not([href*='/rayon']), " + ".product-list__item a[href*='/ref/'], " + ".product-card__link[href*='/ref/']" ), - "wait_selector": ".product-list, .product-card", + "wait_selector": ".product-list, .product-card, .products-list", "category": "Électronique", "requires_js": True, "priority": 14, @@ -241,8 +249,13 @@ DEFAULT_SITE_CONFIGS = { "fnac.com": { "name": "Fnac", "search_url": "https://www.fnac.com/SearchResult/ResultList.aspx?Search={query}", - "product_selector": "a.Article-title, .Article-item a.js-minifa-title", - "wait_selector": ".Article-item", + "product_selector": ( + # Sélecteurs Fnac pour les produits + "a.Article-title[href*='/a'], " + ".Article-item a.js-minifa-title[href*='/a'], " + "a[href*='/a'][href*='.html']:not([href*='/rayon']):not([href*='/univers'])" + ), + "wait_selector": ".Article-item, .Article-list, #SearchResultList", "category": "Culture & Tech", "requires_js": True, "priority": 15, @@ -721,8 +734,9 @@ def _is_navigation_link(href: str) -> bool: """Vérifie si un lien est un lien de navigation (pas un produit)""" nav_patterns = [ # Catégories et navigation - "/category", "/categories", "/categorie", - "/brand", "/brands", "/marque", + "/category", "/categories", "/categorie", "/c/", + "/rayon", "/rayons", "/univers", + "/brand", "/brands", "/marque", "/marques", "/page/", "/pages/", "/cart", "/panier", "/basket", "/checkout", "/commande", @@ -740,13 +754,24 @@ def _is_navigation_link(href: str) -> bool: "/application", "/app/", "/apps/", "/courses", "/course/", "/ep-", "/endpoint", + # Promotions et offres (patterns communs) "/promo/", "/promotions/", "/promotion/", - "/offres/", "/offre/", + "/soldes/", "/solde/", + "/bon-plan", "/bons-plans", + "/offres/", "/offre/", "/offre-", + "/deals/", "/deal/", + "/ventes-flash", "/vente-flash", + # Services et pages informatives "/services/", "/service/", "/magasin", "/magasins", "/stores/", "/store/", "/blog", "/actualites", "/news", "/recettes", "/recipes", "/conseils", "/tips", + "/guide", "/guides", + # Patterns e-commerce spécifiques + "/l-", "/lp-", "/landing", # Landing pages (Cdiscount, etc.) + "/selection", "/selections", + "/top-", "/best-", # Amazon spécifique "/ref=cs_", "/logo", "/nav_", "/gp/help", "/gp/css", "/gp/redirect", "/hz/", @@ -763,6 +788,7 @@ def _is_navigation_link(href: str) -> bool: return any(pattern in href_lower for pattern in nav_patterns) + def _extract_title(element) -> str: """Extrait le titre d'un élément""" # Essayer différentes méthodes @@ -855,16 +881,28 @@ def _is_non_product_url(url: str) -> bool: return True non_product_patterns = [ - "/search", "/category", "/categories", "/brand", "/brands", + # Navigation et catégories + "/search", "/category", "/categories", "/categorie", "/c/", + "/rayon", "/rayons", "/univers", + "/brand", "/brands", "/marque", "/marques", + # Pages utilisateur "/help", "/contact", "/about", "/account", "/cart", "/checkout", "/login", "/register", "/wishlist", "/compare", "/reviews", + # Pages légales "/terms", "/privacy", "/faq", "/shipping", "/returns", + # Erreurs "/503", "/error", "/robot", "/captcha", + # Applications et services "/application", "/courses", "/app/", + # Landing pages et promotions (patterns spécifiques e-commerce français) + "/l-", "/lp-", "/landing", # Cdiscount landing pages + "/promo/", "/soldes/", "/bon-plan", "/offre-", "/deals/", + "/selection", "/top-", "/best-", ] return any(pattern in url_lower for pattern in non_product_patterns) + async def health_check() -> bool: """Vérifie la connectivité avec Browserless""" try: diff --git a/app/services/scheduler_service.py b/app/services/scheduler_service.py index ff9d537..951b677 100644 --- a/app/services/scheduler_service.py +++ b/app/services/scheduler_service.py @@ -103,7 +103,7 @@ async def process_item_check(item_id: int): try: logger.info(f"Checking item: {item_data['name']} ({item_data['url']})") - screenshot_path, page_text = await ScraperService.scrape_item( + screenshot_path, page_text, is_available = await ScraperService.scrape_item( item_data["url"], item_data["selector"], item_id, @@ -113,6 +113,15 @@ async def process_item_check(item_id: int): timeout=config["scraper_timeout"], ) + # Update availability status in database + with database.SessionLocal() as session: + if item := session.query(models.Item).filter(models.Item.id == item_id).first(): + item.is_available = is_available + if not is_available: + logger.warning(f"Product {item_id} marked as unavailable") + item.last_error = "Product no longer available (404 or not found)" + session.commit() + if not screenshot_path: raise Exception("Failed to capture screenshot") diff --git a/app/services/scraper_service.py b/app/services/scraper_service.py index 059185b..0d4bb0a 100644 --- a/app/services/scraper_service.py +++ b/app/services/scraper_service.py @@ -88,10 +88,10 @@ class ScraperService: scroll_pixels: int = 350, text_length: int = 0, timeout: int = 90000, - ) -> tuple[str | None, str]: + ) -> tuple[str | None, str, bool]: """ Scrapes the given URL using Browserless and Playwright. - Returns a tuple: (screenshot_path, page_text) + Returns a tuple: (screenshot_path, page_text, is_available) Args: url: Target URL to scrape @@ -139,7 +139,7 @@ class ScraperService: logger.warning(f"Tentative {attempt + 1} échouée pour {url}") logger.error(f"Toutes les tentatives ont échoué pour {url}") - return None, "" + return None, "", False # Product unavailable if all retries failed @staticmethod async def _do_scrape( # noqa: PLR0913, PLR0912, PLR0915 @@ -150,12 +150,14 @@ class ScraperService: scroll_pixels: int = 350, text_length: int = 0, timeout: int = 90000, - ) -> tuple[str | None, str]: + ) -> tuple[str | None, str, bool]: """Exécute le scraping réel (appelé par scrape_item avec retries).""" async with async_playwright() as p: browser = None context = None page = None + is_available = True # Par défaut, le produit est disponible + try: logger.info(f"Connecting to Browserless at {BROWSERLESS_URL}") @@ -188,9 +190,24 @@ class ScraperService: page = await context.new_page() logger.info(f"Navigating to {url} (Timeout: {timeout}ms)") + response = None try: # First wait for domcontentloaded - this is the minimum we need - await page.goto(url, wait_until="domcontentloaded", timeout=timeout) + response = await page.goto(url, wait_until="domcontentloaded", timeout=timeout) + + # Check HTTP status code + if response: + status = response.status + logger.info(f"HTTP Status: {status}") + + # Detect unavailable products by HTTP status + if status in [404, 410, 451, 503]: + logger.warning(f"Product unavailable - HTTP {status}") + is_available = False + elif status >= 400: + logger.warning(f"HTTP error {status}, marking as potentially unavailable") + is_available = False + logger.info(f"Page loaded (domcontentloaded): {url}") # Then try to wait for networkidle, but don't fail if it times out @@ -203,6 +220,8 @@ class ScraperService: except Exception as e: logger.error(f"Error navigating to {url}: {e}") + # If navigation fails completely, mark as unavailable + is_available = False # Try to take screenshot anyway if page partially loaded pass @@ -347,7 +366,7 @@ class ScraperService: logger.info(f"Screenshot saved to {filename}") except Exception as e: logger.error(f"Screenshot failed: {e}") - return None, "" + return None, "", is_available # Text Extraction (après le screenshot pour ne pas le perdre) page_text = "" @@ -359,15 +378,40 @@ class ScraperService: # Simple truncation page_text = raw_text[:text_length] logger.info(f"Extracted {len(page_text)} characters") + + # Check for "product not found" messages in page text + unavailable_patterns = [ + "produit introuvable", + "produit indisponible", + "page introuvable", + "404", + "product not found", + "item not found", + "page not found", + "n'existe plus", + "plus disponible", + "no longer available", + "article introuvable", + "cette page n'existe pas", + "this page doesn't exist", + ] + + page_text_lower = page_text.lower() + for pattern in unavailable_patterns: + if pattern in page_text_lower: + logger.warning(f"Product unavailable - found pattern: '{pattern}'") + is_available = False + break + except Exception as e: logger.warning(f"Text extraction failed (screenshot saved): {e}") # On continue car le screenshot a été pris - return filename, page_text + return filename, page_text, is_available except Exception as e: logger.error(f"Error scraping {url}: {e}") - return None, "" + return None, "", False # Mark as unavailable on error finally: # Fermeture propre des ressources dans l'ordre inverse diff --git a/app/services/search_service.py b/app/services/search_service.py index 79fe63b..6bdc182 100644 --- a/app/services/search_service.py +++ b/app/services/search_service.py @@ -209,7 +209,7 @@ async def _scrape_with_browserless( """Scrape une URL avec Browserless + extraction IA""" try: # Scraper avec Browserless - screenshot_path, page_text = await ScraperService.scrape_item( + screenshot_path, page_text, is_available = await ScraperService.scrape_item( url=url, item_id=None, smart_scroll=True, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3c0fc52..2d11e0e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,6 +4,7 @@ import { Toaster } from 'sonner' import Layout from '@/components/layout/Layout' import Dashboard from '@/pages/Dashboard' import Search from '@/pages/Search' +import MultiSearch from '@/pages/MultiSearch' import Login from '@/pages/Login' import Admin from '@/pages/Admin' import { useTheme } from '@/hooks/use-theme' @@ -75,6 +76,7 @@ function AppRoutes() { } /> } /> + } /> { if (inStock === true) return { label: t('dashboard.inStock'), color: 'bg-green-500/90 text-white border-green-400/50' }; @@ -134,6 +136,34 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) { )} + {/* Category */} +
+ {item.category ? ( + + + {item.category} + + ) : null} + {onCategoryUpdate && ( + + )} +
+ {/* Description */} {item.description && (

@@ -159,6 +189,11 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {

+ {onViewHistory && ( + + )} diff --git a/frontend/src/components/dashboard/PriceHistoryModal.jsx b/frontend/src/components/dashboard/PriceHistoryModal.jsx new file mode 100644 index 0000000..59e2c1e --- /dev/null +++ b/frontend/src/components/dashboard/PriceHistoryModal.jsx @@ -0,0 +1,227 @@ +import React, { useState, useEffect } from 'react'; +import axios from 'axios'; +import { X, TrendingDown, TrendingUp, Minus } from 'lucide-react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; + +const API_URL = '/api'; + +export function PriceHistoryModal({ open, onClose, item }) { + const [priceHistory, setPriceHistory] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (open && item) { + loadPriceHistory(); + } + }, [open, item]); + + const loadPriceHistory = async () => { + if (!item) return; + + setLoading(true); + try { + const response = await axios.get(`${API_URL}/items/${item.id}/price-history`); + setPriceHistory(response.data); + } catch (error) { + console.error('Error loading price history:', error); + } finally { + setLoading(false); + } + }; + + if (!item) return null; + + // Calculate stats + const prices = priceHistory.map(h => h.price); + const currentPrice = prices[prices.length - 1]; + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + const avgPrice = prices.length > 0 ? prices.reduce((a, b) => a + b, 0) / prices.length : 0; + + // Calculate price change + const priceChange = prices.length > 1 ? ((currentPrice - prices[0]) / prices[0] * 100) : 0; + + // Simple chart rendering + const chartHeight = 200; + const chartWidth = 600; + const padding = 40; + + const renderChart = () => { + if (prices.length === 0) return null; + + const minY = minPrice * 0.95; + const maxY = maxPrice * 1.05; + const rangeY = maxY - minY; + + const points = priceHistory.map((point, index) => { + const x = padding + (index / (priceHistory.length - 1 || 1)) * (chartWidth - 2 * padding); + const y = chartHeight - padding - ((point.price - minY) / rangeY) * (chartHeight - 2 * padding); + return { x, y, price: point.price, date: point.timestamp }; + }); + + const pathData = points.map((point, index) => + `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}` + ).join(' '); + + return ( + + {/* Grid lines */} + {[0, 0.25, 0.5, 0.75, 1].map((ratio) => { + const y = padding + ratio * (chartHeight - 2 * padding); + const price = maxY - ratio * rangeY; + return ( + + + + {price.toFixed(2)}€ + + + ); + })} + + {/* Area under curve */} + + + {/* Line */} + + + {/* Points */} + {points.map((point, index) => ( + + + {`${new Date(point.date).toLocaleDateString('fr-FR')}: ${point.price.toFixed(2)}€`} + + ))} + + ); + }; + + return ( + + + + + Historique des prix + + + + +
+ {/* Product Info */} + + + {/* Stats Cards */} +
+ +
Prix actuel
+
{currentPrice?.toFixed(2) || '--'}€
+
+ +
Prix minimum
+
{minPrice?.toFixed(2) || '--'}€
+
+ +
Prix maximum
+
{maxPrice?.toFixed(2) || '--'}€
+
+ +
Variation
+
0 ? 'text-red-600' : priceChange < 0 ? 'text-green-600' : ''}`}> + {priceChange > 0 && } + {priceChange < 0 && } + {priceChange === 0 && } + {Math.abs(priceChange).toFixed(1)}% +
+
+
+ + {/* Chart */} + {loading ? ( +
+
+
+ ) : priceHistory.length > 0 ? ( +
+ {renderChart()} +
+ ) : ( +
+ Aucun historique de prix disponible +
+ )} + + {/* History Table */} + {priceHistory.length > 0 && ( +
+ + + + + + + + + + {priceHistory.map((entry, index) => { + const prevPrice = index > 0 ? priceHistory[index - 1].price : entry.price; + const change = ((entry.price - prevPrice) / prevPrice * 100); + return ( + + + + + + ); + })} + +
DatePrixVariation
+ {new Date(entry.timestamp).toLocaleString('fr-FR')} + + {entry.price.toFixed(2)}€ + 0 ? 'text-red-600' : change < 0 ? 'text-green-600' : ''}`}> + {change !== 0 ? `${change > 0 ? '+' : ''}${change.toFixed(1)}%` : '-'} +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/layout/Layout.jsx b/frontend/src/components/layout/Layout.jsx index 955d15d..b30360c 100644 --- a/frontend/src/components/layout/Layout.jsx +++ b/frontend/src/components/layout/Layout.jsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { LayoutDashboard, Search, Moon, Sun, Menu, LogOut, Shield } from 'lucide-react'; +import { LayoutDashboard, Search, TrendingUp, Moon, Sun, Menu, LogOut, Shield } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { cn } from '@/lib/utils'; @@ -22,6 +22,7 @@ const Layout = ({ children, theme, toggleTheme }) => { const navItems = [ { icon: LayoutDashboard, label: t('nav.dashboard'), path: '/' }, { icon: Search, label: t('nav.search') || 'Recherche', path: '/search' }, + { icon: TrendingUp, label: 'Comparateur', path: '/compare' }, ]; // Add admin link for admin users diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 570694c..a9ca17f 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -2,12 +2,14 @@ import React, { useState, useEffect } from 'react'; import axios from 'axios'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; -import { Plus, RefreshCw, Search, X } from 'lucide-react'; +import { Plus, RefreshCw, Search, X, Filter } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { ItemCard } from '@/components/dashboard/ItemCard'; import { ItemModal } from '@/components/dashboard/ItemModal'; import { DeleteConfirmationModal } from '@/components/dashboard/DeleteConfirmationModal'; +import { PriceHistoryModal } from '@/components/dashboard/PriceHistoryModal'; const API_URL = '/api'; @@ -15,9 +17,12 @@ export default function Dashboard() { const { t } = useTranslation(); const [items, setItems] = useState([]); const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('all'); + const [categories, setCategories] = useState([]); const [showAddModal, setShowAddModal] = useState(false); const [editingItem, setEditingItem] = useState(null); const [itemToDelete, setItemToDelete] = useState(null); + const [historyItem, setHistoryItem] = useState(null); const [zoomedImage, setZoomedImage] = useState(null); const refreshItems = async () => { @@ -31,8 +36,18 @@ export default function Dashboard() { } }; + const loadCategories = async () => { + try { + const response = await axios.get(`${API_URL}/items/categories/list`); + setCategories(response.data); + } catch (error) { + console.error('Error fetching categories:', error); + } + }; + useEffect(() => { refreshItems(); + loadCategories(); const interval = setInterval(refreshItems, 10000); return () => clearInterval(interval); }, []); @@ -75,14 +90,32 @@ export default function Dashboard() { } }; + const handleCategoryUpdate = async (itemId, category) => { + try { + await axios.patch(`${API_URL}/items/${itemId}/category?category=${encodeURIComponent(category || '')}`); + toast.success('Catégorie mise à jour'); + refreshItems(); + loadCategories(); + } catch (error) { + console.error('Error updating category:', error); + toast.error('Erreur lors de la mise à jour'); + } + }; + const filteredItems = items.filter(item => { const term = searchTerm.toLowerCase(); - return ( + const matchesSearch = ( item.name.toLowerCase().includes(term) || item.url.toLowerCase().includes(term) || (item.tags && item.tags.toLowerCase().includes(term)) || (item.description && item.description.toLowerCase().includes(term)) ); + + const matchesCategory = selectedCategory === 'all' || + (selectedCategory === 'uncategorized' && !item.category) || + item.category === selectedCategory; + + return matchesSearch && matchesCategory; }); return ( @@ -92,7 +125,7 @@ export default function Dashboard() {

{t('dashboard.title')}

{t('dashboard.description')}

-
+
setSearchTerm(e.target.value)} />
+
@@ -146,6 +195,12 @@ export default function Dashboard() { onConfirm={handleDelete} /> + setHistoryItem(null)} + /> + {/* Image Zoom Modal */} {zoomedImage && (
{ + e.preventDefault(); + if (!query.trim()) { + toast.error('Veuillez entrer une recherche'); + return; + } + + // Fermer l'EventSource précédent si existant + if (eventSourceRef.current) { + eventSourceRef.current.close(); + } + + setIsSearching(true); + setResults([]); + setProgress({ status: 'searching', total: 0, completed: 0 }); + + // Construire l'URL - recherche sur TOUS les sites actifs + const params = new URLSearchParams({ + q: query.trim(), + max_results: 50, // Plus de résultats pour la comparaison + }); + + // Créer l'EventSource pour SSE + const eventSource = new EventSource(`${API_URL}/search?${params}`); + eventSourceRef.current = eventSource; + + eventSource.addEventListener('progress', (event) => { + try { + const data = JSON.parse(event.data); + setProgress(data); + setResults(data.results || []); + + if (data.status === 'completed' || data.status === 'error') { + setIsSearching(false); + eventSource.close(); + + if (data.status === 'completed') { + toast.success(`${data.results?.length || 0} produits trouvés`); + } else if (data.message) { + toast.error(data.message); + } + } + } catch (error) { + console.error('Error parsing SSE data:', error); + } + }); + + eventSource.onerror = () => { + setIsSearching(false); + eventSource.close(); + toast.error('Erreur de connexion au serveur'); + }; + }; + + const handleAddToMonitoring = async (result) => { + try { + await axios.post(`${API_URL}/items`, { + url: result.url, + name: result.title, + target_price: null, + check_interval_minutes: 60, + }); + toast.success(`"${result.title}" ajouté au monitoring`); + } catch (error) { + console.error('Error adding item:', error); + toast.error("Erreur lors de l'ajout au monitoring"); + } + }; + + const progressPercent = progress + ? Math.round((progress.completed / Math.max(progress.total, 1)) * 100) + : 0; + + // Grouper les résultats par produit similaire (basé sur le titre) + const groupedResults = results.reduce((acc, result) => { + // Simplifier le titre pour le regroupement + const simplifiedTitle = result.title.toLowerCase().substring(0, 50); + + if (!acc[simplifiedTitle]) { + acc[simplifiedTitle] = []; + } + acc[simplifiedTitle].push(result); + return acc; + }, {}); + + return ( +
+ {/* Header */} +
+
+

Comparateur de Prix

+

+ Recherchez un produit sur tous les sites et comparez les prix +

+
+
+ + {/* Search Form */} + + +
+
+
+ + setQuery(e.target.value)} + className="pl-9" + disabled={isSearching} + /> +
+ +
+
+
+
+ + {/* Progress Bar */} + {progress && isSearching && ( + + +
+
+ {progress.message || 'Recherche en cours...'} + {progressPercent}% +
+ +
+
+
+ )} + + {/* Results Table */} + {results.length > 0 && ( + + + + + Résultats de comparaison + + + {results.length} produit{results.length > 1 ? 's' : ''} trouvé{results.length > 1 ? 's' : ''} avec prix + + + +
+ + + + + + + + + + + + {results.map((result, index) => ( + + + + + + + + ))} + +
SiteProduitPrixStockActions
+
+ {result.site_name} + {result.site_domain} +
+
+
+

{result.title}

+
+
+ {result.price !== null ? ( + + {result.price.toFixed(2)} {result.currency || 'EUR'} + + ) : ( + Prix non disponible + )} + + {result.in_stock === true && ( + + + En stock + + )} + {result.in_stock === false && ( + Rupture + )} + {result.in_stock === null && ( + - + )} + +
+ + +
+
+
+
+
+ )} + + {/* Empty State */} + {!isSearching && results.length === 0 && progress?.status === 'completed' && ( + + + +

Aucun produit trouvé

+

+ Essayez avec d'autres termes de recherche +

+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/Search.jsx b/frontend/src/pages/Search.jsx index 6bcca66..68c4e26 100644 --- a/frontend/src/pages/Search.jsx +++ b/frontend/src/pages/Search.jsx @@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { SearchResultCard } from '@/components/search/SearchResultCard'; const API_URL = '/api'; +const STORAGE_KEY = 'priceflow_search_state'; export default function Search() { const { t } = useTranslation(); @@ -22,6 +23,32 @@ export default function Search() { const [results, setResults] = useState([]); const eventSourceRef = useRef(null); + // Restore search state from sessionStorage on mount + useEffect(() => { + const savedState = sessionStorage.getItem(STORAGE_KEY); + if (savedState) { + try { + const { query: savedQuery, selectedSiteId: savedSiteId, results: savedResults } = JSON.parse(savedState); + if (savedQuery) setQuery(savedQuery); + if (savedSiteId) setSelectedSiteId(savedSiteId); + if (savedResults) setResults(savedResults); + } catch (error) { + console.error('Error restoring search state:', error); + } + } + }, []); + + // Save search state to sessionStorage whenever it changes + useEffect(() => { + if (query || results.length > 0) { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ + query, + selectedSiteId, + results + })); + } + }, [query, selectedSiteId, results]); + // Charger les sites au démarrage useEffect(() => { loadSites(); @@ -32,7 +59,7 @@ export default function Search() { const response = await axios.get(`${API_URL}/search-sites`); const activeSites = response.data.filter(site => site.is_active); setSites(activeSites); - // Sélectionner le premier site actif par défaut + // Sélectionner le premier site actif par défaut si aucun site n'est sélectionné if (activeSites.length > 0 && !selectedSiteId) { setSelectedSiteId(activeSites[0].id.toString()); } @@ -49,6 +76,9 @@ export default function Search() { return; } + // Clear previous storage when starting new search + sessionStorage.removeItem(STORAGE_KEY); + // Fermer l'EventSource précédent si existant if (eventSourceRef.current) { eventSourceRef.current.close(); diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index e8bcbf0..dab5ea8 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -639,9 +639,36 @@ export default function Settings() { setNewProfile({ ...newProfile, name: e.target.value })} placeholder="e.g., Email Alerts" />
-
+
- setNewProfile({ ...newProfile, apprise_url: e.target.value })} placeholder="mailto://user:pass@gmail.com" /> + setNewProfile({ ...newProfile, apprise_url: e.target.value })} + placeholder="mailto://user:pass@gmail.com" + className="font-mono text-sm" + /> +
+

Exemples d'URLs Apprise :

+
+

• mailto://user:pass@gmail.com - Email

+

• tgram://bottoken/ChatID - Telegram

+

• discord://webhook_id/webhook_token - Discord

+

• slack://tokenA/tokenB/tokenC - Slack

+

• ntfy://topic - Ntfy

+

• gotify://hostname/token - Gotify

+
+

+ + 📚 Documentation complète Apprise + +

+
diff --git a/init.sql b/init.sql index e64ebbf..0b32fc1 100644 --- a/init.sql +++ b/init.sql @@ -29,6 +29,8 @@ CREATE TABLE IF NOT EXISTS items ( check_interval_minutes INTEGER DEFAULT 60, current_price FLOAT, in_stock BOOLEAN, + is_available BOOLEAN DEFAULT TRUE, + category VARCHAR(255), tags VARCHAR(512), description TEXT, current_price_confidence FLOAT,