feat: multi-platform scaffolding — phase 1 (eBay-only, wire complete)
Backend:
- app/platforms/__init__.py: add SUPPORTED_PLATFORMS frozenset (single
source of truth for platform validation); add must_include_mode and
adapter fields to SearchFilters dataclass
- api/main.py: add platform: str = Query("ebay") to both /api/search
and /api/search/async; validate against SUPPORTED_PLATFORMS (422 on
unknown platform); thread platform into structured log lines; document
Phase 2 registry extension point in _make_adapter
Frontend:
- SearchView.vue: platform tab strip (eBay active, Mercari + Poshmark
disabled with "soon" badge) above search bar; eBay-specific controls
(category select, data source, pages, keywords) hidden when platform
!== 'ebay'; platform passed to SearchProgress
- search.ts: platform?: string added to SearchFilters; included in
async search params when non-eBay
- SearchProgress.vue: platform prop + PLATFORM_LABELS map; status line
reads "Searching eBay for…" / "Searching Mercari for…" dynamically
This commit is contained in:
parent
b993f6f4a9
commit
f48f8ef80f
5 changed files with 133 additions and 9 deletions
31
api/main.py
31
api/main.py
|
|
@ -24,7 +24,7 @@ from circuitforge_core.affiliates import wrap_url as _wrap_affiliate_url
|
|||
from circuitforge_core.api import make_corrections_router as _make_corrections_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 import Depends, FastAPI, File, HTTPException, Query, Request, Response, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -34,7 +34,7 @@ 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.platforms import SearchFilters
|
||||
from app.platforms import SUPPORTED_PLATFORMS, SearchFilters
|
||||
from app.platforms.ebay.adapter import EbayAdapter
|
||||
from app.platforms.ebay.auth import EbayTokenManager
|
||||
from app.platforms.ebay.query_builder import expand_queries, parse_groups
|
||||
|
|
@ -674,6 +674,11 @@ def _make_adapter(shared_store: Store, force: str = "auto"):
|
|||
|
||||
Adapters receive shared_store because they only read/write sellers and
|
||||
market_comps — never listings. Listings are returned and saved by the caller.
|
||||
|
||||
# Platform registry — add new adapters here as platforms are implemented.
|
||||
# _make_adapter() currently handles eBay only. Phase 2 will add:
|
||||
# "mercari": MercariAdapter
|
||||
# "poshmark": PoshmarkAdapter
|
||||
"""
|
||||
client_id, client_secret, env = _ebay_creds()
|
||||
has_creds = bool(client_id and client_secret)
|
||||
|
|
@ -713,8 +718,15 @@ def search(
|
|||
category_id: str = "", # eBay category ID — forwarded to Browse API / scraper _sacat
|
||||
adapter: str = "auto", # "auto" | "api" | "scraper" — override adapter selection
|
||||
refresh: bool = False, # when True, bypass cache read (still writes fresh result)
|
||||
platform: str = Query("ebay", description="Marketplace platform to search"),
|
||||
session: CloudUser = Depends(get_session),
|
||||
):
|
||||
if platform not in SUPPORTED_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Platform {platform!r} is not yet supported. Supported: {sorted(SUPPORTED_PLATFORMS)}",
|
||||
)
|
||||
|
||||
# If the user pasted an eBay listing or checkout URL, extract the item ID
|
||||
# and use it as the search query so the exact item surfaces in results.
|
||||
ebay_item_id = _extract_ebay_item_id(q)
|
||||
|
|
@ -909,8 +921,8 @@ def search(
|
|||
raise HTTPException(status_code=502, detail=f"eBay search failed: {e}")
|
||||
|
||||
log.info(
|
||||
"search auth=%s tier=%s adapter=%s pages=%d queries=%d listings=%d q=%r",
|
||||
_auth_label(session.user_id), session.tier, adapter_used,
|
||||
"search platform=%s auth=%s tier=%s adapter=%s pages=%d queries=%d listings=%d q=%r",
|
||||
platform, _auth_label(session.user_id), session.tier, adapter_used,
|
||||
pages, len(ebay_queries), len(listings), q,
|
||||
)
|
||||
|
||||
|
|
@ -1073,6 +1085,7 @@ def search_async(
|
|||
category_id: str = "",
|
||||
adapter: str = "auto",
|
||||
refresh: bool = False, # when True, bypass cache read (still writes fresh result)
|
||||
platform: str = Query("ebay", description="Marketplace platform to search"),
|
||||
session: CloudUser = Depends(get_session),
|
||||
):
|
||||
"""Async variant of GET /api/search.
|
||||
|
|
@ -1088,6 +1101,12 @@ def search_async(
|
|||
"seller": {...}, "market_price": ...} (enrichment updates)
|
||||
None (sentinel — stream finished)
|
||||
"""
|
||||
if platform not in SUPPORTED_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Platform {platform!r} is not yet supported. Supported: {sorted(SUPPORTED_PLATFORMS)}",
|
||||
)
|
||||
|
||||
# Validate / normalise params — same logic as synchronous endpoint.
|
||||
ebay_item_id = _extract_ebay_item_id(q)
|
||||
if ebay_item_id:
|
||||
|
|
@ -1285,8 +1304,8 @@ def search_async(
|
|||
comps_future.result()
|
||||
|
||||
log.info(
|
||||
"async_search auth=%s tier=%s adapter=%s pages=%d listings=%d q=%r",
|
||||
_auth_label(_user_id), _tier, adapter_used, pages, len(listings), q_norm,
|
||||
"async_search platform=%s auth=%s tier=%s adapter=%s pages=%d listings=%d q=%r",
|
||||
platform, _auth_label(_user_id), _tier, adapter_used, pages, len(listings), q_norm,
|
||||
)
|
||||
|
||||
shared_store = Store(_shared_db)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ from typing import Optional
|
|||
|
||||
from app.db.models import Listing, Seller
|
||||
|
||||
# Single source of truth for platform validation.
|
||||
# Phase 2 will extend this set as new adapters are implemented.
|
||||
SUPPORTED_PLATFORMS: frozenset[str] = frozenset({"ebay"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchFilters:
|
||||
|
|
@ -18,6 +22,8 @@ class SearchFilters:
|
|||
must_include: list[str] = field(default_factory=list) # client-side title filter
|
||||
must_exclude: list[str] = field(default_factory=list) # forwarded to eBay -term AND client-side
|
||||
category_id: Optional[str] = None # eBay category ID (e.g. "27386" = GPUs)
|
||||
must_include_mode: str = "all" # "all" | "any" | "groups"
|
||||
adapter: str = "auto" # "auto" | "api" | "scraper"
|
||||
|
||||
|
||||
class PlatformAdapter(ABC):
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
<!-- Status line -->
|
||||
<p class="progress-label">
|
||||
Searching eBay for <strong>{{ query }}</strong>…
|
||||
Searching <strong>{{ platformLabel }}</strong> for <strong>{{ query }}</strong>…
|
||||
</p>
|
||||
|
||||
<!-- Skeleton listing cards -->
|
||||
|
|
@ -28,7 +28,19 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ query: string }>()
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ query: string; platform?: string }>()
|
||||
|
||||
const PLATFORM_LABELS: Record<string, string> = {
|
||||
ebay: 'eBay',
|
||||
mercari: 'Mercari',
|
||||
poshmark: 'Poshmark',
|
||||
}
|
||||
|
||||
const platformLabel = computed(() =>
|
||||
PLATFORM_LABELS[props.platform ?? 'ebay'] ?? props.platform ?? 'eBay'
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -59,6 +59,11 @@ export interface SavedSearch {
|
|||
filters_json: string // JSON blob of SearchFilters subset
|
||||
created_at: string | null
|
||||
last_run_at: string | null
|
||||
// Monitor settings (migration 014)
|
||||
monitor_enabled: boolean
|
||||
poll_interval_min: number
|
||||
min_trust_score: number
|
||||
last_checked_at: string | null
|
||||
}
|
||||
|
||||
export interface SearchParamsResult {
|
||||
|
|
@ -93,6 +98,7 @@ export interface SearchFilters {
|
|||
mustExclude?: string // comma-separated; forwarded to eBay -term AND client-side
|
||||
categoryId?: string // eBay category ID (e.g. "27386" = Graphics/Video Cards)
|
||||
adapter?: 'auto' | 'api' | 'scraper' // override adapter selection
|
||||
platform?: string // target platform; defaults to 'ebay' when omitted
|
||||
}
|
||||
|
||||
// ── Session cache ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -173,6 +179,7 @@ export const useSearchStore = defineStore('search', () => {
|
|||
if (filters.mustExclude?.trim()) params.set('must_exclude', filters.mustExclude.trim())
|
||||
if (filters.categoryId?.trim()) params.set('category_id', filters.categoryId.trim())
|
||||
if (filters.adapter && filters.adapter !== 'auto') params.set('adapter', filters.adapter)
|
||||
if (filters.platform && filters.platform !== 'ebay') params.set('platform', filters.platform)
|
||||
|
||||
// Use the async endpoint: returns 202 immediately with a session_id, then
|
||||
// streams listings + trust scores via SSE as the scrape completes.
|
||||
|
|
|
|||
|
|
@ -2,8 +2,29 @@
|
|||
<div class="search-view">
|
||||
<!-- Search bar -->
|
||||
<header class="search-header">
|
||||
<div class="platform-tabs" role="tablist" aria-label="Search platform">
|
||||
<button
|
||||
v-for="p in PLATFORMS"
|
||||
:key="p.value"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="platform-tab"
|
||||
:class="{
|
||||
'platform-tab--active': filters.platform === p.value,
|
||||
'platform-tab--soon': !p.available,
|
||||
}"
|
||||
:aria-selected="filters.platform === p.value"
|
||||
:disabled="!p.available"
|
||||
:title="p.available ? p.label : `${p.label} — coming soon`"
|
||||
@click="p.available && (filters.platform = p.value)"
|
||||
>
|
||||
{{ p.label }}
|
||||
<span v-if="!p.available" class="platform-tab__soon">soon</span>
|
||||
</button>
|
||||
</div>
|
||||
<form class="search-form" @submit.prevent="onSearch" role="search">
|
||||
<div class="search-form-row1">
|
||||
<template v-if="filters.platform === 'ebay' || !filters.platform">
|
||||
<label for="cat-select" class="sr-only">Category</label>
|
||||
<select
|
||||
id="cat-select"
|
||||
|
|
@ -20,6 +41,7 @@
|
|||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</template>
|
||||
<label for="search-input" class="sr-only">Search listings</label>
|
||||
<input
|
||||
id="search-input"
|
||||
|
|
@ -116,6 +138,7 @@
|
|||
|
||||
<!-- ── eBay Search Parameters ─────────────────────────────────────── -->
|
||||
<!-- These are sent to eBay. Changes require a new search to take effect. -->
|
||||
<template v-if="filters.platform === 'ebay' || !filters.platform">
|
||||
<h2 class="filter-section-heading filter-section-heading--search">
|
||||
eBay Search
|
||||
</h2>
|
||||
|
|
@ -216,6 +239,7 @@
|
|||
<p class="filter-pages-hint">Excludes forwarded to eBay on re-search</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<!-- ── Post-search Filters ────────────────────────────────────────── -->
|
||||
<!-- Applied locally to current results — no re-search needed. -->
|
||||
|
|
@ -356,7 +380,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Loading (scraping in progress, no results yet) -->
|
||||
<SearchProgress v-else-if="store.loading && !store.results.length" :query="store.query" />
|
||||
<SearchProgress v-else-if="store.loading && !store.results.length" :query="store.query" :platform="filters.platform ?? 'ebay'" />
|
||||
|
||||
<!-- No results -->
|
||||
<div v-else-if="!store.results.length && !store.loading && store.query" class="results-empty">
|
||||
|
|
@ -636,6 +660,7 @@ const DEFAULT_FILTERS: SearchFilters = {
|
|||
mustExclude: '',
|
||||
categoryId: '',
|
||||
adapter: 'auto' as 'auto' | 'api' | 'scraper',
|
||||
platform: 'ebay',
|
||||
}
|
||||
|
||||
const filters = reactive<SearchFilters>({ ...DEFAULT_FILTERS })
|
||||
|
|
@ -671,6 +696,12 @@ const parsedMustIncludeGroups = computed(() =>
|
|||
.filter(g => g.length > 0)
|
||||
)
|
||||
|
||||
const PLATFORMS: { value: string; label: string; available: boolean }[] = [
|
||||
{ value: 'ebay', label: 'eBay', available: true },
|
||||
{ value: 'mercari', label: 'Mercari', available: false },
|
||||
{ value: 'poshmark', label: 'Poshmark', available: false },
|
||||
]
|
||||
|
||||
const INCLUDE_MODES: { value: MustIncludeMode; label: string }[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'any', label: 'Any' },
|
||||
|
|
@ -1795,4 +1826,53 @@ async function onSearch() {
|
|||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── Platform tab strip ──────────────────────────────────────────────── */
|
||||
.platform-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.platform-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms ease, color 150ms ease, background 150ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.platform-tab:hover:not(:disabled):not(.platform-tab--active) {
|
||||
border-color: var(--app-primary);
|
||||
color: var(--app-primary);
|
||||
}
|
||||
|
||||
.platform-tab--active {
|
||||
background: var(--app-primary);
|
||||
border-color: var(--app-primary);
|
||||
color: var(--color-text-inverse);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.platform-tab--soon {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.platform-tab__soon {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue