From af51de4cecf05231d2eb49afe2481a3793f08eb4 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Thu, 16 Apr 2026 12:39:54 -0700 Subject: [PATCH] feat: landing hero copy polish + frontend test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/src/__tests__/ListingView.test.ts | 255 ++++++++++++++++++++++++++ web/src/__tests__/searchStore.test.ts | 110 +++++++++++ web/src/__tests__/useTheme.test.ts | 63 +++++++ web/src/composables/useTheme.ts | 4 +- web/src/views/SearchView.vue | 16 +- 5 files changed, 439 insertions(+), 9 deletions(-) create mode 100644 web/src/__tests__/ListingView.test.ts create mode 100644 web/src/__tests__/searchStore.test.ts create mode 100644 web/src/__tests__/useTheme.test.ts diff --git a/web/src/__tests__/ListingView.test.ts b/web/src/__tests__/ListingView.test.ts new file mode 100644 index 0000000..9bdc78c --- /dev/null +++ b/web/src/__tests__/ListingView.test.ts @@ -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: '' }, +})) + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeListing(id: string, overrides: Partial = {}): 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 { + 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) => 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) + }) +}) diff --git a/web/src/__tests__/searchStore.test.ts b/web/src/__tests__/searchStore.test.ts new file mode 100644 index 0000000..2433c7b --- /dev/null +++ b/web/src/__tests__/searchStore.test.ts @@ -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 { + 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') + }) +}) diff --git a/web/src/__tests__/useTheme.test.ts b/web/src/__tests__/useTheme.test.ts new file mode 100644 index 0000000..da4b0d6 --- /dev/null +++ b/web/src/__tests__/useTheme.test.ts @@ -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() + }) +}) diff --git a/web/src/composables/useTheme.ts b/web/src/composables/useTheme.ts index 39b3c02..000a249 100644 --- a/web/src/composables/useTheme.ts +++ b/web/src/composables/useTheme.ts @@ -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 } diff --git a/web/src/views/SearchView.vue b/web/src/views/SearchView.vue index 63e48d5..0600faf 100644 --- a/web/src/views/SearchView.vue +++ b/web/src/views/SearchView.vue @@ -311,9 +311,9 @@

Bid with confidence.

- 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.

@@ -321,7 +321,7 @@

Starting May 13, 2026, eBay removes the option for buyers to cancel winning bids. - Auction sales become final. Know what you're buying before you bid. + Auction sales become final. Search above to score listings before you commit.

@@ -330,24 +330,24 @@
Seller trust score -

Feedback count and ratio, account age, and category history — scored 0 to 100.

+

Account age, feedback count and ratio, and category history — does this seller actually know what they're selling? Scored 0–100.

Price vs. market -

Compared against recent completed sales. Flags prices that are suspiciously below market.

+

Checked against recent completed eBay sales. If the price is 40% below median, you'll see it flagged before you bid.

Red flag detection -

Duplicate photos, damage mentions, established bad actors, and zero-feedback sellers.

+

Duplicate listing photos, "scratch and dent" buried in the description, zero-feedback sellers, and known bad actors — flagged automatically.