Add database schema
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal 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
181
DEPLOYMENT.md
Normal 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
31
Dockerfile
Normal 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
19
docker-compose.yml
Normal 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
28
nginx.conf
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
45
src/components/Navigation.tsx
Normal file
45
src/components/Navigation.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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
336
src/pages/Alerts.tsx
Normal 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
141
src/pages/Dashboard.tsx
Normal 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
336
src/pages/Products.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,7 @@
|
||||
project_id = "hhwrnnerlwmstyqhmtrr"
|
||||
project_id = "hhwrnnerlwmstyqhmtrr"
|
||||
|
||||
[functions.check-prices]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.send-discord-alert]
|
||||
verify_jwt = false
|
||||
175
supabase/functions/check-prices/index.ts
Normal file
175
supabase/functions/check-prices/index.ts
Normal 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' }
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
83
supabase/functions/send-discord-alert/index.ts
Normal file
83
supabase/functions/send-discord-alert/index.ts
Normal 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' }
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user