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
This commit is contained in:
pyr0ball 2026-04-16 12:39:54 -07:00
parent 9734c50c19
commit af51de4cec
5 changed files with 439 additions and 9 deletions

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

@ -26,7 +26,9 @@ export function useTheme() {
/** Re-apply from localStorage on hard reload (call from App.vue onMounted). */
function restore() {
_apply(mode.value)
const saved = (localStorage.getItem(LS_KEY) as ThemeMode) ?? 'system'
mode.value = saved
_apply(saved)
}
return { mode, setMode, restore }

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