Compare commits

..

No commits in common. "af51de4cecf05231d2eb49afe2481a3793f08eb4" and "29922ede47db237ce8050432299f0c1a20514035" have entirely different histories.

11 changed files with 33 additions and 1405 deletions

View file

@ -19,7 +19,6 @@ import { onMounted } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { useMotion } from './composables/useMotion'
import { useSnipeMode } from './composables/useSnipeMode'
import { useTheme } from './composables/useTheme'
import { useKonamiCode } from './composables/useKonamiCode'
import { useSessionStore } from './stores/session'
import { useBlocklistStore } from './stores/blocklist'
@ -29,7 +28,6 @@ import FeedbackButton from './components/FeedbackButton.vue'
const motion = useMotion()
const { activate, restore } = useSnipeMode()
const { restore: restoreTheme } = useTheme()
const session = useSessionStore()
const blocklistStore = useBlocklistStore()
const preferencesStore = usePreferencesStore()
@ -39,7 +37,6 @@ useKonamiCode(activate)
onMounted(async () => {
restore() // re-apply snipe mode from localStorage on hard reload
restoreTheme() // re-apply explicit theme override on hard reload
await session.bootstrap() // fetch tier + feature flags from API
blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately
preferencesStore.load() // load user preferences after session resolves

View file

@ -1,255 +0,0 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Listing, TrustScore, Seller } from '../stores/search'
import { useSearchStore } from '../stores/search'
// ── Mock vue-router — ListingView reads route.params.id ──────────────────────
const mockRouteId = { value: 'test-listing-id' }
vi.mock('vue-router', () => ({
useRoute: () => ({ params: { id: mockRouteId.value } }),
RouterLink: { template: '<a><slot /></a>' },
}))
// ── Helpers ──────────────────────────────────────────────────────────────────
function makeListing(id: string, overrides: Partial<Listing> = {}): Listing {
return {
id: null, platform: 'ebay', platform_listing_id: id,
title: 'NVIDIA RTX 4090 24GB — Used Excellent', price: 849.99,
currency: 'USD', condition: 'used_excellent', seller_platform_id: 'seller1',
url: 'https://ebay.com/itm/test', photo_urls: ['https://example.com/img.jpg'],
listing_age_days: 3, buying_format: 'fixed_price', ends_at: null,
fetched_at: null, trust_score_id: null, ...overrides,
}
}
function makeTrust(score: number, flags: string[] = [], partial = false): TrustScore {
return {
id: null, listing_id: 1, composite_score: score,
account_age_score: 18, feedback_count_score: 20, feedback_ratio_score: 20,
price_vs_market_score: 15, category_history_score: 14,
photo_hash_duplicate: false, photo_analysis_json: null,
red_flags_json: JSON.stringify(flags), score_is_partial: partial, scored_at: null,
}
}
function makeSeller(overrides: Partial<Seller> = {}): Seller {
return {
id: null, platform: 'ebay', platform_seller_id: 'seller1',
username: 'techdeals_rog', account_age_days: 720, feedback_count: 4711,
feedback_ratio: 0.997, category_history_json: '{}', fetched_at: null,
...overrides,
}
}
async function mountView(storeSetup?: (store: ReturnType<typeof useSearchStore>) => void) {
setActivePinia(createPinia())
const store = useSearchStore()
if (storeSetup) storeSetup(store)
const { default: ListingView } = await import('../views/ListingView.vue')
return mount(ListingView, {
global: { plugins: [] },
})
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('ListingView — not found', () => {
beforeEach(() => {
mockRouteId.value = 'missing-id'
sessionStorage.clear()
})
it('shows not-found state when listing is absent from store', async () => {
const wrapper = await mountView()
expect(wrapper.text()).toContain('Listing not found')
expect(wrapper.text()).toContain('Return to search')
})
it('does not render the trust section when listing is absent', async () => {
const wrapper = await mountView()
expect(wrapper.find('.lv-trust').exists()).toBe(false)
})
})
describe('ListingView — listing present', () => {
const ID = 'test-listing-id'
beforeEach(() => {
mockRouteId.value = ID
sessionStorage.clear()
})
it('renders the listing title', async () => {
const wrapper = await mountView(store => {
store.results.push(makeListing(ID))
store.trustScores.set(ID, makeTrust(85))
store.sellers.set('seller1', makeSeller())
})
expect(wrapper.text()).toContain('NVIDIA RTX 4090 24GB')
})
it('renders the formatted price', async () => {
const wrapper = await mountView(store => {
store.results.push(makeListing(ID))
store.trustScores.set(ID, makeTrust(85))
})
expect(wrapper.text()).toContain('$849.99')
})
it('shows the composite trust score in the ring', async () => {
const wrapper = await mountView(store => {
store.results.push(makeListing(ID))
store.trustScores.set(ID, makeTrust(72))
})
expect(wrapper.find('.lv-ring__score').text()).toBe('72')
})
it('renders all five signal rows in the table', async () => {
const wrapper = await mountView(store => {
store.results.push(makeListing(ID))
store.trustScores.set(ID, makeTrust(80))
store.sellers.set('seller1', makeSeller())
})
const rows = wrapper.findAll('.lv-signals__row')
expect(rows).toHaveLength(5)
})
it('shows score values in signal table', async () => {
const wrapper = await mountView(store => {
store.results.push(makeListing(ID))
store.trustScores.set(ID, makeTrust(80))
store.sellers.set('seller1', makeSeller())
})
// feedback_count_score = 20
expect(wrapper.text()).toContain('20 / 20')
})
it('shows seller username', async () => {
const wrapper = await mountView(store => {
store.results.push(makeListing(ID))
store.trustScores.set(ID, makeTrust(80))
store.sellers.set('seller1', makeSeller({ username: 'gpu_warehouse' }))
})
expect(wrapper.text()).toContain('gpu_warehouse')
})
})
describe('ListingView — red flags', () => {
const ID = 'test-listing-id'
beforeEach(() => {
mockRouteId.value = ID
sessionStorage.clear()
})
it('renders hard flag badge for new_account', async () => {
const wrapper = await mountView(store => {
store.results.push(makeListing(ID))
store.trustScores.set(ID, makeTrust(40, ['new_account']))
})
const flags = wrapper.findAll('.lv-flag--hard')
expect(flags.length).toBeGreaterThan(0)
expect(wrapper.text()).toContain('New account')
})
it('renders soft flag badge for scratch_dent_mentioned', async () => {
const wrapper = await mountView(store => {
store.results.push(makeListing(ID))
store.trustScores.set(ID, makeTrust(55, ['scratch_dent_mentioned']))
})
const flags = wrapper.findAll('.lv-flag--soft')
expect(flags.length).toBeGreaterThan(0)
expect(wrapper.text()).toContain('Damage mentioned')
})
it('shows no flag badges when red_flags_json is empty', async () => {
const wrapper = await mountView(store => {
store.results.push(makeListing(ID))
store.trustScores.set(ID, makeTrust(90, []))
})
expect(wrapper.find('.lv-flag').exists()).toBe(false)
})
it('applies triple-red class when account + price + photo flags all present', async () => {
const wrapper = await mountView(store => {
store.results.push(makeListing(ID))
store.trustScores.set(ID, makeTrust(12, [
'new_account', 'suspicious_price', 'duplicate_photo',
]))
})
expect(wrapper.find('.lv-layout--triple-red').exists()).toBe(true)
})
it('does not apply triple-red class when only two flag categories present', async () => {
const wrapper = await mountView(store => {
store.results.push(makeListing(ID))
store.trustScores.set(ID, makeTrust(30, ['new_account', 'suspicious_price']))
})
expect(wrapper.find('.lv-layout--triple-red').exists()).toBe(false)
})
})
describe('ListingView — partial/pending signals', () => {
const ID = 'test-listing-id'
beforeEach(() => {
mockRouteId.value = ID
sessionStorage.clear()
})
it('shows pending for account age when seller.account_age_days is null', async () => {
const wrapper = await mountView(store => {
store.results.push(makeListing(ID))
store.trustScores.set(ID, makeTrust(60, [], true))
store.sellers.set('seller1', makeSeller({ account_age_days: null }))
})
expect(wrapper.text()).toContain('pending')
})
it('shows partial warning text when score_is_partial is true', async () => {
const wrapper = await mountView(store => {
store.results.push(makeListing(ID))
store.trustScores.set(ID, makeTrust(60, [], true))
store.sellers.set('seller1', makeSeller({ account_age_days: null }))
})
expect(wrapper.find('.lv-verdict__partial').exists()).toBe(true)
})
})
describe('ListingView — ring colour class', () => {
const ID = 'test-listing-id'
beforeEach(() => {
mockRouteId.value = ID
sessionStorage.clear()
})
it('applies lv-ring--high for score >= 80', async () => {
const wrapper = await mountView(store => {
store.results.push(makeListing(ID))
store.trustScores.set(ID, makeTrust(82))
})
expect(wrapper.find('.lv-ring--high').exists()).toBe(true)
})
it('applies lv-ring--mid for score 5079', async () => {
const wrapper = await mountView(store => {
store.results.push(makeListing(ID))
store.trustScores.set(ID, makeTrust(63))
})
expect(wrapper.find('.lv-ring--mid').exists()).toBe(true)
})
it('applies lv-ring--low for score < 50', async () => {
const wrapper = await mountView(store => {
store.results.push(makeListing(ID))
store.trustScores.set(ID, makeTrust(22))
})
expect(wrapper.find('.lv-ring--low').exists()).toBe(true)
})
})

View file

@ -1,110 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useSearchStore } from '../stores/search'
import type { Listing, TrustScore, Seller } from '../stores/search'
function makeListing(id: string, overrides: Partial<Listing> = {}): Listing {
return {
id: null,
platform: 'ebay',
platform_listing_id: id,
title: `Listing ${id}`,
price: 100,
currency: 'USD',
condition: 'used',
seller_platform_id: 'seller1',
url: `https://ebay.com/itm/${id}`,
photo_urls: [],
listing_age_days: 1,
buying_format: 'fixed_price',
ends_at: null,
fetched_at: null,
trust_score_id: null,
...overrides,
}
}
function makeTrust(score: number, flags: string[] = []): TrustScore {
return {
id: null,
listing_id: 1,
composite_score: score,
account_age_score: 20,
feedback_count_score: 20,
feedback_ratio_score: 20,
price_vs_market_score: 20,
category_history_score: 20,
photo_hash_duplicate: false,
photo_analysis_json: null,
red_flags_json: JSON.stringify(flags),
score_is_partial: false,
scored_at: null,
}
}
describe('useSearchStore.getListing', () => {
beforeEach(() => {
setActivePinia(createPinia())
sessionStorage.clear()
})
it('returns undefined when results are empty', () => {
const store = useSearchStore()
expect(store.getListing('abc')).toBeUndefined()
})
it('returns the listing when present in results', () => {
const store = useSearchStore()
const listing = makeListing('v1|123|0')
store.results.push(listing)
expect(store.getListing('v1|123|0')).toEqual(listing)
})
it('returns undefined for an id not in results', () => {
const store = useSearchStore()
store.results.push(makeListing('v1|123|0'))
expect(store.getListing('v1|999|0')).toBeUndefined()
})
it('returns the correct listing when multiple are present', () => {
const store = useSearchStore()
store.results.push(makeListing('v1|001|0', { title: 'First' }))
store.results.push(makeListing('v1|002|0', { title: 'Second' }))
store.results.push(makeListing('v1|003|0', { title: 'Third' }))
expect(store.getListing('v1|002|0')?.title).toBe('Second')
})
it('handles URL-encoded pipe characters in listing IDs', () => {
const store = useSearchStore()
// The route param arrives decoded from vue-router; store uses decoded string
const listing = makeListing('v1|157831011297|0')
store.results.push(listing)
expect(store.getListing('v1|157831011297|0')).toEqual(listing)
})
})
describe('useSearchStore trust and seller maps', () => {
beforeEach(() => {
setActivePinia(createPinia())
sessionStorage.clear()
})
it('trustScores map returns trust by platform_listing_id', () => {
const store = useSearchStore()
const trust = makeTrust(85, ['low_feedback_count'])
store.trustScores.set('v1|123|0', trust)
expect(store.trustScores.get('v1|123|0')?.composite_score).toBe(85)
})
it('sellers map returns seller by seller_platform_id', () => {
const store = useSearchStore()
const seller: Seller = {
id: null, platform: 'ebay', platform_seller_id: 'sellerA',
username: 'powertech99', account_age_days: 720,
feedback_count: 1200, feedback_ratio: 0.998,
category_history_json: '{}', fetched_at: null,
}
store.sellers.set('sellerA', seller)
expect(store.sellers.get('sellerA')?.username).toBe('powertech99')
})
})

View file

@ -1,63 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
// Re-import after each test to get a fresh module-level ref
// (vi.resetModules() ensures module-level state is cleared between describe blocks)
describe('useTheme', () => {
beforeEach(() => {
localStorage.clear()
delete document.documentElement.dataset.theme
})
it('defaults to system when localStorage is empty', async () => {
const { useTheme } = await import('../composables/useTheme')
const { mode } = useTheme()
expect(mode.value).toBe('system')
})
it('setMode(dark) sets data-theme=dark on html element', async () => {
const { useTheme } = await import('../composables/useTheme')
const { setMode } = useTheme()
setMode('dark')
expect(document.documentElement.dataset.theme).toBe('dark')
})
it('setMode(light) sets data-theme=light on html element', async () => {
const { useTheme } = await import('../composables/useTheme')
const { setMode } = useTheme()
setMode('light')
expect(document.documentElement.dataset.theme).toBe('light')
})
it('setMode(system) removes data-theme attribute', async () => {
const { useTheme } = await import('../composables/useTheme')
const { setMode } = useTheme()
setMode('dark')
setMode('system')
expect(document.documentElement.dataset.theme).toBeUndefined()
})
it('setMode persists to localStorage', async () => {
const { useTheme } = await import('../composables/useTheme')
const { setMode } = useTheme()
setMode('dark')
expect(localStorage.getItem('snipe:theme')).toBe('dark')
})
it('restore() re-applies dark from localStorage', async () => {
localStorage.setItem('snipe:theme', 'dark')
// Dynamically import a fresh module to simulate hard reload
const { useTheme } = await import('../composables/useTheme')
const { restore } = useTheme()
restore()
expect(document.documentElement.dataset.theme).toBe('dark')
})
it('restore() with system mode leaves data-theme absent', async () => {
localStorage.setItem('snipe:theme', 'system')
const { useTheme } = await import('../composables/useTheme')
const { restore } = useTheme()
restore()
expect(document.documentElement.dataset.theme).toBeUndefined()
})
})

View file

@ -80,34 +80,8 @@
Warm cream surfaces with the same amber accent.
Snipe Mode data attribute overrides this via higher specificity.
*/
/* Explicit dark override — beats OS preference when user forces dark in Settings */
[data-theme="dark"]:not([data-snipe-mode="active"]) {
--color-surface: #0d1117;
--color-surface-2: #161b22;
--color-surface-raised: #1c2129;
--color-border: #30363d;
--color-border-light: #21262d;
--color-text: #e6edf3;
--color-text-muted: #8b949e;
--color-text-inverse: #0d1117;
--app-primary: #f59e0b;
--app-primary-hover: #d97706;
--app-primary-light: rgba(245, 158, 11, 0.12);
--trust-high: #3fb950;
--trust-mid: #d29922;
--trust-low: #f85149;
--color-success: #3fb950;
--color-error: #f85149;
--color-warning: #d29922;
--color-info: #58a6ff;
--color-accent: #a478ff;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3);
--shadow-md: 0 4px 12px rgba(0,0,0,0.5), 0 2px 4px rgba(0,0,0,0.3);
--shadow-lg: 0 10px 30px rgba(0,0,0,0.6), 0 4px 8px rgba(0,0,0,0.3);
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]):not([data-snipe-mode="active"]) {
:root:not([data-snipe-mode="active"]) {
/* Surfaces — warm cream, like a tactical field notebook */
--color-surface: #f8f5ee;
--color-surface-2: #f0ece3;
@ -146,32 +120,6 @@
}
}
/* Explicit light override — beats OS preference when user forces light in Settings */
[data-theme="light"]:not([data-snipe-mode="active"]) {
--color-surface: #f8f5ee;
--color-surface-2: #f0ece3;
--color-surface-raised: #e8e3d8;
--color-border: #c8bfae;
--color-border-light: #dbd3c4;
--color-text: #1c1a16;
--color-text-muted: #6b6357;
--color-text-inverse: #f8f5ee;
--app-primary: #d97706;
--app-primary-hover: #b45309;
--app-primary-light: rgba(217, 119, 6, 0.12);
--trust-high: #16a34a;
--trust-mid: #b45309;
--trust-low: #dc2626;
--color-success: #16a34a;
--color-error: #dc2626;
--color-warning: #b45309;
--color-info: #2563eb;
--color-accent: #7c3aed;
--shadow-sm: 0 1px 3px rgba(60,45,20,0.12), 0 1px 2px rgba(60,45,20,0.08);
--shadow-md: 0 4px 12px rgba(60,45,20,0.15), 0 2px 4px rgba(60,45,20,0.1);
--shadow-lg: 0 10px 30px rgba(60,45,20,0.2), 0 4px 8px rgba(60,45,20,0.1);
}
/* ── Snipe Mode easter egg theme ─────────────────── */
/* Activated by Konami code; stored as 'cf-snipe-mode' in localStorage */
/* Applied: document.documentElement.dataset.snipeMode = 'active' */

