/* ============================================================================
DILO AHORA · v2.1 PRODUCCIÓN — App.jsx (single-file build, CORREGIDO)
----------------------------------------------------------------------------
CICLO DE AUDITORÍA 3 (tras reporte de error de sintaxis 342:3):
· CORREGIDO: llave de cierre faltante en el arrow body de `s.onload` dentro
de StyleLoader → provocaba "Unexpected token" y pantalla en blanco.
Ahora la config de Tailwind se asigna en bloque multilínea balanceado.
· CORREGIDO: `ref` callbacks con retorno implícito → ahora usan bloque {}.
· MEJORA: Button acepta prop `type` y el formulario de Auth envía con submit.
· Verificado: balance de llaves/paréntesis de todo el monolito.
Estructura lógica emulada (para extracción a Next.js):
/lib/utils · /lib/store · /lib/ai · /components · /views
Rutas: #/ · #/auth · #/app · #/app/buzon · #/app/memorias · #/app/boveda · #/app/legado
============================================================================ */
import React, { useState, useEffect, useRef, useContext, createContext, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
/* ============================== /lib/assets ============================== */
const IMG = {
hero: 'https://image.qwenlm.ai/public_source/564b485f-db05-42fe-ba78-e69fc191160c/1f9e5a32b-a9fd-4c9a-9e95-a4f17b13247c.png',
thumbAna: 'https://image.qwenlm.ai/public_source/564b485f-db05-42fe-ba78-e69fc191160c/157b1d2ad-a104-43b0-96a0-3da592ebde05.png',
thumbMama: 'https://image.qwenlm.ai/public_source/564b485f-db05-42fe-ba78-e69fc191160c/1e113aa3e-da05-4587-a37c-55a96ec86365.png',
abuelo: 'https://image.qwenlm.ai/public_source/564b485f-db05-42fe-ba78-e69fc191160c/1b8f75fe9-8c5d-4758-b138-4cefada0406b.png',
mama: 'https://image.qwenlm.ai/public_source/564b485f-db05-42fe-ba78-e69fc191160c/118d95fd7-f744-4b59-a5b4-c4de68f8e800.png',
papa: 'https://image.qwenlm.ai/public_source/564b485f-db05-42fe-ba78-e69fc191160c/135e1663c-5aba-4948-a549-17f9fa5307fd.png',
};
/* ============================== /lib/icons =============================== */
const mkIcon = (nodes) => {
const C = ({ size = 24, className = '', strokeWidth = 2, fill = 'none' }) => (
{nodes}
);
return C;
};
const Heart = mkIcon( );
const Mic = mkIcon(<> >);
const Sparkles = mkIcon(<> >);
const Video = mkIcon(<> >);
const HomeIc = mkIcon(<> >);
const Lock = mkIcon(<> >);
const XIc = mkIcon(<> >);
const Camera = mkIcon(<> >);
const ChevronRight = mkIcon( );
const Play = mkIcon( );
const MessageCircle = mkIcon( );
const Trophy = mkIcon(<> >);
const Send = mkIcon(<> >);
const LogOut = mkIcon(<> >);
const Calendar = mkIcon(<> >);
const Shield = mkIcon( );
const Star = mkIcon( );
const Check = mkIcon( );
const Flame = mkIcon( );
const Clock = mkIcon(<> >);
const Archive = mkIcon(<> >);
const StopSq = mkIcon( );
const GoogleG = ({ size = 20 }) => (
);
/* ============================== /lib/utils =============================== */
const uid = () => (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : 'id-' + Math.random().toString(36).slice(2) + Date.now());
const hashPass = (s) => { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return 'h' + (h >>> 0); };
const timeAgo = (ts) => {
const d = Date.now() - ts, m = Math.floor(d / 60000), h = Math.floor(d / 3600000), dd = Math.floor(d / 86400000);
if (m < 1) return 'justo ahora'; if (m < 60) return 'hace ' + m + ' min'; if (h < 24) return 'hace ' + h + ' h';
if (dd === 1) return 'ayer'; return 'hace ' + dd + ' días';
};
const fmtDur = (s) => String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
const todayStr = () => new Date().toDateString();
/* --- IndexedDB: almacén real de blobs de video --- */
const idbOpen = () => new Promise((res, rej) => {
const q = indexedDB.open('dilo-ahora', 1);
q.onupgradeneeded = () => { q.result.createObjectStore('videos'); };
q.onsuccess = () => res(q.result);
q.onerror = () => rej(q.error);
});
const idbPut = async (id, blob) => {
try {
const db = await idbOpen();
return await new Promise((res, rej) => {
const tx = db.transaction('videos', 'readwrite');
tx.objectStore('videos').put(blob, id);
tx.oncomplete = res; tx.onerror = rej;
});
} catch (e) { console.warn('idbPut', e); }
};
const idbGet = async (id) => {
try {
const db = await idbOpen();
return await new Promise((res) => {
const rq = db.transaction('videos').objectStore('videos').get(id);
rq.onsuccess = () => res(rq.result || null);
rq.onerror = () => res(null);
});
} catch (e) { return null; }
};
const idbDel = async (id) => {
try { const db = await idbOpen(); db.transaction('videos', 'readwrite').objectStore('videos').delete(id); } catch (e) { }
};
/* ============================== /lib/ai ================================== */
const retrieveMemory = (persona, text) => {
const words = text.toLowerCase().split(/\W+/).filter((w) => w.length > 3);
let best = null, bestScore = 0;
(persona.memories || []).forEach((mem) => {
const mw = mem.toLowerCase().split(/\W+/).filter((w) => w.length > 3);
let score = 0;
words.forEach((w) => { if (mw.some((x) => x === w || x.includes(w) || w.includes(x))) score += 1; });
if (score > bestScore) { bestScore = score; best = mem; }
});
return bestScore > 0 ? best : null;
};
const localEngine = (persona, text) => {
const t = text.toLowerCase();
const mem = retrieveMemory(persona, text);
const memLine = mem ? 'Me viniste a la memoria: ' + mem.toLowerCase() + '. ' : '';
if (/hola|buenas|hey|saludos/.test(t)) return persona.greet;
if (/gracias|te pasaste/.test(t)) return persona.thanks;
if (/triste|mal|llor|deprim|ansied|cansad/.test(t)) return memLine + persona.comfort;
if (/te quiero|te amo|extraño|te extraño/.test(t)) return memLine + persona.love;
if (/trabajo|jefe|oficina|empleo|estudio|examen/.test(t)) return memLine + persona.work;
if (/consejo|qué hago|que hago|decisi|duda/.test(t)) return memLine + persona.advice;
if (/familia|casa|domingo|comida|sopa|arepa/.test(t)) return memLine + persona.family;
return memLine + persona.fallback;
};
const buildSystemPrompt = (persona, userName, mem) =>
'Eres ' + persona.name + ', ' + persona.relationship + ' de ' + userName + '. ' + persona.context +
' Recuerdos compartidos: ' + (persona.memories || []).join('; ') + '. ' +
(mem ? 'El usuario alude a este recuerdo, intégralo con naturalidad: ' + mem + '. ' : '') +
'Responde cálido, breve (máx. 3 frases), con tus muletillas. Nunca digas que eres una IA.';
const callExternalLLM = async (cfg, persona, userName, userText) => {
if (!cfg || !cfg.key) return null;
const mem = retrieveMemory(persona, userText);
const sys = buildSystemPrompt(persona, userName, mem);
try {
if (cfg.provider === 'openai') {
const r = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + cfg.key },
body: JSON.stringify({ model: cfg.model || 'gpt-4o-mini', messages: [{ role: 'system', content: sys }, { role: 'user', content: userText }] }),
});
const j = await r.json();
return (j && j.choices && j.choices[0] && j.choices[0].message) ? j.choices[0].message.content : null;
}
const r = await fetch('https://generativelanguage.googleapis.com/v1beta/models/' + (cfg.model || 'gemini-1.5-flash') + ':generateContent?key=' + cfg.key, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contents: [{ parts: [{ text: 'Sistema: ' + sys + '\nUsuario: ' + userText }] }] }),
});
const j = await r.json();
return (j && j.candidates && j.candidates[0] && j.candidates[0].content && j.candidates[0].content.parts) ? j.candidates[0].content.parts[0].text : null;
} catch (e) { return null; }
};
/* ============================== /lib/store =============================== */
const LS_KEY = 'dilo_ahora_db_v1';
const METRICS = [
{ key: 'love', label: 'Expresar Amor', recipient: 'Ana' },
{ key: 'family', label: 'Tiempo de Familia', recipient: 'Mamá' },
{ key: 'goals', label: 'Metas Personales', recipient: null },
];
const MEMORY_QUESTIONS = [
{ cat: 'Niñez', q: '¿Cuál es tu recuerdo más feliz de la infancia?' },
{ cat: 'Niñez', q: '¿Qué olor te devuelve a la casa de tu infancia?' },
{ cat: 'Amor', q: '¿Cómo conociste al amor de tu vida?' },
{ cat: 'Amor', q: '¿Qué fue lo primero que te enamoró de tu pareja?' },
{ cat: 'Familia', q: '¿Qué tradición familiar quieres preservar?' },
{ cat: 'Familia', q: '¿Qué deseas que los tuyos recuerden de ti?' },
{ cat: 'Trabajo', q: '¿Cuál fue tu primer trabajo y qué aprendiste?' },
{ cat: 'Sabiduría', q: '¿Qué le dirías a tu yo de 20 años?' },
];
const seedPersonas = () => ([
{
id: 'p-abuelo', name: 'Abuelo', relationship: 'abuelo', avatar: IMG.abuelo,
context: 'Abuelo colombiano cálido, sabio y jocoso. Muletillas: "mijito", "con calma y un tinto". Habla de la finca, el ajedrez y la paciencia. Ríe suave ("jeje").',
memories: ['Cuando te enseñé a montar en bicicleta en el patio de la finca', 'Las partidas de ajedrez con don Ramiro bajo el mango', 'El día que plantamos el árbol de mango y te dije que los buenos frutos toman tiempo'],
greet: '¡Jeje! Aquí está tu abuelo, mijito. Cuéntame, ¿cómo anda ese corazón?',
comfort: 'Con calma y un tinto, mijito. Las penas pesan menos cuando se cuentan. Yo pasé trabajos grandes y siempre salió el sol.',
love: 'El amor no se guarda, se dice, mijito. A tu abuela yo se lo dije todos los días… y aún me faltaron.',
work: 'El trabajo dignifica, pero no lo es todo. Siembra tiempo con los tuyos, que esa es la cosecha que queda, mijito.',
advice: 'Mira, mijito: la vida es como el ajedrez. A veces hay que perder una pieza para ganar la partida. Piensa con calma y mueve con corazón.',
family: 'La familia es el patio de la finca: siempre está ahí para que vuelvas, mijito.',
thanks: 'No me des las gracias, mijito. Para eso está el abuelo. Jeje.',
fallback: 'Te escucho, mijito, con calma y un tinto. Sigue contándome, que para eso estoy.',
},
{
id: 'p-mama', name: 'Mamá', relationship: 'madre', avatar: IMG.mama,
context: 'Madre latina amorosa, protectora y alegre. Muletillas: "mi amor", "¿ya comiste?". Habla de la cocina de los domingos y de su fe incondicional en ti.',
memories: ['La receta de las arepas de domingo que solo te enseñé a ti', 'La noche que velé tu fiebre con paños frescos y canciones', 'Tu primer día de escuela, cuando lloré de orgullo sin que me vieras'],
greet: '¡Mi amor! Qué alegría escucharte. ¿Ya comiste? Cuéntame todo.',
comfort: 'Ven acá, mi amor. Respira. Mientras yo esté aquí —y siempre estaré— no hay pena que no se pase con un abrazo y algo caliente en la barriga.',
love: 'Te quiero más que a nada en este mundo, mi amor. Nunca lo dudes, ni un segundo.',
work: 'Trabaja con ganas, mi amor, pero no te olvides de descansar y comer bien. ¿Ya comiste?',
advice: 'Hazlo con amor, mi amor. Lo que se hace con amor nunca sale mal; y si sale, se vuelve aprendizaje.',
family: 'El domingo se cocina en familia, mi amor. Esa receta no se pierde mientras tú la cuentes.',
thanks: 'Ay, mi amor, no me des las gracias. Ser tu mamá es mi premio.',
fallback: 'Te escucho con el corazón, mi amor. Sigue, sigue contándome.',
},
{
id: 'p-papa', name: 'Papá', relationship: 'padre', avatar: IMG.papa,
context: 'Padre serio por fuera, tierno por dentro, humor seco. Muletillas: "campeón", "eso se arregla en el garaje". Orgullo callado, fútbol de domingos.',
memories: ['La tarde que arreglamos tu bicicleta en el garaje y me manchaste de grasa', 'El día que te enseñé a manejar y no soltaste el volante ni yo tampoco', 'Cuando fingí que no lloraba en tu graduación'],
greet: 'Hola, campeón. Aquí ando, como siempre, pendiente de ti.',
comfort: 'No te me achiques, campeón. Los problemas son como tu bici: se arreglan en el garaje, con paciencia y buena herramienta.',
love: 'No soy de muchas palabras, campeón. Pero todo lo que hice, lo hice por ti. Estoy orgulloso, aunque no lo diga.',
work: 'El trabajo se respeta, campeón. Llega temprano, hazlo bien y no te quejes. Lo demás llega solo.',
advice: 'Hazlo bien, hazlo una vez, y si sale mal, lo vuelves a hacer. Así se aprende, campeón.',
family: 'La familia primero, campeón. El domingo es de fútbol y de todos juntos, así se ha hecho siempre.',
thanks: 'Nada que agradecer, campeón. Eso y más.',
fallback: 'Sigue, campeón, que te estoy escuchando. Aquí no se interrumpe a nadie.',
},
]);
const seedData = () => ({
metrics: { love: 35, family: 90, goals: 72 },
prevMetrics: { love: 30, family: 84, goals: 70 },
streak: { count: 12, last: null },
checkin: { last: null },
messages: [
{ id: 'm-seed-1', dir: 'in', from: 'Ana', relation: 'Pareja', kind: 'illustrated', cover: IMG.thumbAna, tag: '+15% Amor', at: Date.now() - 5 * 3600000, transcript: 'Hola, mi amor. Sé que la semana ha sido dura. Solo quería verte la cara y recordarte que estoy orgullosa de ti. Respira hondo. Esta noche cocinamos juntos y olvidamos el mundo. Te amo.' },
{ id: 'm-seed-2', dir: 'in', from: 'Mamá', relation: 'Madre', kind: 'illustrated', cover: IMG.thumbMama, tag: '+10% Familia', at: Date.now() - 26 * 3600000, transcript: '¡Mijo! Hice la sopa de domingo y me acordé de ti. No esperes a la próxima visita para llamarme. Un videito tuyo me alegra la semana entera. Te quiero mucho.' },
],
memories: [],
personas: seedPersonas(),
chats: {},
});
const loadDB = () => {
try {
const raw = localStorage.getItem(LS_KEY);
if (raw) return JSON.parse(raw);
} catch (e) { }
return { users: [], session: null, data: {} };
};
const AppCtx = createContext(null);
const useApp = () => useContext(AppCtx);
function AppProvider({ children }) {
const [db, setDb] = useState(loadDB);
const [toasts, setToasts] = useState([]);
const [burst, setBurst] = useState(0);
useEffect(() => {
try { localStorage.setItem(LS_KEY, JSON.stringify(db)); } catch (e) { console.warn('persist', e); }
}, [db]);
const user = db.users.find((u) => u.id === db.session) || null;
const data = user ? db.data[user.id] : null;
const toast = useCallback((msg, kind) => {
const id = uid();
setToasts((t) => t.concat([{ id, msg, kind: kind || 'ok' }]));
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3400);
}, []);
const celebrate = useCallback(() => setBurst((b) => b + 1), []);
const mutate = useCallback((fn) => {
setDb((d) => {
if (!d.session) return d;
const next = {};
Object.keys(d.data).forEach((k) => { next[k] = d.data[k]; });
next[d.session] = fn(d.data[d.session]);
return Object.assign({}, d, { data: next });
});
}, []);
const ensureUser = (email, name, pass, plan) => {
let target = db.users.find((u) => u.email === email);
if (!target) {
target = { id: uid(), email, full_name: name, pass: hashPass(pass), plan_type: plan, avatar_url: null, created_at: Date.now() };
const newData = {};
Object.keys(db.data).forEach((k) => { newData[k] = db.data[k]; });
newData[target.id] = seedData();
setDb((d) => Object.assign({}, d, { users: d.users.concat([target]), session: target.id, data: newData }));
} else {
setDb((d) => Object.assign({}, d, { session: target.id }));
}
return target;
};
const actions = {
toast, celebrate,
navigate: (p) => { window.location.hash = p; },
register: (name, email, pass) => {
if ((name || '').trim().length < 2) return 'Cuéntanos tu nombre (mín. 2 letras).';
if (!/^\S+@\S+\.\S+$/.test(email)) return 'Ese correo no parece válido.';
if ((pass || '').length < 6) return 'La contraseña necesita 6 caracteres.';
if (db.users.some((u) => u.email === email)) return 'Ya existe una cuenta con ese correo. Inicia sesión.';
ensureUser(email, name.trim(), pass, 'pro');
toast('Bienvenido a tu Legado Vivo, ' + name.trim().split(' ')[0] + ' ❤');
return null;
},
login: (email, pass) => {
const u = db.users.find((x) => x.email === email);
if (!u || u.pass !== hashPass(pass)) return 'Correo o contraseña incorrectos.';
setDb((d) => Object.assign({}, d, { session: u.id }));
toast('Hola de nuevo, ' + u.full_name.split(' ')[0] + ' 👋');
return null;
},
google: () => { ensureUser('cuenta.google@gmail.com', 'Cuenta Google', 'oauth-' + uid(), 'pro'); toast('Sesión iniciada con Google ✔'); },
demo: () => { ensureUser('demo@diloahora.com', 'Jairo', 'demo1234', 'legacy_plus'); toast('Explorando con la cuenta demo ✨'); },
logout: () => { setDb((d) => Object.assign({}, d, { session: null })); window.location.hash = '#/'; },
checkin: (vals) => {
const today = todayStr();
mutate((dd) => {
if (dd.checkin.last === today) return dd;
const yesterday = new Date(Date.now() - 86400000).toDateString();
const count = (dd.streak.last === yesterday || dd.streak.last === null) ? dd.streak.count + 1 : 1;
return Object.assign({}, dd, {
prevMetrics: dd.metrics,
metrics: Object.assign({}, vals),
streak: { count, last: today },
checkin: { last: today },
});
});
celebrate();
toast('Check-in registrado · tu Gimnasio Emocional se actualizó 💪');
},
sendMessage: (payload) => {
mutate((dd) => Object.assign({}, dd, { messages: [Object.assign({}, payload, { id: uid(), dir: 'out', at: Date.now() })].concat(dd.messages) }));
if (payload.tag && payload.tag.indexOf('Amor') !== -1) mutate((dd) => Object.assign({}, dd, { metrics: Object.assign({}, dd.metrics, { love: Math.min(100, dd.metrics.love + 15) }) }));
if (payload.tag && payload.tag.indexOf('Familia') !== -1) mutate((dd) => Object.assign({}, dd, { metrics: Object.assign({}, dd.metrics, { family: Math.min(100, dd.metrics.family + 10) }) }));
celebrate();
toast('Video enviado a ' + payload.recipient + ' · ' + payload.tag + ' 🎉');
},
addMemory: (payload) => {
mutate((dd) => Object.assign({}, dd, { memories: [Object.assign({}, payload, { id: uid(), at: Date.now(), privacy: 'private', deliverAt: null })].concat(dd.memories) }));
celebrate();
toast('Memoria preservada en tu Bóveda 🔐');
},
updateMemory: (id, patch) => mutate((dd) => Object.assign({}, dd, { memories: dd.memories.map((m) => (m.id === id ? Object.assign({}, m, patch) : m)) })),
deleteMemory: (id) => { mutate((dd) => Object.assign({}, dd, { memories: dd.memories.filter((m) => m.id !== id) })); idbDel(id); toast('Memoria eliminada de la Bóveda', 'warn'); },
pushChat: (pid, msg) => mutate((dd) => {
const chats = Object.assign({}, dd.chats);
chats[pid] = (chats[pid] || []).concat([msg]);
return Object.assign({}, dd, { chats });
}),
};
const value = Object.assign({ user, data, toasts, burst }, actions);
return {children} ;
}
/* ============================ /components/ui ============================= */
const GLOBAL_CSS = [
'html{scroll-behavior:smooth}',
"body{font-family:'Inter',ui-sans-serif,system-ui,sans-serif;background:#F9FAFB}",
'@keyframes da-blob{0%,100%{transform:translate(0,0) scale(1)}33%{transform:translate(26px,-34px) scale(1.08)}66%{transform:translate(-22px,22px) scale(.94)}}',
'.da-blob{animation:da-blob 9s ease-in-out infinite}',
'.da-blob-2{animation:da-blob 11s ease-in-out infinite reverse}',
'@keyframes da-kenburns{0%{transform:scale(1) translate(0,0)}50%{transform:scale(1.14) translate(2%,-2%)}100%{transform:scale(1) translate(0,0)}}',
'.da-kenburns{animation:da-kenburns 14s ease-in-out infinite}',
'@keyframes da-eq{0%,100%{height:28%}50%{height:96%}}',
'.no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{scrollbar-width:none}',
].join('\n');
function StyleLoader() {
useEffect(() => {
if (!document.getElementById('da-tw')) {
const s = document.createElement('script');
s.id = 'da-tw';
s.src = 'https://cdn.tailwindcss.com';
s.onload = () => {
if (window.tailwind) {
window.tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'] },
},
},
};
}
};
document.head.appendChild(s);
}
if (!document.getElementById('da-font')) {
const l = document.createElement('link');
l.id = 'da-font';
l.rel = 'stylesheet';
l.href = 'https://cdn.jsdelivr.net/fontsource/css/inter@latest/index.css';
document.head.appendChild(l);
}
if (!document.getElementById('da-fav')) {
const f = document.createElement('link');
f.id = 'da-fav';
f.rel = 'icon';
const svg = ' ';
f.href = 'data:image/svg+xml,' + encodeURIComponent(svg);
document.head.appendChild(f);
}
}, []);
return null;
}
class ErrorBoundary extends React.Component {
constructor(p) { super(p); this.state = { err: null }; }
static getDerivedStateFromError(err) { return { err }; }
render() {
if (this.state.err) {
return (
Algo se nos cruzó en el camino
Tus recuerdos están a salvo en tu Bóveda. Recarga la aplicación para continuar.
window.location.reload()} className="w-full px-6 py-3 rounded-2xl bg-[#E74C3C] text-white font-semibold hover:bg-red-600 transition-colors">Reintentar ahora
);
}
return this.props.children;
}
}
const LogoMark = ({ size = 40 }) => (
);
const Logo = ({ size = 38, dark = false, tagline = false }) => (
Dilo ahora
{tagline &&
Habla hoy. Deja tu huella. Inspira siempre.
}
);
const Button = ({ children, variant = 'primary', className = '', onClick, icon: Icon, size = 'md', type = 'button' }) => {
const base = 'rounded-2xl font-semibold transition-all duration-300 flex items-center justify-center gap-2 shadow-lg hover:shadow-xl active:scale-95 disabled:opacity-50 disabled:pointer-events-none';
const sizes = { sm: 'px-4 py-2 text-sm', md: 'px-6 py-3', lg: 'px-8 py-4 text-lg' };
const variants = {
primary: 'bg-[#E74C3C] text-white hover:bg-red-600',
outline: 'border-2 border-[#E74C3C] text-[#E74C3C] hover:bg-red-50 shadow-none',
white: 'bg-white text-[#E74C3C] hover:bg-gray-50',
ghost: 'bg-transparent text-gray-600 hover:bg-gray-100 shadow-none',
gold: 'bg-gradient-to-r from-[#FCD34D] to-[#D97706] text-white',
dark: 'bg-gray-900 text-white hover:bg-gray-800',
};
return (
{Icon ? : null}
{children}
);
};
const statusOf = (v) => (v >= 95 ? 'gold' : v >= 50 ? 'success' : 'warning');
const WellbeingBar = ({ label, percentage, status, onFix, delta }) => {
const colorClass = status === 'warning' ? 'bg-[#F39C12]' : status === 'success' ? 'bg-[#2ECC71]' : 'bg-gradient-to-r from-[#FCD34D] via-yellow-500 to-[#D97706]';
const textColor = status === 'warning' ? 'text-[#F39C12]' : status === 'success' ? 'text-[#2ECC71]' : 'text-yellow-600';
return (
{label}
{status === 'gold' && }
{typeof delta === 'number' && delta !== 0 && (
0 ? 'text-[#2ECC71]' : 'text-[#F39C12]')}>{delta > 0 ? '▲' : '▼'} {Math.abs(delta)}
)}
{percentage}%
{status === 'warning' && onFix && (
Recupera
)}
);
};
const Modal = ({ open, onClose, children, wide = false, dark = false }) => (
{open && (
e.stopPropagation()}
className={'w-full ' + (wide ? 'max-w-2xl' : 'max-w-md') + ' ' + (dark ? 'bg-gray-950 text-white' : 'bg-white text-gray-800') + ' rounded-3xl shadow-2xl overflow-hidden relative'}>
{children}
)}
);
const Toasts = () => {
const { toasts } = useApp();
return (
{toasts.map((t) => (
{t.kind === 'warn' ? : }
{t.msg}
))}
);
};
const Confetti = () => {
const { burst } = useApp();
const [pieces, setPieces] = useState([]);
useEffect(() => {
if (!burst) return undefined;
const colors = ['#E74C3C', '#2ECC71', '#F39C12', '#FCD34D', '#F97316'];
const next = Array.from({ length: 28 }, (_, i) => ({ id: burst + '-' + i, x: Math.random() * 100, d: Math.random() * 0.4, r: Math.random() * 360 - 180, c: colors[i % colors.length], s: 6 + Math.random() * 8 }));
setPieces(next);
const t = setTimeout(() => setPieces([]), 2600);
return () => clearTimeout(t);
}, [burst]);
return (
{pieces.map((p) => (
))}
);
};
const Monogram = ({ name, className = 'w-10 h-10 text-sm', grad = 'from-[#E74C3C] to-[#C0392B]' }) => (
{(name || '?').split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase()}
);
/* ====================== /components/recorder & player ==================== */
function RecorderModal({ open, onClose, mode, question, defaultRecipient }) {
const app = useApp();
const recipients = [{ name: 'Ana', tag: '+15% Amor' }, { name: 'Mamá', tag: '+10% Familia' }, { name: 'Papá', tag: '+10% Familia' }];
const [phase, setPhase] = useState('starting');
const [recording, setRecording] = useState(false);
const [secs, setSecs] = useState(0);
const [recipient, setRecipient] = useState(defaultRecipient || 'Ana');
const [postalText, setPostalText] = useState('');
const videoRef = useRef(null);
const streamRef = useRef(null);
const recRef = useRef(null);
const chunksRef = useRef([]);
const timerRef = useRef(null);
const rafRef = useRef(null);
const audioRef = useRef(null);
const barsRef = useRef([]);
const cleanup = useCallback(() => {
if (timerRef.current) clearInterval(timerRef.current);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
if (streamRef.current) streamRef.current.getTracks().forEach((t) => t.stop());
if (audioRef.current) audioRef.current.close().catch(() => { });
streamRef.current = null;
audioRef.current = null;
}, []);
const start = useCallback(() => {
setPhase('starting'); setRecording(false); setSecs(0);
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setPhase('denied'); return; }
navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' }, audio: true })
.then((stream) => {
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.play().catch(() => { });
}
try {
const Ctx = window.AudioContext || window.webkitAudioContext;
const ctx = new Ctx();
const src = ctx.createMediaStreamSource(stream);
const an = ctx.createAnalyser();
an.fftSize = 64;
src.connect(an);
audioRef.current = ctx;
const buf = new Uint8Array(an.frequencyBinCount);
const loop = () => {
an.getByteFrequencyData(buf);
barsRef.current.forEach((b, i) => {
if (b) b.style.height = (18 + (buf[i * 4] / 255) * 80) + '%';
});
rafRef.current = requestAnimationFrame(loop);
};
loop();
} catch (e) { }
setPhase('live');
})
.catch(() => setPhase('denied'));
}, []);
useEffect(() => {
if (open) start();
return cleanup;
}, [open, start, cleanup]);
useEffect(() => () => {
if (recRef.current && recRef.current.state === 'recording') recRef.current.stop();
}, []);
const captureThumb = () => {
try {
const v = videoRef.current;
if (!v) return null;
const c = document.createElement('canvas');
c.width = 320; c.height = 240;
c.getContext('2d').drawImage(v, 0, 0, 320, 240);
return c.toDataURL('image/jpeg', 0.6);
} catch (e) { return null; }
};
const stopRec = () => {
if (timerRef.current) clearInterval(timerRef.current);
setRecording(false);
if (recRef.current && recRef.current.state !== 'inactive') recRef.current.stop();
};
const startRec = () => {
const stream = streamRef.current;
if (!stream) return;
chunksRef.current = [];
let mime = '';
if (window.MediaRecorder) {
const opts = ['video/webm;codecs=vp9', 'video/webm', 'video/mp4'];
for (let i = 0; i < opts.length; i++) {
if (MediaRecorder.isTypeSupported(opts[i])) { mime = opts[i]; break; }
}
}
const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
rec.ondataavailable = (e) => { if (e.data && e.data.size) chunksRef.current.push(e.data); };
rec.onstop = async () => {
const blob = new Blob(chunksRef.current, { type: rec.mimeType || 'video/webm' });
const id = uid();
await idbPut(id, blob);
const tag = mode === 'memory' ? 'Memoria' : ((recipients.find((r) => r.name === recipient) || recipients[0]).tag);
const payload = mode === 'memory'
? { kind: 'video', videoId: id, thumb: captureThumb(), question, duration: secs, category: 'spoken_memory' }
: { kind: 'video', videoId: id, thumb: captureThumb(), recipient, tag, duration: secs, category: 'async_affect' };
if (mode === 'memory') app.addMemory(payload); else app.sendMessage(payload);
onClose();
};
recRef.current = rec;
rec.start();
setRecording(true);
setSecs(0);
timerRef.current = setInterval(() => {
setSecs((s) => {
if (s + 1 >= 60) stopRec();
return s + 1;
});
}, 1000);
};
const savePostal = () => {
if (postalText.trim().length < 3) { app.toast('Escribe al menos una frase con cariño', 'warn'); return; }
if (mode === 'memory') app.addMemory({ kind: 'postal', question, transcript: postalText.trim(), duration: 0, category: 'spoken_memory', thumb: null });
else app.sendMessage({ kind: 'postal', recipient, tag: (recipients.find((r) => r.name === recipient) || recipients[0]).tag, transcript: postalText.trim(), category: 'async_affect', thumb: null });
onClose();
};
return (
{ if (recording) stopRec(); onClose(); }} dark wide>
{mode === 'memory' ? 'Memoria Hablada' : 'Buzón de Afecto'}
{mode === 'memory' ? (question ? question.q : 'Cuéntale al futuro') : 'Graba un video-mensaje'}
{ if (recording) stopRec(); onClose(); }} className="p-2 rounded-xl hover:bg-white/10 transition-colors">
{mode === 'affect' && phase !== 'postal' && (
{recipients.map((r) => (
setRecipient(r.name)}
className={'px-4 py-2 rounded-full text-sm font-semibold transition-all ' + (recipient === r.name ? 'bg-[#E74C3C] text-white shadow-lg shadow-red-900/40' : 'bg-white/10 text-gray-300 hover:bg-white/20')}>
{r.name} · {r.tag}
))}
)}
{(phase === 'starting' || phase === 'live') && (
<>
{phase === 'starting' && (
Encendiendo cámara y micrófono…
)}
{phase === 'live' && (
<>
{recording ? 'REC' : 'LISTO'} · {fmtDur(secs)}
{[0, 1, 2, 3, 4].map((i) => (
{ barsRef.current[i] = el; }} className={'w-1 rounded-full ' + (recording ? 'bg-red-400' : 'bg-gray-600')} style={{ height: '25%', animation: recording ? 'da-eq ' + (0.5 + i * 0.13) + 's ease-in-out infinite' : 'none' }} />
))}
>
)}
{ if (recording) stopRec(); onClose(); }}>Cancelar
{recording ? : }
{recording ? 'Toca para guardar' : 'Toca para grabar · máx. 60 s'}
>
)}
{phase === 'denied' && (
No pudimos acceder a tu cámara
Puede que el navegador no tenga permisos en este dispositivo. No dejes de decirlo: conviértelo en una postal animada.
Reintentar cámara
setPhase('postal')} icon={Heart}>Crear postal animada
)}
{phase === 'postal' && (
)}
);
}
function PlayerModal({ msg, onClose }) {
const [url, setUrl] = useState(null);
const [typed, setTyped] = useState(0);
useEffect(() => {
let objUrl = null;
let alive = true;
if (msg && msg.videoId) {
idbGet(msg.videoId).then((b) => {
if (b && alive) { objUrl = URL.createObjectURL(b); setUrl(objUrl); }
});
}
return () => { alive = false; if (objUrl) URL.revokeObjectURL(objUrl); };
}, [msg]);
useEffect(() => {
setTyped(0);
if (msg && msg.transcript) {
const iv = setInterval(() => setTyped((t) => (t >= msg.transcript.length ? t : t + 2)), 30);
return () => clearInterval(iv);
}
return undefined;
}, [msg]);
if (!msg) return null;
return (
{msg.dir === 'out' ? 'Para ' + msg.recipient : 'De ' + msg.from}
{timeAgo(msg.at)} {msg.tag && {msg.tag} }
{url ? (
) : msg.cover ? (
{(msg.transcript || '').slice(0, typed)}
) : (
{(msg.transcript || '').slice(0, typed)}
Postal animada · Dilo Ahora
)}
);
}
/* ============================== /views/landing =========================== */
const scrollToId = (id) => {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: 'smooth' });
};
function useCountUp(target, started, dur = 1400) {
const [v, setV] = useState(0);
useEffect(() => {
if (!started) return undefined;
let raf = 0;
const t0 = performance.now();
const step = (t) => {
const p = Math.min(1, (t - t0) / dur);
setV(target * (1 - Math.pow(1 - p, 3)));
if (p < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [started, target, dur]);
return v;
}
function StatsBand() {
const ref = useRef(null);
const [started, setStarted] = useState(false);
useEffect(() => {
const ob = new IntersectionObserver((es) => {
if (es[0].isIntersecting) { setStarted(true); ob.disconnect(); }
}, { threshold: 0.3 });
if (ref.current) ob.observe(ref.current);
return () => ob.disconnect();
}, []);
const fam = useCountUp(12400, started);
const pct = useCountUp(92, started);
const rate = useCountUp(4.9, started);
const mins = useCountUp(38, started);
const items = [
{ v: Math.round(fam).toLocaleString('es-ES'), l: 'familias ya lo dicen ahora' },
{ v: Math.round(pct) + ' %', l: 'reporta vínculos más cercanos en 30 días' },
{ v: rate.toFixed(1).replace('.', ','), l: 'valoración media de la comunidad' },
{ v: Math.round(mins) + ' min', l: 'de memoria viva preservada al mes' },
];
return (
{items.map((it, i) => (
))}
);
}
function SpotModal({ open, onClose, onCTA }) {
const scenes = [
{ at: 0, node:
Hay palabras que no pueden esperar.
},
{ at: 3, node:
Un «te quiero» a tiempo cambia una vida.
},
{ at: 6, node:
},
{ at: 9, node:
Preserva tu esencia para siempre.
},
{ at: 12, node:
Dilo ahora
Habla hoy. Deja tu huella. Inspira siempre.
},
];
const [idx, setIdx] = useState(0);
const [ended, setEnded] = useState(false);
useEffect(() => {
if (!open) { setIdx(0); setEnded(false); return undefined; }
const timers = scenes.map((s, i) => setTimeout(() => setIdx(i), s.at * 1000));
const end = setTimeout(() => setEnded(true), 15000);
return () => { timers.forEach(clearTimeout); clearTimeout(end); };
}, [open]);
return (
Spot · 15 s
{ended ? (
Tu historia merece ser dicha.
Comenzar mi Legado
{ setEnded(false); setIdx(0); }}>Repetir spot
) : (
{scenes[idx].node}
)}
);
}
function LegalModal({ type, onClose }) {
const content = {
privacidad: { t: 'Privacidad', b: 'Tus videos y recuerdos se cifran y solo tú decides quién los ve y cuándo. En esta demo local, todo permanece en tu dispositivo. En producción usamos cifrado AES-256 en reposo y TLS 1.3 en tránsito, con cumplimiento RGPD y derecho al olvido garantizado.' },
terminos: { t: 'Términos', b: 'Dilo Ahora es una plataforma de bienestar emocional y preservación de memoria. No sustituye terapia psicológica ni asesoría legal. El Chatbot de Legado es una recreación conmemorativa basada en el contenido que tú decidas preservar, y su uso requiere consentimiento explícito del titular de la esencia.' },
seguridad: { t: 'Seguridad', b: 'Bóveda con cifrado de extremo a extremo, entregas programadas con doble verificación y contactos de confianza. Ningún tercero —ni siquiera nuestro equipo— puede acceder al contenido de tu Bóveda sin tu llave familiar.' },
};
const c = content[type] || content.privacidad;
return (
);
}
function Landing() {
const app = useApp();
const [scrolled, setScrolled] = useState(false);
const [spot, setSpot] = useState(false);
const [legal, setLegal] = useState(null);
const [step, setStep] = useState(0);
useEffect(() => {
const f = () => setScrolled(window.scrollY > 12);
window.addEventListener('scroll', f);
return () => window.removeEventListener('scroll', f);
}, []);
useEffect(() => {
const iv = setInterval(() => setStep((s) => (s + 1) % 3), 4500);
return () => clearInterval(iv);
}, []);
const steps = [
{ icon: Heart, t: 'Mide', d: 'Tu Gimnasio Emocional traduce cómo estás con los tuyos en barras vivas que respiran con tu check-in diario.' },
{ icon: Video, t: 'Conecta', d: 'Envía video-mensajes sin prisa ni doble check: comunicación asíncrona que baja la ansiedad y sube el afecto.' },
{ icon: Lock, t: 'Preserva', d: 'Responde preguntas guiadas, guarda tu voz en la Bóveda y deja que nuestra IA ética la convierta en consuelo futuro.' },
];
const feats = [
{ icon: Heart, color: 'text-[#E74C3C]', bg: 'bg-red-50', t: 'El Gimnasio Emocional', d: 'Mide y entrena tus necesidades afectivas como un juego serio: barras de amor, familia y metas que responden a tus acciones reales.', demo: 'bars' },
{ icon: MessageCircle, color: 'text-purple-600', bg: 'bg-purple-50', t: 'Buzón de Afecto Asíncrono', d: 'Video-mensajes que se abren con calma y quedan guardados para siempre. Sin inmediatismo, con intención.', demo: 'chat' },
{ icon: Sparkles, color: 'text-blue-500', bg: 'bg-blue-50', t: 'Bóveda & Chatbot de Legado', d: 'Una IA ética entrenada con tu esencia para ofrecer consuelo, recuerdos y compañía a tus seres queridos.', demo: 'vault' },
];
const tests = [
{ n: 'Mariana G.', c: '58 · Madrid', g: 'from-[#E74C3C] to-[#C0392B]', q: 'Le grabé a mi hija lo que nunca supe decirle en persona. Ahora lo abre cada domingo, como quien hereda un abrazo.' },
{ n: 'Diego R.', c: '24 · CDMX', g: 'from-[#F39C12] to-[#D97706]', q: 'Mi abuelo falleció en marzo. Su esencia digital me explicó su jugada de ajedrez favorita… y su risa sigue en casa.' },
{ n: 'Jairo M.', c: '35 · Bogotá', g: 'from-[#2ECC71] to-[#16A085]', q: 'Las barras me mostraron que trabajaba mucho y abrazaba poco. Cambié mis tardes, y mi familia lo notó en una semana.' },
];
const plans = [
{ n: 'Gratis', p: '0 €', d: 'Para empezar a sentir', f: ['3 barras emocionales', '10 video-mensajes al mes', '1 persona de legado'], hot: false },
{ n: 'Pro', p: '4,99 €', d: 'Para vivir con intención', f: ['Todo lo del plan Gratis', 'Buzón y check-ins ilimitados', 'Bóveda con 5 h de memoria', 'Rachas y logros dorados'], hot: true },
{ n: 'Legacy+', p: '9,99 €', d: 'Para trascender con amor', f: ['Chatbot de Legado completo', 'Entregas programadas póstumas', 'Cifrado familiar compartido', 'Concierge de memoria'], hot: false },
];
return (
window.scrollTo({ top: 0, behavior: 'smooth' })}>
scrollToId('como-funciona')} className="hover:text-[#E74C3C] transition-colors">Cómo funciona
scrollToId('manifiesto')} className="hover:text-[#E74C3C] transition-colors">Manifiesto
scrollToId('testimonios')} className="hover:text-[#E74C3C] transition-colors">Testimonios
scrollToId('precios')} className="hover:text-[#E74C3C] transition-colors">Precios
app.navigate('#/auth')}>Iniciar sesión
app.navigate('#/auth')}>Comenzar
Bienestar emocional + Legado vivo
Tu Legado Vivo.Tu Bienestar Hoy.
Dilo Ahora es el gimnasio donde se entrenan los afectos: conecta profundamente con quienes amas hoy y preserva tu esencia para siempre.
app.navigate('#/auth')} className="shadow-red-500/25">Comenzar mi Legado
setSpot(true)}>Ver spot · 15 s
{['AL', 'DR', 'JM', 'MC'].map((ini, i) => (
{ini}
))}
{[0, 1, 2, 3, 4].map((i) => )}
+12.400 familias ya lo dicen ahora
REC 00:12
Racha actual
12 días conectando
+15 % Amor
El método
Tres pasos para vivir con intención
{steps.map((s, i) => (
setStep(i)} whileHover={{ y: -6 }}
className={'text-left p-8 rounded-3xl border transition-all ' + (step === i ? 'bg-[#F9FAFB] border-[#E74C3C]/30 shadow-xl ring-2 ring-[#E74C3C]/20' : 'bg-white border-gray-100 shadow-sm')}>
{s.t}
{s.d}
{step === i && }
))}
Hecho para sentir, diseñado para durar
Cada módulo nació de una pregunta simple: ¿qué pasaría si dijéramos a tiempo lo que realmente importa?
{feats.map((f, i) => (
{f.t}
{f.d}
{f.demo === 'bars' && (
)}
{f.demo === 'chat' && (
«Mijo, ¿ya comiste?» ❤
«Sí má, te mando video 😄»
)}
{f.demo === 'vault' && (
)}
))}
{['Postergamos los «te quiero».', 'Esperamos al cumpleaños, a la despedida, al abrazo.', 'Guardamos los recuerdos como si el tiempo sobrara.'].map((l, i) => (
{l}
))}
Hoy no. Hoy lo decimos. Ahora.
app.navigate('#/auth')} icon={Heart}>Empezar hoy, gratis
Historias que ya se dijeron a tiempo
{tests.map((t, i) => (
{[0, 1, 2, 3, 4].map((k) => )}
“{t.q}”
))}
Un plan para cada etapa del cariño
Sin permanencia. Cancela cuando quieras; tus recuerdos nunca se borran.
{plans.map((p, i) => (
{p.hot && Más elegido }
{p.n}
{p.d}
{p.p} /mes
{p.f.map((ft, k) => (
{ft}
))}
app.navigate('#/auth')}>Elegir {p.n}
))}
Lo que no se dice hoy, se convierte en silencio mañana.
Dos minutos de video hoy valen más que mil mensajes de texto olvidados.
app.navigate('#/auth')}>Comenzar mi Legado
setSpot(false)} onCTA={() => { setSpot(false); app.navigate('#/auth'); }} />
setLegal(null)} />
);
}
/* ============================== /views/auth ============================== */
function AuthView({ notice }) {
const app = useApp();
const [tab, setTab] = useState('login');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [pass, setPass] = useState('');
const [err, setErr] = useState(null);
const submit = (e) => {
e.preventDefault();
const r = tab === 'login' ? app.login(email.trim(), pass) : app.register(name, email.trim(), pass);
if (r) setErr(r); else app.navigate('#/app');
};
return (
{notice && {notice}
}
{['login', 'register'].map((t) => (
{ setTab(t); setErr(null); }} className={'py-2.5 rounded-xl text-sm font-bold transition-all ' + (tab === t ? 'bg-white shadow text-[#E74C3C]' : 'text-gray-500')}>
{t === 'login' ? 'Iniciar sesión' : 'Crear cuenta'}
))}
{ app.google(); app.navigate('#/app'); }} className="flex items-center justify-center gap-2 py-3 rounded-2xl border border-gray-200 text-sm font-semibold hover:bg-gray-50 transition-colors"> Google
{ app.demo(); app.navigate('#/app'); }} className="flex items-center justify-center gap-2 py-3 rounded-2xl bg-gray-900 text-white text-sm font-semibold hover:bg-gray-800 transition-colors"> Cuenta demo
Demo local: tus datos viven solo en este dispositivo. En producción: Supabase Auth + OAuth Google reales.
app.navigate('#/')} className="block mx-auto mt-4 text-xs font-semibold text-gray-500 hover:text-[#E74C3C] transition-colors">← Volver al inicio
);
}
/* ============================ /views/app shell =========================== */
const NAV = [
{ path: '#/app', label: 'Inicio', icon: HomeIc },
{ path: '#/app/buzon', label: 'Buzón', icon: Video },
{ path: '#/app/memorias', label: 'Memorias', icon: Mic },
{ path: '#/app/boveda', label: 'Bóveda', icon: Lock },
{ path: '#/app/legado', label: 'Legado', icon: Sparkles },
];
function AppShell({ route, children }) {
const app = useApp();
return (
app.navigate('#/')}>
{app.data ? app.data.streak.count : 0}
{NAV.map((item) => {
const active = route === item.path;
return (
app.navigate(item.path)} className="relative px-3 py-2 flex flex-col items-center gap-1">
{active && }
{item.label}
);
})}
{children}
);
}
/* ============================ /views/dashboard =========================== */
function CheckinModal({ open, onClose }) {
const app = useApp();
const OPTS = [{ v: 20, e: '😶', l: 'Apenas' }, { v: 40, e: '🙁', l: 'Un poco' }, { v: 60, e: '😐', l: 'Regular' }, { v: 80, e: '🙂', l: 'Bastante' }, { v: 100, e: '🤩', l: '¡A tope!' }];
const QS = [
{ k: 'love', q: '¿Cuánto afecto expresaste o recibiste hoy?' },
{ k: 'family', q: '¿Cuánto tiempo de calidad compartiste con los tuyos?' },
{ k: 'goals', q: '¿Avanzaste hoy en algo que te importa?' },
];
const [sel, setSel] = useState({ love: null, family: null, goals: null });
const ready = sel.love && sel.family && sel.goals;
return (
Tres preguntas, treinta segundos. Tus barras respiran contigo.
{QS.map((q) => (
{q.q}
{OPTS.map((o) => (
setSel((s) => Object.assign({}, s, { [q.k]: o.v }))}
className={'flex flex-col items-center gap-1 py-2 rounded-xl border text-[10px] font-bold transition-all ' + (sel[q.k] === o.v ? 'border-[#E74C3C] bg-red-50 text-[#E74C3C]' : 'border-gray-100 bg-gray-50 text-gray-500 hover:border-gray-200')}>
{o.e} {o.l}
))}
))}
{ app.checkin({ love: sel.love, family: sel.family, goals: sel.goals }); setSel({ love: null, family: null, goals: null }); onClose(); }}>
Registrar y actualizar mis barras
);
}
function DashboardView() {
const app = useApp();
const d = app.data;
const [checkin, setCheckin] = useState(false);
const [rec, setRec] = useState(null);
const [player, setPlayer] = useState(null);
const doneToday = d.checkin.last === todayStr();
const dayIdx = Math.floor(Date.now() / 86400000) % MEMORY_QUESTIONS.length;
const qod = MEMORY_QUESTIONS[dayIdx];
const lowest = METRICS.map((m) => ({ key: m.key, label: m.label, recipient: m.recipient, v: d.metrics[m.key] })).sort((a, b) => a.v - b.v)[0];
const fix = (key) => {
const meta = METRICS.find((m) => m.key === key);
if (key === 'goals') app.navigate('#/app/memorias');
else setRec({ mode: 'affect', recipient: meta.recipient });
};
const inboxPreview = d.messages.filter((m) => m.dir === 'in').slice(0, 2);
return (
Hola, {app.user ? app.user.full_name.split(' ')[0] : ''} 👋
Hoy es un buen día para conectar.
{doneToday ? (
Check-in de hoy completado
) : (
setCheckin(true)}>Check-in diario · racha {d.streak.count}
)}
Tus Niveles Emocionales
{METRICS.map((m) => (
fix(m.key)} />
))}
{lowest.v < 50 ? (
Tu barra de {lowest.label} está en {lowest.v} %
Pequeñas acciones crean grandes vínculos.{lowest.recipient ? ' Envía un video a ' + lowest.recipient + ' hoy.' : ' Graba tu avance de hoy en Memorias.'}
(lowest.recipient ? setRec({ mode: 'affect', recipient: lowest.recipient }) : app.navigate('#/app/memorias'))} icon={Camera} className="w-full md:w-auto bg-[#F39C12] hover:bg-orange-600">
{lowest.recipient ? 'Grabar video-mensaje' : 'Grabar mi avance'}
) : (
Estás en zona verde Mantén la racha: un mensaje a tiempo vale oro.
)}
MEMORIA DEL DÍA
{qod.q}
Los tuyos querrán escuchar esto algún día. Tómate 2 minutos.
setRec({ mode: 'memory', question: qod })}>
Grabar respuesta
Chat de Legado
ESENCIA DIGITAL
{d.personas.map((p, i) => (
{p.name}
))}
«{d.personas[0].greet}»
app.navigate('#/app/legado')} icon={ChevronRight}>Abrir conversación
Buzón de Afecto
app.navigate('#/app/buzon')} className="text-sm font-bold text-[#E74C3C] hover:underline flex items-center gap-1">Ver todo
{inboxPreview.length === 0 ? (
Aún no hay mensajes. Rompe el hielo: graba el primero.
) : (
{inboxPreview.map((m) => (
setPlayer(m)} className="flex items-center gap-4 p-3 rounded-2xl border border-gray-100 hover:border-[#E74C3C]/30 hover:shadow-md transition-all text-left">
{m.cover ?
:
}
De {m.from}
{timeAgo(m.at)} · {m.tag}
))}
)}
setCheckin(false)} />
{rec && setRec(null)} mode={rec.mode} question={rec.question} defaultRecipient={rec.recipient} />}
setPlayer(null)} />
);
}
/* ============================== /views/inbox ============================= */
function InboxView() {
const app = useApp();
const [tab, setTab] = useState('in');
const [rec, setRec] = useState(false);
const [player, setPlayer] = useState(null);
const list = app.data.messages.filter((m) => m.dir === tab);
return (
Buzón de Afecto
Comunicación asíncrona con intención: sin prisa, con corazón.
setRec(true)}>Grabar video-mensaje
{[{ k: 'in', l: 'Recibidos' }, { k: 'out', l: 'Enviados' }].map((t) => (
setTab(t.k)} className={'px-5 py-2.5 rounded-2xl text-sm font-bold transition-all ' + (tab === t.k ? 'bg-[#E74C3C] text-white shadow-lg shadow-red-500/20' : 'bg-white text-gray-500 border border-gray-100 hover:border-gray-200')}>
{t.l} · {app.data.messages.filter((m) => m.dir === t.k).length}
))}
{list.length === 0 ? (
{tab === 'in' ? 'Tu buzón espera su primer mensaje' : 'Aún no has enviado mensajes'}
{tab === 'in' ? 'Cuando alguien te envíe un video-mensaje, vivirá aquí para siempre.' : 'Dos minutos de tu voz valen más que mil textos. Atrévete.'}
setRec(true)}>Grabar el primero
) : (
{list.map((m) => (
setPlayer(m)} className="bg-white rounded-3xl overflow-hidden border border-gray-100 shadow-sm hover:shadow-xl transition-shadow text-left">
{m.thumb ?
: m.cover ?
: (
)}
{m.duration > 0 &&
{fmtDur(m.duration)} }
{m.dir === 'in' ? 'De ' + m.from : 'Para ' + m.recipient}
{timeAgo(m.at)}
{m.tag || 'Memoria'}
))}
)}
{rec && setRec(false)} mode="affect" />}
setPlayer(null)} />
);
}
/* ============================ /views/memories ============================ */
function MemoriesView() {
const app = useApp();
const cats = ['Niñez', 'Amor', 'Familia', 'Trabajo', 'Sabiduría'];
const [cat, setCat] = useState('Niñez');
const [rec, setRec] = useState(null);
const qs = MEMORY_QUESTIONS.filter((q) => q.cat === cat);
const total = app.data.memories.length;
const mins = Math.round(app.data.memories.reduce((a, m) => a + (m.duration || 0), 0) / 60);
return (
Memorias Habladas
Tu biografía en video, construida pregunta a pregunta.
{total} memorias
{mins} min
{cats.map((c) => (
setCat(c)} className={'px-5 py-2.5 rounded-2xl text-sm font-bold whitespace-nowrap transition-all ' + (cat === c ? 'bg-gray-900 text-white shadow-lg' : 'bg-white text-gray-500 border border-gray-100 hover:border-gray-200')}>{c}
))}
{qs.map((q, i) => (
setRec(q)} className="flex-shrink-0">Grabar respuesta
))}
Consejo de la casa: las memorias más valiosas no son las perfectas, son las sinceras. Equivocarse también es legado.
{rec && setRec(null)} mode="memory" question={rec} />}
);
}
/* ============================== /views/vault ============================= */
function VaultView() {
const app = useApp();
const list = app.data.memories;
const [confirmDel, setConfirmDel] = useState(null);
return (
Bóveda de Legado
Tu voz, custodiada para el momento exacto en que alguien la necesite.
Cifrado de extremo a extremo
{list.length === 0 ? (
Tu Bóveda está vacía… por ahora
Graba tu primera Memoria Hablada y quedará custodiada aquí, lista para entregarse hoy o dentro de décadas.
app.navigate('#/app/memorias')}>Ir a Memorias
) : (
{list.map((m) => (
{m.thumb ?
: m.cover ?
: (
)}
{m.cat || 'Memoria'}
{m.question ? m.question.q : 'Memoria de vida'}
{new Date(m.at).toLocaleDateString('es-ES', { day: 'numeric', month: 'long', year: 'numeric' })} · {fmtDur(m.duration || 0)}
app.updateMemory(m.id, { privacy: m.privacy === 'private' ? 'scheduled' : 'private', deliverAt: m.deliverAt || new Date(Date.now() + 315360000000).toISOString().slice(0, 10) })}
className={'flex-1 flex items-center justify-center gap-1.5 text-xs font-bold py-2.5 rounded-xl transition-all ' + (m.privacy === 'private' ? 'bg-gray-900 text-white' : 'bg-[#F39C12]/10 text-[#F39C12]')}>
{m.privacy === 'private' ? <> Privada> : <> Programada>}
{confirmDel === m.id ? (
{ app.deleteMemory(m.id); setConfirmDel(null); }} className="px-3 py-2.5 rounded-xl bg-[#E74C3C] text-white text-xs font-bold">¿Seguro?
) : (
{ setConfirmDel(m.id); setTimeout(() => setConfirmDel(null), 2500); }} className="px-3 py-2.5 rounded-xl bg-gray-100 text-gray-500 text-xs font-bold hover:bg-red-50 hover:text-[#E74C3C] transition-colors">
)}
{m.privacy === 'scheduled' && (
Entrega:
app.updateMemory(m.id, { deliverAt: e.target.value })}
className="flex-1 border border-gray-200 rounded-lg px-2 py-1 text-xs outline-none focus:border-[#F39C12]" />
)}
))}
)}
Entregas que abrazan a tiempo
Programa memorias para cumpleaños, bodas o momentos futuros. Nuestra IA ética las entregará con el contexto y el consuelo adecuados.
app.navigate('#/app/legado')}>Conocer el Chatbot de Legado
);
}
/* ============================== /views/legacy ============================ */
function LegacyView() {
const app = useApp();
const personas = app.data.personas;
const [pid, setPid] = useState(personas[0].id);
const persona = personas.find((p) => p.id === pid) || personas[0];
const history = app.data.chats[pid] || [];
const [input, setInput] = useState('');
const [waiting, setWaiting] = useState(false);
const [stream, setStream] = useState(null);
const [cfgOpen, setCfgOpen] = useState(false);
const [cfg, setCfg] = useState(() => {
try { return JSON.parse(localStorage.getItem('da_llm') || '{}'); } catch (e) { return {}; }
});
const endRef = useRef(null);
useEffect(() => {
if (endRef.current) endRef.current.scrollIntoView({ behavior: 'smooth' });
}, [history.length, stream, waiting]);
const send = async (text) => {
const clean = (text || '').trim();
if (!clean || waiting || stream) return;
setInput('');
app.pushChat(pid, { role: 'user', text: clean, at: Date.now() });
setWaiting(true);
const external = await callExternalLLM(cfg, persona, app.user ? app.user.full_name.split(' ')[0] : 'alguien', clean);
const full = external || localEngine(persona, clean);
setWaiting(false);
setStream('');
const words = full.split(' ');
let acc = '';
const iv = setInterval(() => {
const w = words.shift();
acc = acc ? acc + ' ' + w : w;
setStream(acc);
if (words.length === 0) {
clearInterval(iv);
setStream(null);
app.pushChat(pid, { role: 'ai', text: full, at: Date.now() });
}
}, 55);
};
const saveCfg = () => {
localStorage.setItem('da_llm', JSON.stringify(cfg));
setCfgOpen(false);
app.toast(cfg.key ? 'IA externa conectada · caerá al motor local si falla' : 'Configuración guardada · motor local activo');
};
return (
Chatbot de Legado
La esencia digital de los tuyos, disponible con amor y ética.
setCfgOpen(true)} className="flex items-center gap-2 text-xs font-bold text-gray-500 bg-white border border-gray-100 px-4 py-2.5 rounded-2xl hover:border-gray-200 transition-colors"> Conectar IA
{personas.map((p) => (
setPid(p.id)} className="flex flex-col items-center gap-1.5 min-w-[72px]">
{p.name}
))}
{persona.name}
Esencia Digital · IA ética conmemorativa
{persona.greet}
{history.map((m, i) => (
{m.role === 'ai' &&
}
{m.text}
))}
{waiting && (
{[0, 1, 2].map((i) => )}
)}
{stream && (
{stream}
)}
El Chatbot de Legado es una recreación conmemorativa basada únicamente en el contenido que su titular preservó. Nunca suplanta decisiones legales, médicas ni financieras.
setCfgOpen(false)}>
Conexión LLM (producción)
Preparado para OpenAI o Google Gemini con system-prompt de persona + RAG de transcripciones. Sin clave, se usa el motor local de esencia.
{ setCfg({}); localStorage.removeItem('da_llm'); setCfgOpen(false); }}>Usar motor local
Guardar conexión
);
}
/* ================================ /root ================================== */
function useHashRoute() {
const [hash, setHash] = useState(window.location.hash || '#/');
useEffect(() => {
const f = () => setHash(window.location.hash || '#/');
window.addEventListener('hashchange', f);
return () => window.removeEventListener('hashchange', f);
}, []);
return hash;
}
function Root() {
const route = useHashRoute();
const { user } = useApp();
useEffect(() => { window.scrollTo(0, 0); }, [route]);
let view;
if (route.indexOf('#/auth') === 0) view =
;
else if (route.indexOf('#/app') === 0) {
view = user ? (
{route === '#/app/buzon' ? : route === '#/app/memorias' ? : route === '#/app/boveda' ? : route === '#/app/legado' ? : }
) :
;
} else view =
;
return (
<>
{view}
>
);
}
export default function DiloAhoraApp() {
return (
);
}
import { createRoot } from 'react-dom/client';
const raiz = document.getElementById('root');
if (raiz) {
createRoot(raiz).render(
);
}