feat(monitor): background saved-search monitoring with watch alerts (#12)
Backend:
- Migrations 013-015: eBay user tokens, monitor settings on saved_searches
(monitor_enabled, poll_interval_min, min_trust_score, last_checked_at),
watch_alerts table with UNIQUE dedup on (saved_search_id, platform_listing_id),
active_monitors registry for cross-user polling
- WatchAlert model + store methods: upsert_alert, list_alerts, dismiss_alert,
count_undismissed_alerts, dismiss_all_alerts, list_active_monitors
- monitor.py: run_monitor_search() using TrustScorer.score_batch(); should_alert()
with BIN/auction/partial-score logic (auction window = 24h, partial +10 buffer)
- PATCH /api/saved-searches/{id}/monitor, GET /api/alerts, POST /api/alerts/*/dismiss
- Background polling loop at startup (asyncio.to_thread every 60s check cycle)
- ebay/adapter.py: enrich_seller_trading_api() via Trading API GetUser (OAuth token)
- nginx: raise proxy_read_timeout to 120s for slow eBay search responses
Frontend:
- AlertBell component: bell button + unread badge + panel with dismiss/clear-all;
polls /api/alerts every 2 minutes; aria-live announcement on count change
- alerts.ts Pinia store: fetchAlerts, dismiss, dismissAll
- SavedSearchesView: monitor toggle + poll interval + min trust score controls
- SettingsView: eBay OAuth connect/disconnect section
- AppNav: AlertBell wired for logged-in and local-tier users
Tests: 24 monitor tests (should_alert branches, store alert CRUD, run_monitor_search
with mocked adapter); fix browser_pool test assertions for new wait_for_* params.
This commit is contained in:
parent
ac5e6166c9
commit
89d3862f62
15 changed files with 1897 additions and 36 deletions
20
app/db/migrations/013_ebay_user_tokens.sql
Normal file
20
app/db/migrations/013_ebay_user_tokens.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
-- Migration 013: eBay user OAuth tokens
|
||||||
|
--
|
||||||
|
-- Stores per-user eBay Authorization Code tokens so the app can call
|
||||||
|
-- Trading API GetUser for instant account_age_days + category feedback
|
||||||
|
-- without Playwright scraping.
|
||||||
|
--
|
||||||
|
-- Stored in the per-user DB (user.db), never the shared DB.
|
||||||
|
-- access_token is short-lived (2h); refresh_token is valid 18 months.
|
||||||
|
-- The API layer refreshes access_token automatically before expiry.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ebay_user_tokens (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
-- Single row per user DB — upsert on reconnect
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
refresh_token TEXT NOT NULL,
|
||||||
|
expires_at REAL NOT NULL, -- epoch seconds; access token expiry
|
||||||
|
scopes TEXT NOT NULL DEFAULT '',
|
||||||
|
connected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
last_refreshed TEXT
|
||||||
|
);
|
||||||
24
app/db/migrations/014_saved_search_monitor.sql
Normal file
24
app/db/migrations/014_saved_search_monitor.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
-- Migration 014: background monitor settings on saved_searches + watch_alerts table
|
||||||
|
|
||||||
|
ALTER TABLE saved_searches ADD COLUMN monitor_enabled INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE saved_searches ADD COLUMN poll_interval_min INTEGER NOT NULL DEFAULT 60;
|
||||||
|
ALTER TABLE saved_searches ADD COLUMN min_trust_score INTEGER NOT NULL DEFAULT 60;
|
||||||
|
ALTER TABLE saved_searches ADD COLUMN last_checked_at TEXT;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS watch_alerts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
saved_search_id INTEGER NOT NULL REFERENCES saved_searches(id) ON DELETE CASCADE,
|
||||||
|
platform_listing_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
price REAL NOT NULL,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
trust_score INTEGER NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
first_alerted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
dismissed_at TEXT,
|
||||||
|
UNIQUE(saved_search_id, platform_listing_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_watch_alerts_undismissed
|
||||||
|
ON watch_alerts(saved_search_id)
|
||||||
|
WHERE dismissed_at IS NULL;
|
||||||
20
app/db/migrations/015_active_monitors.sql
Normal file
20
app/db/migrations/015_active_monitors.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
-- Migration 015: cross-user monitor registry for the background polling loop
|
||||||
|
--
|
||||||
|
-- In cloud mode this table lives in shared.db — the polling loop queries it
|
||||||
|
-- to find all due monitors without scanning per-user DB files.
|
||||||
|
-- In local mode it lives in the single local DB (same result, one user).
|
||||||
|
--
|
||||||
|
-- user_db_path references the per-user snipe user.db so the poller knows
|
||||||
|
-- which DB to open for the full SavedSearch config and to write alerts.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS active_monitors (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_db_path TEXT NOT NULL,
|
||||||
|
saved_search_id INTEGER NOT NULL,
|
||||||
|
poll_interval_min INTEGER NOT NULL DEFAULT 60,
|
||||||
|
last_checked_at TEXT,
|
||||||
|
UNIQUE(user_db_path, saved_search_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_active_monitors_due
|
||||||
|
ON active_monitors(last_checked_at);
|
||||||
|
|
@ -81,6 +81,26 @@ class SavedSearch:
|
||||||
id: Optional[int] = None
|
id: Optional[int] = None
|
||||||
created_at: Optional[str] = None
|
created_at: Optional[str] = None
|
||||||
last_run_at: Optional[str] = None
|
last_run_at: Optional[str] = None
|
||||||
|
# Monitor settings (migration 014)
|
||||||
|
monitor_enabled: bool = False
|
||||||
|
poll_interval_min: int = 60
|
||||||
|
min_trust_score: int = 60
|
||||||
|
last_checked_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WatchAlert:
|
||||||
|
"""A new listing surfaced by the background monitor for a saved search."""
|
||||||
|
saved_search_id: int
|
||||||
|
platform_listing_id: str
|
||||||
|
title: str
|
||||||
|
price: float
|
||||||
|
trust_score: int
|
||||||
|
currency: str = "USD"
|
||||||
|
url: Optional[str] = None
|
||||||
|
id: Optional[int] = None
|
||||||
|
first_alerted_at: Optional[str] = None
|
||||||
|
dismissed_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
|
||||||
165
app/db/store.py
165
app/db/store.py
|
|
@ -8,7 +8,7 @@ from typing import Optional
|
||||||
|
|
||||||
from circuitforge_core.db import get_connection, run_migrations
|
from circuitforge_core.db import get_connection, run_migrations
|
||||||
|
|
||||||
from .models import Listing, MarketComp, SavedSearch, ScammerEntry, Seller, TrustScore
|
from .models import Listing, MarketComp, SavedSearch, ScammerEntry, Seller, TrustScore, WatchAlert
|
||||||
|
|
||||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
|
|
||||||
|
|
@ -310,15 +310,66 @@ class Store:
|
||||||
|
|
||||||
def list_saved_searches(self) -> list[SavedSearch]:
|
def list_saved_searches(self) -> list[SavedSearch]:
|
||||||
rows = self._conn.execute(
|
rows = self._conn.execute(
|
||||||
"SELECT name, query, platform, filters_json, id, created_at, last_run_at "
|
"SELECT name, query, platform, filters_json, id, created_at, last_run_at, "
|
||||||
|
"monitor_enabled, poll_interval_min, min_trust_score, last_checked_at "
|
||||||
"FROM saved_searches ORDER BY created_at DESC"
|
"FROM saved_searches ORDER BY created_at DESC"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [
|
return [
|
||||||
SavedSearch(name=r[0], query=r[1], platform=r[2], filters_json=r[3],
|
SavedSearch(
|
||||||
id=r[4], created_at=r[5], last_run_at=r[6])
|
name=r[0], query=r[1], platform=r[2], filters_json=r[3],
|
||||||
|
id=r[4], created_at=r[5], last_run_at=r[6],
|
||||||
|
monitor_enabled=bool(r[7]), poll_interval_min=r[8],
|
||||||
|
min_trust_score=r[9], last_checked_at=r[10],
|
||||||
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def update_monitor_settings(
|
||||||
|
self,
|
||||||
|
saved_id: int,
|
||||||
|
*,
|
||||||
|
monitor_enabled: bool,
|
||||||
|
poll_interval_min: int,
|
||||||
|
min_trust_score: int,
|
||||||
|
) -> None:
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE saved_searches "
|
||||||
|
"SET monitor_enabled=?, poll_interval_min=?, min_trust_score=? "
|
||||||
|
"WHERE id=?",
|
||||||
|
(int(monitor_enabled), poll_interval_min, min_trust_score, saved_id),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def list_monitored_searches(self) -> list[SavedSearch]:
|
||||||
|
"""Return all saved searches with monitoring enabled (used by background poller)."""
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"SELECT name, query, platform, filters_json, id, created_at, last_run_at, "
|
||||||
|
"monitor_enabled, poll_interval_min, min_trust_score, last_checked_at "
|
||||||
|
"FROM saved_searches WHERE monitor_enabled=1"
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
SavedSearch(
|
||||||
|
name=r[0], query=r[1], platform=r[2], filters_json=r[3],
|
||||||
|
id=r[4], created_at=r[5], last_run_at=r[6],
|
||||||
|
monitor_enabled=True, poll_interval_min=r[8],
|
||||||
|
min_trust_score=r[9], last_checked_at=r[10],
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def mark_search_checked(self, saved_id: int) -> None:
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE saved_searches SET last_checked_at=? WHERE id=?",
|
||||||
|
(datetime.now(timezone.utc).isoformat(), saved_id),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def count_active_monitors(self) -> int:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM saved_searches WHERE monitor_enabled=1"
|
||||||
|
).fetchone()
|
||||||
|
return row[0] if row else 0
|
||||||
|
|
||||||
def delete_saved_search(self, saved_id: int) -> None:
|
def delete_saved_search(self, saved_id: int) -> None:
|
||||||
self._conn.execute("DELETE FROM saved_searches WHERE id=?", (saved_id,))
|
self._conn.execute("DELETE FROM saved_searches WHERE id=?", (saved_id,))
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
|
@ -330,6 +381,112 @@ class Store:
|
||||||
)
|
)
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
|
||||||
|
# --- WatchAlerts ---
|
||||||
|
|
||||||
|
def upsert_alert(self, alert: WatchAlert) -> tuple[int, bool]:
|
||||||
|
"""Insert alert if not already present. Returns (id, is_new)."""
|
||||||
|
existing = self._conn.execute(
|
||||||
|
"SELECT id FROM watch_alerts WHERE saved_search_id=? AND platform_listing_id=?",
|
||||||
|
(alert.saved_search_id, alert.platform_listing_id),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
return existing[0], False
|
||||||
|
cur = self._conn.execute(
|
||||||
|
"INSERT INTO watch_alerts "
|
||||||
|
"(saved_search_id, platform_listing_id, title, price, currency, trust_score, url) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(alert.saved_search_id, alert.platform_listing_id, alert.title,
|
||||||
|
alert.price, alert.currency, alert.trust_score, alert.url),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return cur.lastrowid, True
|
||||||
|
|
||||||
|
def list_alerts(self, *, include_dismissed: bool = False) -> list[WatchAlert]:
|
||||||
|
where = "" if include_dismissed else "WHERE dismissed_at IS NULL"
|
||||||
|
rows = self._conn.execute(
|
||||||
|
f"SELECT id, saved_search_id, platform_listing_id, title, price, currency, "
|
||||||
|
f"trust_score, url, first_alerted_at, dismissed_at "
|
||||||
|
f"FROM watch_alerts {where} ORDER BY first_alerted_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
WatchAlert(
|
||||||
|
id=r[0], saved_search_id=r[1], platform_listing_id=r[2],
|
||||||
|
title=r[3], price=r[4], currency=r[5], trust_score=r[6],
|
||||||
|
url=r[7], first_alerted_at=r[8], dismissed_at=r[9],
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def count_undismissed_alerts(self) -> int:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM watch_alerts WHERE dismissed_at IS NULL"
|
||||||
|
).fetchone()
|
||||||
|
return row[0] if row else 0
|
||||||
|
|
||||||
|
def dismiss_alert(self, alert_id: int) -> None:
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE watch_alerts SET dismissed_at=? WHERE id=?",
|
||||||
|
(datetime.now(timezone.utc).isoformat(), alert_id),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def dismiss_all_alerts(self) -> int:
|
||||||
|
"""Dismiss all undismissed alerts. Returns count dismissed."""
|
||||||
|
cur = self._conn.execute(
|
||||||
|
"UPDATE watch_alerts SET dismissed_at=? WHERE dismissed_at IS NULL",
|
||||||
|
(datetime.now(timezone.utc).isoformat(),),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
|
# --- ActiveMonitors (sched_db / shared_db) ---
|
||||||
|
|
||||||
|
def upsert_active_monitor(
|
||||||
|
self,
|
||||||
|
user_db_path: str,
|
||||||
|
saved_search_id: int,
|
||||||
|
poll_interval_min: int,
|
||||||
|
) -> None:
|
||||||
|
"""Register or update a monitor in the cross-user registry (sched_db)."""
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO active_monitors (user_db_path, saved_search_id, poll_interval_min) "
|
||||||
|
"VALUES (?,?,?) "
|
||||||
|
"ON CONFLICT(user_db_path, saved_search_id) DO UPDATE SET "
|
||||||
|
" poll_interval_min=excluded.poll_interval_min",
|
||||||
|
(user_db_path, saved_search_id, poll_interval_min),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def remove_active_monitor(self, user_db_path: str, saved_search_id: int) -> None:
|
||||||
|
self._conn.execute(
|
||||||
|
"DELETE FROM active_monitors WHERE user_db_path=? AND saved_search_id=?",
|
||||||
|
(user_db_path, saved_search_id),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def list_due_active_monitors(self) -> list[tuple[str, int, int]]:
|
||||||
|
"""Return (user_db_path, saved_search_id, poll_interval_min) for monitors that are due.
|
||||||
|
|
||||||
|
Due = never checked OR last_checked_at is old enough given poll_interval_min.
|
||||||
|
Uses SQLite's strftime('%s') for epoch arithmetic without Python datetime overhead.
|
||||||
|
"""
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"SELECT user_db_path, saved_search_id, poll_interval_min "
|
||||||
|
"FROM active_monitors "
|
||||||
|
"WHERE last_checked_at IS NULL "
|
||||||
|
" OR (strftime('%s','now') - strftime('%s', last_checked_at)) "
|
||||||
|
" >= poll_interval_min * 60"
|
||||||
|
).fetchall()
|
||||||
|
return [(r[0], r[1], r[2]) for r in rows]
|
||||||
|
|
||||||
|
def mark_active_monitor_checked(self, user_db_path: str, saved_search_id: int) -> None:
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE active_monitors SET last_checked_at=? "
|
||||||
|
"WHERE user_db_path=? AND saved_search_id=?",
|
||||||
|
(datetime.now(timezone.utc).isoformat(), user_db_path, saved_search_id),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
# --- ScammerBlocklist ---
|
# --- ScammerBlocklist ---
|
||||||
|
|
||||||
def add_to_blocklist(self, entry: ScammerEntry) -> ScammerEntry:
|
def add_to_blocklist(self, entry: ScammerEntry) -> ScammerEntry:
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
"""eBay Browse API adapter."""
|
"""eBay Browse + Trading API adapter."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -210,6 +211,70 @@ class EbayAdapter(PlatformAdapter):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.debug("Shopping API enrich failed for %s: %s", username, e)
|
log.debug("Shopping API enrich failed for %s: %s", username, e)
|
||||||
|
|
||||||
|
# ── Trading API GetUser (requires user OAuth token) ───────────────────────
|
||||||
|
|
||||||
|
_TRADING_API_URL = "https://api.ebay.com/ws/api.dll"
|
||||||
|
_TRADING_API_COMPATIBILITY = "1283"
|
||||||
|
|
||||||
|
def enrich_seller_trading_api(self, username: str, user_access_token: str) -> bool:
|
||||||
|
"""Enrich a seller's account_age_days using Trading API GetUser.
|
||||||
|
|
||||||
|
Uses the connected user's OAuth access token (Authorization Code flow),
|
||||||
|
which bypasses Shopping API rate limits and works even when the Shopping
|
||||||
|
API GetUserProfile call is throttled.
|
||||||
|
|
||||||
|
Unlike BTF scraping, this is a clean API call (~200ms, no Playwright).
|
||||||
|
Called from the search endpoint when the requesting user has connected
|
||||||
|
their eBay account.
|
||||||
|
|
||||||
|
Returns True if enrichment succeeded, False on any failure.
|
||||||
|
"""
|
||||||
|
xml_body = (
|
||||||
|
'<?xml version="1.0" encoding="utf-8"?>'
|
||||||
|
'<GetUserRequest xmlns="urn:ebay:apis:eBLBaseComponents">'
|
||||||
|
f'<UserID>{username}</UserID>'
|
||||||
|
'</GetUserRequest>'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
self._TRADING_API_URL,
|
||||||
|
headers={
|
||||||
|
"X-EBAY-API-CALL-NAME": "GetUser",
|
||||||
|
"X-EBAY-API-SITEID": "0",
|
||||||
|
"X-EBAY-API-COMPATIBILITY-LEVEL": self._TRADING_API_COMPATIBILITY,
|
||||||
|
"X-EBAY-API-IAF-TOKEN": f"Bearer {user_access_token}",
|
||||||
|
"Content-Type": "text/xml",
|
||||||
|
},
|
||||||
|
data=xml_body.encode("utf-8"),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
root = ET.fromstring(resp.text)
|
||||||
|
ns = {"e": "urn:ebay:apis:eBLBaseComponents"}
|
||||||
|
|
||||||
|
ack = root.findtext("e:Ack", namespaces=ns)
|
||||||
|
if ack not in ("Success", "Warning"):
|
||||||
|
errors = [e.findtext("e:LongMessage", namespaces=ns, default="")
|
||||||
|
for e in root.findall("e:Errors", namespaces=ns)]
|
||||||
|
log.debug("Trading API GetUser failed for %s: %s", username, errors)
|
||||||
|
return False
|
||||||
|
|
||||||
|
reg_date = root.findtext("e:User/e:RegistrationDate", namespaces=ns)
|
||||||
|
if not reg_date:
|
||||||
|
return False
|
||||||
|
|
||||||
|
dt = datetime.fromisoformat(reg_date.replace("Z", "+00:00"))
|
||||||
|
age_days = (datetime.now(timezone.utc) - dt).days
|
||||||
|
seller = self._store.get_seller("ebay", username)
|
||||||
|
if seller:
|
||||||
|
self._store.save_seller(replace(seller, account_age_days=age_days))
|
||||||
|
log.debug("Trading API GetUser: %s registered %d days ago", username, age_days)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("Trading API GetUser failed for %s: %s", username, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
def get_seller(self, seller_platform_id: str) -> Optional[Seller]:
|
def get_seller(self, seller_platform_id: str) -> Optional[Seller]:
|
||||||
cached = self._store.get_seller("ebay", seller_platform_id)
|
cached = self._store.get_seller("ebay", seller_platform_id)
|
||||||
if cached:
|
if cached:
|
||||||
|
|
|
||||||
145
app/tasks/monitor.py
Normal file
145
app/tasks/monitor.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
# app/tasks/monitor.py
|
||||||
|
"""Background saved-search monitor — polls eBay and writes WatchAlerts for new listings.
|
||||||
|
|
||||||
|
Design notes:
|
||||||
|
- Runs synchronously inside an asyncio.to_thread() call from the polling loop.
|
||||||
|
- Uses the same eBay adapter + trust scoring pipeline as the live search endpoint.
|
||||||
|
- Dedup via watch_alerts (saved_search_id, platform_listing_id) UNIQUE constraint.
|
||||||
|
- Never takes any transactional action — alert only.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.db.models import SavedSearch, WatchAlert
|
||||||
|
from app.db.store import Store
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_AUCTION_ALERT_WINDOW_HOURS = 24 # alert on auctions ending within this window
|
||||||
|
|
||||||
|
|
||||||
|
def should_alert(
|
||||||
|
*,
|
||||||
|
trust_score: int,
|
||||||
|
score_is_partial: bool,
|
||||||
|
price: float,
|
||||||
|
buying_format: str,
|
||||||
|
min_trust_score: int,
|
||||||
|
ends_at: "str | None" = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Return True if a listing qualifies for a watch alert.
|
||||||
|
|
||||||
|
BIN (fixed_price / best_offer): alert immediately — these sell on a first-come
|
||||||
|
basis, so speed matters. Require a higher trust bar on partial scores to reduce
|
||||||
|
false positives while BTF scraping is still in flight.
|
||||||
|
|
||||||
|
Auction: only alert when the auction is within _AUCTION_ALERT_WINDOW_HOURS of
|
||||||
|
ending. Alerting on a 7-day auction 6 days early is noise — the user can't act
|
||||||
|
usefully until the end window anyway. Bid scheduling (paid+) and sniping algo
|
||||||
|
(premium) are separate features built on top of this alert layer.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# Partial scores: apply a +10 buffer so we don't surface unreliable signals.
|
||||||
|
effective_min = min_trust_score + 10 if score_is_partial else min_trust_score
|
||||||
|
if trust_score < effective_min:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if buying_format in ("fixed_price", "best_offer"):
|
||||||
|
# BIN: alert immediately — inventory can disappear any time.
|
||||||
|
return True
|
||||||
|
|
||||||
|
if buying_format == "auction":
|
||||||
|
if not ends_at:
|
||||||
|
# No end time recorded — alert anyway rather than silently skip.
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
end = datetime.fromisoformat(ends_at.replace("Z", "+00:00"))
|
||||||
|
hours_remaining = (end - datetime.now(timezone.utc)).total_seconds() / 3600
|
||||||
|
return 0 < hours_remaining <= _AUCTION_ALERT_WINDOW_HOURS
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
log.debug("should_alert: could not parse ends_at=%r, alerting anyway", ends_at)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Unknown format — alert and let the user decide.
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def run_monitor_search(
|
||||||
|
search: SavedSearch,
|
||||||
|
*,
|
||||||
|
user_db: Path,
|
||||||
|
shared_db: Path,
|
||||||
|
) -> int:
|
||||||
|
"""Execute one background monitor run for a saved search.
|
||||||
|
|
||||||
|
Fetches current listings, scores them, writes new high-trust finds
|
||||||
|
to watch_alerts. Returns the count of new alerts written.
|
||||||
|
|
||||||
|
Called from the async polling loop via asyncio.to_thread().
|
||||||
|
"""
|
||||||
|
from app.platforms.ebay.adapter import EbayAdapter
|
||||||
|
from app.trust import TrustScorer
|
||||||
|
|
||||||
|
log.info("Monitor: checking saved search %d (%r)", search.id, search.name)
|
||||||
|
|
||||||
|
filters = json.loads(search.filters_json or "{}")
|
||||||
|
query = filters.pop("query_raw", search.query)
|
||||||
|
|
||||||
|
try:
|
||||||
|
adapter = EbayAdapter()
|
||||||
|
raw_listings = adapter.search(query, **filters)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Monitor: eBay search failed for search %d: %s", search.id, exc)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
shared_store = Store(shared_db)
|
||||||
|
user_store = Store(user_db)
|
||||||
|
scorer = TrustScorer(shared_store)
|
||||||
|
|
||||||
|
try:
|
||||||
|
trust_scores = scorer.score_batch(raw_listings, query)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Monitor: trust scoring failed for search %d: %s", search.id, exc)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
new_alert_count = 0
|
||||||
|
for listing, trust in zip(raw_listings, trust_scores):
|
||||||
|
qualifies = should_alert(
|
||||||
|
trust_score=trust.composite_score,
|
||||||
|
score_is_partial=trust.score_is_partial,
|
||||||
|
price=listing.price,
|
||||||
|
buying_format=listing.buying_format,
|
||||||
|
min_trust_score=search.min_trust_score,
|
||||||
|
ends_at=listing.ends_at,
|
||||||
|
)
|
||||||
|
if not qualifies:
|
||||||
|
continue
|
||||||
|
|
||||||
|
alert = WatchAlert(
|
||||||
|
saved_search_id=search.id,
|
||||||
|
platform_listing_id=listing.platform_listing_id,
|
||||||
|
title=listing.title,
|
||||||
|
price=listing.price,
|
||||||
|
currency=listing.currency,
|
||||||
|
trust_score=trust.composite_score,
|
||||||
|
url=listing.url,
|
||||||
|
)
|
||||||
|
_, is_new = user_store.upsert_alert(alert)
|
||||||
|
if is_new:
|
||||||
|
new_alert_count += 1
|
||||||
|
log.info(
|
||||||
|
"Monitor: new alert — search %d, listing %s, score=%d",
|
||||||
|
search.id, listing.platform_listing_id, trust.composite_score,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_store.mark_search_checked(search.id)
|
||||||
|
log.info(
|
||||||
|
"Monitor: search %d done — %d new alerts from %d listings",
|
||||||
|
search.id, new_alert_count, len(raw_listings),
|
||||||
|
)
|
||||||
|
return new_alert_count
|
||||||
|
|
@ -16,6 +16,10 @@ server {
|
||||||
# Forward the session header injected by Caddy from the cf_session cookie.
|
# Forward the session header injected by Caddy from the cf_session cookie.
|
||||||
# Caddy adds: header_up X-CF-Session {http.request.cookie.cf_session}
|
# Caddy adds: header_up X-CF-Session {http.request.cookie.cf_session}
|
||||||
proxy_set_header X-CF-Session $http_x_cf_session;
|
proxy_set_header X-CF-Session $http_x_cf_session;
|
||||||
|
# eBay search + comps can take 60-90s (Marketplace Insights 404 → Browse fallback).
|
||||||
|
# Default 60s proxy_read_timeout drops slow searches with a NetworkError on the client.
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# index.html — never cache; ensures clients always get the latest entry point
|
# index.html — never cache; ensures clients always get the latest entry point
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,10 @@ class TestFetchHtmlPoolHit:
|
||||||
html = pool.fetch_html("https://www.ebay.com/sch/i.html?_nkw=test", delay=0)
|
html = pool.fetch_html("https://www.ebay.com/sch/i.html?_nkw=test", delay=0)
|
||||||
|
|
||||||
assert html == "<html>ok</html>"
|
assert html == "<html>ok</html>"
|
||||||
mock_fetch.assert_called_once_with(slot, "https://www.ebay.com/sch/i.html?_nkw=test")
|
mock_fetch.assert_called_once_with(
|
||||||
|
slot, "https://www.ebay.com/sch/i.html?_nkw=test",
|
||||||
|
wait_for_selector=None, wait_for_timeout_ms=2000,
|
||||||
|
)
|
||||||
mock_replenish.assert_called_once_with(slot)
|
mock_replenish.assert_called_once_with(slot)
|
||||||
# Fresh slot returned to queue
|
# Fresh slot returned to queue
|
||||||
assert pool._q.get_nowait() is fresh_slot
|
assert pool._q.get_nowait() is fresh_slot
|
||||||
|
|
@ -197,7 +200,10 @@ class TestFetchHtmlFallback:
|
||||||
html = pool.fetch_html("https://www.ebay.com/sch/i.html?_nkw=widget", delay=0)
|
html = pool.fetch_html("https://www.ebay.com/sch/i.html?_nkw=widget", delay=0)
|
||||||
|
|
||||||
assert html == "<html>fresh</html>"
|
assert html == "<html>fresh</html>"
|
||||||
mock_fresh.assert_called_once_with("https://www.ebay.com/sch/i.html?_nkw=widget")
|
mock_fresh.assert_called_once_with(
|
||||||
|
"https://www.ebay.com/sch/i.html?_nkw=widget",
|
||||||
|
wait_for_selector=None, wait_for_timeout_ms=2000,
|
||||||
|
)
|
||||||
|
|
||||||
def test_falls_back_when_pooled_fetch_raises(self):
|
def test_falls_back_when_pooled_fetch_raises(self):
|
||||||
"""If _fetch_with_slot raises, the slot is closed and _fetch_fresh is used."""
|
"""If _fetch_with_slot raises, the slot is closed and _fetch_fresh is used."""
|
||||||
|
|
|
||||||
372
tests/test_tasks/test_monitor.py
Normal file
372
tests/test_tasks/test_monitor.py
Normal file
|
|
@ -0,0 +1,372 @@
|
||||||
|
"""Tests for the background monitor: should_alert logic, store alert methods, and run_monitor_search."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.tasks.monitor import _AUCTION_ALERT_WINDOW_HOURS, should_alert
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# should_alert — pure function, no I/O
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestShouldAlert:
|
||||||
|
def test_bin_above_threshold_alerts(self):
|
||||||
|
assert should_alert(
|
||||||
|
trust_score=70, score_is_partial=False,
|
||||||
|
price=100.0, buying_format="fixed_price",
|
||||||
|
min_trust_score=60,
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_bin_below_threshold_no_alert(self):
|
||||||
|
assert should_alert(
|
||||||
|
trust_score=55, score_is_partial=False,
|
||||||
|
price=100.0, buying_format="fixed_price",
|
||||||
|
min_trust_score=60,
|
||||||
|
) is False
|
||||||
|
|
||||||
|
def test_partial_score_applies_buffer(self):
|
||||||
|
# Score 65 with min 60 passes normally but fails with the +10 partial buffer.
|
||||||
|
assert should_alert(
|
||||||
|
trust_score=65, score_is_partial=True,
|
||||||
|
price=100.0, buying_format="fixed_price",
|
||||||
|
min_trust_score=60,
|
||||||
|
) is False
|
||||||
|
|
||||||
|
def test_partial_score_above_buffered_threshold_alerts(self):
|
||||||
|
assert should_alert(
|
||||||
|
trust_score=75, score_is_partial=True,
|
||||||
|
price=100.0, buying_format="fixed_price",
|
||||||
|
min_trust_score=60,
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_best_offer_treated_like_bin(self):
|
||||||
|
assert should_alert(
|
||||||
|
trust_score=80, score_is_partial=False,
|
||||||
|
price=200.0, buying_format="best_offer",
|
||||||
|
min_trust_score=60,
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_auction_within_window_alerts(self):
|
||||||
|
soon = (datetime.now(timezone.utc) + timedelta(hours=12)).isoformat()
|
||||||
|
assert should_alert(
|
||||||
|
trust_score=70, score_is_partial=False,
|
||||||
|
price=100.0, buying_format="auction",
|
||||||
|
min_trust_score=60, ends_at=soon,
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_auction_outside_window_no_alert(self):
|
||||||
|
far = (datetime.now(timezone.utc) + timedelta(hours=48)).isoformat()
|
||||||
|
assert should_alert(
|
||||||
|
trust_score=70, score_is_partial=False,
|
||||||
|
price=100.0, buying_format="auction",
|
||||||
|
min_trust_score=60, ends_at=far,
|
||||||
|
) is False
|
||||||
|
|
||||||
|
def test_auction_no_ends_at_alerts_anyway(self):
|
||||||
|
assert should_alert(
|
||||||
|
trust_score=70, score_is_partial=False,
|
||||||
|
price=100.0, buying_format="auction",
|
||||||
|
min_trust_score=60, ends_at=None,
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_auction_bad_ends_at_alerts_anyway(self):
|
||||||
|
assert should_alert(
|
||||||
|
trust_score=70, score_is_partial=False,
|
||||||
|
price=100.0, buying_format="auction",
|
||||||
|
min_trust_score=60, ends_at="not-a-date",
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_auction_expired_no_alert(self):
|
||||||
|
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
||||||
|
assert should_alert(
|
||||||
|
trust_score=70, score_is_partial=False,
|
||||||
|
price=100.0, buying_format="auction",
|
||||||
|
min_trust_score=60, ends_at=past,
|
||||||
|
) is False
|
||||||
|
|
||||||
|
def test_unknown_format_alerts(self):
|
||||||
|
# Fail-open: unknown buying_format should not silently suppress.
|
||||||
|
assert should_alert(
|
||||||
|
trust_score=70, score_is_partial=False,
|
||||||
|
price=100.0, buying_format="mystery_format",
|
||||||
|
min_trust_score=60,
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_score_exactly_at_threshold_passes(self):
|
||||||
|
assert should_alert(
|
||||||
|
trust_score=60, score_is_partial=False,
|
||||||
|
price=100.0, buying_format="fixed_price",
|
||||||
|
min_trust_score=60,
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_auction_exactly_at_window_boundary_alerts(self):
|
||||||
|
boundary = (datetime.now(timezone.utc) + timedelta(hours=_AUCTION_ALERT_WINDOW_HOURS - 0.1)).isoformat()
|
||||||
|
assert should_alert(
|
||||||
|
trust_score=70, score_is_partial=False,
|
||||||
|
price=100.0, buying_format="auction",
|
||||||
|
min_trust_score=60, ends_at=boundary,
|
||||||
|
) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Store alert methods — integration against real SQLite
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _create_monitor_db(path: Path) -> None:
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS saved_searches (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
query TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL DEFAULT 'ebay',
|
||||||
|
filters_json TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_run_at TEXT,
|
||||||
|
monitor_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
poll_interval_min INTEGER NOT NULL DEFAULT 60,
|
||||||
|
min_trust_score INTEGER NOT NULL DEFAULT 60,
|
||||||
|
last_checked_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS watch_alerts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
saved_search_id INTEGER NOT NULL REFERENCES saved_searches(id) ON DELETE CASCADE,
|
||||||
|
platform_listing_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
price REAL NOT NULL,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
trust_score INTEGER NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
first_alerted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
dismissed_at TEXT,
|
||||||
|
UNIQUE(saved_search_id, platform_listing_id)
|
||||||
|
);
|
||||||
|
INSERT INTO saved_searches (name, query, monitor_enabled) VALUES ('RTX 4090', 'rtx 4090', 1);
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def monitor_db(tmp_path: Path) -> Path:
|
||||||
|
db = tmp_path / "snipe.db"
|
||||||
|
_create_monitor_db(db)
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
class TestStoreAlertMethods:
|
||||||
|
def test_upsert_alert_new(self, monitor_db: Path):
|
||||||
|
from app.db.models import WatchAlert
|
||||||
|
from app.db.store import Store
|
||||||
|
|
||||||
|
store = Store(monitor_db)
|
||||||
|
alert = WatchAlert(
|
||||||
|
saved_search_id=1, platform_listing_id="ebay-001",
|
||||||
|
title="RTX 4090", price=750.0, trust_score=72, currency="USD",
|
||||||
|
url="https://ebay.com/itm/001",
|
||||||
|
)
|
||||||
|
alert_id, is_new = store.upsert_alert(alert)
|
||||||
|
assert is_new is True
|
||||||
|
assert alert_id > 0
|
||||||
|
|
||||||
|
def test_upsert_alert_dedup(self, monitor_db: Path):
|
||||||
|
from app.db.models import WatchAlert
|
||||||
|
from app.db.store import Store
|
||||||
|
|
||||||
|
store = Store(monitor_db)
|
||||||
|
alert = WatchAlert(
|
||||||
|
saved_search_id=1, platform_listing_id="ebay-002",
|
||||||
|
title="RTX 4090 FE", price=800.0, trust_score=68,
|
||||||
|
)
|
||||||
|
id1, new1 = store.upsert_alert(alert)
|
||||||
|
id2, new2 = store.upsert_alert(alert)
|
||||||
|
assert id1 == id2
|
||||||
|
assert new1 is True
|
||||||
|
assert new2 is False
|
||||||
|
|
||||||
|
def test_list_alerts_returns_undismissed(self, monitor_db: Path):
|
||||||
|
from app.db.models import WatchAlert
|
||||||
|
from app.db.store import Store
|
||||||
|
|
||||||
|
store = Store(monitor_db)
|
||||||
|
alert = WatchAlert(
|
||||||
|
saved_search_id=1, platform_listing_id="ebay-003",
|
||||||
|
title="Test listing", price=500.0, trust_score=75,
|
||||||
|
)
|
||||||
|
store.upsert_alert(alert)
|
||||||
|
alerts = store.list_alerts(include_dismissed=False)
|
||||||
|
assert len(alerts) == 1
|
||||||
|
assert alerts[0].platform_listing_id == "ebay-003"
|
||||||
|
|
||||||
|
def test_count_undismissed_alerts(self, monitor_db: Path):
|
||||||
|
from app.db.models import WatchAlert
|
||||||
|
from app.db.store import Store
|
||||||
|
|
||||||
|
store = Store(monitor_db)
|
||||||
|
for i in range(3):
|
||||||
|
store.upsert_alert(WatchAlert(
|
||||||
|
saved_search_id=1, platform_listing_id=f"ebay-{i:03d}",
|
||||||
|
title=f"Listing {i}", price=float(100 + i), trust_score=70,
|
||||||
|
))
|
||||||
|
assert store.count_undismissed_alerts() == 3
|
||||||
|
|
||||||
|
def test_dismiss_alert(self, monitor_db: Path):
|
||||||
|
from app.db.models import WatchAlert
|
||||||
|
from app.db.store import Store
|
||||||
|
|
||||||
|
store = Store(monitor_db)
|
||||||
|
alert = WatchAlert(
|
||||||
|
saved_search_id=1, platform_listing_id="ebay-dismiss",
|
||||||
|
title="To dismiss", price=400.0, trust_score=65,
|
||||||
|
)
|
||||||
|
alert_id, _ = store.upsert_alert(alert)
|
||||||
|
store.dismiss_alert(alert_id)
|
||||||
|
alerts = store.list_alerts(include_dismissed=False)
|
||||||
|
assert all(a.id != alert_id for a in alerts)
|
||||||
|
|
||||||
|
def test_dismiss_all_alerts(self, monitor_db: Path):
|
||||||
|
from app.db.models import WatchAlert
|
||||||
|
from app.db.store import Store
|
||||||
|
|
||||||
|
store = Store(monitor_db)
|
||||||
|
for i in range(3):
|
||||||
|
store.upsert_alert(WatchAlert(
|
||||||
|
saved_search_id=1, platform_listing_id=f"all-{i}",
|
||||||
|
title=f"All {i}", price=float(100 * i), trust_score=70,
|
||||||
|
))
|
||||||
|
count = store.dismiss_all_alerts()
|
||||||
|
assert count == 3
|
||||||
|
assert store.count_undismissed_alerts() == 0
|
||||||
|
|
||||||
|
def test_mark_search_checked_updates_timestamp(self, monitor_db: Path):
|
||||||
|
from app.db.store import Store
|
||||||
|
|
||||||
|
store = Store(monitor_db)
|
||||||
|
store.mark_search_checked(1)
|
||||||
|
searches = store.list_monitored_searches()
|
||||||
|
assert searches[0].last_checked_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# run_monitor_search — mocked adapter + trust aggregator
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunMonitorSearch:
|
||||||
|
def test_new_qualifying_listing_creates_alert(self, monitor_db: Path):
|
||||||
|
from app.db.models import Listing, SavedSearch, TrustScore
|
||||||
|
from app.db.store import Store
|
||||||
|
from app.tasks.monitor import run_monitor_search
|
||||||
|
|
||||||
|
search = SavedSearch(
|
||||||
|
id=1, name="RTX 4090", query="rtx 4090",
|
||||||
|
platform="ebay", monitor_enabled=True,
|
||||||
|
min_trust_score=60,
|
||||||
|
)
|
||||||
|
mock_listing = Listing(
|
||||||
|
platform="ebay", platform_listing_id="ebay-new",
|
||||||
|
title="ASUS RTX 4090", price=750.0, currency="USD",
|
||||||
|
condition="used", url="https://ebay.com/itm/new",
|
||||||
|
buying_format="fixed_price", seller_platform_id="seller123",
|
||||||
|
)
|
||||||
|
mock_trust = TrustScore(
|
||||||
|
listing_id=0, composite_score=72, score_is_partial=False,
|
||||||
|
account_age_score=0, feedback_count_score=0, feedback_ratio_score=0,
|
||||||
|
price_vs_market_score=0, category_history_score=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.platforms.ebay.adapter.EbayAdapter") as MockAdapter, \
|
||||||
|
patch("app.trust.TrustScorer") as MockAgg:
|
||||||
|
MockAdapter.return_value.search.return_value = [mock_listing]
|
||||||
|
MockAgg.return_value.score_batch.return_value = [mock_trust]
|
||||||
|
|
||||||
|
count = run_monitor_search(search, user_db=monitor_db, shared_db=monitor_db)
|
||||||
|
|
||||||
|
assert count == 1
|
||||||
|
alerts = Store(monitor_db).list_alerts()
|
||||||
|
assert len(alerts) == 1
|
||||||
|
assert alerts[0].platform_listing_id == "ebay-new"
|
||||||
|
|
||||||
|
def test_below_threshold_listing_not_alerted(self, monitor_db: Path):
|
||||||
|
from app.db.models import Listing, SavedSearch, TrustScore
|
||||||
|
from app.tasks.monitor import run_monitor_search
|
||||||
|
|
||||||
|
search = SavedSearch(
|
||||||
|
id=1, name="RTX 4090", query="rtx 4090",
|
||||||
|
platform="ebay", monitor_enabled=True,
|
||||||
|
min_trust_score=70,
|
||||||
|
)
|
||||||
|
mock_listing = Listing(
|
||||||
|
platform="ebay", platform_listing_id="ebay-low",
|
||||||
|
title="Sketchy RTX 4090", price=500.0, currency="USD",
|
||||||
|
condition="used", url="https://ebay.com/itm/low",
|
||||||
|
buying_format="fixed_price", seller_platform_id="s1",
|
||||||
|
)
|
||||||
|
mock_trust = TrustScore(
|
||||||
|
listing_id=0, composite_score=55, score_is_partial=False,
|
||||||
|
account_age_score=0, feedback_count_score=0, feedback_ratio_score=0,
|
||||||
|
price_vs_market_score=0, category_history_score=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.platforms.ebay.adapter.EbayAdapter") as MockAdapter, \
|
||||||
|
patch("app.trust.TrustScorer") as MockAgg:
|
||||||
|
MockAdapter.return_value.search.return_value = [mock_listing]
|
||||||
|
MockAgg.return_value.score_batch.return_value = [mock_trust]
|
||||||
|
|
||||||
|
count = run_monitor_search(search, user_db=monitor_db, shared_db=monitor_db)
|
||||||
|
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
def test_duplicate_listing_not_double_alerted(self, monitor_db: Path):
|
||||||
|
from app.db.models import Listing, SavedSearch, TrustScore
|
||||||
|
from app.tasks.monitor import run_monitor_search
|
||||||
|
|
||||||
|
search = SavedSearch(
|
||||||
|
id=1, name="RTX 4090", query="rtx 4090",
|
||||||
|
platform="ebay", monitor_enabled=True, min_trust_score=60,
|
||||||
|
)
|
||||||
|
mock_listing = Listing(
|
||||||
|
platform="ebay", platform_listing_id="ebay-dupe",
|
||||||
|
title="RTX 4090", price=700.0, currency="USD",
|
||||||
|
condition="used", url="https://ebay.com/itm/dupe",
|
||||||
|
buying_format="fixed_price", seller_platform_id="s1",
|
||||||
|
)
|
||||||
|
mock_trust = TrustScore(
|
||||||
|
listing_id=0, composite_score=75, score_is_partial=False,
|
||||||
|
account_age_score=0, feedback_count_score=0, feedback_ratio_score=0,
|
||||||
|
price_vs_market_score=0, category_history_score=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.platforms.ebay.adapter.EbayAdapter") as MockAdapter, \
|
||||||
|
patch("app.trust.TrustScorer") as MockAgg:
|
||||||
|
MockAdapter.return_value.search.return_value = [mock_listing]
|
||||||
|
MockAgg.return_value.score_batch.return_value = [mock_trust]
|
||||||
|
|
||||||
|
count1 = run_monitor_search(search, user_db=monitor_db, shared_db=monitor_db)
|
||||||
|
count2 = run_monitor_search(search, user_db=monitor_db, shared_db=monitor_db)
|
||||||
|
|
||||||
|
assert count1 == 1
|
||||||
|
assert count2 == 0 # deduped by UNIQUE constraint
|
||||||
|
|
||||||
|
def test_adapter_failure_returns_zero(self, monitor_db: Path):
|
||||||
|
from app.db.models import SavedSearch
|
||||||
|
from app.tasks.monitor import run_monitor_search
|
||||||
|
|
||||||
|
search = SavedSearch(
|
||||||
|
id=1, name="RTX 4090", query="rtx 4090",
|
||||||
|
platform="ebay", monitor_enabled=True, min_trust_score=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.platforms.ebay.adapter.EbayAdapter") as MockAdapter:
|
||||||
|
MockAdapter.return_value.search.side_effect = RuntimeError("eBay down")
|
||||||
|
count = run_monitor_search(search, user_db=monitor_db, shared_db=monitor_db)
|
||||||
|
|
||||||
|
assert count == 0
|
||||||
398
web/src/components/AlertBell.vue
Normal file
398
web/src/components/AlertBell.vue
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
<template>
|
||||||
|
<div class="alert-bell-wrap" ref="wrapRef">
|
||||||
|
<!-- Bell trigger button -->
|
||||||
|
<button
|
||||||
|
ref="bellRef"
|
||||||
|
class="alert-bell"
|
||||||
|
:class="{ 'alert-bell--active': panelOpen }"
|
||||||
|
:aria-label="unreadCount > 0 ? `${unreadCount} new watch alert${unreadCount === 1 ? '' : 's'}` : 'Watch alerts'"
|
||||||
|
:aria-expanded="panelOpen"
|
||||||
|
aria-haspopup="true"
|
||||||
|
@click="togglePanel"
|
||||||
|
>
|
||||||
|
<BellIcon class="alert-bell__icon" aria-hidden="true" />
|
||||||
|
<span
|
||||||
|
v-if="unreadCount > 0"
|
||||||
|
class="alert-badge"
|
||||||
|
aria-hidden="true"
|
||||||
|
>{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Polite live region — announces count changes without moving focus -->
|
||||||
|
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
||||||
|
{{ liveAnnouncement }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alert panel -->
|
||||||
|
<Transition name="panel">
|
||||||
|
<div
|
||||||
|
v-if="panelOpen"
|
||||||
|
class="alert-panel"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Watch alerts"
|
||||||
|
aria-modal="false"
|
||||||
|
>
|
||||||
|
<div class="alert-panel__header">
|
||||||
|
<span class="alert-panel__title">Watch Alerts</span>
|
||||||
|
<button
|
||||||
|
v-if="store.alerts.length > 0"
|
||||||
|
class="alert-panel__clear"
|
||||||
|
@click="onDismissAll"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="alert-panel__close"
|
||||||
|
aria-label="Close alerts panel"
|
||||||
|
@click="closePanel"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="store.loading" class="alert-panel__state">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="store.alerts.length === 0" class="alert-panel__state">
|
||||||
|
<span aria-hidden="true">🔔</span>
|
||||||
|
<p>No new alerts. Enable monitoring on a saved search to get notified.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-else class="alert-list" role="list">
|
||||||
|
<li
|
||||||
|
v-for="alert in store.alerts"
|
||||||
|
:key="alert.id"
|
||||||
|
class="alert-card"
|
||||||
|
>
|
||||||
|
<div class="alert-card__body">
|
||||||
|
<p class="alert-card__title">{{ alert.title }}</p>
|
||||||
|
<div class="alert-card__meta">
|
||||||
|
<span class="alert-card__price">${{ alert.price.toFixed(2) }}</span>
|
||||||
|
<span class="alert-card__score" :class="scoreClass(alert.trust_score)">
|
||||||
|
Trust {{ alert.trust_score }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert-card__actions">
|
||||||
|
<a
|
||||||
|
v-if="alert.url"
|
||||||
|
:href="alert.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="alert-card__view"
|
||||||
|
:aria-label="`View listing: ${alert.title}`"
|
||||||
|
>
|
||||||
|
View on eBay
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
class="alert-card__dismiss"
|
||||||
|
:aria-label="`Dismiss alert: ${alert.title}`"
|
||||||
|
@click="onDismiss(alert.id)"
|
||||||
|
@keydown.delete.prevent="onDismiss(alert.id)"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { BellIcon } from '@heroicons/vue/24/outline'
|
||||||
|
import { useAlertsStore } from '../stores/alerts'
|
||||||
|
|
||||||
|
const store = useAlertsStore()
|
||||||
|
const panelOpen = ref(false)
|
||||||
|
const bellRef = ref<HTMLButtonElement | null>(null)
|
||||||
|
const wrapRef = ref<HTMLDivElement | null>(null)
|
||||||
|
const liveAnnouncement = ref('')
|
||||||
|
|
||||||
|
const unreadCount = computed(() => store.unreadCount)
|
||||||
|
|
||||||
|
// Announce count changes to screen readers via the polite live region.
|
||||||
|
watch(unreadCount, (count, prev) => {
|
||||||
|
if (count > prev) {
|
||||||
|
liveAnnouncement.value = `${count} new watch alert${count === 1 ? '' : 's'}`
|
||||||
|
// Reset after announcement so repeat counts still fire.
|
||||||
|
setTimeout(() => { liveAnnouncement.value = '' }, 1500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function togglePanel() {
|
||||||
|
panelOpen.value = !panelOpen.value
|
||||||
|
if (panelOpen.value) store.fetchAlerts()
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
panelOpen.value = false
|
||||||
|
bellRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDismiss(id: number) {
|
||||||
|
await store.dismiss(id)
|
||||||
|
if (store.alerts.length === 0) {
|
||||||
|
// Return focus to bell when last alert is dismissed.
|
||||||
|
panelOpen.value = false
|
||||||
|
bellRef.value?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDismissAll() {
|
||||||
|
await store.dismissAll()
|
||||||
|
panelOpen.value = false
|
||||||
|
bellRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreClass(score: number) {
|
||||||
|
if (score >= 75) return 'score--high'
|
||||||
|
if (score >= 50) return 'score--medium'
|
||||||
|
return 'score--low'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close on outside click.
|
||||||
|
function handleOutsideClick(e: MouseEvent) {
|
||||||
|
if (wrapRef.value && !wrapRef.value.contains(e.target as Node)) {
|
||||||
|
panelOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.fetchAlerts()
|
||||||
|
// Poll for new alerts every 2 minutes while the app is open.
|
||||||
|
const interval = setInterval(() => store.fetchAlerts(), 120_000)
|
||||||
|
document.addEventListener('click', handleOutsideClick)
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(interval)
|
||||||
|
document.removeEventListener('click', handleOutsideClick)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-bell-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-bell {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 150ms ease, color 150ms ease, background 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-bell:hover,
|
||||||
|
.alert-bell--active {
|
||||||
|
border-color: var(--app-primary);
|
||||||
|
color: var(--app-primary);
|
||||||
|
background: var(--app-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-bell__icon {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 4px;
|
||||||
|
background: var(--color-error, #ef4444);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
border-radius: 9px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel */
|
||||||
|
.alert-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
width: min(360px, 92vw);
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 200;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-panel__title {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-panel__clear {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: color 150ms ease;
|
||||||
|
}
|
||||||
|
.alert-panel__clear:hover { color: var(--color-error); }
|
||||||
|
|
||||||
|
.alert-panel__close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-1);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 150ms ease;
|
||||||
|
min-width: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
.alert-panel__close:hover { color: var(--color-error); }
|
||||||
|
|
||||||
|
.alert-panel__state {
|
||||||
|
padding: var(--space-8) var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert list */
|
||||||
|
.alert-list {
|
||||||
|
list-style: none;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
transition: background 150ms ease;
|
||||||
|
}
|
||||||
|
.alert-card:hover { background: var(--color-surface); }
|
||||||
|
.alert-card:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.alert-card__body { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.alert-card__title {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0 0 var(--space-1);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-card__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-card__price {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--app-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-card__score {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.score--high { background: rgba(34,197,94,0.15); color: #22c55e; }
|
||||||
|
.score--medium { background: rgba(234,179,8,0.15); color: #eab308; }
|
||||||
|
.score--low { background: rgba(239,68,68,0.15); color: #ef4444; }
|
||||||
|
|
||||||
|
.alert-card__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-card__view {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--app-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background 150ms ease;
|
||||||
|
}
|
||||||
|
.alert-card__view:hover { background: var(--app-primary-light); }
|
||||||
|
|
||||||
|
.alert-card__dismiss {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.625rem;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
transition: border-color 150ms ease, color 150ms ease;
|
||||||
|
}
|
||||||
|
.alert-card__dismiss:hover { border-color: var(--color-error); color: var(--color-error); }
|
||||||
|
|
||||||
|
/* Transition */
|
||||||
|
.panel-enter-active,
|
||||||
|
.panel-leave-active { transition: opacity 120ms ease, transform 120ms ease; }
|
||||||
|
.panel-enter-from,
|
||||||
|
.panel-leave-to { opacity: 0; transform: translateY(-6px); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.panel-enter-active,
|
||||||
|
.panel-leave-active { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<!-- Desktop: persistent sidebar (≥1024px) -->
|
<!-- Desktop: persistent sidebar (≥1024px) -->
|
||||||
<!-- Mobile: bottom tab bar (<1024px) -->
|
<!-- Mobile: bottom tab bar (<1024px) -->
|
||||||
<nav class="app-sidebar" role="navigation" aria-label="Main navigation">
|
<nav class="app-sidebar" role="navigation" aria-label="Sidebar">
|
||||||
<!-- Brand -->
|
<!-- Brand -->
|
||||||
<div class="sidebar__brand">
|
<div class="sidebar__brand">
|
||||||
<RouterLink to="/" class="sidebar__logo">
|
<RouterLink to="/" class="sidebar__logo">
|
||||||
|
|
@ -32,17 +32,20 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings at bottom -->
|
<!-- Settings + alert bell at bottom -->
|
||||||
<div class="sidebar__footer">
|
<div class="sidebar__footer">
|
||||||
<RouterLink to="/settings" class="sidebar__link sidebar__link--footer" active-class="sidebar__link--active">
|
<div class="sidebar__footer-row">
|
||||||
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
|
<RouterLink to="/settings" class="sidebar__link sidebar__link--footer" active-class="sidebar__link--active">
|
||||||
<span class="sidebar__label">Settings</span>
|
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
|
||||||
</RouterLink>
|
<span class="sidebar__label">Settings</span>
|
||||||
|
</RouterLink>
|
||||||
|
<AlertBell v-if="session.isLoggedIn || session.tier === 'local'" class="sidebar__bell" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Mobile bottom tab bar -->
|
<!-- Mobile bottom tab bar -->
|
||||||
<nav class="app-tabbar" role="navigation" aria-label="Main navigation">
|
<nav class="app-tabbar" role="navigation" aria-label="Tab bar">
|
||||||
<ul class="tabbar__links" role="list">
|
<ul class="tabbar__links" role="list">
|
||||||
<li v-for="link in mobileLinks" :key="link.to">
|
<li v-for="link in mobileLinks" :key="link.to">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
|
|
@ -69,8 +72,11 @@ import {
|
||||||
ShieldExclamationIcon,
|
ShieldExclamationIcon,
|
||||||
} from '@heroicons/vue/24/outline'
|
} from '@heroicons/vue/24/outline'
|
||||||
import { useSnipeMode } from '../composables/useSnipeMode'
|
import { useSnipeMode } from '../composables/useSnipeMode'
|
||||||
|
import { useSessionStore } from '../stores/session'
|
||||||
|
import AlertBell from './AlertBell.vue'
|
||||||
|
|
||||||
const { active: isSnipeMode, deactivate } = useSnipeMode()
|
const { active: isSnipeMode, deactivate } = useSnipeMode()
|
||||||
|
const session = useSessionStore()
|
||||||
|
|
||||||
const navLinks = computed(() => [
|
const navLinks = computed(() => [
|
||||||
{ to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
|
{ to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
|
||||||
|
|
@ -81,7 +87,7 @@ const navLinks = computed(() => [
|
||||||
const mobileLinks = [
|
const mobileLinks = [
|
||||||
{ to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
|
{ to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
|
||||||
{ to: '/saved', icon: BookmarkIcon, label: 'Saved' },
|
{ to: '/saved', icon: BookmarkIcon, label: 'Saved' },
|
||||||
{ to: '/blocklist', icon: ShieldExclamationIcon, label: 'Block' },
|
{ to: '/blocklist', icon: ShieldExclamationIcon, label: 'Blocklist' },
|
||||||
{ to: '/settings', icon: Cog6ToothIcon, label: 'Settings' },
|
{ to: '/settings', icon: Cog6ToothIcon, label: 'Settings' },
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -202,6 +208,20 @@ const mobileLinks = [
|
||||||
border-top: 1px solid var(--color-border-light);
|
border-top: 1px solid var(--color-border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar__footer-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__footer-row .sidebar__link {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__bell {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Mobile tab bar (<1024px) ───────────────────────── */
|
/* ── Mobile tab bar (<1024px) ───────────────────────── */
|
||||||
.app-tabbar {
|
.app-tabbar {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
|
||||||
63
web/src/stores/alerts.ts
Normal file
63
web/src/stores/alerts.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export interface WatchAlert {
|
||||||
|
id: number
|
||||||
|
saved_search_id: number
|
||||||
|
platform_listing_id: string
|
||||||
|
title: string
|
||||||
|
price: number
|
||||||
|
currency: string
|
||||||
|
trust_score: number
|
||||||
|
url: string | null
|
||||||
|
first_alerted_at: string
|
||||||
|
dismissed_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = import.meta.env.VITE_API_BASE ?? ''
|
||||||
|
|
||||||
|
export const useAlertsStore = defineStore('alerts', () => {
|
||||||
|
const alerts = ref<WatchAlert[]>([])
|
||||||
|
const unreadCount = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function fetchAlerts(includeDismissed = false) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${BASE}/api/alerts${includeDismissed ? '?include_dismissed=true' : ''}`,
|
||||||
|
{ credentials: 'include' },
|
||||||
|
)
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
alerts.value = data.alerts
|
||||||
|
unreadCount.value = data.unread_count
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load alerts'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismiss(alertId: number) {
|
||||||
|
await fetch(`${BASE}/api/alerts/${alertId}/dismiss`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
alerts.value = alerts.value.filter((a) => a.id !== alertId)
|
||||||
|
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissAll() {
|
||||||
|
await fetch(`${BASE}/api/alerts/dismiss-all`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
alerts.value = []
|
||||||
|
unreadCount.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return { alerts, unreadCount, loading, error, fetchAlerts, dismiss, dismissAll }
|
||||||
|
})
|
||||||
|
|
@ -31,41 +31,112 @@
|
||||||
<span v-if="item.last_run_at">Last run {{ formatDate(item.last_run_at) }}</span>
|
<span v-if="item.last_run_at">Last run {{ formatDate(item.last_run_at) }}</span>
|
||||||
<span v-else>Never run</span>
|
<span v-else>Never run</span>
|
||||||
· Saved {{ formatDate(item.created_at) }}
|
· Saved {{ formatDate(item.created_at) }}
|
||||||
|
<span v-if="item.last_checked_at" class="saved-card-checked">
|
||||||
|
· Monitored {{ formatDate(item.last_checked_at) }}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="saved-card-actions">
|
|
||||||
<button class="saved-run-btn" type="button" @click="onRun(item)">
|
<div class="saved-card-right">
|
||||||
Run
|
<!-- Monitor toggle — only shown to paid+ users -->
|
||||||
</button>
|
<div v-if="session.isPaid || session.tier === 'local'" class="monitor-section">
|
||||||
<button
|
<label class="monitor-toggle-label">
|
||||||
class="saved-delete-btn"
|
<input
|
||||||
type="button"
|
type="checkbox"
|
||||||
:aria-label="`Delete saved search: ${item.name}`"
|
class="monitor-toggle-input"
|
||||||
@click="onDelete(item.id)"
|
:checked="item.monitor_enabled"
|
||||||
>
|
:aria-label="`Monitor ${item.name}`"
|
||||||
✕
|
@change="onToggleMonitor(item, ($event.target as HTMLInputElement).checked)"
|
||||||
</button>
|
/>
|
||||||
|
<span class="monitor-toggle-track" aria-hidden="true" />
|
||||||
|
<span class="monitor-toggle-text">Monitor</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Inline settings — only when enabled -->
|
||||||
|
<Transition name="slide">
|
||||||
|
<div v-if="item.monitor_enabled" class="monitor-settings">
|
||||||
|
<label class="monitor-setting-label">
|
||||||
|
Check every
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="monitor-setting-input"
|
||||||
|
:value="item.poll_interval_min"
|
||||||
|
min="15"
|
||||||
|
max="1440"
|
||||||
|
step="15"
|
||||||
|
:aria-label="`Poll interval for ${item.name} in minutes`"
|
||||||
|
@change="onIntervalChange(item, ($event.target as HTMLInputElement).valueAsNumber)"
|
||||||
|
/>
|
||||||
|
min
|
||||||
|
<span class="monitor-hint">Min 15. 60 = hourly.</span>
|
||||||
|
</label>
|
||||||
|
<label class="monitor-setting-label">
|
||||||
|
Trust ≥
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="monitor-setting-input"
|
||||||
|
:value="item.min_trust_score"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
:aria-label="`Minimum trust score for ${item.name}`"
|
||||||
|
@change="onThresholdChange(item, ($event.target as HTMLInputElement).valueAsNumber)"
|
||||||
|
/>
|
||||||
|
<span class="monitor-hint">0–100. 60 = medium confidence.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="saved-card-actions">
|
||||||
|
<button class="saved-run-btn" type="button" @click="onRun(item)">
|
||||||
|
Run
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="saved-delete-btn"
|
||||||
|
type="button"
|
||||||
|
:aria-label="`Delete saved search: ${item.name}`"
|
||||||
|
@click="onDelete(item)"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<!-- Undo toast for delete -->
|
||||||
|
<Transition name="toast">
|
||||||
|
<div v-if="pendingDelete" class="undo-toast" role="status" aria-live="polite">
|
||||||
|
<span>Deleted "{{ pendingDelete.name }}"</span>
|
||||||
|
<button class="undo-btn" @click="onUndoDelete">Undo</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useRouter, RouterLink } from 'vue-router'
|
import { useRouter, RouterLink } from 'vue-router'
|
||||||
import { useSavedSearchesStore } from '../stores/savedSearches'
|
import { useSavedSearchesStore } from '../stores/savedSearches'
|
||||||
|
import { useSessionStore } from '../stores/session'
|
||||||
import type { SavedSearch } from '../stores/savedSearches'
|
import type { SavedSearch } from '../stores/savedSearches'
|
||||||
|
|
||||||
const store = useSavedSearchesStore()
|
const store = useSavedSearchesStore()
|
||||||
|
const session = useSessionStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const BASE = import.meta.env.VITE_API_BASE ?? ''
|
||||||
|
|
||||||
|
// Soft-delete state — holds for 3 seconds before committing
|
||||||
|
const pendingDelete = ref<SavedSearch | null>(null)
|
||||||
|
let deleteTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
onMounted(() => store.fetchAll())
|
onMounted(() => store.fetchAll())
|
||||||
|
|
||||||
function formatDate(iso: string | null): string {
|
function formatDate(iso: string | null): string {
|
||||||
if (!iso) return '—'
|
if (!iso) return '—'
|
||||||
const d = new Date(iso)
|
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onRun(item: SavedSearch) {
|
async function onRun(item: SavedSearch) {
|
||||||
|
|
@ -75,8 +146,65 @@ async function onRun(item: SavedSearch) {
|
||||||
router.push({ path: '/', query })
|
router.push({ path: '/', query })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onDelete(id: number) {
|
function onDelete(item: SavedSearch) {
|
||||||
await store.remove(id)
|
// Soft-delete: show undo toast, commit after 3s.
|
||||||
|
if (deleteTimer) clearTimeout(deleteTimer)
|
||||||
|
pendingDelete.value = item
|
||||||
|
deleteTimer = setTimeout(async () => {
|
||||||
|
if (pendingDelete.value?.id === item.id) {
|
||||||
|
await store.remove(item.id)
|
||||||
|
pendingDelete.value = null
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUndoDelete() {
|
||||||
|
if (deleteTimer) clearTimeout(deleteTimer)
|
||||||
|
pendingDelete.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggleMonitor(item: SavedSearch, enabled: boolean) {
|
||||||
|
await fetch(`${BASE}/api/saved-searches/${item.id}/monitor`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
monitor_enabled: enabled,
|
||||||
|
poll_interval_min: item.poll_interval_min,
|
||||||
|
min_trust_score: item.min_trust_score,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
await store.fetchAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onIntervalChange(item: SavedSearch, minutes: number) {
|
||||||
|
if (isNaN(minutes) || minutes < 15) return
|
||||||
|
await fetch(`${BASE}/api/saved-searches/${item.id}/monitor`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
monitor_enabled: item.monitor_enabled,
|
||||||
|
poll_interval_min: minutes,
|
||||||
|
min_trust_score: item.min_trust_score,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
await store.fetchAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onThresholdChange(item: SavedSearch, score: number) {
|
||||||
|
if (isNaN(score)) return
|
||||||
|
await fetch(`${BASE}/api/saved-searches/${item.id}/monitor`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
monitor_enabled: item.monitor_enabled,
|
||||||
|
poll_interval_min: item.poll_interval_min,
|
||||||
|
min_trust_score: score,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
await store.fetchAll()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -127,12 +255,12 @@ async function onDelete(id: number) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
max-width: 720px;
|
max-width: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saved-card {
|
.saved-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
padding: var(--space-4) var(--space-5);
|
padding: var(--space-4) var(--space-5);
|
||||||
background: var(--color-surface-2);
|
background: var(--color-surface-2);
|
||||||
|
|
@ -174,13 +302,131 @@ async function onDelete(id: number) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.saved-card-checked {
|
||||||
|
color: var(--app-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right column: monitor section + action buttons */
|
||||||
|
.saved-card-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.saved-card-actions {
|
.saved-card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monitor toggle */
|
||||||
|
.monitor-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-toggle-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visually hide the native checkbox but keep it accessible */
|
||||||
|
.monitor-toggle-input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-toggle-track {
|
||||||
|
display: inline-block;
|
||||||
|
width: 32px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--color-border);
|
||||||
|
position: relative;
|
||||||
|
transition: background 150ms ease;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.monitor-toggle-track::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
transition: transform 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-toggle-input:checked + .monitor-toggle-track {
|
||||||
|
background: var(--app-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-toggle-input:checked + .monitor-toggle-track::after {
|
||||||
|
transform: translateX(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus ring on the label when the hidden checkbox is focused */
|
||||||
|
.monitor-toggle-label:has(.monitor-toggle-input:focus-visible) .monitor-toggle-track {
|
||||||
|
outline: 2px solid var(--app-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-toggle-text {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline monitor settings */
|
||||||
|
.monitor-settings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-setting-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-setting-input {
|
||||||
|
width: 60px;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-hint {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
.saved-run-btn {
|
.saved-run-btn {
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
background: var(--app-primary);
|
background: var(--app-primary);
|
||||||
|
|
@ -206,13 +452,65 @@ async function onDelete(id: number) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 150ms ease, color 150ms ease;
|
transition: border-color 150ms ease, color 150ms ease;
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
|
min-height: 28px;
|
||||||
}
|
}
|
||||||
.saved-delete-btn:hover { border-color: var(--color-error); color: var(--color-error); }
|
.saved-delete-btn:hover { border-color: var(--color-error); color: var(--color-error); }
|
||||||
|
|
||||||
|
/* Undo toast */
|
||||||
|
.undo-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(var(--space-6) + env(safe-area-inset-bottom));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
z-index: 300;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.undo-btn {
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
background: var(--app-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
.slide-enter-active,
|
||||||
|
.slide-leave-active { transition: opacity 150ms ease, max-height 200ms ease; max-height: 200px; overflow: hidden; }
|
||||||
|
.slide-enter-from,
|
||||||
|
.slide-leave-to { opacity: 0; max-height: 0; }
|
||||||
|
|
||||||
|
.toast-enter-active,
|
||||||
|
.toast-leave-active { transition: opacity 200ms ease, transform 200ms ease; }
|
||||||
|
.toast-enter-from,
|
||||||
|
.toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.slide-enter-active, .slide-leave-active,
|
||||||
|
.toast-enter-active, .toast-leave-active { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.saved-header { padding: var(--space-4); }
|
.saved-header { padding: var(--space-4); }
|
||||||
.saved-list { padding: var(--space-4); }
|
.saved-list { padding: var(--space-4); }
|
||||||
.saved-card { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
|
.saved-card { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
|
||||||
|
.saved-card-right { width: 100%; align-items: flex-start; }
|
||||||
.saved-card-actions { width: 100%; justify-content: flex-end; }
|
.saved-card-actions { width: 100%; justify-content: flex-end; }
|
||||||
|
.monitor-section { width: 100%; align-items: flex-start; }
|
||||||
|
.monitor-settings { width: 100%; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,74 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- eBay Account Connection — paid+ only -->
|
||||||
|
<section v-if="ebay.oauth_available && session.isLoggedIn" class="settings-section">
|
||||||
|
<h2 class="settings-section-title">eBay Account</h2>
|
||||||
|
|
||||||
|
<!-- Connected state -->
|
||||||
|
<div v-if="ebay.connected" class="ebay-connected">
|
||||||
|
<div class="ebay-status-row">
|
||||||
|
<span class="ebay-status-dot ebay-status-dot--on" aria-hidden="true" />
|
||||||
|
<span class="settings-toggle-label">Connected</span>
|
||||||
|
</div>
|
||||||
|
<p class="settings-toggle-desc">
|
||||||
|
Snipe uses your eBay account to fetch seller registration dates instantly
|
||||||
|
via the Trading API, without Playwright scraping. This means faster, more
|
||||||
|
accurate trust scores on every search.
|
||||||
|
<span v-if="ebay.access_token_expired" class="ebay-warn">
|
||||||
|
Your access token has expired — reconnect to restore instant enrichment.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div class="ebay-action-row">
|
||||||
|
<button
|
||||||
|
v-if="ebay.access_token_expired"
|
||||||
|
class="ebay-btn ebay-btn--primary"
|
||||||
|
:disabled="ebay.connecting"
|
||||||
|
@click="startConnect"
|
||||||
|
>
|
||||||
|
Reconnect eBay account
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="ebay-btn ebay-btn--danger"
|
||||||
|
:disabled="ebay.disconnecting"
|
||||||
|
@click="disconnect"
|
||||||
|
>
|
||||||
|
{{ ebay.disconnecting ? 'Disconnecting…' : 'Disconnect' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Not connected — paid tier -->
|
||||||
|
<div v-else-if="session.isPaid || session.isPremium" class="ebay-disconnected">
|
||||||
|
<p class="settings-toggle-desc">
|
||||||
|
Connect your eBay account to enable instant seller registration date lookup
|
||||||
|
via the Trading API. Without it, Snipe falls back to slower Playwright
|
||||||
|
scraping (or Shopping API rate-limited calls) to determine account age.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="ebay-btn ebay-btn--primary"
|
||||||
|
:disabled="ebay.connecting"
|
||||||
|
@click="startConnect"
|
||||||
|
>
|
||||||
|
{{ ebay.connecting ? 'Redirecting to eBay…' : 'Connect eBay account' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Not connected — free tier upsell -->
|
||||||
|
<div v-else class="ebay-disconnected">
|
||||||
|
<p class="settings-toggle-desc">
|
||||||
|
Connect your eBay account for instant seller trust scoring without scraping.
|
||||||
|
Available on Paid tier and above.
|
||||||
|
</p>
|
||||||
|
<a class="ebay-btn ebay-btn--upsell" href="/pricing" rel="noopener">
|
||||||
|
Upgrade to Paid
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="ebay.error" class="settings-error" role="alert">{{ ebay.error }}</p>
|
||||||
|
<p v-if="ebay.success" class="settings-success" role="status">{{ ebay.success }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Affiliate Links — only shown to signed-in cloud users -->
|
<!-- Affiliate Links — only shown to signed-in cloud users -->
|
||||||
<section v-if="session.isLoggedIn" class="settings-section">
|
<section v-if="session.isLoggedIn" class="settings-section">
|
||||||
<h2 class="settings-section-title">Affiliate Links</h2>
|
<h2 class="settings-section-title">Affiliate Links</h2>
|
||||||
|
|
@ -174,13 +242,16 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, reactive, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useTrustSignalPref } from '../composables/useTrustSignalPref'
|
import { useTrustSignalPref } from '../composables/useTrustSignalPref'
|
||||||
import { useTheme } from '../composables/useTheme'
|
import { useTheme } from '../composables/useTheme'
|
||||||
import { useSessionStore } from '../stores/session'
|
import { useSessionStore } from '../stores/session'
|
||||||
import { usePreferencesStore } from '../stores/preferences'
|
import { usePreferencesStore } from '../stores/preferences'
|
||||||
import { useLLMQueryBuilder } from '../composables/useLLMQueryBuilder'
|
import { useLLMQueryBuilder } from '../composables/useLLMQueryBuilder'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const { enabled: trustSignalEnabled, setEnabled } = useTrustSignalPref()
|
const { enabled: trustSignalEnabled, setEnabled } = useTrustSignalPref()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const themeOptions: { value: 'system' | 'dark' | 'light'; label: string }[] = [
|
const themeOptions: { value: 'system' | 'dark' | 'light'; label: string }[] = [
|
||||||
|
|
@ -212,6 +283,90 @@ watch(() => prefs.affiliateByokId, (val) => { byokInput.value = val })
|
||||||
function saveByokId() {
|
function saveByokId() {
|
||||||
prefs.setAffiliateByokId(byokInput.value)
|
prefs.setAffiliateByokId(byokInput.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── eBay Account Connection ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ebay = reactive({
|
||||||
|
oauth_available: false,
|
||||||
|
connected: false,
|
||||||
|
access_token_expired: false,
|
||||||
|
scopes: [] as string[],
|
||||||
|
connecting: false,
|
||||||
|
disconnecting: false,
|
||||||
|
error: '',
|
||||||
|
success: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchEbayStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ebay/status')
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
ebay.oauth_available = data.oauth_available ?? false
|
||||||
|
ebay.connected = data.connected ?? false
|
||||||
|
ebay.access_token_expired = data.access_token_expired ?? false
|
||||||
|
ebay.scopes = data.scopes ?? []
|
||||||
|
} catch {
|
||||||
|
// silently ignore — section stays hidden if fetch fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startConnect() {
|
||||||
|
ebay.connecting = true
|
||||||
|
ebay.error = ''
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ebay/connect')
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
ebay.error = body.detail ?? 'eBay connection unavailable.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { auth_url } = await res.json()
|
||||||
|
window.location.href = auth_url
|
||||||
|
} catch {
|
||||||
|
ebay.error = 'Could not reach the server. Try again.'
|
||||||
|
ebay.connecting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnect() {
|
||||||
|
ebay.disconnecting = true
|
||||||
|
ebay.error = ''
|
||||||
|
ebay.success = ''
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ebay/disconnect', { method: 'DELETE' })
|
||||||
|
if (res.ok || res.status === 204) {
|
||||||
|
ebay.connected = false
|
||||||
|
ebay.access_token_expired = false
|
||||||
|
ebay.scopes = []
|
||||||
|
ebay.success = 'eBay account disconnected.'
|
||||||
|
} else {
|
||||||
|
ebay.error = 'Disconnect failed. Try again.'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ebay.error = 'Could not reach the server. Try again.'
|
||||||
|
} finally {
|
||||||
|
ebay.disconnecting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchEbayStatus()
|
||||||
|
|
||||||
|
// Handle OAuth callback redirect params: ?ebay_connected=1 or ?ebay_error=access_denied
|
||||||
|
const connected = route.query.ebay_connected
|
||||||
|
const oauthError = route.query.ebay_error
|
||||||
|
if (connected) {
|
||||||
|
ebay.success = 'eBay account connected! Trust scores will now use the Trading API.'
|
||||||
|
await fetchEbayStatus()
|
||||||
|
router.replace({ query: { ...route.query, ebay_connected: undefined } })
|
||||||
|
} else if (oauthError) {
|
||||||
|
ebay.error = oauthError === 'access_denied'
|
||||||
|
? 'eBay authorization was cancelled.'
|
||||||
|
: `eBay OAuth error: ${oauthError}`
|
||||||
|
router.replace({ query: { ...route.query, ebay_error: undefined } })
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -373,7 +528,7 @@ function saveByokId() {
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Error feedback ---- */
|
/* ---- Error / success feedback ---- */
|
||||||
.settings-error {
|
.settings-error {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
color: var(--color-danger, #f85149);
|
color: var(--color-danger, #f85149);
|
||||||
|
|
@ -398,6 +553,100 @@ function saveByokId() {
|
||||||
border-color: var(--app-primary);
|
border-color: var(--app-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-success {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-success, #3fb950);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- eBay Account section ---- */
|
||||||
|
.ebay-status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ebay-status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ebay-status-dot--on {
|
||||||
|
background: var(--color-success, #3fb950);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ebay-connected,
|
||||||
|
.ebay-disconnected {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ebay-warn {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
color: var(--color-warning, #d29922);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ebay-action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ebay-btn {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ebay-btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ebay-btn--primary {
|
||||||
|
background: var(--app-primary);
|
||||||
|
color: var(--color-text-inverse, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ebay-btn--primary:hover:not(:disabled) { opacity: 0.85; }
|
||||||
|
|
||||||
|
.ebay-btn--danger {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-danger, #f85149);
|
||||||
|
border: 1px solid var(--color-danger, #f85149);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ebay-btn--danger:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--color-danger, #f85149) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ebay-btn--upsell {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ebay-btn--upsell:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
.ebay-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--app-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.theme-btn-group {
|
.theme-btn-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue