Add database schema

This commit is contained in:
gpt-engineer-app[bot]
2025-10-21 04:23:22 +00:00
parent 0570846b00
commit f9feb67425
15 changed files with 1620 additions and 4 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
.env.local
dist
.vscode
.idea
*.log

181
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,181 @@
# Price Tracker - Docker Deployment Guide
## 🚀 Quick Start
This is a complete price tracking system with:
- Product management (Add/Edit/Remove products)
- Specification-based alert profiles (e.g., "any 6TB harddrive under 500 DKK")
- Discord webhook notifications
- No login required
## Prerequisites
- Docker and Docker Compose installed
- A Discord webhook URL (optional, for notifications)
## Installation
### 1. Clone the repository
```bash
git clone <your-repo-url>
cd <repo-name>
```
### 2. Build and run with Docker Compose
```bash
docker-compose up -d
```
The application will be available at: **http://localhost:8080**
### 3. Stop the application
```bash
docker-compose down
```
## How It Works
### Product Management
1. Go to **Products** tab
2. Click "Add Product"
3. Enter:
- Product name and URL
- Store (Proshop, Komplett, Amazon)
- Category (Hard Drive, RAM, Processor, Graphics Card)
- Current price
- Specifications (size, type, brand)
### Alert Profiles (Specification-Based)
Instead of tracking specific products, you create profiles based on what you need:
**Example**: "I need any 6TB harddrive under 500 DKK"
1. Go to **Alerts** tab
2. Click "Add Alert Profile"
3. Configure:
- Profile name: "6TB Storage Deal"
- Category: Hard Drive
- Target price: 500 DKK
- Minimum size: 6TB
- Type: SSD (optional)
- Discord webhook URL (optional)
### Discord Notifications
To receive Discord notifications:
1. In Discord, go to Server Settings → Integrations → Webhooks
2. Create a new webhook
3. Copy the webhook URL
4. Paste it in your alert profile
When a product matches your criteria and is below your target price, you'll get a Discord message!
### Checking Prices
Click the "Check Prices Now" button in the Alerts tab to manually trigger price checks.
**Automated Checks**: Set up a cron job to call the check-prices endpoint:
```bash
# Check prices every hour
0 * * * * curl -X POST http://localhost:8080/functions/v1/check-prices
```
## Architecture
```
Frontend (React + Vite)
Lovable Cloud Backend
PostgreSQL Database
Discord Webhooks
```
### Database Tables:
- **products**: All tracked products with specifications
- **alert_profiles**: Specification-based alert configurations
- **price_history**: Historical price data
- **alerts_log**: Log of triggered alerts
### Backend Functions:
- **check-prices**: Matches products to alert profiles and triggers notifications
- **send-discord-alert**: Sends formatted alerts to Discord
## Configuration
### Environment Variables
The app uses Lovable Cloud which is pre-configured. No additional environment setup needed!
### Port Configuration
Default port is 8080. To change it, edit `docker-compose.yml`:
```yaml
ports:
- "YOUR_PORT:80"
```
## Development
### Local Development (without Docker)
```bash
npm install
npm run dev
```
### Building for Production
```bash
npm run build
```
## Troubleshooting
### Can't see products or alerts
- Check browser console for errors
- Verify Lovable Cloud is connected
- Check database tables in Cloud tab
### Discord notifications not working
- Verify webhook URL is correct
- Check edge function logs in Cloud tab
- Test webhook manually with curl
### Docker build fails
- Clear Docker cache: `docker-compose build --no-cache`
- Check Docker logs: `docker-compose logs`
## Features
✅ Product CRUD operations
✅ Specification-based alerts (not tied to specific products)
✅ Discord webhook integration
✅ No authentication required
✅ Real-time price tracking
✅ Dashboard with statistics
✅ Single Docker deployment
## Example Alert Scenarios
1. **Budget Storage**: "Any 6TB+ harddrive under 450 DKK"
2. **RAM Upgrade**: "32GB+ DDR5 RAM under 900 DKK"
3. **GPU Deal**: "Any RTX 4070 graphics card under 4000 DKK"
4. **Fast Storage**: "2TB+ NVMe SSD under 1000 DKK"
Each alert will match ANY product that meets your specifications!
## Support
For issues or questions, check:
- Lovable Cloud tab for backend logs
- Browser console for frontend errors
- Docker logs: `docker-compose logs -f`

