Inicial
This commit is contained in:
parent
c0bb948a41
commit
7c34d03728
|
|
@ -17,6 +17,7 @@
|
||||||
"@ionic/react-router": "^8.5.0",
|
"@ionic/react-router": "^8.5.0",
|
||||||
"@types/react-router": "^5.1.20",
|
"@types/react-router": "^5.1.20",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"axios": "^1.12.2",
|
||||||
"ionicons": "^7.4.0",
|
"ionicons": "^7.4.0",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
|
|
@ -4525,7 +4526,6 @@
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/at-least-node": {
|
"node_modules/at-least-node": {
|
||||||
|
|
@ -4571,6 +4571,23 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/babel-plugin-polyfill-corejs2": {
|
||||||
"version": "0.4.14",
|
"version": "0.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
|
"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",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
|
|
@ -5094,7 +5110,6 @@
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"delayed-stream": "~1.0.0"
|
"delayed-stream": "~1.0.0"
|
||||||
|
|
@ -5520,7 +5535,6 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
|
|
@ -5584,7 +5598,6 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
|
@ -5760,7 +5773,6 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -5770,7 +5782,6 @@
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -5808,7 +5819,6 @@
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
|
|
@ -5821,7 +5831,6 @@
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
|
|
@ -6433,6 +6442,26 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
|
|
@ -6493,7 +6522,6 @@
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
|
|
@ -6574,7 +6602,6 @@
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
|
@ -6635,7 +6662,6 @@
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
|
@ -6660,7 +6686,6 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dunder-proto": "^1.0.1",
|
"dunder-proto": "^1.0.1",
|
||||||
|
|
@ -6827,7 +6852,6 @@
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -6906,7 +6930,6 @@
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -6919,7 +6942,6 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-symbols": "^1.0.3"
|
"has-symbols": "^1.0.3"
|
||||||
|
|
@ -6935,7 +6957,6 @@
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
|
|
@ -8454,7 +8475,6 @@
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -8521,7 +8541,6 @@
|
||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
|
|
@ -8531,7 +8550,6 @@
|
||||||
"version": "2.1.35",
|
"version": "2.1.35",
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "1.52.0"
|
"mime-db": "1.52.0"
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
"@ionic/react-router": "^8.5.0",
|
"@ionic/react-router": "^8.5.0",
|
||||||
"@types/react-router": "^5.1.20",
|
"@types/react-router": "^5.1.20",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"axios": "^1.12.2",
|
||||||
"ionicons": "^7.4.0",
|
"ionicons": "^7.4.0",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 332 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
130
src/App.tsx
130
src/App.tsx
|
|
@ -1,87 +1,71 @@
|
||||||
import { Redirect, Route } from 'react-router-dom';
|
// src/App.tsx
|
||||||
import {
|
import React from "react";
|
||||||
IonApp,
|
import { IonApp } from "@ionic/react";
|
||||||
IonIcon,
|
import { IonReactRouter } from "@ionic/react-router";
|
||||||
IonLabel,
|
import { IonRouterOutlet } from "@ionic/react";
|
||||||
IonRouterOutlet,
|
import { Route, Redirect } from "react-router-dom";
|
||||||
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';
|
|
||||||
|
|
||||||
/* Core CSS required for Ionic components to work properly */
|
// Páginas públicas
|
||||||
import '@ionic/react/css/core.css';
|
import Login from "./pages/Login";
|
||||||
|
import Registro from "./pages/Registro";
|
||||||
|
|
||||||
/* Basic CSS for apps built with Ionic */
|
// Layout nuevo con sidebar
|
||||||
import '@ionic/react/css/normalize.css';
|
import AppShell from "./pages/AppShell";
|
||||||
import '@ionic/react/css/structure.css';
|
|
||||||
import '@ionic/react/css/typography.css';
|
|
||||||
|
|
||||||
/* Optional CSS utils that can be commented out */
|
// Guard de autenticación
|
||||||
import '@ionic/react/css/padding.css';
|
import RequireAuth from "./components/RequireAuth";
|
||||||
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';
|
|
||||||
|
|
||||||
/**
|
const hasToken = () =>
|
||||||
* Ionic Dark Mode
|
!!(localStorage.getItem("token") || sessionStorage.getItem("token"));
|
||||||
* -----------------------------------------------------
|
|
||||||
* For more info, please see:
|
|
||||||
* https://ionicframework.com/docs/theming/dark-mode
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* import '@ionic/react/css/palettes/dark.always.css'; */
|
const App: React.FC = () => {
|
||||||
/* import '@ionic/react/css/palettes/dark.class.css'; */
|
return (
|
||||||
import '@ionic/react/css/palettes/dark.system.css';
|
<IonApp>
|
||||||
|
<IonReactRouter>
|
||||||
/* Theme variables */
|
|
||||||
import './theme/variables.css';
|
|
||||||
|
|
||||||
setupIonicReact();
|
|
||||||
|
|
||||||
const App: React.FC = () => (
|
|
||||||
<IonApp>
|
|
||||||
<IonReactRouter>
|
|
||||||
<IonTabs>
|
|
||||||
<IonRouterOutlet>
|
<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>
|
||||||
<Route exact path="/tab2">
|
|
||||||
<Tab2 />
|
{/* ===== Redirects de compatibilidad (antiguas rutas) ===== */}
|
||||||
|
<Route exact path="/bienvenida">
|
||||||
|
<Redirect to="/app/inicio" />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/tab3">
|
|
||||||
<Tab3 />
|
<Route exact path="/categorias">
|
||||||
|
<Redirect to="/app/categorias" />
|
||||||
</Route>
|
</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="/">
|
<Route exact path="/">
|
||||||
<Redirect to="/tab1" />
|
{hasToken() ? <Redirect to="/app/inicio" /> : <Redirect to="/login" />}
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
</IonRouterOutlet>
|
</IonRouterOutlet>
|
||||||
<IonTabBar slot="bottom">
|
</IonReactRouter>
|
||||||
<IonTabButton tab="tab1" href="/tab1">
|
</IonApp>
|
||||||
<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;
|
export default App;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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:''})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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} />;
|
||||||
|
}
|
||||||
|
|
@ -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}</>;
|
||||||
|
}
|
||||||
|
|
@ -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}</>;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
32
src/main.tsx
32
src/main.tsx
|
|
@ -1,10 +1,30 @@
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import { createRoot } from 'react-dom/client';
|
import ReactDOM from "react-dom/client";
|
||||||
import App from './App';
|
import App from "./App";
|
||||||
|
import { setupIonicReact } from "@ionic/react";
|
||||||
|
|
||||||
const container = document.getElementById('root');
|
/* Core CSS requerido por Ionic */
|
||||||
const root = createRoot(container!);
|
import "@ionic/react/css/core.css";
|
||||||
root.render(
|
|
||||||
|
/* 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>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,25 +1,10 @@
|
||||||
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/react';
|
// src/pages/Tab2.tsx
|
||||||
import ExploreContainer from '../components/ExploreContainer';
|
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent } from "@ionic/react";
|
||||||
import './Tab2.css';
|
export default function Tab2() {
|
||||||
|
|
||||||
const Tab2: React.FC = () => {
|
|
||||||
return (
|
return (
|
||||||
<IonPage>
|
<IonPage>
|
||||||
<IonHeader>
|
<IonHeader><IonToolbar><IonTitle>Tab 2</IonTitle></IonToolbar></IonHeader>
|
||||||
<IonToolbar>
|
<IonContent className="ion-padding">Contenido de Tab 2</IonContent>
|
||||||
<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>
|
|
||||||
</IonPage>
|
</IonPage>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default Tab2;
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,10 @@
|
||||||
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/react';
|
// src/pages/Tab3.tsx
|
||||||
import ExploreContainer from '../components/ExploreContainer';
|
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent } from "@ionic/react";
|
||||||
import './Tab3.css';
|
export default function Tab3() {
|
||||||
|
|
||||||
const Tab3: React.FC = () => {
|
|
||||||
return (
|
return (
|
||||||
<IonPage>
|
<IonPage>
|
||||||
<IonHeader>
|
<IonHeader><IonToolbar><IonTitle>Tab 3</IonTitle></IonToolbar></IonHeader>
|
||||||
<IonToolbar>
|
<IonContent className="ion-padding">Contenido de Tab 3</IonContent>
|
||||||
<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>
|
|
||||||
</IonPage>
|
</IonPage>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default Tab3;
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
@ -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."
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -1,2 +1,7 @@
|
||||||
/* For information on how to create your own theme, please see:
|
/* For information on how to create your own theme, please see:
|
||||||
http://ionicframework.com/docs/theming/ */
|
http://ionicframework.com/docs/theming/ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* color primario por defecto de Ionic */
|
||||||
|
--ion-color-primary: #3880ff;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
Loading…
Reference in New Issue