This commit is contained in:
Raquel3312 2025-10-18 18:31:40 -06:00
parent c0bb948a41
commit 7c34d03728
48 changed files with 2859 additions and 166 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
VITE_API_URL=http://localhost:3000

58
package-lock.json generated
View File

@ -17,6 +17,7 @@
"@ionic/react-router": "^8.5.0",
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3",
"axios": "^1.12.2",
"ionicons": "^7.4.0",
"react": "19.0.0",
"react-dom": "19.0.0",
@ -4525,7 +4526,6 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/at-least-node": {
@ -4571,6 +4571,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axios/node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/babel-plugin-polyfill-corejs2": {
"version": "0.4.14",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
@ -4860,7 +4877,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -5094,7 +5110,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@ -5520,7 +5535,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@ -5584,7 +5598,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@ -5760,7 +5773,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -5770,7 +5782,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -5808,7 +5819,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@ -5821,7 +5831,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -6433,6 +6442,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@ -6493,7 +6522,6 @@
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@ -6574,7 +6602,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@ -6635,7 +6662,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@ -6660,7 +6686,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@ -6827,7 +6852,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -6906,7 +6930,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -6919,7 +6942,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@ -6935,7 +6957,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@ -8454,7 +8475,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -8521,7 +8541,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@ -8531,7 +8550,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"

View File

@ -21,6 +21,7 @@
"@ionic/react-router": "^8.5.0",
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3",
"axios": "^1.12.2",
"ionicons": "^7.4.0",
"react": "19.0.0",
"react-dom": "19.0.0",

BIN
public/Escudo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

