// ─── CONFIGURACIÓN ────────────────────────────────────────────────────── // Pega aquí la URL de tu webhook de n8n: const N8N_WEBHOOK_URL = "https://n8n.creadsfactory.com/webhook/sinaptica-app"; // Ejemplo: "https://tu-n8n.app.n8n.cloud/webhook/sinaptica-audio" // ────────────────────────────────────────────────────────────────────────
const { useState, useRef } = React;
const C = { bg:"#FEF8F3", card:"#FFFFFF", primary:"#C4644A", primaryL:"#FAEEE9", accent:"#6B9E81", accentL:"#EAF4EF", warn:"#C99A2E", warnL:"#FEF6DC", danger:"#B94040", dangerL:"#FAECEC", text:"#1C1C1C", muted:"#888", border:"#EDE3DC", borderL:"#F5EDE8", };
const toBase64 = (blob) => new Promise((res, rej) => { const r = new FileReader(); r.onloadend = () => res(r.result.split(",")[1]); r.onerror = rej; r.readAsDataURL(blob); });
const getBestMimeType = () => { const types = ["audio/mp4","audio/aac","audio/webm;codecs=opus","audio/webm","audio/ogg;codecs=opus","audio/ogg"]; for (const t of types) { if (MediaRecorder.isTypeSupported(t)) return t; } return ""; };
const ageMonths = (y, m) => parseInt(y||0)*12 + parseInt(m||0);
const getTip = (months) => { if (months < 24) return "Señala objetos conocidos y observa si los nombra."; if (months < 36) return "Muéstrale un libro y pídele que cuente qué ve."; if (months < 60) return "Pídele que te cuente qué hizo hoy o su caricatura favorita."; return "Hazle preguntas abiertas: '¿Cómo estuvo tu día?' Deja que hable."; }; const SemaforoVisual = ({ active }) => { const lights = [ { key:"rojo", on:"#E05050", off:"#2E1010", glow:"#FF7070" }, { key:"amarillo", on:"#D4A020", off:"#2E2508", glow:"#FFD566" }, { key:"verde", on:"#3DAA6A", off:"#0A2515", glow:"#5FE09A" }, ]; return (
); })}
); };
const BarraNivel = ({ actualPct, semaforo, nombre }) => { const clamped = Math.min(Math.max(actualPct||50, 0), 130); const barWidth = Math.min(clamped, 100); const barColor = semaforo==="verde" ? "#3DAA6A" : semaforo==="amarillo" ? "#C99A2E" : "#B94040"; const bgColor = semaforo==="verde" ? C.accentL : semaforo==="amarillo" ? C.warnL : C.dangerL; const label = clamped>=110 ? "¡Por encima de lo esperado!" : clamped>=90 ? "Muy cerca del nivel esperado" : clamped>=70 ? "En proceso de alcanzar el nivel esperado" : "Por debajo del nivel esperado para su edad"; return (
); };
const semColors = { verde: { border:"#3DAA6A" }, amarillo: { border:C.warn }, rojo: { border:C.danger }, };
function App() { const [step, setStep] = useState(0); const [childName, setChildName] = useState(""); const [ageYears, setAgeYears] = useState(""); const [ageMonthsVal, setAgeMonthsVal] = useState("0"); const [concern, setConcern] = useState(""); const [recording, setRecording] = useState(false); const [recTime, setRecTime] = useState(0); const [audioBlob, setAudioBlob] = useState(null); const [audioMime, setAudioMime] = useState(""); const [fallback, setFallback] = useState(false); const [fallbackText, setFallbackText] = useState(""); const [results, setResults] = useState(null); const [apiErr, setApiErr] = useState(null);
const mrRef = useRef(null); const chunksRef = useRef([]); const timerRef = useRef(null);
const totalMonths = ageMonths(ageYears, ageMonthsVal); const hasAge = ageYears !== "" && totalMonths >= 6; const canAnalyze = (audioBlob && !fallback) || (fallback && fallbackText.trim().length > 15);
const startRec = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mimeType = getBestMimeType(); chunksRef.current = []; const options = mimeType ? { mimeType } : {}; const mr = new MediaRecorder(stream, options); const actualMime = mr.mimeType || mimeType || "audio/mp4"; setAudioMime(actualMime); mr.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); }; mr.onstop = () => { setAudioBlob(new Blob(chunksRef.current, { type: actualMime })); stream.getTracks().forEach(t => t.stop()); }; mrRef.current = mr; mr.start(100); setRecording(true); setRecTime(0); setAudioBlob(null); timerRef.current = setInterval(() => { setRecTime(t => { if (t >= 60) { stopRec(); return 60; } return t+1; }); }, 1000); } catch { setFallback(true); } };
const stopRec = () => { if (mrRef.current?.state !== "inactive") mrRef.current?.stop(); clearInterval(timerRef.current); setRecording(false); };
const analyze = async () => { setStep(3); setApiErr(null); const yrs = Math.floor(totalMonths/12); const mos = totalMonths % 12; try { let body;
if (audioBlob && !fallback) { // ── Envía audio a n8n ── const b64 = await toBase64(audioBlob); body = JSON.stringify({ audio_base64: b64, mime_type: audioMime, age_months: totalMonths, child_name: childName || "", concern: concern || "", }); } else { // ── Envía texto a n8n (fallback) ── body = JSON.stringify({ audio_base64: null, transcription: fallbackText, age_months: totalMonths, child_name: childName || "", concern: concern || "", }); }
const res = await fetch(N8N_WEBHOOK_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body, });
const data = await res.json();
if (!data.success) throw new Error(data.error || "Error en el servidor");
setResults(data.data); setStep(4); } catch(e) { console.error(e); setApiErr("Hubo un problema al analizar. Verifica tu conexión e intenta de nuevo."); setStep(2); } };
const reset = () => { setStep(0); setChildName(""); setAgeYears(""); setAgeMonthsVal("0"); setConcern(""); setRecording(false); setRecTime(0); setAudioBlob(null); setAudioMime(""); setFallback(false); setFallbackText(""); setResults(null); setApiErr(null); };
const btn = (variant="primary", extra={}) => ({ background: variant==="primary" ? C.primary : "transparent", color: variant==="primary" ? "#fff" : C.muted, border: variant==="ghost" ? `1.5px solid ${C.border}` : "none", borderRadius:12, padding:"0.75rem 1.5rem", fontSize:15, fontFamily:"'Nunito',sans-serif", fontWeight:700, cursor:"pointer", display:"inline-flex", alignItems:"center", gap:8, ...extra }); const inp = (extra={}) => ({ width:"100%", border:`1.5px solid ${C.border}`, borderRadius:10, padding:"0.65rem 0.9rem", fontSize:15, fontFamily:"'Nunito',sans-serif", color:C.text, background:"#fff", boxSizing:"border-box", outline:"none", ...extra }); const lbl = { display:"block", fontSize:13, fontWeight:700, color:C.muted, marginBottom:6, letterSpacing:0.3, textTransform:"uppercase" }; const tag = { background:C.primaryL, color:C.primary, borderRadius:20, padding:"3px 12px", fontSize:12, fontWeight:700 };
const Dots = ({ current }) => (
);
const wrap = ch =>
; const card = (ch, ex={}) =>
;
if (step === 0) return wrap(card(<>
¿Cómo está el lenguaje
de tu hij@?
En pocos minutos recibe una orientación clara sobre el desarrollo del lenguaje de tu hijo/a.
))}
Esta valoración NO reemplaza el diagnóstico clínico. Es orientación inicial.
>));
if (step === 1) return wrap(card(<>
Datos del menor
La edad es el dato más importante para la valoración.
{hasAge &&
}
>));
if (step === 2) return (
{card(<>
{fallback ? "Describe el lenguaje" : "Graba al menor"}
{fallback ? "Describe cómo habla tu hijo/a con ejemplos concretos." : "Graba entre 30 segundos y 1 minuto de habla natural."}
{!fallback ? (<>
: audioBlob ?
:
}
{apiErr &&
} >) : (<>
Más detalle = mejor análisis.
>)}
>)}
);
if (step === 3) return (
Analizando muestra...
Comparando con hitos esperados para la edad de {childName || "tu hij@"}.
{["Enviando audio...","Transcribiendo con Whisper...","Analizando con IA...","Generando recomendaciones..."].map((msg,i) => (
))}
);
if (step === 4 && results) { const sem = semColors[results.semaforo] || semColors.amarillo; const pct = typeof results.nivel_actual_pct === "number" ? results.nivel_actual_pct : 70; const semLevels = [ { key:"rojo", color:"#B94040", bg:C.dangerL, label:"Requiere apoyo pronto" }, { key:"amarillo", color:"#C99A2E", bg:C.warnL, label:"Monitorear y dar seguimiento" }, { key:"verde", color:"#3DAA6A", bg:C.accentL, label:"Desarrollo acorde a su edad" }, ]; return (
Reporte de desarrollo del lenguaje
); })}
{results.semaforo_descripcion}
{text}
))}
{results.fortalezas?.map((f,i) =>
)}
{results.areas_atencion?.length ? results.areas_atencion.map((a,i) =>
) :
}
{results.recomendacion}
⚠️ Nota importante: {results.nota_limitacion}
); } return null; }
ReactDOM.createRoot(document.getElementById("root")).render(