View file

@ -174,12 +174,6 @@
<span v-if="marketPrice" class="card__market-price" title="Median market price">
market ~{{ formattedMarket }}
</span>
<RouterLink
:to="`/listing/${listing.platform_listing_id}`"
class="card__detail-link"
:aria-label="`View trust breakdown for: ${listing.title}`"
@click.stop
>Details</RouterLink>
</div>
</div>
</article>
@ -187,7 +181,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import type { Listing, TrustScore, Seller } from '../stores/search'
import { useSearchStore } from '../stores/search'
import { useBlocklistStore } from '../stores/blocklist'
@ -740,16 +733,6 @@ const formattedMarket = computed(() => {
font-family: var(--font-mono);
}
.card__detail-link {
display: block;
font-size: 0.7rem;
color: var(--app-primary);
text-decoration: none;
margin-top: var(--space-1);
transition: opacity 150ms ease;
}
.card__detail-link:hover { opacity: 0.75; }
/* ── Triple Red easter egg ──────────────────────────────────────────────── */
/* Fires when: (new_account | account_under_30d) + suspicious_price + hard flag */
.listing-card--triple-red {

View file

@ -1,35 +0,0 @@
import { ref, watchEffect } from 'vue'
const LS_KEY = 'snipe:theme'
type ThemeMode = 'system' | 'dark' | 'light'
// Module-level — shared across all callers
const mode = ref<ThemeMode>((localStorage.getItem(LS_KEY) as ThemeMode) ?? 'system')
function _apply(m: ThemeMode) {
const el = document.documentElement
if (m === 'dark') {
el.dataset.theme = 'dark'
} else if (m === 'light') {
el.dataset.theme = 'light'
} else {
delete el.dataset.theme
}
}
export function useTheme() {
function setMode(m: ThemeMode) {
mode.value = m
localStorage.setItem(LS_KEY, m)
_apply(m)
}
/** Re-apply from localStorage on hard reload (call from App.vue onMounted). */
function restore() {
const saved = (localStorage.getItem(LS_KEY) as ThemeMode) ?? 'system'
mode.value = saved
_apply(saved)
}
return { mode, setMode, restore }
}

View file

@ -312,10 +312,6 @@ export const useSearchStore = defineStore('search', () => {
}
}
function getListing(platformListingId: string): Listing | undefined {
return results.value.find(l => l.platform_listing_id === platformListingId)
}
return {
query,
results,
@ -334,6 +330,5 @@ export const useSearchStore = defineStore('search', () => {
closeUpdates,
clearResults,
populateFromLLM,
getListing,
}
})

View file

@ -1,830 +1,55 @@
<template>
<div class="listing-view">
<!-- Not found store was cleared (page refresh or direct URL) -->
<div v-if="!listing" class="lv-empty">
<span class="lv-empty__icon" aria-hidden="true">🎯</span>
<h1 class="lv-empty__title">Listing not found</h1>
<p class="lv-empty__body">
Search results are held in session memory.
Return to search and click a listing to view its trust breakdown.
</p>
<RouterLink to="/" class="lv-empty__back"> Back to Search</RouterLink>
<div class="placeholder">
<span class="placeholder__icon" aria-hidden="true">🎯</span>
<h1 class="placeholder__title">Listing Detail</h1>
<p class="placeholder__body">Coming soon full listing detail view with trust score breakdown, photo analysis, and seller history.</p>
<RouterLink to="/" class="placeholder__back"> Back to Search</RouterLink>
</div>
<template v-else>
<!-- Back link -->
<RouterLink to="/" class="lv-back"> Back to results</RouterLink>
<div class="lv-layout" :class="{ 'lv-layout--triple-red': tripleRed }">
<!-- Photo carousel -->
<section class="lv-photos" aria-label="Listing photos">
<div v-if="listing.photo_urls.length" class="lv-carousel">
<img
:src="listing.photo_urls[photoIdx]"
:alt="`Photo ${photoIdx + 1} of ${listing.photo_urls.length}: ${listing.title}`"
class="lv-carousel__img"
@error="onImgError"
/>
<div v-if="listing.photo_urls.length > 1" class="lv-carousel__controls">
<button
class="lv-carousel__btn"
aria-label="Previous photo"
:disabled="photoIdx === 0"
@click="photoIdx--"
></button>
<span class="lv-carousel__counter">{{ photoIdx + 1 }} / {{ listing.photo_urls.length }}</span>
<button
class="lv-carousel__btn"
aria-label="Next photo"
:disabled="photoIdx === listing.photo_urls.length - 1"
@click="photoIdx++"
></button>
</div>
</div>
<div v-else class="lv-carousel lv-carousel--empty" aria-hidden="true">📷</div>
</section>
<!-- Main content -->
<div class="lv-content">
<!-- Header -->
<header class="lv-header">
<h1 class="lv-title">{{ listing.title }}</h1>
<div class="lv-price-row">
<span class="lv-price">{{ formattedPrice }}</span>
<span v-if="store.marketPrice" class="lv-market">
market ~{{ formattedMarket }}
</span>
<span v-if="isSteal" class="lv-steal-badge">🎯 Potential steal</span>
</div>
<div class="lv-badges">
<span class="lv-badge">{{ conditionLabel }}</span>
<span class="lv-badge">{{ formatLabel }}</span>
<span v-if="listing.category_name" class="lv-badge">{{ listing.category_name }}</span>
<span v-if="isAuction && listing.ends_at" class="lv-badge lv-badge--auction">
Ends {{ auctionEnds }}
</span>
</div>
</header>
<!-- Red flags -->
<div v-if="redFlags.length" class="lv-flags" role="list" aria-label="Risk flags">
<span
v-for="flag in redFlags"
:key="flag"
class="lv-flag"
:class="hardFlags.has(flag) ? 'lv-flag--hard' : 'lv-flag--soft'"
role="listitem"
>{{ flagLabel(flag) }}</span>
</div>
<!-- Trust score: ring + verdict + signal table -->
<section class="lv-trust" aria-labelledby="trust-heading">
<h2 id="trust-heading" class="lv-section-heading">Trust Score</h2>
<div class="lv-ring-row">
<!-- SVG ring -->
<div class="lv-ring" :class="ringClass" role="img" :aria-label="`Trust score: ${scoreDisplay} out of 100`">
<svg width="88" height="88" viewBox="0 0 88 88" aria-hidden="true">
<circle cx="44" cy="44" r="36" fill="none" stroke="var(--ring-track)" stroke-width="8"/>
<circle
v-if="trust && trust.composite_score != null"
cx="44" cy="44" r="36"
fill="none"
:stroke="ringColor"
stroke-width="8"
stroke-linecap="round"
:stroke-dasharray="`${ringFill} 226.2`"
transform="rotate(-90 44 44)"
/>
</svg>
<div class="lv-ring__center">
<span class="lv-ring__score" :style="{ color: ringColor }">{{ scoreDisplay }}</span>
<span class="lv-ring__denom">/ 100</span>
</div>
</div>
<!-- Verdict -->
<div class="lv-verdict">
<p class="lv-verdict__label" :style="{ color: ringColor }">{{ verdictLabel }}</p>
<p class="lv-verdict__text">{{ verdictText }}</p>
<p v-if="trust?.score_is_partial" class="lv-verdict__partial">
Some signals are still loading run again to update
</p>
</div>
</div>
<!-- Signal table -->
<table class="lv-signals" aria-label="Trust signal breakdown">
<thead>
<tr>
<th scope="col" class="lv-signals__col-name">Signal</th>
<th scope="col" class="lv-signals__col-bar" aria-hidden="true"></th>
<th scope="col" class="lv-signals__col-score">Score</th>
</tr>
</thead>
<tbody>
<tr v-for="sig in signals" :key="sig.key" class="lv-signals__row">
<td class="lv-signals__name">{{ sig.label }}</td>
<td class="lv-signals__bar" aria-hidden="true">
<div class="lv-mini-bar">
<div
class="lv-mini-bar__fill"
:class="sig.pending ? 'lv-mini-bar__fill--pending' : barClass(sig.score)"
:style="{ width: sig.pending ? '8%' : `${(sig.score / 20) * 100}%` }"
></div>
</div>
</td>
<td class="lv-signals__score">
<span v-if="sig.pending" class="lv-sig-pending"> pending</span>
<span v-else>{{ sig.score }} / 20</span>
</td>
</tr>
</tbody>
</table>
</section>
<!-- Seller panel -->
<section v-if="seller" class="lv-seller" aria-label="Seller information">
<h2 class="lv-section-heading">Seller</h2>
<div class="lv-seller__panel">
<div class="lv-seller__avatar" aria-hidden="true">👤</div>
<div class="lv-seller__info">
<p class="lv-seller__name">{{ seller.username }}</p>
<p class="lv-seller__stats">
{{ seller.feedback_count.toLocaleString() }} feedback
· {{ (seller.feedback_ratio * 100).toFixed(1) }}% positive
</p>
<p class="lv-seller__age" v-if="seller.account_age_days != null">
Account age: {{ accountAgeLabel }}
</p>
<p class="lv-seller__age lv-seller__age--unknown" v-else>
Account age: pending enrichment
</p>
</div>
</div>
</section>
<!-- Actions -->
<div class="lv-actions">
<a
:href="listing.url"
target="_blank"
rel="noopener noreferrer"
class="lv-btn-primary"
> View on eBay</a>
<button
v-if="seller"
class="lv-btn-secondary"
type="button"
@click="blockingOpen = !blockingOpen"
> Block seller</button>
</div>
<!-- Block seller inline form -->
<div v-if="blockingOpen && seller" class="lv-block-form" role="dialog" aria-label="Block seller">
<p class="lv-block-form__title">Block <strong>{{ seller.username }}</strong>?</p>
<input
v-model="blockReason"
class="lv-block-form__input"
placeholder="Reason (optional)"
aria-label="Reason for blocking seller (optional)"
maxlength="200"
@keydown.enter="onBlock"
@keydown.escape="blockingOpen = false"
/>
<div class="lv-block-form__btns">
<button class="lv-block-form__confirm" type="button" @click="onBlock">Block</button>
<button class="lv-block-form__cancel" type="button" @click="blockingOpen = false; blockReason = ''">Cancel</button>
</div>
<p v-if="blockError" class="lv-block-form__error" role="alert">{{ blockError }}</p>
</div>
</div><!-- /lv-content -->
</div><!-- /lv-layout -->
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
import { useSearchStore } from '../stores/search'
const RING_CIRCUMFERENCE = 226.2 // 2π × r=36
const route = useRoute()
const store = useSearchStore()
const id = route.params.id as string
const listing = computed(() => store.getListing(id))
const trust = computed(() => store.trustScores.get(id) ?? null)
const seller = computed(() => {
if (!listing.value) return null
return store.sellers.get(listing.value.seller_platform_id) ?? null
})
// Photo carousel
const photoIdx = ref(0)
function onImgError() {
// Skip broken photos by advancing to next; if at end, go back
const max = (listing.value?.photo_urls.length ?? 1) - 1
if (photoIdx.value < max) photoIdx.value++
else if (photoIdx.value > 0) photoIdx.value--
}
// Price / format helpers
const formattedPrice = computed(() => {
if (!listing.value) return ''
const sym = listing.value.currency === 'USD' ? '$' : listing.value.currency + ' '
return `${sym}${listing.value.price.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
})
const formattedMarket = computed(() =>
store.marketPrice
? `$${store.marketPrice.toLocaleString('en-US', { maximumFractionDigits: 0 })}`
: ''
)
const isSteal = computed(() => {
const s = trust.value?.composite_score
if (!s || s < 80 || !store.marketPrice || !listing.value) return false
return listing.value.price < store.marketPrice * 0.8
})
const isAuction = computed(() => listing.value?.buying_format === 'auction')
const auctionEnds = computed(() => {
const end = listing.value?.ends_at
if (!end) return ''
return new Date(end).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
})
const conditionLabel = computed(() => {
const c = listing.value?.condition ?? ''
const map: Record<string, string> = {
new: 'New',
open_box: 'Open Box',
used_excellent:'Used Excellent',
used_good: 'Used Good',
used_fair: 'Used Fair',
for_parts: 'For Parts',
}
return map[c] ?? c
})
const formatLabel = computed(() => {
const f = listing.value?.buying_format ?? 'fixed_price'
if (f === 'auction') return 'Auction'
if (f === 'best_offer') return 'Best Offer'
return 'Fixed Price'
})
// Red flags
const FLAG_LABELS: Record<string, string> = {
new_account: '✗ New account',
account_under_30_days: '⚠ Account <30d',
low_feedback_count: '⚠ Low feedback',
suspicious_price: '✗ Suspicious price',
duplicate_photo: '✗ Duplicate photo',
established_bad_actor: '✗ Known bad actor',
zero_feedback: '✗ No feedback',
marketing_photo: '✗ Marketing photo',
scratch_dent_mentioned: '⚠ Damage mentioned',
long_on_market: '⚠ Long on market',
significant_price_drop: '⚠ Price dropped',
}
const HARD_FLAGS = new Set([
'new_account', 'established_bad_actor', 'zero_feedback', 'suspicious_price', 'duplicate_photo',
])
const hardFlags = HARD_FLAGS
function flagLabel(flag: string): string {
return FLAG_LABELS[flag] ?? `${flag}`
}
const redFlags = computed<string[]>(() => {
try { return JSON.parse(trust.value?.red_flags_json ?? '[]') } catch { return [] }
})
// Score ring
const scoreDisplay = computed(() => trust.value?.composite_score ?? '?')
const ringColor = computed(() => {
const s = trust.value?.composite_score
if (s == null) return 'var(--color-text-muted)'
if (s >= 80) return 'var(--trust-high)'
if (s >= 50) return 'var(--trust-mid)'
return 'var(--trust-low)'
})
const ringClass = computed(() => {
const s = trust.value?.composite_score
if (s == null) return 'lv-ring--unknown'
if (s >= 80) return 'lv-ring--high'
if (s >= 50) return 'lv-ring--mid'
return 'lv-ring--low'
})
const ringFill = computed(() => {
const s = trust.value?.composite_score
if (s == null) return 0
return (s / 100) * RING_CIRCUMFERENCE
})
// Verdict
const verdictLabel = computed(() => {
const s = trust.value?.composite_score
if (s == null) return 'Unscored'
if (s >= 80) return 'Looks trustworthy'
if (s >= 50) return 'Moderate risk'
return 'High risk'
})
const verdictText = computed(() => {
const s = trust.value?.composite_score
const f = new Set(redFlags.value)
const sel = seller.value
if (s == null) return 'No trust data available for this listing.'
const parts: string[] = []
if (f.has('established_bad_actor')) return 'This seller is on your blocklist. Do not proceed.'
if (f.has('zero_feedback')) parts.push('seller has no feedback history')
if (f.has('new_account')) parts.push('account is less than a week old')
else if (f.has('account_under_30_days') && sel)
parts.push(`account is only ${sel.account_age_days} days old`)
if (f.has('suspicious_price')) parts.push('price is suspiciously below market')
else if (trust.value?.price_vs_market_score === 0 && store.marketPrice)
parts.push('price is above the market median')
if (f.has('duplicate_photo')) parts.push('photo appears in other listings')
if (f.has('scratch_dent_mentioned')) parts.push('title mentions damage or wear')
if (f.has('long_on_market')) parts.push('listing has been sitting for a while')
if (f.has('significant_price_drop')) parts.push('price has dropped significantly since first seen')
if (s >= 80 && parts.length === 0)
return 'Strong seller history and clean signals across the board.'
if (parts.length === 0)
return 'No specific red flags, but some signals are weak or pending.'
const list = parts.map((p, i) => (i === 0 ? p[0].toUpperCase() + p.slice(1) : p))
return list.join(', ') + '.'
})
// Signal table
interface Signal { key: string; label: string; score: number; pending: boolean }
const signals = computed<Signal[]>(() => {
const t = trust.value
const sel = seller.value
return [
{
key: 'feedback_count', label: 'Feedback Volume',
score: t?.feedback_count_score ?? 0,
pending: false,
},
{
key: 'feedback_ratio', label: 'Feedback Ratio',
score: t?.feedback_ratio_score ?? 0,
pending: false,
},
{
key: 'account_age', label: 'Account Age',
score: t?.account_age_score ?? 0,
pending: sel?.account_age_days == null,
},
{
key: 'price_vs_market', label: 'Price vs Market',
score: t?.price_vs_market_score ?? 0,
pending: store.marketPrice == null,
},
{
key: 'category_history', label: 'Category History',
score: t?.category_history_score ?? 0,
pending: !sel || sel.category_history_json === '{}',
},
]
})
function barClass(score: number): string {
if (score >= 16) return 'lv-mini-bar__fill--high'
if (score >= 8) return 'lv-mini-bar__fill--mid'
return 'lv-mini-bar__fill--low'
}
// Triple Red easter egg
const tripleRed = computed(() => {
const f = new Set(redFlags.value)
const hasAccountFlag = f.has('new_account') || f.has('account_under_30_days')
const hasPriceFlag = f.has('suspicious_price')
const hasThirdFlag = f.has('duplicate_photo') || f.has('established_bad_actor') ||
f.has('zero_feedback') || f.has('scratch_dent_mentioned')
return hasAccountFlag && hasPriceFlag && hasThirdFlag
})
// Seller account age label
const accountAgeLabel = computed(() => {
const d = seller.value?.account_age_days
if (d == null) return 'unknown'
if (d < 30) return `${d} days`
if (d < 365) return `${Math.floor(d / 30)} months`
const y = Math.floor(d / 365)
const m = Math.floor((d % 365) / 30)
return m > 0 ? `${y}y ${m}mo` : `${y} year${y !== 1 ? 's' : ''}`
})
// Block seller
const blockingOpen = ref(false)
const blockReason = ref('')
const blockError = ref('')
const apiBase = import.meta.env.VITE_API_BASE ?? ''
async function onBlock() {
if (!seller.value) return
blockError.value = ''
try {
const res = await fetch(`${apiBase}/api/blocklist`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
platform: seller.value.platform,
platform_seller_id: seller.value.platform_seller_id,
username: seller.value.username,
reason: blockReason.value.trim() || null,
source: 'manual',
}),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
blockError.value = body.detail ?? `Error ${res.status}`
return
}
blockingOpen.value = false
blockReason.value = ''
} catch {
blockError.value = 'Network error — try again'
}
}
import { RouterLink } from 'vue-router'
</script>
<style scoped>
.listing-view {
max-width: 900px;
margin: 0 auto;
padding: var(--space-6) var(--space-4);
display: flex;
align-items: center;
justify-content: center;
min-height: 60dvh;
padding: var(--space-8);
}
/* ── Empty / not-found ── */
.lv-empty {
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
text-align: center;
padding: var(--space-16) var(--space-8);
}
.lv-empty__icon { font-size: 3rem; }
.lv-empty__title { font-family: var(--font-display); font-size: 1.5rem; color: var(--app-primary); }
.lv-empty__body { color: var(--color-text-muted); line-height: 1.6; max-width: 400px; }
.lv-empty__back { color: var(--app-primary); font-weight: 600; text-decoration: none; }
.lv-empty__back:hover { opacity: 0.75; }
/* ── Back link ── */
.lv-back {
display: inline-block;
color: var(--color-text-muted);
text-decoration: none;
font-size: 0.85rem;
margin-bottom: var(--space-4);
transition: color var(--transition);
}
.lv-back:hover { color: var(--app-primary); }
/* ── Layout ── */
.lv-layout {
display: grid;
grid-template-columns: 260px 1fr;
gap: var(--space-6);
align-items: start;
max-width: 480px;
}
/* Triple Red: pulsing red glow around the whole card */
.lv-layout--triple-red {
border-radius: var(--radius-lg);
animation: triple-red-pulse 1.8s ease-in-out infinite;
}
@keyframes triple-red-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(248,81,73,0); }
50% { box-shadow: 0 0 0 8px rgba(248,81,73,0.25); }
}
@media (prefers-reduced-motion: reduce) {
.lv-layout--triple-red { animation: none; box-shadow: 0 0 0 2px rgba(248,81,73,0.4); }
}
.placeholder__icon { font-size: 3rem; }
/* ── Photo carousel ── */
.lv-photos { position: sticky; top: var(--space-4); }
.lv-carousel {
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
}
.lv-carousel--empty {
height: 220px;
align-items: center;
justify-content: center;
font-size: 3rem;
color: var(--color-text-muted);
}
.lv-carousel__img {
width: 100%;
aspect-ratio: 1;
object-fit: contain;
background: var(--color-surface-2);
}
.lv-carousel__controls {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-4);
border-top: 1px solid var(--color-border);
}
.lv-carousel__btn {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 1.25rem;
cursor: pointer;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
transition: color var(--transition);
line-height: 1;
}
.lv-carousel__btn:hover:not(:disabled) { color: var(--app-primary); }
.lv-carousel__btn:disabled { opacity: 0.3; cursor: default; }
.lv-carousel__counter { font-size: 0.75rem; color: var(--color-text-muted); font-family: var(--font-mono); }
/* ── Content column ── */
.lv-content { display: flex; flex-direction: column; gap: var(--space-5); min-width: 0; }
/* ── Header ── */
.lv-title {
.placeholder__title {
font-family: var(--font-display);
font-size: 1.2rem;
line-height: 1.4;
color: var(--color-text);
margin-bottom: var(--space-2);
overflow-wrap: break-word;
word-break: break-word;
}
.lv-price-row { display: flex; align-items: baseline; gap: var(--space-3); flex-wrap: wrap; margin-bottom: var(--space-2); }
.lv-price {
font-family: var(--font-mono);
font-size: 1.6rem;
font-weight: 700;
font-size: 1.5rem;
color: var(--app-primary);
}
.lv-market { font-size: 0.8rem; color: var(--color-text-muted); }
.lv-steal-badge {
font-size: 0.72rem;
padding: 0.2rem 0.6rem;
border-radius: var(--radius-full);
background: rgba(63,185,80,0.15);
color: var(--trust-high);
border: 1px solid rgba(63,185,80,0.3);
font-weight: 600;
}
.lv-badges { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.lv-badge {
font-size: 0.72rem;
padding: 0.2rem 0.6rem;
border-radius: var(--radius-full);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
.placeholder__body {
color: var(--color-text-muted);
}
.lv-badge--auction { color: var(--color-warning); border-color: rgba(210,153,34,0.3); background: rgba(210,153,34,0.1); }
/* ── Red flags ── */
.lv-flags { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.lv-flag {
font-size: 0.72rem;
padding: 0.2rem 0.6rem;
border-radius: var(--radius-full);
font-weight: 600;
}
.lv-flag--hard { background: rgba(248,81,73,0.12); color: var(--trust-low); border: 1px solid rgba(248,81,73,0.3); }
.lv-flag--soft { background: rgba(210,153,34,0.1); color: var(--trust-mid); border: 1px solid rgba(210,153,34,0.3); }
/* ── Section heading ── */
.lv-section-heading {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--color-text-muted);
font-weight: 600;
margin-bottom: var(--space-3);
line-height: 1.6;
}
/* ── Trust section ── */
.lv-trust {
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
/* ── Ring row ── */
.lv-ring-row { display: flex; align-items: center; gap: var(--space-5); margin-bottom: var(--space-4); }
.lv-ring {
position: relative;
width: 88px;
height: 88px;
flex-shrink: 0;
}
.lv-ring--high { --ring-track: rgba(63,185,80,0.12); }
.lv-ring--mid { --ring-track: rgba(210,153,34,0.12); }
.lv-ring--low { --ring-track: rgba(248,81,73,0.12); }
.lv-ring--unknown { --ring-track: var(--color-surface-raised); }
.lv-ring__center {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.lv-ring__score {
font-family: var(--font-mono);
font-size: 1.4rem;
font-weight: 700;
line-height: 1;
}
.lv-ring__denom { font-size: 0.55rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-top: 0.1rem; }
/* ── Verdict ── */
.lv-verdict { flex: 1; }
.lv-verdict__label { font-size: 0.9rem; font-weight: 700; margin-bottom: var(--space-1); }
.lv-verdict__text { font-size: 0.8rem; color: var(--color-text-muted); line-height: 1.55; }
.lv-verdict__partial { font-size: 0.72rem; color: var(--color-info); margin-top: var(--space-2); }
/* ── Signal table ── */
.lv-signals { width: 100%; border-collapse: collapse; }
.lv-signals th {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
font-weight: 600;
padding: 0 var(--space-2) var(--space-2);
text-align: left;
border-bottom: 1px solid var(--color-border-light);
}
.lv-signals__col-score { text-align: right; }
.lv-signals__row td { padding: var(--space-2) var(--space-2); border-bottom: 1px solid var(--color-border-light); }
.lv-signals__row:last-child td { border-bottom: none; }
.lv-signals__name { font-size: 0.82rem; color: var(--color-text); }
.lv-signals__bar { width: 80px; }
.lv-signals__score { font-family: var(--font-mono); font-size: 0.78rem; color: var(--color-text-muted); text-align: right; white-space: nowrap; }
.lv-mini-bar { height: 4px; background: var(--color-surface-raised); border-radius: var(--radius-full); overflow: hidden; }
.lv-mini-bar__fill { height: 100%; border-radius: var(--radius-full); transition: width 0.4s ease; }
.lv-mini-bar__fill--high { background: var(--trust-high); }
.lv-mini-bar__fill--mid { background: var(--trust-mid); }
.lv-mini-bar__fill--low { background: var(--trust-low); }
.lv-mini-bar__fill--pending { background: var(--color-border); }
.lv-sig-pending { color: var(--color-info); font-size: 0.72rem; }
/* ── Seller ── */
.lv-seller__panel {
display: flex;
align-items: center;
gap: var(--space-4);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-3) var(--space-4);
}
.lv-seller__avatar {
width: 40px; height: 40px;
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 1.1rem;
flex-shrink: 0;
}
.lv-seller__name { font-weight: 700; font-size: 0.9rem; color: var(--color-text); }
.lv-seller__stats { font-size: 0.75rem; color: var(--color-text-muted); margin-top: var(--space-1); }
.lv-seller__age { font-size: 0.72rem; color: var(--color-info); margin-top: 0.2rem; }
.lv-seller__age--unknown { color: var(--color-text-muted); }
/* ── Actions ── */
.lv-actions { display: flex; gap: var(--space-3); }
.lv-btn-primary {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
background: var(--app-primary);
color: var(--color-text-inverse);
font-weight: 700;
font-size: 0.85rem;
.placeholder__back {
color: var(--app-primary);
text-decoration: none;
transition: background var(--transition);
font-weight: 600;
transition: opacity 150ms ease;
}
.lv-btn-primary:hover { background: var(--app-primary-hover); }
.lv-btn-secondary {
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
transition: border-color var(--transition), color var(--transition);
font-family: inherit;
}
.lv-btn-secondary:hover { border-color: var(--trust-low); color: var(--trust-low); }
/* ── Block seller form ── */
.lv-block-form {
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-3);
}
.lv-block-form__title { font-size: 0.85rem; }
.lv-block-form__input {
width: 100%;
padding: var(--space-2) var(--space-3);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
font-size: 0.82rem;
font-family: inherit;
}
.lv-block-form__input:focus { outline: 2px solid var(--app-primary); outline-offset: 1px; }
.lv-block-form__btns { display: flex; gap: var(--space-2); }
.lv-block-form__confirm {
padding: var(--space-2) var(--space-4);
background: var(--trust-low);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
font-family: inherit;
}
.lv-block-form__cancel {
padding: var(--space-2) var(--space-4);
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
border-radius: var(--radius-md);
font-size: 0.82rem;
cursor: pointer;
font-family: inherit;
}
.lv-block-form__error { font-size: 0.78rem; color: var(--trust-low); }
/* ── Responsive: single column on narrow ── */
@media (max-width: 640px) {
.lv-layout { grid-template-columns: 1fr; }
.lv-photos { position: static; }
.lv-carousel__img { aspect-ratio: 4/3; }
}
.placeholder__back:hover { opacity: 0.75; }
</style>

View file

@ -311,9 +311,9 @@
<div class="landing-hero__eyebrow" aria-hidden="true">🎯 Snipe</div>
<h1 class="landing-hero__headline">Bid with confidence.</h1>
<p class="landing-hero__sub">
Seen a listing that looks almost too good to pass up? Snipe tells you if it's safe
to bid: seller account age, feedback history, price vs. completed sales, and red flag
detection one trust score before you commit. Free. No account required.
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 -->
@ -321,7 +321,7 @@
<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. Search above to score listings before you commit.
Auction sales become final. Know what you're buying before you bid.
</p>
</div>
@ -330,24 +330,24 @@
<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">Account age, feedback count and ratio, and category history does this seller actually know what they're selling? Scored 0100.</p>
<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">Checked against recent completed eBay sales. If the price is 40% below median, you'll see it flagged before you bid.</p>
<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 listing photos, "scratch and dent" buried in the description, zero-feedback sellers, and known bad actors flagged automatically.</p>
<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, up to 5 pages of results, and the community-maintained scammer blocklist.
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

View file

@ -26,28 +26,6 @@
</label>
</section>
<!-- Appearance -->
<section class="settings-section">
<h2 class="settings-section-title">Appearance</h2>
<div class="settings-toggle">
<div class="settings-toggle-text">
<span class="settings-toggle-label">Theme</span>
<span class="settings-toggle-desc">Override the system color scheme. Default follows your OS preference.</span>
</div>
<div class="theme-btn-group" role="group" aria-label="Theme selection">
<button
v-for="opt in themeOptions"
:key="opt.value"
class="theme-btn"
:class="{ 'theme-btn--active': theme.mode.value === opt.value }"
:aria-pressed="theme.mode.value === opt.value"
type="button"
@click="theme.setMode(opt.value)"
>{{ opt.label }}</button>
</div>
</div>
</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>
@ -131,18 +109,11 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useTrustSignalPref } from '../composables/useTrustSignalPref'
import { useTheme } from '../composables/useTheme'
import { useSessionStore } from '../stores/session'
import { usePreferencesStore } from '../stores/preferences'
import { useLLMQueryBuilder } from '../composables/useLLMQueryBuilder'
const { enabled: trustSignalEnabled, setEnabled } = useTrustSignalPref()
const theme = useTheme()
const themeOptions: { value: 'system' | 'dark' | 'light'; label: string }[] = [
{ value: 'system', label: 'System' },
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' },
]
const session = useSessionStore()
const prefs = usePreferencesStore()
const { autoRun: llmAutoRun, setAutoRun: setLLMAutoRun } = useLLMQueryBuilder()
@ -321,32 +292,4 @@ function saveByokId() {
color: var(--color-danger, #f85149);
margin: 0;
}
.theme-btn-group {
display: flex;
gap: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
flex-shrink: 0;
}
.theme-btn {
padding: var(--space-2) var(--space-4);
background: transparent;
border: none;
border-right: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
transition: background var(--transition), color var(--transition);
}
.theme-btn:last-child { border-right: none; }
.theme-btn:hover { background: var(--color-surface-raised); color: var(--color-text); }
.theme-btn--active {
background: var(--app-primary-light);
color: var(--app-primary);
font-weight: 600;
}
</style>