31 Commits

Author SHA1 Message Date
Timo Uttenweiler
85cc6efc19 Add debug logging to leads API 2026-04-09 11:45:04 +02:00
Timo Uttenweiler
2c9afe76cf Show error toast when leads API fails in Leadspeicher
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 11:40:37 +02:00
Timo Uttenweiler
3063c0860d Add Turso support: schema SQL + entrypoint skip for cloud DB
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 11:28:26 +02:00
Timo Uttenweiler
b01d14b784 Fix internal API calls in Docker (use localhost instead of origin)
req.nextUrl.origin resolves to external domain behind a reverse proxy,
breaking server-to-server fetch calls. Use localhost:PORT instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 11:22:57 +02:00
Timo Uttenweiler
807b82f633 Fix Docker volume permissions for /data (SQLite)
Add su-exec to runner stage, run entrypoint as root to chown /data,
then drop to nextjs user for migrations and app start. Fixes permission
denied errors when Docker volume is mounted as root.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 11:19:27 +02:00
Timo Uttenweiler
7db914084e Stitch redesign, Energieversorger-Kampagne, UI improvements
- Apply Stitch design system to leadspeicher, suche, TopBar, globals.css
- Add Energieversorger queue campaign (Netzbetreiber, Fernwärme, Industriepark)
  with BW + Bayern priority, tracks usage per term+location combo
- Remove TopBar right-side actions (Leads finden, bell, settings)
- Remove mode tabs from manual search, rename KI button
- Fix Google Fonts @import order (move to <link> in layout.tsx)
- Add cursor-pointer globally via globals.css
- Responsive fixes for campaign buttons and KI button
- Fix .dockerignore to exclude .env from image build
- Add stadtwerke-cities API + city data (50 cities per Bundesland)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 10:08:00 +02:00
Timo Uttenweiler
54e0d22f9c mein-solar: full feature set
- Schema: companyType, topics, salesScore, salesReason, offerPackage, approved, approvedAt, SearchHistory table
- /api/search-history: GET (by mode) + POST (save query)
- /api/ai-search: stadtwerke/industrie/custom prompts with history dedup
- /api/enrich-leads: website scraping + GPT-4o-mini enrichment (fire-and-forget after each job)
- /api/generate-email: personalized outreach via GPT-4o
- Suche page: 3 mode tabs (Stadtwerke/Industrie/Freie Suche), Alle-Bundesländer queue button, AiSearchModal gets searchMode + history
- Leadspeicher: Bewertung dots column, Paket badge column, Freigeben toggle button, email generator in SidePanel, approved-only export option
- Leads API: approvedOnly + companyType filters, new fields in PATCH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:06:07 +02:00
Timo Uttenweiler
e5172cbdc5 Remove count from AI search prompt, add query prioritization
- count no longer part of prompt or JSON output — fixed at 50 in backend
- Added prioritization rules: most common term wins (Dachdecker > Spengler)
- Cleaner examples without count field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:07:11 +02:00
Timo Uttenweiler
edbf8cb1e2 Improve AI search system prompt
- Explains Google Maps pipeline context so model understands query constraints
- Adds splitting strategy for large regions and multi-industry searches
- 4 concrete JSON examples covering common use cases
- count derived from user context, no hardcoded default in prompt
- Strict output format instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:03:19 +02:00
Timo Uttenweiler
0f5d18dac7 Add KI-Suche via OpenRouter GPT-4o-mini
- /api/ai-search: sends user description to GPT-4o-mini, returns 2-4
  structured query/region pairs as JSON
- AiSearchModal: textarea, generates previews, user selects queries to run
- KI-Suche button in hero section of /suche page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:00:54 +02:00
TimoUttenweiler
a39a98b6dc feat: start SERP supplement in parallel as soon as Maps scraping stabilizes
When Maps scraping finishes (totalLeads stable for one poll, < targetCount),
fire the SERP supplement job immediately — don't wait for Anymailfinder
enrichment to complete. Both jobs now poll independently; results are merged
and deduplicated by domain once both are done.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 11:28:41 +02:00
TimoUttenweiler
1719062b47 feat: Hover-Effekt für Nur-neue-speichern Button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 11:01:33 +02:00
TimoUttenweiler
f94f7c6314 feat: Nur neue speichern als Button in den Suchergebnissen
Button erscheint in der Ergebnis-Headerzeile sobald vorhandene Leads
dabei sind. Löscht diese aus dem Leadspeicher und zeigt Bilanz-Toast.
Verschwindet nach Ausführung. Checkbox vor der Suche entfernt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 11:00:10 +02:00
TimoUttenweiler
6264ebf786 feat: Alle/Nur-neue Tabs + Option Nur neue Leads speichern
- Suchergebnis-Filter als Tabs: [Alle (46)] [Nur neue (X)] — beide klickbar
- Checkbox "Nur neue Leads speichern" erscheint vor der Suche
- Bei aktivierter Option: nach Abschluss werden vorhandene Leads
  automatisch aus dem Leadspeicher gelöscht, Toast zeigt Bilanz

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:56:30 +02:00
TimoUttenweiler
25234b70ee fix: Leads ohne Domain werden gefiltert und nicht gespeichert
Maps-Ergebnisse ohne Domain werden vor Speicherung herausgefiltert.
sinkLeadsToVault überspringt Leads ohne Domain komplett.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:54:30 +02:00
TimoUttenweiler
929d5ab3a1 fix: Leads ohne E-Mails wenn Anymailfinder-Guthaben leer
Anymailfinder-Fehler (z.B. 402) markiert Job nicht mehr als failed.
Maps- und SERP-Jobs schließen als complete ab und liefern die gefundenen
Unternehmen ohne Kontaktdaten — SERP-Supplement triggert danach normal.

- maps-enrich: Anymailfinder in eigenem try-catch, Fehler → complete
- serp-enrich: SERP-Rohdaten zuerst speichern, dann Enrichment versuchen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:52:50 +02:00
TimoUttenweiler
89a176700d feat: GPT-4.1 optimierte Ergänzungssuche bei Maps-Lücke
Wenn Google Maps weniger Leads findet als angefragt, wird automatisch
eine optimierte Suchanfrage via GPT-4.1 generiert und als SERP-Job
gestartet, um die Lücke zu füllen. Die KI-Query wird im LoadingCard
angezeigt. Fallback auf Original-Query wenn kein OpenAI-Key konfiguriert.

- lib/services/openai.ts: GPT-4.1 Query-Generator
- app/api/search/supplement: neuer Endpoint (GPT + SERP-Job)
- LoadingCard: ruft /api/search/supplement statt direkt SERP
- apiKey.ts + .env.local.example: openai Key-Support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:43:33 +02:00
TimoUttenweiler
aa6707b8bc feat: Kundensuche – Progressbar, SERP-Supplement, Dedup, Löschen, Neu-Filter
- Progressbar geht nie mehr rückwärts (Math.min-Cap entfernt)
- E-Mails-suchen-Phase wird immer kurz angezeigt bevor Fertig
- SERP-Supplement startet automatisch wenn Maps < Zielanzahl liefert
- Suchergebnisse bleiben nach Abschluss sichtbar (kein Redirect)
- Dedup in leadVault strikt nach Domain (verhindert Duplikate)
- isNew-Flag pro Result (Batch-Query gegen bestehende Vault-Domains)
- Nur-neue-Filter + vorhanden-Badge in Suchergebnissen
- Einzeln und Bulk löschen aus Suchergebnissen + Leadspeicher
- Fehlermeldung zeigt echten API-Fehler (z.B. 402 Anymailfinder)
- SERP-Supplement aus /api/search entfernt (LoadingCard übernimmt)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:25:43 +02:00
Timo Uttenweiler
c232f0cb79 Fix stats: count kontaktiert via tag instead of status field 2026-03-27 17:15:09 +01:00
Timo Uttenweiler
11197c9db1 Progress bar: forward-only crawl, never goes backward 2026-03-27 17:13:30 +01:00
Timo Uttenweiler
4c82e96f5a Remove 200 leads option from search 2026-03-27 17:11:03 +01:00
Timo Uttenweiler
a1d2c34f36 Search: Maps primary + SERP supplement for count > 60
- Always use Google Maps (max 60 per call)
- If count > 60: fire SERP job in background for additional results
- Dedup handled automatically by LeadVault domain upsert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:10:09 +01:00
Timo Uttenweiler
bf3fcd4210 Increase suche page padding and max-width 2026-03-27 17:06:04 +01:00
Timo Uttenweiler
8c00d4ea6f Increase leadspeicher padding to 72px 2026-03-27 17:05:34 +01:00
Timo Uttenweiler
b2a963a901 Improve SearchCard: hover effects, responsive grid, smooth transitions 2026-03-27 17:04:23 +01:00
Timo Uttenweiler
6b7ac42d1d Style suche hero like admin maps header 2026-03-27 17:03:12 +01:00
Timo Uttenweiler
9939ba9fdb Update leadspeicher subtitle text 2026-03-27 17:02:17 +01:00
Timo Uttenweiler
ea3138ac64 Remove Quelle column from customer leadspeicher 2026-03-27 17:01:56 +01:00
Timo Uttenweiler
a67dd2cc8c Add padding to leadspeicher page 2026-03-27 17:01:07 +01:00
Timo Uttenweiler
df90477bef Use admin leadspeicher UI in customer branch, fix Topbar import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:59:59 +01:00
Timo Uttenweiler
60073b97c9 feat: OnyvaLeads customer UI — Suche + Leadspeicher
- New Topbar: logo, 2-tab pill switcher, live "Neu" badge
- /suche page: SearchCard, LoadingCard, ExamplePills
- /leadspeicher page: full leads table with filters, pagination
- StatusBadge, StatusPopover, LeadSidePanel, BulkActionBar
- POST /api/search: unified search entry point → serp-enrich
- Remove Sidebar + old TopBar from layout
- Title: OnyvaLeads, redirect / → /suche

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:48:05 +01:00
44 changed files with 5546 additions and 233 deletions

View File

@@ -12,7 +12,8 @@ out
*.db-wal
/data
# Environment files (injected at runtime)
# Environment files (injected at runtime via Coolify)
.env
.env.local
.env.*.local

View File

@@ -1,4 +1,4 @@
APP_ENCRYPTION_SECRET=437065e334849562d991112d74e23653
APP_ENCRYPTION_SECRET=32 Zeichen
# Lokal (wird ignoriert wenn TURSO_DATABASE_URL gesetzt ist)
DATABASE_URL=file:./leadflow.db
@@ -12,3 +12,4 @@ ANYMAILFINDER_API_KEY=
APIFY_API_KEY=
VAYNE_API_KEY=
GOOGLE_MAPS_API_KEY=
OPENAI_API_KEY=

View File

@@ -34,7 +34,7 @@ RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
RUN apk add --no-cache python3 make g++
RUN apk add --no-cache python3 make g++ su-exec
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
@@ -66,8 +66,7 @@ RUN chmod +x docker-entrypoint.sh
# Data directory for SQLite — must be a volume
RUN mkdir -p /data && chown nextjs:nodejs /data
USER nextjs
# Entrypoint runs as root, fixes /data permissions, then drops to nextjs via su-exec
EXPOSE 3000
ENTRYPOINT ["./docker-entrypoint.sh"]

221
app/api/ai-search/route.ts Normal file
View File

