/* 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 ? (
{busy ? 'Disconnecting…' : 'Disconnect'}
) : (
Connect Gmail
)}
{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',
}} />
saveSendAs(false)} disabled={sendAsSaving} style={{
padding: '10px 18px', fontFamily: 'var(--sans)', fontSize: 13, fontWeight: 600,
background: PC.gold, color: 'var(--bg-deep)',
border: 0, borderRadius: 999, cursor: 'pointer',
opacity: sendAsSaving ? 0.5 : 1,
}}>{sendAsSaving ? 'Saving…' : 'Save'}
{gmail.send_from && (
saveSendAs(true)} disabled={sendAsSaving} style={{
padding: '10px 14px', fontFamily: 'var(--sans)', fontSize: 13, fontWeight: 500,
background: 'rgba(255,255,255,0.05)', color: PC.creamDim,
border: 0, borderRadius: 999, cursor: 'pointer',
}}>Clear
)}
{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.
toggleForceTemplates(e.target.checked)}
style={{ width: 18, height: 18, accentColor: PC.gold }} />
Always use a template
{forceTemplates
? 'AI uses the best-fit template verbatim (filling {slots}).'
: 'AI picks a template only when one clearly fits; rewrites freely.'}
{templates.length === 0 && (
No templates yet. Add one to bias follow-ups toward your voice.
)}
{templates.map(t => (
setEditingTemplate(t)}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 14px',
background: 'rgba(255,255,255,0.04)',
border: 0, borderRadius: 12, cursor: 'pointer',
color: PC.cream, fontFamily: 'var(--sans)', textAlign: 'left',
}}>
{t.name}
{(t.match_hints || t.subject) && (
{t.match_hints || t.subject}
)}
))}
setEditingTemplate({})} style={{
padding: '10px 16px', fontFamily: 'var(--sans)', fontSize: 13, fontWeight: 600,
background: PC.gold, color: 'var(--bg-deep)',
border: 0, borderRadius: 999, cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', gap: 6,
}}> New template
{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}
window.foyerSignOut?.()} style={{
padding: '10px 16px', fontFamily: 'var(--sans)', fontSize: 13, fontWeight: 500,
background: 'rgba(255,255,255,0.05)', color: PC.creamDim,
border: 0, borderRadius: 999, cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', gap: 6,
}}> Log out
{/* 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.
setOpen(true)} style={{
padding: '10px 16px', fontFamily: 'var(--sans)', fontSize: 13, fontWeight: 500,
background: 'rgba(202,80,71,0.1)', color: PC.terracotta,
border: '1px solid rgba(202,80,71,0.4)', borderRadius: 999, cursor: 'pointer',
}}>Delete account…
) : (
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}
)}
{ setOpen(false); setTyped(''); setErr(null); }} disabled={submitting} style={{
padding: '10px 16px', fontFamily: 'var(--sans)', fontSize: 13,
background: 'transparent', color: PC.creamDim,
border: '1px solid var(--hairline)', borderRadius: 999, cursor: 'pointer',
}}>Cancel
{submitting ? 'Deleting…' : 'Delete forever'}
)}
);
}
function SectionEyebrow({ title }) {
return (
);
}
// 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'}
Cancel
Match hints (when this fits)
Use {'{first_name}'} and {'{full_name}'} for auto-fill, or any other {'{slot}'} to mark a spot for the AI (soft mode) or yourself (forced mode) to fill in.
{err &&
{err}
}
{onDelete && (
{deleting ? 'Deleting…' : 'Delete'}
)}
{saving ? 'Saving…' : 'Save'}
);
}
window.ProfilePage = ProfilePage;