BIN
public/hoja.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -1,87 +1,71 @@
import { Redirect, Route } from 'react-router-dom';
import {
IonApp,
IonIcon,
IonLabel,
IonRouterOutlet,
IonTabBar,
IonTabButton,
IonTabs,
setupIonicReact
} from '@ionic/react';
import { IonReactRouter } from '@ionic/react-router';
import { ellipse, square, triangle } from 'ionicons/icons';
import Tab1 from './pages/Tab1';
import Tab2 from './pages/Tab2';
import Tab3 from './pages/Tab3';
// src/App.tsx
import React from "react";
import { IonApp } from "@ionic/react";
import { IonReactRouter } from "@ionic/react-router";
import { IonRouterOutlet } from "@ionic/react";
import { Route, Redirect } from "react-router-dom";
/* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css';
// Páginas públicas
import Login from "./pages/Login";
import Registro from "./pages/Registro";
/* Basic CSS for apps built with Ionic */
import '@ionic/react/css/normalize.css';
import '@ionic/react/css/structure.css';
import '@ionic/react/css/typography.css';
// Layout nuevo con sidebar
import AppShell from "./pages/AppShell";
/* Optional CSS utils that can be commented out */
import '@ionic/react/css/padding.css';
import '@ionic/react/css/float-elements.css';
import '@ionic/react/css/text-alignment.css';
import '@ionic/react/css/text-transformation.css';
import '@ionic/react/css/flex-utils.css';
import '@ionic/react/css/display.css';
// Guard de autenticación
import RequireAuth from "./components/RequireAuth";
/**
* Ionic Dark Mode
* -----------------------------------------------------
* For more info, please see:
* https://ionicframework.com/docs/theming/dark-mode
*/
const hasToken = () =>
!!(localStorage.getItem("token") || sessionStorage.getItem("token"));
/* import '@ionic/react/css/palettes/dark.always.css'; */
/* import '@ionic/react/css/palettes/dark.class.css'; */
import '@ionic/react/css/palettes/dark.system.css';
/* Theme variables */
import './theme/variables.css';
setupIonicReact();
const App: React.FC = () => (
const App: React.FC = () => {
return (
<IonApp>
<IonReactRouter>
<IonTabs>
<IonRouterOutlet>
<Route exact path="/tab1">
<Tab1 />
{/* ===== PÚBLICAS ===== */}
<Route exact path="/login" component={Login} />
<Route exact path="/registro" component={Registro} />
{/* ===== PRIVADO: SHELL con sidebar en /app ===== */}
<Route path="/app">
{/* Verifica contra backend; si prefieres probar sin verificación, cambia a verify={false} */}
<RequireAuth verify={true}>
<AppShell />
</RequireAuth>
</Route>
<Route exact path="/tab2">
<Tab2 />
{/* ===== Redirects de compatibilidad (antiguas rutas) ===== */}
<Route exact path="/bienvenida">
<Redirect to="/app/inicio" />
</Route>
<Route path="/tab3">
<Tab3 />
<Route exact path="/categorias">
<Redirect to="/app/categorias" />
</Route>
{/* Redirección dinámica /categorias/:slug -> /app/categorias/:slug */}
<Route
path="/categorias/:slug"
render={({ match }) => (
<Redirect to={`/app/categorias/${match.params.slug}`} />
)}
/>
<Route path="/tabs">
<Redirect to="/app/inicio" />
</Route>
{/* ===== ROOT ===== */}
<Route exact path="/">
<Redirect to="/tab1" />
{hasToken() ? <Redirect to="/app/inicio" /> : <Redirect to="/login" />}
</Route>
</IonRouterOutlet>
<IonTabBar slot="bottom">
<IonTabButton tab="tab1" href="/tab1">
<IonIcon aria-hidden="true" icon={triangle} />
<IonLabel>Tab 1</IonLabel>
</IonTabButton>
<IonTabButton tab="tab2" href="/tab2">
<IonIcon aria-hidden="true" icon={ellipse} />
<IonLabel>Tab 2</IonLabel>
</IonTabButton>
<IonTabButton tab="tab3" href="/tab3">
<IonIcon aria-hidden="true" icon={square} />
<IonLabel>Tab 3</IonLabel>
</IonTabButton>
</IonTabBar>
</IonTabs>
</IonReactRouter>
</IonApp>
);
};
export default App;

View File

@ -0,0 +1,49 @@
// src/components/ActivityTimer.tsx
import { IonButton, IonText } from "@ionic/react";
import { useEffect, useRef, useState } from "react";
type Props = {
targetSec?: number; // objetivo opcional
};
export default function ActivityTimer({ targetSec }: Props) {
const [running, setRunning] = useState(false);
const [elapsed, setElapsed] = useState(0);
const ref = useRef<number | null>(null);
useEffect(() => {
if (running) {
ref.current = window.setInterval(() => setElapsed((e) => e + 1), 1000);
} else if (ref.current) {
clearInterval(ref.current);
ref.current = null;
}
return () => {
if (ref.current) clearInterval(ref.current);
};
}, [running]);
const mmss = new Date(elapsed * 1000).toISOString().substring(14, 19);
const goal = targetSec ? new Date(targetSec * 1000).toISOString().substring(14, 19) : undefined;
const completed = targetSec ? elapsed >= targetSec : false;
return (
<div className="timer-wrap">
<IonText className="timer-time">
{mmss}{goal ? ` / ${goal}` : ""}
</IonText>
<IonButton
size="small"
color={running ? "danger" : "success"}
onClick={() => setRunning((v) => !v)}
>
{running ? "Stop" : "Iniciar"}
</IonButton>
{completed && (
<IonText color="success" className="timer-done" style={{marginLeft: 8}}>
Completado
</IonText>
)}
</div>
);
}

View File

@ -0,0 +1,103 @@
import { useState } from 'react';
import {
IonButton, IonIcon, IonModal, IonHeader, IonToolbar, IonTitle,
IonContent, IonItem, IonLabel, IonInput, IonTextarea, IonToggle,
IonButtons, IonSpinner, IonToast
} from '@ionic/react';
import { addOutline, closeOutline, saveOutline } from 'ionicons/icons';
import { crearCategoria } from '../services/api';
type Props = { onCreated?: () => void };
export default function AddCategoriaButton({ onCreated }: Props) {
const [open, setOpen] = useState(false);
const [nombre, setNombre] = useState('');
const [descripcion, setDescripcion] = useState('');
const [activo, setActivo] = useState(true);
const [loading, setLoading] = useState(false);
const [toast, setToast] = useState<{open:boolean; msg:string}>({open:false, msg:''});
async function handleSave() {
if (!nombre.trim()) {
setToast({ open:true, msg:'El nombre es obligatorio' });
return;
}
try {
setLoading(true);
await crearCategoria({ nombre: nombre.trim(), descripcion: descripcion || null, activo });
setToast({ open:true, msg:'Categoría creada 🎉' });
setOpen(false);
setNombre(''); setDescripcion(''); setActivo(true);
onCreated?.(); // refresca la lista del padre
} catch (e:any) {
setToast({ open:true, msg: e?.message || 'Error al crear' });
} finally {
setLoading(false);
}
}
return (
<>
{/* Botón “Añadir” para poner en el header */}
<IonButton onClick={() => setOpen(true)}>
<IonIcon slot="start" icon={addOutline} />
Añadir
</IonButton>
<IonModal isOpen={open} onDidDismiss={() => setOpen(false)}>
<IonHeader>
<IonToolbar>
<IonTitle>Nueva categoría</IonTitle>
<IonButtons slot="end">
<IonButton onClick={() => setOpen(false)}>
<IonIcon icon={closeOutline} />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<IonItem>
<IonLabel position="stacked">Nombre *</IonLabel>
<IonInput
value={nombre}
onIonChange={e => setNombre(e.detail.value || '')}
placeholder="p.ej. Creativas"
required
/>
</IonItem>
<IonItem>
<IonLabel position="stacked">Descripción</IonLabel>
<IonTextarea
value={descripcion}
onIonChange={e => setDescripcion(e.detail.value || '')}
autoGrow
maxlength={500}
placeholder="Texto opcional"
/>
</IonItem>
<IonItem lines="none">
<IonLabel>Activo</IonLabel>
<IonToggle checked={activo} onIonChange={e => setActivo(e.detail.checked)} />
</IonItem>
<div className="ion-padding-top">
<IonButton expand="block" onClick={handleSave} disabled={loading}>
{loading ? <IonSpinner name="crescent" /> : <IonIcon slot="start" icon={saveOutline} />}
{loading ? 'Guardando...' : 'Guardar'}
</IonButton>
</div>
</IonContent>
</IonModal>
<IonToast
isOpen={toast.open}
message={toast.msg}
duration={1800}
onDidDismiss={() => setToast({open:false, msg:''})}
/>
</>
);
}

View File

@ -0,0 +1,228 @@
import {
IonList,
IonItem,
IonLabel,
IonBadge,
IonButton,
IonSkeletonText,
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonContent,
} from "@ionic/react";
import { useEffect, useState } from "react";
import api from "../lib/api";
import { startRegistro, finishRegistro } from "../services/Registro";
export type Actividad = {
id_actividad: number;
id_categoria: number;
nombre: string;
descripcion: string | null;
duracion_minutos: number;
dificultad: number | null;
puntos_base: number | null;
activo?: number;
realizada?: 0 | 1 | boolean; // 👈 viene del backend
};
async function fetchActividadesPorCategoria(categoriaId: number): Promise<Actividad[]> {
const { data } = await api.get(`/api/actividades?categoria=${categoriaId}&activo=1`);
return data;
}
type Props = { categoriaId: number };
export default function ListaActividades({ categoriaId }: Props) {
const [items, setItems] = useState<Actividad[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Modal de instrucciones
const [open, setOpen] = useState(false);
const [sel, setSel] = useState<Actividad | null>(null);
// Estado de ejecución: actividad -> id_registro
const [running, setRunning] = useState<Record<number, number | null>>({});
const [saving, setSaving] = useState<Record<number, boolean>>({});
useEffect(() => {
let alive = true;
setLoading(true);
setError(null);
fetchActividadesPorCategoria(categoriaId)
.then((data) => alive && setItems(data))
.catch((err) => {
console.error(err);
if (alive) {
setError("No se pudieron cargar las actividades.");
setItems([]);
}
})
.finally(() => alive && setLoading(false));
return () => { alive = false; };
}, [categoriaId]);
const abrirInstrucciones = (act: Actividad) => {
setSel(act);
setOpen(true);
};
async function handleStart(actId: number) {
try {
setSaving((s) => ({ ...s, [actId]: true }));
const { id_registro } = await startRegistro(actId);
setRunning((r) => ({ ...r, [actId]: id_registro }));
} catch (e: any) {
console.error(e);
alert(e?.response?.data?.error || "No se pudo iniciar la actividad");
} finally {
setSaving((s) => ({ ...s, [actId]: false }));
}
}
async function handleStop(actId: number, act?: Actividad) {
const regId = running[actId];
if (!regId) return;
try {
setSaving((s) => ({ ...s, [actId]: true }));
await finishRegistro(regId, {
puntos_obtenidos: act?.puntos_base ?? undefined,
});
setRunning((r) => ({ ...r, [actId]: null }));
// 👇 Marca como realizada permanentemente en UI
setItems((prev) =>
prev.map((x) =>
x.id_actividad === actId ? { ...x, realizada: 1 } : x
)
);
} catch (e) {
console.error(e);
alert("No se pudo finalizar la actividad");
} finally {
setSaving((s) => ({ ...s, [actId]: false }));
}
}
if (loading) {
return (
<>
<div style={{ background: "#fff", padding: 16, borderRadius: 12, marginBottom: 12, boxShadow: "0 10px 24px rgba(16,24,40,.06)" }}>
<IonSkeletonText animated style={{ width: "40%", height: "18px" }} />
<IonSkeletonText animated style={{ width: "70%", height: "14px", marginTop: 8 }} />
</div>
<div style={{ background: "#fff", padding: 16, borderRadius: 12, marginBottom: 12, boxShadow: "0 10px 24px rgba(16,24,40,.06)" }}>
<IonSkeletonText animated style={{ width: "48%", height: "18px" }} />
<IonSkeletonText animated style={{ width: "66%", height: "14px", marginTop: 8 }} />
</div>
</>
);
}
if (error) return <p>{error}</p>;
if (items.length === 0) return <p>No hay actividades disponibles.</p>;
return (
<>
<IonList>
{items.map((act) => {
const yaRealizada = Boolean(act.realizada);
return (
<IonItem
key={act.id_actividad}
lines="none"
className="actividad-item"
style={{
background: "#fff",
borderRadius: 16,
marginBottom: 14,
boxShadow: "0 12px 28px rgba(16,24,40,.08)",
opacity: yaRealizada ? 0.6 : 1,
}}
>
<IonLabel>
<h3 style={{ fontWeight: 700, marginBottom: 4 }}>
{act.nombre}{" "}
{yaRealizada && (
<IonBadge color="success" style={{ marginLeft: 8 }}>Realizada</IonBadge>
)}
</h3>
{/* Si no quieres la descripción, comenta esta línea */}
{/* {act.descripcion && <p style={{ color: "#667085", margin: 0 }}>{act.descripcion}</p>} */}
<div style={{ display: "flex", gap: 12, alignItems: "center", marginTop: 8 }}>
<IonBadge color="success">{act.duracion_minutos} min</IonBadge>
{act.dificultad != null && <IonBadge color="medium">dif. {act.dificultad}</IonBadge>}
{act.puntos_base != null && <IonBadge color="tertiary">{act.puntos_base} pts</IonBadge>}
</div>
</IonLabel>
<div style={{ display: "flex", gap: 8 }}>
<IonButton fill="outline" size="small" onClick={() => abrirInstrucciones(act)}>
INSTRUCCIONES
</IonButton>
{yaRealizada ? (
// Deshabilitado para siempre
<IonButton color="medium" size="small" disabled>
COMPLETADA
</IonButton>
) : running[act.id_actividad] ? (
<IonButton
color="medium"
size="small"
disabled={!!saving[act.id_actividad]}
onClick={() => handleStop(act.id_actividad, act)}
>
DETENER
</IonButton>
) : (
<IonButton
color="success"
size="small"
disabled={!!saving[act.id_actividad]}
onClick={() => handleStart(act.id_actividad)}
>
INICIAR
</IonButton>
)}
</div>
</IonItem>
);
})}
</IonList>
{/* Modal de instrucciones */}
<IonModal isOpen={open} onDidDismiss={() => setOpen(false)}>
<IonHeader>
<IonToolbar>
<IonTitle>{sel?.nombre || "Instrucciones"}</IonTitle>
<IonButtons slot="end">
<IonButton onClick={() => setOpen(false)}>Cerrar</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
{sel && (
<>
<div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
<IonBadge color="success">{sel.duracion_minutos} min</IonBadge>
{sel.dificultad != null && <IonBadge color="medium">dif. {sel.dificultad}</IonBadge>}
{sel.puntos_base != null && <IonBadge color="tertiary">{sel.puntos_base} pts</IonBadge>}
</div>
<p style={{ lineHeight: 1.6 }}>
{sel.descripcion || "Sin descripción disponible."}
</p>
</>
)}
</IonContent>
</IonModal>
</>
);
}

View File

@ -0,0 +1,15 @@
// src/components/LogoutButton.tsx
import { IonButton, IonIcon } from "@ionic/react";
import { logOutOutline } from "ionicons/icons";
import { logout } from "../services/auth";
type Props = { label?: string };
export default function LogoutButton({ label = "Cerrar sesión" }: Props) {
return (
<IonButton color="medium" onClick={() => logout()}>
<IonIcon slot="start" icon={logOutOutline} />
{label}
</IonButton>
);
}

View File

@ -0,0 +1,28 @@
// src/components/RedirectByRole.tsx
import React, { useEffect, useState } from "react";
import { Redirect } from "react-router-dom";
import api from "../services/api";
export default function RedirectByRole() {
const [to, setTo] = useState<string | null>(null);
useEffect(() => {
const token = localStorage.getItem("token") || sessionStorage.getItem("token");
if (!token) { setTo("/login"); return; }
// ✅ usa /api/auth/me
api.get("/api/auth/me")
.then(({ data }) => {
const u = data?.usuario || data?.user || data || {};
const roles = (u.roles || []).map((r: any) =>
(r?.nombre || r)?.toString().toLowerCase()
);
setTo(roles.includes("admin") ? "/app/inicio" : "/app/inicio");
// si quieres que admin vaya a otra, cámbialo a "/app/inicio" o "/bienvenida"
})
.catch(() => setTo("/login"));
}, []);
if (!to) return null;
return <Redirect to={to} />;
}

View File

@ -0,0 +1,42 @@
// src/components/RequireAdmin.tsx
import React, { ReactNode, useEffect, useState } from "react";
import { Redirect } from "react-router-dom";
import api from "../services/api";
import { logout } from "../services/auth";
type Props = { children: ReactNode };
export default function RequireAdmin({ children }: Props) {
const [state, setState] =
useState<"loading" | "admin" | "noauth" | "notadmin">("loading");
useEffect(() => {
let alive = true;
const token =
localStorage.getItem("token") || sessionStorage.getItem("token");
if (!token) { if (alive) setState("noauth"); return; }
// ✅ usa /api/auth/me
api.get("/api/auth/me")
.then(({ data }) => {
const u = data?.usuario || data?.user || data || {};
const roles = (u.roles || []).map((r: any) =>
(r?.nombre || r)?.toString().toLowerCase()
);
if (!alive) return;
if (roles.includes("admin")) setState("admin");
else setState("notadmin");
})
.catch(() => {
logout(false);
if (alive) setState("noauth");
});
return () => { alive = false; };
}, []);
if (state === "loading") return null;
if (state === "noauth") return <Redirect to="/login" />;
if (state === "notadmin") return <Redirect to="/app/inicio" />;
return <>{children}</>;
}

View File

@ -0,0 +1,60 @@
// src/components/RequireAuth.tsx
import React, { ReactNode, useEffect, useState } from "react";
import { Redirect } from "react-router-dom";
import api from "../services/api";
import { logout } from "../services/auth";
import { IonPage, IonContent, IonSpinner } from "@ionic/react";
type Props = { children: ReactNode; verify?: boolean };
export default function RequireAuth({ children, verify = true }: Props) {
const [ok, setOk] = useState<boolean | null>(null);
useEffect(() => {
let mounted = true;
const token =
localStorage.getItem("token") || sessionStorage.getItem("token");
if (!token) {
if (mounted) setOk(false);
return () => { mounted = false; };
}
if (!verify) {
if (mounted) setOk(true);
return () => { mounted = false; };
}
// ✅ verificación contra tu backend
api.get("/api/auth/me")
.then(() => mounted && setOk(true))
.catch(() => {
// limpia y deja que Redirect maneje la navegación
logout(false);
// quita cualquier header Authorization en memoria
if ((api.defaults.headers.common as any).Authorization) {
delete (api.defaults.headers.common as any).Authorization;
}
mounted && setOk(false);
});
return () => { mounted = false; };
}, [verify]);
// ⬇️ loader visible en lugar de “pantalla en blanco”
if (ok === null) {
return (
<IonPage>
<IonContent className="ion-padding" fullscreen>
<div style={{display:"flex",alignItems:"center",justifyContent:"center",height:"100%"}}>
<IonSpinner name="crescent" />
</div>
</IonContent>
</IonPage>
);
}
if (!ok) return <Redirect to="/login" />;
return <>{children}</>;
}

View File

@ -0,0 +1,62 @@
// src/components/SidebarMenu.tsx
import {
IonContent,
IonList,
IonMenu,
IonMenuToggle,
IonItem,
IonIcon,
IonLabel,
IonFooter,
IonToolbar,
IonTitle,
} from "@ionic/react";
import {
homeOutline,
gridOutline,
personCircleOutline,
} from "ionicons/icons";
import LogoutButton from "./LogoutButton";
export default function SidebarMenu() {
return (
<IonMenu contentId="main" type="overlay" side="start" menuId="main-menu">
<IonContent>
<IonToolbar>
<IonTitle style={{ paddingLeft: 12 }}>Institución educativa</IonTitle>
</IonToolbar>
<IonList>
<IonMenuToggle autoHide={true}>
<IonItem button detail={false} routerLink="/app/inicio" routerDirection="root">
<IonIcon icon={homeOutline} slot="start" />
<IonLabel>Inicio</IonLabel>
</IonItem>
</IonMenuToggle>
<IonMenuToggle autoHide={true}>
<IonItem button detail={false} routerLink="/app/categorias" routerDirection="root">
<IonIcon icon={gridOutline} slot="start" />
<IonLabel>Categorías</IonLabel>
</IonItem>
</IonMenuToggle>
<IonMenuToggle autoHide={true}>
<IonItem button detail={false} routerLink="/app/perfil" routerDirection="root">
<IonIcon icon={personCircleOutline} slot="start" />
<IonLabel>Perfil</IonLabel>
</IonItem>
</IonMenuToggle>
</IonList>
</IonContent>
<IonFooter>
<IonToolbar>
<div style={{ display: "flex", justifyContent: "center", padding: 8 }}>
<LogoutButton label="Cerrar sesión" />
</div>
</IonToolbar>
</IonFooter>
</IonMenu>
);
}

38
src/hooks/useMe.ts Normal file
View File

@ -0,0 +1,38 @@
// src/hooks/useMe.ts
import { useEffect, useState } from "react";
import api from "../services/api";
export type Me = {
id_usuario?: number;
email?: string;
nombre?: string;
roles?: string[]; // nombres en minúsculas
};
export default function useMe() {
const [me, setMe] = useState<Me | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
async function run() {
try {
const { data } = await api.get("/api/usuarios/me");
const u = data?.usuario || data?.user || data || {};
const roles = (u.roles || []).map((r: any) =>
(r?.nombre || r)?.toString().toLowerCase()
);
if (alive) setMe({ id_usuario: u.id_usuario, email: u.email, nombre: u.nombre, roles });
} catch {
if (alive) setMe(null);
} finally {
if (alive) setLoading(false);
}
}
run();
return () => { alive = false; };
}, []);
const isAdmin = !!me?.roles?.includes("admin");
return { me, isAdmin, loading };
}

