-
Notifications
You must be signed in to change notification settings - Fork 1
Copy + UX: fear hook on FindJobs no-scan state, WinStatsBanner zero-state, TrustCenter accuracy #504
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Copy + UX: fear hook on FindJobs no-scan state, WinStatsBanner zero-state, TrustCenter accuracy #504
Changes from all commits
b69e70a
cb0eada
e0ead09
92dd758
fa20291
fbcffc3
8559879
9109c33
caf166b
cbe6a37
0420434
e2a23fe
3eb24c8
bfd5d66
301486f
a318d8b
5455580
015ebe4
74dbb5e
4b4a08c
9b9c53e
989d605
8af8310
121949e
6e9aa27
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| # Changelog 2026-08-24 — NightlyBuildAgent | ||
|
|
||
| ## Build Status | ||
| - npm run build: PASS | ||
| - npx tsc --noEmit: PASS (0 errors) | ||
| - Dependencies: Fresh install required (node_modules absent in remote container) | ||
|
|
||
| ## Phase 1 — Fix Broken | ||
| - No broken builds or TypeScript errors found after fresh npm install | ||
|
|
||
| ## Phase 2 — Features | ||
| All Tier 1 features already built in prior sessions: | ||
| - Scan counter (weekly reset, Monday midnight): DONE | ||
| - Google Calendar ICS export: DONE | ||
| - WinStatsBanner (/api/wins/stats): DONE | ||
| - WhatsApp templates (quick_quote_offer + availability_check): DONE | ||
|
|
||
| **New this run:** | ||
| - WinStatsBanner zero-state: Component now shows placeholder when `wonCount === 0` — "No wins logged in your area yet — be the first to track a job won." Previously returned null silently. | ||
|
|
||
| ## Phase 3 — Copy Polish | ||
|
|
||
| ### FindJobsPage.tsx (no-scan state) | ||
| - Removed: SVG map illustration (decorative noise, slows comprehension) | ||
| - Removed: Background dot pattern (SVG data URI) | ||
| - Removed: "READY?" micro-label + generic "CHECK THE CURRENT PUBLIC-TENDER FEED." headline | ||
| - Added: "YOUR PATCH RIGHT NOW" micro-label | ||
| - Added: Fear hook headline — "EVERY WEEK YOU DON'T SCAN IS WORK YOUR COMPETITORS ARE PRICING." | ||
| - Added: Proof line — "Planning approvals, energy ratings, contract notices — scored for your trade before Checkatrade, Bark, or MyBuilder list the same job." | ||
| - Added: "Takes 10 seconds · No credit card required" footer note | ||
| - Result: Reads in <2 seconds. Fear → proof → control. No fluff. | ||
|
|
||
| ### TrustCenterPage.tsx (accuracy fix) | ||
| - Removed: "One exclusive territory lock — your patch, nobody else in it" (false — any trade can scan any postcode) | ||
| - Added: "Your scan is private — other trades scanning the same postcode see nothing of yours" (accurate + still compelling) | ||
|
|
||
| ## Phase 4 — Site Health | ||
|
|
||
| ### NEEDLE — Top 3 UX issues found | ||
| 1. FindJobsPage "no scan yet" state used a generic SVG map and corporate headline that wouldn't hook a tradesman — fixed this run | ||
| 2. TrustCenterPage "exclusive territory lock" claim was factually wrong — could damage trust — fixed this run | ||
| 3. WinStatsBanner disappeared silently with zero wins, missing an opportunity to introduce win-tracking concept to new users — fixed this run | ||
|
|
||
| ### BUILDER — Fixes applied | ||
| All three issues fixed in this run. | ||
|
|
||
| ### CRITIC — Clearer in <3 seconds? | ||
| - YES: "Every week you don't scan is work your competitors are pricing" is scannable and actionable | ||
| - YES: TrustCenterPage now makes an accurate claim that still builds confidence | ||
|
|
||
| ### REVENUE — Increases likelihood of £39/month? | ||
| - YES: Fear hook drives scan action → see value → convert | ||
| - YES: Trust accuracy prevents late-stage doubt when users investigate further | ||
|
|
||
| ## Commit | ||
| - Branch: nightly/2026-08-24-copy-ux | ||
| - PR: https://github.com/manazoid4/JobFilterV1/pull/504 | ||
|
|
||
| ## Next Run — Top 3 Priorities | ||
| 1. Copy polish: ForYourTradePage bottom CTA section — test the WaitlistForm component is wired (check /api/waitlist endpoint) | ||
| 2. Feature: Trade-specific scoring UX — electrician badge labels should emphasise EV charger/rewire/EICR explicitly; currently parsing `reasons` array which is good but the fallback label "Verified signal" is too vague | ||
| 3. Review PR #504 CI — if check passes, merge to main |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,28 +1,32 @@ | ||
| # Daily To-Do — JobFilter | ||
|
|
||
| Last updated: 2026-08-22 (NightlyBuildAgent) | ||
| Last updated: 2026-08-24 (NightlyBuildAgent) | ||
|
|
||
| ## Completed This Run | ||
| - [x] npm run build — green | ||
| - [x] npx tsc --noEmit — clean | ||
| - [x] HomePage hero copy — fear→proof→control, competitor names in ops strip | ||
| - [x] PricingPage CTAs — removed "START AFTER COVERAGE CHECK" friction, clear pricing | ||
| - [x] PR #499 opened — nightly/2026-08-22-copy-polish | ||
| - [x] Codex P1 (30-day money-back vs LegalPage) — removed refund promise (commit 29b59c8) | ||
| - [x] Codex P2 (can win overstatement) — changed to "worth pursuing" (commit 29b59c8) | ||
| - [x] Codex P1 round-2 (pilot promises ungated features) — restored activation caveat (commit 0ac934d) | ||
| - [x] Codex P2 round-2 (empty result too categorical) — qualified for partial feed failure (commit 0ac934d) | ||
| - [x] All 4 Codex review threads replied to on PR #499 | ||
| - [x] FindJobsPage "no scan yet" state — fear hook headline + removed SVG map illustration | ||
| - [x] WinStatsBanner zero-state — show placeholder when wonCount = 0 | ||
| - [x] TrustCenterPage accuracy fix — removed false "exclusive territory lock" claim | ||
| - [x] PR #504 opened — nightly/2026-08-24-copy-ux | ||
|
|
||
| ## Completed Previous Runs | ||
| - [x] HomePage hero copy — fear→proof→control, competitor names in ops strip (PR #499) | ||
| - [x] PricingPage CTAs — removed "START AFTER COVERAGE CHECK" friction, clear pricing (PR #499) | ||
| - [x] Scan counter (weekly reset, localStorage-based): DONE — FindJobsPage.tsx | ||
| - [x] WinStatsBanner with /api/wins/stats endpoint: DONE | ||
| - [x] Google Calendar ICS export: DONE — calendarExport.ts + LeadDetailPage.tsx | ||
| - [x] WhatsApp templates (quick_quote_offer + availability_check): DONE — chaseTemplates.ts | ||
|
|
||
| ## In Progress | ||
| - [ ] PR #499 — awaiting CI "check" status on commit 0ac934d before merge | ||
| - [ ] PR #504 — awaiting CI "check" status | ||
|
|
||
| ## Next Run Priorities | ||
| 1. Copy polish: ForYourTradePage.tsx — trade-specific fear hooks and competitor callouts | ||
| 2. FindJobsPage "no scan yet" state — stronger fear hook, replace generic SVG map text | ||
| 3. Check if /api/wins/stats has any real data yet — WinStatsBanner only shows when wonCount > 0; if no data, add a placeholder message like "Be the first to log a win in your area" | ||
| 1. Review PR #504 CI — merge if green | ||
| 2. Copy polish: ForYourTradePage bottom CTA — verify WaitlistForm is wired to /api/waitlist (no fake flows) | ||
| 3. Trade-specific scoring UX: fallback "Verified signal" badge label on lead cards is too vague — improve to show a trade-specific reason even without a parsed tradeMatch reason | ||
|
|
||
| ## Known Issues | ||
| - node_modules not committed (expected) — fresh install needed each remote session | ||
| - Obsidian vault was absent this session — recreated from scratch | ||
| - Main branch protected: requires "check" CI status — all agent pushes must go via PR | ||
| - Obsidian vault Product docs absent from this repo clone (roadmap, key problems, design direction files were not found — vault only has Changelog + Sessions) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,10 @@ | ||
| import type { Express, Request, Response } from 'express'; | ||
| import { supabase } from '../lib/supabase'; | ||
| import { resolveRequestAccess, type RequestAccess } from '../lib/requestAuth'; | ||
| import { outwardFromPostcode, isKnownUkArea } from '../utils/postcode'; | ||
| import { rateLimit, makeRateLimit } from '../middleware/rateLimit'; | ||
|
|
||
| const rateLimitStats = makeRateLimit(60); | ||
|
|
||
| const OUTCOME_STATUSES = new Set([ | ||
| 'delivered', | ||
|
|
@@ -59,29 +63,39 @@ export function registerOutcomeReportRoute(app: Express) { | |
| } | ||
| }); | ||
|
|
||
| app.get('/api/wins/stats', async (req: Request, res: Response) => { | ||
| app.get('/api/wins/stats', rateLimitStats, async (req: Request, res: Response) => { | ||
| try { | ||
| const rows = await readOutcomeRows(); | ||
| const postcodePrefix = String(req.query.postcode || '').toUpperCase().slice(0, 4).trim(); | ||
| const areaPrefix = postcodePrefix.slice(0, 2); | ||
| const won = rows.filter((o) => { | ||
| if (o.status !== 'won') return false; | ||
| if (!areaPrefix || !o.postcode_outward) return true; | ||
| return String(o.postcode_outward).toUpperCase().startsWith(areaPrefix); | ||
| }); | ||
| if (!supabase) { | ||
| return res.status(503).json({ ok: false, error: 'Supabase is not configured; stats are unavailable.' }); | ||
| } | ||
| const outward = outwardFromPostcode(String(req.query.postcode || '')); | ||
| const areaPrefix = outward.match(/^[A-Z]+/)?.[0] ?? ''; | ||
| if (!areaPrefix || !isKnownUkArea(areaPrefix)) { | ||
| return res.json({ ok: true, available: false }); | ||
| } | ||
| const { wonCount: totalWonCount, totalValue, suppressed, valueSuppressed } = await readWonStatsByArea(areaPrefix); | ||
|
manazoid4 marked this conversation as resolved.
|
||
|
|
||
| const totalWonCount = won.length; | ||
| const totalValue = won.reduce((sum, o) => sum + Number(o.won_value ?? 0), 0); | ||
| let message: string; | ||
| if (suppressed) { | ||
| message = 'Wins logged in your area — details stay private until more trades track jobs. Be the next to log one.'; | ||
| } else if (totalWonCount > 0 && !valueSuppressed && totalValue > 0) { | ||
| message = `${totalWonCount} trade${totalWonCount === 1 ? '' : 's'} in your area won jobs worth £${totalValue.toLocaleString()} via JobFilter`; | ||
| } else if (totalWonCount > 0) { | ||
| message = `${totalWonCount} trade${totalWonCount === 1 ? '' : 's'} in your area logged wins via JobFilter`; | ||
| } else { | ||
| message = 'Be the first trade in your area to log a win.'; | ||
| } | ||
|
|
||
| return res.json({ | ||
| ok: true, | ||
| postcodeArea: postcodePrefix || 'UK', | ||
| available: true, | ||
| postcodeArea: areaPrefix, | ||
| wonCount: totalWonCount, | ||
| totalValue, | ||
| totalValueFormatted: `£${totalValue.toLocaleString()}`, | ||
| message: totalWonCount > 0 | ||
| ? `${totalWonCount} trade${totalWonCount === 1 ? '' : 's'} in your area won jobs worth £${totalValue.toLocaleString()} via JobFilter` | ||
| : 'Be the first trade in your area to log a win.', | ||
| suppressed, | ||
| valueSuppressed, | ||
| message, | ||
| }); | ||
| } catch (error: any) { | ||
| return res.status(500).json({ ok: false, error: String(error?.message ?? 'Stats failed.') }); | ||
|
|
@@ -210,6 +224,74 @@ async function leadIsOwnedBy(leadId: string, userId: string) { | |
| return data?.user_id === userId; | ||
| } | ||
|
|
||
| const SMALL_AREA_PRIVACY_THRESHOLD = 3; | ||
|
|
||
| async function readWonStatsByArea(areaPrefix: string): Promise<{ wonCount: number; totalValue: number; suppressed: boolean; valueSuppressed: boolean }> { | ||
| const client = supabase!; | ||
| const areaFilter = `^${areaPrefix}[0-9]`; | ||
|
|
||
| const distinctWinContributors = await countDistinctContributors(areaFilter, SMALL_AREA_PRIVACY_THRESHOLD, { requireValue: false }); | ||
| if (distinctWinContributors === 0) { | ||
| return { wonCount: 0, totalValue: 0, suppressed: false, valueSuppressed: false }; | ||
| } | ||
| if (distinctWinContributors < SMALL_AREA_PRIVACY_THRESHOLD) { | ||
| return { wonCount: 0, totalValue: 0, suppressed: true, valueSuppressed: true }; | ||
| } | ||
|
|
||
| const distinctValueContributors = await countDistinctContributors(areaFilter, SMALL_AREA_PRIVACY_THRESHOLD, { requireValue: true }); | ||
| const valueSuppressed = distinctValueContributors < SMALL_AREA_PRIVACY_THRESHOLD; | ||
|
|
||
| const { data: countData, error: countError } = await (client as any) | ||
| .from('lead_outcomes') | ||
| .select('won_count:count()') | ||
| .eq('status', 'won') | ||
| .filter('postcode_outward', 'imatch', areaFilter) | ||
| .not('user_id', 'is', null); | ||
| if (countError) throw new Error(countError.message); | ||
| const wonCount = Number((countData as any)?.[0]?.won_count ?? 0); | ||
|
|
||
| let totalValue = 0; | ||
| if (!valueSuppressed) { | ||
| const { data: sumData, error: sumError } = await (client as any) | ||
| .from('lead_outcomes') | ||
| .select('won_value_sum:won_value.sum()') | ||
| .eq('status', 'won') | ||
| .filter('postcode_outward', 'imatch', areaFilter) | ||
| .not('user_id', 'is', null) | ||
| .gt('won_value', 0); | ||
| if (sumError) throw new Error(sumError.message); | ||
| totalValue = Number((sumData as any)?.[0]?.won_value_sum ?? 0); | ||
| } | ||
|
|
||
| return { wonCount, totalValue, suppressed: false, valueSuppressed }; | ||
| } | ||
|
|
||
| async function countDistinctContributors(areaFilter: string, target: number, opts: { requireValue: boolean }): Promise<number> { | ||
| const client = supabase!; | ||
| const seen: string[] = []; | ||
| for (let i = 0; i < target; i++) { | ||
| let query: any = (client as any) | ||
| .from('lead_outcomes') | ||
| .select('user_id') | ||
| .eq('status', 'won') | ||
| .filter('postcode_outward', 'imatch', areaFilter) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a win comes from a feed without an exact postcode, Useful? React with 👍 / 👎. |
||
| .not('user_id', 'is', null) | ||
|
manazoid4 marked this conversation as resolved.
|
||
| .limit(1); | ||
| if (opts.requireValue) { | ||
| query = query.gt('won_value', 0); | ||
| } | ||
| if (seen.length > 0) { | ||
| query = query.not('user_id', 'in', `(${seen.join(',')})`); | ||
| } | ||
| const { data, error } = await query; | ||
| if (error) throw new Error(error.message); | ||
| const userId = (data as any)?.[0]?.user_id; | ||
| if (!userId) return seen.length; | ||
| seen.push(userId); | ||
| } | ||
| return seen.length; | ||
| } | ||
|
|
||
| async function readOutcomeRows() { | ||
| if (!supabase) return [] as any[]; | ||
| const { data, error } = await supabase | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.