/* global React, AppShell, foyerApi, Icon, FoyerLoader */ // ============================================================ // Offers — CRUD library of marketing offers / campaigns. // // Mirrors the iPad Offers tab exactly: a list of cards with name + // body preview, an enabled toggle per offer, "New offer" pill at the // top, and an inline editor sheet for creating / editing. // // Offers participate in the AI follow-up pipeline two ways: // 1. Free-form @mention in any prompt ("send the @Spring Buyer // credit blast to all buyers") — the AI loads the offer body and // uses it as context. // 2. Default library access — even WITHOUT an explicit @mention, the // AI may pull from the enabled offer pool when picking the best // angle for a lead. Disabled offers are excluded. // // Backend: /me/offers (GET/POST), /me/offers/{id} (PATCH/DELETE), // /me/offers/{id}/enabled (POST). Same wire as the iPad app — both // surfaces see the same library because it's stored server-side. // ============================================================ function OffersPage() { const [offers, setOffers] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); // editing is one of: // null — no sheet open // {} — sheet open for a new offer // { id, … } — sheet open for editing an existing offer const [editing, setEditing] = React.useState(null); const [pendingDelete, setPendingDelete] = React.useState(null); const refresh = React.useCallback(async () => { setLoading(true); try { const r = await foyerApi.get('/me/offers'); setOffers(r.offers || []); setError(null); } catch (e) { setError(e.message || String(e)); } finally { setLoading(false); } }, []); React.useEffect(() => { refresh(); }, [refresh]); const toggleEnabled = async (offer) => { // Optimistic flip so the toggle feels instant. Revert on error. const next = !offer.enabled; setOffers(curr => curr.map(o => o.id === offer.id ? { ...o, enabled: next } : o)); try { await foyerApi.post(`/me/offers/${offer.id}/enabled`, { enabled: next }); } catch (e) { setOffers(curr => curr.map(o => o.id === offer.id ? { ...o, enabled: !next } : o)); window.foyerToast({ message: 'Could not save toggle: ' + (e.message || e), kind: 'error' }); } }; const onSaved = (offer) => { setOffers(curr => { const idx = curr.findIndex(o => o.id === offer.id); if (idx === -1) return [...curr, offer]; const next = [...curr]; next[idx] = offer; return next; }); setEditing(null); window.foyerToast('Offer saved'); }; const performDelete = async (offer) => { setPendingDelete(null); try { await foyerApi.del(`/me/offers/${offer.id}`); setOffers(curr => curr.filter(o => o.id !== offer.id)); window.foyerToast('Offer deleted'); } catch (e) { window.foyerToast({ message: 'Delete failed: ' + (e.message || e), kind: 'error' }); } }; return (
{/* Header */}

Offers

Marketing offers and campaigns the AI can reference. Mention one in any prompt with @name, or leave it enabled and the AI will pull from the library on its own.

{/* Body */}
{loading && offers.length === 0 ? (
) : error ? (
Couldn't load offers: {error}
) : offers.length === 0 ? ( setEditing({})} /> ) : (
{offers.map(offer => ( setEditing(offer)} onToggle={() => toggleEnabled(offer)} onDelete={() => setPendingDelete(offer)} /> ))}
)}
{editing && ( setEditing(null)} onSaved={onSaved} /> )} {pendingDelete && ( setPendingDelete(null)} onConfirm={() => performDelete(pendingDelete)} /> )}
); } function EmptyOffers({ onCreate }) { return (
No offers yet

Create your first campaign — Open House Copilot can drop it into follow-ups when the lead is a fit, or you can call it out by name in a prompt.

); } function OfferCard({ offer, onEdit, onToggle, onDelete }) { return (
{offer.enabled ? 'ACTIVE' : 'DISABLED'}
{offer.name}
{ e.stopPropagation(); onToggle(); }} />
{offer.body || No body yet}
@{(offer.name || '').toLowerCase().split(/\s+/)[0] || 'offer'}
); } function Toggle({ on, onChange }) { return ( ); } function OfferEditor({ existing, onCancel, onSaved }) { const [name, setName] = React.useState(existing?.name || ''); const [body, setBody] = React.useState(existing?.body || ''); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); const trimmedName = name.trim(); const canSave = trimmedName.length > 0 && body.trim().length > 0 && !saving; const save = async () => { if (!canSave) return; setSaving(true); setError(null); try { let offer; if (existing?.id) { offer = await foyerApi.patch(`/me/offers/${existing.id}`, { name: trimmedName, body: body.trim(), }); } else { offer = await foyerApi.post('/me/offers', { name: trimmedName, body: body.trim(), }); } // Backend returns the persisted offer with id+enabled. The endpoint // shape is {offer: {...}} for some routes — be defensive. onSaved(offer.offer || offer); } catch (e) { setError(e.message || String(e)); } finally { setSaving(false); } }; return (

{existing ? 'Edit offer' : 'New offer'}

setName(e.target.value)} placeholder="e.g. $2,500 buyer credit" autoFocus style={inputStyle} />
REFERENCE THIS OFFER WITH @{(trimmedName || 'name').toLowerCase().split(/\s+/)[0]}