claude
@@ -8,15 +8,26 @@
|
|||||||
- [x] tests unitaires parser, normalisation, pricing
|
- [x] tests unitaires parser, normalisation, pricing
|
||||||
- [x] intégrer scheduler APScheduler pour `scrape_all`
|
- [x] intégrer scheduler APScheduler pour `scrape_all`
|
||||||
|
|
||||||
## Phase 2 - Frontend (EN COURS)
|
## Phase 2 - Frontend (TERMINÉ ✓)
|
||||||
- [ ] connecter App.jsx à l'API backend (fetch produits)
|
- [x] page debug avec tables SQLite et logs (Étape 1)
|
||||||
- [ ] implémenter ProductCard avec données réelles
|
- [x] store Zustand pour état global (Étape 2)
|
||||||
- [ ] ajouter formulaire d'ajout de produit (URL Amazon)
|
- [x] connecter App.jsx à l'API backend (fetch produits) (Étape 2)
|
||||||
- [ ] graphique Chart.js historique 30j
|
- [x] ajouter formulaire d'ajout de produit (URL Amazon) (Étape 3)
|
||||||
- [ ] store Zustand pour état global
|
- [x] actions scrape/delete sur produit (Étape 4)
|
||||||
|
- [x] amélioration visuelle ProductCard (Étape 5)
|
||||||
|
- [x] API enrichie avec ProductWithSnapshot
|
||||||
|
- [x] section prix (actuel, conseillé, réduction, min 30j)
|
||||||
|
- [x] badges (Prime, Choix Amazon, Deal, Exclusivité)
|
||||||
|
- [x] note + nombre d'avis
|
||||||
|
- [x] stock status coloré
|
||||||
|
- [x] image non tronquée (object-fit: contain)
|
||||||
|
- [x] grille responsive avec colonnes configurables
|
||||||
|
- [x] graphique Chart.js historique 30j (Étape 6)
|
||||||
|
- [x] composant PriceChart avec chart.js + react-chartjs-2
|
||||||
|
- [x] affichage min/max/tendance
|
||||||
|
- [x] couleurs selon tendance (vert/jaune/rouge)
|
||||||
|
|
||||||
## Phase 3 - Industrialisation
|
## Phase 3 - Industrialisation
|
||||||
- [ ] dockeriser backend + frontend + scheduler
|
- [ ] dockeriser backend + frontend + scheduler
|
||||||
- [ ] docker-compose avec volumes persistants
|
- [ ] docker-compose avec volumes persistants
|
||||||
- [ ] page debug/logs affichant tables SQLite
|
|
||||||
- [ ] tests E2E frontend
|
- [ ] tests E2E frontend
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, HTTPException
|
from fastapi import APIRouter, Body, HTTPException
|
||||||
|
|
||||||
from backend.app.core.config import BackendConfig, CONFIG_PATH, load_config
|
from backend.app.core.config import BackendConfig, CONFIG_PATH, load_config
|
||||||
|
|
||||||
router = APIRouter(prefix="/config", tags=["config"])
|
router = APIRouter(prefix="/config", tags=["config"])
|
||||||
|
|
||||||
|
# Chemin vers la config frontend
|
||||||
|
FRONTEND_CONFIG_PATH = Path(__file__).resolve().parent.parent.parent.parent / "frontend" / "config_frontend.json"
|
||||||
|
|
||||||
|
|
||||||
@router.get("/backend", response_model=BackendConfig)
|
@router.get("/backend", response_model=BackendConfig)
|
||||||
def read_backend_config() -> BackendConfig:
|
def read_backend_config() -> BackendConfig:
|
||||||
@@ -18,9 +24,55 @@ def update_backend_config(payload: dict = Body(...)) -> BackendConfig:
|
|||||||
current = load_config()
|
current = load_config()
|
||||||
try:
|
try:
|
||||||
# validation via Pydantic avant écriture
|
# validation via Pydantic avant écriture
|
||||||
updated = current.copy(update=payload)
|
updated = current.model_copy(update=payload)
|
||||||
CONFIG_PATH.write_text(updated.json(indent=2, ensure_ascii=False))
|
CONFIG_PATH.write_text(updated.model_dump_json(indent=2), encoding="utf-8")
|
||||||
load_config.cache_clear()
|
load_config.cache_clear()
|
||||||
return load_config()
|
return load_config()
|
||||||
except Exception as exc: # pragma: no cover
|
except Exception as exc: # pragma: no cover
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/frontend")
|
||||||
|
def read_frontend_config() -> dict:
|
||||||
|
"""Retourne la configuration frontend."""
|
||||||
|
if not FRONTEND_CONFIG_PATH.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Config frontend introuvable")
|
||||||
|
return json.loads(FRONTEND_CONFIG_PATH.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/frontend")
|
||||||
|
def update_frontend_config(payload: dict = Body(...)) -> dict:
|
||||||
|
"""Met à jour la configuration frontend."""
|
||||||
|
try:
|
||||||
|
# Charger la config actuelle
|
||||||
|
current = {}
|
||||||
|
if FRONTEND_CONFIG_PATH.exists():
|
||||||
|
current = json.loads(FRONTEND_CONFIG_PATH.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
# Fusion profonde des configs
|
||||||
|
def deep_merge(base: dict, update: dict) -> dict:
|
||||||
|
result = base.copy()
|
||||||
|
for key, value in update.items():
|
||||||
|
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
||||||
|
result[key] = deep_merge(result[key], value)
|
||||||
|
else:
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
updated = deep_merge(current, payload)
|
||||||
|
FRONTEND_CONFIG_PATH.write_text(
|
||||||
|
json.dumps(updated, indent=2, ensure_ascii=False),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mettre à jour aussi dans public/ pour le frontend dev
|
||||||
|
public_config = FRONTEND_CONFIG_PATH.parent / "public" / "config_frontend.json"
|
||||||
|
if public_config.parent.exists():
|
||||||
|
public_config.write_text(
|
||||||
|
json.dumps(updated, indent=2, ensure_ascii=False),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
return updated
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ from backend.app.scraper.runner import scrape_product
|
|||||||
router = APIRouter(prefix="/products", tags=["products"])
|
router = APIRouter(prefix="/products", tags=["products"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[schemas.ProductRead])
|
@router.get("", response_model=list[schemas.ProductWithSnapshot])
|
||||||
def list_products(skip: int = 0, limit: int = 50, db: Session = Depends(get_db)) -> list[schemas.ProductRead]:
|
def list_products(skip: int = 0, limit: int = 50, db: Session = Depends(get_db)) -> list[schemas.ProductWithSnapshot]:
|
||||||
# on retourne la liste paginée de produits
|
# on retourne la liste paginée de produits enrichis avec les derniers snapshots
|
||||||
return crud.list_products(db, skip=skip, limit=limit)
|
return crud.list_products_with_snapshots(db, skip=skip, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=schemas.ProductRead, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=schemas.ProductRead, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -28,9 +28,9 @@ def create_product(
|
|||||||
return product
|
return product
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{product_id}", response_model=schemas.ProductRead)
|
@router.get("/{product_id}", response_model=schemas.ProductWithSnapshot)
|
||||||
def read_product(product_id: int, db: Session = Depends(get_db)) -> schemas.ProductRead:
|
def read_product(product_id: int, db: Session = Depends(get_db)) -> schemas.ProductWithSnapshot:
|
||||||
product = crud.get_product(db, product_id)
|
product = crud.get_product_with_snapshot(db, product_id)
|
||||||
if not product:
|
if not product:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Produit introuvable")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Produit introuvable")
|
||||||
return product
|
return product
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ def list_products(db: Session, skip: int = 0, limit: int = 100) -> list[models.P
|
|||||||
|
|
||||||
|
|
||||||
def create_product(db: Session, data: schemas.ProductCreate) -> models.Product:
|
def create_product(db: Session, data: schemas.ProductCreate) -> models.Product:
|
||||||
product = models.Product(**data.dict())
|
# Convertir les HttpUrl en strings pour SQLite
|
||||||
|
data_dict = data.model_dump()
|
||||||
|
if data_dict.get("url"):
|
||||||
|
data_dict["url"] = str(data_dict["url"])
|
||||||
|
if data_dict.get("url_image"):
|
||||||
|
data_dict["url_image"] = str(data_dict["url_image"])
|
||||||
|
|
||||||
|
product = models.Product(**data_dict)
|
||||||
db.add(product)
|
db.add(product)
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -62,3 +69,63 @@ def get_latest_snapshot(db: Session, product_id: int) -> models.ProductSnapshot
|
|||||||
.order_by(models.ProductSnapshot.scrape_le.desc())
|
.order_by(models.ProductSnapshot.scrape_le.desc())
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_product_with_snapshot(db: Session, product_id: int) -> dict | None:
|
||||||
|
"""Retourne un produit enrichi avec les données du dernier snapshot."""
|
||||||
|
product = get_product(db, product_id)
|
||||||
|
if not product:
|
||||||
|
return None
|
||||||
|
return _enrich_product_with_snapshot(db, product)
|
||||||
|
|
||||||
|
|
||||||
|
def list_products_with_snapshots(db: Session, skip: int = 0, limit: int = 100) -> list[dict]:
|
||||||
|
"""Retourne la liste des produits enrichis avec leurs derniers snapshots."""
|
||||||
|
products = list_products(db, skip=skip, limit=limit)
|
||||||
|
return [_enrich_product_with_snapshot(db, p) for p in products]
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_product_with_snapshot(db: Session, product: models.Product) -> dict:
|
||||||
|
"""Ajoute les données du dernier snapshot au produit."""
|
||||||
|
snapshot = get_latest_snapshot(db, product.id)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"id": product.id,
|
||||||
|
"boutique": product.boutique,
|
||||||
|
"url": str(product.url),
|
||||||
|
"asin": product.asin,
|
||||||
|
"titre": product.titre,
|
||||||
|
"url_image": str(product.url_image) if product.url_image else None,
|
||||||
|
"categorie": product.categorie,
|
||||||
|
"type": product.type,
|
||||||
|
"actif": product.actif,
|
||||||
|
"cree_le": product.cree_le,
|
||||||
|
"modifie_le": product.modifie_le,
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot:
|
||||||
|
# Calcul de la réduction en pourcentage
|
||||||
|
reduction = None
|
||||||
|
if snapshot.prix_actuel and snapshot.prix_conseille:
|
||||||
|
reduction = round((1 - snapshot.prix_actuel / snapshot.prix_conseille) * 100)
|
||||||
|
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"prix_actuel": snapshot.prix_actuel,
|
||||||
|
"prix_conseille": snapshot.prix_conseille,
|
||||||
|
"prix_min_30j": snapshot.prix_min_30j,
|
||||||
|
"reduction_pourcent": reduction,
|
||||||
|
"etat_stock": snapshot.etat_stock,
|
||||||
|
"en_stock": snapshot.en_stock,
|
||||||
|
"note": snapshot.note,
|
||||||
|
"nombre_avis": snapshot.nombre_avis,
|
||||||
|
"prime": snapshot.prime,
|
||||||
|
"choix_amazon": snapshot.choix_amazon,
|
||||||
|
"offre_limitee": snapshot.offre_limitee,
|
||||||
|
"exclusivite_amazon": snapshot.exclusivite_amazon,
|
||||||
|
"dernier_scrape": snapshot.scrape_le,
|
||||||
|
"statut_scrap": snapshot.statut_scrap,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|||||||
@@ -61,3 +61,29 @@ class ProductSnapshotRead(ProductSnapshotBase):
|
|||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
orm_mode = True
|
orm_mode = True
|
||||||
|
|
||||||
|
|
||||||
|
class ProductWithSnapshot(ProductBase):
|
||||||
|
"""Produit enrichi avec les données du dernier snapshot."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
cree_le: datetime
|
||||||
|
modifie_le: datetime
|
||||||
|
# Données du dernier snapshot
|
||||||
|
prix_actuel: Optional[float] = None
|
||||||
|
prix_conseille: Optional[float] = None
|
||||||
|
prix_min_30j: Optional[float] = None
|
||||||
|
reduction_pourcent: Optional[int] = None
|
||||||
|
etat_stock: Optional[str] = None
|
||||||
|
en_stock: Optional[bool] = None
|
||||||
|
note: Optional[float] = None
|
||||||
|
nombre_avis: Optional[int] = None
|
||||||
|
prime: Optional[bool] = None
|
||||||
|
choix_amazon: Optional[bool] = None
|
||||||
|
offre_limitee: Optional[bool] = None
|
||||||
|
exclusivite_amazon: Optional[bool] = None
|
||||||
|
dernier_scrape: Optional[datetime] = None
|
||||||
|
statut_scrap: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from os import getenv
|
from os import getenv
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from backend.app.api import routes_config, routes_debug, routes_products, routes_scrape
|
from backend.app.api import routes_config, routes_debug, routes_products, routes_scrape
|
||||||
@@ -14,6 +15,15 @@ load_dotenv()
|
|||||||
|
|
||||||
app = FastAPI(title="suivi_produit")
|
app = FastAPI(title="suivi_produit")
|
||||||
|
|
||||||
|
# CORS pour le frontend
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
app.include_router(routes_products.router)
|
app.include_router(routes_products.router)
|
||||||
app.include_router(routes_scrape.router)
|
app.include_router(routes_scrape.router)
|
||||||
app.include_router(routes_config.router)
|
app.include_router(routes_config.router)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 3.8 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 913 KiB |
@@ -13,7 +13,13 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from backend.app.core.config import load_config
|
from backend.app.core.config import load_config
|
||||||
from backend.app.db import database, models
|
from backend.app.db import database, models
|
||||||
from backend.app.scraper.amazon.parser import detect_blocked, extract_product_data
|
from backend.app.scraper.amazon.parser import extract_product_data
|
||||||
|
|
||||||
|
# Répertoires de stockage
|
||||||
|
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "samples"
|
||||||
|
DEBUG_DIR = SAMPLES_DIR / "debug"
|
||||||
|
STORAGE_STATE_PATH = SAMPLES_DIR / "storage_state.json"
|
||||||
|
RAW_DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "raw"
|
||||||
|
|
||||||
|
|
||||||
def _create_run(session: Session) -> models.ScrapeRun:
|
def _create_run(session: Session) -> models.ScrapeRun:
|
||||||
@@ -32,9 +38,8 @@ def _finalize_run(run: models.ScrapeRun, session: Session, status: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _save_raw_json(payload: dict, product_id: int) -> Path:
|
def _save_raw_json(payload: dict, product_id: int) -> Path:
|
||||||
base_dir = Path(__file__).resolve().parent.parent.parent / "data" / "raw"
|
|
||||||
timestamp = datetime.utcnow().strftime("%Y-%m-%d")
|
timestamp = datetime.utcnow().strftime("%Y-%m-%d")
|
||||||
folder = base_dir / timestamp
|
folder = RAW_DATA_DIR / timestamp
|
||||||
folder.mkdir(parents=True, exist_ok=True)
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
filename = f"{product_id}_{datetime.utcnow().strftime('%H%M%S')}.json"
|
filename = f"{product_id}_{datetime.utcnow().strftime('%H%M%S')}.json"
|
||||||
path = folder / filename
|
path = folder / filename
|
||||||
@@ -42,15 +47,24 @@ def _save_raw_json(payload: dict, product_id: int) -> Path:
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def _save_debug_artifacts(page, product_id: int) -> tuple[Path, Path]:
|
def _save_debug_artifacts(page, product_id: int, suffix: str = "capture") -> dict:
|
||||||
base_dir = Path(__file__).resolve().parent.parent.parent / "data" / "screenshots"
|
"""Sauvegarde screenshot et HTML dans le répertoire debug."""
|
||||||
base_dir.mkdir(parents=True, exist_ok=True)
|
DEBUG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
stamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
stamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||||
screenshot_path = base_dir / f"{product_id}_{stamp}.png"
|
debug_files = {}
|
||||||
html_path = base_dir / f"{product_id}_{stamp}.html"
|
try:
|
||||||
|
screenshot_path = DEBUG_DIR / f"{product_id}_{stamp}_{suffix}.png"
|
||||||
|
html_path = DEBUG_DIR / f"{product_id}_{stamp}_{suffix}.html"
|
||||||
page.screenshot(path=str(screenshot_path), full_page=True)
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
||||||
html_path.write_text(page.content())
|
html_path.write_text(page.content(), encoding="utf-8")
|
||||||
return screenshot_path, html_path
|
debug_files = {
|
||||||
|
"screenshot": str(screenshot_path),
|
||||||
|
"html": str(html_path),
|
||||||
|
}
|
||||||
|
logger.info("Artifacts debug sauvegardés: screenshot={}, html={}", screenshot_path.name, html_path.name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Impossible de générer les artifacts de debug: {}", e)
|
||||||
|
return debug_files
|
||||||
|
|
||||||
|
|
||||||
def _update_product_from_scrape(
|
def _update_product_from_scrape(
|
||||||
@@ -101,37 +115,56 @@ def _create_snapshot(
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
def scrape_product(product_id: int) -> None:
|
def _create_browser_context(playwright, config):
|
||||||
logger.info("Déclenchement du scraping pour le produit %s", product_id)
|
"""Crée un contexte navigateur avec storage_state si disponible."""
|
||||||
session = database.SessionLocal()
|
|
||||||
run = _create_run(session)
|
|
||||||
try:
|
|
||||||
product = session.get(models.Product, product_id)
|
|
||||||
if not product:
|
|
||||||
logger.warning("Produit %s introuvable", product_id)
|
|
||||||
_finalize_run(run, session, "echec")
|
|
||||||
return
|
|
||||||
config = load_config()
|
|
||||||
run.nb_total = 1
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
with sync_playwright() as playwright:
|
|
||||||
browser = playwright.chromium.launch(headless=config.scrape.headless)
|
browser = playwright.chromium.launch(headless=config.scrape.headless)
|
||||||
context = browser.new_context(
|
context_kwargs = {
|
||||||
locale=config.scrape.locale,
|
"locale": config.scrape.locale,
|
||||||
timezone_id=config.scrape.timezone,
|
"timezone_id": config.scrape.timezone,
|
||||||
user_agent=config.scrape.user_agent,
|
"user_agent": config.scrape.user_agent,
|
||||||
viewport=config.scrape.viewport,
|
"viewport": config.scrape.viewport,
|
||||||
)
|
}
|
||||||
page = context.new_page()
|
# Charger la session persistée si disponible
|
||||||
page.set_default_timeout(config.scrape.timeout_ms)
|
if STORAGE_STATE_PATH.exists():
|
||||||
|
context_kwargs["storage_state"] = str(STORAGE_STATE_PATH)
|
||||||
|
logger.info("Session persistée chargée: {}", STORAGE_STATE_PATH)
|
||||||
|
|
||||||
|
context = browser.new_context(**context_kwargs)
|
||||||
|
return browser, context
|
||||||
|
|
||||||
|
|
||||||
|
def _save_storage_state(context) -> None:
|
||||||
|
"""Sauvegarde l'état de session pour réutilisation."""
|
||||||
try:
|
try:
|
||||||
|
context.storage_state(path=str(STORAGE_STATE_PATH))
|
||||||
|
logger.info("Session persistée sauvegardée: {}", STORAGE_STATE_PATH)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Impossible de sauvegarder la session: {}", e)
|
||||||
|
|
||||||
|
|
||||||
|
def _process_product(
|
||||||
|
page,
|
||||||
|
session: Session,
|
||||||
|
product: models.Product,
|
||||||
|
run: models.ScrapeRun,
|
||||||
|
config,
|
||||||
|
) -> tuple[bool, dict]:
|
||||||
|
"""Scrape un produit et retourne (success, data)."""
|
||||||
|
logger.info("Scraping produit {} ({})", product.id, product.url)
|
||||||
|
|
||||||
page.goto(product.url, wait_until="domcontentloaded", timeout=config.scrape.timeout_ms)
|
page.goto(product.url, wait_until="domcontentloaded", timeout=config.scrape.timeout_ms)
|
||||||
|
|
||||||
html = page.content()
|
# Toujours sauvegarder les artifacts de debug
|
||||||
if detect_blocked(html):
|
debug_files = _save_debug_artifacts(page, product.id, "capture")
|
||||||
screenshot_path, html_path = _save_debug_artifacts(page, product.id)
|
|
||||||
data = {"url": product.url, "asin": product.asin, "bloque": True}
|
# Extraire les données
|
||||||
|
data = extract_product_data(page, product.url)
|
||||||
|
|
||||||
|
# Vérifier si bloqué (pas de titre = probable blocage)
|
||||||
|
if not data.get("titre"):
|
||||||
|
logger.warning("Titre absent pour produit {}, probable blocage Amazon", product.id)
|
||||||
|
data["bloque"] = True
|
||||||
|
data["debug_files"] = debug_files
|
||||||
raw_path = _save_raw_json(data, product.id)
|
raw_path = _save_raw_json(data, product.id)
|
||||||
_create_snapshot(
|
_create_snapshot(
|
||||||
session,
|
session,
|
||||||
@@ -140,17 +173,17 @@ def scrape_product(product_id: int) -> None:
|
|||||||
data,
|
data,
|
||||||
status="bloque",
|
status="bloque",
|
||||||
raw_json_path=raw_path,
|
raw_json_path=raw_path,
|
||||||
error_message=f"Bloque: {screenshot_path.name} / {html_path.name}",
|
error_message=f"Blocage détecté - debug: {debug_files.get('screenshot', 'N/A')}",
|
||||||
)
|
)
|
||||||
run.nb_echec = 1
|
return False, data
|
||||||
_finalize_run(run, session, "partiel")
|
|
||||||
return
|
|
||||||
|
|
||||||
data = extract_product_data(page, product.url)
|
# Succès ou partiel
|
||||||
|
data["debug_files"] = debug_files
|
||||||
raw_path = _save_raw_json(data, product.id)
|
raw_path = _save_raw_json(data, product.id)
|
||||||
required = ["titre", "prix_actuel", "note"]
|
required = ["titre", "prix_actuel"]
|
||||||
missing = [field for field in required if not data.get(field)]
|
missing = [field for field in required if not data.get(field)]
|
||||||
status = "champs_manquants" if missing else "ok"
|
status = "champs_manquants" if missing else "ok"
|
||||||
|
|
||||||
_create_snapshot(
|
_create_snapshot(
|
||||||
session,
|
session,
|
||||||
product,
|
product,
|
||||||
@@ -160,18 +193,52 @@ def scrape_product(product_id: int) -> None:
|
|||||||
raw_json_path=raw_path,
|
raw_json_path=raw_path,
|
||||||
error_message=", ".join(missing) if missing else None,
|
error_message=", ".join(missing) if missing else None,
|
||||||
)
|
)
|
||||||
run.nb_ok = 1 if not missing else 0
|
|
||||||
run.nb_echec = 0 if not missing else 1
|
|
||||||
_finalize_run(run, session, "succes" if not missing else "partiel")
|
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
logger.warning("Champs manquants pour {}: {}", product.id, missing)
|
||||||
|
return False, data
|
||||||
|
|
||||||
|
logger.info("Scraping OK pour {} (titre={})", product.id, data.get("titre", "")[:50])
|
||||||
|
return True, data
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_product(product_id: int) -> None:
|
||||||
|
logger.info("Déclenchement du scraping pour le produit {}", product_id)
|
||||||
|
session = database.SessionLocal()
|
||||||
|
run = _create_run(session)
|
||||||
|
try:
|
||||||
|
product = session.get(models.Product, product_id)
|
||||||
|
if not product:
|
||||||
|
logger.warning("Produit {} introuvable", product_id)
|
||||||
|
_finalize_run(run, session, "echec")
|
||||||
|
return
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
run.nb_total = 1
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
browser, context = _create_browser_context(playwright, config)
|
||||||
|
page = context.new_page()
|
||||||
|
page.set_default_timeout(config.scrape.timeout_ms)
|
||||||
|
|
||||||
|
try:
|
||||||
|
success, _ = _process_product(page, session, product, run, config)
|
||||||
|
run.nb_ok = 1 if success else 0
|
||||||
|
run.nb_echec = 0 if success else 1
|
||||||
|
_finalize_run(run, session, "succes" if success else "partiel")
|
||||||
|
|
||||||
|
# Sauvegarder la session pour réutilisation
|
||||||
|
_save_storage_state(context)
|
||||||
|
|
||||||
|
# Délai anti-blocage
|
||||||
delay_min, delay_max = config.scrape.delay_range_ms
|
delay_min, delay_max = config.scrape.delay_range_ms
|
||||||
time.sleep(random.uniform(delay_min, delay_max) / 1000.0)
|
time.sleep(random.uniform(delay_min, delay_max) / 1000.0)
|
||||||
finally:
|
finally:
|
||||||
# fermeture propre du navigateur
|
|
||||||
context.close()
|
context.close()
|
||||||
browser.close()
|
browser.close()
|
||||||
except Exception: # pragma: no cover
|
except Exception as e:
|
||||||
logger.exception("Erreur pendant le scraping de %s", product_id)
|
logger.exception("Erreur pendant le scraping de {}: {}", product_id, e)
|
||||||
_finalize_run(run, session, "erreur")
|
_finalize_run(run, session, "erreur")
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
@@ -183,20 +250,19 @@ def scrape_all(product_ids: Iterable[int] | None = None) -> None:
|
|||||||
run = _create_run(session)
|
run = _create_run(session)
|
||||||
try:
|
try:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
products = session.query(models.Product).all()
|
products = session.query(models.Product).filter(models.Product.actif == True).all()
|
||||||
if product_ids:
|
if product_ids:
|
||||||
products = [product for product in products if product.id in product_ids]
|
products = [product for product in products if product.id in product_ids]
|
||||||
run.nb_total = len(products)
|
run.nb_total = len(products)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
if not products:
|
||||||
|
logger.info("Aucun produit actif à scraper")
|
||||||
|
_finalize_run(run, session, "succes")
|
||||||
|
return
|
||||||
|
|
||||||
with sync_playwright() as playwright:
|
with sync_playwright() as playwright:
|
||||||
browser = playwright.chromium.launch(headless=config.scrape.headless)
|
browser, context = _create_browser_context(playwright, config)
|
||||||
context = browser.new_context(
|
|
||||||
locale=config.scrape.locale,
|
|
||||||
timezone_id=config.scrape.timezone,
|
|
||||||
user_agent=config.scrape.user_agent,
|
|
||||||
viewport=config.scrape.viewport,
|
|
||||||
)
|
|
||||||
page = context.new_page()
|
page = context.new_page()
|
||||||
page.set_default_timeout(config.scrape.timeout_ms)
|
page.set_default_timeout(config.scrape.timeout_ms)
|
||||||
|
|
||||||
@@ -205,55 +271,31 @@ def scrape_all(product_ids: Iterable[int] | None = None) -> None:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
for product in products:
|
for product in products:
|
||||||
page.goto(product.url, wait_until="domcontentloaded", timeout=config.scrape.timeout_ms)
|
try:
|
||||||
html = page.content()
|
success, _ = _process_product(page, session, product, run, config)
|
||||||
if detect_blocked(html):
|
if success:
|
||||||
screenshot_path, html_path = _save_debug_artifacts(page, product.id)
|
|
||||||
data = {"url": product.url, "asin": product.asin, "bloque": True}
|
|
||||||
raw_path = _save_raw_json(data, product.id)
|
|
||||||
_create_snapshot(
|
|
||||||
session,
|
|
||||||
product,
|
|
||||||
run,
|
|
||||||
data,
|
|
||||||
status="bloque",
|
|
||||||
raw_json_path=raw_path,
|
|
||||||
error_message=f"Bloque: {screenshot_path.name} / {html_path.name}",
|
|
||||||
)
|
|
||||||
nb_echec += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
data = extract_product_data(page, product.url)
|
|
||||||
raw_path = _save_raw_json(data, product.id)
|
|
||||||
required = ["titre", "prix_actuel", "note"]
|
|
||||||
missing = [field for field in required if not data.get(field)]
|
|
||||||
status = "champs_manquants" if missing else "ok"
|
|
||||||
_create_snapshot(
|
|
||||||
session,
|
|
||||||
product,
|
|
||||||
run,
|
|
||||||
data,
|
|
||||||
status=status,
|
|
||||||
raw_json_path=raw_path,
|
|
||||||
error_message=", ".join(missing) if missing else None,
|
|
||||||
)
|
|
||||||
if missing:
|
|
||||||
nb_echec += 1
|
|
||||||
else:
|
|
||||||
nb_ok += 1
|
nb_ok += 1
|
||||||
|
else:
|
||||||
|
nb_echec += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Erreur scraping produit {}: {}", product.id, e)
|
||||||
|
nb_echec += 1
|
||||||
|
|
||||||
|
# Délai anti-blocage entre les produits
|
||||||
delay_min, delay_max = config.scrape.delay_range_ms
|
delay_min, delay_max = config.scrape.delay_range_ms
|
||||||
time.sleep(random.uniform(delay_min, delay_max) / 1000.0)
|
time.sleep(random.uniform(delay_min, delay_max) / 1000.0)
|
||||||
|
|
||||||
run.nb_ok = nb_ok
|
run.nb_ok = nb_ok
|
||||||
run.nb_echec = nb_echec
|
run.nb_echec = nb_echec
|
||||||
_finalize_run(run, session, "succes" if nb_echec == 0 else "partiel")
|
_finalize_run(run, session, "succes" if nb_echec == 0 else "partiel")
|
||||||
|
|
||||||
|
# Sauvegarder la session pour réutilisation
|
||||||
|
_save_storage_state(context)
|
||||||
finally:
|
finally:
|
||||||
# fermeture propre du navigateur
|
|
||||||
context.close()
|
context.close()
|
||||||
browser.close()
|
browser.close()
|
||||||
except Exception: # pragma: no cover
|
except Exception as e:
|
||||||
logger.exception("Erreur du scraping global")
|
logger.exception("Erreur du scraping global: {}", e)
|
||||||
_finalize_run(run, session, "erreur")
|
_finalize_run(run, session, "erreur")
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|||||||
@@ -10,21 +10,47 @@
|
|||||||
"headless": true,
|
"headless": true,
|
||||||
"timeout_ms": 30000,
|
"timeout_ms": 30000,
|
||||||
"retries": 1,
|
"retries": 1,
|
||||||
"delay_range_ms": [1000, 3000],
|
"delay_range_ms": [
|
||||||
|
1000,
|
||||||
|
3000
|
||||||
|
],
|
||||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36",
|
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36",
|
||||||
"viewport": { "width": 1366, "height": 768 },
|
"viewport": {
|
||||||
|
"width": 1366,
|
||||||
|
"height": 768
|
||||||
|
},
|
||||||
"locale": "fr-FR",
|
"locale": "fr-FR",
|
||||||
"timezone": "Europe/Paris",
|
"timezone": "Europe/Paris",
|
||||||
"proxy": null
|
"proxy": null
|
||||||
},
|
},
|
||||||
"stores_enabled": ["amazon_fr"],
|
"stores_enabled": [
|
||||||
|
"amazon_fr"
|
||||||
|
],
|
||||||
"taxonomy": {
|
"taxonomy": {
|
||||||
"categories": ["SSD", "CPU", "GPU", "RAM"],
|
"categories": [
|
||||||
|
"SSD",
|
||||||
|
"CPU",
|
||||||
|
"GPU",
|
||||||
|
"RAM",
|
||||||
|
"Laptop"
|
||||||
|
],
|
||||||
"types_by_category": {
|
"types_by_category": {
|
||||||
"SSD": ["NVMe", "SATA"],
|
"SSD": [
|
||||||
"CPU": ["Desktop", "Mobile"],
|
"NVMe",
|
||||||
"GPU": ["Gaming", "Workstation"],
|
"SATA"
|
||||||
"RAM": ["DDR4", "DDR5"]
|
],
|
||||||
|
"CPU": [
|
||||||
|
"Desktop",
|
||||||
|
"Mobile"
|
||||||
|
],
|
||||||
|
"GPU": [
|
||||||
|
"Gaming",
|
||||||
|
"Workstation"
|
||||||
|
],
|
||||||
|
"RAM": [
|
||||||
|
"DDR4",
|
||||||
|
"DDR5"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,31 +141,35 @@ Frontend :
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Étape 5 : Amélioration visuelle ProductCard
|
### Étape 5 : Amélioration visuelle ProductCard (TERMINÉ)
|
||||||
**Objectif** : Vignette produit complète selon le schéma CLAUDE.md
|
**Objectif** : Vignette produit complète selon le schéma CLAUDE.md
|
||||||
|
|
||||||
Frontend :
|
Frontend :
|
||||||
- [ ] Image non tronquée (object-fit: contain)
|
- [x] Image non tronquée (object-fit: contain)
|
||||||
- [ ] Section prix (actuel, conseillé, réduction)
|
- [x] Section prix (actuel, conseillé, réduction)
|
||||||
- [ ] Badges (Prime, Choix Amazon, Deal, Exclusivité)
|
- [x] Badges (Prime, Choix Amazon, Deal, Exclusivité)
|
||||||
- [ ] Note + nombre d'avis
|
- [x] Note + nombre d'avis
|
||||||
- [ ] Stock status
|
- [x] Stock status
|
||||||
- [ ] Responsive grid (colonnes configurables via config_frontend.json)
|
- [x] Responsive grid (colonnes configurables via config_frontend.json)
|
||||||
|
|
||||||
|
Backend :
|
||||||
|
- [x] Créer schéma ProductWithSnapshot
|
||||||
|
- [x] Modifier API pour retourner produits avec derniers snapshots
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Étape 6 : Graphique historique (Phase 2.5)
|
### Étape 6 : Graphique historique (Phase 2.5) (TERMINÉ)
|
||||||
**Objectif** : Visualiser l'évolution des prix sur 30 jours
|
**Objectif** : Visualiser l'évolution des prix sur 30 jours
|
||||||
|
|
||||||
Backend :
|
Backend :
|
||||||
- [ ] Créer endpoint `GET /products/{id}/snapshots`
|
- [x] Endpoint `GET /products/{id}/snapshots` (existait déjà)
|
||||||
|
|
||||||
Frontend :
|
Frontend :
|
||||||
- [ ] Installer chart.js + react-chartjs-2
|
- [x] Installer chart.js + react-chartjs-2
|
||||||
- [ ] Créer composant PriceChart
|
- [x] Créer composant PriceChart
|
||||||
- [ ] Intégrer dans ProductCard
|
- [x] Intégrer dans ProductCard
|
||||||
- [ ] Afficher min/max/tendance sous le graphique
|
- [x] Afficher min/max/tendance sous le graphique
|
||||||
- [ ] Couleurs selon tendance (vert baisse, orange stable, rouge hausse)
|
- [x] Couleurs selon tendance (vert baisse, orange stable, rouge hausse)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"ui": {
|
"ui": {
|
||||||
"theme": "gruvbox_vintage_dark",
|
"theme": "gruvbox_vintage_dark",
|
||||||
"button_mode": "text/icon",
|
"button_mode": "text/icon",
|
||||||
"columns_desktop": 3,
|
"columns_desktop": 4,
|
||||||
"card_density": "comfortable",
|
"card_density": "comfortable",
|
||||||
"show_fields": {
|
"show_fields": {
|
||||||
"price": true,
|
"price": true,
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.5.0",
|
"@fortawesome/fontawesome-free": "^6.5.0",
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.5.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.22.0",
|
"react-router-dom": "^6.22.0",
|
||||||
"zustand": "^4.4.0"
|
"zustand": "^4.4.0"
|
||||||
@@ -1907,6 +1908,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-chartjs-2": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": "^4.1.1",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
|
|||||||
@@ -8,16 +8,17 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "^6.5.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.22.0",
|
"react-router-dom": "^6.22.0",
|
||||||
"chart.js": "^4.4.0",
|
"zustand": "^4.4.0"
|
||||||
"zustand": "^4.4.0",
|
|
||||||
"@fortawesome/fontawesome-free": "^6.5.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^5.1.3",
|
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
"sass": "^1.77.0"
|
"sass": "^1.77.0",
|
||||||
|
"vite": "^5.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"ui": {
|
||||||
|
"theme": "gruvbox_vintage_dark",
|
||||||
|
"button_mode": "text/icon",
|
||||||
|
"columns_desktop": 4,
|
||||||
|
"card_density": "comfortable",
|
||||||
|
"show_fields": {
|
||||||
|
"price": true,
|
||||||
|
"stock": true,
|
||||||
|
"ratings": true,
|
||||||
|
"badges": true
|
||||||
|
},
|
||||||
|
"refresh_auto_seconds": 300
|
||||||
|
},
|
||||||
|
"versions": {
|
||||||
|
"frontend": "0.1.0",
|
||||||
|
"backend_expected": "0.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React, { useState } from "react";
|
|||||||
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
|
||||||
import HomePage from "./pages/HomePage";
|
import HomePage from "./pages/HomePage";
|
||||||
import DebugPage from "./pages/DebugPage";
|
import DebugPage from "./pages/DebugPage";
|
||||||
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
import useProductStore from "./stores/useProductStore";
|
import useProductStore from "./stores/useProductStore";
|
||||||
import AddProductModal from "./components/products/AddProductModal";
|
import AddProductModal from "./components/products/AddProductModal";
|
||||||
|
|
||||||
@@ -35,6 +36,9 @@ const Header = () => {
|
|||||||
<NavLink to="/debug" className={({ isActive }) => isActive ? "active" : ""}>
|
<NavLink to="/debug" className={({ isActive }) => isActive ? "active" : ""}>
|
||||||
<i className="fa-solid fa-bug"></i> Debug
|
<i className="fa-solid fa-bug"></i> Debug
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink to="/settings" className={({ isActive }) => isActive ? "active" : ""}>
|
||||||
|
<i className="fa-solid fa-cog"></i> Settings
|
||||||
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
|
<button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
|
||||||
@@ -63,6 +67,7 @@ const App = () => (
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/debug" element={<DebugPage />} />
|
<Route path="/debug" element={<DebugPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -80,3 +80,33 @@ export const fetchDebugLogs = async (lines = 100) => {
|
|||||||
const response = await fetch(`${BASE_URL}/debug/logs?lines=${lines}`);
|
const response = await fetch(`${BASE_URL}/debug/logs?lines=${lines}`);
|
||||||
return handleResponse(response);
|
return handleResponse(response);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Config frontend (via API backend)
|
||||||
|
export const fetchFrontendConfig = async () => {
|
||||||
|
const response = await fetch(`${BASE_URL}/config/frontend`);
|
||||||
|
return handleResponse(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateFrontendConfig = async (config) => {
|
||||||
|
const response = await fetch(`${BASE_URL}/config/frontend`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Config backend
|
||||||
|
export const fetchBackendConfig = async () => {
|
||||||
|
const response = await fetch(`${BASE_URL}/config/backend`);
|
||||||
|
return handleResponse(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateBackendConfig = async (config) => {
|
||||||
|
const response = await fetch(`${BASE_URL}/config/backend`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Filler,
|
||||||
|
} from "chart.js";
|
||||||
|
import { Line } from "react-chartjs-2";
|
||||||
|
import * as api from "../../api/client";
|
||||||
|
|
||||||
|
// Enregistrer les composants Chart.js
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Filler
|
||||||
|
);
|
||||||
|
|
||||||
|
// Couleurs Gruvbox
|
||||||
|
const COLORS = {
|
||||||
|
green: "#b8bb26",
|
||||||
|
yellow: "#fabd2f",
|
||||||
|
red: "#fb4934",
|
||||||
|
orange: "#fe8019",
|
||||||
|
text: "#ebdbb2",
|
||||||
|
muted: "#928374",
|
||||||
|
bg: "#282828",
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPrice = (price) => {
|
||||||
|
if (price == null) return "-";
|
||||||
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(price);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTrend = (snapshots) => {
|
||||||
|
if (!snapshots || snapshots.length < 2) {
|
||||||
|
return { direction: "stable", percent: 0, color: COLORS.yellow, arrow: "→" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const prices = snapshots
|
||||||
|
.filter((s) => s.prix_actuel != null)
|
||||||
|
.map((s) => s.prix_actuel);
|
||||||
|
|
||||||
|
if (prices.length < 2) {
|
||||||
|
return { direction: "stable", percent: 0, color: COLORS.yellow, arrow: "→" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = prices[prices.length - 1]; // Le plus ancien
|
||||||
|
const last = prices[0]; // Le plus récent
|
||||||
|
const percentChange = ((last - first) / first) * 100;
|
||||||
|
|
||||||
|
if (percentChange < -2) {
|
||||||
|
return { direction: "down", percent: percentChange, color: COLORS.green, arrow: "↓" };
|
||||||
|
} else if (percentChange > 2) {
|
||||||
|
return { direction: "up", percent: percentChange, color: COLORS.red, arrow: "↑" };
|
||||||
|
}
|
||||||
|
return { direction: "stable", percent: percentChange, color: COLORS.yellow, arrow: "→" };
|
||||||
|
};
|
||||||
|
|
||||||
|
const PriceChart = ({ productId }) => {
|
||||||
|
const [snapshots, setSnapshots] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSnapshots = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await api.fetchSnapshots(productId, 30);
|
||||||
|
setSnapshots(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSnapshots();
|
||||||
|
}, [productId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="price-chart-loading">
|
||||||
|
<i className="fa-solid fa-spinner fa-spin"></i>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="price-chart-error">Erreur: {error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshots || snapshots.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="price-chart-empty">
|
||||||
|
<i className="fa-solid fa-chart-line"></i>
|
||||||
|
<span>Pas encore de données</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Préparer les données (inverser pour ordre chronologique)
|
||||||
|
const sortedSnapshots = [...snapshots].reverse();
|
||||||
|
const labels = sortedSnapshots.map((s) => formatDate(s.scrape_le));
|
||||||
|
const prices = sortedSnapshots.map((s) => s.prix_actuel);
|
||||||
|
|
||||||
|
// Calculer min/max
|
||||||
|
const validPrices = prices.filter((p) => p != null);
|
||||||
|
const minPrice = Math.min(...validPrices);
|
||||||
|
const maxPrice = Math.max(...validPrices);
|
||||||
|
|
||||||
|
// Calculer la tendance
|
||||||
|
const trend = calculateTrend(snapshots);
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: prices,
|
||||||
|
borderColor: trend.color,
|
||||||
|
backgroundColor: `${trend.color}20`,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
pointBackgroundColor: trend.color,
|
||||||
|
pointBorderColor: trend.color,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: COLORS.bg,
|
||||||
|
titleColor: COLORS.text,
|
||||||
|
bodyColor: COLORS.text,
|
||||||
|
borderColor: COLORS.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 10,
|
||||||
|
callbacks: {
|
||||||
|
label: (context) => formatPrice(context.raw),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: COLORS.muted,
|
||||||
|
font: { size: 10 },
|
||||||
|
maxRotation: 0,
|
||||||
|
maxTicksLimit: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: {
|
||||||
|
color: `${COLORS.muted}30`,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: COLORS.muted,
|
||||||
|
font: { size: 10 },
|
||||||
|
callback: (value) => formatPrice(value),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastSnapshot = snapshots[0];
|
||||||
|
const lastDate = lastSnapshot
|
||||||
|
? new Date(lastSnapshot.scrape_le).toLocaleDateString("fr-FR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
: "-";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="price-chart-container">
|
||||||
|
<div className="price-chart">
|
||||||
|
<Line data={chartData} options={chartOptions} />
|
||||||
|
</div>
|
||||||
|
<div className="price-chart-stats">
|
||||||
|
<span className="stat">
|
||||||
|
<span className="label">Min</span>
|
||||||
|
<span className="value">{formatPrice(minPrice)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="stat">
|
||||||
|
<span className="label">Max</span>
|
||||||
|
<span className="value">{formatPrice(maxPrice)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="stat trend" style={{ color: trend.color }}>
|
||||||
|
<span className="label">Tendance</span>
|
||||||
|
<span className="value">
|
||||||
|
{trend.arrow} {trend.percent >= 0 ? "+" : ""}
|
||||||
|
{trend.percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="stat">
|
||||||
|
<span className="label">Dernier</span>
|
||||||
|
<span className="value">{lastDate}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PriceChart;
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import useProductStore from "../../stores/useProductStore";
|
import useProductStore from "../../stores/useProductStore";
|
||||||
|
import PriceChart from "../charts/PriceChart";
|
||||||
|
|
||||||
|
const formatPrice = (price) => {
|
||||||
|
if (price == null) return null;
|
||||||
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(price);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (num) => {
|
||||||
|
if (num == null) return null;
|
||||||
|
return new Intl.NumberFormat("fr-FR").format(num);
|
||||||
|
};
|
||||||
|
|
||||||
const ProductCard = ({ product }) => {
|
const ProductCard = ({ product }) => {
|
||||||
const { scrapeProduct, deleteProduct, scraping } = useProductStore();
|
const { scrapeProduct, deleteProduct, scraping } = useProductStore();
|
||||||
@@ -22,10 +36,18 @@ const ProductCard = ({ product }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasBadges =
|
||||||
|
product.prime ||
|
||||||
|
product.choix_amazon ||
|
||||||
|
product.offre_limitee ||
|
||||||
|
product.exclusivite_amazon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="product-card">
|
<article className="product-card">
|
||||||
<div className="product-header">
|
<div className="product-header">
|
||||||
<span className="boutique">{product.boutique}</span>
|
<span className="boutique">
|
||||||
|
<i className="fa-brands fa-amazon"></i> {product.boutique}
|
||||||
|
</span>
|
||||||
{product.actif ? (
|
{product.actif ? (
|
||||||
<span className="status active">Actif</span>
|
<span className="status active">Actif</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -33,10 +55,14 @@ const ProductCard = ({ product }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 className="product-title" title={product.titre}>
|
||||||
|
{product.titre || "Titre non disponible"}
|
||||||
|
</h3>
|
||||||
|
|
||||||
<div className="product-body">
|
<div className="product-body">
|
||||||
<div className="product-image">
|
<div className="product-image">
|
||||||
{product.url_image ? (
|
{product.url_image ? (
|
||||||
<img src={product.url_image} alt={product.titre} />
|
<img src={product.url_image} alt={product.titre} loading="lazy" />
|
||||||
) : (
|
) : (
|
||||||
<div className="no-image">
|
<div className="no-image">
|
||||||
<i className="fa-solid fa-image"></i>
|
<i className="fa-solid fa-image"></i>
|
||||||
@@ -45,15 +71,89 @@ const ProductCard = ({ product }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="product-info">
|
<div className="product-info">
|
||||||
<h3 className="product-title" title={product.titre}>
|
{/* Section Prix */}
|
||||||
{product.titre || "Titre non disponible"}
|
<div className="price-section">
|
||||||
</h3>
|
{product.prix_actuel != null && (
|
||||||
|
<div className="price-row price-current">
|
||||||
<div className="product-meta">
|
<span className="price-label">Actuel</span>
|
||||||
<span className="asin">ASIN: {product.asin}</span>
|
<span className="price-value">{formatPrice(product.prix_actuel)}</span>
|
||||||
{product.categorie && (
|
</div>
|
||||||
<span className="category">{product.categorie}</span>
|
|
||||||
)}
|
)}
|
||||||
|
{product.prix_conseille != null && (
|
||||||
|
<div className="price-row price-list">
|
||||||
|
<span className="price-label">Prix conseillé</span>
|
||||||
|
<span className="price-value strikethrough">
|
||||||
|
{formatPrice(product.prix_conseille)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{product.reduction_pourcent != null && product.reduction_pourcent !== 0 && (
|
||||||
|
<div className="price-row price-discount">
|
||||||
|
<span className="price-label">Réduction</span>
|
||||||
|
<span className="price-value discount">-{product.reduction_pourcent}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{product.prix_min_30j != null && (
|
||||||
|
<div className="price-row price-min">
|
||||||
|
<span className="price-label">Min 30j</span>
|
||||||
|
<span className="price-value">{formatPrice(product.prix_min_30j)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stock et Note */}
|
||||||
|
<div className="product-stats">
|
||||||
|
{product.etat_stock && (
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-label">Stock</span>
|
||||||
|
<span className={`stat-value ${product.en_stock ? "in-stock" : "out-of-stock"}`}>
|
||||||
|
{product.etat_stock}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{product.note != null && (
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-label">Note</span>
|
||||||
|
<span className="stat-value rating">
|
||||||
|
<i className="fa-solid fa-star"></i> {product.note.toFixed(1)}
|
||||||
|
{product.nombre_avis != null && (
|
||||||
|
<span className="review-count">({formatNumber(product.nombre_avis)})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges */}
|
||||||
|
{hasBadges && (
|
||||||
|
<div className="product-badges">
|
||||||
|
{product.choix_amazon && (
|
||||||
|
<span className="badge badge-choice">
|
||||||
|
<i className="fa-solid fa-check-circle"></i> Choix Amazon
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{product.prime && (
|
||||||
|
<span className="badge badge-prime">
|
||||||
|
<i className="fa-solid fa-truck-fast"></i> Prime
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{product.offre_limitee && (
|
||||||
|
<span className="badge badge-deal">
|
||||||
|
<i className="fa-solid fa-bolt"></i> Offre limitée
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{product.exclusivite_amazon && (
|
||||||
|
<span className="badge badge-exclusive">
|
||||||
|
<i className="fa-solid fa-gem"></i> Exclusivité
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Méta */}
|
||||||
|
<div className="product-meta">
|
||||||
|
<span className="asin">Ref: {product.asin}</span>
|
||||||
|
{product.categorie && <span className="category">{product.categorie}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
@@ -67,6 +167,9 @@ const ProductCard = ({ product }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Graphique historique 30j */}
|
||||||
|
<PriceChart productId={product.id} />
|
||||||
|
|
||||||
<div className="product-actions">
|
<div className="product-actions">
|
||||||
<button
|
<button
|
||||||
className="btn btn-scrape"
|
className="btn btn-scrape"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ProductCard from "./ProductCard";
|
import ProductCard from "./ProductCard";
|
||||||
|
|
||||||
const ProductGrid = ({ products }) => {
|
const ProductGrid = ({ products, columns = 3 }) => {
|
||||||
if (!products || products.length === 0) {
|
if (!products || products.length === 0) {
|
||||||
return (
|
return (
|
||||||
<section className="empty-state">
|
<section className="empty-state">
|
||||||
@@ -12,8 +12,12 @@ const ProductGrid = ({ products }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gridStyle = {
|
||||||
|
"--grid-columns": columns,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="product-grid">
|
<div className="product-grid" style={gridStyle}>
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<ProductCard key={product.id} product={product} />
|
<ProductCard key={product.id} product={product} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import useProductStore from "../stores/useProductStore";
|
import useProductStore from "../stores/useProductStore";
|
||||||
|
import useConfigStore from "../stores/useConfigStore";
|
||||||
import ProductGrid from "../components/products/ProductGrid";
|
import ProductGrid from "../components/products/ProductGrid";
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
const { products, loading, error, fetchProducts, clearError } = useProductStore();
|
const { products, loading, error, fetchProducts, clearError } = useProductStore();
|
||||||
|
const { config, fetchConfig } = useConfigStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
fetchProducts();
|
fetchProducts();
|
||||||
}, [fetchProducts]);
|
}, [fetchProducts, fetchConfig]);
|
||||||
|
|
||||||
|
const columns = config.ui?.columns_desktop || 3;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="home-page">
|
<main className="home-page">
|
||||||
@@ -26,7 +31,7 @@ const HomePage = () => {
|
|||||||
<p>Chargement des produits...</p>
|
<p>Chargement des produits...</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ProductGrid products={products} />
|
<ProductGrid products={products} columns={columns} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import * as api from "../api/client";
|
||||||
|
|
||||||
|
const SettingsPage = () => {
|
||||||
|
const [frontendConfig, setFrontendConfig] = useState(null);
|
||||||
|
const [backendConfig, setBackendConfig] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [success, setSuccess] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfigs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadConfigs = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [fe, be] = await Promise.all([
|
||||||
|
api.fetchFrontendConfig(),
|
||||||
|
api.fetchBackendConfig(),
|
||||||
|
]);
|
||||||
|
setFrontendConfig(fe);
|
||||||
|
setBackendConfig(be);
|
||||||
|
} catch (err) {
|
||||||
|
setError("Erreur lors du chargement des configurations: " + err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveFrontendConfig = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
try {
|
||||||
|
const updated = await api.updateFrontendConfig(frontendConfig);
|
||||||
|
setFrontendConfig(updated);
|
||||||
|
setSuccess("Configuration frontend sauvegardée");
|
||||||
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
} catch (err) {
|
||||||
|
setError("Erreur sauvegarde frontend: " + err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveBackendConfig = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
try {
|
||||||
|
const updated = await api.updateBackendConfig(backendConfig);
|
||||||
|
setBackendConfig(updated);
|
||||||
|
setSuccess("Configuration backend sauvegardée");
|
||||||
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
} catch (err) {
|
||||||
|
setError("Erreur sauvegarde backend: " + err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<main className="settings-page">
|
||||||
|
<div className="loading-state">
|
||||||
|
<i className="fa-solid fa-spinner fa-spin"></i>
|
||||||
|
<p>Chargement des configurations...</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="settings-page">
|
||||||
|
<h2>
|
||||||
|
<i className="fa-solid fa-cog"></i> Paramètres
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error-banner">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button onClick={() => setError(null)} className="btn-close">
|
||||||
|
<i className="fa-solid fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="success-banner">
|
||||||
|
<i className="fa-solid fa-check"></i> {success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="settings-grid">
|
||||||
|
{/* Section Frontend */}
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h3>
|
||||||
|
<i className="fa-solid fa-desktop"></i> Configuration Frontend
|
||||||
|
</h3>
|
||||||
|
<span className="version-badge">v{frontendConfig?.versions?.frontend}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{frontendConfig && (
|
||||||
|
<div className="settings-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Thème</label>
|
||||||
|
<select
|
||||||
|
value={frontendConfig.ui?.theme || "gruvbox_vintage_dark"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFrontendConfig({
|
||||||
|
...frontendConfig,
|
||||||
|
ui: { ...frontendConfig.ui, theme: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="gruvbox_vintage_dark">Gruvbox Dark</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Colonnes (desktop)</label>
|
||||||
|
<div className="slider-group">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
value={frontendConfig.ui?.columns_desktop || 3}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFrontendConfig({
|
||||||
|
...frontendConfig,
|
||||||
|
ui: { ...frontendConfig.ui, columns_desktop: parseInt(e.target.value) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="slider-value">{frontendConfig.ui?.columns_desktop || 3}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Densité des cartes</label>
|
||||||
|
<select
|
||||||
|
value={frontendConfig.ui?.card_density || "comfortable"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFrontendConfig({
|
||||||
|
...frontendConfig,
|
||||||
|
ui: { ...frontendConfig.ui, card_density: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="comfortable">Confortable</option>
|
||||||
|
<option value="compact">Compact</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Rafraîchissement auto (secondes)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="3600"
|
||||||
|
value={frontendConfig.ui?.refresh_auto_seconds || 300}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFrontendConfig({
|
||||||
|
...frontendConfig,
|
||||||
|
ui: { ...frontendConfig.ui, refresh_auto_seconds: parseInt(e.target.value) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="form-hint">0 = désactivé</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Champs à afficher</label>
|
||||||
|
<div className="checkbox-group">
|
||||||
|
{["price", "stock", "ratings", "badges"].map((field) => (
|
||||||
|
<label key={field} className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={frontendConfig.ui?.show_fields?.[field] ?? true}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFrontendConfig({
|
||||||
|
...frontendConfig,
|
||||||
|
ui: {
|
||||||
|
...frontendConfig.ui,
|
||||||
|
show_fields: {
|
||||||
|
...frontendConfig.ui?.show_fields,
|
||||||
|
[field]: e.target.checked,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{field === "price" && "Prix"}
|
||||||
|
{field === "stock" && "Stock"}
|
||||||
|
{field === "ratings" && "Notes"}
|
||||||
|
{field === "badges" && "Badges"}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={saveFrontendConfig}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<><i className="fa-solid fa-spinner fa-spin"></i> Sauvegarde...</>
|
||||||
|
) : (
|
||||||
|
<><i className="fa-solid fa-save"></i> Sauvegarder Frontend</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section Backend */}
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h3>
|
||||||
|
<i className="fa-solid fa-server"></i> Configuration Backend
|
||||||
|
</h3>
|
||||||
|
<span className="version-badge">v{backendConfig?.app?.version}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{backendConfig && (
|
||||||
|
<div className="settings-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Environnement</label>
|
||||||
|
<select
|
||||||
|
value={backendConfig.app?.env || "dev"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBackendConfig({
|
||||||
|
...backendConfig,
|
||||||
|
app: { ...backendConfig.app, env: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="dev">Développement</option>
|
||||||
|
<option value="prod">Production</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Niveau de log</label>
|
||||||
|
<select
|
||||||
|
value={backendConfig.app?.log_level || "INFO"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBackendConfig({
|
||||||
|
...backendConfig,
|
||||||
|
app: { ...backendConfig.app, log_level: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="DEBUG">DEBUG</option>
|
||||||
|
<option value="INFO">INFO</option>
|
||||||
|
<option value="WARNING">WARNING</option>
|
||||||
|
<option value="ERROR">ERROR</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Scraping</h4>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Intervalle scraping (minutes)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="5"
|
||||||
|
max="1440"
|
||||||
|
value={backendConfig.scrape?.interval_minutes || 60}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBackendConfig({
|
||||||
|
...backendConfig,
|
||||||
|
scrape: { ...backendConfig.scrape, interval_minutes: parseInt(e.target.value) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Mode headless</label>
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={backendConfig.scrape?.headless ?? true}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBackendConfig({
|
||||||
|
...backendConfig,
|
||||||
|
scrape: { ...backendConfig.scrape, headless: e.target.checked },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Navigateur invisible
|
||||||
|
</label>
|
||||||
|
<span className="form-hint">Désactiver pour debug manuel</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Timeout (ms)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="5000"
|
||||||
|
max="120000"
|
||||||
|
step="1000"
|
||||||
|
value={backendConfig.scrape?.timeout_ms || 30000}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBackendConfig({
|
||||||
|
...backendConfig,
|
||||||
|
scrape: { ...backendConfig.scrape, timeout_ms: parseInt(e.target.value) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Délai entre pages (min-max ms)</label>
|
||||||
|
<div className="range-inputs">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="500"
|
||||||
|
max="10000"
|
||||||
|
value={backendConfig.scrape?.delay_range_ms?.[0] || 1000}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBackendConfig({
|
||||||
|
...backendConfig,
|
||||||
|
scrape: {
|
||||||
|
...backendConfig.scrape,
|
||||||
|
delay_range_ms: [parseInt(e.target.value), backendConfig.scrape?.delay_range_ms?.[1] || 3000],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>-</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1000"
|
||||||
|
max="30000"
|
||||||
|
value={backendConfig.scrape?.delay_range_ms?.[1] || 3000}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBackendConfig({
|
||||||
|
...backendConfig,
|
||||||
|
scrape: {
|
||||||
|
...backendConfig.scrape,
|
||||||
|
delay_range_ms: [backendConfig.scrape?.delay_range_ms?.[0] || 1000, parseInt(e.target.value)],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>User Agent</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={backendConfig.scrape?.user_agent || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBackendConfig({
|
||||||
|
...backendConfig,
|
||||||
|
scrape: { ...backendConfig.scrape, user_agent: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Taxonomie</h4>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Catégories disponibles</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={backendConfig.taxonomy?.categories?.join(", ") || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBackendConfig({
|
||||||
|
...backendConfig,
|
||||||
|
taxonomy: {
|
||||||
|
...backendConfig.taxonomy,
|
||||||
|
categories: e.target.value.split(",").map((s) => s.trim()).filter(Boolean),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="form-hint">Séparées par des virgules</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={saveBackendConfig}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<><i className="fa-solid fa-spinner fa-spin"></i> Sauvegarde...</>
|
||||||
|
) : (
|
||||||
|
<><i className="fa-solid fa-save"></i> Sauvegarder Backend</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsPage;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import * as api from "../api/client";
|
||||||
|
|
||||||
|
// Configuration par défaut
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
ui: {
|
||||||
|
theme: "gruvbox_vintage_dark",
|
||||||
|
button_mode: "text/icon",
|
||||||
|
columns_desktop: 3,
|
||||||
|
card_density: "comfortable",
|
||||||
|
show_fields: {
|
||||||
|
price: true,
|
||||||
|
stock: true,
|
||||||
|
ratings: true,
|
||||||
|
badges: true,
|
||||||
|
},
|
||||||
|
refresh_auto_seconds: 300,
|
||||||
|
},
|
||||||
|
versions: {
|
||||||
|
frontend: "0.1.0",
|
||||||
|
backend_expected: "0.1.0",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const useConfigStore = create((set) => ({
|
||||||
|
config: DEFAULT_CONFIG,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
fetchConfig: async () => {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const config = await api.fetchFrontendConfig();
|
||||||
|
set({ config, loading: false });
|
||||||
|
} catch (err) {
|
||||||
|
// En cas d'erreur, on garde la config par défaut
|
||||||
|
console.warn("Impossible de charger config_frontend.json:", err.message);
|
||||||
|
set({ loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Getters pratiques
|
||||||
|
getColumns: () => {
|
||||||
|
const state = useConfigStore.getState();
|
||||||
|
return state.config.ui?.columns_desktop || 3;
|
||||||
|
},
|
||||||
|
|
||||||
|
getShowFields: () => {
|
||||||
|
const state = useConfigStore.getState();
|
||||||
|
return state.config.ui?.show_fields || DEFAULT_CONFIG.ui.show_fields;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useConfigStore;
|
||||||
@@ -126,9 +126,18 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.product-grid {
|
.product-grid {
|
||||||
|
--grid-columns: 3;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
grid-template-columns: repeat(var(--grid-columns), 1fr);
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
@@ -197,6 +206,8 @@ a {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
@@ -213,6 +224,9 @@ a {
|
|||||||
border-bottom: 1px solid $bg;
|
border-bottom: 1px solid $bg;
|
||||||
|
|
||||||
.boutique {
|
.boutique {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -236,22 +250,36 @@ a {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-card > .product-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 16px 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.product-body {
|
.product-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-image {
|
.product-image {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 100px;
|
width: 120px;
|
||||||
height: 100px;
|
height: 120px;
|
||||||
background: $bg;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -268,6 +296,9 @@ a {
|
|||||||
.product-info {
|
.product-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-title {
|
.product-title {
|
||||||
@@ -281,11 +312,139 @@ a {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Section Prix
|
||||||
|
.price-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
|
||||||
|
.price-label {
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-value {
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&.strikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: $text-muted;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.discount {
|
||||||
|
color: $accent-green;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-current .price-value {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats (Stock, Note)
|
||||||
|
.product-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
&.in-stock {
|
||||||
|
color: $accent-green;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.out-of-stock {
|
||||||
|
color: $accent-red;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: $accent-yellow;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-count {
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badges
|
||||||
|
.product-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-choice {
|
||||||
|
background: rgba($accent-aqua, 0.15);
|
||||||
|
color: $accent-aqua;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-prime {
|
||||||
|
background: rgba(#00a8e1, 0.15);
|
||||||
|
color: #00a8e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-deal {
|
||||||
|
background: rgba($accent-red, 0.15);
|
||||||
|
color: $accent-red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-exclusive {
|
||||||
|
background: rgba($accent-yellow, 0.15);
|
||||||
|
color: $accent-yellow;
|
||||||
|
}
|
||||||
|
|
||||||
.product-meta {
|
.product-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-top: auto;
|
||||||
|
|
||||||
.asin {
|
.asin {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -309,12 +468,69 @@ a {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Price Chart
|
||||||
|
.price-chart-container {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid $bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-chart {
|
||||||
|
height: 120px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-chart-loading,
|
||||||
|
.price-chart-error,
|
||||||
|
.price-chart-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 80px;
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid $bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-chart-error {
|
||||||
|
color: $accent-red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-chart-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: $text-muted;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.trend .value {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.product-actions {
|
.product-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-top: 1px solid $bg;
|
border-top: 1px solid $bg;
|
||||||
background: $bg-soft;
|
background: $bg-soft;
|
||||||
|
margin-top: auto;
|
||||||
|
|
||||||
.btn-scrape {
|
.btn-scrape {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -574,6 +790,192 @@ a {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings Page
|
||||||
|
.settings-page {
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: $text;
|
||||||
|
|
||||||
|
i {
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
background: $card;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid $bg;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 24px 0 16px 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: $accent-yellow;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: $bg;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: $text-muted;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
background: $bg;
|
||||||
|
border-radius: 3px;
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $accent;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid $card;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-range-thumb {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $accent;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid $card;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-value {
|
||||||
|
min-width: 30px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: $accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-inputs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba($accent-green, 0.15);
|
||||||
|
color: $accent-green;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
// Scrollbar styling
|
// Scrollbar styling
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|||||||
@@ -2,13 +2,10 @@
|
|||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
- Docker Compose setup
|
- Docker Compose setup
|
||||||
- Page debug/logs SQLite
|
|
||||||
- Tests E2E frontend
|
- Tests E2E frontend
|
||||||
|
|
||||||
## Doing
|
## Doing
|
||||||
- Frontend: connecter App.jsx à l'API
|
(vide)
|
||||||
- Frontend: ProductCard avec données réelles
|
|
||||||
- Frontend: formulaire ajout produit
|
|
||||||
|
|
||||||
## Review
|
## Review
|
||||||
- Scheduler APScheduler (fonctionnel, à tester en charge)
|
- Scheduler APScheduler (fonctionnel, à tester en charge)
|
||||||
@@ -20,3 +17,25 @@
|
|||||||
- Scraper Playwright + parser Amazon
|
- Scraper Playwright + parser Amazon
|
||||||
- Tests unitaires (7 tests OK)
|
- Tests unitaires (7 tests OK)
|
||||||
- Tests CLI scraper (9/9 produits OK)
|
- Tests CLI scraper (9/9 produits OK)
|
||||||
|
- Page debug avec tables SQLite et logs
|
||||||
|
- Store Zustand + connexion API
|
||||||
|
- Formulaire ajout produit (URL Amazon)
|
||||||
|
- Actions scrape/delete sur ProductCard
|
||||||
|
- ProductCard améliorée (prix, badges, note, stock, image)
|
||||||
|
- API ProductWithSnapshot (produits enrichis avec dernier snapshot)
|
||||||
|
- Grille responsive avec colonnes configurables
|
||||||
|
- Graphique Chart.js historique 30j avec tendance
|
||||||
|
|
||||||
|
## Divers probleme a resoudre
|
||||||
|
- consigne : analyse du probleme avant de le resoudre + plan
|
||||||
|
- [ ] Frontend : lors de l'ajout d'un produit, il faut rafraichir la page pour le voir bien rempli
|
||||||
|
- [ ] il y a un decalage horaire pour la date du dernier scrap
|
||||||
|
- [ ] lors de l'ajout du produit ajouter un popup de validation avant enregistrement
|
||||||
|
- [ ] si on clique sur une vignette produit, affiche un popup avec le detail, brainstorming sur la meilleur methode de clique et sur le contenu a afficher
|
||||||
|
- [ ] peut on recuperer une categorie lors du scrap sur le site amazon 
|
||||||
|
- [ ] peut on recuperer le manuel utilisateur s'il est present 
|
||||||
|
- [ ] clique sur image pour afficher une image en grand
|
||||||
|
- [ ] configurer chemin des images, des captures, des debug et log
|
||||||
|
- [ ] categorie : doit etre modifiable et extensisible avec des sous categories (format json ?)
|
||||||
|
- [ ] un mode edit dispo dans detail produit
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 19 KiB |