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:
parent
aff5bdda39
commit
fb81422c54
27 changed files with 1293 additions and 73 deletions
28
.cliff.toml
Normal file
28
.cliff.toml
Normal 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
57
.forgejo/workflows/ci.yml
Normal 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
|
||||
30
.forgejo/workflows/mirror.yml
Normal file
30
.forgejo/workflows/mirror.yml
Normal 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
|
||||
92
.forgejo/workflows/release.yml
Normal file
92
.forgejo/workflows/release.yml
Normal 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}')"
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
169
api/main.py
169
api/main.py
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
11
app/db/migrations/008_community_signals.sql
Normal file
11
app/db/migrations/008_community_signals.sql
Normal 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);
|
||||
9
app/db/migrations/009_user_preferences.sql
Normal file
9
app/db/migrations/009_user_preferences.sql
Normal 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'))
|
||||
);
|
||||
|
|
@ -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,7 +314,9 @@ class ScrapedEbayAdapter(PlatformAdapter):
|
|||
env["DISPLAY"] = display
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright # noqa: PLC0415 — lazy: only needed in Docker
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] = {
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
BIN
web/public/og-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
17
web/src/composables/useTrustSignalPref.ts
Normal file
17
web/src/composables/useTrustSignalPref.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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: '/' },
|
||||
],
|
||||
|
|
|
|||
78
web/src/stores/preferences.ts
Normal file
78
web/src/stores/preferences.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
|
|
@ -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 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
269
web/src/views/SettingsView.vue
Normal file
269
web/src/views/SettingsView.vue
Normal 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>
|
||||
Loading…
Reference in a new issue