Skip to content

Commit f52d610

Browse files
committed
2.0: polish pass — error surfacing, fragile-error fixes, brutalist README, CI
Two-component repo (dashboard + scraper) — polish + privacy + branding, no architectural changes. Privacy / .env -------------- - .env.example now lists every required var (was Supabase-only). Adds GOOGLE_PLACES_API_KEY_1/2, GEMINI_API_KEY, GEMINI_MODEL, scraper delay tuning, and the USER_NAME/TITLE/EMAIL/WEBSITE block consumed by ai-drafter.js. Anyone forking was hitting confusing runtime errors before this. Dashboard fixes --------------- - src/lib/hooks.ts: useLeads / useSearches / useSearchRequests all had silent `if (!error && data)` fallbacks. Errors are now console.error'd so they're at least debuggable. - src/components/dashboard/LeadDetail.tsx: regenerate-message `catch {}` was swallowing failures silently — user clicked the button, nothing happened, no feedback. Now: HTTP status check, explicit error capture, inline rose-tinted error chip below the button, auto clears after 4s. New regenerateError state. - src/components/dashboard/LeadCard.tsx: hover:scale-110 on 14px icon buttons was imperceptible. Swapped to hover:opacity-70 across all five action buttons for consistent muted-feedback UX. Scraper fixes ------------- - src/config.js: ENV_MAP was duplicated between getConfig and getAllConfig. Extracted to a top-level const + derived CONFIG_KEYS, so the two paths stay in sync and adding a new env var is a one-line change. - src/index.js watch poll: the transient-error filter was `!err.message.includes('fetch')` — brittle and broad. Replaced with a real classification (TypeError, AbortError, ECONNRESET, ETIMEDOUT, ENOTFOUND). Real errors still get logged; flaky Wi-Fi doesn't spam. - src/analyzer.js: PageSpeed `response.json()` was called without checking `response.ok` first, which yielded confusing SyntaxError on 4xx/5xx (API returns HTML error pages). Now: explicit status check with a descriptive error message. - src/crawler.js: `Sucesss` → `Success` typo. Brand + CI ---------- - assets-readme/ hero banner SVGs (light + dark) - README rewritten in brutalist house style — ASCII pipeline diagram, 23-factor breakdown, every section explicit. The old centered-div format didn't match the rest of my portfolio. - .github/workflows/ci.yml: two jobs. (1) Dashboard runs `next build` with placeholder env so the supabase client module-loader doesn't throw. (2) Scraper does an import-check on every src/*.js file, catching syntax errors and unresolved imports. - tsconfig.tsbuildinfo added to .gitignore (Next.js artifact).
1 parent b9da3cb commit f52d610

14 files changed

Lines changed: 487 additions & 345 deletions

File tree

‎.env.example‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,28 @@
1-
# Supabase (only config needed - everything else is configured via the dashboard UI)
1+
# ── Supabase (required — both dashboard and scraper) ─────────────────
2+
# Project → Settings → API
23
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
3-
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
4+
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-public-key
5+
6+
# ── Scraper-only env (optional — also settable from the dashboard UI) ─
7+
# Anything you leave blank here can be set later from
8+
# /dashboard/settings inside the app, which writes to the `ms_settings`
9+
# table. Putting them in .env is just the dev convenience path.
10+
11+
# Google Places API — you can supply up to 2 keys for failover / quota
12+
GOOGLE_PLACES_API_KEY_1=
13+
GOOGLE_PLACES_API_KEY_2=
14+
15+
# Gemini (used to draft personalised outreach for each scored lead)
16+
GEMINI_API_KEY=
17+
GEMINI_MODEL=gemini-2.5-flash
18+
19+
# Scraper tuning
20+
SCRAPER_DELAY_MIN=2000
21+
SCRAPER_DELAY_MAX=5000
22+
SCRAPER_MAX_RESULTS=60
23+
24+
# Your identity — interpolated into every AI-drafted message
25+
USER_NAME=
26+
USER_TITLE=
27+
USER_EMAIL=
28+
USER_WEBSITE=

