// Quiz screen + Glossary + Mural + Trophies + Videos
const { useState: useStateQ, useEffect: useEffectQ, useMemo, useRef: useRefQ } = React;
// Reusable nav header for secondary screens — back to map + jump to any module
function NavHeader({ title, icon, onClose, current, onNav }) {
const tabs = [
{ id: 'map', icon: '🗺️', label: 'Mapa' },
{ id: 'videos', icon: '🎬', label: 'Vídeos' },
{ id: 'glossary', icon: '📖', label: 'Dicionário' },
{ id: 'trophies', icon: '🏆', label: 'Troféus' },
{ id: 'mural', icon: '👥', label: 'Mural' },
];
return (
{icon} {title}
{tabs.map(t => (
))}
);
}
function arraysEqual(a, b) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
const A = [...a].sort((x, y) => x - y);
const B = [...b].sort((x, y) => x - y);
return A.every((v, i) => v === B[i]);
}
function isCorrect(q, answer) {
if (q.type === 'mc') return answer === q.correct;
if (q.type === 'vf') {
if (!Array.isArray(answer) || answer.length !== q.correct.length) return false;
return q.correct.every((v, i) => v === answer[i]);
}
if (q.type === 'multi') return arraysEqual(q.correct, Array.isArray(answer) ? answer : []);
return false;
}
function Confetti({ count = 60, big = false }) {
const pieces = Array.from({ length: count });
const colors = ['#fbbf24', '#22d3ee', '#fb7185', '#a78bfa', '#4ade80', '#f97316', '#f472b6'];
const shapes = ['square', 'circle', 'star'];
return (
{pieces.map((_, i) => {
const shape = shapes[i % shapes.length];
const color = colors[i % colors.length];
return (
{shape === 'star' ? '★' : ''}
);
})}
);
}
function CelebrationBurst({ kind = 'good' }) {
// Random celebratory message + emoji combo
const goodMessages = [
{ msg: 'INCRÍVEL!', emoji: '🎉' },
{ msg: 'MANDOU BEM!', emoji: '⭐' },
{ msg: 'FANTÁSTICO!', emoji: '🚀' },
{ msg: 'PERFEITO!', emoji: '✨' },
{ msg: 'ARRASOU!', emoji: '🌟' },
{ msg: 'GENIAL!', emoji: '💫' },
{ msg: 'BOOM!', emoji: '💥' },
{ msg: 'UAU!', emoji: '🎊' }
];
const pick = useMemo(() => goodMessages[Math.floor(Math.random() * goodMessages.length)], []);
if (kind !== 'good') return null;
return (
);
}
// Fisher-Yates shuffle (returns new array)
function shuffled(arr) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// ----- Quiz Screen -----
function QuizScreen({ user, mission, progress, onExit, onComplete, onOpenGlossary }) {
const [queue, setQueue] = useStateQ(() => shuffled(mission.questions.map((_, i) => i)));
const [pos, setPos] = useStateQ(0);
const [confirmExit, setConfirmExit] = useStateQ(false);
const [answer, setAnswer] = useStateQ(null);
const [submitted, setSubmitted] = useStateQ(false);
const [correct, setCorrect] = useStateQ(false);
const [results, setResults] = useStateQ({});
const [showSummary, setShowSummary] = useStateQ(false);
const [shake, setShake] = useStateQ(false);
const [streak, setStreak] = useStateQ(0);
const [showCelebration, setShowCelebration] = useStateQ(false);
const [resumed, setResumed] = useStateQ(false);
const total = mission.questions.length;
// ----- Resume on mount -----
useEffectQ(() => {
const mp = progress.missions[mission.id];
const r = mp?.resume;
if (r && !mp?.completed && r.results && r.queue && r.pos != null) {
setQueue(r.queue);
setPos(r.pos);
setResults(r.results);
setStreak(r.streak || 0);
setResumed(true);
}
// eslint-disable-next-line
}, []);
const currentIdx = queue[pos];
const q = currentIdx != null ? mission.questions[currentIdx] : null;
// Reset answer per question
useEffectQ(() => {
if (!q) return;
if (q.type === 'mc') setAnswer(null);
else if (q.type === 'vf') setAnswer(q.statements.map(() => null));
else if (q.type === 'multi') setAnswer([]);
setSubmitted(false);
setCorrect(false);
}, [pos, queue]);
// ----- Persist resume state -----
useEffectQ(() => {
if (showSummary) return;
const mp = { ...(progress.missions[mission.id] || {}) };
mp.resume = {
queue,
pos,
results,
streak,
answeredCount: Object.keys(results).length
};
const newProg = { ...progress, missions: { ...progress.missions, [mission.id]: mp } };
window.AppStorage.saveProgress(user.id, newProg);
// eslint-disable-next-line
}, [pos, results, streak]);
function canSubmit() {
if (!q) return false;
if (q.type === 'mc') return answer !== null;
if (q.type === 'vf') return Array.isArray(answer) && answer.every(v => v !== null);
if (q.type === 'multi') return Array.isArray(answer) && answer.length === q.pickCount;
return false;
}
function handleSubmit() {
if (!canSubmit() || submitted) return;
const ok = isCorrect(q, answer);
setSubmitted(true);
setCorrect(ok);
setResults(prev => {
const old = prev[currentIdx] || { correct: false, attempts: 0 };
return {
...prev,
[currentIdx]: { correct: ok || old.correct, attempts: old.attempts + 1 }
};
});
if (ok) {
setStreak(s => s + 1);
setShowCelebration(true);
setTimeout(() => setShowCelebration(false), 1400);
} else {
setStreak(0);
setShake(true);
setTimeout(() => setShake(false), 500);
}
}
function handleNext() {
if (pos + 1 < queue.length) {
setPos(pos + 1);
} else {
// Clear resume state when reaching summary
const mp = { ...(progress.missions[mission.id] || {}) };
mp.resume = null;
const newProg = { ...progress, missions: { ...progress.missions, [mission.id]: mp } };
window.AppStorage.saveProgress(user.id, newProg);
setShowSummary(true);
// Scroll to top so users see the hero
window.scrollTo({ top: 0, behavior: 'instant' });
}
}
function retryWrong() {
const wrong = mission.questions.map((_, i) => i).filter(i => !results[i]?.correct);
if (wrong.length === 0) return;
setQueue(shuffled(wrong));
setPos(0);
setShowSummary(false);
}
// Compute stars: 1 ponto base por acerto, -0.34 por tentativa extra. >=80% pct sem retries = 3⭐, etc.
function computeStars() {
const correctCount = mission.questions.filter((_, i) => results[i]?.correct).length;
const totalAttempts = mission.questions.reduce((s, _, i) => s + (results[i]?.attempts || 0), 0);
const extraAttempts = Math.max(0, totalAttempts - total); // erros + retries
const pct = correctCount / total;
if (pct < 0.6) return 0;
// Penaliza: cada erro/retry tira pontos. 0 erros = 3, 1–2 erros = 2, 3+ erros = 1
if (extraAttempts === 0 && pct >= 1) return 3;
if (extraAttempts <= 2 && pct >= 0.8) return 2;
return 1;
}
function finishMission() {
const correctCount = mission.questions.filter((_, i) => results[i]?.correct).length;
const totalAttempts = mission.questions.reduce((s, _, i) => s + (results[i]?.attempts || 0), 0);
const stars = computeStars();
onComplete({ stars, correctCount, total, results, totalAttempts });
}
// ----- Summary -----
if (showSummary) {
const correctCount = mission.questions.filter((_, i) => results[i]?.correct).length;
const totalAttempts = mission.questions.reduce((s, _, i) => s + (results[i]?.attempts || 0), 0);
const extraAttempts = Math.max(0, totalAttempts - total);
const pct = correctCount / total;
const stars = computeStars();
const wrongIdxs = mission.questions.map((_, i) => i).filter(i => !results[i]?.correct);
// Compute simulated overall progress (this mission counts at the new stars level)
const simulatedMissions = window.MISSIONS.map(mm => {
if (mm.id === mission.id) {
const prevStars = progress.missions[mm.id]?.stars || 0;
return { ...mm, stars: Math.max(prevStars, stars), completed: true };
}
const mp = progress.missions[mm.id];
return { ...mm, stars: mp?.stars || 0, completed: !!mp?.completed };
});
const totalStarsAcc = simulatedMissions.reduce((s, m) => s + m.stars, 0);
const maxStarsAcc = window.MISSIONS.length * 3;
const completedCount = simulatedMissions.filter(m => m.completed).length;
const earnedBadges = new Set(progress.badges || []);
if (stars > 0) earnedBadges.add(`mission_${mission.id}`);
if (stars === 3) earnedBadges.add('mestre_cosmico');
const allThree = simulatedMissions.every(m => m.stars === 3);
if (allThree) earnedBadges.add('lenda_cosmica');
const isNewBest = stars > (progress.missions[mission.id]?.stars || 0);
const nextMissionIdx = window.MISSIONS.findIndex(m => m.id === mission.id) + 1;
const nextMission = nextMissionIdx < window.MISSIONS.length ? window.MISSIONS[nextMissionIdx] : null;
const justUnlockedNext = stars > 0 && nextMission && !(progress.missions[nextMission.id]?.stars > 0);
const headlineMsg = stars === 3 ? { text: 'MISSÃO PERFEITA!', sub: 'Você é uma LENDA do espaço! 🌟', cls: 'great', emoji: '🏆' }
: stars === 2 ? { text: 'EXCELENTE, CADETE!', sub: 'Quase perfeito! Continue assim! 🎉', cls: 'good', emoji: '🎉' }
: stars === 1 ? { text: 'MISSÃO CONCLUÍDA!', sub: 'Bom trabalho! Tente de novo pra brilhar mais! 💪', cls: 'ok', emoji: '🚀' }
: { text: 'NÃO DESISTA!', sub: 'Cada erro é um passo pro acerto! Bora de novo! 💪', cls: 'low', emoji: '🌠' };
return (
{stars > 0 &&
}
{stars === 3 &&
}
{/* HERO */}
{headlineMsg.text}
{headlineMsg.sub}
{mission.icon}
{mission.title}
{[0, 1, 2].map(i => (
★
))}
{isNewBest && stars > 0 && (
✨ NOVO RECORDE! ✨
)}
{extraAttempts > 0 && stars < 3 && (
💡 {extraAttempts === 1 ? '1 erro' : `${extraAttempts} erros`} no caminho · Acerte tudo de primeira para 3⭐!
)}
{/* STATS GRID */}
🎯
{correctCount}/{total}
Acertos
⭐
{totalStarsAcc}/{maxStarsAcc}
Estrelas Totais
🌍
{completedCount}/{window.MISSIONS.length}
Missões
🏅
{earnedBadges.size}
Medalhas
{/* UNLOCK CARD */}
{justUnlockedNext && (
✨🔓✨
Você destravou:
{nextMission.icon}
{nextMission.title}
)}
{/* MISSIONS PATH */}
🗺️ Sua Jornada
{simulatedMissions.map((mm, i) => (
0 ? 'done' : ''}`}>
{mm.stars > 0 ? mm.icon : '🔒'}
{[0,1,2].map(s => ★)}
))}
{/* ACTIONS */}
{wrongIdxs.length > 0 && (
)}
);
}
if (!q) return null;
const progressPct = ((pos + (submitted ? 1 : 0)) / queue.length) * 100;
return (
{confirmExit && (
setConfirmExit(false)}>
e.stopPropagation()}>
🚀
Sair da missão?
Não se preocupe! Seu progresso fica salvo e você pode continuar de onde parou quando voltar.
Progresso desta missão:
{Object.keys(results).length} de {total} respondidas
)}
{resumed && (
▶ Continuando de onde você parou!
)}
{streak >= 2 && (
🔥 Sequência de {streak}!
)}
Pergunta {pos + 1}
{q.prompt}
{q.type === 'mc' && (
{q.options.map((opt, i) => {
let cls = 'option';
if (submitted) {
if (correct && i === q.correct) cls += ' correct';
else if (i === answer && !correct) cls += ' wrong';
} else if (answer === i) cls += ' selected';
return (
);
})}
)}
{q.type === 'vf' && (
{q.statements.map((s, i) => {
const cur = Array.isArray(answer) ? answer[i] : null;
const showResult = submitted;
const isRight = q.correct[i];
return (
{s}
);
})}
)}
{q.type === 'multi' && (
Marque {q.pickCount} opções • Selecionou: {(Array.isArray(answer) ? answer : []).length}/{q.pickCount}
{q.options.map((opt, i) => {
const sel = (Array.isArray(answer) ? answer : []).includes(i);
const isAnsCorrect = q.correct.includes(i);
let cls = 'option';
if (submitted) {
if (correct && isAnsCorrect) cls += ' correct';
else if (sel && !correct) cls += ' wrong';
} else if (sel) cls += ' selected';
return (
);
})}
)}
{submitted && (
{correct ? '🎉 Mandou bem, Cadete!' : '🤔 Hmm, não foi dessa vez!'}
{correct ? (
{q.explain}
) : (
<>
📘 Vamos aprender: {q.explain}
💡 Dica: revise os vídeos da Profª. e o Dicionário Espacial 📖 para fixar o conteúdo. Depois, use "🔄 Treinar erradas" no fim da missão!
>
)}
)}
{!submitted ? (
) : (
)}
{showCelebration && (
<>
>
)}
);
}
// ----- Glossary -----
function GlossaryScreen({ onClose, onView, onNav }) {
const [search, setSearch] = useStateQ('');
const filtered = useMemo(() => {
const s = search.toLowerCase().trim();
if (!s) return window.GLOSSARY;
return window.GLOSSARY.filter(g =>
g.term.toLowerCase().includes(s) || g.def.toLowerCase().includes(s)
);
}, [search]);
useEffectQ(() => { if (onView) onView(); }, []);
return (
setSearch(e.target.value)}
/>
{filtered.length === 0 &&
Nenhuma palavra encontrada.
}
{filtered.map(g => (
{g.icon || '✨'} {g.term}
{g.def}
))}
);
}
// ----- Mural -----
function MuralScreen({ user, onClose, onNav }) {
const [entries, setEntries] = useStateQ([]);
const [loading, setLoading] = useStateQ(true);
useEffectQ(() => {
window.AppStorage.getMural().then(e => {
setEntries(e || []);
setLoading(false);
});
}, []);
function timeAgo(ts) {
const diff = Date.now() - ts;
const m = Math.floor(diff / 60000);
if (m < 1) return 'agora';
if (m < 60) return `${m} min atrás`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} h atrás`;
return `${Math.floor(h / 24)} d atrás`;
}
return (
Conquistas dos colegas que escolheram compartilhar!
{loading &&
Carregando...
}
{!loading && entries.length === 0 && (
📭
Nenhuma publicação ainda.
Termine uma missão e seja o(a) primeiro(a) a publicar!
)}
{entries.map((e, i) => {
const av = window.Avatars.get(e.avatar);
const Av = av.component;
return (
{e.nick}
{timeAgo(e.ts)}
Concluiu a missão {e.missionIcon} {e.missionTitle}
{e.correctCount}/{e.total} acertos
);
})}
);
}
// ----- Trophies -----
function TrophiesScreen({ user, progress, onClose, onNav }) {
const allBadges = window.BADGES;
const earned = new Set(progress.badges || []);
const totalStars = window.MISSIONS.reduce((sum, m) => sum + (Number.isFinite(progress.missions[m.id]?.stars) ? progress.missions[m.id].stars : 0), 0);
const rank = window.Avatars.getRank(totalStars);
const maxStars = window.MISSIONS.length * 3;
const rankProgress = rank.next ? Math.round(((totalStars - rank.min) / (rank.next.min - rank.min)) * 100) : 100;
return (
{user.nick}
{user.name}
{rank.emoji}
Patente: {rank.title}
{rank.next && (
Próxima patente: {rank.next.emoji} {rank.next.title} — faltam {rank.next.min - totalStars}⭐
)}
⭐ {totalStars}/{maxStars}estrelas
🏅 {earned.size}medalhas
Medalhas
{allBadges.map(b => {
const got = earned.has(b.id);
return (
{got ? b.icon : '🔒'}
{b.name}
{got ? b.desc : b.hint}
);
})}
Missões
{window.MISSIONS.map(m => {
const mp = progress.missions[m.id];
const stars = mp?.stars || 0;
return (
{m.icon}
{m.title}
);
})}
);
}
// ----- Videos -----
function VideoPlayer({ video }) {
const [src, setSrc] = useStateQ(null);
useEffectQ(() => {
let revoke = null;
if (video.kind === 'file') {
// Modo API: usa fileUrl direto. Modo local: busca blob no IndexedDB.
if (video.fileUrl) {
setSrc(video.fileUrl);
} else if (video.blobId) {
window.AppStorage.getVideoBlob(video.blobId).then(blob => {
if (blob) {
const url = URL.createObjectURL(blob);
revoke = url;
setSrc(url);
}
});
}
}
return () => { if (revoke) URL.revokeObjectURL(revoke); };
}, [video.id]);
const ytId = video.youtubeId || video.youtube;
if (video.kind === 'youtube' && ytId) {
return (
);
}
if (video.kind === 'file') {
return (
{src ? (
) : (
Carregando vídeo...
)}
);
}
return null;
}
function VideosScreen({ onClose, onNav }) {
const [videos, setVideos] = useStateQ([]);
const [loading, setLoading] = useStateQ(true);
const [playing, setPlaying] = useStateQ(null);
useEffectQ(() => {
window.AppStorage.getVideos().then(v => {
setVideos(v || []);
setLoading(false);
});
}, []);
function fmtSize(bytes) {
if (!bytes) return '';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
return (
Assista os vídeos da Profª. para reforçar o aprendizado!
{loading &&
Carregando...
}
{!loading && videos.length === 0 && (
🎥
Nenhum vídeo ainda!
A Profª. ainda não adicionou os vídeos. Volte depois!
)}
{videos.map(v => (
{playing === v.id ? (
) : (
setPlaying(v.id)}>
{(v.kind === 'youtube' && (v.youtubeId || v.youtube)) ? (

) : (
{v.kind === 'file' ? '📁🎬' : '🎬'}
)}
▶
{v.kind === 'file' &&
📁 Arquivo
}
{v.kind === 'youtube' &&
▶ YouTube
}
)}
{v.title}
{(v.description || v.desc) &&
{v.description || v.desc}
}
{v.kind === 'file' && (
📁 {v.fileName} • {fmtSize(v.fileSize)}
)}
{playing !== v.id && (
)}
{playing === v.id && (
)}
{v.kind === 'youtube' && (
🔗 Abrir no YouTube
)}
))}
);
}
// ----- Share dialog -----
function ShareDialog({ user, mission, result, onShare, onSkip }) {
return (
{mission.icon}
Missão concluída!
Você quer publicar essa conquista no Mural da Turma?
{[0, 1, 2].map(i => (
★
))}
);
}
window.QuizScreens = { QuizScreen, GlossaryScreen, MuralScreen, TrophiesScreen, VideosScreen, ShareDialog, isCorrect };