24
src/lib/api.ts Normal file
View File

@ -0,0 +1,24 @@
// src/lib/api.ts
import axios from "axios";
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || "http://localhost:3000",
withCredentials: false,
});
if (typeof window !== "undefined") {
console.log("🔌 API baseURL:", api.defaults.baseURL);
}
api.interceptors.request.use((config) => {
const token = localStorage.getItem("token") || sessionStorage.getItem("token");
if (token) {
config.headers = config.headers ?? {};
config.headers.Authorization = `Bearer ${token}`;
}
// LOG: deja esto temporalmente
console.log("➡️", config.method?.toUpperCase(), config.url, "Auth:", config.headers?.Authorization);
return config;
});
export default api;

View File

@ -1,10 +1,30 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { setupIonicReact } from "@ionic/react";
const container = document.getElementById('root');
const root = createRoot(container!);
root.render(
/* Core CSS requerido por Ionic */
import "@ionic/react/css/core.css";
/* CSS básico de Ionic */
import "@ionic/react/css/normalize.css";
import "@ionic/react/css/structure.css";
import "@ionic/react/css/typography.css";
/* (Opcional) Utilidades de CSS */
import "@ionic/react/css/padding.css";
import "@ionic/react/css/float-elements.css";
import "@ionic/react/css/text-alignment.css";
import "@ionic/react/css/text-transformation.css";
import "@ionic/react/css/flex-utils.css";
import "@ionic/react/css/display.css";
/* Variables del tema */
import "./theme/variables.css";
setupIonicReact();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>

56
src/pages/AppShell.tsx Normal file
View File

@ -0,0 +1,56 @@
// src/pages/AppShell.tsx
import {
IonPage,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonMenuButton,
IonSplitPane,
IonRouterOutlet,
IonContent,
} from "@ionic/react";
import { Route, Redirect } from "react-router-dom";
import SidebarMenu from "../components/SidebarMenu";
// Tus páginas
import Bienvenida from "./Bienvenida";
import Categorias from "./Categorias";
import Categoria from "./Categoria";
import Perfil from "./Perfil";
export default function AppShell() {
return (
<IonSplitPane contentId="main">
<SidebarMenu />
<IonPage id="main">
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonMenuButton menu="main-menu" />
</IonButtons>
<IonTitle>Institución educativa</IonTitle>
</IonToolbar>
</IonHeader>
<IonRouterOutlet>
<Route exact path="/app/inicio" component={Bienvenida} />
<Route exact path="/app/categorias" component={Categorias} />
<Route exact path="/app/categorias/:slug" component={Categoria} />
<Route exact path="/app/perfil" component={Perfil} />
{/* fallback interno del shell */}
<Route exact path="/app">
<Redirect to="/app/inicio" />
</Route>
<Route path="/app/*">
<IonContent className="ion-padding">
<h3>Ruta no encontrada</h3>
</IonContent>
</Route>
</IonRouterOutlet>
</IonPage>
</IonSplitPane>
);
}

46
src/pages/Auth.css Normal file
View File

@ -0,0 +1,46 @@
.auth-container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.auth-card {
display: flex;
width: 90%;
max-width: 800px;
background: #fff;
box-shadow: 0px 4px 15px rgba(0,0,0,0.1);
border-radius: 12px;
overflow: hidden;
}
.auth-side {
flex: 1;
padding: 24px;
display: none;
flex-direction: column;
justify-content: center;
}
.auth-side.active {
display: flex;
}
h2 {
text-align: center;
margin-bottom: 16px;
color: #4a148c;
}
.switch-text {
margin-top: 12px;
text-align: center;
font-size: 14px;
}
.switch-text span {
color: #4a148c;
cursor: pointer;
font-weight: bold;
}

70
src/pages/Auth.tsx Normal file
View File

@ -0,0 +1,70 @@
// src/pages/Auth.tsx
import React, { useState } from "react";
import {
IonPage,
IonContent,
IonButton,
IonInput,
IonItem,
IonLabel
} from "@ionic/react";
import "./Auth.css";
const Auth: React.FC = () => {
const [isLogin, setIsLogin] = useState(true);
return (
<IonPage>
<IonContent fullscreen className="auth-container">
<div className="auth-card">
{/* Lado Izquierdo (Login) */}
<div className={`auth-side ${isLogin ? "active" : ""}`}>
<h2>¡Bienvenido!</h2>
<p>Inicia sesión con tu cuenta</p>
<IonItem>
<IonLabel position="stacked">Correo electrónico</IonLabel>
<IonInput type="email" placeholder="ejemplo@correo.com" />
</IonItem>
<IonItem>
<IonLabel position="stacked">Contraseña</IonLabel>
<IonInput type="password" placeholder="********" />
</IonItem>
<IonButton expand="block" color="primary">
Iniciar Sesión
</IonButton>
<p className="switch-text">
¿No tienes cuenta?
<span onClick={() => setIsLogin(false)}> Regístrate</span>
</p>
</div>
{/* Lado Derecho (Registro) */}
<div className={`auth-side ${!isLogin ? "active" : ""}`}>
<h2>Crea tu Cuenta</h2>
<IonItem>
<IonLabel position="stacked">Nombre</IonLabel>
<IonInput type="text" placeholder="Tu nombre" />
</IonItem>
<IonItem>
<IonLabel position="stacked">Correo electrónico</IonLabel>
<IonInput type="email" placeholder="ejemplo@correo.com" />
</IonItem>
<IonItem>
<IonLabel position="stacked">Contraseña</IonLabel>
<IonInput type="password" placeholder="********" />
</IonItem>
<IonButton expand="block" color="secondary">
Registrarse
</IonButton>
<p className="switch-text">
¿Ya tienes cuenta?
<span onClick={() => setIsLogin(true)}> Inicia sesión</span>
</p>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default Auth;

60
src/pages/Bienvenida.css Normal file
View File

@ -0,0 +1,60 @@
/* FONDO de IonContent (usa variable por Shadow DOM) */
.bienvenida-content {
--background: #f8f9fa; /* color del fondo */
--padding-start: 0;
--padding-end: 0;
--padding-top: 0;
--padding-bottom: 0;
}
/* ⬇️ Wrapper que sí podemos controlar: centra en toda la pantalla */
.center-wrap {
min-height: 100vh;
display: flex;
align-items: center; /* centro vertical */
justify-content: center; /* centro horizontal */
}
/* Tarjeta */
.bienvenida-card {
background: #fff;
border-radius: 20px;
padding: 40px 30px;
width: 400px;
text-align: center;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12);
}
/* Imagen */
.hoja-icono {
width: 50px;
height: 50px;
margin-bottom: 15px;
}
/* Título */
.bienvenida-titulo {
font-size: 26px;
font-weight: 800;
color: #4b0082;
margin: 0 0 10px 0;
}
/* Descripción */
.bienvenida-descripcion {
font-size: 16px;
color: #555;
margin: 0 0 26px 0;
}
/* Botón */
.btn-empezar {
--background: #7c6eea;
--background-activated: #6a5cd9;
width: 200px;
margin: 0 auto;
display: block;
border-radius: 10px;
font-weight: 800;
box-shadow: 0 6px 18px rgba(124, 110, 234, 0.35);
}

79
src/pages/Bienvenida.tsx Normal file
View File

@ -0,0 +1,79 @@
// src/pages/Bienvenida.tsx
import { IonPage, IonContent } from "@ionic/react";
import AddCategoriaButton from "../components/AddCategoriaButton";
import "./Categorias.css";
// Helper simple: detecta si el usuario es admin leyendo el usuario guardado
function isAdminFromStorage(): boolean {
try {
const raw = localStorage.getItem("auth_user") || sessionStorage.getItem("auth_user");
if (!raw) return false;
const u = JSON.parse(raw);
const roles: any[] = Array.isArray(u?.roles) ? u.roles : [];
return roles.some((r) =>
(r?.nombre || r)?.toString().toLowerCase() === "admin"
);
} catch {
return false;
}
}
export default function Bienvenida() {
const isAdmin = isAdminFromStorage();
const go = (slug: "mentales" | "fisicas" | "creativas" | "sociales") => {
// Navegación declarativa hacia las rutas del shell
window.location.href = `/app/categorias/${slug}`;
};
return (
<IonPage>
<IonContent className="ion-padding">
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
marginBottom: 12,
}}
>
<h1 style={{ margin: 0, color: "#246B2E", fontWeight: 700 }}>
¡Bienvenida, Raquel! 🌿
</h1>
{/* Mostrar Añadir solo a admins */}
{isAdmin && (
<AddCategoriaButton onCreated={() => { /* refrescar si lo necesitas */ }} />
)}
</div>
<p style={{ textAlign: "center", color: "#555" }}>
Selecciona una categoría para comenzar tus actividades
</p>
<div className="grid-categorias">
<button className="tile purple" onClick={() => go("mentales")}>
<span className="icon">🎓</span>
<span className="label">Mentales</span>
</button>
<button className="tile coral" onClick={() => go("fisicas")}>
<span className="icon">🏋</span>
<span className="label">Físicas</span>
</button>
<button className="tile amber" onClick={() => go("creativas")}>
<span className="icon">💡</span>
<span className="label">Creativas</span>
</button>
<button className="tile green" onClick={() => go("sociales")}>
<span className="icon">👥</span>
<span className="label">Sociales</span>
</button>
</div>
</IonContent>
</IonPage>
);
}

