feat: Add multi-site product search system with SearXNG integration

This commit adds a comprehensive product search feature that allows users
to search for products across multiple configured e-commerce sites.

Backend changes:
- Add SearXNG service to docker-compose for meta-search capability
- Add SearchSite model and Alembic migration with default sites
- Create searxng_service.py for search queries across sites
- Create light_scraper_service.py for fast HTTP-based extraction
- Create search_service.py orchestrator combining SearXNG + HTTP + Browserless
- Add API endpoints for search (/api/search) with SSE streaming
- Add CRUD endpoints for search sites (/api/search-sites)

Frontend changes:
- Add new /search page with real-time results display
- Create SearchResultCard component for product display
- Add Progress, Checkbox, and Badge UI components
- Update Settings page with search sites management section
- Add navigation link to search page
- Create new modern SVG logo with price-flow gradient design
- Update favicon to SVG format

The search system uses a hybrid approach:
1. SearXNG finds product URLs across configured sites
2. Light HTTP scraper attempts fast extraction
3. Falls back to Browserless for JavaScript-heavy sites
4. Results stream progressively via SSE
This commit is contained in:
Claude committed 2025-11-22 11:03:48 +00:00
1 parent 8795c6fea1
commit 1116c3a648
26 files changed
+1831 -10

No files matched your search

