Spaces:
Sleeping
Sleeping
File size: 14,073 Bytes
e9d1895 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 | 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>
);
}
|