Compare commits
5 commits
aff5bdda39
...
2dda26a911
| Author | SHA1 | Date | |
|---|---|---|---|
| 2dda26a911 | |||
| 6d5ceac0a1 | |||
| d5651e5fe8 | |||
| c93466c037 | |||
| fb81422c54 |
57 changed files with 3145 additions and 273 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].*"
|
||||
12
.env.example
12
.env.example
|
|
@ -72,9 +72,15 @@ SNIPE_DB=data/snipe.db
|
|||
# OLLAMA_HOST=http://localhost:11434
|
||||
# OLLAMA_MODEL=llava:7b
|
||||
|
||||
# CF Orchestrator — managed inference for Paid+ cloud users (internal use only).
|
||||
# Self-hosted users leave this unset; it has no effect without a valid allocation token.
|
||||
# CF_ORCH_URL=https://orch.circuitforge.tech
|
||||
# CF Orchestrator — routes vision/LLM tasks to a cf-orch coordinator for VRAM management.
|
||||
# Self-hosted: point at a local cf-orch coordinator if you have one running.
|
||||
# Cloud (internal): managed coordinator at orch.circuitforge.tech.
|
||||
# Leave unset to run vision tasks inline (no VRAM coordination).
|
||||
# CF_ORCH_URL=http://10.1.10.71:7700
|
||||
#
|
||||
# cf-orch agent (compose --profile orch) — coordinator URL for the sidecar agent.
|
||||
# Defaults to CF_ORCH_URL if unset.
|
||||
# CF_ORCH_COORDINATOR_URL=http://10.1.10.71:7700
|
||||
|
||||
# ── In-app feedback (beta) ────────────────────────────────────────────────────
|
||||
# When set, a feedback FAB appears in the UI and routes submissions to Forgejo.
|
||||
|
|
|
|||
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}')"
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
|
|
@ -6,6 +6,36 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||
|
||||
---
|
||||
|
||||
## [0.3.0] — 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
**Infrastructure and DevOps**
|
||||
|
||||
- `.forgejo/workflows/ci.yml` — Python lint (ruff) + pytest + Vue typecheck + vitest on every PR/push to main. Installs circuitforge-core from GitHub mirror so the CI runner doesn't need the sibling directory.
|
||||
- `.forgejo/workflows/release.yml` — Docker build and push (api + web images) to Forgejo container registry on `v*` tags. Builds both images multi-arch (amd64 + arm64). Creates a Forgejo release with git-cliff changelog notes.
|
||||
- `.forgejo/workflows/mirror.yml` — Mirror push to GitHub and Codeberg on main/tags.
|
||||
- `install.sh` — Full rewrite following the CircuitForge installer pattern: colored output, `--docker` / `--bare-metal` / `--help` flags, auto-detection of Docker/conda/Python/Node/Chromium/Xvfb, license key prompting, structured named functions.
|
||||
- `docs/nginx-self-hosted.conf` — Sample nginx config for bare-metal self-hosted deployments (SPA fallback, SSE proxy settings, long-term asset caching).
|
||||
- `docs/getting-started/installation.md` — No-Docker install section: bare-metal instructions, nginx setup, Chromium/Xvfb note.
|
||||
- `compose.override.yml` — `cf-orch-agent` sidecar service for routing vision tasks to a cf-orch GPU coordinator (`--profile orch` opt-in). `CF_ORCH_COORDINATOR_URL` env var documented.
|
||||
- `.env.example` — `CF_ORCH_URL` and `CF_ORCH_COORDINATOR_URL` comments expanded with self-hosted coordinator guidance.
|
||||
|
||||
**Screenshots** (post CSS fix)
|
||||
|
||||
- Retook all docs screenshots (`01-hero`, `02-results`, `03-steal-badge`, `hero`) after the color-mix token fix so tints match the theme in both dark and light mode.
|
||||
|
||||
### Closed
|
||||
|
||||
- `#1` SSE live score push — already fully implemented in 0.2.0; closed.
|
||||
- `#22` Forgejo Actions CI/CD — shipped.
|
||||
- `#24` nginx config for no-Docker self-hosting — shipped.
|
||||
- `#25` Self-hosted installer script — shipped.
|
||||
- `#15` cf-orch agent in compose stack — shipped.
|
||||
- `#27` MCP server — already shipped in 0.2.0; closed.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0] — 2026-04-12
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
);
|
||||
0
app/mcp/__init__.py
Normal file
0
app/mcp/__init__.py
Normal file
110
app/mcp/formatters.py
Normal file
110
app/mcp/formatters.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""Condense Snipe API search results into LLM-friendly format.
|
||||
|
||||
Raw Snipe responses are verbose — full listing dicts, nested seller objects,
|
||||
redundant fields. This module trims to what an LLM needs for reasoning:
|
||||
title, price, market delta, trust summary, GPU inference score, url.
|
||||
|
||||
Results are sorted by a composite key: trust × gpu_inference_score / price.
|
||||
This surfaces high-trust, VRAM-rich, underpriced boards at the top.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.mcp.gpu_scoring import parse_gpu, score_gpu
|
||||
|
||||
|
||||
def format_results(
|
||||
response: dict[str, Any],
|
||||
vram_weight: float = 0.6,
|
||||
arch_weight: float = 0.4,
|
||||
top_n: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a condensed, LLM-ready summary of a Snipe search response."""
|
||||
listings: list[dict] = response.get("listings", [])
|
||||
trust_map: dict = response.get("trust_scores", {})
|
||||
seller_map: dict = response.get("sellers", {})
|
||||
market_price: float | None = response.get("market_price")
|
||||
|
||||
condensed = []
|
||||
for listing in listings:
|
||||
lid = listing.get("platform_listing_id", "")
|
||||
title = listing.get("title", "")
|
||||
price = float(listing.get("price") or 0)
|
||||
trust = trust_map.get(lid, {})
|
||||
seller_id = listing.get("seller_platform_id", "")
|
||||
seller = seller_map.get(seller_id, {})
|
||||
|
||||
gpu_info = _gpu_info(title, vram_weight, arch_weight)
|
||||
trust_score = trust.get("composite_score", 0) or 0
|
||||
inference_score = gpu_info["inference_score"] if gpu_info else 0.0
|
||||
|
||||
condensed.append({
|
||||
"id": lid,
|
||||
"title": title,
|
||||
"price": price,
|
||||
"vs_market": _vs_market(price, market_price),
|
||||
"trust_score": trust_score,
|
||||
"trust_partial": bool(trust.get("score_is_partial")),
|
||||
"red_flags": _parse_flags(trust.get("red_flags_json", "[]")),
|
||||
"seller_age_days": seller.get("account_age_days"),
|
||||
"seller_feedback": seller.get("feedback_count"),
|
||||
"gpu": gpu_info,
|
||||
"url": listing.get("url", ""),
|
||||
# Sort key — not included in output
|
||||
"_sort_key": _composite_key(trust_score, inference_score, price),
|
||||
})
|
||||
|
||||
condensed.sort(key=lambda r: r["_sort_key"], reverse=True)
|
||||
for r in condensed:
|
||||
del r["_sort_key"]
|
||||
|
||||
no_gpu = sum(1 for r in condensed if r["gpu"] is None)
|
||||
return {
|
||||
"total_found": len(listings),
|
||||
"showing": min(top_n, len(condensed)),
|
||||
"market_price": market_price,
|
||||
"adapter": response.get("adapter_used"),
|
||||
"no_gpu_detected": no_gpu,
|
||||
"results": condensed[:top_n],
|
||||
}
|
||||
|
||||
|
||||
def _gpu_info(title: str, vram_weight: float, arch_weight: float) -> dict | None:
|
||||
spec = parse_gpu(title)
|
||||
if not spec:
|
||||
return None
|
||||
match = score_gpu(spec, vram_weight, arch_weight)
|
||||
return {
|
||||
"model": spec.model,
|
||||
"vram_gb": spec.vram_gb,
|
||||
"arch": spec.arch_name,
|
||||
"vendor": spec.vendor,
|
||||
"vram_score": match.vram_score,
|
||||
"arch_score": match.arch_score,
|
||||
"inference_score": match.inference_score,
|
||||
}
|
||||
|
||||
|
||||
def _vs_market(price: float, market_price: float | None) -> str | None:
|
||||
if not market_price or price <= 0:
|
||||
return None
|
||||
delta_pct = ((market_price - price) / market_price) * 100
|
||||
if delta_pct >= 0:
|
||||
return f"{delta_pct:.0f}% below market (${market_price:.0f} median)"
|
||||
return f"{abs(delta_pct):.0f}% above market (${market_price:.0f} median)"
|
||||
|
||||
|
||||
def _composite_key(trust_score: float, inference_score: float, price: float) -> float:
|
||||
"""Higher = better value. Zero price or zero trust scores near zero."""
|
||||
if price <= 0 or trust_score <= 0:
|
||||
return 0.0
|
||||
return (trust_score * (inference_score or 50.0)) / price
|
||||
|
||||
|
||||
def _parse_flags(flags_json: str) -> list[str]:
|
||||
try:
|
||||
return json.loads(flags_json) or []
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
143
app/mcp/gpu_scoring.py
Normal file
143
app/mcp/gpu_scoring.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""GPU architecture and VRAM scoring for laptop mainboard inference-value ranking.
|
||||
|
||||
Parses GPU model names from eBay listing titles and scores them on two axes:
|
||||
- vram_score: linear 0–100, anchored at 24 GB = 100
|
||||
- arch_score: linear 0–100, architecture tier 1–5 (5 = newest)
|
||||
|
||||
inference_score = (vram_score × vram_weight + arch_score × arch_weight)
|
||||
/ (vram_weight + arch_weight)
|
||||
|
||||
Patterns are matched longest-first to prevent "RTX 3070" matching before "RTX 3070 Ti".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GpuSpec:
|
||||
model: str # canonical name, e.g. "RTX 3070 Ti"
|
||||
vram_gb: int
|
||||
arch_tier: int # 1–5; 5 = newest generation
|
||||
arch_name: str # human-readable, e.g. "Ampere"
|
||||
vendor: str # "nvidia" | "amd" | "intel"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GpuMatch:
|
||||
spec: GpuSpec
|
||||
vram_score: float
|
||||
arch_score: float
|
||||
inference_score: float
|
||||
|
||||
|
||||
# ── GPU database ──────────────────────────────────────────────────────────────
|
||||
# Laptop VRAM often differs from desktop; using common laptop variants.
|
||||
# Listed longest-name-first within each family to guide sort order.
|
||||
|
||||
_GPU_DB: list[GpuSpec] = [
|
||||
# NVIDIA Ada Lovelace — tier 5
|
||||
GpuSpec("RTX 4090", 16, 5, "Ada Lovelace", "nvidia"),
|
||||
GpuSpec("RTX 4080", 12, 5, "Ada Lovelace", "nvidia"),
|
||||
GpuSpec("RTX 4070 Ti", 12, 5, "Ada Lovelace", "nvidia"),
|
||||
GpuSpec("RTX 4070", 8, 5, "Ada Lovelace", "nvidia"),
|
||||
GpuSpec("RTX 4060 Ti", 8, 5, "Ada Lovelace", "nvidia"),
|
||||
GpuSpec("RTX 4060", 8, 5, "Ada Lovelace", "nvidia"),
|
||||
GpuSpec("RTX 4050", 6, 5, "Ada Lovelace", "nvidia"),
|
||||
# NVIDIA Ampere — tier 4
|
||||
GpuSpec("RTX 3090", 24, 4, "Ampere", "nvidia"), # rare laptop variant
|
||||
GpuSpec("RTX 3080 Ti", 16, 4, "Ampere", "nvidia"),
|
||||
GpuSpec("RTX 3080", 8, 4, "Ampere", "nvidia"), # most laptop 3080s = 8 GB
|
||||
GpuSpec("RTX 3070 Ti", 8, 4, "Ampere", "nvidia"),
|
||||
GpuSpec("RTX 3070", 8, 4, "Ampere", "nvidia"),
|
||||
GpuSpec("RTX 3060", 6, 4, "Ampere", "nvidia"),
|
||||
GpuSpec("RTX 3050 Ti", 4, 4, "Ampere", "nvidia"),
|
||||
GpuSpec("RTX 3050", 4, 4, "Ampere", "nvidia"),
|
||||
# NVIDIA Turing — tier 3
|
||||
GpuSpec("RTX 2080", 8, 3, "Turing", "nvidia"),
|
||||
GpuSpec("RTX 2070", 8, 3, "Turing", "nvidia"),
|
||||
GpuSpec("RTX 2060", 6, 3, "Turing", "nvidia"),
|
||||
GpuSpec("GTX 1660 Ti", 6, 3, "Turing", "nvidia"),
|
||||
GpuSpec("GTX 1660", 6, 3, "Turing", "nvidia"),
|
||||
GpuSpec("GTX 1650 Ti", 4, 3, "Turing", "nvidia"),
|
||||
GpuSpec("GTX 1650", 4, 3, "Turing", "nvidia"),
|
||||
# NVIDIA Pascal — tier 2
|
||||
GpuSpec("GTX 1080", 8, 2, "Pascal", "nvidia"),
|
||||
GpuSpec("GTX 1070", 8, 2, "Pascal", "nvidia"),
|
||||
GpuSpec("GTX 1060", 6, 2, "Pascal", "nvidia"),
|
||||
GpuSpec("GTX 1050 Ti", 4, 2, "Pascal", "nvidia"),
|
||||
GpuSpec("GTX 1050", 4, 2, "Pascal", "nvidia"),
|
||||
# AMD RDNA3 — tier 5
|
||||
GpuSpec("RX 7900M", 16, 5, "RDNA3", "amd"),
|
||||
GpuSpec("RX 7700S", 8, 5, "RDNA3", "amd"),
|
||||
GpuSpec("RX 7600M XT", 8, 5, "RDNA3", "amd"),
|
||||
GpuSpec("RX 7600S", 8, 5, "RDNA3", "amd"),
|
||||
GpuSpec("RX 7600M", 8, 5, "RDNA3", "amd"),
|
||||
# AMD RDNA2 — tier 4
|
||||
GpuSpec("RX 6850M XT", 12, 4, "RDNA2", "amd"),
|
||||
GpuSpec("RX 6800S", 12, 4, "RDNA2", "amd"),
|
||||
GpuSpec("RX 6800M", 12, 4, "RDNA2", "amd"),
|
||||
GpuSpec("RX 6700S", 10, 4, "RDNA2", "amd"),
|
||||
GpuSpec("RX 6700M", 10, 4, "RDNA2", "amd"),
|
||||
GpuSpec("RX 6650M", 8, 4, "RDNA2", "amd"),
|
||||
GpuSpec("RX 6600S", 8, 4, "RDNA2", "amd"),
|
||||
GpuSpec("RX 6600M", 8, 4, "RDNA2", "amd"),
|
||||
GpuSpec("RX 6500M", 4, 4, "RDNA2", "amd"),
|
||||
# AMD RDNA1 — tier 3
|
||||
GpuSpec("RX 5700M", 8, 3, "RDNA1", "amd"),
|
||||
GpuSpec("RX 5600M", 6, 3, "RDNA1", "amd"),
|
||||
GpuSpec("RX 5500M", 4, 3, "RDNA1", "amd"),
|
||||
# Intel Arc Alchemist — tier 4 (improving ROCm/IPEX-LLM support)
|
||||
GpuSpec("Arc A770M", 16, 4, "Alchemist", "intel"),
|
||||
GpuSpec("Arc A550M", 8, 4, "Alchemist", "intel"),
|
||||
GpuSpec("Arc A370M", 4, 4, "Alchemist", "intel"),
|
||||
GpuSpec("Arc A350M", 4, 4, "Alchemist", "intel"),
|
||||
]
|
||||
|
||||
|
||||
def _build_patterns() -> list[tuple[re.Pattern[str], GpuSpec]]:
|
||||
"""Compile regex patterns, sorted longest-model-name first to prevent prefix shadowing."""
|
||||
result = []
|
||||
for spec in sorted(_GPU_DB, key=lambda s: -len(s.model)):
|
||||
# Allow optional space or hyphen between tokens (e.g. "RTX3070" or "RTX-3070")
|
||||
escaped = re.escape(spec.model).replace(r"\ ", r"[\s\-]?")
|
||||
result.append((re.compile(escaped, re.IGNORECASE), spec))
|
||||
return result
|
||||
|
||||
|
||||
_PATTERNS: list[tuple[re.Pattern[str], GpuSpec]] = _build_patterns()
|
||||
|
||||
|
||||
def parse_gpu(title: str) -> GpuSpec | None:
|
||||
"""Return the first GPU model found in a listing title, or None."""
|
||||
for pattern, spec in _PATTERNS:
|
||||
if pattern.search(title):
|
||||
return spec
|
||||
return None
|
||||
|
||||
|
||||
def score_gpu(spec: GpuSpec, vram_weight: float, arch_weight: float) -> GpuMatch:
|
||||
"""Compute normalized inference value scores for a GPU spec.
|
||||
|
||||
vram_score: linear scale, 24 GB anchors at 100. Capped at 100.
|
||||
arch_score: linear scale, tier 1 = 0, tier 5 = 100.
|
||||
inference_score: weighted average of both, normalized to the total weight.
|
||||
"""
|
||||
vram_score = min(100.0, (spec.vram_gb / 24.0) * 100.0)
|
||||
arch_score = ((spec.arch_tier - 1) / 4.0) * 100.0
|
||||
|
||||
total_weight = vram_weight + arch_weight
|
||||
if total_weight <= 0:
|
||||
inference_score = 0.0
|
||||
else:
|
||||
inference_score = (
|
||||
vram_score * vram_weight + arch_score * arch_weight
|
||||
) / total_weight
|
||||
|
||||
return GpuMatch(
|
||||
spec=spec,
|
||||
vram_score=round(vram_score, 1),
|
||||
arch_score=round(arch_score, 1),
|
||||
inference_score=round(inference_score, 1),
|
||||
)
|
||||
262
app/mcp/server.py
Normal file
262
app/mcp/server.py
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
"""Snipe MCP Server — eBay search with trust scoring and GPU inference-value ranking.
|
||||
|
||||
Exposes three tools to Claude:
|
||||
snipe_search — search eBay via Snipe, GPU-scored and trust-ranked
|
||||
snipe_enrich — deep seller/listing enrichment for a specific result
|
||||
snipe_save — persist a productive search for ongoing monitoring
|
||||
|
||||
Run with:
|
||||
python -m app.mcp.server
|
||||
(from /Library/Development/CircuitForge/snipe with cf conda env active)
|
||||
|
||||
Configure in Claude Code ~/.claude.json:
|
||||
"snipe": {
|
||||
"command": "/devl/miniconda3/envs/cf/bin/python",
|
||||
"args": ["-m", "app.mcp.server"],
|
||||
"cwd": "/Library/Development/CircuitForge/snipe",
|
||||
"env": { "SNIPE_API_URL": "http://localhost:8510" }
|
||||
}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import TextContent, Tool
|
||||
|
||||
_SNIPE_API = os.environ.get("SNIPE_API_URL", "http://localhost:8510")
|
||||
_TIMEOUT = 120.0
|
||||
|
||||
server = Server("snipe")
|
||||
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
return [
|
||||
Tool(
|
||||
name="snipe_search",
|
||||
description=(
|
||||
"Search eBay listings via Snipe. Returns results condensed for LLM reasoning, "
|
||||
"sorted by composite value: trust_score × gpu_inference_score / price. "
|
||||
"GPU inference_score weights VRAM and architecture tier — tune with vram_weight/arch_weight. "
|
||||
"Use must_include_mode='groups' with pipe-separated OR alternatives for broad GPU coverage "
|
||||
"(e.g. 'rtx 3060|rtx 3070|rtx 3080'). "
|
||||
"Laptop Motherboard category ID: 177946."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"required": ["query"],
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Base eBay search keywords, e.g. 'laptop motherboard'",
|
||||
},
|
||||
"must_include": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Comma-separated AND groups; use | for OR within a group. "
|
||||
"E.g. 'rtx 3060|rtx 3070|rx 6700m, 8gb|12gb|16gb'"
|
||||
),
|
||||
},
|
||||
"must_include_mode": {
|
||||
"type": "string",
|
||||
"enum": ["all", "any", "groups"],
|
||||
"default": "groups",
|
||||
"description": "groups: pipe=OR comma=AND. Recommended for multi-GPU searches.",
|
||||
},
|
||||
"must_exclude": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Comma-separated terms to exclude. "
|
||||
"Suggested: 'broken,cracked,no post,for parts,parts only,untested,"
|
||||
"lcd,screen,chassis,housing,bios locked'"
|
||||
),
|
||||
},
|
||||
"max_price": {
|
||||
"type": "number",
|
||||
"default": 0,
|
||||
"description": "Max price USD (0 = no limit)",
|
||||
},
|
||||
"min_price": {
|
||||
"type": "number",
|
||||
"default": 0,
|
||||
"description": "Min price USD (0 = no limit)",
|
||||
},
|
||||
"pages": {
|
||||
"type": "integer",
|
||||
"default": 2,
|
||||
"description": "Pages of eBay results to fetch (1 page ≈ 50 listings)",
|
||||
},
|
||||
"category_id": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": (
|
||||
"eBay category ID. "
|
||||
"177946 = Laptop Motherboards & System Boards. "
|
||||
"27386 = Graphics Cards (PCIe, for price comparison). "
|
||||
"Leave empty to search all categories."
|
||||
),
|
||||
},
|
||||
"vram_weight": {
|
||||
"type": "number",
|
||||
"default": 0.6,
|
||||
"description": (
|
||||
"0–1. Weight of VRAM in GPU inference score. "
|
||||
"Higher = VRAM is primary ranking factor. "
|
||||
"Use 1.0 to rank purely by VRAM (ignores arch generation)."
|
||||
),
|
||||
},
|
||||
"arch_weight": {
|
||||
"type": "number",
|
||||
"default": 0.4,
|
||||
"description": (
|
||||
"0–1. Weight of architecture generation in GPU inference score. "
|
||||
"Higher = prefer newer GPU arch (Ada > Ampere > Turing etc.). "
|
||||
"Use 0.0 to ignore arch and rank purely by VRAM."
|
||||
),
|
||||
},
|
||||
"top_n": {
|
||||
"type": "integer",
|
||||
"default": 20,
|
||||
"description": "Max results to return after sorting",
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="snipe_enrich",
|
||||
description=(
|
||||
"Deep-dive enrichment for a specific seller + listing. "
|
||||
"Runs BTF scraping and category history to fill partial trust scores (~20s). "
|
||||
"Use when snipe_search returns trust_partial=true on a promising listing."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"required": ["seller_id", "listing_id"],
|
||||
"properties": {
|
||||
"seller_id": {
|
||||
"type": "string",
|
||||
"description": "eBay seller platform ID (from snipe_search result seller_id field)",
|
||||
},
|
||||
"listing_id": {
|
||||
"type": "string",
|
||||
"description": "eBay listing platform ID (from snipe_search result id field)",
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Original search query — provides market comp context for re-scoring",
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="snipe_save",
|
||||
description="Persist a productive search for ongoing monitoring in the Snipe UI.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"required": ["name", "query"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable label, e.g. 'RTX 3070+ laptop boards under $250'",
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The eBay search query string",
|
||||
},
|
||||
"filters_json": {
|
||||
"type": "string",
|
||||
"default": "{}",
|
||||
"description": "JSON string of filter params to preserve (max_price, must_include, etc.)",
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
if name == "snipe_search":
|
||||
return await _search(arguments)
|
||||
if name == "snipe_enrich":
|
||||
return await _enrich(arguments)
|
||||
if name == "snipe_save":
|
||||
return await _save(arguments)
|
||||
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
||||
|
||||
|
||||
async def _search(args: dict) -> list[TextContent]:
|
||||
from app.mcp.formatters import format_results
|
||||
|
||||
# Build params — omit empty strings and zero numerics (except q)
|
||||
raw = {
|
||||
"q": args.get("query", ""),
|
||||
"must_include": args.get("must_include", ""),
|
||||
"must_include_mode": args.get("must_include_mode", "groups"),
|
||||
"must_exclude": args.get("must_exclude", ""),
|
||||
"max_price": args.get("max_price", 0),
|
||||
"min_price": args.get("min_price", 0),
|
||||
"pages": args.get("pages", 2),
|
||||
"category_id": args.get("category_id", ""),
|
||||
}
|
||||
params = {k: v for k, v in raw.items() if v != "" and v != 0 or k == "q"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
resp = await client.get(f"{_SNIPE_API}/api/search", params=params)
|
||||
resp.raise_for_status()
|
||||
|
||||
formatted = format_results(
|
||||
resp.json(),
|
||||
vram_weight=float(args.get("vram_weight", 0.6)),
|
||||
arch_weight=float(args.get("arch_weight", 0.4)),
|
||||
top_n=int(args.get("top_n", 20)),
|
||||
)
|
||||
return [TextContent(type="text", text=json.dumps(formatted, indent=2))]
|
||||
|
||||
|
||||
async def _enrich(args: dict) -> list[TextContent]:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
resp = await client.post(
|
||||
f"{_SNIPE_API}/api/enrich",
|
||||
params={
|
||||
"seller": args["seller_id"],
|
||||
"listing_id": args["listing_id"],
|
||||
"query": args.get("query", ""),
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return [TextContent(type="text", text=json.dumps(resp.json(), indent=2))]
|
||||
|
||||
|
||||
async def _save(args: dict) -> list[TextContent]:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
resp = await client.post(
|
||||
f"{_SNIPE_API}/api/saved-searches",
|
||||
json={
|
||||
"name": args["name"],
|
||||
"query": args["query"],
|
||||
"filters_json": args.get("filters_json", "{}"),
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return [TextContent(type="text", text=f"Saved (id={data.get('id')}): {args['name']}")]
|
||||
|
||||
|
||||
async def _main() -> None:
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
server.create_initialization_options(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(_main())
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
# What this adds over compose.yml:
|
||||
# - Live source mounts so code changes take effect without rebuilding images
|
||||
# - RELOAD=true to enable uvicorn --reload for the API
|
||||
# - NOTE: circuitforge-core is NOT mounted here — use `./manage.sh build` to
|
||||
# - cf-orch-agent sidecar for local GPU task routing (opt-in: --profile orch)
|
||||
#
|
||||
# NOTE: circuitforge-core is NOT mounted here — use `./manage.sh build` to
|
||||
# pick up cf-core changes. Mounting it as a bind volume would break self-hosted
|
||||
# installs that don't have the sibling directory.
|
||||
services:
|
||||
|
|
@ -15,3 +17,32 @@ services:
|
|||
- ./tests:/app/snipe/tests
|
||||
environment:
|
||||
- RELOAD=true
|
||||
# Point the LLM/vision task scheduler at the local cf-orch coordinator.
|
||||
# Only has effect when CF_ORCH_URL is set (uncomment in .env, or set inline).
|
||||
# - CF_ORCH_URL=http://10.1.10.71:7700
|
||||
|
||||
# cf-orch agent — routes trust_photo_analysis vision tasks to the GPU coordinator.
|
||||
# Only starts when you pass --profile orch:
|
||||
# docker compose --profile orch up
|
||||
#
|
||||
# Requires a running cf-orch coordinator. Default: Heimdall at 10.1.10.71:7700.
|
||||
# Override via CF_ORCH_COORDINATOR_URL in .env.
|
||||
#
|
||||
# To use a locally-built cf-orch image instead of the published one:
|
||||
# build:
|
||||
# context: ../circuitforge-orch
|
||||
# dockerfile: Dockerfile
|
||||
cf-orch-agent:
|
||||
image: ghcr.io/circuitforgellc/cf-orch:latest
|
||||
command: >
|
||||
agent
|
||||
--coordinator ${CF_ORCH_COORDINATOR_URL:-http://10.1.10.71:7700}
|
||||
--node-id snipe-dev
|
||||
--host 0.0.0.0
|
||||
--port 7701
|
||||
--advertise-host 127.0.0.1
|
||||
environment:
|
||||
CF_COORDINATOR_URL: ${CF_ORCH_COORDINATOR_URL:-http://10.1.10.71:7700}
|
||||
restart: on-failure
|
||||
profiles:
|
||||
- orch
|
||||
|
|
|
|||
39
docs/getting-started/ebay-api.md
Normal file
39
docs/getting-started/ebay-api.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# eBay API Keys (Optional)
|
||||
|
||||
Snipe works without any credentials using its Playwright scraper fallback. Adding eBay API credentials unlocks faster searches and higher rate limits.
|
||||
|
||||
## What API keys enable
|
||||
|
||||
| Feature | Without keys | With keys |
|
||||
|---------|-------------|-----------|
|
||||
| Listing search | Playwright scraper | eBay Browse API (faster, higher limits) |
|
||||
| Market comps (completed sales) | Not available | eBay Marketplace Insights API |
|
||||
| Seller account data | BTF scraper (Xvfb) | BTF scraper (same — eBay API doesn't expose join date) |
|
||||
|
||||
## Getting credentials
|
||||
|
||||
1. Create a developer account at [developer.ebay.com](https://developer.ebay.com/my/keys)
|
||||
2. Create a new application (choose **Production**)
|
||||
3. Copy your **App ID (Client ID)** and **Cert ID (Client Secret)**
|
||||
|
||||
## Configuration
|
||||
|
||||
Add your credentials to `.env`:
|
||||
|
||||
```bash
|
||||
EBAY_APP_ID=YourAppID-...
|
||||
EBAY_CERT_ID=YourCertID-...
|
||||
```
|
||||
|
||||
Then restart:
|
||||
|
||||
```bash
|
||||
./manage.sh restart
|
||||
```
|
||||
|
||||
## Verifying
|
||||
|
||||
After restart, the search bar shows **API** as available in the data source selector. The auto mode will use the API by default.
|
||||
|
||||
!!! note
|
||||
The Marketplace Insights API (for completed sales comps) requires an approved eBay developer account. New accounts may not have access. Snipe gracefully falls back to Browse API results when Insights returns 403 or 404.
|
||||
102
docs/getting-started/installation.md
Normal file
102
docs/getting-started/installation.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Installation
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker with Compose plugin
|
||||
- Git
|
||||
- No API keys required to get started
|
||||
|
||||
## One-line install
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/raw/branch/main/install.sh)
|
||||
```
|
||||
|
||||
This clones the repo to `~/snipe` and starts the stack. Open **http://localhost:8509** when it completes.
|
||||
|
||||
## Manual install
|
||||
|
||||
Snipe's API image is built from a context that includes `circuitforge-core`. Both repos must sit as siblings:
|
||||
|
||||
```
|
||||
workspace/
|
||||
├── snipe/ ← this repo
|
||||
└── circuitforge-core/ ← required sibling
|
||||
```
|
||||
|
||||
```bash
|
||||
mkdir snipe-workspace && cd snipe-workspace
|
||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/snipe.git
|
||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git
|
||||
cd snipe
|
||||
cp .env.example .env
|
||||
./manage.sh start
|
||||
```
|
||||
|
||||
## Managing the stack
|
||||
|
||||
```bash
|
||||
./manage.sh start # build and start all containers
|
||||
./manage.sh stop # stop containers
|
||||
./manage.sh restart # rebuild and restart
|
||||
./manage.sh status # container health
|
||||
./manage.sh logs # tail logs
|
||||
./manage.sh open # open in browser
|
||||
```
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
git pull
|
||||
./manage.sh restart
|
||||
```
|
||||
|
||||
## Ports
|
||||
|
||||
| Service | Default port |
|
||||
|---------|-------------|
|
||||
| Web UI | 8509 |
|
||||
| API | 8510 |
|
||||
|
||||
Both ports are configurable in `.env`.
|
||||
|
||||
---
|
||||
|
||||
## No-Docker install (bare metal)
|
||||
|
||||
Run `install.sh --bare-metal` to skip Docker and install via conda or venv instead.
|
||||
This sets up the Python environment, builds the Vue frontend, and writes helper scripts.
|
||||
|
||||
**Requirements:** Python 3.11+, Node.js 20+, `xvfb` (for the eBay scraper).
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/raw/branch/main/install.sh) --bare-metal
|
||||
```
|
||||
|
||||
After install, you get two scripts:
|
||||
|
||||
| Script | What it does |
|
||||
|--------|-------------|
|
||||
| `./start-local.sh` | Start the FastAPI API on port 8510 |
|
||||
| `./serve-ui.sh` | Serve the built frontend with `python3 -m http.server 8509` (dev only) |
|
||||
|
||||
`serve-ui.sh` is single-threaded and suitable for testing only. For a real deployment, use nginx.
|
||||
|
||||
### nginx config (production bare-metal)
|
||||
|
||||
Install nginx, copy the sample config, and reload:
|
||||
|
||||
```bash
|
||||
sudo cp docs/nginx-self-hosted.conf /etc/nginx/sites-available/snipe
|
||||
sudo ln -s /etc/nginx/sites-available/snipe /etc/nginx/sites-enabled/snipe
|
||||
# Edit the file — update `root` to your actual web/dist path
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
See [`docs/nginx-self-hosted.conf`](../nginx-self-hosted.conf) for the full config with TLS notes.
|
||||
|
||||
### Chromium / Xvfb note
|
||||
|
||||
Snipe uses headed Chromium via Xvfb to bypass Kasada (the anti-bot layer on eBay seller profile pages). If Chromium is not detected, the scraper falls back to the eBay Browse API — add `EBAY_APP_ID` / `EBAY_CERT_ID` to `.env` so that fallback has credentials.
|
||||
|
||||
The installer detects and installs Xvfb automatically on Debian/Ubuntu/Fedora. Chromium is installed via `playwright install chromium`. macOS is not supported for the scraper path.
|
||||
39
docs/getting-started/quick-start.md
Normal file
39
docs/getting-started/quick-start.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Quick Start
|
||||
|
||||
## 1. Run a search
|
||||
|
||||
Type a query into the search bar and press **Search** or hit Enter.
|
||||
|
||||
!!! tip
|
||||
Start broad (`vintage camera`) then narrow with keyword filters once you see results. The must-include and must-exclude fields let you refine without re-searching from scratch.
|
||||
|
||||
## 2. Read the trust badge
|
||||
|
||||
Each listing card shows a trust badge in the top-right corner:
|
||||
|
||||
| Badge | Meaning |
|
||||
|-------|---------|
|
||||
| Green (70–100) | Established seller, no major concerns |
|
||||
| Yellow (40–69) | Some signals missing or marginal |
|
||||
| Red (0–39) | Multiple red flags — proceed carefully |
|
||||
| `STEAL` label | Price significantly below market median |
|
||||
|
||||
A spinning indicator below the badge means enrichment is still in progress (account age is being fetched). Scores update automatically when enrichment completes.
|
||||
|
||||
## 3. Check red flags
|
||||
|
||||
Red flag pills appear below the listing title when Snipe detects a concern. Hover or tap a flag for a plain-language explanation.
|
||||
|
||||
## 4. Click through to eBay
|
||||
|
||||
Listing titles link directly to eBay. In cloud mode, links include an affiliate code that supports Snipe's development at no cost to you. You can opt out in Settings.
|
||||
|
||||
## 5. Filter results
|
||||
|
||||
Use the sidebar filters to narrow results without re-running the eBay search:
|
||||
|
||||
- **Min trust score** — slider to hide low-confidence listings
|
||||
- **Min account age / Min feedback** — hide new or low-volume sellers
|
||||
- **Hide listings checkboxes** — hide new accounts, suspicious prices, duplicate photos, damage mentions, long-on-market, significant price drop
|
||||
|
||||
These filters apply instantly to the current result set. Use the search bar to change the underlying eBay query.
|
||||
33
docs/index.md
Normal file
33
docs/index.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Snipe
|
||||
|
||||
**eBay trust scoring before you bid.**
|
||||
|
||||

|
||||
|
||||
Snipe scores eBay listings and sellers for trustworthiness before you place a bid. Paste a search query, get results with trust scores, and know exactly which listings are worth your time.
|
||||
|
||||
## What it catches
|
||||
|
||||
- **New accounts** selling high-value items with no track record
|
||||
- **Suspicious prices** — listings priced far below completed sales
|
||||
- **Duplicate photos** — images copy-pasted from other listings (perceptual hash deduplication)
|
||||
- **Damage buried in titles** — scratch, dent, untested, for parts, and similar
|
||||
- **Known bad actors** — sellers on the community blocklist
|
||||
|
||||
## How it works
|
||||
|
||||

|
||||
|
||||
Each listing gets a composite trust score from 0–100 based on five seller signals: account age, feedback count, feedback ratio, price vs. market, and category history. Red flags are surfaced alongside the score, not buried in it.
|
||||
|
||||
## Free, no account required
|
||||
|
||||
Search and scoring work without creating an account. Community features (reporting sellers, importing blocklists) require a free account.
|
||||
|
||||
## Quick links
|
||||
|
||||
- [Installation](getting-started/installation.md)
|
||||
- [Understanding trust scores](user-guide/trust-scores.md)
|
||||
- [Red flags reference](user-guide/red-flags.md)
|
||||
- [Cloud demo](https://menagerie.circuitforge.tech/snipe)
|
||||
- [Source code](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe)
|
||||
58
docs/nginx-self-hosted.conf
Normal file
58
docs/nginx-self-hosted.conf
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# nginx config for Snipe — bare-metal self-hosted (no Docker).
|
||||
#
|
||||
# Usage:
|
||||
# sudo cp docs/nginx-self-hosted.conf /etc/nginx/sites-available/snipe
|
||||
# # Edit: update `root` to your actual web/dist path and `server_name` to your hostname
|
||||
# sudo ln -s /etc/nginx/sites-available/snipe /etc/nginx/sites-enabled/snipe
|
||||
# sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# Assumes:
|
||||
# - The Snipe FastAPI API is running on 127.0.0.1:8510 (./start-local.sh)
|
||||
# - The Vue frontend was built by install.sh into web/dist/
|
||||
# - TLS termination is handled separately (Caddy, certbot, or upstream proxy)
|
||||
#
|
||||
# For TLS with Let's Encrypt, run:
|
||||
# sudo certbot --nginx -d your.domain.com
|
||||
# Certbot will add the ssl_certificate lines automatically.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name your.domain.com; # replace or use _ for catch-all
|
||||
|
||||
# Path to the Vue production build — update to match your install directory
|
||||
root /home/youruser/snipe/snipe/web/dist;
|
||||
index index.html;
|
||||
|
||||
# Proxy all /api/ requests to the FastAPI backend
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8510;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# SSE (Server-Sent Events) — live trust score updates
|
||||
# These are long-lived streaming responses; disable buffering.
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# index.html — never cache; ensures clients always get the latest entry point
|
||||
# after a deployment (JS/CSS chunks are content-hashed so they cache forever)
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
# SPA fallback — all unknown paths serve index.html so Vue Router handles routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Long-term cache for content-hashed static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
66
docs/reference/architecture.md
Normal file
66
docs/reference/architecture.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Architecture
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Frontend | Vue 3, Pinia, UnoCSS |
|
||||
| API | FastAPI (Python), host networking |
|
||||
| Database | SQLite (WAL mode) |
|
||||
| Scraper | Playwright + Chromium + Xvfb |
|
||||
| Container | Docker Compose |
|
||||
|
||||
## Data flow
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
User -->|search query| VueSPA
|
||||
VueSPA -->|GET /api/search| FastAPI
|
||||
FastAPI -->|Browse API or Playwright| eBay
|
||||
eBay --> FastAPI
|
||||
FastAPI -->|score_batch| TrustScorer
|
||||
TrustScorer --> FastAPI
|
||||
FastAPI -->|BTF enrich queue| XvfbScraper
|
||||
XvfbScraper -->|seller profile| eBayProfile
|
||||
eBayProfile --> XvfbScraper
|
||||
XvfbScraper -->|account_age update| SQLite
|
||||
FastAPI -->|SSE push| VueSPA
|
||||
```
|
||||
|
||||
## Database layout
|
||||
|
||||
Snipe uses two SQLite databases in cloud mode:
|
||||
|
||||
| Database | Contents |
|
||||
|----------|---------|
|
||||
| `shared.db` | Sellers, listings, market comps, community signals, scammer blocklist |
|
||||
| `user.db` | Trust scores, saved searches, user preferences, background tasks |
|
||||
|
||||
In local (self-hosted) mode, everything uses a single `snipe.db`.
|
||||
|
||||
WAL (Write-Ahead Logging) mode is enabled on all connections for concurrent reader safety.
|
||||
|
||||
## Seller enrichment pipeline
|
||||
|
||||
eBay's Browse API returns listings without seller account ages. Snipe fetches account ages by loading the seller's eBay profile page in a headed Chromium instance via Xvfb.
|
||||
|
||||
Each enrichment session uses a unique Xvfb display number (`:200`–`:299`, cycling) to prevent lock file collisions across parallel sessions. Kasada bot protection blocks headless Chrome and curl-based requests — only a full headed browser session passes.
|
||||
|
||||
## Affiliate URL wrapping
|
||||
|
||||
All listing URLs are wrapped with an eBay Partner Network (EPN) affiliate code before being returned to the frontend. Resolution order:
|
||||
|
||||
1. User opted out → plain URL
|
||||
2. User has BYOK EPN ID (Premium) → wrap with user's ID
|
||||
3. CF affiliate ID configured in `.env` → wrap with CF's ID
|
||||
4. Not configured → plain URL
|
||||
|
||||
## Licensing
|
||||
|
||||
| Layer | License |
|
||||
|-------|---------|
|
||||
| Discovery pipeline (scraper, trust scoring, search) | MIT |
|
||||
| AI features (photo analysis, description reasoning) | BSL 1.1 |
|
||||
| Fine-tuned model weights | Proprietary |
|
||||
|
||||
BSL 1.1 is free for personal non-commercial self-hosting. SaaS re-hosting requires a commercial license. Converts to MIT after 4 years.
|
||||
32
docs/reference/tier-system.md
Normal file
32
docs/reference/tier-system.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Tier System
|
||||
|
||||
Snipe uses Circuit Forge's standard four-tier model.
|
||||
|
||||
## Tiers
|
||||
|
||||
| Tier | Price | Key features |
|
||||
|------|-------|-------------|
|
||||
| **Free** | Free | Search, trust scoring, red flags, blocklist, market comps, affiliate links, saved searches |
|
||||
| **Paid** | $4.99/mo or $129 lifetime | Photo analysis, background monitoring (up to 5 searches), serial number check |
|
||||
| **Premium** | $9.99/mo or $249 lifetime | All Paid features, background monitoring (up to 25), custom affiliate ID (BYOK EPN) |
|
||||
| **Ultra** | Contact us | Human-in-the-loop assistance |
|
||||
|
||||
## Free tier philosophy
|
||||
|
||||
Snipe's core trust-scoring pipeline — the part that actually catches scammers — is entirely free and requires no account. This is intentional.
|
||||
|
||||
More users = more community blocklist data = better protection for everyone. The free tier drives the network effect that makes the paid features more valuable.
|
||||
|
||||
## Self-hosted
|
||||
|
||||
Running Snipe yourself? All features are available with no tier gates in local mode. Bring your own LLM (Ollama compatible) to unlock photo analysis and description reasoning on your own hardware.
|
||||
|
||||
## BYOK (Bring Your Own Key)
|
||||
|
||||
Premium subscribers can supply:
|
||||
- **Local LLM endpoint** — any OpenAI-compatible server (Ollama, vLLM, LM Studio) unlocks AI features on Free tier
|
||||
- **eBay Partner Network campaign ID** — your affiliate revenue instead of Snipe's
|
||||
|
||||
## Cloud trial
|
||||
|
||||
15-day free trial of Paid tier on first signup. No credit card required.
|
||||
84
docs/reference/trust-scoring.md
Normal file
84
docs/reference/trust-scoring.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Trust Score Algorithm
|
||||
|
||||
## Signal scoring
|
||||
|
||||
Each signal contributes 0–20 points to the composite score.
|
||||
|
||||
### account_age
|
||||
|
||||
| Days old | Score |
|
||||
|----------|-------|
|
||||
| < 7 | 0 (triggers `new_account` hard flag) |
|
||||
| 7–30 | 5 |
|
||||
| 30–90 | 10 |
|
||||
| 90–365 | 15 |
|
||||
| > 365 | 20 |
|
||||
|
||||
Data source: eBay profile page (BTF scraper via headed Chromium + Xvfb — eBay API does not expose account registration date).
|
||||
|
||||
### feedback_count
|
||||
|
||||
| Count | Score |
|
||||
|-------|-------|
|
||||
| 0 | 0 (triggers `zero_feedback` hard flag, score capped at 35) |
|
||||
| 1–9 | 5 |
|
||||
| 10–49 | 10 |
|
||||
| 50–199 | 15 |
|
||||
| 200+ | 20 |
|
||||
|
||||
### feedback_ratio
|
||||
|
||||
| Ratio | Score |
|
||||
|-------|-------|
|
||||
| < 80% (with 20+ reviews) | 0 (triggers `established_bad_actor`) |
|
||||
| < 90% | 5 |
|
||||
| 90–94% | 10 |
|
||||
| 95–98% | 15 |
|
||||
| 99–100% | 20 |
|
||||
|
||||
### price_vs_market
|
||||
|
||||
Compares listing price to the median of recent completed sales from eBay Marketplace Insights API.
|
||||
|
||||
| Price vs. median | Score |
|
||||
|-----------------|-------|
|
||||
| < 40% | 0 (triggers `suspicious_price` flag) |
|
||||
| 40–59% | 5 |
|
||||
| 60–79% | 10 |
|
||||
| 80–120% | 20 (normal range) |
|
||||
| 121–149% | 15 |
|
||||
| 150%+ | 10 |
|
||||
|
||||
`suspicious_price` flag is suppressed when the market price distribution is too wide (standard deviation > 50% of median) — this prevents false positives on heterogeneous search results.
|
||||
|
||||
When no market data is available, this signal returns `None` and is excluded from the composite.
|
||||
|
||||
### category_history
|
||||
|
||||
Derived from the seller's recent listing history (categories of their sold items):
|
||||
|
||||
| Result | Score |
|
||||
|--------|-------|
|
||||
| Seller has history in this category | 20 |
|
||||
| Seller sells cross-category (generalist) | 10 |
|
||||
| No category history available | None (excluded from composite) |
|
||||
|
||||
## Composite calculation
|
||||
|
||||
```
|
||||
composite = (sum of available signal scores) / (20 × count of available signals) × 100
|
||||
```
|
||||
|
||||
This ensures missing signals don't penalize a seller — only available signals count toward the denominator.
|
||||
|
||||
## Zero-feedback cap
|
||||
|
||||
When `feedback_count == 0`, the composite is hard-capped at **35** after the standard calculation. A 0-feedback seller cannot score above 35 regardless of other signals.
|
||||
|
||||
## Partial scores
|
||||
|
||||
A score is marked **partial** when one or more signals are `None` (not yet available). The score is recalculated and the partial flag is cleared when enrichment completes.
|
||||
|
||||
## Red flag override
|
||||
|
||||
Red flags are evaluated independently of the composite score. A seller can have a high composite score and still trigger red flags — for example, a long-established seller with a suspicious-priced listing and duplicate photos.
|
||||
BIN
docs/screenshots/01-hero.png
Normal file
BIN
docs/screenshots/01-hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
BIN
docs/screenshots/02-results.png
Normal file
BIN
docs/screenshots/02-results.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
BIN
docs/screenshots/03-steal-badge.png
Normal file
BIN
docs/screenshots/03-steal-badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
docs/screenshots/hero.png
Normal file
BIN
docs/screenshots/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
34
docs/user-guide/blocklist.md
Normal file
34
docs/user-guide/blocklist.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Community Blocklist
|
||||
|
||||
The blocklist is a shared database of sellers flagged by Snipe users. When a blocklisted seller appears in search results, their listing card is marked with an `established_bad_actor` flag.
|
||||
|
||||
## Viewing the blocklist
|
||||
|
||||
Navigate to **Blocklist** in the sidebar to see all reported sellers, with usernames, platforms, and optional reasons.
|
||||
|
||||
## Reporting a seller
|
||||
|
||||
On any listing card, click the **Block** button (shield icon) to report the seller. You can optionally add a reason (e.g. "sent counterfeit item", "never shipped").
|
||||
|
||||
!!! note
|
||||
In cloud mode, blocking requires a signed-in account. Anonymous users can view the blocklist but cannot report sellers.
|
||||
|
||||
## Importing a blocklist
|
||||
|
||||
The Blocklist view has an **Import CSV** button. The accepted format:
|
||||
|
||||
```csv
|
||||
platform,platform_seller_id,username,reason
|
||||
ebay,seller123,seller123,counterfeit item
|
||||
ebay,badactor99,badactor99,
|
||||
```
|
||||
|
||||
The `reason` column is optional. `platform` defaults to `ebay` if omitted.
|
||||
|
||||
## Exporting the blocklist
|
||||
|
||||
Click **Export CSV** in the Blocklist view to download the current blocklist. Use this to back up, share with others, or import into another Snipe instance.
|
||||
|
||||
## Blocklist sync (roadmap)
|
||||
|
||||
Batch reporting to eBay's Trust & Safety team is on the roadmap (issue #4). This would allow community-flagged sellers to be reported directly to eBay from within Snipe.
|
||||
58
docs/user-guide/red-flags.md
Normal file
58
docs/user-guide/red-flags.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Red Flags
|
||||
|
||||
Red flags appear as pills on listing cards when Snipe detects a concern. Each flag is independent — a listing can have multiple flags at once.
|
||||
|
||||
## Hard red flags
|
||||
|
||||
These override the composite score display with a strong visual warning.
|
||||
|
||||
### `zero_feedback`
|
||||
Seller has received zero feedback. Score is capped at 35.
|
||||
|
||||
### `new_account`
|
||||
Account registered within the last 7 days. Extremely high fraud indicator for high-value listings.
|
||||
|
||||
### `established_bad_actor`
|
||||
Feedback ratio below 80% with 20 or more reviews. A sustained pattern of negative feedback from an established seller.
|
||||
|
||||
## Soft flags
|
||||
|
||||
Shown as warnings — not automatic disqualifiers, but worth investigating.
|
||||
|
||||
### `account_under_30_days`
|
||||
Account is less than 30 days old. Less severe than `new_account` but worth noting for high-value items.
|
||||
|
||||
### `low_feedback_count`
|
||||
Fewer than 10 feedback ratings total. Seller is new to eBay or rarely transacts.
|
||||
|
||||
### `suspicious_price`
|
||||
Listing price is more than 50% below the market median from recent completed sales.
|
||||
|
||||
!!! note
|
||||
This flag is suppressed automatically when the search returns a heterogeneous price range — for example, a search that mixes laptop generations spanning $200–$2,000. In that case, the median is not meaningful and flagging would produce false positives.
|
||||
|
||||
### `duplicate_photo`
|
||||
The same image (by perceptual hash) appears on another listing. Common in scams where photos are lifted from legitimate listings.
|
||||
|
||||
### `scratch_dent_mentioned`
|
||||
The title or description contains keywords indicating cosmetic damage, functional problems, or evasive language:
|
||||
|
||||
- Damage: *scratch, dent, crack, chip, broken, damaged*
|
||||
- Functional: *untested, for parts, parts only, as-is, not working*
|
||||
- Evasive: *read description, see description, sold as-is*
|
||||
|
||||
### `long_on_market`
|
||||
The listing has been seen 5 or more times over 14 or more days without selling. A listing that isn't moving may be overpriced or have undisclosed problems.
|
||||
|
||||
### `significant_price_drop`
|
||||
The current price is more than 20% below the price when Snipe first saw this listing. Sudden drops can indicate seller desperation — or a motivated seller — depending on context.
|
||||
|
||||
## Triple Red
|
||||
|
||||
When a listing hits all three of these simultaneously:
|
||||
|
||||
- `new_account` OR `account_under_30_days`
|
||||
- `suspicious_price`
|
||||
- `duplicate_photo` OR `zero_feedback` OR `established_bad_actor` OR `scratch_dent_mentioned`
|
||||
|
||||
The card gets a **pulsing red border glow** to make it impossible to miss in a crowded results grid.
|
||||
56
docs/user-guide/searching.md
Normal file
56
docs/user-guide/searching.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Searching
|
||||
|
||||
## Basic search
|
||||
|
||||
Type a query and press **Search**. Snipe fetches listings from eBay and scores each seller in parallel.
|
||||
|
||||
Result count depends on the **Pages to fetch** setting (1 page = up to 200 listings). More pages means a more complete picture but a longer wait.
|
||||
|
||||
## Keyword modes
|
||||
|
||||
The must-include field has three modes:
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|---------|
|
||||
| **All** | Every term must appear in results (eBay AND search) |
|
||||
| **Any** | At least one term must appear (eBay OR search) |
|
||||
| **Groups** | Comma-separated groups, each searched separately and merged |
|
||||
|
||||
Groups mode is the most powerful. Use it to search for variations that eBay's relevance ranking might drop:
|
||||
|
||||
```
|
||||
16gb, 32gb
|
||||
RTX 4090, 4090 founders
|
||||
```
|
||||
|
||||
This sends two separate eBay queries and deduplicates the results by listing ID.
|
||||
|
||||
## Must-exclude
|
||||
|
||||
Terms in the must-exclude field are forwarded to eBay on re-search. Common uses:
|
||||
|
||||
```
|
||||
broken, parts only, for parts, untested, cracked
|
||||
```
|
||||
|
||||
!!! note
|
||||
Must-exclude applies on re-search (it goes to eBay). The **Hide listings: Scratch/dent mentioned** sidebar filter applies instantly to current results using Snipe's own detection logic, which is more comprehensive than eBay's keyword exclusion.
|
||||
|
||||
## Filters sidebar
|
||||
|
||||
The sidebar has two sections:
|
||||
|
||||
**eBay Search** — settings forwarded to eBay on re-search:
|
||||
- Category filter
|
||||
- Price range (min/max)
|
||||
- Pages to fetch
|
||||
- Data source (Auto / API / Scraper)
|
||||
|
||||
**Filter Results** — applied instantly to current results:
|
||||
- Min trust score slider
|
||||
- Min account age / Min feedback count
|
||||
- Hide listings checkboxes
|
||||
|
||||
## Saved searches
|
||||
|
||||
Click the bookmark icon next to the Search button to save a search with its current filter settings. Saved searches appear in the **Saved** view and can be re-run with one click, restoring all filters.
|
||||
25
docs/user-guide/settings.md
Normal file
25
docs/user-guide/settings.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Settings
|
||||
|
||||
Navigate to **Settings** in the sidebar to access preferences.
|
||||
|
||||
## Community
|
||||
|
||||
### Trust score feedback
|
||||
|
||||
Shows "This score looks right / wrong" buttons on each listing card. Your feedback is recorded anonymously and used to improve trust scoring for all users.
|
||||
|
||||
This is opt-in and enabled by default.
|
||||
|
||||
## Affiliate Links (cloud accounts only)
|
||||
|
||||
### Opt out of affiliate links
|
||||
|
||||
When enabled, listing links go directly to eBay without an affiliate code. Your purchases won't generate revenue for Snipe's development.
|
||||
|
||||
By default, Snipe includes an affiliate code in eBay links at no cost to you — you pay the same price either way.
|
||||
|
||||
### Custom affiliate ID (Premium)
|
||||
|
||||
Premium subscribers can supply their own eBay Partner Network (EPN) campaign ID. When set, your eBay purchases through Snipe links generate revenue for your own EPN account instead of Snipe's.
|
||||
|
||||
This requires an active EPN account at [partnernetwork.ebay.com](https://partnernetwork.ebay.com).
|
||||
39
docs/user-guide/trust-scores.md
Normal file
39
docs/user-guide/trust-scores.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Trust Scores
|
||||
|
||||
## How scoring works
|
||||
|
||||
Each listing gets a composite trust score from 0–100, built from five signals:
|
||||
|
||||
| Signal | Max points | What it measures |
|
||||
|--------|-----------|-----------------|
|
||||
| `account_age` | 20 | Days since the seller's eBay account was registered |
|
||||
| `feedback_count` | 20 | Total feedback received (volume proxy for experience) |
|
||||
| `feedback_ratio` | 20 | Percentage of positive feedback |
|
||||
| `price_vs_market` | 20 | How the listing price compares to recent completed sales |
|
||||
| `category_history` | 20 | Whether the seller has a history in this item category |
|
||||
|
||||
The composite score is the sum of available signals divided by the maximum possible from available signals. Missing signals don't penalize the seller — they reduce the max rather than adding a zero.
|
||||
|
||||
## Score bands
|
||||
|
||||
| Score | Label | Meaning |
|
||||
|-------|-------|---------|
|
||||
| 70–100 | Green | Established seller, no major concerns |
|
||||
| 40–69 | Yellow | Some signals marginal or missing |
|
||||
| 0–39 | Red | Multiple red flags — proceed carefully |
|
||||
|
||||
## Zero-feedback cap
|
||||
|
||||
A seller with zero feedback is hard-capped at a composite score of **35**, regardless of other signals. Zero feedback is the single strongest indicator of a fraudulent or new account, and it would be misleading to allow such a seller to score higher based on price alignment alone.
|
||||
|
||||
## Partial scores
|
||||
|
||||
When account age hasn't yet been enriched (the BTF scraper is still running), the score is marked **partial** and shown with a spinning indicator. Partial scores are based on available signals only and update automatically when enrichment completes — typically within 30–60 seconds per seller.
|
||||
|
||||
## STEAL badge
|
||||
|
||||
The **STEAL** badge appears when a listing's price is significantly below the market median from recently completed sales. This is a useful signal for buyers, but it can also indicate a scam — always cross-reference with the trust score and red flags.
|
||||
|
||||
## Market comps
|
||||
|
||||
Market price data comes from eBay's Marketplace Insights API (completed sales). When this API is unavailable (requires an approved eBay developer account), Snipe falls back to listing prices from the Browse API, which is less accurate. The market price shown in search results reflects whichever source was available.
|
||||
444
install.sh
444
install.sh
|
|
@ -1,226 +1,384 @@
|
|||
#!/usr/bin/env bash
|
||||
# Snipe — self-hosted install script
|
||||
# Snipe — self-hosted installer
|
||||
#
|
||||
# Supports two install paths:
|
||||
# Docker (recommended) — everything in containers, no system Python deps required
|
||||
# No-Docker — conda or venv + direct uvicorn, for machines without Docker
|
||||
# Bare metal — conda or pip venv + uvicorn, for machines without Docker
|
||||
#
|
||||
# Usage:
|
||||
# bash install.sh # installs to ~/snipe
|
||||
# bash install.sh /opt/snipe # custom install directory
|
||||
# bash install.sh ~/snipe --no-docker # force no-Docker path even if Docker present
|
||||
# bash install.sh # interactive (auto-detects Docker)
|
||||
# bash install.sh --docker # Docker Compose setup only
|
||||
# bash install.sh --bare-metal # conda or venv + uvicorn
|
||||
# bash install.sh --help
|
||||
#
|
||||
# Requirements (Docker path): Docker with Compose plugin, Git
|
||||
# Requirements (no-Docker path): Python 3.11+, Node.js 20+, Git, xvfb (system)
|
||||
# No account or API key required. eBay credentials are optional (faster searches).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="${1:-$HOME/snipe}"
|
||||
FORCE_NO_DOCKER="${2:-}"
|
||||
# ── Terminal colours ───────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
DIM='\033[2m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${BLUE}▶${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}✓${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}⚠${NC} $*"; }
|
||||
error() { echo -e "${RED}✗${NC} $*" >&2; }
|
||||
header() { echo; echo -e "${BOLD}$*${NC}"; printf '%0.s─' {1..60}; echo; }
|
||||
dim() { echo -e "${DIM}$*${NC}"; }
|
||||
ask() { echo -e "${CYAN}?${NC} ${BOLD}$*${NC}"; }
|
||||
fail() { error "$*"; exit 1; }
|
||||
|
||||
# ── Paths ──────────────────────────────────────────────────────────────────────
|
||||
SNIPE_CONFIG_DIR="${HOME}/.config/circuitforge"
|
||||
SNIPE_ENV_FILE="${SNIPE_CONFIG_DIR}/snipe.env"
|
||||
SNIPE_VENV_DIR="${SNIPE_CONFIG_DIR}/venv"
|
||||
FORGEJO="https://git.opensourcesolarpunk.com/Circuit-Forge"
|
||||
CONDA_ENV="cf"
|
||||
|
||||
info() { echo " [snipe] $*"; }
|
||||
ok() { echo "✓ $*"; }
|
||||
warn() { echo "! $*"; }
|
||||
fail() { echo "✗ $*" >&2; exit 1; }
|
||||
hr() { echo "────────────────────────────────────────────────────────"; }
|
||||
# Default install directory. Overridable:
|
||||
# SNIPE_DIR=/opt/snipe bash install.sh
|
||||
SNIPE_INSTALL_DIR="${SNIPE_DIR:-${HOME}/snipe}"
|
||||
|
||||
echo ""
|
||||
echo " Snipe — self-hosted installer"
|
||||
echo " Install directory: $INSTALL_DIR"
|
||||
echo ""
|
||||
# ── Argument parsing ───────────────────────────────────────────────────────────
|
||||
MODE_FORCE=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--bare-metal) MODE_FORCE="bare-metal" ;;
|
||||
--docker) MODE_FORCE="docker" ;;
|
||||
--help|-h)
|
||||
echo "Usage: bash install.sh [--docker|--bare-metal|--help]"
|
||||
echo
|
||||
echo " --docker Docker Compose install (recommended)"
|
||||
echo " --bare-metal conda or pip venv + uvicorn"
|
||||
echo " --help Show this message"
|
||||
echo
|
||||
echo " Set SNIPE_DIR=/path to change the install directory (default: ~/snipe)"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "Unknown argument: $arg" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Detect capabilities ──────────────────────────────────────────────────────
|
||||
# ── Banner ─────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BOLD} 🎯 Snipe — eBay listing intelligence${NC}"
|
||||
echo -e "${DIM} Bid with confidence. Privacy-first, no account required.${NC}"
|
||||
echo -e "${DIM} Part of the Circuit Forge LLC suite (BSL 1.1)${NC}"
|
||||
echo
|
||||
|
||||
# ── System checks ──────────────────────────────────────────────────────────────
|
||||
header "System checks"
|
||||
|
||||
HAS_DOCKER=false
|
||||
HAS_CONDA=false
|
||||
HAS_CONDA_CMD=""
|
||||
HAS_PYTHON=false
|
||||
HAS_NODE=false
|
||||
HAS_CHROMIUM=false
|
||||
HAS_XVFB=false
|
||||
|
||||
command -v git >/dev/null 2>&1 || fail "Git is required. Install: sudo apt-get install git"
|
||||
ok "Git found"
|
||||
|
||||
docker compose version >/dev/null 2>&1 && HAS_DOCKER=true
|
||||
conda --version >/dev/null 2>&1 && HAS_CONDA=true
|
||||
python3 --version >/dev/null 2>&1 && HAS_PYTHON=true
|
||||
node --version >/dev/null 2>&1 && HAS_NODE=true
|
||||
command -v git >/dev/null 2>&1 || fail "Git is required. Install with: sudo apt-get install git"
|
||||
if $HAS_DOCKER; then ok "Docker (Compose plugin) found"; fi
|
||||
|
||||
# Honour --no-docker flag
|
||||
[[ "$FORCE_NO_DOCKER" == "--no-docker" ]] && HAS_DOCKER=false
|
||||
# Detect conda / mamba / micromamba in preference order
|
||||
for _c in conda mamba micromamba; do
|
||||
if command -v "$_c" >/dev/null 2>&1; then
|
||||
HAS_CONDA=true
|
||||
HAS_CONDA_CMD="$_c"
|
||||
ok "Conda manager found: $_c"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if $HAS_DOCKER; then
|
||||
INSTALL_PATH="docker"
|
||||
ok "Docker found — using Docker install path (recommended)"
|
||||
# Python 3.11+ check
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
_py_ok=$(python3 -c "import sys; print(sys.version_info >= (3,11))" 2>/dev/null || echo "False")
|
||||
if [[ "$_py_ok" == "True" ]]; then
|
||||
HAS_PYTHON=true
|
||||
ok "Python 3.11+ found ($(python3 --version))"
|
||||
else
|
||||
warn "Python found but version is below 3.11 ($(python3 --version)) — bare-metal path may fail"
|
||||
fi
|
||||
fi
|
||||
|
||||
command -v node >/dev/null 2>&1 && HAS_NODE=true
|
||||
if $HAS_NODE; then ok "Node.js found ($(node --version))"; fi
|
||||
|
||||
# Chromium / Google Chrome — needed for the Kasada-bypass scraper
|
||||
for _chrome in google-chrome chromium-browser chromium; do
|
||||
if command -v "$_chrome" >/dev/null 2>&1; then
|
||||
HAS_CHROMIUM=true
|
||||
ok "Chromium/Chrome found: $_chrome"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if ! $HAS_CHROMIUM; then
|
||||
warn "Chromium / Google Chrome not found."
|
||||
warn "Snipe uses headed Chromium + Xvfb to bypass eBay's Kasada anti-bot."
|
||||
warn "The installer will install Chromium via Playwright. If that fails,"
|
||||
warn "add eBay API credentials to .env to use the API adapter instead."
|
||||
fi
|
||||
|
||||
# Xvfb — virtual framebuffer for headed Chromium on headless servers
|
||||
command -v Xvfb >/dev/null 2>&1 && HAS_XVFB=true
|
||||
if $HAS_XVFB; then ok "Xvfb found"; fi
|
||||
|
||||
# ── Mode selection ─────────────────────────────────────────────────────────────
|
||||
header "Install mode"
|
||||
|
||||
INSTALL_MODE=""
|
||||
if [[ -n "$MODE_FORCE" ]]; then
|
||||
INSTALL_MODE="$MODE_FORCE"
|
||||
info "Mode forced: $INSTALL_MODE"
|
||||
elif $HAS_DOCKER; then
|
||||
INSTALL_MODE="docker"
|
||||
ok "Docker available — using Docker install (recommended)"
|
||||
dim " Pass --bare-metal to override"
|
||||
elif $HAS_PYTHON; then
|
||||
INSTALL_PATH="python"
|
||||
warn "Docker not found — using no-Docker path (conda or venv)"
|
||||
INSTALL_MODE="bare-metal"
|
||||
warn "Docker not found — using bare-metal install"
|
||||
else
|
||||
fail "Docker or Python 3.11+ is required. Install Docker: https://docs.docker.com/get-docker/"
|
||||
fi
|
||||
|
||||
# ── Clone repos ──────────────────────────────────────────────────────────────
|
||||
# ── Clone repos ───────────────────────────────────────────────────────────────
|
||||
header "Clone repositories"
|
||||
|
||||
# compose.yml and the Dockerfile both use context: .. (parent directory), so
|
||||
# snipe/ and circuitforge-core/ must be siblings inside INSTALL_DIR.
|
||||
SNIPE_DIR="$INSTALL_DIR/snipe"
|
||||
CORE_DIR="$INSTALL_DIR/circuitforge-core"
|
||||
# snipe/ and circuitforge-core/ must be siblings inside SNIPE_INSTALL_DIR.
|
||||
REPO_DIR="$SNIPE_INSTALL_DIR"
|
||||
SNIPE_DIR_ACTUAL="$REPO_DIR/snipe"
|
||||
CORE_DIR="$REPO_DIR/circuitforge-core"
|
||||
|
||||
if [[ -d "$SNIPE_DIR" ]]; then
|
||||
info "Snipe already cloned — pulling latest..."
|
||||
git -C "$SNIPE_DIR" pull --ff-only
|
||||
else
|
||||
info "Cloning Snipe..."
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
git clone "$FORGEJO/snipe.git" "$SNIPE_DIR"
|
||||
fi
|
||||
ok "Snipe → $SNIPE_DIR"
|
||||
_clone_or_pull() {
|
||||
local label="$1" url="$2" dest="$3"
|
||||
if [[ -d "$dest/.git" ]]; then
|
||||
info "$label already cloned — pulling latest..."
|
||||
git -C "$dest" pull --ff-only
|
||||
else
|
||||
info "Cloning $label..."
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
git clone "$url" "$dest"
|
||||
fi
|
||||
ok "$label → $dest"
|
||||
}
|
||||
|
||||
if [[ -d "$CORE_DIR" ]]; then
|
||||
info "circuitforge-core already cloned — pulling latest..."
|
||||
git -C "$CORE_DIR" pull --ff-only
|
||||
else
|
||||
info "Cloning circuitforge-core (shared library)..."
|
||||
git clone "$FORGEJO/circuitforge-core.git" "$CORE_DIR"
|
||||
fi
|
||||
ok "circuitforge-core → $CORE_DIR"
|
||||
_clone_or_pull "snipe" "$FORGEJO/snipe.git" "$SNIPE_DIR_ACTUAL"
|
||||
_clone_or_pull "circuitforge-core" "$FORGEJO/circuitforge-core.git" "$CORE_DIR"
|
||||
|
||||
# ── Configure environment ────────────────────────────────────────────────────
|
||||
# ── Config file ────────────────────────────────────────────────────────────────
|
||||
header "Configuration"
|
||||
|
||||
ENV_FILE="$SNIPE_DIR/.env"
|
||||
ENV_FILE="$SNIPE_DIR_ACTUAL/.env"
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
cp "$SNIPE_DIR/.env.example" "$ENV_FILE"
|
||||
# Safe defaults for local installs — no eBay registration, no Heimdall
|
||||
cp "$SNIPE_DIR_ACTUAL/.env.example" "$ENV_FILE"
|
||||
# Disable webhook signature verification for local installs
|
||||
# (no production eBay key yet — the endpoint won't be registered)
|
||||
sed -i 's/^EBAY_WEBHOOK_VERIFY_SIGNATURES=true/EBAY_WEBHOOK_VERIFY_SIGNATURES=false/' "$ENV_FILE"
|
||||
ok ".env created from .env.example"
|
||||
echo ""
|
||||
info "Snipe works out of the box with no API keys."
|
||||
info "Add EBAY_APP_ID / EBAY_CERT_ID later for faster searches (optional)."
|
||||
echo ""
|
||||
echo
|
||||
dim " Snipe works out of the box with no API keys (scraper mode)."
|
||||
dim " Add EBAY_APP_ID / EBAY_CERT_ID later for faster searches (optional)."
|
||||
dim " Edit: $ENV_FILE"
|
||||
echo
|
||||
else
|
||||
info ".env already exists — skipping (delete it to reset)"
|
||||
info ".env already exists — skipping (delete to reset defaults)"
|
||||
fi
|
||||
|
||||
cd "$SNIPE_DIR"
|
||||
# ── License key (optional) ─────────────────────────────────────────────────────
|
||||
header "CircuitForge license key (optional)"
|
||||
dim " Snipe is free to self-host. A Paid/Premium key unlocks cloud features"
|
||||
dim " (photo analysis, eBay OAuth). Skip this if you don't have one."
|
||||
echo
|
||||
ask "Enter your license key, or press Enter to skip:"
|
||||
read -r _license_key || true
|
||||
|
||||
# ── Docker install path ───────────────────────────────────────────────────────
|
||||
if [[ -n "${_license_key:-}" ]]; then
|
||||
_key_re='^CFG-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
|
||||
if echo "$_license_key" | grep -qP "$_key_re" 2>/dev/null || \
|
||||
echo "$_license_key" | grep -qE "$_key_re" 2>/dev/null; then
|
||||
# Append / uncomment Heimdall vars in .env
|
||||
if grep -q "^# HEIMDALL_URL=" "$ENV_FILE" 2>/dev/null; then
|
||||
sed -i "s|^# HEIMDALL_URL=.*|HEIMDALL_URL=https://license.circuitforge.tech|" "$ENV_FILE"
|
||||
else
|
||||
echo "HEIMDALL_URL=https://license.circuitforge.tech" >> "$ENV_FILE"
|
||||
fi
|
||||
# Write or replace CF_LICENSE_KEY
|
||||
if grep -q "^CF_LICENSE_KEY=" "$ENV_FILE" 2>/dev/null; then
|
||||
sed -i "s|^CF_LICENSE_KEY=.*|CF_LICENSE_KEY=${_license_key}|" "$ENV_FILE"
|
||||
else
|
||||
echo "CF_LICENSE_KEY=${_license_key}" >> "$ENV_FILE"
|
||||
fi
|
||||
ok "License key saved to .env"
|
||||
else
|
||||
warn "Key format not recognised (expected CFG-XXXX-XXXX-XXXX-XXXX) — skipping."
|
||||
warn "Edit $ENV_FILE to add it manually."
|
||||
fi
|
||||
else
|
||||
info "No license key entered — self-hosted free tier."
|
||||
fi
|
||||
|
||||
if [[ "$INSTALL_PATH" == "docker" ]]; then
|
||||
# ── Docker install ─────────────────────────────────────────────────────────────
|
||||
_install_docker() {
|
||||
header "Docker install"
|
||||
|
||||
cd "$SNIPE_DIR_ACTUAL"
|
||||
info "Building Docker images (~1 GB download on first run)..."
|
||||
docker compose build
|
||||
|
||||
info "Starting Snipe..."
|
||||
docker compose up -d
|
||||
|
||||
echo ""
|
||||
echo
|
||||
ok "Snipe is running!"
|
||||
hr
|
||||
echo " Web UI: http://localhost:8509"
|
||||
echo " API: http://localhost:8510/docs"
|
||||
echo ""
|
||||
echo " Manage: cd $SNIPE_DIR && ./manage.sh {start|stop|restart|logs|test}"
|
||||
hr
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
printf '%0.s─' {1..60}; echo
|
||||
echo -e " ${GREEN}Web UI:${NC} http://localhost:8509"
|
||||
echo -e " ${GREEN}API:${NC} http://localhost:8510/docs"
|
||||
echo
|
||||
echo -e " ${DIM}Manage: cd $SNIPE_DIR_ACTUAL && ./manage.sh {start|stop|restart|logs|test}${NC}"
|
||||
printf '%0.s─' {1..60}; echo
|
||||
echo
|
||||
}
|
||||
|
||||
# ── No-Docker install path ───────────────────────────────────────────────────
|
||||
|
||||
# System deps: Xvfb is required for Playwright (Kasada bypass via headed Chromium)
|
||||
if ! command -v Xvfb >/dev/null 2>&1; then
|
||||
# ── Bare-metal install ─────────────────────────────────────────────────────────
|
||||
_install_xvfb() {
|
||||
if $HAS_XVFB; then return; fi
|
||||
info "Installing Xvfb (required for eBay scraper)..."
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get install -y --no-install-recommends xvfb
|
||||
ok "Xvfb installed"
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
sudo dnf install -y xorg-x11-server-Xvfb
|
||||
ok "Xvfb installed"
|
||||
elif command -v brew >/dev/null 2>&1; then
|
||||
warn "macOS: Xvfb not available. The scraper fallback may fail."
|
||||
warn "macOS: Xvfb not available via Homebrew."
|
||||
warn "The scraper (Kasada bypass) will not work on macOS."
|
||||
warn "Add eBay API credentials to .env to use the API adapter instead."
|
||||
else
|
||||
warn "Could not install Xvfb automatically. Install it with your package manager."
|
||||
warn "Could not install Xvfb automatically. Install it with your system package manager."
|
||||
warn " Debian/Ubuntu: sudo apt-get install xvfb"
|
||||
warn " Fedora/RHEL: sudo dnf install xorg-x11-server-Xvfb"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Python environment setup ─────────────────────────────────────────────────
|
||||
|
||||
if $HAS_CONDA; then
|
||||
info "Setting up conda environment '$CONDA_ENV'..."
|
||||
if conda env list | grep -q "^$CONDA_ENV "; then
|
||||
info "Conda env '$CONDA_ENV' already exists — updating..."
|
||||
conda run -n "$CONDA_ENV" pip install --quiet -e "$CORE_DIR"
|
||||
conda run -n "$CONDA_ENV" pip install --quiet -e "$SNIPE_DIR"
|
||||
_setup_python_env() {
|
||||
if $HAS_CONDA; then
|
||||
info "Setting up conda environment (manager: $HAS_CONDA_CMD)..."
|
||||
_env_name="cf"
|
||||
if "$HAS_CONDA_CMD" env list 2>/dev/null | grep -q "^${_env_name} "; then
|
||||
info "Conda env '$_env_name' already exists — updating packages..."
|
||||
else
|
||||
"$HAS_CONDA_CMD" create -n "$_env_name" python=3.11 -y
|
||||
fi
|
||||
"$HAS_CONDA_CMD" run -n "$_env_name" pip install --quiet -e "$CORE_DIR"
|
||||
"$HAS_CONDA_CMD" run -n "$_env_name" pip install --quiet -e "$SNIPE_DIR_ACTUAL"
|
||||
"$HAS_CONDA_CMD" run -n "$_env_name" playwright install chromium
|
||||
"$HAS_CONDA_CMD" run -n "$_env_name" playwright install-deps chromium
|
||||
PYTHON_BIN="$HAS_CONDA_CMD run -n $_env_name"
|
||||
ok "Conda environment '$_env_name' ready"
|
||||
else
|
||||
conda create -n "$CONDA_ENV" python=3.11 -y
|
||||
conda run -n "$CONDA_ENV" pip install --quiet -e "$CORE_DIR"
|
||||
conda run -n "$CONDA_ENV" pip install --quiet -e "$SNIPE_DIR"
|
||||
info "Setting up pip venv at $SNIPE_VENV_DIR ..."
|
||||
mkdir -p "$SNIPE_CONFIG_DIR"
|
||||
python3 -m venv "$SNIPE_VENV_DIR"
|
||||
"$SNIPE_VENV_DIR/bin/pip" install --quiet -e "$CORE_DIR"
|
||||
"$SNIPE_VENV_DIR/bin/pip" install --quiet -e "$SNIPE_DIR_ACTUAL"
|
||||
"$SNIPE_VENV_DIR/bin/playwright" install chromium
|
||||
"$SNIPE_VENV_DIR/bin/playwright" install-deps chromium
|
||||
PYTHON_BIN="$SNIPE_VENV_DIR/bin"
|
||||
ok "Python venv ready at $SNIPE_VENV_DIR"
|
||||
fi
|
||||
conda run -n "$CONDA_ENV" playwright install chromium
|
||||
conda run -n "$CONDA_ENV" playwright install-deps chromium
|
||||
PYTHON_RUN="conda run -n $CONDA_ENV"
|
||||
ok "Conda environment '$CONDA_ENV' ready"
|
||||
else
|
||||
info "Setting up Python venv at $SNIPE_DIR/.venv ..."
|
||||
python3 -m venv "$SNIPE_DIR/.venv"
|
||||
"$SNIPE_DIR/.venv/bin/pip" install --quiet -e "$CORE_DIR"
|
||||
"$SNIPE_DIR/.venv/bin/pip" install --quiet -e "$SNIPE_DIR"
|
||||
"$SNIPE_DIR/.venv/bin/playwright" install chromium
|
||||
"$SNIPE_DIR/.venv/bin/playwright" install-deps chromium
|
||||
PYTHON_RUN="$SNIPE_DIR/.venv/bin"
|
||||
ok "Python venv ready at $SNIPE_DIR/.venv"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Frontend ─────────────────────────────────────────────────────────────────
|
||||
|
||||
if $HAS_NODE; then
|
||||
_build_frontend() {
|
||||
if ! $HAS_NODE; then
|
||||
warn "Node.js not found — skipping frontend build."
|
||||
warn "Install Node.js 20+ from https://nodejs.org and re-run install.sh."
|
||||
warn "Until then, access the API at http://localhost:8510/docs"
|
||||
return
|
||||
fi
|
||||
info "Building Vue frontend..."
|
||||
cd "$SNIPE_DIR/web"
|
||||
cd "$SNIPE_DIR_ACTUAL/web"
|
||||
npm ci --prefer-offline --silent
|
||||
npm run build
|
||||
cd "$SNIPE_DIR"
|
||||
cd "$SNIPE_DIR_ACTUAL"
|
||||
ok "Frontend built → web/dist/"
|
||||
else
|
||||
warn "Node.js not found — skipping frontend build."
|
||||
warn "Install Node.js 20+ from https://nodejs.org and re-run install.sh to build the UI."
|
||||
warn "Until then, you can access the API directly at http://localhost:8510/docs"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Write start/stop scripts ─────────────────────────────────────────────────
|
||||
|
||||
cat > "$SNIPE_DIR/start-local.sh" << 'STARTSCRIPT'
|
||||
_write_start_scripts() {
|
||||
# start-local.sh — launches the FastAPI server
|
||||
cat > "$SNIPE_DIR_ACTUAL/start-local.sh" << 'STARTSCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
# Start Snipe without Docker (API only — run from the snipe/ directory)
|
||||
# Start Snipe API (bare-metal / no-Docker mode)
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [[ -f .venv/bin/uvicorn ]]; then
|
||||
UVICORN=".venv/bin/uvicorn"
|
||||
elif command -v conda >/dev/null 2>&1 && conda env list | grep -q "^cf "; then
|
||||
if [[ -f "$HOME/.config/circuitforge/venv/bin/uvicorn" ]]; then
|
||||
UVICORN="$HOME/.config/circuitforge/venv/bin/uvicorn"
|
||||
elif command -v conda >/dev/null 2>&1 && conda env list 2>/dev/null | grep -q "^cf "; then
|
||||
UVICORN="conda run -n cf uvicorn"
|
||||
elif command -v mamba >/dev/null 2>&1 && mamba env list 2>/dev/null | grep -q "^cf "; then
|
||||
UVICORN="mamba run -n cf uvicorn"
|
||||
else
|
||||
echo "No Python env found. Run install.sh first." >&2; exit 1
|
||||
echo "No Snipe Python environment found. Run install.sh first." >&2; exit 1
|
||||
fi
|
||||
|
||||
mkdir -p data
|
||||
echo "Starting Snipe API on http://localhost:8510 ..."
|
||||
$UVICORN api.main:app --host 0.0.0.0 --port 8510 "${@}"
|
||||
echo "Starting Snipe API → http://localhost:8510 ..."
|
||||
exec $UVICORN api.main:app --host 0.0.0.0 --port 8510 "${@}"
|
||||
STARTSCRIPT
|
||||
chmod +x "$SNIPE_DIR/start-local.sh"
|
||||
chmod +x "$SNIPE_DIR_ACTUAL/start-local.sh"
|
||||
|
||||
# Frontend serving (if built)
|
||||
cat > "$SNIPE_DIR/serve-ui.sh" << 'UISCRIPT'
|
||||
# serve-ui.sh — serves the built Vue frontend (dev only)
|
||||
cat > "$SNIPE_DIR_ACTUAL/serve-ui.sh" << 'UISCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
# Serve the pre-built Vue frontend on port 8509 (dev only — use nginx for production)
|
||||
# Serve the pre-built Vue frontend (dev only — use nginx for production).
|
||||
# See docs/nginx-self-hosted.conf for a production nginx config.
|
||||
cd "$(dirname "$0")/web/dist"
|
||||
python3 -m http.server 8509
|
||||
echo "Serving Snipe UI → http://localhost:8509 (Ctrl+C to stop)"
|
||||
exec python3 -m http.server 8509
|
||||
UISCRIPT
|
||||
chmod +x "$SNIPE_DIR/serve-ui.sh"
|
||||
chmod +x "$SNIPE_DIR_ACTUAL/serve-ui.sh"
|
||||
|
||||
echo ""
|
||||
ok "Snipe installed (no-Docker mode)"
|
||||
hr
|
||||
echo " Start API: cd $SNIPE_DIR && ./start-local.sh"
|
||||
echo " Serve UI: cd $SNIPE_DIR && ./serve-ui.sh (separate terminal)"
|
||||
echo " API docs: http://localhost:8510/docs"
|
||||
echo " Web UI: http://localhost:8509 (after ./serve-ui.sh)"
|
||||
echo ""
|
||||
echo " For production, point nginx at web/dist/ and proxy /api/ to localhost:8510"
|
||||
hr
|
||||
echo ""
|
||||
ok "Start scripts written"
|
||||
}
|
||||
|
||||
_install_bare_metal() {
|
||||
header "Bare-metal install"
|
||||
_install_xvfb
|
||||
_setup_python_env
|
||||
_build_frontend
|
||||
_write_start_scripts
|
||||
|
||||
echo
|
||||
ok "Snipe installed (bare-metal mode)"
|
||||
printf '%0.s─' {1..60}; echo
|
||||
echo -e " ${GREEN}Start API:${NC} cd $SNIPE_DIR_ACTUAL && ./start-local.sh"
|
||||
echo -e " ${GREEN}Serve UI:${NC} cd $SNIPE_DIR_ACTUAL && ./serve-ui.sh ${DIM}(separate terminal)${NC}"
|
||||
echo -e " ${GREEN}API docs:${NC} http://localhost:8510/docs"
|
||||
echo -e " ${GREEN}Web UI:${NC} http://localhost:8509 ${DIM}(after ./serve-ui.sh)${NC}"
|
||||
echo
|
||||
echo -e " ${DIM}For production, configure nginx to proxy /api/ to localhost:8510${NC}"
|
||||
echo -e " ${DIM}and serve web/dist/ as the document root.${NC}"
|
||||
echo -e " ${DIM}See: $SNIPE_DIR_ACTUAL/docs/nginx-self-hosted.conf${NC}"
|
||||
printf '%0.s─' {1..60}; echo
|
||||
echo
|
||||
}
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────────────────────────
|
||||
main() {
|
||||
if [[ "$INSTALL_MODE" == "docker" ]]; then
|
||||
_install_docker
|
||||
else
|
||||
_install_bare_metal
|
||||
fi
|
||||
}
|
||||
|
||||
main
|
||||
|
|
|
|||
63
mkdocs.yml
Normal file
63
mkdocs.yml
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
site_name: Snipe
|
||||
site_description: eBay trust scoring before you bid — catch scammers, flag suspicious prices, surface duplicate photos.
|
||||
site_author: Circuit Forge LLC
|
||||
site_url: https://docs.circuitforge.tech/snipe
|
||||
repo_url: https://git.opensourcesolarpunk.com/Circuit-Forge/snipe
|
||||
repo_name: Circuit-Forge/snipe
|
||||
|
||||
theme:
|
||||
name: material
|
||||
palette:
|
||||
- scheme: default
|
||||
primary: deep orange
|
||||
accent: orange
|
||||
toggle:
|
||||
icon: material/brightness-7
|
||||
name: Switch to dark mode
|
||||
- scheme: slate
|
||||
primary: deep orange
|
||||
accent: orange
|
||||
toggle:
|
||||
icon: material/brightness-4
|
||||
name: Switch to light mode
|
||||
features:
|
||||
- navigation.tabs
|
||||
- navigation.sections
|
||||
- navigation.expand
|
||||
- navigation.top
|
||||
- search.suggest
|
||||
- search.highlight
|
||||
- content.code.copy
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- pymdownx.details
|
||||
- pymdownx.superfences:
|
||||
custom_fences:
|
||||
- name: mermaid
|
||||
class: mermaid
|
||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||
- pymdownx.highlight:
|
||||
anchor_linenums: true
|
||||
- pymdownx.tabbed:
|
||||
alternate_style: true
|
||||
- tables
|
||||
- toc:
|
||||
permalink: true
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Getting Started:
|
||||
- Installation: getting-started/installation.md
|
||||
- Quick Start: getting-started/quick-start.md
|
||||
- eBay API Keys (Optional): getting-started/ebay-api.md
|
||||
- User Guide:
|
||||
- Searching: user-guide/searching.md
|
||||
- Trust Scores: user-guide/trust-scores.md
|
||||
- Red Flags: user-guide/red-flags.md
|
||||
- Community Blocklist: user-guide/blocklist.md
|
||||
- Settings: user-guide/settings.md
|
||||
- Reference:
|
||||
- Trust Score Algorithm: reference/trust-scoring.md
|
||||
- Tier System: reference/tier-system.md
|
||||
- Architecture: reference/architecture.md
|
||||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
[project]
|
||||
name = "snipe"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "Auction listing monitor and trust scorer"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
|
@ -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 |
|
|
@ -38,6 +38,7 @@
|
|||
--color-error: #f85149;
|
||||
--color-warning: #d29922;
|
||||
--color-info: #58a6ff;
|
||||
--color-accent: #a478ff; /* purple — csv import badge, secondary accent */
|
||||
|
||||
/* Typography */
|
||||
--font-display: 'Fraunces', Georgia, serif;
|
||||
|
|
@ -49,6 +50,7 @@
|
|||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-12: 3rem;
|
||||
|
|
@ -109,6 +111,7 @@
|
|||
--color-error: #dc2626;
|
||||
--color-warning: #b45309;
|
||||
--color-info: #2563eb;
|
||||
--color-accent: #7c3aed; /* purple — deeper for contrast on cream */
|
||||
|
||||
/* Shadows — lighter, warm tint */
|
||||
--shadow-sm: 0 1px 3px rgba(60, 45, 20, 0.12), 0 1px 2px rgba(60, 45, 20, 0.08);
|
||||
|
|
|
|||
|
|
@ -140,11 +140,13 @@ import { ref, computed, onMounted } from 'vue'
|
|||
|
||||
const props = defineProps<{ currentView?: string }>()
|
||||
|
||||
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
|
||||
|
||||
// Probe once on mount — hidden until confirmed enabled so button never flashes
|
||||
const enabled = ref(false)
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/feedback/status')
|
||||
const res = await fetch(`${apiBase}/api/feedback/status`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
enabled.value = data.enabled === true
|
||||
|
|
@ -205,7 +207,7 @@ async function submit() {
|
|||
loading.value = true
|
||||
submitError.value = ''
|
||||
try {
|
||||
const res = await fetch('/api/feedback', {
|
||||
const res = await fetch(`${apiBase}/api/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
|
@ -237,18 +239,18 @@ async function submit() {
|
|||
/* ── Floating action button ─────────────────────────────────────────── */
|
||||
.feedback-fab {
|
||||
position: fixed;
|
||||
right: var(--spacing-md);
|
||||
bottom: calc(68px + var(--spacing-md)); /* above mobile bottom nav */
|
||||
right: var(--space-4);
|
||||
bottom: calc(68px + var(--space-4)); /* above mobile bottom nav */
|
||||
z-index: 190;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: 9px var(--spacing-md);
|
||||
background: var(--color-bg-elevated);
|
||||
gap: var(--space-2);
|
||||
padding: 9px var(--space-4);
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
|
|
@ -256,9 +258,9 @@ async function submit() {
|
|||
transition: background 0.15s, color 0.15s, box-shadow 0.15s, border-color 0.15s;
|
||||
}
|
||||
.feedback-fab:hover {
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border-focus);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border-color: var(--app-primary);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.feedback-fab-icon { width: 15px; height: 15px; flex-shrink: 0; }
|
||||
|
|
@ -267,7 +269,7 @@ async function submit() {
|
|||
/* On desktop, bottom nav is gone — drop to standard corner */
|
||||
@media (min-width: 769px) {
|
||||
.feedback-fab {
|
||||
bottom: var(--spacing-lg);
|
||||
bottom: var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -286,13 +288,13 @@ async function submit() {
|
|||
@media (min-width: 500px) {
|
||||
.feedback-overlay {
|
||||
align-items: center;
|
||||
padding: var(--spacing-md);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Modal ────────────────────────────────────────────────────────────── */
|
||||
.feedback-modal {
|
||||
background: var(--color-bg-elevated);
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
width: 100%;
|
||||
|
|
@ -300,7 +302,7 @@ async function submit() {
|
|||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
@media (min-width: 500px) {
|
||||
|
|
@ -316,13 +318,13 @@ async function submit() {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-md) var(--spacing-md) var(--spacing-sm);
|
||||
padding: var(--space-4) var(--space-4) var(--space-3);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.feedback-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--font-size-lg);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -337,23 +339,23 @@ async function submit() {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.feedback-close:hover { color: var(--color-text-primary); }
|
||||
.feedback-close:hover { color: var(--color-text); }
|
||||
|
||||
.feedback-body {
|
||||
padding: var(--spacing-md);
|
||||
padding: var(--space-4);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.feedback-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
|
@ -362,23 +364,23 @@ async function submit() {
|
|||
resize: vertical;
|
||||
min-height: 80px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-sm);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.form-required { color: var(--color-error); margin-left: 2px; }
|
||||
|
||||
.feedback-error {
|
||||
color: var(--color-error);
|
||||
font-size: var(--font-size-sm);
|
||||
font-size: 0.8125rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.feedback-success {
|
||||
color: var(--color-success);
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--color-success-bg);
|
||||
border: 1px solid var(--color-success-border);
|
||||
font-size: 0.8125rem;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.feedback-link { color: var(--color-success); font-weight: 600; text-decoration: underline; }
|
||||
|
|
@ -387,15 +389,15 @@ async function submit() {
|
|||
.feedback-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--color-bg-secondary);
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-surface-2);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.feedback-summary-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
.feedback-summary-row > :first-child { min-width: 72px; flex-shrink: 0; }
|
||||
|
|
@ -404,8 +406,115 @@ async function submit() {
|
|||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mt-md { margin-top: var(--spacing-md); }
|
||||
.mt-xs { margin-top: var(--spacing-xs); }
|
||||
.mt-md { margin-top: var(--space-4); }
|
||||
.mt-xs { margin-top: var(--space-2); }
|
||||
|
||||
/* ── Form elements ────────────────────────────────────────────────────── */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
transition: border-color 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--app-primary);
|
||||
}
|
||||
.form-input::placeholder { color: var(--color-text-muted); opacity: 0.7; }
|
||||
|
||||
/* ── Buttons ──────────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--app-primary);
|
||||
color: #fff;
|
||||
border: 1px solid var(--app-primary);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border-color: var(--app-primary);
|
||||
}
|
||||
|
||||
/* ── Filter chips ─────────────────────────────────────────────────────── */
|
||||
.filter-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.btn-chip {
|
||||
padding: 5px var(--space-3);
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.btn-chip.active,
|
||||
.btn-chip:hover {
|
||||
background: color-mix(in srgb, var(--app-primary) 15%, transparent);
|
||||
border-color: var(--app-primary);
|
||||
color: var(--app-primary);
|
||||
}
|
||||
|
||||
/* ── Card ─────────────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* ── Text utilities ───────────────────────────────────────────────────── */
|
||||
.text-muted { color: var(--color-text-muted); }
|
||||
.text-sm { font-size: 0.8125rem; line-height: 1.5; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
|
||||
/* Transition */
|
||||
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.2s ease; }
|
||||
|
|
|
|||
|
|
@ -5,10 +5,25 @@
|
|||
'steal-card': isSteal,
|
||||
'listing-card--auction': isAuction && hoursRemaining !== null && hoursRemaining > 1,
|
||||
'listing-card--triple-red': tripleRed,
|
||||
'listing-card--selected': selected,
|
||||
}"
|
||||
@click="selectMode ? $emit('toggle') : undefined"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="card__thumb">
|
||||
<!-- Selection checkbox — always in DOM; shown on hover or when in select mode -->
|
||||
<button
|
||||
v-show="selectMode || selected"
|
||||
class="card__select-btn"
|
||||
:class="{ 'card__select-btn--checked': selected }"
|
||||
:aria-pressed="selected"
|
||||
:aria-label="selected ? 'Deselect listing' : 'Select listing'"
|
||||
@click.stop="$emit('toggle')"
|
||||
>
|
||||
<svg v-if="selected" viewBox="0 0 12 12" fill="currentColor" width="10" height="10">
|
||||
<path d="M1.5 6L4.5 9L10.5 3" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<img
|
||||
v-if="listing.photo_urls.length"
|
||||
:src="listing.photo_urls[0]"
|
||||
|
|
@ -25,9 +40,13 @@
|
|||
<!-- Main info -->
|
||||
<div class="card__body">
|
||||
<!-- Title row -->
|
||||
<a :href="listing.url" target="_blank" rel="noopener noreferrer" class="card__title">
|
||||
{{ listing.title }}
|
||||
</a>
|
||||
<a
|
||||
:href="listing.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="card__title"
|
||||
@click="selectMode && $event.preventDefault()"
|
||||
>{{ listing.title }}</a>
|
||||
|
||||
<!-- Format + condition badges -->
|
||||
<div class="card__badges">
|
||||
|
|
@ -77,6 +96,7 @@
|
|||
v-model="blockReason"
|
||||
class="card__block-reason"
|
||||
placeholder="Reason (optional)"
|
||||
aria-label="Reason for blocking (optional)"
|
||||
maxlength="200"
|
||||
@keydown.enter="onBlock"
|
||||
@keydown.esc="blockingOpen = false"
|
||||
|
|
@ -96,6 +116,8 @@
|
|||
class="card__trust"
|
||||
:class="[trustClass, { 'card__trust--partial': trust?.score_is_partial }]"
|
||||
:title="trustBadgeTitle"
|
||||
:aria-label="trustBadgeTitle"
|
||||
role="img"
|
||||
>
|
||||
<span class="card__trust-num">{{ trust?.composite_score ?? '?' }}</span>
|
||||
<span class="card__trust-label">Trust</span>
|
||||
|
|
@ -115,6 +137,7 @@
|
|||
class="card__enrich-btn"
|
||||
:class="{ 'card__enrich-btn--spinning': enriching, 'card__enrich-btn--error': enrichError }"
|
||||
:title="enrichError ? 'Enrichment failed — try again' : 'Refresh score now'"
|
||||
:aria-label="enrichError ? 'Enrichment failed, try again' : 'Refresh trust score'"
|
||||
:disabled="enriching"
|
||||
@click.stop="onEnrich"
|
||||
>{{ enrichError ? '✗' : '↻' }}</button>
|
||||
|
|
@ -124,12 +147,15 @@
|
|||
class="card__block-btn"
|
||||
:class="{ 'card__block-btn--active': isBlocked }"
|
||||
:title="isBlocked ? 'Seller is blocked' : 'Block this seller'"
|
||||
:aria-label="isBlocked ? `${seller.username} is blocked` : `Block seller ${seller.username}`"
|
||||
:aria-pressed="isBlocked"
|
||||
@click.stop="isBlocked ? null : (blockingOpen = !blockingOpen)"
|
||||
>⚑</button>
|
||||
</div>
|
||||
|
||||
<!-- Trust feedback: calm "looks right / wrong" signal buttons -->
|
||||
<!-- Trust feedback: opt-in signal buttons (off by default, enabled in Settings) -->
|
||||
<TrustFeedbackButtons
|
||||
v-if="trustSignalEnabled"
|
||||
:seller-id="`ebay::${listing.seller_platform_id}`"
|
||||
:trust="trust"
|
||||
/>
|
||||
|
|
@ -159,14 +185,21 @@ import type { Listing, TrustScore, Seller } from '../stores/search'
|
|||
import { useSearchStore } from '../stores/search'
|
||||
import { useBlocklistStore } from '../stores/blocklist'
|
||||
import TrustFeedbackButtons from './TrustFeedbackButtons.vue'
|
||||
import { useTrustSignalPref } from '../composables/useTrustSignalPref'
|
||||
|
||||
const { enabled: trustSignalEnabled } = useTrustSignalPref()
|
||||
|
||||
const props = defineProps<{
|
||||
listing: Listing
|
||||
trust: TrustScore | null
|
||||
seller: Seller | null
|
||||
marketPrice: number | null
|
||||
selected?: boolean
|
||||
selectMode?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ toggle: [] }>()
|
||||
|
||||
const store = useSearchStore()
|
||||
const blocklist = useBlocklistStore()
|
||||
const enriching = ref(false)
|
||||
|
|
@ -365,17 +398,55 @@ const formattedMarket = computed(() => {
|
|||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
.listing-card--selected {
|
||||
border-color: var(--app-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--app-primary) 30%, transparent);
|
||||
}
|
||||
|
||||
.listing-card:hover .card__select-btn {
|
||||
display: flex !important; /* reveal on hover even when v-show hides it */
|
||||
}
|
||||
|
||||
.card__select-btn {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
left: var(--space-2);
|
||||
z-index: 5;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1.5px solid var(--color-border);
|
||||
background: var(--color-surface-raised);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: background 120ms ease, border-color 120ms ease, opacity 120ms ease;
|
||||
color: #fff;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.card__select-btn:hover,
|
||||
.card__select-btn--checked { opacity: 1; }
|
||||
.card__select-btn:hover { border-color: var(--app-primary); }
|
||||
.card__select-btn--checked {
|
||||
background: var(--app-primary);
|
||||
border-color: var(--app-primary);
|
||||
}
|
||||
|
||||
/* Thumbnail */
|
||||
.card__thumb {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
overflow: visible; /* allow checkbox to poke out */
|
||||
flex-shrink: 0;
|
||||
background: var(--color-surface-raised);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card__img {
|
||||
|
|
@ -442,9 +513,9 @@ const formattedMarket = computed(() => {
|
|||
}
|
||||
|
||||
.card__flag-badge {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
background: color-mix(in srgb, var(--color-error) 15%, transparent);
|
||||
color: var(--color-error);
|
||||
border: 1px solid rgba(248, 81, 73, 0.3);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.6875rem;
|
||||
|
|
@ -563,6 +634,7 @@ const formattedMarket = computed(() => {
|
|||
}
|
||||
.listing-card:hover .card__block-btn { opacity: 0.5; }
|
||||
.listing-card:hover .card__block-btn:hover { opacity: 1; color: var(--color-error); border-color: var(--color-error); }
|
||||
.card__block-btn:focus-visible { opacity: 0.6; outline: 2px solid var(--app-primary); outline-offset: 2px; }
|
||||
.card__block-btn--active { opacity: 1 !important; color: var(--color-error); border-color: var(--color-error); cursor: default; }
|
||||
|
||||
/* Block popover */
|
||||
|
|
@ -688,7 +760,7 @@ const formattedMarket = computed(() => {
|
|||
.listing-card--triple-red:hover {
|
||||
animation: none;
|
||||
border-color: var(--color-error);
|
||||
box-shadow: 0 0 10px 2px rgba(248, 81, 73, 0.35);
|
||||
box-shadow: 0 0 10px 2px color-mix(in srgb, var(--color-error) 35%, transparent);
|
||||
}
|
||||
|
||||
.listing-card--triple-red:hover::after {
|
||||
|
|
@ -698,12 +770,12 @@ const formattedMarket = computed(() => {
|
|||
|
||||
@keyframes triple-red-glow {
|
||||
0%, 100% {
|
||||
border-color: rgba(248, 81, 73, 0.5);
|
||||
box-shadow: 0 0 5px 1px rgba(248, 81, 73, 0.2);
|
||||
border-color: color-mix(in srgb, var(--color-error) 50%, transparent);
|
||||
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--color-error) 20%, transparent);
|
||||
}
|
||||
50% {
|
||||
border-color: var(--color-error);
|
||||
box-shadow: 0 0 14px 3px rgba(248, 81, 73, 0.45);
|
||||
box-shadow: 0 0 14px 3px color-mix(in srgb, var(--color-error) 45%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
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 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -288,9 +288,9 @@ function formatDate(iso: string | null): string {
|
|||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.bl-source-badge--manual { background: rgba(88, 166, 255, 0.15); color: var(--app-primary); }
|
||||
.bl-source-badge--csv_import { background: rgba(164, 120, 255, 0.15); color: #a478ff; }
|
||||
.bl-source-badge--community { background: rgba(63, 185, 80, 0.15); color: var(--trust-high); }
|
||||
.bl-source-badge--manual { background: color-mix(in srgb, var(--color-info) 15%, transparent); color: var(--color-info); }
|
||||
.bl-source-badge--csv_import { background: color-mix(in srgb, var(--color-accent) 15%, transparent); color: var(--color-accent); }
|
||||
.bl-source-badge--community { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
|
||||
|
||||
.bl-remove-btn {
|
||||
background: none;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
@ -732,7 +908,8 @@ async function onSearch() {
|
|||
flex-shrink: 0;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
.cancel-btn:hover { background: rgba(248, 81, 73, 0.1); }
|
||||
.cancel-btn:hover { background: color-mix(in srgb, var(--color-error) 10%, transparent); }
|
||||
.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;
|
||||
|
|
@ -1039,8 +1221,8 @@ async function onSearch() {
|
|||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
border: 1px solid rgba(248, 81, 73, 0.3);
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-error);
|
||||
font-size: 0.9375rem;
|
||||
|
|
@ -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: color-mix(in srgb, var(--color-error) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 35%, transparent);
|
||||
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: color-mix(in srgb, var(--color-error) 12%, transparent);
|
||||
}
|
||||
|
||||
.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