Seth
Claude Sonnet 5
Add safe LinkedIn/email send pacing, per-account caps, and generation stop control
37fda38 Download frontend/src/components/settings/LinkedInConnectSettings.jsx from Seth0330/EMAILOUT: direct link, hf CLI and curl.
- Browser
- Download file 11.8 kB
-
https://huggingface.co/spaces/Seth0330/EMAILOUT/resolve/main/frontend/src/components/settings/LinkedInConnectSettings.jsx
- Command line
-
hf download hf://spaces/Seth0330/EMAILOUT/frontend/src/components/settings/LinkedInConnectSettings.jsx
-
curl -L -o LinkedInConnectSettings.jsx https://huggingface.co/spaces/Seth0330/EMAILOUT/resolve/main/frontend/src/components/settings/LinkedInConnectSettings.jsx
11.8 kB
| import React, { useCallback, useEffect, useState } from 'react'; | |
| import { Link2, RefreshCw, Loader2 } from 'lucide-react'; | |
| import { Button } from '@/components/ui/button'; | |
| import { apiFetch } from '@/lib/api'; | |
| import { cn } from '@/lib/utils'; | |
| const CAP_FIELDS = [ | |
| { key: 'linkedin_daily_connection_cap', label: 'Max connects / day', min: 1, max: 200 }, | |
| { key: 'linkedin_daily_message_cap', label: 'Max messages / day', min: 1, max: 300 }, | |
| { key: 'linkedin_weekly_connection_cap', label: 'Max connects / week', min: 1, max: 1000 }, | |
| { key: 'linkedin_weekly_message_cap', label: 'Max messages / week', min: 1, max: 1500 }, | |
| ]; | |
| export default function LinkedInConnectSettings() { | |
| const [loading, setLoading] = useState(true); | |
| const [busy, setBusy] = useState(false); | |
| const [accounts, setAccounts] = useState([]); | |
| const [defaultRefId, setDefaultRefId] = useState(null); | |
| const [caps, setCaps] = useState({}); | |
| const [savingCapId, setSavingCapId] = useState(null); | |
| const load = useCallback(async () => { | |
| setLoading(true); | |
| try { | |
| const r = await apiFetch('/api/unipile/linkedin/campaign-defaults'); | |
| const d = r.ok ? await r.json() : {}; | |
| const accts = d.accounts || []; | |
| setAccounts(accts); | |
| setDefaultRefId(d.default_unipile_account_ref_id ?? null); | |
| setCaps((prev) => { | |
| const next = { ...prev }; | |
| accts.forEach((a) => { | |
| next[a.id] = { | |
| linkedin_daily_connection_cap: a.linkedin_daily_connection_cap ?? 20, | |
| linkedin_daily_message_cap: a.linkedin_daily_message_cap ?? 40, | |
| linkedin_weekly_connection_cap: a.linkedin_weekly_connection_cap ?? 100, | |
| linkedin_weekly_message_cap: a.linkedin_weekly_message_cap ?? 200, | |
| }; | |
| }); | |
| return next; | |
| }); | |
| } catch { | |
| setAccounts([]); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, []); | |
| const saveCaps = useCallback(async (accountId) => { | |
| const patch = caps[accountId]; | |
| if (!patch) return; | |
| setSavingCapId(accountId); | |
| try { | |
| const res = await apiFetch(`/api/unipile/linkedin/accounts/${accountId}/limits`, { | |
| method: 'PATCH', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(patch), | |
| }); | |
| if (!res.ok) throw new Error('Could not save limits'); | |
| } catch (e) { | |
| alert(e.message || 'Failed to save limits'); | |
| } finally { | |
| setSavingCapId(null); | |
| } | |
| }, [caps]); | |
| useEffect(() => { | |
| load(); | |
| }, [load]); | |
| const connectAccount = async () => { | |
| setBusy(true); | |
| try { | |
| const res = await apiFetch('/api/unipile/linkedin/hosted-link', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| label: 'LinkedIn account', | |
| }), | |
| }); | |
| const data = await res.json().catch(() => ({})); | |
| if (!res.ok) throw new Error(data.detail || 'Could not start LinkedIn connect'); | |
| if (data.url) window.location.href = data.url; | |
| } catch (e) { | |
| alert(e.message || 'Connect failed'); | |
| } finally { | |
| setBusy(false); | |
| } | |
| }; | |
| return ( | |
| <section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"> | |
| <div className="flex flex-wrap items-start justify-between gap-3"> | |
| <div className="flex items-center gap-2 font-semibold text-slate-800"> | |
| <Link2 className="h-5 w-5 text-violet-600" /> | |
| Connect LinkedIn | |
| </div> | |
| <Button type="button" variant="outline" size="sm" disabled={loading || busy} onClick={load}> | |
| <RefreshCw className={cn('mr-2 h-4 w-4', loading && 'animate-spin')} /> | |
| Refresh | |
| </Button> | |
| </div> | |
| <div className="mt-4 flex flex-col gap-3 md:flex-row md:items-end"> | |
| <Button type="button" disabled={busy} onClick={connectAccount} className="bg-slate-900 text-white"> | |
| <Link2 className="mr-2 h-4 w-4" /> | |
| Connect account | |
| </Button> | |
| </div> | |
| {loading ? ( | |
| <div className="mt-6 flex justify-center py-8 text-slate-400"> | |
| <Loader2 className="h-8 w-8 animate-spin" /> | |
| </div> | |
| ) : accounts.length === 0 ? ( | |
| <p className="mt-6 text-sm text-slate-500">Connected accounts: 0</p> | |
| ) : ( | |
| <div className="mt-6 space-y-3"> | |
| <p className="text-sm font-medium text-slate-700"> | |
| Connected accounts ({accounts.length}) — default for LinkedIn sequence steps | |
| </p> | |
| <ul className="space-y-2"> | |
| {accounts.map((a) => { | |
| const name = a.display_name || a.label || 'LinkedIn'; | |
| const initials = name | |
| .split(/\s+/) | |
| .slice(0, 2) | |
| .map((s) => s[0] || '') | |
| .join('') | |
| .toUpperCase(); | |
| const checked = defaultRefId != null && Number(defaultRefId) === Number(a.id); | |
| const accCaps = caps[a.id] || {}; | |
| return ( | |
| <li | |
| key={a.id} | |
| className={cn( | |
| 'rounded-xl border px-3 py-2 transition', | |
| checked ? 'border-violet-300 bg-violet-50/60' : 'border-slate-200 hover:bg-slate-50' | |
| )} | |
| > | |
| <div className="flex cursor-pointer items-center gap-3" onClick={() => setDefaultRefId(a.id)}> | |
| <input | |
| type="radio" | |
| className="h-4 w-4 accent-violet-600" | |
| checked={checked} | |
| onChange={() => setDefaultRefId(a.id)} | |
| /> | |
| {a.avatar_url ? ( | |
| <img | |
| src={a.avatar_url} | |
| alt="" | |
| className="h-9 w-9 rounded-full object-cover" | |
| /> | |
| ) : ( | |
| <span className="flex h-9 w-9 items-center justify-center rounded-full bg-violet-100 text-xs font-semibold text-violet-800"> | |
| {initials || 'LI'} | |
| </span> | |
| )} | |
| <div className="min-w-0 flex-1"> | |
| <p className="truncate font-medium text-slate-900">{name}</p> | |
| <p className="truncate text-xs text-slate-500">{a.label}</p> | |
| </div> | |
| <span className="text-[10px] font-semibold uppercase text-slate-400"> | |
| {a.status || '—'} | |
| </span> | |
| </div> | |
| <div | |
| className="mt-3 grid grid-cols-2 gap-2 border-t border-slate-200/80 pt-3 sm:grid-cols-4" | |
| onClick={(e) => e.stopPropagation()} | |
| > | |
| {CAP_FIELDS.map((f) => ( | |
| <label key={f.key} className="flex flex-col gap-1 text-[11px] font-medium text-slate-500"> | |
| {f.label} | |
| <input | |
| type="number" | |
| min={f.min} | |
| max={f.max} | |
| value={accCaps[f.key] ?? ''} | |
| onChange={(e) => | |
| setCaps((prev) => ({ | |
| ...prev, | |
| [a.id]: { | |
| ...prev[a.id], | |
| [f.key]: e.target.value === '' ? '' : Number(e.target.value), | |
| }, | |
| })) | |
| } | |
| onBlur={() => saveCaps(a.id)} | |
| className="rounded-md border border-slate-200 px-2 py-1 text-xs text-slate-800 focus:border-violet-400 focus:outline-none" | |
| /> | |
| </label> | |
| ))} | |
| {savingCapId === a.id ? ( | |
| <span className="col-span-2 self-end text-[10px] text-violet-500 sm:col-span-4">Saving…</span> | |
| ) : null} | |
| </div> | |
| </li> | |
| ); | |
| })} | |
| </ul> | |
| <div className="flex flex-wrap items-center gap-3"> | |
| <button | |
| type="button" | |
| className="text-xs font-medium text-violet-700 hover:underline" | |
| onClick={async () => { | |
| setBusy(true); | |
| try { | |
| const res = await apiFetch('/api/me/linkedin-prefs', { | |
| method: 'PATCH', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ default_unipile_account_ref_id: 0 }), | |
| }); | |
| if (!res.ok) throw new Error('Could not clear'); | |
| setDefaultRefId(null); | |
| await load(); | |
| } catch (e) { | |
| alert(e.message || 'Failed'); | |
| } finally { | |
| setBusy(false); | |
| } | |
| }} | |
| > | |
| Clear default | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| </section> | |
| ); | |
| } | |