diff --git a/.env b/.env new file mode 100644 index 0000000..5317fce --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:3000 diff --git a/package-lock.json b/package-lock.json index cce624f..8fe75db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 3f815d2..86be14e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/Escudo.png b/public/Escudo.png new file mode 100644 index 0000000..0f899c8 Binary files /dev/null and b/public/Escudo.png differ diff --git a/public/hoja.png b/public/hoja.png new file mode 100644 index 0000000..8b9d45a Binary files /dev/null and b/public/hoja.png differ diff --git a/src/App.tsx b/src/App.tsx index cd2a292..6877661 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( + + - - + + {/* ===== PÚBLICAS ===== */} + + + + {/* ===== PRIVADO: SHELL con sidebar en /app ===== */} + + {/* Verifica contra backend; si prefieres probar sin verificación, cambia a verify={false} */} + + + - - + + {/* ===== Redirects de compatibilidad (antiguas rutas) ===== */} + + - - + + + + + {/* Redirección dinámica /categorias/:slug -> /app/categorias/:slug */} + ( + + )} + /> + + + + + + {/* ===== ROOT ===== */} - + {hasToken() ? : } + - - - - - - - - - - - -); + + + ); +}; export default App; diff --git a/src/components/ActivityTimer.tsx b/src/components/ActivityTimer.tsx new file mode 100644 index 0000000..a13f272 --- /dev/null +++ b/src/components/ActivityTimer.tsx @@ -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(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 ( +
+ + {mmss}{goal ? ` / ${goal}` : ""} + + setRunning((v) => !v)} + > + {running ? "Stop" : "Iniciar"} + + {completed && ( + + Completado ✅ + + )} +
+ ); +} diff --git a/src/components/AddCategoriaButton.tsx b/src/components/AddCategoriaButton.tsx new file mode 100644 index 0000000..92852d6 --- /dev/null +++ b/src/components/AddCategoriaButton.tsx @@ -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 */} + setOpen(true)}> + + Añadir + + + setOpen(false)}> + + + Nueva categoría + + setOpen(false)}> + + + + + + + + + Nombre * + setNombre(e.detail.value || '')} + placeholder="p.ej. Creativas" + required + /> + + + + Descripción + setDescripcion(e.detail.value || '')} + autoGrow + maxlength={500} + placeholder="Texto opcional" + /> + + + + Activo + setActivo(e.detail.checked)} /> + + +
+ + {loading ? : } + {loading ? 'Guardando...' : 'Guardar'} + +
+
+
+ + setToast({open:false, msg:''})} + /> + + ); +} diff --git a/src/components/ListaActividades.tsx b/src/components/ListaActividades.tsx new file mode 100644 index 0000000..f162912 --- /dev/null +++ b/src/components/ListaActividades.tsx @@ -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 { + 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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Modal de instrucciones + const [open, setOpen] = useState(false); + const [sel, setSel] = useState(null); + + // Estado de ejecución: actividad -> id_registro + const [running, setRunning] = useState>({}); + const [saving, setSaving] = useState>({}); + + 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 ( + <> +
+ + +
+
+ + +
+ + ); + } + + if (error) return

{error}

; + if (items.length === 0) return

No hay actividades disponibles.

