/* ============================================================================ 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.

); } 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 ( ); }; 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 && ( )}
); }; 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'}

{mode === 'affect' && phase !== 'postal' && (
{recipients.map((r) => ( ))}
)} {(phase === 'starting' || phase === 'live') && ( <>