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>
This commit is contained in:
Timo Uttenweiler
2026-04-08 21:06:07 +02:00
parent e5172cbdc5
commit 54e0d22f9c
14 changed files with 866 additions and 47 deletions

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
const SYSTEM_PROMPT = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschsprachigen Raum.
const SYSTEM_PROMPT_DEFAULT = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschsprachigen Raum.
## Was passiert mit deiner Ausgabe
@@ -36,45 +36,110 @@ Nutze mehrere Queries wenn:
Maximal 4 Queries. Keine Duplikate (gleiche query + gleiche region).
## Beispiele
Eingabe: "Dachdecker in Bayern"
Ausgabe:
[
{ "query": "Dachdecker", "region": "München" },
{ "query": "Dachdecker", "region": "Nürnberg" }
]
Eingabe: "Steuerberater in ganz Deutschland"
Ausgabe:
[
{ "query": "Steuerberater", "region": "Bayern" },
{ "query": "Steuerberater", "region": "NRW" },
{ "query": "Steuerberater", "region": "Hamburg" },
{ "query": "Steuerberater", "region": "Berlin" }
]
Eingabe: "Solaranlagen Installateure und Elektriker in Stuttgart"
Ausgabe:
[
{ "query": "Solaranlage", "region": "Stuttgart" },
{ "query": "Elektriker", "region": "Stuttgart" }
]
Eingabe: "Metallbaubetriebe in Süddeutschland"
Ausgabe:
[
{ "query": "Metallbau", "region": "Bayern" },
{ "query": "Metallbau", "region": "Baden-Württemberg" }
]
## 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 { description } = await req.json() as { description: string };
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 });
@@ -85,20 +150,22 @@ export async function POST(req: NextRequest) {
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://onvyaleads.app",
"X-Title": "OnyvaLeads",
"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: SYSTEM_PROMPT },
{ role: "system", content: systemPrompt },
{ role: "user", content: description.trim() },
],
}),
@@ -134,6 +201,18 @@ export async function POST(req: NextRequest) {
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);

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

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

@@ -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,
@@ -46,8 +48,14 @@ export async function GET(req: NextRequest) {
"LinkedIn": l.linkedinUrl || "",
"Branche": l.industry || "",
"Suchbegriff": l.sourceTerm || "",
"Tags": l.tags ? (JSON.parse(l.tags) as string[]).join(", ") : "",
"Erfasst am": new Date(l.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }),
"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" }),
}));
const filename = `onyva-leads-vault-${new Date().toISOString().split("T")[0]}`;

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 => ({

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 });
}
}

View File