; + + return ( + <> + + {items.map((act) => { + const yaRealizada = Boolean(act.realizada); + return ( + + +

+ {act.nombre}{" "} + {yaRealizada && ( + Realizada + )} +

+ + {/* Si no quieres la descripción, comenta esta línea */} + {/* {act.descripcion &&

{act.descripcion}

} */} + +
+ {act.duracion_minutos} min + {act.dificultad != null && dif. {act.dificultad}} + {act.puntos_base != null && {act.puntos_base} pts} +
+
+ +
+ abrirInstrucciones(act)}> + INSTRUCCIONES + + + {yaRealizada ? ( + // Deshabilitado para siempre + + COMPLETADA + + ) : running[act.id_actividad] ? ( + handleStop(act.id_actividad, act)} + > + DETENER + + ) : ( + handleStart(act.id_actividad)} + > + INICIAR + + )} +
+
+ ); + })} +
+ + {/* Modal de instrucciones */} + setOpen(false)}> + + + {sel?.nombre || "Instrucciones"} + + setOpen(false)}>Cerrar + + + + + {sel && ( + <> +
+ {sel.duracion_minutos} min + {sel.dificultad != null && dif. {sel.dificultad}} + {sel.puntos_base != null && {sel.puntos_base} pts} +
+

+ {sel.descripcion || "Sin descripción disponible."} +

+ + )} +
+
+ + ); +} diff --git a/src/components/LogoutButton.tsx b/src/components/LogoutButton.tsx new file mode 100644 index 0000000..fb136ea --- /dev/null +++ b/src/components/LogoutButton.tsx @@ -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 ( + logout()}> + + {label} + + ); +} diff --git a/src/components/RedirectByRole.tsx b/src/components/RedirectByRole.tsx new file mode 100644 index 0000000..e79107b --- /dev/null +++ b/src/components/RedirectByRole.tsx @@ -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(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 ; +} diff --git a/src/components/RequireAdmin.tsx b/src/components/RequireAdmin.tsx new file mode 100644 index 0000000..d4afd5e --- /dev/null +++ b/src/components/RequireAdmin.tsx @@ -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 ; + if (state === "notadmin") return ; + return <>{children}; +} diff --git a/src/components/RequireAuth.tsx b/src/components/RequireAuth.tsx new file mode 100644 index 0000000..30ecd7c --- /dev/null +++ b/src/components/RequireAuth.tsx @@ -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(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 ( + + +
+ +
+
+
+ ); + } + + if (!ok) return ; + return <>{children}; +} diff --git a/src/components/SidebarMenu.tsx b/src/components/SidebarMenu.tsx new file mode 100644 index 0000000..979334b --- /dev/null +++ b/src/components/SidebarMenu.tsx @@ -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 ( + + + + Institución educativa + + + + + + + Inicio + + + + + + + Categorías + + + + + + + Perfil + + + + + + + +
+ +
+
+
+
+ ); +} diff --git a/src/hooks/useMe.ts b/src/hooks/useMe.ts new file mode 100644 index 0000000..68c77cc --- /dev/null +++ b/src/hooks/useMe.ts @@ -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(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 }; +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..1c3aaef --- /dev/null +++ b/src/lib/api.ts @@ -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; diff --git a/src/main.tsx b/src/main.tsx index fbff6c8..69070f7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( diff --git a/src/pages/AppShell.tsx b/src/pages/AppShell.tsx new file mode 100644 index 0000000..3427316 --- /dev/null +++ b/src/pages/AppShell.tsx @@ -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 ( + + + + + + + + + + Institución educativa + + + + + + + + + + {/* fallback interno del shell */} + + + + + +

Ruta no encontrada

+
+
+
+
+
+ ); +} diff --git a/src/pages/Auth.css b/src/pages/Auth.css new file mode 100644 index 0000000..d8befd6 --- /dev/null +++ b/src/pages/Auth.css @@ -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; +} diff --git a/src/pages/Auth.tsx b/src/pages/Auth.tsx new file mode 100644 index 0000000..a5752f3 --- /dev/null +++ b/src/pages/Auth.tsx @@ -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 ( + + +
+ {/* Lado Izquierdo (Login) */} +
+

¡Bienvenido!

+

Inicia sesión con tu cuenta

+ + Correo electrónico + + + + Contraseña + + + + Iniciar Sesión + +

+ ¿No tienes cuenta? + setIsLogin(false)}> Regístrate +

+
+ + {/* Lado Derecho (Registro) */} +
+

Crea tu Cuenta

+ + Nombre + + + + Correo electrónico + + + + Contraseña + + + + Registrarse + +

+ ¿Ya tienes cuenta? + setIsLogin(true)}> Inicia sesión +

+
+
+
+
+ ); +}; + +export default Auth; diff --git a/src/pages/Bienvenida.css b/src/pages/Bienvenida.css new file mode 100644 index 0000000..4d63740 --- /dev/null +++ b/src/pages/Bienvenida.css @@ -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); +} diff --git a/src/pages/Bienvenida.tsx b/src/pages/Bienvenida.tsx new file mode 100644 index 0000000..e681285 --- /dev/null +++ b/src/pages/Bienvenida.tsx @@ -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 ( + + +
+

+ ¡Bienvenida, Raquel! 🌿 +

+ + {/* Mostrar Añadir solo a admins */} + {isAdmin && ( + { /* refrescar si lo necesitas */ }} /> + )} +
+ +

