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:
@@ -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
|
||||
|
||||
|
||||
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}
|
||||
|
||||
@@ -153,14 +153,14 @@ function SidePanel({ lead, onClose, onUpdate, onDelete }: {
|
||||
<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 && (
|
||||
@@ -169,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" />
|
||||
@@ -353,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"
|
||||
@@ -551,7 +551,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));
|
||||
@@ -559,21 +559,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>
|
||||
@@ -585,7 +586,7 @@ 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)],
|
||||
@@ -604,33 +605,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>
|
||||
<Sparkline data={stats.dailyCounts.map(d => d.count)} color={color} />
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
</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
|
||||
@@ -638,14 +642,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>
|
||||
))}
|
||||
@@ -653,41 +658,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>
|
||||
))}
|
||||
@@ -696,11 +706,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>
|
||||
@@ -708,11 +717,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>
|
||||
@@ -720,55 +728,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>
|
||||
@@ -805,7 +816,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>
|
||||
@@ -821,9 +832,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={() => {
|
||||
@@ -834,7 +849,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>
|
||||
@@ -883,11 +898,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}
|
||||
@@ -1008,20 +1022,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) => {
|
||||
@@ -1033,20 +1049,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,10 +19,16 @@ export default function SuchePage() {
|
||||
const [onlyNew, setOnlyNew] = useState(false);
|
||||
const [saveOnlyNew, setSaveOnlyNew] = useState(false);
|
||||
const [aiOpen, setAiOpen] = useState(false);
|
||||
const [searchMode, setSearchMode] = useState<"stadtwerke" | "industrie" | "custom">("stadtwerke");
|
||||
const [queueRunning, setQueueRunning] = useState(false);
|
||||
const [queueIndex, setQueueIndex] = useState(0);
|
||||
const [queueTotal, setQueueTotal] = useState(0);
|
||||
const [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",
|
||||
@@ -85,51 +90,174 @@ export default function SuchePage() {
|
||||
}
|
||||
}, [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);
|
||||
setQueueTotal(BUNDESLAENDER.length);
|
||||
setQueueIndex(0);
|
||||
setLeads([]);
|
||||
setSearchDone(false);
|
||||
|
||||
for (let i = 0; i < BUNDESLAENDER.length; i++) {
|
||||
setQueueIndex(i + 1);
|
||||
const bl = BUNDESLAENDER[i];
|
||||
toast.info(`Suche ${i + 1}/${BUNDESLAENDER.length}: Stadtwerke ${bl}`, { duration: 2000 });
|
||||
try {
|
||||
const res = await fetch("/api/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: "Stadtwerke", region: bl, count: 50 }),
|
||||
});
|
||||
const data = await res.json() as { jobId?: string };
|
||||
if (data.jobId) {
|
||||
// Save to history
|
||||
fetch("/api/search-history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: "Stadtwerke", region: bl, searchMode: "stadtwerke" }),
|
||||
}).catch(() => {});
|
||||
// Poll until done
|
||||
await new Promise<void>((resolve) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const statusRes = await fetch(`/api/jobs/${data.jobId}/status`);
|
||||
const status = await statusRes.json() as { status: string };
|
||||
if (status.status === "complete" || status.status === "failed") {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
} catch { clearInterval(interval); resolve(); }
|
||||
}, 3000);
|
||||
});
|
||||
// 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 { /* 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);
|
||||
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[]) {
|
||||
@@ -182,121 +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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{/* Mode Tabs */}
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
|
||||
{([
|
||||
{ id: "stadtwerke" as const, icon: "⚡", label: "Stadtwerke", desc: "Kommunale Energieversorger" },
|
||||
{ id: "industrie" as const, icon: "🏭", label: "Industriebetriebe", desc: "Energieintensive Betriebe" },
|
||||
{ id: "custom" as const, icon: "🔍", label: "Freie Suche", desc: "Beliebige Zielgruppe" },
|
||||
]).map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setSearchMode(tab.id)}
|
||||
style={{
|
||||
flex: 1, padding: "12px 16px", borderRadius: 10, cursor: "pointer", textAlign: "left",
|
||||
border: searchMode === tab.id ? "1px solid rgba(59,130,246,0.5)" : "1px solid #1e1e2e",
|
||||
background: searchMode === tab.id ? "rgba(59,130,246,0.08)" : "#111118",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 16, marginBottom: 4 }}>{tab.icon}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: searchMode === tab.id ? "#93c5fd" : "#fff", marginBottom: 2 }}>{tab.label}</div>
|
||||
<div style={{ fontSize: 11, color: "#6b7280" }}>{tab.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stadtwerke Queue Button */}
|
||||
{searchMode === "stadtwerke" && !loading && !queueRunning && (
|
||||
<button
|
||||
onClick={startStadtwerkeQueue}
|
||||
style={{
|
||||
width: "100%", padding: "14px 20px", borderRadius: 10, marginBottom: 12,
|
||||
border: "1px solid rgba(59,130,246,0.4)", background: "rgba(59,130,246,0.06)",
|
||||
color: "#60a5fa", cursor: "pointer", textAlign: "left",
|
||||
display: "flex", flexDirection: "column", gap: 4,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = "rgba(59,130,246,0.12)"; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = "rgba(59,130,246,0.06)"; }}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>⚡ Alle deutschen Stadtwerke durchsuchen (16 Bundesländer)</div>
|
||||
<div style={{ fontSize: 11, color: "#6b7280" }}>Startet 16 aufeinanderfolgende Suchen · ca. 800 Ergebnisse</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Queue running indicator */}
|
||||
{queueRunning && (
|
||||
<div style={{
|
||||
padding: "14px 20px", borderRadius: 10, marginBottom: 12,
|
||||
border: "1px solid rgba(59,130,246,0.3)", background: "rgba(59,130,246,0.06)",
|
||||
display: "flex", alignItems: "center", gap: 12,
|
||||
}}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth="2" style={{ animation: "spin 1s linear infinite", flexShrink: 0 }}>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||
</svg>
|
||||
{/* ── Manuelle Suche ── */}
|
||||
<section>
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-end gap-3 mb-6">
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: "#93c5fd", fontWeight: 500 }}>Bundesland {queueIndex} von {queueTotal} wird durchsucht…</div>
|
||||
<div style={{ fontSize: 11, color: "#6b7280", marginTop: 2 }}>Nicht schließen — Leads werden automatisch gespeichert</div>
|
||||
<h2 className="text-2xl font-extrabold tracking-tight" style={{ fontFamily: "Manrope, sans-serif", color: "#dce3ea" }}>
|
||||
Manuelle Lead- & Nischensuche
|
||||
</h2>
|
||||
<p className="text-sm mt-1" style={{ color: "#c1c6d6" }}>Verfeinern Sie Ihre Zielparameter für eine hochspezifische Extraktion.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAiOpen(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-3 py-2 md:px-4 rounded-xl transition-all disabled:opacity-50 flex-shrink-0"
|
||||
style={{ background: "#161c21", border: "1px solid rgba(173,199,255,0.2)", boxShadow: "0 0 20px rgba(26,115,232,0.1)" }}
|
||||
>
|
||||
<span className="material-symbols-outlined text-xl" style={{ color: "#adc7ff", fontVariationSettings: "'FILL' 1" }}>smart_toy</span>
|
||||
<span className="hidden sm:inline font-bold text-sm uppercase tracking-wider" style={{ fontFamily: "Manrope, sans-serif", color: "#adc7ff" }}>KI-gestützte Suche</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl p-8 shadow-2xl" style={{ background: "#161c21", border: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
{/* Form fields */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-12 gap-4 md:gap-6 items-end">
|
||||
<div className="md:col-span-5 space-y-2">
|
||||
<label className="block text-[10px] font-bold uppercase tracking-widest ml-1" style={{ color: "#c1c6d6" }}>Suchbegriff</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className="w-full rounded-xl px-4 py-4 text-sm transition-all outline-none"
|
||||
style={{ background: "#080f13", border: "1px solid rgba(65,71,84,0.4)", color: "#dce3ea" }}
|
||||
value={query}
|
||||
onChange={e => handleChange("query", e.target.value)}
|
||||
onKeyDown={e => { if (e.key === "Enter" && query.trim() && !loading) handleSubmit(); }}
|
||||
placeholder="z.B. Stadtwerke"
|
||||
onFocus={e => { e.currentTarget.style.borderColor = "#adc7ff"; e.currentTarget.style.boxShadow = "0 0 0 2px rgba(173,199,255,0.2)"; }}
|
||||
onBlur={e => { e.currentTarget.style.borderColor = "rgba(65,71,84,0.4)"; e.currentTarget.style.boxShadow = "none"; }}
|
||||
/>
|
||||
<span className="absolute right-4 top-1/2 -translate-y-1/2 material-symbols-outlined" style={{ color: "#8b909f", fontSize: 20 }}>search</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-4 space-y-2">
|
||||
<label className="block text-[10px] font-bold uppercase tracking-widest ml-1" style={{ color: "#c1c6d6" }}>Region</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className="w-full rounded-xl px-4 py-4 text-sm transition-all outline-none"
|
||||
style={{ background: "#080f13", border: "1px solid rgba(65,71,84,0.4)", color: "#dce3ea" }}
|
||||
value={region}
|
||||
onChange={e => handleChange("region", e.target.value)}
|
||||
onKeyDown={e => { if (e.key === "Enter" && query.trim() && !loading) handleSubmit(); }}
|
||||
placeholder="z.B. Bayern"
|
||||
onFocus={e => { e.currentTarget.style.borderColor = "#adc7ff"; e.currentTarget.style.boxShadow = "0 0 0 2px rgba(173,199,255,0.2)"; }}
|
||||
onBlur={e => { e.currentTarget.style.borderColor = "rgba(65,71,84,0.4)"; e.currentTarget.style.boxShadow = "none"; }}
|
||||
/>
|
||||
<span className="absolute right-4 top-1/2 -translate-y-1/2 material-symbols-outlined" style={{ color: "#8b909f", fontSize: 20 }}>location_on</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="block text-[10px] font-bold uppercase tracking-widest ml-1" style={{ color: "#c1c6d6" }}>Anzahl</label>
|
||||
<select
|
||||
className="w-full rounded-xl px-4 py-4 text-sm outline-none appearance-none"
|
||||
style={{ background: "#080f13", border: "1px solid rgba(65,71,84,0.4)", color: "#dce3ea" }}
|
||||
value={count}
|
||||
onChange={e => handleChange("count", Number(e.target.value))}
|
||||
>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="sm:col-span-2 md:col-span-1">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!query.trim() || loading || queueRunning}
|
||||
className="w-full h-[58px] rounded-xl flex items-center justify-center gap-2 transition-all active:scale-95 disabled:opacity-50"
|
||||
style={{ background: "#333a3f" }}
|
||||
onMouseEnter={e => { if (!e.currentTarget.disabled) { e.currentTarget.style.background = "#1a73e8"; } }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = "#333a3f"; }}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ color: "#dce3ea" }}>bolt</span>
|
||||
<span className="md:hidden text-sm font-bold" style={{ color: "#dce3ea", fontFamily: "Manrope, sans-serif" }}>Suchen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Presets */}
|
||||
<div className="mt-8 pt-8" style={{ borderTop: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest mb-4" style={{ color: "#c1c6d6" }}>Quick Presets</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{["Stadtwerke", "Energieversorger", "Industrie-Energie", "Gemeindewerke", "Netzbetreiber"].map(preset => (
|
||||
<button
|
||||
key={preset}
|
||||
onClick={() => handleChange("query", preset)}
|
||||
className="px-5 py-2 rounded-full text-sm font-medium transition-all"
|
||||
style={{ background: "rgba(47,54,59,0.5)", border: "1px solid rgba(255,255,255,0.05)", color: "#dce3ea" }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = "rgba(26,115,232,0.2)"; e.currentTarget.style.borderColor = "rgba(173,199,255,0.3)"; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = "rgba(47,54,59,0.5)"; e.currentTarget.style.borderColor = "rgba(255,255,255,0.05)"; }}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
||||
|
||||
{/* Search Card */}
|
||||
<SearchCard
|
||||
query={query}
|
||||
region={region}
|
||||
count={count}
|
||||
loading={loading || queueRunning}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
{/* Loading Card */}
|
||||
@@ -314,7 +527,7 @@ export default function SuchePage() {
|
||||
{/* AI Modal */}
|
||||
{aiOpen && (
|
||||
<AiSearchModal
|
||||
searchMode={searchMode}
|
||||
searchMode="custom"
|
||||
onStart={(queries) => {
|
||||
setAiOpen(false);
|
||||
if (!queries.length) return;
|
||||
@@ -331,7 +544,7 @@ export default function SuchePage() {
|
||||
fetch("/api/search-history", {
|
||||
method: "POST",
|
||||
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(() => {});
|
||||
fetch("/api/search", {
|
||||
method: "POST",
|
||||
@@ -363,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,90 +20,65 @@ 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 */}
|
||||
<Link href="/suche" style={{ textDecoration: "none" }}>
|
||||
<OnyvaLogo />
|
||||
</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 (
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.href === "/leadspeicher" && newLeadsCount > 0 && (
|
||||
<span
|
||||
style={{
|
||||
background: "#3b82f6",
|
||||
color: "#ffffff",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 4px",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{newLeadsCount > 99 ? "99+" : newLeadsCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{/* Left: Logo + Nav */}
|
||||
<div className="flex items-center gap-8">
|
||||
<Link href="/suche" style={{ textDecoration: "none" }}>
|
||||
<span className="text-xl font-black text-white tracking-tighter" style={{ fontFamily: "Manrope, sans-serif" }}>
|
||||
mein-solar | Lead Finder
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="hidden md:flex gap-6 items-center h-full">
|
||||
<Link
|
||||
href="/suche"
|
||||
className={`font-bold tracking-tight transition-all pb-1 text-sm ${
|
||||
isSearch
|
||||
? "text-blue-400 border-b-2 border-blue-500"
|
||||
: "text-slate-400 hover:text-white"
|
||||
}`}
|
||||
style={{ fontFamily: "Manrope, sans-serif", textDecoration: "none" }}
|
||||
>
|
||||
Lead-Suche
|
||||
</Link>
|
||||
<Link
|
||||
href="/leadspeicher"
|
||||
className={`font-bold tracking-tight transition-all pb-1 text-sm flex items-center gap-2 ${
|
||||
isLeadspeicher
|
||||
? "text-blue-400 border-b-2 border-blue-500"
|
||||
: "text-slate-400 hover:text-white"
|
||||
}`}
|
||||
style={{ fontFamily: "Manrope, sans-serif", textDecoration: "none" }}
|
||||
>
|
||||
Leadspeicher
|
||||
{newLeadsCount > 0 && (
|
||||
<span
|
||||
className="text-white font-bold rounded-full flex items-center justify-center px-1"
|
||||
style={{
|
||||
background: "#1a73e8",
|
||||
fontSize: 10,
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{newLeadsCount > 99 ? "99+" : newLeadsCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ export function AiSearchModal({ onStart, onClose, searchMode = "custom" }: AiSea
|
||||
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>
|
||||
|
||||
|
||||
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);
|
||||
Reference in New Issue
Block a user