@@ -41,6 +41,13 @@ interface Lead {
description: string | null;
capturedAt: string;
contactedAt: string | null;
companyType: string | null;
topics: string | null;
salesScore: number | null;
salesReason: string | null;
offerPackage: string | null;
approved: boolean;
approvedAt: string | null;
events?: Array<{ id: string; event: string; at: string }>;
}
@@ -115,12 +122,33 @@ function SidePanel({ lead, onClose, onUpdate, onDelete }: {
}) {
const tags: string[] = JSON.parse(lead.tags || "[]");
const src = SOURCE_CONFIG[lead.sourceTab];
const [emailLoading, setEmailLoading] = useState(false);
const [generatedEmail, setGeneratedEmail] = useState<{ subject: string; body: string } | null>(null);
function copy(text: string, label: string) {
navigator.clipboard.writeText(text);
toast.success(`${label} kopiert`);
}
async function generateEmail() {
setEmailLoading(true);
setGeneratedEmail(null);
try {
const res = await fetch("/api/generate-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ leadId: lead.id }),
});
const data = await res.json() as { subject?: string; body?: string; error?: string };
if (!res.ok || !data.subject) throw new Error(data.error || "Fehler");
setGeneratedEmail({ subject: data.subject, body: data.body! });
} catch (e) {
toast.error(e instanceof Error ? e.message : "E-Mail-Generierung fehlgeschlagen");
} finally {
setEmailLoading(false);
}
}
return (
<div className="fixed inset-0 z-40 flex justify-end" onClick={onClose}>
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
@@ -217,6 +245,77 @@ function SidePanel({ lead, onClose, onUpdate, onDelete }: {
</div>
)}
{/* KI-Bewertung */}
{(lead.salesScore || lead.offerPackage) && (
<div>
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2">KI-Bewertung</p>
<div className="space-y-1.5">
{lead.salesScore && (
<div className="flex items-center gap-2">
<div className="flex gap-0.5">
{[1,2,3,4,5].map(i => (
<span key={i} style={{
width: 8, height: 8, borderRadius: "50%", display: "inline-block",
background: i <= lead.salesScore! ? (lead.salesScore! >= 4 ? "#22c55e" : lead.salesScore! >= 3 ? "#f59e0b" : "#6b7280") : "#1e1e2e",
}} />
))}
</div>
{lead.salesReason && <span className="text-xs text-gray-400">{lead.salesReason}</span>}
</div>
)}
{lead.offerPackage && <span className="text-xs text-gray-400">Paket: <span className="text-gray-200">{lead.offerPackage}</span></span>}
{lead.topics && (() => {
try { return <span className="text-xs text-gray-400">Themen: <span className="text-gray-200">{(JSON.parse(lead.topics) as string[]).join(", ")}</span></span>; }
catch { return null; }
})()}
</div>
</div>
)}
{/* E-Mail Generator */}
{lead.email && (
<div>
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2">Erstansprache</p>
{!generatedEmail ? (
<button
onClick={generateEmail}
disabled={emailLoading}
style={{
width: "100%", padding: "8px 12px", borderRadius: 8, border: "1px solid rgba(139,92,246,0.35)",
background: "rgba(139,92,246,0.08)", color: "#a78bfa", fontSize: 12, cursor: emailLoading ? "not-allowed" : "pointer",
display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
}}
>
{emailLoading ? "✨ E-Mail wird generiert…" : "✨ E-Mail generieren"}
</button>
) : (
<div style={{ background: "#0d0d18", border: "1px solid #1e1e2e", borderRadius: 8, padding: 12 }}>
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 10, color: "#6b7280", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.05em" }}>Betreff</div>
<div style={{ display: "flex", gap: 6, alignItems: "flex-start" }}>
<span style={{ fontSize: 12, color: "#fff", flex: 1 }}>{generatedEmail.subject}</span>
<button onClick={() => copy(generatedEmail.subject, "Betreff")} style={{ background: "none", border: "none", color: "#6b7280", cursor: "pointer", padding: 2 }}>
<Copy className="w-3 h-3" />
</button>
</div>
</div>
<div>
<div style={{ fontSize: 10, color: "#6b7280", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.05em" }}>Nachricht</div>
<div style={{ display: "flex", gap: 6, alignItems: "flex-start" }}>
<pre style={{ fontSize: 11, color: "#d1d5db", whiteSpace: "pre-wrap", flex: 1, fontFamily: "inherit", lineHeight: 1.6, margin: 0 }}>{generatedEmail.body}</pre>
<button onClick={() => copy(generatedEmail.body, "E-Mail")} style={{ background: "none", border: "none", color: "#6b7280", cursor: "pointer", padding: 2, flexShrink: 0 }}>
<Copy className="w-3 h-3" />
</button>
</div>
</div>
<button onClick={() => setGeneratedEmail(null)} style={{ marginTop: 8, fontSize: 11, color: "#6b7280", background: "none", border: "none", cursor: "pointer" }}>
Neu generieren
</button>
</div>
)}
</div>
)}
{/* Company info */}
{(lead.headcount || lead.country) && (
<div>
@@ -490,6 +589,7 @@ export default function LeadVaultPage() {
{([
["Aktuelle Ansicht", () => exportFile("xlsx")],
["Nur mit E-Mail", () => exportFile("xlsx", true)],
["✓ Freigegebene exportieren", () => { const p = new URLSearchParams({ format: "xlsx", approvedOnly: "true" }); window.open(`/api/leads/export?${p}`, "_blank"); }],
] as [string, () => void][]).map(([label, fn]) => (
<button key={label} onClick={() => { fn(); setExportOpen(false); }}
className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-[#2e2e3e] rounded">
@@ -686,6 +786,8 @@ export default function LeadVaultPage() {
</button>
</th>
))}
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500">Bewertung</th>
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500">Paket</th>
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500">Tags</th>
<th className="px-3 py-2.5 text-right text-xs font-medium text-gray-500">Aktionen</th>
</tr>
@@ -797,6 +899,39 @@ export default function LeadVaultPage() {
<td className="px-3 py-2.5 whitespace-nowrap" title={new Date(lead.capturedAt).toLocaleString("de-DE")}>
<span className="text-xs text-gray-500">{new Date(lead.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" })}</span>
</td>
{/* Bewertung */}
<td className="px-3 py-2.5" title={lead.salesReason || ""}>
{lead.salesScore ? (
<div className="flex gap-0.5">
{[1,2,3,4,5].map(i => (
<span key={i} style={{
width: 7, height: 7, borderRadius: "50%",
background: i <= lead.salesScore!
? (lead.salesScore! >= 4 ? "#22c55e" : lead.salesScore! >= 3 ? "#f59e0b" : "#6b7280")
: "#1e1e2e",
display: "inline-block",
}} />
))}
</div>
) : <span className="text-xs text-gray-600"></span>}
</td>
{/* Paket */}
<td className="px-3 py-2.5">
{lead.offerPackage ? (() => {
const pkgMap: Record<string, { label: string; style: React.CSSProperties }> = {
"solar-basis": { label: "Basis", style: { background: "#1e1e2e", color: "#9ca3af" } },
"solar-pro": { label: "Pro", style: { background: "#1e3a5f", color: "#93c5fd" } },
"solar-speicher": { label: "Solar+Speicher", style: { background: "#2e1065", color: "#d8b4fe" } },
"komplett": { label: "Komplett", style: { background: "#064e3b", color: "#6ee7b7" } },
};
const pkg = pkgMap[lead.offerPackage] ?? { label: lead.offerPackage, style: { background: "#1e1e2e", color: "#9ca3af" } };
return (
<span style={{ ...pkg.style, fontSize: 10, padding: "2px 7px", borderRadius: 10, whiteSpace: "nowrap" }}>
{pkg.label}
</span>
);
})() : <span className="text-xs text-gray-600"></span>}
</td>
<td className="px-3 py-2.5">
<div className="flex gap-1 flex-wrap max-w-[120px]">
{tags.slice(0, 2).map(tag => (
@@ -837,6 +972,20 @@ export default function LeadVaultPage() {
: "p-1 rounded text-gray-600 hover:text-amber-400 hover:bg-amber-500/10 transition-all"}>
<Star className="w-3.5 h-3.5" />
</button>
<button
onClick={e => {
e.stopPropagation();
updateLead(lead.id, { approved: !lead.approved, approvedAt: !lead.approved ? new Date().toISOString() : null });
}}
title={lead.approved ? "Freigegeben" : "Freigeben"}
className={lead.approved
? "p-1 rounded text-green-400 bg-green-500/10 transition-all"
: "p-1 rounded text-gray-600 hover:text-green-400 hover:bg-green-500/10 transition-all"}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
</button>
{lead.domain && (
<a href={`https://${lead.domain}`} target="_blank" rel="noreferrer"
title="Website öffnen"

View File

@@ -20,6 +20,17 @@ export default function SuchePage() {
const [onlyNew, setOnlyNew] = useState(false);
const [saveOnlyNew, setSaveOnlyNew] = useState(false);
const [aiOpen, setAiOpen] = useState(false);
const [searchMode, setSearchMode] = useState<"stadtwerke" | "industrie" | "custom">("stadtwerke");
const [queueRunning, setQueueRunning] = useState(false);
const [queueIndex, setQueueIndex] = useState(0);
const [queueTotal, setQueueTotal] = useState(0);
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);
@@ -59,12 +70,67 @@ export default function SuchePage() {
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 startStadtwerkeQueue() {
if (loading || queueRunning) return;
setQueueRunning(true);
setQueueTotal(BUNDESLAENDER.length);
setQueueIndex(0);
setLeads([]);
setSearchDone(false);
for (let i = 0; i < BUNDESLAENDER.length; i++) {
setQueueIndex(i + 1);
const bl = BUNDESLAENDER[i];
toast.info(`Suche ${i + 1}/${BUNDESLAENDER.length}: Stadtwerke ${bl}`, { duration: 2000 });
try {
const res = await fetch("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: "Stadtwerke", region: bl, count: 50 }),
});
const data = await res.json() as { jobId?: string };
if (data.jobId) {
// Save to history
fetch("/api/search-history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: "Stadtwerke", region: bl, searchMode: "stadtwerke" }),
}).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 */ }
}
setQueueRunning(false);
toast.success(`✓ Alle ${BUNDESLAENDER.length} Bundesländer durchsucht — Leads im Leadspeicher`, { duration: 5000 });
}
async function handleDelete(ids: string[]) {
if (!ids.length || deleting) return;
@@ -162,12 +228,72 @@ export default function SuchePage() {
</div>
</div>
{/* Mode Tabs */}
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
{([
{ id: "stadtwerke" as const, icon: "⚡", label: "Stadtwerke", desc: "Kommunale Energieversorger" },
{ id: "industrie" as const, icon: "🏭", label: "Industriebetriebe", desc: "Energieintensive Betriebe" },
{ id: "custom" as const, icon: "🔍", label: "Freie Suche", desc: "Beliebige Zielgruppe" },
]).map(tab => (
<button
key={tab.id}
onClick={() => setSearchMode(tab.id)}
style={{
flex: 1, padding: "12px 16px", borderRadius: 10, cursor: "pointer", textAlign: "left",
border: searchMode === tab.id ? "1px solid rgba(59,130,246,0.5)" : "1px solid #1e1e2e",
background: searchMode === tab.id ? "rgba(59,130,246,0.08)" : "#111118",
transition: "all 0.15s",
}}
>
<div style={{ fontSize: 16, marginBottom: 4 }}>{tab.icon}</div>
<div style={{ fontSize: 13, fontWeight: 500, color: searchMode === tab.id ? "#93c5fd" : "#fff", marginBottom: 2 }}>{tab.label}</div>
<div style={{ fontSize: 11, color: "#6b7280" }}>{tab.desc}</div>
</button>
))}
</div>
{/* Stadtwerke Queue Button */}
{searchMode === "stadtwerke" && !loading && !queueRunning && (
<button
onClick={startStadtwerkeQueue}
style={{
width: "100%", padding: "14px 20px", borderRadius: 10, marginBottom: 12,
border: "1px solid rgba(59,130,246,0.4)", background: "rgba(59,130,246,0.06)",
color: "#60a5fa", cursor: "pointer", textAlign: "left",
display: "flex", flexDirection: "column", gap: 4,
}}
onMouseEnter={e => { e.currentTarget.style.background = "rgba(59,130,246,0.12)"; }}
onMouseLeave={e => { e.currentTarget.style.background = "rgba(59,130,246,0.06)"; }}
>
<div style={{ fontSize: 13, fontWeight: 500 }}> Alle deutschen Stadtwerke durchsuchen (16 Bundesländer)</div>
<div style={{ fontSize: 11, color: "#6b7280" }}>Startet 16 aufeinanderfolgende Suchen · ca. 800 Ergebnisse</div>
</button>
)}
{/* Queue running indicator */}
{queueRunning && (
<div style={{
padding: "14px 20px", borderRadius: 10, marginBottom: 12,
border: "1px solid rgba(59,130,246,0.3)", background: "rgba(59,130,246,0.06)",
display: "flex", alignItems: "center", gap: 12,
}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth="2" style={{ animation: "spin 1s linear infinite", flexShrink: 0 }}>
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
<div>
<div style={{ fontSize: 13, color: "#93c5fd", fontWeight: 500 }}>Bundesland {queueIndex} von {queueTotal} wird durchsucht</div>
<div style={{ fontSize: 11, color: "#6b7280", marginTop: 2 }}>Nicht schließen Leads werden automatisch gespeichert</div>
</div>
</div>
)}
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
{/* Search Card */}
<SearchCard
query={query}
region={region}
count={count}
loading={loading}
loading={loading || queueRunning}
onChange={handleChange}
onSubmit={handleSubmit}
/>
@@ -188,10 +314,10 @@ export default function SuchePage() {
{/* AI Modal */}
{aiOpen && (
<AiSearchModal
searchMode={searchMode}
onStart={(queries) => {
setAiOpen(false);
if (!queries.length) return;
// Fill first query into the search fields and submit
const first = queries[0];
setQuery(first.query);
setRegion(first.region);
@@ -201,6 +327,12 @@ export default function SuchePage() {
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 }),
}).catch(() => {});
fetch("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },