Compare commits
31 Commits
main
...
mein-solar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85cc6efc19 | ||
|
|
2c9afe76cf | ||
|
|
3063c0860d | ||
|
|
b01d14b784 | ||
|
|
807b82f633 | ||
|
|
7db914084e | ||
|
|
54e0d22f9c | ||
|
|
e5172cbdc5 | ||
|
|
edbf8cb1e2 | ||
|
|
0f5d18dac7 | ||
|
|
a39a98b6dc | ||
|
|
1719062b47 | ||
|
|
f94f7c6314 | ||
|
|
6264ebf786 | ||
|
|
25234b70ee | ||
|
|
929d5ab3a1 | ||
|
|
89a176700d | ||
|
|
aa6707b8bc | ||
|
|
c232f0cb79 | ||
|
|
11197c9db1 | ||
|
|
4c82e96f5a | ||
|
|
a1d2c34f36 | ||
|
|
bf3fcd4210 | ||
|
|
8c00d4ea6f | ||
|
|
b2a963a901 | ||
|
|
6b7ac42d1d | ||
|
|
9939ba9fdb | ||
|
|
ea3138ac64 | ||
|
|
a67dd2cc8c | ||
|
|
df90477bef | ||
|
|
60073b97c9 |
@@ -12,7 +12,8 @@ out
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
/data
|
/data
|
||||||
|
|
||||||
# Environment files (injected at runtime)
|
# Environment files (injected at runtime via Coolify)
|
||||||
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
APP_ENCRYPTION_SECRET=437065e334849562d991112d74e23653
|
APP_ENCRYPTION_SECRET=32 Zeichen
|
||||||
|
|
||||||
# Lokal (wird ignoriert wenn TURSO_DATABASE_URL gesetzt ist)
|
# Lokal (wird ignoriert wenn TURSO_DATABASE_URL gesetzt ist)
|
||||||
DATABASE_URL=file:./leadflow.db
|
DATABASE_URL=file:./leadflow.db
|
||||||
@@ -12,3 +12,4 @@ ANYMAILFINDER_API_KEY=
|
|||||||
APIFY_API_KEY=
|
APIFY_API_KEY=
|
||||||
VAYNE_API_KEY=
|
VAYNE_API_KEY=
|
||||||
GOOGLE_MAPS_API_KEY=
|
GOOGLE_MAPS_API_KEY=
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ RUN npm run build
|
|||||||
FROM node:22-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
WORKDIR /app
|
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 NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
@@ -66,8 +66,7 @@ RUN chmod +x docker-entrypoint.sh
|
|||||||
# Data directory for SQLite — must be a volume
|
# Data directory for SQLite — must be a volume
|
||||||
RUN mkdir -p /data && chown nextjs:nodejs /data
|
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
|
EXPOSE 3000
|
||||||
|
|
||||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
|
|||||||
221
app/api/ai-search/route.ts
Normal file
221
app/api/ai-search/route.ts
Normal 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 2–4 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 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 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,21 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
if (!job) return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
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({
|
return NextResponse.json({
|
||||||
id: job.id,
|
id: job.id,
|
||||||
type: job.type,
|
type: job.type,
|
||||||
@@ -49,6 +64,7 @@ export async function GET(
|
|||||||
address,
|
address,
|
||||||
phone,
|
phone,
|
||||||
createdAt: r.createdAt,
|
createdAt: r.createdAt,
|
||||||
|
isNew: !r.domain || !preExistingDomains.has(r.domain),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ async function runMapsEnrich(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 2. Store raw Google Maps results + immediately sink to LeadVault
|
// 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({
|
await prisma.leadResult.create({
|
||||||
data: {
|
data: {
|
||||||
jobId,
|
jobId,
|
||||||
@@ -94,7 +98,7 @@ async function runMapsEnrich(
|
|||||||
// Sink Google Maps results to vault immediately (before enrichment)
|
// Sink Google Maps results to vault immediately (before enrichment)
|
||||||
// so they're available even if Anymailfinder fails
|
// so they're available even if Anymailfinder fails
|
||||||
await sinkLeadsToVault(
|
await sinkLeadsToVault(
|
||||||
places.map(p => ({
|
placesWithDomain.map(p => ({
|
||||||
domain: p.domain,
|
domain: p.domain,
|
||||||
companyName: p.name || null,
|
companyName: p.name || null,
|
||||||
phone: p.phone,
|
phone: p.phone,
|
||||||
@@ -108,12 +112,18 @@ async function runMapsEnrich(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 3. Optionally enrich with Anymailfinder
|
// 3. Optionally enrich with Anymailfinder
|
||||||
if (params.enrichEmails && places.length > 0) {
|
if (params.enrichEmails && placesWithDomain.length > 0) {
|
||||||
const anymailKey = await getApiKey("anymailfinder");
|
const anymailKey = await getApiKey("anymailfinder");
|
||||||
if (!anymailKey) throw new Error("Anymailfinder API-Key fehlt — bitte in den Einstellungen eintragen");
|
if (!anymailKey) {
|
||||||
const domains = places.filter(p => p.domain).map(p => p.domain!);
|
// 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 domainToResultId = new Map<string, string>();
|
||||||
const existingResults = await prisma.leadResult.findMany({
|
const existingResults = await prisma.leadResult.findMany({
|
||||||
where: { jobId },
|
where: { jobId },
|
||||||
@@ -155,7 +165,7 @@ async function runMapsEnrich(
|
|||||||
|
|
||||||
await prisma.job.update({
|
await prisma.job.update({
|
||||||
where: { id: jobId },
|
where: { id: jobId },
|
||||||
data: { status: "complete", emailsFound, totalLeads: places.length },
|
data: { status: "complete", emailsFound, totalLeads: placesWithDomain.length },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update vault entries with enrichment results
|
// Update vault entries with enrichment results
|
||||||
@@ -173,6 +183,14 @@ async function runMapsEnrich(
|
|||||||
params.queries.join(", "),
|
params.queries.join(", "),
|
||||||
jobId,
|
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 {
|
} else {
|
||||||
await prisma.job.update({
|
await prisma.job.update({
|
||||||
where: { id: jobId },
|
where: { id: jobId },
|
||||||
|
|||||||
@@ -97,7 +97,35 @@ async function runSerpEnrich(
|
|||||||
data: { totalLeads: domains.length },
|
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(
|
const enrichResults = await bulkSearchDomains(
|
||||||
domains,
|
domains,
|
||||||
params.categories,
|
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;
|
let emailsFound = 0;
|
||||||
for (const result of enrichResults) {
|
for (const result of enrichResults) {
|
||||||
const serpData = serpMap.get(result.domain || "");
|
|
||||||
const hasEmail = !!result.valid_email;
|
const hasEmail = !!result.valid_email;
|
||||||
if (hasEmail) emailsFound++;
|
if (hasEmail) emailsFound++;
|
||||||
|
|
||||||
await prisma.leadResult.create({
|
const resultId = domainToResultId.get(result.domain || "");
|
||||||
|
if (resultId) {
|
||||||
|
await prisma.leadResult.update({
|
||||||
|
where: { id: resultId },
|
||||||
data: {
|
data: {
|
||||||
jobId,
|
|
||||||
companyName: serpData?.title || null,
|
|
||||||
domain: result.domain || null,
|
|
||||||
contactName: result.person_full_name || null,
|
contactName: result.person_full_name || null,
|
||||||
contactTitle: result.person_job_title || null,
|
contactTitle: result.person_job_title || null,
|
||||||
email: result.email || null,
|
email: result.email || null,
|
||||||
linkedinUrl: result.person_linkedin_url || 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({
|
await prisma.job.update({
|
||||||
where: { id: jobId },
|
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(
|
await sinkLeadsToVault(
|
||||||
enrichResults.map(r => {
|
enrichResults
|
||||||
|
.filter(r => r.email)
|
||||||
|
.map(r => {
|
||||||
const serpData = serpMap.get(r.domain || "");
|
const serpData = serpMap.get(r.domain || "");
|
||||||
return {
|
return {
|
||||||
domain: r.domain || null,
|
domain: r.domain || null,
|
||||||
@@ -159,6 +192,14 @@ async function runSerpEnrich(
|
|||||||
params.query,
|
params.query,
|
||||||
jobId,
|
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) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
await prisma.job.update({
|
await prisma.job.update({
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
|
|||||||
"status", "priority", "notes", "tags", "country", "headcount",
|
"status", "priority", "notes", "tags", "country", "headcount",
|
||||||
"industry", "contactedAt", "companyName", "contactName", "contactTitle",
|
"industry", "contactedAt", "companyName", "contactName", "contactTitle",
|
||||||
"email", "phone", "linkedinUrl", "domain",
|
"email", "phone", "linkedinUrl", "domain",
|
||||||
|
"companyType", "topics", "salesScore", "salesReason", "offerPackage",
|
||||||
|
"approved", "approvedAt",
|
||||||
];
|
];
|
||||||
|
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
|
|||||||
36
app/api/leads/delete-from-results/route.ts
Normal file
36
app/api/leads/delete-from-results/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,11 +23,13 @@ export async function GET(req: NextRequest) {
|
|||||||
{ email: { contains: search } },
|
{ email: { contains: search } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
const approvedOnly = searchParams.get("approvedOnly") === "true";
|
||||||
if (statuses.length > 0) where.status = { in: statuses };
|
if (statuses.length > 0) where.status = { in: statuses };
|
||||||
if (sourceTabs.length > 0) where.sourceTab = { in: sourceTabs };
|
if (sourceTabs.length > 0) where.sourceTab = { in: sourceTabs };
|
||||||
if (priorities.length > 0) where.priority = { in: priorities };
|
if (priorities.length > 0) where.priority = { in: priorities };
|
||||||
if (hasEmail === "yes" || emailOnly) where.email = { not: null };
|
if (hasEmail === "yes" || emailOnly) where.email = { not: null };
|
||||||
else if (hasEmail === "no") where.email = null;
|
else if (hasEmail === "no") where.email = null;
|
||||||
|
if (approvedOnly) where.approved = true;
|
||||||
|
|
||||||
const leads = await prisma.lead.findMany({
|
const leads = await prisma.lead.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -47,6 +49,12 @@ export async function GET(req: NextRequest) {
|
|||||||
"Branche": l.industry || "",
|
"Branche": l.industry || "",
|
||||||
"Suchbegriff": l.sourceTerm || "",
|
"Suchbegriff": l.sourceTerm || "",
|
||||||
"Tags": l.tags ? (JSON.parse(l.tags) as string[]).join(", ") : "",
|
"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" }),
|
"Erfasst am": new Date(l.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export async function GET(req: NextRequest) {
|
|||||||
const searchTerms = searchParams.getAll("searchTerm");
|
const searchTerms = searchParams.getAll("searchTerm");
|
||||||
const contacted = searchParams.get("contacted");
|
const contacted = searchParams.get("contacted");
|
||||||
const favorite = searchParams.get("favorite");
|
const favorite = searchParams.get("favorite");
|
||||||
|
const approvedOnly = searchParams.get("approvedOnly");
|
||||||
|
const companyType = searchParams.get("companyType");
|
||||||
|
|
||||||
const where: Prisma.LeadWhereInput = {};
|
const where: Prisma.LeadWhereInput = {};
|
||||||
|
|
||||||
@@ -61,6 +63,9 @@ export async function GET(req: NextRequest) {
|
|||||||
where.tags = { contains: "favorit" };
|
where.tags = { contains: "favorit" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (approvedOnly === "true") where.approved = true;
|
||||||
|
if (companyType) where.companyType = companyType;
|
||||||
|
|
||||||
if (tags.length > 0) {
|
if (tags.length > 0) {
|
||||||
// SQLite JSON contains — search for each tag in the JSON string
|
// SQLite JSON contains — search for each tag in the JSON string
|
||||||
where.AND = tags.map(tag => ({
|
where.AND = tags.map(tag => ({
|
||||||
@@ -74,7 +79,7 @@ export async function GET(req: NextRequest) {
|
|||||||
};
|
};
|
||||||
const orderByField = validSortFields[sortBy] ? sortBy : "capturedAt";
|
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.count({ where }),
|
||||||
prisma.lead.findMany({
|
prisma.lead.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -82,8 +87,11 @@ export async function GET(req: NextRequest) {
|
|||||||
skip: (page - 1) * perPage,
|
skip: (page - 1) * perPage,
|
||||||
take: perPage,
|
take: perPage,
|
||||||
}),
|
}),
|
||||||
|
prisma.lead.count(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
console.log(`[leads API] rawCount=${rawCount} total=${total} leads=${leads.length} where=${JSON.stringify(where)}`);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
leads,
|
leads,
|
||||||
total,
|
total,
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { prisma } from "@/lib/db";
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const [total, newLeads, contacted, withEmail] = await Promise.all([
|
const [total, contacted, withEmail] = await Promise.all([
|
||||||
prisma.lead.count(),
|
prisma.lead.count(),
|
||||||
prisma.lead.count({ where: { status: "new" } }),
|
prisma.lead.count({ where: { tags: { contains: "kontaktiert" } } }),
|
||||||
prisma.lead.count({ where: { status: { in: ["contacted", "in_progress"] } } }),
|
|
||||||
prisma.lead.count({ where: { email: { not: null } } }),
|
prisma.lead.count({ where: { email: { not: null } } }),
|
||||||
]);
|
]);
|
||||||
|
const newLeads = total - contacted;
|
||||||
|
|
||||||
// Daily counts for last 7 days
|
// Daily counts for last 7 days
|
||||||
const sevenDaysAgo = new Date();
|
const sevenDaysAgo = new Date();
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/api/search/route.ts
Normal file
40
app/api/search/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/api/search/supplement/route.ts
Normal file
59
app/api/search/supplement/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/api/stadtwerke-cities/route.ts
Normal file
84
app/api/stadtwerke-cities/route.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
113
app/globals.css
113
app/globals.css
@@ -1,21 +1,80 @@
|
|||||||
@import "tailwindcss";
|
@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 {
|
@keyframes shimmer {
|
||||||
0% { transform: translateX(-100%); }
|
0% { transform: translateX(-100%); }
|
||||||
100% { transform: translateX(400%); }
|
100% { transform: translateX(400%); }
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #0a0a0f;
|
--background: #0d1419;
|
||||||
--card: #111118;
|
--card: #161c21;
|
||||||
--border: #1e1e2e;
|
--border: #414754;
|
||||||
--primary: #3b82f6;
|
--primary: #adc7ff;
|
||||||
--secondary: #8b5cf6;
|
--secondary: #fb8c00;
|
||||||
--success: #22c55e;
|
--success: #a0d82c;
|
||||||
--warning: #f59e0b;
|
--warning: #ffb77b;
|
||||||
--error: #ef4444;
|
--error: #ffb4ab;
|
||||||
--foreground: #f0f0f5;
|
--foreground: #dce3ea;
|
||||||
--muted: #6b7280;
|
--muted: #c1c6d6;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -23,13 +82,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--background);
|
background-color: #0d1419;
|
||||||
color: var(--foreground);
|
color: #dce3ea;
|
||||||
font-family: var(--font-inter), Inter, system-ui, -apple-system, sans-serif;
|
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 */
|
/* Scrollbar */
|
||||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
::-webkit-scrollbar-track { background: #0a0a0f; }
|
::-webkit-scrollbar-track { background: #0d1419; }
|
||||||
::-webkit-scrollbar-thumb { background: #1e1e2e; border-radius: 3px; }
|
::-webkit-scrollbar-thumb { background: #414754; border-radius: 3px; }
|
||||||
::-webkit-scrollbar-thumb:hover { background: #3b82f6; }
|
::-webkit-scrollbar-thumb:hover { background: #adc7ff; }
|
||||||
|
|
||||||
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||||
|
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
|||||||
@@ -1,30 +1,29 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
|
||||||
import "./globals.css";
|
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";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "OnyvaLeads — Lead Generation Platform",
|
title: "OnyvaLeads",
|
||||||
description: "Unified lead generation and email enrichment platform",
|
description: "Leads finden und verwalten",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="dark">
|
<html lang="de" className="dark">
|
||||||
<body className={`${inter.variable} antialiased`}>
|
<head>
|
||||||
<div className="flex h-screen overflow-hidden bg-[#0a0a0f]">
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<Sidebar />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
<div className="flex flex-col flex-1 overflow-hidden">
|
<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" />
|
||||||
<TopBar />
|
<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" />
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
</head>
|
||||||
|
<body className="antialiased">
|
||||||
|
<div className="flex flex-col min-h-screen" style={{ background: "#0d1419" }}>
|
||||||
|
<Topbar />
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<Toaster position="bottom-right" theme="dark" />
|
<Toaster position="bottom-right" theme="dark" />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
1088
app/leadspeicher/page.tsx
Normal file
1088
app/leadspeicher/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
redirect("/airscale");
|
redirect("/suche");
|
||||||
}
|
}
|
||||||
|
|||||||
850
app/suche/page.tsx
Normal file
850
app/suche/page.tsx
Normal 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 &<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 & 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- & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,38 +1,84 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useAppStore } from "@/lib/store";
|
import { useEffect, useState } from "react";
|
||||||
import { Activity } from "lucide-react";
|
|
||||||
|
|
||||||
const BREADCRUMBS: Record<string, string> = {
|
export function Topbar() {
|
||||||
"/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() {
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { activeJobs } = useAppStore();
|
const [newLeadsCount, setNewLeadsCount] = useState(0);
|
||||||
const runningJobs = activeJobs.filter(j => j.status === "running").length;
|
|
||||||
const label = BREADCRUMBS[pathname] || "Dashboard";
|
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 (
|
return (
|
||||||
<header className="h-14 border-b border-[#1e1e2e] bg-[#111118]/80 backdrop-blur flex items-center justify-between px-6 flex-shrink-0">
|
<header className="sticky top-0 w-full z-50 flex justify-between items-center px-8 h-16"
|
||||||
<div className="flex items-center gap-2 text-sm">
|
style={{
|
||||||
<span className="text-gray-500">OnyvaLeads</span>
|
background: "rgba(13, 20, 25, 0.85)",
|
||||||
<span className="text-gray-600">/</span>
|
backdropFilter: "blur(20px)",
|
||||||
<span className="text-white font-medium">{label}</span>
|
WebkitBackdropFilter: "blur(20px)",
|
||||||
</div>
|
boxShadow: "0 20px 40px rgba(0,0,0,0.4)",
|
||||||
{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" />
|
{/* Left: Logo + Nav */}
|
||||||
<span className="text-xs text-blue-400 font-medium">{runningJobs} Active Job{runningJobs > 1 ? "s" : ""}</span>
|
<div className="flex items-center gap-8">
|
||||||
</div>
|
<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>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
125
components/leadspeicher/BulkActionBar.tsx
Normal file
125
components/leadspeicher/BulkActionBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
412
components/leadspeicher/LeadSidePanel.tsx
Normal file
412
components/leadspeicher/LeadSidePanel.tsx
Normal 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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
335
components/leadspeicher/LeadsTable.tsx
Normal file
335
components/leadspeicher/LeadsTable.tsx
Normal 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));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
components/leadspeicher/LeadsToolbar.tsx
Normal file
136
components/leadspeicher/LeadsToolbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
components/leadspeicher/StatusBadge.tsx
Normal file
54
components/leadspeicher/StatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
components/leadspeicher/StatusPopover.tsx
Normal file
82
components/leadspeicher/StatusPopover.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
components/search/AiSearchModal.tsx
Normal file
232
components/search/AiSearchModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
components/search/ExamplePills.tsx
Normal file
53
components/search/ExamplePills.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
351
components/search/LoadingCard.tsx
Normal file
351
components/search/LoadingCard.tsx
Normal 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:
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
components/search/SearchCard.tsx
Normal file
162
components/search/SearchCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Run Prisma migrations on every startup (idempotent)
|
if [ -n "$TURSO_DATABASE_URL" ]; then
|
||||||
echo "Running database migrations..."
|
echo "Turso database configured — skipping local migrations."
|
||||||
DATABASE_URL="${DATABASE_URL:-file:/data/leadflow.db}" \
|
else
|
||||||
node node_modules/prisma/build/index.js migrate deploy \
|
# 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}" \
|
||||||
|
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)"
|
--schema ./prisma/schema.prisma 2>&1 || echo "Migration warning (may already be up to date)"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Starting LeadFlow..."
|
echo "Starting LeadFlow..."
|
||||||
exec node server.js
|
exec su-exec nextjs node server.js
|
||||||
|
|||||||
161
lib/data/stadtwerke-cities.ts
Normal file
161
lib/data/stadtwerke-cities.ts
Normal 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);
|
||||||
@@ -35,20 +35,11 @@ export async function sinkLeadToVault(
|
|||||||
const email = lead.email || null;
|
const email = lead.email || null;
|
||||||
|
|
||||||
if (domain) {
|
if (domain) {
|
||||||
// Check for exact duplicate (same domain + same email)
|
// Strict domain dedup: one lead per domain
|
||||||
if (email) {
|
const existing = await prisma.lead.findFirst({ where: { domain } });
|
||||||
const exact = await prisma.lead.findFirst({
|
|
||||||
where: { domain, email },
|
|
||||||
});
|
|
||||||
if (exact) return exact.id; // already exists, skip
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if same domain exists without email and we now have one
|
if (existing) {
|
||||||
const existing = await prisma.lead.findFirst({
|
if (email && !existing.email) {
|
||||||
where: { domain, email: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing && email) {
|
|
||||||
// Upgrade: fill in email + other missing fields
|
// Upgrade: fill in email + other missing fields
|
||||||
await prisma.lead.update({
|
await prisma.lead.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
@@ -61,7 +52,8 @@ export async function sinkLeadToVault(
|
|||||||
updatedAt: new Date(),
|
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 domain = lead.domain || null;
|
||||||
const email = lead.email || null;
|
const email = lead.email || null;
|
||||||
|
|
||||||
if (domain) {
|
if (!domain) { skipped++; continue; } // no domain → skip entirely
|
||||||
if (email) {
|
|
||||||
const exact = await prisma.lead.findFirst({ where: { domain, email } });
|
|
||||||
if (exact) { skipped++; continue; }
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await prisma.lead.findFirst({ where: { domain, email: null } });
|
if (domain) {
|
||||||
if (existing && email) {
|
// 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({
|
await prisma.lead.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -126,6 +119,9 @@ export async function sinkLeadsToVault(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
updated++;
|
updated++;
|
||||||
|
} else {
|
||||||
|
skipped++; // domain already exists → skip
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
lib/services/openai.ts
Normal file
50
lib/services/openai.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ const ENV_VARS: Record<string, string> = {
|
|||||||
apify: "APIFY_API_KEY",
|
apify: "APIFY_API_KEY",
|
||||||
vayne: "VAYNE_API_KEY",
|
vayne: "VAYNE_API_KEY",
|
||||||
googlemaps: "GOOGLE_MAPS_API_KEY",
|
googlemaps: "GOOGLE_MAPS_API_KEY",
|
||||||
|
openai: "OPENAI_API_KEY",
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getApiKey(service: string): Promise<string | null> {
|
export async function getApiKey(service: string): Promise<string | null> {
|
||||||
|
|||||||
46
package-lock.json
generated
46
package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "leadflow",
|
"name": "onyva-leads",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "leadflow",
|
"name": "onyva-leads",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.3.0",
|
"@base-ui/react": "^1.3.0",
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.7",
|
"eslint-config-next": "16.1.7",
|
||||||
|
"openai": "^6.33.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
@@ -115,6 +116,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -757,6 +759,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -812,7 +815,8 @@
|
|||||||
"version": "0.3.15",
|
"version": "0.3.15",
|
||||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz",
|
||||||
"integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==",
|
"integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@electric-sql/pglite-socket": {
|
"node_modules/@electric-sql/pglite-socket": {
|
||||||
"version": "0.0.20",
|
"version": "0.0.20",
|
||||||
@@ -2129,6 +2133,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^14.21.3 || >=16"
|
"node": "^14.21.3 || >=16"
|
||||||
},
|
},
|
||||||
@@ -2891,6 +2896,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -2977,6 +2983,7 @@
|
|||||||
"integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==",
|
"integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.57.1",
|
"@typescript-eslint/scope-manager": "8.57.1",
|
||||||
"@typescript-eslint/types": "8.57.1",
|
"@typescript-eslint/types": "8.57.1",
|
||||||
@@ -3540,6 +3547,7 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4141,6 +4149,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -5467,6 +5476,7 @@
|
|||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -5652,6 +5662,7 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -6855,6 +6866,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
|
||||||
"integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==",
|
"integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
@@ -9015,6 +9027,28 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -9454,6 +9488,7 @@
|
|||||||
"integrity": "sha512-n30qZpWehaYQzigLjmuPisyEsvOzHt7bZeRyg8gZ5DvJo9FGjD+gNaY59Ns3hlLD5/jZH5GBeftIss0jDbUoLg==",
|
"integrity": "sha512-n30qZpWehaYQzigLjmuPisyEsvOzHt7bZeRyg8gZ5DvJo9FGjD+gNaY59Ns3hlLD5/jZH5GBeftIss0jDbUoLg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/config": "7.5.0",
|
"@prisma/config": "7.5.0",
|
||||||
"@prisma/dev": "0.20.0",
|
"@prisma/dev": "0.20.0",
|
||||||
@@ -9719,6 +9754,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -9728,6 +9764,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -11080,6 +11117,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -11371,6 +11409,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -12009,6 +12048,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.7",
|
"eslint-config-next": "16.1.7",
|
||||||
|
"openai": "^6.33.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -75,6 +75,14 @@ model Lead {
|
|||||||
industry String?
|
industry String?
|
||||||
description String?
|
description String?
|
||||||
|
|
||||||
|
companyType String?
|
||||||
|
topics String?
|
||||||
|
salesScore Int?
|
||||||
|
salesReason String?
|
||||||
|
offerPackage String?
|
||||||
|
approved Boolean @default(false)
|
||||||
|
approvedAt DateTime?
|
||||||
|
|
||||||
capturedAt DateTime @default(now())
|
capturedAt DateTime @default(now())
|
||||||
contactedAt DateTime?
|
contactedAt DateTime?
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -86,6 +94,19 @@ model Lead {
|
|||||||
@@index([sourceTab])
|
@@index([sourceTab])
|
||||||
@@index([capturedAt])
|
@@index([capturedAt])
|
||||||
@@index([email])
|
@@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 {
|
model LeadEvent {
|
||||||
|
|||||||
102
prisma/turso-schema.sql
Normal file
102
prisma/turso-schema.sql
Normal 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");
|
||||||
Reference in New Issue
Block a user