‎.github/workflows/ci.yml‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
concurrency:
10+
group: ${{ github.workflow }}-${{ github.ref }}
11+
cancel-in-progress: true
12+
13+
jobs:
14+
dashboard:
15+
name: Dashboard · Next build
16+
runs-on: ubuntu-latest
17+
timeout-minutes: 8
18+
defaults:
19+
run:
20+
working-directory: dashboard
21+
steps:
22+
- uses: actions/checkout@v5
23+
- uses: actions/setup-node@v4
24+
with:
25+
node-version: "20"
26+
cache: npm
27+
cache-dependency-path: dashboard/package-lock.json
28+
- name: Install
29+
run: npm ci
30+
- name: Build
31+
# Provide harmless placeholders so the dashboard's supabase client
32+
# initialiser doesn't throw at module-load during the build pass.
33+
env:
34+
NEXT_PUBLIC_SUPABASE_URL: https://example.supabase.co
35+
NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder-anon-key
36+
run: npm run build
37+
38+
scraper:
39+
name: Scraper · syntax + import check
40+
runs-on: ubuntu-latest
41+
timeout-minutes: 5
42+
defaults:
43+
run:
44+
working-directory: scraper
45+
steps:
46+
- uses: actions/checkout@v5
47+
- uses: actions/setup-node@v4
48+
with:
49+
node-version: "20"
50+
cache: npm
51+
cache-dependency-path: scraper/package-lock.json
52+
- name: Install
53+
run: npm ci
54+
- name: Parse-check every source file
55+
# `node --check` doesn't follow imports, so for ESM we Just-Import
56+
# each file. Any unresolved import or syntax error blows up here.
57+
env:
58+
NEXT_PUBLIC_SUPABASE_URL: https://example.supabase.co
59+
NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder-anon-key
60+
run: |
61+
for f in src/*.js; do
62+
echo "→ checking $f"
63+
node --input-type=module -e "import('./$f').then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); })" || exit 1
64+
done

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ node_modules/
66
dist/
77
.DS_Store
88
coverage/
9+
tsconfig.tsbuildinfo

‎README.md‎

Lines changed: 168 additions & 283 deletions
Large diffs are not rendered by default.

‎assets-readme/hero-banner-dark.svg‎

Lines changed: 32 additions & 0 deletions
Loading

‎assets-readme/hero-banner.svg‎

Lines changed: 32 additions & 0 deletions
Loading

‎dashboard/src/components/dashboard/LeadCard.tsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export default function LeadCard({ lead, onClick, isDragging }: LeadCardProps) {
6767
<a
6868
href={getEmailLink(primaryEmail, '', lead.ai_email_draft || '')}
6969
onClick={(e) => e.stopPropagation()}
70-
className="p-1.5 rounded-lg transition-colors hover:scale-110"
70+
className="p-1.5 rounded-lg transition-colors hover:opacity-70"
7171
style={{ color: 'var(--accent-blue)' }}
7272
title={`Email: ${primaryEmail}`}
7373
>
@@ -80,7 +80,7 @@ export default function LeadCard({ lead, onClick, isDragging }: LeadCardProps) {
8080
target="_blank"
8181
rel="noopener noreferrer"
8282
onClick={(e) => e.stopPropagation()}
83-
className="p-1.5 rounded-lg transition-colors hover:scale-110"
83+
className="p-1.5 rounded-lg transition-colors hover:opacity-70"
8484
style={{ color: 'var(--accent-green)' }}
8585
title={`WhatsApp: ${lead.phone}`}
8686
>
@@ -91,7 +91,7 @@ export default function LeadCard({ lead, onClick, isDragging }: LeadCardProps) {
9191
<a
9292
href={`tel:${lead.phone}`}
9393
onClick={(e) => e.stopPropagation()}
94-
className="p-1.5 rounded-lg transition-colors hover:scale-110"
94+
className="p-1.5 rounded-lg transition-colors hover:opacity-70"
9595
style={{ color: 'var(--accent-amber)' }}
9696
title={`Call: ${lead.phone}`}
9797
>
@@ -104,7 +104,7 @@ export default function LeadCard({ lead, onClick, isDragging }: LeadCardProps) {
104104
target="_blank"
105105
rel="noopener noreferrer"
106106
onClick={(e) => e.stopPropagation()}
107-
className="p-1.5 rounded-lg transition-colors hover:scale-110"
107+
className="p-1.5 rounded-lg transition-colors hover:opacity-70"
108108
style={{ color: 'var(--text-secondary)' }}
109109
title="Visit website"
110110
>
@@ -117,7 +117,7 @@ export default function LeadCard({ lead, onClick, isDragging }: LeadCardProps) {
117117
target="_blank"
118118
rel="noopener noreferrer"
119119
onClick={(e) => e.stopPropagation()}
120-
className="p-1.5 rounded-lg transition-colors hover:scale-110 ml-auto"
120+
className="p-1.5 rounded-lg transition-colors hover:opacity-70 ml-auto"
121121
style={{ color: 'var(--text-muted)' }}
122122
title="View on Google Maps"
123123
>

‎dashboard/src/components/dashboard/LeadDetail.tsx‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export default function LeadDetail({ lead, onUpdate }: LeadDetailProps) {
3232
const [selectedTones, setSelectedTones] = useState<string[]>([]);
3333
const [selectedService, setSelectedService] = useState<ServiceType | ''>('');
3434
const [regenerating, setRegenerating] = useState<string | null>(null);
35+
const [regenerateError, setRegenerateError] = useState<Record<string, string>>({});
3536
const [generatedMessage, setGeneratedMessage] = useState<Record<string, string>>({});
3637
// Contact tracking
3738
const [showContactLog, setShowContactLog] = useState(false);
@@ -79,12 +80,29 @@ export default function LeadDetail({ lead, onUpdate }: LeadDetailProps) {
7980
settings,
8081
}),
8182
});
83+
if (!res.ok) {
84+
throw new Error(`Regenerate failed: HTTP ${res.status}`);
85+
}
8286
const data = await res.json();
8387
if (data.message) {
8488
setGeneratedMessage(prev => ({ ...prev, [channel]: data.message }));
89+
} else if (data.error) {
90+
throw new Error(data.error);
8591
}
86-
} catch {
87-
// Silently fail
92+
} catch (err: unknown) {
93+
// Surface the failure on the regenerate button itself — no silent
94+
// catch, no surprise empty state.
95+
const msg = err instanceof Error ? err.message : String(err);
96+
console.error('[regenerate]', msg);
97+
setRegenerateError((prev) => ({ ...prev, [channel]: msg }));
98+
// Auto-clear the error after a few seconds so the next click is clean
99+
setTimeout(() => {
100+
setRegenerateError((prev) => {
101+
const next = { ...prev };
102+
delete next[channel];
103+
return next;
104+
});
105+
}, 4000);
88106
}
89107
setRegenerating(null);
90108
};
@@ -560,6 +578,11 @@ export default function LeadDetail({ lead, onUpdate }: LeadDetailProps) {
560578
<><Sparkles size={12} /> Generate {ch.label} Message</>
561579
)}
562580
</button>
581+
{regenerateError[ch.key] && (
582+
<div className="text-xs px-3 py-2 rounded-md" style={{ background: 'rgba(248,113,113,0.1)', color: '#f87171', border: '1px solid rgba(248,113,113,0.3)' }}>
583+
⚠ {regenerateError[ch.key]}
584+
</div>
585+
)}
563586
</div>
564587
</motion.div>
565588
)}

‎dashboard/src/lib/hooks.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ export function useLeads(filters?: { status?: LeadStatus; searchId?: string }) {
2020
if (filters?.searchId) query = query.eq('search_id', filters.searchId);
2121

2222
const { data, error } = await query;
23-
if (!error && data) setLeads(data as Lead[]);
23+
if (error) {
24+
console.error('[useLeads] supabase error:', error);
25+
} else if (data) {
26+
setLeads(data as Lead[]);
27+
}
2428
setLoading(false);
2529
}, [filters?.status, filters?.searchId]);
2630

@@ -82,7 +86,11 @@ export function useSearches() {
8286
.select('*')
8387
.order('created_at', { ascending: false });
8488

85-
if (!error && data) setSearches(data as Search[]);
89+
if (error) {
90+
console.error('[useSearches] supabase error:', error);
91+
} else if (data) {
92+
setSearches(data as Search[]);
93+
}
8694
setLoading(false);
8795
}, []);
8896

@@ -120,7 +128,11 @@ export function useSearchRequests() {
120128
.order('created_at', { ascending: false })
121129
.limit(20);
122130

123-
if (!error && data) setRequests(data as SearchRequest[]);
131+
if (error) {
132+
console.error('[useSearchRequests] supabase error:', error);
133+
} else if (data) {
134+
setRequests(data as SearchRequest[]);
135+
}
124136
setLoading(false);
125137
}, []);
126138

‎scraper/fix-drafts.js‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { supabase, getAllConfig } from './src/config.js';
2+
import { generateMessages } from './src/ai-drafter.js';
3+
import chalk from 'chalk';
4+
import ora from 'ora';
5+
6+
async function fixDrafts() {
7+
console.log(chalk.bold.hex('#7c5cfc')('\n === LEAD SNIPER DRAFT FIXER ===\n'));
8+
9+
const configSpinner = ora('Loading configuration...').start();
10+
const config = await getAllConfig();
11+
if (!config.gemini_api_key) {
12+
configSpinner.fail('No Gemini API key found in configuration.');
13+
process.exit(1);
14+
}
15+
configSpinner.succeed(chalk.green('Configuration loaded'));
16+
17+
const fetchSpinner = ora('Fetching existing leads...').start();
18+
// Fetch leads that have an ai_email_draft. We'll regenerate them.
19+
const { data: leads, error } = await supabase
20+
.from('ms_leads')
21+
.select('*')
22+
.not('ai_email_draft', 'is', null);
23+
24+
if (error) {
25+
fetchSpinner.fail(`Failed to fetch leads: ${error.message}`);
26+
process.exit(1);
27+
}
28+
fetchSpinner.succeed(`Found ${leads.length} leads to evaluate for re-drafting.`);
29+
30+
let updated = 0;
31+
let skipped = 0;
32+
let errors = 0;
33+
34+
for (let i = 0; i < leads.length; i++) {
35+
const lead = leads[i];
36+
37+
// Simple heuristic: if the total draft length is unusually short (e.g., < 200 chars)
38+
// OR if it's missing the LINKEDIN section (which is the last one usually), it might be cut off.
39+
const draftStr = lead.ai_email_draft || '';
40+
const mightBeCutOff = !draftStr.includes('--- LINKEDIN ---') || draftStr.length < 300;
41+
42+
// You can force it to redraft all by removing this condition, but let's be smart about API usage.
43+
if (mightBeCutOff) {
44+
process.stdout.write(`\r [${i + 1}/${leads.length}] Regenerating draft for ${chalk.white.bold(lead.business_name)}...`);
45+
46+
try {
47+
const messages = await generateMessages(lead, config);
48+
const parts = [];
49+
if (messages.email) parts.push(`--- EMAIL ---\n${messages.email}`);
50+
if (messages.whatsapp) parts.push(`--- WHATSAPP ---\n${messages.whatsapp}`);
51+
if (messages.linkedin) parts.push(`--- LINKEDIN ---\n${messages.linkedin}`);
52+
const newDraft = parts.join('\n\n') || null;
53+
54+
const { error: updateError } = await supabase
55+
.from('ms_leads')
56+
.update({ ai_email_draft: newDraft })
57+
.eq('id', lead.id);
58+
59+
if (updateError) errors++;
60+
else updated++;
61+
62+
} catch (err) {
63+
errors++;
64+
}
65+
} else {
66+
skipped++;
67+
}
68+
}
69+
70+
console.log(`\n\n${chalk.green('Done!')}`);
71+
console.log(` Regenerated: ${updated}`);
72+
console.log(` Skipped (Looked fine): ${skipped}`);
73+
console.log(` Errors: ${errors}`);
74+
process.exit(0);
75+
}
76+
77+
fixDrafts();

0 commit comments

Comments
 (0)