mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
AlertsPartialView (htmx-polled every 30s from the dashboard) returned
500 because _alerts.html used {% trans %} without {% load i18n %}.
Added the load tag so the partial renders correctly.
The dashboard's inline {% include 'pricemon/_alerts.html' %} also
relied on an undefined 'alerts' context var (DashboardView sets
'active_alerts'), so the alerts panel rendered empty even when alerts
existed. Pass alerts=active_alerts explicitly via the include.
Add regression tests covering both the dashboard render with an alert
and the alerts partial endpoint.
328 lines
18 KiB
HTML
328 lines
18 KiB
HTML
{% extends "base.html" %}
|
|
{% load i18n %}
|
|
|
|
{% block title %}{% trans "Price Monitor" %}{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="max-w-7xl mx-auto px-4 py-6">
|
|
|
|
<!-- Header -->
|
|
<div class="flex items-start justify-between gap-4 mb-6">
|
|
<div class="min-w-0">
|
|
<h1 class="text-2xl font-bold text-gray-900">{% trans "Price Monitor" %}</h1>
|
|
<p class="text-sm text-gray-500 mt-0.5">{% trans "Track product prices and get notified on drops" %}</p>
|
|
</div>
|
|
<a href="{% url 'pricemon-add' %}"
|
|
class="flex-shrink-0 inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition">
|
|
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
|
</svg>
|
|
{% trans "Add Watcher" %}
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Stat cards -->
|
|
<div class="grid grid-cols-3 gap-3 sm:gap-4 mb-6">
|
|
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
|
<div class="text-xs text-gray-400 font-medium uppercase tracking-wide mb-1">{% trans "Watchers" %}</div>
|
|
<div class="text-2xl font-bold text-gray-900">{{ total_watchers }}</div>
|
|
</div>
|
|
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
|
<div class="text-xs text-gray-400 font-medium uppercase tracking-wide mb-1">{% trans "Active Alerts" %}</div>
|
|
<div class="text-2xl font-bold {% if active_alerts_count %}text-red-600{% else %}text-gray-900{% endif %}">
|
|
{{ active_alerts_count }}
|
|
</div>
|
|
</div>
|
|
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
|
<div class="text-xs text-gray-400 font-medium uppercase tracking-wide mb-1">{% trans "Enabled" %}</div>
|
|
<div class="text-2xl font-bold text-gray-900">{{ enabled_watchers_count }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Active Alerts Panel -->
|
|
{% if active_alerts %}
|
|
<div id="alerts-container"
|
|
hx-get="{% url 'pricemon-alerts-partial' %}"
|
|
hx-trigger="every 30s"
|
|
hx-swap="innerHTML">
|
|
{% include 'pricemon/_alerts.html' with alerts=active_alerts %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Watchers List -->
|
|
<div class="bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden">
|
|
{% if watchers %}
|
|
|
|
<!-- Mobile / Card view (default, hidden on lg+) -->
|
|
<div class="divide-y divide-gray-100 lg:hidden">
|
|
{% for watcher in watchers %}
|
|
<div class="p-4" id="watcher-row-{{ watcher.pk }}">
|
|
<!-- Top row: name + badge + price -->
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center flex-wrap gap-2">
|
|
<span class="font-medium text-gray-900 leading-tight">{{ watcher.name }}</span>
|
|
{% if watcher.enabled %}
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">{% trans "Active" %}</span>
|
|
{% else %}
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500">{% trans "Paused" %}</span>
|
|
{% endif %}
|
|
</div>
|
|
<a href="{{ watcher.url }}" target="_blank" rel="noopener"
|
|
class="text-xs text-indigo-500 hover:underline truncate block mt-0.5 max-w-full">
|
|
{{ watcher.url|truncatechars:55 }}
|
|
</a>
|
|
<div class="flex flex-wrap gap-3 mt-1.5 text-xs text-gray-400">
|
|
{% if watcher.last_checked_at %}
|
|
<span>{% trans "Checked" %} {{ watcher.last_checked_at|timesince }} {% trans "ago" %}</span>
|
|
{% else %}
|
|
<span>{% trans "Never checked" %}</span>
|
|
{% endif %}
|
|
<span>{{ watcher.get_check_interval_hours_display }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex-shrink-0 text-right">
|
|
{% if watcher.last_price %}
|
|
<span class="text-xl font-bold text-gray-900">${{ watcher.last_price }}</span>
|
|
{% else %}
|
|
<span class="text-gray-400 text-lg">—</span>
|
|
{% endif %}
|
|
<div class="mt-1 text-xs text-gray-500">
|
|
{% if watcher.initial_price %}
|
|
<span class="block">{% trans "Initial" %} ${{ watcher.initial_price }}</span>
|
|
{% endif %}
|
|
{% if watcher.price_change_direction %}
|
|
{% if watcher.price_change_direction == 'up' %}
|
|
<span class="block text-red-600">
|
|
<span aria-hidden="true">{{ watcher.price_change_symbol }}</span>
|
|
{% trans "up" %} {{ watcher.price_change_pct }}%
|
|
</span>
|
|
{% elif watcher.price_change_direction == 'down' %}
|
|
<span class="block text-green-600">
|
|
<span aria-hidden="true">{{ watcher.price_change_symbol }}</span>
|
|
{% trans "down" %} {{ watcher.price_change_pct }}%
|
|
</span>
|
|
{% else %}
|
|
<span class="block text-gray-500">
|
|
<span aria-hidden="true">{{ watcher.price_change_symbol }}</span>
|
|
0.00%
|
|
</span>
|
|
{% endif %}
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Actions row -->
|
|
<div class="flex items-center justify-end gap-1 mt-3 pt-2 border-t border-gray-50">
|
|
<button
|
|
hx-post="{% url 'pricemon-check-now' watcher.pk %}"
|
|
hx-confirm="Run a price check now for {{ watcher.name }}?"
|
|
hx-swap="none"
|
|
hx-on::after-request="if(event.detail.successful) window.location.reload()"
|
|
title="{% trans 'Check Now' %}"
|
|
class="p-2 rounded text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
|
</svg>
|
|
</button>
|
|
<a href="{% url 'pricemon-history' watcher.pk %}"
|
|
title="{% trans 'Price History' %}"
|
|
class="p-2 rounded text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
|
</svg>
|
|
</a>
|
|
<a href="{% url 'pricemon-edit' watcher.pk %}"
|
|
title="{% trans 'Edit' %}"
|
|
class="p-2 rounded text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
|
</svg>
|
|
</a>
|
|
<form method="post" action="{% url 'pricemon-toggle-enabled' watcher.pk %}" class="inline"
|
|
{% if watcher.enabled %}onsubmit="return confirm('Disable price monitoring for this product?')"{% endif %}>
|
|
{% csrf_token %}
|
|
<button type="submit"
|
|
title="{% if watcher.enabled %}{% trans 'Disable monitoring' %}{% else %}{% trans 'Enable monitoring' %}{% endif %}"
|
|
class="p-2 rounded text-gray-400 {% if watcher.enabled %}hover:text-amber-600 hover:bg-amber-50{% else %}hover:text-green-600 hover:bg-green-50{% endif %} transition">
|
|
{% if watcher.enabled %}
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m5-3a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
|
</svg>
|
|
{% else %}
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5v14l11-7L8 5z"/>
|
|
</svg>
|
|
{% endif %}
|
|
</button>
|
|
</form>
|
|
<form method="post" action="{% url 'pricemon-delete' watcher.pk %}" class="inline"
|
|
onsubmit="return confirm('Delete watcher {{ watcher.name }}?')">
|
|
{% csrf_token %}
|
|
<button type="submit" title="{% trans 'Delete' %}"
|
|
class="p-2 rounded text-gray-400 hover:text-red-600 hover:bg-red-50 transition">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
|
</svg>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<!-- Desktop / Table view (hidden below lg) -->
|
|
<table class="hidden lg:table min-w-full divide-y divide-gray-200 text-sm">
|
|
<thead class="bg-gray-50">
|
|
<tr>
|
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">{% trans "Product" %}</th>
|
|
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{% trans "Current Price" %}</th>
|
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">{% trans "Last Checked" %}</th>
|
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">{% trans "Interval" %}</th>
|
|
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">{% trans "Status" %}</th>
|
|
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{% trans "Actions" %}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-100">
|
|
{% for watcher in watchers %}
|
|
<tr class="hover:bg-gray-50" id="watcher-row-desktop-{{ watcher.pk }}">
|
|
<td class="px-4 py-3">
|
|
<div class="font-medium text-gray-900">{{ watcher.name }}</div>
|
|
<a href="{{ watcher.url }}" target="_blank" rel="noopener"
|
|
class="text-xs text-indigo-500 hover:underline truncate block max-w-xs">
|
|
{{ watcher.url|truncatechars:60 }}
|
|
</a>
|
|
</td>
|
|
<td class="px-4 py-3 text-right">
|
|
{% if watcher.last_price %}
|
|
<span class="text-lg font-bold text-gray-900">${{ watcher.last_price }}</span>
|
|
{% else %}
|
|
<span class="text-gray-400">—</span>
|
|
{% endif %}
|
|
<div class="mt-1 space-y-0.5 text-xs text-gray-500">
|
|
{% if watcher.initial_price %}
|
|
<div>{% trans "Initial" %} ${{ watcher.initial_price }}</div>
|
|
{% endif %}
|
|
{% if watcher.price_change_direction %}
|
|
{% if watcher.price_change_direction == 'up' %}
|
|
<div class="font-medium text-red-600">
|
|
<span aria-hidden="true">{{ watcher.price_change_symbol }}</span>
|
|
{% trans "up" %} {{ watcher.price_change_pct }}%
|
|
</div>
|
|
{% elif watcher.price_change_direction == 'down' %}
|
|
<div class="font-medium text-green-600">
|
|
<span aria-hidden="true">{{ watcher.price_change_symbol }}</span>
|
|
{% trans "down" %} {{ watcher.price_change_pct }}%
|
|
</div>
|
|
{% else %}
|
|
<div class="font-medium text-gray-500">
|
|
<span aria-hidden="true">{{ watcher.price_change_symbol }}</span>
|
|
0.00%
|
|
</div>
|
|
{% endif %}
|
|
{% endif %}
|
|
</div>
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-500">
|
|
{% if watcher.last_checked_at %}
|
|
{{ watcher.last_checked_at|timesince }} {% trans "ago" %}
|
|
{% else %}
|
|
<span class="text-gray-300">{% trans "Never" %}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-500">
|
|
{{ watcher.get_check_interval_hours_display }}
|
|
</td>
|
|
<td class="px-4 py-3 text-center">
|
|
{% if watcher.enabled %}
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">{% trans "Active" %}</span>
|
|
{% else %}
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500">{% trans "Paused" %}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="px-4 py-3 text-right">
|
|
<div class="flex items-center justify-end gap-1">
|
|
<button
|
|
hx-post="{% url 'pricemon-check-now' watcher.pk %}"
|
|
hx-confirm="Run a price check now for {{ watcher.name }}?"
|
|
hx-swap="none"
|
|
hx-on::after-request="if(event.detail.successful) window.location.reload()"
|
|
title="{% trans 'Check Now' %}"
|
|
class="p-1.5 rounded text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
|
</svg>
|
|
</button>
|
|
<a href="{% url 'pricemon-history' watcher.pk %}"
|
|
title="{% trans 'Price History' %}"
|
|
class="p-1.5 rounded text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
|
</svg>
|
|
</a>
|
|
<a href="{% url 'pricemon-edit' watcher.pk %}"
|
|
title="{% trans 'Edit' %}"
|
|
class="p-1.5 rounded text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
|
</svg>
|
|
</a>
|
|
<form method="post" action="{% url 'pricemon-toggle-enabled' watcher.pk %}" class="inline"
|
|
{% if watcher.enabled %}onsubmit="return confirm('Disable price monitoring for this product?')"{% endif %}>
|
|
{% csrf_token %}
|
|
<button type="submit"
|
|
title="{% if watcher.enabled %}{% trans 'Disable monitoring' %}{% else %}{% trans 'Enable monitoring' %}{% endif %}"
|
|
class="p-1.5 rounded text-gray-400 {% if watcher.enabled %}hover:text-amber-600 hover:bg-amber-50{% else %}hover:text-green-600 hover:bg-green-50{% endif %} transition">
|
|
{% if watcher.enabled %}
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m5-3a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
|
</svg>
|
|
{% else %}
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5v14l11-7L8 5z"/>
|
|
</svg>
|
|
{% endif %}
|
|
</button>
|
|
</form>
|
|
<form method="post" action="{% url 'pricemon-delete' watcher.pk %}" class="inline"
|
|
onsubmit="return confirm('Delete watcher {{ watcher.name }}?')">
|
|
{% csrf_token %}
|
|
<button type="submit" title="{% trans 'Delete' %}"
|
|
class="p-1.5 rounded text-gray-400 hover:text-red-600 hover:bg-red-50 transition">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
|
</svg>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
|
|
{% else %}
|
|
<div class="text-center py-16 text-gray-400">
|
|
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
|
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/>
|
|
</svg>
|
|
<p class="font-medium">{% trans "No watchers yet" %}</p>
|
|
<p class="text-sm mt-1">
|
|
<a href="{% url 'pricemon-add' %}" class="text-indigo-500 hover:underline">{% trans "Add your first price watcher" %}</a>
|
|
</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
</div>
|
|
{% endblock %}
|