commit 10660e5626212c1670c261f08b3288546a6d5c82 Author: clucraft Date: Tue Jan 20 13:58:13 2026 -0500 Initial commit: PriceGhost price tracking application Full-stack application for tracking product prices: - Backend: Node.js + Express + TypeScript - Frontend: React + Vite + TypeScript - Database: PostgreSQL - Price scraping with Cheerio - JWT authentication - Background price checking with node-cron - Price history charts with Recharts - Docker support with docker-compose Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da87885 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +build/ + +# Environment files +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Testing +coverage/ + +# Misc +*.tgz +.cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3157456 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# PriceGhost + +A full-stack web application for tracking product prices across any website. Monitor prices over time and visualize price history with interactive charts. + +## Features + +- Track prices from any product URL +- Automatic price extraction using heuristics and structured data +- Configurable refresh intervals (15 min to 24 hours) +- Price history visualization with Recharts +- User authentication with JWT +- Background price checking with node-cron +- Docker support for easy deployment + +## Tech Stack + +- **Backend**: Node.js, Express, TypeScript +- **Frontend**: React, Vite, TypeScript +- **Database**: PostgreSQL +- **Scraping**: Cheerio +- **Charts**: Recharts +- **Auth**: JWT + bcrypt +- **Containerization**: Docker + +## Quick Start with Docker + +1. Clone the repository: +```bash +git clone https://github.com/yourusername/priceghost.git +cd priceghost +``` + +2. Start all services: +```bash +docker-compose up -d +``` + +3. Initialize the database: +```bash +docker-compose exec backend npm run db:init +``` + +4. Access the application at http://localhost + +## Development Setup + +### Prerequisites + +- Node.js 20+ +- PostgreSQL 16+ + +### Backend Setup + +```bash +cd backend +npm install + +# Create .env file +cp .env.example .env +# Edit .env with your database credentials + +# Initialize database +npm run db:init + +# Start development server +npm run dev +``` + +### Frontend Setup + +```bash +cd frontend +npm install + +# Start development server +npm run dev +``` + +## Environment Variables + +### Backend (.env) + +``` +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/priceghost +JWT_SECRET=your-secret-key +PORT=3001 +NODE_ENV=development +``` + +## API Endpoints + +### Authentication +- `POST /api/auth/register` - Register new user +- `POST /api/auth/login` - Login + +### Products (Protected) +- `GET /api/products` - List tracked products +- `POST /api/products` - Add product to track +- `GET /api/products/:id` - Get product details +- `PUT /api/products/:id` - Update product settings +- `DELETE /api/products/:id` - Remove product + +### Prices (Protected) +- `GET /api/products/:id/prices` - Get price history +- `POST /api/products/:id/refresh` - Force price refresh + +## Project Structure + +``` +PriceGhost/ +├── backend/ +│ ├── src/ +│ │ ├── config/ # Database configuration +│ │ ├── middleware/ # JWT auth middleware +│ │ ├── models/ # Database queries +│ │ ├── routes/ # API routes +│ │ ├── services/ # Scraper & scheduler +│ │ └── utils/ # Price parsing utilities +│ ├── Dockerfile +│ └── package.json +├── frontend/ +│ ├── src/ +│ │ ├── api/ # Axios client +│ │ ├── components/ # React components +│ │ ├── context/ # Auth context +│ │ ├── hooks/ # Custom hooks +│ │ └── pages/ # Page components +│ ├── Dockerfile +│ └── package.json +├── docker-compose.yml +└── README.md +``` + +## License + +MIT diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..3cf626e --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,10 @@ +node_modules +dist +npm-debug.log +.env +.env.local +.git +.gitignore +README.md +Dockerfile +.dockerignore diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..2040fe8 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,9 @@ +# Database +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/priceghost + +# JWT +JWT_SECRET=your-super-secret-jwt-key-change-in-production + +# Server +PORT=3001 +NODE_ENV=development diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8410f83 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,46 @@ +# 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 TypeScript +RUN npm run build + +# Production stage +FROM node:20-alpine AS production + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install production dependencies only +RUN npm ci --only=production + +# Copy built files from builder +COPY --from=builder /app/dist ./dist + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 + +USER nodejs + +# Expose port +EXPOSE 3001 + +# Set environment variables +ENV NODE_ENV=production +ENV PORT=3001 + +# Start the application +CMD ["node", "dist/index.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..74d4f1f --- /dev/null +++ b/backend/package.json @@ -0,0 +1,34 @@ +{ + "name": "priceghost-backend", + "version": "1.0.0", + "description": "PriceGhost price tracking API", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "db:init": "tsx src/config/init-db.ts" + }, + "dependencies": { + "axios": "^1.6.0", + "bcrypt": "^5.1.1", + "cheerio": "^1.0.0-rc.12", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "node-cron": "^3.0.3", + "pg": "^8.11.3" + }, + "devDependencies": { + "@types/bcrypt": "^5.0.2", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.5", + "@types/node": "^20.10.0", + "@types/node-cron": "^3.0.11", + "@types/pg": "^8.10.9", + "tsx": "^4.6.0", + "typescript": "^5.3.2" + } +} diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts new file mode 100644 index 0000000..0a07789 --- /dev/null +++ b/backend/src/config/database.ts @@ -0,0 +1,19 @@ +import { Pool } from 'pg'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +pool.on('connect', () => { + console.log('Connected to PostgreSQL database'); +}); + +pool.on('error', (err) => { + console.error('Unexpected error on idle client', err); + process.exit(-1); +}); + +export default pool; diff --git a/backend/src/config/init-db.ts b/backend/src/config/init-db.ts new file mode 100644 index 0000000..d0cfdc7 --- /dev/null +++ b/backend/src/config/init-db.ts @@ -0,0 +1,67 @@ +import pool from './database'; + +const initDatabase = async () => { + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + // Create users table + await client.query(` + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `); + console.log('Created users table'); + + // Create products table + await client.query(` + CREATE TABLE IF NOT EXISTS products ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + url TEXT NOT NULL, + name VARCHAR(255), + image_url TEXT, + refresh_interval INTEGER DEFAULT 3600, + last_checked TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, url) + ); + `); + console.log('Created products table'); + + // Create price_history table + await client.query(` + CREATE TABLE IF NOT EXISTS price_history ( + id SERIAL PRIMARY KEY, + product_id INTEGER REFERENCES products(id) ON DELETE CASCADE, + price DECIMAL(10,2) NOT NULL, + currency VARCHAR(10) DEFAULT 'USD', + recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `); + console.log('Created price_history table'); + + // Create index for price history queries + await client.query(` + CREATE INDEX IF NOT EXISTS idx_price_history_product_date + ON price_history(product_id, recorded_at); + `); + console.log('Created price_history index'); + + await client.query('COMMIT'); + console.log('Database initialization complete'); + } catch (error) { + await client.query('ROLLBACK'); + console.error('Error initializing database:', error); + throw error; + } finally { + client.release(); + await pool.end(); + } +}; + +initDatabase().catch(console.error); diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..0d02ae7 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,53 @@ +import express from 'express'; +import cors from 'cors'; +import dotenv from 'dotenv'; + +import authRoutes from './routes/auth'; +import productRoutes from './routes/products'; +import priceRoutes from './routes/prices'; +import { startScheduler } from './services/scheduler'; + +// Load environment variables +dotenv.config(); + +const app = express(); +const PORT = process.env.PORT || 3001; + +// Middleware +app.use(cors()); +app.use(express.json()); + +// Health check endpoint +app.get('/health', (_, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// API Routes +app.use('/api/auth', authRoutes); +app.use('/api/products', productRoutes); +app.use('/api/products', priceRoutes); + +// Error handling middleware +app.use( + ( + err: Error, + _req: express.Request, + res: express.Response, + _next: express.NextFunction + ) => { + console.error('Unhandled error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +); + +// Start server +app.listen(PORT, () => { + console.log(`PriceGhost API server running on port ${PORT}`); + + // Start the background price checker + if (process.env.NODE_ENV !== 'test') { + startScheduler(); + } +}); + +export default app; diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..52484f1 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,61 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; + +export interface AuthRequest extends Request { + userId?: number; +} + +interface JwtPayload { + userId: number; +} + +export const authMiddleware = ( + req: AuthRequest, + res: Response, + next: NextFunction +): void => { + const authHeader = req.headers.authorization; + + if (!authHeader) { + res.status(401).json({ error: 'No authorization header provided' }); + return; + } + + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') { + res.status(401).json({ error: 'Invalid authorization header format' }); + return; + } + + const token = parts[1]; + + try { + const secret = process.env.JWT_SECRET; + if (!secret) { + throw new Error('JWT_SECRET not configured'); + } + + const decoded = jwt.verify(token, secret) as JwtPayload; + req.userId = decoded.userId; + next(); + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + res.status(401).json({ error: 'Token expired' }); + return; + } + if (error instanceof jwt.JsonWebTokenError) { + res.status(401).json({ error: 'Invalid token' }); + return; + } + res.status(500).json({ error: 'Authentication failed' }); + } +}; + +export const generateToken = (userId: number): string => { + const secret = process.env.JWT_SECRET; + if (!secret) { + throw new Error('JWT_SECRET not configured'); + } + + return jwt.sign({ userId }, secret, { expiresIn: '7d' }); +}; diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts new file mode 100644 index 0000000..f6692f8 --- /dev/null +++ b/backend/src/models/index.ts @@ -0,0 +1,233 @@ +import pool from '../config/database'; + +// User types and queries +export interface User { + id: number; + email: string; + password_hash: string; + created_at: Date; +} + +export const userQueries = { + findByEmail: async (email: string): Promise => { + const result = await pool.query( + 'SELECT * FROM users WHERE email = $1', + [email] + ); + return result.rows[0] || null; + }, + + findById: async (id: number): Promise => { + const result = await pool.query( + 'SELECT * FROM users WHERE id = $1', + [id] + ); + return result.rows[0] || null; + }, + + create: async (email: string, passwordHash: string): Promise => { + const result = await pool.query( + 'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *', + [email, passwordHash] + ); + return result.rows[0]; + }, +}; + +// Product types and queries +export interface Product { + id: number; + user_id: number; + url: string; + name: string | null; + image_url: string | null; + refresh_interval: number; + last_checked: Date | null; + created_at: Date; +} + +export interface ProductWithLatestPrice extends Product { + current_price: number | null; + currency: string | null; +} + +export const productQueries = { + findByUserId: async (userId: number): Promise => { + const result = await pool.query( + `SELECT p.*, ph.price as current_price, ph.currency + FROM products p + LEFT JOIN LATERAL ( + SELECT price, currency FROM price_history + WHERE product_id = p.id + ORDER BY recorded_at DESC + LIMIT 1 + ) ph ON true + WHERE p.user_id = $1 + ORDER BY p.created_at DESC`, + [userId] + ); + return result.rows; + }, + + findById: async (id: number, userId: number): Promise => { + const result = await pool.query( + `SELECT p.*, ph.price as current_price, ph.currency + FROM products p + LEFT JOIN LATERAL ( + SELECT price, currency FROM price_history + WHERE product_id = p.id + ORDER BY recorded_at DESC + LIMIT 1 + ) ph ON true + WHERE p.id = $1 AND p.user_id = $2`, + [id, userId] + ); + return result.rows[0] || null; + }, + + create: async ( + userId: number, + url: string, + name: string | null, + imageUrl: string | null, + refreshInterval: number = 3600 + ): Promise => { + const result = await pool.query( + `INSERT INTO products (user_id, url, name, image_url, refresh_interval) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [userId, url, name, imageUrl, refreshInterval] + ); + return result.rows[0]; + }, + + update: async ( + id: number, + userId: number, + updates: { name?: string; refresh_interval?: number } + ): Promise => { + const fields: string[] = []; + const values: (string | number)[] = []; + let paramIndex = 1; + + if (updates.name !== undefined) { + fields.push(`name = $${paramIndex++}`); + values.push(updates.name); + } + if (updates.refresh_interval !== undefined) { + fields.push(`refresh_interval = $${paramIndex++}`); + values.push(updates.refresh_interval); + } + + if (fields.length === 0) return null; + + values.push(id, userId); + const result = await pool.query( + `UPDATE products SET ${fields.join(', ')} + WHERE id = $${paramIndex++} AND user_id = $${paramIndex} + RETURNING *`, + values + ); + return result.rows[0] || null; + }, + + delete: async (id: number, userId: number): Promise => { + const result = await pool.query( + 'DELETE FROM products WHERE id = $1 AND user_id = $2', + [id, userId] + ); + return (result.rowCount ?? 0) > 0; + }, + + updateLastChecked: async (id: number): Promise => { + await pool.query( + 'UPDATE products SET last_checked = CURRENT_TIMESTAMP WHERE id = $1', + [id] + ); + }, + + findDueForRefresh: async (): Promise => { + const result = await pool.query( + `SELECT * FROM products + WHERE last_checked IS NULL + OR last_checked + (refresh_interval || ' seconds')::interval < CURRENT_TIMESTAMP` + ); + return result.rows; + }, +}; + +// Price History types and queries +export interface PriceHistory { + id: number; + product_id: number; + price: number; + currency: string; + recorded_at: Date; +} + +export const priceHistoryQueries = { + findByProductId: async ( + productId: number, + days?: number + ): Promise => { + let query = ` + SELECT * FROM price_history + WHERE product_id = $1 + `; + const values: (number | string)[] = [productId]; + + if (days) { + query += ` AND recorded_at >= CURRENT_TIMESTAMP - ($2 || ' days')::interval`; + values.push(days.toString()); + } + + query += ' ORDER BY recorded_at ASC'; + + const result = await pool.query(query, values); + return result.rows; + }, + + create: async ( + productId: number, + price: number, + currency: string = 'USD' + ): Promise => { + const result = await pool.query( + `INSERT INTO price_history (product_id, price, currency) + VALUES ($1, $2, $3) + RETURNING *`, + [productId, price, currency] + ); + return result.rows[0]; + }, + + getLatest: async (productId: number): Promise => { + const result = await pool.query( + `SELECT * FROM price_history + WHERE product_id = $1 + ORDER BY recorded_at DESC + LIMIT 1`, + [productId] + ); + return result.rows[0] || null; + }, + + getStats: async (productId: number): Promise<{ + min_price: number; + max_price: number; + avg_price: number; + price_count: number; + } | null> => { + const result = await pool.query( + `SELECT + MIN(price) as min_price, + MAX(price) as max_price, + AVG(price)::decimal(10,2) as avg_price, + COUNT(*) as price_count + FROM price_history + WHERE product_id = $1`, + [productId] + ); + return result.rows[0] || null; + }, +}; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..cb2a3a1 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,93 @@ +import { Router, Request, Response } from 'express'; +import bcrypt from 'bcrypt'; +import { userQueries } from '../models'; +import { generateToken } from '../middleware/auth'; + +const router = Router(); + +// Register new user +router.post('/register', async (req: Request, res: Response) => { + try { + const { email, password } = req.body; + + if (!email || !password) { + res.status(400).json({ error: 'Email and password are required' }); + return; + } + + if (password.length < 8) { + res.status(400).json({ error: 'Password must be at least 8 characters' }); + return; + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + res.status(400).json({ error: 'Invalid email format' }); + return; + } + + const existingUser = await userQueries.findByEmail(email); + if (existingUser) { + res.status(409).json({ error: 'Email already registered' }); + return; + } + + const saltRounds = 12; + const passwordHash = await bcrypt.hash(password, saltRounds); + + const user = await userQueries.create(email, passwordHash); + const token = generateToken(user.id); + + res.status(201).json({ + message: 'User registered successfully', + token, + user: { + id: user.id, + email: user.email, + }, + }); + } catch (error) { + console.error('Registration error:', error); + res.status(500).json({ error: 'Registration failed' }); + } +}); + +// Login +router.post('/login', async (req: Request, res: Response) => { + try { + const { email, password } = req.body; + + if (!email || !password) { + res.status(400).json({ error: 'Email and password are required' }); + return; + } + + const user = await userQueries.findByEmail(email); + if (!user) { + res.status(401).json({ error: 'Invalid email or password' }); + return; + } + + const isValidPassword = await bcrypt.compare(password, user.password_hash); + if (!isValidPassword) { + res.status(401).json({ error: 'Invalid email or password' }); + return; + } + + const token = generateToken(user.id); + + res.json({ + message: 'Login successful', + token, + user: { + id: user.id, + email: user.email, + }, + }); + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ error: 'Login failed' }); + } +}); + +export default router; diff --git a/backend/src/routes/prices.ts b/backend/src/routes/prices.ts new file mode 100644 index 0000000..51aa22f --- /dev/null +++ b/backend/src/routes/prices.ts @@ -0,0 +1,93 @@ +import { Router, Response } from 'express'; +import { AuthRequest, authMiddleware } from '../middleware/auth'; +import { productQueries, priceHistoryQueries } from '../models'; +import { scrapePrice } from '../services/scraper'; + +const router = Router(); + +// All routes require authentication +router.use(authMiddleware); + +// Get price history for a product +router.get('/:productId/prices', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const productId = parseInt(req.params.productId, 10); + + if (isNaN(productId)) { + res.status(400).json({ error: 'Invalid product ID' }); + return; + } + + // Verify product belongs to user + const product = await productQueries.findById(productId, userId); + if (!product) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + // Get optional days filter from query + const days = req.query.days ? parseInt(req.query.days as string, 10) : undefined; + + const priceHistory = await priceHistoryQueries.findByProductId( + productId, + days + ); + + res.json({ + product, + prices: priceHistory, + }); + } catch (error) { + console.error('Error fetching price history:', error); + res.status(500).json({ error: 'Failed to fetch price history' }); + } +}); + +// Force immediate price refresh +router.post('/:productId/refresh', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const productId = parseInt(req.params.productId, 10); + + if (isNaN(productId)) { + res.status(400).json({ error: 'Invalid product ID' }); + return; + } + + // Verify product belongs to user + const product = await productQueries.findById(productId, userId); + if (!product) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + // Scrape new price + const priceData = await scrapePrice(product.url); + + if (!priceData) { + res.status(400).json({ error: 'Could not extract price from URL' }); + return; + } + + // Record new price + const newPrice = await priceHistoryQueries.create( + productId, + priceData.price, + priceData.currency + ); + + // Update last_checked timestamp + await productQueries.updateLastChecked(productId); + + res.json({ + message: 'Price refreshed successfully', + price: newPrice, + }); + } catch (error) { + console.error('Error refreshing price:', error); + res.status(500).json({ error: 'Failed to refresh price' }); + } +}); + +export default router; diff --git a/backend/src/routes/products.ts b/backend/src/routes/products.ts new file mode 100644 index 0000000..c90a4ea --- /dev/null +++ b/backend/src/routes/products.ts @@ -0,0 +1,172 @@ +import { Router, Response } from 'express'; +import { AuthRequest, authMiddleware } from '../middleware/auth'; +import { productQueries, priceHistoryQueries } from '../models'; +import { scrapeProduct } from '../services/scraper'; + +const router = Router(); + +// All routes require authentication +router.use(authMiddleware); + +// Get all products for the authenticated user +router.get('/', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const products = await productQueries.findByUserId(userId); + res.json(products); + } catch (error) { + console.error('Error fetching products:', error); + res.status(500).json({ error: 'Failed to fetch products' }); + } +}); + +// Add a new product to track +router.post('/', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const { url, refresh_interval } = req.body; + + if (!url) { + res.status(400).json({ error: 'URL is required' }); + return; + } + + // Validate URL + try { + new URL(url); + } catch { + res.status(400).json({ error: 'Invalid URL format' }); + return; + } + + // Scrape product info + const scrapedData = await scrapeProduct(url); + + if (!scrapedData.price) { + res.status(400).json({ + error: 'Could not extract price from the provided URL', + }); + return; + } + + // Create product + const product = await productQueries.create( + userId, + url, + scrapedData.name, + scrapedData.imageUrl, + refresh_interval || 3600 + ); + + // Record initial price + await priceHistoryQueries.create( + product.id, + scrapedData.price.price, + scrapedData.price.currency + ); + + // Update last_checked timestamp + await productQueries.updateLastChecked(product.id); + + // Fetch the product with the price + const productWithPrice = await productQueries.findById(product.id, userId); + + res.status(201).json(productWithPrice); + } catch (error) { + // Handle unique constraint violation + if ( + error instanceof Error && + error.message.includes('duplicate key value') + ) { + res.status(409).json({ error: 'You are already tracking this product' }); + return; + } + console.error('Error adding product:', error); + res.status(500).json({ error: 'Failed to add product' }); + } +}); + +// Get a specific product +router.get('/:id', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const productId = parseInt(req.params.id, 10); + + if (isNaN(productId)) { + res.status(400).json({ error: 'Invalid product ID' }); + return; + } + + const product = await productQueries.findById(productId, userId); + + if (!product) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + // Get price stats + const stats = await priceHistoryQueries.getStats(productId); + + res.json({ ...product, stats }); + } catch (error) { + console.error('Error fetching product:', error); + res.status(500).json({ error: 'Failed to fetch product' }); + } +}); + +// Update product settings +router.put('/:id', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const productId = parseInt(req.params.id, 10); + + if (isNaN(productId)) { + res.status(400).json({ error: 'Invalid product ID' }); + return; + } + + const { name, refresh_interval } = req.body; + + const product = await productQueries.update(productId, userId, { + name, + refresh_interval, + }); + + if (!product) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + res.json(product); + } catch (error) { + console.error('Error updating product:', error); + res.status(500).json({ error: 'Failed to update product' }); + } +}); + +// Delete a product +router.delete('/:id', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const productId = parseInt(req.params.id, 10); + + if (isNaN(productId)) { + res.status(400).json({ error: 'Invalid product ID' }); + return; + } + + const deleted = await productQueries.delete(productId, userId); + + if (!deleted) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + res.json({ message: 'Product deleted successfully' }); + } catch (error) { + console.error('Error deleting product:', error); + res.status(500).json({ error: 'Failed to delete product' }); + } +}); + +export default router; diff --git a/backend/src/services/scheduler.ts b/backend/src/services/scheduler.ts new file mode 100644 index 0000000..8bdcb41 --- /dev/null +++ b/backend/src/services/scheduler.ts @@ -0,0 +1,76 @@ +import cron from 'node-cron'; +import { productQueries, priceHistoryQueries } from '../models'; +import { scrapePrice } from './scraper'; + +let isRunning = false; + +async function checkPrices(): Promise { + if (isRunning) { + console.log('Price check already in progress, skipping...'); + return; + } + + isRunning = true; + console.log('Starting scheduled price check...'); + + try { + // Find all products that are due for a refresh + const products = await productQueries.findDueForRefresh(); + console.log(`Found ${products.length} products to check`); + + for (const product of products) { + try { + console.log(`Checking price for product ${product.id}: ${product.url}`); + + const priceData = await scrapePrice(product.url); + + if (priceData) { + // Get the latest recorded price to compare + const latestPrice = await priceHistoryQueries.getLatest(product.id); + + // Only record if price has changed or it's the first entry + if (!latestPrice || latestPrice.price !== priceData.price) { + await priceHistoryQueries.create( + product.id, + priceData.price, + priceData.currency + ); + console.log( + `Recorded new price for product ${product.id}: ${priceData.currency} ${priceData.price}` + ); + } else { + console.log(`Price unchanged for product ${product.id}`); + } + } else { + console.warn(`Could not extract price for product ${product.id}`); + } + + // Update last_checked even if price extraction failed + await productQueries.updateLastChecked(product.id); + + // Add a small delay between requests to avoid rate limiting + await new Promise((resolve) => setTimeout(resolve, 2000)); + } catch (error) { + console.error(`Error checking product ${product.id}:`, error); + // Continue with next product even if one fails + } + } + } catch (error) { + console.error('Error in scheduled price check:', error); + } finally { + isRunning = false; + console.log('Scheduled price check complete'); + } +} + +export function startScheduler(): void { + // Run every minute + cron.schedule('* * * * *', () => { + checkPrices().catch(console.error); + }); + + console.log('Price check scheduler started (runs every minute)'); +} + +// Allow manual trigger for testing +export { checkPrices }; diff --git a/backend/src/services/scraper.ts b/backend/src/services/scraper.ts new file mode 100644 index 0000000..d3908a1 --- /dev/null +++ b/backend/src/services/scraper.ts @@ -0,0 +1,267 @@ +import axios from 'axios'; +import * as cheerio from 'cheerio'; +import { + parsePrice, + ParsedPrice, + findMostLikelyPrice, +} from '../utils/priceParser'; + +export interface ScrapedProduct { + name: string | null; + price: ParsedPrice | null; + imageUrl: string | null; + url: string; +} + +// Common price selectors used across e-commerce sites +const priceSelectors = [ + // Schema.org + '[itemprop="price"]', + '[data-price]', + '[data-product-price]', + + // Common class names + '.price', + '.product-price', + '.current-price', + '.sale-price', + '.final-price', + '.offer-price', + '#price', + '#priceblock_ourprice', + '#priceblock_dealprice', + '#priceblock_saleprice', + + // Amazon specific + '.a-price .a-offscreen', + '.a-price-whole', + '#corePrice_feature_div .a-price .a-offscreen', + '#corePriceDisplay_desktop_feature_div .a-price .a-offscreen', + + // Generic patterns + '[class*="price"]', + '[class*="Price"]', + '[id*="price"]', + '[id*="Price"]', +]; + +// Selectors for product name +const nameSelectors = [ + '[itemprop="name"]', + 'h1[class*="product"]', + 'h1[class*="title"]', + '#productTitle', + '.product-title', + '.product-name', + 'h1', +]; + +// Selectors for product image +const imageSelectors = [ + '[itemprop="image"]', + '[property="og:image"]', + '#landingImage', + '#imgBlkFront', + '.product-image img', + '.main-image img', + '[data-zoom-image]', + 'img[class*="product"]', +]; + +export async function scrapeProduct(url: string): Promise { + const result: ScrapedProduct = { + name: null, + price: null, + imageUrl: null, + url, + }; + + try { + const response = await axios.get(url, { + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate, br', + Connection: 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + }, + timeout: 15000, + maxRedirects: 5, + }); + + const $ = cheerio.load(response.data); + + // Try to extract from JSON-LD structured data first + const jsonLdData = extractJsonLd($); + if (jsonLdData) { + if (jsonLdData.name) result.name = jsonLdData.name; + if (jsonLdData.price) result.price = jsonLdData.price; + if (jsonLdData.image) result.imageUrl = jsonLdData.image; + } + + // Extract product name + if (!result.name) { + result.name = extractName($); + } + + // Extract price + if (!result.price) { + result.price = extractPrice($); + } + + // Extract image + if (!result.imageUrl) { + result.imageUrl = extractImage($, url); + } + + // Try Open Graph meta tags as fallback + if (!result.name) { + result.name = $('meta[property="og:title"]').attr('content') || null; + } + if (!result.imageUrl) { + result.imageUrl = $('meta[property="og:image"]').attr('content') || null; + } + } catch (error) { + console.error(`Error scraping ${url}:`, error); + } + + return result; +} + +function extractJsonLd( + $: cheerio.CheerioAPI +): { name?: string; price?: ParsedPrice; image?: string } | null { + try { + const scripts = $('script[type="application/ld+json"]'); + for (let i = 0; i < scripts.length; i++) { + const content = $(scripts[i]).html(); + if (!content) continue; + + const data = JSON.parse(content); + const product = findProduct(data); + + if (product) { + const result: { name?: string; price?: ParsedPrice; image?: string } = + {}; + + if (product.name) { + result.name = product.name; + } + + if (product.offers) { + const offer = Array.isArray(product.offers) + ? product.offers[0] + : product.offers; + if (offer.price) { + result.price = { + price: parseFloat(offer.price), + currency: offer.priceCurrency || 'USD', + }; + } + } + + if (product.image) { + result.image = Array.isArray(product.image) + ? product.image[0] + : typeof product.image === 'string' + ? product.image + : product.image.url; + } + + return result; + } + } + } catch { + // JSON parse error, continue with other methods + } + return null; +} + +function findProduct(data: unknown): Record | null { + if (!data || typeof data !== 'object') return null; + + const obj = data as Record; + + if (obj['@type'] === 'Product') { + return obj; + } + + if (Array.isArray(data)) { + for (const item of data) { + const found = findProduct(item); + if (found) return found; + } + } + + if (obj['@graph'] && Array.isArray(obj['@graph'])) { + for (const item of obj['@graph']) { + const found = findProduct(item); + if (found) return found; + } + } + + return null; +} + +function extractPrice($: cheerio.CheerioAPI): ParsedPrice | null { + const prices: ParsedPrice[] = []; + + for (const selector of priceSelectors) { + const elements = $(selector); + elements.each((_, el) => { + const text = + $(el).attr('content') || $(el).attr('data-price') || $(el).text(); + const parsed = parsePrice(text); + if (parsed) { + prices.push(parsed); + } + }); + + if (prices.length > 0) break; + } + + return findMostLikelyPrice(prices); +} + +function extractName($: cheerio.CheerioAPI): string | null { + for (const selector of nameSelectors) { + const element = $(selector).first(); + if (element.length) { + const text = element.text().trim(); + if (text && text.length > 0 && text.length < 500) { + return text; + } + } + } + return null; +} + +function extractImage($: cheerio.CheerioAPI, baseUrl: string): string | null { + for (const selector of imageSelectors) { + const element = $(selector).first(); + if (element.length) { + const src = + element.attr('src') || + element.attr('content') || + element.attr('data-zoom-image') || + element.attr('data-src'); + if (src) { + // Handle relative URLs + try { + return new URL(src, baseUrl).href; + } catch { + return src; + } + } + } + } + return null; +} + +export async function scrapePrice(url: string): Promise { + const product = await scrapeProduct(url); + return product.price; +} diff --git a/backend/src/utils/priceParser.ts b/backend/src/utils/priceParser.ts new file mode 100644 index 0000000..89268c9 --- /dev/null +++ b/backend/src/utils/priceParser.ts @@ -0,0 +1,121 @@ +export interface ParsedPrice { + price: number; + currency: string; +} + +// Currency symbols and their codes +const currencyMap: Record = { + '$': 'USD', + '€': 'EUR', + '£': 'GBP', + '¥': 'JPY', + '₹': 'INR', + 'CAD': 'CAD', + 'AUD': 'AUD', + 'USD': 'USD', + 'EUR': 'EUR', + 'GBP': 'GBP', +}; + +// Patterns to match prices in text +const pricePatterns = [ + // $29.99 or $29,99 or $ 29.99 + /(?[$€£¥₹])\s*(?[\d,]+\.?\d*)/, + // 29.99 USD or 29,99 EUR + /(?[\d,]+\.?\d*)\s*(?USD|EUR|GBP|CAD|AUD|JPY|INR)/i, + // Plain number with optional decimal (fallback) + /(?\d{1,3}(?:[,.\s]?\d{3})*(?:[.,]\d{2})?)/, +]; + +export function parsePrice(text: string): ParsedPrice | null { + if (!text) return null; + + // Clean up the text + const cleanText = text.trim().replace(/\s+/g, ' '); + + for (const pattern of pricePatterns) { + const match = cleanText.match(pattern); + if (match && match.groups) { + const priceStr = match.groups.price || match[1]; + const currencySymbol = match.groups.currency || '$'; + + if (priceStr) { + const price = normalizePrice(priceStr); + if (price !== null && price > 0) { + const currency = currencyMap[currencySymbol] || 'USD'; + return { price, currency }; + } + } + } + } + + // Try to extract just a number as fallback + const numberMatch = cleanText.match(/[\d,]+\.?\d*/); + if (numberMatch) { + const price = normalizePrice(numberMatch[0]); + if (price !== null && price > 0) { + return { price, currency: 'USD' }; + } + } + + return null; +} + +function normalizePrice(priceStr: string): number | null { + if (!priceStr) return null; + + // Remove spaces + let normalized = priceStr.replace(/\s/g, ''); + + // Handle European format (1.234,56) vs US format (1,234.56) + const hasCommaDecimal = /,\d{2}$/.test(normalized); + const hasDotDecimal = /\.\d{2}$/.test(normalized); + + if (hasCommaDecimal && !hasDotDecimal) { + // European format: 1.234,56 -> 1234.56 + normalized = normalized.replace(/\./g, '').replace(',', '.'); + } else { + // US format or plain number: remove commas + normalized = normalized.replace(/,/g, ''); + } + + const price = parseFloat(normalized); + return isNaN(price) ? null : Math.round(price * 100) / 100; +} + +export function extractPricesFromText(html: string): ParsedPrice[] { + const prices: ParsedPrice[] = []; + const seen = new Set(); + + // Match all price-like patterns in the HTML + const allMatches = html.matchAll( + /(?:[$€£¥₹])\s*[\d,]+\.?\d*|[\d,]+\.?\d*\s*(?:USD|EUR|GBP|CAD|AUD)/gi + ); + + for (const match of allMatches) { + const parsed = parsePrice(match[0]); + if (parsed && !seen.has(parsed.price)) { + seen.add(parsed.price); + prices.push(parsed); + } + } + + return prices; +} + +export function findMostLikelyPrice(prices: ParsedPrice[]): ParsedPrice | null { + if (prices.length === 0) return null; + if (prices.length === 1) return prices[0]; + + // Filter out very small prices (likely not product prices) + const validPrices = prices.filter((p) => p.price >= 0.99); + + if (validPrices.length === 0) return prices[0]; + + // Sort by price and pick the middle one (often the actual price) + // This helps avoid picking shipping costs or discounts + validPrices.sort((a, b) => a.price - b.price); + + // Return the first (lowest) valid price - often the current/sale price + return validPrices[0]; +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..1951778 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..66962d9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +version: '3.8' + +services: + # PostgreSQL Database + postgres: + image: postgres:16-alpine + container_name: priceghost-db + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: priceghost + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + # Backend API + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: priceghost-backend + environment: + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/priceghost + JWT_SECRET: ${JWT_SECRET:-change-this-in-production-use-strong-secret} + PORT: 3001 + NODE_ENV: production + ports: + - "3001:3001" + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + + # Frontend + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: priceghost-frontend + ports: + - "80:80" + depends_on: + - backend + restart: unless-stopped + +volumes: + postgres_data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..3cf626e --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,10 @@ +node_modules +dist +npm-debug.log +.env +.env.local +.git +.gitignore +README.md +Dockerfile +.dockerignore diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..6464d87 --- /dev/null +++ b/frontend/Dockerfile @@ -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 - serve with nginx +FROM nginx:alpine AS production + +# 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;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f683e74 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + PriceGhost - Track Product Prices + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..f723298 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,34 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Proxy API requests to backend + location /api { + proxy_pass http://backend:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # Serve static files and handle SPA routing + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b1c9aad --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "priceghost-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.6.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "recharts": "^2.10.3" + }, + "devDependencies": { + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@vitejs/plugin-react": "^4.2.0", + "typescript": "^5.3.2", + "vite": "^5.0.0" + } +} diff --git a/frontend/public/ghost.svg b/frontend/public/ghost.svg new file mode 100644 index 0000000..9b68216 --- /dev/null +++ b/frontend/public/ghost.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..391143b --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,106 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { AuthProvider, useAuth } from './context/AuthContext'; +import Login from './pages/Login'; +import Register from './pages/Register'; +import Dashboard from './pages/Dashboard'; +import ProductDetail from './pages/ProductDetail'; + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { user, isLoading } = useAuth(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!user) { + return ; + } + + return <>{children}; +} + +function PublicRoute({ children }: { children: React.ReactNode }) { + const { user, isLoading } = useAuth(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (user) { + return ; + } + + return <>{children}; +} + +function AppRoutes() { + return ( + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + ); +} + +export default function App() { + return ( + + + + + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..37627f9 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,102 @@ +import axios from 'axios'; + +const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'; + +const api = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Add auth token to requests +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// Handle auth errors +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.href = '/login'; + } + return Promise.reject(error); + } +); + +// Auth API +export const authApi = { + register: (email: string, password: string) => + api.post('/auth/register', { email, password }), + + login: (email: string, password: string) => + api.post('/auth/login', { email, password }), +}; + +// Products API +export interface Product { + id: number; + user_id: number; + url: string; + name: string | null; + image_url: string | null; + refresh_interval: number; + last_checked: string | null; + created_at: string; + current_price: number | null; + currency: string | null; +} + +export interface ProductWithStats extends Product { + stats: { + min_price: number; + max_price: number; + avg_price: number; + price_count: number; + } | null; +} + +export interface PriceHistory { + id: number; + product_id: number; + price: number; + currency: string; + recorded_at: string; +} + +export const productsApi = { + getAll: () => api.get('/products'), + + getById: (id: number) => api.get(`/products/${id}`), + + create: (url: string, refreshInterval?: number) => + api.post('/products', { url, refresh_interval: refreshInterval }), + + update: (id: number, data: { name?: string; refresh_interval?: number }) => + api.put(`/products/${id}`, data), + + delete: (id: number) => api.delete(`/products/${id}`), +}; + +// Prices API +export const pricesApi = { + getHistory: (productId: number, days?: number) => + api.get<{ product: Product; prices: PriceHistory[] }>( + `/products/${productId}/prices`, + { params: days ? { days } : undefined } + ), + + refresh: (productId: number) => + api.post<{ message: string; price: PriceHistory }>( + `/products/${productId}/refresh` + ), +}; + +export default api; diff --git a/frontend/src/components/AuthForm.tsx b/frontend/src/components/AuthForm.tsx new file mode 100644 index 0000000..8091d47 --- /dev/null +++ b/frontend/src/components/AuthForm.tsx @@ -0,0 +1,184 @@ +import { useState, FormEvent } from 'react'; +import { Link } from 'react-router-dom'; + +interface AuthFormProps { + mode: 'login' | 'register'; + onSubmit: (email: string, password: string) => Promise; +} + +export default function AuthForm({ mode, onSubmit }: AuthFormProps) { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + + if (mode === 'register' && password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + if (password.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + + setIsLoading(true); + + try { + await onSubmit(email, password); + } catch (err) { + if (err instanceof Error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const axiosError = err as any; + setError(axiosError.response?.data?.error || 'An error occurred'); + } else { + setError('An error occurred'); + } + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + +
+
+
👻
+