@@ -0,0 +1,221 @@
import { NextRequest, NextResponse } from "next/server";
const SYSTEM_PROMPT_DEFAULT = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschsprachigen Raum.
## Was passiert mit deiner Ausgabe
Die Suchanfragen die du erzeugst werden direkt an die Google Maps Places API übergeben. Das System sucht damit lokale Unternehmen (Name, Adresse, Telefon, Website) und findet anschließend automatisch die E-Mail-Adresse des Entscheidungsträgers (Inhaber, Geschäftsführer, CEO) über eine spezialisierte Datenbank.
Das bedeutet: Deine Queries müssen so formuliert sein, wie jemand einen Handwerker oder Dienstleister bei Google Maps suchen würde — kurz, präzise, branchenbezogen. Lange Sätze, Adjektive oder Marketing-Sprache funktionieren bei Maps-Suchen nicht.
## Deine Aufgabe
Analysiere die Beschreibung des Nutzers und erstelle 24 Google-Maps-optimierte Suchanfragen, die zusammen die gewünschte Zielgruppe möglichst vollständig abdecken.
## Feldmuster
- **query**: Die Branche oder Tätigkeit — so kurz wie möglich, auf Deutsch, wie ein Mensch bei Google Maps tippt. Keine Adjektive wie "klein" oder "professionell". Keine Firmennamen. Keine URLs.
- **region**: Bundesland, Stadt oder geografisches Gebiet. Wenn der Nutzer eine große Region nennt (z.B. "Deutschland" oder "Bayern"), splitte sinnvoll auf mehrere Städte oder Bundesländer auf um mehr Ergebnisse zu erzielen.
## Priorisierung
Wähle immer den geläufigsten, meistgesuchten Begriff für eine Branche — nicht Nischenbegriffe oder Synonyme. Beispiele:
- "Dachdecker" vor "Spengler" oder "Klempner"
- "Elektriker" vor "Elektroinstallateur"
- "Steuerberater" vor "Steuerkanzlei"
- "Solaranlage" vor "Photovoltaik Fachbetrieb"
Wenn der Nutzer mehrere Branchen nennt, priorisiere die volumenstärkste zuerst.
## Splitting-Strategie
Nutze mehrere Queries wenn:
- Die Region zu groß ist für eine Suche (Deutschland → München, Hamburg, Berlin, Köln)
- Der Nutzer explizit mehrere verschiedene Branchen nennt (dann je eine Query pro Branche)
- Der Nutzer breite Abdeckung möchte
Maximal 4 Queries. Keine Duplikate (gleiche query + gleiche region).
## Ausgabeformat
Antworte ausschließlich mit einem JSON-Array. Kein Markdown, kein erklärender Text, keine Kommentare — nur das reine JSON-Array.`;
const SYSTEM_PROMPT_STADTWERKE = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschen Energiesektor.
## Kontext
Das Tool sucht deutsche Stadtwerke und kommunale Energieversorger als potenzielle Kunden für ein Solarunternehmen (mein-solar.com). Stadtwerke betreiben lokale Energie-, Wasser- und Verkehrsinfrastruktur und sind starke Abnehmer für Photovoltaik-Großanlagen, Batteriespeicher und Ladeinfrastruktur.
## Was passiert mit deiner Ausgabe
Die Queries werden an die Google Maps Places API und Google SERP übergeben. Gesucht werden Stadtwerke-Websites, aus denen dann Entscheidungsträger-E-Mails extrahiert werden.
## Deine Aufgabe
Generiere 24 Suchanfragen die Stadtwerke, Gemeindewerke und kommunale Energieversorger in Deutschland finden. Decke verschiedene Bundesländer oder Regionen ab.
## Suchbegriffe die funktionieren
- "Stadtwerke" — findet kommunale Versorger
- "Gemeindewerk" — kleinere Gemeindebetriebe
- "Stadtwerk" — Variante
- "kommunaler Energieversorger" — für SERP
- Niemals: LinkedIn, XING, Verzeichnisse, Social Media
## Priorisierung
"Stadtwerke" schlägt immer "Gemeindewerk". Große Bundesländer (NRW, Bayern, BW) zuerst wenn keine Region angegeben.
## Bereits verwendete Queries (diese NICHT nochmal verwenden — generiere neue Regionen/Varianten):
[HISTORY_PLACEHOLDER]
## Beispiel
Eingabe: "Stadtwerke in Norddeutschland"
Ausgabe:
[
{ "query": "Stadtwerke", "region": "Hamburg" },
{ "query": "Stadtwerke", "region": "Schleswig-Holstein" },
{ "query": "Gemeindewerk", "region": "Niedersachsen" }
]
## Ausgabeformat
Nur reines JSON-Array, kein Markdown, keine Erklärungen.`;
const SYSTEM_PROMPT_INDUSTRIE = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschen Industriesektor.
## Kontext
Das Tool sucht deutsche Industriebetriebe als Kunden für ein Solarunternehmen (mein-solar.com), das PV-Großanlagen (5 kW bis 10+ MW), Batteriespeicher und Ladeinfrastruktur anbietet. Zielkunden sind Firmen mit hohem Energieverbrauch, großen Dachflächen oder Freiflächen, und Interesse an Kostensenkung durch Eigenerzeugung.
## Was passiert mit deiner Ausgabe
Die Queries werden an Google Maps und SERP übergeben um Unternehmenswebsites zu finden, aus denen Entscheidungsträger-E-Mails extrahiert werden.
## Deine Aufgabe
Generiere 24 Suchanfragen die energieintensive Industriebetriebe, Produktionsunternehmen, Logistiker oder Gewerbeparks finden — ideale Kandidaten für Solar-Großanlagen.
## Gut geeignete Branchen
Produktion/Fertigung, Logistik/Lager, Lebensmittelindustrie, Metallverarbeitung, Automobilzulieferer, Landwirtschaft (Agri-PV), Gewerbeparks, Einzelhandel (große Flächen)
## Suchbegriffe die funktionieren
Kurze branchenbezogene Begriffe die bei Google Maps echte Betriebe finden. Keine Adjektive.
## Priorisierung
Wähle immer den geläufigsten Begriff (z.B. "Logistikzentrum" vor "Lagerhaus"). Branchen mit hohem Energieverbrauch und Dachfläche zuerst.
## Bereits verwendete Queries (diese NICHT nochmal verwenden):
[HISTORY_PLACEHOLDER]
## Beispiel
Eingabe: "Industriebetriebe in Bayern mit großen Dachflächen"
Ausgabe:
[
{ "query": "Produktionsbetrieb", "region": "München" },
{ "query": "Logistikzentrum", "region": "Augsburg" },
{ "query": "Lebensmittelproduktion", "region": "Bayern" }
]
## Ausgabeformat
Nur reines JSON-Array, kein Markdown, keine Erklärungen.`;
function buildSystemPrompt(
searchMode: string,
history: Array<{ query: string; region: string }>
): string {
const historyText =
history.length > 0
? history
.slice(0, 20)
.map(h => `- "${h.query}" in "${h.region}"`)
.join("\n")
: "Keine bisherigen Suchen.";
if (searchMode === "stadtwerke") {
return SYSTEM_PROMPT_STADTWERKE.replace("[HISTORY_PLACEHOLDER]", historyText);
}
if (searchMode === "industrie") {
return SYSTEM_PROMPT_INDUSTRIE.replace("[HISTORY_PLACEHOLDER]", historyText);
}
return SYSTEM_PROMPT_DEFAULT;
}
export async function POST(req: NextRequest) {
try {
const body = await req.json() as {
description: string;
searchMode?: string;
history?: Array<{ query: string; region: string }>;
};
const { description, searchMode = "custom", history = [] } = body;
if (!description?.trim()) {
return NextResponse.json({ error: "Beschreibung fehlt" }, { status: 400 });
}
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "OpenRouter API Key nicht konfiguriert" }, { status: 500 });
}
const systemPrompt = buildSystemPrompt(searchMode, history);
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://mein-solar.com",
"X-Title": "MeinSolar Leads",
},
body: JSON.stringify({
model: "openai/gpt-4o-mini",
temperature: 0.4,
max_tokens: 512,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: description.trim() },
],
}),
});
if (!res.ok) {
const err = await res.text();
console.error("[ai-search] OpenRouter error:", err);
return NextResponse.json({ error: "KI-Anfrage fehlgeschlagen" }, { status: 500 });
}
const data = await res.json() as {
choices: Array<{ message: { content: string } }>;
};
const raw = data.choices[0]?.message?.content?.trim() ?? "";
let queries: Array<{ query: string; region: string; count: number }>;
try {
queries = JSON.parse(raw);
} catch {
const match = raw.match(/\[[\s\S]*\]/);
if (!match) throw new Error("Kein JSON in Antwort");
queries = JSON.parse(match[0]);
}
queries = queries
.filter(q => typeof q.query === "string" && q.query.trim())
.slice(0, 4)
.map(q => ({
query: q.query.trim(),
region: (q.region ?? "").trim(),
count: 50,
}));
// Save to SearchHistory (fire and forget)
if (searchMode && searchMode !== "custom" && queries.length > 0) {
const baseUrl = req.url.replace(/\/api\/ai-search.*/, "");
for (const q of queries) {
fetch(`${baseUrl}/api/search-history`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: q.query, region: q.region, searchMode }),
}).catch(err => console.error("[ai-search] search-history save error:", err));
}
}
return NextResponse.json({ queries });
} catch (err) {
console.error("[ai-search] error:", err);
return NextResponse.json({ error: "Fehler bei der KI-Anfrage" }, { status: 500 });
}
}

View File

@@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
const ENRICHMENT_PROMPT = `Du analysierst eine Unternehmenswebsite für ein Solarunternehmen und extrahierst strukturierte Informationen.
Antworte ausschließlich mit JSON:
{
"companyType": "stadtwerke" | "industrie" | "sonstiges",
"topics": ["photovoltaik"|"speicher"|"emobilitaet"|"waerme"|"netz"|"wasserstoff"],
"salesScore": 1-5,
"salesReason": "Ein Satz warum dieser Lead gut/schlecht passt",
"offerPackage": "solar-basis" | "solar-pro" | "solar-speicher" | "komplett"
}
Bewertungskriterien salesScore:
5 = Stadtwerk oder Industriebetrieb mit explizitem Energiethema, perfekter Fit
4 = Energierelevante Branche, wahrscheinlich guter Fit
3 = Mittelständischer Betrieb, möglicher Fit
2 = Kleiner Betrieb oder unklare Relevanz
1 = Offensichtlich nicht relevant (Privatpersonen, Behörden ohne Energie-Relevanz)
Angebotspaket-Logik:
- solar-basis: Kleine Betriebe <50 Mitarbeiter, residentiell
- solar-pro: Mittelstand, Gewerbe
- solar-speicher: Energieintensive Betriebe, Stadtwerke
- komplett: Stadtwerke, Großindustrie, wenn topics viele Themen enthält
Website-Text:
[TEXT]`;
async function fetchWebsiteText(domain: string): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
try {
const url = domain.startsWith("http") ? domain : `https://${domain}`;
const res = await fetch(url, {
signal: controller.signal,
headers: { "User-Agent": "Mozilla/5.0 (compatible; LeadBot/1.0)" },
});
clearTimeout(timeout);
const html = await res.text();
// Strip tags, extract text
const text = html
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 2000);
return text;
} catch {
clearTimeout(timeout);
return "";
}
}
interface EnrichmentResult {
companyType: string;
topics: string[];
salesScore: number;
salesReason: string;
offerPackage: string;
}
async function enrichWithAI(text: string, apiKey: string): Promise<EnrichmentResult | null> {
if (!text.trim()) return null;
const prompt = ENRICHMENT_PROMPT.replace("[TEXT]", text);
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://mein-solar.com",
"X-Title": "MeinSolar Leads",
},
body: JSON.stringify({
model: "openai/gpt-4o-mini",
temperature: 0.2,
max_tokens: 256,
messages: [
{ role: "user", content: prompt },
],
}),
});
if (!res.ok) return null;
const data = await res.json() as { choices: Array<{ message: { content: string } }> };
const raw = data.choices[0]?.message?.content?.trim() ?? "";
try {
const parsed = JSON.parse(raw) as EnrichmentResult;
return parsed;
} catch {
const match = raw.match(/\{[\s\S]*\}/);
if (!match) return null;
try {
return JSON.parse(match[0]) as EnrichmentResult;
} catch {
return null;
}
}
}
export async function POST(req: NextRequest) {
try {
const { jobId } = await req.json() as { jobId: string };
if (!jobId) {
return NextResponse.json({ error: "jobId fehlt" }, { status: 400 });
}
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "OpenRouter API Key nicht konfiguriert" }, { status: 500 });
}
// Load all LeadResults for this job
const results = await prisma.leadResult.findMany({
where: { jobId },
});
// Find corresponding leads with domains
const domainsToEnrich = results
.filter(r => r.domain)
.map(r => r.domain as string);
if (domainsToEnrich.length === 0) {
return NextResponse.json({ enriched: 0, skipped: 0 });
}
// Find leads matching these domains
const leads = await prisma.lead.findMany({
where: {
domain: { in: domainsToEnrich },
salesScore: null, // only enrich if not already enriched
},
});
let enriched = 0;
let skipped = 0;
for (const lead of leads) {
if (!lead.domain) { skipped++; continue; }
const text = await fetchWebsiteText(lead.domain);
if (!text) { skipped++; continue; }
const result = await enrichWithAI(text, apiKey);
if (!result) { skipped++; continue; }
await prisma.lead.update({
where: { id: lead.id },
data: {
companyType: result.companyType || null,
topics: result.topics ? JSON.stringify(result.topics) : null,
salesScore: typeof result.salesScore === "number" ? result.salesScore : null,
salesReason: result.salesReason || null,
offerPackage: result.offerPackage || null,
},
});
enriched++;
}
return NextResponse.json({ enriched, skipped, total: leads.length });
} catch (err) {
console.error("POST /api/enrich-leads error:", err);
return NextResponse.json({ error: "Enrichment failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
const EMAIL_PROMPT_TEMPLATE = `Du schreibst eine professionelle Erstansprache-E-Mail für mein-solar.com an einen potenziellen Kunden.
mein-solar.com ist ein Full-Service EPC-Anbieter für Photovoltaik (5 kW bis 10+ MW), Batteriespeicher und Ladeinfrastruktur.
Firmendaten des Empfängers:
- Unternehmen: [companyName]
- Ansprechpartner: [contactName], [contactTitle]
- Unternehmenstyp: [companyType]
- Themen des Unternehmens: [topics]
- Empfohlenes Paket: [offerPackage]
Schreibe eine E-Mail auf Deutsch:
- Betreff: prägnant, bezieht sich auf konkretes Thema des Unternehmens
- Anrede: personalisiert wenn Name bekannt
- 2-3 kurze Absätze: Bezug auf das Unternehmen → Mehrwert von mein-solar → konkreter nächster Schritt (Erstgespräch)
- Ton: professionell, direkt, kein Marketing-Sprech
- Keine Buzzwords wie "innovativ" oder "nachhaltig"
- Signatur: [Vorname] von mein-solar.com
Antworte mit JSON: { "subject": "...", "body": "..." }`;
export async function POST(req: NextRequest) {
try {
const { leadId } = await req.json() as { leadId: string };
if (!leadId) {
return NextResponse.json({ error: "leadId fehlt" }, { status: 400 });
}
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "OpenRouter API Key nicht konfiguriert" }, { status: 500 });
}
const lead = await prisma.lead.findUnique({ where: { id: leadId } });
if (!lead) {
return NextResponse.json({ error: "Lead nicht gefunden" }, { status: 404 });
}
if (!lead.email) {
return NextResponse.json({ error: "Lead hat keine E-Mail-Adresse" }, { status: 400 });
}
let topicsText = "";
if (lead.topics) {
try {
const topicsArr = JSON.parse(lead.topics) as string[];
topicsText = topicsArr.join(", ");
} catch {
topicsText = lead.topics;
}
}
const prompt = EMAIL_PROMPT_TEMPLATE
.replace("[companyName]", lead.companyName || "Unbekannt")
.replace("[contactName]", lead.contactName || "")
.replace("[contactTitle]", lead.contactTitle || "")
.replace("[companyType]", lead.companyType || "")
.replace("[topics]", topicsText || "")
.replace("[offerPackage]", lead.offerPackage || "");
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://mein-solar.com",
"X-Title": "MeinSolar Leads",
},
body: JSON.stringify({
model: "openai/gpt-4o",
temperature: 0.5,
max_tokens: 600,
messages: [
{ role: "user", content: prompt },
],
}),
});
if (!res.ok) {
const err = await res.text();
console.error("[generate-email] OpenRouter error:", err);
return NextResponse.json({ error: "KI-Anfrage fehlgeschlagen" }, { status: 500 });
}
const data = await res.json() as { choices: Array<{ message: { content: string } }> };
const raw = data.choices[0]?.message?.content?.trim() ?? "";
let email: { subject: string; body: string };
try {
email = JSON.parse(raw) as { subject: string; body: string };
} catch {
const match = raw.match(/\{[\s\S]*\}/);
if (!match) {
return NextResponse.json({ error: "KI-Antwort konnte nicht geparst werden" }, { status: 500 });
}
email = JSON.parse(match[0]) as { subject: string; body: string };
}
return NextResponse.json({ subject: email.subject, body: email.body });
} catch (err) {
console.error("POST /api/generate-email error:", err);
return NextResponse.json({ error: "E-Mail-Generierung fehlgeschlagen" }, { status: 500 });
}
}

View File

@@ -18,6 +18,21 @@ export async function GET(
});
if (!job) return NextResponse.json({ error: "Job not found" }, { status: 404 });
// Find which domains already existed in vault before this job
const resultDomains = job.results
.map(r => r.domain)
.filter((d): d is string => !!d);
const preExistingDomains = new Set(
(await prisma.lead.findMany({
where: {
domain: { in: resultDomains },
sourceJobId: { not: id },
},
select: { domain: true },
})).map(l => l.domain).filter((d): d is string => !!d)
);
return NextResponse.json({
id: job.id,
type: job.type,
@@ -49,6 +64,7 @@ export async function GET(
address,
phone,
createdAt: r.createdAt,
isNew: !r.domain || !preExistingDomains.has(r.domain),
};
}),
});

View File

