/* app.jsx — App shell with auth, API, and AI generation */
/* ── API helpers ───────────────────────────────────────────────────────────── */
async function apiFetch(url, opts = {}) {
const res = await fetch(url, { credentials: 'same-origin', ...opts });
if (res.status === 401) { window.location.href = '/login.html'; throw new Error('unauth'); }
return res;
}
/* ── Debounce ──────────────────────────────────────────────────────────────── */
function useDebounce(fn, delay) {
const timerRef = React.useRef(null);
return React.useCallback((...args) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => fn(...args), delay);
}, [fn, delay]);
}
/* ── GenerateModal ─────────────────────────────────────────────────────────── */
const TIPOS = [
'Landing page', 'Sitio web corporativo', 'Tienda en línea',
'Estrategia SEO', 'Google Ads', 'Paquete combinado', 'Otro',
];
function GenerateModal({ onClose, onCreated }) {
const [form, setForm] = React.useState({ empresa: '', contacto: '', email: '', tipo: '', descripcion: '', presupuesto: '' });
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState('');
function set(k, v) { setForm(f => ({ ...f, [k]: v })); }
async function handleSubmit(e) {
e.preventDefault();
if (!form.empresa || !form.tipo || !form.descripcion) { setError('Empresa, tipo y descripción son requeridos.'); return; }
setError(''); setLoading(true);
try {
const genRes = await apiFetch('/api/generate.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
if (!genRes.ok) { const d = await genRes.json(); setError(d.error || 'Error al generar.'); setLoading(false); return; }
const { data } = await genRes.json();
const today = new Date();
const fmt = (d) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
const validUntil = new Date(today); validUntil.setDate(validUntil.getDate() + 30);
const quote = { ...data, status: 'draft', createdAt: fmt(today), validUntil: fmt(validUntil) };
const saveRes = await apiFetch('/api/quotes.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(quote),
});
if (!saveRes.ok) { setError('Error al guardar la cotización.'); setLoading(false); return; }
const { id, token } = await saveRes.json();
onCreated({ ...quote, id, token });
} catch (err) {
if (err.message !== 'unauth') setError('Error de conexión. Intenta de nuevo.');
setLoading(false);
}
}
const labelStyle = { display: 'block', fontSize: 11, fontFamily: 'Poppins', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '1.5px', color: 'rgba(255,255,255,0.45)', marginBottom: 6 };
const inputStyle = { width: '100%', padding: '10px 14px', background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 10, color: '#fff', fontSize: 14, fontFamily: 'inherit', outline: 'none' };
return (
e.target === e.currentTarget && onClose()}>
Generar con IA
Claude redactará la propuesta automáticamente
);
}
/* ── App ───────────────────────────────────────────────────────────────────── */
function App() {
const [ready, setReady] = React.useState(false);
const [view, setView] = React.useState('dashboard'); // 'dashboard' | 'editor' | 'preview'
const [activeId, setActiveId] = React.useState(null);
const [quotes, setQuotes] = React.useState([]);
const [saveState, setSaveState] = React.useState('idle'); // 'idle' | 'saving' | 'saved' | 'error'
const [showGenerate, setShowGenerate] = React.useState(false);
/* ── Auth check + initial load ─────────────────────────────────────────── */
React.useEffect(() => {
apiFetch('/api/auth-check.php')
.then(res => { if (!res.ok) { window.location.href = '/login.html'; } else { return loadQuotes(); } })
.catch(() => {});
}, []);
async function loadQuotes() {
try {
const res = await apiFetch('/api/quotes.php');
const data = await res.json();
setQuotes(Array.isArray(data) ? data : []);
setReady(true);
} catch {}
}
/* ── Auto-save ─────────────────────────────────────────────────────────── */
const saveQuote = React.useCallback(async (quote) => {
setSaveState('saving');
try {
const res = await apiFetch(`/api/quote.php?id=${encodeURIComponent(quote.id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(quote),
});
setSaveState(res.ok ? 'saved' : 'error');
setTimeout(() => setSaveState('idle'), 2000);
} catch { setSaveState('error'); setTimeout(() => setSaveState('idle'), 2000); }
}, []);
const debouncedSave = useDebounce(saveQuote, 1200);
function updateQuote(id, fn) {
setQuotes(prev => {
const next = prev.map(q => q.id === id ? (typeof fn === 'function' ? fn(q) : fn) : q);
const updated = next.find(q => q.id === id);
if (updated) debouncedSave(updated);
return next;
});
}
/* ── New blank quote ───────────────────────────────────────────────────── */
async function newQuote() {
const today = new Date();
const fmt = (d) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
const validUntil = new Date(today); validUntil.setDate(validUntil.getDate() + 30);
const fresh = {
status: 'draft',
createdAt: fmt(today),
validUntil: fmt(validUntil),
client: { company: 'Nuevo cliente', contact: '', email: '', rfc: '', address: '' },
project: { name: 'Proyecto sin título', type: '', summary: '', startDate: '—', duration: '4 semanas' },
scope: [],
phases: [],
excludes: [],
whyUs: ['8 años diseñando experiencias digitales en CDMX', 'Proceso de revisión abierto y colaborativo en cada etapa', 'Soporte real después de la entrega, no desaparecemos'],
nextStep: '',
items: [],
discount: { type: 'pct', value: 0 },
schedule: [{ label: 'Anticipo a la firma', pct: 50 }, { label: 'Entrega final', pct: 50 }],
terms: 'Vigencia 30 días. Incluye 2 rondas de revisión por etapa.',
};
try {
const res = await apiFetch('/api/quotes.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fresh),
});
const { id, token } = await res.json();
const created = { ...fresh, id, token };
setQuotes(prev => [created, ...prev]);
setActiveId(id);
setView('editor');
} catch {}
}
/* ── Delete quote ──────────────────────────────────────────────────────── */
async function deleteQuote(id) {
try {
await apiFetch(`/api/quote.php?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
setQuotes(prev => prev.filter(q => q.id !== id));
if (activeId === id) { setActiveId(null); setView('dashboard'); }
} catch {}
}
/* ── Duplicate quote ───────────────────────────────────────────────────── */
async function duplicateQuote(id) {
const src = quotes.find(q => q.id === id); if (!src) return;
const copy = JSON.parse(JSON.stringify(src));
delete copy.id; delete copy.token;
copy.status = 'draft';
copy.createdAt = new Date().toISOString().slice(0, 10);
try {
const res = await apiFetch('/api/quotes.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(copy),
});
const { id: newId, token } = await res.json();
setQuotes(prev => [{ ...copy, id: newId, token }, ...prev]);
} catch {}
}
/* ── After AI generation ───────────────────────────────────────────────── */
function handleGenerated(quote) {
setQuotes(prev => [quote, ...prev]);
setActiveId(quote.id);
setShowGenerate(false);
setView('editor');
}
/* ── Logout ────────────────────────────────────────────────────────────── */
async function logout() {
await fetch('/api/logout.php', { credentials: 'same-origin' });
window.location.href = '/login.html';
}
const active = quotes.find(q => q.id === activeId);
const counts = {
draft: quotes.filter(q => q.status === 'draft').length,
sent: quotes.filter(q => q.status === 'sent').length,
accepted: quotes.filter(q => q.status === 'accepted').length,
};
const saveLabel = saveState === 'saving' ? 'Guardando…' : saveState === 'saved' ? 'Guardado ✓' : saveState === 'error' ? 'Error al guardar' : '';
if (!ready) {
return (
);
}
return (
{/* SIDEBAR */}
{/* MAIN */}
{view === 'dashboard' && (
{ setActiveId(id); setView('editor'); }}
openPreview={(id) => { setActiveId(id); setView('preview'); }}
newQuote={newQuote}
duplicateQuote={duplicateQuote}
deleteQuote={deleteQuote}
onGenerate={() => setShowGenerate(true)}
/>
)}
{view === 'editor' && active && (
{ setActiveId(id); setView('preview'); }}
back={() => setView('dashboard')}
/>
)}
{view === 'preview' && active && (
setView('dashboard')}
openEditor={(id) => { setActiveId(id); setView('editor'); }}
/>
)}
{/* AI GENERATION MODAL */}
{showGenerate && (
setShowGenerate(false)}
onCreated={handleGenerated}
/>
)}
);
}
ReactDOM.createRoot(document.getElementById('root')).render();