Stitch redesign, Energieversorger-Kampagne, UI improvements

- Apply Stitch design system to leadspeicher, suche, TopBar, globals.css
- Add Energieversorger queue campaign (Netzbetreiber, Fernwärme, Industriepark)
  with BW + Bayern priority, tracks usage per term+location combo
- Remove TopBar right-side actions (Leads finden, bell, settings)
- Remove mode tabs from manual search, rename KI button
- Fix Google Fonts @import order (move to <link> in layout.tsx)
- Add cursor-pointer globally via globals.css
- Responsive fixes for campaign buttons and KI button
- Fix .dockerignore to exclude .env from image build
- Add stadtwerke-cities API + city data (50 cities per Bundesland)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Timo Uttenweiler
2026-04-09 10:08:00 +02:00
parent 54e0d22f9c
commit 7db914084e
9 changed files with 868 additions and 356 deletions

View File

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

View File

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

View File

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

View File

@@ -11,8 +11,14 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="de" className="dark"> <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"> <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 /> <Topbar />
<main className="flex-1 overflow-y-auto"> <main className="flex-1 overflow-y-auto">
{children} {children}

View File

@@ -153,14 +153,14 @@ function SidePanel({ lead, onClose, onUpdate, onDelete }: {
<div className="fixed inset-0 z-40 flex justify-end" onClick={onClose}> <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="absolute inset-0 bg-black/40 backdrop-blur-sm" />
<div <div
className="relative z-50 w-full max-w-sm h-full bg-[#0e0e1a] border-l border-[#1e1e2e] flex flex-col overflow-hidden" className="relative z-50 w-full max-w-sm h-full flex flex-col overflow-hidden"
style={{ animation: "slideIn 200ms ease-out" }} style={{ background: "#161c21", borderLeft: "1px solid rgba(255,255,255,0.07)", animation: "slideIn 200ms ease-out" }}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<style>{`@keyframes slideIn { from { transform: translateX(100%) } to { transform: translateX(0) } }`}</style> <style>{`@keyframes slideIn { from { transform: translateX(100%) } to { transform: translateX(0) } }`}</style>
{/* Header */} {/* 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"> <div className="flex-1 min-w-0">
<h2 className="text-base font-bold text-white truncate">{lead.companyName || lead.domain || "Unbekannt"}</h2> <h2 className="text-base font-bold text-white truncate">{lead.companyName || lead.domain || "Unbekannt"}</h2>
{lead.domain && ( {lead.domain && (
@@ -169,7 +169,7 @@ function SidePanel({ lead, onClose, onUpdate, onDelete }: {
<Globe className="w-3 h-3" />{lead.domain} <Globe className="w-3 h-3" />{lead.domain}
</a> </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> </div>
<button onClick={onClose} className="text-gray-500 hover:text-white p-1 rounded flex-shrink-0"> <button onClick={onClose} className="text-gray-500 hover:text-white p-1 rounded flex-shrink-0">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
@@ -353,7 +353,7 @@ function SidePanel({ lead, onClose, onUpdate, onDelete }: {
</div> </div>
{/* Delete */} {/* 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 <button
onClick={() => { onDelete(lead.id); onClose(); }} 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" 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"
@@ -551,7 +551,7 @@ export default function LeadVaultPage() {
function SortIcon({ field }: { field: string }) { function SortIcon({ field }: { field: string }) {
if (sortBy !== field) return <ArrowUpDown className="w-3 h-3 text-gray-600" />; 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)); const allSelected = leads.length > 0 && leads.every(l => selected.has(l.id));
@@ -559,21 +559,22 @@ export default function LeadVaultPage() {
return ( return (
<div className="space-y-5 max-w-full px-[72px] py-[72px]"> <div className="space-y-5 max-w-full px-[72px] py-[72px]">
{/* Header */} {/* 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="relative rounded-2xl p-6 overflow-hidden" style={{ background: "#161c21", border: "1px solid rgba(255,255,255,0.05)" }}>
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/5 to-transparent" /> <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 className="relative flex items-center justify-between">
<div> <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" /> <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> </div>
<h1 className="text-2xl font-bold text-white">🗄 Leadspeicher</h1> <h1 className="text-2xl font-extrabold" style={{ fontFamily: "Manrope, sans-serif", color: "#dce3ea" }}>Leadspeicher</h1>
<p className="text-gray-400 mt-1 text-sm">Alle Leads an einem Ort.</p> <p className="mt-1 text-sm" style={{ color: "#8b909f" }}>Alle Leads an einem Ort.</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => setExportOpen(v => !v)} 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 <Download className="w-3.5 h-3.5" /> Export
</button> </button>
@@ -585,7 +586,7 @@ export default function LeadVaultPage() {
{exportOpen && ( {exportOpen && (
<> <>
<div className="fixed inset-0 z-40" onClick={() => setExportOpen(false)} /> <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")], ["Aktuelle Ansicht", () => exportFile("xlsx")],
["Nur mit E-Mail", () => exportFile("xlsx", true)], ["Nur mit E-Mail", () => exportFile("xlsx", true)],
@@ -604,33 +605,36 @@ export default function LeadVaultPage() {
{stats && ( {stats && (
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-4 gap-4">
{[ {[
{ label: "Leads gesamt", value: stats.total, color: "#a78bfa" }, { label: "Leads gesamt", value: stats.total, color: "#adc7ff", accent: "rgba(173,199,255,0.15)" },
{ label: "Neu / Nicht kontaktiert", value: stats.new, color: "#60a5fa" }, { label: "Neu / Unbearbeitet", value: stats.new, color: "#adc7ff", accent: "rgba(173,199,255,0.1)" },
{ label: "Kontaktiert / In Bearbeitung", value: stats.contacted, color: "#2dd4bf" }, { label: "Kontaktiert", value: stats.contacted, color: "#a0d82c", accent: "rgba(160,216,44,0.1)" },
{ label: "Mit verifizierter E-Mail", value: stats.withEmail, color: "#34d399" }, { label: "Mit E-Mail", value: stats.withEmail, color: "#ffb77b", accent: "rgba(255,183,123,0.1)" },
].map(({ label, value, color }) => ( ].map(({ label, value, color, accent }) => (
<Card key={label} className="bg-[#111118] border-[#1e1e2e] p-4"> <div key={label} className="rounded-xl p-4" style={{ background: "#161c21", border: "1px solid rgba(255,255,255,0.05)" }}>
<p className="text-xs text-gray-500 mb-1">{label}</p> <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"> <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>
<Sparkline data={stats.dailyCounts.map(d => d.count)} color={color} /> <div className="rounded-lg p-2" style={{ background: accent }}>
<Sparkline data={stats.dailyCounts.map(d => d.count)} color={color} />
</div>
</div> </div>
</Card> </div>
))} ))}
</div> </div>
)} )}
{/* Quick SERP */} {/* 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 <button
onClick={() => setSerpOpen(!serpOpen)} 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> <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" /> : <ChevronDown className="w-4 h-4" />} {serpOpen ? <ChevronUp className="w-4 h-4" style={{ color: "#8b909f" }} /> : <ChevronDown className="w-4 h-4" style={{ color: "#8b909f" }} />}
</button> </button>
{serpOpen && ( {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="grid grid-cols-2 gap-3">
<div className="col-span-2"> <div className="col-span-2">
<Input <Input
@@ -638,14 +642,15 @@ export default function LeadVaultPage() {
value={serpQuery} value={serpQuery}
onChange={e => setSerpQuery(e.target.value)} onChange={e => setSerpQuery(e.target.value)}
onKeyDown={e => e.key === "Enter" && runQuickSerp()} 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> </div>
<Select value={serpCount} onValueChange={v => setSerpCount(v ?? "25")}> <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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]"> <SelectContent style={{ background: "#1a2025", borderColor: "rgba(255,255,255,0.08)" }}>
{["10", "25", "50", "100"].map(v => ( {["10", "25", "50", "100"].map(v => (
<SelectItem key={v} value={v} className="text-gray-300">{v} Ergebnisse</SelectItem> <SelectItem key={v} value={v} className="text-gray-300">{v} Ergebnisse</SelectItem>
))} ))}
@@ -653,41 +658,46 @@ export default function LeadVaultPage() {
</Select> </Select>
</div> </div>
<div className="flex items-center justify-between"> <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" /> <input type="checkbox" checked={serpFilter} onChange={e => setSerpFilter(e.target.checked)} className="rounded" />
Social-Media / Verzeichnisse herausfiltern Social-Media / Verzeichnisse herausfiltern
</label> </label>
<Button <button
onClick={runQuickSerp} onClick={runQuickSerp}
disabled={serpRunning || !serpQuery.trim()} 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"} {serpRunning ? "Suche läuft..." : "SERP Capture starten"}
</Button> </button>
</div> </div>
</div> </div>
)} )}
</Card> </div>
{/* Filter Bar */} {/* 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"> <div className="flex items-center gap-3 flex-wrap">
{/* Search */} {/* Search */}
<div className="relative flex-1 min-w-[200px]"> <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 <input
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
placeholder="Domain, Firma, Name, E-Mail suchen..." 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> </div>
{/* Has Email toggle */} {/* 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]) => ( {[["", "Alle"], ["yes", "Mit E-Mail"], ["no", "Ohne E-Mail"]].map(([v, l]) => (
<button key={v} onClick={() => { setFilterHasEmail(v); setPage(1); }} <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} {l}
</button> </button>
))} ))}
@@ -696,11 +706,10 @@ export default function LeadVaultPage() {
{/* Kontaktiert toggle */} {/* Kontaktiert toggle */}
<button <button
onClick={() => { setFilterContacted(v => !v); setPage(1); }} onClick={() => { setFilterContacted(v => !v); setPage(1); }}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-all ${ className="px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all"
filterContacted style={filterContacted
? "bg-teal-500/20 text-teal-300 border-teal-500/30" ? { background: "rgba(160,216,44,0.15)", color: "#a0d82c", border: "1px solid rgba(160,216,44,0.3)" }
: "bg-[#0d0d18] text-gray-500 border-[#2e2e3e] hover:text-gray-300 hover:border-[#4e4e6e]" : { background: "#080f13", color: "#8b909f", border: "1px solid rgba(255,255,255,0.08)" }}
}`}
> >
Kontaktiert Kontaktiert
</button> </button>
@@ -708,11 +717,10 @@ export default function LeadVaultPage() {
{/* Favoriten toggle */} {/* Favoriten toggle */}
<button <button
onClick={() => { setFilterFavorite(v => !v); setPage(1); }} onClick={() => { setFilterFavorite(v => !v); setPage(1); }}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-all ${ className="px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all"
filterFavorite style={filterFavorite
? "bg-amber-500/20 text-amber-300 border-amber-500/30" ? { background: "rgba(255,183,123,0.15)", color: "#ffb77b", border: "1px solid rgba(255,183,123,0.3)" }
: "bg-[#0d0d18] text-gray-500 border-[#2e2e3e] hover:text-gray-300 hover:border-[#4e4e6e]" : { background: "#080f13", color: "#8b909f", border: "1px solid rgba(255,255,255,0.08)" }}
}`}
> >
Favoriten Favoriten
</button> </button>
@@ -720,55 +728,58 @@ export default function LeadVaultPage() {
{/* Clear + count */} {/* Clear + count */}
<div className="flex items-center gap-2 ml-auto"> <div className="flex items-center gap-2 ml-auto">
{(search || filterHasEmail || filterContacted || filterFavorite || filterSearchTerms.length) && ( {(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 <X className="w-3 h-3" /> Filter zurücksetzen
</button> </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>
</div> </div>
</Card> </div>
{/* Bulk Actions */} {/* Bulk Actions */}
{selected.size > 0 && ( {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"> <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 text-purple-300 font-medium">{selected.size} ausgewählt</span> <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-2 flex-wrap">
<div className="flex gap-1"> <div className="flex gap-1">
<input <input
value={bulkTag} value={bulkTag}
onChange={e => setBulkTag(e.target.value)} onChange={e => setBulkTag(e.target.value)}
placeholder="Tag hinzufügen..." 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(""); } }} <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" /> <Tag className="w-3 h-3" />
</button> </button>
</div> </div>
<button onClick={() => bulkAction("delete", "")} <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 <Trash2 className="w-3 h-3" /> Löschen
</button> </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>
</div> </div>
)} )}
{/* Table */} {/* 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"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <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"> <th className="px-3 py-2.5 text-left">
<button onClick={() => { <button onClick={() => {
if (allSelected) setSelected(new Set()); if (allSelected) setSelected(new Set());
else setSelected(new Set(leads.map(l => l.id))); else setSelected(new Set(leads.map(l => l.id)));
}}> }}>
{allSelected {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" />} : <Square className="w-4 h-4 text-gray-600" />}
</button> </button>
</th> </th>
@@ -805,7 +816,7 @@ export default function LeadVaultPage() {
: "Noch keine Leads. Pipeline ausführen oder Quick SERP nutzen."} : "Noch keine Leads. Pipeline ausführen oder Quick SERP nutzen."}
</p> </p>
{search && ( {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> </td>
</tr> </tr>
@@ -821,9 +832,13 @@ export default function LeadVaultPage() {
<tr <tr
key={lead.id} key={lead.id}
onClick={() => setPanelLead(lead)} onClick={() => setPanelLead(lead)}
className={`border-b border-[#1a1a28] cursor-pointer transition-colors hover:bg-[#1a1a28] ${ className="cursor-pointer transition-colors"
isSelected ? "bg-[#1a1a2e]" : i % 2 === 0 ? "bg-[#111118]" : "bg-[#0f0f16]" 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()}> <td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
<button onClick={() => { <button onClick={() => {
@@ -834,7 +849,7 @@ export default function LeadVaultPage() {
}); });
}}> }}>
{isSelected {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" />} : <Square className="w-4 h-4 text-gray-600" />}
</button> </button>
</td> </td>
@@ -883,11 +898,10 @@ export default function LeadVaultPage() {
{lead.sourceTerm ? ( {lead.sourceTerm ? (
<button <button
onClick={e => { e.stopPropagation(); toggleFilter(filterSearchTerms, setFilterSearchTerms, lead.sourceTerm!); setPage(1); }} 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 ${ className="text-xs px-2 py-0.5 rounded-full border transition-all truncate max-w-full block"
filterSearchTerms.includes(lead.sourceTerm) style={filterSearchTerms.includes(lead.sourceTerm)
? "bg-amber-500/20 text-amber-300 border-amber-500/30" ? { background: "rgba(255,183,123,0.15)", color: "#ffb77b", borderColor: "rgba(255,183,123,0.3)" }
: "bg-[#1a1a28] text-gray-400 border-[#2e2e3e] hover:border-amber-500/30 hover:text-amber-300" : { background: "#1a2025", color: "#8b909f", borderColor: "rgba(255,255,255,0.08)" }}
}`}
title={lead.sourceTerm} title={lead.sourceTerm}
> >
🔎 {lead.sourceTerm} 🔎 {lead.sourceTerm}
@@ -1008,20 +1022,22 @@ export default function LeadVaultPage() {
{/* Pagination */} {/* Pagination */}
{pages > 1 && ( {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"> <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 <select
value={perPage} value={perPage}
onChange={e => { setPerPage(Number(e.target.value)); setPage(1); }} 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>)} {[25, 50, 100].map(n => <option key={n} value={n}>{n}</option>)}
</select> </select>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button disabled={page <= 1} onClick={() => setPage(p => p - 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 Zurück
</button> </button>
{Array.from({ length: Math.min(7, pages) }, (_, i) => { {Array.from({ length: Math.min(7, pages) }, (_, i) => {
@@ -1033,20 +1049,24 @@ export default function LeadVaultPage() {
} }
return ( return (
<button key={p} onClick={() => setPage(p)} <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} {p}
</button> </button>
); );
})} })}
<button disabled={page >= pages} onClick={() => setPage(p => p + 1)} <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 Weiter
</button> </button>
</div> </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> </div>
)} )}
</Card> </div>
{/* Side Panel */} {/* Side Panel */}
{panelLead && ( {panelLead && (

View File

@@ -3,7 +3,6 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { toast } from "sonner"; import { toast } from "sonner";
import { SearchCard } from "@/components/search/SearchCard";
import { LoadingCard, type LeadResult } from "@/components/search/LoadingCard"; import { LoadingCard, type LeadResult } from "@/components/search/LoadingCard";
import { AiSearchModal } from "@/components/search/AiSearchModal"; import { AiSearchModal } from "@/components/search/AiSearchModal";
@@ -20,10 +19,16 @@ export default function SuchePage() {
const [onlyNew, setOnlyNew] = useState(false); const [onlyNew, setOnlyNew] = useState(false);
const [saveOnlyNew, setSaveOnlyNew] = useState(false); const [saveOnlyNew, setSaveOnlyNew] = useState(false);
const [aiOpen, setAiOpen] = useState(false); const [aiOpen, setAiOpen] = useState(false);
const [searchMode, setSearchMode] = useState<"stadtwerke" | "industrie" | "custom">("stadtwerke");
const [queueRunning, setQueueRunning] = useState(false); const [queueRunning, setQueueRunning] = useState(false);
const [queueIndex, setQueueIndex] = useState(0); const [queueIndex, setQueueIndex] = useState(0);
const [queueTotal, setQueueTotal] = 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 = [ const BUNDESLAENDER = [
"Bayern", "NRW", "Baden-Württemberg", "Hessen", "Niedersachsen", "Bayern", "NRW", "Baden-Württemberg", "Hessen", "Niedersachsen",
@@ -85,51 +90,174 @@ export default function SuchePage() {
} }
}, [jobId]); }, [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() { async function startStadtwerkeQueue() {
if (loading || queueRunning) return; if (loading || queueRunning) return;
setQueueRunning(true); setQueueRunning(true);
setQueueTotal(BUNDESLAENDER.length);
setQueueIndex(0); setQueueIndex(0);
setLeads([]); setLeads([]);
setSearchDone(false); setSearchDone(false);
for (let i = 0; i < BUNDESLAENDER.length; i++) { // Fetch already-used regions to skip Bundesländer already done
setQueueIndex(i + 1); let usedRegions = new Set<string>();
const bl = BUNDESLAENDER[i]; try {
toast.info(`Suche ${i + 1}/${BUNDESLAENDER.length}: Stadtwerke ${bl}`, { duration: 2000 }); const hRes = await fetch("/api/search-history?mode=stadtwerke");
try { if (hRes.ok) {
const res = await fetch("/api/search", { const hist = await hRes.json() as Array<{ region: string }>;
method: "POST", usedRegions = new Set(hist.map(h => h.region));
headers: { "Content-Type": "application/json" }, }
body: JSON.stringify({ query: "Stadtwerke", region: bl, count: 50 }), } catch { /* ignore */ }
});
const data = await res.json() as { jobId?: string }; // Phase 1: Unused Bundesländer
if (data.jobId) { const unusedBL = BUNDESLAENDER.filter(bl => !usedRegions.has(bl));
// Save to history
fetch("/api/search-history", { // Phase 2: Next batch of unused cities
method: "POST", let cities: string[] = [];
headers: { "Content-Type": "application/json" }, try {
body: JSON.stringify({ query: "Stadtwerke", region: bl, searchMode: "stadtwerke" }), const cRes = await fetch("/api/stadtwerke-cities?count=50");
}).catch(() => {}); if (cRes.ok) {
// Poll until done const cData = await cRes.json() as { cities: string[]; exhausted: boolean };
await new Promise<void>((resolve) => { cities = cData.cities;
const interval = setInterval(async () => { if (cData.exhausted) {
try { toast.info("Alle vordefinierten Städte durchsucht — KI generiert neue Vorschläge", { duration: 4000 });
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 */ } }
} 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); setQueueRunning(false);
toast.success(`✓ Alle ${BUNDESLAENDER.length} Bundesländer durchsucht — Leads im Leadspeicher`, { duration: 5000 }); 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[]) { async function handleDelete(ids: string[]) {
@@ -182,121 +310,206 @@ export default function SuchePage() {
}, []); }, []);
return ( return (
<div <div className="pb-12 px-8 max-w-7xl mx-auto pt-8 space-y-10">
style={{ <style>{`
padding: "72px 120px", @keyframes spin { to { transform: rotate(360deg) } }
maxWidth: 900, .del-btn:hover { opacity: 1 !important; }
margin: "0 auto", .lead-row:hover { background: rgba(255,255,255,0.02); }
}} .lead-row:hover .row-del { opacity: 1 !important; }
> `}</style>
{/* Hero */}
<div className="relative rounded-2xl border border-[#1e1e2e] p-6 overflow-hidden mb-6" {/* ── Strategische Kampagnen ── */}
style={{ background: "linear-gradient(135deg, rgba(59,130,246,0.08) 0%, rgba(139,92,246,0.08) 100%)" }}> <section>
<div className="absolute inset-0" style={{ background: "linear-gradient(135deg, rgba(59,130,246,0.04) 0%, transparent 60%)" }} /> <h2 className="text-2xl font-extrabold tracking-tight mb-6" style={{ fontFamily: "Manrope, sans-serif", color: "#dce3ea" }}>
<div className="relative"> Strategische Energiewirtschafts-Kampagnen
<div className="flex items-center gap-2 text-xs mb-2" style={{ color: "#3b82f6" }}> </h2>
<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> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<span>Lead-Suche</span> {/* CTA Card — Stadtwerke */}
</div> <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="flex items-start justify-between"> <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> <div className="relative z-10">
<h1 style={{ fontSize: 22, fontWeight: 500, color: "#ffffff", margin: 0, marginBottom: 6 }}> <p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "#c1c6d6", fontFamily: "Inter, sans-serif", letterSpacing: "0.1em" }}>Hauptkampagne</p>
Leads finden <h3 className="text-3xl font-extrabold mb-6 leading-tight" style={{ fontFamily: "Manrope, sans-serif" }}>
</h1> Bundesweite<br />Lead-Gewinnung
<p style={{ fontSize: 13, color: "#9ca3af", margin: 0 }}> </h3>
Suchbegriff eingeben wir finden passende Unternehmen mit Kontaktdaten. <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> </p>
{queueRunning ? (
<div className="w-full py-4 rounded-xl flex items-center justify-center gap-3" style={{ background: "#242b30" }}>
<svg style={{ animation: "spin 1s linear infinite", flexShrink: 0 }} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#adc7ff" strokeWidth="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<div>
<div className="font-bold text-sm" style={{ color: "#adc7ff", fontFamily: "Manrope, sans-serif" }}>{queueLabel || "Wird durchsucht…"} ({queueIndex}/{queueTotal})</div>
<div className="text-xs mt-0.5" style={{ color: "#c1c6d6" }}>Leads werden automatisch gespeichert</div>
</div>
</div>
) : (
<button
onClick={startStadtwerkeQueue}
disabled={loading}
className="w-full py-5 rounded-xl font-black text-lg text-white transition-all active:scale-[0.98] disabled:opacity-50"
style={{
fontFamily: "Manrope, sans-serif",
background: "linear-gradient(135deg, #adc7ff, #1a73e8)",
boxShadow: "0 10px 30px rgba(26,115,232,0.4)",
}}
onMouseEnter={e => { e.currentTarget.style.boxShadow = "0 15px 40px rgba(26,115,232,0.6)"; }}
onMouseLeave={e => { e.currentTarget.style.boxShadow = "0 10px 30px rgba(26,115,232,0.4)"; }}
>
START 16 BUNDESLÄNDER STADTWERKE-SUCHE (1-Klick)
</button>
)}
</div>
</div>
{/* CTA Card — Energieversorger */}
<div className="rounded-xl p-8 relative overflow-hidden flex flex-col justify-center" style={{ background: "#161c21", border: "1px solid rgba(255,255,255,0.05)" }}>
<div className="absolute -top-24 -right-24 w-64 h-64 rounded-full pointer-events-none" style={{ background: "rgba(160,216,44,0.12)", filter: "blur(100px)" }} />
<div className="relative z-10">
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "#c1c6d6", fontFamily: "Inter, sans-serif", letterSpacing: "0.1em" }}>Energieversorger-Kampagne</p>
<h3 className="text-3xl font-extrabold mb-6 leading-tight" style={{ fontFamily: "Manrope, sans-serif" }}>
Industrie &amp;<br />Energieversorger
</h3>
<p className="text-sm leading-relaxed mb-8 max-w-md" style={{ color: "#c1c6d6" }}>
Sucht nach <strong style={{ color: "#a0d82c" }}>Netzbetreiber</strong>, <strong style={{ color: "#a0d82c" }}>Fernwärme</strong> und <strong style={{ color: "#a0d82c" }}>Industriepark</strong> priorisiert BW &amp; Bayern, dann alle weiteren Bundesländer.
</p>
{industrieRunning ? (
<div className="w-full py-4 rounded-xl flex items-center justify-center gap-3" style={{ background: "#242b30" }}>
<svg style={{ animation: "spin 1s linear infinite", flexShrink: 0 }} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a0d82c" strokeWidth="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<div>
<div className="font-bold text-sm" style={{ color: "#a0d82c", fontFamily: "Manrope, sans-serif" }}>{industrieLabel || "Wird durchsucht…"} ({industrieIndex}/{industrieTotal})</div>
<div className="text-xs mt-0.5" style={{ color: "#c1c6d6" }}>Leads werden automatisch gespeichert</div>
</div>
</div>
) : (
<button
onClick={startIndustrieQueue}
disabled={loading || queueRunning}
className="w-full py-5 rounded-xl font-black text-lg transition-all active:scale-[0.98] disabled:opacity-50"
style={{
fontFamily: "Manrope, sans-serif",
background: "linear-gradient(135deg, #a0d82c, #5c8200)",
color: "#131f00",
boxShadow: "0 10px 30px rgba(160,216,44,0.3)",
}}
onMouseEnter={e => { e.currentTarget.style.boxShadow = "0 15px 40px rgba(160,216,44,0.5)"; }}
onMouseLeave={e => { e.currentTarget.style.boxShadow = "0 10px 30px rgba(160,216,44,0.3)"; }}
>
START ENERGIEVERSORGER-SUCHE
</button>
)}
</div> </div>
<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)"; }}
>
KI-Suche
</button>
</div> </div>
</div> </div>
</div> </section>
{/* Mode Tabs */} {/* ── Manuelle Suche ── */}
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}> <section>
{([ <div className="flex flex-col sm:flex-row sm:justify-between sm:items-end gap-3 mb-6">
{ id: "stadtwerke" as const, icon: "⚡", label: "Stadtwerke", desc: "Kommunale Energieversorger" },
{ id: "industrie" as const, icon: "🏭", label: "Industriebetriebe", desc: "Energieintensive Betriebe" },
{ id: "custom" as const, icon: "🔍", label: "Freie Suche", desc: "Beliebige Zielgruppe" },
]).map(tab => (
<button
key={tab.id}
onClick={() => setSearchMode(tab.id)}
style={{
flex: 1, padding: "12px 16px", borderRadius: 10, cursor: "pointer", textAlign: "left",
border: searchMode === tab.id ? "1px solid rgba(59,130,246,0.5)" : "1px solid #1e1e2e",
background: searchMode === tab.id ? "rgba(59,130,246,0.08)" : "#111118",
transition: "all 0.15s",
}}
>
<div style={{ fontSize: 16, marginBottom: 4 }}>{tab.icon}</div>
<div style={{ fontSize: 13, fontWeight: 500, color: searchMode === tab.id ? "#93c5fd" : "#fff", marginBottom: 2 }}>{tab.label}</div>
<div style={{ fontSize: 11, color: "#6b7280" }}>{tab.desc}</div>
</button>
))}
</div>
{/* Stadtwerke Queue Button */}
{searchMode === "stadtwerke" && !loading && !queueRunning && (
<button
onClick={startStadtwerkeQueue}
style={{
width: "100%", padding: "14px 20px", borderRadius: 10, marginBottom: 12,
border: "1px solid rgba(59,130,246,0.4)", background: "rgba(59,130,246,0.06)",
color: "#60a5fa", cursor: "pointer", textAlign: "left",
display: "flex", flexDirection: "column", gap: 4,
}}
onMouseEnter={e => { e.currentTarget.style.background = "rgba(59,130,246,0.12)"; }}
onMouseLeave={e => { e.currentTarget.style.background = "rgba(59,130,246,0.06)"; }}
>
<div style={{ fontSize: 13, fontWeight: 500 }}> Alle deutschen Stadtwerke durchsuchen (16 Bundesländer)</div>
<div style={{ fontSize: 11, color: "#6b7280" }}>Startet 16 aufeinanderfolgende Suchen · ca. 800 Ergebnisse</div>
</button>
)}
{/* Queue running indicator */}
{queueRunning && (
<div style={{
padding: "14px 20px", borderRadius: 10, marginBottom: 12,
border: "1px solid rgba(59,130,246,0.3)", background: "rgba(59,130,246,0.06)",
display: "flex", alignItems: "center", gap: 12,
}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth="2" style={{ animation: "spin 1s linear infinite", flexShrink: 0 }}>
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
<div> <div>
<div style={{ fontSize: 13, color: "#93c5fd", fontWeight: 500 }}>Bundesland {queueIndex} von {queueTotal} wird durchsucht</div> <h2 className="text-2xl font-extrabold tracking-tight" style={{ fontFamily: "Manrope, sans-serif", color: "#dce3ea" }}>
<div style={{ fontSize: 11, color: "#6b7280", marginTop: 2 }}>Nicht schließen Leads werden automatisch gespeichert</div> Manuelle Lead- &amp; Nischensuche
</h2>
<p className="text-sm mt-1" style={{ color: "#c1c6d6" }}>Verfeinern Sie Ihre Zielparameter für eine hochspezifische Extraktion.</p>
</div>
<button
onClick={() => setAiOpen(true)}
disabled={loading}
className="flex items-center gap-2 px-3 py-2 md:px-4 rounded-xl transition-all disabled:opacity-50 flex-shrink-0"
style={{ background: "#161c21", border: "1px solid rgba(173,199,255,0.2)", boxShadow: "0 0 20px rgba(26,115,232,0.1)" }}
>
<span className="material-symbols-outlined text-xl" style={{ color: "#adc7ff", fontVariationSettings: "'FILL' 1" }}>smart_toy</span>
<span className="hidden sm:inline font-bold text-sm uppercase tracking-wider" style={{ fontFamily: "Manrope, sans-serif", color: "#adc7ff" }}>KI-gestützte Suche</span>
</button>
</div>
<div className="rounded-2xl p-8 shadow-2xl" style={{ background: "#161c21", border: "1px solid rgba(255,255,255,0.05)" }}>
{/* Form fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-12 gap-4 md:gap-6 items-end">
<div className="md:col-span-5 space-y-2">
<label className="block text-[10px] font-bold uppercase tracking-widest ml-1" style={{ color: "#c1c6d6" }}>Suchbegriff</label>
<div className="relative">
<input
className="w-full rounded-xl px-4 py-4 text-sm transition-all outline-none"
style={{ background: "#080f13", border: "1px solid rgba(65,71,84,0.4)", color: "#dce3ea" }}
value={query}
onChange={e => handleChange("query", e.target.value)}
onKeyDown={e => { if (e.key === "Enter" && query.trim() && !loading) handleSubmit(); }}
placeholder="z.B. Stadtwerke"
onFocus={e => { e.currentTarget.style.borderColor = "#adc7ff"; e.currentTarget.style.boxShadow = "0 0 0 2px rgba(173,199,255,0.2)"; }}
onBlur={e => { e.currentTarget.style.borderColor = "rgba(65,71,84,0.4)"; e.currentTarget.style.boxShadow = "none"; }}
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 material-symbols-outlined" style={{ color: "#8b909f", fontSize: 20 }}>search</span>
</div>
</div>
<div className="md:col-span-4 space-y-2">
<label className="block text-[10px] font-bold uppercase tracking-widest ml-1" style={{ color: "#c1c6d6" }}>Region</label>
<div className="relative">
<input
className="w-full rounded-xl px-4 py-4 text-sm transition-all outline-none"
style={{ background: "#080f13", border: "1px solid rgba(65,71,84,0.4)", color: "#dce3ea" }}
value={region}
onChange={e => handleChange("region", e.target.value)}
onKeyDown={e => { if (e.key === "Enter" && query.trim() && !loading) handleSubmit(); }}
placeholder="z.B. Bayern"
onFocus={e => { e.currentTarget.style.borderColor = "#adc7ff"; e.currentTarget.style.boxShadow = "0 0 0 2px rgba(173,199,255,0.2)"; }}
onBlur={e => { e.currentTarget.style.borderColor = "rgba(65,71,84,0.4)"; e.currentTarget.style.boxShadow = "none"; }}
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 material-symbols-outlined" style={{ color: "#8b909f", fontSize: 20 }}>location_on</span>
</div>
</div>
<div className="md:col-span-2 space-y-2">
<label className="block text-[10px] font-bold uppercase tracking-widest ml-1" style={{ color: "#c1c6d6" }}>Anzahl</label>
<select
className="w-full rounded-xl px-4 py-4 text-sm outline-none appearance-none"
style={{ background: "#080f13", border: "1px solid rgba(65,71,84,0.4)", color: "#dce3ea" }}
value={count}
onChange={e => handleChange("count", Number(e.target.value))}
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
<div className="sm:col-span-2 md:col-span-1">
<button
onClick={handleSubmit}
disabled={!query.trim() || loading || queueRunning}
className="w-full h-[58px] rounded-xl flex items-center justify-center gap-2 transition-all active:scale-95 disabled:opacity-50"
style={{ background: "#333a3f" }}
onMouseEnter={e => { if (!e.currentTarget.disabled) { e.currentTarget.style.background = "#1a73e8"; } }}
onMouseLeave={e => { e.currentTarget.style.background = "#333a3f"; }}
>
<span className="material-symbols-outlined" style={{ color: "#dce3ea" }}>bolt</span>
<span className="md:hidden text-sm font-bold" style={{ color: "#dce3ea", fontFamily: "Manrope, sans-serif" }}>Suchen</span>
</button>
</div>
</div>
{/* Quick Presets */}
<div className="mt-8 pt-8" style={{ borderTop: "1px solid rgba(255,255,255,0.05)" }}>
<p className="text-[10px] font-bold uppercase tracking-widest mb-4" style={{ color: "#c1c6d6" }}>Quick Presets</p>
<div className="flex flex-wrap gap-3">
{["Stadtwerke", "Energieversorger", "Industrie-Energie", "Gemeindewerke", "Netzbetreiber"].map(preset => (
<button
key={preset}
onClick={() => handleChange("query", preset)}
className="px-5 py-2 rounded-full text-sm font-medium transition-all"
style={{ background: "rgba(47,54,59,0.5)", border: "1px solid rgba(255,255,255,0.05)", color: "#dce3ea" }}
onMouseEnter={e => { e.currentTarget.style.background = "rgba(26,115,232,0.2)"; e.currentTarget.style.borderColor = "rgba(173,199,255,0.3)"; }}
onMouseLeave={e => { e.currentTarget.style.background = "rgba(47,54,59,0.5)"; e.currentTarget.style.borderColor = "rgba(255,255,255,0.05)"; }}
>
{preset}
</button>
))}
</div>
</div> </div>
</div> </div>
)} </section>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
{/* Search Card */}
<SearchCard
query={query}
region={region}
count={count}
loading={loading || queueRunning}
onChange={handleChange}
onSubmit={handleSubmit}
/>
{/* Loading Card */} {/* Loading Card */}
@@ -314,7 +527,7 @@ export default function SuchePage() {
{/* AI Modal */} {/* AI Modal */}
{aiOpen && ( {aiOpen && (
<AiSearchModal <AiSearchModal
searchMode={searchMode} searchMode="custom"
onStart={(queries) => { onStart={(queries) => {
setAiOpen(false); setAiOpen(false);
if (!queries.length) return; if (!queries.length) return;
@@ -331,7 +544,7 @@ export default function SuchePage() {
fetch("/api/search-history", { fetch("/api/search-history", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: first.query, region: first.region, searchMode }), body: JSON.stringify({ query: first.query, region: first.region, searchMode: "custom" }),
}).catch(() => {}); }).catch(() => {});
fetch("/api/search", { fetch("/api/search", {
method: "POST", method: "POST",
@@ -363,9 +576,8 @@ export default function SuchePage() {
return ( return (
<div <div
style={{ style={{
marginTop: 16, background: "#161c21",
background: "#111118", border: "1px solid rgba(65,71,84,0.3)",
border: "1px solid #1e1e2e",
borderRadius: 12, borderRadius: 12,
overflow: "hidden", overflow: "hidden",
}} }}

View File

@@ -4,34 +4,6 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react"; 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() { export function Topbar() {
const pathname = usePathname(); const pathname = usePathname();
const [newLeadsCount, setNewLeadsCount] = useState(0); const [newLeadsCount, setNewLeadsCount] = useState(0);
@@ -48,90 +20,65 @@ export function Topbar() {
return () => clearInterval(t); return () => clearInterval(t);
}, []); }, []);
const tabs = [ const isSearch = pathname === "/suche" || pathname.startsWith("/suche/");
{ href: "/suche", label: "Suche" }, const isLeadspeicher = pathname === "/leadspeicher" || pathname.startsWith("/leadspeicher/");
{ href: "/leadspeicher", label: "Leadspeicher" },
];
return ( return (
<header <header className="sticky top-0 w-full z-50 flex justify-between items-center px-8 h-16"
style={{ style={{
position: "sticky", background: "rgba(13, 20, 25, 0.85)",
top: 0, backdropFilter: "blur(20px)",
zIndex: 50, WebkitBackdropFilter: "blur(20px)",
height: 52, boxShadow: "0 20px 40px rgba(0,0,0,0.4)",
background: "#111118",
borderBottom: "1px solid #1e1e2e",
display: "flex",
alignItems: "center",
paddingLeft: 20,
paddingRight: 20,
gap: 16,
flexShrink: 0,
}} }}
> >
{/* Logo */} {/* Left: Logo + Nav */}
<Link href="/suche" style={{ textDecoration: "none" }}> <div className="flex items-center gap-8">
<OnyvaLogo /> <Link href="/suche" style={{ textDecoration: "none" }}>
</Link> <span className="text-xl font-black text-white tracking-tighter" style={{ fontFamily: "Manrope, sans-serif" }}>
mein-solar | Lead Finder
{/* Tab switcher */} </span>
<div </Link>
style={{ <nav className="hidden md:flex gap-6 items-center h-full">
background: "#0a0a0f", <Link
borderRadius: 8, href="/suche"
padding: 3, className={`font-bold tracking-tight transition-all pb-1 text-sm ${
display: "flex", isSearch
gap: 2, ? "text-blue-400 border-b-2 border-blue-500"
}} : "text-slate-400 hover:text-white"
> }`}
{tabs.map((tab) => { style={{ fontFamily: "Manrope, sans-serif", textDecoration: "none" }}
const isActive = pathname === tab.href || pathname.startsWith(tab.href + "/"); >
return ( Lead-Suche
<Link </Link>
key={tab.href} <Link
href={tab.href} href="/leadspeicher"
style={{ className={`font-bold tracking-tight transition-all pb-1 text-sm flex items-center gap-2 ${
display: "flex", isLeadspeicher
alignItems: "center", ? "text-blue-400 border-b-2 border-blue-500"
gap: 6, : "text-slate-400 hover:text-white"
padding: "4px 12px", }`}
borderRadius: 6, style={{ fontFamily: "Manrope, sans-serif", textDecoration: "none" }}
fontSize: 13, >
fontWeight: isActive ? 500 : 400, Leadspeicher
color: isActive ? "#ffffff" : "#9ca3af", {newLeadsCount > 0 && (
background: isActive ? "#111118" : "transparent", <span
boxShadow: isActive ? "0 1px 3px rgba(0,0,0,0.2)" : "none", className="text-white font-bold rounded-full flex items-center justify-center px-1"
textDecoration: "none", style={{
transition: "all 0.15s", background: "#1a73e8",
whiteSpace: "nowrap", fontSize: 10,
}} minWidth: 18,
> height: 18,
{tab.label} lineHeight: 1,
{tab.href === "/leadspeicher" && newLeadsCount > 0 && ( }}
<span >
style={{ {newLeadsCount > 99 ? "99+" : newLeadsCount}
background: "#3b82f6", </span>
color: "#ffffff", )}
fontSize: 10, </Link>
fontWeight: 600, </nav>
minWidth: 18,
height: 18,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0 4px",
lineHeight: 1,
}}
>
{newLeadsCount > 99 ? "99+" : newLeadsCount}
</span>
)}
</Link>
);
})}
</div> </div>
</header> </header>
); );
} }

View File

@@ -121,7 +121,7 @@ export function AiSearchModal({ onStart, onClose, searchMode = "custom" }: AiSea
onBlur={e => { e.currentTarget.style.borderColor = "#1e1e2e"; }} onBlur={e => { e.currentTarget.style.borderColor = "#1e1e2e"; }}
/> />
<p style={{ margin: "4px 0 0", fontSize: 11, color: "#4b5563" }}> <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> </p>
</div> </div>

View File

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