PriceGhost

+

+ {mode === 'login' + ? 'Sign in to track prices' + : 'Create your account'} +

+
+ + {error &&
{error}
} + +
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + required + autoComplete="email" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + required + autoComplete={mode === 'login' ? 'current-password' : 'new-password'} + /> +
+ + {mode === 'register' && ( +
+ + setConfirmPassword(e.target.value)} + placeholder="••••••••" + required + autoComplete="new-password" + /> +
+ )} + + +
+ +
+ {mode === 'login' ? ( + <> + Don't have an account? Sign up + + ) : ( + <> + Already have an account? Sign in + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..496367b --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,115 @@ +import { ReactNode } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; + +interface LayoutProps { + children: ReactNode; +} + +export default function Layout({ children }: LayoutProps) { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + return ( +
+ + + + +
+
{children}
+
+
+ ); +} diff --git a/frontend/src/components/PriceChart.tsx b/frontend/src/components/PriceChart.tsx new file mode 100644 index 0000000..1168e40 --- /dev/null +++ b/frontend/src/components/PriceChart.tsx @@ -0,0 +1,262 @@ +import { useState } from 'react'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + ReferenceLine, +} from 'recharts'; +import { PriceHistory } from '../api/client'; + +interface PriceChartProps { + prices: PriceHistory[]; + currency: string; + onRangeChange?: (days: number | undefined) => void; +} + +const DATE_RANGES = [ + { value: 7, label: '7 days' }, + { value: 30, label: '30 days' }, + { value: 90, label: '90 days' }, + { value: undefined, label: 'All time' }, +]; + +export default function PriceChart({ + prices, + currency, + onRangeChange, +}: PriceChartProps) { + const [selectedRange, setSelectedRange] = useState(30); + + const handleRangeChange = (days: number | undefined) => { + setSelectedRange(days); + onRangeChange?.(days); + }; + + const currencySymbol = + currency === 'EUR' ? '€' : currency === 'GBP' ? '£' : '$'; + + const chartData = prices.map((p) => ({ + date: new Date(p.recorded_at).getTime(), + price: parseFloat(p.price.toString()), + })); + + const minPrice = Math.min(...chartData.map((d) => d.price)); + const maxPrice = Math.max(...chartData.map((d) => d.price)); + const avgPrice = + chartData.reduce((sum, d) => sum + d.price, 0) / chartData.length; + + const formatDate = (timestamp: number) => { + const date = new Date(timestamp); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }; + + const formatPrice = (value: number) => { + return `${currencySymbol}${value.toFixed(2)}`; + }; + + if (prices.length === 0) { + return ( +
+ +

No price history available yet.

+
+ ); + } + + return ( +
+ + +
+

Price History

+
+ {DATE_RANGES.map((range) => ( + + ))} +
+
+ +
+ + + + + + [formatPrice(value), 'Price']} + labelFormatter={(label) => + new Date(label).toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + } + contentStyle={{ + background: 'white', + border: '1px solid #e2e8f0', + borderRadius: '0.5rem', + boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', + }} + /> + + + + +
+ +
+
+
Lowest
+
{formatPrice(minPrice)}
+
+
+
Average
+
{formatPrice(avgPrice)}
+
+
+
Highest
+
{formatPrice(maxPrice)}
+
+
+
+ ); +} diff --git a/frontend/src/components/ProductCard.tsx b/frontend/src/components/ProductCard.tsx new file mode 100644 index 0000000..9253fc4 --- /dev/null +++ b/frontend/src/components/ProductCard.tsx @@ -0,0 +1,155 @@ +import { Link } from 'react-router-dom'; +import { Product } from '../api/client'; + +interface ProductCardProps { + product: Product; + onDelete: (id: number) => void; +} + +export default function ProductCard({ product, onDelete }: ProductCardProps) { + const formatPrice = (price: number | null, currency: string | null) => { + if (price === null) return 'N/A'; + const currencySymbol = + currency === 'EUR' ? '€' : currency === 'GBP' ? '£' : '$'; + return `${currencySymbol}${price.toFixed(2)}`; + }; + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return 'Never'; + const date = new Date(dateStr); + return date.toLocaleDateString() + ' ' + date.toLocaleTimeString(); + }; + + const truncateUrl = (url: string, maxLength: number = 50) => { + try { + const parsed = new URL(url); + const display = parsed.hostname + parsed.pathname; + return display.length > maxLength + ? display.slice(0, maxLength) + '...' + : display; + } catch { + return url.length > maxLength ? url.slice(0, maxLength) + '...' : url; + } + }; + + return ( +
+ + + {product.image_url ? ( + {product.name + ) : ( +
📦
+ )} + +
+

{product.name || 'Unknown Product'}

+

{truncateUrl(product.url)}

+
+ {formatPrice(product.current_price, product.currency)} +
+

+ Last checked: {formatDate(product.last_checked)} +

+ +
+ + View Details + + +
+
+
+ ); +} diff --git a/frontend/src/components/ProductForm.tsx b/frontend/src/components/ProductForm.tsx new file mode 100644 index 0000000..02868f0 --- /dev/null +++ b/frontend/src/components/ProductForm.tsx @@ -0,0 +1,130 @@ +import { useState, FormEvent } from 'react'; + +interface ProductFormProps { + onSubmit: (url: string, refreshInterval: number) => Promise; +} + +const REFRESH_INTERVALS = [ + { value: 900, label: '15 minutes' }, + { value: 1800, label: '30 minutes' }, + { value: 3600, label: '1 hour' }, + { value: 7200, label: '2 hours' }, + { value: 21600, label: '6 hours' }, + { value: 43200, label: '12 hours' }, + { value: 86400, label: '24 hours' }, +]; + +export default function ProductForm({ onSubmit }: ProductFormProps) { + const [url, setUrl] = useState(''); + const [refreshInterval, setRefreshInterval] = useState(3600); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + + // Validate URL + try { + new URL(url); + } catch { + setError('Please enter a valid URL'); + return; + } + + setIsLoading(true); + + try { + await onSubmit(url, refreshInterval); + setUrl(''); + setRefreshInterval(3600); + } catch (err) { + if (err instanceof Error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const axiosError = err as any; + setError(axiosError.response?.data?.error || 'Failed to add product'); + } else { + setError('Failed to add product'); + } + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + +

Track a New Product

+ + {error &&
{error}
} + +
+
+
+ + setUrl(e.target.value)} + placeholder="https://www.example.com/product" + required + /> +
+ +
+ + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..3115759 --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -0,0 +1,82 @@ +import { + createContext, + useContext, + useState, + useEffect, + ReactNode, +} from 'react'; +import { authApi } from '../api/client'; + +interface User { + id: number; + email: string; +} + +interface AuthContextType { + user: User | null; + isLoading: boolean; + login: (email: string, password: string) => Promise; + register: (email: string, password: string) => Promise; + logout: () => void; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // Check for existing session on mount + const storedUser = localStorage.getItem('user'); + const token = localStorage.getItem('token'); + + if (storedUser && token) { + try { + setUser(JSON.parse(storedUser)); + } catch { + localStorage.removeItem('user'); + localStorage.removeItem('token'); + } + } + setIsLoading(false); + }, []); + + const login = async (email: string, password: string) => { + const response = await authApi.login(email, password); + const { token, user: userData } = response.data; + + localStorage.setItem('token', token); + localStorage.setItem('user', JSON.stringify(userData)); + setUser(userData); + }; + + const register = async (email: string, password: string) => { + const response = await authApi.register(email, password); + const { token, user: userData } = response.data; + + localStorage.setItem('token', token); + localStorage.setItem('user', JSON.stringify(userData)); + setUser(userData); + }; + + const logout = () => { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + setUser(null); + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..300d05c --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1 @@ +export { useAuth } from '../context/AuthContext'; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..6ce56b3 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,225 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --primary: #6366f1; + --primary-dark: #4f46e5; + --secondary: #10b981; + --danger: #ef4444; + --background: #f8fafc; + --surface: #ffffff; + --text: #1e293b; + --text-muted: #64748b; + --border: #e2e8f0; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, + Ubuntu, Cantarell, sans-serif; + background-color: var(--background); + color: var(--text); + line-height: 1.6; +} + +a { + color: var(--primary); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +button { + cursor: pointer; + font-family: inherit; +} + +input, button { + font-size: 1rem; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; +} + +/* Form styles */ +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--text); +} + +.form-group input, +.form-group select { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + font-size: 1rem; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +/* Button styles */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 0.5rem; + font-weight: 500; + transition: all 0.2s; +} + +.btn-primary { + background-color: var(--primary); + color: white; +} + +.btn-primary:hover { + background-color: var(--primary-dark); +} + +.btn-secondary { + background-color: var(--surface); + color: var(--text); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + background-color: var(--background); +} + +.btn-danger { + background-color: var(--danger); + color: white; +} + +.btn-danger:hover { + background-color: #dc2626; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Card styles */ +.card { + background: var(--surface); + border-radius: 0.75rem; + box-shadow: var(--shadow); + overflow: hidden; +} + +.card-header { + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border); +} + +.card-body { + padding: 1.5rem; +} + +/* Alert styles */ +.alert { + padding: 1rem; + border-radius: 0.5rem; + margin-bottom: 1rem; +} + +.alert-error { + background-color: #fef2f2; + color: #991b1b; + border: 1px solid #fecaca; +} + +.alert-success { + background-color: #f0fdf4; + color: #166534; + border: 1px solid #bbf7d0; +} + +/* Loading spinner */ +.spinner { + display: inline-block; + width: 1.5rem; + height: 1.5rem; + border: 2px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Utility classes */ +.text-center { + text-align: center; +} + +.text-muted { + color: var(--text-muted); +} + +.mt-1 { margin-top: 0.25rem; } +.mt-2 { margin-top: 0.5rem; } +.mt-3 { margin-top: 1rem; } +.mt-4 { margin-top: 1.5rem; } + +.mb-1 { margin-bottom: 0.25rem; } +.mb-2 { margin-bottom: 0.5rem; } +.mb-3 { margin-bottom: 1rem; } +.mb-4 { margin-bottom: 1.5rem; } + +.flex { + display: flex; +} + +.flex-col { + flex-direction: column; +} + +.items-center { + align-items: center; +} + +.justify-between { + justify-content: space-between; +} + +.gap-2 { + gap: 0.5rem; +} + +.gap-3 { + gap: 1rem; +} + +.gap-4 { + gap: 1.5rem; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..2339d59 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..6935d9a --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,136 @@ +import { useState, useEffect } from 'react'; +import Layout from '../components/Layout'; +import ProductCard from '../components/ProductCard'; +import ProductForm from '../components/ProductForm'; +import { productsApi, Product } from '../api/client'; + +export default function Dashboard() { + const [products, setProducts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(''); + + const fetchProducts = async () => { + try { + const response = await productsApi.getAll(); + setProducts(response.data); + } catch { + setError('Failed to load products'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchProducts(); + }, []); + + const handleAddProduct = async (url: string, refreshInterval: number) => { + const response = await productsApi.create(url, refreshInterval); + setProducts((prev) => [response.data, ...prev]); + }; + + const handleDeleteProduct = async (id: number) => { + if (!confirm('Are you sure you want to stop tracking this product?')) { + return; + } + + try { + await productsApi.delete(id); + setProducts((prev) => prev.filter((p) => p.id !== id)); + } catch { + alert('Failed to delete product'); + } + }; + + return ( + + + +
+

Your Tracked Products

+

+ Monitor prices and get notified when they drop +

+
+ + + + {error &&
{error}
} + + {isLoading ? ( +
+ +
+ ) : products.length === 0 ? ( +
+
📦
+

No products yet

+

+ Add your first product URL above to start tracking prices! +

+
+ ) : ( +
+ {products.map((product) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..c125595 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,15 @@ +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; +import AuthForm from '../components/AuthForm'; + +export default function Login() { + const { login } = useAuth(); + const navigate = useNavigate(); + + const handleLogin = async (email: string, password: string) => { + await login(email, password); + navigate('/'); + }; + + return ; +} diff --git a/frontend/src/pages/ProductDetail.tsx b/frontend/src/pages/ProductDetail.tsx new file mode 100644 index 0000000..11db18b --- /dev/null +++ b/frontend/src/pages/ProductDetail.tsx @@ -0,0 +1,353 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import Layout from '../components/Layout'; +import PriceChart from '../components/PriceChart'; +import { + productsApi, + pricesApi, + ProductWithStats, + PriceHistory, +} from '../api/client'; + +export default function ProductDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const [product, setProduct] = useState(null); + const [prices, setPrices] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(''); + + const productId = parseInt(id || '0', 10); + + const fetchData = async (days?: number) => { + try { + const [productRes, pricesRes] = await Promise.all([ + productsApi.getById(productId), + pricesApi.getHistory(productId, days), + ]); + setProduct(productRes.data); + setPrices(pricesRes.data.prices); + } catch { + setError('Failed to load product details'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (productId) { + fetchData(30); + } + }, [productId]); + + const handleRefresh = async () => { + setIsRefreshing(true); + try { + await pricesApi.refresh(productId); + await fetchData(30); + } catch { + alert('Failed to refresh price'); + } finally { + setIsRefreshing(false); + } + }; + + const handleDelete = async () => { + if (!confirm('Are you sure you want to stop tracking this product?')) { + return; + } + + try { + await productsApi.delete(productId); + navigate('/'); + } catch { + alert('Failed to delete product'); + } + }; + + const handleRangeChange = (days: number | undefined) => { + fetchData(days); + }; + + const formatPrice = (price: number | null, currency: string | null) => { + if (price === null) return 'N/A'; + const currencySymbol = + currency === 'EUR' ? '€' : currency === 'GBP' ? '£' : '$'; + return `${currencySymbol}${price.toFixed(2)}`; + }; + + if (isLoading) { + return ( + +
+ +
+
+ ); + } + + if (error || !product) { + return ( + +
{error || 'Product not found'}
+ + Back to Dashboard + +
+ ); + } + + const priceChange = + product.stats && prices.length > 1 + ? ((product.current_price || 0) - prices[0].price) / prices[0].price + : null; + + return ( + + + +
+ + ← Back to Dashboard + +
+ +
+
+ {product.image_url ? ( + {product.name + ) : ( +
📦
+ )} + +
+

+ {product.name || 'Unknown Product'} +

+

+ + {product.url} + +

+ +
+ {formatPrice(product.current_price, product.currency)} +
+ + {priceChange !== null && priceChange !== 0 && ( + 0 ? 'up' : 'down'}`} + > + {priceChange > 0 ? '↑' : '↓'}{' '} + {Math.abs(priceChange * 100).toFixed(1)}% since tracking started + + )} + +
+
+ Last Checked + + {product.last_checked + ? new Date(product.last_checked).toLocaleString() + : 'Never'} + +
+
+ Check Interval + + {product.refresh_interval < 3600 + ? `${product.refresh_interval / 60} minutes` + : `${product.refresh_interval / 3600} hour(s)`} + +
+
+ Tracking Since + + {new Date(product.created_at).toLocaleDateString()} + +
+
+ Price Records + + {product.stats?.price_count || 0} + +
+
+ +
+ + +
+
+
+
+ + +
+ ); +} diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx new file mode 100644 index 0000000..5ce920b --- /dev/null +++ b/frontend/src/pages/Register.tsx @@ -0,0 +1,15 @@ +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; +import AuthForm from '../components/AuthForm'; + +export default function Register() { + const { register } = useAuth(); + const navigate = useNavigate(); + + const handleRegister = async (email: string, password: string) => { + await register(email, password); + navigate('/'); + }; + + return ; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..29c29a1 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..3934b8f --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..4414e51 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + }, + }, +});