/* global React, AppShell, foyerApi, useFoyerData, Icon, FoyerLoader */ // ============================================================ // Profile / Settings — Gmail connect + future agent prefs. Matches // the iPad Profile tab visually so cross-device parity is obvious. // ============================================================ const PC = window.ProfileColors = { bg: 'var(--bg-deep)', card: 'rgba(255,255,255,0.03)', card2: 'rgba(255,255,255,0.05)', hairline: 'var(--hairline)', gold: 'var(--gold)', goldSoft: 'var(--gold-soft)', cream: 'var(--cream)', creamDim: 'var(--cream-dim)', textDim: 'var(--text-dim)', textMuted: 'var(--text-muted)', terracotta: 'var(--terracotta)', sage: 'var(--sage)', }; function ProfilePage() { const { user, loading } = useFoyerData(); const [gmail, setGmail] = React.useState(null); const [gmailLoading, setGmailLoading] = React.useState(true); const [error, setError] = React.useState(null); const [busy, setBusy] = React.useState(false); // Send-as alias — what gets stamped on every outgoing From: header. // Kept locally as a draft so the agent can type before hitting Save. const [sendAsDraft, setSendAsDraft] = React.useState(''); const [sendAsSaving, setSendAsSaving] = React.useState(false); const [sendAsMsg, setSendAsMsg] = React.useState(null); // Templates state const [templates, setTemplates] = React.useState([]); const [forceTemplates, setForceTemplates] = React.useState(false); const [templatesErr, setTemplatesErr] = React.useState(null); const [editingTemplate, setEditingTemplate] = React.useState(null); // {} for new, {id,...} for edit, null for closed const refreshTemplates = React.useCallback(async () => { try { const r = await foyerApi.get('/me/templates'); setTemplates(r.templates || []); setForceTemplates(!!r.force_templates); setTemplatesErr(null); } catch (e) { setTemplatesErr(e.message || String(e)); } }, []); React.useEffect(() => { refreshTemplates(); }, [refreshTemplates]); const toggleForceTemplates = async (next) => { setForceTemplates(next); try { await foyerApi.post('/me/force_templates', { force: next }); } catch (e) { setForceTemplates(!next); setTemplatesErr(e.message || String(e)); } }; const saveTemplate = async (template) => { const payload = { name: template.name || '', subject: template.subject || '', body: template.body || '', match_hints: template.match_hints || '', }; if (template.id) { await foyerApi.patch(`/me/templates/${template.id}`, payload); } else { await foyerApi.post('/me/templates', payload); } await refreshTemplates(); }; const deleteTemplate = async (templateId) => { await foyerApi.del(`/me/templates/${templateId}`); await refreshTemplates(); }; const refreshGmail = React.useCallback(async () => { setGmailLoading(true); try { const r = await foyerApi.get('/auth/gmail/status'); setGmail(r); setSendAsDraft(r?.send_from || ''); setError(null); } catch (e) { setError(e.message || String(e)); } finally { setGmailLoading(false); } }, []); const saveSendAs = async (clear = false) => { setSendAsSaving(true); setSendAsMsg(null); try { const address = clear ? null : (sendAsDraft.trim() || null); const r = await foyerApi.post('/auth/gmail/send_from', { address }); setGmail(r); setSendAsDraft(r?.send_from || ''); window.foyerToast?.(clear ? 'Send-as cleared' : 'Send-as saved'); } catch (e) { setSendAsMsg(e.message || String(e)); } finally { setSendAsSaving(false); } }; // Initial fetch + handle OAuth completion redirect. When the user lands // on /#/profile?gmail=connected, strip the query, refresh status, toast. React.useEffect(() => { refreshGmail(); const params = new URLSearchParams(window.location.search); if (params.get('gmail') === 'connected') { window.history.replaceState({}, '', window.location.pathname + window.location.hash); setTimeout(() => { refreshGmail(); window.foyerToast?.('Gmail connected'); }, 300); } }, [refreshGmail]); const connect = () => { // Top-level navigation — Google's consent screen replaces this tab. // After /auth/gmail/callback finishes, we land back here with // ?gmail=connected (handled in the effect above). window.location.href = '/auth/gmail/start?platform=web'; }; const disconnect = async () => { if (!confirm('Disconnect Gmail? Scheduled sends will fail until you reconnect.')) return; setBusy(true); try { await foyerApi.post('/auth/gmail/disconnect'); await refreshGmail(); window.foyerToast?.('Gmail disconnected'); } catch (e) { setError(e.message); } finally { setBusy(false); } }; return (
{/* Header */}
Profile
{user?.name || 'Signed in'}
{user?.email}
{/* Gmail card */}
{gmailLoading ? ( ) : gmail?.connected ? ( <>
Connected
{gmail.email || 'Gmail account linked'}
Used to send follow-ups. Persists across devices — same on the iPad app.
) : ( <>
Not connected
Connect Gmail to send follow-ups from the web. The same connection works on the iPad — sign in there and it's already linked.
)}
{gmail?.connected ? ( ) : ( )}
{error &&
{error}
}
{/* Send-as card — only relevant once Gmail is connected. */} {gmail?.connected && (
Stamp outgoing follow-ups with a different From address (e.g. your work alias). The alias has to be verified in Gmail first — open Gmail → Settings → Accounts → Send mail as and add it there. Gmail silently falls back to your connected address if it isn't verified.
setSendAsDraft(e.target.value)} placeholder={gmail.email || 'name@company.com'} style={{ flex: '1 1 240px', minWidth: 0, background: 'rgba(255,255,255,0.05)', color: PC.cream, border: 0, borderRadius: 10, padding: '12px 14px', fontFamily: 'var(--sans)', fontSize: 14, outline: 'none', }} /> {gmail.send_from && ( )}
{gmail.send_from && (
Sending as {gmail.send_from} via {gmail.email}
)} {sendAsMsg &&
{sendAsMsg}
}
)} {/* Templates card */}
Designs the AI uses when drafting follow-ups. Use {'{first_name}'} and {'{full_name}'} for auto-fill, or any other {'{slot}'} you want filled.
{templates.length === 0 && (
No templates yet. Add one to bias follow-ups toward your voice.
)} {templates.map(t => ( ))}
{templatesErr &&
{templatesErr}
}
{editingTemplate && ( setEditingTemplate(null)} onSave={async (next) => { try { await saveTemplate(next); setEditingTemplate(null); } catch (e) { throw e; } }} onDelete={editingTemplate.id ? async () => { try { await deleteTemplate(editingTemplate.id); setEditingTemplate(null); } catch (e) { throw e; } } : null} /> )} {/* Account card */}
{user?.picture ? :
{(user?.name || '?').slice(0, 1).toUpperCase()}
}
{user?.name || '—'}
{user?.email}
{/* Danger zone — account deletion. Hits DELETE /me which wipes every session, transcript, headshot, and user record on the server. Confirmation is a typed string, not a button, so a misclick can't trigger it. */}
); } function DangerZone() { const [open, setOpen] = React.useState(false); const [typed, setTyped] = React.useState(''); const [submitting, setSubmitting] = React.useState(false); const [err, setErr] = React.useState(null); const canConfirm = typed.trim().toUpperCase() === 'DELETE' && !submitting; const confirmDelete = async () => { if (!canConfirm) return; setSubmitting(true); setErr(null); try { const result = await foyerApi.del('/me'); const wiped = result.sessions_deleted || 0; window.foyerToast?.({ message: `Account deleted · ${wiped} session${wiped === 1 ? '' : 's'} wiped`, kind: 'success', }); // Forget any client-side cache + bounce to the marketing page. try { window.foyerCache = null; } catch {} setTimeout(() => { window.location.href = '/#/'; window.location.reload(); }, 600); } catch (e) { setErr(e.message || String(e)); setSubmitting(false); } }; return (
{!open ? (
Delete my account
Permanently wipe every session, transcript, follow-up draft, headshot, and the Google connection. This can't be undone.
) : (
This is permanent.
Every recorded open house, transcript, drafted follow-up, and your agent profile will be removed from our servers. Audio already uploaded to AssemblyAI is wiped from their servers immediately after each transcription — there's nothing to delete from third parties separately. Type {' '}DELETE{' '} below to confirm.
setTyped(e.target.value)} placeholder="Type DELETE" style={{ marginTop: 16, width: '100%', boxSizing: 'border-box', padding: '12px 14px', background: 'var(--bg-deep)', border: '1px solid var(--hairline)', borderRadius: 10, color: PC.cream, fontSize: 14, fontFamily: 'var(--sans)', outline: 'none', letterSpacing: '0.06em', }} /> {err && (
{err}
)}
)}
); } function SectionEyebrow({ title }) { return (
{title}
); } // Modal-style template editor — covers the page with a centered card. function TemplateModal({ template, onCancel, onSave, onDelete }) { const [name, setName] = React.useState(template.name || ''); const [matchHints, setMatchHints] = React.useState(template.match_hints || ''); const [subject, setSubject] = React.useState(template.subject || ''); const [body, setBody] = React.useState(template.body || ''); const [saving, setSaving] = React.useState(false); const [deleting, setDeleting] = React.useState(false); const [err, setErr] = React.useState(null); const canSave = name.trim() && body.trim() && !saving && !deleting; const doSave = async () => { setSaving(true); setErr(null); try { await onSave({ id: template.id, name: name.trim(), match_hints: matchHints.trim(), subject: subject.trim(), body: body.trim(), }); } catch (e) { setErr(e.message || String(e)); } finally { setSaving(false); } }; const doDelete = async () => { if (!onDelete) return; if (!confirm('Delete this template? This can\'t be undone.')) return; setDeleting(true); setErr(null); try { await onDelete(); } catch (e) { setErr(e.message || String(e)); } finally { setDeleting(false); } }; const fieldLabel = { fontSize: 10, letterSpacing: '0.14em', color: PC.textDim, textTransform: 'uppercase', fontWeight: 600, marginBottom: 6 }; const fieldInput = { width: '100%', background: 'rgba(255,255,255,0.05)', color: PC.cream, border: 0, borderRadius: 10, padding: '12px 14px', fontFamily: 'var(--sans)', fontSize: 14, outline: 'none', resize: 'vertical', boxSizing: 'border-box', }; return (
{ if (e.target === e.currentTarget) onCancel(); }}>
{template.id ? 'Edit template' : 'New template'}
Name
setName(e.target.value)} placeholder="Interested buyer, no offer yet" style={fieldInput} />
Match hints (when this fits)