feat: Rediseño completo de UI, SCORM y ajustes de experiencia
This commit is contained in:
+1
-184
@@ -1,184 +1 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
/* App.css — Los estilos del proyecto se manejan con Tailwind CSS (ver index.css) */
|
||||
|
||||
+74
-14
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import MainLayout from './layouts/MainLayout'
|
||||
import ScaleWrapper from './components/layout/ScaleWrapper'
|
||||
import Intro from './components/screens/Intro'
|
||||
import Cover from './components/screens/Cover'
|
||||
import Game from './components/screens/Game'
|
||||
import Result from './components/screens/Result'
|
||||
@@ -7,7 +8,7 @@ import { consumirIntento, intentosAgotados } from './hooks/useIntentos'
|
||||
import { setScore, setLessonStatus, finishSCORM } from './scorm/scorm'
|
||||
import { useSCORM } from './hooks/useSCORM'
|
||||
|
||||
type Screen = 'cover' | 'game' | 'result'
|
||||
type Screen = 'intro' | 'cover' | 'game' | 'result'
|
||||
|
||||
interface ResultState {
|
||||
aprobado: boolean
|
||||
@@ -17,20 +18,24 @@ interface ResultState {
|
||||
export default function App() {
|
||||
useSCORM()
|
||||
const [screen, setScreen] = useState<Screen>(() =>
|
||||
intentosAgotados() ? 'result' : 'cover'
|
||||
intentosAgotados() ? 'result' : 'intro'
|
||||
)
|
||||
const [resultState, setResultState] = useState<ResultState>({
|
||||
aprobado: false,
|
||||
sinIntentos: intentosAgotados(),
|
||||
})
|
||||
|
||||
function handleIntroContinue() {
|
||||
setScreen('cover')
|
||||
}
|
||||
|
||||
function handleStart() {
|
||||
consumirIntento()
|
||||
setScreen('game')
|
||||
}
|
||||
|
||||
function handleGameEnd(aprobado: boolean, correctas: number) {
|
||||
setScore(correctas * 10, 100, 0)
|
||||
setScore(Math.round((correctas / 12) * 100), 100, 0)
|
||||
setLessonStatus(aprobado ? 'passed' : 'failed')
|
||||
finishSCORM()
|
||||
setResultState({ aprobado, sinIntentos: intentosAgotados() })
|
||||
@@ -42,16 +47,71 @@ export default function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
{screen === 'cover' && <Cover onStart={handleStart} />}
|
||||
{screen === 'game' && <Game onEnd={handleGameEnd} />}
|
||||
{screen === 'result' && (
|
||||
<Result
|
||||
aprobado={resultState.aprobado}
|
||||
sinIntentos={resultState.sinIntentos}
|
||||
onReintentar={handleReintentar}
|
||||
/>
|
||||
<>
|
||||
{screen === 'intro' && (
|
||||
<ScaleWrapper
|
||||
backgroundImage="./img/fondo1.jpg"
|
||||
barProps={{
|
||||
preguntaActual: 1,
|
||||
totalPreguntas: 4,
|
||||
mensaje: 'Da clic en la flecha para continuar',
|
||||
puedeAnterior: false,
|
||||
puedeSiguiente: true,
|
||||
onNext: handleIntroContinue,
|
||||
}}
|
||||
>
|
||||
<Intro />
|
||||
</ScaleWrapper>
|
||||
)}
|
||||
</MainLayout>
|
||||
{screen === 'cover' && (
|
||||
<ScaleWrapper
|
||||
backgroundImage="./img/fondo2.jpg"
|
||||
barProps={{
|
||||
preguntaActual: 2,
|
||||
totalPreguntas: 4,
|
||||
mensaje: 'Presiona Comenzar para iniciar',
|
||||
puedeAnterior: true,
|
||||
onPrev: () => setScreen('intro'),
|
||||
puedeSiguiente: true,
|
||||
onNext: handleStart,
|
||||
}}
|
||||
>
|
||||
<Cover onStart={handleStart} />
|
||||
</ScaleWrapper>
|
||||
)}
|
||||
{screen === 'game' && (
|
||||
<ScaleWrapper
|
||||
backgroundImage="./img/fondo3.jpg"
|
||||
barProps={{
|
||||
preguntaActual: 3,
|
||||
totalPreguntas: 4,
|
||||
mensaje: 'Gira la ruleta y responde correctamente',
|
||||
puedeAnterior: false,
|
||||
puedeSiguiente: false,
|
||||
}}
|
||||
>
|
||||
<Game onEnd={handleGameEnd} />
|
||||
</ScaleWrapper>
|
||||
)}
|
||||
{screen === 'result' && (
|
||||
<ScaleWrapper
|
||||
backgroundImage="./img/fondo4.jpg"
|
||||
barProps={{
|
||||
preguntaActual: 4,
|
||||
totalPreguntas: 4,
|
||||
mensaje: 'Puedes cerrar esta ventana',
|
||||
puedeAnterior: true,
|
||||
onPrev: () => setScreen('cover'),
|
||||
puedeSiguiente: false,
|
||||
}}
|
||||
>
|
||||
<Result
|
||||
aprobado={resultState.aprobado}
|
||||
sinIntentos={resultState.sinIntentos}
|
||||
onReintentar={handleReintentar}
|
||||
/>
|
||||
</ScaleWrapper>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { Pregunta, Categoria } from '../../types'
|
||||
|
||||
interface FeedbackModalProps {
|
||||
pregunta: Pregunta
|
||||
categoria: Categoria
|
||||
feedbackCorrecto: boolean
|
||||
tiempoAgotado: boolean
|
||||
onCerrar: () => void
|
||||
}
|
||||
|
||||
export default function FeedbackModal({
|
||||
pregunta,
|
||||
categoria: _categoria,
|
||||
feedbackCorrecto,
|
||||
tiempoAgotado,
|
||||
onCerrar,
|
||||
}: FeedbackModalProps) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setVisible(true), 10)
|
||||
return () => clearTimeout(t)
|
||||
}, [])
|
||||
|
||||
const esCorrecto = feedbackCorrecto
|
||||
const label = esCorrecto ? 'Correcto' : tiempoAgotado ? 'Tiempo agotado' : 'Incorrecto'
|
||||
const accentColor = esCorrecto ? '#39ad4a' : '#ed1f24'
|
||||
const imgSrc = esCorrecto ? './img/correcto.png' : './img/incorrecto.png'
|
||||
const imagenALaIzquierda = !esCorrecto
|
||||
|
||||
const reflexion = pregunta.reflexion ?? pregunta.retroalimentacion
|
||||
const datoCurioso = pregunta.datoCurioso
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 transition-opacity duration-300"
|
||||
style={{ opacity: visible ? 1 : 0 }}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* ─── Barra superior flotante (25px sobre el modal) ─── */}
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 z-10 flex items-center gap-3"
|
||||
style={{ top: '-90px' }}
|
||||
>
|
||||
{imagenALaIzquierda && (
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={label}
|
||||
style={{ width: '180px', height: '180px', marginRight: '-80px',zIndex: '5' }}
|
||||
className="object-contain"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="font-extrabold text-white px-8 py-3 rounded-full tracking-wide"
|
||||
style={{
|
||||
backgroundColor: accentColor,
|
||||
fontSize: '40px',
|
||||
marginRight: '50px',
|
||||
paddingRight: '60px',
|
||||
paddingLeft: '60px'
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{!imagenALaIzquierda && (
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={label}
|
||||
style={{ width: '120px', height: '120px', marginLeft:'-100px' }}
|
||||
className="object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Tarjeta del modal ─── */}
|
||||
<div
|
||||
className="flex flex-col justify-center transition-all duration-300"
|
||||
style={{
|
||||
width: '1700px',
|
||||
maxWidth: '1824px',
|
||||
height: '594px',
|
||||
background: 'linear-gradient(180deg, #ffffff 0%, #d8dcec 100%)',
|
||||
borderRadius: '18px',
|
||||
boxShadow: '0 8px 40px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.1)',
|
||||
opacity: visible ? 1 : 0,
|
||||
transform: visible ? 'translateY(0) scale(1)' : 'translateY(12px) scale(0.96)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* ─── Botón cerrar ─── */}
|
||||
<button
|
||||
onClick={onCerrar}
|
||||
aria-label="Cerrar"
|
||||
className="absolute top-4 right-6 cursor-pointer transition-all duration-200 hover:scale-110 hover:opacity-70 z-10"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '80px',
|
||||
color: 'rgb(0, 0, 0)',
|
||||
padding: '0',
|
||||
lineHeight: '1',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
{/* ─── Tiempo agotado ─── */}
|
||||
{tiempoAgotado && (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-6">
|
||||
<img
|
||||
src="./img/incorrecto.png"
|
||||
alt="Tiempo agotado"
|
||||
style={{ width: '140px', height: '140px' }}
|
||||
className="object-contain"
|
||||
/>
|
||||
<p
|
||||
className="text-center"
|
||||
style={{ color: '#555555', fontSize: '26px', maxWidth: '500px' }}
|
||||
>
|
||||
No te preocupes, gira la ruleta de nuevo para continuar con la siguiente pregunta.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Reflexión ─── */}
|
||||
{!tiempoAgotado && (
|
||||
<div className="px-12 pt-10 pb-6">
|
||||
<p
|
||||
className="leading-relaxed"
|
||||
style={{ color: '#333333', fontSize: '22px', lineHeight: '1.6' }}
|
||||
>
|
||||
<strong>Reflexión:</strong> {reflexion}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── ¿Sabías qué? ─── */}
|
||||
{!tiempoAgotado && datoCurioso && (
|
||||
<div className="flex items-stretch mt-4">
|
||||
<div
|
||||
className="flex items-center justify-center px-8 py-5 shrink-0"
|
||||
style={{ backgroundColor: '#383838', borderRadius:'0px 45px 45px 0px' }}
|
||||
>
|
||||
<span
|
||||
className="font-bold text-white whitespace-nowrap"
|
||||
style={{ fontSize: '28px' }}
|
||||
>
|
||||
¿Sabías qué?
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 px-6 py-5">
|
||||
<p
|
||||
className="leading-relaxed"
|
||||
style={{ color: '#444444', fontSize: '22px', lineHeight: '1.6' }}
|
||||
>
|
||||
{datoCurioso}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +1,20 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Pregunta, Categoria } from '../../types'
|
||||
import { TIEMPO_LIMITE } from '../../hooks/useRuleta'
|
||||
|
||||
interface QuestionModalProps {
|
||||
pregunta: Pregunta
|
||||
categoria: Categoria
|
||||
mostrarFeedback: boolean
|
||||
feedbackCorrecto: boolean
|
||||
tiempoRestante: number
|
||||
onResponder: (indice: number | null) => void
|
||||
onCerrar: () => void
|
||||
}
|
||||
|
||||
const LETRAS = ['A', 'B', 'C', 'D']
|
||||
const TIEMPO_LIMITE = 30
|
||||
|
||||
export default function QuestionModal({ pregunta, categoria, mostrarFeedback, feedbackCorrecto, onResponder, onCerrar }: QuestionModalProps) {
|
||||
const [tiempo, setTiempo] = useState(TIEMPO_LIMITE)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const tiempoAgotadoRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (mostrarFeedback) {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
return
|
||||
}
|
||||
|
||||
setTiempo(TIEMPO_LIMITE)
|
||||
tiempoAgotadoRef.current = false
|
||||
intervalRef.current = setInterval(() => {
|
||||
setTiempo((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(intervalRef.current!)
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
|
||||
}, [pregunta.id, mostrarFeedback])
|
||||
|
||||
useEffect(() => {
|
||||
if (tiempo === 0 && !mostrarFeedback && !tiempoAgotadoRef.current) {
|
||||
tiempoAgotadoRef.current = true
|
||||
onResponder(null)
|
||||
}
|
||||
}, [tiempo, mostrarFeedback])
|
||||
|
||||
const porcentaje = (tiempo / TIEMPO_LIMITE) * 100
|
||||
export default function QuestionModal({ pregunta, categoria, mostrarFeedback, feedbackCorrecto, tiempoRestante, onResponder, onCerrar }: QuestionModalProps) {
|
||||
const porcentaje = (tiempoRestante / TIEMPO_LIMITE) * 100
|
||||
const colorBarra = porcentaje > 50 ? '#22c55e' : porcentaje > 25 ? '#f97316' : '#ef4444'
|
||||
|
||||
return (
|
||||
@@ -66,7 +34,7 @@ export default function QuestionModal({ pregunta, categoria, mostrarFeedback, fe
|
||||
<div className="px-5 pt-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-gray-500">Tiempo restante</span>
|
||||
<span className="text-sm font-bold" style={{ color: colorBarra }}>{tiempo}s</span>
|
||||
<span className="text-sm font-bold" style={{ color: colorBarra }}>{tiempoRestante}s</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
@@ -106,7 +74,7 @@ export default function QuestionModal({ pregunta, categoria, mostrarFeedback, fe
|
||||
</h3>
|
||||
{feedbackCorrecto
|
||||
? <p className="text-gray-600">{pregunta.retroalimentacion}</p>
|
||||
: <p className="text-gray-600">{tiempo === 0 ? 'Se agotó el tiempo.' : 'Respuesta incorrecta.'}</p>
|
||||
: <p className="text-gray-600">{tiempoRestante === 0 ? 'Se agotó el tiempo.' : 'Respuesta incorrecta.'}</p>
|
||||
}
|
||||
<button
|
||||
onClick={onCerrar}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { Categoria } from '../../../types'
|
||||
|
||||
interface RuletaSVGProps {
|
||||
@@ -7,92 +8,232 @@ interface RuletaSVGProps {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/* ─── Constantes geométricas ──────────────────── */
|
||||
const CX = 250
|
||||
const CY = 265
|
||||
const SEGMENT_R = 195
|
||||
const RING_R = 207
|
||||
const RING_STROKE = 26
|
||||
const CENTER_R = 28
|
||||
const VIEWBOX_W = 500
|
||||
const VIEWBOX_H = 550
|
||||
const TOTAL_SEGMENTS = 4
|
||||
|
||||
/* ─── Estilos visuales por segmento ───────────── */
|
||||
interface SegmentStyle {
|
||||
bg: string
|
||||
textColor: string
|
||||
iconColor: string
|
||||
}
|
||||
|
||||
const SEGMENT_STYLES: SegmentStyle[] = [
|
||||
{ bg: '#DC2626', textColor: '#FFFFFF', iconColor: '#FFFFFF' }, // Agua — rojo
|
||||
{ bg: '#D1D5DB', textColor: '#1F2937', iconColor: '#374151' }, // Residuos — gris claro
|
||||
{ bg: '#374151', textColor: '#FFFFFF', iconColor: '#FBBF24' }, // Energía — gris oscuro
|
||||
{ bg: '#F9FAFB', textColor: '#1F2937', iconColor: '#2563EB' }, // Electromovilidad — blanco
|
||||
]
|
||||
|
||||
/* ─── Utilidad: polar → cartesiano ────────────── */
|
||||
function polarToCartesian(cx: number, cy: number, angle: number, radius: number) {
|
||||
const rad = (angle - 90) * (Math.PI / 180)
|
||||
return {
|
||||
x: cx + radius * Math.cos(rad),
|
||||
y: cy + radius * Math.sin(rad),
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Path de sector circular ─────────────────── */
|
||||
function sectorPath(cx: number, cy: number, r: number, startAngle: number, endAngle: number) {
|
||||
const start = polarToCartesian(cx, cy, startAngle, r)
|
||||
const end = polarToCartesian(cx, cy, endAngle, r)
|
||||
const largeArc = endAngle - startAngle > 180 ? 1 : 0
|
||||
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y} Z`
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════
|
||||
COMPONENTE PRINCIPAL
|
||||
═══════════════════════════════════════════════ */
|
||||
|
||||
export default function RuletaSVG({ categorias, rotacion, isSpinning, onClick }: RuletaSVGProps) {
|
||||
const cx = 200
|
||||
const cy = 200
|
||||
const r = 180
|
||||
const total = categorias.length
|
||||
const angleStep = 360 / total
|
||||
const angleStep = 360 / TOTAL_SEGMENTS
|
||||
|
||||
function polarToCartesian(angle: number, radius: number) {
|
||||
const rad = (angle - 90) * (Math.PI / 180)
|
||||
return {
|
||||
x: cx + radius * Math.cos(rad),
|
||||
y: cy + radius * Math.sin(rad),
|
||||
/* Puntos decorativos del anillo exterior */
|
||||
const decorElements = useMemo(() => {
|
||||
const dots: { x: number; y: number; isStar: boolean }[] = []
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const angle = (i * 15)
|
||||
const pos = polarToCartesian(CX, CY, angle, RING_R)
|
||||
dots.push({ x: pos.x, y: pos.y, isStar: i % 3 === 0 })
|
||||
}
|
||||
}
|
||||
|
||||
function sectorPath(startAngle: number, endAngle: number) {
|
||||
const start = polarToCartesian(startAngle, r)
|
||||
const end = polarToCartesian(endAngle, r)
|
||||
const largeArc = endAngle - startAngle > 180 ? 1 : 0
|
||||
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y} Z`
|
||||
}
|
||||
return dots
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="relative flex flex-col items-center justify-center w-full select-none">
|
||||
|
||||
{/* Indicador */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-2 z-10">
|
||||
<div className="w-0 h-0 border-l-[12px] border-r-[12px] border-t-[24px] border-l-transparent border-r-transparent border-t-gray-800 drop-shadow-md" />
|
||||
{/* ─── Puntero (fuera del SVG, fijo arriba) ─── */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 z-20" style={{ marginTop: '35px' }}>
|
||||
<img
|
||||
src="./img/indicador.png"
|
||||
alt="Indicador"
|
||||
style={{ width: '86px', height: '96px' }}
|
||||
className="object-contain drop-shadow-[0_4px_6px_rgba(0,0,0,0.35)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ruleta SVG */}
|
||||
{/* ─── SVG principal (ruleta + anillo + soporte) ─── */}
|
||||
<svg
|
||||
width="400"
|
||||
height="400"
|
||||
viewBox="0 0 400 400"
|
||||
viewBox={`0 0 ${VIEWBOX_W} ${VIEWBOX_H}`}
|
||||
onClick={!isSpinning ? onClick : undefined}
|
||||
className={`select-none drop-shadow-xl ${!isSpinning ? 'cursor-pointer' : 'cursor-not-allowed'}`}
|
||||
style={{
|
||||
transition: 'transform 4s cubic-bezier(0.25, 0.1, 0.25, 1)',
|
||||
transform: `rotate(${rotacion}deg)`,
|
||||
}}
|
||||
className={`w-[90%] h-auto select-none drop-shadow-[0_10px_25px_rgba(0,0,0,0.4)] ${!isSpinning ? 'cursor-pointer' : 'cursor-not-allowed'}`}
|
||||
>
|
||||
{categorias.map((cat, i) => {
|
||||
const startAngle = i * angleStep
|
||||
const endAngle = startAngle + angleStep
|
||||
const midAngle = startAngle + angleStep / 2
|
||||
const textPos = polarToCartesian(midAngle, r * 0.62)
|
||||
<defs>
|
||||
{/* Sombra del anillo */}
|
||||
<filter id="ringShadow" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feDropShadow dx="0" dy="3" stdDeviation="6" floodColor="#000" floodOpacity="0.35" />
|
||||
</filter>
|
||||
{/* Sombra del botón central */}
|
||||
<filter id="centerShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="#000" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
return (
|
||||
<g key={cat.id}>
|
||||
{/* Sector */}
|
||||
<path
|
||||
d={sectorPath(startAngle, endAngle)}
|
||||
fill={cat.color}
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
{/* Emoji */}
|
||||
<text
|
||||
x={textPos.x}
|
||||
y={textPos.y - 14}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="28"
|
||||
>
|
||||
{cat.emoji}
|
||||
</text>
|
||||
{/* Nombre */}
|
||||
<text
|
||||
x={textPos.x}
|
||||
y={textPos.y + 16}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="13"
|
||||
fontWeight="bold"
|
||||
fill="white"
|
||||
>
|
||||
{cat.nombre}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{/* ══════ GRUPO GIRATORIO: Segmentos + centro ══════ */}
|
||||
<g
|
||||
style={{
|
||||
transformOrigin: `${CX}px ${CY}px`,
|
||||
transform: `rotate(${rotacion}deg)`,
|
||||
transition: 'transform 4s cubic-bezier(0.17, 0.67, 0.12, 0.99)',
|
||||
}}
|
||||
>
|
||||
{categorias.slice(0, TOTAL_SEGMENTS).map((cat, i) => {
|
||||
const startAngle = i * angleStep
|
||||
const endAngle = startAngle + angleStep
|
||||
const midAngle = startAngle + angleStep / 2
|
||||
const style = SEGMENT_STYLES[i] ?? SEGMENT_STYLES[0]
|
||||
|
||||
{/* Centro */}
|
||||
<circle cx={cx} cy={cy} r={22} fill="white" stroke="#e5e7eb" strokeWidth="3" />
|
||||
<circle cx={cx} cy={cy} r={10} fill="#7C3AED" />
|
||||
// Posición de la imagen (~58% del radio)
|
||||
const imgCenter = polarToCartesian(CX, CY, midAngle, SEGMENT_R * 0.58)
|
||||
// Posición del texto: radio y offset por categoría
|
||||
const textRadius = i === 0 ? SEGMENT_R * 0.58 : SEGMENT_R * 0.84
|
||||
const textPosRaw = polarToCartesian(CX, CY, midAngle, textRadius)
|
||||
const textOffsetX = i === 0 ? 0 : i === 1 ? -40 : i === 2 ? 30 : i === 3 ? 38 : 0
|
||||
const textOffsetY = i === 0 ? -45 : i === 1 ? 10 : i === 3 ? -12 : 0
|
||||
const textPos = { x: textPosRaw.x + textOffsetX, y: textPosRaw.y + textOffsetY }
|
||||
const imgSize = 60
|
||||
|
||||
/* Imágenes por segmento */
|
||||
const imgs = ['agua.png', 'residuos.png', 'energia.png', 'movilidad.png']
|
||||
|
||||
/* Determinar si el texto es multilínea */
|
||||
const esMultilinea = cat.nombre.length > 20
|
||||
const lineas = esMultilinea ? cat.nombre.split(' y ') : [cat.nombre]
|
||||
return (
|
||||
<g key={cat.id}>
|
||||
{/* Sector de color base */}
|
||||
<path
|
||||
d={sectorPath(CX, CY, SEGMENT_R, startAngle, endAngle)}
|
||||
fill={style.bg}
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
|
||||
{/* Imagen PNG (arriba, más cerca del centro) */}
|
||||
<image
|
||||
href={`./img/${imgs[i]}`}
|
||||
x={imgCenter.x - imgSize / 2}
|
||||
y={imgCenter.y - imgSize / 2}
|
||||
width={imgSize}
|
||||
height={imgSize}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
/>
|
||||
|
||||
{/* Texto del nombre (debajo de la imagen) */}
|
||||
<text
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill={style.textColor}
|
||||
fontWeight="800"
|
||||
fontSize={esMultilinea ? '11' : '14'}
|
||||
letterSpacing="0.5"
|
||||
>
|
||||
{lineas.map((linea, j) => (
|
||||
<tspan
|
||||
key={j}
|
||||
x={textPos.x}
|
||||
y={textPos.y}
|
||||
dy={j === 0 ? (esMultilinea ? -7 : 0) : 15}
|
||||
>
|
||||
{linea}{j < lineas.length - 1 ? ' y' : ''}
|
||||
</tspan>
|
||||
))}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ─── Botón central ─── */}
|
||||
<g>
|
||||
{/* Círculo exterior (borde rojo) */}
|
||||
<circle cx={CX} cy={CY} r={CENTER_R + 6} fill="#DC2626" filter="url(#centerShadow)" />
|
||||
{/* Círculo blanco */}
|
||||
<circle cx={CX} cy={CY} r={CENTER_R} fill="#FFFFFF" />
|
||||
{/* Logo TIP */}
|
||||
<image
|
||||
href="./img/logo-solo.png"
|
||||
x={CX - 18}
|
||||
y={CY - 18}
|
||||
width="36"
|
||||
height="36"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* ══════ ANILLO EXTERIOR (estático) ══════ */}
|
||||
<g filter="url(#ringShadow)">
|
||||
<circle
|
||||
cx={CX} cy={CY} r={RING_R}
|
||||
fill="none"
|
||||
stroke="#2D2D2D"
|
||||
strokeWidth={RING_STROKE}
|
||||
/>
|
||||
{/* Borde interior del anillo */}
|
||||
<circle
|
||||
cx={CX} cy={CY} r={RING_R - RING_STROKE / 2 + 1}
|
||||
fill="none"
|
||||
stroke="#1F2937"
|
||||
strokeWidth="2"
|
||||
opacity="0.6"
|
||||
/>
|
||||
{/* Borde exterior del anillo */}
|
||||
<circle
|
||||
cx={CX} cy={CY} r={RING_R + RING_STROKE / 2 - 1}
|
||||
fill="none"
|
||||
stroke="#1F2937"
|
||||
strokeWidth="2"
|
||||
opacity="0.5"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* ══════ ELEMENTOS DECORATIVOS DEL ANILLO ══════ */}
|
||||
{decorElements.map((d, i) => (
|
||||
<g key={i}>
|
||||
{d.isStar ? (
|
||||
/* Estrella de 4 puntas */
|
||||
<g transform={`translate(${d.x}, ${d.y})`}>
|
||||
<path
|
||||
d="M0,-7 L1.5,-2 L7,0 L1.5,2 L0,7 L-1.5,2 L-7,0 L-1.5,-2 Z"
|
||||
fill="#F9FAFB"
|
||||
opacity="0.85"
|
||||
/>
|
||||
</g>
|
||||
) : (
|
||||
/* Círculo decorativo */
|
||||
<circle cx={d.x} cy={d.y} r="3.5" fill="#9CA3AF" opacity="0.7" />
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="w-full bg-purple-700 text-white shadow-md">
|
||||
<div className="max-w-5xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<span className="text-2xl font-bold tracking-wide">🎡 Ruleta</span>
|
||||
<span className="text-sm opacity-75">Actividad Interactiva</span>
|
||||
<header
|
||||
className="w-full bg-transparent flex items-center shrink-0"
|
||||
style={{
|
||||
height: '90px',
|
||||
}}
|
||||
>
|
||||
{/* Logo izquierdo */}
|
||||
<div style={{ marginLeft: '75px', display: 'flex', alignItems: 'center' }}>
|
||||
<img src="./img/logo.png" alt="TIP" style={{ height: '50px', width: 'auto' }} />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useScale } from '../../hooks/useScale'
|
||||
import Header from './Header'
|
||||
import ActivityBar from '../ui/ActivityBar'
|
||||
|
||||
interface ScaleWrapperProps {
|
||||
children: ReactNode
|
||||
/** Imagen de fondo opcional (ruta relativa a public/) */
|
||||
backgroundImage?: string
|
||||
/** Props para el ActivityBar. Si no se pasa, la pantalla hija debe renderizar su propio ActivityBar. */
|
||||
barProps?: {
|
||||
preguntaActual?: number
|
||||
totalPreguntas?: number
|
||||
mensaje: string
|
||||
puedeAnterior?: boolean
|
||||
puedeSiguiente?: boolean
|
||||
onPrev?: () => void
|
||||
onNext?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoltorio de escalado adaptativo 1920×1080.
|
||||
* Incluye Header + contenido + ActivityBar (si se pasan barProps).
|
||||
*/
|
||||
export default function ScaleWrapper({ children, backgroundImage, barProps }: ScaleWrapperProps) {
|
||||
const scaleRef = useScale()
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen overflow-hidden bg-white relative">
|
||||
<div
|
||||
ref={scaleRef}
|
||||
className="flex flex-col overflow-hidden"
|
||||
style={{
|
||||
width: '1920px',
|
||||
height: '1080px',
|
||||
...(backgroundImage
|
||||
? {
|
||||
backgroundImage: `url('${backgroundImage}')`,
|
||||
backgroundSize: '100% 100%',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: { backgroundColor: '#ffffff' }),
|
||||
}}
|
||||
>
|
||||
<Header />
|
||||
<div className="flex-1 flex flex-col overflow-hidden" style={{ minHeight: 0 }}>
|
||||
{children}
|
||||
</div>
|
||||
{barProps && (
|
||||
<ActivityBar
|
||||
preguntaActual={barProps.preguntaActual ?? 0}
|
||||
totalPreguntas={barProps.totalPreguntas ?? 0}
|
||||
mensaje={barProps.mensaje}
|
||||
puedeAnterior={barProps.puedeAnterior ?? false}
|
||||
puedeSiguiente={barProps.puedeSiguiente ?? false}
|
||||
onPrev={barProps.onPrev}
|
||||
onNext={barProps.onNext}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import hero from '../../assets/hero.png'
|
||||
import Button from '../ui/Button'
|
||||
import { useAudio } from '../../hooks/useAudio'
|
||||
|
||||
interface CoverProps {
|
||||
@@ -14,33 +12,72 @@ export default function Cover({ onStart }: CoverProps) {
|
||||
onStart()
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col-reverse md:flex-row items-center justify-center text-center gap-10 py-10">
|
||||
<div className="flex flex-row items-center justify-center text-center gap-10 py-10 w-full h-full">
|
||||
|
||||
{/* Texto izquierdo */}
|
||||
<div className="flex-1 flex flex-col gap-5 text-center md:text-left">
|
||||
<h1 className="text-4xl font-bold text-purple-700 leading-tight">
|
||||
¡Bienvenido a la Ruleta Interactiva!
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Pon a prueba tus conocimientos girando la ruleta.
|
||||
Responde cada pregunta correctamente y acumula el mayor puntaje posible.
|
||||
</p>
|
||||
<div>
|
||||
<Button size="lg" onClick={handleStart}>
|
||||
Comenzar ▶
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Imagen derecha */}
|
||||
{/* Imagen izquierda */}
|
||||
<div className="flex-1 flex justify-center">
|
||||
<img
|
||||
src={hero}
|
||||
src="./img/02.png"
|
||||
alt="Ruleta Interactiva"
|
||||
className="w-full max-w-sm object-contain drop-shadow-xl"
|
||||
className="object-contain drop-shadow-xl"
|
||||
style={{ width: '80%', height: 'auto' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Texto derecho */}
|
||||
<div className="flex-1 flex flex-col justify-center gap-0 text-center" style={{ marginRight: '45px' }}>
|
||||
|
||||
{/* Soporte con degradado */}
|
||||
<div
|
||||
className="rounded-3xl p-8 shadow-lg"
|
||||
style={{ background: 'linear-gradient(180deg, #ffffff 0%, #d8dcec 100%)' }}
|
||||
>
|
||||
<p className="text-gray-700 leading-relaxed" style={{ fontSize: '22px' }}>
|
||||
Las reglas son muy sencillas: la ruleta girará, la suerte elegirá una categoría
|
||||
y te tocará responder una pregunta. Contarás con 30 segundos para responder
|
||||
cada una.
|
||||
</p>
|
||||
<p className="text-gray-700 leading-relaxed mt-4" style={{ fontSize: '22px' }}>
|
||||
No se trata de encontrar la respuesta perfecta, sino de reflexionar y responder
|
||||
desde tu experiencia y perspectiva. ¡Prepárate para poner a prueba tus
|
||||
conocimientos y disfrutar de la dinámica!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Soporte negro */}
|
||||
<div
|
||||
className="py-5 px-8 shadow-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: '#383838',
|
||||
width: '65%',
|
||||
alignSelf: 'center',
|
||||
borderRadius: '0px 0px 25px 25px'
|
||||
}}
|
||||
>
|
||||
<h1 className="text-white font-bold" style={{ fontSize: '32px' }}>
|
||||
¡y que empiece a girar!
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '25px' }}>
|
||||
<button
|
||||
onClick={handleStart}
|
||||
className="font-semibold transition-all duration-200 cursor-pointer hover:scale-105 hover:brightness-110"
|
||||
style={{
|
||||
backgroundColor: '#ed1f24',
|
||||
color: '#ffffff',
|
||||
border: '5px solid #ffffff',
|
||||
padding: '14px 28px',
|
||||
fontSize: '30px',
|
||||
borderRadius: '50px',
|
||||
fontWeight: 'bold',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.85)',
|
||||
}}
|
||||
>
|
||||
Comenzar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+139
-35
@@ -1,13 +1,23 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useRuleta } from '../../hooks/useRuleta'
|
||||
import { sounds } from '../../audio/AudioController'
|
||||
import QuestionModal from '../game/QuestionModal'
|
||||
import RuletaSVG from '../game/Ruleta/RuletaSVG'
|
||||
import FeedbackModal from '../game/FeedbackModal'
|
||||
|
||||
interface GameProps {
|
||||
onEnd: (aprobado: boolean, correctas: number) => void
|
||||
}
|
||||
|
||||
const LETRAS = ['A', 'B', 'C', 'D']
|
||||
|
||||
/** Mapeo de nombre de categoría → imagen PNG */
|
||||
const CAT_IMG: Record<string, string> = {
|
||||
'Agua': 'agua.png',
|
||||
'Residuos': 'residuos.png',
|
||||
'Energía': 'energia.png',
|
||||
'Electromovilidad': 'movilidad.png',
|
||||
}
|
||||
|
||||
export default function Game({ onEnd }: GameProps) {
|
||||
const { estado, categorias, girar, responder, cerrarFeedback, MAX_PREGUNTAS } = useRuleta()
|
||||
|
||||
@@ -20,49 +30,143 @@ export default function Game({ onEnd }: GameProps) {
|
||||
girar()
|
||||
}
|
||||
|
||||
const pregunta = estado.preguntaActual
|
||||
const categoria = estado.categoriaActual
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6 py-6 w-full">
|
||||
<div className="flex flex-col h-full w-full px-0 pt-8 relative">
|
||||
|
||||
{/* Instrucciones */}
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-xl px-6 py-4 max-w-lg text-center">
|
||||
<p className="text-purple-700 font-semibold text-lg">¿Cómo jugar?</p>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Haz clic en la <strong>ruleta</strong> para girarla y responde correctamente para ganar puntos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-4 w-full">
|
||||
|
||||
{/* Progreso de preguntas */}
|
||||
<div className="flex gap-2">
|
||||
{Array.from({ length: MAX_PREGUNTAS }).map((_, i) => (
|
||||
{/* ─── Contenedor rojo con imagen + nombre de categoría (top-right) ─── */}
|
||||
{categoria && (
|
||||
<div
|
||||
className="absolute top-0 right-0 z-20 flex items-center gap-3 pl-5 pr-6"
|
||||
style={{
|
||||
backgroundColor: '#ed1f24',
|
||||
borderRadius: '0px 0px 0px 25px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||
maxWidth: '520px',
|
||||
}}
|
||||
>
|
||||
{/* Círculo blanco con imagen de categoría */}
|
||||
{CAT_IMG[categoria.nombre] && (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-6 h-6 rounded-full border-2 border-purple-600 transition-all duration-500 ${
|
||||
i < estado.preguntasRespondidas ? 'bg-purple-600' : 'bg-white'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
className="rounded-full flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
width: '52px',
|
||||
height: '52px',
|
||||
backgroundColor: '#FFFFFF',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`./img/${CAT_IMG[categoria.nombre]}`}
|
||||
alt={categoria.nombre}
|
||||
className="object-contain"
|
||||
style={{ width: '36px', height: '36px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className="text-white font-extrabold tracking-wide py-3 whitespace-nowrap"
|
||||
style={{ fontSize: '22px' }}
|
||||
>
|
||||
{categoria.nombre}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Contenido principal: Ruleta + Panel de pregunta ─── */}
|
||||
<div className="flex-1 flex items-center justify-center min-h-0">
|
||||
|
||||
{/* ─── Columna izquierda: Ruleta 40% ─── */}
|
||||
<div className="flex flex-col items-center justify-center z-10" style={{ width: '40%', marginRight: '-170px' }}>
|
||||
<RuletaSVG
|
||||
categorias={categorias}
|
||||
rotacion={estado.rotacion}
|
||||
isSpinning={estado.isSpinning}
|
||||
onClick={handleGirar}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ruleta SVG */}
|
||||
<RuletaSVG
|
||||
categorias={categorias}
|
||||
rotacion={estado.rotacion}
|
||||
isSpinning={estado.isSpinning}
|
||||
onClick={handleGirar}
|
||||
/>
|
||||
{/* ─── Columna derecha: Panel de pregunta 60% ─── */}
|
||||
<div className="flex flex-col justify-center pl-16 pr-10" style={{ width: '60%' }}>
|
||||
{pregunta && !estado.completado && (
|
||||
<div
|
||||
className="rounded-2xl shadow-xl overflow-hidden"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, #ffffff 0%, #d8dcec 100%)',
|
||||
marginLeft: '-80px',
|
||||
}}
|
||||
>
|
||||
{/* Header con pregunta (fondo transparente) */}
|
||||
<div
|
||||
className="px-6 py-4 flex flex-col items-center gap-1"
|
||||
style={{ marginLeft: '50px' }}
|
||||
>
|
||||
<p className="font-bold text-center" style={{ color: '#ed1f24', fontSize: '25px' }}>{pregunta.pregunta}</p>
|
||||
</div>
|
||||
|
||||
{/* Opciones */}
|
||||
<div className="p-5 flex flex-col gap-3" style={{ marginLeft: '115px' }}>
|
||||
{pregunta.opciones.map((opcion, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => responder(i)}
|
||||
disabled={estado.mostrarFeedback}
|
||||
className="group flex items-stretch w-full rounded-lg border border-gray-200 bg-gray-50 hover:bg-[#383838] hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 overflow-hidden text-left disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span
|
||||
className="text-white font-bold w-10 flex items-center justify-center shrink-0 group-hover:text-white"
|
||||
style={{ backgroundColor: '#ed1f24' }}
|
||||
>
|
||||
{LETRAS[i]}
|
||||
</span>
|
||||
<span className="px-4 py-3 text-gray-700 font-medium group-hover:text-white" style={{ fontSize: '22px' }}>{opcion}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Temporizador debajo del panel */}
|
||||
{pregunta && !estado.completado && (
|
||||
<div className="flex justify-center mt-3">
|
||||
<span
|
||||
className="font-bold text-white bg-black rounded-md px-[10px] py-[10px]"
|
||||
style={{ fontSize: '22px' }}
|
||||
>
|
||||
{estado.tiempoRestante}seg
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sin pregunta activa: placeholder */}
|
||||
{!pregunta && !estado.completado && (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center text-center p-10 rounded-2xl shadow-xl"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, #ffffff 0%, #d8dcec 100%)',
|
||||
}}
|
||||
>
|
||||
<p className="text-gray-700 font-medium" style={{ fontSize: '22px' }}>
|
||||
Gira la ruleta para obtener una pregunta
|
||||
</p>
|
||||
<p className="text-gray-500 mt-2" style={{ fontSize: '22px' }}>
|
||||
Cada respuesta correcta te acerca al objetivo de {MAX_PREGUNTAS} preguntas
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Modal pregunta */}
|
||||
{estado.preguntaActual && !estado.completado && (
|
||||
<QuestionModal
|
||||
pregunta={estado.preguntaActual}
|
||||
categoria={estado.categoriaActual!}
|
||||
mostrarFeedback={estado.mostrarFeedback}
|
||||
{/* ─── Modal de feedback ─── */}
|
||||
{estado.mostrarFeedback && pregunta && categoria && (
|
||||
<FeedbackModal
|
||||
pregunta={pregunta}
|
||||
categoria={categoria}
|
||||
feedbackCorrecto={estado.feedbackCorrecto}
|
||||
onResponder={responder}
|
||||
tiempoAgotado={estado.tiempoRestante === 0}
|
||||
onCerrar={cerrarFeedback}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/* ─── SVG de Agua ──────────────────────────────────── */
|
||||
function IconoAgua() {
|
||||
return (
|
||||
<svg viewBox="0 0 120 120" width="105" height="105" className="transition-transform duration-300 group-hover:scale-110">
|
||||
{/* Gota de agua */}
|
||||
<path
|
||||
d="M60 15 C60 15 30 55 30 75 C30 91.6 43.4 105 60 105 C76.6 105 90 91.6 90 75 C90 55 60 15 60 15Z"
|
||||
fill="#3B82F6"
|
||||
opacity="0.85"
|
||||
/>
|
||||
{/* Brillo */}
|
||||
<ellipse cx="50" cy="60" rx="10" ry="16" fill="white" opacity="0.3" transform="rotate(-15 50 60)" />
|
||||
{/* Grifo simplificado arriba */}
|
||||
<rect x="52" y="4" width="16" height="18" rx="6" fill="#64748B" />
|
||||
<rect x="48" y="16" width="24" height="8" rx="3" fill="#64748B" />
|
||||
<circle cx="60" cy="8" r="5" fill="#3B82F6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── SVG de Residuos y Circularidad ───────────────── */
|
||||
function IconoResiduos() {
|
||||
return (
|
||||
<svg viewBox="0 0 120 120" width="105" height="105" className="transition-transform duration-300 group-hover:scale-110">
|
||||
{/* Contenedor de reciclaje */}
|
||||
<rect x="30" y="50" width="60" height="55" rx="5" fill="#16A34A" />
|
||||
<rect x="32" y="52" width="56" height="10" rx="3" fill="#15803D" />
|
||||
<line x1="35" y1="55" x2="85" y2="55" stroke="#86EFAC" strokeWidth="3" strokeLinecap="round" />
|
||||
<line x1="35" y1="62" x2="85" y2="62" stroke="#86EFAC" strokeWidth="3" strokeLinecap="round" />
|
||||
<line x1="35" y1="69" x2="85" y2="69" stroke="#86EFAC" strokeWidth="3" strokeLinecap="round" />
|
||||
{/* Símbolo de reciclaje ♻ */}
|
||||
<g transform="translate(60, 32) scale(0.55)">
|
||||
<path d="M0-28 L-24 14 L-8 14 L-8 28 L8 28 L8 14 L24 14 Z"
|
||||
fill="none" stroke="#16A34A" strokeWidth="6" strokeLinejoin="round" />
|
||||
<path d="M24-14 L0-42 L-24-14 L-10-6 L-2-20 L6-6 Z"
|
||||
fill="none" stroke="#16A34A" strokeWidth="6" strokeLinejoin="round"
|
||||
transform="rotate(120)" style={{ transformOrigin: '0 0' }} />
|
||||
<path d="M-24-14 L0-42 L24-14 L10-6 L2-20 L-6-6 Z"
|
||||
fill="none" stroke="#16A34A" strokeWidth="6" strokeLinejoin="round"
|
||||
transform="rotate(240)" style={{ transformOrigin: '0 0' }} />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── SVG de Energía ───────────────────────────────── */
|
||||
function IconoEnergia() {
|
||||
return (
|
||||
<svg viewBox="0 0 120 120" width="105" height="105" className="transition-transform duration-300 group-hover:scale-110">
|
||||
{/* Sol */}
|
||||
<circle cx="85" cy="20" r="12" fill="#FACC15" />
|
||||
{[0, 45, 90, 135, 180, 225, 270, 315].map((angle, i) => (
|
||||
<line key={i}
|
||||
x1={85 + 8 * Math.cos(angle * Math.PI / 180)}
|
||||
y1={20 + 8 * Math.sin(angle * Math.PI / 180)}
|
||||
x2={85 + 16 * Math.cos(angle * Math.PI / 180)}
|
||||
y2={20 + 16 * Math.sin(angle * Math.PI / 180)}
|
||||
stroke="#FACC15" strokeWidth="2.5" strokeLinecap="round" />
|
||||
))}
|
||||
{/* Colinas verdes */}
|
||||
<ellipse cx="35" cy="85" rx="42" ry="28" fill="#22C55E" opacity="0.8" />
|
||||
<ellipse cx="75" cy="90" rx="38" ry="22" fill="#16A34A" opacity="0.7" />
|
||||
{/* Aerogenerador */}
|
||||
<rect x="32" y="38" width="4" height="40" rx="2" fill="#94A3B8" />
|
||||
<g transform="translate(34, 38)">
|
||||
<rect x="-2" y="-28" width="4" height="27" rx="2" fill="#CBD5E1" transform="rotate(0)" />
|
||||
<rect x="-2" y="-28" width="4" height="27" rx="2" fill="#CBD5E1" transform="rotate(120)" />
|
||||
<rect x="-2" y="-28" width="4" height="27" rx="2" fill="#CBD5E1" transform="rotate(240)" />
|
||||
<circle cx="0" cy="0" r="4" fill="#64748B" />
|
||||
</g>
|
||||
{/* Panel solar */}
|
||||
<rect x="62" y="65" width="28" height="18" rx="2" fill="#3B82F6" opacity="0.8" transform="rotate(-8 76 74)" />
|
||||
<line x1="68" y1="72" x2="84" y2="70" stroke="#BFDBFE" strokeWidth="1" />
|
||||
<line x1="69" y1="76" x2="85" y2="74" stroke="#BFDBFE" strokeWidth="1" />
|
||||
<line x1="70" y1="80" x2="86" y2="78" stroke="#BFDBFE" strokeWidth="1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── SVG de Electromovilidad ──────────────────────── */
|
||||
function IconoElectromovilidad() {
|
||||
return (
|
||||
<svg viewBox="0 0 120 120" width="105" height="105" className="transition-transform duration-300 group-hover:scale-110">
|
||||
{/* Carretera */}
|
||||
<rect x="5" y="78" width="110" height="6" rx="3" fill="#CBD5E1" />
|
||||
<line x1="20" y1="81" x2="40" y2="81" stroke="white" strokeWidth="2" strokeDasharray="6,4" />
|
||||
<line x1="55" y1="81" x2="75" y2="81" stroke="white" strokeWidth="2" strokeDasharray="6,4" />
|
||||
<line x1="90" y1="81" x2="105" y2="81" stroke="white" strokeWidth="2" strokeDasharray="6,4" />
|
||||
{/* Coche eléctrico azul */}
|
||||
<g transform="translate(45, 50)">
|
||||
{/* Carrocería */}
|
||||
<rect x="0" y="6" width="50" height="18" rx="8" fill="#3B82F6" />
|
||||
{/* Techo / cabina */}
|
||||
<path d="M12 6 L18 -6 L36 -6 L42 6 Z" fill="#3B82F6" />
|
||||
{/* Ventanas */}
|
||||
<path d="M14 4 L19 -4 L25 -4 L25 4 Z" fill="#BFDBFE" />
|
||||
<path d="M27 -4 L34 -4 L39 4 L27 4 Z" fill="#BFDBFE" />
|
||||
{/* Ruedas */}
|
||||
<circle cx="12" cy="24" r="6" fill="#374151" />
|
||||
<circle cx="12" cy="24" r="2.5" fill="#9CA3AF" />
|
||||
<circle cx="38" cy="24" r="6" fill="#374151" />
|
||||
<circle cx="38" cy="24" r="2.5" fill="#9CA3AF" />
|
||||
{/* Faro */}
|
||||
<rect x="48" y="14" width="3" height="5" rx="1" fill="#FACC15" />
|
||||
</g>
|
||||
{/* Cargador / estación */}
|
||||
<rect x="8" y="48" width="8" height="30" rx="3" fill="#64748B" />
|
||||
<rect x="6" y="44" width="12" height="6" rx="2" fill="#475569" />
|
||||
{/* Cable */}
|
||||
<path d="M20 48 Q28 40 42 52" fill="none" stroke="#FACC15" strokeWidth="2.5" strokeLinecap="round" />
|
||||
{/* Indicador verde */}
|
||||
<circle cx="12" cy="52" r="2" fill="#22C55E" />
|
||||
{/* Rayo */}
|
||||
<path d="M10 38 L7 42 L11 42 L9 46 L14 40 L10 40 Z" fill="#FACC15" transform="scale(0.7) translate(12, 14)" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Datos de categorías ──────────────────────────── */
|
||||
const CATEGORIAS = [
|
||||
{ id: 'agua', nombre: 'Agua', Icono: IconoAgua },
|
||||
{ id: 'residuos', nombre: 'Residuos y', nombre2: 'Circularidad', Icono: IconoResiduos },
|
||||
{ id: 'energia', nombre: 'Energía', Icono: IconoEnergia },
|
||||
{ id: 'electromovilidad', nombre: 'Electromovilidad', nombre2: 'y Transporte', nombre3: 'Sostenible', Icono: IconoElectromovilidad },
|
||||
]
|
||||
|
||||
export default function Intro() {
|
||||
return (
|
||||
<div className="relative w-full h-full flex flex-col">
|
||||
|
||||
{/* ─── Contenido scrolleable ──────────────────── */}
|
||||
<div className="relative z-10 flex w-full flex-1 overflow-y-auto px-16 pb-6">
|
||||
|
||||
{/* ─── Columna izquierda (60%): bienvenida ───────── */}
|
||||
<div className="flex flex-col items-start justify-center" style={{ width: '65%' }}>
|
||||
<h1 style={{ color: '#000000', fontSize: '105px', fontWeight: 'bold', lineHeight: '1.1', textAlign: 'center', width: '100%' }}>¡BIENVENIDOS!</h1>
|
||||
<img src="./img/01.png" alt="Logo Ruleta" style={{ marginTop: '20px', width: '70%', height: 'auto' }} />
|
||||
</div>
|
||||
|
||||
{/* ─── Columna derecha (40%): texto ─────────── */}
|
||||
<div className="flex flex-col justify-center" style={{ width: '35%', paddingRight: '20px' }}>
|
||||
<div className="relative z-10 w-full bg-transparent rounded-3xl p-10" style={{top: '-10%'}}>
|
||||
{/* Texto introductorio */}
|
||||
<p className="text-gray-700 leading-relaxed mb-8" style={{ fontSize: '22px' }}>
|
||||
Hoy vamos a poner a prueba nuestros conocimientos y a reflexionar juntos sobre
|
||||
cómo nuestras acciones impactan el planeta. En esta ruleta tenemos{' '}
|
||||
<strong>4 ejes fundamentales para el futuro:</strong>
|
||||
</p>
|
||||
|
||||
{/* Lista de ejes con imágenes */}
|
||||
<div className="flex flex-col gap-5">
|
||||
{CATEGORIAS.map((cat, i) => {
|
||||
const imagenes = ['agua.png', 'residuos.png', 'energia.png', 'movilidad.png']
|
||||
return (
|
||||
<div key={cat.id} className="flex items-center gap-4">
|
||||
<img
|
||||
src={`./img/${imagenes[i]}`}
|
||||
alt={cat.nombre}
|
||||
className="shrink-0 object-contain"
|
||||
style={{ width: '75px', height: '78px' }}
|
||||
/>
|
||||
<div>
|
||||
<strong className="text-gray-800" style={{ fontSize: '22px' }}>
|
||||
{cat.nombre}
|
||||
{cat.nombre2 ? ` ${cat.nombre2}` : ''}
|
||||
{cat.nombre3 ? ` ${cat.nombre3}` : ''}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,49 +4,85 @@ interface ResultProps {
|
||||
onReintentar: () => void
|
||||
}
|
||||
|
||||
export default function Result({ aprobado, sinIntentos, onReintentar }: ResultProps) {
|
||||
export default function Result(_props: ResultProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-8 py-10 w-full text-center">
|
||||
|
||||
{/* Aprobado */}
|
||||
{aprobado && (
|
||||
<>
|
||||
<span className="text-8xl">🏆</span>
|
||||
<h2 className="text-4xl font-bold text-purple-700">¡Felicidades!</h2>
|
||||
<p className="text-gray-600 text-xl max-w-md">
|
||||
Has completado la actividad exitosamente. ¡Excelente desempeño!
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Reprobado con intentos disponibles */}
|
||||
{!aprobado && !sinIntentos && (
|
||||
<>
|
||||
<span className="text-8xl">💪</span>
|
||||
<h2 className="text-4xl font-bold text-orange-500">¡Casi lo logras!</h2>
|
||||
<p className="text-gray-600 text-xl max-w-md">
|
||||
Aún te falta un poco. Vuelve a intentarlo, ¡tú puedes!
|
||||
</p>
|
||||
<button
|
||||
onClick={onReintentar}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white font-bold px-10 py-4 rounded-xl text-lg transition-colors"
|
||||
<div className="flex w-full h-full">
|
||||
{/* ─── Columna izquierda 50% ─── */}
|
||||
<div className="flex flex-col justify-center" style={{ width: '50%', paddingRight: '20px' }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
{/* Cabecera roja flotante */}
|
||||
<div
|
||||
className="px-8 py-6"
|
||||
style={{
|
||||
backgroundColor: '#ed1f24',
|
||||
borderRadius: '25px',
|
||||
width: '90%',
|
||||
position: 'absolute',
|
||||
top: '-55px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<p
|
||||
className="font-bold text-white text-center"
|
||||
style={{ fontSize: '28px', lineHeight: '1.4' }}
|
||||
>
|
||||
¡Y con este último giro cerramos nuestra ruleta del día de hoy!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sin intentos */}
|
||||
{sinIntentos && (
|
||||
<>
|
||||
<span className="text-8xl">😔</span>
|
||||
<h2 className="text-4xl font-bold text-red-600">Intentos agotados</h2>
|
||||
<p className="text-gray-600 text-xl max-w-md">
|
||||
Has utilizado todos tus intentos disponibles. Te recomendamos repasar el contenido del curso antes de volver a intentarlo.
|
||||
{/* Contenido degradado */}
|
||||
<div
|
||||
className="px-10 py-8"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, #ffffff 0%, #d8dcec 100%)',
|
||||
borderRadius: '0px 25px 25px 0px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
opacity: 0.88,
|
||||
paddingTop: '95px',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-gray-700 mb-6"
|
||||
style={{ fontSize: '22px', lineHeight: '1.6' }}
|
||||
>
|
||||
Recorrimos desde la gestión del agua y los residuos, hasta la transición hacia energías más limpias y la movilidad del futuro. Más allá de las respuestas correctas o los puntos, lo verdaderamente importante es que la sostenibilidad no se queda en esta pantalla: se construye con las decisiones que tomamos todos los días.
|
||||
</p>
|
||||
<p
|
||||
className="text-gray-700"
|
||||
style={{ fontSize: '22px', lineHeight: '1.6', fontWeight: '600' }}
|
||||
>
|
||||
Muchísimas gracias a todos por su participación, su energía y sus aportaciones.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Columna derecha 50% ─── */}
|
||||
<div className="flex flex-col items-center justify-center" style={{ width: '50%', paddingLeft: '20px' }}>
|
||||
<img
|
||||
src="./img/03.png"
|
||||
alt="Resultado"
|
||||
className="object-contain mb-8"
|
||||
style={{ width: '80%', height: 'auto' }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="px-8 py-6 rounded-xl text-center"
|
||||
style={{
|
||||
backgroundColor: '#383838',
|
||||
width: '90%',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="font-bold text-white"
|
||||
style={{ fontSize: '22px', lineHeight: '1.5' }}
|
||||
>
|
||||
¡Nos llevamos grandes aprendizajes para seguir cuidando el planeta juntos!
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/* ─── Barra inferior de actividad ─────────────── */
|
||||
|
||||
interface ActivityBarProps {
|
||||
preguntaActual?: number
|
||||
totalPreguntas?: number
|
||||
mensaje: string
|
||||
onPrev?: () => void
|
||||
onNext?: () => void
|
||||
puedeAnterior?: boolean
|
||||
puedeSiguiente?: boolean
|
||||
}
|
||||
|
||||
/* ─── Flecha izquierda ────────────────────────── */
|
||||
function FlechaIzq({ disabled }: { disabled?: boolean }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 40" width="28" height="44" fill="none">
|
||||
<path
|
||||
d="M20 4 L6 20 L20 36"
|
||||
stroke={disabled ? '#cbd5e1' : '#DC2626'}
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Flecha derecha ──────────────────────────── */
|
||||
function FlechaDer({ disabled }: { disabled?: boolean }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 40" width="28" height="44" fill="none">
|
||||
<path
|
||||
d="M4 4 L18 20 L4 36"
|
||||
stroke={disabled ? '#cbd5e1' : '#DC2626'}
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ActivityBar({
|
||||
preguntaActual = 0,
|
||||
totalPreguntas = 0,
|
||||
mensaje,
|
||||
onPrev,
|
||||
onNext,
|
||||
puedeAnterior = false,
|
||||
puedeSiguiente = false,
|
||||
}: ActivityBarProps) {
|
||||
const counterText = totalPreguntas > 0
|
||||
? `${preguntaActual}/${totalPreguntas}`
|
||||
: '0/0'
|
||||
|
||||
return (
|
||||
<footer
|
||||
className="w-full flex items-center justify-end gap-0 shrink-0"
|
||||
style={{
|
||||
backgroundColor: '#f8f9fa05',
|
||||
height: '80px',
|
||||
paddingRight: '0px',
|
||||
boxShadow: '0 -1px 4px rgba(0,0,0,0.04)',
|
||||
}}
|
||||
>
|
||||
{/* ─── Instrucción en soporte rojo ─────────── */}
|
||||
<div
|
||||
className="flex items-center justify-center px-8 py-3"
|
||||
style={{
|
||||
backgroundColor: '#ee2b33',
|
||||
minWidth: '280px',
|
||||
borderRadius: '25px 0px 0px 0px',
|
||||
boxShadow: '0 2px 6px rgba(238,43,51,0.3)'
|
||||
}}
|
||||
>
|
||||
<span className="text-white font-semibold leading-tight text-center" style={{ fontSize: '22px' }}>
|
||||
{mensaje}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ─── Botones de navegación ───────────────── */}
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
style={{
|
||||
height: '100%',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.6)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.85)',
|
||||
padding: '0 16px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={!puedeAnterior}
|
||||
aria-label="Anterior"
|
||||
className="p-2 rounded-full transition-transform duration-200 cursor-pointer
|
||||
disabled:opacity-30 disabled:cursor-not-allowed
|
||||
hover:scale-125"
|
||||
>
|
||||
<FlechaIzq disabled={!puedeAnterior} />
|
||||
</button>
|
||||
|
||||
<span className="text-gray-700 font-bold tabular-nums select-none min-w-[60px] text-center" style={{ fontSize: '22px' }}>
|
||||
{counterText}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!puedeSiguiente}
|
||||
aria-label="Siguiente"
|
||||
className="p-2 rounded-full transition-transform duration-200 cursor-pointer
|
||||
disabled:opacity-30 disabled:cursor-not-allowed
|
||||
hover:scale-125"
|
||||
>
|
||||
<FlechaDer disabled={!puedeSiguiente} />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/* ─── Indicador de navegación (esquina superior derecha) ─── */
|
||||
|
||||
interface NavIndicatorProps {
|
||||
preguntaActual: number
|
||||
totalPreguntas: number
|
||||
onPrev?: () => void
|
||||
onNext?: () => void
|
||||
puedeAnterior: boolean
|
||||
puedeSiguiente: boolean
|
||||
}
|
||||
|
||||
/* ─── Flecha izquierda ────────────────────────── */
|
||||
function FlechaIzq({ disabled }: { disabled?: boolean }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 40" width="18" height="30" fill="none">
|
||||
<path
|
||||
d="M20 4 L6 20 L20 36"
|
||||
stroke={disabled ? '#cbd5e1' : '#DC2626'}
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Flecha derecha ──────────────────────────── */
|
||||
function FlechaDer({ disabled }: { disabled?: boolean }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 40" width="18" height="30" fill="none">
|
||||
<path
|
||||
d="M4 4 L18 20 L4 36"
|
||||
stroke={disabled ? '#cbd5e1' : '#DC2626'}
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NavIndicator({
|
||||
preguntaActual,
|
||||
totalPreguntas,
|
||||
onPrev,
|
||||
onNext,
|
||||
puedeAnterior,
|
||||
puedeSiguiente,
|
||||
}: NavIndicatorProps) {
|
||||
const counterText = totalPreguntas > 0
|
||||
? `${preguntaActual}/${totalPreguntas}`
|
||||
: '0/0'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute flex items-center gap-3 z-30"
|
||||
style={{ top: '20px', right: '40px' }}
|
||||
>
|
||||
{/* Flecha izquierda */}
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={!puedeAnterior}
|
||||
aria-label="Anterior"
|
||||
className="p-2 rounded-full transition-colors duration-200 cursor-pointer
|
||||
disabled:opacity-30 disabled:cursor-not-allowed
|
||||
hover:bg-gray-100 active:bg-gray-200"
|
||||
>
|
||||
<FlechaIzq disabled={!puedeAnterior} />
|
||||
</button>
|
||||
|
||||
{/* Contador */}
|
||||
<span className="text-gray-700 font-bold text-2xl tabular-nums select-none min-w-[60px] text-center">
|
||||
{counterText}
|
||||
</span>
|
||||
|
||||
{/* Flecha derecha */}
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!puedeSiguiente}
|
||||
aria-label="Siguiente"
|
||||
className="p-2 rounded-full transition-colors duration-200 cursor-pointer
|
||||
disabled:opacity-30 disabled:cursor-not-allowed
|
||||
hover:bg-gray-100 active:bg-gray-200"
|
||||
>
|
||||
<FlechaDer disabled={!puedeSiguiente} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+196
-169
@@ -1,297 +1,324 @@
|
||||
{
|
||||
{
|
||||
"categorias": [
|
||||
{
|
||||
"id": 0,
|
||||
"nombre": "Historia",
|
||||
"color": "#7C3AED",
|
||||
"emoji": "🏛️",
|
||||
"nombre": "Agua",
|
||||
"color": "#0284c7",
|
||||
"emoji": "💧",
|
||||
"preguntas": [
|
||||
{
|
||||
"id": 1,
|
||||
"pregunta": "¿En qué año cayó el Muro de Berlín?",
|
||||
"opciones": ["1987", "1989", "1991", "1993"],
|
||||
"pregunta": "¿Cuánta agua se puede ahorrar al día si cerramos la llave mientras nos cepillamos los dientes?",
|
||||
"opciones": ["Menos de 5 litros", "Entre 20 y 30 litros", "Más de 100 litros"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "El Muro de Berlín cayó el 9 de noviembre de 1989, marcando el fin de la Guerra Fría."
|
||||
"retroalimentacion": "Un gesto de unos segundos, repetido todos los días, se convierte en un hábito que suma litros de agua cuidada sin ningún esfuerzo extra.",
|
||||
"reflexion": "Un gesto de unos segundos, repetido todos los días, se convierte en un hábito que suma litros de agua cuidada sin ningún esfuerzo extra.",
|
||||
"datoCurioso": "Si cada persona cerrara la llave al cepillarse los dientes, ahorraríamos millones de litros de agua diariamente en el mundo."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"pregunta": "¿Quién fue el primer presidente de los Estados Unidos?",
|
||||
"opciones": ["Abraham Lincoln", "Thomas Jefferson", "George Washington", "Benjamin Franklin"],
|
||||
"pregunta": "¿Aproximadamente cuántos litros de agua se necesitan para producir una sola hamburguesa?",
|
||||
"opciones": ["200 litros", "800 litros", "2,000 litros"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "George Washington fue el primer presidente de EE.UU., gobernando de 1789 a 1797."
|
||||
"retroalimentacion": "Nuestras decisiones alimentarias tienen un impacto oculto en el agua; comer con conciencia también es una forma de cuidar este recurso.",
|
||||
"reflexion": "Nuestras decisiones alimentarias tienen un impacto oculto en el agua; comer con conciencia también es una forma de cuidar este recurso.",
|
||||
"datoCurioso": "Cultivar el pasto para alimentar al ganado y procesar la carne genera una huella hídrica altísima: hasta 2,000 litros de agua por hamburguesa."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"pregunta": "¿En qué año comenzó la Primera Guerra Mundial?",
|
||||
"opciones": ["1912", "1914", "1916", "1918"],
|
||||
"pregunta": "¿Cuánta agua puede desperdiciar aproximadamente una llave que gotea constantemente?",
|
||||
"opciones": ["Menos de 1 litro al año", "Hasta miles de litros al año", "Exactamente 10 litros al año"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "La Primera Guerra Mundial comenzó en 1914 tras el asesinato del archiduque Francisco Fernando."
|
||||
"retroalimentacion": "Una fuga pequeña puede parecer insignificante, pero el desperdicio acumulado puede ser enorme.",
|
||||
"reflexion": "Una fuga pequeña puede parecer insignificante, pero el desperdicio acumulado puede ser enorme.",
|
||||
"datoCurioso": "Reparar una llave o tubería con fugas es una de las formas más sencillas de evitar el desperdicio de agua."
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"pregunta": "¿Qué civilización construyó las pirámides de Giza?",
|
||||
"opciones": ["Griega", "Romana", "Egipcia", "Mesopotámica"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "Las pirámides de Giza fueron construidas por los antiguos egipcios hace más de 4,500 años."
|
||||
"pregunta": "¿Qué porcentaje aproximado del agua del planeta es agua dulce?",
|
||||
"opciones": ["Menos del 3%", "25%", "Más del 50%"],
|
||||
"correcta": 0,
|
||||
"retroalimentacion": "Aunque la Tierra parece tener abundante agua, solo una pequeña fracción es agua dulce.",
|
||||
"reflexion": "Aunque la Tierra parece tener abundante agua, solo una pequeña fracción es agua dulce.",
|
||||
"datoCurioso": "Cerca del 97% del agua del planeta es salada y gran parte del agua dulce está congelada o se encuentra bajo tierra."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"pregunta": "¿En qué año llegó Cristóbal Colón a América?",
|
||||
"opciones": ["1488", "1490", "1492", "1495"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "Cristóbal Colón llegó a América el 12 de octubre de 1492."
|
||||
"pregunta": "¿Qué acción ayuda más a reducir el consumo de agua al bañarnos?",
|
||||
"opciones": ["Tomar duchas más cortas", "Dejar correr el agua mientras nos enjabonamos", "Aumentar la presión del agua"],
|
||||
"correcta": 0,
|
||||
"retroalimentacion": "Reducir algunos minutos de la ducha puede generar un ahorro importante cuando se convierte en un hábito diario.",
|
||||
"reflexion": "Reducir algunos minutos de la ducha puede generar un ahorro importante cuando se convierte en un hábito diario.",
|
||||
"datoCurioso": "Cada minuto adicional en la ducha representa más agua utilizada, por lo que reducir el tiempo puede hacer una diferencia significativa."
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"pregunta": "¿Quién fue Napoleón Bonaparte?",
|
||||
"opciones": ["Rey de España", "Emperador de Francia", "Presidente de Italia", "General inglés"],
|
||||
"pregunta": "¿Qué porcentaje aproximado del agua que utilizamos en casa se puede desperdiciar por fugas?",
|
||||
"opciones": ["Ninguna, las fugas no representan un problema", "Una cantidad importante si no se detectan y reparan", "Exactamente el 90% del consumo"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "Napoleón Bonaparte fue Emperador de Francia entre 1804 y 1815."
|
||||
"retroalimentacion": "Revisar periódicamente llaves, tuberías y sanitarios ayuda a detectar pérdidas que muchas veces pasan desapercibidas.",
|
||||
"reflexion": "Revisar periódicamente llaves, tuberías y sanitarios ayuda a detectar pérdidas que muchas veces pasan desapercibidas.",
|
||||
"datoCurioso": "Algunas fugas pueden ser silenciosas y difíciles de detectar, pero aun así desperdiciar agua continuamente."
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"pregunta": "¿En qué país se originó la Revolución Industrial?",
|
||||
"opciones": ["Francia", "Alemania", "Estados Unidos", "Inglaterra"],
|
||||
"correcta": 3,
|
||||
"retroalimentacion": "La Revolución Industrial comenzó en Inglaterra a mediados del siglo XVIII."
|
||||
"pregunta": "¿Qué actividad tiene una huella hídrica?",
|
||||
"opciones": ["Solo beber agua", "Solo bañarse", "Prácticamente todos los productos y actividades que realizamos"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "El agua que utilizamos no siempre es visible; también está presente en la producción de alimentos, ropa, energía y otros productos.",
|
||||
"reflexion": "El agua que utilizamos no siempre es visible; también está presente en la producción de alimentos, ropa, energía y otros productos.",
|
||||
"datoCurioso": "La huella hídrica considera el agua utilizada directa e indirectamente para producir bienes y servicios."
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"pregunta": "¿Cuál fue el primer país en conceder el voto a la mujer?",
|
||||
"opciones": ["Francia", "Nueva Zelanda", "Estados Unidos", "Suecia"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "Nueva Zelanda fue el primer país en conceder el voto a la mujer en 1893."
|
||||
"pregunta": "¿Cuál de estas acciones ayuda a ahorrar agua al lavar ropa?",
|
||||
"opciones": ["Utilizar la lavadora con cargas completas", "Lavar una sola prenda por ciclo", "Usar siempre el ciclo más largo"],
|
||||
"correcta": 0,
|
||||
"retroalimentacion": "Aprovechar mejor cada ciclo de lavado permite utilizar menos agua y energía por prenda.",
|
||||
"reflexion": "Aprovechar mejor cada ciclo de lavado permite utilizar menos agua y energía por prenda.",
|
||||
"datoCurioso": "Planificar las cargas de lavado es una forma sencilla de hacer más eficiente el uso de los recursos."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"nombre": "Geografía",
|
||||
"color": "#059669",
|
||||
"emoji": "🌍",
|
||||
"nombre": "Residuos y Circularidad",
|
||||
"color": "#16a34a",
|
||||
"emoji": "♻️",
|
||||
"preguntas": [
|
||||
{
|
||||
"id": 9,
|
||||
"pregunta": "¿Cuál es el río más largo del mundo?",
|
||||
"opciones": ["Amazonas", "Nilo", "Yangtsé", "Misisipi"],
|
||||
"pregunta": "¿Todo lo que se tira en el contenedor de reciclaje termina reciclándose?",
|
||||
"opciones": ["Sí, siempre", "No, si está mal separado o sucio contamina el resto"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "El río Nilo, con aproximadamente 6,650 km, es considerado el río más largo del mundo."
|
||||
"retroalimentacion": "Separar bien nuestros residuos, informándonos antes de tirarlos, evita que se desperdicie el esfuerzo de reciclar.",
|
||||
"reflexion": "Separar bien nuestros residuos, informándonos antes de tirarlos, evita que se desperdicie el esfuerzo de reciclar.",
|
||||
"datoCurioso": "Un solo cartón con grasa de pizza puede contaminar el resto del material en el contenedor de reciclaje."
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"pregunta": "¿Cuál es el país más grande del mundo por superficie?",
|
||||
"opciones": ["China", "Canadá", "Estados Unidos", "Rusia"],
|
||||
"correcta": 3,
|
||||
"retroalimentacion": "Rusia es el país más grande del mundo con 17.1 millones de km²."
|
||||
"pregunta": "¿La economía circular —reducir, reutilizar, reparar, reciclar— es algo que solo pueden aplicar las grandes industrias?",
|
||||
"opciones": ["Sí, requiere infraestructura industrial", "No, cualquier persona puede aplicarla desde casa"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "La circularidad comienza en las decisiones diarias de cada persona: compostar, reparar, reutilizar y elegir productos duraderos.",
|
||||
"reflexion": "La circularidad comienza en las decisiones diarias de cada persona: compostar, reparar, reutilizar y elegir productos duraderos.",
|
||||
"datoCurioso": "Todos podemos aplicar la economía circular en casa, en la escuela y en la comunidad; no es exclusiva de las grandes industrias."
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"pregunta": "¿En qué continente se encuentra el desierto del Sahara?",
|
||||
"opciones": ["Asia", "América", "África", "Australia"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "El Sahara se encuentra en África y es el desierto cálido más grande del mundo."
|
||||
"pregunta": "¿Cuál es la mejor forma de reducir la cantidad de residuos que generamos?",
|
||||
"opciones": ["Comprar más productos desechables", "Reducir el consumo innecesario", "Comprar productos con más empaques"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "El mejor residuo es el que no se genera. Antes de comprar, podemos preguntarnos si realmente necesitamos el producto.",
|
||||
"reflexion": "El mejor residuo es el que no se genera. Antes de comprar, podemos preguntarnos si realmente necesitamos el producto.",
|
||||
"datoCurioso": "La prevención y reducción de residuos son preferibles a tener que gestionar los residuos después de que ya fueron generados."
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"pregunta": "¿Cuál es la capital de Australia?",
|
||||
"opciones": ["Sídney", "Melbourne", "Canberra", "Brisbane"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "Canberra es la capital de Australia, no Sídney como muchos creen."
|
||||
"pregunta": "¿Qué significa reutilizar un producto?",
|
||||
"opciones": ["Usarlo nuevamente para el mismo u otro propósito", "Tirarlo después del primer uso", "Comprar uno nuevo para reemplazarlo"],
|
||||
"correcta": 0,
|
||||
"retroalimentacion": "Reutilizar prolonga la vida útil de los productos y evita generar nuevos residuos.",
|
||||
"reflexion": "Reutilizar prolonga la vida útil de los productos y evita generar nuevos residuos.",
|
||||
"datoCurioso": "Muchos objetos cotidianos pueden tener una segunda vida antes de convertirse en residuos."
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"pregunta": "¿Cuántos océanos hay en el planeta?",
|
||||
"opciones": ["3", "4", "5", "6"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "Hay 5 océanos: Pacífico, Atlántico, Índico, Ártico y Antártico."
|
||||
"pregunta": "¿Qué tipo de residuos pueden utilizarse para hacer composta?",
|
||||
"opciones": ["Restos de frutas y verduras", "Pilas y baterías", "Vidrio y aluminio"],
|
||||
"correcta": 0,
|
||||
"retroalimentacion": "Los residuos orgánicos pueden transformarse en un recurso en lugar de terminar en un relleno sanitario.",
|
||||
"reflexion": "Los residuos orgánicos pueden transformarse en un recurso en lugar de terminar en un relleno sanitario.",
|
||||
"datoCurioso": "La composta permite aprovechar materia orgánica y convertirla en un material que puede mejorar la calidad del suelo."
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"pregunta": "¿Cuál es el volcán activo más alto del mundo?",
|
||||
"opciones": ["Etna", "Kilimanjaro", "Ojos del Salado", "Cotopaxi"],
|
||||
"correcta": 3,
|
||||
"retroalimentacion": "El Cotopaxi en Ecuador es uno de los volcanes activos más altos del mundo con 5,897 metros."
|
||||
"pregunta": "¿Por qué es importante separar correctamente los residuos desde el origen?",
|
||||
"opciones": ["Para facilitar su aprovechamiento y disposición adecuada", "Para que todos los residuos se mezclen mejor", "Porque los residuos separados pesan menos"],
|
||||
"correcta": 0,
|
||||
"retroalimentacion": "La separación comienza en el lugar donde generamos el residuo. Si lo hacemos correctamente, ayudamos a que los materiales puedan aprovecharse mejor.",
|
||||
"reflexion": "La separación comienza en el lugar donde generamos el residuo. Si lo hacemos correctamente, ayudamos a que los materiales puedan aprovecharse mejor.",
|
||||
"datoCurioso": "En TIP México los residuos se separan en orgánicos, inorgánicos reciclables, inorgánicos no reciclables y papel."
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"pregunta": "¿En qué país se encuentra la Torre de Pisa?",
|
||||
"opciones": ["España", "Francia", "Italia", "Portugal"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "La Torre de Pisa se encuentra en la ciudad de Pisa, Italia."
|
||||
"pregunta": "¿Qué deberíamos hacer con las pilas usadas?",
|
||||
"opciones": ["Tirarlas junto con la basura común", "Llevarlas a un punto de recolección adecuado", "Tirarlas al drenaje"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "Algunos residuos requieren un manejo especial para evitar impactos ambientales.",
|
||||
"reflexion": "Algunos residuos requieren un manejo especial para evitar impactos ambientales.",
|
||||
"datoCurioso": "Las pilas pueden contener materiales que requieren una disposición diferente a la de los residuos comunes."
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"pregunta": "¿Cuál es el país más pequeño del mundo?",
|
||||
"opciones": ["Mónaco", "San Marino", "Liechtenstein", "Ciudad del Vaticano"],
|
||||
"correcta": 3,
|
||||
"retroalimentacion": "Ciudad del Vaticano es el país más pequeño del mundo con apenas 0.44 km²."
|
||||
"pregunta": "¿Cuál de estas opciones genera menos residuos?",
|
||||
"opciones": ["Usar una botella desechable nueva todos los días", "Utilizar una botella reutilizable", "Comprar varias botellas para tener reservas"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "Sustituir productos de un solo uso por alternativas reutilizables es una forma sencilla de reducir la cantidad de residuos que generamos.",
|
||||
"reflexion": "Sustituir productos de un solo uso por alternativas reutilizables es una forma sencilla de reducir la cantidad de residuos que generamos.",
|
||||
"datoCurioso": "Las decisiones de consumo cotidiano pueden ayudar a disminuir significativamente los residuos de un solo uso."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"nombre": "Ciencia",
|
||||
"color": "#DC2626",
|
||||
"emoji": "🔬",
|
||||
"nombre": "Energía",
|
||||
"color": "#ea580c",
|
||||
"emoji": "⚡",
|
||||
"preguntas": [
|
||||
{
|
||||
"id": 17,
|
||||
"pregunta": "¿Cuál es el elemento más abundante en el universo?",
|
||||
"opciones": ["Oxígeno", "Carbono", "Hidrógeno", "Helio"],
|
||||
"pregunta": "¿Cuánta menos energía consumen los focos LED comparados con los incandescentes?",
|
||||
"opciones": ["20%", "50%", "Hasta 80%"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "El hidrógeno es el elemento más abundante del universo, constituyendo el 75% de su masa."
|
||||
"retroalimentacion": "Cambiar la iluminación es una de las acciones más simples y de mayor retorno para reducir el consumo energético en casa y en el trabajo.",
|
||||
"reflexion": "Cambiar la iluminación es una de las acciones más simples y de mayor retorno para reducir el consumo energético en casa y en el trabajo.",
|
||||
"datoCurioso": "Cambiar un solo foco incandescente por uno LED puede significar un ahorro de cientos de pesos al año, además de reducir el consumo de energía."
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"pregunta": "¿A qué velocidad viaja la luz en el vacío?",
|
||||
"opciones": ["200,000 km/s", "300,000 km/s", "400,000 km/s", "150,000 km/s"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "La luz viaja a aproximadamente 299,792 km/s en el vacío."
|
||||
"pregunta": "¿Los aparatos electrónicos en \"modo espera\" (standby) siguen consumiendo energía aunque parezcan apagados?",
|
||||
"opciones": ["Sí, es lo que se conoce como \"consumo fantasma\"", "No, si la pantalla está apagada no consumen nada"],
|
||||
"correcta": 0,
|
||||
"retroalimentacion": "Desconectar los aparatos o usar regletas con interruptor es un cambio pequeño que evita un gasto de energía invisible pero constante.",
|
||||
"reflexion": "Desconectar los aparatos o usar regletas con interruptor es un cambio pequeño que evita un gasto de energía invisible pero constante.",
|
||||
"datoCurioso": "Un televisor en modo espera sigue consumiendo electricidad aunque parezca apagado; este consumo fantasma, multiplicado por millones de personas, es significativo."
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"pregunta": "¿Cuántos huesos tiene el cuerpo humano adulto?",
|
||||
"opciones": ["196", "206", "216", "226"],
|
||||
"pregunta": "¿Qué significa ser eficiente energéticamente?",
|
||||
"opciones": ["Utilizar más energía para obtener mejores resultados", "Obtener el mismo resultado utilizando menos energía", "No utilizar energía nunca"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "El cuerpo humano adulto tiene 206 huesos."
|
||||
"retroalimentacion": "La eficiencia energética no significa dejar de hacer las cosas, sino hacerlas mejor utilizando menos recursos.",
|
||||
"reflexion": "La eficiencia energética no significa dejar de hacer las cosas, sino hacerlas mejor utilizando menos recursos.",
|
||||
"datoCurioso": "Un equipo eficiente puede ofrecer el mismo servicio que otro utilizando menos electricidad."
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"pregunta": "¿Cuál es el planeta más grande del sistema solar?",
|
||||
"opciones": ["Saturno", "Neptuno", "Júpiter", "Urano"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "Júpiter es el planeta más grande del sistema solar."
|
||||
"pregunta": "¿Qué fuente de energía aprovechan los paneles solares?",
|
||||
"opciones": ["La energía del viento", "La energía del sol", "La energía de las olas"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "Las energías renovables aprovechan recursos naturales que se regeneran continuamente.",
|
||||
"reflexion": "Las energías renovables aprovechan recursos naturales que se regeneran continuamente.",
|
||||
"datoCurioso": "La energía solar puede utilizarse para generar electricidad y reducir la dependencia de fuentes fósiles."
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"pregunta": "¿Qué gas es el más abundante en la atmósfera terrestre?",
|
||||
"opciones": ["Oxígeno", "Dióxido de carbono", "Nitrógeno", "Argón"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "El nitrógeno constituye aproximadamente el 78% de la atmósfera terrestre."
|
||||
"pregunta": "¿Qué acción ayuda a reducir el consumo de energía en una oficina?",
|
||||
"opciones": ["Dejar los equipos encendidos toda la noche", "Apagar o configurar los equipos cuando no se utilizan", "Mantener todas las pantallas encendidas"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "La gestión responsable de los equipos de trabajo puede reducir consumos innecesarios.",
|
||||
"reflexion": "La gestión responsable de los equipos de trabajo puede reducir consumos innecesarios.",
|
||||
"datoCurioso": "Configurar computadoras y monitores para entrar en modo ahorro de energía ayuda a reducir el consumo durante los periodos de inactividad."
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"pregunta": "¿Cuántos cromosomas tiene una célula humana normal?",
|
||||
"opciones": ["23", "44", "46", "48"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "Las células humanas normales tienen 46 cromosomas, organizados en 23 pares."
|
||||
"pregunta": "¿Qué ocurre cuando utilizamos menos energía eléctrica?",
|
||||
"opciones": ["Siempre aumentan las emisiones", "Podemos reducir el consumo de recursos y las emisiones asociadas a la generación de electricidad", "No ocurre nada"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "Ahorrar energía puede beneficiar tanto al medio ambiente como a la economía de las personas y las organizaciones.",
|
||||
"reflexion": "Ahorrar energía puede beneficiar tanto al medio ambiente como a la economía de las personas y las organizaciones.",
|
||||
"datoCurioso": "El impacto ambiental de consumir electricidad depende, entre otros factores, de cómo se genera esa electricidad."
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"pregunta": "¿Quién formuló la teoría de la relatividad?",
|
||||
"opciones": ["Isaac Newton", "Albert Einstein", "Nikola Tesla", "Stephen Hawking"],
|
||||
"pregunta": "¿Cuál de estas acciones ayuda a utilizar de manera más eficiente el aire acondicionado?",
|
||||
"opciones": ["Mantener puertas y ventanas abiertas", "Mantener cerrados los espacios climatizados", "Encenderlo con las puertas abiertas"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "Albert Einstein formuló la teoría de la relatividad especial en 1905 y la general en 1915."
|
||||
"retroalimentacion": "Evitar la pérdida de aire acondicionado permite que el equipo trabaje menos y consuma menos energía.",
|
||||
"reflexion": "Evitar la pérdida de aire acondicionado permite que el equipo trabaje menos y consuma menos energía.",
|
||||
"datoCurioso": "El uso adecuado de los sistemas de climatización puede ayudar a reducir significativamente el consumo energético."
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"pregunta": "¿Cuál es el símbolo químico del oro?",
|
||||
"opciones": ["Go", "Ag", "Au", "Or"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "El símbolo químico del oro es Au, del latín Aurum."
|
||||
"pregunta": "¿Qué tipo de iluminación suele ser más eficiente?",
|
||||
"opciones": ["La iluminación incandescente", "La iluminación LED", "Todas consumen exactamente lo mismo"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "Elegir tecnologías eficientes permite obtener la iluminación que necesitamos utilizando menos energía.",
|
||||
"reflexion": "Elegir tecnologías eficientes permite obtener la iluminación que necesitamos utilizando menos energía.",
|
||||
"datoCurioso": "Además de consumir menos energía, los focos LED suelen tener una vida útil más larga que los focos incandescentes."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"nombre": "Arte y Literatura",
|
||||
"color": "#D97706",
|
||||
"emoji": "🎨",
|
||||
"nombre": "Electromovilidad y Transporte Sostenible",
|
||||
"color": "#7c3aed",
|
||||
"emoji": "🚗",
|
||||
"preguntas": [
|
||||
{
|
||||
"id": 25,
|
||||
"pregunta": "¿Quién escribió 'Cien años de soledad'?",
|
||||
"opciones": ["Mario Vargas Llosa", "Pablo Neruda", "Gabriel García Márquez", "Jorge Luis Borges"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "Gabriel García Márquez escribió Cien años de soledad en 1967, obra cumbre del realismo mágico."
|
||||
"pregunta": "¿Qué acción tiene mayor impacto positivo para reducir tu huella de carbono en el transporte diario?",
|
||||
"opciones": ["Usar transporte público o compartir el auto", "Viajar siempre en auto propio, aunque sea solo"],
|
||||
"correcta": 0,
|
||||
"retroalimentacion": "Elegir cómo nos transportamos es una de las decisiones cotidianas con mayor peso en nuestra huella ambiental.",
|
||||
"reflexion": "Elegir cómo nos transportamos es una de las decisiones cotidianas con mayor peso en nuestra huella ambiental.",
|
||||
"datoCurioso": "Un solo autobús puede reemplazar hasta 40 autos, reduciendo la congestión, el consumo de combustible y la contaminación."
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"pregunta": "¿Quién pintó 'La noche estrellada'?",
|
||||
"opciones": ["Pablo Picasso", "Claude Monet", "Salvador Dalí", "Vincent van Gogh"],
|
||||
"correcta": 3,
|
||||
"retroalimentacion": "La noche estrellada fue pintada por Vincent van Gogh en 1889."
|
||||
"pregunta": "¿TIP México cuenta con infraestructura de carga para vehículos eléctricos e híbridos en sus instalaciones?",
|
||||
"opciones": ["Sí, cuenta con 10 cargadores eléctricos", "No, aún no se ha implementado"],
|
||||
"correcta": 0,
|
||||
"retroalimentacion": "La movilidad sostenible también se construye desde la infraestructura que facilita la transición hacia vehículos de menor impacto ambiental.",
|
||||
"reflexion": "La movilidad sostenible también se construye desde la infraestructura que facilita la transición hacia vehículos de menor impacto ambiental.",
|
||||
"datoCurioso": "TIP México cuenta con 10 cargadores eléctricos instalados como parte de su compromiso con la movilidad sostenible."
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"pregunta": "¿En qué país nació Mozart?",
|
||||
"opciones": ["Alemania", "Austria", "Italia", "Francia"],
|
||||
"pregunta": "¿Cuál es la principal fuente de energía de un vehículo eléctrico?",
|
||||
"opciones": ["Gasolina", "Electricidad almacenada en una batería", "Diésel"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "Wolfgang Amadeus Mozart nació en Salzburgo, Austria, en 1756."
|
||||
"retroalimentacion": "La electromovilidad cambia la forma en que utilizamos la energía para movernos.",
|
||||
"reflexion": "La electromovilidad cambia la forma en que utilizamos la energía para movernos.",
|
||||
"datoCurioso": "Los vehículos eléctricos utilizan uno o más motores eléctricos alimentados por la energía almacenada en sus baterías."
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"pregunta": "¿Quién escribió 'Don Quijote de la Mancha'?",
|
||||
"opciones": ["Lope de Vega", "Miguel de Cervantes", "Francisco de Quevedo", "Garcilaso de la Vega"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "Don Quijote de la Mancha fue escrita por Miguel de Cervantes y publicada en 1605."
|
||||
"pregunta": "¿Qué caracteriza a un vehículo híbrido?",
|
||||
"opciones": ["Combina un motor de combustión con un sistema eléctrico", "Funciona únicamente con gasolina", "No utiliza ningún tipo de batería"],
|
||||
"correcta": 0,
|
||||
"retroalimentacion": "La tecnología híbrida combina diferentes sistemas de propulsión para mejorar la eficiencia en determinadas condiciones.",
|
||||
"reflexion": "La tecnología híbrida combina diferentes sistemas de propulsión para mejorar la eficiencia en determinadas condiciones.",
|
||||
"datoCurioso": "Los vehículos híbridos pueden utilizar un motor de combustión interna y uno o más motores eléctricos."
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"pregunta": "¿Quién compuso la Quinta Sinfonía?",
|
||||
"opciones": ["Mozart", "Bach", "Beethoven", "Chopin"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "La Quinta Sinfonía fue compuesta por Ludwig van Beethoven entre 1804 y 1808."
|
||||
"pregunta": "¿Qué es un vehículo híbrido enchufable (PHEV)?",
|
||||
"opciones": ["Un vehículo que puede cargarse conectándolo a una fuente eléctrica y también utiliza un motor de combustión", "Un vehículo que solo utiliza gasolina", "Un vehículo que no tiene batería"],
|
||||
"correcta": 0,
|
||||
"retroalimentacion": "Los vehículos híbridos enchufables combinan la posibilidad de conducir utilizando electricidad con la flexibilidad de un motor de combustión.",
|
||||
"reflexion": "Los vehículos híbridos enchufables combinan la posibilidad de conducir utilizando electricidad con la flexibilidad de un motor de combustión.",
|
||||
"datoCurioso": "Los PHEV pueden recorrer cierta distancia utilizando únicamente energía eléctrica, dependiendo del modelo y de la carga disponible."
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"pregunta": "¿En qué museo se encuentra la Mona Lisa?",
|
||||
"opciones": ["Museo del Prado", "Museo Británico", "Museo del Louvre", "Museo Uffizi"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "La Mona Lisa se encuentra en el Museo del Louvre en París, Francia."
|
||||
"pregunta": "¿Qué sucede con parte de la energía durante el frenado regenerativo?",
|
||||
"opciones": ["Puede recuperarse y almacenarse nuevamente en la batería", "Desaparece completamente", "Se transforma en gasolina"],
|
||||
"correcta": 0,
|
||||
"retroalimentacion": "Esta tecnología permite aprovechar parte de la energía del movimiento que normalmente se perdería durante el frenado.",
|
||||
"reflexion": "Esta tecnología permite aprovechar parte de la energía del movimiento que normalmente se perdería durante el frenado.",
|
||||
"datoCurioso": "El frenado regenerativo convierte parte de la energía cinética del vehículo en electricidad."
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"pregunta": "¿Quién escribió 'Romeo y Julieta'?",
|
||||
"opciones": ["Charles Dickens", "William Shakespeare", "Jane Austen", "Victor Hugo"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "Romeo y Julieta fue escrita por William Shakespeare alrededor de 1595."
|
||||
"pregunta": "¿Qué elemento es fundamental para impulsar la adopción de vehículos eléctricos?",
|
||||
"opciones": ["Una infraestructura adecuada de carga", "Eliminar todas las alternativas de transporte", "Evitar nuevas tecnologías"],
|
||||
"correcta": 0,
|
||||
"retroalimentacion": "Contar con puntos de carga accesibles facilita que más personas y empresas consideren la movilidad eléctrica.",
|
||||
"reflexion": "Contar con puntos de carga accesibles facilita que más personas y empresas consideren la movilidad eléctrica.",
|
||||
"datoCurioso": "La infraestructura de carga es un elemento clave para integrar los vehículos eléctricos a las actividades diarias."
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"pregunta": "¿Qué movimiento artístico lideró Salvador Dalí?",
|
||||
"opciones": ["Cubismo", "Impresionismo", "Surrealismo", "Expresionismo"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "Salvador Dalí fue uno de los principales representantes del Surrealismo."
|
||||
"pregunta": "¿Cuál de estas opciones puede ayudar a reducir las emisiones asociadas al transporte?",
|
||||
"opciones": ["Usar transporte público, compartir el auto o utilizar vehículos de menor emisión", "Utilizar siempre un vehículo individual aunque el trayecto sea corto", "Mantener el vehículo encendido cuando está estacionado"],
|
||||
"correcta": 0,
|
||||
"retroalimentacion": "La movilidad sostenible no depende de una única solución; combinar diferentes alternativas puede ayudarnos a reducir el impacto ambiental de nuestros traslados.",
|
||||
"reflexion": "La movilidad sostenible no depende de una única solución; combinar diferentes alternativas puede ayudarnos a reducir el impacto ambiental de nuestros traslados.",
|
||||
"datoCurioso": "La movilidad sostenible incluye opciones como el transporte público, la movilidad activa, el uso compartido de vehículos y la electromovilidad."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"bancoGenerico": [
|
||||
{
|
||||
"id": 101,
|
||||
"pregunta": "¿Cuál es el océano más grande del mundo?",
|
||||
"opciones": ["Atlántico", "Índico", "Pacífico", "Ártico"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "El océano Pacífico es el más grande, cubriendo más de 165 millones de km²."
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"pregunta": "¿Cuántos planetas tiene el sistema solar?",
|
||||
"opciones": ["7", "8", "9", "10"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "El sistema solar tiene 8 planetas desde que Plutón fue reclasificado en 2006."
|
||||
},
|
||||
{
|
||||
"id": 103,
|
||||
"pregunta": "¿Cuál es el animal terrestre más rápido?",
|
||||
"opciones": ["León", "Guepardo", "Antílope", "Caballo"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "El guepardo puede alcanzar velocidades de hasta 120 km/h, siendo el animal terrestre más rápido."
|
||||
},
|
||||
{
|
||||
"id": 104,
|
||||
"pregunta": "¿En qué año se fundó la ONU?",
|
||||
"opciones": ["1943", "1945", "1947", "1950"],
|
||||
"correcta": 1,
|
||||
"retroalimentacion": "La ONU fue fundada el 24 de octubre de 1945 tras el fin de la Segunda Guerra Mundial."
|
||||
},
|
||||
{
|
||||
"id": 105,
|
||||
"pregunta": "¿Cuál es el idioma más hablado en el mundo?",
|
||||
"opciones": ["Inglés", "Español", "Mandarín", "Hindi"],
|
||||
"correcta": 2,
|
||||
"retroalimentacion": "El mandarín es el idioma con más hablantes nativos, con más de 900 millones."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,11 +2,19 @@ const KEY_INTENTOS = 'ruleta_intentos_usados'
|
||||
const KEY_AGOTADOS = 'ruleta_intentos_agotados'
|
||||
const MAX_INTENTOS = 3
|
||||
|
||||
let intentosInfinitos = false
|
||||
|
||||
export function setIntentosInfinitos(valor: boolean) {
|
||||
intentosInfinitos = valor
|
||||
}
|
||||
|
||||
function getIntentosUsados(): number {
|
||||
if (intentosInfinitos) return 0
|
||||
return parseInt(sessionStorage.getItem(KEY_INTENTOS) ?? '0', 10)
|
||||
}
|
||||
|
||||
function setIntentosUsados(n: number): void {
|
||||
if (intentosInfinitos) return
|
||||
sessionStorage.setItem(KEY_INTENTOS, String(n))
|
||||
if (n >= MAX_INTENTOS) {
|
||||
sessionStorage.setItem(KEY_AGOTADOS, 'true')
|
||||
@@ -14,14 +22,17 @@ function setIntentosUsados(n: number): void {
|
||||
}
|
||||
|
||||
export function intentosAgotados(): boolean {
|
||||
if (intentosInfinitos) return false
|
||||
return sessionStorage.getItem(KEY_AGOTADOS) === 'true'
|
||||
}
|
||||
|
||||
export function intentosRestantes(): number {
|
||||
if (intentosInfinitos) return 999
|
||||
return Math.max(0, MAX_INTENTOS - getIntentosUsados())
|
||||
}
|
||||
|
||||
export function consumirIntento(): void {
|
||||
if (intentosInfinitos) return
|
||||
setIntentosUsados(getIntentosUsados() + 1)
|
||||
}
|
||||
|
||||
|
||||
+112
-38
@@ -1,13 +1,15 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import type { Pregunta, Categoria, PreguntasData } from '../types'
|
||||
import data from '../data/preguntas.json'
|
||||
import { sounds } from '../audio/AudioController'
|
||||
|
||||
const { categorias, bancoGenerico } = data as PreguntasData
|
||||
const MAX_PREGUNTAS = 10
|
||||
const MIN_CORRECTAS = 8
|
||||
const { categorias } = data as PreguntasData
|
||||
const MAX_PREGUNTAS = 12
|
||||
const PREGUNTAS_POR_CATEGORIA = 3
|
||||
const MIN_CORRECTAS = 10 // 80% de 12 para aprobar
|
||||
const SECCIONES = categorias.length
|
||||
const ANGULO_SECCION = 360 / SECCIONES
|
||||
export const TIEMPO_LIMITE = 30
|
||||
|
||||
export interface EstadoJuego {
|
||||
isSpinning: boolean
|
||||
@@ -20,11 +22,34 @@ export interface EstadoJuego {
|
||||
correctas: number
|
||||
completado: boolean
|
||||
aprobado: boolean
|
||||
preguntasUsadas: Record<number, number[]>
|
||||
bancoPreguntasUsadas: number[]
|
||||
/** Cuántas preguntas se han usado ya por categoría (id → conteo) */
|
||||
preguntasUsadasPorCategoria: Record<number, number>
|
||||
/** Pool de 3 preguntas pre-seleccionadas por categoría (id → preguntas) */
|
||||
preguntasPool: Record<number, Pregunta[]>
|
||||
tiempoRestante: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-selecciona aleatoriamente 3 preguntas de cada categoría.
|
||||
* Se ejecuta una sola vez al montar el hook.
|
||||
*/
|
||||
function crearPool(): Record<number, Pregunta[]> {
|
||||
const pool: Record<number, Pregunta[]> = {}
|
||||
for (const cat of categorias) {
|
||||
const indices = Array.from({ length: cat.preguntas.length }, (_, i) => i)
|
||||
// Fisher‑Yates shuffle
|
||||
for (let i = indices.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[indices[i], indices[j]] = [indices[j], indices[i]]
|
||||
}
|
||||
pool[cat.id] = indices.slice(0, PREGUNTAS_POR_CATEGORIA).map((i) => cat.preguntas[i])
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
export function useRuleta() {
|
||||
const poolInicial = useMemo(() => crearPool(), [])
|
||||
|
||||
const [estado, setEstado] = useState<EstadoJuego>({
|
||||
isSpinning: false,
|
||||
rotacion: 0,
|
||||
@@ -36,26 +61,66 @@ export function useRuleta() {
|
||||
correctas: 0,
|
||||
completado: false,
|
||||
aprobado: false,
|
||||
preguntasUsadas: {},
|
||||
bancoPreguntasUsadas: [],
|
||||
preguntasUsadasPorCategoria: {},
|
||||
preguntasPool: poolInicial,
|
||||
tiempoRestante: TIEMPO_LIMITE,
|
||||
})
|
||||
|
||||
const rotacionRef = useRef(0)
|
||||
|
||||
function seleccionarPregunta(categoria: Categoria, preguntasUsadas: Record<number, number[]>, bancoPreguntasUsadas: number[]): { pregunta: Pregunta, esGenerica: boolean } {
|
||||
const usadas = preguntasUsadas[categoria.id] ?? []
|
||||
const disponibles = categoria.preguntas.filter((_, i) => !usadas.includes(i))
|
||||
// ─── Temporizador por pregunta ───────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (estado.mostrarFeedback || !estado.preguntaActual || estado.completado) return
|
||||
|
||||
if (disponibles.length > 0) {
|
||||
const idx = Math.floor(Math.random() * disponibles.length)
|
||||
return { pregunta: disponibles[idx], esGenerica: false }
|
||||
const interval = setInterval(() => {
|
||||
setEstado((prev) => {
|
||||
if (prev.mostrarFeedback || !prev.preguntaActual) return prev
|
||||
|
||||
if (prev.tiempoRestante <= 1) {
|
||||
sounds.incorrect?.play()
|
||||
const nuevasRespondidas = prev.preguntasRespondidas + 1
|
||||
const completado = nuevasRespondidas >= MAX_PREGUNTAS
|
||||
const aprobado = completado && prev.correctas >= MIN_CORRECTAS
|
||||
return {
|
||||
...prev,
|
||||
tiempoRestante: 0,
|
||||
preguntasRespondidas: nuevasRespondidas,
|
||||
mostrarFeedback: true,
|
||||
feedbackCorrecto: false,
|
||||
completado,
|
||||
aprobado,
|
||||
}
|
||||
}
|
||||
|
||||
return { ...prev, tiempoRestante: prev.tiempoRestante - 1 }
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [estado.preguntaActual?.id, estado.mostrarFeedback, estado.completado])
|
||||
|
||||
/**
|
||||
* Busca la siguiente pregunta disponible.
|
||||
* 1. Intenta usar la categoría donde cayó la ruleta (si le quedan preguntas).
|
||||
* 2. Si no, recorre las demás categorías en orden circular.
|
||||
* 3. Retorna null si ya se usaron las 12 preguntas.
|
||||
*/
|
||||
function obtenerSiguientePregunta(
|
||||
catIndice: number,
|
||||
usadasPorCat: Record<number, number>,
|
||||
pool: Record<number, Pregunta[]>,
|
||||
): { pregunta: Pregunta; categoria: Categoria } | null {
|
||||
// Recorrer desde catIndice en círculo
|
||||
for (let vuelta = 0; vuelta < SECCIONES; vuelta++) {
|
||||
const idx = (catIndice + vuelta) % SECCIONES
|
||||
const cat = categorias[idx]
|
||||
const usadas = usadasPorCat[cat.id] ?? 0
|
||||
const preguntasCat = pool[cat.id] ?? []
|
||||
if (usadas < preguntasCat.length) {
|
||||
return { pregunta: preguntasCat[usadas], categoria: cat }
|
||||
}
|
||||
}
|
||||
|
||||
// Banco genérico
|
||||
const disponiblesGenericas = bancoGenerico.filter((_, i) => !bancoPreguntasUsadas.includes(i))
|
||||
const pool = disponiblesGenericas.length > 0 ? disponiblesGenericas : bancoGenerico
|
||||
const idx = Math.floor(Math.random() * pool.length)
|
||||
return { pregunta: pool[idx], esGenerica: true }
|
||||
return null
|
||||
}
|
||||
|
||||
function girar() {
|
||||
@@ -73,33 +138,36 @@ export function useRuleta() {
|
||||
setTimeout(() => {
|
||||
const anguloNormalizado = ((360 - (rotacionRef.current % 360)) + 360) % 360
|
||||
const indiceCategoria = Math.floor(anguloNormalizado / ANGULO_SECCION) % SECCIONES
|
||||
const categoria = categorias[indiceCategoria]
|
||||
|
||||
setEstado((prev) => {
|
||||
const { pregunta, esGenerica } = seleccionarPregunta(categoria, prev.preguntasUsadas, prev.bancoPreguntasUsadas)
|
||||
const originalIdx = esGenerica
|
||||
? bancoGenerico.indexOf(pregunta)
|
||||
: categoria.preguntas.indexOf(pregunta)
|
||||
const resultado = obtenerSiguientePregunta(
|
||||
indiceCategoria,
|
||||
prev.preguntasUsadasPorCategoria,
|
||||
prev.preguntasPool,
|
||||
)
|
||||
|
||||
if (!resultado) {
|
||||
// Ya no hay preguntas — marcar como completado
|
||||
return {
|
||||
...prev,
|
||||
completado: true,
|
||||
aprobado: prev.correctas >= MIN_CORRECTAS,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
preguntaActual: pregunta,
|
||||
categoriaActual: categoria,
|
||||
preguntasUsadas: esGenerica ? prev.preguntasUsadas : {
|
||||
...prev.preguntasUsadas,
|
||||
[categoria.id]: [...(prev.preguntasUsadas[categoria.id] ?? []), originalIdx],
|
||||
},
|
||||
bancoPreguntasUsadas: esGenerica
|
||||
? [...prev.bancoPreguntasUsadas, originalIdx]
|
||||
: prev.bancoPreguntasUsadas,
|
||||
preguntaActual: resultado.pregunta,
|
||||
categoriaActual: resultado.categoria,
|
||||
tiempoRestante: TIEMPO_LIMITE,
|
||||
}
|
||||
})
|
||||
}, 4000)
|
||||
}
|
||||
|
||||
function responder(indiceOpcion: number | null) {
|
||||
if (!estado.preguntaActual) return
|
||||
const esCorrecta = indiceOpcion !== null && indiceOpcion === estado.preguntaActual.correcta
|
||||
function responder(indiceOpcion: number) {
|
||||
if (!estado.preguntaActual || !estado.categoriaActual) return
|
||||
const esCorrecta = indiceOpcion === estado.preguntaActual.correcta
|
||||
|
||||
if (esCorrecta) sounds.correct?.play()
|
||||
else sounds.incorrect?.play()
|
||||
@@ -117,6 +185,11 @@ export function useRuleta() {
|
||||
feedbackCorrecto: esCorrecta,
|
||||
completado,
|
||||
aprobado,
|
||||
preguntasUsadasPorCategoria: {
|
||||
...prev.preguntasUsadasPorCategoria,
|
||||
[prev.categoriaActual!.id]:
|
||||
(prev.preguntasUsadasPorCategoria[prev.categoriaActual!.id] ?? 0) + 1,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -127,8 +200,9 @@ export function useRuleta() {
|
||||
categoriaActual: null,
|
||||
mostrarFeedback: false,
|
||||
isSpinning: false,
|
||||
tiempoRestante: TIEMPO_LIMITE,
|
||||
}))
|
||||
}
|
||||
|
||||
return { estado, categorias, girar, responder, cerrarFeedback, MAX_PREGUNTAS, MIN_CORRECTAS }
|
||||
return { estado, categorias, girar, responder, cerrarFeedback, MAX_PREGUNTAS, MIN_CORRECTAS, TIEMPO_LIMITE }
|
||||
}
|
||||
|
||||
@@ -1,23 +1,43 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useScale } from '../hooks/useScale'
|
||||
import Header from '../components/layout/Header'
|
||||
import Footer from '../components/layout/Footer'
|
||||
import ActivityBar from '../components/ui/ActivityBar'
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: ReactNode
|
||||
/** Props para el ActivityBar. Si no se pasa, la pantalla hija debe renderizar su propio ActivityBar. */
|
||||
barProps?: {
|
||||
preguntaActual?: number
|
||||
totalPreguntas?: number
|
||||
mensaje: string
|
||||
puedeAnterior?: boolean
|
||||
puedeSiguiente?: boolean
|
||||
onPrev?: () => void
|
||||
onNext?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
export default function MainLayout({ children }: MainLayoutProps) {
|
||||
export default function MainLayout({ children, barProps }: MainLayoutProps) {
|
||||
const scaleRef = useScale()
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen overflow-hidden bg-white relative">
|
||||
<div ref={scaleRef} className="bg-gray-100 flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1 flex items-center justify-center px-4 py-8">
|
||||
<main className="flex-1 flex flex-col items-center justify-center px-0 pt-8 overflow-hidden" style={{ minHeight: 0 }}>
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
{barProps && (
|
||||
<ActivityBar
|
||||
preguntaActual={barProps.preguntaActual ?? 0}
|
||||
totalPreguntas={barProps.totalPreguntas ?? 0}
|
||||
mensaje={barProps.mensaje}
|
||||
puedeAnterior={barProps.puedeAnterior ?? false}
|
||||
puedeSiguiente={barProps.puedeSiguiente ?? false}
|
||||
onPrev={barProps.onPrev}
|
||||
onNext={barProps.onNext}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,10 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { setIntentosInfinitos } from './hooks/useIntentos'
|
||||
|
||||
// Activar intentos infinitos
|
||||
setIntentosInfinitos(true)
|
||||
|
||||
// Simulador SCORM solo en desarrollo
|
||||
if (import.meta.env.DEV) {
|
||||
|
||||
+4
-1
@@ -4,6 +4,10 @@ export interface Pregunta {
|
||||
opciones: string[]
|
||||
correcta: number
|
||||
retroalimentacion: string
|
||||
/** Texto de reflexión educativa (opcional, usa retroalimentacion si no se provee) */
|
||||
reflexion?: string
|
||||
/** Dato curioso para la sección "¿Sabías qué?" (opcional) */
|
||||
datoCurioso?: string
|
||||
}
|
||||
|
||||
export interface Categoria {
|
||||
@@ -16,5 +20,4 @@ export interface Categoria {
|
||||
|
||||
export interface PreguntasData {
|
||||
categorias: Categoria[]
|
||||
bancoGenerico: Pregunta[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user