- New Maps page with keyword + region chips, German city presets, query preview, enrichment toggle - Google Maps Places API (New) service with pagination and deduplication - maps-enrich job route: Maps search → store raw leads → optional Anymailfinder bulk enrichment - Settings: Google Maps API key credential card with Places API instructions - Sidebar: MapPin nav item + googlemaps credential status indicator - Results: maps job type with MapPin icon (text-green-400) - Credentials API: added googlemaps to SERVICES array and test endpoint Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
253 lines
9.1 KiB
TypeScript
253 lines
9.1 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { Card } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { toast } from "sonner";
|
|
import { CheckCircle2, XCircle, Loader2, ExternalLink, Eye, EyeOff } from "lucide-react";
|
|
|
|
interface Credential {
|
|
service: string;
|
|
label: string;
|
|
placeholder: string;
|
|
link: string;
|
|
linkLabel: string;
|
|
isTextarea?: boolean;
|
|
instructions?: string;
|
|
}
|
|
|
|
const CREDENTIALS: Credential[] = [
|
|
{
|
|
service: "anymailfinder",
|
|
label: "Anymailfinder API Key",
|
|
placeholder: "amf_...",
|
|
link: "https://anymailfinder.com/account",
|
|
linkLabel: "Get API key",
|
|
},
|
|
{
|
|
service: "googlemaps",
|
|
label: "Google Maps API Key",
|
|
placeholder: "AIza...",
|
|
link: "https://console.cloud.google.com/apis/credentials",
|
|
linkLabel: "Google Cloud Console",
|
|
instructions: 'Enable the "Places API (New)" in Google Cloud Console. Go to APIs & Services → Enable APIs → search "Places API (New)". Then create an API key under Credentials. Restrict the key to "Places API (New)" for security. Free tier: $200/month credit ≈ 6,000 searches.',
|
|
},
|
|
{
|
|
service: "apify",
|
|
label: "Apify API Token",
|
|
placeholder: "apify_api_...",
|
|
link: "https://console.apify.com/account/integrations",
|
|
linkLabel: "Get API token",
|
|
},
|
|
{
|
|
service: "vayne",
|
|
label: "Vayne API Token",
|
|
placeholder: "Bearer token from vayne.io dashboard",
|
|
link: "https://www.vayne.io",
|
|
linkLabel: "Vayne Dashboard",
|
|
isTextarea: true,
|
|
instructions: "Generate your API token in the API Settings section of your Vayne Dashboard at vayne.io. Use Bearer authentication — Vayne manages the LinkedIn session on their end.",
|
|
},
|
|
];
|
|
|
|
interface CredentialState {
|
|
value: string;
|
|
saved: boolean;
|
|
testing: boolean;
|
|
testResult: "ok" | "fail" | null;
|
|
show: boolean;
|
|
}
|
|
|
|
export default function SettingsPage() {
|
|
const [creds, setCreds] = useState<Record<string, CredentialState>>(() =>
|
|
Object.fromEntries(
|
|
CREDENTIALS.map(c => [c.service, { value: "", saved: false, testing: false, testResult: null, show: false }])
|
|
)
|
|
);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// Load existing (masked) status
|
|
useEffect(() => {
|
|
fetch("/api/credentials")
|
|
.then(r => r.json())
|
|
.then((data: Record<string, boolean>) => {
|
|
setCreds(prev => {
|
|
const next = { ...prev };
|
|
for (const [k, v] of Object.entries(data)) {
|
|
if (next[k]) {
|
|
next[k] = { ...next[k], saved: v };
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const update = (service: string, field: keyof CredentialState, val: unknown) => {
|
|
setCreds(prev => ({ ...prev, [service]: { ...prev[service], [field]: val } }));
|
|
};
|
|
|
|
const saveAll = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const body: Record<string, string> = {};
|
|
for (const [service, state] of Object.entries(creds)) {
|
|
if (state.value) body[service] = state.value;
|
|
}
|
|
const res = await fetch("/api/credentials", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) throw new Error("Save failed");
|
|
toast.success("Credentials saved successfully");
|
|
setCreds(prev => {
|
|
const next = { ...prev };
|
|
for (const service of Object.keys(body)) {
|
|
next[service] = { ...next[service], saved: true };
|
|
}
|
|
return next;
|
|
});
|
|
} catch {
|
|
toast.error("Failed to save credentials");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const testConnection = async (service: string) => {
|
|
update(service, "testing", true);
|
|
update(service, "testResult", null);
|
|
try {
|
|
const res = await fetch(`/api/credentials/test?service=${service}`);
|
|
const data = await res.json() as { ok: boolean };
|
|
update(service, "testResult", data.ok ? "ok" : "fail");
|
|
if (data.ok) toast.success(`${service} connection verified`);
|
|
else toast.error(`${service} connection failed — check your key`);
|
|
} catch {
|
|
update(service, "testResult", "fail");
|
|
toast.error("Connection test failed");
|
|
} finally {
|
|
update(service, "testing", false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-2xl space-y-6">
|
|
{/* Header */}
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
|
<p className="text-gray-400 mt-1 text-sm">
|
|
API credentials are encrypted with AES-256 and stored locally in SQLite.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Credential cards */}
|
|
{CREDENTIALS.map(cred => {
|
|
const state = creds[cred.service];
|
|
return (
|
|
<Card key={cred.service} className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div>
|
|
<Label className="text-white font-semibold text-base">{cred.label}</Label>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
{state.saved ? (
|
|
<span className="flex items-center gap-1 text-xs text-green-400">
|
|
<CheckCircle2 className="w-3.5 h-3.5" /> Configured
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-1 text-xs text-red-400">
|
|
<XCircle className="w-3.5 h-3.5" /> Not configured
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<a
|
|
href={cred.link}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300 transition-colors"
|
|
>
|
|
{cred.linkLabel} <ExternalLink className="w-3 h-3" />
|
|
</a>
|
|
</div>
|
|
|
|
{cred.instructions && (
|
|
<p className="text-xs text-gray-500 bg-[#0d0d18] rounded-lg p-3 border border-[#1e1e2e]">
|
|
{cred.instructions}
|
|
</p>
|
|
)}
|
|
|
|
<div className="relative">
|
|
{cred.isTextarea ? (
|
|
<Textarea
|
|
placeholder={cred.placeholder}
|
|
value={state.value}
|
|
onChange={e => update(cred.service, "value", e.target.value)}
|
|
className="bg-[#0d0d18] border-[#2e2e3e] text-white placeholder:text-gray-600 focus:border-blue-500 resize-none h-20"
|
|
/>
|
|
) : (
|
|
<div className="relative">
|
|
<Input
|
|
type={state.show ? "text" : "password"}
|
|
placeholder={state.saved && !state.value ? "••••••••••••••••" : cred.placeholder}
|
|
value={state.value}
|
|
onChange={e => update(cred.service, "value", e.target.value)}
|
|
className="bg-[#0d0d18] border-[#2e2e3e] text-white placeholder:text-gray-600 focus:border-blue-500 pr-10"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => update(cred.service, "show", !state.show)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
|
>
|
|
{state.show ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={state.testing || (!state.saved && !state.value)}
|
|
onClick={() => testConnection(cred.service)}
|
|
className="border-[#2e2e3e] hover:border-blue-500/50 text-gray-300"
|
|
>
|
|
{state.testing ? (
|
|
<><Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" /> Testing...</>
|
|
) : "Test Connection"}
|
|
</Button>
|
|
{state.testResult === "ok" && (
|
|
<span className="flex items-center gap-1 text-xs text-green-400">
|
|
<CheckCircle2 className="w-3.5 h-3.5" /> Connected
|
|
</span>
|
|
)}
|
|
{state.testResult === "fail" && (
|
|
<span className="flex items-center gap-1 text-xs text-red-400">
|
|
<XCircle className="w-3.5 h-3.5" /> Failed
|
|
</span>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
})}
|
|
|
|
{/* Save button */}
|
|
<Button
|
|
disabled={saving}
|
|
onClick={saveAll}
|
|
className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium px-8 shadow-lg hover:shadow-blue-500/25 transition-all"
|
|
>
|
|
{saving ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Saving...</> : "Save All Credentials"}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|