31
Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files to nginx
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
version: '3.8'
services:
pricetracker:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:80"
environment:
- NODE_ENV=production
restart: unless-stopped
container_name: pricetracker
networks:
- pricetracker-network
networks:
pricetracker-network:
driver: bridge

28
nginx.conf Normal file
View File

@@ -0,0 +1,28 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Handle React Router
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View File

@@ -3,7 +3,9 @@ import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Index from "./pages/Index";
import Dashboard from "./pages/Dashboard";
import Products from "./pages/Products";
import Alerts from "./pages/Alerts";
import NotFound from "./pages/NotFound";
const queryClient = new QueryClient();
@@ -15,7 +17,9 @@ const App = () => (
<Sonner />
<BrowserRouter>
<Routes>
<Route path="/" element={<Index />} />
<Route path="/" element={<Dashboard />} />
<Route path="/products" element={<Products />} />
<Route path="/alerts" element={<Alerts />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>

View File

@@ -0,0 +1,45 @@
import { Link, useLocation } from "react-router-dom";
import { cn } from "@/lib/utils";
import { LayoutDashboard, Package, Bell } from "lucide-react";
export const Navigation = () => {
const location = useLocation();
const links = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/products", label: "Products", icon: Package },
{ href: "/alerts", label: "Alerts", icon: Bell },
];
return (
<nav className="border-b bg-card">
<div className="max-w-7xl mx-auto px-6">
<div className="flex h-16 items-center gap-8">
<Link to="/" className="text-xl font-bold">
PriceTracker
</Link>
<div className="flex gap-1">
{links.map((link) => {
const Icon = link.icon;
return (
<Link
key={link.href}
to={link.href}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors",
location.pathname === link.href
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
<Icon className="h-4 w-4" />
{link.label}
</Link>
);
})}
</div>
</div>
</div>
</nav>
);
};

View File

@@ -14,7 +14,152 @@ export type Database = {
}
public: {
Tables: {
[_ in never]: never
alert_profiles: {
Row: {
category: string
created_at: string | null
discord_webhook_url: string | null
enabled: boolean | null
id: string
name: string
specs_filter: Json
target_price: number
}
Insert: {
category: string
created_at?: string | null
discord_webhook_url?: string | null
enabled?: boolean | null
id?: string
name: string
specs_filter: Json
target_price: number
}
Update: {
category?: string
created_at?: string | null
discord_webhook_url?: string | null
enabled?: boolean | null
id?: string
name?: string
specs_filter?: Json
target_price?: number
}
Relationships: []
}
alerts_log: {
Row: {
alert_profile_id: string | null
created_at: string | null
id: string
message: string | null
product_id: string | null
sent_to_discord: boolean | null
triggered_price: number
}
Insert: {
alert_profile_id?: string | null
created_at?: string | null
id?: string
message?: string | null
product_id?: string | null
sent_to_discord?: boolean | null
triggered_price: number
}
Update: {
alert_profile_id?: string | null
created_at?: string | null
id?: string
message?: string | null
product_id?: string | null
sent_to_discord?: boolean | null
triggered_price?: number
}
Relationships: [
{
foreignKeyName: "alerts_log_alert_profile_id_fkey"
columns: ["alert_profile_id"]
isOneToOne: false
referencedRelation: "alert_profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "alerts_log_product_id_fkey"
columns: ["product_id"]
isOneToOne: false
referencedRelation: "products"
referencedColumns: ["id"]
},
]
}
price_history: {
Row: {
checked_at: string | null
id: string
price: number
product_id: string | null
}
Insert: {
checked_at?: string | null
id?: string
price: number
product_id?: string | null
}
Update: {
checked_at?: string | null
id?: string
price?: number
product_id?: string | null
}
Relationships: [
{
foreignKeyName: "price_history_product_id_fkey"
columns: ["product_id"]
isOneToOne: false
referencedRelation: "products"
referencedColumns: ["id"]
},
]
}
products: {
Row: {
category: string
created_at: string | null
currency: string | null
current_price: number
id: string
last_checked: string | null
name: string
specs: Json
store: string
url: string
}
Insert: {
category: string
created_at?: string | null
currency?: string | null
current_price: number
id?: string
last_checked?: string | null
name: string
specs: Json
store: string
url: string
}
Update: {
category?: string
created_at?: string | null
currency?: string | null
current_price?: number
id?: string
last_checked?: string | null
name?: string
specs?: Json
store?: string
url?: string
}
Relationships: []
}
}
Views: {
[_ in never]: never

336
src/pages/Alerts.tsx Normal file
View File

@@ -0,0 +1,336 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { toast } from "sonner";
import { Plus, Trash2, Edit, Bell } from "lucide-react";
import { Navigation } from "@/components/Navigation";
export default function Alerts() {
const queryClient = useQueryClient();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingAlert, setEditingAlert] = useState<any>(null);
const [formData, setFormData] = useState({
name: "",
category: "harddrive",
target_price: "",
min_size: "",
type: "",
discord_webhook_url: "",
enabled: true
});
const { data: alerts, isLoading } = useQuery({
queryKey: ["alert_profiles"],
queryFn: async () => {
const { data, error } = await supabase
.from("alert_profiles")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
return data;
},
});
const addMutation = useMutation({
mutationFn: async (alert: any) => {
const { error } = await supabase.from("alert_profiles").insert([{
name: alert.name,
category: alert.category,
target_price: parseFloat(alert.target_price),
specs_filter: {
min_size: alert.min_size,
type: alert.type
},
discord_webhook_url: alert.discord_webhook_url || null,
enabled: alert.enabled
}]);
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["alert_profiles"] });
toast.success("Alert profile created successfully!");
setIsDialogOpen(false);
resetForm();
},
onError: () => toast.error("Failed to create alert profile"),
});
const updateMutation = useMutation({
mutationFn: async (alert: any) => {
const { error } = await supabase
.from("alert_profiles")
.update({
name: alert.name,
category: alert.category,
target_price: parseFloat(alert.target_price),
specs_filter: {
min_size: alert.min_size,
type: alert.type
},
discord_webhook_url: alert.discord_webhook_url || null,
enabled: alert.enabled
})
.eq("id", alert.id);
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["alert_profiles"] });
toast.success("Alert profile updated successfully!");
setIsDialogOpen(false);
setEditingAlert(null);
resetForm();
},
onError: () => toast.error("Failed to update alert profile"),
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
const { error } = await supabase.from("alert_profiles").delete().eq("id", id);
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["alert_profiles"] });
toast.success("Alert profile deleted successfully!");
},
onError: () => toast.error("Failed to delete alert profile"),
});
const checkPricesMutation = useMutation({
mutationFn: async () => {
const response = await fetch(
`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/check-prices`,
{ method: "POST" }
);
if (!response.ok) throw new Error("Failed to check prices");
return response.json();
},
onSuccess: (data) => {
toast.success(`Price check complete! Found ${data.alertsTriggered} matching products.`);
},
onError: () => toast.error("Failed to check prices"),
});
const resetForm = () => {
setFormData({
name: "",
category: "harddrive",
target_price: "",
min_size: "",
type: "",
discord_webhook_url: "",
enabled: true
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (editingAlert) {
updateMutation.mutate({ ...formData, id: editingAlert.id });
} else {
addMutation.mutate(formData);
}
};
const handleEdit = (alert: any) => {
setEditingAlert(alert);
setFormData({
name: alert.name,
category: alert.category,
target_price: alert.target_price.toString(),
min_size: alert.specs_filter?.min_size || "",
type: alert.specs_filter?.type || "",
discord_webhook_url: alert.discord_webhook_url || "",
enabled: alert.enabled
});
setIsDialogOpen(true);
};
return (
<div className="min-h-screen bg-background">
<Navigation />
<div className="p-6">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-8">
<h1 className="text-4xl font-bold">Alert Profiles</h1>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => checkPricesMutation.mutate()}
disabled={checkPricesMutation.isPending}
>
<Bell className="mr-2 h-4 w-4" />
{checkPricesMutation.isPending ? "Checking..." : "Check Prices Now"}
</Button>
<Dialog open={isDialogOpen} onOpenChange={(open) => {
setIsDialogOpen(open);
if (!open) {
setEditingAlert(null);
resetForm();
}
}}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Alert Profile
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editingAlert ? "Edit Alert Profile" : "Create Alert Profile"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="name">Profile Name</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., 6TB Storage Deal"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="category">Category</Label>
<Select value={formData.category} onValueChange={(value) => setFormData({ ...formData, category: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="harddrive">Hard Drive</SelectItem>
<SelectItem value="ram">RAM</SelectItem>
<SelectItem value="processor">Processor</SelectItem>
<SelectItem value="graphics_card">Graphics Card</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="target_price">Target Price (DKK)</Label>
<Input
id="target_price"
type="number"
step="0.01"
value={formData.target_price}
onChange={(e) => setFormData({ ...formData, target_price: e.target.value })}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="min_size">Minimum Size (e.g., 6TB, 32GB)</Label>
<Input
id="min_size"
value={formData.min_size}
onChange={(e) => setFormData({ ...formData, min_size: e.target.value })}
placeholder="6TB"
required
/>
</div>
<div>
<Label htmlFor="type">Type (e.g., SSD, HDD, DDR5)</Label>
<Input
id="type"
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
placeholder="SSD"
/>
</div>
</div>
<div>
<Label htmlFor="discord_webhook_url">Discord Webhook URL (Optional)</Label>
<Input
id="discord_webhook_url"
type="url"
value={formData.discord_webhook_url}
onChange={(e) => setFormData({ ...formData, discord_webhook_url: e.target.value })}
placeholder="https://discord.com/api/webhooks/..."
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="enabled"
checked={formData.enabled}
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
/>
<Label htmlFor="enabled">Alert Enabled</Label>
</div>
<Button type="submit" className="w-full">
{editingAlert ? "Update Alert Profile" : "Create Alert Profile"}
</Button>
</form>
</DialogContent>
</Dialog>
</div>
</div>
<div className="grid gap-4">
{isLoading ? (
<div>Loading alert profiles...</div>
) : alerts?.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
No alert profiles yet. Create your first alert to get notified when prices drop!
</CardContent>
</Card>
) : (
alerts?.map((alert: any) => (
<Card key={alert.id} className={!alert.enabled ? "opacity-50" : ""}>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="flex items-center gap-2">
{alert.name}
{alert.enabled && <span className="text-sm text-green-500"></span>}
</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
{alert.category} Target: {alert.target_price} DKK
</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => handleEdit(alert)}>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => deleteMutation.mutate(alert.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Min Size</p>
<p className="font-semibold">{alert.specs_filter?.min_size || "Any"}</p>
</div>
<div>
<p className="text-muted-foreground">Type</p>
<p className="font-semibold">{alert.specs_filter?.type || "Any"}</p>
</div>
<div>
<p className="text-muted-foreground">Discord</p>
<p className="font-semibold">{alert.discord_webhook_url ? "✓ Enabled" : "✗ Disabled"}</p>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
</div>
</div>
);
}

141
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,141 @@
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Package, Bell, TrendingDown, Clock } from "lucide-react";
import { Navigation } from "@/components/Navigation";
export default function Dashboard() {
const { data: products } = useQuery({
queryKey: ["products"],
queryFn: async () => {
const { data, error } = await supabase.from("products").select("*");
if (error) throw error;
return data;
},
});
const { data: alerts } = useQuery({
queryKey: ["alert_profiles"],
queryFn: async () => {
const { data, error } = await supabase.from("alert_profiles").select("*");
if (error) throw error;
return data;
},
});
const { data: recentAlerts } = useQuery({
queryKey: ["recent_alerts"],
queryFn: async () => {
const { data, error } = await supabase
.from("alerts_log")
.select("*, product:products(*), profile:alert_profiles(*)")
.order("created_at", { ascending: false })
.limit(10);
if (error) throw error;
return data;
},
});
const activeAlerts = alerts?.filter((a) => a.enabled).length || 0;
const totalProducts = products?.length || 0;
const triggeredToday = recentAlerts?.filter(
(a) => new Date(a.created_at).toDateString() === new Date().toDateString()
).length || 0;
return (
<div className="min-h-screen bg-background">
<Navigation />
<div className="p-6">
<div className="max-w-7xl mx-auto">
<h1 className="text-4xl font-bold mb-8">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Products</CardTitle>
<Package className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalProducts}</div>
<p className="text-xs text-muted-foreground">Products being tracked</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Alerts</CardTitle>
<Bell className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{activeAlerts}</div>
<p className="text-xs text-muted-foreground">Alert profiles enabled</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Triggered Today</CardTitle>
<TrendingDown className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{triggeredToday}</div>
<p className="text-xs text-muted-foreground">Price alerts triggered</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Alerts</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{recentAlerts?.length || 0}</div>
<p className="text-xs text-muted-foreground">All time alerts</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Recent Price Alerts</CardTitle>
</CardHeader>
<CardContent>
{!recentAlerts || recentAlerts.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
No alerts triggered yet. Add products and alert profiles to start tracking!
</p>
) : (
<div className="space-y-4">
{recentAlerts.map((alert: any) => (
<div
key={alert.id}
className="flex items-start gap-4 p-4 rounded-lg border border-border bg-card hover:bg-accent/50 transition-colors"
>
<div className="flex-1">
<h4 className="font-medium mb-1">{alert.profile?.name}</h4>
<p className="text-sm text-muted-foreground mb-2">{alert.product?.name}</p>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>Price: {alert.triggered_price} DKK</span>
<span></span>
<span>{alert.product?.store}</span>
<span></span>
<span>{new Date(alert.created_at).toLocaleString()}</span>
{alert.sent_to_discord && (
<>
<span></span>
<span className="text-green-500"> Sent to Discord</span>
</>
)}
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}

336
src/pages/Products.tsx Normal file
View File

@@ -0,0 +1,336 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { toast } from "sonner";
import { Plus, Trash2, Edit } from "lucide-react";
import { Navigation } from "@/components/Navigation";
export default function Products() {
const queryClient = useQueryClient();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<any>(null);
const [formData, setFormData] = useState({
name: "",
url: "",
store: "proshop",
category: "harddrive",
current_price: "",
size: "",
type: "",
brand: ""
});
const { data: products, isLoading } = useQuery({
queryKey: ["products"],
queryFn: async () => {
const { data, error } = await supabase
.from("products")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
return data;
},
});
const addMutation = useMutation({
mutationFn: async (product: any) => {
const { error } = await supabase.from("products").insert([{
name: product.name,
url: product.url,
store: product.store,
category: product.category,
current_price: parseFloat(product.current_price),
specs: {
size: product.size,
type: product.type,
brand: product.brand
}
}]);
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
toast.success("Product added successfully!");
setIsDialogOpen(false);
resetForm();
},
onError: () => toast.error("Failed to add product"),
});
const updateMutation = useMutation({
mutationFn: async (product: any) => {
const { error } = await supabase
.from("products")
.update({
name: product.name,
url: product.url,
store: product.store,
category: product.category,
current_price: parseFloat(product.current_price),
specs: {
size: product.size,
type: product.type,
brand: product.brand
}
})
.eq("id", product.id);
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
toast.success("Product updated successfully!");
setIsDialogOpen(false);
setEditingProduct(null);
resetForm();
},
onError: () => toast.error("Failed to update product"),
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
const { error } = await supabase.from("products").delete().eq("id", id);
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
toast.success("Product deleted successfully!");
},
onError: () => toast.error("Failed to delete product"),
});
const resetForm = () => {
setFormData({
name: "",
url: "",
store: "proshop",
category: "harddrive",
current_price: "",
size: "",
type: "",
brand: ""
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (editingProduct) {
updateMutation.mutate({ ...formData, id: editingProduct.id });
} else {
addMutation.mutate(formData);
}
};
const handleEdit = (product: any) => {
setEditingProduct(product);
setFormData({
name: product.name,
url: product.url,
store: product.store,
category: product.category,
current_price: product.current_price.toString(),
size: product.specs?.size || "",
type: product.specs?.type || "",
brand: product.specs?.brand || ""
});
setIsDialogOpen(true);
};
return (
<div className="min-h-screen bg-background">
<Navigation />
<div className="p-6">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-8">
<h1 className="text-4xl font-bold">Product Management</h1>
<Dialog open={isDialogOpen} onOpenChange={(open) => {
setIsDialogOpen(open);
if (!open) {
setEditingProduct(null);
resetForm();
}
}}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Product
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editingProduct ? "Edit Product" : "Add New Product"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="name">Product Name</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div>
<Label htmlFor="url">Product URL</Label>
<Input
id="url"
type="url"
value={formData.url}
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="store">Store</Label>
<Select value={formData.store} onValueChange={(value) => setFormData({ ...formData, store: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="proshop">Proshop</SelectItem>
<SelectItem value="komplett">Komplett</SelectItem>
<SelectItem value="amazon">Amazon</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="category">Category</Label>
<Select value={formData.category} onValueChange={(value) => setFormData({ ...formData, category: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="harddrive">Hard Drive</SelectItem>
<SelectItem value="ram">RAM</SelectItem>
<SelectItem value="processor">Processor</SelectItem>
<SelectItem value="graphics_card">Graphics Card</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="price">Current Price (DKK)</Label>
<Input
id="price"
type="number"
step="0.01"
value={formData.current_price}
onChange={(e) => setFormData({ ...formData, current_price: e.target.value })}
required
/>
</div>
<div>
<Label htmlFor="size">Size (e.g., 6TB, 32GB)</Label>
<Input
id="size"
value={formData.size}
onChange={(e) => setFormData({ ...formData, size: e.target.value })}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="type">Type (e.g., SSD, HDD, DDR5)</Label>
<Input
id="type"
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
/>
</div>
<div>
<Label htmlFor="brand">Brand</Label>
<Input
id="brand"
value={formData.brand}
onChange={(e) => setFormData({ ...formData, brand: e.target.value })}
/>
</div>
</div>
<Button type="submit" className="w-full">
{editingProduct ? "Update Product" : "Add Product"}
</Button>
</form>
</DialogContent>
</Dialog>
</div>
<div className="grid gap-4">
{isLoading ? (
<div>Loading products...</div>
) : products?.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
No products yet. Add your first product to start tracking prices!
</CardContent>
</Card>
) : (
products?.map((product: any) => (
<Card key={product.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>{product.name}</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
{product.store} {product.category}
</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => handleEdit(product)}>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => deleteMutation.mutate(product.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Price</p>
<p className="font-semibold">{product.current_price} {product.currency}</p>
</div>
<div>
<p className="text-muted-foreground">Size</p>
<p className="font-semibold">{product.specs?.size || "N/A"}</p>
</div>
<div>
<p className="text-muted-foreground">Type</p>
<p className="font-semibold">{product.specs?.type || "N/A"}</p>
</div>
<div>
<p className="text-muted-foreground">Brand</p>
<p className="font-semibold">{product.specs?.brand || "N/A"}</p>
</div>
</div>
<a
href={product.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline mt-2 inline-block"
>
View on {product.store}
</a>
</CardContent>
</Card>
))
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1 +1,7 @@
project_id = "hhwrnnerlwmstyqhmtrr"
project_id = "hhwrnnerlwmstyqhmtrr"
[functions.check-prices]
verify_jwt = false
[functions.send-discord-alert]
verify_jwt = false

View File

@@ -0,0 +1,175 @@
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
try {
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseKey);
console.log('Starting price check...');
// Get all enabled alert profiles
const { data: alertProfiles, error: profilesError } = await supabase
.from('alert_profiles')
.select('*')
.eq('enabled', true);
if (profilesError) {
throw profilesError;
}
console.log(`Found ${alertProfiles?.length || 0} active alert profiles`);
// Get all products
const { data: products, error: productsError } = await supabase
.from('products')
.select('*');
if (productsError) {
throw productsError;
}
console.log(`Found ${products?.length || 0} products`);
const alerts = [];
// Check each alert profile against products
for (const profile of alertProfiles || []) {
for (const product of products || []) {
// Match category
if (product.category !== profile.category) continue;
// Check if product meets specification requirements
const specsFilter = profile.specs_filter;
const productSpecs = product.specs;
let meetsRequirements = true;
// Check minimum size (for harddrives/ram)
if (specsFilter.min_size) {
const minSizeValue = parseFloat(specsFilter.min_size);
const productSizeValue = parseFloat(productSpecs.size || '0');
if (productSizeValue < minSizeValue) {
meetsRequirements = false;
}
}
// Check type (SSD/HDD/etc)
if (specsFilter.type && productSpecs.type !== specsFilter.type) {
meetsRequirements = false;
}
// Check if price is below target
if (!meetsRequirements || product.current_price > profile.target_price) {
continue;
}
// Found a match! Log the alert
const message = `🎯 Alert: ${profile.name}\n` +
`Product: ${product.name}\n` +
`Price: ${product.current_price} ${product.currency}\n` +
`Target: ${profile.target_price} ${product.currency}\n` +
`Store: ${product.store}\n` +
`URL: ${product.url}`;
const { error: logError } = await supabase
.from('alerts_log')
.insert({
alert_profile_id: profile.id,
product_id: product.id,
triggered_price: product.current_price,
message,
sent_to_discord: false
});
if (logError) {
console.error('Error logging alert:', logError);
}
alerts.push({
profile,
product,
message
});
// Send Discord notification if webhook URL is set
if (profile.discord_webhook_url) {
try {
await fetch(profile.discord_webhook_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: message,
embeds: [{
title: '💰 Price Alert!',
description: message,
color: 0x00ff00,
fields: [
{
name: 'Current Price',
value: `${product.current_price} ${product.currency}`,
inline: true
},
{
name: 'Target Price',
value: `${profile.target_price} ${product.currency}`,
inline: true
},
{
name: 'Savings',
value: `${(profile.target_price - product.current_price).toFixed(2)} ${product.currency}`,
inline: true
}
],
timestamp: new Date().toISOString()
}]
})
});
// Update log to mark as sent
await supabase
.from('alerts_log')
.update({ sent_to_discord: true })
.eq('alert_profile_id', profile.id)
.eq('product_id', product.id);
console.log(`Sent Discord notification for ${product.name}`);
} catch (discordError) {
console.error('Error sending Discord notification:', discordError);
}
}
}
}
console.log(`Price check complete. Found ${alerts.length} matching alerts`);
return new Response(
JSON.stringify({
success: true,
alertsTriggered: alerts.length,
alerts
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error in check-prices function:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
return new Response(
JSON.stringify({ error: errorMessage }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
);
}
});

View File

@@ -0,0 +1,83 @@
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
try {
const { webhookUrl, message, product, profile } = await req.json();
if (!webhookUrl) {
throw new Error('Discord webhook URL is required');
}
const embed = {
title: '💰 Price Alert Triggered!',
description: message,
color: 0x00ff00,
fields: [
{
name: 'Product',
value: product.name,
inline: false
},
{
name: 'Current Price',
value: `${product.current_price} ${product.currency}`,
inline: true
},
{
name: 'Target Price',
value: `${profile.target_price} ${product.currency}`,
inline: true
},
{
name: 'Store',
value: product.store,
inline: true
},
{
name: 'Link',
value: product.url,
inline: false
}
],
timestamp: new Date().toISOString()
};
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: `🎯 **${profile.name}** - Price target reached!`,
embeds: [embed]
})
});
if (!response.ok) {
throw new Error(`Discord API error: ${response.status}`);
}
console.log('Discord notification sent successfully');
return new Response(
JSON.stringify({ success: true }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error sending Discord alert:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
return new Response(
JSON.stringify({ error: errorMessage }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
);
}
});

View File

@@ -0,0 +1,75 @@
-- Create products table
CREATE TABLE IF NOT EXISTS public.products (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
store TEXT NOT NULL,
category TEXT NOT NULL, -- 'harddrive', 'ram', 'processor', 'graphics_card'
current_price DECIMAL(10,2) NOT NULL,
currency TEXT DEFAULT 'DKK',
specs JSONB NOT NULL, -- Store size, brand, speed, etc.
last_checked TIMESTAMP WITH TIME ZONE DEFAULT now(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-- Create alert profiles table (specification-based, not product-specific)
CREATE TABLE IF NOT EXISTS public.alert_profiles (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
target_price DECIMAL(10,2) NOT NULL,
specs_filter JSONB NOT NULL, -- e.g., {"min_size": "6TB", "type": "SSD"}
discord_webhook_url TEXT,
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-- Create price history table
CREATE TABLE IF NOT EXISTS public.price_history (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
product_id UUID REFERENCES public.products(id) ON DELETE CASCADE,
price DECIMAL(10,2) NOT NULL,
checked_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-- Create alerts log table
CREATE TABLE IF NOT EXISTS public.alerts_log (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
alert_profile_id UUID REFERENCES public.alert_profiles(id) ON DELETE CASCADE,
product_id UUID REFERENCES public.products(id) ON DELETE CASCADE,
triggered_price DECIMAL(10,2) NOT NULL,
message TEXT,
sent_to_discord BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-- Enable Row Level Security (but make everything public since no login)
ALTER TABLE public.products ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.alert_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.price_history ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.alerts_log ENABLE ROW LEVEL SECURITY;
-- Create public access policies (no authentication required)
CREATE POLICY "Public read access for products" ON public.products FOR SELECT USING (true);
CREATE POLICY "Public insert access for products" ON public.products FOR INSERT WITH CHECK (true);
CREATE POLICY "Public update access for products" ON public.products FOR UPDATE USING (true);
CREATE POLICY "Public delete access for products" ON public.products FOR DELETE USING (true);
CREATE POLICY "Public read access for alert_profiles" ON public.alert_profiles FOR SELECT USING (true);
CREATE POLICY "Public insert access for alert_profiles" ON public.alert_profiles FOR INSERT WITH CHECK (true);
CREATE POLICY "Public update access for alert_profiles" ON public.alert_profiles FOR UPDATE USING (true);
CREATE POLICY "Public delete access for alert_profiles" ON public.alert_profiles FOR DELETE USING (true);
CREATE POLICY "Public read access for price_history" ON public.price_history FOR SELECT USING (true);
CREATE POLICY "Public insert access for price_history" ON public.price_history FOR INSERT WITH CHECK (true);
CREATE POLICY "Public read access for alerts_log" ON public.alerts_log FOR SELECT USING (true);
CREATE POLICY "Public insert access for alerts_log" ON public.alerts_log FOR INSERT WITH CHECK (true);
-- Create indexes for better performance
CREATE INDEX idx_products_category ON public.products(category);
CREATE INDEX idx_products_last_checked ON public.products(last_checked);
CREATE INDEX idx_alert_profiles_category ON public.alert_profiles(category);
CREATE INDEX idx_alert_profiles_enabled ON public.alert_profiles(enabled);
CREATE INDEX idx_price_history_product ON public.price_history(product_id);
CREATE INDEX idx_alerts_log_profile ON public.alerts_log(alert_profile_id);