Compare commits

...

3 commits

Author SHA1 Message Date
af51de4cec feat: landing hero copy polish + frontend test suite
Some checks are pending
CI / Python tests (push) Waiting to run
CI / Frontend typecheck + tests (push) Waiting to run
Mirror / mirror (push) Waiting to run
- Rewrite landing hero subtitle with narrative opener ("Seen a listing
  that looks almost too good to pass up?") — more universal than the
  feature-list version, avoids category assumptions
- Update eBay cancellation callout CTA to "Search above to score
  listings before you commit" — direct action vs. passive reminder
- Tile descriptions rewritten with concrete examples: "does this seller
  actually know what they're selling?", "40% below median", quoted
  "scratch and dent" pattern for instant recognition
- Sign-in strip: add specific page count ("up to 5 pages") and
  "community-maintained" attribution for blocklist credibility
- Fix useTheme restore() to re-read from localStorage instead of using
  cached module-level ref — fixes test isolation failure where previous
  test's setMode('dark') leaked into the restore() system test
- Add 32-test Vitest suite: useTheme (7), searchStore (7),
  ListingView component (18) — all green
2026-04-16 12:39:54 -07:00
9734c50c19 feat: explicit dark/light theme override with Settings toggle
Adds user-controlled theme selection independent of OS preference:

- useTheme composable: sets data-theme="dark"|"light" on <html>,
  persisted to localStorage as snipe:theme. Follows the same pattern
  as useSnipeMode.
- theme.css: [data-theme="dark"] and [data-theme="light"] explicit
  attribute selectors override @media (prefers-color-scheme: light).
  Media query updated to :root:not([data-theme="dark"]) so it has no
  effect when the user has forced dark on a light-OS machine.
- App.vue: restoreTheme() called in onMounted alongside restoreSnipeMode.
- SettingsView: Appearance section with System/Dark/Light segmented
  button group.
2026-04-16 11:52:10 -07:00
c90061733c feat: listing detail page with trust score ring, signal breakdown, seller panel
Replaces the Coming Soon placeholder. Clicking Details on any card opens
a full trust breakdown view:

- SVG score ring with composite score and colour-coded verdict label
- Auto-generated verdict text (identifies worst signals in plain English)
- Signal table with mini-bars: Feedback Volume/Ratio, Account Age,
  Price vs Market, Category History — pending state shown for unresolved
- Red flag badges (hard vs soft) above the score ring
- Photo carousel with prev/next controls and img-error skip
- Seller panel (feedback count/ratio, account age, pending enrichment note)
- Block seller inline form wired to POST /api/blocklist
- Triple Red pulsing border easter egg carried over from card
- Not-found state for direct URL access (store cleared on refresh)
- Responsive: single-column layout on ≤640px
- ListingCard: adds Details RouterLink to price column
- search store: adds getListing(id) lookup helper
2026-04-16 11:48:30 -07:00
11 changed files with 1411 additions and 39 deletions

View file

@ -19,6 +19,7 @@ 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'
@ -28,6 +29,7 @@ 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()
@ -37,6 +39,7 @@ 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

@ -0,0 +1,255 @@
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

@ -0,0 +1,110 @@
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

@ -0,0 +1,63 @@
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,8 +80,34 @@
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-snipe-mode="active"]) {
:root:not([data-theme="dark"]):not([data-snipe-mode="active"]) {
/* Surfaces — warm cream, like a tactical field notebook */
--color-surface: #f8f5ee;
--color-surface-2: #f0ece3;
@ -120,6 +146,32 @@
}
}
/* 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,6 +174,12 @@
<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>
@ -181,6 +187,7 @@
<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'
@ -733,6 +740,16 @@ 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

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

View file

@ -1,55 +1,830 @@
<template>
<div class="listing-view">
<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>
<!-- 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>
<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 { RouterLink } from 'vue-router'
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'
}
}
</script>
<style scoped>
.listing-view {
display: flex;
align-items: center;
justify-content: center;
min-height: 60dvh;
padding: var(--space-8);
max-width: 900px;
margin: 0 auto;
padding: var(--space-6) var(--space-4);
}
.placeholder {
/* ── Empty / not-found ── */
.lv-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
text-align: center;
max-width: 480px;
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; }
.placeholder__icon { font-size: 3rem; }
.placeholder__title {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--app-primary);
}
.placeholder__body {
/* ── Back link ── */
.lv-back {
display: inline-block;
color: var(--color-text-muted);
line-height: 1.6;
}
.placeholder__back {
color: var(--app-primary);
text-decoration: none;
font-weight: 600;
transition: opacity 150ms ease;
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;
}
.placeholder__back:hover { opacity: 0.75; }
/* 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); }
}
/* ── 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 {
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;
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);
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);
}
/* ── 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;
text-decoration: none;
transition: background var(--transition);
}
.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; }
}
</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">
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.
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.
</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. Know what you're buying before you bid.
Auction sales become final. Search above to score listings before you commit.
</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">Feedback count and ratio, account age, and category history scored 0 to 100.</p>
<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>
</div>
<div class="landing-hero__tile" role="listitem">
<span class="landing-hero__tile-icon" aria-hidden="true">📊</span>
<strong class="landing-hero__tile-title">Price vs. market</strong>
<p class="landing-hero__tile-desc">Compared against recent completed sales. Flags prices that are suspiciously below market.</p>
<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>
</div>
<div class="landing-hero__tile" role="listitem">
<span class="landing-hero__tile-icon" aria-hidden="true">🚩</span>
<strong class="landing-hero__tile-title">Red flag detection</strong>
<p class="landing-hero__tile-desc">Duplicate photos, damage mentions, established bad actors, and zero-feedback sellers.</p>
<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>
</div>
</div>
<!-- Sign-in unlock strip (cloud, unauthenticated only) -->
<div v-if="session.isCloud && !session.isLoggedIn" class="landing-hero__signin-strip">
<p class="landing-hero__signin-text">
Free account unlocks saved searches, more results pages, and the community scammer blocklist.
Free account unlocks saved searches, up to 5 pages of results, and the community-maintained scammer blocklist.
</p>
<a href="https://circuitforge.tech/login" class="landing-hero__signin-cta">
Create a free account

View file

@ -26,6 +26,28 @@
</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>
@ -109,11 +131,18 @@
<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()
@ -292,4 +321,32 @@ 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>