feat: landing hero copy polish + frontend test suite
- 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:
parent
9734c50c19
commit
af51de4cec
5 changed files with 439 additions and 9 deletions
255
web/src/__tests__/ListingView.test.ts
Normal file
255
web/src/__tests__/ListingView.test.ts
Normal 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 50–79', 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)
|
||||
})
|
||||
})
|
||||
110
web/src/__tests__/searchStore.test.ts
Normal file
110
web/src/__tests__/searchStore.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
63
web/src/__tests__/useTheme.test.ts
Normal file
63
web/src/__tests__/useTheme.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 0–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">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 →
|
||||
|
|
|
|||
Loading…
Reference in a new issue