+ Selecciona una categoría para comenzar tus actividades +

+ +
+ + + + + + + +
+
+
+ ); +} diff --git a/src/pages/Categoria.tsx b/src/pages/Categoria.tsx new file mode 100644 index 0000000..defc17b --- /dev/null +++ b/src/pages/Categoria.tsx @@ -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(); + const key = useMemo(() => (slug || "").toLowerCase(), [slug]); + + const [titulo, setTitulo] = useState(""); + const [categoriaId, setCategoriaId] = useState(null); + const [loading, setLoading] = useState(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 ( + + + + + + + {titulo || "Categoría"} + + + + + {loading ? ( +
+ +
+ ) : categoriaId === null ? ( + No hay actividades para mostrar. + ) : ( + <> + {/* Lista las actividades de la categoría resuelta */} + {/* Tu componente llama GET /api/actividades?categoria=&activo=1 */} + + + )} +
+
+ ); +} diff --git a/src/pages/Categorias.css b/src/pages/Categorias.css new file mode 100644 index 0000000..36604a6 --- /dev/null +++ b/src/pages/Categorias.css @@ -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; } diff --git a/src/pages/Categorias.tsx b/src/pages/Categorias.tsx new file mode 100644 index 0000000..386c18e --- /dev/null +++ b/src/pages/Categorias.tsx @@ -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([]); + + 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 ( + + {/* Header con botón Añadir */} + + + Categorías + + + + + + + + {/* --- TUS 4 TILES ESTÁTICOS (sin tocar) --- */} +
+ + + + + + + +
+ + {/* --- LISTA DINÁMICA DESDE EL BACKEND (para verificar nuevas) --- */} +

Otras categorías

+ + {loading ? ( +
+ +
+ ) : cats.length === 0 ? ( + No hay categorías adicionales. + ) : ( + + {cats.map((c) => ( + + (window.location.href = `/tabs/categorias/${encodeURIComponent( + c.nombre.toLowerCase() + )}`) + } + > + +

{c.nombre}

+ {c.descripcion &&

{c.descripcion}

} +
+ {(c.activo === 1 || c.activo === true) ? "Activo" : "Inactivo"} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/pages/Completadas.tsx b/src/pages/Completadas.tsx new file mode 100644 index 0000000..ef13a8d --- /dev/null +++ b/src/pages/Completadas.tsx @@ -0,0 +1,13 @@ +import { IonPage, IonContent } from "@ionic/react"; + +export default function Completadas() { + return ( + + +

Actividades realizadas

+

Lista/Historial de actividades completadas por el estudiante.

+
+
+ ); +} + diff --git a/src/pages/Inicio.css b/src/pages/Inicio.css new file mode 100644 index 0000000..37672f4 --- /dev/null +++ b/src/pages/Inicio.css @@ -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); +} diff --git a/src/pages/Inicio.tsx b/src/pages/Inicio.tsx new file mode 100644 index 0000000..05c5f98 --- /dev/null +++ b/src/pages/Inicio.tsx @@ -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 ( + + {/* Sin IonHeader aquí: el AppShell ya tiene el suyo */} + + {/* Encabezado de bienvenida */} +
+

¡Bienvenida, {usuario}! 🌿

+

Selecciona una categoría para comenzar tus actividades

+
+ +
+ + + {cats.map((c) => ( + + +
+ + + + {c.titulo} + + +
+
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/pages/Login.css b/src/pages/Login.css new file mode 100644 index 0000000..108bb53 --- /dev/null +++ b/src/pages/Login.css @@ -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; +} diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx new file mode 100644 index 0000000..e8f058e --- /dev/null +++ b/src/pages/Login.tsx @@ -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(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 ( + + + + Iniciar sesión + + + + +
+ + Correo + setEmail(e.detail.value || "")} + required + /> + + + + Contraseña + setPassword(e.detail.value || "")} + required + /> + + +
+ setRemember(!!e.detail.checked)} + /> + Recordarme en este dispositivo +
+ + + {loading ? : "Ingresar"} + + +
+ + ¿No tienes cuenta? Regístrate + +
+
+ + setErrMsg(null)} + /> +
+
+ ); +} diff --git a/src/pages/Perfil.css b/src/pages/Perfil.css new file mode 100644 index 0000000..34737c0 --- /dev/null +++ b/src/pages/Perfil.css @@ -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; } diff --git a/src/pages/Perfil.tsx b/src/pages/Perfil.tsx new file mode 100644 index 0000000..6214ad6 --- /dev/null +++ b/src/pages/Perfil.tsx @@ -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 { + 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(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( + localStorage.getItem("avatar") || "https://i.pravatar.cc/150?img=5" + ); + const [nombre, setNombre] = useState( + parsedUser?.nombre || localStorage.getItem("nombre") || "Nombre de Usuario" + ); + const [email] = useState(parsedUser?.email || "usuario@correo.com"); + + // password + const [passActual, setPassActual] = useState(""); + const [passNueva, setPassNueva] = useState(""); + const [passConfirma, setPassConfirma] = useState(""); + const [msgPass, setMsgPass] = useState(""); + + // toasts simples + const [okMsg, setOkMsg] = useState(null); + const [errMsg, setErrMsg] = useState(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) => { + 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 ( + + + + Perfil + + + + +
+
+ + avatar + + + + + + + +

{nombre}

+ {email} +
+
+ +
+
+ {/* Editar nombre */} + + + + Nombre + setNombre(e.detail.value || "")} + /> + + + + + Guardar cambios + + + {/* Cambiar contraseña */} +
+ +

Cambiar contraseña

+
+ + + + Contraseña actual + setPassActual(e.detail.value || "")} + /> + + + + + Nueva contraseña + setPassNueva(e.detail.value || "")} + /> + + + + + Confirmar nueva contraseña + setPassConfirma(e.detail.value || "")} + /> + + + {msgPass && ( + + {msgPass} + + )} + + + Actualizar contraseña + +
+ + {/* Botón Cerrar sesión */} + setShowConfirm(true)} + > + + Cerrar sesión + + + {/* Accesos de abajo */} + + + + +

