mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-22 14:48:13 +02:00
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:
26 files changed
+1831
-10
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user