@@ -75,7 +75,11 @@ async function runMapsEnrich(
});
// 2. Store raw Google Maps results + immediately sink to LeadVault
for (const place of places) {
// Skip places without a domain — not actionable as leads
const placesWithDomain = places.filter(p => !!p.domain);
await prisma.job.update({ where: { id: jobId }, data: { totalLeads: placesWithDomain.length } });
for (const place of placesWithDomain) {
await prisma.leadResult.create({
data: {
jobId,
@@ -94,7 +98,7 @@ async function runMapsEnrich(
// Sink Google Maps results to vault immediately (before enrichment)
// so they're available even if Anymailfinder fails
await sinkLeadsToVault(
places.map(p => ({
placesWithDomain.map(p => ({
domain: p.domain,
companyName: p.name || null,
phone: p.phone,
@@ -108,12 +112,18 @@ async function runMapsEnrich(
);
// 3. Optionally enrich with Anymailfinder
if (params.enrichEmails && places.length > 0) {
if (params.enrichEmails && placesWithDomain.length > 0) {
const anymailKey = await getApiKey("anymailfinder");
if (!anymailKey) throw new Error("Anymailfinder API-Key fehlt — bitte in den Einstellungen eintragen");
const domains = places.filter(p => p.domain).map(p => p.domain!);
if (!anymailKey) {
// No key configured — complete with Maps-only results (no emails)
await prisma.job.update({ where: { id: jobId }, data: { status: "complete", totalLeads: placesWithDomain.length } });
return;
}
// Map domain → placeId for updating results
try {
const domains = placesWithDomain.map(p => p.domain!);
// Map domain → leadResult id for updating
const domainToResultId = new Map<string, string>();
const existingResults = await prisma.leadResult.findMany({
where: { jobId },
@@ -155,7 +165,7 @@ async function runMapsEnrich(
await prisma.job.update({
where: { id: jobId },
data: { status: "complete", emailsFound, totalLeads: places.length },
data: { status: "complete", emailsFound, totalLeads: placesWithDomain.length },
});
// Update vault entries with enrichment results
@@ -173,6 +183,14 @@ async function runMapsEnrich(
params.queries.join(", "),
jobId,
);
} catch (enrichErr) {
// Anymailfinder failed (e.g. 402 quota) — complete with Maps-only results
console.warn(`[maps-enrich] Anymailfinder failed for job ${jobId}:`, enrichErr);
await prisma.job.update({
where: { id: jobId },
data: { status: "complete", totalLeads: placesWithDomain.length },
});
}
} else {
await prisma.job.update({
where: { id: jobId },

View File

@@ -97,7 +97,35 @@ async function runSerpEnrich(
data: { totalLeads: domains.length },
});
// 7. Enrich with Anymailfinder Bulk API
// 7. Store raw SERP results first (so we have leads even if enrichment fails)
for (const r of filteredResults) {
await prisma.leadResult.create({
data: {
jobId,
companyName: r.title || null,
domain: r.domain || null,
source: JSON.stringify({ url: r.url, description: r.description, position: r.position }),
},
});
}
// 8. Sink raw results to vault immediately (no contact info yet)
await sinkLeadsToVault(
filteredResults.map(r => ({
domain: r.domain || null,
companyName: r.title || null,
serpTitle: r.title || null,
serpSnippet: r.description || null,
serpRank: r.position ?? null,
serpUrl: r.url || null,
})),
"serp",
params.query,
jobId,
);
// 9. Enrich with Anymailfinder (best-effort — failure still completes the job)
try {
const enrichResults = await bulkSearchDomains(
domains,
params.categories,
@@ -107,40 +135,45 @@ async function runSerpEnrich(
}
);
// 8. Store results
// Build domain → leadResult id map for updating
const domainToResultId = new Map<string, string>();
const storedResults = await prisma.leadResult.findMany({
where: { jobId },
select: { id: true, domain: true },
});
for (const r of storedResults) {
if (r.domain) domainToResultId.set(r.domain, r.id);
}
let emailsFound = 0;
for (const result of enrichResults) {
const serpData = serpMap.get(result.domain || "");
const hasEmail = !!result.valid_email;
if (hasEmail) emailsFound++;
await prisma.leadResult.create({
const resultId = domainToResultId.get(result.domain || "");
if (resultId) {
await prisma.leadResult.update({
where: { id: resultId },
data: {
jobId,
companyName: serpData?.title || null,
domain: result.domain || null,
contactName: result.person_full_name || null,
contactTitle: result.person_job_title || null,
email: result.email || null,
linkedinUrl: result.person_linkedin_url || null,
source: JSON.stringify({
url: serpData?.url,
description: serpData?.description,
position: serpData?.position,
email_status: result.email_status,
}),
},
});
}
}
await prisma.job.update({
where: { id: jobId },
data: { status: "complete", emailsFound, totalLeads: enrichResults.length },
data: { status: "complete", emailsFound, totalLeads: filteredResults.length },
});
// Sync to LeadVault
// Update vault entries with contact info
await sinkLeadsToVault(
enrichResults.map(r => {
enrichResults
.filter(r => r.email)
.map(r => {
const serpData = serpMap.get(r.domain || "");
return {
domain: r.domain || null,
@@ -159,6 +192,14 @@ async function runSerpEnrich(
params.query,
jobId,
);
} catch (enrichErr) {
// Anymailfinder failed — complete with SERP-only results (no emails)
console.warn(`[serp-enrich] Anymailfinder failed for job ${jobId}:`, enrichErr);
await prisma.job.update({
where: { id: jobId },
data: { status: "complete", totalLeads: filteredResults.length },
});
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await prisma.job.update({

View File

@@ -13,6 +13,8 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
"status", "priority", "notes", "tags", "country", "headcount",
"industry", "contactedAt", "companyName", "contactName", "contactTitle",
"email", "phone", "linkedinUrl", "domain",
"companyType", "topics", "salesScore", "salesReason", "offerPackage",
"approved", "approvedAt",
];
const data: Record<string, unknown> = {};

View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
// Deletes vault leads that were created from the given job result IDs.
// Looks up domain from each LeadResult and removes matching Lead records.
export async function POST(req: NextRequest) {
try {
const { resultIds } = await req.json() as { resultIds: string[] };
if (!resultIds?.length) {
return NextResponse.json({ error: "No result IDs provided" }, { status: 400 });
}
// Get domains from the job results
const results = await prisma.leadResult.findMany({
where: { id: { in: resultIds } },
select: { id: true, domain: true, email: true },
});
const domains = results.map(r => r.domain).filter((d): d is string => !!d);
// Delete vault leads with matching domains
let deleted = 0;
if (domains.length > 0) {
const { count } = await prisma.lead.deleteMany({
where: { domain: { in: domains } },
});
deleted = count;
}
return NextResponse.json({ deleted });
} catch (err) {
console.error("POST /api/leads/delete-from-results error:", err);
return NextResponse.json({ error: "Delete failed" }, { status: 500 });
}
}

View File

@@ -23,11 +23,13 @@ export async function GET(req: NextRequest) {
{ email: { contains: search } },
];
}
const approvedOnly = searchParams.get("approvedOnly") === "true";
if (statuses.length > 0) where.status = { in: statuses };
if (sourceTabs.length > 0) where.sourceTab = { in: sourceTabs };
if (priorities.length > 0) where.priority = { in: priorities };
if (hasEmail === "yes" || emailOnly) where.email = { not: null };
else if (hasEmail === "no") where.email = null;
if (approvedOnly) where.approved = true;
const leads = await prisma.lead.findMany({
where,
@@ -47,6 +49,12 @@ export async function GET(req: NextRequest) {
"Branche": l.industry || "",
"Suchbegriff": l.sourceTerm || "",
"Tags": l.tags ? (JSON.parse(l.tags) as string[]).join(", ") : "",
"Unternehmenstyp": l.companyType || "",
"Themen": l.topics ? (JSON.parse(l.topics) as string[]).join(", ") : "",
"Vertriebsrelevanz": l.salesScore?.toString() || "",
"Begründung": l.salesReason || "",
"Angebotspaket": l.offerPackage || "",
"Freigegeben": l.approved ? "Ja" : "Nein",
"Erfasst am": new Date(l.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }),
}));

View File

@@ -22,6 +22,8 @@ export async function GET(req: NextRequest) {
const searchTerms = searchParams.getAll("searchTerm");
const contacted = searchParams.get("contacted");
const favorite = searchParams.get("favorite");
const approvedOnly = searchParams.get("approvedOnly");
const companyType = searchParams.get("companyType");
const where: Prisma.LeadWhereInput = {};
@@ -61,6 +63,9 @@ export async function GET(req: NextRequest) {
where.tags = { contains: "favorit" };
}
if (approvedOnly === "true") where.approved = true;
if (companyType) where.companyType = companyType;
if (tags.length > 0) {
// SQLite JSON contains — search for each tag in the JSON string
where.AND = tags.map(tag => ({
@@ -74,7 +79,7 @@ export async function GET(req: NextRequest) {
};
const orderByField = validSortFields[sortBy] ? sortBy : "capturedAt";
const [total, leads] = await Promise.all([
const [total, leads, rawCount] = await Promise.all([
prisma.lead.count({ where }),
prisma.lead.findMany({
where,
@@ -82,8 +87,11 @@ export async function GET(req: NextRequest) {
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.lead.count(),
]);
console.log(`[leads API] rawCount=${rawCount} total=${total} leads=${leads.length} where=${JSON.stringify(where)}`);
return NextResponse.json({
leads,
total,

View File

@@ -3,12 +3,12 @@ import { prisma } from "@/lib/db";
export async function GET() {
try {
const [total, newLeads, contacted, withEmail] = await Promise.all([
const [total, contacted, withEmail] = await Promise.all([
prisma.lead.count(),
prisma.lead.count({ where: { status: "new" } }),
prisma.lead.count({ where: { status: { in: ["contacted", "in_progress"] } } }),
prisma.lead.count({ where: { tags: { contains: "kontaktiert" } } }),
prisma.lead.count({ where: { email: { not: null } } }),
]);
const newLeads = total - contacted;
// Daily counts for last 7 days
const sevenDaysAgo = new Date();

View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const mode = searchParams.get("mode") || "";
const where = mode ? { searchMode: mode } : {};
const history = await prisma.searchHistory.findMany({
where,
orderBy: { executedAt: "desc" },
take: 50,
select: { query: true, region: true, searchMode: true, executedAt: true },
});
return NextResponse.json(history);
} catch (err) {
console.error("GET /api/search-history error:", err);
return NextResponse.json({ error: "Failed to fetch search history" }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json() as { query: string; region: string; searchMode: string };
const { query, region, searchMode } = body;
if (!query?.trim() || !searchMode?.trim()) {
return NextResponse.json({ error: "query und searchMode sind erforderlich" }, { status: 400 });
}
const entry = await prisma.searchHistory.create({
data: {
query: query.trim(),
region: (region || "").trim(),
searchMode: searchMode.trim(),
},
});
return NextResponse.json(entry);
} catch (err) {
console.error("POST /api/search-history error:", err);
return NextResponse.json({ error: "Failed to save search history" }, { status: 500 });
}
}

40
app/api/search/route.ts Normal file
View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
try {
const body = await req.json() as { query: string; region: string; count: number };
const { query, region, count } = body;
if (!query || typeof query !== "string") {
return NextResponse.json({ error: "Suchbegriff fehlt" }, { status: 400 });
}
const searchQuery = region ? `${query} ${region}` : query;
const base = `http://localhost:${process.env.PORT || 3000}`;
// ── 1. Maps job (always, max 60) ──────────────────────────────────────────
const mapsRes = await fetch(`${base}/api/jobs/maps-enrich`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
queries: [searchQuery],
maxResultsPerQuery: Math.min(count, 60),
languageCode: "de",
categories: ["ceo"],
enrichEmails: true,
}),
});
if (!mapsRes.ok) {
const err = await mapsRes.json() as { error?: string };
return NextResponse.json({ error: err.error || "Suche konnte nicht gestartet werden" }, { status: 500 });
}
const { jobId } = await mapsRes.json() as { jobId: string };
return NextResponse.json({ jobId });
} catch (err) {
console.error("POST /api/search error:", err);
return NextResponse.json({ error: "Suche konnte nicht gestartet werden" }, { status: 500 });
}
}

View File

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import { getApiKey } from "@/lib/utils/apiKey";
import { generateSupplementQuery } from "@/lib/services/openai";
export async function POST(req: NextRequest) {
try {
const body = await req.json() as {
query: string;
region: string;
targetCount: number;
foundCount: number;
};
const { query, region, targetCount, foundCount } = body;
const base = `http://localhost:${process.env.PORT || 3000}`;
const deficit = targetCount - foundCount;
// 1. Try to generate an optimized query via GPT-4.1
let optimizedQuery = region ? `${query} ${region}` : query;
let usedAI = false;
const openaiKey = await getApiKey("openai");
if (openaiKey) {
const aiQuery = await generateSupplementQuery(query, region, foundCount, targetCount, openaiKey);
if (aiQuery) {
optimizedQuery = aiQuery;
usedAI = true;
}
}
// 2. Start SERP job with the (possibly optimized) query
const maxPages = Math.min(Math.max(1, Math.ceil(deficit / 10)), 3);
const serpRes = await fetch(`${base}/api/jobs/serp-enrich`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: optimizedQuery,
maxPages,
countryCode: "de",
languageCode: "de",
filterSocial: true,
categories: ["ceo"],
enrichEmails: true,
}),
});
if (!serpRes.ok) {
const err = await serpRes.json() as { error?: string };
return NextResponse.json({ error: err.error || "SERP job konnte nicht gestartet werden" }, { status: 500 });
}
const { jobId } = await serpRes.json() as { jobId: string };
return NextResponse.json({ jobId, optimizedQuery, usedAI });
} catch (err) {
console.error("POST /api/search/supplement error:", err);
return NextResponse.json({ error: "Supplement-Suche fehlgeschlagen" }, { status: 500 });
}
}

View File

@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { ALL_CITIES } from "@/lib/data/stadtwerke-cities";
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || "";
const OPENROUTER_BASE = "https://openrouter.ai/api/v1";
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const count = Math.min(parseInt(searchParams.get("count") || "20", 10), 100);
// Get all regions already used for stadtwerke mode
const used = await prisma.searchHistory.findMany({
where: { searchMode: "stadtwerke" },
select: { region: true },
});
const usedRegions = new Set(used.map(u => u.region.toLowerCase().trim()));
// Filter static list — cities not yet searched
const remaining = ALL_CITIES.filter(city => !usedRegions.has(city.toLowerCase().trim()));
if (remaining.length === 0) {
// All static cities exhausted — ask AI for new suggestions
const newCities = await generateNewCities(usedRegions);
return NextResponse.json({ cities: newCities.slice(0, count), exhausted: true, totalRemaining: newCities.length });
}
return NextResponse.json({
cities: remaining.slice(0, count),
exhausted: false,
totalRemaining: remaining.length,
});
} catch (err) {
console.error("GET /api/stadtwerke-cities error:", err);
return NextResponse.json({ error: "Failed to fetch cities" }, { status: 500 });
}
}
async function generateNewCities(usedRegions: Set<string>): Promise<string[]> {
if (!OPENROUTER_API_KEY) return [];
const usedList = Array.from(usedRegions)
.filter(r => r.length > 0)
.slice(0, 150)
.join(", ");
try {
const res = await fetch(`${OPENROUTER_BASE}/chat/completions`, {
method: "POST",
headers: {
"Authorization": `Bearer ${OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://mein-solar.de",
},
body: JSON.stringify({
model: "openai/gpt-4o-mini",
messages: [
{
role: "system",
content:
"Du bist ein Experte für kommunale Energieversorger in Deutschland. Antworte ausschließlich mit einem gültigen JSON-Array aus Städtenamen, ohne Erklärungen.",
},
{
role: "user",
content: `Generiere 40 deutsche Städte und Gemeinden, in denen es lokale Stadtwerke oder kommunale Energieversorger gibt. Keine dieser Städte darf in der folgenden Liste enthalten sein:\n${usedList}\n\nGib nur ein JSON-Array zurück, z.B.: ["Landsberg am Lech", "Bühl", "Leutkirch"]`,
},
],
max_tokens: 600,
temperature: 0.7,
}),
});
const data = await res.json() as { choices?: Array<{ message?: { content?: string } }> };
const content = data.choices?.[0]?.message?.content?.trim() || "[]";
// Strip markdown code blocks if present
const cleaned = content.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim();
const cities = JSON.parse(cleaned) as unknown;
return Array.isArray(cities) ? (cities as string[]).filter(c => typeof c === "string") : [];
} catch {
return [];
}
}

View File

@@ -1,21 +1,80 @@
@import "tailwindcss";
@theme {
/* Stitch Solar Design System Colors */
--color-background: #0d1419;
--color-surface: #0d1419;
--color-surface-dim: #0d1419;
--color-surface-container-lowest: #080f13;
--color-surface-container-low: #161c21;
--color-surface-container: #1a2025;
--color-surface-container-high: #242b30;
--color-surface-container-highest: #2f363b;
--color-surface-bright: #333a3f;
--color-surface-variant: #2f363b;
--color-surface-tint: #adc7ff;
--color-primary: #adc7ff;
--color-primary-container: #1a73e8;
--color-primary-fixed: #d8e2ff;
--color-primary-fixed-dim: #adc7ff;
--color-on-primary: #002e68;
--color-on-primary-container: #ffffff;
--color-on-primary-fixed: #001a41;
--color-on-primary-fixed-variant: #004493;
--color-secondary: #ffb77b;
--color-secondary-container: #fb8c00;
--color-secondary-fixed: #ffdcc2;
--color-secondary-fixed-dim: #ffb77b;
--color-on-secondary: #4c2700;
--color-on-secondary-container: #5f3200;
--color-on-secondary-fixed: #2e1500;
--color-on-secondary-fixed-variant: #6d3a00;
--color-tertiary: #a0d82c;
--color-tertiary-container: #5c8200;
--color-tertiary-fixed: #baf549;
--color-tertiary-fixed-dim: #a0d82c;
--color-on-tertiary: #243600;
--color-on-tertiary-container: #ffffff;
--color-on-tertiary-fixed: #131f00;
--color-on-tertiary-fixed-variant: #364e00;
--color-on-surface: #dce3ea;
--color-on-surface-variant: #c1c6d6;
--color-on-background: #dce3ea;
--color-outline: #8b909f;
--color-outline-variant: #414754;
--color-error: #ffb4ab;
--color-error-container: #93000a;
--color-on-error: #690005;
--color-on-error-container: #ffdad6;
--color-inverse-surface: #dce3ea;
--color-inverse-on-surface: #2a3136;
--color-inverse-primary: #005bc0;
/* Font families */
--font-headline: 'Manrope', sans-serif;
--font-body: 'Inter', sans-serif;
--font-label: 'Inter', sans-serif;
}
button { cursor: pointer; }
button:disabled { cursor: not-allowed; }
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
:root {
--background: #0a0a0f;
--card: #111118;
--border: #1e1e2e;
--primary: #3b82f6;
--secondary: #8b5cf6;
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
--foreground: #f0f0f5;
--muted: #6b7280;
--background: #0d1419;
--card: #161c21;
--border: #414754;
--primary: #adc7ff;
--secondary: #fb8c00;
--success: #a0d82c;
--warning: #ffb77b;
--error: #ffb4ab;
--foreground: #dce3ea;
--muted: #c1c6d6;
}
* {
@@ -23,13 +82,35 @@
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-inter), Inter, system-ui, -apple-system, sans-serif;
background-color: #0d1419;
color: #dce3ea;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Manrope', sans-serif;
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
font-family: 'Material Symbols Outlined';
font-style: normal;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: #0a0a0f; }
::-webkit-scrollbar-thumb { background: #1e1e2e; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #3b82f6; }
::-webkit-scrollbar-track { background: #0d1419; }
::-webkit-scrollbar-thumb { background: #414754; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #adc7ff; }
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }

View File

@@ -1,30 +1,29 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Sidebar } from "@/components/layout/Sidebar";
import { TopBar } from "@/components/layout/TopBar";
import { Topbar } from "@/components/layout/TopBar";
import { Toaster } from "@/components/ui/sonner";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
export const metadata: Metadata = {
title: "OnyvaLeads — Lead Generation Platform",
description: "Unified lead generation and email enrichment platform",
title: "OnyvaLeads",
description: "Leads finden und verwalten",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<body className={`${inter.variable} antialiased`}>
<div className="flex h-screen overflow-hidden bg-[#0a0a0f]">
<Sidebar />
<div className="flex flex-col flex-1 overflow-hidden">
<TopBar />
<main className="flex-1 overflow-y-auto p-6">
<html lang="de" className="dark">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@200;400;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet" />
</head>
<body className="antialiased">
<div className="flex flex-col min-h-screen" style={{ background: "#0d1419" }}>
<Topbar />
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
</div>
<Toaster position="bottom-right" theme="dark" />
</body>
</html>

1088
app/leadspeicher/page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/airscale");
redirect("/suche");
}

850
app/suche/page.tsx Normal file
View File

@@ -0,0 +1,850 @@
"use client";
import { useState, useCallback } from "react";
import Link from "next/link";
import { toast } from "sonner";
import { LoadingCard, type LeadResult } from "@/components/search/LoadingCard";
import { AiSearchModal } from "@/components/search/AiSearchModal";
export default function SuchePage() {
const [query, setQuery] = useState("");
const [region, setRegion] = useState("");
const [count, setCount] = useState(50);
const [loading, setLoading] = useState(false);
const [jobId, setJobId] = useState<string | null>(null);
const [leads, setLeads] = useState<LeadResult[]>([]);
const [searchDone, setSearchDone] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [deleting, setDeleting] = useState(false);
const [onlyNew, setOnlyNew] = useState(false);
const [saveOnlyNew, setSaveOnlyNew] = useState(false);
const [aiOpen, setAiOpen] = useState(false);
const [queueRunning, setQueueRunning] = useState(false);
const [queueIndex, setQueueIndex] = useState(0);
const [queueTotal, setQueueTotal] = useState(0);
const [queueLabel, setQueueLabel] = useState("");
const [industrieRunning, setIndustrieRunning] = useState(false);
const [industrieIndex, setIndustrieIndex] = useState(0);
const [industrieTotal, setIndustrieTotal] = useState(0);
const [industrieLabel, setIndustrieLabel] = useState("");
const INDUSTRIE_TERMS = ["Netzbetreiber", "Fernwärme", "Industriepark"];
const BUNDESLAENDER = [
"Bayern", "NRW", "Baden-Württemberg", "Hessen", "Niedersachsen",
"Sachsen", "Berlin", "Hamburg", "Bremen", "Thüringen",
"Sachsen-Anhalt", "Brandenburg", "Mecklenburg-Vorpommern",
"Saarland", "Rheinland-Pfalz", "Schleswig-Holstein",
];
function handleChange(field: "query" | "region" | "count", value: string | number) {
if (field === "query") setQuery(value as string);
if (field === "region") setRegion(value as string);
if (field === "count") setCount(value as number);
}
async function handleSubmit() {
if (!query.trim() || loading) return;
setLoading(true);
setJobId(null);
setLeads([]);
setSearchDone(false);
setSelected(new Set());
setOnlyNew(false);
// saveOnlyNew intentionally kept — user setting persists across searches
try {
const res = await fetch("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: query.trim(), region: region.trim(), count }),
});
if (!res.ok) {
const err = await res.json() as { error?: string };
throw new Error(err.error || "Fehler beim Starten der Suche");
}
const data = await res.json() as { jobId: string };
setJobId(data.jobId);
} catch (err) {
const msg = err instanceof Error ? err.message : "Unbekannter Fehler";
toast.error(msg);
setLoading(false);
}
}
const handleDone = useCallback((result: LeadResult[], warning?: string) => {
setLoading(false);
setLeads(result);
setSearchDone(true);
// Fire-and-forget KI-Anreicherung
if (jobId) {
fetch("/api/enrich-leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jobId }),
}).catch(() => {});
}
if (warning) {
toast.warning(`${result.length} Unternehmen gefunden — E-Mail-Anreicherung fehlgeschlagen: ${warning}`, { duration: 6000 });
} else {
toast.success(`${result.length} Leads gefunden und im Leadspeicher gespeichert`, { duration: 4000 });
}
}, [jobId]);
async function runSearchAndWait(query: string, region: string, historyMode = "stadtwerke") {
try {
const res = await fetch("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, region, count: 50 }),
});
const data = await res.json() as { jobId?: string };
if (!data.jobId) return;
// Save to history
fetch("/api/search-history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, region, searchMode: historyMode }),
}).catch(() => {});
// Poll until done
await new Promise<void>((resolve) => {
const interval = setInterval(async () => {
try {
const statusRes = await fetch(`/api/jobs/${data.jobId}/status`);
const status = await statusRes.json() as { status: string };
if (status.status === "complete" || status.status === "failed") {
clearInterval(interval);
resolve();
}
} catch { clearInterval(interval); resolve(); }
}, 3000);
});
} catch { /* continue with next */ }
}
async function startStadtwerkeQueue() {
if (loading || queueRunning) return;
setQueueRunning(true);
setQueueIndex(0);
setLeads([]);
setSearchDone(false);
// Fetch already-used regions to skip Bundesländer already done
let usedRegions = new Set<string>();
try {
const hRes = await fetch("/api/search-history?mode=stadtwerke");
if (hRes.ok) {
const hist = await hRes.json() as Array<{ region: string }>;
usedRegions = new Set(hist.map(h => h.region));
}
} catch { /* ignore */ }
// Phase 1: Unused Bundesländer
const unusedBL = BUNDESLAENDER.filter(bl => !usedRegions.has(bl));
// Phase 2: Next batch of unused cities
let cities: string[] = [];
try {
const cRes = await fetch("/api/stadtwerke-cities?count=50");
if (cRes.ok) {
const cData = await cRes.json() as { cities: string[]; exhausted: boolean };
cities = cData.cities;
if (cData.exhausted) {
toast.info("Alle vordefinierten Städte durchsucht — KI generiert neue Vorschläge", { duration: 4000 });
}
}
} catch { /* ignore */ }
const allTargets = [
...unusedBL.map(bl => ({ label: `Bundesland: ${bl}`, query: "Stadtwerke", region: bl })),
...cities.map(city => ({ label: `Stadt: ${city}`, query: "Stadtwerke", region: city })),
];
if (allTargets.length === 0) {
setQueueRunning(false);
toast.info("Alle bekannten Regionen wurden bereits durchsucht", { duration: 4000 });
return;
}
setQueueTotal(allTargets.length);
for (let i = 0; i < allTargets.length; i++) {
const target = allTargets[i];
setQueueIndex(i + 1);
setQueueLabel(target.label);
await runSearchAndWait(target.query, target.region);
}
setQueueRunning(false);
const blCount = unusedBL.length;
const cityCount = cities.length;
const parts = [];
if (blCount > 0) parts.push(`${blCount} Bundesländer`);
if (cityCount > 0) parts.push(`${cityCount} Städte`);
toast.success(`${parts.join(" + ")} durchsucht — Leads im Leadspeicher`, { duration: 5000 });
}
async function startIndustrieQueue() {
if (loading || queueRunning || industrieRunning) return;
setIndustrieRunning(true);
setIndustrieIndex(0);
// Load already-searched [term::location] combos
let usedKeys = new Set<string>();
try {
const hRes = await fetch("/api/search-history?mode=industrie");
if (hRes.ok) {
const hist = await hRes.json() as Array<{ region: string }>;
usedKeys = new Set(hist.map(h => h.region));
}
} catch { /* ignore */ }
// Cities data (inline priority order)
const BW_CITIES = [
"Stuttgart","Karlsruhe","Mannheim","Freiburg","Heidelberg","Ulm","Heilbronn","Pforzheim","Reutlingen","Ludwigsburg",
"Esslingen","Tübingen","Villingen-Schwenningen","Konstanz","Aalen","Friedrichshafen","Sindelfingen","Ravensburg","Offenburg","Göppingen",
"Böblingen","Schwäbisch Gmünd","Lahr","Waiblingen","Baden-Baden","Bruchsal","Weinheim","Leonberg","Bietigheim-Bissingen","Heidenheim",
"Schwäbisch Hall","Nagold","Singen","Nürtingen","Fellbach","Tuttlingen","Überlingen","Backnang","Ditzingen","Kirchheim",
"Schorndorf","Filderstadt","Leinfelden-Echterdingen","Ettlingen","Weil am Rhein","Rottenburg","Rheinfelden","Leutkirch","Mosbach","Crailsheim",
];
const BAYERN_CITIES = [
"München","Nürnberg","Augsburg","Regensburg","Ingolstadt","Würzburg","Fürth","Erlangen","Bayreuth","Landshut",
"Rosenheim","Kempten","Bamberg","Aschaffenburg","Neu-Ulm","Schweinfurt","Ansbach","Straubing","Passau","Coburg",
"Dachau","Freising","Germering","Memmingen","Kaufbeuren","Hof","Amberg","Weiden","Pfaffenhofen","Starnberg",
"Traunreut","Gauting","Garching","Erding","Fürstenfeldbruck","Unterschleißheim","Waldkraiburg","Marktoberdorf","Neumarkt","Altötting",
"Weißenburg","Schwabach","Deggendorf","Traunstein","Burghausen","Bad Reichenhall","Neuburg an der Donau","Kelheim","Dillingen","Günzburg",
];
const OTHER_BL = ["NRW","Hessen","Niedersachsen","Sachsen","Berlin","Hamburg","Bremen","Thüringen","Sachsen-Anhalt","Brandenburg","Mecklenburg-Vorpommern","Saarland","Rheinland-Pfalz","Schleswig-Holstein"];
// Build priority targets: BW + Bayern first, then rest
const priorityLocations = [
"Baden-Württemberg",
...BW_CITIES,
"Bayern",
...BAYERN_CITIES,
];
const restLocations = OTHER_BL;
// All [term, location] combos in priority order
const allTargets: Array<{ label: string; term: string; location: string }> = [];
for (const loc of [...priorityLocations, ...restLocations]) {
for (const term of INDUSTRIE_TERMS) {
const key = `${term}::${loc}`;
if (!usedKeys.has(key)) {
allTargets.push({ label: `${term} · ${loc}`, term, location: loc });
}
}
}
if (allTargets.length === 0) {
setIndustrieRunning(false);
toast.info("Alle Energieversorger-Suchen wurden bereits durchgeführt", { duration: 4000 });
return;
}
setIndustrieTotal(allTargets.length);
for (let i = 0; i < allTargets.length; i++) {
const t = allTargets[i];
setIndustrieIndex(i + 1);
setIndustrieLabel(t.label);
await runSearchAndWait(t.term, t.location, "industrie");
// Override the region saved in history with the key so we can track term+location combos
fetch("/api/search-history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: t.term, region: `${t.term}::${t.location}`, searchMode: "industrie" }),
}).catch(() => {});
}
setIndustrieRunning(false);
toast.success(`${allTargets.length} Energieversorger-Suchen abgeschlossen — Leads im Leadspeicher`, { duration: 5000 });
}
async function handleDelete(ids: string[]) {
if (!ids.length || deleting) return;
setDeleting(true);
try {
await fetch("/api/leads/delete-from-results", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resultIds: ids }),
});
setLeads(prev => prev.filter(l => !ids.includes(l.id)));
setSelected(prev => {
const next = new Set(prev);
ids.forEach(id => next.delete(id));
return next;
});
toast.success(`${ids.length} Lead${ids.length > 1 ? "s" : ""} gelöscht`);
} catch {
toast.error("Löschen fehlgeschlagen");
} finally {
setDeleting(false);
}
}
async function handleSaveOnlyNew() {
const existingIds = leads.filter(l => !l.isNew).map(l => l.id);
if (!existingIds.length || deleting) return;
setDeleting(true);
try {
await fetch("/api/leads/delete-from-results", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resultIds: existingIds }),
});
setSaveOnlyNew(true);
const newCount = leads.filter(l => l.isNew).length;
toast.success(`${newCount} neue Leads behalten, ${existingIds.length} vorhandene aus Leadspeicher entfernt`, { duration: 5000 });
} catch {
toast.error("Fehler beim Bereinigen");
} finally {
setDeleting(false);
}
}
const handleError = useCallback((message: string) => {
setLoading(false);
setJobId(null);
toast.error(`Suche fehlgeschlagen: ${message}`);
}, []);
return (
<div className="pb-12 px-8 max-w-7xl mx-auto pt-8 space-y-10">
<style>{`
@keyframes spin { to { transform: rotate(360deg) } }
.del-btn:hover { opacity: 1 !important; }
.lead-row:hover { background: rgba(255,255,255,0.02); }
.lead-row:hover .row-del { opacity: 1 !important; }
`}</style>
{/* ── Strategische Kampagnen ── */}
<section>
<h2 className="text-2xl font-extrabold tracking-tight mb-6" style={{ fontFamily: "Manrope, sans-serif", color: "#dce3ea" }}>
Strategische Energiewirtschafts-Kampagnen
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* CTA Card — Stadtwerke */}
<div className="rounded-xl p-8 relative overflow-hidden flex flex-col justify-center" style={{ background: "#161c21", border: "1px solid rgba(255,255,255,0.05)" }}>
<div className="absolute -top-24 -right-24 w-64 h-64 rounded-full pointer-events-none" style={{ background: "rgba(26,115,232,0.2)", filter: "blur(100px)" }} />
<div className="relative z-10">
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "#c1c6d6", fontFamily: "Inter, sans-serif", letterSpacing: "0.1em" }}>Hauptkampagne</p>
<h3 className="text-3xl font-extrabold mb-6 leading-tight" style={{ fontFamily: "Manrope, sans-serif" }}>
Bundesweite<br />Lead-Gewinnung
</h3>
<p className="text-sm leading-relaxed mb-8 max-w-md" style={{ color: "#c1c6d6" }}>
Startet sofort den Deep-Scrape aller Stadtwerke in allen deutschen Bundesländern.
</p>
{queueRunning ? (
<div className="w-full py-4 rounded-xl flex items-center justify-center gap-3" style={{ background: "#242b30" }}>
<svg style={{ animation: "spin 1s linear infinite", flexShrink: 0 }} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#adc7ff" strokeWidth="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<div>
<div className="font-bold text-sm" style={{ color: "#adc7ff", fontFamily: "Manrope, sans-serif" }}>{queueLabel || "Wird durchsucht…"} ({queueIndex}/{queueTotal})</div>
<div className="text-xs mt-0.5" style={{ color: "#c1c6d6" }}>Leads werden automatisch gespeichert</div>
</div>
</div>
) : (
<button
onClick={startStadtwerkeQueue}
disabled={loading}
className="w-full py-5 rounded-xl font-black text-lg text-white transition-all active:scale-[0.98] disabled:opacity-50"
style={{
fontFamily: "Manrope, sans-serif",
background: "linear-gradient(135deg, #adc7ff, #1a73e8)",
boxShadow: "0 10px 30px rgba(26,115,232,0.4)",
}}
onMouseEnter={e => { e.currentTarget.style.boxShadow = "0 15px 40px rgba(26,115,232,0.6)"; }}
onMouseLeave={e => { e.currentTarget.style.boxShadow = "0 10px 30px rgba(26,115,232,0.4)"; }}
>
START 16 BUNDESLÄNDER STADTWERKE-SUCHE (1-Klick)
</button>
)}
</div>
</div>
{/* CTA Card — Energieversorger */}
<div className="rounded-xl p-8 relative overflow-hidden flex flex-col justify-center" style={{ background: "#161c21", border: "1px solid rgba(255,255,255,0.05)" }}>
<div className="absolute -top-24 -right-24 w-64 h-64 rounded-full pointer-events-none" style={{ background: "rgba(160,216,44,0.12)", filter: "blur(100px)" }} />
<div className="relative z-10">
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "#c1c6d6", fontFamily: "Inter, sans-serif", letterSpacing: "0.1em" }}>Energieversorger-Kampagne</p>
<h3 className="text-3xl font-extrabold mb-6 leading-tight" style={{ fontFamily: "Manrope, sans-serif" }}>
Industrie &amp;<br />Energieversorger
</h3>
<p className="text-sm leading-relaxed mb-8 max-w-md" style={{ color: "#c1c6d6" }}>
Sucht nach <strong style={{ color: "#a0d82c" }}>Netzbetreiber</strong>, <strong style={{ color: "#a0d82c" }}>Fernwärme</strong> und <strong style={{ color: "#a0d82c" }}>Industriepark</strong> priorisiert BW &amp; Bayern, dann alle weiteren Bundesländer.
</p>
{industrieRunning ? (
<div className="w-full py-4 rounded-xl flex items-center justify-center gap-3" style={{ background: "#242b30" }}>
<svg style={{ animation: "spin 1s linear infinite", flexShrink: 0 }} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a0d82c" strokeWidth="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<div>
<div className="font-bold text-sm" style={{ color: "#a0d82c", fontFamily: "Manrope, sans-serif" }}>{industrieLabel || "Wird durchsucht…"} ({industrieIndex}/{industrieTotal})</div>
<div className="text-xs mt-0.5" style={{ color: "#c1c6d6" }}>Leads werden automatisch gespeichert</div>
</div>
</div>
) : (
<button
onClick={startIndustrieQueue}
disabled={loading || queueRunning}
className="w-full py-5 rounded-xl font-black text-lg transition-all active:scale-[0.98] disabled:opacity-50"
style={{
fontFamily: "Manrope, sans-serif",
background: "linear-gradient(135deg, #a0d82c, #5c8200)",
color: "#131f00",
boxShadow: "0 10px 30px rgba(160,216,44,0.3)",
}}
onMouseEnter={e => { e.currentTarget.style.boxShadow = "0 15px 40px rgba(160,216,44,0.5)"; }}
onMouseLeave={e => { e.currentTarget.style.boxShadow = "0 10px 30px rgba(160,216,44,0.3)"; }}
>
START ENERGIEVERSORGER-SUCHE
</button>
)}
</div>
</div>
</div>
</section>
{/* ── Manuelle Suche ── */}
<section>
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-end gap-3 mb-6">
<div>
<h2 className="text-2xl font-extrabold tracking-tight" style={{ fontFamily: "Manrope, sans-serif", color: "#dce3ea" }}>
Manuelle Lead- &amp; Nischensuche
</h2>
<p className="text-sm mt-1" style={{ color: "#c1c6d6" }}>Verfeinern Sie Ihre Zielparameter für eine hochspezifische Extraktion.</p>
</div>
<button
onClick={() => setAiOpen(true)}
disabled={loading}
className="flex items-center gap-2 px-3 py-2 md:px-4 rounded-xl transition-all disabled:opacity-50 flex-shrink-0"
style={{ background: "#161c21", border: "1px solid rgba(173,199,255,0.2)", boxShadow: "0 0 20px rgba(26,115,232,0.1)" }}
>
<span className="material-symbols-outlined text-xl" style={{ color: "#adc7ff", fontVariationSettings: "'FILL' 1" }}>smart_toy</span>
<span className="hidden sm:inline font-bold text-sm uppercase tracking-wider" style={{ fontFamily: "Manrope, sans-serif", color: "#adc7ff" }}>KI-gestützte Suche</span>
</button>
</div>
<div className="rounded-2xl p-8 shadow-2xl" style={{ background: "#161c21", border: "1px solid rgba(255,255,255,0.05)" }}>
{/* Form fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-12 gap-4 md:gap-6 items-end">
<div className="md:col-span-5 space-y-2">
<label className="block text-[10px] font-bold uppercase tracking-widest ml-1" style={{ color: "#c1c6d6" }}>Suchbegriff</label>
<div className="relative">
<input
className="w-full rounded-xl px-4 py-4 text-sm transition-all outline-none"
style={{ background: "#080f13", border: "1px solid rgba(65,71,84,0.4)", color: "#dce3ea" }}
value={query}
onChange={e => handleChange("query", e.target.value)}
onKeyDown={e => { if (e.key === "Enter" && query.trim() && !loading) handleSubmit(); }}
placeholder="z.B. Stadtwerke"
onFocus={e => { e.currentTarget.style.borderColor = "#adc7ff"; e.currentTarget.style.boxShadow = "0 0 0 2px rgba(173,199,255,0.2)"; }}
onBlur={e => { e.currentTarget.style.borderColor = "rgba(65,71,84,0.4)"; e.currentTarget.style.boxShadow = "none"; }}
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 material-symbols-outlined" style={{ color: "#8b909f", fontSize: 20 }}>search</span>
</div>
</div>
<div className="md:col-span-4 space-y-2">
<label className="block text-[10px] font-bold uppercase tracking-widest ml-1" style={{ color: "#c1c6d6" }}>Region</label>
<div className="relative">
<input
className="w-full rounded-xl px-4 py-4 text-sm transition-all outline-none"
style={{ background: "#080f13", border: "1px solid rgba(65,71,84,0.4)", color: "#dce3ea" }}
value={region}
onChange={e => handleChange("region", e.target.value)}
onKeyDown={e => { if (e.key === "Enter" && query.trim() && !loading) handleSubmit(); }}
placeholder="z.B. Bayern"
onFocus={e => { e.currentTarget.style.borderColor = "#adc7ff"; e.currentTarget.style.boxShadow = "0 0 0 2px rgba(173,199,255,0.2)"; }}
onBlur={e => { e.currentTarget.style.borderColor = "rgba(65,71,84,0.4)"; e.currentTarget.style.boxShadow = "none"; }}
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 material-symbols-outlined" style={{ color: "#8b909f", fontSize: 20 }}>location_on</span>
</div>
</div>
<div className="md:col-span-2 space-y-2">
<label className="block text-[10px] font-bold uppercase tracking-widest ml-1" style={{ color: "#c1c6d6" }}>Anzahl</label>
<select
className="w-full rounded-xl px-4 py-4 text-sm outline-none appearance-none"
style={{ background: "#080f13", border: "1px solid rgba(65,71,84,0.4)", color: "#dce3ea" }}
value={count}
onChange={e => handleChange("count", Number(e.target.value))}
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
<div className="sm:col-span-2 md:col-span-1">
<button
onClick={handleSubmit}
disabled={!query.trim() || loading || queueRunning}
className="w-full h-[58px] rounded-xl flex items-center justify-center gap-2 transition-all active:scale-95 disabled:opacity-50"
style={{ background: "#333a3f" }}
onMouseEnter={e => { if (!e.currentTarget.disabled) { e.currentTarget.style.background = "#1a73e8"; } }}
onMouseLeave={e => { e.currentTarget.style.background = "#333a3f"; }}
>
<span className="material-symbols-outlined" style={{ color: "#dce3ea" }}>bolt</span>
<span className="md:hidden text-sm font-bold" style={{ color: "#dce3ea", fontFamily: "Manrope, sans-serif" }}>Suchen</span>
</button>
</div>
</div>
{/* Quick Presets */}
<div className="mt-8 pt-8" style={{ borderTop: "1px solid rgba(255,255,255,0.05)" }}>
<p className="text-[10px] font-bold uppercase tracking-widest mb-4" style={{ color: "#c1c6d6" }}>Quick Presets</p>
<div className="flex flex-wrap gap-3">
{["Stadtwerke", "Energieversorger", "Industrie-Energie", "Gemeindewerke", "Netzbetreiber"].map(preset => (
<button
key={preset}
onClick={() => handleChange("query", preset)}
className="px-5 py-2 rounded-full text-sm font-medium transition-all"
style={{ background: "rgba(47,54,59,0.5)", border: "1px solid rgba(255,255,255,0.05)", color: "#dce3ea" }}
onMouseEnter={e => { e.currentTarget.style.background = "rgba(26,115,232,0.2)"; e.currentTarget.style.borderColor = "rgba(173,199,255,0.3)"; }}
onMouseLeave={e => { e.currentTarget.style.background = "rgba(47,54,59,0.5)"; e.currentTarget.style.borderColor = "rgba(255,255,255,0.05)"; }}
>
{preset}
</button>
))}
</div>
</div>
</div>
</section>
{/* Loading Card */}
{loading && jobId && (
<LoadingCard
jobId={jobId}
targetCount={count}
query={query}
region={region}
onDone={handleDone}
onError={handleError}
/>
)}
{/* AI Modal */}
{aiOpen && (
<AiSearchModal
searchMode="custom"
onStart={(queries) => {
setAiOpen(false);
if (!queries.length) return;
const first = queries[0];
setQuery(first.query);
setRegion(first.region);
setCount(first.count);
setLoading(true);
setJobId(null);
setLeads([]);
setSearchDone(false);
setSelected(new Set());
// Save to history
fetch("/api/search-history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: first.query, region: first.region, searchMode: "custom" }),
}).catch(() => {});
fetch("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: first.query, region: first.region, count: first.count }),
})
.then(r => r.json())
.then((d: { jobId?: string; error?: string }) => {
if (d.jobId) setJobId(d.jobId);
else throw new Error(d.error);
})
.catch(err => {
toast.error(err instanceof Error ? err.message : "Fehler");
setLoading(false);
});
}}
onClose={() => setAiOpen(false)}
/>
)}
{/* Results */}
{searchDone && leads.length > 0 && (() => {
const newCount = leads.filter(l => l.isNew).length;
const visibleLeads = onlyNew ? leads.filter(l => l.isNew) : leads;
const allVisibleSelected = visibleLeads.length > 0 && visibleLeads.every(l => selected.has(l.id));
const someVisibleSelected = visibleLeads.some(l => selected.has(l.id));
const selectedVisible = visibleLeads.filter(l => selected.has(l.id));
return (
<div
style={{
background: "#161c21",
border: "1px solid rgba(65,71,84,0.3)",
borderRadius: 12,
overflow: "hidden",
}}
>
<style>{`
.del-btn:hover { opacity: 1 !important; }
.lead-row:hover { background: rgba(255,255,255,0.02); }
.lead-row:hover .row-del { opacity: 1 !important; }
.filter-pill { transition: background 0.15s, color 0.15s; }
.filter-pill:hover { background: rgba(59,130,246,0.15) !important; }
.save-new-btn {
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.12s, box-shadow 0.15s;
}
.save-new-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgba(59,130,246,0.22), rgba(139,92,246,0.22)) !important;
border-color: rgba(99,102,241,0.65) !important;
color: #c7d2fe !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99,102,241,0.2);
}
.save-new-btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: none;
}
`}</style>
{/* Header */}
<div
style={{
padding: "12px 16px",
borderBottom: "1px solid #1e1e2e",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
flexWrap: "wrap",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{/* Select-all checkbox */}
<input
type="checkbox"
checked={allVisibleSelected}
ref={el => { if (el) el.indeterminate = someVisibleSelected && !allVisibleSelected; }}
onChange={e => {
if (e.target.checked) {
setSelected(prev => {
const next = new Set(prev);
visibleLeads.forEach(l => next.add(l.id));
return next;
});
} else {
setSelected(prev => {
const next = new Set(prev);
visibleLeads.forEach(l => next.delete(l.id));
return next;
});
}
}}
style={{ accentColor: "#3b82f6", cursor: "pointer", width: 14, height: 14 }}
/>
<span style={{ fontSize: 13, fontWeight: 500, color: "#ffffff" }}>
{selectedVisible.length > 0
? `${selectedVisible.length} ausgewählt`
: `${visibleLeads.length} Leads`}
</span>
{/* Filter tabs */}
<div style={{
display: "flex",
background: "#0d0d18",
border: "1px solid #2e2e3e",
borderRadius: 8,
padding: 2,
gap: 2,
}}>
{[
{ label: `Alle (${leads.length})`, value: false },
{ label: `Nur neue (${newCount})`, value: true },
].map(tab => (
<button
key={String(tab.value)}
onClick={() => setOnlyNew(tab.value)}
style={{
padding: "3px 10px",
borderRadius: 6,
border: "none",
fontSize: 11,
cursor: "pointer",
background: onlyNew === tab.value ? "#1e1e2e" : "transparent",
color: onlyNew === tab.value ? "#ffffff" : "#6b7280",
fontWeight: onlyNew === tab.value ? 500 : 400,
transition: "background 0.12s, color 0.12s",
}}
>
{tab.label}
</button>
))}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{selectedVisible.length > 0 && (
<button
className="del-btn"
onClick={() => handleDelete(selectedVisible.map(l => l.id))}
disabled={deleting}
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: "rgba(239,68,68,0.12)",
border: "1px solid rgba(239,68,68,0.3)",
borderRadius: 7,
padding: "5px 12px",
fontSize: 12,
color: "#ef4444",
cursor: deleting ? "not-allowed" : "pointer",
opacity: deleting ? 0.5 : 0.85,
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/>
</svg>
{selectedVisible.length} löschen
</button>
)}
{/* Nur neue speichern — only shown when there are existing leads */}
{!saveOnlyNew && leads.some(l => !l.isNew) && (
<button
className="save-new-btn"
onClick={handleSaveOnlyNew}
disabled={deleting}
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: "linear-gradient(135deg, rgba(59,130,246,0.12), rgba(139,92,246,0.12))",
border: "1px solid rgba(99,102,241,0.35)",
borderRadius: 7,
padding: "5px 12px",
fontSize: 12,
color: "#a5b4fc",
cursor: deleting ? "not-allowed" : "pointer",
whiteSpace: "nowrap",
opacity: deleting ? 0.5 : 1,
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z"/>
<path d="M9 12h6M12 9v6"/>
</svg>
Nur neue speichern
</button>
)}
<Link href="/leadspeicher" style={{ fontSize: 12, color: "#3b82f6", textDecoration: "none" }}>
Im Leadspeicher
</Link>
</div>
</div>
{/* Table */}
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}>
<thead>
<tr style={{ borderBottom: "1px solid #1e1e2e" }}>
<th style={{ width: 40, padding: "8px 16px" }} />
{["Unternehmen", "Domain", "Kontakt", "E-Mail"].map(h => (
<th key={h} style={{ padding: "8px 16px", textAlign: "left", color: "#6b7280", fontWeight: 500 }}>
{h}
</th>
))}
<th style={{ width: 40, padding: "8px 16px" }} />
</tr>
</thead>
<tbody>
{visibleLeads.map((lead, i) => {
const isSelected = selected.has(lead.id);
return (
<tr
key={lead.id}
className="lead-row"
style={{
borderBottom: i < visibleLeads.length - 1 ? "1px solid #1a1a2a" : "none",
background: isSelected ? "rgba(59,130,246,0.06)" : undefined,
}}
>
<td style={{ padding: "9px 16px", textAlign: "center" }}>
<input
type="checkbox"
checked={isSelected}
onChange={e => {
setSelected(prev => {
const next = new Set(prev);
e.target.checked ? next.add(lead.id) : next.delete(lead.id);
return next;
});
}}
style={{ accentColor: "#3b82f6", cursor: "pointer", width: 13, height: 13 }}
/>
</td>
<td style={{ padding: "9px 16px", color: "#ffffff" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{lead.companyName || "—"}
{!lead.isNew && (
<span style={{
fontSize: 10,
color: "#6b7280",
background: "#1e1e2e",
borderRadius: 4,
padding: "1px 6px",
whiteSpace: "nowrap",
}}>
vorhanden
</span>
)}
</div>
</td>
<td style={{ padding: "9px 16px", color: "#6b7280" }}>{lead.domain || "—"}</td>
<td style={{ padding: "9px 16px", color: "#d1d5db" }}>
{lead.contactName
? `${lead.contactName}${lead.contactTitle ? ` · ${lead.contactTitle}` : ""}`
: "—"}
</td>
<td style={{ padding: "9px 16px" }}>
{lead.email ? (
<a href={`mailto:${lead.email}`} style={{ color: "#3b82f6", textDecoration: "none" }}>
{lead.email}
</a>
) : (
<span style={{ color: "#4b5563" }}></span>
)}
</td>
<td style={{ padding: "9px 12px", textAlign: "center" }}>
<button
className="row-del"
onClick={() => handleDelete([lead.id])}
disabled={deleting}
title="Lead löschen"
style={{
background: "none",
border: "none",
cursor: deleting ? "not-allowed" : "pointer",
color: "#6b7280",
opacity: 0,
padding: 4,
borderRadius: 4,
display: "flex",
alignItems: "center",
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/>
</svg>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
})()}
</div>
);
}

View File

@@ -1,38 +1,84 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAppStore } from "@/lib/store";
import { Activity } from "lucide-react";
import { useEffect, useState } from "react";
const BREADCRUMBS: Record<string, string> = {
"/airscale": "AirScale → E-Mail",
"/linkedin": "LinkedIn → E-Mail",
"/serp": "SERP → E-Mail",
"/maps": "Google Maps → E-Mail",
"/leadvault": "🗄️ Leadspeicher",
"/results": "Ergebnisse & Verlauf",
"/settings": "Einstellungen",
};
export function TopBar() {
export function Topbar() {
const pathname = usePathname();
const { activeJobs } = useAppStore();
const runningJobs = activeJobs.filter(j => j.status === "running").length;
const label = BREADCRUMBS[pathname] || "Dashboard";
const [newLeadsCount, setNewLeadsCount] = useState(0);
useEffect(() => {
function fetchNewLeads() {
fetch("/api/leads/stats")
.then((r) => r.json())
.then((d: { new?: number }) => setNewLeadsCount(d.new ?? 0))
.catch(() => {});
}
fetchNewLeads();
const t = setInterval(fetchNewLeads, 30000);
return () => clearInterval(t);
}, []);
const isSearch = pathname === "/suche" || pathname.startsWith("/suche/");
const isLeadspeicher = pathname === "/leadspeicher" || pathname.startsWith("/leadspeicher/");
return (
<header className="h-14 border-b border-[#1e1e2e] bg-[#111118]/80 backdrop-blur flex items-center justify-between px-6 flex-shrink-0">
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-500">OnyvaLeads</span>
<span className="text-gray-600">/</span>
<span className="text-white font-medium">{label}</span>
</div>
{runningJobs > 0 && (
<div className="flex items-center gap-2 bg-blue-500/10 border border-blue-500/20 rounded-full px-3 py-1">
<Activity className="w-3.5 h-3.5 text-blue-400 animate-pulse" />
<span className="text-xs text-blue-400 font-medium">{runningJobs} Active Job{runningJobs > 1 ? "s" : ""}</span>
</div>
<header className="sticky top-0 w-full z-50 flex justify-between items-center px-8 h-16"
style={{
background: "rgba(13, 20, 25, 0.85)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
boxShadow: "0 20px 40px rgba(0,0,0,0.4)",
}}
>
{/* Left: Logo + Nav */}
<div className="flex items-center gap-8">
<Link href="/suche" style={{ textDecoration: "none" }}>
<span className="text-xl font-black text-white tracking-tighter" style={{ fontFamily: "Manrope, sans-serif" }}>
mein-solar | Lead Finder
</span>
</Link>
<nav className="hidden md:flex gap-6 items-center h-full">
<Link
href="/suche"
className={`font-bold tracking-tight transition-all pb-1 text-sm ${
isSearch
? "text-blue-400 border-b-2 border-blue-500"
: "text-slate-400 hover:text-white"
}`}
style={{ fontFamily: "Manrope, sans-serif", textDecoration: "none" }}
>
Lead-Suche
</Link>
<Link
href="/leadspeicher"
className={`font-bold tracking-tight transition-all pb-1 text-sm flex items-center gap-2 ${
isLeadspeicher
? "text-blue-400 border-b-2 border-blue-500"
: "text-slate-400 hover:text-white"
}`}
style={{ fontFamily: "Manrope, sans-serif", textDecoration: "none" }}
>
Leadspeicher
{newLeadsCount > 0 && (
<span
className="text-white font-bold rounded-full flex items-center justify-center px-1"
style={{
background: "#1a73e8",
fontSize: 10,
minWidth: 18,
height: 18,
lineHeight: 1,
}}
>
{newLeadsCount > 99 ? "99+" : newLeadsCount}
</span>
)}
</Link>
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import { STATUS_OPTIONS } from "./StatusBadge";
interface BulkActionBarProps {
selectedCount: number;
onSetStatus: (status: string) => void;
onDelete: () => void;
}
export function BulkActionBar({ selectedCount, onSetStatus, onDelete }: BulkActionBarProps) {
const [showStatusMenu, setShowStatusMenu] = useState(false);
if (selectedCount === 0) return null;
return (
<div
style={{
position: "fixed",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
background: "#111118",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: "8px 16px",
display: "flex",
alignItems: "center",
gap: 12,
zIndex: 200,
boxShadow: "0 4px 24px rgba(0,0,0,0.5)",
animation: "slideUp 0.15s ease",
}}
>
<span style={{ fontSize: 13, color: "#9ca3af", whiteSpace: "nowrap" }}>
{selectedCount} ausgewählt
</span>
<span style={{ color: "#1e1e2e" }}>·</span>
{/* Status dropdown */}
<div style={{ position: "relative" }}>
<button
onClick={() => setShowStatusMenu((v) => !v)}
style={{
background: "#0d0d18",
border: "1px solid #2e2e3e",
borderRadius: 6,
padding: "5px 12px",
fontSize: 13,
color: "#ffffff",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
Status setzen
</button>
{showStatusMenu && (
<div
style={{
position: "absolute",
bottom: "calc(100% + 4px)",
left: 0,
background: "#111118",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: 4,
minWidth: 160,
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
zIndex: 300,
}}
>
{STATUS_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => { onSetStatus(opt.value); setShowStatusMenu(false); }}
style={{
width: "100%",
display: "block",
padding: "7px 10px",
borderRadius: 6,
fontSize: 13,
color: "#9ca3af",
background: "transparent",
border: "none",
cursor: "pointer",
textAlign: "left",
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = "#1e1e2e"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = "transparent"; }}
>
{opt.label}
</button>
))}
</div>
)}
</div>
{/* Delete */}
<button
onClick={onDelete}
style={{
background: "rgba(239,68,68,0.1)",
border: "1px solid rgba(239,68,68,0.3)",
borderRadius: 6,
padding: "5px 12px",
fontSize: 13,
color: "#ef4444",
cursor: "pointer",
}}
>
Löschen
</button>
<style>{`
@keyframes slideUp {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,412 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { STATUS_OPTIONS, STATUS_MAP } from "./StatusBadge";
interface Lead {
id: string;
domain: string | null;
companyName: string | null;
contactName: string | null;
contactTitle: string | null;
email: string | null;
linkedinUrl: string | null;
phone: string | null;
industry: string | null;
sourceTerm: string | null;
address: string | null;
status: string;
notes: string | null;
capturedAt: string;
}
interface LeadSidePanelProps {
lead: Lead;
onClose: () => void;
onUpdate: (id: string, updates: Partial<Lead>) => void;
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
export function LeadSidePanel({ lead, onClose, onUpdate }: LeadSidePanelProps) {
const [notes, setNotes] = useState(lead.notes || "");
const [status, setStatus] = useState(lead.status);
const [saved, setSaved] = useState(false);
const saveTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
setNotes(lead.notes || "");
setStatus(lead.status);
}, [lead.id, lead.notes, lead.status]);
const saveNotes = useCallback(
async (value: string) => {
try {
await fetch(`/api/leads/${lead.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notes: value }),
});
onUpdate(lead.id, { notes: value });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch {
// silently fail
}
},
[lead.id, onUpdate]
);
function handleNotesChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
const val = e.target.value;
setNotes(val);
if (saveTimeout.current) clearTimeout(saveTimeout.current);
saveTimeout.current = setTimeout(() => saveNotes(val), 500);
}
async function handleStatusChange(e: React.ChangeEvent<HTMLSelectElement>) {
const newStatus = e.target.value;
setStatus(newStatus);
try {
await fetch(`/api/leads/${lead.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});
onUpdate(lead.id, { status: newStatus });
} catch {
// silently fail
}
}
async function copyEmail() {
if (lead.email) {
await navigator.clipboard.writeText(lead.email);
}
}
return (
<>
{/* Backdrop */}
<div
onClick={onClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.4)",
zIndex: 300,
}}
/>
{/* Panel */}
<div
style={{
position: "fixed",
top: 0,
right: 0,
bottom: 0,
width: 380,
background: "#111118",
borderLeft: "1px solid #1e1e2e",
zIndex: 400,
overflowY: "auto",
padding: 24,
animation: "slideInRight 0.2s ease",
}}
>
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 20,
}}
>
<div>
<div style={{ fontSize: 15, fontWeight: 500, color: "#ffffff", marginBottom: 2 }}>
{lead.companyName || lead.domain || "Unbekannt"}
</div>
{lead.domain && (
<div style={{ fontSize: 11, color: "#6b7280" }}>{lead.domain}</div>
)}
</div>
<button
onClick={onClose}
style={{
background: "transparent",
border: "none",
color: "#6b7280",
cursor: "pointer",
fontSize: 18,
lineHeight: 1,
padding: 4,
}}
>
&times;
</button>
</div>
{/* Status */}
<div style={{ marginBottom: 16 }}>
<label
style={{
display: "block",
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Status
</label>
<select
value={status}
onChange={handleStatusChange}
style={{
width: "100%",
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: "8px 12px",
fontSize: 13,
color: STATUS_MAP[status]?.color ?? "#ffffff",
cursor: "pointer",
outline: "none",
}}
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div style={{ height: 1, background: "#1e1e2e", marginBottom: 16 }} />
{/* Email */}
<div style={{ marginBottom: 14 }}>
<label
style={{
display: "block",
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
E-Mail
</label>
{lead.email ? (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
fontFamily: "monospace",
fontSize: 13,
color: "#3b82f6",
flex: 1,
wordBreak: "break-all",
}}
>
{lead.email}
</span>
<button
onClick={copyEmail}
title="Kopieren"
style={{
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 6,
padding: "4px 8px",
fontSize: 11,
color: "#9ca3af",
cursor: "pointer",
flexShrink: 0,
}}
>
Kopieren
</button>
</div>
) : (
<span style={{ fontSize: 13, color: "#6b7280" }}> nicht gefunden</span>
)}
</div>
{/* LinkedIn */}
{lead.linkedinUrl && (
<div style={{ marginBottom: 14 }}>
<label
style={{
display: "block",
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
LinkedIn
</label>
<a
href={lead.linkedinUrl}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 13, color: "#3b82f6", wordBreak: "break-all" }}
>
{lead.linkedinUrl}
</a>
</div>
)}
{/* Phone */}
{lead.phone && (
<div style={{ marginBottom: 14 }}>
<label
style={{
display: "block",
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Telefon
</label>
<span style={{ fontSize: 13, color: "#ffffff" }}>{lead.phone}</span>
</div>
)}
{/* Industry */}
{lead.industry && (
<div style={{ marginBottom: 14 }}>
<label
style={{
display: "block",
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Branche
</label>
<span style={{ fontSize: 13, color: "#9ca3af" }}>{lead.industry}</span>
</div>
)}
{/* Region */}
{(lead.sourceTerm || lead.address) && (
<div style={{ marginBottom: 14 }}>
<label
style={{
display: "block",
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Region
</label>
<span style={{ fontSize: 13, color: "#9ca3af" }}>
{lead.address ?? lead.sourceTerm}
</span>
</div>
)}
<div style={{ height: 1, background: "#1e1e2e", marginBottom: 16 }} />
{/* Notes */}
<div style={{ marginBottom: 14 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 6,
}}
>
<label
style={{
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Notiz
</label>
{saved && (
<span style={{ fontSize: 11, color: "#22c55e" }}>Gespeichert </span>
)}
</div>
<textarea
value={notes}
onChange={handleNotesChange}
placeholder="Notizen zu diesem Lead…"
rows={4}
style={{
width: "100%",
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: "8px 12px",
fontSize: 13,
color: "#ffffff",
resize: "vertical",
outline: "none",
fontFamily: "inherit",
boxSizing: "border-box",
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = "#3b82f6";
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = "#1e1e2e";
}}
/>
</div>
{/* Meta */}
<div
style={{
borderTop: "1px solid #1e1e2e",
paddingTop: 16,
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={{ fontSize: 11, color: "#6b7280" }}>Gefunden am</span>
<span style={{ fontSize: 11, color: "#9ca3af" }}>{formatDate(lead.capturedAt)}</span>
</div>
{lead.sourceTerm && (
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={{ fontSize: 11, color: "#6b7280" }}>Suchbegriff</span>
<span style={{ fontSize: 11, color: "#9ca3af" }}>{lead.sourceTerm}</span>
</div>
)}
</div>
</div>
<style>{`
@keyframes slideInRight {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`}</style>
</>
);
}

View File

@@ -0,0 +1,335 @@
"use client";
import { useState, useCallback } from "react";
import { StatusBadge } from "./StatusBadge";
import { StatusPopover } from "./StatusPopover";
import { LeadSidePanel } from "./LeadSidePanel";
export interface Lead {
id: string;
domain: string | null;
companyName: string | null;
contactName: string | null;
contactTitle: string | null;
email: string | null;
linkedinUrl: string | null;
phone: string | null;
address: string | null;
sourceTab: string;
sourceTerm: string | null;
sourceJobId: string | null;
serpTitle: string | null;
serpSnippet: string | null;
serpRank: number | null;
serpUrl: string | null;
status: string;
priority: string;
notes: string | null;
tags: string | null;
country: string | null;
headcount: string | null;
industry: string | null;
description: string | null;
capturedAt: string;
contactedAt: string | null;
}
interface LeadsTableProps {
leads: Lead[];
selected: Set<string>;
onToggleSelect: (id: string) => void;
onToggleAll: () => void;
allSelected: boolean;
onLeadUpdate: (id: string, updates: Partial<Lead>) => void;
}
function relativeDate(iso: string): string {
const now = new Date();
const d = new Date(iso);
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "heute";
if (diffDays === 1) return "gestern";
if (diffDays < 7) return `vor ${diffDays} Tagen`;
const diffWeeks = Math.floor(diffDays / 7);
if (diffWeeks < 5) return `vor ${diffWeeks} Woche${diffWeeks > 1 ? "n" : ""}`;
const diffMonths = Math.floor(diffDays / 30);
return `vor ${diffMonths} Monat${diffMonths > 1 ? "en" : ""}`;
}
function fullDate(iso: string): string {
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
interface StatusCellProps {
lead: Lead;
onUpdate: (id: string, updates: Partial<Lead>) => void;
}
function StatusCell({ lead, onUpdate }: StatusCellProps) {
const [open, setOpen] = useState(false);
async function handleSelect(newStatus: string) {
// Optimistic update
onUpdate(lead.id, { status: newStatus });
try {
await fetch(`/api/leads/${lead.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});
} catch {
// revert on error
onUpdate(lead.id, { status: lead.status });
}
}
return (
<div style={{ position: "relative", display: "inline-block" }}>
<StatusBadge status={lead.status} onClick={() => setOpen((v) => !v)} />
{open && (
<StatusPopover
currentStatus={lead.status}
onSelect={handleSelect}
onClose={() => setOpen(false)}
/>
)}
</div>
);
}
export function LeadsTable({
leads,
selected,
onToggleSelect,
onToggleAll,
allSelected,
onLeadUpdate,
}: LeadsTableProps) {
const [sidePanelLead, setSidePanelLead] = useState<Lead | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const handleCopyEmail = useCallback(async (lead: Lead, e: React.MouseEvent) => {
e.stopPropagation();
if (!lead.email) return;
await navigator.clipboard.writeText(lead.email);
setCopiedId(lead.id);
setTimeout(() => setCopiedId(null), 1500);
}, []);
async function handleMarkContacted(lead: Lead) {
onLeadUpdate(lead.id, { status: "contacted" });
try {
await fetch(`/api/leads/${lead.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "contacted" }),
});
} catch {
onLeadUpdate(lead.id, { status: lead.status });
}
}
const thStyle: React.CSSProperties = {
padding: "10px 16px",
fontSize: 11,
fontWeight: 500,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
textAlign: "left",
whiteSpace: "nowrap",
background: "#0d0d18",
borderBottom: "1px solid #1e1e2e",
};
return (
<>
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={{ ...thStyle, width: 40 }}>
<input
type="checkbox"
checked={allSelected && leads.length > 0}
onChange={onToggleAll}
style={{ cursor: "pointer", accentColor: "#3b82f6" }}
/>
</th>
<th style={thStyle}>Unternehmen</th>
<th style={thStyle}>E-Mail</th>
<th style={thStyle}>Branche</th>
<th style={thStyle}>Region</th>
<th style={thStyle}>Status</th>
<th style={thStyle}>Gefunden am</th>
<th style={{ ...thStyle, textAlign: "right" }}>Aktionen</th>
</tr>
</thead>
<tbody>
{leads.map((lead) => {
const isSelected = selected.has(lead.id);
const isNew = lead.status === "new";
return (
<tr
key={lead.id}
style={{
background: isSelected ? "rgba(59,130,246,0.04)" : "transparent",
borderBottom: "1px solid #1e1e2e",
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
if (!isSelected) (e.currentTarget as HTMLTableRowElement).style.background = "#0d0d18";
}}
onMouseLeave={(e) => {
if (!isSelected) (e.currentTarget as HTMLTableRowElement).style.background = "transparent";
}}
>
{/* Checkbox */}
<td style={{ padding: "11px 16px", width: 40 }}>
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggleSelect(lead.id)}
style={{ cursor: "pointer", accentColor: "#3b82f6" }}
/>
</td>
{/* Unternehmen */}
<td style={{ padding: "11px 16px", maxWidth: 200 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: "#ffffff", marginBottom: 1 }}>
{lead.companyName || lead.domain || "—"}
</div>
{lead.domain && lead.companyName && (
<div style={{ fontSize: 11, color: "#6b7280" }}>{lead.domain}</div>
)}
</td>
{/* Email */}
<td style={{ padding: "11px 16px", maxWidth: 220 }}>
{lead.email ? (
<div
className="email-cell"
style={{ display: "flex", alignItems: "center", gap: 6 }}
>
<span
style={{
fontFamily: "monospace",
fontSize: 11,
color: "#3b82f6",
wordBreak: "break-all",
}}
>
{lead.email}
</span>
<button
onClick={(e) => handleCopyEmail(lead, e)}
title="Kopieren"
style={{
background: "transparent",
border: "none",
cursor: "pointer",
padding: 2,
color: copiedId === lead.id ? "#22c55e" : "#6b7280",
flexShrink: 0,
fontSize: 11,
}}
>
{copiedId === lead.id ? "✓" : "⎘"}
</button>
</div>
) : (
<span style={{ fontSize: 11, color: "#6b7280" }}> nicht gefunden</span>
)}
</td>
{/* Branche */}
<td style={{ padding: "11px 16px" }}>
<span style={{ fontSize: 12, color: "#9ca3af" }}>
{lead.industry || "—"}
</span>
</td>
{/* Region */}
<td style={{ padding: "11px 16px" }}>
<span style={{ fontSize: 12, color: "#9ca3af" }}>
{lead.address ?? lead.sourceTerm ?? "—"}
</span>
</td>
{/* Status */}
<td style={{ padding: "11px 16px" }}>
<StatusCell lead={lead} onUpdate={onLeadUpdate} />
</td>
{/* Gefunden am */}
<td style={{ padding: "11px 16px" }}>
<span
style={{ fontSize: 12, color: "#9ca3af" }}
title={fullDate(lead.capturedAt)}
>
{relativeDate(lead.capturedAt)}
</span>
</td>
{/* Aktionen */}
<td style={{ padding: "11px 16px", textAlign: "right", whiteSpace: "nowrap" }}>
<div style={{ display: "flex", gap: 6, justifyContent: "flex-end" }}>
{/* Primary action */}
<button
onClick={() => (isNew ? handleMarkContacted(lead) : undefined)}
style={{
background: isNew ? "rgba(59,130,246,0.12)" : "#0d0d18",
border: `1px solid ${isNew ? "rgba(59,130,246,0.3)" : "#2e2e3e"}`,
borderRadius: 6,
padding: "4px 10px",
fontSize: 11,
color: isNew ? "#93c5fd" : "#9ca3af",
cursor: "pointer",
transition: "all 0.1s",
}}
>
{isNew ? "Kontaktiert ✓" : "Status ändern"}
</button>
{/* Notiz */}
<button
onClick={() => setSidePanelLead(lead)}
style={{
background: "#0d0d18",
border: "1px solid #2e2e3e",
borderRadius: 6,
padding: "4px 10px",
fontSize: 11,
color: "#9ca3af",
cursor: "pointer",
}}
>
Notiz
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Side panel */}
{sidePanelLead && (
<LeadSidePanel
lead={sidePanelLead}
onClose={() => setSidePanelLead(null)}
onUpdate={(id, updates) => {
onLeadUpdate(id, updates);
setSidePanelLead((prev) => (prev && prev.id === id ? { ...prev, ...updates } : prev));
}}
/>
)}
</>
);
}

View File

@@ -0,0 +1,136 @@
"use client";
import { useRef } from "react";
interface LeadsToolbarProps {
search: string;
status: string;
onSearchChange: (v: string) => void;
onStatusChange: (v: string) => void;
exportParams: Record<string, string>;
}
export function LeadsToolbar({
search,
status,
onSearchChange,
onStatusChange,
exportParams,
}: LeadsToolbarProps) {
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
function handleSearchInput(e: React.ChangeEvent<HTMLInputElement>) {
const val = e.target.value;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => onSearchChange(val), 300);
// immediate update of input
e.currentTarget.value = val;
}
function handleExport() {
const params = new URLSearchParams({ format: "xlsx", ...exportParams });
window.location.href = `/api/leads/export?${params.toString()}`;
}
return (
<div
style={{
position: "sticky",
top: 52,
zIndex: 40,
background: "#111118",
borderBottom: "1px solid #1e1e2e",
padding: "14px 20px",
display: "flex",
alignItems: "center",
gap: 12,
}}
>
{/* Search input */}
<div style={{ flex: 1, position: "relative" }}>
<div
style={{
position: "absolute",
left: 10,
top: "50%",
transform: "translateY(-50%)",
pointerEvents: "none",
color: "#6b7280",
}}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="6" cy="6" r="4.5" stroke="currentColor" strokeWidth="1.5" />
<line x1="9.5" y1="9.5" x2="12.5" y2="12.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</div>
<input
type="text"
defaultValue={search}
onChange={handleSearchInput}
placeholder="Unternehmen, E-Mail, Branche…"
style={{
width: "100%",
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: "8px 12px 8px 32px",
fontSize: 13,
color: "#ffffff",
outline: "none",
boxSizing: "border-box",
}}
onFocus={(e) => { e.currentTarget.style.borderColor = "#3b82f6"; }}
onBlur={(e) => { e.currentTarget.style.borderColor = "#1e1e2e"; }}
/>
</div>
{/* Status filter */}
<select
value={status}
onChange={(e) => onStatusChange(e.target.value)}
style={{
width: 150,
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: "8px 12px",
fontSize: 13,
color: "#ffffff",
cursor: "pointer",
outline: "none",
flexShrink: 0,
}}
>
<option value="">Alle Status</option>
<option value="new">Neu</option>
<option value="contacted">Kontaktiert</option>
<option value="in_progress">In Bearbeitung</option>
<option value="not_relevant">Nicht relevant</option>
<option value="converted">Konvertiert</option>
</select>
{/* Export button */}
<button
onClick={handleExport}
style={{
background: "#0d0d18",
border: "1px solid #2e2e3e",
borderRadius: 8,
padding: "8px 14px",
fontSize: 13,
color: "#9ca3af",
cursor: "pointer",
flexShrink: 0,
display: "flex",
alignItems: "center",
gap: 6,
transition: "color 0.15s",
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.color = "#ffffff"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.color = "#9ca3af"; }}
>
Excel Export
</button>
</div>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
export type LeadStatus = "new" | "contacted" | "in_progress" | "not_relevant" | "converted";
interface StatusConfig {
label: string;
background: string;
color: string;
}
export const STATUS_MAP: Record<string, StatusConfig> = {
new: { label: "Neu", background: "#1e3a5f", color: "#93c5fd" },
contacted: { label: "Kontaktiert", background: "#064e3b", color: "#6ee7b7" },
in_progress: { label: "In Bearbeitung", background: "#451a03", color: "#fcd34d" },
not_relevant: { label: "Nicht relevant", background: "#1e1e2e", color: "#6b7280" },
converted: { label: "Konvertiert", background: "#2e1065", color: "#d8b4fe" },
};
export const STATUS_OPTIONS = [
{ value: "new", label: "Neu" },
{ value: "contacted", label: "Kontaktiert" },
{ value: "in_progress", label: "In Bearbeitung" },
{ value: "not_relevant", label: "Nicht relevant" },
{ value: "converted", label: "Konvertiert" },
];
interface StatusBadgeProps {
status: string;
onClick?: (e: React.MouseEvent) => void;
}
export function StatusBadge({ status, onClick }: StatusBadgeProps) {
const cfg = STATUS_MAP[status] ?? STATUS_MAP.new;
return (
<span
onClick={onClick}
style={{
display: "inline-flex",
alignItems: "center",
padding: "3px 10px",
borderRadius: 20,
fontSize: 12,
fontWeight: 500,
background: cfg.background,
color: cfg.color,
cursor: onClick ? "pointer" : "default",
userSelect: "none",
whiteSpace: "nowrap",
}}
>
{cfg.label}
</span>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useEffect, useRef } from "react";
import { STATUS_OPTIONS, STATUS_MAP } from "./StatusBadge";
interface StatusPopoverProps {
currentStatus: string;
onSelect: (status: string) => void;
onClose: () => void;
}
export function StatusPopover({ currentStatus, onSelect, onClose }: StatusPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose]);
return (
<div
ref={ref}
style={{
position: "absolute",
zIndex: 100,
top: "calc(100% + 4px)",
left: 0,
background: "#111118",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: 4,
minWidth: 160,
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
}}
>
{STATUS_OPTIONS.map((opt) => {
const cfg = STATUS_MAP[opt.value];
const isActive = opt.value === currentStatus;
return (
<button
key={opt.value}
onClick={() => { onSelect(opt.value); onClose(); }}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "7px 10px",
borderRadius: 6,
fontSize: 13,
color: isActive ? "#ffffff" : "#9ca3af",
background: isActive ? "#1e1e2e" : "transparent",
border: "none",
cursor: "pointer",
textAlign: "left",
transition: "background 0.1s",
}}
onMouseEnter={(e) => { if (!isActive) (e.currentTarget as HTMLButtonElement).style.background = "#0d0d18"; }}
onMouseLeave={(e) => { if (!isActive) (e.currentTarget as HTMLButtonElement).style.background = "transparent"; }}
>
<span
style={{
display: "inline-block",
width: 8,
height: 8,
borderRadius: 4,
background: cfg.color,
flexShrink: 0,
}}
/>
{opt.label}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,232 @@
"use client";
import { useState } from "react";
interface Query {
query: string;
region: string;
count: number;
}
interface AiSearchModalProps {
onStart: (queries: Query[]) => void;
onClose: () => void;
searchMode?: string;
}
export function AiSearchModal({ onStart, onClose, searchMode = "custom" }: AiSearchModalProps) {
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(false);
const [queries, setQueries] = useState<Query[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [error, setError] = useState("");
async function generate() {
if (!description.trim() || loading) return;
setLoading(true);
setError("");
setQueries([]);
try {
// Load history for this mode
let history: Array<{ query: string; region: string }> = [];
if (searchMode !== "custom") {
try {
const hRes = await fetch(`/api/search-history?mode=${searchMode}`);
if (hRes.ok) history = await hRes.json() as Array<{ query: string; region: string }>;
} catch { /* ignore */ }
}
const res = await fetch("/api/ai-search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description, searchMode, history }),
});
const data = await res.json() as { queries?: Query[]; error?: string };
if (!res.ok || !data.queries) throw new Error(data.error || "Fehler");
setQueries(data.queries);
setSelected(new Set(data.queries.map((_, i) => i)));
} catch (e) {
setError(e instanceof Error ? e.message : "Unbekannter Fehler");
} finally {
setLoading(false);
}
}
function toggle(i: number) {
setSelected(prev => {
const n = new Set(prev);
if (n.has(i)) n.delete(i); else n.add(i);
return n;
});
}
function handleStart() {
const chosen = queries.filter((_, i) => selected.has(i));
if (!chosen.length) return;
onStart(chosen);
}
return (
<div
style={{
position: "fixed", inset: 0, zIndex: 50,
display: "flex", alignItems: "center", justifyContent: "center",
background: "rgba(0,0,0,0.6)",
}}
onClick={onClose}
>
<div
style={{
background: "#111118", border: "1px solid #1e1e2e", borderRadius: 14,
padding: 28, width: "100%", maxWidth: 520,
display: "flex", flexDirection: "column", gap: 16,
}}
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: 18 }}></span>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 500, color: "#fff" }}>
KI-gestützte Suche
</h2>
</div>
<p style={{ margin: 0, fontSize: 12, color: "#6b7280" }}>
Beschreibe deine Zielgruppe die KI generiert passende Suchanfragen.
</p>
</div>
<button onClick={onClose} style={{ background: "none", border: "none", color: "#6b7280", cursor: "pointer", padding: 4 }}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
</svg>
</button>
</div>
{/* Textarea */}
<div>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
onKeyDown={e => { if (e.key === "Enter" && e.ctrlKey) generate(); }}
placeholder="z.B. Ich suche kleine Dachdecker und Solarinstallateure in Bayern, am liebsten mit Inhaber direkt erreichbar."
rows={4}
style={{
width: "100%", background: "#0d0d18", border: "1px solid #1e1e2e",
borderRadius: 8, padding: "10px 12px", fontSize: 13, color: "#fff",
outline: "none", resize: "vertical", fontFamily: "inherit",
lineHeight: 1.6, boxSizing: "border-box",
}}
onFocus={e => { e.currentTarget.style.borderColor = "#3b82f6"; }}
onBlur={e => { e.currentTarget.style.borderColor = "#1e1e2e"; }}
/>
<p style={{ margin: "4px 0 0", fontSize: 11, color: "#4b5563" }}>
Tipp: Branche, Region, Firmengröße und gewünschten Entscheidungsträger erwähnen
</p>
</div>
{/* Generate button */}
{queries.length === 0 && (
<button
onClick={generate}
disabled={!description.trim() || loading}
style={{
width: "100%", padding: "11px 16px", borderRadius: 8, border: "none",
background: !description.trim() || loading ? "#1e1e2e" : "linear-gradient(135deg, #3b82f6, #8b5cf6)",
color: !description.trim() || loading ? "#4b5563" : "#fff",
fontSize: 14, fontWeight: 500, cursor: !description.trim() || loading ? "not-allowed" : "pointer",
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
}}
>
{loading ? (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ animation: "spin 1s linear infinite" }}>
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
Suchanfragen werden generiert
</>
) : (
<> Suchanfragen generieren</>
)}
</button>
)}
{/* Error */}
{error && (
<p style={{ margin: 0, fontSize: 12, color: "#ef4444", background: "rgba(239,68,68,0.08)", padding: "8px 12px", borderRadius: 6, borderLeft: "3px solid #ef4444" }}>
{error}
</p>
)}
{/* Generated queries */}
{queries.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<p style={{ margin: 0, fontSize: 11, color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Generierte Suchanfragen wähle aus was starten soll
</p>
{queries.map((q, i) => (
<button
key={i}
onClick={() => toggle(i)}
style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "10px 14px", borderRadius: 8, cursor: "pointer", textAlign: "left",
border: selected.has(i) ? "1px solid rgba(59,130,246,0.5)" : "1px solid #1e1e2e",
background: selected.has(i) ? "rgba(59,130,246,0.08)" : "#0d0d18",
transition: "all 0.15s",
}}
>
<div>
<span style={{ fontSize: 13, color: "#fff", fontWeight: 500 }}>{q.query}</span>
{q.region && (
<span style={{ fontSize: 12, color: "#6b7280", marginLeft: 8 }}>📍 {q.region}</span>
)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{
width: 16, height: 16, borderRadius: 4,
border: selected.has(i) ? "none" : "1px solid #2e2e3e",
background: selected.has(i) ? "#3b82f6" : "transparent",
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
}}>
{selected.has(i) && (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M2 5l2.5 2.5L8 3" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</div>
</div>
</button>
))}
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
<button
onClick={() => { setQueries([]); setSelected(new Set()); }}
style={{
flex: 1, padding: "10px", borderRadius: 8, border: "1px solid #1e1e2e",
background: "#0d0d18", color: "#6b7280", fontSize: 13, cursor: "pointer",
}}
>
Neu generieren
</button>
<button
onClick={handleStart}
disabled={selected.size === 0}
style={{
flex: 2, padding: "10px", borderRadius: 8, border: "none",
background: selected.size > 0 ? "linear-gradient(135deg, #3b82f6, #8b5cf6)" : "#1e1e2e",
color: selected.size > 0 ? "#fff" : "#4b5563",
fontSize: 13, fontWeight: 500, cursor: selected.size > 0 ? "pointer" : "not-allowed",
}}
>
{selected.size} {selected.size === 1 ? "Suche" : "Suchen"} starten
</button>
</div>
</div>
)}
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
interface ExamplePill {
label: string;
query: string;
region: string;
}
const EXAMPLES: ExamplePill[] = [
{ label: "☀️ Solaranlagen Bayern", query: "Solaranlagen", region: "Bayern" },
{ label: "🔧 Dachdecker NRW", query: "Dachdecker", region: "NRW" },
{ label: "📊 Steuerberater Berlin", query: "Steuerberater", region: "Berlin" },
{ label: "🏗️ Bauunternehmen Süddeutschland", query: "Bauunternehmen", region: "Süddeutschland" },
{ label: "🌿 Landschaftsgärtner Hamburg", query: "Landschaftsgärtner", region: "Hamburg" },
{ label: "🔌 Elektroinstallateur München", query: "Elektroinstallateur", region: "München" },
];
interface ExamplePillsProps {
onSelect: (query: string, region: string) => void;
}
export function ExamplePills({ onSelect }: ExamplePillsProps) {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{EXAMPLES.map((ex) => (
<button
key={ex.label}
onClick={() => onSelect(ex.query, ex.region)}
style={{
padding: "5px 12px",
border: "1px solid #2e2e3e",
borderRadius: 20,
fontSize: 12,
color: "#9ca3af",
background: "transparent",
cursor: "pointer",
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.borderColor = "#3b82f6";
(e.currentTarget as HTMLButtonElement).style.color = "#3b82f6";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.borderColor = "#2e2e3e";
(e.currentTarget as HTMLButtonElement).style.color = "#9ca3af";
}}
>
{ex.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,351 @@
"use client";
import { useEffect, useState } from "react";
type Phase = "scraping" | "enriching" | "emails" | "topping" | "done";
interface JobStatus {
status: "running" | "complete" | "failed";
totalLeads: number;
emailsFound: number;
error?: string | null;
results?: LeadResult[];
}
export interface LeadResult {
id: string;
companyName: string | null;
domain: string | null;
contactName: string | null;
contactTitle: string | null;
email: string | null;
linkedinUrl: string | null;
address: string | null;
phone: string | null;
createdAt: string;
isNew: boolean;
}
interface LoadingCardProps {
jobId: string;
targetCount: number;
query: string;
region: string;
onDone: (leads: LeadResult[], warning?: string) => void;
onError: (message: string) => void;
}
const STEPS = [
{ id: "scraping" as Phase, label: "Unternehmen gefunden" },
{ id: "enriching" as Phase, label: "Kontakte ermitteln" },
{ id: "emails" as Phase, label: "E-Mails suchen" },
{ id: "done" as Phase, label: "Fertig" },
];
function getPhase(s: JobStatus): Phase {
if (s.status === "complete") return "done";
if (s.emailsFound > 0) return "emails";
if (s.totalLeads > 0) return "enriching";
return "scraping";
}
function stepState(step: Phase, currentPhase: Phase): "done" | "active" | "pending" {
const order: Phase[] = ["scraping", "enriching", "emails", "done"];
const stepIdx = order.indexOf(step);
const currentIdx = order.indexOf(currentPhase);
if (stepIdx < currentIdx) return "done";
if (stepIdx === currentIdx) return "active";
return "pending";
}
const PHASE_MIN: Record<Phase, number> = {
scraping: 3,
enriching: 35,
emails: 60,
topping: 88,
done: 100,
};
export function LoadingCard({ jobId, targetCount, query, region, onDone, onError }: LoadingCardProps) {
const [phase, setPhase] = useState<Phase>("scraping");
const [totalLeads, setTotalLeads] = useState(0);
const [emailsFound, setEmailsFound] = useState(0);
const [progressWidth, setProgressWidth] = useState(3);
const [isTopping, setIsTopping] = useState(false);
const [optimizedQuery, setOptimizedQuery] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let crawlInterval: ReturnType<typeof setInterval> | null = null;
let mapsPollTimeout: ReturnType<typeof setTimeout> | null = null;
let serpPollTimeout: ReturnType<typeof setTimeout> | null = null;
// Parallel tracking
let supplementStarted = false;
let mapsDone = false;
let serpDone = false;
let serpNeeded = false;
let mapsLeads: LeadResult[] = [];
let serpLeads: LeadResult[] = [];
let serpJobId: string | null = null;
let lastTotalLeads = 0; // detect when Maps scraping has stabilized
function advanceBar(to: number) {
setProgressWidth(prev => Math.max(prev, to));
}
crawlInterval = setInterval(() => {
if (cancelled) return;
setProgressWidth(prev => prev >= 88 ? prev : prev + 0.4);
}, 200);
// Called when both Maps and SERP (if needed) are done
function tryFinalize() {
if (!mapsDone || (serpNeeded && !serpDone)) return;
if (crawlInterval) clearInterval(crawlInterval);
let finalLeads: LeadResult[];
if (serpNeeded && serpLeads.length > 0) {
const seenDomains = new Set(mapsLeads.map(l => l.domain).filter(Boolean));
const newSerpLeads = serpLeads.filter(l => !l.domain || !seenDomains.has(l.domain));
finalLeads = [...mapsLeads, ...newSerpLeads];
} else {
finalLeads = mapsLeads;
}
setTotalLeads(finalLeads.length);
setProgressWidth(100);
setPhase("done");
setTimeout(() => { if (!cancelled) onDone(finalLeads); }, 800);
}
// Start SERP supplement in parallel — doesn't block Maps polling
async function startSerpSupplement(foundCount: number) {
supplementStarted = true;
serpNeeded = true;
setIsTopping(true);
try {
const res = await fetch("/api/search/supplement", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, region, targetCount, foundCount }),
});
if (!res.ok) throw new Error("supplement start failed");
const data = await res.json() as { jobId: string; optimizedQuery: string; usedAI: boolean };
serpJobId = data.jobId;
if (data.optimizedQuery) setOptimizedQuery(data.optimizedQuery);
serpPollTimeout = setTimeout(pollSerp, 2500);
} catch {
// Supplement failed — mark done with no results so tryFinalize can proceed
serpDone = true;
tryFinalize();
}
}
// Independent SERP poll loop
async function pollSerp() {
if (cancelled || !serpJobId) return;
try {
const res = await fetch(`/api/jobs/${serpJobId}/status`);
if (!res.ok) throw new Error("fetch failed");
const data = await res.json() as JobStatus;
if (!cancelled) {
if (data.status === "complete" || data.status === "failed") {
serpLeads = (data.results ?? []) as LeadResult[];
serpDone = true;
tryFinalize();
} else {
serpPollTimeout = setTimeout(pollSerp, 2500);
}
}
} catch {
if (!cancelled) serpPollTimeout = setTimeout(pollSerp, 3000);
}
}
// Maps poll loop
async function pollMaps() {
if (cancelled) return;
try {
const res = await fetch(`/api/jobs/${jobId}/status`);
if (!res.ok) throw new Error("fetch failed");
const data = await res.json() as JobStatus;
if (!cancelled) {
setTotalLeads(data.totalLeads);
setEmailsFound(data.emailsFound);
const p = getPhase(data);
setPhase(p);
advanceBar(PHASE_MIN[p]);
// Fire supplement as soon as Maps scraping stabilizes with fewer results than needed.
// "Stabilized" = totalLeads unchanged since last poll (scraping done, enrichment started).
if (
!supplementStarted &&
data.status === "running" &&
data.totalLeads > 0 &&
data.totalLeads < targetCount &&
data.totalLeads === lastTotalLeads
) {
startSerpSupplement(data.totalLeads).catch(console.error);
}
lastTotalLeads = data.totalLeads;
if (data.status === "complete" || data.status === "failed") {
const leads = (data.results ?? []) as LeadResult[];
if (data.status === "failed" && leads.length === 0) {
if (crawlInterval) clearInterval(crawlInterval);
onError(data.error ?? "Unbekannter Fehler");
return;
}
// Flash "E-Mails suchen" step before completing (poll often misses it)
setPhase("emails");
advanceBar(PHASE_MIN["emails"]);
await new Promise(r => setTimeout(r, 500));
if (cancelled) return;
mapsLeads = leads;
mapsDone = true;
// If supplement wasn't triggered, no SERP needed
if (!supplementStarted) serpDone = true;
// Trigger supplement now if Maps finished with fewer results and we haven't yet
if (!supplementStarted && data.totalLeads < targetCount) {
startSerpSupplement(data.totalLeads).catch(console.error);
} else {
tryFinalize();
}
} else {
mapsPollTimeout = setTimeout(pollMaps, 2500);
}
}
} catch {
if (!cancelled) mapsPollTimeout = setTimeout(pollMaps, 3000);
}
}
pollMaps();
return () => {
cancelled = true;
if (crawlInterval) clearInterval(crawlInterval);
if (mapsPollTimeout) clearTimeout(mapsPollTimeout);
if (serpPollTimeout) clearTimeout(serpPollTimeout);
};
}, [jobId]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div
style={{
background: "#111118",
border: "1px solid #1e1e2e",
borderRadius: 12,
padding: 24,
marginTop: 16,
}}
>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: "#ffffff" }}>
{isTopping ? "Ergebnisse werden ergänzt" : "Ergebnisse werden gesucht"}
</span>
<span style={{ fontSize: 12, color: "#9ca3af" }}>
{totalLeads > 0 || emailsFound > 0
? `${emailsFound} E-Mails · ${totalLeads} Unternehmen`
: "Wird gestartet…"}
</span>
</div>
{/* Progress bar — only ever moves forward */}
<div
style={{
height: 5,
borderRadius: 3,
background: "#1e1e2e",
overflow: "hidden",
marginBottom: 16,
}}
>
<div
style={{
height: "100%",
width: `${progressWidth}%`,
background: "linear-gradient(90deg, #3b82f6, #8b5cf6)",
borderRadius: 3,
transition: phase === "done" ? "width 0.5s ease" : "width 0.12s linear",
}}
/>
</div>
{/* Steps */}
<div style={{ display: "flex", gap: 20, marginBottom: 16, flexWrap: "wrap" }}>
{STEPS.map((step) => {
const state = isTopping ? "done" : stepState(step.id, phase);
return (
<div key={step.id} style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div
style={{
width: 6,
height: 6,
borderRadius: 3,
background: state === "done" ? "#22c55e" : state === "active" ? "#3b82f6" : "#2e2e3e",
animation: state === "active" ? "pulse 1.5s infinite" : "none",
}}
/>
<span
style={{
fontSize: 12,
color: state === "done" ? "#22c55e" : state === "active" ? "#ffffff" : "#6b7280",
}}
>
{step.label}
</span>
</div>
);
})}
{isTopping && (
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 6, height: 6, borderRadius: 3, background: "#3b82f6", animation: "pulse 1.5s infinite" }} />
<span style={{ fontSize: 12, color: "#ffffff" }}>Ergebnisse auffüllen</span>
</div>
)}
</div>
{/* Optimized query hint */}
{isTopping && optimizedQuery && (
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 14, fontSize: 11, color: "#6b7280" }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" strokeWidth="2.5">
<path d="M9.663 17h4.673M12 3v1m6.364 1.636-.707.707M21 12h-1M4 12H3m3.343-5.657-.707-.707m2.828 9.9a5 5 0 1 1 7.072 0l-.548.547A3.374 3.374 0 0 0 14 18.469V19a2 2 0 1 1-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
KI-optimierte Suche:&nbsp;
<span style={{ color: "#9ca3af", fontStyle: "italic" }}>{optimizedQuery}"</span>
</div>
)}
{/* Warning banner */}
<div
style={{
borderLeft: "3px solid #f59e0b",
background: "rgba(245,158,11,0.08)",
padding: "10px 14px",
borderRadius: 8,
fontSize: 12,
color: "#9ca3af",
}}
>
Bitte diesen Tab nicht schließen, während die Suche läuft.
</div>
<style>{`
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { ExamplePills } from "./ExamplePills";
interface SearchCardProps {
query: string;
region: string;
count: number;
loading: boolean;
onChange: (field: "query" | "region" | "count", value: string | number) => void;
onSubmit: () => void;
}
const inputStyle: React.CSSProperties = {
width: "100%",
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: "10px 12px",
fontSize: 14,
color: "#ffffff",
outline: "none",
boxSizing: "border-box",
transition: "border-color 0.15s",
};
const labelStyle: React.CSSProperties = {
display: "block",
fontSize: 11,
fontWeight: 500,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
};
export function SearchCard({ query, region, count, loading, onChange, onSubmit }: SearchCardProps) {
const canSubmit = query.trim().length > 0 && !loading;
return (
<div
style={{
background: "#111118",
border: "1px solid #1e1e2e",
borderRadius: 12,
padding: 24,
opacity: loading ? 0.55 : 1,
pointerEvents: loading ? "none" : "auto",
transition: "opacity 0.2s",
}}
>
<style>{`
.search-input:hover { border-color: #2e2e3e !important; }
.search-input:focus { border-color: #3b82f6 !important; }
.search-btn:hover:not(:disabled) { filter: brightness(1.15); transform: translateY(-1px); }
.search-btn:active:not(:disabled) { transform: translateY(0); }
.search-btn { transition: filter 0.15s, transform 0.15s, opacity 0.15s; }
@media (max-width: 600px) {
.search-grid { grid-template-columns: 1fr !important; }
}
`}</style>
{/* 3-column grid */}
<div
className="search-grid"
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 120px",
gap: 12,
marginBottom: 16,
}}
>
{/* Suchbegriff */}
<div>
<label style={labelStyle}>Suchbegriff</label>
<input
className="search-input"
type="text"
value={query}
onChange={(e) => onChange("query", e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && canSubmit) onSubmit(); }}
placeholder="z.B. Dachdecker, Solaranlage…"
style={inputStyle}
/>
</div>
{/* Region */}
<div>
<label style={labelStyle}>Region</label>
<input
className="search-input"
type="text"
value={region}
onChange={(e) => onChange("region", e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && canSubmit) onSubmit(); }}
placeholder="z.B. Bayern, München…"
style={inputStyle}
/>
</div>
{/* Anzahl */}
<div>
<label style={labelStyle}>Anzahl</label>
<select
className="search-input"
value={count}
onChange={(e) => onChange("count", Number(e.target.value))}
style={{ ...inputStyle, cursor: "pointer" }}
>
<option value={25}>25 Leads</option>
<option value={50}>50 Leads</option>
<option value={100}>100 Leads</option>
</select>
</div>
</div>
{/* Submit button */}
<button
className="search-btn"
onClick={onSubmit}
disabled={!canSubmit}
style={{
width: "100%",
background: canSubmit ? "linear-gradient(135deg, #3b82f6, #8b5cf6)" : "#1e1e2e",
color: canSubmit ? "#ffffff" : "#6b7280",
border: "none",
borderRadius: 10,
padding: "12px 16px",
fontSize: 14,
fontWeight: 500,
cursor: canSubmit ? "pointer" : "not-allowed",
opacity: canSubmit ? 1 : 0.5,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
marginBottom: 20,
}}
>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<circle cx="6.5" cy="6.5" r="4.5" stroke="currentColor" strokeWidth="1.5" />
<line x1="10.5" y1="10.5" x2="13.5" y2="13.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
Leads suchen
</button>
{/* Divider */}
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 16 }}>
<div style={{ flex: 1, height: 1, background: "#1e1e2e" }} />
<span style={{ fontSize: 12, color: "#6b7280", flexShrink: 0 }}>Beispielsuche</span>
<div style={{ flex: 1, height: 1, background: "#1e1e2e" }} />
</div>
<ExamplePills
onSelect={(q, r) => {
onChange("query", q);
onChange("region", r);
}}
/>
</div>
);
}

View File

@@ -1,11 +1,17 @@
#!/bin/sh
set -e
# Run Prisma migrations on every startup (idempotent)
echo "Running database migrations..."
if [ -n "$TURSO_DATABASE_URL" ]; then
echo "Turso database configured — skipping local migrations."
else
# Fix /data permissions — Docker volumes are mounted as root by default
chown -R nextjs:nodejs /data 2>/dev/null || true
echo "Running SQLite migrations..."
DATABASE_URL="${DATABASE_URL:-file:/data/leadflow.db}" \
node node_modules/prisma/build/index.js migrate deploy \
su-exec nextjs node node_modules/prisma/build/index.js migrate deploy \
--schema ./prisma/schema.prisma 2>&1 || echo "Migration warning (may already be up to date)"
fi
echo "Starting LeadFlow..."
exec node server.js
exec su-exec nextjs node server.js

View File

@@ -0,0 +1,161 @@
// Größte Städte je Bundesland, sortiert nach Einwohnerzahl (absteigend)
// Quelle: Destatis / Wikipedia, Stand 2024
export const STADTWERKE_CITIES: Record<string, string[]> = {
Bayern: [
"München", "Nürnberg", "Augsburg", "Regensburg", "Ingolstadt",
"Würzburg", "Fürth", "Erlangen", "Bayreuth", "Landshut",
"Rosenheim", "Kempten", "Bamberg", "Aschaffenburg", "Neu-Ulm",
"Schweinfurt", "Ansbach", "Straubing", "Passau", "Coburg",
"Dachau", "Freising", "Germering", "Memmingen", "Kaufbeuren",
"Hof", "Amberg", "Weiden", "Pfaffenhofen", "Starnberg",
"Traunreut", "Gauting", "Garching", "Erding", "Fürstenfeldbruck",
"Unterschleißheim", "Waldkraiburg", "Marktoberdorf", "Neumarkt", "Altötting",
"Weißenburg", "Schwabach", "Deggendorf", "Traunstein", "Burghausen",
"Bad Reichenhall", "Neuburg an der Donau", "Kelheim", "Dillingen", "Günzburg",
],
"Nordrhein-Westfalen": [
"Köln", "Düsseldorf", "Dortmund", "Essen", "Duisburg",
"Bochum", "Wuppertal", "Bielefeld", "Bonn", "Münster",
"Gelsenkirchen", "Aachen", "Mönchengladbach", "Krefeld", "Oberhausen",
"Hagen", "Hamm", "Solingen", "Leverkusen", "Osnabrück",
"Herne", "Neuss", "Paderborn", "Gütersloh", "Recklinghausen",
"Mülheim", "Siegen", "Bergisch Gladbach", "Witten", "Bottrop",
"Heiligenhaus", "Velbert", "Troisdorf", "Moers", "Iserlohn",
"Lünen", "Detmold", "Remscheid", "Castrop-Rauxel", "Minden",
"Lippstadt", "Herford", "Viersen", "Düren", "Marl",
"Dinslaken", "Dormagen", "Ratingen", "Wesel", "Gladbeck",
],
"Baden-Württemberg": [
"Stuttgart", "Karlsruhe", "Mannheim", "Freiburg", "Heidelberg",
"Ulm", "Heilbronn", "Pforzheim", "Reutlingen", "Ludwigsburg",
"Esslingen", "Tübingen", "Villingen-Schwenningen", "Konstanz", "Aalen",
"Friedrichshafen", "Sindelfingen", "Ravensburg", "Offenburg", "Göppingen",
"Böblingen", "Schwäbisch Gmünd", "Lahr", "Waiblingen", "Baden-Baden",
"Bruchsal", "Weinheim", "Leonberg", "Bietigheim-Bissingen", "Heidenheim",
"Schwäbisch Hall", "Nagold", "Singen", "Nürtingen", "Fellbach",
"Tuttlingen", "Überlingen", "Backnang", "Ditzingen", "Kirchheim",
"Schorndorf", "Filderstadt", "Leinfelden-Echterdingen", "Ettlingen", "Weil am Rhein",
"Rottenburg", "Rheinfelden", "Leutkirch", "Mosbach", "Crailsheim",
],
Hessen: [
"Frankfurt", "Wiesbaden", "Kassel", "Darmstadt", "Offenbach",
"Hanau", "Marburg", "Gießen", "Fulda", "Wetzlar",
"Rüsselsheim", "Langen", "Bad Homburg", "Dreieich", "Viernheim",
"Maintal", "Friedberg", "Bensheim", "Rodgau", "Eschborn",
"Limburg", "Hofheim", "Bad Nauheim", "Gelnhausen", "Herborn",
"Mörfelden-Walldorf", "Heppenheim", "Seligenstadt", "Bruchköbel", "Büdingen",
"Korbach", "Mühlheim", "Neu-Isenburg", "Oberursel", "Königstein",
"Seligenstädt", "Lampertheim", "Bad Hersfeld", "Groß-Gerau", "Lauterbach",
"Riedstadt", "Baunatal", "Taunusstein", "Bebra", "Schlüchtern",
"Dillenburg", "Alsfeld", "Bad Vilbel", "Griesheim", "Hünfeld",
],
Niedersachsen: [
"Hannover", "Braunschweig", "Osnabrück", "Oldenburg", "Wolfsburg",
"Göttingen", "Salzgitter", "Hildesheim", "Delmenhorst", "Wilhelmshaven",
"Celle", "Lüneburg", "Wolfenbüttel", "Garbsen", "Hameln",
"Lingen", "Langenhagen", "Peine", "Cuxhaven", "Emden",
"Nordhorn", "Goslar", "Stade", "Rheine", "Leer",
"Buxtehude", "Hameln", "Alfeld", "Rotenburg", "Achim",
"Winsen", "Buchholz", "Sarstedt", "Bad Salzdetfurth", "Seelze",
"Wunstorf", "Nienburg", "Uelzen", "Holzminden", "Osterode",
"Clausthal-Zellerfeld", "Bückeburg", "Springe", "Hemmingen", "Isernhagen",
"Ganderkesee", "Papenburg", "Meppen", "Gifhorn", "Schöningen",
],
Sachsen: [
"Leipzig", "Dresden", "Chemnitz", "Zwickau", "Erfurt",
"Plauen", "Görlitz", "Hoyerswerda", "Bautzen", "Gera",
"Zittau", "Freiberg", "Riesa", "Pirna", "Döbeln",
"Freital", "Mittweida", "Meißen", "Werdau", "Crimmitschau",
"Annaberg-Buchholz", "Stollberg", "Torgau", "Oelsnitz", "Aue-Bad Schlema",
"Limbach-Oberfrohna", "Borna", "Glauchau", "Delitzsch", "Coswig",
"Radebeul", "Weißwasser", "Grimma", "Meerane", "Frankenberg",
"Wittenberg", "Zschopau", "Reichenbach", "Marienberg", "Auerbach",
"Großenhain", "Lößnitz", "Hohenstein-Ernstthal", "Schneeberg", "Flöha",
"Eilenburg", "Geithain", "Brand-Erbisdorf", "Lugau", "Radeberg",
],
Berlin: [
"Berlin-Mitte", "Berlin-Charlottenburg", "Berlin-Spandau", "Berlin-Steglitz",
"Berlin-Tempelhof", "Berlin-Schöneberg", "Berlin-Kreuzberg", "Berlin-Prenzlauer Berg",
"Berlin-Friedrichshain", "Berlin-Lichtenberg", "Berlin-Hohenschönhausen",
"Berlin-Reinickendorf", "Berlin-Wedding", "Berlin-Neukölln", "Berlin-Treptow",
"Berlin-Köpenick", "Berlin-Wilmersdorf", "Berlin-Zehlendorf", "Berlin-Pankow",
"Berlin-Weißensee", "Berlin-Hellersdorf", "Berlin-Marzahn", "Berlin-Adlershof",
"Berlin-Buch", "Berlin-Mahlsdorf",
],
Hamburg: [
"Hamburg-Mitte", "Hamburg-Altona", "Hamburg-Eimsbüttel", "Hamburg-Nord",
"Hamburg-Wandsbek", "Hamburg-Bergedorf", "Hamburg-Harburg",
"Norderstedt", "Ahrensburg", "Reinbek", "Glinde", "Bargteheide",
"Bad Oldesloe", "Elmshorn", "Pinneberg", "Wedel", "Geesthacht",
"Lauenburg", "Buchholz", "Buxtehude", "Stade", "Winsen",
"Heide", "Itzehoe", "Bad Segeberg",
],
Bremen: [
"Bremen", "Bremerhaven", "Delmenhorst", "Achim", "Syke",
"Lilienthal", "Stuhr", "Weyhe", "Bassum", "Schwanewede",
],
Thüringen: [
"Erfurt", "Jena", "Gera", "Weimar", "Gotha",
"Nordhausen", "Suhl", "Ilmenau", "Eisenach", "Altenburg",
"Mühlhausen", "Sonneberg", "Sömmerda", "Saalfeld", "Bad Langensalza",
"Pößneck", "Apolda", "Arnstadt", "Greiz", "Schmalkalden",
"Hildburghausen", "Rudolstadt", "Zeulenroda", "Leinefelde-Worbis", "Bad Salzungen",
"Meiningen", "Sonneberg", "Schleiz", "Neustadt an der Orla", "Eisenberg",
"Lobenstein", "Sondershausen", "Bleicherode", "Heilbad Heiligenstadt", "Dingelstädt",
],
"Sachsen-Anhalt": [
"Halle", "Magdeburg", "Dessau-Roßlau", "Wittenberg", "Halle-Neustadt",
"Halberstadt", "Stendal", "Quedlinburg", "Bitterfeld-Wolfen", "Merseburg",
"Bernburg", "Köthen", "Weißenfels", "Zeitz", "Naumburg",
"Sangerhausen", "Aschersleben", "Staßfurt", "Burg", "Gardelegen",
"Wernigerode", "Schönebeck", "Blauen", "Eisleben", "Wolfen",
"Zerbst", "Calbe", "Tangermünde", "Wanzleben", "Klötze",
],
Brandenburg: [
"Potsdam", "Cottbus", "Brandenburg an der Havel", "Frankfurt (Oder)", "Oranienburg",
"Eberswalde", "Bernau", "Neuruppin", "Schwedt", "Falkensee",
"Strausberg", "Eisenhüttenstadt", "Ludwigsfelde", "Werder", "Königs Wusterhausen",
"Prenzlau", "Nauen", "Luckenwalde", "Senftenberg", "Spremberg",
"Forst", "Guben", "Neuenhagen", "Templin", "Rathenow",
"Finowfurt", "Bad Belzig", "Jüterbog", "Zossen", "Zehdenick",
],
"Mecklenburg-Vorpommern": [
"Rostock", "Schwerin", "Neubrandenburg", "Stralsund", "Greifswald",
"Wismar", "Güstrow", "Neustadt-Glewe", "Waren", "Bergen auf Rügen",
"Ribnitz-Damgarten", "Ueckermünde", "Wolgast", "Anklam", "Parchim",
"Hagenow", "Neustrelitz", "Teterow", "Pasewalk", "Demmin",
"Sassnitz", "Lüdershagen", "Ludwigslust", "Malchin", "Stavenhagen",
],
Saarland: [
"Saarbrücken", "Neunkirchen", "Saarlouis", "Sankt Ingbert", "Homburg",
"Völklingen", "Merzig", "Dillingen", "Sulzbach", "Überherrn",
"Saarwellingen", "Bexbach", "Püttlingen", "Friedrichsthal", "Blieskastel",
"Sankt Wendel", "Lebach", "Ottweiler", "Wadern", "Losheim",
],
"Rheinland-Pfalz": [
"Mainz", "Ludwigshafen", "Koblenz", "Trier", "Kaiserslautern",
"Worms", "Neustadt", "Bad Kreuznach", "Pirmasens", "Andernach",
"Speyer", "Zweibrücken", "Frankenthal", "Bingen", "Neuwied",
"Idar-Oberstein", "Landau", "Ingelheim", "Mayen", "Remagen",
"Konz", "Simmern", "Montabaur", "Kusel", "Birkenfeld",
"Cochem", "Lahnstein", "Alzey", "Bad Dürkheim", "Germersheim",
"Grünstadt", "Winnweiler", "Daun", "Gerolstein", "Linz",
"Bendorf", "Nastätten", "Simmern", "Kirchheimbolanden", "Rockenhausen",
],
"Schleswig-Holstein": [
"Kiel", "Lübeck", "Flensburg", "Neumünster", "Norderstedt",
"Elmshorn", "Pinneberg", "Itzehoe", "Schleswig", "Heide",
"Bad Oldesloe", "Wedel", "Reinbek", "Ahrensburg", "Bargteheide",
"Kaltenkirchen", "Quickborn", "Büdelsdorf", "Rendsburg", "Brunsbüttel",
"Husum", "Niebüll", "Eckernförde", "Neustadt", "Bad Segeberg",
"Preetz", "Eutin", "Mölln", "Ratzeburg", "Geesthacht",
"Glinde", "Wahlstedt", "Bad Schwartau", "Lütjenburg", "Plön",
],
};
// Flache Liste aller Städte
export const ALL_CITIES = Object.values(STADTWERKE_CITIES).flat();
// Alle Bundesländer
export const BUNDESLAENDER = Object.keys(STADTWERKE_CITIES);

View File

@@ -35,20 +35,11 @@ export async function sinkLeadToVault(
const email = lead.email || null;
if (domain) {
// Check for exact duplicate (same domain + same email)
if (email) {
const exact = await prisma.lead.findFirst({
where: { domain, email },
});
if (exact) return exact.id; // already exists, skip
}
// Strict domain dedup: one lead per domain
const existing = await prisma.lead.findFirst({ where: { domain } });
// Check if same domain exists without email and we now have one
const existing = await prisma.lead.findFirst({
where: { domain, email: null },
});
if (existing && email) {
if (existing) {
if (email && !existing.email) {
// Upgrade: fill in email + other missing fields
await prisma.lead.update({
where: { id: existing.id },
@@ -61,7 +52,8 @@ export async function sinkLeadToVault(
updatedAt: new Date(),
},
});
return existing.id;
}
return existing.id; // domain already exists → skip or upgraded
}
}
@@ -106,14 +98,15 @@ export async function sinkLeadsToVault(
const domain = lead.domain || null;
const email = lead.email || null;
if (domain) {
if (email) {
const exact = await prisma.lead.findFirst({ where: { domain, email } });
if (exact) { skipped++; continue; }
}
if (!domain) { skipped++; continue; } // no domain → skip entirely
const existing = await prisma.lead.findFirst({ where: { domain, email: null } });
if (existing && email) {
if (domain) {
// Strict domain dedup: one lead per domain
const existing = await prisma.lead.findFirst({ where: { domain } });
if (existing) {
if (email && !existing.email) {
// Upgrade: existing has no email, new one does → fill it in
await prisma.lead.update({
where: { id: existing.id },
data: {
@@ -126,6 +119,9 @@ export async function sinkLeadsToVault(
},
});
updated++;
} else {
skipped++; // domain already exists → skip
}
continue;
}
}

50
lib/services/openai.ts Normal file
View File

@@ -0,0 +1,50 @@
import OpenAI from "openai";
/**
* Generates an optimized alternative search query when the primary Maps search
* returned fewer results than requested. Uses GPT-4.1 to find synonyms,
* related terms, or slight regional variations that surface different businesses.
*
* Returns null if OpenAI is not configured or the call fails (caller should fall
* back to the original query).
*/
export async function generateSupplementQuery(
query: string,
region: string,
foundCount: number,
targetCount: number,
apiKey: string,
): Promise<string | null> {
const client = new OpenAI({ apiKey });
const searchQuery = region ? `${query} ${region}` : query;
try {
const response = await client.chat.completions.create({
model: "gpt-4.1",
messages: [
{
role: "system",
content:
"Du bist ein Experte für B2B-Lead-Generierung in Deutschland. " +
"Antworte immer nur mit der Suchanfrage selbst — keine Anführungszeichen, keine Erklärungen.",
},
{
role: "user",
content:
`Eine Google-Suche nach "${searchQuery}" hat nur ${foundCount} von ${targetCount} Unternehmen gefunden. ` +
`Erstelle eine alternative Suchanfrage (max. 6 Wörter), die weitere passende Unternehmen findet, ` +
`die die erste Suche nicht erfasst hat. Nutze Synonyme, verwandte Branchen-Begriffe oder ` +
`leichte Variationen der Region — aber halte den Fokus auf dieselbe Branche und Region.`,
},
],
max_tokens: 30,
temperature: 0.7,
});
const text = response.choices[0]?.message?.content?.trim();
return text || null;
} catch (err) {
console.error("OpenAI query generation failed:", err);
return null;
}
}

View File

@@ -6,6 +6,7 @@ const ENV_VARS: Record<string, string> = {
apify: "APIFY_API_KEY",
vayne: "VAYNE_API_KEY",
googlemaps: "GOOGLE_MAPS_API_KEY",
openai: "OPENAI_API_KEY",
};
export async function getApiKey(service: string): Promise<string | null> {

46
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{
"name": "leadflow",
"name": "onyva-leads",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "leadflow",
"name": "onyva-leads",
"version": "0.1.0",
"dependencies": {
"@base-ui/react": "^1.3.0",
@@ -44,6 +44,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.7",
"openai": "^6.33.0",
"tailwindcss": "^4",
"typescript": "^5"
}
@@ -115,6 +116,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -757,6 +759,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -812,7 +815,8 @@
"version": "0.3.15",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz",
"integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==",
"license": "Apache-2.0"
"license": "Apache-2.0",
"peer": true
},
"node_modules/@electric-sql/pglite-socket": {
"version": "0.0.20",
@@ -2129,6 +2133,7 @@
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -2891,6 +2896,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2977,6 +2983,7 @@
"integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.57.1",
"@typescript-eslint/types": "8.57.1",
@@ -3540,6 +3547,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4141,6 +4149,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5467,6 +5476,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5652,6 +5662,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -6855,6 +6866,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
"integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -9015,6 +9027,28 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openai": {
"version": "6.33.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.33.0.tgz",
"integrity": "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -9454,6 +9488,7 @@
"integrity": "sha512-n30qZpWehaYQzigLjmuPisyEsvOzHt7bZeRyg8gZ5DvJo9FGjD+gNaY59Ns3hlLD5/jZH5GBeftIss0jDbUoLg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/config": "7.5.0",
"@prisma/dev": "0.20.0",
@@ -9719,6 +9754,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9728,6 +9764,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -11080,6 +11117,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11371,6 +11409,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12009,6 +12048,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -45,6 +45,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.7",
"openai": "^6.33.0",
"tailwindcss": "^4",
"typescript": "^5"
}

View File

@@ -0,0 +1,67 @@
-- CreateTable
CREATE TABLE "SearchHistory" (
"id" TEXT NOT NULL PRIMARY KEY,
"query" TEXT NOT NULL,
"region" TEXT NOT NULL,
"searchMode" TEXT NOT NULL,
"executedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Lead" (
"id" TEXT NOT NULL PRIMARY KEY,
"domain" TEXT,
"companyName" TEXT,
"contactName" TEXT,
"contactTitle" TEXT,
"email" TEXT,
"linkedinUrl" TEXT,
"phone" TEXT,
"address" TEXT,
"sourceTab" TEXT NOT NULL,
"sourceTerm" TEXT,
"sourceJobId" TEXT,
"serpTitle" TEXT,
"serpSnippet" TEXT,
"serpRank" INTEGER,
"serpUrl" TEXT,
"emailConfidence" REAL,
"status" TEXT NOT NULL DEFAULT 'new',
"priority" TEXT NOT NULL DEFAULT 'normal',
"notes" TEXT,
"tags" TEXT,
"country" TEXT,
"headcount" TEXT,
"industry" TEXT,
"description" TEXT,
"companyType" TEXT,
"topics" TEXT,
"salesScore" INTEGER,
"salesReason" TEXT,
"offerPackage" TEXT,
"approved" BOOLEAN NOT NULL DEFAULT false,
"approvedAt" DATETIME,
"capturedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"contactedAt" DATETIME,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Lead" ("address", "capturedAt", "companyName", "contactName", "contactTitle", "contactedAt", "country", "description", "domain", "email", "emailConfidence", "headcount", "id", "industry", "linkedinUrl", "notes", "phone", "priority", "serpRank", "serpSnippet", "serpTitle", "serpUrl", "sourceJobId", "sourceTab", "sourceTerm", "status", "tags", "updatedAt") SELECT "address", "capturedAt", "companyName", "contactName", "contactTitle", "contactedAt", "country", "description", "domain", "email", "emailConfidence", "headcount", "id", "industry", "linkedinUrl", "notes", "phone", "priority", "serpRank", "serpSnippet", "serpTitle", "serpUrl", "sourceJobId", "sourceTab", "sourceTerm", "status", "tags", "updatedAt" FROM "Lead";
DROP TABLE "Lead";
ALTER TABLE "new_Lead" RENAME TO "Lead";
CREATE INDEX "Lead_domain_idx" ON "Lead"("domain");
CREATE INDEX "Lead_status_idx" ON "Lead"("status");
CREATE INDEX "Lead_sourceTab_idx" ON "Lead"("sourceTab");
CREATE INDEX "Lead_capturedAt_idx" ON "Lead"("capturedAt");
CREATE INDEX "Lead_email_idx" ON "Lead"("email");
CREATE INDEX "Lead_approved_idx" ON "Lead"("approved");
CREATE INDEX "Lead_companyType_idx" ON "Lead"("companyType");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE INDEX "SearchHistory_searchMode_idx" ON "SearchHistory"("searchMode");
-- CreateIndex
CREATE INDEX "SearchHistory_executedAt_idx" ON "SearchHistory"("executedAt");

View File

@@ -75,6 +75,14 @@ model Lead {
industry String?
description String?
companyType String?
topics String?
salesScore Int?
salesReason String?
offerPackage String?
approved Boolean @default(false)
approvedAt DateTime?
capturedAt DateTime @default(now())
contactedAt DateTime?
updatedAt DateTime @updatedAt
@@ -86,6 +94,19 @@ model Lead {
@@index([sourceTab])
@@index([capturedAt])
@@index([email])
@@index([approved])
@@index([companyType])
}
model SearchHistory {
id String @id @default(cuid())
query String
region String
searchMode String
executedAt DateTime @default(now())
@@index([searchMode])
@@index([executedAt])
}
model LeadEvent {

102
prisma/turso-schema.sql Normal file
View File

@@ -0,0 +1,102 @@
-- LeadFlow schema for Turso (combined migrations)
-- Apply with: turso db shell <db-name> < prisma/turso-schema.sql
CREATE TABLE IF NOT EXISTS "ApiCredential" (
"id" TEXT NOT NULL PRIMARY KEY,
"service" TEXT NOT NULL,
"value" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "ApiCredential_service_key" ON "ApiCredential"("service");
CREATE TABLE IF NOT EXISTS "Job" (
"id" TEXT NOT NULL PRIMARY KEY,
"type" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"config" TEXT NOT NULL DEFAULT '{}',
"totalLeads" INTEGER NOT NULL DEFAULT 0,
"emailsFound" INTEGER NOT NULL DEFAULT 0,
"error" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS "LeadResult" (
"id" TEXT NOT NULL PRIMARY KEY,
"jobId" TEXT NOT NULL,
"companyName" TEXT,
"domain" TEXT,
"contactName" TEXT,
"contactTitle" TEXT,
"email" TEXT,
"confidence" REAL,
"linkedinUrl" TEXT,
"source" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("jobId") REFERENCES "Job" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE IF NOT EXISTS "Lead" (
"id" TEXT NOT NULL PRIMARY KEY,
"domain" TEXT,
"companyName" TEXT,
"contactName" TEXT,
"contactTitle" TEXT,
"email" TEXT,
"linkedinUrl" TEXT,
"phone" TEXT,
"address" TEXT,
"sourceTab" TEXT NOT NULL,
"sourceTerm" TEXT,
"sourceJobId" TEXT,
"serpTitle" TEXT,
"serpSnippet" TEXT,
"serpRank" INTEGER,
"serpUrl" TEXT,
"emailConfidence" REAL,
"status" TEXT NOT NULL DEFAULT 'new',
"priority" TEXT NOT NULL DEFAULT 'normal',
"notes" TEXT,
"tags" TEXT,
"country" TEXT,
"headcount" TEXT,
"industry" TEXT,
"description" TEXT,
"companyType" TEXT,
"topics" TEXT,
"salesScore" INTEGER,
"salesReason" TEXT,
"offerPackage" TEXT,
"approved" INTEGER NOT NULL DEFAULT 0,
"approvedAt" DATETIME,
"capturedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"contactedAt" DATETIME,
"updatedAt" DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS "Lead_domain_idx" ON "Lead"("domain");
CREATE INDEX IF NOT EXISTS "Lead_status_idx" ON "Lead"("status");
CREATE INDEX IF NOT EXISTS "Lead_sourceTab_idx" ON "Lead"("sourceTab");
CREATE INDEX IF NOT EXISTS "Lead_capturedAt_idx" ON "Lead"("capturedAt");
CREATE INDEX IF NOT EXISTS "Lead_email_idx" ON "Lead"("email");
CREATE INDEX IF NOT EXISTS "Lead_approved_idx" ON "Lead"("approved");
CREATE INDEX IF NOT EXISTS "Lead_companyType_idx" ON "Lead"("companyType");
CREATE TABLE IF NOT EXISTS "LeadEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"leadId" TEXT NOT NULL,
"event" TEXT NOT NULL,
"at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("leadId") REFERENCES "Lead" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS "LeadEvent_leadId_idx" ON "LeadEvent"("leadId");
CREATE TABLE IF NOT EXISTS "SearchHistory" (
"id" TEXT NOT NULL PRIMARY KEY,
"query" TEXT NOT NULL,
"region" TEXT NOT NULL,
"searchMode" TEXT NOT NULL,
"executedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS "SearchHistory_searchMode_idx" ON "SearchHistory"("searchMode");
CREATE INDEX IF NOT EXISTS "SearchHistory_executedAt_idx" ON "SearchHistory"("executedAt");