Initial Push
This commit is contained in:
184
templates/add_product.html
Normal file
184
templates/add_product.html
Normal file
@@ -0,0 +1,184 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Add Product - Price Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="mb-0">
|
||||
<i class="fas fa-plus-circle me-2 text-primary"></i>Add New Product
|
||||
</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3">
|
||||
{{ form.name.label(class="form-label fw-bold") }}
|
||||
{{ form.name(class="form-control form-control-lg") }}
|
||||
{% if form.name.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.name.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
{{ form.target_price.label(class="form-label fw-bold") }}
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">£</span>
|
||||
{{ form.target_price(class="form-control form-control-lg") }}
|
||||
</div>
|
||||
{% if form.target_price.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.target_price.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">Optional: Alert when price drops below this</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.description.label(class="form-label fw-bold") }}
|
||||
{{ form.description(class="form-control", rows="3") }}
|
||||
{% if form.description.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.description.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">Optional: Brief description of the product</small>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<h4 class="mb-3">
|
||||
<i class="fas fa-link me-2 text-info"></i>Product URLs
|
||||
</h4>
|
||||
<p class="text-muted mb-4">Add URLs from the sites you want to track. At least one URL is required.</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12 mb-3">
|
||||
{{ form.jjfoodservice_url.label(class="form-label fw-bold") }}
|
||||
<div class="input-group">
|
||||
<span class="input-group-text jjfoodservice">
|
||||
<i class="fas fa-utensils"></i> JJ Food Service
|
||||
</span>
|
||||
{{ form.jjfoodservice_url(class="form-control", placeholder="https://www.jjfoodservice.com/...") }}
|
||||
</div>
|
||||
{% if form.jjfoodservice_url.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.jjfoodservice_url.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-12 mb-3">
|
||||
{{ form.atoz_catering_url.label(class="form-label fw-bold") }}
|
||||
<div class="input-group">
|
||||
<span class="input-group-text atoz_catering">
|
||||
<i class="fas fa-store"></i> A to Z Catering
|
||||
</span>
|
||||
{{ form.atoz_catering_url(class="form-control", placeholder="https://www.atoz-catering.co.uk/...") }}
|
||||
</div>
|
||||
{% if form.atoz_catering_url.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.atoz_catering_url.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-12 mb-3">
|
||||
{{ form.amazon_uk_url.label(class="form-label fw-bold") }}
|
||||
<div class="input-group">
|
||||
<span class="input-group-text amazon_uk">
|
||||
<i class="fab fa-amazon"></i> Amazon UK
|
||||
</span>
|
||||
{{ form.amazon_uk_url(class="form-control", placeholder="https://www.amazon.co.uk/...") }}
|
||||
</div>
|
||||
{% if form.amazon_uk_url.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.amazon_uk_url.errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
<strong>Tips:</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
<li>Make sure URLs point to the specific product page</li>
|
||||
<li>Test URLs in your browser first to ensure they work</li>
|
||||
<li>Some sites may block automated requests - we'll handle this gracefully</li>
|
||||
<li>For best results, use direct product page URLs</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary me-md-2">
|
||||
<i class="fas fa-arrow-left me-1"></i>Cancel
|
||||
</a>
|
||||
{{ form.submit(class="btn btn-primary btn-lg") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-question-circle me-2"></i>How to Find Product URLs
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6 class="fw-bold">JJ Food Service</h6>
|
||||
<p class="small text-muted">
|
||||
Navigate to the specific product page on JJ Food Service and copy the URL.
|
||||
Make sure you're logged in for accurate pricing.
|
||||
</p>
|
||||
|
||||
<h6 class="fw-bold">A to Z Catering</h6>
|
||||
<p class="small text-muted">
|
||||
Go to the product page on A to Z Catering and copy the URL.
|
||||
URLs typically contain "/products/product/" followed by the product name.
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="fw-bold">Amazon UK</h6>
|
||||
<p class="small text-muted">
|
||||
Navigate to the product page on Amazon.co.uk and copy the URL.
|
||||
The URL should contain "/dp/" followed by the product identifier.
|
||||
</p>
|
||||
|
||||
<h6 class="fw-bold text-muted">Note</h6>
|
||||
<p class="small text-muted">
|
||||
We focus on UK catering supply websites that work well with automated price tracking.
|
||||
This provides reliable price monitoring for your business needs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
225
templates/base.html
Normal file
225
templates/base.html
Normal file
@@ -0,0 +1,225 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Price Tracker{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
.navbar {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,.1);
|
||||
}
|
||||
.navbar-brand {
|
||||
font-weight: bold;
|
||||
color: white !important;
|
||||
}
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.price-badge {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.price-best {
|
||||
background: linear-gradient(135deg, #4CAF50, #45a049);
|
||||
}
|
||||
.price-high {
|
||||
background: linear-gradient(135deg, #f44336, #d32f2f);
|
||||
}
|
||||
.price-medium {
|
||||
background: linear-gradient(135deg, #ff9800, #f57c00);
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
padding: 10px 25px;
|
||||
}
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #4CAF50, #45a049);
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #f44336, #d32f2f);
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
}
|
||||
.alert {
|
||||
border-radius: 15px;
|
||||
border: none;
|
||||
}
|
||||
.site-badge {
|
||||
font-size: 0.8em;
|
||||
padding: 0.3em 0.6em;
|
||||
border-radius: 15px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.jjfoodservice { background-color: #e74c3c; color: white; }
|
||||
.atoz_catering { background-color: #3498db; color: white; }
|
||||
.amazon_uk { background-color: #ff9900; color: white; }
|
||||
.ebay { background-color: #0064d2; color: white; }
|
||||
.walmart { background-color: #0071ce; color: white; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{{ url_for('index') }}">
|
||||
<i class="fas fa-chart-line me-2"></i>Price Tracker
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('index') }}">
|
||||
<i class="fas fa-home me-1"></i>Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('add_product') }}">
|
||||
<i class="fas fa-plus me-1"></i>Add Product
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('settings') }}">
|
||||
<i class="fas fa-cog me-1"></i>Settings
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="navbar-nav">
|
||||
<button class="btn btn-outline-light btn-sm" onclick="scrapeAll()">
|
||||
<i class="fas fa-sync-alt me-1"></i>Scrape All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||
<i class="fas fa-{{ 'exclamation-triangle' if category == 'error' else 'check-circle' if category == 'success' else 'info-circle' }} me-2"></i>
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<footer class="bg-dark text-light py-4 mt-5">
|
||||
<div class="container text-center">
|
||||
<p>© 2025 Price Tracker. Built with Beautiful Soup & Flask.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
|
||||
<script>
|
||||
function scrapeProduct(productId) {
|
||||
const btn = document.querySelector(`[onclick="scrapeProduct(${productId})"]`);
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Scraping...';
|
||||
btn.disabled = true;
|
||||
|
||||
fetch(`/scrape/${productId}`, { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error: ' + error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function scrapeAll() {
|
||||
const btn = document.querySelector('[onclick="scrapeAll()"]');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Scraping...';
|
||||
btn.disabled = true;
|
||||
|
||||
fetch('/scrape_all', { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(`Success! Updated ${data.total_updated} price entries.`);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error: ' + error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function testNotifications() {
|
||||
const btn = document.querySelector('[onclick="testNotifications()"]');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Testing...';
|
||||
btn.disabled = true;
|
||||
|
||||
fetch('/test_notifications', { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
let message = 'Notification Test Results:\n';
|
||||
if (data.email.enabled) {
|
||||
message += `Email: ${data.email.success ? 'Success' : 'Failed - ' + data.email.error}\n`;
|
||||
}
|
||||
if (data.webhook.enabled) {
|
||||
message += `Webhook: ${data.webhook.success ? 'Success' : 'Failed - ' + data.webhook.error}\n`;
|
||||
}
|
||||
if (!data.email.enabled && !data.webhook.enabled) {
|
||||
message += 'No notifications are enabled.';
|
||||
}
|
||||
alert(message);
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error: ' + error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
184
templates/index.html
Normal file
184
templates/index.html
Normal file
@@ -0,0 +1,184 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - Price Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="display-4">
|
||||
<i class="fas fa-tachometer-alt me-3 text-primary"></i>Dashboard
|
||||
</h1>
|
||||
<a href="{{ url_for('add_product') }}" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-plus me-2"></i>Add Product
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if not products %}
|
||||
<div class="text-center py-5">
|
||||
<div class="card mx-auto" style="max-width: 500px;">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-shopping-cart fa-4x text-muted mb-3"></i>
|
||||
<h3 class="text-muted">No Products Yet</h3>
|
||||
<p class="text-muted">Start tracking prices by adding your first product!</p>
|
||||
<a href="{{ url_for('add_product') }}" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-plus me-2"></i>Add Your First Product
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="row">
|
||||
{% for product in products %}
|
||||
<div class="col-lg-6 col-xl-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<h5 class="card-title fw-bold">{{ product.name }}</h5>
|
||||
{% if product.target_price %}
|
||||
<span class="badge bg-info">
|
||||
Target: £{{ "%.2f"|format(product.target_price) }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if product.description %}
|
||||
<p class="card-text text-muted small">{{ product.description[:100] }}{% if product.description|length > 100 %}...{% endif %}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Sites being tracked -->
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Tracking on:</small><br>
|
||||
{% for site_name in product.urls.keys() %}
|
||||
<span class="site-badge {{ site_name }}">{{ site_name.title() }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Current Prices -->
|
||||
{% if product.latest_prices %}
|
||||
<div class="row g-2 mb-3">
|
||||
{% for site_name, price_data in product.latest_prices.items() %}
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center p-2 bg-light rounded">
|
||||
<span class="site-badge {{ site_name }} small">{{ site_name.title() }}</span>
|
||||
<div class="text-end">
|
||||
<span class="fw-bold">£{{ "%.2f"|format(price_data.price) }}</span>
|
||||
<br><small class="text-muted">{{ price_data.timestamp[:10] }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Best Price Highlight -->
|
||||
{% if product.best_price %}
|
||||
<div class="alert alert-success py-2 mb-3">
|
||||
<i class="fas fa-trophy me-2"></i>
|
||||
<strong>Best Price: £{{ "%.2f"|format(product.best_price.price) }}</strong>
|
||||
{% if product.target_price and product.best_price.price <= product.target_price %}
|
||||
<span class="badge bg-danger ms-2">
|
||||
<i class="fas fa-bell me-1"></i>Target Reached!
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="alert alert-warning py-2 mb-3">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
No price data yet. Click "Scrape Now" to get prices.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="d-grid gap-2">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('product_detail', product_id=product.id) }}" class="btn btn-outline-primary">
|
||||
<i class="fas fa-chart-line me-1"></i>Details
|
||||
</a>
|
||||
<button class="btn btn-success" onclick="scrapeProduct({{ product.id }})">
|
||||
<i class="fas fa-sync-alt me-1"></i>Scrape Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Footer with last update -->
|
||||
<div class="card-footer bg-transparent">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-clock me-1"></i>
|
||||
Added: {{ product.created_at[:10] }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-shopping-bag fa-2x text-primary mb-2"></i>
|
||||
<h4 class="fw-bold">{{ products|length }}</h4>
|
||||
<p class="text-muted mb-0">Products Tracked</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-store fa-2x text-success mb-2"></i>
|
||||
<h4 class="fw-bold">
|
||||
{% if products %}
|
||||
{% set total_urls = 0 %}
|
||||
{% for product in products %}
|
||||
{% set total_urls = total_urls + product.urls|length %}
|
||||
{% endfor %}
|
||||
{{ total_urls }}
|
||||
{% else %}
|
||||
0
|
||||
{% endif %}
|
||||
</h4>
|
||||
<p class="text-muted mb-0">Total URLs</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-bell fa-2x text-warning mb-2"></i>
|
||||
<h4 class="fw-bold">
|
||||
{% set alerts = [] %}
|
||||
{% for product in products %}
|
||||
{% if product.target_price and product.best_price and product.best_price.price <= product.target_price %}
|
||||
{% set _ = alerts.append(1) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{{ alerts|length }}
|
||||
</h4>
|
||||
<p class="text-muted mb-0">Price Alerts</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-chart-bar fa-2x text-info mb-2"></i>
|
||||
<h4 class="fw-bold">
|
||||
{% set total_savings = 0 %}
|
||||
{% for product in products %}
|
||||
{% if product.target_price and product.best_price %}
|
||||
{% set savings = product.target_price - product.best_price.price %}
|
||||
{% if savings > 0 %}
|
||||
{% set total_savings = total_savings + savings %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
£{{ "%.0f"|format(total_savings) }}
|
||||
</h4>
|
||||
<p class="text-muted mb-0">Potential Savings</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
234
templates/product_detail.html
Normal file
234
templates/product_detail.html
Normal file
@@ -0,0 +1,234 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ product.name }} - Price Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="display-5">{{ product.name }}</h1>
|
||||
{% if product.description %}
|
||||
<p class="text-muted">{{ product.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-success me-2" onclick="scrapeProduct({{ product.id }})">
|
||||
<i class="fas fa-sync-alt me-1"></i>Scrape Now
|
||||
</button>
|
||||
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i>Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Price Overview -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-tags me-2"></i>Current Prices
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if latest_prices %}
|
||||
{% set price_list = latest_prices.values() | list %}
|
||||
{% set min_price = price_list | min(attribute='price') %}
|
||||
{% set max_price = price_list | max(attribute='price') %}
|
||||
|
||||
{% for site_name, price_data in latest_prices.items() %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 p-3 rounded
|
||||
{% if price_data.price == min_price.price %}bg-success bg-opacity-10{% endif %}">
|
||||
<div>
|
||||
<span class="site-badge {{ site_name }}">{{ site_name.title() }}</span>
|
||||
{% if not price_data.availability %}
|
||||
<span class="badge bg-warning ms-2">Out of Stock</span>
|
||||
{% endif %}
|
||||
{% if price_data.price == min_price.price %}
|
||||
<span class="badge bg-success ms-2">
|
||||
<i class="fas fa-trophy"></i> Best Price
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="h5 mb-0">£{{ "%.2f"|format(price_data.price) }}</div>
|
||||
<small class="text-muted">{{ price_data.timestamp[:10] }}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if product.target_price %}
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold">Target Price:</span>
|
||||
<span class="h5 mb-0 text-info">£{{ "%.2f"|format(product.target_price) }}</span>
|
||||
</div>
|
||||
|
||||
{% if min_price.price <= product.target_price %}
|
||||
<div class="alert alert-success mt-3 py-2">
|
||||
<i class="fas fa-bell me-2"></i>
|
||||
<strong>Target Reached!</strong> Best price is at or below your target.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info mt-3 py-2">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
You could save <strong>£{{ "%.2f"|format(min_price.price - product.target_price) }}</strong>
|
||||
when price drops to target.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p class="text-muted text-center py-4">
|
||||
<i class="fas fa-exclamation-triangle fa-2x mb-3"></i><br>
|
||||
No price data available yet.<br>
|
||||
<button class="btn btn-primary mt-2" onclick="scrapeProduct({{ product.id }})">
|
||||
Get Prices Now
|
||||
</button>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product URLs -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-link me-2"></i>Tracked URLs
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for site_name, url in product.urls.items() %}
|
||||
<div class="mb-2">
|
||||
<span class="site-badge {{ site_name }}">{{ site_name.title() }}</span>
|
||||
<a href="{{ url }}" target="_blank" class="btn btn-sm btn-outline-primary ms-2">
|
||||
<i class="fas fa-external-link-alt"></i> View
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Price Chart -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-chart-line me-2"></i>Price History (Last 30 Days)
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if price_history %}
|
||||
<div id="priceChart" style="height: 400px;"></div>
|
||||
{% else %}
|
||||
<p class="text-muted text-center py-5">
|
||||
<i class="fas fa-chart-line fa-3x mb-3"></i><br>
|
||||
No price history available yet. Price data will appear here after scraping.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Price Statistics -->
|
||||
{% if price_stats %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-calculator me-2"></i>Price Statistics (Last 30 Days)
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{% for site_name, stats in price_stats.items() %}
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card border-0 bg-light">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<span class="site-badge {{ site_name }}">{{ site_name.title() }}</span>
|
||||
</h6>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<small class="text-muted">Min Price</small>
|
||||
<div class="fw-bold text-success">£{{ "%.2f"|format(stats.min_price) }}</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<small class="text-muted">Max Price</small>
|
||||
<div class="fw-bold text-danger">£{{ "%.2f"|format(stats.max_price) }}</div>
|
||||
</div>
|
||||
<div class="col-6 mt-2">
|
||||
<small class="text-muted">Avg Price</small>
|
||||
<div class="fw-bold">£{{ "%.2f"|format(stats.avg_price) }}</div>
|
||||
</div>
|
||||
<div class="col-6 mt-2">
|
||||
<small class="text-muted">Data Points</small>
|
||||
<div class="fw-bold">{{ stats.data_points }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Recent Price History -->
|
||||
{% if price_history %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-history me-2"></i>Recent Price Updates
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Site</th>
|
||||
<th>Price</th>
|
||||
<th>Available</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in price_history[:20] %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="site-badge {{ entry.site_name }}">{{ entry.site_name.title() }}</span>
|
||||
</td>
|
||||
<td class="fw-bold">£{{ "%.2f"|format(entry.price) }}</td>
|
||||
<td>
|
||||
{% if entry.availability %}
|
||||
<span class="badge bg-success">Available</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Out of Stock</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted">{{ entry.timestamp[:16] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if price_history|length > 20 %}
|
||||
<p class="text-muted text-center mt-3">
|
||||
Showing 20 most recent entries of {{ price_history|length }} total.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if chart_json %}
|
||||
<script>
|
||||
var chartData = {{ chart_json|safe }};
|
||||
Plotly.newPlot('priceChart', chartData.data, chartData.layout, {responsive: true});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
217
templates/settings.html
Normal file
217
templates/settings.html
Normal file
@@ -0,0 +1,217 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Settings - Price Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<h1 class="display-5 mb-4">
|
||||
<i class="fas fa-cog me-3 text-primary"></i>Settings
|
||||
</h1>
|
||||
|
||||
<!-- Scraping Settings -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-spider me-2"></i>Scraping Configuration
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Request Settings</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li><strong>Delay between requests:</strong> {{ config.delay_between_requests }}s</li>
|
||||
<li><strong>Max concurrent requests:</strong> {{ config.max_concurrent_requests }}</li>
|
||||
<li><strong>Request timeout:</strong> {{ config.timeout }}s</li>
|
||||
<li><strong>Retry attempts:</strong> {{ config.retry_attempts }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>User Agents</h6>
|
||||
<p class="text-muted small">{{ config.user_agents|length }} user agents configured</p>
|
||||
<details>
|
||||
<summary class="text-primary" style="cursor: pointer;">View user agents</summary>
|
||||
<div class="mt-2">
|
||||
{% for ua in config.user_agents %}
|
||||
<div class="small text-muted mb-1">{{ ua[:80] }}...</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Site Configuration -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-store me-2"></i>Supported Sites
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{% for site_name, site_config in config.sites_config.items() %}
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card border-0 bg-light">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<span class="site-badge {{ site_name }}">{{ site_name.title() }}</span>
|
||||
{% if site_config.enabled %}
|
||||
<span class="badge bg-success ms-2">Enabled</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary ms-2">Disabled</span>
|
||||
{% endif %}
|
||||
</h6>
|
||||
<p class="card-text small text-muted">
|
||||
<strong>Base URL:</strong> {{ site_config.base_url }}<br>
|
||||
<strong>Price selectors:</strong> {{ site_config.selectors.price|length }}<br>
|
||||
<strong>Title selectors:</strong> {{ site_config.selectors.title|length }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Settings -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-bell me-2"></i>Notification Settings
|
||||
</h5>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="testNotifications()">
|
||||
<i class="fas fa-test-tube me-1"></i>Test Notifications
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>
|
||||
<i class="fas fa-envelope me-2"></i>Email Notifications
|
||||
{% if config.notification_config.email.enabled %}
|
||||
<span class="badge bg-success">Enabled</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Disabled</span>
|
||||
{% endif %}
|
||||
</h6>
|
||||
{% if config.notification_config.email.enabled %}
|
||||
<ul class="list-unstyled small">
|
||||
<li><strong>SMTP Server:</strong> {{ config.notification_config.email.smtp_server }}</li>
|
||||
<li><strong>Port:</strong> {{ config.notification_config.email.smtp_port }}</li>
|
||||
<li><strong>Sender:</strong> {{ config.notification_config.email.sender_email }}</li>
|
||||
<li><strong>Recipient:</strong> {{ config.notification_config.email.recipient_email }}</li>
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted small">Email notifications are disabled. Configure in config.json to enable.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>
|
||||
<i class="fas fa-webhook me-2"></i>Webhook Notifications
|
||||
{% if config.notification_config.webhook.enabled %}
|
||||
<span class="badge bg-success">Enabled</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Disabled</span>
|
||||
{% endif %}
|
||||
</h6>
|
||||
{% if config.notification_config.webhook.enabled %}
|
||||
<p class="small">
|
||||
<strong>Webhook URL:</strong><br>
|
||||
<code>{{ config.notification_config.webhook.url }}</code>
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="text-muted small">Webhook notifications are disabled. Configure in config.json to enable.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Information -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-database me-2"></i>Database Information
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong>Database Path:</strong> <code>{{ config.database_path }}</code></p>
|
||||
<p class="text-muted small">
|
||||
The SQLite database stores all product information and price history.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-tools me-2"></i>Quick Actions
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-primary" onclick="scrapeAll()">
|
||||
<i class="fas fa-sync-alt me-2"></i>Scrape All Products
|
||||
</button>
|
||||
<button class="btn btn-info" onclick="testNotifications()">
|
||||
<i class="fas fa-bell me-2"></i>Test Notifications
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="checkSystemHealth()">
|
||||
<i class="fas fa-heartbeat me-2"></i>System Health Check
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Help -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-question-circle me-2"></i>Configuration Help
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h6>Configuration File</h6>
|
||||
<p class="small text-muted">
|
||||
Settings are stored in <code>config.json</code>.
|
||||
Edit this file to customize scraping behavior, add new sites, or configure notifications.
|
||||
</p>
|
||||
|
||||
<h6>Adding New Sites</h6>
|
||||
<p class="small text-muted">
|
||||
To add support for new e-commerce sites, add a new section to the "sites"
|
||||
configuration with CSS selectors for price, title, and availability.
|
||||
</p>
|
||||
|
||||
<h6>Email Setup</h6>
|
||||
<p class="small text-muted">
|
||||
For Gmail, use <code>smtp.gmail.com:587</code> and an app-specific password.
|
||||
Enable "Less secure app access" or use OAuth2.
|
||||
</p>
|
||||
|
||||
<h6>Webhooks</h6>
|
||||
<p class="small text-muted">
|
||||
Webhook notifications send JSON payloads to your specified URL.
|
||||
Useful for integrating with Slack, Discord, or custom applications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function checkSystemHealth() {
|
||||
alert('System health check functionality would be implemented here.');
|
||||
// This would make an API call to check system health
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user