114
src/pages/Categoria.tsx Normal file
View File

@ -0,0 +1,114 @@
// src/pages/Categoria.tsx
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonBackButton,
IonSpinner,
IonLabel,
} from "@ionic/react";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import ListaActividades from "../components/ListaActividades";
import { listarCategorias } from "../services/api";
type Params = { slug: "mentales" | "fisicas" | "creativas" | "sociales" | string };
/**
* Mapa según tu BD:
* 1 = Física
* 2 = Sociales
* 3 = Mentales
* 4 = Creativas
*/
const MAP = {
mentales: { id: 3, titulo: "Mentales" },
fisicas: { id: 1, titulo: "Físicas" },
creativas: { id: 4, titulo: "Creativas" },
sociales: { id: 2, titulo: "Sociales" },
} as const;
export default function Categoria() {
const { slug } = useParams<Params>();
const key = useMemo(() => (slug || "").toLowerCase(), [slug]);
const [titulo, setTitulo] = useState<string>("");
const [categoriaId, setCategoriaId] = useState<number | null>(null);
const [loading, setLoading] = useState<boolean>(false);
useEffect(() => {
let mounted = true;
async function resolveCategoria() {
setLoading(true);
// 1) Si está en el mapa fijo, úsalo
// @ts-ignore
const cfg = MAP[key];
if (cfg) {
if (!mounted) return;
setTitulo(cfg.titulo);
setCategoriaId(cfg.id);
setLoading(false);
return;
}
// 2) Fallback: buscar por nombre en el backend (?q=slug)
try {
const res = await listarCategorias({ q: key, page: 1, limit: 1 });
const data = Array.isArray(res) ? res : res?.data ?? [];
if (mounted) {
if (data.length) {
setTitulo(data[0].nombre);
setCategoriaId(data[0].id_categoria);
} else {
setTitulo("Categoría no encontrada");
setCategoriaId(null);
}
}
} catch {
if (mounted) {
setTitulo("Categoría no encontrada");
setCategoriaId(null);
}
} finally {
if (mounted) setLoading(false);
}
}
resolveCategoria();
return () => { mounted = false; };
}, [key]);
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/tabs/categorias" />
</IonButtons>
<IonTitle>{titulo || "Categoría"}</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
{loading ? (
<div className="ion-text-center" style={{ padding: 16 }}>
<IonSpinner name="crescent" />
</div>
) : categoriaId === null ? (
<IonLabel color="medium">No hay actividades para mostrar.</IonLabel>
) : (
<>
{/* Lista las actividades de la categoría resuelta */}
{/* Tu componente llama GET /api/actividades?categoria=<id>&activo=1 */}
<ListaActividades categoriaId={categoriaId} />
</>
)}
</IonContent>
</IonPage>
);
}

29
src/pages/Categorias.css Normal file
View File

