Add Hardware Price Tracker MVP
This commit is contained in:
10
index.html
10
index.html
@@ -3,12 +3,12 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>specseek-tracker</title>
|
||||
<meta name="description" content="Lovable Generated Project" />
|
||||
<meta name="author" content="Lovable" />
|
||||
<title>Hardware Price Tracker - Real-time Monitoring & Alerts</title>
|
||||
<meta name="description" content="Track hardware prices across multiple stores with automated alerts and spec-based search profiles for SSDs, RAM, and more." />
|
||||
<meta name="author" content="Hardware Price Tracker" />
|
||||
|
||||
<meta property="og:title" content="specseek-tracker" />
|
||||
<meta property="og:description" content="Lovable Generated Project" />
|
||||
<meta property="og:title" content="Hardware Price Tracker - Smart Price Monitoring" />
|
||||
<meta property="og:description" content="Automated hardware price tracking with real-time alerts and spec-based searches." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
||||
|
||||
|
||||
121
src/components/AlertsList.tsx
Normal file
121
src/components/AlertsList.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertCircle, ExternalLink } from "lucide-react";
|
||||
|
||||
interface AlertsListProps {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export const AlertsList = ({ limit }: AlertsListProps) => {
|
||||
// Mock data
|
||||
const alerts = [
|
||||
{
|
||||
id: 1,
|
||||
type: "product",
|
||||
sourceTitle: "Samsung 990 PRO 2TB NVMe SSD",
|
||||
message: "Price dropped to 699 DKK (target: 750 DKK)",
|
||||
store: "proshop",
|
||||
price: 699,
|
||||
targetPrice: 750,
|
||||
currency: "DKK",
|
||||
url: "https://www.proshop.dk/example",
|
||||
createdAt: "5 min ago",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "search_profile",
|
||||
sourceTitle: "32GB DDR5-6000 RAM",
|
||||
message: "Corsair Vengeance 32GB DDR5-6000 @ 899 DKK (target: 1000 DKK)",
|
||||
store: "proshop",
|
||||
price: 899,
|
||||
targetPrice: 1000,
|
||||
currency: "DKK",
|
||||
url: "https://www.proshop.dk/example",
|
||||
createdAt: "1 hour ago",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: "search_profile",
|
||||
sourceTitle: "1TB NVMe SSD Budget",
|
||||
message: "Crucial P3 1TB @ 449 DKK (target: 500 DKK)",
|
||||
store: "proshop",
|
||||
price: 449,
|
||||
targetPrice: 500,
|
||||
currency: "DKK",
|
||||
url: "https://www.proshop.dk/example",
|
||||
createdAt: "3 hours ago",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: "product",
|
||||
sourceTitle: "WD Black SN850X 2TB NVMe",
|
||||
message: "Price dropped to 1399 DKK (target: 1400 DKK)",
|
||||
store: "proshop",
|
||||
price: 1399,
|
||||
targetPrice: 1400,
|
||||
currency: "DKK",
|
||||
url: "https://www.proshop.dk/example",
|
||||
createdAt: "5 hours ago",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: "product",
|
||||
sourceTitle: "G.Skill Trident Z5 32GB DDR5-6400",
|
||||
message: "Price dropped to 1099 DKK (target: 1200 DKK)",
|
||||
store: "proshop",
|
||||
price: 1099,
|
||||
targetPrice: 1200,
|
||||
currency: "DKK",
|
||||
url: "https://www.proshop.dk/example",
|
||||
createdAt: "8 hours ago",
|
||||
},
|
||||
];
|
||||
|
||||
const displayAlerts = limit ? alerts.slice(0, limit) : alerts;
|
||||
|
||||
const getDiscountPercent = (price: number, target: number) => {
|
||||
return (((target - price) / target) * 100).toFixed(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{displayAlerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="flex items-start gap-3 p-4 rounded-lg border border-border bg-destructive/5 hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<div className="mt-0.5">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium truncate">{alert.sourceTitle}</h4>
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{alert.type.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-foreground mb-2">{alert.message}</p>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="capitalize">{alert.store}</span>
|
||||
<span>•</span>
|
||||
<span>{alert.createdAt}</span>
|
||||
<span>•</span>
|
||||
<span className="text-success font-medium">
|
||||
{getDiscountPercent(alert.price, alert.targetPrice)}% below target
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<a href={alert.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
73
src/components/PriceChart.tsx
Normal file
73
src/components/PriceChart.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts";
|
||||
|
||||
export const PriceChart = () => {
|
||||
// Mock data - will be replaced with real price history
|
||||
const data = [
|
||||
{ date: "Jan 1", samsung: 1499, wd: 1499, crucial: 599 },
|
||||
{ date: "Jan 3", samsung: 1449, wd: 1450, crucial: 579 },
|
||||
{ date: "Jan 5", samsung: 1399, wd: 1450, crucial: 569 },
|
||||
{ date: "Jan 7", samsung: 1349, wd: 1399, crucial: 549 },
|
||||
{ date: "Jan 9", samsung: 1299, wd: 1450, crucial: 549 },
|
||||
];
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-3 shadow-lg">
|
||||
<p className="text-sm font-medium mb-2">{label}</p>
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<p key={index} className="text-sm" style={{ color: entry.color }}>
|
||||
{entry.name}: {entry.value} DKK
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="samsung"
|
||||
stroke="hsl(var(--chart-1))"
|
||||
name="Samsung 990 PRO"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "hsl(var(--chart-1))" }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="wd"
|
||||
stroke="hsl(var(--chart-2))"
|
||||
name="WD Black SN850X"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "hsl(var(--chart-2))" }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="crucial"
|
||||
stroke="hsl(var(--chart-3))"
|
||||
name="Crucial P5 Plus"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "hsl(var(--chart-3))" }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
151
src/components/ProductsList.tsx
Normal file
151
src/components/ProductsList.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink, TrendingDown, TrendingUp } from "lucide-react";
|
||||
|
||||
interface ProductsListProps {
|
||||
limit?: number;
|
||||
sortBy?: "date" | "deal";
|
||||
}
|
||||
|
||||
export const ProductsList = ({ limit, sortBy = "date" }: ProductsListProps) => {
|
||||
// Mock data - will be replaced with real data from Lovable Cloud
|
||||
const products = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Samsung 990 PRO 2TB NVMe SSD",
|
||||
store: "proshop",
|
||||
currentPrice: 1299,
|
||||
targetPrice: 1500,
|
||||
lastPrice: 1399,
|
||||
currency: "DKK",
|
||||
url: "https://www.proshop.dk/example",
|
||||
lastChecked: "2 min ago",
|
||||
availability: "in_stock",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Corsair Vengeance RGB 32GB DDR5-6000",
|
||||
store: "proshop",
|
||||
currentPrice: 899,
|
||||
targetPrice: 1000,
|
||||
lastPrice: 949,
|
||||
currency: "DKK",
|
||||
url: "https://www.proshop.dk/example",
|
||||
lastChecked: "5 min ago",
|
||||
availability: "in_stock",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "WD Black SN850X 2TB NVMe",
|
||||
store: "proshop",
|
||||
currentPrice: 1450,
|
||||
targetPrice: 1400,
|
||||
lastPrice: 1399,
|
||||
currency: "DKK",
|
||||
url: "https://www.proshop.dk/example",
|
||||
lastChecked: "8 min ago",
|
||||
availability: "in_stock",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "G.Skill Trident Z5 32GB DDR5-6400",
|
||||
store: "proshop",
|
||||
currentPrice: 1099,
|
||||
targetPrice: 1200,
|
||||
lastPrice: 1149,
|
||||
currency: "DKK",
|
||||
url: "https://www.proshop.dk/example",
|
||||
lastChecked: "12 min ago",
|
||||
availability: "in_stock",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Crucial P5 Plus 1TB NVMe",
|
||||
store: "proshop",
|
||||
currentPrice: 549,
|
||||
targetPrice: 600,
|
||||
lastPrice: 599,
|
||||
currency: "DKK",
|
||||
url: "https://www.proshop.dk/example",
|
||||
lastChecked: "15 min ago",
|
||||
availability: "in_stock",
|
||||
},
|
||||
];
|
||||
|
||||
const displayProducts = limit ? products.slice(0, limit) : products;
|
||||
|
||||
const getPriceChange = (current: number, last: number) => {
|
||||
const change = ((current - last) / last) * 100;
|
||||
return change.toFixed(1);
|
||||
};
|
||||
|
||||
const isPriceDown = (current: number, last: number) => current < last;
|
||||
|
||||
const isBelowTarget = (current: number, target: number) => current <= target;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{displayProducts.map((product) => {
|
||||
const priceChange = getPriceChange(product.currentPrice, product.lastPrice);
|
||||
const priceDown = isPriceDown(product.currentPrice, product.lastPrice);
|
||||
const belowTarget = isBelowTarget(product.currentPrice, product.targetPrice);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={product.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg border border-border bg-secondary/30 hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium truncate">{product.name}</h4>
|
||||
{belowTarget && (
|
||||
<Badge variant="default" className="bg-success text-success-foreground">
|
||||
Target Hit
|
||||
</Badge>
|
||||
)}
|
||||
{product.availability === "in_stock" && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
In Stock
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="capitalize">{product.store}</span>
|
||||
<span>•</span>
|
||||
<span>Target: {product.targetPrice} {product.currency}</span>
|
||||
<span>•</span>
|
||||
<span>{product.lastChecked}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold">
|
||||
{product.currentPrice} <span className="text-sm text-muted-foreground">{product.currency}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
{priceDown ? (
|
||||
<TrendingDown className="h-4 w-4 text-success" />
|
||||
) : (
|
||||
<TrendingUp className="h-4 w-4 text-destructive" />
|
||||
)}
|
||||
<span className={priceDown ? "text-success" : "text-destructive"}>
|
||||
{priceDown ? "" : "+"}{priceChange}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<a href={product.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
162
src/components/SearchProfiles.tsx
Normal file
162
src/components/SearchProfiles.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, TrendingDown } from "lucide-react";
|
||||
|
||||
export const SearchProfiles = () => {
|
||||
// Mock data
|
||||
const profiles = [
|
||||
{
|
||||
id: 1,
|
||||
title: "2TB NVMe SSD",
|
||||
category: "ssd",
|
||||
filters: {
|
||||
capacity_gb: 2000,
|
||||
interface: "NVMe",
|
||||
brand: ["Samsung", "Crucial"],
|
||||
},
|
||||
targetPrice: 750,
|
||||
currency: "DKK",
|
||||
bestMatch: {
|
||||
name: "Samsung 990 PRO 2TB",
|
||||
price: 699,
|
||||
store: "proshop",
|
||||
},
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "32GB DDR5-6000 RAM",
|
||||
category: "ram",
|
||||
filters: {
|
||||
capacity_gb: 32,
|
||||
type: "DDR5",
|
||||
brand: ["Corsair", "G.Skill"],
|
||||
},
|
||||
targetPrice: 1000,
|
||||
currency: "DKK",
|
||||
bestMatch: {
|
||||
name: "Corsair Vengeance 32GB DDR5-6000",
|
||||
price: 899,
|
||||
store: "proshop",
|
||||
},
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "1TB NVMe SSD Budget",
|
||||
category: "ssd",
|
||||
filters: {
|
||||
capacity_gb: 1000,
|
||||
interface: "NVMe",
|
||||
},
|
||||
targetPrice: 500,
|
||||
currency: "DKK",
|
||||
bestMatch: {
|
||||
name: "Crucial P3 1TB",
|
||||
price: 449,
|
||||
store: "proshop",
|
||||
},
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "16GB DDR4-3200 RAM",
|
||||
category: "ram",
|
||||
filters: {
|
||||
capacity_gb: 16,
|
||||
type: "DDR4",
|
||||
},
|
||||
targetPrice: 400,
|
||||
currency: "DKK",
|
||||
bestMatch: null,
|
||||
active: true,
|
||||
},
|
||||
];
|
||||
|
||||
const getFilterSummary = (filters: any) => {
|
||||
const parts = [];
|
||||
if (filters.capacity_gb) {
|
||||
parts.push(filters.capacity_gb >= 1000 ? `${filters.capacity_gb / 1000}TB` : `${filters.capacity_gb}GB`);
|
||||
}
|
||||
if (filters.interface) {
|
||||
parts.push(filters.interface);
|
||||
}
|
||||
if (filters.type) {
|
||||
parts.push(filters.type);
|
||||
}
|
||||
if (filters.brand && filters.brand.length > 0) {
|
||||
parts.push(`${filters.brand.join(", ")}`);
|
||||
}
|
||||
return parts.join(" • ");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{profiles.map((profile) => {
|
||||
const belowTarget = profile.bestMatch && profile.bestMatch.price <= profile.targetPrice;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={profile.id}
|
||||
className="p-4 rounded-lg border border-border bg-secondary/30 hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Search className="h-4 w-4 text-primary" />
|
||||
<h4 className="font-medium">{profile.title}</h4>
|
||||
{belowTarget && (
|
||||
<Badge variant="default" className="bg-success text-success-foreground">
|
||||
<TrendingDown className="h-3 w-3 mr-1" />
|
||||
Deal Found
|
||||
</Badge>
|
||||
)}
|
||||
{profile.active && (
|
||||
<Badge variant="outline" className="text-xs">Active</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Badge variant="secondary" className="text-xs uppercase">{profile.category}</Badge>
|
||||
<span>•</span>
|
||||
<span>{getFilterSummary(profile.filters)}</span>
|
||||
<span>•</span>
|
||||
<span>Target: {profile.targetPrice} {profile.currency}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost">
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{profile.bestMatch ? (
|
||||
<div className="pl-6 pt-3 border-t border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{profile.bestMatch.name}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{profile.bestMatch.store}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold">
|
||||
{profile.bestMatch.price} <span className="text-sm text-muted-foreground">{profile.currency}</span>
|
||||
</div>
|
||||
{belowTarget && (
|
||||
<p className="text-xs text-success">
|
||||
{((profile.targetPrice - profile.bestMatch.price) / profile.targetPrice * 100).toFixed(1)}% below target
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pl-6 pt-3 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground">No matches found yet</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
114
src/index.css
114
src/index.css
@@ -8,89 +8,95 @@ All colors MUST be HSL.
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--background: 220 26% 14%;
|
||||
--foreground: 210 40% 98%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--card: 220 24% 18%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--popover: 220 24% 18%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 222 47% 11%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 220 17% 25%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--muted: 220 17% 25%;
|
||||
--muted-foreground: 215 20% 65%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--accent: 263 70% 60%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--success: 142 76% 36%;
|
||||
--success-foreground: 210 40% 98%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--border: 220 17% 28%;
|
||||
--input: 220 17% 28%;
|
||||
--ring: 217 91% 60%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
--radius: 0.75rem;
|
||||
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--chart-1: 217 91% 60%;
|
||||
--chart-2: 263 70% 60%;
|
||||
--chart-3: 142 76% 36%;
|
||||
--chart-4: 43 96% 56%;
|
||||
--chart-5: 0 84% 60%;
|
||||
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
|
||||
--sidebar-border: 220 13% 91%;
|
||||
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--sidebar-background: 220 26% 14%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 217 91% 60%;
|
||||
--sidebar-primary-foreground: 222 47% 11%;
|
||||
--sidebar-accent: 220 17% 25%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 220 17% 28%;
|
||||
--sidebar-ring: 217 91% 60%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--background: 220 26% 14%;
|
||||
--foreground: 210 40% 98%;
|
||||
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card: 220 24% 18%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover: 220 24% 18%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 222 47% 11%;
|
||||
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary: 220 17% 25%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--muted: 220 17% 25%;
|
||||
--muted-foreground: 215 20% 65%;
|
||||
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent: 263 70% 60%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--success: 142 76% 36%;
|
||||
--success-foreground: 210 40% 98%;
|
||||
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--border: 220 17% 28%;
|
||||
--input: 220 17% 28%;
|
||||
--ring: 217 91% 60%;
|
||||
|
||||
--sidebar-background: 220 26% 14%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 217 91% 60%;
|
||||
--sidebar-primary-foreground: 222 47% 11%;
|
||||
--sidebar-accent: 220 17% 25%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 220 17% 28%;
|
||||
--sidebar-ring: 217 91% 60%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,178 @@
|
||||
// Update this page (the content is just a fallback if you fail to update the page)
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AlertCircle, TrendingDown, TrendingUp, Activity, Plus, Search, Package } from "lucide-react";
|
||||
import { ProductsList } from "@/components/ProductsList";
|
||||
import { SearchProfiles } from "@/components/SearchProfiles";
|
||||
import { AlertsList } from "@/components/AlertsList";
|
||||
import { PriceChart } from "@/components/PriceChart";
|
||||
|
||||
const Index = () => {
|
||||
const [activeTab, setActiveTab] = useState("dashboard");
|
||||
|
||||
// Mock data - will be replaced with real data later
|
||||
const stats = {
|
||||
trackedProducts: 12,
|
||||
activeProfiles: 5,
|
||||
recentAlerts: 3,
|
||||
avgPriceDrop: 8.5,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold">Welcome to Your Blank App</h1>
|
||||
<p className="text-xl text-muted-foreground">Start building your amazing project here!</p>
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-50">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Activity className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Hardware Price Tracker</h1>
|
||||
<p className="text-sm text-muted-foreground">Real-time monitoring & alerts</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Product
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Stats Overview */}
|
||||
<div className="grid gap-4 md:grid-cols-4 mb-8">
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Tracked Products</CardTitle>
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.trackedProducts}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Active monitoring</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Search Profiles</CardTitle>
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.activeProfiles}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Spec-based searches</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Recent Alerts</CardTitle>
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.recentAlerts}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Last 24 hours</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Avg Price Drop</CardTitle>
|
||||
<TrendingDown className="h-4 w-4 text-success" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.avgPriceDrop}%</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Below target</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Content Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList className="bg-card/50 border border-border">
|
||||
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
|
||||
<TabsTrigger value="products">Products</TabsTrigger>
|
||||
<TabsTrigger value="profiles">Search Profiles</TabsTrigger>
|
||||
<TabsTrigger value="alerts">Alerts</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="dashboard" className="space-y-6">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border">
|
||||
<CardHeader>
|
||||
<CardTitle>Price Trends</CardTitle>
|
||||
<CardDescription>Latest price movements for tracked products</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PriceChart />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
Recent Alerts
|
||||
</CardTitle>
|
||||
<CardDescription>Price drops below target threshold</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AlertsList limit={5} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border">
|
||||
<CardHeader>
|
||||
<CardTitle>Top Opportunities</CardTitle>
|
||||
<CardDescription>Best deals currently available</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProductsList limit={5} sortBy="deal" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="products">
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border">
|
||||
<CardHeader>
|
||||
<CardTitle>Tracked Products</CardTitle>
|
||||
<CardDescription>Direct URL monitoring for specific products</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProductsList />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="profiles">
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border">
|
||||
<CardHeader>
|
||||
<CardTitle>Search Profiles</CardTitle>
|
||||
<CardDescription>Spec-based searches with brand filters</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SearchProfiles />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="alerts">
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border">
|
||||
<CardHeader>
|
||||
<CardTitle>Alert History</CardTitle>
|
||||
<CardDescription>All price drop notifications</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AlertsList />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -31,6 +31,10 @@ export default {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
success: {
|
||||
DEFAULT: "hsl(var(--success))",
|
||||
foreground: "hsl(var(--success-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
@@ -57,6 +61,13 @@ export default {
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
},
|
||||
chart: {
|
||||
"1": "hsl(var(--chart-1))",
|
||||
"2": "hsl(var(--chart-2))",
|
||||
"3": "hsl(var(--chart-3))",
|
||||
"4": "hsl(var(--chart-4))",
|
||||
"5": "hsl(var(--chart-5))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
|
||||
Reference in New Issue
Block a user