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:
@@ -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 2–4 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 2–4 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);
|
||||
|
||||
173
app/api/enrich-leads/route.ts
Normal file
173
app/api/enrich-leads/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
108
app/api/generate-email/route.ts
Normal file
108
app/api/generate-email/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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]}`;
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
47
app/api/search-history/route.ts
Normal file
47
app/api/search-history/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user