Mi progreso

+

Resumen y estadísticas

+
+ +
+ + + + +

Actividades completas

+

Historial de actividades

+
+ +
+
+
+
+ + {/* Confirmación logout */} + setShowConfirm(false)} + buttons={[ + { text: "Cancelar", role: "cancel" }, + { text: "Salir", handler: doLogout }, + ]} + /> + + {/* Toasts */} + setToastOut(false)} + /> + setOkMsg(null)} + /> + setErrMsg(null)} + /> +
+
+ ); +} diff --git a/src/pages/Progreso.tsx b/src/pages/Progreso.tsx new file mode 100644 index 0000000..decd5e6 --- /dev/null +++ b/src/pages/Progreso.tsx @@ -0,0 +1,12 @@ +import { IonPage, IonContent } from "@ionic/react"; + +export default function Progreso() { + return ( + + +

Mi progreso

+

Aquí puedes renderizar tu gráfica / estadísticas del estudiante.

+
+
+ ); +} diff --git a/src/pages/Registro.css b/src/pages/Registro.css new file mode 100644 index 0000000..71edd1e --- /dev/null +++ b/src/pages/Registro.css @@ -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; } diff --git a/src/pages/Registro.tsx b/src/pages/Registro.tsx new file mode 100644 index 0000000..12c675d --- /dev/null +++ b/src/pages/Registro.tsx @@ -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(""); // yyyy-mm-dd + const [genero, setGenero] = useState<"F" | "M" | "O" | "">(""); + + const [loading, setLoading] = useState(false); + const [errorMsg, setErrorMsg] = useState(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 ( + + +
+ + + +

Registrarse

