Compare commits
No commits in common. "main" and "feature/shared-task-scheduler" have entirely different histories.
main
...
feature/sh
11 changed files with 8 additions and 696 deletions
36
.env.example
36
.env.example
|
|
@ -46,39 +46,3 @@ SNIPE_DB=data/snipe.db
|
||||||
# Heimdall license server — for tier resolution and free-key auto-provisioning.
|
# Heimdall license server — for tier resolution and free-key auto-provisioning.
|
||||||
# HEIMDALL_URL=https://license.circuitforge.tech
|
# HEIMDALL_URL=https://license.circuitforge.tech
|
||||||
# HEIMDALL_ADMIN_TOKEN=
|
# HEIMDALL_ADMIN_TOKEN=
|
||||||
|
|
||||||
# ── eBay Affiliate (optional) ─────────────────────────────────────────────────
|
|
||||||
# Set to your eBay Partner Network (EPN) campaign ID to earn commissions on
|
|
||||||
# listing click-throughs. Leave blank for clean /itm/ URLs (no tracking).
|
|
||||||
# Register at https://partnernetwork.ebay.com — self-hosted users can use their
|
|
||||||
# own ID; the CF cloud instance uses CF's campaign ID (disclosed in the UI).
|
|
||||||
# EBAY_AFFILIATE_CAMPAIGN_ID=
|
|
||||||
|
|
||||||
# ── LLM inference (vision / photo analysis) ──────────────────────────────────
|
|
||||||
# circuitforge-core LLMRouter auto-detects backends from these env vars
|
|
||||||
# (no llm.yaml required). Backends are tried in this priority order:
|
|
||||||
# 1. ANTHROPIC_API_KEY → Claude API (cloud; requires Paid tier key)
|
|
||||||
# 2. OPENAI_API_KEY → OpenAI-compatible endpoint
|
|
||||||
# 3. OLLAMA_HOST → local Ollama (default: http://localhost:11434)
|
|
||||||
# Leave all unset to disable LLM features (photo analysis won't run).
|
|
||||||
|
|
||||||
# ANTHROPIC_API_KEY=
|
|
||||||
# ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
|
||||||
|
|
||||||
# OPENAI_API_KEY=
|
|
||||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
|
||||||
# OPENAI_MODEL=gpt-4o-mini
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# ── In-app feedback (beta) ────────────────────────────────────────────────────
|
|
||||||
# When set, a feedback FAB appears in the UI and routes submissions to Forgejo.
|
|
||||||
# Leave unset to silently hide the button (demo/offline deployments).
|
|
||||||
# FORGEJO_API_TOKEN=
|
|
||||||
# FORGEJO_REPO=Circuit-Forge/snipe
|
|
||||||
# FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
|
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> *Part of the Circuit Forge LLC "AI for the tasks you hate most" suite.*
|
> *Part of the Circuit Forge LLC "AI for the tasks you hate most" suite.*
|
||||||
|
|
||||||
**Status:** Active — eBay listing intelligence MVP complete (search, trust scoring, affiliate links, feedback FAB, vision task scheduling). Auction sniping engine and multi-platform support are next.
|
**Status:** Active — eBay listing search + seller trust scoring MVP complete. Auction sniping engine and multi-platform support are next.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
|
|
@ -68,20 +68,6 @@ Scans listing titles for signals the item may have undisclosed damage or problem
|
||||||
- **On-demand**: ↻ button on any listing card triggers `POST /api/enrich` — runs enrichment and re-scores without waiting for a second search
|
- **On-demand**: ↻ button on any listing card triggers `POST /api/enrich` — runs enrichment and re-scores without waiting for a second search
|
||||||
- **Category history**: derived from the seller's accumulated listing data (Browse API `categories` field); improves with every search, no extra API calls
|
- **Category history**: derived from the seller's accumulated listing data (Browse API `categories` field); improves with every search, no extra API calls
|
||||||
|
|
||||||
### Affiliate link builder
|
|
||||||
|
|
||||||
Listing cards surface eBay affiliate-wrapped URLs. Uses `circuitforge_core.affiliates.wrap_url` — resolution order: user opted out → plain URL; user has BYOK affiliate ID → their ID; CF env var set (`EBAY_AFFILIATE_ID`) → CF's ID; otherwise plain URL. Users can configure their own eBay Partner Network ID or opt out entirely in Settings.
|
|
||||||
|
|
||||||
Disclosure tooltip appears on first encounter per-session and on each wrapped link (per-retailer copy from `get_disclosure_text`).
|
|
||||||
|
|
||||||
### Feedback FAB
|
|
||||||
|
|
||||||
In-app feedback button (bottom-right FAB) opens a modal: title, description, optional screenshot. Posts to the CF feedback endpoint. Status probed on load; FAB hidden if endpoint unreachable.
|
|
||||||
|
|
||||||
### Vision task scheduling
|
|
||||||
|
|
||||||
Photo condition assessment tasks queued through `circuitforge_core.tasks.TaskScheduler` — VRAM-aware slot management shared with any other LLM workloads on the same host. Runs moondream2 locally (free tier) or Claude vision (paid/cloud). Results stored per-listing and update the trust score card.
|
|
||||||
|
|
||||||
### Market price comparison
|
### Market price comparison
|
||||||
Completed sales fetched via eBay Marketplace Insights API (with Browse API fallback for app tiers that don't have Insights access). Median stored per query hash, used to score `price_vs_market` across all listings in a search.
|
Completed sales fetched via eBay Marketplace Insights API (with Browse API fallback for app tiers that don't have Insights access). Median stored per query hash, used to score `price_vs_market` across all listings in a search.
|
||||||
|
|
||||||
|
|
|
||||||
132
api/main.py
132
api/main.py
|
|
@ -11,19 +11,12 @@ from pathlib import Path
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import platform as _platform
|
|
||||||
import subprocess
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
import requests as _requests
|
|
||||||
from fastapi import Depends, FastAPI, HTTPException, UploadFile, File
|
from fastapi import Depends, FastAPI, HTTPException, UploadFile, File
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from circuitforge_core.config import load_env
|
from circuitforge_core.config import load_env
|
||||||
from circuitforge_core.affiliates import wrap_url as _wrap_affiliate_url
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.db.models import SavedSearch as SavedSearchModel, ScammerEntry
|
from app.db.models import SavedSearch as SavedSearchModel, ScammerEntry
|
||||||
from app.platforms import SearchFilters
|
from app.platforms import SearchFilters
|
||||||
|
|
@ -70,7 +63,6 @@ def _ebay_creds() -> tuple[str, str, str]:
|
||||||
client_secret = (os.environ.get("EBAY_CERT_ID") or os.environ.get("EBAY_CLIENT_SECRET", "")).strip()
|
client_secret = (os.environ.get("EBAY_CERT_ID") or os.environ.get("EBAY_CLIENT_SECRET", "")).strip()
|
||||||
return client_id, client_secret, env
|
return client_id, client_secret, env
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Snipe API", version="0.1.0", lifespan=_lifespan)
|
app = FastAPI(title="Snipe API", version="0.1.0", lifespan=_lifespan)
|
||||||
app.include_router(ebay_webhook_router)
|
app.include_router(ebay_webhook_router)
|
||||||
|
|
||||||
|
|
@ -397,18 +389,12 @@ def search(
|
||||||
and shared_store.get_seller("ebay", listing.seller_platform_id)
|
and shared_store.get_seller("ebay", listing.seller_platform_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
def _serialize_listing(l: object) -> dict:
|
|
||||||
d = dataclasses.asdict(l)
|
|
||||||
d["url"] = _wrap_affiliate_url(d["url"], retailer="ebay")
|
|
||||||
return d
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"listings": [_serialize_listing(l) for l in listings],
|
"listings": [dataclasses.asdict(l) for l in listings],
|
||||||
"trust_scores": trust_map,
|
"trust_scores": trust_map,
|
||||||
"sellers": seller_map,
|
"sellers": seller_map,
|
||||||
"market_price": market_price,
|
"market_price": market_price,
|
||||||
"adapter_used": adapter_used,
|
"adapter_used": adapter_used,
|
||||||
"affiliate_active": bool(os.environ.get("EBAY_AFFILIATE_CAMPAIGN_ID", "").strip()),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -643,119 +629,3 @@ async def import_blocklist(
|
||||||
|
|
||||||
log.info("Blocklist import: %d added, %d errors", imported, len(errors))
|
log.info("Blocklist import: %d added, %d errors", imported, len(errors))
|
||||||
return {"imported": imported, "errors": errors}
|
return {"imported": imported, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
# ── Feedback ────────────────────────────────────────────────────────────────
|
|
||||||
# Creates Forgejo issues from in-app beta feedback.
|
|
||||||
# Silently disabled when FORGEJO_API_TOKEN is not set.
|
|
||||||
|
|
||||||
_FEEDBACK_LABEL_COLORS = {
|
|
||||||
"beta-feedback": "#0075ca",
|
|
||||||
"needs-triage": "#e4e669",
|
|
||||||
"bug": "#d73a4a",
|
|
||||||
"feature-request": "#a2eeef",
|
|
||||||
"question": "#d876e3",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _fb_headers() -> dict:
|
|
||||||
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
|
||||||
return {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_feedback_labels(names: list[str]) -> list[int]:
|
|
||||||
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
|
|
||||||
repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/snipe")
|
|
||||||
resp = _requests.get(f"{base}/repos/{repo}/labels", headers=_fb_headers(), timeout=10)
|
|
||||||
existing = {lb["name"]: lb["id"] for lb in resp.json()} if resp.ok else {}
|
|
||||||
ids: list[int] = []
|
|
||||||
for name in names:
|
|
||||||
if name in existing:
|
|
||||||
ids.append(existing[name])
|
|
||||||
else:
|
|
||||||
r = _requests.post(
|
|
||||||
f"{base}/repos/{repo}/labels",
|
|
||||||
headers=_fb_headers(),
|
|
||||||
json={"name": name, "color": _FEEDBACK_LABEL_COLORS.get(name, "#ededed")},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if r.ok:
|
|
||||||
ids.append(r.json()["id"])
|
|
||||||
return ids
|
|
||||||
|
|
||||||
|
|
||||||
class FeedbackRequest(BaseModel):
|
|
||||||
title: str
|
|
||||||
description: str
|
|
||||||
type: Literal["bug", "feature", "other"] = "other"
|
|
||||||
repro: str = ""
|
|
||||||
view: str = "unknown"
|
|
||||||
submitter: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class FeedbackResponse(BaseModel):
|
|
||||||
issue_number: int
|
|
||||||
issue_url: str
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/feedback/status")
|
|
||||||
def feedback_status() -> dict:
|
|
||||||
"""Return whether feedback submission is configured on this instance."""
|
|
||||||
demo = os.environ.get("DEMO_MODE", "false").lower() in ("1", "true", "yes")
|
|
||||||
return {"enabled": bool(os.environ.get("FORGEJO_API_TOKEN")) and not demo}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/feedback", response_model=FeedbackResponse)
|
|
||||||
def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse:
|
|
||||||
"""File a Forgejo issue from in-app feedback."""
|
|
||||||
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(status_code=503, detail="Feedback disabled: FORGEJO_API_TOKEN not configured.")
|
|
||||||
if os.environ.get("DEMO_MODE", "false").lower() in ("1", "true", "yes"):
|
|
||||||
raise HTTPException(status_code=403, detail="Feedback disabled in demo mode.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
version = subprocess.check_output(
|
|
||||||
["git", "describe", "--tags", "--always"],
|
|
||||||
cwd=Path(__file__).resolve().parents[1], text=True, timeout=5,
|
|
||||||
).strip()
|
|
||||||
except Exception:
|
|
||||||
version = "dev"
|
|
||||||
|
|
||||||
_TYPE_LABELS = {"bug": "🐛 Bug", "feature": "✨ Feature Request", "other": "💬 Other"}
|
|
||||||
body_lines = [
|
|
||||||
f"## {_TYPE_LABELS.get(payload.type, '💬 Other')}",
|
|
||||||
"",
|
|
||||||
payload.description,
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
if payload.type == "bug" and payload.repro:
|
|
||||||
body_lines += ["### Reproduction Steps", "", payload.repro, ""]
|
|
||||||
body_lines += [
|
|
||||||
"### Context", "",
|
|
||||||
f"- **view:** {payload.view}",
|
|
||||||
f"- **version:** {version}",
|
|
||||||
f"- **platform:** {_platform.platform()}",
|
|
||||||
f"- **timestamp:** {datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')}",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
if payload.submitter:
|
|
||||||
body_lines += ["---", f"*Submitted by: {payload.submitter}*"]
|
|
||||||
|
|
||||||
labels = ["beta-feedback", "needs-triage",
|
|
||||||
{"bug": "bug", "feature": "feature-request"}.get(payload.type, "question")]
|
|
||||||
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
|
|
||||||
repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/snipe")
|
|
||||||
|
|
||||||
label_ids = _ensure_feedback_labels(labels)
|
|
||||||
resp = _requests.post(
|
|
||||||
f"{base}/repos/{repo}/issues",
|
|
||||||
headers=_fb_headers(),
|
|
||||||
json={"title": payload.title, "body": "\n".join(body_lines), "labels": label_ids},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
if not resp.ok:
|
|
||||||
raise HTTPException(status_code=502, detail=f"Forgejo error: {resp.text[:200]}")
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
return FeedbackResponse(issue_number=data["number"], issue_url=data["html_url"])
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ class TrustScorer:
|
||||||
signal_scores, is_dup, seller,
|
signal_scores, is_dup, seller,
|
||||||
listing_id=listing.id or 0,
|
listing_id=listing.id or 0,
|
||||||
listing_title=listing.title,
|
listing_title=listing.title,
|
||||||
listing_condition=listing.condition,
|
|
||||||
times_seen=listing.times_seen,
|
times_seen=listing.times_seen,
|
||||||
first_seen_at=listing.first_seen_at,
|
first_seen_at=listing.first_seen_at,
|
||||||
price=listing.price,
|
price=listing.price,
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,8 @@ _SCRATCH_DENT_KEYWORDS = frozenset([
|
||||||
"crack", "cracked", "chip", "chipped",
|
"crack", "cracked", "chip", "chipped",
|
||||||
"damage", "damaged", "cosmetic damage",
|
"damage", "damaged", "cosmetic damage",
|
||||||
"blemish", "wear", "worn", "worn in",
|
"blemish", "wear", "worn", "worn in",
|
||||||
# Parts / condition catch-alls (also matches eBay condition field strings verbatim)
|
# Parts / condition catch-alls
|
||||||
"as is", "for parts", "parts only", "spares or repair", "parts or repair",
|
"as is", "for parts", "parts only", "spares or repair", "parts or repair",
|
||||||
"parts/repair", "parts or not working", "not working",
|
|
||||||
# Evasive redirects — seller hiding damage detail in listing body
|
# Evasive redirects — seller hiding damage detail in listing body
|
||||||
"see description", "read description", "read listing", "see listing",
|
"see description", "read description", "read listing", "see listing",
|
||||||
"see photos for", "see pics for", "see images for",
|
"see photos for", "see pics for", "see images for",
|
||||||
|
|
@ -73,7 +72,6 @@ class Aggregator:
|
||||||
seller: Optional[Seller],
|
seller: Optional[Seller],
|
||||||
listing_id: int = 0,
|
listing_id: int = 0,
|
||||||
listing_title: str = "",
|
listing_title: str = "",
|
||||||
listing_condition: str = "",
|
|
||||||
times_seen: int = 1,
|
times_seen: int = 1,
|
||||||
first_seen_at: Optional[str] = None,
|
first_seen_at: Optional[str] = None,
|
||||||
price: float = 0.0,
|
price: float = 0.0,
|
||||||
|
|
@ -139,9 +137,7 @@ class Aggregator:
|
||||||
)
|
)
|
||||||
if photo_hash_duplicate and not is_established_retailer:
|
if photo_hash_duplicate and not is_established_retailer:
|
||||||
red_flags.append("duplicate_photo")
|
red_flags.append("duplicate_photo")
|
||||||
if (listing_title and _has_damage_keywords(listing_title)) or (
|
if listing_title and _has_damage_keywords(listing_title):
|
||||||
listing_condition and _has_damage_keywords(listing_condition)
|
|
||||||
):
|
|
||||||
red_flags.append("scratch_dent_mentioned")
|
red_flags.append("scratch_dent_mentioned")
|
||||||
|
|
||||||
# Staging DB signals
|
# Staging DB signals
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ version = "0.1.0"
|
||||||
description = "Auction listing monitor and trust scorer"
|
description = "Auction listing monitor and trust scorer"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"circuitforge-core>=0.8.0",
|
"circuitforge-core",
|
||||||
"streamlit>=1.32",
|
"streamlit>=1.32",
|
||||||
"requests>=2.31",
|
"requests>=2.31",
|
||||||
"imagehash>=4.3",
|
"imagehash>=4.3",
|
||||||
|
|
|
||||||
|
|
@ -80,45 +80,6 @@ def test_suspicious_price_flagged_when_price_genuinely_low():
|
||||||
assert "suspicious_price" in result.red_flags_json
|
assert "suspicious_price" in result.red_flags_json
|
||||||
|
|
||||||
|
|
||||||
def test_scratch_dent_flagged_from_title_slash_variant():
|
|
||||||
"""Title containing 'parts/repair' (slash variant, no 'or') must trigger scratch_dent_mentioned."""
|
|
||||||
agg = Aggregator()
|
|
||||||
scores = {k: 15 for k in ["account_age", "feedback_count",
|
|
||||||
"feedback_ratio", "price_vs_market", "category_history"]}
|
|
||||||
result = agg.aggregate(
|
|
||||||
scores, photo_hash_duplicate=False, seller=None,
|
|
||||||
listing_title="Generic Widget XL - Parts/Repair",
|
|
||||||
)
|
|
||||||
assert "scratch_dent_mentioned" in result.red_flags_json
|
|
||||||
|
|
||||||
|
|
||||||
def test_scratch_dent_flagged_from_condition_field():
|
|
||||||
"""eBay formal condition 'for parts or not working' must trigger scratch_dent_mentioned
|
|
||||||
even when the listing title contains no damage keywords."""
|
|
||||||
agg = Aggregator()
|
|
||||||
scores = {k: 15 for k in ["account_age", "feedback_count",
|
|
||||||
"feedback_ratio", "price_vs_market", "category_history"]}
|
|
||||||
result = agg.aggregate(
|
|
||||||
scores, photo_hash_duplicate=False, seller=None,
|
|
||||||
listing_title="Generic Widget XL",
|
|
||||||
listing_condition="for parts or not working",
|
|
||||||
)
|
|
||||||
assert "scratch_dent_mentioned" in result.red_flags_json
|
|
||||||
|
|
||||||
|
|
||||||
def test_scratch_dent_not_flagged_for_clean_listing():
|
|
||||||
"""Clean title + 'New' condition must NOT trigger scratch_dent_mentioned."""
|
|
||||||
agg = Aggregator()
|
|
||||||
scores = {k: 15 for k in ["account_age", "feedback_count",
|
|
||||||
"feedback_ratio", "price_vs_market", "category_history"]}
|
|
||||||
result = agg.aggregate(
|
|
||||||
scores, photo_hash_duplicate=False, seller=None,
|
|
||||||
listing_title="Generic Widget XL",
|
|
||||||
listing_condition="new",
|
|
||||||
)
|
|
||||||
assert "scratch_dent_mentioned" not in result.red_flags_json
|
|
||||||
|
|
||||||
|
|
||||||
def test_new_account_not_flagged_when_age_absent():
|
def test_new_account_not_flagged_when_age_absent():
|
||||||
"""account_age_days=None (scraper tier) must NOT trigger new_account or account_under_30_days."""
|
"""account_age_days=None (scraper tier) must NOT trigger new_account or account_under_30_days."""
|
||||||
agg = Aggregator()
|
agg = Aggregator()
|
||||||
|
|
|
||||||
|
|
@ -8,28 +8,23 @@
|
||||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Feedback FAB — hidden when FORGEJO_API_TOKEN not configured -->
|
|
||||||
<FeedbackButton :current-view="String(route.name ?? 'unknown')" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { RouterView, useRoute } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
import { useMotion } from './composables/useMotion'
|
import { useMotion } from './composables/useMotion'
|
||||||
import { useSnipeMode } from './composables/useSnipeMode'
|
import { useSnipeMode } from './composables/useSnipeMode'
|
||||||
import { useKonamiCode } from './composables/useKonamiCode'
|
import { useKonamiCode } from './composables/useKonamiCode'
|
||||||
import { useSessionStore } from './stores/session'
|
import { useSessionStore } from './stores/session'
|
||||||
import { useBlocklistStore } from './stores/blocklist'
|
import { useBlocklistStore } from './stores/blocklist'
|
||||||
import AppNav from './components/AppNav.vue'
|
import AppNav from './components/AppNav.vue'
|
||||||
import FeedbackButton from './components/FeedbackButton.vue'
|
|
||||||
|
|
||||||
const motion = useMotion()
|
const motion = useMotion()
|
||||||
const { activate, restore } = useSnipeMode()
|
const { activate, restore } = useSnipeMode()
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
const blocklistStore = useBlocklistStore()
|
const blocklistStore = useBlocklistStore()
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
useKonamiCode(activate)
|
useKonamiCode(activate)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,413 +0,0 @@
|
||||||
<template>
|
|
||||||
<!-- Floating trigger button -->
|
|
||||||
<button
|
|
||||||
v-if="enabled"
|
|
||||||
class="feedback-fab"
|
|
||||||
@click="open = true"
|
|
||||||
aria-label="Send feedback or report a bug"
|
|
||||||
title="Send feedback or report a bug"
|
|
||||||
>
|
|
||||||
<svg class="feedback-fab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="feedback-fab-label">Feedback</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Modal — teleported to body to avoid z-index / overflow clipping -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<Transition name="modal-fade">
|
|
||||||
<div v-if="open" class="feedback-overlay" @click.self="close">
|
|
||||||
<div class="feedback-modal" role="dialog" aria-modal="true" aria-label="Send Feedback">
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="feedback-header">
|
|
||||||
<h2 class="feedback-title">{{ step === 1 ? "What's on your mind?" : "Review & submit" }}</h2>
|
|
||||||
<button class="feedback-close" @click="close" aria-label="Close">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Step 1: Form ─────────────────────────────────────────── -->
|
|
||||||
<div v-if="step === 1" class="feedback-body">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Type</label>
|
|
||||||
<div class="filter-chip-row">
|
|
||||||
<button
|
|
||||||
v-for="t in types"
|
|
||||||
:key="t.value"
|
|
||||||
:class="['btn-chip', { active: form.type === t.value }]"
|
|
||||||
@click="form.type = t.value"
|
|
||||||
type="button"
|
|
||||||
>{{ t.label }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Title <span class="form-required">*</span></label>
|
|
||||||
<input
|
|
||||||
v-model="form.title"
|
|
||||||
class="form-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="Short summary of the issue or idea"
|
|
||||||
maxlength="120"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Description <span class="form-required">*</span></label>
|
|
||||||
<textarea
|
|
||||||
v-model="form.description"
|
|
||||||
class="form-input feedback-textarea"
|
|
||||||
placeholder="Describe what happened or what you'd like to see…"
|
|
||||||
rows="4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="form.type === 'bug'" class="form-group">
|
|
||||||
<label class="form-label">Reproduction steps</label>
|
|
||||||
<textarea
|
|
||||||
v-model="form.repro"
|
|
||||||
class="form-input feedback-textarea"
|
|
||||||
placeholder="1. Go to… 2. Tap… 3. See error"
|
|
||||||
rows="3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="stepError" class="feedback-error">{{ stepError }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Step 2: Attribution + confirm ──────────────────────────── -->
|
|
||||||
<div v-if="step === 2" class="feedback-body">
|
|
||||||
<div class="feedback-summary card">
|
|
||||||
<div class="feedback-summary-row">
|
|
||||||
<span class="text-muted text-sm">Type</span>
|
|
||||||
<span class="text-sm font-semibold">{{ typeLabel }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="feedback-summary-row">
|
|
||||||
<span class="text-muted text-sm">Title</span>
|
|
||||||
<span class="text-sm">{{ form.title }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="feedback-summary-row">
|
|
||||||
<span class="text-muted text-sm">Description</span>
|
|
||||||
<span class="text-sm feedback-summary-desc">{{ form.description }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group mt-md">
|
|
||||||
<label class="form-label">Attribution (optional)</label>
|
|
||||||
<input
|
|
||||||
v-model="form.submitter"
|
|
||||||
class="form-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="Your name <email@example.com>"
|
|
||||||
/>
|
|
||||||
<p class="text-muted text-xs mt-xs">Include your name and email in the issue if you'd like a response. Never required.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="submitError" class="feedback-error">{{ submitError }}</p>
|
|
||||||
<div v-if="submitted" class="feedback-success">
|
|
||||||
Issue filed! <a :href="issueUrl" target="_blank" rel="noopener" class="feedback-link">View on Forgejo →</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer nav -->
|
|
||||||
<div class="feedback-footer">
|
|
||||||
<button v-if="step === 2 && !submitted" class="btn btn-ghost" @click="step = 1" :disabled="loading">← Back</button>
|
|
||||||
<button v-if="!submitted" class="btn btn-ghost" @click="close" :disabled="loading">Cancel</button>
|
|
||||||
<button
|
|
||||||
v-if="step === 1"
|
|
||||||
class="btn btn-primary"
|
|
||||||
@click="nextStep"
|
|
||||||
>Next →</button>
|
|
||||||
<button
|
|
||||||
v-if="step === 2 && !submitted"
|
|
||||||
class="btn btn-primary"
|
|
||||||
@click="submit"
|
|
||||||
:disabled="loading"
|
|
||||||
>{{ loading ? 'Filing…' : 'Submit' }}</button>
|
|
||||||
<button v-if="submitted" class="btn btn-primary" @click="close">Done</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps<{ currentView?: 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')
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
enabled.value = data.enabled === true
|
|
||||||
}
|
|
||||||
} catch { /* network error — stay hidden */ }
|
|
||||||
})
|
|
||||||
|
|
||||||
const open = ref(false)
|
|
||||||
const step = ref(1)
|
|
||||||
const loading = ref(false)
|
|
||||||
const stepError = ref('')
|
|
||||||
const submitError = ref('')
|
|
||||||
const submitted = ref(false)
|
|
||||||
const issueUrl = ref('')
|
|
||||||
|
|
||||||
const types: { value: 'bug' | 'feature' | 'other'; label: string }[] = [
|
|
||||||
{ value: 'bug', label: '🐛 Bug' },
|
|
||||||
{ value: 'feature', label: '✨ Feature request' },
|
|
||||||
{ value: 'other', label: '💬 Other' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const form = ref({
|
|
||||||
type: 'bug' as 'bug' | 'feature' | 'other',
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
repro: '',
|
|
||||||
submitter: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const typeLabel = computed(() => types.find(t => t.value === form.value.type)?.label ?? '')
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
open.value = false
|
|
||||||
// reset after transition
|
|
||||||
setTimeout(reset, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
step.value = 1
|
|
||||||
loading.value = false
|
|
||||||
stepError.value = ''
|
|
||||||
submitError.value = ''
|
|
||||||
submitted.value = false
|
|
||||||
issueUrl.value = ''
|
|
||||||
form.value = { type: 'bug', title: '', description: '', repro: '', submitter: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextStep() {
|
|
||||||
stepError.value = ''
|
|
||||||
if (!form.value.title.trim() || !form.value.description.trim()) {
|
|
||||||
stepError.value = 'Please fill in both Title and Description.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
step.value = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
loading.value = true
|
|
||||||
submitError.value = ''
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/feedback', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: form.value.title.trim(),
|
|
||||||
description: form.value.description.trim(),
|
|
||||||
type: form.value.type,
|
|
||||||
repro: form.value.repro.trim(),
|
|
||||||
view: props.currentView ?? 'unknown',
|
|
||||||
submitter: form.value.submitter.trim(),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
|
||||||
submitError.value = err.detail ?? 'Submission failed.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const data = await res.json()
|
|
||||||
issueUrl.value = data.issue_url
|
|
||||||
submitted.value = true
|
|
||||||
} catch (e) {
|
|
||||||
submitError.value = 'Network error — please try again.'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* ── Floating action button ─────────────────────────────────────────── */
|
|
||||||
.feedback-fab {
|
|
||||||
position: fixed;
|
|
||||||
right: var(--spacing-md);
|
|
||||||
bottom: calc(68px + var(--spacing-md)); /* 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);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 999px;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
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);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
}
|
|
||||||
.feedback-fab-icon { width: 15px; height: 15px; flex-shrink: 0; }
|
|
||||||
.feedback-fab-label { white-space: nowrap; }
|
|
||||||
|
|
||||||
/* On desktop, bottom nav is gone — drop to standard corner */
|
|
||||||
@media (min-width: 769px) {
|
|
||||||
.feedback-fab {
|
|
||||||
bottom: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Overlay ──────────────────────────────────────────────────────────── */
|
|
||||||
.feedback-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 500px) {
|
|
||||||
.feedback-overlay {
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Modal ────────────────────────────────────────────────────────────── */
|
|
||||||
.feedback-modal {
|
|
||||||
background: var(--color-bg-elevated);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
|
||||||
width: 100%;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 500px) {
|
|
||||||
.feedback-modal {
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
width: 100%;
|
|
||||||
max-width: 520px;
|
|
||||||
max-height: 85vh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--spacing-md) var(--spacing-md) var(--spacing-sm);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.feedback-title {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.feedback-close {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.feedback-close:hover { color: var(--color-text-primary); }
|
|
||||||
|
|
||||||
.feedback-body {
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 80px;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-required { color: var(--color-error); margin-left: 2px; }
|
|
||||||
|
|
||||||
.feedback-error {
|
|
||||||
color: var(--color-error);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
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);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
.feedback-link { color: var(--color-success); font-weight: 600; text-decoration: underline; }
|
|
||||||
|
|
||||||
/* Summary card (step 2) */
|
|
||||||
.feedback-summary {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
.feedback-summary-row {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.feedback-summary-row > :first-child { min-width: 72px; flex-shrink: 0; }
|
|
||||||
.feedback-summary-desc {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mt-md { margin-top: var(--spacing-md); }
|
|
||||||
.mt-xs { margin-top: var(--spacing-xs); }
|
|
||||||
|
|
||||||
/* Transition */
|
|
||||||
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.2s ease; }
|
|
||||||
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -120,7 +120,6 @@ export const useSearchStore = defineStore('search', () => {
|
||||||
)
|
)
|
||||||
const marketPrice = ref<number | null>(cached?.marketPrice ?? null)
|
const marketPrice = ref<number | null>(cached?.marketPrice ?? null)
|
||||||
const adapterUsed = ref<'api' | 'scraper' | null>(cached?.adapterUsed ?? null)
|
const adapterUsed = ref<'api' | 'scraper' | null>(cached?.adapterUsed ?? null)
|
||||||
const affiliateActive = ref<boolean>(false)
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -165,7 +164,6 @@ export const useSearchStore = defineStore('search', () => {
|
||||||
sellers: Record<string, Seller>
|
sellers: Record<string, Seller>
|
||||||
market_price: number | null
|
market_price: number | null
|
||||||
adapter_used: 'api' | 'scraper'
|
adapter_used: 'api' | 'scraper'
|
||||||
affiliate_active: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
results.value = data.listings ?? []
|
results.value = data.listings ?? []
|
||||||
|
|
@ -173,7 +171,6 @@ export const useSearchStore = defineStore('search', () => {
|
||||||
sellers.value = new Map(Object.entries(data.sellers ?? {}))
|
sellers.value = new Map(Object.entries(data.sellers ?? {}))
|
||||||
marketPrice.value = data.market_price ?? null
|
marketPrice.value = data.market_price ?? null
|
||||||
adapterUsed.value = data.adapter_used ?? null
|
adapterUsed.value = data.adapter_used ?? null
|
||||||
affiliateActive.value = data.affiliate_active ?? false
|
|
||||||
saveCache({
|
saveCache({
|
||||||
query: q,
|
query: q,
|
||||||
results: results.value,
|
results: results.value,
|
||||||
|
|
@ -228,7 +225,6 @@ export const useSearchStore = defineStore('search', () => {
|
||||||
sellers,
|
sellers,
|
||||||
marketPrice,
|
marketPrice,
|
||||||
adapterUsed,
|
adapterUsed,
|
||||||
affiliateActive,
|
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
search,
|
search,
|
||||||
|
|
|
||||||
|
|
@ -91,17 +91,6 @@
|
||||||
aria-label="Search filters"
|
aria-label="Search filters"
|
||||||
>
|
>
|
||||||
|
|
||||||
<!-- Clear all filters — only shown when at least one filter is active -->
|
|
||||||
<button
|
|
||||||
v-if="activeFilterCount > 0"
|
|
||||||
type="button"
|
|
||||||
class="filter-clear-btn"
|
|
||||||
@click="resetFilters"
|
|
||||||
aria-label="Clear all filters"
|
|
||||||
>
|
|
||||||
✕ Clear filters ({{ activeFilterCount }})
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- ── eBay Search Parameters ─────────────────────────────────────── -->
|
<!-- ── eBay Search Parameters ─────────────────────────────────────── -->
|
||||||
<!-- These are sent to eBay. Changes require a new search to take effect. -->
|
<!-- These are sent to eBay. Changes require a new search to take effect. -->
|
||||||
<h2 class="filter-section-heading filter-section-heading--search">
|
<h2 class="filter-section-heading filter-section-heading--search">
|
||||||
|
|
@ -307,9 +296,6 @@
|
||||||
<span v-if="hiddenCount > 0" class="results-hidden">
|
<span v-if="hiddenCount > 0" class="results-hidden">
|
||||||
· {{ hiddenCount }} hidden by filters
|
· {{ hiddenCount }} hidden by filters
|
||||||
</span>
|
</span>
|
||||||
<span v-if="store.affiliateActive" class="affiliate-disclosure">
|
|
||||||
· Links may include an affiliate code
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<label for="sort-select" class="sr-only">Sort by</label>
|
<label for="sort-select" class="sr-only">Sort by</label>
|
||||||
|
|
@ -416,7 +402,7 @@ onMounted(() => {
|
||||||
|
|
||||||
// ── Filters ──────────────────────────────────────────────────────────────────
|
// ── Filters ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DEFAULT_FILTERS: SearchFilters = {
|
const filters = reactive<SearchFilters>({
|
||||||
minTrustScore: 0,
|
minTrustScore: 0,
|
||||||
minPrice: undefined,
|
minPrice: undefined,
|
||||||
maxPrice: undefined,
|
maxPrice: undefined,
|
||||||
|
|
@ -435,13 +421,7 @@ const DEFAULT_FILTERS: SearchFilters = {
|
||||||
mustExclude: '',
|
mustExclude: '',
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
adapter: 'auto' as 'auto' | 'api' | 'scraper',
|
adapter: 'auto' as 'auto' | 'api' | 'scraper',
|
||||||
}
|
})
|
||||||
|
|
||||||
const filters = reactive<SearchFilters>({ ...DEFAULT_FILTERS })
|
|
||||||
|
|
||||||
function resetFilters() {
|
|
||||||
Object.assign(filters, DEFAULT_FILTERS)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse comma-separated keyword strings into trimmed, lowercase, non-empty term arrays
|
// Parse comma-separated keyword strings into trimmed, lowercase, non-empty term arrays
|
||||||
const parsedMustInclude = computed(() =>
|
const parsedMustInclude = computed(() =>
|
||||||
|
|
@ -784,27 +764,6 @@ async function onSearch() {
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Clear all filters button */
|
|
||||||
.filter-clear-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--space-1) var(--space-2);
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
background: color-mix(in srgb, var(--color-red, #ef4444) 12%, transparent);
|
|
||||||
color: var(--color-red, #ef4444);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-red, #ef4444) 30%, transparent);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
.filter-clear-btn:hover {
|
|
||||||
background: color-mix(in srgb, var(--color-red, #ef4444) 22%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section headings that separate eBay Search params from local filters */
|
/* Section headings that separate eBay Search params from local filters */
|
||||||
.filter-section-heading {
|
.filter-section-heading {
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
|
|
@ -1070,7 +1029,6 @@ async function onSearch() {
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-hidden { color: var(--color-warning); }
|
.results-hidden { color: var(--color-warning); }
|
||||||
.affiliate-disclosure { color: var(--color-text-muted, #8b949e); font-size: 0.8em; }
|
|
||||||
|
|
||||||
.toolbar-actions {
|
.toolbar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue