Compare commits
7 Commits
customer
...
mein-solar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85cc6efc19 | ||
|
|
2c9afe76cf | ||
|
|
3063c0860d | ||
|
|
b01d14b784 | ||
|
|
807b82f633 | ||
|
|
7db914084e | ||
|
|
54e0d22f9c |
@@ -12,7 +12,8 @@ out
|
||||
*.db-wal
|
||||
/data
|
||||
|
||||
# Environment files (injected at runtime)
|
||||
# Environment files (injected at runtime via Coolify)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ RUN npm run build
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
RUN apk add --no-cache python3 make g++ su-exec
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
@@ -66,8 +66,7 @@ RUN chmod +x docker-entrypoint.sh
|
||||
# Data directory for SQLite — must be a volume
|
||||
RUN mkdir -p /data && chown nextjs:nodejs /data
|
||||
|
||||
USER nextjs
|
||||
|
||||
# Entrypoint runs as root, fixes /data permissions, then drops to nextjs via su-exec
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const SYSTEM_PROMPT = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschsprachigen Raum.
|
||||
const SYSTEM_PROMPT_DEFAULT = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschsprachigen Raum.
|
||||
|
||||
## Was passiert mit deiner Ausgabe
|
||||
|
||||
@@ -36,45 +36,110 @@ Nutze mehrere Queries wenn:
|
||||
|
||||
Maximal 4 Queries. Keine Duplikate (gleiche query + gleiche region).
|
||||
|
||||
## Beispiele
|
||||
|
||||
Eingabe: "Dachdecker in Bayern"
|
||||
Ausgabe:
|
||||
[
|
||||
{ "query": "Dachdecker", "region": "München" },
|
||||
{ "query": "Dachdecker", "region": "Nürnberg" }
|
||||
]
|
||||
|
||||
Eingabe: "Steuerberater in ganz Deutschland"
|
||||
Ausgabe:
|
||||
[
|
||||
{ "query": "Steuerberater", "region": "Bayern" },
|
||||
{ "query": "Steuerberater", "region": "NRW" },
|
||||
{ "query": "Steuerberater", "region": "Hamburg" },
|
||||
{ "query": "Steuerberater", "region": "Berlin" }
|
||||
]
|
||||
|
||||
Eingabe: "Solaranlagen Installateure und Elektriker in Stuttgart"
|
||||
Ausgabe:
|
||||
[
|
||||
{ "query": "Solaranlage", "region": "Stuttgart" },
|
||||
{ "query": "Elektriker", "region": "Stuttgart" }
|
||||
]
|
||||
|
||||
Eingabe: "Metallbaubetriebe in Süddeutschland"
|
||||
Ausgabe:
|
||||
[
|
||||
{ "query": "Metallbau", "region": "Bayern" },
|
||||
{ "query": "Metallbau", "region": "Baden-Württemberg" }
|
||||
]
|
||||
|
||||
## Ausgabeformat
|
||||
|
||||
Antworte ausschließlich mit einem JSON-Array. Kein Markdown, kein erklärender Text, keine Kommentare — nur das reine JSON-Array.`;
|
||||
|
||||
const SYSTEM_PROMPT_STADTWERKE = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschen Energiesektor.
|
||||
|
||||
## Kontext
|
||||
Das Tool sucht deutsche Stadtwerke und kommunale Energieversorger als potenzielle Kunden für ein Solarunternehmen (mein-solar.com). Stadtwerke betreiben lokale Energie-, Wasser- und Verkehrsinfrastruktur und sind starke Abnehmer für Photovoltaik-Großanlagen, Batteriespeicher und Ladeinfrastruktur.
|
||||
|
||||
## Was passiert mit deiner Ausgabe
|
||||
Die Queries werden an die Google Maps Places API und Google SERP übergeben. Gesucht werden Stadtwerke-Websites, aus denen dann Entscheidungsträger-E-Mails extrahiert werden.
|
||||
|
||||
## Deine Aufgabe
|
||||
Generiere 2–4 Suchanfragen die Stadtwerke, Gemeindewerke und kommunale Energieversorger in Deutschland finden. Decke verschiedene Bundesländer oder Regionen ab.
|
||||
|
||||
## Suchbegriffe die funktionieren
|
||||
- "Stadtwerke" — findet kommunale Versorger
|
||||
- "Gemeindewerk" — kleinere Gemeindebetriebe
|
||||
- "Stadtwerk" — Variante
|
||||
- "kommunaler Energieversorger" — für SERP
|
||||
- Niemals: LinkedIn, XING, Verzeichnisse, Social Media
|
||||
|
||||
## Priorisierung
|
||||
"Stadtwerke" schlägt immer "Gemeindewerk". Große Bundesländer (NRW, Bayern, BW) zuerst wenn keine Region angegeben.
|
||||
|
||||
## Bereits verwendete Queries (diese NICHT nochmal verwenden — generiere neue Regionen/Varianten):
|
||||
[HISTORY_PLACEHOLDER]
|
||||
|
||||
## Beispiel
|
||||
Eingabe: "Stadtwerke in Norddeutschland"
|
||||
Ausgabe:
|
||||
[
|
||||
{ "query": "Stadtwerke", "region": "Hamburg" },
|
||||
{ "query": "Stadtwerke", "region": "Schleswig-Holstein" },
|
||||
{ "query": "Gemeindewerk", "region": "Niedersachsen" }
|
||||
]
|
||||
|
||||
## Ausgabeformat
|
||||
Nur reines JSON-Array, kein Markdown, keine Erklärungen.`;
|
||||
|
||||
const SYSTEM_PROMPT_INDUSTRIE = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschen Industriesektor.
|
||||
|
||||
## Kontext
|
||||
Das Tool sucht deutsche Industriebetriebe als Kunden für ein Solarunternehmen (mein-solar.com), das PV-Großanlagen (5 kW bis 10+ MW), Batteriespeicher und Ladeinfrastruktur anbietet. Zielkunden sind Firmen mit hohem Energieverbrauch, großen Dachflächen oder Freiflächen, und Interesse an Kostensenkung durch Eigenerzeugung.
|
||||
|
||||
## Was passiert mit deiner Ausgabe
|
||||
Die Queries werden an Google Maps und SERP übergeben um Unternehmenswebsites zu finden, aus denen Entscheidungsträger-E-Mails extrahiert werden.
|
||||
|
||||
## Deine Aufgabe
|
||||
Generiere 2–4 Suchanfragen die energieintensive Industriebetriebe, Produktionsunternehmen, Logistiker oder Gewerbeparks finden — ideale Kandidaten für Solar-Großanlagen.
|
||||
|
||||
## Gut geeignete Branchen
|
||||
Produktion/Fertigung, Logistik/Lager, Lebensmittelindustrie, Metallverarbeitung, Automobilzulieferer, Landwirtschaft (Agri-PV), Gewerbeparks, Einzelhandel (große Flächen)
|
||||
|
||||
## Suchbegriffe die funktionieren
|
||||
Kurze branchenbezogene Begriffe die bei Google Maps echte Betriebe finden. Keine Adjektive.
|
||||
|
||||
## Priorisierung
|
||||
Wähle immer den geläufigsten Begriff (z.B. "Logistikzentrum" vor "Lagerhaus"). Branchen mit hohem Energieverbrauch und Dachfläche zuerst.
|
||||
|
||||
## Bereits verwendete Queries (diese NICHT nochmal verwenden):
|
||||
[HISTORY_PLACEHOLDER]
|
||||
|
||||
## Beispiel
|
||||
Eingabe: "Industriebetriebe in Bayern mit großen Dachflächen"
|
||||
Ausgabe:
|
||||
[
|
||||
{ "query": "Produktionsbetrieb", "region": "München" },
|
||||
{ "query": "Logistikzentrum", "region": "Augsburg" },
|
||||
{ "query": "Lebensmittelproduktion", "region": "Bayern" }
|
||||
]
|
||||
|
||||
## Ausgabeformat
|
||||
Nur reines JSON-Array, kein Markdown, keine Erklärungen.`;
|
||||
|
||||
function buildSystemPrompt(
|
||||
searchMode: string,
|
||||
history: Array<{ query: string; region: string }>
|
||||
): string {
|
||||
const historyText =
|
||||
history.length > 0
|
||||
? history
|
||||
.slice(0, 20)
|
||||
.map(h => `- "${h.query}" in "${h.region}"`)
|
||||
.join("\n")
|
||||
: "Keine bisherigen Suchen.";
|
||||
|
||||
if (searchMode === "stadtwerke") {
|
||||
return SYSTEM_PROMPT_STADTWERKE.replace("[HISTORY_PLACEHOLDER]", historyText);
|
||||
}
|
||||
if (searchMode === "industrie") {
|
||||
return SYSTEM_PROMPT_INDUSTRIE.replace("[HISTORY_PLACEHOLDER]", historyText);
|
||||
}
|
||||
return SYSTEM_PROMPT_DEFAULT;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { description } = await req.json() as { description: string };
|
||||
const body = await req.json() as {
|
||||
description: string;
|
||||
searchMode?: string;
|
||||
history?: Array<{ query: string; region: string }>;
|
||||
};
|
||||
const { description, searchMode = "custom", history = [] } = body;
|
||||
|
||||
if (!description?.trim()) {
|
||||
return NextResponse.json({ error: "Beschreibung fehlt" }, { status: 400 });
|
||||
@@ -85,20 +150,22 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "OpenRouter API Key nicht konfiguriert" }, { status: 500 });
|
||||
}
|
||||
|
||||
const systemPrompt = buildSystemPrompt(searchMode, history);
|
||||
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://onvyaleads.app",
|
||||
"X-Title": "OnyvaLeads",
|
||||
"HTTP-Referer": "https://mein-solar.com",
|
||||
"X-Title": "MeinSolar Leads",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "openai/gpt-4o-mini",
|
||||
temperature: 0.4,
|
||||
max_tokens: 512,
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: description.trim() },
|
||||
],
|
||||
}),
|
||||
@@ -134,6 +201,18 @@ export async function POST(req: NextRequest) {
|
||||
count: 50,
|
||||
}));
|
||||
|
||||
// Save to SearchHistory (fire and forget)
|
||||
if (searchMode && searchMode !== "custom" && queries.length > 0) {
|
||||
const baseUrl = req.url.replace(/\/api\/ai-search.*/, "");
|
||||
for (const q of queries) {
|
||||
fetch(`${baseUrl}/api/search-history`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: q.query, region: q.region, searchMode }),
|
||||
}).catch(err => console.error("[ai-search] search-history save error:", err));
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ queries });
|
||||
} catch (err) {
|
||||
console.error("[ai-search] error:", err);
|
||||
|
||||
173
app/api/enrich-leads/route.ts
Normal file
173
app/api/enrich-leads/route.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const ENRICHMENT_PROMPT = `Du analysierst eine Unternehmenswebsite für ein Solarunternehmen und extrahierst strukturierte Informationen.
|
||||
|
||||
Antworte ausschließlich mit JSON:
|
||||
{
|
||||
"companyType": "stadtwerke" | "industrie" | "sonstiges",
|
||||
"topics": ["photovoltaik"|"speicher"|"emobilitaet"|"waerme"|"netz"|"wasserstoff"],
|
||||
"salesScore": 1-5,
|
||||
"salesReason": "Ein Satz warum dieser Lead gut/schlecht passt",
|
||||
"offerPackage": "solar-basis" | "solar-pro" | "solar-speicher" | "komplett"
|
||||
}
|
||||
|
||||
Bewertungskriterien salesScore:
|
||||
5 = Stadtwerk oder Industriebetrieb mit explizitem Energiethema, perfekter Fit
|
||||
4 = Energierelevante Branche, wahrscheinlich guter Fit
|
||||
3 = Mittelständischer Betrieb, möglicher Fit
|
||||
2 = Kleiner Betrieb oder unklare Relevanz
|
||||
1 = Offensichtlich nicht relevant (Privatpersonen, Behörden ohne Energie-Relevanz)
|
||||
|
||||
Angebotspaket-Logik:
|
||||
- solar-basis: Kleine Betriebe <50 Mitarbeiter, residentiell
|
||||
- solar-pro: Mittelstand, Gewerbe
|
||||
- solar-speicher: Energieintensive Betriebe, Stadtwerke
|
||||
- komplett: Stadtwerke, Großindustrie, wenn topics viele Themen enthält
|
||||
|
||||
Website-Text:
|
||||
[TEXT]`;
|
||||
|
||||
async function fetchWebsiteText(domain: string): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
try {
|
||||
const url = domain.startsWith("http") ? domain : `https://${domain}`;
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { "User-Agent": "Mozilla/5.0 (compatible; LeadBot/1.0)" },
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
const html = await res.text();
|
||||
// Strip tags, extract text
|
||||
const text = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 2000);
|
||||
return text;
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
interface EnrichmentResult {
|
||||
companyType: string;
|
||||
topics: string[];
|
||||
salesScore: number;
|
||||
salesReason: string;
|
||||
offerPackage: string;
|
||||
}
|
||||
|
||||
async function enrichWithAI(text: string, apiKey: string): Promise<EnrichmentResult | null> {
|
||||
if (!text.trim()) return null;
|
||||
|
||||
const prompt = ENRICHMENT_PROMPT.replace("[TEXT]", text);
|
||||
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://mein-solar.com",
|
||||
"X-Title": "MeinSolar Leads",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "openai/gpt-4o-mini",
|
||||
temperature: 0.2,
|
||||
max_tokens: 256,
|
||||
messages: [
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
const data = await res.json() as { choices: Array<{ message: { content: string } }> };
|
||||
const raw = data.choices[0]?.message?.content?.trim() ?? "";
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as EnrichmentResult;
|
||||
return parsed;
|
||||
} catch {
|
||||
const match = raw.match(/\{[\s\S]*\}/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
return JSON.parse(match[0]) as EnrichmentResult;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { jobId } = await req.json() as { jobId: string };
|
||||
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: "jobId fehlt" }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "OpenRouter API Key nicht konfiguriert" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Load all LeadResults for this job
|
||||
const results = await prisma.leadResult.findMany({
|
||||
where: { jobId },
|
||||
});
|
||||
|
||||
// Find corresponding leads with domains
|
||||
const domainsToEnrich = results
|
||||
.filter(r => r.domain)
|
||||
.map(r => r.domain as string);
|
||||
|
||||
if (domainsToEnrich.length === 0) {
|
||||
return NextResponse.json({ enriched: 0, skipped: 0 });
|
||||
}
|
||||
|
||||
// Find leads matching these domains
|
||||
const leads = await prisma.lead.findMany({
|
||||
where: {
|
||||
domain: { in: domainsToEnrich },
|
||||
salesScore: null, // only enrich if not already enriched
|
||||
},
|
||||
});
|
||||
|
||||
let enriched = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const lead of leads) {
|
||||
if (!lead.domain) { skipped++; continue; }
|
||||
|
||||
const text = await fetchWebsiteText(lead.domain);
|
||||
if (!text) { skipped++; continue; }
|
||||
|
||||
const result = await enrichWithAI(text, apiKey);
|
||||
if (!result) { skipped++; continue; }
|
||||
|
||||
await prisma.lead.update({
|
||||
where: { id: lead.id },
|
||||
data: {
|
||||
companyType: result.companyType || null,
|
||||
topics: result.topics ? JSON.stringify(result.topics) : null,
|
||||
salesScore: typeof result.salesScore === "number" ? result.salesScore : null,
|
||||
salesReason: result.salesReason || null,
|
||||
offerPackage: result.offerPackage || null,
|
||||
},
|
||||
});
|
||||
|
||||
enriched++;
|
||||
}
|
||||
|
||||
return NextResponse.json({ enriched, skipped, total: leads.length });
|
||||
} catch (err) {
|
||||
console.error("POST /api/enrich-leads error:", err);
|
||||
return NextResponse.json({ error: "Enrichment failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
108
app/api/generate-email/route.ts
Normal file
108
app/api/generate-email/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const EMAIL_PROMPT_TEMPLATE = `Du schreibst eine professionelle Erstansprache-E-Mail für mein-solar.com an einen potenziellen Kunden.
|
||||
|
||||
mein-solar.com ist ein Full-Service EPC-Anbieter für Photovoltaik (5 kW bis 10+ MW), Batteriespeicher und Ladeinfrastruktur.
|
||||
|
||||
Firmendaten des Empfängers:
|
||||
- Unternehmen: [companyName]
|
||||
- Ansprechpartner: [contactName], [contactTitle]
|
||||
- Unternehmenstyp: [companyType]
|
||||
- Themen des Unternehmens: [topics]
|
||||
- Empfohlenes Paket: [offerPackage]
|
||||
|
||||
Schreibe eine E-Mail auf Deutsch:
|
||||
- Betreff: prägnant, bezieht sich auf konkretes Thema des Unternehmens
|
||||
- Anrede: personalisiert wenn Name bekannt
|
||||
- 2-3 kurze Absätze: Bezug auf das Unternehmen → Mehrwert von mein-solar → konkreter nächster Schritt (Erstgespräch)
|
||||
- Ton: professionell, direkt, kein Marketing-Sprech
|
||||
- Keine Buzzwords wie "innovativ" oder "nachhaltig"
|
||||
- Signatur: [Vorname] von mein-solar.com
|
||||
|
||||
Antworte mit JSON: { "subject": "...", "body": "..." }`;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { leadId } = await req.json() as { leadId: string };
|
||||
|
||||
if (!leadId) {
|
||||
return NextResponse.json({ error: "leadId fehlt" }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "OpenRouter API Key nicht konfiguriert" }, { status: 500 });
|
||||
}
|
||||
|
||||
const lead = await prisma.lead.findUnique({ where: { id: leadId } });
|
||||
if (!lead) {
|
||||
return NextResponse.json({ error: "Lead nicht gefunden" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!lead.email) {
|
||||
return NextResponse.json({ error: "Lead hat keine E-Mail-Adresse" }, { status: 400 });
|
||||
}
|
||||
|
||||
let topicsText = "";
|
||||
if (lead.topics) {
|
||||
try {
|
||||
const topicsArr = JSON.parse(lead.topics) as string[];
|
||||
topicsText = topicsArr.join(", ");
|
||||
} catch {
|
||||
topicsText = lead.topics;
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = EMAIL_PROMPT_TEMPLATE
|
||||
.replace("[companyName]", lead.companyName || "Unbekannt")
|
||||
.replace("[contactName]", lead.contactName || "–")
|
||||
.replace("[contactTitle]", lead.contactTitle || "–")
|
||||
.replace("[companyType]", lead.companyType || "–")
|
||||
.replace("[topics]", topicsText || "–")
|
||||
.replace("[offerPackage]", lead.offerPackage || "–");
|
||||
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://mein-solar.com",
|
||||
"X-Title": "MeinSolar Leads",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "openai/gpt-4o",
|
||||
temperature: 0.5,
|
||||
max_tokens: 600,
|
||||
messages: [
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
console.error("[generate-email] OpenRouter error:", err);
|
||||
return NextResponse.json({ error: "KI-Anfrage fehlgeschlagen" }, { status: 500 });
|
||||
}
|
||||
|
||||
const data = await res.json() as { choices: Array<{ message: { content: string } }> };
|
||||
const raw = data.choices[0]?.message?.content?.trim() ?? "";
|
||||
|
||||
let email: { subject: string; body: string };
|
||||
try {
|
||||
email = JSON.parse(raw) as { subject: string; body: string };
|
||||
} catch {
|
||||
const match = raw.match(/\{[\s\S]*\}/);
|
||||
if (!match) {
|
||||
return NextResponse.json({ error: "KI-Antwort konnte nicht geparst werden" }, { status: 500 });
|
||||
}
|
||||
email = JSON.parse(match[0]) as { subject: string; body: string };
|
||||
}
|
||||
|
||||
return NextResponse.json({ subject: email.subject, body: email.body });
|
||||
} catch (err) {
|
||||
console.error("POST /api/generate-email error:", err);
|
||||
return NextResponse.json({ error: "E-Mail-Generierung fehlgeschlagen" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
|
||||
"status", "priority", "notes", "tags", "country", "headcount",
|
||||
"industry", "contactedAt", "companyName", "contactName", "contactTitle",
|
||||
"email", "phone", "linkedinUrl", "domain",
|
||||
"companyType", "topics", "salesScore", "salesReason", "offerPackage",
|
||||
"approved", "approvedAt",
|
||||
];
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
@@ -23,11 +23,13 @@ export async function GET(req: NextRequest) {
|
||||
{ email: { contains: search } },
|
||||
];
|
||||
}
|
||||
const approvedOnly = searchParams.get("approvedOnly") === "true";
|
||||
if (statuses.length > 0) where.status = { in: statuses };
|
||||
if (sourceTabs.length > 0) where.sourceTab = { in: sourceTabs };
|
||||
if (priorities.length > 0) where.priority = { in: priorities };
|
||||
if (hasEmail === "yes" || emailOnly) where.email = { not: null };
|
||||
else if (hasEmail === "no") where.email = null;
|
||||
if (approvedOnly) where.approved = true;
|
||||
|
||||
const leads = await prisma.lead.findMany({
|
||||
where,
|
||||
@@ -47,6 +49,12 @@ export async function GET(req: NextRequest) {
|
||||
"Branche": l.industry || "",
|
||||
"Suchbegriff": l.sourceTerm || "",
|
||||
"Tags": l.tags ? (JSON.parse(l.tags) as string[]).join(", ") : "",
|
||||
"Unternehmenstyp": l.companyType || "",
|
||||
"Themen": l.topics ? (JSON.parse(l.topics) as string[]).join(", ") : "",
|
||||
"Vertriebsrelevanz": l.salesScore?.toString() || "",
|
||||
"Begründung": l.salesReason || "",
|
||||
"Angebotspaket": l.offerPackage || "",
|
||||
"Freigegeben": l.approved ? "Ja" : "Nein",
|
||||
"Erfasst am": new Date(l.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export async function GET(req: NextRequest) {
|
||||
const searchTerms = searchParams.getAll("searchTerm");
|
||||
const contacted = searchParams.get("contacted");
|
||||
const favorite = searchParams.get("favorite");
|
||||
const approvedOnly = searchParams.get("approvedOnly");
|
||||
const companyType = searchParams.get("companyType");
|
||||
|
||||
const where: Prisma.LeadWhereInput = {};
|
||||
|
||||
@@ -61,6 +63,9 @@ export async function GET(req: NextRequest) {
|
||||
where.tags = { contains: "favorit" };
|
||||
}
|
||||
|
||||
if (approvedOnly === "true") where.approved = true;
|
||||
if (companyType) where.companyType = companyType;
|
||||
|
||||
if (tags.length > 0) {
|
||||
// SQLite JSON contains — search for each tag in the JSON string
|
||||
where.AND = tags.map(tag => ({
|
||||
@@ -74,7 +79,7 @@ export async function GET(req: NextRequest) {
|
||||
};
|
||||
const orderByField = validSortFields[sortBy] ? sortBy : "capturedAt";
|
||||
|
||||
const [total, leads] = await Promise.all([
|
||||
const [total, leads, rawCount] = await Promise.all([
|
||||
prisma.lead.count({ where }),
|
||||
prisma.lead.findMany({
|
||||
where,
|
||||
@@ -82,8 +87,11 @@ export async function GET(req: NextRequest) {
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
}),
|
||||
prisma.lead.count(),
|
||||
]);
|
||||
|
||||
console.log(`[leads API] rawCount=${rawCount} total=${total} leads=${leads.length} where=${JSON.stringify(where)}`);
|
||||
|
||||
return NextResponse.json({
|
||||
leads,
|
||||
total,
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const searchQuery = region ? `${query} ${region}` : query;
|
||||
const base = req.nextUrl.origin;
|
||||
const base = `http://localhost:${process.env.PORT || 3000}`;
|
||||
|
||||
// ── 1. Maps job (always, max 60) ──────────────────────────────────────────
|
||||
const mapsRes = await fetch(`${base}/api/jobs/maps-enrich`, {
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function POST(req: NextRequest) {
|
||||
};
|
||||
const { query, region, targetCount, foundCount } = body;
|
||||
|
||||
const base = req.nextUrl.origin;
|
||||
const base = `http://localhost:${process.env.PORT || 3000}`;
|
||||
const deficit = targetCount - foundCount;
|
||||
|
||||
// 1. Try to generate an optimized query via GPT-4.1
|
||||
|
||||
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";
|
||||
|
||||
@theme {
|
||||
/* Stitch Solar Design System Colors */
|
||||
--color-background: #0d1419;
|
||||
--color-surface: #0d1419;
|
||||
--color-surface-dim: #0d1419;
|
||||
--color-surface-container-lowest: #080f13;
|
||||
--color-surface-container-low: #161c21;
|
||||
--color-surface-container: #1a2025;
|
||||
--color-surface-container-high: #242b30;
|
||||
--color-surface-container-highest: #2f363b;
|
||||
--color-surface-bright: #333a3f;
|
||||
--color-surface-variant: #2f363b;
|
||||
--color-surface-tint: #adc7ff;
|
||||
--color-primary: #adc7ff;
|
||||
--color-primary-container: #1a73e8;
|
||||
--color-primary-fixed: #d8e2ff;
|
||||
--color-primary-fixed-dim: #adc7ff;
|
||||
--color-on-primary: #002e68;
|
||||
--color-on-primary-container: #ffffff;
|
||||
--color-on-primary-fixed: #001a41;
|
||||
--color-on-primary-fixed-variant: #004493;
|
||||
--color-secondary: #ffb77b;
|
||||
--color-secondary-container: #fb8c00;
|
||||
--color-secondary-fixed: #ffdcc2;
|
||||
--color-secondary-fixed-dim: #ffb77b;
|
||||
--color-on-secondary: #4c2700;
|
||||
--color-on-secondary-container: #5f3200;
|
||||
--color-on-secondary-fixed: #2e1500;
|
||||
--color-on-secondary-fixed-variant: #6d3a00;
|
||||
--color-tertiary: #a0d82c;
|
||||
--color-tertiary-container: #5c8200;
|
||||
--color-tertiary-fixed: #baf549;
|
||||
--color-tertiary-fixed-dim: #a0d82c;
|
||||
--color-on-tertiary: #243600;
|
||||
--color-on-tertiary-container: #ffffff;
|
||||
--color-on-tertiary-fixed: #131f00;
|
||||
--color-on-tertiary-fixed-variant: #364e00;
|
||||
--color-on-surface: #dce3ea;
|
||||
--color-on-surface-variant: #c1c6d6;
|
||||
--color-on-background: #dce3ea;
|
||||
--color-outline: #8b909f;
|
||||
--color-outline-variant: #414754;
|
||||
--color-error: #ffb4ab;
|
||||
--color-error-container: #93000a;
|
||||
--color-on-error: #690005;
|
||||
--color-on-error-container: #ffdad6;
|
||||
--color-inverse-surface: #dce3ea;
|
||||
--color-inverse-on-surface: #2a3136;
|
||||
--color-inverse-primary: #005bc0;
|
||||
|
||||
/* Font families */
|
||||
--font-headline: 'Manrope', sans-serif;
|
||||
--font-body: 'Inter', sans-serif;
|
||||
--font-label: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; }
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #0a0a0f;
|
||||
--card: #111118;
|
||||
--border: #1e1e2e;
|
||||
--primary: #3b82f6;
|
||||
--secondary: #8b5cf6;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--foreground: #f0f0f5;
|
||||
--muted: #6b7280;
|
||||
--background: #0d1419;
|
||||
--card: #161c21;
|
||||
--border: #414754;
|
||||
--primary: #adc7ff;
|
||||
--secondary: #fb8c00;
|
||||
--success: #a0d82c;
|
||||
--warning: #ffb77b;
|
||||
--error: #ffb4ab;
|
||||
--foreground: #dce3ea;
|
||||
--muted: #c1c6d6;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -23,13 +82,35 @@
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-inter), Inter, system-ui, -apple-system, sans-serif;
|
||||
background-color: #0d1419;
|
||||
color: #dce3ea;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Manrope', sans-serif;
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0a0a0f; }
|
||||
::-webkit-scrollbar-thumb { background: #1e1e2e; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #3b82f6; }
|
||||
::-webkit-scrollbar-track { background: #0d1419; }
|
||||
::-webkit-scrollbar-thumb { background: #414754; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #adc7ff; }
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
|
||||
@@ -11,8 +11,14 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="de" className="dark">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@200;400;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<div className="flex flex-col min-h-screen" style={{ background: "#0a0a0f" }}>
|
||||
<div className="flex flex-col min-h-screen" style={{ background: "#0d1419" }}>
|
||||
<Topbar />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
|
||||
@@ -41,6 +41,13 @@ interface Lead {
|
||||
description: string | null;
|
||||
capturedAt: string;
|
||||
contactedAt: string | null;
|
||||
companyType: string | null;
|
||||
topics: string | null;
|
||||
salesScore: number | null;
|
||||
salesReason: string | null;
|
||||
offerPackage: string | null;
|
||||
approved: boolean;
|
||||
approvedAt: string | null;
|
||||
events?: Array<{ id: string; event: string; at: string }>;
|
||||
}
|
||||
|
||||
@@ -115,24 +122,45 @@ function SidePanel({ lead, onClose, onUpdate, onDelete }: {
|
||||
}) {
|
||||
const tags: string[] = JSON.parse(lead.tags || "[]");
|
||||
const src = SOURCE_CONFIG[lead.sourceTab];
|
||||
const [emailLoading, setEmailLoading] = useState(false);
|
||||
const [generatedEmail, setGeneratedEmail] = useState<{ subject: string; body: string } | null>(null);
|
||||
|
||||
function copy(text: string, label: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} kopiert`);
|
||||
}
|
||||
|
||||
async function generateEmail() {
|
||||
setEmailLoading(true);
|
||||
setGeneratedEmail(null);
|
||||
try {
|
||||
const res = await fetch("/api/generate-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ leadId: lead.id }),
|
||||
});
|
||||
const data = await res.json() as { subject?: string; body?: string; error?: string };
|
||||
if (!res.ok || !data.subject) throw new Error(data.error || "Fehler");
|
||||
setGeneratedEmail({ subject: data.subject, body: data.body! });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "E-Mail-Generierung fehlgeschlagen");
|
||||
} finally {
|
||||
setEmailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex justify-end" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
|
||||
<div
|
||||
className="relative z-50 w-full max-w-sm h-full bg-[#0e0e1a] border-l border-[#1e1e2e] flex flex-col overflow-hidden"
|
||||
style={{ animation: "slideIn 200ms ease-out" }}
|
||||
className="relative z-50 w-full max-w-sm h-full flex flex-col overflow-hidden"
|
||||
style={{ background: "#161c21", borderLeft: "1px solid rgba(255,255,255,0.07)", animation: "slideIn 200ms ease-out" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<style>{`@keyframes slideIn { from { transform: translateX(100%) } to { transform: translateX(0) } }`}</style>
|
||||
|
||||
{/* Header */}
|
||||
<div className="border-b border-[#1e1e2e] p-4 flex items-start gap-3 flex-shrink-0">
|
||||
<div className="p-4 flex items-start gap-3 flex-shrink-0" style={{ borderBottom: "1px solid rgba(255,255,255,0.07)" }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-base font-bold text-white truncate">{lead.companyName || lead.domain || "Unbekannt"}</h2>
|
||||
{lead.domain && (
|
||||
@@ -141,7 +169,7 @@ function SidePanel({ lead, onClose, onUpdate, onDelete }: {
|
||||
<Globe className="w-3 h-3" />{lead.domain}
|
||||
</a>
|
||||
)}
|
||||
{lead.industry && <p className="text-xs text-purple-400 mt-0.5">{lead.industry}</p>}
|
||||
{lead.industry && <p className="text-xs mt-0.5" style={{ color: "#adc7ff" }}>{lead.industry}</p>}
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-white p-1 rounded flex-shrink-0">
|
||||
<X className="w-4 h-4" />
|
||||
@@ -217,6 +245,77 @@ function SidePanel({ lead, onClose, onUpdate, onDelete }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KI-Bewertung */}
|
||||
{(lead.salesScore || lead.offerPackage) && (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2">KI-Bewertung</p>
|
||||
<div className="space-y-1.5">
|
||||
{lead.salesScore && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-0.5">
|
||||
{[1,2,3,4,5].map(i => (
|
||||
<span key={i} style={{
|
||||
width: 8, height: 8, borderRadius: "50%", display: "inline-block",
|
||||
background: i <= lead.salesScore! ? (lead.salesScore! >= 4 ? "#22c55e" : lead.salesScore! >= 3 ? "#f59e0b" : "#6b7280") : "#1e1e2e",
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
{lead.salesReason && <span className="text-xs text-gray-400">{lead.salesReason}</span>}
|
||||
</div>
|
||||
)}
|
||||
{lead.offerPackage && <span className="text-xs text-gray-400">Paket: <span className="text-gray-200">{lead.offerPackage}</span></span>}
|
||||
{lead.topics && (() => {
|
||||
try { return <span className="text-xs text-gray-400">Themen: <span className="text-gray-200">{(JSON.parse(lead.topics) as string[]).join(", ")}</span></span>; }
|
||||
catch { return null; }
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* E-Mail Generator */}
|
||||
{lead.email && (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2">Erstansprache</p>
|
||||
{!generatedEmail ? (
|
||||
<button
|
||||
onClick={generateEmail}
|
||||
disabled={emailLoading}
|
||||
style={{
|
||||
width: "100%", padding: "8px 12px", borderRadius: 8, border: "1px solid rgba(139,92,246,0.35)",
|
||||
background: "rgba(139,92,246,0.08)", color: "#a78bfa", fontSize: 12, cursor: emailLoading ? "not-allowed" : "pointer",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
|
||||
}}
|
||||
>
|
||||
{emailLoading ? "✨ E-Mail wird generiert…" : "✨ E-Mail generieren"}
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ background: "#0d0d18", border: "1px solid #1e1e2e", borderRadius: 8, padding: 12 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 10, color: "#6b7280", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.05em" }}>Betreff</div>
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "flex-start" }}>
|
||||
<span style={{ fontSize: 12, color: "#fff", flex: 1 }}>{generatedEmail.subject}</span>
|
||||
<button onClick={() => copy(generatedEmail.subject, "Betreff")} style={{ background: "none", border: "none", color: "#6b7280", cursor: "pointer", padding: 2 }}>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: "#6b7280", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.05em" }}>Nachricht</div>
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "flex-start" }}>
|
||||
<pre style={{ fontSize: 11, color: "#d1d5db", whiteSpace: "pre-wrap", flex: 1, fontFamily: "inherit", lineHeight: 1.6, margin: 0 }}>{generatedEmail.body}</pre>
|
||||
<button onClick={() => copy(generatedEmail.body, "E-Mail")} style={{ background: "none", border: "none", color: "#6b7280", cursor: "pointer", padding: 2, flexShrink: 0 }}>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setGeneratedEmail(null)} style={{ marginTop: 8, fontSize: 11, color: "#6b7280", background: "none", border: "none", cursor: "pointer" }}>
|
||||
Neu generieren
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Company info */}
|
||||
{(lead.headcount || lead.country) && (
|
||||
<div>
|
||||
@@ -254,7 +353,7 @@ function SidePanel({ lead, onClose, onUpdate, onDelete }: {
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="p-4 border-t border-[#1e1e2e] flex-shrink-0">
|
||||
<div className="p-4 flex-shrink-0" style={{ borderTop: "1px solid rgba(255,255,255,0.07)" }}>
|
||||
<button
|
||||
onClick={() => { onDelete(lead.id); onClose(); }}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 text-sm transition-all"
|
||||
@@ -344,6 +443,9 @@ export default function LeadVaultPage() {
|
||||
setLeads(data.leads);
|
||||
setTotal(data.total);
|
||||
setPages(data.pages);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({})) as { error?: string };
|
||||
toast.error(`Leads konnten nicht geladen werden: ${err.error || res.status}`);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -452,7 +554,7 @@ export default function LeadVaultPage() {
|
||||
|
||||
function SortIcon({ field }: { field: string }) {
|
||||
if (sortBy !== field) return <ArrowUpDown className="w-3 h-3 text-gray-600" />;
|
||||
return sortDir === "asc" ? <ArrowUp className="w-3 h-3 text-purple-400" /> : <ArrowDown className="w-3 h-3 text-purple-400" />;
|
||||
return sortDir === "asc" ? <ArrowUp className="w-3 h-3" style={{ color: "#adc7ff" }} /> : <ArrowDown className="w-3 h-3" style={{ color: "#adc7ff" }} />;
|
||||
}
|
||||
|
||||
const allSelected = leads.length > 0 && leads.every(l => selected.has(l.id));
|
||||
@@ -460,21 +562,22 @@ export default function LeadVaultPage() {
|
||||
return (
|
||||
<div className="space-y-5 max-w-full px-[72px] py-[72px]">
|
||||
{/* Header */}
|
||||
<div className="relative rounded-2xl bg-gradient-to-r from-purple-600/10 to-blue-600/10 border border-[#1e1e2e] p-6 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/5 to-transparent" />
|
||||
<div className="relative rounded-2xl p-6 overflow-hidden" 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(173,199,255,0.08)", filter: "blur(80px)" }} />
|
||||
<div className="relative flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm text-purple-400 mb-2">
|
||||
<div className="flex items-center gap-2 text-sm mb-2" style={{ color: "#adc7ff" }}>
|
||||
<Database className="w-4 h-4" />
|
||||
<span>Zentrale Datenbank</span>
|
||||
<span style={{ fontFamily: "Inter, sans-serif", fontWeight: 500, letterSpacing: "0.05em", textTransform: "uppercase", fontSize: 11 }}>Zentrale Datenbank</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">🗄️ Leadspeicher</h1>
|
||||
<p className="text-gray-400 mt-1 text-sm">Alle Leads an einem Ort.</p>
|
||||
<h1 className="text-2xl font-extrabold" style={{ fontFamily: "Manrope, sans-serif", color: "#dce3ea" }}>Leadspeicher</h1>
|
||||
<p className="mt-1 text-sm" style={{ color: "#8b909f" }}>Alle Leads an einem Ort.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setExportOpen(v => !v)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-purple-500/30 bg-purple-500/10 text-purple-300 hover:bg-purple-500/20 text-sm transition-all"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-all"
|
||||
style={{ border: "1px solid rgba(173,199,255,0.2)", background: "rgba(173,199,255,0.08)", color: "#adc7ff" }}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> Export
|
||||
</button>
|
||||
@@ -486,10 +589,11 @@ export default function LeadVaultPage() {
|
||||
{exportOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setExportOpen(false)} />
|
||||
<div className="fixed top-20 right-6 z-50 bg-[#1a1a28] border border-[#2e2e3e] rounded-lg shadow-2xl p-1 min-w-[230px]">
|
||||
<div className="fixed top-20 right-6 z-50 rounded-lg shadow-2xl p-1 min-w-[230px]" style={{ background: "#1a2025", border: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
{([
|
||||
["Aktuelle Ansicht", () => exportFile("xlsx")],
|
||||
["Nur mit E-Mail", () => exportFile("xlsx", true)],
|
||||
["✓ Freigegebene exportieren", () => { const p = new URLSearchParams({ format: "xlsx", approvedOnly: "true" }); window.open(`/api/leads/export?${p}`, "_blank"); }],
|
||||
] as [string, () => void][]).map(([label, fn]) => (
|
||||
<button key={label} onClick={() => { fn(); setExportOpen(false); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-[#2e2e3e] rounded">
|
||||
@@ -504,33 +608,36 @@ export default function LeadVaultPage() {
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Leads gesamt", value: stats.total, color: "#a78bfa" },
|
||||
{ label: "Neu / Nicht kontaktiert", value: stats.new, color: "#60a5fa" },
|
||||
{ label: "Kontaktiert / In Bearbeitung", value: stats.contacted, color: "#2dd4bf" },
|
||||
{ label: "Mit verifizierter E-Mail", value: stats.withEmail, color: "#34d399" },
|
||||
].map(({ label, value, color }) => (
|
||||
<Card key={label} className="bg-[#111118] border-[#1e1e2e] p-4">
|
||||
<p className="text-xs text-gray-500 mb-1">{label}</p>
|
||||
{ label: "Leads gesamt", value: stats.total, color: "#adc7ff", accent: "rgba(173,199,255,0.15)" },
|
||||
{ label: "Neu / Unbearbeitet", value: stats.new, color: "#adc7ff", accent: "rgba(173,199,255,0.1)" },
|
||||
{ label: "Kontaktiert", value: stats.contacted, color: "#a0d82c", accent: "rgba(160,216,44,0.1)" },
|
||||
{ label: "Mit E-Mail", value: stats.withEmail, color: "#ffb77b", accent: "rgba(255,183,123,0.1)" },
|
||||
].map(({ label, value, color, accent }) => (
|
||||
<div key={label} className="rounded-xl p-4" style={{ background: "#161c21", border: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
<p className="text-xs font-medium uppercase tracking-wider mb-3" style={{ color: "#8b909f", fontFamily: "Inter, sans-serif", letterSpacing: "0.08em" }}>{label}</p>
|
||||
<div className="flex items-end justify-between">
|
||||
<p className="text-2xl font-bold text-white">{value.toLocaleString()}</p>
|
||||
<p className="text-3xl font-extrabold" style={{ fontFamily: "Manrope, sans-serif", color: "#dce3ea" }}>{value.toLocaleString()}</p>
|
||||
<div className="rounded-lg p-2" style={{ background: accent }}>
|
||||
<Sparkline data={stats.dailyCounts.map(d => d.count)} color={color} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick SERP */}
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] overflow-hidden">
|
||||
<div className="rounded-xl overflow-hidden" style={{ background: "#161c21", border: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
<button
|
||||
onClick={() => setSerpOpen(!serpOpen)}
|
||||
className="w-full flex items-center justify-between px-5 py-3.5 text-sm font-medium text-gray-300 hover:text-white transition-colors"
|
||||
className="w-full flex items-center justify-between px-5 py-3.5 text-sm font-medium transition-colors"
|
||||
style={{ color: "#c1c6d6", fontFamily: "Inter, sans-serif" }}
|
||||
>
|
||||
<span className="flex items-center gap-2"><Search className="w-4 h-4 text-purple-400" />⚡ Schnell neue Suche</span>
|
||||
{serpOpen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
<span className="flex items-center gap-2" style={{ color: "#adc7ff" }}><Search className="w-4 h-4" />⚡ Schnell neue Suche</span>
|
||||
{serpOpen ? <ChevronUp className="w-4 h-4" style={{ color: "#8b909f" }} /> : <ChevronDown className="w-4 h-4" style={{ color: "#8b909f" }} />}
|
||||
</button>
|
||||
{serpOpen && (
|
||||
<div className="px-5 pb-5 border-t border-[#1e1e2e] pt-4 space-y-4">
|
||||
<div className="px-5 pb-5 pt-4 space-y-4" style={{ borderTop: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2">
|
||||
<Input
|
||||
@@ -538,14 +645,15 @@ export default function LeadVaultPage() {
|
||||
value={serpQuery}
|
||||
onChange={e => setSerpQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && runQuickSerp()}
|
||||
className="bg-[#0d0d18] border-[#2e2e3e] text-white placeholder:text-gray-600 focus:border-purple-500"
|
||||
className="text-white placeholder:text-gray-600"
|
||||
style={{ background: "#080f13", borderColor: "rgba(255,255,255,0.08)" }}
|
||||
/>
|
||||
</div>
|
||||
<Select value={serpCount} onValueChange={v => setSerpCount(v ?? "25")}>
|
||||
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
|
||||
<SelectTrigger className="text-white" style={{ background: "#080f13", borderColor: "rgba(255,255,255,0.08)" }}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
|
||||
<SelectContent style={{ background: "#1a2025", borderColor: "rgba(255,255,255,0.08)" }}>
|
||||
{["10", "25", "50", "100"].map(v => (
|
||||
<SelectItem key={v} value={v} className="text-gray-300">{v} Ergebnisse</SelectItem>
|
||||
))}
|
||||
@@ -553,41 +661,46 @@ export default function LeadVaultPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer" style={{ color: "#8b909f" }}>
|
||||
<input type="checkbox" checked={serpFilter} onChange={e => setSerpFilter(e.target.checked)} className="rounded" />
|
||||
Social-Media / Verzeichnisse herausfiltern
|
||||
</label>
|
||||
<Button
|
||||
<button
|
||||
onClick={runQuickSerp}
|
||||
disabled={serpRunning || !serpQuery.trim()}
|
||||
className="bg-gradient-to-r from-purple-500 to-blue-600 hover:from-purple-600 hover:to-blue-700 text-white font-medium px-6"
|
||||
className="px-6 py-2 rounded-xl font-bold text-sm transition-all active:scale-95 disabled:opacity-50"
|
||||
style={{ fontFamily: "Manrope, sans-serif", background: "linear-gradient(135deg, #adc7ff, #1a73e8)", color: "#002e68" }}
|
||||
>
|
||||
{serpRunning ? "Suche läuft..." : "🔍 SERP Capture starten"}
|
||||
</Button>
|
||||
{serpRunning ? "Suche läuft..." : "SERP Capture starten"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] p-4 space-y-3">
|
||||
<div className="rounded-xl p-4 space-y-3" style={{ background: "#161c21", border: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500" />
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5" style={{ color: "#8b909f" }} />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Domain, Firma, Name, E-Mail suchen..."
|
||||
className="w-full bg-[#0d0d18] border border-[#2e2e3e] rounded-lg pl-8 pr-3 py-1.5 text-sm text-white outline-none focus:border-purple-500"
|
||||
className="w-full rounded-lg pl-8 pr-3 py-1.5 text-sm text-white outline-none"
|
||||
style={{ background: "#080f13", border: "1px solid rgba(255,255,255,0.08)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Has Email toggle */}
|
||||
<div className="flex gap-1 bg-[#0d0d18] border border-[#2e2e3e] rounded-lg p-1">
|
||||
<div className="flex gap-1 rounded-lg p-1" style={{ background: "#080f13", border: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
{[["", "Alle"], ["yes", "Mit E-Mail"], ["no", "Ohne E-Mail"]].map(([v, l]) => (
|
||||
<button key={v} onClick={() => { setFilterHasEmail(v); setPage(1); }}
|
||||
className={`px-2.5 py-1 rounded text-xs font-medium transition-all ${filterHasEmail === v ? "bg-purple-500/30 text-purple-300" : "text-gray-500 hover:text-gray-300"}`}>
|
||||
className="px-2.5 py-1 rounded text-xs font-medium transition-all"
|
||||
style={filterHasEmail === v
|
||||
? { background: "rgba(173,199,255,0.15)", color: "#adc7ff" }
|
||||
: { color: "#8b909f" }}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
@@ -596,11 +709,10 @@ export default function LeadVaultPage() {
|
||||
{/* Kontaktiert toggle */}
|
||||
<button
|
||||
onClick={() => { setFilterContacted(v => !v); setPage(1); }}
|
||||
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-all ${
|
||||
filterContacted
|
||||
? "bg-teal-500/20 text-teal-300 border-teal-500/30"
|
||||
: "bg-[#0d0d18] text-gray-500 border-[#2e2e3e] hover:text-gray-300 hover:border-[#4e4e6e]"
|
||||
}`}
|
||||
className="px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all"
|
||||
style={filterContacted
|
||||
? { background: "rgba(160,216,44,0.15)", color: "#a0d82c", border: "1px solid rgba(160,216,44,0.3)" }
|
||||
: { background: "#080f13", color: "#8b909f", border: "1px solid rgba(255,255,255,0.08)" }}
|
||||
>
|
||||
Kontaktiert
|
||||
</button>
|
||||
@@ -608,11 +720,10 @@ export default function LeadVaultPage() {
|
||||
{/* Favoriten toggle */}
|
||||
<button
|
||||
onClick={() => { setFilterFavorite(v => !v); setPage(1); }}
|
||||
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-all ${
|
||||
filterFavorite
|
||||
? "bg-amber-500/20 text-amber-300 border-amber-500/30"
|
||||
: "bg-[#0d0d18] text-gray-500 border-[#2e2e3e] hover:text-gray-300 hover:border-[#4e4e6e]"
|
||||
}`}
|
||||
className="px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all"
|
||||
style={filterFavorite
|
||||
? { background: "rgba(255,183,123,0.15)", color: "#ffb77b", border: "1px solid rgba(255,183,123,0.3)" }
|
||||
: { background: "#080f13", color: "#8b909f", border: "1px solid rgba(255,255,255,0.08)" }}
|
||||
>
|
||||
★ Favoriten
|
||||
</button>
|
||||
@@ -620,55 +731,58 @@ export default function LeadVaultPage() {
|
||||
{/* Clear + count */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{(search || filterHasEmail || filterContacted || filterFavorite || filterSearchTerms.length) && (
|
||||
<button onClick={clearFilters} className="text-xs text-gray-500 hover:text-white flex items-center gap-1">
|
||||
<button onClick={clearFilters} className="text-xs flex items-center gap-1" style={{ color: "#8b909f" }}>
|
||||
<X className="w-3 h-3" /> Filter zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">{total.toLocaleString()} Leads</span>
|
||||
<span className="text-xs" style={{ color: "#8b909f" }}>{total.toLocaleString()} Leads</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions */}
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center gap-3 bg-purple-500/10 border border-purple-500/20 rounded-xl px-4 py-2.5">
|
||||
<span className="text-sm text-purple-300 font-medium">{selected.size} ausgewählt</span>
|
||||
<div className="flex items-center gap-3 rounded-xl px-4 py-2.5" style={{ background: "rgba(173,199,255,0.07)", border: "1px solid rgba(173,199,255,0.15)" }}>
|
||||
<span className="text-sm font-medium" style={{ color: "#adc7ff", fontFamily: "Manrope, sans-serif" }}>{selected.size} ausgewählt</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={bulkTag}
|
||||
onChange={e => setBulkTag(e.target.value)}
|
||||
placeholder="Tag hinzufügen..."
|
||||
className="bg-[#1a1a28] border border-[#2e2e3e] text-gray-300 text-xs rounded px-2 py-1 w-32 outline-none"
|
||||
className="text-gray-300 text-xs rounded px-2 py-1 w-32 outline-none"
|
||||
style={{ background: "#1a2025", border: "1px solid rgba(255,255,255,0.08)" }}
|
||||
/>
|
||||
<button onClick={() => { if (bulkTag) { bulkAction("tag", bulkTag); setBulkTag(""); } }}
|
||||
className="text-xs px-2 py-1 rounded bg-[#2e2e3e] text-gray-300 hover:bg-[#3e3e5e]">
|
||||
className="text-xs px-2 py-1 rounded text-gray-300"
|
||||
style={{ background: "#242b30" }}>
|
||||
<Tag className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => bulkAction("delete", "")}
|
||||
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded bg-red-500/20 text-red-300 border border-red-500/30 hover:bg-red-500/30">
|
||||
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded text-red-300 border border-red-500/30"
|
||||
style={{ background: "rgba(255,100,100,0.1)" }}>
|
||||
<Trash2 className="w-3 h-3" /> Löschen
|
||||
</button>
|
||||
<button onClick={() => setSelected(new Set())} className="text-xs text-gray-500 hover:text-white px-2">✕</button>
|
||||
<button onClick={() => setSelected(new Set())} className="text-xs px-2" style={{ color: "#8b909f" }}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<Card className="bg-[#111118] border-[#1e1e2e] overflow-hidden">
|
||||
<div className="rounded-xl overflow-hidden" style={{ background: "#161c21", border: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#1e1e2e]">
|
||||
<tr style={{ borderBottom: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
<th className="px-3 py-2.5 text-left">
|
||||
<button onClick={() => {
|
||||
if (allSelected) setSelected(new Set());
|
||||
else setSelected(new Set(leads.map(l => l.id)));
|
||||
}}>
|
||||
{allSelected
|
||||
? <CheckSquare className="w-4 h-4 text-purple-400" />
|
||||
? <CheckSquare className="w-4 h-4" style={{ color: "#adc7ff" }} />
|
||||
: <Square className="w-4 h-4 text-gray-600" />}
|
||||
</button>
|
||||
</th>
|
||||
@@ -686,6 +800,8 @@ export default function LeadVaultPage() {
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500">Bewertung</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500">Paket</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500">Tags</th>
|
||||
<th className="px-3 py-2.5 text-right text-xs font-medium text-gray-500">Aktionen</th>
|
||||
</tr>
|
||||
@@ -703,7 +819,7 @@ export default function LeadVaultPage() {
|
||||
: "Noch keine Leads. Pipeline ausführen oder Quick SERP nutzen."}
|
||||
</p>
|
||||
{search && (
|
||||
<button onClick={clearFilters} className="mt-2 text-xs text-purple-400 hover:underline">Filter zurücksetzen</button>
|
||||
<button onClick={clearFilters} className="mt-2 text-xs hover:underline" style={{ color: "#adc7ff" }}>Filter zurücksetzen</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -719,9 +835,13 @@ export default function LeadVaultPage() {
|
||||
<tr
|
||||
key={lead.id}
|
||||
onClick={() => setPanelLead(lead)}
|
||||
className={`border-b border-[#1a1a28] cursor-pointer transition-colors hover:bg-[#1a1a28] ${
|
||||
isSelected ? "bg-[#1a1a2e]" : i % 2 === 0 ? "bg-[#111118]" : "bg-[#0f0f16]"
|
||||
}`}
|
||||
className="cursor-pointer transition-colors"
|
||||
style={{
|
||||
borderBottom: "1px solid rgba(255,255,255,0.03)",
|
||||
background: isSelected ? "rgba(173,199,255,0.07)" : i % 2 === 0 ? "#161c21" : "#1a2025",
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = "#242b30"; }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = i % 2 === 0 ? "#161c21" : "#1a2025"; }}
|
||||
>
|
||||
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => {
|
||||
@@ -732,7 +852,7 @@ export default function LeadVaultPage() {
|
||||
});
|
||||
}}>
|
||||
{isSelected
|
||||
? <CheckSquare className="w-4 h-4 text-purple-400" />
|
||||
? <CheckSquare className="w-4 h-4" style={{ color: "#adc7ff" }} />
|
||||
: <Square className="w-4 h-4 text-gray-600" />}
|
||||
</button>
|
||||
</td>
|
||||
@@ -781,11 +901,10 @@ export default function LeadVaultPage() {
|
||||
{lead.sourceTerm ? (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); toggleFilter(filterSearchTerms, setFilterSearchTerms, lead.sourceTerm!); setPage(1); }}
|
||||
className={`text-xs px-2 py-0.5 rounded-full border transition-all truncate max-w-full block ${
|
||||
filterSearchTerms.includes(lead.sourceTerm)
|
||||
? "bg-amber-500/20 text-amber-300 border-amber-500/30"
|
||||
: "bg-[#1a1a28] text-gray-400 border-[#2e2e3e] hover:border-amber-500/30 hover:text-amber-300"
|
||||
}`}
|
||||
className="text-xs px-2 py-0.5 rounded-full border transition-all truncate max-w-full block"
|
||||
style={filterSearchTerms.includes(lead.sourceTerm)
|
||||
? { background: "rgba(255,183,123,0.15)", color: "#ffb77b", borderColor: "rgba(255,183,123,0.3)" }
|
||||
: { background: "#1a2025", color: "#8b909f", borderColor: "rgba(255,255,255,0.08)" }}
|
||||
title={lead.sourceTerm}
|
||||
>
|
||||
🔎 {lead.sourceTerm}
|
||||
@@ -797,6 +916,39 @@ export default function LeadVaultPage() {
|
||||
<td className="px-3 py-2.5 whitespace-nowrap" title={new Date(lead.capturedAt).toLocaleString("de-DE")}>
|
||||
<span className="text-xs text-gray-500">{new Date(lead.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" })}</span>
|
||||
</td>
|
||||
{/* Bewertung */}
|
||||
<td className="px-3 py-2.5" title={lead.salesReason || ""}>
|
||||
{lead.salesScore ? (
|
||||
<div className="flex gap-0.5">
|
||||
{[1,2,3,4,5].map(i => (
|
||||
<span key={i} style={{
|
||||
width: 7, height: 7, borderRadius: "50%",
|
||||
background: i <= lead.salesScore!
|
||||
? (lead.salesScore! >= 4 ? "#22c55e" : lead.salesScore! >= 3 ? "#f59e0b" : "#6b7280")
|
||||
: "#1e1e2e",
|
||||
display: "inline-block",
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
) : <span className="text-xs text-gray-600">–</span>}
|
||||
</td>
|
||||
{/* Paket */}
|
||||
<td className="px-3 py-2.5">
|
||||
{lead.offerPackage ? (() => {
|
||||
const pkgMap: Record<string, { label: string; style: React.CSSProperties }> = {
|
||||
"solar-basis": { label: "Basis", style: { background: "#1e1e2e", color: "#9ca3af" } },
|
||||
"solar-pro": { label: "Pro", style: { background: "#1e3a5f", color: "#93c5fd" } },
|
||||
"solar-speicher": { label: "Solar+Speicher", style: { background: "#2e1065", color: "#d8b4fe" } },
|
||||
"komplett": { label: "Komplett", style: { background: "#064e3b", color: "#6ee7b7" } },
|
||||
};
|
||||
const pkg = pkgMap[lead.offerPackage] ?? { label: lead.offerPackage, style: { background: "#1e1e2e", color: "#9ca3af" } };
|
||||
return (
|
||||
<span style={{ ...pkg.style, fontSize: 10, padding: "2px 7px", borderRadius: 10, whiteSpace: "nowrap" }}>
|
||||
{pkg.label}
|
||||
</span>
|
||||
);
|
||||
})() : <span className="text-xs text-gray-600">–</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex gap-1 flex-wrap max-w-[120px]">
|
||||
{tags.slice(0, 2).map(tag => (
|
||||
@@ -837,6 +989,20 @@ export default function LeadVaultPage() {
|
||||
: "p-1 rounded text-gray-600 hover:text-amber-400 hover:bg-amber-500/10 transition-all"}>
|
||||
<Star className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
updateLead(lead.id, { approved: !lead.approved, approvedAt: !lead.approved ? new Date().toISOString() : null });
|
||||
}}
|
||||
title={lead.approved ? "Freigegeben" : "Freigeben"}
|
||||
className={lead.approved
|
||||
? "p-1 rounded text-green-400 bg-green-500/10 transition-all"
|
||||
: "p-1 rounded text-gray-600 hover:text-green-400 hover:bg-green-500/10 transition-all"}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
{lead.domain && (
|
||||
<a href={`https://${lead.domain}`} target="_blank" rel="noreferrer"
|
||||
title="Website öffnen"
|
||||
@@ -859,20 +1025,22 @@ export default function LeadVaultPage() {
|
||||
|
||||
{/* Pagination */}
|
||||
{pages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[#1e1e2e]">
|
||||
<div className="flex items-center justify-between px-4 py-3" style={{ borderTop: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Zeilen pro Seite:</span>
|
||||
<span className="text-xs" style={{ color: "#8b909f" }}>Zeilen pro Seite:</span>
|
||||
<select
|
||||
value={perPage}
|
||||
onChange={e => { setPerPage(Number(e.target.value)); setPage(1); }}
|
||||
className="bg-[#0d0d18] border border-[#2e2e3e] text-gray-300 text-xs rounded px-2 py-1"
|
||||
className="text-gray-300 text-xs rounded px-2 py-1"
|
||||
style={{ background: "#080f13", border: "1px solid rgba(255,255,255,0.08)" }}
|
||||
>
|
||||
{[25, 50, 100].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
|
||||
className="px-2.5 py-1 rounded text-xs text-gray-400 border border-[#2e2e3e] hover:border-[#4e4e6e] disabled:opacity-30">
|
||||
className="px-2.5 py-1 rounded text-xs text-gray-400 disabled:opacity-30"
|
||||
style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
‹ Zurück
|
||||
</button>
|
||||
{Array.from({ length: Math.min(7, pages) }, (_, i) => {
|
||||
@@ -884,20 +1052,24 @@ export default function LeadVaultPage() {
|
||||
}
|
||||
return (
|
||||
<button key={p} onClick={() => setPage(p)}
|
||||
className={`w-7 h-7 rounded text-xs ${page === p ? "bg-purple-500/30 text-purple-300 border border-purple-500/30" : "text-gray-500 hover:text-gray-300"}`}>
|
||||
className="w-7 h-7 rounded text-xs"
|
||||
style={page === p
|
||||
? { background: "rgba(173,199,255,0.15)", color: "#adc7ff", border: "1px solid rgba(173,199,255,0.3)" }
|
||||
: { color: "#8b909f" }}>
|
||||
{p}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button disabled={page >= pages} onClick={() => setPage(p => p + 1)}
|
||||
className="px-2.5 py-1 rounded text-xs text-gray-400 border border-[#2e2e3e] hover:border-[#4e4e6e] disabled:opacity-30">
|
||||
className="px-2.5 py-1 rounded text-xs text-gray-400 disabled:opacity-30"
|
||||
style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
Weiter ›
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">Seite {page} von {pages}</span>
|
||||
<span className="text-xs" style={{ color: "#8b909f" }}>Seite {page} von {pages}</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Side Panel */}
|
||||
{panelLead && (
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { SearchCard } from "@/components/search/SearchCard";
|
||||
import { LoadingCard, type LeadResult } from "@/components/search/LoadingCard";
|
||||
import { AiSearchModal } from "@/components/search/AiSearchModal";
|
||||
|
||||
@@ -20,6 +19,23 @@ export default function SuchePage() {
|
||||
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);
|
||||
@@ -59,12 +75,190 @@ export default function SuchePage() {
|
||||
setLoading(false);
|
||||
setLeads(result);
|
||||
setSearchDone(true);
|
||||
// Fire-and-forget KI-Anreicherung
|
||||
if (jobId) {
|
||||
fetch("/api/enrich-leads", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jobId }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (warning) {
|
||||
toast.warning(`${result.length} Unternehmen gefunden — E-Mail-Anreicherung fehlgeschlagen: ${warning}`, { duration: 6000 });
|
||||
} else {
|
||||
toast.success(`✓ ${result.length} Leads gefunden und im Leadspeicher gespeichert`, { duration: 4000 });
|
||||
}
|
||||
}, []);
|
||||
}, [jobId]);
|
||||
|
||||
async function 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;
|
||||
@@ -116,61 +310,206 @@ export default function SuchePage() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "72px 120px",
|
||||
maxWidth: 900,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
{/* Hero */}
|
||||
<div className="relative rounded-2xl border border-[#1e1e2e] p-6 overflow-hidden mb-6"
|
||||
style={{ background: "linear-gradient(135deg, rgba(59,130,246,0.08) 0%, rgba(139,92,246,0.08) 100%)" }}>
|
||||
<div className="absolute inset-0" style={{ background: "linear-gradient(135deg, rgba(59,130,246,0.04) 0%, transparent 60%)" }} />
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2 text-xs mb-2" style={{ color: "#3b82f6" }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
<span>Lead-Suche</span>
|
||||
</div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 500, color: "#ffffff", margin: 0, marginBottom: 6 }}>
|
||||
Leads finden
|
||||
</h1>
|
||||
<p style={{ fontSize: 13, color: "#9ca3af", margin: 0 }}>
|
||||
Suchbegriff eingeben — wir finden passende Unternehmen mit Kontaktdaten.
|
||||
<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}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 7,
|
||||
padding: "8px 14px", borderRadius: 8,
|
||||
border: "1px solid rgba(139,92,246,0.35)",
|
||||
background: "rgba(139,92,246,0.1)", color: "#a78bfa",
|
||||
fontSize: 13, fontWeight: 500,
|
||||
cursor: loading ? "not-allowed" : "pointer",
|
||||
opacity: loading ? 0.5 : 1, whiteSpace: "nowrap",
|
||||
}}
|
||||
onMouseEnter={e => { if (!loading) { e.currentTarget.style.background = "rgba(139,92,246,0.18)"; e.currentTarget.style.borderColor = "rgba(139,92,246,0.6)"; }}}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = "rgba(139,92,246,0.1)"; e.currentTarget.style.borderColor = "rgba(139,92,246,0.35)"; }}
|
||||
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)" }}
|
||||
>
|
||||
✨ KI-Suche
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Search Card */}
|
||||
<SearchCard
|
||||
query={query}
|
||||
region={region}
|
||||
count={count}
|
||||
loading={loading}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
{/* 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 */}
|
||||
@@ -188,10 +527,10 @@ export default function SuchePage() {
|
||||
{/* AI Modal */}
|
||||
{aiOpen && (
|
||||
<AiSearchModal
|
||||
searchMode="custom"
|
||||
onStart={(queries) => {
|
||||
setAiOpen(false);
|
||||
if (!queries.length) return;
|
||||
// Fill first query into the search fields and submit
|
||||
const first = queries[0];
|
||||
setQuery(first.query);
|
||||
setRegion(first.region);
|
||||
@@ -201,6 +540,12 @@ export default function SuchePage() {
|
||||
setLeads([]);
|
||||
setSearchDone(false);
|
||||
setSelected(new Set());
|
||||
// Save to history
|
||||
fetch("/api/search-history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: first.query, region: first.region, searchMode: "custom" }),
|
||||
}).catch(() => {});
|
||||
fetch("/api/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -231,9 +576,8 @@ export default function SuchePage() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
background: "#111118",
|
||||
border: "1px solid #1e1e2e",
|
||||
background: "#161c21",
|
||||
border: "1px solid rgba(65,71,84,0.3)",
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
|
||||
@@ -4,34 +4,6 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function OnyvaLogo() {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 6,
|
||||
background: "linear-gradient(135deg, #3b82f6, #8b5cf6)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* 5-pointed star SVG */}
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<polygon
|
||||
points="7,1 8.5,5.5 13,5.5 9.5,8.5 10.8,13 7,10.2 3.2,13 4.5,8.5 1,5.5 5.5,5.5"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span style={{ fontSize: 15, fontWeight: 500, color: "#ffffff" }}>OnyvaLeads</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Topbar() {
|
||||
const pathname = usePathname();
|
||||
const [newLeadsCount, setNewLeadsCount] = useState(0);
|
||||
@@ -48,80 +20,55 @@ export function Topbar() {
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const tabs = [
|
||||
{ href: "/suche", label: "Suche" },
|
||||
{ href: "/leadspeicher", label: "Leadspeicher" },
|
||||
];
|
||||
const isSearch = pathname === "/suche" || pathname.startsWith("/suche/");
|
||||
const isLeadspeicher = pathname === "/leadspeicher" || pathname.startsWith("/leadspeicher/");
|
||||
|
||||
return (
|
||||
<header
|
||||
<header className="sticky top-0 w-full z-50 flex justify-between items-center px-8 h-16"
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 50,
|
||||
height: 52,
|
||||
background: "#111118",
|
||||
borderBottom: "1px solid #1e1e2e",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
paddingLeft: 20,
|
||||
paddingRight: 20,
|
||||
gap: 16,
|
||||
flexShrink: 0,
|
||||
background: "rgba(13, 20, 25, 0.85)",
|
||||
backdropFilter: "blur(20px)",
|
||||
WebkitBackdropFilter: "blur(20px)",
|
||||
boxShadow: "0 20px 40px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
{/* Left: Logo + Nav */}
|
||||
<div className="flex items-center gap-8">
|
||||
<Link href="/suche" style={{ textDecoration: "none" }}>
|
||||
<OnyvaLogo />
|
||||
<span className="text-xl font-black text-white tracking-tighter" style={{ fontFamily: "Manrope, sans-serif" }}>
|
||||
mein-solar | Lead Finder
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div
|
||||
style={{
|
||||
background: "#0a0a0f",
|
||||
borderRadius: 8,
|
||||
padding: 3,
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = pathname === tab.href || pathname.startsWith(tab.href + "/");
|
||||
return (
|
||||
<nav className="hidden md:flex gap-6 items-center h-full">
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "4px 12px",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
color: isActive ? "#ffffff" : "#9ca3af",
|
||||
background: isActive ? "#111118" : "transparent",
|
||||
boxShadow: isActive ? "0 1px 3px rgba(0,0,0,0.2)" : "none",
|
||||
textDecoration: "none",
|
||||
transition: "all 0.15s",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
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" }}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.href === "/leadspeicher" && newLeadsCount > 0 && (
|
||||
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: "#3b82f6",
|
||||
color: "#ffffff",
|
||||
background: "#1a73e8",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 4px",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
@@ -129,9 +76,9 @@ export function Topbar() {
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@ interface Query {
|
||||
interface AiSearchModalProps {
|
||||
onStart: (queries: Query[]) => void;
|
||||
onClose: () => void;
|
||||
searchMode?: string;
|
||||
}
|
||||
|
||||
export function AiSearchModal({ onStart, onClose }: AiSearchModalProps) {
|
||||
export function AiSearchModal({ onStart, onClose, searchMode = "custom" }: AiSearchModalProps) {
|
||||
const [description, setDescription] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [queries, setQueries] = useState<Query[]>([]);
|
||||
@@ -26,10 +27,19 @@ export function AiSearchModal({ onStart, onClose }: AiSearchModalProps) {
|
||||
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 }),
|
||||
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");
|
||||
@@ -111,7 +121,7 @@ export function AiSearchModal({ onStart, onClose }: AiSearchModalProps) {
|
||||
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 · Strg+Enter zum Generieren
|
||||
Tipp: Branche, Region, Firmengröße und gewünschten Entscheidungsträger erwähnen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Run Prisma migrations on every startup (idempotent)
|
||||
echo "Running database migrations..."
|
||||
DATABASE_URL="${DATABASE_URL:-file:/data/leadflow.db}" \
|
||||
node node_modules/prisma/build/index.js migrate deploy \
|
||||
if [ -n "$TURSO_DATABASE_URL" ]; then
|
||||
echo "Turso database configured — skipping local migrations."
|
||||
else
|
||||
# Fix /data permissions — Docker volumes are mounted as root by default
|
||||
chown -R nextjs:nodejs /data 2>/dev/null || true
|
||||
|
||||
echo "Running SQLite migrations..."
|
||||
DATABASE_URL="${DATABASE_URL:-file:/data/leadflow.db}" \
|
||||
su-exec nextjs node node_modules/prisma/build/index.js migrate deploy \
|
||||
--schema ./prisma/schema.prisma 2>&1 || echo "Migration warning (may already be up to date)"
|
||||
fi
|
||||
|
||||
echo "Starting LeadFlow..."
|
||||
exec node server.js
|
||||
exec su-exec nextjs node server.js
|
||||
|
||||
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);
|
||||
22
package-lock.json
generated
22
package-lock.json
generated
@@ -26,7 +26,6 @@
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"openai": "^6.33.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"prisma": "^7.5.0",
|
||||
"react": "19.2.3",
|
||||
@@ -45,6 +44,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.7",
|
||||
"openai": "^6.33.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
@@ -116,6 +116,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -758,6 +759,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -813,7 +815,8 @@
|
||||
"version": "0.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz",
|
||||
"integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==",
|
||||
"license": "Apache-2.0"
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@electric-sql/pglite-socket": {
|
||||
"version": "0.0.20",
|
||||
@@ -2130,6 +2133,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
@@ -2892,6 +2896,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2978,6 +2983,7 @@
|
||||
"integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.57.1",
|
||||
"@typescript-eslint/types": "8.57.1",
|
||||
@@ -3541,6 +3547,7 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4142,6 +4149,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -5468,6 +5476,7 @@
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -5653,6 +5662,7 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -6856,6 +6866,7 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
|
||||
"integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -9020,6 +9031,7 @@
|
||||
"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"
|
||||
@@ -9476,6 +9488,7 @@
|
||||
"integrity": "sha512-n30qZpWehaYQzigLjmuPisyEsvOzHt7bZeRyg8gZ5DvJo9FGjD+gNaY59Ns3hlLD5/jZH5GBeftIss0jDbUoLg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@prisma/config": "7.5.0",
|
||||
"@prisma/dev": "0.20.0",
|
||||
@@ -9741,6 +9754,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -9750,6 +9764,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -11102,6 +11117,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -11393,6 +11409,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -12031,6 +12048,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"openai": "^6.33.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"prisma": "^7.5.0",
|
||||
"react": "19.2.3",
|
||||
@@ -46,6 +45,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.7",
|
||||
"openai": "^6.33.0",
|
||||
"tailwindcss": "^4",
|
||||
"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?
|
||||
description String?
|
||||
|
||||
companyType String?
|
||||
topics String?
|
||||
salesScore Int?
|
||||
salesReason String?
|
||||
offerPackage String?
|
||||
approved Boolean @default(false)
|
||||
approvedAt DateTime?
|
||||
|
||||
capturedAt DateTime @default(now())
|
||||
contactedAt DateTime?
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -86,6 +94,19 @@ model Lead {
|
||||
@@index([sourceTab])
|
||||
@@index([capturedAt])
|
||||
@@index([email])
|
||||
@@index([approved])
|
||||
@@index([companyType])
|
||||
}
|
||||
|
||||
model SearchHistory {
|
||||
id String @id @default(cuid())
|
||||
query String
|
||||
region String
|
||||
searchMode String
|
||||
executedAt DateTime @default(now())
|
||||
|
||||
@@index([searchMode])
|
||||
@@index([executedAt])
|
||||
}
|
||||
|
||||
model LeadEvent {
|
||||
|
||||
102
prisma/turso-schema.sql
Normal file
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