+
+ +
+ {/* Nombre */} + + + Nombre + setNombre(e.detail.value || "")} + required + /> + + + {/* Apellido */} + + + Apellido + setApellido(e.detail.value || "")} + required + /> + + + {/* Correo */} + + + Correo + setEmail(e.detail.value || "")} + required + /> + + + {/* Contraseña */} + + + Contraseña + setPassword(e.detail.value || "")} + required + /> + + + {/* Fecha de nacimiento */} + + + Fecha de nacimiento + {/* Input nativo de fecha para ver el calendario a la derecha */} + setFechaNacimiento(e.detail.value || "")} + required + /> + + + {/* Género */} + + + Género + setGenero(e.detail.value)} + required + > + Femenino + Masculino + Otro / Prefiero no decir + + + + {/* Error */} + {errorMsg &&

{errorMsg}

} + + {/* Botón principal */} + + {loading ? "Registrando…" : "REGISTRARSE"} + +
+ +

+ ¿Ya tienes cuenta?{" "} + + Inicia sesión + +

+
+
+
+ + setOkToast(false)} + /> +
+
+ ); +} diff --git a/src/pages/Tab1.tsx b/src/pages/Tab1.tsx deleted file mode 100644 index 97aa72d..0000000 --- a/src/pages/Tab1.tsx +++ /dev/null @@ -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 ( - - - - Tab 1 - - - - - - Tab 1 - - - - - - ); -}; - -export default Tab1; diff --git a/src/pages/Tab2.tsx b/src/pages/Tab2.tsx index 05458aa..3c3d376 100644 --- a/src/pages/Tab2.tsx +++ b/src/pages/Tab2.tsx @@ -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 ( - - - Tab 2 - - - - - - Tab 2 - - - - + Tab 2 + Contenido de Tab 2 ); -}; - -export default Tab2; +} diff --git a/src/pages/Tab3.tsx b/src/pages/Tab3.tsx index 3a29b8a..66aff83 100644 --- a/src/pages/Tab3.tsx +++ b/src/pages/Tab3.tsx @@ -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 ( - - - Tab 3 - - - - - - Tab 3 - - - - + Tab 3 + Contenido de Tab 3 ); -}; - -export default Tab3; +} diff --git a/src/pages/Tab1.css b/src/pages/Tabs.css similarity index 100% rename from src/pages/Tab1.css rename to src/pages/Tabs.css diff --git a/src/pages/Tabs.tsx b/src/pages/Tabs.tsx new file mode 100644 index 0000000..ab1f75d --- /dev/null +++ b/src/pages/Tabs.tsx @@ -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 ( + + + + + + + + + + + + + + + + history.goBack()}> + + Regresar + + + + + Inicio + + + + + Perfil + + + + ); +} diff --git a/src/services/Registro.ts b/src/services/Registro.ts new file mode 100644 index 0000000..77b00b2 --- /dev/null +++ b/src/services/Registro.ts @@ -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; +} diff --git a/src/services/actividades.ts b/src/services/actividades.ts new file mode 100644 index 0000000..7d6d020 --- /dev/null +++ b/src/services/actividades.ts @@ -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=&activo=1 +export async function fetchActividadesPorCategoria(categoriaId: number) { + const { data } = await api.get("/api/actividades", { + params: { categoria: categoriaId, activo: 1 }, + }); + return data; +} diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..6e4beca --- /dev/null +++ b/src/services/api.ts @@ -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 { + try { + const { data } = await api.post("/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 { + 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); + } +} diff --git a/src/services/auth.ts b/src/services/auth.ts new file mode 100644 index 0000000..6dcfa28 --- /dev/null +++ b/src/services/auth.ts @@ -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(); +} diff --git a/src/services/authService.ts b/src/services/authService.ts new file mode 100644 index 0000000..1688281 --- /dev/null +++ b/src/services/authService.ts @@ -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'); +} diff --git a/src/services/data.ts b/src/services/data.ts new file mode 100644 index 0000000..838456e --- /dev/null +++ b/src/services/data.ts @@ -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." + }, +]; diff --git a/src/theme/variables.css b/src/theme/variables.css index 131c419..f7fc0df 100644 --- a/src/theme/variables.css +++ b/src/theme/variables.css @@ -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; +} diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..aba2b95 --- /dev/null +++ b/src/utils/auth.ts @@ -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");