+2
View File
@@ -3,6 +3,7 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import { Toaster } from 'sonner'
import Layout from '@/components/layout/Layout'
import Dashboard from '@/pages/Dashboard'
import Search from '@/pages/Search'
import Settings from '@/pages/Settings'
import { useTheme } from '@/hooks/use-theme'
@@ -15,6 +16,7 @@ export default function App() {
<Layout theme={theme} toggleTheme={toggleTheme}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/search" element={<Search />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Layout>
+6 -5
View File
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { LayoutDashboard, Settings, Moon, Sun, Menu } from 'lucide-react';
import { LayoutDashboard, Search, Settings, Moon, Sun, Menu } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
@@ -13,6 +13,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: Settings, label: t('nav.settings'), path: '/settings' },
];
@@ -23,7 +24,7 @@ const Layout = ({ children, theme, toggleTheme }) => {
<div className="flex h-full flex-col">
<div className="flex h-14 items-center border-b px-6">
<Link to="/" className="flex items-center gap-2 font-semibold">
<img src="/logo.png" alt="PriceFlow Logo" className="h-8 w-8" />
<img src="/logo.svg" alt="PriceFlow Logo" className="h-8 w-8 rounded-lg" />
<span className="text-xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
PriceFlow
</span>
@@ -80,9 +81,9 @@ const Layout = ({ children, theme, toggleTheme }) => {
<SheetContent side="left" className="w-64 p-0">
<div className="flex h-full flex-col">
<div className="flex h-14 items-center border-b px-6">
<img src="/logo.png" alt="PriceFlow Logo" className="h-8 w-8 mr-2" />
<img src="/logo.svg" alt="PriceFlow Logo" className="h-8 w-8 mr-2 rounded-lg" />
<span className="text-xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
Pricecious
PriceFlow
</span>
</div>
<nav className="flex-1 space-y-1 p-4">
@@ -125,7 +126,7 @@ const Layout = ({ children, theme, toggleTheme }) => {
</SheetContent>
</Sheet>
<Link to="/" className="flex items-center gap-2 font-semibold">
<img src="/logo.png" alt="PriceFlow Logo" className="h-6 w-6" />
<img src="/logo.svg" alt="PriceFlow Logo" className="h-6 w-6 rounded" />
PriceFlow
</Link>
<div className="ml-auto flex items-center gap-2">
@@ -0,0 +1,112 @@
import React from 'react';
import { ExternalLink, Plus, Package, PackageX } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
export function SearchResultCard({ result, onAddToMonitoring }) {
const formatPrice = (price) => {
if (price === null || price === undefined) return 'N/A';
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: result.currency || 'EUR',
}).format(price);
};
const truncateTitle = (title, maxLength = 60) => {
if (!title) return 'Produit sans nom';
if (title.length <= maxLength) return title;
return title.substring(0, maxLength) + '...';
};
return (
<Card className="overflow-hidden hover:shadow-lg transition-shadow">
<CardContent className="p-0">
{/* Image */}
<div className="aspect-square bg-muted flex items-center justify-center">
{result.image_url ? (
<img
src={result.image_url}
alt={result.title}
className="w-full h-full object-contain"
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
) : null}
<div
className={`w-full h-full items-center justify-center text-muted-foreground ${result.image_url ? 'hidden' : 'flex'}`}
>
<Package className="h-16 w-16" />
</div>
</div>
{/* Content */}
<div className="p-4 space-y-3">
{/* Title */}
<h3 className="font-medium text-sm leading-tight line-clamp-2" title={result.title}>
{truncateTitle(result.title)}
</h3>
{/* Price & Stock */}
<div className="flex items-center justify-between">
<span className="text-xl font-bold text-primary">
{formatPrice(result.price)}
</span>
{result.in_stock !== null && (
<Badge
variant={result.in_stock ? 'default' : 'destructive'}
className="text-xs"
>
{result.in_stock ? (
<>
<Package className="h-3 w-3 mr-1" />
En stock
</>
) : (
<>
<PackageX className="h-3 w-3 mr-1" />
Rupture
</>
)}
</Badge>
)}
</div>
{/* Site */}
<div className="flex items-center text-xs text-muted-foreground">
<span className="truncate">{result.site_name}</span>
{result.confidence && (
<span className="ml-auto text-xs opacity-50">
{Math.round(result.confidence * 100)}%
</span>
)}
</div>
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button
variant="default"
size="sm"
className="flex-1"
onClick={onAddToMonitoring}
>
<Plus className="h-4 w-4 mr-1" />
Suivre
</Button>
<Button
variant="outline"
size="sm"
asChild
>
<a href={result.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
success:
"border-transparent bg-green-500 text-white shadow hover:bg-green-500/80",
warning:
"border-transparent bg-yellow-500 text-white shadow hover:bg-yellow-500/80",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({ className, variant, ...props }) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
+1
View File
@@ -5,6 +5,7 @@
},
"nav": {
"dashboard": "Tableau de bord",
"search": "Recherche",
"settings": "Paramètres",
"lightMode": "Mode clair",
"darkMode": "Mode sombre"
+262
View File
@@ -0,0 +1,262 @@
import React, { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Search as SearchIcon, Loader2, Plus, ExternalLink, Settings } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Checkbox } from '@/components/ui/checkbox';
import { SearchResultCard } from '@/components/search/SearchResultCard';
import { Link } from 'react-router-dom';
const API_URL = '/api';
export default function Search() {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [sites, setSites] = useState([]);
const [selectedSites, setSelectedSites] = useState([]);
const [isSearching, setIsSearching] = useState(false);
const [progress, setProgress] = useState(null);
const [results, setResults] = useState([]);
const eventSourceRef = useRef(null);
// Charger les sites au démarrage
useEffect(() => {
loadSites();
}, []);
const loadSites = async () => {
try {
const response = await axios.get(`${API_URL}/search-sites`);
setSites(response.data);
// Sélectionner tous les sites actifs par défaut
setSelectedSites(
response.data
.filter(site => site.is_active)
.map(site => site.id)
);
} catch (error) {
console.error('Error loading sites:', error);
toast.error('Erreur lors du chargement des sites');
}
};
const toggleSite = (siteId) => {
setSelectedSites(prev =>
prev.includes(siteId)
? prev.filter(id => id !== siteId)
: [...prev, siteId]
);
};
const handleSearch = async (e) => {
e.preventDefault();
if (!query.trim() || selectedSites.length === 0) {
toast.error('Veuillez entrer une recherche et sélectionner au moins un site');
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 avec les paramètres
const params = new URLSearchParams({
q: query.trim(),
sites: selectedSites.join(','),
max_results: 20,
});
// 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;
return (
<div className="space-y-6 animate-in fade-in duration-500">
{/* Header */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Recherche de produits</h2>
<p className="text-muted-foreground">
Recherchez des produits sur plusieurs sites et ajoutez-les au monitoring
</p>
</div>
<Link to="/settings">
<Button variant="outline" size="sm">
<Settings className="h-4 w-4 mr-2" />
Gérer les sites
</Button>
</Link>
</div>
{/* Search Form */}
<Card>
<CardContent className="pt-6">
<form onSubmit={handleSearch} className="space-y-4">
{/* Search Input */}
<div className="flex gap-2">
<div className="relative flex-1">
<SearchIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Rechercher un produit..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
disabled={isSearching}
/>
</div>
<Button type="submit" disabled={isSearching || !query.trim()}>
{isSearching ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Recherche...
</>
) : (
<>
<SearchIcon className="h-4 w-4 mr-2" />
Rechercher
</>
)}
</Button>
</div>
{/* Site Selection */}
<div className="space-y-2">
<p className="text-sm font-medium">Sites de recherche :</p>
<div className="flex flex-wrap gap-3">
{sites.filter(s => s.is_active).map(site => (
<label
key={site.id}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
selectedSites.includes(site.id)
? 'bg-primary/10 border-primary'
: 'bg-background border-border hover:bg-muted'
}`}
>
<Checkbox
checked={selectedSites.includes(site.id)}
onCheckedChange={() => toggleSite(site.id)}
/>
<span className="text-sm">{site.name}</span>
</label>
))}
{sites.filter(s => s.is_active).length === 0 && (
<p className="text-sm text-muted-foreground">
Aucun site configuré.{' '}
<Link to="/settings" className="text-primary underline">
Ajouter des sites
</Link>
</p>
)}
</div>
</div>
</form>
</CardContent>
</Card>
{/* Progress Bar */}
{progress && isSearching && (
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{progress.message || 'Recherche en cours...'}</span>
<span>{progress.completed}/{progress.total}</span>
</div>
<Progress value={progressPercent} />
</div>
</CardContent>
</Card>
)}
{/* Results */}
{results.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">
{results.length} produit{results.length > 1 ? 's' : ''} trouvé{results.length > 1 ? 's' : ''}
</h3>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{results.map((result, index) => (
<SearchResultCard
key={`${result.url}-${index}`}
result={result}
onAddToMonitoring={() => handleAddToMonitoring(result)}
/>
))}
</div>
</div>
)}
{/* Empty State */}
{!isSearching && results.length === 0 && progress?.status === 'completed' && (
<Card>
<CardContent className="py-12 text-center">
<SearchIcon className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">Aucun produit trouvé</h3>
<p className="text-muted-foreground">
Essayez avec d'autres termes de recherche ou sélectionnez plus de sites
</p>
</CardContent>
</Card>
)}
</div>
);
}
+178 -3
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { toast } from 'sonner';
import { Sun, Clock, Cpu, Settings as SettingsIcon, Bell, Trash2, ChevronDown, ChevronUp, Edit2, CheckCircle2, AlertCircle, TrendingDown, DollarSign, Package, RefreshCw, Filter, Eye, Code, Brain, Sparkles } from 'lucide-react';
import { Sun, Clock, Cpu, Settings as SettingsIcon, Bell, Trash2, ChevronDown, ChevronUp, Edit2, CheckCircle2, AlertCircle, TrendingDown, DollarSign, Package, RefreshCw, Filter, Eye, Code, Brain, Sparkles, Globe, Plus } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
@@ -54,6 +54,11 @@ export default function Settings() {
const [editingProfileId, setEditingProfileId] = useState(null);
const [showAdvancedAI, setShowAdvancedAI] = useState(false);
// Search Sites state
const [searchSites, setSearchSites] = useState([]);
const [newSite, setNewSite] = useState({ name: '', domain: '', category: '', is_active: true, priority: 0, requires_js: false });
const [editingSiteId, setEditingSiteId] = useState(null);
// OpenRouter state
const [openrouterModels, setOpenrouterModels] = useState([]);
const [openrouterLoading, setOpenrouterLoading] = useState(false);
@@ -108,14 +113,16 @@ export default function Settings() {
const fetchAll = async () => {
try {
const [profilesRes, settingsRes, jobRes] = await Promise.all([
const [profilesRes, settingsRes, jobRes, sitesRes] = await Promise.all([
axios.get(`${API_URL}/notification-profiles`),
axios.get(`${API_URL}/settings`),
axios.get(`${API_URL}/jobs/config`)
axios.get(`${API_URL}/jobs/config`),
axios.get(`${API_URL}/search-sites`)
]);
setProfiles(profilesRes.data);
setJobConfig(jobRes.data);
setSearchSites(sitesRes.data);
const settingsMap = {};
settingsRes.data.forEach(s => settingsMap[s.key] = s.value);
@@ -233,6 +240,66 @@ export default function Settings() {
}
};
// Search Sites functions
const handleSiteSubmit = async (e) => {
e.preventDefault();
try {
if (editingSiteId) {
await axios.put(`${API_URL}/search-sites/${editingSiteId}`, newSite);
toast.success('Site mis à jour');
} else {
await axios.post(`${API_URL}/search-sites`, newSite);
toast.success('Site ajouté');
}
setNewSite({ name: '', domain: '', category: '', is_active: true, priority: 0, requires_js: false });
setEditingSiteId(null);
fetchAll();
} catch (error) {
toast.error('Erreur lors de l\'opération sur le site');
}
};
const editSite = (site) => {
setNewSite({
name: site.name,
domain: site.domain,
category: site.category || '',
is_active: site.is_active,
priority: site.priority,
requires_js: site.requires_js
});
setEditingSiteId(site.id);
};
const cancelSiteEdit = () => {
setNewSite({ name: '', domain: '', category: '', is_active: true, priority: 0, requires_js: false });
setEditingSiteId(null);
};
const deleteSite = async (id) => {
if (confirm('Êtes-vous sûr de vouloir supprimer ce site ?')) {
try {
await axios.delete(`${API_URL}/search-sites/${id}`);
toast.success('Site supprimé');
if (editingSiteId === id) {
cancelSiteEdit();
}
fetchAll();
} catch (error) {
toast.error('Erreur lors de la suppression');
}
}
};
const toggleSiteActive = async (site) => {
try {
await axios.put(`${API_URL}/search-sites/${site.id}`, { is_active: !site.is_active });
fetchAll();
} catch (error) {
toast.error('Erreur lors de la mise à jour');
}
};
return (
<div className="space-y-6 animate-in fade-in duration-500 max-w-5xl mx-auto pb-10">
<div>
@@ -533,6 +600,114 @@ export default function Settings() {
</CardContent>
</Card>
{/* Search Sites Configuration */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Globe className="h-5 w-5" />Sites de recherche</CardTitle>
<CardDescription>Configurez les sites sur lesquels rechercher des produits.</CardDescription>
</CardHeader>
<CardContent className="space-y-8">
<form onSubmit={handleSiteSubmit} className="space-y-4 border-b pb-8">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">{editingSiteId ? 'Modifier le site' : 'Ajouter un site'}</h4>
{editingSiteId && (
<Button type="button" variant="ghost" size="sm" onClick={cancelSiteEdit}>Annuler</Button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Nom du site</Label>
<Input required value={newSite.name} onChange={(e) => setNewSite({ ...newSite, name: e.target.value })} placeholder="ex: Amazon France" />
</div>
<div className="space-y-2">
<Label>Domaine</Label>
<Input required value={newSite.domain} onChange={(e) => setNewSite({ ...newSite, domain: e.target.value })} placeholder="ex: amazon.fr" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Catégorie</Label>
<Input value={newSite.category} onChange={(e) => setNewSite({ ...newSite, category: e.target.value })} placeholder="ex: Électronique, Déco, Généraliste" />
</div>
<div className="space-y-2">
<Label>Priorité</Label>
<Input type="number" min="0" value={newSite.priority} onChange={(e) => setNewSite({ ...newSite, priority: parseInt(e.target.value) || 0 })} />
</div>
</div>
<div className="flex flex-wrap gap-4">
<div className="flex items-center space-x-2">
<Switch checked={newSite.is_active} onCheckedChange={(checked) => setNewSite({ ...newSite, is_active: checked })} />
<Label>Actif</Label>
</div>
<div className="flex items-center space-x-2">
<Switch checked={newSite.requires_js} onCheckedChange={(checked) => setNewSite({ ...newSite, requires_js: checked })} />
<Label>Nécessite JavaScript</Label>
</div>
</div>
<Button type="submit">
<Plus className="h-4 w-4 mr-2" />
{editingSiteId ? 'Mettre à jour' : 'Ajouter le site'}
</Button>
</form>
<div className="space-y-4">
<h4 className="text-sm font-medium">Sites configurés ({searchSites.length})</h4>
{searchSites.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucun site configuré.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{searchSites.map(site => (
<div key={site.id} className={cn(
"relative group overflow-hidden rounded-xl border bg-card text-card-foreground shadow transition-all hover:shadow-md",
editingSiteId === site.id && "ring-2 ring-primary",
!site.is_active && "opacity-60"
)}>
<div className="p-4 space-y-3">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold leading-none tracking-tight flex items-center gap-2">
{site.name}
{site.is_active ? (
<span className="px-2 py-0.5 bg-green-500/10 text-green-700 dark:text-green-400 rounded text-xs">Actif</span>
) : (
<span className="px-2 py-0.5 bg-red-500/10 text-red-700 dark:text-red-400 rounded text-xs">Inactif</span>
)}
</h3>
<p className="text-sm text-muted-foreground mt-1">{site.domain}</p>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => editSite(site)}>
<Edit2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => deleteSite(site.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{site.category && (
<span className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-secondary text-secondary-foreground">
{site.category}
</span>
)}
{site.requires_js && (
<span className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-blue-500/10 text-blue-700 dark:text-blue-400">
JavaScript
</span>
)}
</div>
<div className="pt-2">
<Switch checked={site.is_active} onCheckedChange={() => toggleSiteActive(site)} />
</div>
</div>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
{/* Notification Profiles */}
<Card>
<CardHeader>