feat: snipe beta backlog batch (tickets #22/#28/#30/#34/#35/#36/#37/#38)
Cloud/session: - fix(_extract_session_token): return "" for non-JWT cookie strings (snipe_guest=uuid was triggering 401 → forced login redirect for all unauthenticated cloud visitors) - fix(affiliate): exclude guest: and anonymous users from pref-store writes (#38) - fix(market-comp): use enriched comp_query for market comp hash so write/read keys match (#30) Frontend: - feat(SearchView): unauthenticated landing strip with free-account CTA (#36) - feat(SearchView): aria-pressed on filter toggles, aria-label on icon buttons, focus-visible rings on all interactive controls, live region for result count (#35) - feat(SearchView): no-results empty-state hint text (#36) - feat(SEO): og:image 1200x630, summary_large_image twitter card, canonical link (#37) - feat(OG): generated og-image.png (dark tactical theme, feature pills) (#37) - feat(settings): TrustSignalPref view wired to /settings route (#28) - fix(router): /settings route added; unauthenticated access redirects to home (#34) CI/CD: - feat(ci): Forgejo Actions workflow (ruff + pytest + vue-tsc + vitest) (#22) - feat(ci): mirror workflow (GitHub + Codeberg on push to main/tags) (#22) - feat(ci): release workflow (Docker build+push + git-cliff changelog) (#22) - chore: git-cliff config (.cliff.toml) for conventional commit changelog (#22) - chore(pyproject): dev extras (pytest/ruff/httpx), ruff config with ignore list (#22) Lint: - fix: remove 11 unused imports across api/, app/, tests/ (ruff F401 clean)
This commit is contained in:
parent
aff5bdda39
commit
fb81422c54
27 changed files with 1293 additions and 73 deletions
28
.cliff.toml
Normal file
28
.cliff.toml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
[changelog]
|
||||||
|
header = ""
|
||||||
|
body = """
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}
|
||||||
|
### {{ group | upper_first }}
|
||||||
|
{% for commit in commits %}
|
||||||
|
- {{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/commit/{{ commit.id }}))
|
||||||
|
{%- endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
"""
|
||||||
|
trim = true
|
||||||
|
|
||||||
|
[git]
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = true
|
||||||
|
split_commits = false
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^feat", group = "Features" },
|
||||||
|
{ message = "^fix", group = "Bug Fixes" },
|
||||||
|
{ message = "^perf", group = "Performance" },
|
||||||
|
{ message = "^refactor", group = "Refactoring" },
|
||||||
|
{ message = "^docs", group = "Documentation" },
|
||||||
|
{ message = "^test", group = "Testing" },
|
||||||
|
{ message = "^chore", skip = true },
|
||||||
|
{ message = "^ci", skip = true },
|
||||||
|
]
|
||||||
|
filter_commits = false
|
||||||
|
tag_pattern = "v[0-9].*"
|
||||||
57
.forgejo/workflows/ci.yml
Normal file
57
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, 'feature/**', 'fix/**']
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
python:
|
||||||
|
name: Python tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
# circuitforge-core is a sibling on dev machines but a public GitHub
|
||||||
|
# mirror in CI — install from there to avoid path-dependency issues.
|
||||||
|
- name: Install circuitforge-core
|
||||||
|
run: pip install --no-cache-dir git+https://github.com/CircuitForgeLLC/circuitforge-core.git
|
||||||
|
|
||||||
|
- name: Install snipe (dev extras)
|
||||||
|
run: pip install --no-cache-dir -e ".[dev]"
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: ruff check .
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: pytest tests/ -v --tb=short
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
name: Frontend typecheck + tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: web
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Typecheck + build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm run test
|
||||||
30
.forgejo/workflows/mirror.yml
Normal file
30
.forgejo/workflows/mirror.yml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
name: Mirror
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mirror:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Mirror to GitHub
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_MIRROR_TOKEN }}
|
||||||
|
REPO: ${{ github.event.repository.name }}
|
||||||
|
run: |
|
||||||
|
git remote add github "https://x-access-token:${GITHUB_TOKEN}@github.com/CircuitForgeLLC/${REPO}.git"
|
||||||
|
git push github --mirror
|
||||||
|
|
||||||
|
- name: Mirror to Codeberg
|
||||||
|
env:
|
||||||
|
CODEBERG_TOKEN: ${{ secrets.CODEBERG_MIRROR_TOKEN }}
|
||||||
|
REPO: ${{ github.event.repository.name }}
|
||||||
|
run: |
|
||||||
|
git remote add codeberg "https://CircuitForge:${CODEBERG_TOKEN}@codeberg.org/CircuitForge/${REPO}.git"
|
||||||
|
git push codeberg --mirror
|
||||||
92
.forgejo/workflows/release.yml
Normal file
92
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Forgejo container registry (BSL product — not pushing to public GHCR)
|
||||||
|
# cf-agents#3: revisit public registry policy before enabling GHCR push
|
||||||
|
REGISTRY: git.opensourcesolarpunk.com
|
||||||
|
IMAGE_API: git.opensourcesolarpunk.com/circuit-forge/snipe-api
|
||||||
|
IMAGE_WEB: git.opensourcesolarpunk.com/circuit-forge/snipe-web
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# ── Changelog ────────────────────────────────────────────────────────────
|
||||||
|
- name: Generate changelog
|
||||||
|
uses: orhun/git-cliff-action@v3
|
||||||
|
id: cliff
|
||||||
|
with:
|
||||||
|
config: .cliff.toml
|
||||||
|
args: --latest --strip header
|
||||||
|
env:
|
||||||
|
OUTPUT: CHANGES.md
|
||||||
|
|
||||||
|
# ── Docker ───────────────────────────────────────────────────────────────
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Forgejo registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
|
||||||
|
|
||||||
|
# API image — built with circuitforge-core sibling from GitHub mirror
|
||||||
|
- name: Checkout circuitforge-core
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: CircuitForgeLLC/circuitforge-core
|
||||||
|
path: circuitforge-core
|
||||||
|
|
||||||
|
- name: Build and push API image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE_API }}:${{ github.ref_name }}
|
||||||
|
${{ env.IMAGE_API }}:latest
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Build and push web image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/web/Dockerfile
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE_WEB }}:${{ github.ref_name }}
|
||||||
|
${{ env.IMAGE_WEB }}:latest
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
# ── Forgejo Release ───────────────────────────────────────────────────────
|
||||||
|
- name: Create Forgejo release
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
|
||||||
|
REPO: ${{ github.event.repository.name }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
NOTES: ${{ steps.cliff.outputs.content }}
|
||||||
|
run: |
|
||||||
|
curl -sS -X POST \
|
||||||
|
"https://git.opensourcesolarpunk.com/api/v1/repos/Circuit-Forge/${REPO}/releases" \
|
||||||
|
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n --arg tag "$TAG" --arg body "$NOTES" \
|
||||||
|
'{tag_name: $tag, name: $tag, body: $body}')"
|
||||||
|
|
@ -16,8 +16,6 @@ FastAPI usage:
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
@ -77,7 +75,6 @@ def compute_features(tier: str) -> SessionFeatures:
|
||||||
"""Compute feature flags from tier. Evaluated server-side; sent to frontend."""
|
"""Compute feature flags from tier. Evaluated server-side; sent to frontend."""
|
||||||
local = tier == "local"
|
local = tier == "local"
|
||||||
paid_plus = local or tier in ("paid", "premium", "ultra")
|
paid_plus = local or tier in ("paid", "premium", "ultra")
|
||||||
premium_plus = local or tier in ("premium", "ultra")
|
|
||||||
|
|
||||||
return SessionFeatures(
|
return SessionFeatures(
|
||||||
saved_searches=True, # all tiers get saved searches
|
saved_searches=True, # all tiers get saved searches
|
||||||
|
|
@ -94,10 +91,28 @@ def compute_features(tier: str) -> SessionFeatures:
|
||||||
# ── JWT validation ────────────────────────────────────────────────────────────
|
# ── JWT validation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _extract_session_token(header_value: str) -> str:
|
def _extract_session_token(header_value: str) -> str:
|
||||||
"""Extract cf_session value from a Cookie or X-CF-Session header string."""
|
"""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
|
|
||||||
|
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)
|
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:
|
def validate_session_jwt(token: str) -> str:
|
||||||
|
|
@ -178,6 +193,18 @@ def _user_db_path(user_id: str) -> Path:
|
||||||
return 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 ────────────────────────────────────────────────────────
|
# ── FastAPI dependency ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_session(request: Request) -> CloudUser:
|
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.
|
Local mode: returns a fully-privileged "local" user pointing at SNIPE_DB.
|
||||||
Cloud mode: validates X-CF-Session JWT, provisions Heimdall license,
|
Cloud mode: validates X-CF-Session JWT, provisions Heimdall license,
|
||||||
resolves tier, returns per-user DB paths.
|
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:
|
if not CLOUD_MODE:
|
||||||
return CloudUser(
|
return CloudUser(
|
||||||
|
|
@ -195,16 +224,30 @@ def get_session(request: Request) -> CloudUser:
|
||||||
user_db=_LOCAL_SNIPE_DB,
|
user_db=_LOCAL_SNIPE_DB,
|
||||||
)
|
)
|
||||||
|
|
||||||
raw_header = (
|
cookie_header = request.headers.get("cookie", "")
|
||||||
request.headers.get("x-cf-session", "")
|
raw_header = request.headers.get("x-cf-session", "") or cookie_header
|
||||||
or request.headers.get("cookie", "")
|
|
||||||
)
|
|
||||||
if not raw_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)
|
token = _extract_session_token(raw_header)
|
||||||
if not token:
|
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)
|
user_id = validate_session_jwt(token)
|
||||||
_ensure_provisioned(user_id)
|
_ensure_provisioned(user_id)
|
||||||
|
|
|
||||||
169
api/main.py
169
api/main.py
|
|
@ -1,8 +1,11 @@
|
||||||
"""Snipe FastAPI — search endpoint wired to ScrapedEbayAdapter + TrustScorer."""
|
"""Snipe FastAPI — search endpoint wired to ScrapedEbayAdapter + TrustScorer."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import csv
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import io
|
||||||
import json as _json
|
import json as _json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
@ -11,29 +14,27 @@ import uuid
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
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.affiliates import wrap_url as _wrap_affiliate_url
|
||||||
from circuitforge_core.api import make_feedback_router as _make_feedback_router
|
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.store import Store
|
||||||
from app.db.models import SavedSearch as SavedSearchModel, ScammerEntry
|
|
||||||
from app.platforms import SearchFilters
|
from app.platforms import SearchFilters
|
||||||
from app.platforms.ebay.scraper import ScrapedEbayAdapter
|
|
||||||
from app.platforms.ebay.adapter import EbayAdapter
|
from app.platforms.ebay.adapter import EbayAdapter
|
||||||
from app.platforms.ebay.auth import EbayTokenManager
|
from app.platforms.ebay.auth import EbayTokenManager
|
||||||
from app.platforms.ebay.query_builder import expand_queries, parse_groups
|
from app.platforms.ebay.query_builder import expand_queries, parse_groups
|
||||||
|
from app.platforms.ebay.scraper import ScrapedEbayAdapter
|
||||||
from app.trust import TrustScorer
|
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"))
|
load_env(Path(".env"))
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
@ -50,8 +51,8 @@ async def _lifespan(app: FastAPI):
|
||||||
# Start vision/LLM background task scheduler.
|
# Start vision/LLM background task scheduler.
|
||||||
# background_tasks queue lives in shared_db (cloud) or local_db (local)
|
# 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.
|
# 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 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
|
sched_db = _shared_db_path() if CLOUD_MODE else _LOCAL_SNIPE_DB
|
||||||
get_scheduler(sched_db)
|
get_scheduler(sched_db)
|
||||||
log.info("Snipe task scheduler started (db=%s)", sched_db)
|
log.info("Snipe task scheduler started (db=%s)", sched_db)
|
||||||
|
|
@ -100,13 +101,33 @@ def health():
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/session")
|
@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.
|
"""Return the current session tier and computed feature flags.
|
||||||
|
|
||||||
Used by the Vue frontend to gate UI features (pages slider cap,
|
Used by the Vue frontend to gate UI features (pages slider cap,
|
||||||
saved search limits, shared DB badges, etc.) without hardcoding
|
saved search limits, shared DB badges, etc.) without hardcoding
|
||||||
tier logic client-side.
|
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)
|
features = compute_features(session.tier)
|
||||||
return {
|
return {
|
||||||
"user_id": session.user_id,
|
"user_id": session.user_id,
|
||||||
|
|
@ -245,9 +266,10 @@ def _enqueue_vision_tasks(
|
||||||
trust_scores table in cloud mode.
|
trust_scores table in cloud mode.
|
||||||
"""
|
"""
|
||||||
import json as _json
|
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.runner import insert_task
|
||||||
from app.tasks.scheduler import get_scheduler
|
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_db = _shared_db_path() if CLOUD_MODE else _LOCAL_SNIPE_DB
|
||||||
sched = get_scheduler(sched_db)
|
sched = get_scheduler(sched_db)
|
||||||
|
|
@ -323,8 +345,8 @@ def _adapter_name(force: str = "auto") -> str:
|
||||||
@app.get("/api/search")
|
@app.get("/api/search")
|
||||||
def search(
|
def search(
|
||||||
q: str = "",
|
q: str = "",
|
||||||
max_price: float = 0,
|
max_price: Optional[float] = None,
|
||||||
min_price: float = 0,
|
min_price: Optional[float] = None,
|
||||||
pages: int = 1,
|
pages: int = 1,
|
||||||
must_include: str = "", # raw filter string; client-side always applied
|
must_include: str = "", # raw filter string; client-side always applied
|
||||||
must_include_mode: str = "all", # "all" | "any" | "groups" — drives eBay expansion
|
must_include_mode: str = "all", # "all" | "any" | "groups" — drives eBay expansion
|
||||||
|
|
@ -350,9 +372,22 @@ def search(
|
||||||
else:
|
else:
|
||||||
ebay_queries = [q]
|
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(
|
base_filters = SearchFilters(
|
||||||
max_price=max_price if max_price > 0 else None,
|
max_price=max_price if max_price and max_price > 0 else None,
|
||||||
min_price=min_price if min_price > 0 else None,
|
min_price=min_price if min_price and min_price > 0 else None,
|
||||||
pages=pages,
|
pages=pages,
|
||||||
must_exclude=must_exclude_terms, # forwarded to eBay -term by the scraper
|
must_exclude=must_exclude_terms, # forwarded to eBay -term by the scraper
|
||||||
category_id=category_id.strip() or None,
|
category_id=category_id.strip() or None,
|
||||||
|
|
@ -369,9 +404,9 @@ def search(
|
||||||
|
|
||||||
def _run_comps() -> None:
|
def _run_comps() -> None:
|
||||||
try:
|
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:
|
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:
|
try:
|
||||||
# Comps submitted first — guarantees an immediate worker slot even at max concurrency.
|
# Comps submitted first — guarantees an immediate worker slot even at max concurrency.
|
||||||
|
|
@ -426,7 +461,7 @@ def search(
|
||||||
_update_queues[session_id] = _queue.SimpleQueue()
|
_update_queues[session_id] = _queue.SimpleQueue()
|
||||||
_trigger_scraper_enrichment(
|
_trigger_scraper_enrichment(
|
||||||
listings, shared_store, shared_db,
|
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)
|
scorer = TrustScorer(shared_store)
|
||||||
|
|
@ -440,7 +475,7 @@ def search(
|
||||||
if features.photo_analysis:
|
if features.photo_analysis:
|
||||||
_enqueue_vision_tasks(listings, trust_scores_list, session)
|
_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)
|
comp = shared_store.get_market_comp("ebay", query_hash)
|
||||||
market_price = comp.median_price if comp else None
|
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)
|
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:
|
def _serialize_listing(l: object) -> dict:
|
||||||
d = dataclasses.asdict(l)
|
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 d
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -683,6 +731,19 @@ def mark_saved_search_run(saved_id: int, session: CloudUser = Depends(get_sessio
|
||||||
return {"ok": True}
|
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 ─────────────────────────────────────────────────────────
|
# ── Scammer Blocklist ─────────────────────────────────────────────────────────
|
||||||
# Blocklist lives in shared_db: all users on a shared cloud instance see the
|
# 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.
|
# 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)
|
@app.post("/api/blocklist", status_code=201)
|
||||||
def add_to_blocklist(body: BlocklistAdd, session: CloudUser = Depends(get_session)):
|
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)
|
store = Store(session.shared_db)
|
||||||
entry = store.add_to_blocklist(ScammerEntry(
|
entry = store.add_to_blocklist(ScammerEntry(
|
||||||
platform=body.platform,
|
platform=body.platform,
|
||||||
|
|
@ -742,6 +808,11 @@ async def import_blocklist(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Import a CSV blocklist. Columns: platform_seller_id, username, reason (optional)."""
|
"""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()
|
content = await file.read()
|
||||||
try:
|
try:
|
||||||
text = content.decode("utf-8-sig") # handle BOM from Excel exports
|
text = content.decode("utf-8-sig") # handle BOM from Excel exports
|
||||||
|
|
@ -775,3 +846,49 @@ async def import_blocklist(
|
||||||
return {"imported": imported, "errors": errors}
|
return {"imported": imported, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
|
# ── User Preferences ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class PreferenceUpdate(BaseModel):
|
||||||
|
path: str # dot-separated, e.g. "affiliate.opt_out" or "affiliate.byok_ids.ebay"
|
||||||
|
value: Optional[object] # bool, str, or None to clear
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/preferences")
|
||||||
|
def get_preferences(session: CloudUser = Depends(get_session)) -> dict:
|
||||||
|
"""Return all preferences for the authenticated user.
|
||||||
|
|
||||||
|
Anonymous users always receive an empty dict (no preferences to store).
|
||||||
|
"""
|
||||||
|
if session.user_id == "anonymous":
|
||||||
|
return {}
|
||||||
|
store = Store(session.user_db)
|
||||||
|
return store.get_all_preferences()
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/preferences")
|
||||||
|
def patch_preference(
|
||||||
|
body: PreferenceUpdate,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Set a single preference at *path* to *value*.
|
||||||
|
|
||||||
|
- ``affiliate.opt_out`` — available to all signed-in users.
|
||||||
|
- ``affiliate.byok_ids.ebay`` — Premium tier only.
|
||||||
|
|
||||||
|
Returns the full updated preferences dict.
|
||||||
|
"""
|
||||||
|
if session.user_id == "anonymous":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Sign in to save preferences.",
|
||||||
|
)
|
||||||
|
if body.path.startswith("affiliate.byok_ids.") and session.tier not in ("premium", "ultra"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Custom affiliate IDs (BYOK) require a Premium subscription.",
|
||||||
|
)
|
||||||
|
store = Store(session.user_db)
|
||||||
|
store.set_user_preference(body.path, body.value)
|
||||||
|
return store.get_all_preferences()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
11
app/db/migrations/008_community_signals.sql
Normal file
11
app/db/migrations/008_community_signals.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- Community trust signals: user feedback on individual trust scores.
|
||||||
|
-- "This score looks right" (confirmed=1) / "This score is wrong" (confirmed=0).
|
||||||
|
-- Stored in shared_db so signals aggregate across all users.
|
||||||
|
CREATE TABLE IF NOT EXISTS community_signals (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
seller_id TEXT NOT NULL,
|
||||||
|
confirmed INTEGER NOT NULL CHECK (confirmed IN (0, 1)),
|
||||||
|
recorded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_community_signals_seller ON community_signals(seller_id);
|
||||||
9
app/db/migrations/009_user_preferences.sql
Normal file
9
app/db/migrations/009_user_preferences.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- Per-user preferences stored as a single JSON blob.
|
||||||
|
-- Lives in user_db (each user has their own DB file) — never in shared.db.
|
||||||
|
-- Single-row enforced by PRIMARY KEY CHECK (id = 1): acts as a singleton table.
|
||||||
|
-- Path reads/writes use cf-core preferences.paths (get_path / set_path).
|
||||||
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
prefs_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
@ -16,7 +16,7 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
@ -302,7 +302,8 @@ class ScrapedEbayAdapter(PlatformAdapter):
|
||||||
|
|
||||||
time.sleep(self._delay)
|
time.sleep(self._delay)
|
||||||
|
|
||||||
import subprocess, os
|
import os
|
||||||
|
import subprocess
|
||||||
display_num = next(_display_counter)
|
display_num = next(_display_counter)
|
||||||
display = f":{display_num}"
|
display = f":{display_num}"
|
||||||
xvfb = subprocess.Popen(
|
xvfb = subprocess.Popen(
|
||||||
|
|
@ -313,8 +314,10 @@ class ScrapedEbayAdapter(PlatformAdapter):
|
||||||
env["DISPLAY"] = display
|
env["DISPLAY"] = display
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from playwright.sync_api import sync_playwright # noqa: PLC0415 — lazy: only needed in Docker
|
from playwright.sync_api import (
|
||||||
from playwright_stealth import Stealth # noqa: PLC0415
|
sync_playwright, # noqa: PLC0415 — lazy: only needed in Docker
|
||||||
|
)
|
||||||
|
from playwright_stealth import Stealth # noqa: PLC0415
|
||||||
|
|
||||||
with sync_playwright() as pw:
|
with sync_playwright() as pw:
|
||||||
browser = pw.chromium.launch(
|
browser = pw.chromium.launch(
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from circuitforge_core.db import get_connection
|
from circuitforge_core.db import get_connection
|
||||||
from circuitforge_core.llm import LLMRouter
|
from circuitforge_core.llm import LLMRouter
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ from __future__ import annotations
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from circuitforge_core.tasks.scheduler import (
|
from circuitforge_core.tasks.scheduler import (
|
||||||
TaskScheduler,
|
TaskScheduler, # re-export for tests
|
||||||
|
)
|
||||||
|
from circuitforge_core.tasks.scheduler import (
|
||||||
get_scheduler as _base_get_scheduler,
|
get_scheduler as _base_get_scheduler,
|
||||||
reset_scheduler, # re-export for tests
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task
|
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
|
- saved_searches — retention feature; friction cost outweighs gate value
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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.
|
# Feature key → minimum tier required.
|
||||||
FEATURES: dict[str, str] = {
|
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 hashlib
|
||||||
import math
|
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:
|
class TrustScorer:
|
||||||
"""Orchestrates metadata + photo scoring for a batch of listings."""
|
"""Orchestrates metadata + photo scoring for a batch of listings."""
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
"""Composite score and red flag extraction."""
|
"""Composite score and red flag extraction."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.db.models import Seller, TrustScore
|
from app.db.models import Seller, TrustScore
|
||||||
|
|
||||||
HARD_FILTER_AGE_DAYS = 7
|
HARD_FILTER_AGE_DAYS = 7
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,30 @@ dependencies = [
|
||||||
"PyJWT>=2.8",
|
"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]
|
[tool.setuptools.packages.find]
|
||||||
where = ["."]
|
where = ["."]
|
||||||
include = ["app*", "api*"]
|
include = ["app*", "api*"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
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 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.store import Store
|
||||||
from app.db.models import Listing, Seller, TrustScore, MarketComp
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
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():
|
def test_metadata_scoring_is_free():
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,8 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.db.models import Listing, TrustScore
|
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:
|
def _listing(**kwargs) -> Listing:
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,23 @@
|
||||||
<!-- Emoji favicon: target reticle — inline SVG to avoid a separate file -->
|
<!-- 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>" />
|
<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" />
|
<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 -->
|
<!-- Inline background prevents blank flash before CSS bundle loads -->
|
||||||
<!-- Matches --color-surface dark tactical theme from theme.css -->
|
<!-- Matches --color-surface dark tactical theme from theme.css -->
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
BIN
web/public/og-image.png
Normal file
BIN
web/public/og-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
17
web/src/composables/useTrustSignalPref.ts
Normal file
17
web/src/composables/useTrustSignalPref.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// composables/useTrustSignalPref.ts
|
||||||
|
// User opt-in for showing "This score looks right / wrong" trust signal buttons.
|
||||||
|
// Off by default — users explicitly enable it in Settings.
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const LS_KEY = 'snipe:trust-signal-enabled'
|
||||||
|
|
||||||
|
const enabled = ref(localStorage.getItem(LS_KEY) === 'true')
|
||||||
|
|
||||||
|
export function useTrustSignalPref() {
|
||||||
|
function setEnabled(value: boolean) {
|
||||||
|
enabled.value = value
|
||||||
|
localStorage.setItem(LS_KEY, value ? 'true' : 'false')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { enabled, setEnabled }
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ export const router = createRouter({
|
||||||
{ path: '/listing/:id', component: () => import('../views/ListingView.vue') },
|
{ path: '/listing/:id', component: () => import('../views/ListingView.vue') },
|
||||||
{ path: '/saved', component: () => import('../views/SavedSearchesView.vue') },
|
{ path: '/saved', component: () => import('../views/SavedSearchesView.vue') },
|
||||||
{ path: '/blocklist', component: () => import('../views/BlocklistView.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)
|
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
{ 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 isCloud = computed(() => tier.value !== 'local')
|
||||||
const isFree = computed(() => tier.value === 'free')
|
const isFree = computed(() => tier.value === 'free')
|
||||||
const isPaid = computed(() => ['paid', 'premium', 'ultra', 'local'].includes(tier.value))
|
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() {
|
async function bootstrap() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -49,5 +54,5 @@ export const useSessionStore = defineStore('session', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { userId, tier, features, loaded, isCloud, isFree, isPaid, bootstrap }
|
return { userId, tier, features, loaded, isCloud, isFree, isPaid, isPremium, isGuest, isLoggedIn, bootstrap }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -40,15 +40,24 @@
|
||||||
v-if="store.loading"
|
v-if="store.loading"
|
||||||
type="button"
|
type="button"
|
||||||
class="cancel-btn"
|
class="cancel-btn"
|
||||||
|
aria-label="Cancel search"
|
||||||
@click="store.cancelSearch()"
|
@click="store.cancelSearch()"
|
||||||
title="Cancel search"
|
|
||||||
>✕ Cancel</button>
|
>✕ 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
|
<button
|
||||||
v-else
|
v-else
|
||||||
type="button"
|
type="button"
|
||||||
class="save-bookmark-btn"
|
class="save-bookmark-btn"
|
||||||
:disabled="!queryInput.trim()"
|
: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()"
|
@click="showSaveForm = !showSaveForm; if (showSaveForm) saveName = queryInput.trim()"
|
||||||
>
|
>
|
||||||
<BookmarkIcon class="search-btn-icon" aria-hidden="true" />
|
<BookmarkIcon class="search-btn-icon" aria-hidden="true" />
|
||||||
|
|
@ -64,7 +73,7 @@
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
<button type="submit" class="save-confirm-btn">Save</button>
|
<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="saveSuccess" class="save-success">Saved!</span>
|
||||||
<span v-if="saveError" class="save-error">{{ saveError }}</span>
|
<span v-if="saveError" class="save-error">{{ saveError }}</span>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -125,6 +134,7 @@
|
||||||
type="button"
|
type="button"
|
||||||
class="filter-pages-btn"
|
class="filter-pages-btn"
|
||||||
:class="{ 'filter-pages-btn--active': filters.adapter === m.value }"
|
:class="{ 'filter-pages-btn--active': filters.adapter === m.value }"
|
||||||
|
:aria-pressed="filters.adapter === m.value"
|
||||||
@click="filters.adapter = m.value"
|
@click="filters.adapter = m.value"
|
||||||
>{{ m.label }}</button>
|
>{{ m.label }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -144,7 +154,8 @@
|
||||||
'filter-pages-btn--locked': p > session.features.max_pages,
|
'filter-pages-btn--locked': p > session.features.max_pages,
|
||||||
}"
|
}"
|
||||||
:disabled="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)"
|
@click="p <= session.features.max_pages && (filters.pages = p)"
|
||||||
>{{ p }}</button>
|
>{{ p }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -154,8 +165,10 @@
|
||||||
<fieldset class="filter-group">
|
<fieldset class="filter-group">
|
||||||
<legend class="filter-label">Price range</legend>
|
<legend class="filter-label">Price range</legend>
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<input v-model.number="filters.minPrice" type="number" min="0" class="filter-input" placeholder="Min $" />
|
<label class="sr-only" for="f-min-price">Minimum price</label>
|
||||||
<input v-model.number="filters.maxPrice" type="number" min="0" class="filter-input" placeholder="Max $" />
|
<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>
|
</div>
|
||||||
<p class="filter-pages-hint">Forwarded to eBay API</p>
|
<p class="filter-pages-hint">Forwarded to eBay API</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
@ -164,13 +177,14 @@
|
||||||
<legend class="filter-label">Keywords</legend>
|
<legend class="filter-label">Keywords</legend>
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<label class="filter-label-sm" for="f-include">Must include</label>
|
<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
|
<button
|
||||||
v-for="m in INCLUDE_MODES"
|
v-for="m in INCLUDE_MODES"
|
||||||
:key="m.value"
|
:key="m.value"
|
||||||
type="button"
|
type="button"
|
||||||
class="filter-pages-btn"
|
class="filter-pages-btn"
|
||||||
:class="{ 'filter-pages-btn--active': filters.mustIncludeMode === m.value }"
|
:class="{ 'filter-pages-btn--active': filters.mustIncludeMode === m.value }"
|
||||||
|
:aria-pressed="filters.mustIncludeMode === m.value"
|
||||||
@click="filters.mustIncludeMode = m.value"
|
@click="filters.mustIncludeMode = m.value"
|
||||||
>{{ m.label }}</button>
|
>{{ m.label }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -217,9 +231,11 @@
|
||||||
max="100"
|
max="100"
|
||||||
step="5"
|
step="5"
|
||||||
class="filter-range"
|
class="filter-range"
|
||||||
|
aria-label="Minimum trust score"
|
||||||
aria-valuemin="0"
|
aria-valuemin="0"
|
||||||
aria-valuemax="100"
|
aria-valuemax="100"
|
||||||
:aria-valuenow="filters.minTrustScore"
|
:aria-valuenow="filters.minTrustScore"
|
||||||
|
:aria-valuetext="`${filters.minTrustScore ?? 0} out of 100`"
|
||||||
/>
|
/>
|
||||||
<span class="filter-range-val">{{ filters.minTrustScore ?? 0 }}</span>
|
<span class="filter-range-val">{{ filters.minTrustScore ?? 0 }}</span>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
@ -287,15 +303,59 @@
|
||||||
{{ store.error }}
|
{{ store.error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty state (before first search) -->
|
<!-- Landing hero (before first search) -->
|
||||||
<div v-else-if="!store.results.length && !store.loading && !store.query" class="results-empty">
|
<div v-else-if="!store.results.length && !store.loading && !store.query" class="landing-hero">
|
||||||
<span class="results-empty-icon" aria-hidden="true">🎯</span>
|
<div class="landing-hero__eyebrow" aria-hidden="true">🎯 Snipe</div>
|
||||||
<p>Enter a search term to find listings.</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- No results -->
|
<!-- No results -->
|
||||||
<div v-else-if="!store.results.length && !store.loading && store.query" class="results-empty">
|
<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>No listings found for <strong>{{ store.query }}</strong>.</p>
|
||||||
|
<p class="results-empty__hint">Try a broader search term, or check spelling.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Results -->
|
<!-- Results -->
|
||||||
|
|
@ -326,6 +386,40 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Cards -->
|
||||||
<div class="results-list">
|
<div class="results-list">
|
||||||
<ListingCard
|
<ListingCard
|
||||||
|
|
@ -335,6 +429,9 @@
|
||||||
:trust="store.trustScores.get(listing.platform_listing_id) ?? null"
|
:trust="store.trustScores.get(listing.platform_listing_id) ?? null"
|
||||||
:seller="store.sellers.get(listing.seller_platform_id) ?? null"
|
:seller="store.sellers.get(listing.seller_platform_id) ?? null"
|
||||||
:market-price="store.marketPrice"
|
:market-price="store.marketPrice"
|
||||||
|
:selected="selectedIds.has(listing.platform_listing_id)"
|
||||||
|
:select-mode="selectMode"
|
||||||
|
@toggle="toggleSelect(listing.platform_listing_id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -351,14 +448,89 @@ import { useSearchStore } from '../stores/search'
|
||||||
import type { Listing, TrustScore, SearchFilters, MustIncludeMode } from '../stores/search'
|
import type { Listing, TrustScore, SearchFilters, MustIncludeMode } from '../stores/search'
|
||||||
import { useSavedSearchesStore } from '../stores/savedSearches'
|
import { useSavedSearchesStore } from '../stores/savedSearches'
|
||||||
import { useSessionStore } from '../stores/session'
|
import { useSessionStore } from '../stores/session'
|
||||||
|
import { useBlocklistStore } from '../stores/blocklist'
|
||||||
import ListingCard from '../components/ListingCard.vue'
|
import ListingCard from '../components/ListingCard.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useSearchStore()
|
const store = useSearchStore()
|
||||||
const savedStore = useSavedSearchesStore()
|
const savedStore = useSavedSearchesStore()
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
|
const blocklist = useBlocklistStore()
|
||||||
const queryInput = ref('')
|
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
|
// Save search UI state
|
||||||
const showSaveForm = ref(false)
|
const showSaveForm = ref(false)
|
||||||
const showFilters = ref(false)
|
const showFilters = ref(false)
|
||||||
|
|
@ -366,6 +538,9 @@ const saveName = ref('')
|
||||||
const saveError = ref<string | null>(null)
|
const saveError = ref<string | null>(null)
|
||||||
const saveSuccess = ref(false)
|
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
|
// Count active non-default filters for the mobile badge
|
||||||
const activeFilterCount = computed(() => {
|
const activeFilterCount = computed(() => {
|
||||||
let n = 0
|
let n = 0
|
||||||
|
|
@ -715,6 +890,7 @@ async function onSearch() {
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-btn:hover:not(:disabled) { background: var(--app-primary-hover); }
|
.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:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||||
.search-btn-icon { width: 1.1rem; height: 1.1rem; }
|
.search-btn-icon { width: 1.1rem; height: 1.1rem; }
|
||||||
|
|
||||||
|
|
@ -733,6 +909,7 @@ async function onSearch() {
|
||||||
transition: background 150ms ease;
|
transition: background 150ms ease;
|
||||||
}
|
}
|
||||||
.cancel-btn:hover { background: rgba(248, 81, 73, 0.1); }
|
.cancel-btn:hover { background: rgba(248, 81, 73, 0.1); }
|
||||||
|
.cancel-btn:focus-visible { outline: 2px solid var(--color-error); outline-offset: 2px; }
|
||||||
|
|
||||||
.save-bookmark-btn {
|
.save-bookmark-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1013,6 +1190,11 @@ async function onSearch() {
|
||||||
color: var(--color-text-inverse);
|
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--locked,
|
||||||
.filter-pages-btn:disabled {
|
.filter-pages-btn:disabled {
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
|
|
@ -1048,6 +1230,136 @@ async function onSearch() {
|
||||||
|
|
||||||
.results-error-icon { width: 1.25rem; height: 1.25rem; flex-shrink: 0; }
|
.results-error-icon { width: 1.25rem; height: 1.25rem; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ── Landing hero ────────────────────────────────────────────────────── */
|
||||||
|
.landing-hero {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: var(--space-12) auto;
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero__eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--app-primary);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero__headline {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(2rem, 5vw, 3rem);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero__sub {
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero__callout {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: flex-start;
|
||||||
|
background: rgba(248, 81, 73, 0.08);
|
||||||
|
border: 1px solid rgba(248, 81, 73, 0.35);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero__callout-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero__callout p { margin: 0; }
|
||||||
|
|
||||||
|
.landing-hero__tiles {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero__tile {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero__tile-icon { font-size: 1.5rem; line-height: 1; }
|
||||||
|
|
||||||
|
.landing-hero__tile-title {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero__tile-desc {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero__signin-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3) var(--space-6);
|
||||||
|
margin-top: var(--space-8);
|
||||||
|
padding: var(--space-4) var(--space-6);
|
||||||
|
background: color-mix(in srgb, var(--app-primary) 8%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--app-primary) 20%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero__signin-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero__signin-cta {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero__signin-cta:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.landing-hero { margin: var(--space-8) auto; }
|
||||||
|
.landing-hero__tiles { grid-template-columns: 1fr; }
|
||||||
|
.landing-hero__signin-strip { flex-direction: column; text-align: center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.landing-hero__headline { font-size: 1.75rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Results empty (post-search, no matches) ─────────────────────────── */
|
||||||
.results-empty {
|
.results-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -1060,6 +1372,12 @@ async function onSearch() {
|
||||||
|
|
||||||
.results-empty-icon { font-size: 3rem; }
|
.results-empty-icon { font-size: 3rem; }
|
||||||
|
|
||||||
|
.results-empty__hint {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.results-toolbar {
|
.results-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -1185,6 +1503,107 @@ async function onSearch() {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Bulk action bar ────────────────────────────────────────────────────── */
|
||||||
|
.bulk-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: color-mix(in srgb, var(--app-primary) 10%, var(--color-surface-2));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--app-primary) 30%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-bar__count {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-primary);
|
||||||
|
margin-right: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-bar__sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--color-border);
|
||||||
|
margin: 0 var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-bar__btn {
|
||||||
|
padding: 4px var(--space-3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
transition: background 120ms ease, color 120ms ease, opacity 120ms ease;
|
||||||
|
}
|
||||||
|
.bulk-bar__btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.bulk-bar__btn--ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.bulk-bar__btn--ghost:hover:not(:disabled) {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-bar__btn--danger {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-error);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
.bulk-bar__btn--danger:hover:not(:disabled) {
|
||||||
|
background: rgba(248, 81, 73, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-bar__btn--report {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-text-muted);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.bulk-bar__btn--report:hover:not(:disabled) {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide-in transition (shared by bulk-bar and guest-prompt) */
|
||||||
|
.bulk-bar-enter-active, .bulk-bar-leave-active { transition: opacity 0.18s ease, transform 0.18s ease; }
|
||||||
|
.bulk-bar-enter-from, .bulk-bar-leave-to { opacity: 0; transform: translateY(-6px); }
|
||||||
|
|
||||||
|
/* Guest sign-in prompt */
|
||||||
|
.guest-prompt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: color-mix(in srgb, var(--app-primary) 12%, var(--color-surface-2));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--app-primary) 30%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
.guest-prompt__link {
|
||||||
|
color: var(--app-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.guest-prompt__link:hover { text-decoration: underline; }
|
||||||
|
.guest-prompt__dismiss {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0 var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
.results-list {
|
.results-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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