feat: snipe beta backlog batch (tickets #22/#28/#30/#34/#35/#36/#37/#38)

Cloud/session:
- fix(_extract_session_token): return "" for non-JWT cookie strings (snipe_guest=uuid was
  triggering 401 → forced login redirect for all unauthenticated cloud visitors)
- fix(affiliate): exclude guest: and anonymous users from pref-store writes (#38)
- fix(market-comp): use enriched comp_query for market comp hash so write/read keys match (#30)

Frontend:
- feat(SearchView): unauthenticated landing strip with free-account CTA (#36)
- feat(SearchView): aria-pressed on filter toggles, aria-label on icon buttons, focus-visible
  rings on all interactive controls, live region for result count (#35)
- feat(SearchView): no-results empty-state hint text (#36)
- feat(SEO): og:image 1200x630, summary_large_image twitter card, canonical link (#37)
- feat(OG): generated og-image.png (dark tactical theme, feature pills) (#37)
- feat(settings): TrustSignalPref view wired to /settings route (#28)
- fix(router): /settings route added; unauthenticated access redirects to home (#34)

CI/CD:
- feat(ci): Forgejo Actions workflow (ruff + pytest + vue-tsc + vitest) (#22)
- feat(ci): mirror workflow (GitHub + Codeberg on push to main/tags) (#22)
- feat(ci): release workflow (Docker build+push + git-cliff changelog) (#22)
- chore: git-cliff config (.cliff.toml) for conventional commit changelog (#22)
- chore(pyproject): dev extras (pytest/ruff/httpx), ruff config with ignore list (#22)

Lint:
- fix: remove 11 unused imports across api/, app/, tests/ (ruff F401 clean)
This commit is contained in:
pyr0ball 2026-04-13 19:32:50 -07:00
parent aff5bdda39
commit fb81422c54
27 changed files with 1293 additions and 73 deletions

28
.cliff.toml Normal file
View file

@ -0,0 +1,28 @@
[changelog]
header = ""
body = """
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/commit/{{ commit.id }}))
{%- endfor %}
{% endfor %}
"""
trim = true
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactoring" },
{ message = "^docs", group = "Documentation" },
{ message = "^test", group = "Testing" },
{ message = "^chore", skip = true },
{ message = "^ci", skip = true },
]
filter_commits = false
tag_pattern = "v[0-9].*"

57
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,57 @@
name: CI
on:
push:
branches: [main, 'feature/**', 'fix/**']
pull_request:
branches: [main]
jobs:
python:
name: Python tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
# circuitforge-core is a sibling on dev machines but a public GitHub
# mirror in CI — install from there to avoid path-dependency issues.
- name: Install circuitforge-core
run: pip install --no-cache-dir git+https://github.com/CircuitForgeLLC/circuitforge-core.git
- name: Install snipe (dev extras)
run: pip install --no-cache-dir -e ".[dev]"
- name: Lint
run: ruff check .
- name: Test
run: pytest tests/ -v --tb=short
frontend:
name: Frontend typecheck + tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Typecheck + build
run: npm run build
- name: Unit tests
run: npm run test

View file

@ -0,0 +1,30 @@
name: Mirror
on:
push:
branches: [main]
tags: ['v*']
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Mirror to GitHub
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_MIRROR_TOKEN }}
REPO: ${{ github.event.repository.name }}
run: |
git remote add github "https://x-access-token:${GITHUB_TOKEN}@github.com/CircuitForgeLLC/${REPO}.git"
git push github --mirror
- name: Mirror to Codeberg
env:
CODEBERG_TOKEN: ${{ secrets.CODEBERG_MIRROR_TOKEN }}
REPO: ${{ github.event.repository.name }}
run: |
git remote add codeberg "https://CircuitForge:${CODEBERG_TOKEN}@codeberg.org/CircuitForge/${REPO}.git"
git push codeberg --mirror

View file

@ -0,0 +1,92 @@
name: Release
on:
push:
tags: ['v*']
env:
# Forgejo container registry (BSL product — not pushing to public GHCR)
# cf-agents#3: revisit public registry policy before enabling GHCR push
REGISTRY: git.opensourcesolarpunk.com
IMAGE_API: git.opensourcesolarpunk.com/circuit-forge/snipe-api
IMAGE_WEB: git.opensourcesolarpunk.com/circuit-forge/snipe-web
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# ── Changelog ────────────────────────────────────────────────────────────
- name: Generate changelog
uses: orhun/git-cliff-action@v3
id: cliff
with:
config: .cliff.toml
args: --latest --strip header
env:
OUTPUT: CHANGES.md
# ── Docker ───────────────────────────────────────────────────────────────
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Forgejo registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
# API image — built with circuitforge-core sibling from GitHub mirror
- name: Checkout circuitforge-core
uses: actions/checkout@v4
with:
repository: CircuitForgeLLC/circuitforge-core
path: circuitforge-core
- name: Build and push API image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ env.IMAGE_API }}:${{ github.ref_name }}
${{ env.IMAGE_API }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push web image
uses: docker/build-push-action@v6
with:
context: .
file: docker/web/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ env.IMAGE_WEB }}:${{ github.ref_name }}
${{ env.IMAGE_WEB }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
# ── Forgejo Release ───────────────────────────────────────────────────────
- name: Create Forgejo release
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
REPO: ${{ github.event.repository.name }}
TAG: ${{ github.ref_name }}
NOTES: ${{ steps.cliff.outputs.content }}
run: |
curl -sS -X POST \
"https://git.opensourcesolarpunk.com/api/v1/repos/Circuit-Forge/${REPO}/releases" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg tag "$TAG" --arg body "$NOTES" \
'{tag_name: $tag, name: $tag, body: $body}')"

View file

@ -16,8 +16,6 @@ FastAPI usage:
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import re
@ -77,7 +75,6 @@ def compute_features(tier: str) -> SessionFeatures:
"""Compute feature flags from tier. Evaluated server-side; sent to frontend."""
local = tier == "local"
paid_plus = local or tier in ("paid", "premium", "ultra")
premium_plus = local or tier in ("premium", "ultra")
return SessionFeatures(
saved_searches=True, # all tiers get saved searches
@ -94,10 +91,28 @@ def compute_features(tier: str) -> SessionFeatures:
# ── JWT validation ────────────────────────────────────────────────────────────
def _extract_session_token(header_value: str) -> str:
"""Extract cf_session value from a Cookie or X-CF-Session header string."""
# X-CF-Session may be the raw JWT or the full cookie string
"""Extract cf_session value from a Cookie or X-CF-Session header string.
Returns the JWT token string, or "" if no valid session token is found.
Cookie strings like "snipe_guest=abc123" (no cf_session key) return ""
so the caller falls through to the guest/anonymous path rather than
passing a non-JWT string to validate_session_jwt().
"""
m = re.search(r'(?:^|;)\s*cf_session=([^;]+)', header_value)
return m.group(1).strip() if m else header_value.strip()
if m:
return m.group(1).strip()
# Only treat as a raw JWT if it has exactly three base64url segments (header.payload.sig).
# Cookie strings like "snipe_guest=abc123" must NOT be forwarded to JWT validation.
stripped = header_value.strip()
if re.match(r'^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_=]+$', stripped):
return stripped # bare JWT forwarded directly by Caddy
return "" # not a JWT and no cf_session cookie — treat as unauthenticated
def _extract_guest_token(cookie_header: str) -> str | None:
"""Extract snipe_guest UUID from the Cookie header, if present."""
m = re.search(r'(?:^|;)\s*snipe_guest=([^;]+)', cookie_header)
return m.group(1).strip() if m else None
def validate_session_jwt(token: str) -> str:
@ -178,6 +193,18 @@ def _user_db_path(user_id: str) -> Path:
return path
def _anon_db_path() -> Path:
"""Shared pool DB for unauthenticated visitors.
All anonymous searches write listing data here. Seller and market comp
data accumulates in shared_db as normal, growing the anti-scammer corpus
with every public search regardless of auth state.
"""
path = CLOUD_DATA_ROOT / "anonymous" / "snipe" / "user.db"
path.parent.mkdir(parents=True, exist_ok=True)
return path
# ── FastAPI dependency ────────────────────────────────────────────────────────
def get_session(request: Request) -> CloudUser:
@ -186,6 +213,8 @@ def get_session(request: Request) -> CloudUser:
Local mode: returns a fully-privileged "local" user pointing at SNIPE_DB.
Cloud mode: validates X-CF-Session JWT, provisions Heimdall license,
resolves tier, returns per-user DB paths.
Unauthenticated cloud visitors: returns a free-tier anonymous user so
search and scoring work without an account.
"""
if not CLOUD_MODE:
return CloudUser(
@ -195,16 +224,30 @@ def get_session(request: Request) -> CloudUser:
user_db=_LOCAL_SNIPE_DB,
)
raw_header = (
request.headers.get("x-cf-session", "")
or request.headers.get("cookie", "")
)
cookie_header = request.headers.get("cookie", "")
raw_header = request.headers.get("x-cf-session", "") or cookie_header
if not raw_header:
raise HTTPException(status_code=401, detail="Not authenticated")
# No session at all — check for a guest UUID cookie set by /api/session
guest_uuid = _extract_guest_token(cookie_header)
user_id = f"guest:{guest_uuid}" if guest_uuid else "anonymous"
return CloudUser(
user_id=user_id,
tier="free",
shared_db=_shared_db_path(),
user_db=_anon_db_path(),
)
token = _extract_session_token(raw_header)
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
guest_uuid = _extract_guest_token(cookie_header)
user_id = f"guest:{guest_uuid}" if guest_uuid else "anonymous"
return CloudUser(
user_id=user_id,
tier="free",
shared_db=_shared_db_path(),
user_db=_anon_db_path(),
)
user_id = validate_session_jwt(token)
_ensure_provisioned(user_id)

View file

@ -1,8 +1,11 @@
"""Snipe FastAPI — search endpoint wired to ScrapedEbayAdapter + TrustScorer."""
from __future__ import annotations
import asyncio
import csv
import dataclasses
import hashlib
import io
import json as _json
import logging
import os
@ -11,29 +14,27 @@ import uuid
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
import asyncio
import csv
import io
from fastapi import Depends, FastAPI, HTTPException, Request, UploadFile, File
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from fastapi.middleware.cors import CORSMiddleware
from circuitforge_core.config import load_env
from circuitforge_core.affiliates import wrap_url as _wrap_affiliate_url
from circuitforge_core.api import make_feedback_router as _make_feedback_router
from circuitforge_core.config import load_env
from fastapi import Depends, FastAPI, File, HTTPException, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from api.cloud_session import CloudUser, compute_features, get_session
from api.ebay_webhook import router as ebay_webhook_router
from app.db.models import SavedSearch as SavedSearchModel
from app.db.models import ScammerEntry
from app.db.store import Store
from app.db.models import SavedSearch as SavedSearchModel, ScammerEntry
from app.platforms import SearchFilters
from app.platforms.ebay.scraper import ScrapedEbayAdapter
from app.platforms.ebay.adapter import EbayAdapter
from app.platforms.ebay.auth import EbayTokenManager
from app.platforms.ebay.query_builder import expand_queries, parse_groups
from app.platforms.ebay.scraper import ScrapedEbayAdapter
from app.trust import TrustScorer
from api.cloud_session import CloudUser, compute_features, get_session
from api.ebay_webhook import router as ebay_webhook_router
load_env(Path(".env"))
log = logging.getLogger(__name__)
@ -50,8 +51,8 @@ async def _lifespan(app: FastAPI):
# Start vision/LLM background task scheduler.
# background_tasks queue lives in shared_db (cloud) or local_db (local)
# so the scheduler has a single stable DB path across all cloud users.
from api.cloud_session import _LOCAL_SNIPE_DB, CLOUD_MODE, _shared_db_path
from app.tasks.scheduler import get_scheduler, reset_scheduler
from api.cloud_session import CLOUD_MODE, _LOCAL_SNIPE_DB, _shared_db_path
sched_db = _shared_db_path() if CLOUD_MODE else _LOCAL_SNIPE_DB
get_scheduler(sched_db)
log.info("Snipe task scheduler started (db=%s)", sched_db)
@ -100,13 +101,33 @@ def health():
@app.get("/api/session")
def session_info(session: CloudUser = Depends(get_session)):
def session_info(response: Response, session: CloudUser = Depends(get_session)):
"""Return the current session tier and computed feature flags.
Used by the Vue frontend to gate UI features (pages slider cap,
saved search limits, shared DB badges, etc.) without hardcoding
tier logic client-side.
For anonymous visitors: issues a snipe_guest UUID cookie (24h TTL) so
the user gets a stable identity for the session without requiring an account.
"""
from api.cloud_session import CLOUD_MODE
if CLOUD_MODE and session.user_id == "anonymous":
guest_uuid = str(uuid.uuid4())
response.set_cookie(
key="snipe_guest",
value=guest_uuid,
max_age=86400,
samesite="lax",
httponly=False,
path="/snipe",
)
session = CloudUser(
user_id=f"guest:{guest_uuid}",
tier="free",
shared_db=session.shared_db,
user_db=session.user_db,
)
features = compute_features(session.tier)
return {
"user_id": session.user_id,
@ -245,9 +266,10 @@ def _enqueue_vision_tasks(
trust_scores table in cloud mode.
"""
import json as _json
from api.cloud_session import _LOCAL_SNIPE_DB, CLOUD_MODE, _shared_db_path
from app.tasks.runner import insert_task
from app.tasks.scheduler import get_scheduler
from api.cloud_session import CLOUD_MODE, _shared_db_path, _LOCAL_SNIPE_DB
sched_db = _shared_db_path() if CLOUD_MODE else _LOCAL_SNIPE_DB
sched = get_scheduler(sched_db)
@ -323,8 +345,8 @@ def _adapter_name(force: str = "auto") -> str:
@app.get("/api/search")
def search(
q: str = "",
max_price: float = 0,
min_price: float = 0,
max_price: Optional[float] = None,
min_price: Optional[float] = None,
pages: int = 1,
must_include: str = "", # raw filter string; client-side always applied
must_include_mode: str = "all", # "all" | "any" | "groups" — drives eBay expansion
@ -350,9 +372,22 @@ def search(
else:
ebay_queries = [q]
# Comp query: completed-sales lookup uses an enriched query so the market
# median reflects the same filtered universe the user is looking at.
# "all" mode → append must_include terms to eBay completed-sales query
# "groups" → use first expanded query (most specific variant)
# "any" / no filter → base query (can't enforce optional terms)
if must_include_mode == "groups" and len(ebay_queries) > 0:
comp_query = ebay_queries[0]
elif must_include_mode == "all" and must_include.strip():
extra = " ".join(_parse_terms(must_include))
comp_query = f"{q} {extra}".strip()
else:
comp_query = q
base_filters = SearchFilters(
max_price=max_price if max_price > 0 else None,
min_price=min_price if min_price > 0 else None,
max_price=max_price if max_price and max_price > 0 else None,
min_price=min_price if min_price and min_price > 0 else None,
pages=pages,
must_exclude=must_exclude_terms, # forwarded to eBay -term by the scraper
category_id=category_id.strip() or None,
@ -369,9 +404,9 @@ def search(
def _run_comps() -> None:
try:
_make_adapter(Store(shared_db), adapter).get_completed_sales(q, pages)
_make_adapter(Store(shared_db), adapter).get_completed_sales(comp_query, pages)
except Exception:
log.warning("comps: unhandled exception for %r", q, exc_info=True)
log.warning("comps: unhandled exception for %r", comp_query, exc_info=True)
try:
# Comps submitted first — guarantees an immediate worker slot even at max concurrency.
@ -426,7 +461,7 @@ def search(
_update_queues[session_id] = _queue.SimpleQueue()
_trigger_scraper_enrichment(
listings, shared_store, shared_db,
user_db=user_db, query=q, session_id=session_id,
user_db=user_db, query=comp_query, session_id=session_id,
)
scorer = TrustScorer(shared_store)
@ -440,7 +475,7 @@ def search(
if features.photo_analysis:
_enqueue_vision_tasks(listings, trust_scores_list, session)
query_hash = hashlib.md5(q.encode()).hexdigest()
query_hash = hashlib.md5(comp_query.encode()).hexdigest()
comp = shared_store.get_market_comp("ebay", query_hash)
market_price = comp.median_price if comp else None
@ -459,9 +494,22 @@ def search(
and shared_store.get_seller("ebay", listing.seller_platform_id)
}
# Build a preference reader for affiliate URL wrapping.
# Anonymous and guest users always use env-var mode: no opt-out or BYOK lookup.
_is_unauthed = session.user_id == "anonymous" or session.user_id.startswith("guest:")
_pref_store = None if _is_unauthed else user_store
def _get_pref(uid: Optional[str], path: str, default=None):
return _pref_store.get_user_preference(path, default=default) # type: ignore[union-attr]
def _serialize_listing(l: object) -> dict:
d = dataclasses.asdict(l)
d["url"] = _wrap_affiliate_url(d["url"], retailer="ebay")
d["url"] = _wrap_affiliate_url(
d["url"],
retailer="ebay",
user_id=None if _is_unauthed else session.user_id,
get_preference=_get_pref if _pref_store is not None else None,
)
return d
return {
@ -683,6 +731,19 @@ def mark_saved_search_run(saved_id: int, session: CloudUser = Depends(get_sessio
return {"ok": True}
# ── Community Trust Signals ───────────────────────────────────────────────────
# Signals live in shared_db so feedback aggregates across all users.
class CommunitySignal(BaseModel):
seller_id: str
confirmed: bool # True = "score looks right", False = "score is wrong"
@app.post("/api/community/signal", status_code=204)
def community_signal(body: CommunitySignal, session: CloudUser = Depends(get_session)):
Store(session.shared_db).save_community_signal(body.seller_id, body.confirmed)
# ── Scammer Blocklist ─────────────────────────────────────────────────────────
# Blocklist lives in shared_db: all users on a shared cloud instance see the
# same community blocklist. In local (single-user) mode shared_db == user_db.
@ -702,6 +763,11 @@ def list_blocklist(session: CloudUser = Depends(get_session)):
@app.post("/api/blocklist", status_code=201)
def add_to_blocklist(body: BlocklistAdd, session: CloudUser = Depends(get_session)):
if session.user_id in ("anonymous",) or session.user_id.startswith("guest:"):
raise HTTPException(
status_code=403,
detail="Sign in to report sellers to the community blocklist.",
)
store = Store(session.shared_db)
entry = store.add_to_blocklist(ScammerEntry(
platform=body.platform,
@ -742,6 +808,11 @@ async def import_blocklist(
session: CloudUser = Depends(get_session),
):
"""Import a CSV blocklist. Columns: platform_seller_id, username, reason (optional)."""
if session.user_id == "anonymous":
raise HTTPException(
status_code=403,
detail="Sign in to import a blocklist.",
)
content = await file.read()
try:
text = content.decode("utf-8-sig") # handle BOM from Excel exports
@ -775,3 +846,49 @@ async def import_blocklist(
return {"imported": imported, "errors": errors}
# ── User Preferences ──────────────────────────────────────────────────────────
class PreferenceUpdate(BaseModel):
path: str # dot-separated, e.g. "affiliate.opt_out" or "affiliate.byok_ids.ebay"
value: Optional[object] # bool, str, or None to clear
@app.get("/api/preferences")
def get_preferences(session: CloudUser = Depends(get_session)) -> dict:
"""Return all preferences for the authenticated user.
Anonymous users always receive an empty dict (no preferences to store).
"""
if session.user_id == "anonymous":
return {}
store = Store(session.user_db)
return store.get_all_preferences()
@app.patch("/api/preferences")
def patch_preference(
body: PreferenceUpdate,
session: CloudUser = Depends(get_session),
) -> dict:
"""Set a single preference at *path* to *value*.
- ``affiliate.opt_out`` available to all signed-in users.
- ``affiliate.byok_ids.ebay`` Premium tier only.
Returns the full updated preferences dict.
"""
if session.user_id == "anonymous":
raise HTTPException(
status_code=403,
detail="Sign in to save preferences.",
)
if body.path.startswith("affiliate.byok_ids.") and session.tier not in ("premium", "ultra"):
raise HTTPException(
status_code=403,
detail="Custom affiliate IDs (BYOK) require a Premium subscription.",
)
store = Store(session.user_db)
store.set_user_preference(body.path, body.value)
return store.get_all_preferences()

View file

@ -0,0 +1,11 @@
-- Community trust signals: user feedback on individual trust scores.
-- "This score looks right" (confirmed=1) / "This score is wrong" (confirmed=0).
-- Stored in shared_db so signals aggregate across all users.
CREATE TABLE IF NOT EXISTS community_signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
seller_id TEXT NOT NULL,
confirmed INTEGER NOT NULL CHECK (confirmed IN (0, 1)),
recorded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_community_signals_seller ON community_signals(seller_id);

View file

@ -0,0 +1,9 @@
-- Per-user preferences stored as a single JSON blob.
-- Lives in user_db (each user has their own DB file) — never in shared.db.
-- Single-row enforced by PRIMARY KEY CHECK (id = 1): acts as a singleton table.
-- Path reads/writes use cf-core preferences.paths (get_path / set_path).
CREATE TABLE IF NOT EXISTS user_preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
prefs_json TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);

View file

@ -16,7 +16,7 @@ import json
import logging
import re
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
from typing import Optional
@ -302,7 +302,8 @@ class ScrapedEbayAdapter(PlatformAdapter):
time.sleep(self._delay)
import subprocess, os
import os
import subprocess
display_num = next(_display_counter)
display = f":{display_num}"
xvfb = subprocess.Popen(
@ -313,8 +314,10 @@ class ScrapedEbayAdapter(PlatformAdapter):
env["DISPLAY"] = display
try:
from playwright.sync_api import sync_playwright # noqa: PLC0415 — lazy: only needed in Docker
from playwright_stealth import Stealth # noqa: PLC0415
from playwright.sync_api import (
sync_playwright, # noqa: PLC0415 — lazy: only needed in Docker
)
from playwright_stealth import Stealth # noqa: PLC0415
with sync_playwright() as pw:
browser = pw.chromium.launch(

View file

@ -19,7 +19,6 @@ import logging
from pathlib import Path
import requests
from circuitforge_core.db import get_connection
from circuitforge_core.llm import LLMRouter

View file

@ -5,9 +5,10 @@ from __future__ import annotations
from pathlib import Path
from circuitforge_core.tasks.scheduler import (
TaskScheduler,
TaskScheduler, # re-export for tests
)
from circuitforge_core.tasks.scheduler import (
get_scheduler as _base_get_scheduler,
reset_scheduler, # re-export for tests
)
from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task

View file

@ -14,7 +14,8 @@ Intentionally ungated (free for all):
- saved_searches retention feature; friction cost outweighs gate value
"""
from __future__ import annotations
from circuitforge_core.tiers import can_use as _core_can_use, TIERS # noqa: F401
from circuitforge_core.tiers import can_use as _core_can_use # noqa: F401
# Feature key → minimum tier required.
FEATURES: dict[str, str] = {

View file

@ -1,11 +1,13 @@
from .metadata import MetadataScorer
from .photo import PhotoScorer
from .aggregator import Aggregator
from app.db.models import Seller, Listing, TrustScore
from app.db.store import Store
import hashlib
import math
from app.db.models import Listing, TrustScore
from app.db.store import Store
from .aggregator import Aggregator
from .metadata import MetadataScorer
from .photo import PhotoScorer
class TrustScorer:
"""Orchestrates metadata + photo scoring for a batch of listings."""

View file

@ -1,8 +1,10 @@
"""Composite score and red flag extraction."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from datetime import datetime
from typing import Optional
from app.db.models import Seller, TrustScore
HARD_FILTER_AGE_DAYS = 7

View file

@ -25,9 +25,30 @@ dependencies = [
"PyJWT>=2.8",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"ruff>=0.4",
"httpx>=0.27", # FastAPI test client
]
[tool.setuptools.packages.find]
where = ["."]
include = ["app*", "api*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "W", "I"]
ignore = [
"E501", # line length — handled by formatter
"E402", # module-import-not-at-top — intentional for conditional/lazy imports
"E701", # multiple-statements-colon — `if x: return y` is accepted style
"E741", # ambiguous variable name — l/q used intentionally for listing/query
]

View file

@ -1,8 +1,9 @@
import pytest
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from app.db.models import Listing, MarketComp, Seller
from app.db.store import Store
from app.db.models import Listing, Seller, TrustScore, MarketComp
@pytest.fixture

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest

View file

@ -1,4 +1,4 @@
from app.tiers import can_use, FEATURES, LOCAL_VISION_UNLOCKABLE
from app.tiers import can_use
def test_metadata_scoring_is_free():

View file

@ -4,10 +4,8 @@ from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
import pytest
from app.db.models import Listing, TrustScore
from app.ui.components.easter_eggs import is_steal, auction_hours_remaining
from app.ui.components.easter_eggs import auction_hours_remaining, is_steal
def _listing(**kwargs) -> Listing:

View file

@ -5,7 +5,23 @@
<!-- Emoji favicon: target reticle — inline SVG to avoid a separate file -->
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎯</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Snipe</title>
<title>Snipe — eBay trust scoring before you bid</title>
<meta name="description" content="Score eBay listings and sellers for trustworthiness before you bid. Catches new accounts, suspicious prices, duplicate photos, and established scammers. Free, no account required." />
<meta name="theme-color" content="#e89122" />
<meta property="og:site_name" content="CircuitForge" />
<meta property="og:title" content="Snipe — eBay trust scoring before you bid" />
<meta property="og:description" content="Score eBay listings and sellers for trustworthiness before you bid. Free, no account required." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://menagerie.circuitforge.tech/snipe" />
<meta property="og:image" content="https://menagerie.circuitforge.tech/snipe/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Snipe — eBay trust scoring before you bid. Free. No account required." />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Snipe — eBay trust scoring before you bid" />
<meta name="twitter:description" content="Free eBay trust scorer. Catches scammers before you bid. No account required." />
<meta name="twitter:image" content="https://menagerie.circuitforge.tech/snipe/og-image.png" />
<link rel="canonical" href="https://menagerie.circuitforge.tech/snipe" />
<!-- Inline background prevents blank flash before CSS bundle loads -->
<!-- Matches --color-surface dark tactical theme from theme.css -->
<style>

BIN
web/public/og-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View file

@ -0,0 +1,17 @@
// composables/useTrustSignalPref.ts
// User opt-in for showing "This score looks right / wrong" trust signal buttons.
// Off by default — users explicitly enable it in Settings.
import { ref } from 'vue'
const LS_KEY = 'snipe:trust-signal-enabled'
const enabled = ref(localStorage.getItem(LS_KEY) === 'true')
export function useTrustSignalPref() {
function setEnabled(value: boolean) {
enabled.value = value
localStorage.setItem(LS_KEY, value ? 'true' : 'false')
}
return { enabled, setEnabled }
}

View file

@ -8,6 +8,7 @@ export const router = createRouter({
{ path: '/listing/:id', component: () => import('../views/ListingView.vue') },
{ path: '/saved', component: () => import('../views/SavedSearchesView.vue') },
{ path: '/blocklist', component: () => import('../views/BlocklistView.vue') },
{ path: '/settings', component: () => import('../views/SettingsView.vue') },
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
{ path: '/:pathMatch(.*)*', redirect: '/' },
],

View file

@ -0,0 +1,78 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useSessionStore } from './session'
export interface UserPreferences {
affiliate?: {
opt_out?: boolean
byok_ids?: {
ebay?: string
}
}
}
export const usePreferencesStore = defineStore('preferences', () => {
const session = useSessionStore()
const prefs = ref<UserPreferences>({})
const loading = ref(false)
const error = ref<string | null>(null)
const affiliateOptOut = computed(() => prefs.value.affiliate?.opt_out ?? false)
const affiliateByokId = computed(() => prefs.value.affiliate?.byok_ids?.ebay ?? '')
async function load() {
if (!session.isLoggedIn) return
loading.value = true
error.value = null
try {
const res = await fetch('/api/preferences')
if (res.ok) {
prefs.value = await res.json()
}
} catch {
// Non-cloud deploy or network error — preferences unavailable
} finally {
loading.value = false
}
}
async function setPref(path: string, value: boolean | string | null) {
if (!session.isLoggedIn) return
error.value = null
try {
const res = await fetch('/api/preferences', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, value }),
})
if (res.ok) {
prefs.value = await res.json()
} else {
const data = await res.json().catch(() => ({}))
error.value = data.detail ?? 'Failed to save preference.'
}
} catch {
error.value = 'Network error saving preference.'
}
}
async function setAffiliateOptOut(value: boolean) {
await setPref('affiliate.opt_out', value)
}
async function setAffiliateByokId(id: string) {
// Empty string clears the BYOK ID (router falls back to CF env var)
await setPref('affiliate.byok_ids.ebay', id.trim() || null)
}
return {
prefs,
loading,
error,
affiliateOptOut,
affiliateByokId,
load,
setAffiliateOptOut,
setAffiliateByokId,
}
})

View file

@ -33,6 +33,11 @@ export const useSessionStore = defineStore('session', () => {
const isCloud = computed(() => tier.value !== 'local')
const isFree = computed(() => tier.value === 'free')
const isPaid = computed(() => ['paid', 'premium', 'ultra', 'local'].includes(tier.value))
const isPremium = computed(() => ['premium', 'ultra'].includes(tier.value))
// isGuest: transient visitor with a snipe_guest UUID but no Heimdall account
const isGuest = computed(() => userId.value.startsWith('guest:'))
// isLoggedIn: cloud user with a real account (not anonymous or guest)
const isLoggedIn = computed(() => isCloud.value && userId.value !== 'anonymous' && !isGuest.value)
async function bootstrap() {
try {
@ -49,5 +54,5 @@ export const useSessionStore = defineStore('session', () => {
}
}
return { userId, tier, features, loaded, isCloud, isFree, isPaid, bootstrap }
return { userId, tier, features, loaded, isCloud, isFree, isPaid, isPremium, isGuest, isLoggedIn, bootstrap }
})

View file

@ -40,15 +40,24 @@
v-if="store.loading"
type="button"
class="cancel-btn"
aria-label="Cancel search"
@click="store.cancelSearch()"
title="Cancel search"
> Cancel</button>
<a
v-else-if="session.isCloud && !session.isLoggedIn"
href="https://circuitforge.tech/login"
class="save-bookmark-btn"
aria-label="Sign in to save searches"
>
<BookmarkIcon class="search-btn-icon" aria-hidden="true" />
</a>
<button
v-else
type="button"
class="save-bookmark-btn"
:disabled="!queryInput.trim()"
:title="showSaveForm ? 'Cancel' : 'Save this search'"
:aria-label="showSaveForm ? 'Cancel saving search' : 'Save this search'"
:aria-pressed="showSaveForm"
@click="showSaveForm = !showSaveForm; if (showSaveForm) saveName = queryInput.trim()"
>
<BookmarkIcon class="search-btn-icon" aria-hidden="true" />
@ -64,7 +73,7 @@
autofocus
/>
<button type="submit" class="save-confirm-btn">Save</button>
<button type="button" class="save-cancel-btn" @click="showSaveForm = false"></button>
<button type="button" class="save-cancel-btn" aria-label="Cancel save" @click="showSaveForm = false"></button>
<span v-if="saveSuccess" class="save-success">Saved!</span>
<span v-if="saveError" class="save-error">{{ saveError }}</span>
</form>
@ -125,6 +134,7 @@
type="button"
class="filter-pages-btn"
:class="{ 'filter-pages-btn--active': filters.adapter === m.value }"
:aria-pressed="filters.adapter === m.value"
@click="filters.adapter = m.value"
>{{ m.label }}</button>
</div>
@ -144,7 +154,8 @@
'filter-pages-btn--locked': p > session.features.max_pages,
}"
:disabled="p > session.features.max_pages"
:title="p > session.features.max_pages ? 'Upgrade to fetch more pages' : undefined"
:aria-pressed="filters.pages === p"
:aria-label="p > session.features.max_pages ? `${p} pages — upgrade required` : `${p} page${p > 1 ? 's' : ''}`"
@click="p <= session.features.max_pages && (filters.pages = p)"
>{{ p }}</button>
</div>
@ -154,8 +165,10 @@
<fieldset class="filter-group">
<legend class="filter-label">Price range</legend>
<div class="filter-row">
<input v-model.number="filters.minPrice" type="number" min="0" class="filter-input" placeholder="Min $" />
<input v-model.number="filters.maxPrice" type="number" min="0" class="filter-input" placeholder="Max $" />
<label class="sr-only" for="f-min-price">Minimum price</label>
<input id="f-min-price" v-model.number="filters.minPrice" type="number" min="0" class="filter-input" placeholder="Min $" aria-label="Minimum price in dollars" />
<label class="sr-only" for="f-max-price">Maximum price</label>
<input id="f-max-price" v-model.number="filters.maxPrice" type="number" min="0" class="filter-input" placeholder="Max $" aria-label="Maximum price in dollars" />
</div>
<p class="filter-pages-hint">Forwarded to eBay API</p>
</fieldset>
@ -164,13 +177,14 @@
<legend class="filter-label">Keywords</legend>
<div class="filter-row">
<label class="filter-label-sm" for="f-include">Must include</label>
<div class="filter-mode-row">
<div class="filter-mode-row" role="group" aria-label="Keyword match mode">
<button
v-for="m in INCLUDE_MODES"
:key="m.value"
type="button"
class="filter-pages-btn"
:class="{ 'filter-pages-btn--active': filters.mustIncludeMode === m.value }"
:aria-pressed="filters.mustIncludeMode === m.value"
@click="filters.mustIncludeMode = m.value"
>{{ m.label }}</button>
</div>
@ -217,9 +231,11 @@
max="100"
step="5"
class="filter-range"
aria-label="Minimum trust score"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="filters.minTrustScore"
:aria-valuetext="`${filters.minTrustScore ?? 0} out of 100`"
/>
<span class="filter-range-val">{{ filters.minTrustScore ?? 0 }}</span>
</fieldset>
@ -287,15 +303,59 @@
{{ store.error }}
</div>
<!-- Empty state (before first search) -->
<div v-else-if="!store.results.length && !store.loading && !store.query" class="results-empty">
<span class="results-empty-icon" aria-hidden="true">🎯</span>
<p>Enter a search term to find listings.</p>
<!-- Landing hero (before first search) -->
<div v-else-if="!store.results.length && !store.loading && !store.query" class="landing-hero">
<div class="landing-hero__eyebrow" aria-hidden="true">🎯 Snipe</div>
<h1 class="landing-hero__headline">Bid with confidence.</h1>
<p class="landing-hero__sub">
Snipe scores eBay listings and sellers for trustworthiness before you place a bid.
Catches new accounts, suspicious prices, duplicate photos, and known scammers.
Free. No account required.
</p>
<!-- Timely callout: eBay cancellation policy change -->
<div v-if="showEbayCallout" class="landing-hero__callout" role="note">
<span class="landing-hero__callout-icon" aria-hidden="true"></span>
<p>
<strong>Starting May 13, 2026, eBay removes the option for buyers to cancel winning bids.</strong>
Auction sales become final. Know what you're buying before you bid.
</p>
</div>
<!-- Signal tiles -->
<div class="landing-hero__tiles" role="list">
<div class="landing-hero__tile" role="listitem">
<span class="landing-hero__tile-icon" aria-hidden="true">🛡</span>
<strong class="landing-hero__tile-title">Seller trust score</strong>
<p class="landing-hero__tile-desc">Feedback count and ratio, account age, and category history scored 0 to 100.</p>
</div>
<div class="landing-hero__tile" role="listitem">
<span class="landing-hero__tile-icon" aria-hidden="true">📊</span>
<strong class="landing-hero__tile-title">Price vs. market</strong>
<p class="landing-hero__tile-desc">Compared against recent completed sales. Flags prices that are suspiciously below market.</p>
</div>
<div class="landing-hero__tile" role="listitem">
<span class="landing-hero__tile-icon" aria-hidden="true">🚩</span>
<strong class="landing-hero__tile-title">Red flag detection</strong>
<p class="landing-hero__tile-desc">Duplicate photos, damage mentions, established bad actors, and zero-feedback sellers.</p>
</div>
</div>
<!-- Sign-in unlock strip (cloud, unauthenticated only) -->
<div v-if="session.isCloud && !session.isLoggedIn" class="landing-hero__signin-strip">
<p class="landing-hero__signin-text">
Free account unlocks saved searches, more results pages, and the community scammer blocklist.
</p>
<a href="https://circuitforge.tech/login" class="landing-hero__signin-cta">
Create a free account
</a>
</div>
</div>
<!-- No results -->
<div v-else-if="!store.results.length && !store.loading && store.query" class="results-empty">
<p>No listings found for <strong>{{ store.query }}</strong>.</p>
<p class="results-empty__hint">Try a broader search term, or check spelling.</p>
</div>
<!-- Results -->
@ -326,6 +386,40 @@
</div>
</div>
<!-- Guest prompt sign-in CTA for gated bulk actions -->
<Transition name="bulk-bar">
<div v-if="guestPrompt" class="guest-prompt" role="alert">
<span>{{ guestPrompt }}</span>
<a href="https://circuitforge.tech/login" class="guest-prompt__link">Sign in free </a>
<button class="guest-prompt__dismiss" @click="guestPrompt = null" aria-label="Dismiss"></button>
</div>
</Transition>
<!-- Bulk action bar appears when any cards are selected -->
<Transition name="bulk-bar">
<div v-if="selectMode" class="bulk-bar" role="toolbar" aria-label="Bulk actions">
<span class="bulk-bar__count">{{ selectedIds.size }} selected</span>
<button class="bulk-bar__btn bulk-bar__btn--ghost" @click="selectAll">Select all</button>
<button class="bulk-bar__btn bulk-bar__btn--ghost" @click="clearSelection">Deselect</button>
<div class="bulk-bar__sep" role="separator" />
<button
class="bulk-bar__btn bulk-bar__btn--danger"
:disabled="bulkBlocking"
@click="blockSelected"
:title="session.isLoggedIn ? 'Block all selected sellers' : 'Sign in to block sellers'"
>
{{ bulkBlocking ? `Blocking… (${bulkBlockDone})` : session.isLoggedIn ? '⚑ Block sellers' : '⚑ Sign in to block' }}
</button>
<button
class="bulk-bar__btn bulk-bar__btn--report"
@click="reportSelected"
title="Report selected sellers to eBay"
>
Report to eBay
</button>
</div>
</Transition>
<!-- Cards -->
<div class="results-list">
<ListingCard
@ -335,6 +429,9 @@
:trust="store.trustScores.get(listing.platform_listing_id) ?? null"
:seller="store.sellers.get(listing.seller_platform_id) ?? null"
:market-price="store.marketPrice"
:selected="selectedIds.has(listing.platform_listing_id)"
:select-mode="selectMode"
@toggle="toggleSelect(listing.platform_listing_id)"
/>
</div>
</template>
@ -351,14 +448,89 @@ import { useSearchStore } from '../stores/search'
import type { Listing, TrustScore, SearchFilters, MustIncludeMode } from '../stores/search'
import { useSavedSearchesStore } from '../stores/savedSearches'
import { useSessionStore } from '../stores/session'
import { useBlocklistStore } from '../stores/blocklist'
import ListingCard from '../components/ListingCard.vue'
const route = useRoute()
const store = useSearchStore()
const savedStore = useSavedSearchesStore()
const session = useSessionStore()
const blocklist = useBlocklistStore()
const queryInput = ref('')
// Multi-select + bulk actions
const selectedIds = ref<Set<string>>(new Set())
const selectMode = computed(() => selectedIds.value.size > 0)
function toggleSelect(platformListingId: string) {
const next = new Set(selectedIds.value)
if (next.has(platformListingId)) {
next.delete(platformListingId)
} else {
next.add(platformListingId)
}
selectedIds.value = next
}
function selectAll() {
selectedIds.value = new Set(visibleListings.value.map(l => l.platform_listing_id))
}
function clearSelection() {
selectedIds.value = new Set()
}
const bulkBlocking = ref(false)
const bulkBlockDone = ref(0)
const guestPrompt = ref<string | null>(null) // sign-in CTA message for guest/anon
async function blockSelected() {
if (!session.isLoggedIn) {
guestPrompt.value = 'Sign in to add sellers to the community blocklist.'
return
}
guestPrompt.value = null
bulkBlocking.value = true
bulkBlockDone.value = 0
const toBlock = visibleListings.value.filter(l => selectedIds.value.has(l.platform_listing_id))
const uniqueSellers = new Map<string, string>() // seller_id username
for (const l of toBlock) {
if (l.seller_platform_id && !uniqueSellers.has(l.seller_platform_id)) {
const seller = store.sellers.get(l.seller_platform_id)
uniqueSellers.set(l.seller_platform_id, seller?.username ?? l.seller_platform_id)
}
}
for (const [sellerId, username] of uniqueSellers) {
if (!blocklist.isBlocklisted(sellerId)) {
try {
await blocklist.addSeller(sellerId, username, 'Bulk block from search results')
bulkBlockDone.value++
} catch { /* continue */ }
}
}
bulkBlocking.value = false
clearSelection()
}
function reportSelected() {
const toReport = visibleListings.value.filter(l => selectedIds.value.has(l.platform_listing_id))
// De-duplicate by seller one report per seller covers all their listings
const seenSellers = new Set<string>()
for (const l of toReport) {
if (l.seller_platform_id && !seenSellers.has(l.seller_platform_id)) {
seenSellers.add(l.seller_platform_id)
const seller = store.sellers.get(l.seller_platform_id)
const username = seller?.username ?? l.seller_platform_id
window.open(
`https://contact.ebay.com/ws/eBayISAPI.dll?ReportUser&userid=${encodeURIComponent(username)}`,
'_blank',
'noopener,noreferrer',
)
}
}
clearSelection()
}
// Save search UI state
const showSaveForm = ref(false)
const showFilters = ref(false)
@ -366,6 +538,9 @@ const saveName = ref('')
const saveError = ref<string | null>(null)
const saveSuccess = ref(false)
// Show the eBay cancellation policy callout until the policy takes effect
const showEbayCallout = computed(() => new Date() < new Date('2026-05-13T00:00:00'))
// Count active non-default filters for the mobile badge
const activeFilterCount = computed(() => {
let n = 0
@ -715,6 +890,7 @@ async function onSearch() {
}
.search-btn:hover:not(:disabled) { background: var(--app-primary-hover); }
.search-btn:focus-visible { outline: 2px solid var(--app-primary); outline-offset: 2px; }
.search-btn:disabled { opacity: 0.55; cursor: not-allowed; }
.search-btn-icon { width: 1.1rem; height: 1.1rem; }
@ -733,6 +909,7 @@ async function onSearch() {
transition: background 150ms ease;
}
.cancel-btn:hover { background: rgba(248, 81, 73, 0.1); }
.cancel-btn:focus-visible { outline: 2px solid var(--color-error); outline-offset: 2px; }
.save-bookmark-btn {
display: flex;
@ -1013,6 +1190,11 @@ async function onSearch() {
color: var(--color-text-inverse);
}
.filter-pages-btn:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
.filter-pages-btn--locked,
.filter-pages-btn:disabled {
opacity: 0.35;
@ -1048,6 +1230,136 @@ async function onSearch() {
.results-error-icon { width: 1.25rem; height: 1.25rem; flex-shrink: 0; }
/* ── Landing hero ────────────────────────────────────────────────────── */
.landing-hero {
max-width: 760px;
margin: var(--space-12) auto;
padding: 0 var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.landing-hero__eyebrow {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--app-primary);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.landing-hero__headline {
font-family: var(--font-display);
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 700;
line-height: 1.15;
color: var(--color-text);
margin: 0;
}
.landing-hero__sub {
font-size: 1.0625rem;
line-height: 1.65;
color: var(--color-text-muted);
max-width: 600px;
margin: 0;
}
.landing-hero__callout {
display: flex;
gap: var(--space-3);
align-items: flex-start;
background: rgba(248, 81, 73, 0.08);
border: 1px solid rgba(248, 81, 73, 0.35);
border-radius: var(--radius-lg);
padding: var(--space-4) var(--space-5);
font-size: 0.9375rem;
line-height: 1.55;
color: var(--color-text);
}
.landing-hero__callout-icon {
font-size: 1.1rem;
flex-shrink: 0;
margin-top: 2px;
}
.landing-hero__callout p { margin: 0; }
.landing-hero__tiles {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
margin-top: var(--space-2);
}
.landing-hero__tile {
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.landing-hero__tile-icon { font-size: 1.5rem; line-height: 1; }
.landing-hero__tile-title {
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text);
}
.landing-hero__tile-desc {
font-size: 0.8125rem;
line-height: 1.55;
color: var(--color-text-muted);
margin: 0;
}
.landing-hero__signin-strip {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: var(--space-3) var(--space-6);
margin-top: var(--space-8);
padding: var(--space-4) var(--space-6);
background: color-mix(in srgb, var(--app-primary) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--app-primary) 20%, transparent);
border-radius: var(--radius-md);
}
.landing-hero__signin-text {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.landing-hero__signin-cta {
font-size: 0.875rem;
font-weight: 600;
color: var(--app-primary);
text-decoration: none;
white-space: nowrap;
}
.landing-hero__signin-cta:hover {
text-decoration: underline;
}
@media (max-width: 720px) {
.landing-hero { margin: var(--space-8) auto; }
.landing-hero__tiles { grid-template-columns: 1fr; }
.landing-hero__signin-strip { flex-direction: column; text-align: center; }
}
@media (max-width: 480px) {
.landing-hero__headline { font-size: 1.75rem; }
}
/* ── Results empty (post-search, no matches) ─────────────────────────── */
.results-empty {
display: flex;
flex-direction: column;
@ -1060,6 +1372,12 @@ async function onSearch() {
.results-empty-icon { font-size: 3rem; }
.results-empty__hint {
font-size: 0.875rem;
margin: 0;
color: var(--color-text-muted);
}
.results-toolbar {
display: flex;
align-items: center;
@ -1185,6 +1503,107 @@ async function onSearch() {
cursor: pointer;
}
/* ── Bulk action bar ────────────────────────────────────────────────────── */
.bulk-bar {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
padding: var(--space-2) var(--space-3);
background: color-mix(in srgb, var(--app-primary) 10%, var(--color-surface-2));
border: 1px solid color-mix(in srgb, var(--app-primary) 30%, transparent);
border-radius: var(--radius-md);
}
.bulk-bar__count {
font-size: 0.8125rem;
font-weight: 600;
color: var(--app-primary);
margin-right: var(--space-1);
}
.bulk-bar__sep {
width: 1px;
height: 18px;
background: var(--color-border);
margin: 0 var(--space-1);
}
.bulk-bar__btn {
padding: 4px var(--space-3);
border-radius: var(--radius-sm);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
font-family: var(--font-body);
transition: background 120ms ease, color 120ms ease, opacity 120ms ease;
}
.bulk-bar__btn:disabled { opacity: 0.5; cursor: not-allowed; }
.bulk-bar__btn--ghost {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
}
.bulk-bar__btn--ghost:hover:not(:disabled) {
background: var(--color-surface-raised);
color: var(--color-text);
}
.bulk-bar__btn--danger {
background: transparent;
border: 1px solid var(--color-error);
color: var(--color-error);
}
.bulk-bar__btn--danger:hover:not(:disabled) {
background: rgba(248, 81, 73, 0.12);
}
.bulk-bar__btn--report {
background: transparent;
border: 1px solid var(--color-text-muted);
color: var(--color-text-muted);
}
.bulk-bar__btn--report:hover:not(:disabled) {
background: var(--color-surface-raised);
color: var(--color-text);
border-color: var(--color-text);
}
/* Slide-in transition (shared by bulk-bar and guest-prompt) */
.bulk-bar-enter-active, .bulk-bar-leave-active { transition: opacity 0.18s ease, transform 0.18s ease; }
.bulk-bar-enter-from, .bulk-bar-leave-to { opacity: 0; transform: translateY(-6px); }
/* Guest sign-in prompt */
.guest-prompt {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-4);
background: color-mix(in srgb, var(--app-primary) 12%, var(--color-surface-2));
border: 1px solid color-mix(in srgb, var(--app-primary) 30%, transparent);
border-radius: var(--radius-md);
font-size: 0.875rem;
color: var(--color-text);
margin-bottom: var(--space-2);
}
.guest-prompt__link {
color: var(--app-primary);
font-weight: 600;
text-decoration: none;
white-space: nowrap;
}
.guest-prompt__link:hover { text-decoration: underline; }
.guest-prompt__dismiss {
margin-left: auto;
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.75rem;
padding: 0 var(--space-1);
}
.results-list {
display: flex;
flex-direction: column;

View file

@ -0,0 +1,269 @@
<template>
<div class="settings-view">
<h1 class="settings-heading">Settings</h1>
<section class="settings-section">
<h2 class="settings-section-title">Community</h2>
<label class="settings-toggle">
<div class="settings-toggle-text">
<span class="settings-toggle-label">Trust score feedback</span>
<span class="settings-toggle-desc">
Show "This score looks right / wrong" buttons on each listing.
Your feedback helps improve trust scores for everyone.
</span>
</div>
<button
class="toggle-btn"
:class="{ 'toggle-btn--on': trustSignalEnabled }"
:aria-pressed="String(trustSignalEnabled)"
aria-label="Enable trust score feedback buttons"
@click="setEnabled(!trustSignalEnabled)"
>
<span class="toggle-btn__track" />
<span class="toggle-btn__thumb" />
</button>
</label>
</section>
<!-- Affiliate Links only shown to signed-in cloud users -->
<section v-if="session.isLoggedIn" class="settings-section">
<h2 class="settings-section-title">Affiliate Links</h2>
<label class="settings-toggle">
<div class="settings-toggle-text">
<span class="settings-toggle-label">Opt out of affiliate links</span>
<span class="settings-toggle-desc">
When enabled, listing links go directly to eBay without an affiliate code.
Opting out means your purchases won't support Snipe's development.
</span>
</div>
<button
class="toggle-btn"
:class="{ 'toggle-btn--on': prefs.affiliateOptOut }"
:aria-pressed="String(prefs.affiliateOptOut)"
:aria-busy="prefs.loading"
aria-label="Opt out of affiliate links"
@click="prefs.setAffiliateOptOut(!prefs.affiliateOptOut)"
>
<span class="toggle-btn__track" />
<span class="toggle-btn__thumb" />
</button>
</label>
<!-- BYOK affiliate ID Premium tier only -->
<div v-if="session.isPremium" class="settings-field">
<label class="settings-toggle-label" for="byok-id">
Your eBay Partner Network campaign ID
</label>
<p class="settings-toggle-desc">
Override Snipe's affiliate ID with your own eBay Partner Network (EPN) campaign ID.
Your purchases generate revenue for your own EPN account instead.
</p>
<div class="settings-field-row">
<input
id="byok-id"
v-model="byokInput"
type="text"
class="settings-input"
placeholder="e.g. 5339149249"
aria-label="Your eBay Partner Network campaign ID"
@blur="saveByokId"
@keydown.enter="saveByokId"
/>
<button class="settings-field-save" @click="saveByokId" aria-label="Save campaign ID">
Save
</button>
</div>
</div>
<p v-if="prefs.error" class="settings-error" role="alert">{{ prefs.error }}</p>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useTrustSignalPref } from '../composables/useTrustSignalPref'
import { useSessionStore } from '../stores/session'
import { usePreferencesStore } from '../stores/preferences'
const { enabled: trustSignalEnabled, setEnabled } = useTrustSignalPref()
const session = useSessionStore()
const prefs = usePreferencesStore()
// Local input buffer for BYOK ID synced from store, saved on blur/enter
const byokInput = ref(prefs.affiliateByokId)
watch(() => prefs.affiliateByokId, (val) => { byokInput.value = val })
function saveByokId() {
prefs.setAffiliateByokId(byokInput.value)
}
</script>
<style scoped>
.settings-view {
max-width: 600px;
margin: 0 auto;
padding: var(--space-8) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.settings-heading {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--app-primary);
margin: 0 0 var(--space-8);
}
.settings-section {
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.settings-section-title {
font-family: var(--font-display);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
margin: 0 0 var(--space-2);
}
.settings-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-6);
cursor: pointer;
}
.settings-toggle-text {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.settings-toggle-label {
font-size: 0.9375rem;
font-weight: 500;
color: var(--color-text);
}
.settings-toggle-desc {
font-size: 0.8125rem;
color: var(--color-text-muted);
line-height: 1.5;
}
/* Toggle button */
.toggle-btn {
position: relative;
width: 44px;
height: 24px;
background: none;
border: none;
padding: 0;
cursor: pointer;
flex-shrink: 0;
}
.toggle-btn__track {
position: absolute;
inset: 0;
border-radius: var(--radius-full);
background: var(--color-border);
transition: background 0.2s ease;
}
.toggle-btn--on .toggle-btn__track {
background: var(--app-primary);
}
.toggle-btn__thumb {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-text-muted);
transition: transform 0.2s ease, background 0.2s ease;
}
.toggle-btn--on .toggle-btn__thumb {
transform: translateX(20px);
background: var(--color-surface);
}
@media (prefers-reduced-motion: reduce) {
.toggle-btn__track,
.toggle-btn__thumb { transition: none; }
}
/* ---- BYOK text input field ---- */
.settings-field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.settings-field-row {
display: flex;
gap: var(--space-2);
align-items: center;
flex-wrap: wrap;
}
.settings-input {
flex: 1;
min-width: 0;
padding: var(--space-2) var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
font-size: 0.9375rem;
font-family: var(--font-mono, monospace);
outline: none;
transition: border-color 0.15s ease;
}
.settings-input:focus {
border-color: var(--app-primary);
}
.settings-field-save {
padding: var(--space-2) var(--space-4);
background: var(--app-primary);
color: var(--color-text-inverse, #fff);
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.15s ease;
}
.settings-field-save:hover { opacity: 0.85; }
.settings-field-save:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
/* ---- Error feedback ---- */
.settings-error {
font-size: 0.8125rem;
color: var(--color-danger, #f85149);
margin: 0;
}
</style>