email-agent-api / frontend /src /components /RecipientInput.jsx
habib-analyst's picture
Initial commit for deployment
e9d1895
Raw History Blame Contribute Delete
14.1 kB
import { useState, useRef } from "react";
import { Search, Users, Loader2, FileSpreadsheet, Zap, Eye, Sparkles, Globe, Link2 } from "lucide-react";
import { batchAPI } from "../api/client";
import toast from "react-hot-toast";
export default function RecipientInput({ onResearch, onBatchStart, loading, disabled }) {
const [mode, setMode] = useState("single");
const [singleInput, setSingleInput] = useState("");
const [batchInput, setBatchInput] = useState("");
const [extractedEmails, setExtractedEmails] = useState([]);
const [batchMode, setBatchMode] = useState("manual");
const [delayMin, setDelayMin] = useState(15);
const [delayMax, setDelayMax] = useState(45);
const [extracting, setExtracting] = useState(false);
const [directoryUrl, setDirectoryUrl] = useState("");
const [scraping, setScraping] = useState(false);
const fileRef = useRef(null);
const handleSingleSubmit = (e) => { e.preventDefault(); if (singleInput.trim() && onResearch) onResearch(singleInput.trim()); };
const handleCSVUpload = async (e) => {
const file = e.target.files?.[0]; if (!file) return;
setExtracting(true);
try { const res = await batchAPI.uploadCSV(file); setExtractedEmails(res.data.emails); setBatchInput(res.data.emails.join("\n")); toast.success(`Found ${res.data.count} emails from ${file.name}`); }
catch (err) { toast.error(err.response?.data?.detail || "Failed to parse file"); }
setExtracting(false); if (fileRef.current) fileRef.current.value = "";
};
const handleExtractFromText = async () => {
if (!batchInput.trim()) return; setExtracting(true);
try { const res = await batchAPI.extractEmails(batchInput); setExtractedEmails(res.data.emails); setBatchInput(res.data.emails.join("\n")); toast.success(`Extracted ${res.data.count} emails`); }
catch { toast.error("No emails found in the text"); }
setExtracting(false);
};
const handleUrlScrape = async () => {
if (!directoryUrl.trim()) return; setScraping(true);
try { const res = await batchAPI.scrapeFaculty(directoryUrl.trim()); const emails = res.data.emails || []; setBatchInput(emails.join("\n")); setExtractedEmails(emails); setMode("batch"); toast.success(`Found ${res.data.count} email addresses!`); }
catch (err) { toast.error(err.response?.data?.detail || "Could not scrape page"); }
setScraping(false);
};
const handleBatchSubmit = () => {
const list = batchInput.split("\n").map((l) => l.trim()).filter(Boolean);
if (list.length > 0 && onBatchStart) onBatchStart(list, batchMode, delayMin, delayMax);
};
const emailCount = batchInput.split("\n").filter((l) => l.trim()).length;
const tabClass = (active) =>
`flex-1 text-[10px] py-1.5 px-1 rounded-lg font-semibold transition-all ${active
? "bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 border border-indigo-500/20"
: "text-[var(--c-text-muted)] hover:text-[var(--c-text-secondary)] border border-transparent"
}`;
return (
<div className="card-dark p-3.5">
<div className="flex items-center gap-2 mb-3">
<Sparkles size={14} className="text-indigo-500 dark:text-indigo-400" />
<h3 className="text-xs font-semibold text-[var(--c-text-secondary)] uppercase tracking-wider">Compose & Send</h3>
</div>
<div className="flex gap-1 mb-3 bg-[var(--c-bg-subtle)] p-1 rounded-xl border border-[var(--c-border)]">
<button onClick={() => setMode("single")} className={tabClass(mode === "single")}>Single</button>
<button onClick={() => setMode("batch")} className={tabClass(mode === "batch")}>Batch</button>
<button onClick={() => setMode("scrape")} className={tabClass(mode === "scrape")}>Scrape URL</button>
</div>
{mode === "single" && (
<form onSubmit={handleSingleSubmit}>
<input type="text" value={singleInput} onChange={(e) => setSingleInput(e.target.value)} placeholder="recipient@example.com" disabled={disabled || loading} className="w-full input-dark text-sm mb-2.5" />
<button type="submit" disabled={!singleInput.trim() || loading || disabled} className="w-full btn-primary text-xs py-2.5">
{loading ? <><Loader2 size={13} className="animate-spin" /> Generating...</> : <><Search size={13} /> Generate Email</>}
</button>
</form>
)}
{mode === "scrape" && (
<div className="space-y-2.5">
<div className="rounded-xl border border-[var(--c-border)] bg-[var(--c-bg-subtle)] overflow-hidden focus-within:border-indigo-500/30 transition-all">
<div className="flex items-center gap-2 px-3 pt-2.5 pb-1">
<Link2 size={13} className="text-[var(--c-text-muted)] shrink-0" />
<input type="text" value={directoryUrl} onChange={(e) => setDirectoryUrl(e.target.value)} placeholder="https://example.com/team" disabled={disabled || scraping}
className="w-full text-[12px] outline-none bg-transparent text-[var(--c-text-primary)] placeholder:text-[var(--c-text-muted)]" />
</div>
<div className="px-3 py-1.5 bg-[var(--c-bg-subtle)] border-t border-[var(--c-border)]">
<span className="text-[9px] text-[var(--c-text-muted)]">Paste any page URL to extract email addresses</span>
</div>
</div>
<button onClick={handleUrlScrape} disabled={!directoryUrl.trim() || scraping || disabled}
className="w-full flex items-center justify-center gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-400 hover:to-purple-500 disabled:opacity-40 text-white text-xs font-semibold py-2.5 rounded-xl transition-all shadow-lg shadow-violet-500/15">
{scraping ? <><Loader2 size={13} className="animate-spin" /> Scraping page...</> : <><Globe size={13} /> Extract All Emails</>}
</button>
{extractedEmails.length > 0 && (
<div className="bg-emerald-500/5 border border-emerald-500/20 rounded-xl p-3">
<p className="text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 mb-0.5">Found {extractedEmails.length} emails</p>
<p className="text-[10px] text-emerald-600/60 dark:text-emerald-500/60">Switched to Batch tab with all emails loaded</p>
</div>
)}
</div>
)}
{mode === "batch" && (
<div className="space-y-2.5">
<input ref={fileRef} type="file" accept=".csv,.txt,.xlsx" onChange={handleCSVUpload} className="hidden" />
<button onClick={() => fileRef.current?.click()} disabled={disabled || loading}
className="w-full flex items-center justify-center gap-2 border border-dashed border-gray-300 dark:border-white/10 hover:border-indigo-500/30 text-[var(--c-text-muted)] hover:text-indigo-500 dark:hover:text-indigo-400 text-[11px] font-medium py-2 rounded-xl transition-all hover:bg-indigo-500/5">
{extracting ? <><Loader2 size={12} className="animate-spin" /> Extracting...</> : <><FileSpreadsheet size={12} /> Upload CSV / Text File</>}
</button>
<div className="rounded-xl border border-[var(--c-border)] bg-[var(--c-bg-subtle)] overflow-hidden focus-within:border-indigo-500/30 transition-all">
<textarea value={batchInput} onChange={(e) => setBatchInput(e.target.value)} placeholder={"contact1@example.com\ncontact2@company.org\ncontact3@domain.net"} disabled={disabled || loading} rows={4}
className="w-full text-[12px] leading-relaxed px-3 py-2.5 outline-none resize-none bg-transparent text-[var(--c-text-primary)] placeholder:text-[var(--c-text-muted)] disabled:opacity-50" />
<div className="flex items-center justify-between px-3 py-1.5 bg-[var(--c-bg-subtle)] border-t border-[var(--c-border)]">
<span className="text-[10px] font-medium text-[var(--c-text-muted)]">{emailCount} {emailCount === 1 ? "recipient" : "recipients"}</span>
<span className="text-[9px] text-[var(--c-text-muted)]">One per line or paste any text</span>
</div>
</div>
{batchInput.trim() && !batchInput.includes("@") && (
<button onClick={handleExtractFromText} disabled={extracting} className="w-full text-[10px] text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 font-semibold">
Extract emails from text
</button>
)}
<div className="bg-[var(--c-bg-subtle)] rounded-xl p-3 space-y-2 border border-[var(--c-border)]">
<div className="text-[10px] font-semibold text-[var(--c-text-muted)] uppercase tracking-wider">Send Mode</div>
<div className="flex gap-1 bg-[var(--c-bg-subtle)] p-0.5 rounded-lg border border-[var(--c-border)]">
<button onClick={() => setBatchMode("manual")}
className={`flex-1 flex items-center justify-center gap-1.5 text-[11px] py-2 rounded-lg font-semibold transition-all ${batchMode === "manual" ? "bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20" : "text-[var(--c-text-muted)] hover:text-[var(--c-text-secondary)] border border-transparent"}`}>
<Eye size={11} /> Review
</button>
<button onClick={() => setBatchMode("auto")}
className={`flex-1 flex items-center justify-center gap-1.5 text-[11px] py-2 rounded-lg font-semibold transition-all ${batchMode === "auto" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20" : "text-[var(--c-text-muted)] hover:text-[var(--c-text-secondary)] border border-transparent"}`}>
<Zap size={11} /> Auto
</button>
</div>
<p className="text-[9px] text-[var(--c-text-muted)] leading-relaxed">
{batchMode === "manual" ? "Review & approve each email before sending" : "Generate & send all automatically"}
</p>
{batchMode === "auto" && (
<div className="pt-2.5 border-t border-[var(--c-border)]">
<div className="text-[10px] font-semibold text-[var(--c-text-muted)] uppercase tracking-wider mb-2">Delay between emails</div>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-[65px] relative group">
<input type="number" value={delayMin} onChange={(e) => setDelayMin(Number(e.target.value))} min={5} max={120}
className="w-full text-sm font-bold input-dark px-3 py-2.5 text-center tabular-nums [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none focus:ring-2 focus:ring-indigo-500/10 transition-all" />
<div className="absolute inset-0 rounded-xl border border-transparent group-focus-within:border-indigo-500/30 pointer-events-none transition-all" />
</div>
<span className="text-[10px] font-bold text-[var(--c-text-muted)] uppercase shrink-0">to</span>
<div className="flex-1 min-w-[65px] relative group">
<input type="number" value={delayMax} onChange={(e) => setDelayMax(Number(e.target.value))} min={10} max={300}
className="w-full text-sm font-bold input-dark px-3 py-2.5 text-center tabular-nums [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none focus:ring-2 focus:ring-indigo-500/10 transition-all" />
<div className="absolute inset-0 rounded-xl border border-transparent group-focus-within:border-indigo-500/30 pointer-events-none transition-all" />
</div>
<div className="shrink-0 flex items-center justify-center bg-[var(--c-bg-subtle)] border border-[var(--c-border)] rounded-xl px-2.5 py-2.5">
<span className="text-[10px] font-bold text-[var(--c-text-muted)] uppercase">sec</span>
</div>
</div>
</div>
)}
</div>
<button onClick={handleBatchSubmit} disabled={emailCount === 0 || loading || disabled}
className="w-full flex items-center justify-center gap-2 bg-gradient-to-r from-indigo-500 to-violet-600 hover:from-indigo-400 hover:to-violet-500 disabled:opacity-40 text-white text-xs font-semibold py-2.5 rounded-xl transition-all shadow-lg shadow-indigo-500/15">
{loading ? <><Loader2 size={13} className="animate-spin" /> Processing...</> : <><Users size={13} /> Start Batch ({emailCount})</>}
</button>
</div>
)}
</div>
);
}