@ -0,0 +1,29 @@
.grid-categorias {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 22px;
}
.tile {
display: flex;
align-items: center;
gap: 14px;
padding: 22px 28px;
border-radius: 16px;
color: #fff;
font-weight: 800;
font-size: 1.1rem;
box-shadow: 0 16px 34px rgba(16,24,40,.12);
border: none;
cursor: pointer;
transition: transform .05s ease;
}
.tile:active { transform: translateY(1px); }
.tile .icon { font-size: 22px; }
.tile .label { letter-spacing: .2px; }
.purple { background: #6366f1; }
.coral { background: #fb6f61; }
.amber { background: #f4b03e; }
.green { background: #34b27b; }

130
src/pages/Categorias.tsx Normal file
View File

@ -0,0 +1,130 @@
// src/pages/Categorias.tsx
import { useEffect, useState } from "react";
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonList,
IonItem,
IonLabel,
IonSpinner,
IonNote,
} from "@ionic/react";
import "./Categorias.css";
import AddCategoriaButton from "../components/AddCategoriaButton";
import { listarCategorias } from "../services/api";
type CategoriaListItem = {
id_categoria: number;
nombre: string;
descripcion: string | null;
activo: boolean | 0 | 1;
};
export default function Categorias() {
const [loading, setLoading] = useState(false);
const [cats, setCats] = useState<CategoriaListItem[]>([]);
async function load() {
try {
setLoading(true);
const res = await listarCategorias({ page: 1, limit: 50 });
// Tu backend puede devolver { data: [...] } o un array simple:
const data: CategoriaListItem[] = Array.isArray(res) ? res : res.data ?? [];
setCats(data);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
return (
<IonPage>
{/* Header con botón Añadir */}
<IonHeader>
<IonToolbar>
<IonTitle>Categorías</IonTitle>
<IonButtons slot="end">
<AddCategoriaButton onCreated={load} />
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
{/* --- TUS 4 TILES ESTÁTICOS (sin tocar) --- */}
<div className="grid-categorias">
<button
className="tile purple"
onClick={() => (window.location.href = "/tabs/categorias/mentales")}
>
<span className="icon">🎓</span>
<span className="label">Mentales</span>
</button>
<button
className="tile coral"
onClick={() => (window.location.href = "/tabs/categorias/fisicas")}
>
<span className="icon">🏋</span>
<span className="label">Físicas</span>
</button>
<button
className="tile amber"
onClick={() => (window.location.href = "/tabs/categorias/creativas")}
>
<span className="icon">💡</span>
<span className="label">Creativas</span>
</button>
<button
className="tile green"
onClick={() => (window.location.href = "/tabs/categorias/sociales")}
>
<span className="icon">👥</span>
<span className="label">Sociales</span>
</button>
</div>
{/* --- LISTA DINÁMICA DESDE EL BACKEND (para verificar nuevas) --- */}
<h2 style={{ marginTop: 24 }}>Otras categorías</h2>
{loading ? (
<div className="ion-text-center" style={{ padding: 16 }}>
<IonSpinner name="crescent" />
</div>
) : cats.length === 0 ? (
<IonNote color="medium">No hay categorías adicionales.</IonNote>
) : (
<IonList>
{cats.map((c) => (
<IonItem
key={c.id_categoria}
button
detail
onClick={() =>
(window.location.href = `/tabs/categorias/${encodeURIComponent(
c.nombre.toLowerCase()
)}`)
}
>
<IonLabel>
<h3>{c.nombre}</h3>
{c.descripcion && <p>{c.descripcion}</p>}
</IonLabel>
<IonNote slot="end">{(c.activo === 1 || c.activo === true) ? "Activo" : "Inactivo"}</IonNote>
</IonItem>
))}
</IonList>
)}
</IonContent>
</IonPage>
);
}

13
src/pages/Completadas.tsx Normal file
View File

@ -0,0 +1,13 @@
import { IonPage, IonContent } from "@ionic/react";
export default function Completadas() {
return (
<IonPage>
<IonContent className="ion-padding">
<h2>Actividades realizadas</h2>
<p>Lista/Historial de actividades completadas por el estudiante.</p>
</IonContent>
</IonPage>
);
}

76
src/pages/Inicio.css Normal file
View File

@ -0,0 +1,76 @@
/* Fondo general */
.inicio-content {
--background: #f6f8ff;
}
/* Banner de bienvenida centrado */
.welcome-banner {
text-align: center;
margin-top: 40px;
margin-bottom: 30px;
}
.welcome-banner h1 {
font-size: 2rem;
font-weight: 800;
color: #145a3a;
margin-bottom: 6px;
}
.welcome-banner p {
color: #555;
font-size: 1rem;
margin: 0;
}
/* Centrado general de tarjetas */
.center-wrap {
max-width: 1100px;
margin: 0 auto;
padding: 0 16px 16px;
}
.center-row {
justify-content: center;
}
.col-flex {
display: flex;
}
.cat-card {
width: 100%;
border-radius: 16px;
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.12);
}
.cat-inner {
display: flex;
align-items: center;
gap: 14px;
padding: 18px;
}
.cat-icon {
font-size: 28px;
color: #fff;
}
.cat-title {
color: #fff;
font-weight: 800;
}
/* Colores */
.cat-card.c1 {
background: linear-gradient(135deg, #6d74f5, #6673ea);
}
.cat-card.c2 {
background: linear-gradient(135deg, #ff8a70, #ff7d74);
}
.cat-card.c3 {
background: linear-gradient(135deg, #ffc94d, #f9b341);
}
.cat-card.c4 {
background: linear-gradient(135deg, #53c486, #42b77a);
}

64
src/pages/Inicio.tsx Normal file
View File

@ -0,0 +1,64 @@
// src/pages/Inicio.tsx
import {
IonPage,
IonContent,
IonGrid,
IonRow,
IonCol,
IonCard,
IonCardHeader,
IonCardTitle,
IonIcon,
} from "@ionic/react";
import { school, barbell, bulb, people } from "ionicons/icons";
import "./Inicio.css";
export default function Inicio() {
const usuario = "Raquel"; // luego puedes tomarlo de /api/auth/me o de auth_user
const cats = [
{ id: "mentales", titulo: "Mentales", icon: school, colorClass: "c1" },
{ id: "fisicas", titulo: "Físicas", icon: barbell, colorClass: "c2" },
{ id: "creativas", titulo: "Creativas", icon: bulb, colorClass: "c3" },
{ id: "sociales", titulo: "Sociales", icon: people, colorClass: "c4" },
];
return (
<IonPage>
{/* Sin IonHeader aquí: el AppShell ya tiene el suyo */}
<IonContent fullscreen className="inicio-content">
{/* Encabezado de bienvenida */}
<div className="welcome-banner">
<h1>¡Bienvenida, {usuario}! 🌿</h1>
<p>Selecciona una categoría para comenzar tus actividades</p>
</div>
<div className="center-wrap">
<IonGrid fixed>
<IonRow className="center-row">
{cats.map((c) => (
<IonCol size="12" sizeMd="6" key={c.id} className="col-flex">
<IonCard
button
routerLink={`/app/categorias/${c.id}`} // ✅ ruta nueva
routerDirection="forward"
className={`cat-card ${c.colorClass}`}
>
<div className="cat-inner">
<IonIcon icon={c.icon} className="cat-icon" />
<IonCardHeader>
<IonCardTitle className="cat-title">
{c.titulo}
</IonCardTitle>
</IonCardHeader>
</div>
</IonCard>
</IonCol>
))}
</IonRow>
</IonGrid>
</div>
</IonContent>
</IonPage>
);
}

107
src/pages/Login.css Normal file
View File

@ -0,0 +1,107 @@
.login-content {
--background: #f7f8fb;
display: flex;
min-height: 100%;
}
.center-wrap {
margin: auto;
width: 100%;
display: flex;
justify-content: center;
padding: 24px;
}
.login-card {
width: 420px;
max-width: 92vw;
border-radius: 18px;
box-shadow: 0 20px 45px rgba(0,0,0,.12);
}
.icono-avatar {
width: 74px;
height: 74px;
margin: 6px auto 12px;
border-radius: 50%;
background: #6d28d9; /* morado */
color: #fff;
display: grid;
place-items: center;
font-size: 54px;
}
.titulo {
text-align: center;
font-weight: 800;
color: #3a2a6f;
margin: 6px 0;
}
.subtitulo {
text-align: center;
color: #666;
margin: 0 0 16px 0;
}
.form { display: grid; gap: 12px; }
.campo {
--background: #f6f7fb;
--border-radius: 12px;
--padding-start: 10px;
border-radius: 12px;
}
.campo-icono { color: #7c7c90; }
.ojo {
color: #7c7c90;
cursor: pointer;
}
.acciones-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6px;
}
.recordarme {
display: inline-flex;
align-items: center;
gap: 8px;
}
.link-secundario {
color: #6d28d9;
text-decoration: none;
font-weight: 600;
font-size: 0.9rem;
}
.error {
color: #dc2626;
margin: 6px 0 0;
font-size: 0.9rem;
}
.btn-ingresar {
--background: #6d28d9;
--background-activated: #5b21b6;
--border-radius: 12px;
--box-shadow: 0 8px 20px rgba(109,40,217,.35);
margin-top: 4px;
}
.registro-texto {
text-align: center;
margin-top: 12px;
color: #666;
}
.link-registro {
color: #3b82f6;
font-weight: 600;
text-decoration: none;
}

160
src/pages/Login.tsx Normal file
View File

@ -0,0 +1,160 @@
// src/pages/Login.tsx
import React, { useEffect, useState } from "react";
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonItem,
IonLabel,
IonInput,
IonButton,
IonCheckbox,
IonText,
IonToast,
IonSpinner,
} from "@ionic/react";
import { useHistory } from "react-router-dom";
import api from "../services/api";
export default function Login() {
const history = useHistory();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(true);
const [loading, setLoading] = useState(false);
const [errMsg, setErrMsg] = useState<string | null>(null);
// 🔹 Al entrar a /login: limpia cualquier resto de sesión y header Authorization
useEffect(() => {
try {
localStorage.removeItem("token");
localStorage.removeItem("auth_user");
sessionStorage.removeItem("token");
sessionStorage.removeItem("auth_user");
if ((api.defaults.headers.common as any).Authorization) {
delete (api.defaults.headers.common as any).Authorization;
}
} catch (_) {
// no-op
}
}, []);
async function handleLogin(e?: React.FormEvent) {
e?.preventDefault();
setErrMsg(null);
if (!email || !password) {
setErrMsg("Ingresa tu correo y contraseña.");
return;
}
try {
setLoading(true);
// ⇩⇩ Endpoint de tu backend
const { data } = await api.post("/api/auth/login", { email, password });
// el backend podría responder { token, user } o { token, usuario }
const token: string = data?.token || data?.access_token || "";
const user = data?.user || data?.usuario || null;
if (!token) throw new Error("No se recibió token del servidor.");
// Guarda token (según “Recordarme”) y usuario
const storage = remember ? localStorage : sessionStorage;
storage.setItem("token", token);
if (user) storage.setItem("auth_user", JSON.stringify(user));
// Limpia el otro storage para evitar sesiones dobles
const other = remember ? sessionStorage : localStorage;
other.removeItem("token");
other.removeItem("auth_user");
// 🔹 Setea el Authorization global para próximas llamadas inmediatas
(api.defaults.headers.common as any).Authorization = `Bearer ${token}`;
// Redirige al nuevo layout con sidebar
history.replace("/app/inicio");
} catch (err: any) {
// si tu interceptor hace logout en 401, al estar en /login no pasa nada,
// solo mostramos el mensaje
const status = err?.response?.status;
const msg =
err?.response?.data?.error ||
(status === 0 ? "No hay conexión con el servidor" : err?.message) ||
"No fue posible iniciar sesión";
setErrMsg(msg);
console.error("LOGIN ERROR:", err);
} finally {
setLoading(false);
}
}
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonTitle>Iniciar sesión</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<form onSubmit={handleLogin} style={{ maxWidth: 420, margin: "0 auto" }}>
<IonItem lines="full">
<IonLabel position="stacked">Correo</IonLabel>
<IonInput
type="email"
value={email}
placeholder="usuario@correo.com"
onIonChange={(e) => setEmail(e.detail.value || "")}
required
/>
</IonItem>
<IonItem lines="full">
<IonLabel position="stacked">Contraseña</IonLabel>
<IonInput
type="password"
value={password}
placeholder="••••••••"
onIonChange={(e) => setPassword(e.detail.value || "")}
required
/>
</IonItem>
<div
className="ion-padding-vertical"
style={{ display: "flex", gap: 8, alignItems: "center" }}
>
<IonCheckbox
checked={remember}
onIonChange={(e) => setRemember(!!e.detail.checked)}
/>
<IonText>Recordarme en este dispositivo</IonText>
</div>
<IonButton type="submit" expand="block" disabled={loading}>
{loading ? <IonSpinner name="crescent" /> : "Ingresar"}
</IonButton>
<div className="ion-text-center ion-padding-top">
<IonText color="medium">
¿No tienes cuenta? <a href="/registro">Regístrate</a>
</IonText>
</div>
</form>
<IonToast
isOpen={!!errMsg}
message={errMsg || ""}
color="danger"
duration={1800}
onDidDismiss={() => setErrMsg(null)}
/>
</IonContent>
</IonPage>
);
}

97
src/pages/Perfil.css Normal file
View File

@ -0,0 +1,97 @@
.perfil-content{
--background:#f6f8ff;
--padding-bottom:72px; /* espacio para la tab bar */
}
/* Header azul tipo referencia */
.perfil-hero{
background: linear-gradient(180deg, #0f5132, #0f5132 70%, transparent 70%);
height: 180px;
position: relative;
}
.perfil-hero-inner{
position: absolute;
left: 50%;
top: 60%;
transform: translate(-50%, -50%);
text-align: center;
color: #fff;
}
.perfil-avatar{
width: 96px; height: 96px;
margin: 0 auto 8px;
box-shadow: 0 10px 22px rgba(0,0,0,.25);
}
.avatar-camera{
--background:#ffffff;
--color:#145a3a;
position: absolute;
left: calc(50% + 32px);
top: calc(60% - 28px);
transform: translate(-50%, -50%);
border-radius: 999px;
height: 28px;
width: 28px;
min-height: 28px;
min-width: 28px;
padding: 0;
-webkit-transform: translate(-50%, -50%);
-moz-transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%);
-o-transform: translate(-50%, -50%);
}
.perfil-nombre {
margin: 6px 0 0;
font-weight: 800;
color: #222; /* <-- color negro suave para que se lea bien */
}
/* Contenedor centrado */
.center-wrap{
display:flex;
justify-content:center;
padding: 16px;
}
.perfil-card{
width:100%;
max-width: 480px;
background:#fff;
border-radius: 18px;
box-shadow: 0 12px 28px rgba(0,0,0,.12);
padding: 14px;
}
.btn-guardar{
--background:#145a3a;
--background-activated:#145a3a;
margin: 14px 0 10px;
border-radius: 12px;
box-shadow: 0 8px 18px rgba(46,91,255,.35);
}
/*cerrar sesion*/
.btn-logout {
--border-radius: 12px;
margin-top: 12px;
}
.pass-block{
margin-top: 8px;
}
.pass-title{
margin: 6px 0 10px;
font-weight: 800;
}
.menu-list{
margin-top: 10px;
}
.menu-icon{
font-size: 26px;
margin-right: 8px;
}
.menu-icon.c1{ color:#145a3a; }
.menu-icon.c2{ color:#25b872; }

333
src/pages/Perfil.tsx Normal file
View File

@ -0,0 +1,333 @@
// src/pages/Perfil.tsx
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonAvatar,
IonItem,
IonLabel,
IonInput,
IonList,
IonButton,
IonIcon,
IonNote,
IonText,
IonAlert,
IonToast,
} from "@ionic/react";
import {
camera,
personCircle,
lockClosed,
chevronForward,
trendingUp,
checkmarkDone,
logOutOutline,
} from "ionicons/icons";
import { useEffect, useRef, useState } from "react";
import { useHistory } from "react-router-dom";
import api from "../services/api"; // ✅ usa tu cliente axios
import { logout } from "../services/auth"; // ✅ helper de logout
import "./Perfil.css";
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export default function Perfil() {
const history = useHistory();
const fileRef = useRef<HTMLInputElement | null>(null);
// --- lee usuario guardado con máxima seguridad (evita crasheos por JSON inválido) ---
const rawUser =
localStorage.getItem("auth_user") || sessionStorage.getItem("auth_user");
let parsedUser: any = null;
try {
parsedUser = rawUser ? JSON.parse(rawUser) : null;
} catch {
parsedUser = null;
}
const [avatar, setAvatar] = useState<string>(
localStorage.getItem("avatar") || "https://i.pravatar.cc/150?img=5"
);
const [nombre, setNombre] = useState<string>(
parsedUser?.nombre || localStorage.getItem("nombre") || "Nombre de Usuario"
);
const [email] = useState<string>(parsedUser?.email || "usuario@correo.com");
// password
const [passActual, setPassActual] = useState("");
const [passNueva, setPassNueva] = useState("");
const [passConfirma, setPassConfirma] = useState("");
const [msgPass, setMsgPass] = useState<string>("");
// toasts simples
const [okMsg, setOkMsg] = useState<string | null>(null);
const [errMsg, setErrMsg] = useState<string | null>(null);
// logout
const [showConfirm, setShowConfirm] = useState(false);
const [toastOut, setToastOut] = useState(false);
useEffect(() => {
// aquí podrías traer datos reales si quisieras
}, []);
const abrirPicker = () => fileRef.current?.click();
const onPickAvatar = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const data = await fileToDataUrl(file);
setAvatar(data);
};
// ---------- ACTUALIZAR PERFIL (nombre) ----------
const guardarPerfil = async () => {
try {
setErrMsg(null);
// guarda avatar local (demo)
localStorage.setItem("avatar", avatar);
const { data } = await api.put("/api/auth/me/profile", { nombre });
// refresca auth_user guardado
const currentLocal = localStorage.getItem("auth_user");
const currentSess = sessionStorage.getItem("auth_user");
const current =
currentLocal || currentSess
? JSON.parse(currentLocal || currentSess!)
: {};
const updated = { ...current, ...(data?.user || {}) };
if (currentLocal)
localStorage.setItem("auth_user", JSON.stringify(updated));
if (currentSess && !currentLocal)
sessionStorage.setItem("auth_user", JSON.stringify(updated));
localStorage.setItem("nombre", data?.user?.nombre || nombre);
setOkMsg("Perfil actualizado");
} catch (e: any) {
setErrMsg(e?.response?.data?.error || "No se pudo actualizar el perfil");
}
};
// ---------- CAMBIAR CONTRASEÑA ----------
const cambiarPassword = async () => {
setMsgPass("");
try {
if (!passActual || !passNueva || !passConfirma) {
setMsgPass("Completa todos los campos.");
return;
}
if (passNueva.length < 6) {
setMsgPass("La nueva contraseña debe tener al menos 6 caracteres.");
return;
}
if (passNueva !== passConfirma) {
setMsgPass("La confirmación no coincide.");
return;
}
await api.put("/api/auth/me/password", {
actual: passActual,
nueva: passNueva,
});
setMsgPass("✅ Contraseña actualizada.");
setPassActual("");
setPassNueva("");
setPassConfirma("");
} catch (e: any) {
setMsgPass(
e?.response?.data?.error || "No se pudo cambiar la contraseña."
);
}
};
// ---------- LOGOUT ----------
function doLogout() {
// usa el helper centralizado (limpia credenciales y redirige a /login)
setToastOut(true);
logout(); // <- redirige por defecto
// si prefieres controlar la ruta tú misma:
// logout(false);
// history.replace("/login");
}
return (
<IonPage>
<IonHeader translucent>
<IonToolbar>
<IonTitle>Perfil</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen className="perfil-content">
<div className="perfil-hero">
<div className="perfil-hero-inner">
<IonAvatar className="perfil-avatar">
<img src={avatar} alt="avatar" />
</IonAvatar>
<IonButton className="avatar-camera" size="small" onClick={abrirPicker}>
<IonIcon icon={camera} />
</IonButton>
<input
ref={fileRef}
type="file"
accept="image/*"
hidden
onChange={onPickAvatar}
/>
<h3 className="perfil-nombre">{nombre}</h3>
<IonNote color="light">{email}</IonNote>
</div>
</div>
<div className="center-wrap">
<div className="perfil-card">
{/* Editar nombre */}
<IonList lines="full">
<IonItem>
<IonIcon icon={personCircle} slot="start" />
<IonLabel position="stacked">Nombre</IonLabel>
<IonInput
value={nombre}
placeholder="Tu nombre"
onIonChange={(e) => setNombre(e.detail.value || "")}
/>
</IonItem>
</IonList>
<IonButton expand="block" className="btn-guardar" onClick={guardarPerfil}>
Guardar cambios
</IonButton>
{/* Cambiar contraseña */}
<div className="pass-block">
<IonText color="dark">
<h4 className="pass-title">Cambiar contraseña</h4>
</IonText>
<IonItem lines="full">
<IonIcon icon={lockClosed} slot="start" />
<IonLabel position="stacked">Contraseña actual</IonLabel>
<IonInput
type="password"
value={passActual}
onIonChange={(e) => setPassActual(e.detail.value || "")}
/>
</IonItem>
<IonItem lines="full">
<IonIcon icon={lockClosed} slot="start" />
<IonLabel position="stacked">Nueva contraseña</IonLabel>
<IonInput
type="password"
value={passNueva}
onIonChange={(e) => setPassNueva(e.detail.value || "")}
/>
</IonItem>
<IonItem lines="full">
<IonIcon icon={lockClosed} slot="start" />
<IonLabel position="stacked">Confirmar nueva contraseña</IonLabel>
<IonInput
type="password"
value={passConfirma}
onIonChange={(e) => setPassConfirma(e.detail.value || "")}
/>
</IonItem>
{msgPass && (
<IonNote color={msgPass.startsWith("✅") ? "success" : "danger"}>
{msgPass}
</IonNote>
)}
<IonButton expand="block" className="btn-guardar" onClick={cambiarPassword}>
Actualizar contraseña
</IonButton>
</div>
{/* Botón Cerrar sesión */}
<IonButton
expand="block"
color="danger"
className="btn-logout"
onClick={() => setShowConfirm(true)}
>
<IonIcon slot="start" icon={logOutOutline} />
Cerrar sesión
</IonButton>
{/* Accesos de abajo */}
<IonList inset lines="none" className="menu-list">
<IonItem button routerLink="/tabs/progreso" detail={false}>
<IonIcon icon={trendingUp} slot="start" className="menu-icon c1" />
<IonLabel>
<h2>Mi progreso</h2>
<p>Resumen y estadísticas</p>
</IonLabel>
<IonIcon icon={chevronForward} slot="end" />
</IonItem>
<IonItem button routerLink="/tabs/completadas" detail={false}>
<IonIcon icon={checkmarkDone} slot="start" className="menu-icon c2" />
<IonLabel>
<h2>Actividades completas</h2>
<p>Historial de actividades</p>
</IonLabel>
<IonIcon icon={chevronForward} slot="end" />
</IonItem>
</IonList>
</div>
</div>
{/* Confirmación logout */}
<IonAlert
isOpen={showConfirm}
header="Cerrar sesión"
message="¿Seguro que quieres salir?"
onDidDismiss={() => setShowConfirm(false)}
buttons={[
{ text: "Cancelar", role: "cancel" },
{ text: "Salir", handler: doLogout },
]}
/>
{/* Toasts */}
<IonToast
isOpen={toastOut}
message="Sesión cerrada"
duration={900}
position="top"
onDidDismiss={() => setToastOut(false)}
/>
<IonToast
isOpen={!!okMsg}
message={okMsg || ""}
duration={1200}
onDidDismiss={() => setOkMsg(null)}
/>
<IonToast
isOpen={!!errMsg}
color="danger"
message={errMsg || ""}
duration={1500}
onDidDismiss={() => setErrMsg(null)}
/>
</IonContent>
</IonPage>
);
}

12
src/pages/Progreso.tsx Normal file
View File

@ -0,0 +1,12 @@
import { IonPage, IonContent } from "@ionic/react";
export default function Progreso() {
return (
<IonPage>
<IonContent className="ion-padding">
<h2>Mi progreso</h2>
<p>Aquí puedes renderizar tu gráfica / estadísticas del estudiante.</p>
</IonContent>
</IonPage>
);
}

39
src/pages/Registro.css Normal file
View File

@ -0,0 +1,39 @@
.reg-content { --background:#f4faf6; }
/* HERO */
.reg-hero { display:flex; justify-content:center; padding:12px 14px 0; }
.reg-hero-inner{
width: 560px; max-width: 92vw; height: 180px;
background: linear-gradient(180deg,#c7f0d7 0%, #c7f0d7 60%, transparent 60%);
border-radius: 24px; text-align:center; position:relative;
box-shadow: 0 10px 20px rgba(0,0,0,.06);
}
.leaf{ width:40px; margin:12px auto 6px; display:block; }
.hero-title{ margin:0; color:#145a3a; font-weight:800; }
.hero-title.strong{ margin-top:2px; }
.hero-sub{ margin:6px 0 0; color:#2f6b4c; }
/* CARD */
.center-wrap{ display:flex; justify-content:center; padding:14px; }
.reg-card{
width:100%; max-width:560px; background:#fff; border-radius:18px;
box-shadow:0 14px 32px rgba(0,0,0,.08); padding:12px;
}
/* INPUTS */
.input-item{
--highlight-color-focused:#25b872; --border-color:#e6efe9;
border-radius:12px; margin-top:10px;
}
/* BOTÓN */
.btn-reg{
--background:#25b872; --background-activated:#25b872;
margin:16px 8px 8px; border-radius:12px;
box-shadow:0 10px 20px rgba(37,184,114,.35);
}
/* PIE */
.alt-link{ display:block; text-align:center; margin:6px 0 8px; color:#1f3a2e; }
.a-link{ color:#1f8f5f; font-weight:700; text-decoration:none; }
.a-link:hover{ text-decoration:underline; }

218
src/pages/Registro.tsx Normal file
View File

@ -0,0 +1,218 @@
import {
IonPage,
IonContent,
IonCard,
IonCardContent,
IonButton,
IonText,
IonItem,
IonInput,
IonIcon,
IonLabel,
IonSelect,
IonSelectOption,
IonToast,
} from "@ionic/react";
import {
personOutline,
mailOutline,
lockClosedOutline,
calendarOutline,
maleFemaleOutline,
} from "ionicons/icons";
import { useState } from "react";
import { useHistory } from "react-router-dom";
import api from "../lib/api";
import "./Registro.css"; // si ya tenías estilos, se mantienen
export default function Registro() {
const history = useHistory();
const [nombre, setNombre] = useState("");
const [apellido, setApellido] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [fechaNacimiento, setFechaNacimiento] = useState<string>(""); // yyyy-mm-dd
const [genero, setGenero] = useState<"F" | "M" | "O" | "">("");
const [loading, setLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [okToast, setOkToast] = useState(false);
// util: valida campos mínimos
function validate() {
if (!nombre || !apellido || !email || !password) {
setErrorMsg("Completa los campos obligatorios.");
return false;
}
if (!fechaNacimiento) {
setErrorMsg("Selecciona tu fecha de nacimiento.");
return false;
}
if (!genero) {
setErrorMsg("Selecciona tu género.");
return false;
}
return true;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setErrorMsg(null);
if (!validate()) return;
setLoading(true);
try {
// Ajusta la ruta si en tu backend se llama distinto (por ej. /api/auth/registro)
const payload = {
nombre,
apellido,
email,
password, // el backend se encarga de hashear
fecha_nacimiento: fechaNacimiento, // yyyy-mm-dd
genero, // 'F' | 'M' | 'O'
};
await api.post("/api/auth/register", payload);
setOkToast(true); // pequeño feedback
// Pequeño delay y mandamos a login
setTimeout(() => history.replace("/login"), 900);
} catch (err: any) {
const msg =
err?.response?.data?.error ||
err?.response?.data?.message ||
"No fue posible registrar tu cuenta.";
setErrorMsg(msg);
} finally {
setLoading(false);
}
}
return (
<IonPage id="registro-page">
<IonContent className="registro-content" fullscreen>
<div className="center-wrap">
<IonCard className="registro-card">
<IonCardContent>
<IonText color="dark">
<h2 className="titulo">Registrarse</h2>
</IonText>
<form onSubmit={handleSubmit} className="form">
{/* Nombre */}
<IonItem lines="full" className="campo">
<IonIcon slot="start" icon={personOutline} />
<IonLabel position="stacked">Nombre</IonLabel>
<IonInput
placeholder="Tu nombre"
value={nombre}
onIonChange={(e) => setNombre(e.detail.value || "")}
required
/>
</IonItem>
{/* Apellido */}
<IonItem lines="full" className="campo">
<IonIcon slot="start" icon={personOutline} />
<IonLabel position="stacked">Apellido</IonLabel>
<IonInput
placeholder="Tu apellido"
value={apellido}
onIonChange={(e) => setApellido(e.detail.value || "")}
required
/>
</IonItem>
{/* Correo */}
<IonItem lines="full" className="campo">
<IonIcon slot="start" icon={mailOutline} />
<IonLabel position="stacked">Correo</IonLabel>
<IonInput
type="email"
placeholder="ejemplo@correo.com"
value={email}
onIonChange={(e) => setEmail(e.detail.value || "")}
required
/>
</IonItem>
{/* Contraseña */}
<IonItem lines="full" className="campo">
<IonIcon slot="start" icon={lockClosedOutline} />
<IonLabel position="stacked">Contraseña</IonLabel>
<IonInput
type="password"
value={password}
onIonChange={(e) => setPassword(e.detail.value || "")}
required
/>
</IonItem>
{/* Fecha de nacimiento */}
<IonItem lines="full" className="campo">
<IonIcon slot="start" icon={calendarOutline} />
<IonLabel position="stacked">Fecha de nacimiento</IonLabel>
{/* Input nativo de fecha para ver el calendario a la derecha */}
<IonInput
type="date" // devuelve yyyy-mm-dd
placeholder="dd/mm/aaaa"
value={fechaNacimiento}
onIonChange={(e) => setFechaNacimiento(e.detail.value || "")}
required
/>
</IonItem>
{/* Género */}
<IonItem lines="full" className="campo">
<IonIcon slot="start" icon={maleFemaleOutline} />
<IonLabel position="stacked">Género</IonLabel>
<IonSelect
interface="popover"
placeholder="Selecciona tu género"
value={genero}
onIonChange={(e) => setGenero(e.detail.value)}
required
>
<IonSelectOption value="F">Femenino</IonSelectOption>
<IonSelectOption value="M">Masculino</IonSelectOption>
<IonSelectOption value="O">Otro / Prefiero no decir</IonSelectOption>
</IonSelect>
</IonItem>
{/* Error */}
{errorMsg && <p className="error">{errorMsg}</p>}
{/* Botón principal */}
<IonButton
type="submit"
expand="block"
className="btn-registrar"
disabled={loading}
>
{loading ? "Registrando…" : "REGISTRARSE"}
</IonButton>
</form>
<p className="login-texto">
¿Ya tienes cuenta?{" "}
<a className="link-login" href="/login">
Inicia sesión
</a>
</p>
</IonCardContent>
</IonCard>
</div>
<IonToast
isOpen={okToast}
message="Cuenta creada correctamente"
duration={1200}
position="top"
color="success"
onDidDismiss={() => setOkToast(false)}
/>
</IonContent>
</IonPage>
);
}

View File

@ -1,25 +0,0 @@
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/react';
import ExploreContainer from '../components/ExploreContainer';
import './Tab1.css';
const Tab1: React.FC = () => {
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonTitle>Tab 1</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<IonHeader collapse="condense">
<IonToolbar>
<IonTitle size="large">Tab 1</IonTitle>
</IonToolbar>
</IonHeader>
<ExploreContainer name="Tab 1 page" />
</IonContent>
</IonPage>
);
};
export default Tab1;

View File

@ -1,25 +1,10 @@
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/react';
import ExploreContainer from '../components/ExploreContainer';
import './Tab2.css';
const Tab2: React.FC = () => {
// src/pages/Tab2.tsx
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent } from "@ionic/react";
export default function Tab2() {
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonTitle>Tab 2</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<IonHeader collapse="condense">
<IonToolbar>
<IonTitle size="large">Tab 2</IonTitle>
</IonToolbar>
</IonHeader>
<ExploreContainer name="Tab 2 page" />
</IonContent>
<IonHeader><IonToolbar><IonTitle>Tab 2</IonTitle></IonToolbar></IonHeader>
<IonContent className="ion-padding">Contenido de Tab 2</IonContent>
</IonPage>
);
};
export default Tab2;
}

View File

@ -1,25 +1,10 @@
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/react';
import ExploreContainer from '../components/ExploreContainer';
import './Tab3.css';
const Tab3: React.FC = () => {
// src/pages/Tab3.tsx
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent } from "@ionic/react";
export default function Tab3() {
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonTitle>Tab 3</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<IonHeader collapse="condense">
<IonToolbar>
<IonTitle size="large">Tab 3</IonTitle>
</IonToolbar>
</IonHeader>
<ExploreContainer name="Tab 3 page" />
</IonContent>
<IonHeader><IonToolbar><IonTitle>Tab 3</IonTitle></IonToolbar></IonHeader>
<IonContent className="ion-padding">Contenido de Tab 3</IonContent>
</IonPage>
);
};
export default Tab3;
}

56
src/pages/Tabs.tsx Normal file
View File

@ -0,0 +1,56 @@
import {
IonTabs,
IonTabBar,
IonTabButton,
IonIcon,
IonLabel,
IonRouterOutlet,
} from "@ionic/react";
import { Route, Redirect } from "react-router-dom";
import { useHistory } from "react-router-dom";
import { chevronBack, home, personCircle } from "ionicons/icons";
import Inicio from "./Inicio";
import Perfil from "./Perfil";
import Progreso from "./Progreso";
import Completadas from "./Completadas";
import Categorias from "./Categorias";
import Categoria from "./Categoria";
export default function Tabs() {
const history = useHistory();
return (
<IonTabs>
<IonRouterOutlet>
<Route exact path="/tabs/inicio" component={Inicio} />
<Route exact path="/tabs/categorias" component={Categorias} />
<Route exact path="/tabs/categorias/:slug" component={Categoria} />
<Route exact path="/tabs/perfil" component={Perfil} />
<Route exact path="/tabs/progreso" component={Progreso} />
<Route exact path="/tabs/completadas" component={Completadas} />
<Route exact path="/tabs">
<Redirect to="/tabs/inicio" />
</Route>
</IonRouterOutlet>
<IonTabBar slot="bottom">
<IonTabButton tab="back" onClick={() => history.goBack()}>
<IonIcon icon={chevronBack} />
<IonLabel>Regresar</IonLabel>
</IonTabButton>
<IonTabButton tab="inicio" href="/tabs/inicio">
<IonIcon icon={home} />
<IonLabel>Inicio</IonLabel>
</IonTabButton>
<IonTabButton tab="perfil" href="/tabs/perfil">
<IonIcon icon={personCircle} />
<IonLabel>Perfil</IonLabel>
</IonTabButton>
</IonTabBar>
</IonTabs>
);
}

17
src/services/Registro.ts Normal file
View File

@ -0,0 +1,17 @@
// src/services/Registro.ts
import api from "../lib/api";
export async function startRegistro(id_actividad: number) {
const { data } = await api.post("/api/registroactividad/start", { id_actividad });
return data; // { ok: true, id_registro }
}
export async function finishRegistro(id_registro: number, payload?: {
emocion?: string;
puntos_obtenidos?: number;
comentario?: string;
}) {
// 👇 importante: PATCH, no PUT
const { data } = await api.patch(`/api/registroactividad/finish/${id_registro}`, payload || {});
return data;
}

View File

@ -0,0 +1,20 @@
import api from "../lib/api";
export type Actividad = {
id_actividad: number;
id_categoria: number;
nombre: string;
descripcion: string | null;
duracion_minutos: number;
dificultad: string | null;
puntos_base: number | null;
activo: number;
};
// Llama a tu backend: GET /api/actividades?categoria=<id>&activo=1
export async function fetchActividadesPorCategoria(categoriaId: number) {
const { data } = await api.get<Actividad[]>("/api/actividades", {
params: { categoria: categoriaId, activo: 1 },
});
return data;
}

83
src/services/api.ts Normal file
View File

@ -0,0 +1,83 @@
// src/services/api.ts
import axios from "axios";
import { logout } from "./auth";
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || "http://localhost:3000",
});
// ── Request: añade/quita Authorization según haya token ───────────────────────
api.interceptors.request.use((config) => {
const tk =
localStorage.getItem("token") || sessionStorage.getItem("token") || "";
if (tk) {
config.headers.Authorization = `Bearer ${tk}`;
} else {
// si no hay token, nos aseguramos de que no viaje ningún header viejo
if (config.headers && "Authorization" in config.headers) {
delete (config.headers as any).Authorization;
}
if (api.defaults.headers.common.Authorization) {
delete (api.defaults.headers.common as any).Authorization;
}
}
return config;
});
// ── Response: si el backend devuelve 401, limpiamos y redirigimos a /login ───
api.interceptors.response.use(
(res) => res,
(err) => {
const status = err?.response?.status;
if (status === 401) {
// borra credenciales y redirige (lo maneja logout)
logout();
// devolvemos el error igualmente para que el caller pueda manejarlo si quiere
}
return Promise.reject(err);
}
);
export default api;
// === Categorías (admin + público) ===============================
// Tipos opcionales para autocompletado
export interface CategoriaCreate {
nombre: string;
descripcion?: string | null;
activo?: boolean;
}
export interface Categoria {
id_categoria: number;
nombre: string;
descripcion: string | null;
activo: 0 | 1 | boolean;
}
// Crear categoría (requiere token de ADMIN)
export async function crearCategoria(payload: CategoriaCreate): Promise<Categoria> {
try {
const { data } = await api.post<Categoria>("/api/categorias", payload);
return data;
} catch (err: any) {
const msg = err?.response?.data?.error || err?.message || "No se pudo crear la categoría";
throw new Error(msg);
}
}
// Listar categorías (público/estudiante). Soporta paginación/búsqueda si tu backend la usa.
export async function listarCategorias(params?: { page?: number; limit?: number; q?: string }): Promise<any> {
try {
const { data } = await api.get("/api/categorias", { params });
// Tu backend puede devolver { data, page, limit, total } o un array simple
return data;
} catch (err: any) {
const msg = err?.response?.data?.error || err?.message || "No se pudieron cargar las categorías";
throw new Error(msg);
}
}

30
src/services/auth.ts Normal file
View File

@ -0,0 +1,30 @@
// src/services/auth.ts
import api from "./api";
/** borra TODO rastro de sesión y (por defecto) redirige a /login */
export function logout(redirect: boolean = true) {
try {
// borra tokens y cachés de usuario
localStorage.removeItem("token");
localStorage.removeItem("auth_user");
localStorage.removeItem("usuario");
localStorage.removeItem("nombre");
localStorage.removeItem("avatar");
sessionStorage.removeItem("token");
sessionStorage.removeItem("auth_user");
// quita el header Authorization global por si quedó en memoria
delete (api.defaults.headers.common as any)?.Authorization;
} finally {
if (redirect) window.location.href = "/login";
}
}
export function getToken() {
return localStorage.getItem("token") || sessionStorage.getItem("token") || "";
}
export function isLoggedIn() {
return !!getToken();
}

View File

@ -0,0 +1,25 @@
// frontend/src/services/authService.ts
const API = import.meta.env.VITE_API_BASE || 'http://localhost:3000';
export async function login(email: string, password: string) {
const r = await fetch(`${API}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await r.json();
if (!r.ok) throw new Error(data?.error || 'Error al iniciar sesión');
if (data?.token) {
localStorage.setItem('token', data.token); // <- AQUÍ guardamos el token
}
// guarda datos del usuario si quieres
localStorage.setItem('usuario', JSON.stringify(data.usuario || {}));
return data;
}
export function getToken() {
return localStorage.getItem('token');
}

130
src/services/data.ts Normal file
View File

@ -0,0 +1,130 @@
// src/services/data.ts
export type Category = {
id: string;
title: string;
subtitle: string;
color: string;
icon: string;
rank: number;
stats: { follow: number; like: number; points: number };
};
export type Activity = {
id: string;
categoryId: string;
title: string;
description: string;
duration: string; // "10 min" (para mostrar)
durationSec?: number; // 600 = 10 minutos (para objetivo opcional)
instructions: string; // texto simple; si quieres luego lo pasamos a lista
};
export const categories: Category[] = [
{ id: "mentales", title: "Mentales", subtitle: "Entrena tu mente", color: "g1", icon: "🧠", rank: 1, stats: { follow: 2311, like: 3256, points: 162 } },
{ id: "fisicas", title: "Físicas", subtitle: "Actívate con movimiento", color: "g2", icon: "🏃‍♀️", rank: 2, stats: { follow: 3463, like: 2589, points: 142 } },
{ id: "creativas", title: "Creativas", subtitle: "Imaginación y arte", color: "g3", icon: "💡", rank: 3, stats: { follow: 1580, like: 2051, points: 102 } },
{ id: "sociales", title: "Sociales", subtitle: "Expresa y comparte", color: "g4", icon: "🗣️", rank: 4, stats: { follow: 1672, like: 2440, points: 118 } },
{ id: "documentales", title: "Documentales", subtitle: "Aprende y explora", color: "g5", icon: "📚", rank: 5, stats: { follow: 1211, like: 1831, points: 91 } },
];
export const activities: Activity[] = [
// Mentales
{
id: "m1",
categoryId: "mentales",
title: "Sopa de letras",
description: "Encuentra todas las palabras ocultas.",
duration: "10 min",
durationSec: 600,
instructions: "Imprime o abre la sopa; marca cada palabra que encuentres hasta completar la lista."
},
{
id: "m2",
categoryId: "mentales",
title: "Sudoku básico",
description: "Completa el sudoku nivel 1.",
duration: "15 min",
durationSec: 900,
instructions: "Rellena la cuadrícula sin repetir números del 1 al 9 por fila, columna y bloque."
},
// Físicas
{
id: "f1",
categoryId: "fisicas",
title: "Estiramientos",
description: "Rutina guiada para activar tu cuerpo.",
duration: "8 min",
durationSec: 480,
instructions: "Cuello, hombros, brazos, espalda y piernas. Mantén cada estiramiento 20-30s."
},
{
id: "f2",
categoryId: "fisicas",
title: "Caminar 1 km",
description: "Sal a caminar y registra el recorrido.",
duration: "20 min",
durationSec: 1200,
instructions: "Elige una ruta segura; mantén ritmo cómodo. Hidrátate."
},
// Creativas
{
id: "c1",
categoryId: "creativas",
title: "Dibujo libre",
description: "Dibuja lo que imagines.",
duration: "10 min",
durationSec: 600,
instructions: "Usa lápiz o app; no borres, solo añade detalles. Al final firma tu obra."
},
{
id: "c2",
categoryId: "creativas",
title: "Historia corta",
description: "Escribe una micro-historia.",
duration: "12 min",
durationSec: 720,
instructions: "Plantea inicio, conflicto y cierre en 6-10 líneas."
},
// Sociales
{
id: "s1",
categoryId: "sociales",
title: "Agradecer",
description: "Envía un mensaje de agradecimiento a alguien.",
duration: "5 min",
durationSec: 300,
instructions: "Elige a alguien, escribe por qué le agradeces y envía el mensaje."
},
{
id: "s2",
categoryId: "sociales",
title: "Llamada familiar",
description: "Habla con un familiar y cuéntale tu día.",
duration: "10 min",
durationSec: 600,
instructions: "Llama a alguien cercano y comparte algo positivo de tu día."
},
// Documentales
{
id: "d1",
categoryId: "documentales",
title: "Lee un artículo",
description: "Artículo breve sobre hábitos saludables.",
duration: "7 min",
durationSec: 420,
instructions: "Lee con calma y anota 3 ideas clave."
},
{
id: "d2",
categoryId: "documentales",
title: "Micro-documental",
description: "Video de 5 minutos sobre ciencias.",
duration: "5 min",
durationSec: 300,
instructions: "Mira el video y escribe 1 dato curioso que no sabías."
},
];

View File

@ -1,2 +1,7 @@
/* For information on how to create your own theme, please see:
http://ionicframework.com/docs/theming/ */
:root {
/* color primario por defecto de Ionic */
--ion-color-primary: #3880ff;
}

6
src/utils/auth.ts Normal file
View File

@ -0,0 +1,6 @@
// src/utils/auth.ts
export const isLoggedIn = () => !!localStorage.getItem("token");
export const loginDemo = () => localStorage.setItem("token", "demo");
export const logout = () => localStorage.removeItem("token");