feat(all): conexión de servicio de login.
This commit is contained in:
1 parent
33d3afb768
commit
9fa5cc38e8
27 files changed
+2173
-139
No files matched your search
Generated
+359
-2
@@ -8,13 +8,17 @@
|
|||||||
"name": "courses-rynext",
|
"name": "courses-rynext",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@azure/msal-browser": "^5.21.0",
|
||||||
|
"@react-oauth/google": "^0.13.5",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
|
"axios": "^1.20.0",
|
||||||
"next": "^16.3.3",
|
"next": "^16.3.3",
|
||||||
"postcss": "^8.5.26",
|
"postcss": "^8.5.26",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-icons": "^5.7.0",
|
"react-icons": "^5.7.0",
|
||||||
|
"sonner": "^2.0.8",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"zustand": "^5.0.15"
|
"zustand": "^5.0.15"
|
||||||
},
|
},
|
||||||
@@ -43,6 +47,27 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@azure/msal-browser": {
|
||||||
|
"version": "5.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.21.0.tgz",
|
||||||
|
"integrity": "sha512-80OcuXDErmcEDAIH9pBtSqBsed2sPT/IWmbG3xHLoPMl5zc8TINd6SlJAbVSmN5huGa3xGAg5qR7VnpaIEK0Zw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@azure/msal-common": "16.14.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@azure/msal-common": {
|
||||||
|
"version": "16.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.0.tgz",
|
||||||
|
"integrity": "sha512-A4rb55hI86Q9tBl/+jBj7TMz7iX2RFgQs/nExFzcAtoI/BFRVdaH5SL/MivrYD7qvweMpN8AgVvVMHV8UBYxew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -1168,6 +1193,16 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-oauth/google": {
|
||||||
|
"version": "0.13.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.13.5.tgz",
|
||||||
|
"integrity": "sha512-xQWri2s/3nNekZJ4uuov2aAfQYu83bN3864KcFqw2pK1nNbFurQIjPFDXhWaKH3IjYJ2r/9yyIIpsn5lMqrheQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.23",
|
"version": "0.5.23",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||||
@@ -1779,6 +1814,18 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
@@ -1796,6 +1843,24 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
|
||||||
|
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.16.0",
|
||||||
|
"form-data": "^4.0.6",
|
||||||
|
"https-proxy-agent": "^5.0.1",
|
||||||
|
"proxy-from-env": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
@@ -1866,6 +1931,19 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001809",
|
"version": "1.0.30001809",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
|
||||||
@@ -1892,6 +1970,18 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -1925,7 +2015,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -1946,6 +2035,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -1955,6 +2053,20 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.412",
|
"version": "1.5.412",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz",
|
||||||
@@ -1975,6 +2087,51 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -2274,6 +2431,51 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.4",
|
||||||
|
"mime-types": "^2.1.35"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gensync": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
@@ -2284,6 +2486,43 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -2310,12 +2549,63 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hermes-estree": {
|
"node_modules/hermes-estree": {
|
||||||
"version": "0.25.1",
|
"version": "0.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||||
@@ -2333,6 +2623,19 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "6",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -2754,6 +3057,36 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -2774,7 +3107,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
@@ -3021,6 +3353,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -3165,6 +3506,22 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sonner": {
|
||||||
|
"version": "2.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz",
|
||||||
|
"integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
@@ -10,13 +10,17 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@azure/msal-browser": "^5.21.0",
|
||||||
|
"@react-oauth/google": "^0.13.5",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
|
"axios": "^1.20.0",
|
||||||
"next": "^16.3.3",
|
"next": "^16.3.3",
|
||||||
"postcss": "^8.5.26",
|
"postcss": "^8.5.26",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-icons": "^5.7.0",
|
"react-icons": "^5.7.0",
|
||||||
|
"sonner": "^2.0.8",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"zustand": "^5.0.15"
|
"zustand": "^5.0.15"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
FiArrowLeft, FiPlay, FiCheckCircle, FiLock, FiChevronDown, FiChevronUp,
|
||||||
|
FiClock, FiBook, FiAward, FiStar, FiGlobe, FiMenu, FiX,
|
||||||
|
} from "react-icons/fi";
|
||||||
|
import LayoutBase from "@/components/layout/LayoutBase";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useRequireAuth } from "@/hooks/auth/useAuthGuard";
|
||||||
|
|
||||||
|
// ── Mock data ─────────────────────────────────────────────────────────────────
|
||||||
|
const MOCK: Record<string, {
|
||||||
|
id: string; title: string; subtitle: string; instructor: string;
|
||||||
|
instructorAvatar: string; instructorBio: string; instructorRating: number; instructorStudents: number;
|
||||||
|
thumbnail: string; duration: string; language: string; rating: number; reviewsCount: number;
|
||||||
|
progress: number;
|
||||||
|
modules: { id: string; title: string; lessons: { id: string; title: string; duration: string; completed: boolean; free: boolean }[] }[];
|
||||||
|
}> = {
|
||||||
|
"1": {
|
||||||
|
id: "1", title: "Next.js 14 — De cero a producción", subtitle: "Aprende React, Server Components, App Router y despliega en Vercel.",
|
||||||
|
instructor: "Carlos Méndez", instructorAvatar: "CM",
|
||||||
|
instructorBio: "Desarrollador Full Stack con 10 años de experiencia. Especialista en ecosistema React y arquitecturas modernas.",
|
||||||
|
instructorRating: 4.9, instructorStudents: 32400,
|
||||||
|
thumbnail: "https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=800&q=80",
|
||||||
|
duration: "18h 30m", language: "Español", rating: 4.8, reviewsCount: 1240, progress: 68,
|
||||||
|
modules: [
|
||||||
|
{ id: "m1", title: "Introducción y configuración", lessons: [
|
||||||
|
{ id: "l1", title: "Bienvenida al curso", duration: "3:20", completed: true, free: true },
|
||||||
|
{ id: "l2", title: "Instalación y primer proyecto", duration: "8:45", completed: true, free: true },
|
||||||
|
{ id: "l3", title: "Estructura del proyecto", duration: "6:10", completed: true, free: false },
|
||||||
|
]},
|
||||||
|
{ id: "m2", title: "App Router y Pages", lessons: [
|
||||||
|
{ id: "l4", title: "Diferencias con Pages Router", duration: "10:30", completed: true, free: false },
|
||||||
|
{ id: "l5", title: "Layouts y templates", duration: "12:15", completed: true, free: false },
|
||||||
|
{ id: "l6", title: "Loading y Error boundaries", duration: "9:00", completed: false, free: false },
|
||||||
|
]},
|
||||||
|
{ id: "m3", title: "Server Components", lessons: [
|
||||||
|
{ id: "l7", title: "¿Qué son los Server Components?", duration: "14:20", completed: false, free: false },
|
||||||
|
{ id: "l8", title: "Fetching de datos en servidor", duration: "18:45", completed: false, free: false },
|
||||||
|
{ id: "l9", title: "Client vs Server Components", duration: "11:30", completed: false, free: false },
|
||||||
|
]},
|
||||||
|
{ id: "m4", title: "Deploy en Vercel", lessons: [
|
||||||
|
{ id: "l10", title: "Preparando el proyecto", duration: "7:15", completed: false, free: false },
|
||||||
|
{ id: "l11", title: "Variables de entorno", duration: "5:40", completed: false, free: false },
|
||||||
|
{ id: "l12", title: "Deploy y dominio personalizado", duration: "13:00", completed: false, free: false },
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
id: "2", title: "TypeScript Avanzado para React", subtitle: "Domina los tipos avanzados, genéricos y patrones de React con TypeScript.",
|
||||||
|
instructor: "Ana García", instructorAvatar: "AG",
|
||||||
|
instructorBio: "Ingeniera de software y autora técnica. Contribuidora activa de DefinitelyTyped.",
|
||||||
|
instructorRating: 4.8, instructorStudents: 18700,
|
||||||
|
thumbnail: "https://images.unsplash.com/photo-1587620962725-abab7fe55159?w=800&q=80",
|
||||||
|
duration: "12h 15m", language: "Español", rating: 4.9, reviewsCount: 876, progress: 100,
|
||||||
|
modules: [
|
||||||
|
{ id: "m1", title: "Tipos avanzados", lessons: [
|
||||||
|
{ id: "l1", title: "Utility Types", duration: "12:00", completed: true, free: true },
|
||||||
|
{ id: "l2", title: "Conditional Types", duration: "15:30", completed: true, free: false },
|
||||||
|
]},
|
||||||
|
{ id: "m2", title: "Genéricos en React", lessons: [
|
||||||
|
{ id: "l3", title: "Componentes genéricos", duration: "18:00", completed: true, free: false },
|
||||||
|
{ id: "l4", title: "Hooks tipados", duration: "14:20", completed: true, free: false },
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
id: "3", title: "Diseño UI/UX con Figma", subtitle: "Crea interfaces profesionales y prototipos interactivos desde cero.",
|
||||||
|
instructor: "Sofía Rojas", instructorAvatar: "SR",
|
||||||
|
instructorBio: "Diseñadora de producto con experiencia en startups y empresas Fortune 500.",
|
||||||
|
instructorRating: 4.7, instructorStudents: 25100,
|
||||||
|
thumbnail: "https://images.unsplash.com/photo-1561070791-2526d30994b5?w=800&q=80",
|
||||||
|
duration: "15h 45m", language: "Español", rating: 4.7, reviewsCount: 654, progress: 23,
|
||||||
|
modules: [
|
||||||
|
{ id: "m1", title: "Fundamentos de Figma", lessons: [
|
||||||
|
{ id: "l1", title: "Interfaz de Figma", duration: "8:00", completed: true, free: true },
|
||||||
|
{ id: "l2", title: "Frames y componentes", duration: "11:30", completed: true, free: false },
|
||||||
|
{ id: "l3", title: "Auto Layout", duration: "14:00", completed: false, free: false },
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
id: "4", title: "Python para Data Science", subtitle: "Pandas, NumPy, visualización de datos y Machine Learning básico.",
|
||||||
|
instructor: "Luis Torres", instructorAvatar: "LT",
|
||||||
|
instructorBio: "Data Scientist con PhD en estadística. Consultor para empresas de tecnología en LATAM.",
|
||||||
|
instructorRating: 4.9, instructorStudents: 41200,
|
||||||
|
thumbnail: "https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=800&q=80",
|
||||||
|
duration: "22h 00m", language: "Español", rating: 4.9, reviewsCount: 2103, progress: 100,
|
||||||
|
modules: [
|
||||||
|
{ id: "m1", title: "Python esencial", lessons: [
|
||||||
|
{ id: "l1", title: "Tipos y estructuras", duration: "10:00", completed: true, free: true },
|
||||||
|
{ id: "l2", title: "Funciones y clases", duration: "13:00", completed: true, free: false },
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Componente principal ──────────────────────────────────────────────────────
|
||||||
|
export default function CoursePlayer({ id }: { id: string }) {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAuthenticated } = useRequireAuth();
|
||||||
|
|
||||||
|
const course = MOCK[id];
|
||||||
|
const [activeLesson, setActiveLesson] = useState(() => {
|
||||||
|
if (!course) return null;
|
||||||
|
for (const mod of course.modules) {
|
||||||
|
const next = mod.lessons.find(l => !l.completed);
|
||||||
|
if (next) return next.id;
|
||||||
|
}
|
||||||
|
return course.modules[0]?.lessons[0]?.id ?? null;
|
||||||
|
});
|
||||||
|
const [expandedModules, setExpandedModules] = useState<Record<string, boolean>>({ m1: true });
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!isAuthenticated) return null;
|
||||||
|
if (!course) return (
|
||||||
|
<LayoutBase>
|
||||||
|
<div className="flex flex-col items-center justify-center py-32">
|
||||||
|
<p className={`text-lg font-semibold ${isDark ? "text-slate-300" : "text-slate-700"}`}>Curso no encontrado</p>
|
||||||
|
<button onClick={() => router.push("/my-courses")} className="mt-4 text-violet-500 hover:underline text-sm">
|
||||||
|
Volver a mis cursos
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</LayoutBase>
|
||||||
|
);
|
||||||
|
|
||||||
|
const allLessons = course.modules.flatMap(m => m.lessons);
|
||||||
|
const currentLesson = allLessons.find(l => l.id === activeLesson) ?? allLessons[0];
|
||||||
|
const totalLessons = allLessons.length;
|
||||||
|
const completedLessons = allLessons.filter(l => l.completed).length;
|
||||||
|
|
||||||
|
const toggleModule = (id: string) => setExpandedModules(p => ({ ...p, [id]: !p[id] }));
|
||||||
|
|
||||||
|
const border = isDark ? "border-slate-800" : "border-slate-200";
|
||||||
|
const bg = isDark ? "bg-slate-900" : "bg-white";
|
||||||
|
const muted = isDark ? "text-slate-400" : "text-slate-500";
|
||||||
|
|
||||||
|
// ── Sidebar del temario ────────────────────────────────────────────────────
|
||||||
|
const Sidebar = () => (
|
||||||
|
<aside className={`flex flex-col h-full border-l ${border} ${bg} overflow-hidden`}>
|
||||||
|
<div className={`px-4 py-3 border-b ${border} shrink-0`}>
|
||||||
|
<p className={`text-xs font-semibold uppercase tracking-wider ${muted}`}>Contenido del curso</p>
|
||||||
|
<p className={`text-sm font-bold mt-0.5 ${isDark ? "text-white" : "text-slate-900"}`}>
|
||||||
|
{completedLessons}/{totalLessons} lecciones
|
||||||
|
</p>
|
||||||
|
<div className={`h-1 rounded-full mt-2 ${isDark ? "bg-slate-700" : "bg-slate-200"}`}>
|
||||||
|
<div className="h-1 rounded-full bg-violet-500 transition-all" style={{ width: `${(completedLessons / totalLessons) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1">
|
||||||
|
{course.modules.map(mod => (
|
||||||
|
<div key={mod.id}>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleModule(mod.id)}
|
||||||
|
className={`w-full flex items-center justify-between px-4 py-3 text-left text-sm font-semibold transition-colors ${isDark ? "text-slate-200 hover:bg-slate-800" : "text-slate-800 hover:bg-slate-50"}`}
|
||||||
|
>
|
||||||
|
<span className="pr-2">{mod.title}</span>
|
||||||
|
{expandedModules[mod.id] ? <FiChevronUp className="shrink-0" /> : <FiChevronDown className="shrink-0" />}
|
||||||
|
</button>
|
||||||
|
{expandedModules[mod.id] && mod.lessons.map(lesson => {
|
||||||
|
const isActive = lesson.id === activeLesson;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={lesson.id}
|
||||||
|
onClick={() => { setActiveLesson(lesson.id); setSidebarOpen(false); }}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
||||||
|
isActive
|
||||||
|
? isDark ? "bg-violet-600/20 border-l-2 border-violet-500" : "bg-violet-50 border-l-2 border-violet-600"
|
||||||
|
: isDark ? "hover:bg-slate-800" : "hover:bg-slate-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="shrink-0">
|
||||||
|
{lesson.completed
|
||||||
|
? <FiCheckCircle className="text-emerald-500 text-base" />
|
||||||
|
: lesson.free
|
||||||
|
? <FiPlay className={`text-base ${isActive ? "text-violet-500" : muted}`} />
|
||||||
|
: <FiLock className={`text-base ${muted}`} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className={`text-xs leading-snug truncate ${isActive ? (isDark ? "text-violet-300 font-semibold" : "text-violet-700 font-semibold") : (isDark ? "text-slate-300" : "text-slate-700")}`}>
|
||||||
|
{lesson.title}
|
||||||
|
</p>
|
||||||
|
<p className={`text-[10px] mt-0.5 ${muted}`}>{lesson.duration}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col min-h-screen ${isDark ? "bg-slate-950 text-white" : "bg-slate-50 text-slate-900"}`}>
|
||||||
|
|
||||||
|
{/* Top bar */}
|
||||||
|
<header className={`sticky top-0 z-40 flex items-center gap-3 px-4 h-14 border-b ${border} ${isDark ? "bg-slate-950/90 backdrop-blur-xl" : "bg-white/90 backdrop-blur-xl"}`}>
|
||||||
|
<button onClick={() => router.push("/my-courses")} className={`flex items-center gap-1.5 text-sm font-medium transition-colors ${isDark ? "text-slate-400 hover:text-white" : "text-slate-500 hover:text-slate-900"}`}>
|
||||||
|
<FiArrowLeft /> Mis cursos
|
||||||
|
</button>
|
||||||
|
<div className={`w-px h-5 ${isDark ? "bg-slate-700" : "bg-slate-300"}`} />
|
||||||
|
<p className={`text-sm font-semibold truncate flex-1 ${isDark ? "text-slate-200" : "text-slate-800"}`}>{course.title}</p>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className={`hidden sm:flex items-center gap-1 text-xs font-semibold ${course.progress === 100 ? "text-emerald-500" : "text-violet-500"}`}>
|
||||||
|
{course.progress === 100 ? <><FiAward /> Completado</> : <>{course.progress}% completado</>}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(v => !v)}
|
||||||
|
className={`lg:hidden p-2 rounded-lg transition ${isDark ? "text-slate-400 hover:text-white hover:bg-slate-800" : "text-slate-500 hover:text-slate-900 hover:bg-slate-100"}`}
|
||||||
|
>
|
||||||
|
{sidebarOpen ? <FiX /> : <FiMenu />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex flex-1 min-h-0">
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
{/* Video area */}
|
||||||
|
<div className="w-full bg-black aspect-video flex items-center justify-center relative">
|
||||||
|
<img src={course.thumbnail} alt={course.title} className="w-full h-full object-cover opacity-30" />
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4">
|
||||||
|
<button className="w-16 h-16 rounded-full bg-white/90 hover:bg-white flex items-center justify-center shadow-2xl transition">
|
||||||
|
<FiPlay className="text-slate-900 text-2xl ml-1" />
|
||||||
|
</button>
|
||||||
|
<p className="text-white text-sm font-medium">{currentLesson?.title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-8 space-y-8">
|
||||||
|
{/* Lesson title */}
|
||||||
|
<div>
|
||||||
|
<p className={`text-xs font-semibold uppercase tracking-wider text-violet-500 mb-1`}>Lección actual</p>
|
||||||
|
<h1 className={`text-2xl font-extrabold ${isDark ? "text-white" : "text-slate-900"}`}>{currentLesson?.title}</h1>
|
||||||
|
<p className={`text-sm mt-1 flex items-center gap-1 ${muted}`}><FiClock /> {currentLesson?.duration}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<div className={`flex flex-wrap gap-4 text-sm ${muted}`}>
|
||||||
|
<span className="flex items-center gap-1"><FiBook /> {totalLessons} lecciones</span>
|
||||||
|
<span className="flex items-center gap-1"><FiClock /> {course.duration}</span>
|
||||||
|
<span className="flex items-center gap-1"><FiGlobe /> {course.language}</span>
|
||||||
|
<span className="flex items-center gap-1"><FiStar className="text-amber-400" /> {course.rating} ({course.reviewsCount.toLocaleString()} reseñas)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructor */}
|
||||||
|
<div className={`flex items-center gap-4 p-4 rounded-2xl border ${border} ${bg}`}>
|
||||||
|
<div className="w-12 h-12 rounded-full bg-violet-600 flex items-center justify-center text-white font-bold shrink-0">
|
||||||
|
{course.instructorAvatar}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className={`font-semibold ${isDark ? "text-white" : "text-slate-900"}`}>{course.instructor}</p>
|
||||||
|
<p className={`text-xs mt-0.5 line-clamp-2 ${muted}`}>{course.instructorBio}</p>
|
||||||
|
<div className={`flex gap-3 text-xs mt-1 ${muted}`}>
|
||||||
|
<span className="flex items-center gap-1"><FiStar className="text-amber-400" />{course.instructorRating}</span>
|
||||||
|
<span>{course.instructorStudents.toLocaleString()} estudiantes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Course description */}
|
||||||
|
<div>
|
||||||
|
<h2 className={`text-lg font-bold mb-2 ${isDark ? "text-white" : "text-slate-900"}`}>Sobre este curso</h2>
|
||||||
|
<p className={`text-sm leading-relaxed ${muted}`}>{course.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Sidebar desktop */}
|
||||||
|
<div className="hidden lg:block w-80 shrink-0">
|
||||||
|
<div className="sticky top-14 h-[calc(100vh-3.5rem)] flex flex-col">
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar mobile overlay */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div className="lg:hidden fixed inset-0 z-50 flex">
|
||||||
|
<div className="absolute inset-0 bg-black/60" onClick={() => setSidebarOpen(false)} />
|
||||||
|
<div className="relative ml-auto w-80 h-full flex flex-col">
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { use } from "react";
|
||||||
|
import CoursePlayer from "./_content";
|
||||||
|
|
||||||
|
export default function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = use(params);
|
||||||
|
return <CoursePlayer id={id} />;
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
FiChevronDown,
|
FiChevronDown,
|
||||||
} from "react-icons/fi";
|
} from "react-icons/fi";
|
||||||
import { ThemeToggle } from "@/components/ui";
|
import { ThemeToggle } from "@/components/ui";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import { useRequireAuth } from "@/hooks/auth/useAuthGuard";
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: "Dashboard", href: "/instructor", icon: FiHome },
|
{ name: "Dashboard", href: "/instructor", icon: FiHome },
|
||||||
@@ -35,6 +37,8 @@ export default function InstructorLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { isAuthenticated } = useRequireAuth("/login");
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
|
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
|
||||||
const [dropdownPos, setDropdownPos] = useState({ top: 0, right: 0 });
|
const [dropdownPos, setDropdownPos] = useState({ top: 0, right: 0 });
|
||||||
@@ -75,6 +79,18 @@ export default function InstructorLayout({
|
|||||||
}
|
}
|
||||||
}, [profileMenuOpen]);
|
}, [profileMenuOpen]);
|
||||||
|
|
||||||
|
if (!isAuthenticated) return null;
|
||||||
|
|
||||||
|
const initials = user?.name
|
||||||
|
? user.name.split(" ").map((w: string) => w[0]).slice(0, 2).join("").toUpperCase()
|
||||||
|
: user?.email?.[0]?.toUpperCase() ?? "U";
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
localStorage.removeItem("refreshToken");
|
||||||
|
window.location.href = "/";
|
||||||
|
};
|
||||||
|
|
||||||
const SidebarContent = () => (
|
const SidebarContent = () => (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
@@ -200,16 +216,12 @@ export default function InstructorLayout({
|
|||||||
>
|
>
|
||||||
<div className="hidden md:block text-right">
|
<div className="hidden md:block text-right">
|
||||||
<p className="text-sm font-medium text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
|
<p className="text-sm font-medium text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
|
||||||
Instructor Name
|
{user?.name || "Mi cuenta"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-slate-500">instructor@rynext.com</p>
|
<p className="text-xs text-slate-500 truncate max-w-[160px]">{user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-9 h-9 md:w-10 md:h-10 rounded-full bg-linear-to-tr from-purple-500 to-indigo-500 p-0.5">
|
<div className="w-9 h-9 md:w-10 md:h-10 rounded-full bg-gradient-to-tr from-purple-500 to-indigo-500 flex items-center justify-center text-white text-sm font-bold select-none">
|
||||||
<img
|
{initials}
|
||||||
src="https://api.dicebear.com/7.x/avataaars/svg?seed=Felix"
|
|
||||||
alt="Avatar"
|
|
||||||
className="w-full h-full rounded-full bg-slate-100 dark:bg-slate-900"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<FiChevronDown
|
<FiChevronDown
|
||||||
className={`hidden md:block text-sm text-slate-400 transition-transform duration-200 ${
|
className={`hidden md:block text-sm text-slate-400 transition-transform duration-200 ${
|
||||||
@@ -228,8 +240,8 @@ export default function InstructorLayout({
|
|||||||
>
|
>
|
||||||
{/* User info header */}
|
{/* User info header */}
|
||||||
<div className="px-4 py-3 border-b border-slate-100 dark:border-slate-800">
|
<div className="px-4 py-3 border-b border-slate-100 dark:border-slate-800">
|
||||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">Instructor Name</p>
|
<p className="text-sm font-semibold text-slate-900 dark:text-white">{user?.name || "Mi cuenta"}</p>
|
||||||
<p className="text-xs text-slate-500 truncate">instructor@rynext.com</p>
|
<p className="text-xs text-slate-500 truncate">{user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu items */}
|
{/* Menu items */}
|
||||||
@@ -247,7 +259,7 @@ export default function InstructorLayout({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-3 px-4 py-2.5 w-full text-sm text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-400/10 transition-colors duration-150"
|
className="flex items-center gap-3 px-4 py-2.5 w-full text-sm text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-400/10 transition-colors duration-150"
|
||||||
onClick={() => setProfileMenuOpen(false)}
|
onClick={() => { setProfileMenuOpen(false); handleLogout(); }}
|
||||||
>
|
>
|
||||||
<FiLogOut className="text-base" />
|
<FiLogOut className="text-base" />
|
||||||
<span className="font-medium">Cerrar Sesión</span>
|
<span className="font-medium">Cerrar Sesión</span>
|
||||||
|
|||||||
+15
-7
@@ -3,6 +3,9 @@ import { ThemeProvider } from "@/context/ThemeContext";
|
|||||||
import { CartProvider } from "@/context/CartContext";
|
import { CartProvider } from "@/context/CartContext";
|
||||||
import { CartDrawer } from "@/components/buyer/CartDrawer";
|
import { CartDrawer } from "@/components/buyer/CartDrawer";
|
||||||
import { CartAnimator } from "@/components/buyer/CartAnimator";
|
import { CartAnimator } from "@/components/buyer/CartAnimator";
|
||||||
|
import { QueryProvider } from "@/lib/QueryProvider";
|
||||||
|
import { GoogleOAuthProvider } from "@react-oauth/google";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -43,13 +46,18 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
return (
|
return (
|
||||||
<html lang="es" suppressHydrationWarning>
|
<html lang="es" suppressHydrationWarning>
|
||||||
<body className="bg-slate-950">
|
<body className="bg-slate-950">
|
||||||
<ThemeProvider>
|
<GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ?? ""}>
|
||||||
<CartProvider>
|
<QueryProvider>
|
||||||
{children}
|
<ThemeProvider>
|
||||||
<CartDrawer />
|
<CartProvider>
|
||||||
<CartAnimator />
|
{children}
|
||||||
</CartProvider>
|
<CartDrawer />
|
||||||
</ThemeProvider>
|
<CartAnimator />
|
||||||
|
</CartProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
<Toaster richColors position="top-right" />
|
||||||
|
</QueryProvider>
|
||||||
|
</GoogleOAuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,37 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { BrandLogo } from "@/components/common/BrandLogo";
|
import { BrandLogo } from "@/components/common/BrandLogo";
|
||||||
import { LoginForm } from "@/components/auth/LoginForm";
|
import { LoginForm } from "@/components/auth/LoginForm";
|
||||||
import { RegisterForm } from "@/components/auth/RegisterForm";
|
import { RegisterForm } from "@/components/auth/RegisterForm";
|
||||||
|
import { useGuestOnly } from "@/hooks/auth/useAuthGuard";
|
||||||
|
|
||||||
function AuthPanel({ flipped, onFlip }: { flipped: boolean; onFlip: (v: boolean) => void }) {
|
function AuthPanel({
|
||||||
|
flipped,
|
||||||
|
onFlip,
|
||||||
|
registerStep,
|
||||||
|
onRegisterStep,
|
||||||
|
}: {
|
||||||
|
flipped: boolean;
|
||||||
|
onFlip: (v: boolean) => void;
|
||||||
|
registerStep: 1 | 2;
|
||||||
|
onRegisterStep: (s: 1 | 2) => void;
|
||||||
|
}) {
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
|
const fullWidth = flipped && registerStep === 2;
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col w-full md:w-1/2 overflow-y-auto ${isDark ? "bg-slate-900" : "bg-white"}`}>
|
<div className={`flex flex-col overflow-y-auto transition-all duration-500 ${fullWidth ? "w-full" : "w-full md:w-1/2"} ${isDark ? "bg-slate-900" : "bg-white"}`}>
|
||||||
<div className="flex items-center justify-between px-10 py-5">
|
<div className="flex items-center justify-between px-10 py-5">
|
||||||
<BrandLogo size="sm" asLink />
|
<BrandLogo size="sm" asLink />
|
||||||
<ThemeToggle variant="switch" />
|
<ThemeToggle variant="switch" />
|
||||||
</div>
|
</div>
|
||||||
<style>{`@keyframes fadeIn{from{opacity:0}to{opacity:1}}`}</style>
|
<style>{`@keyframes fadeIn{from{opacity:0}to{opacity:1}}`}</style>
|
||||||
<div className="flex flex-1 px-12 py-7">
|
<div className={`flex flex-1 py-7 ${fullWidth ? "px-16 max-w-3xl mx-auto w-full" : "px-12"}`}>
|
||||||
<div key={String(flipped)} className="w-full" style={{ animation: "fadeIn 0.6s ease-out" }}>
|
<div key={String(flipped)} className="w-full" style={{ animation: "fadeIn 0.6s ease-out" }}>
|
||||||
{flipped
|
{flipped
|
||||||
? <RegisterForm onSwitchToLogin={() => onFlip(false)} />
|
? <RegisterForm onSwitchToLogin={() => onFlip(false)} onStepChange={onRegisterStep} />
|
||||||
: <LoginForm onSwitchToRegister={() => onFlip(true)} />
|
: <LoginForm onSwitchToRegister={() => onFlip(true)} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -68,11 +81,36 @@ function HeroPanel({ flipped }: { flipped: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginContent() {
|
export default function LoginContent() {
|
||||||
|
const { isAuthenticated } = useGuestOnly();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [flipped, setFlipped] = useState(false);
|
const [flipped, setFlipped] = useState(false);
|
||||||
|
const [registerStep, setRegisterStep] = useState<1 | 2>(1);
|
||||||
|
|
||||||
|
if (isAuthenticated) return null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get("step") === "profile") {
|
||||||
|
setFlipped(true);
|
||||||
|
setRegisterStep(2);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const handleFlip = (v: boolean) => {
|
||||||
|
setFlipped(v);
|
||||||
|
if (!v) setRegisterStep(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showHero = !flipped || registerStep === 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
<HeroPanel flipped={flipped} />
|
{showHero && <HeroPanel flipped={flipped} />}
|
||||||
<AuthPanel flipped={flipped} onFlip={setFlipped} />
|
<AuthPanel
|
||||||
|
flipped={flipped}
|
||||||
|
onFlip={handleFlip}
|
||||||
|
registerStep={registerStep}
|
||||||
|
onRegisterStep={setRegisterStep}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { FiBookOpen, FiClock, FiAward, FiPlay, FiSearch, FiGrid, FiList } from "react-icons/fi";
|
||||||
|
import LayoutBase from "@/components/layout/LayoutBase";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useRequireAuth } from "@/hooks/auth/useAuthGuard";
|
||||||
|
|
||||||
|
// ── Tipos mock ────────────────────────────────────────────────────────────────
|
||||||
|
interface EnrolledCourse {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
instructor: string;
|
||||||
|
thumbnail: string;
|
||||||
|
progress: number; // 0-100
|
||||||
|
totalLessons: number;
|
||||||
|
completedLessons: number;
|
||||||
|
duration: string;
|
||||||
|
category: string;
|
||||||
|
lastAccessed?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_COURSES: EnrolledCourse[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: "Next.js 14 — De cero a producción",
|
||||||
|
instructor: "Carlos Méndez",
|
||||||
|
thumbnail: "https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=400&q=80",
|
||||||
|
progress: 68,
|
||||||
|
totalLessons: 42,
|
||||||
|
completedLessons: 28,
|
||||||
|
duration: "18h 30m",
|
||||||
|
category: "Desarrollo Web",
|
||||||
|
lastAccessed: "Hace 2 días",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
title: "TypeScript Avanzado para React",
|
||||||
|
instructor: "Ana García",
|
||||||
|
thumbnail: "https://images.unsplash.com/photo-1587620962725-abab7fe55159?w=400&q=80",
|
||||||
|
progress: 100,
|
||||||
|
totalLessons: 28,
|
||||||
|
completedLessons: 28,
|
||||||
|
duration: "12h 15m",
|
||||||
|
category: "Desarrollo Web",
|
||||||
|
lastAccessed: "Hace 1 semana",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
title: "Diseño UI/UX con Figma",
|
||||||
|
instructor: "Sofía Rojas",
|
||||||
|
thumbnail: "https://images.unsplash.com/photo-1561070791-2526d30994b5?w=400&q=80",
|
||||||
|
progress: 23,
|
||||||
|
totalLessons: 35,
|
||||||
|
completedLessons: 8,
|
||||||
|
duration: "15h 45m",
|
||||||
|
category: "Diseño",
|
||||||
|
lastAccessed: "Hace 5 días",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
title: "Python para Data Science",
|
||||||
|
instructor: "Luis Torres",
|
||||||
|
thumbnail: "https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=400&q=80",
|
||||||
|
progress: 100,
|
||||||
|
totalLessons: 50,
|
||||||
|
completedLessons: 50,
|
||||||
|
duration: "22h 00m",
|
||||||
|
category: "Data Science",
|
||||||
|
lastAccessed: "Hace 3 semanas",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Tarjeta de curso ──────────────────────────────────────────────────────────
|
||||||
|
function CourseCard({ course, view }: { course: EnrolledCourse; view: "grid" | "list" }) {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
const router = useRouter();
|
||||||
|
const completed = course.progress === 100;
|
||||||
|
|
||||||
|
const progressBar = (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex justify-between text-xs mb-1">
|
||||||
|
<span className={isDark ? "text-slate-400" : "text-slate-500"}>
|
||||||
|
{course.completedLessons}/{course.totalLessons} lecciones
|
||||||
|
</span>
|
||||||
|
<span className={`font-semibold ${completed ? "text-emerald-500" : "text-violet-500"}`}>
|
||||||
|
{course.progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={`h-1.5 rounded-full ${isDark ? "bg-slate-700" : "bg-slate-200"}`}>
|
||||||
|
<div
|
||||||
|
className={`h-1.5 rounded-full transition-all ${completed ? "bg-emerald-500" : "bg-violet-500"}`}
|
||||||
|
style={{ width: `${course.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (view === "list") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex gap-4 p-4 rounded-2xl border transition-all cursor-pointer hover:shadow-md ${
|
||||||
|
isDark ? "bg-slate-900 border-slate-800 hover:border-slate-700" : "bg-white border-slate-200 hover:border-slate-300"
|
||||||
|
}`}
|
||||||
|
onClick={() => router.push(`/course/${course.id}`)}
|
||||||
|
>
|
||||||
|
<img src={course.thumbnail} alt={course.title} className="w-32 h-20 object-cover rounded-xl shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className={`font-semibold text-sm leading-snug truncate ${isDark ? "text-white" : "text-slate-900"}`}>
|
||||||
|
{course.title}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs mt-0.5 ${isDark ? "text-slate-400" : "text-slate-500"}`}>{course.instructor}</p>
|
||||||
|
</div>
|
||||||
|
{completed
|
||||||
|
? <span className="shrink-0 flex items-center gap-1 text-xs font-semibold text-emerald-500 bg-emerald-500/10 px-2 py-1 rounded-full"><FiAward /> Completado</span>
|
||||||
|
: <span className={`shrink-0 text-xs ${isDark ? "text-slate-500" : "text-slate-400"}`}>{course.lastAccessed}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{progressBar}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); router.push(`/course/${course.id}`); }}
|
||||||
|
className="shrink-0 self-center w-9 h-9 rounded-full bg-violet-600 hover:bg-violet-500 flex items-center justify-center text-white transition"
|
||||||
|
>
|
||||||
|
<FiPlay className="text-sm ml-0.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-2xl border overflow-hidden transition-all cursor-pointer hover:shadow-lg hover:-translate-y-0.5 ${
|
||||||
|
isDark ? "bg-slate-900 border-slate-800 hover:border-slate-700" : "bg-white border-slate-200 hover:border-slate-300"
|
||||||
|
}`}
|
||||||
|
onClick={() => router.push(`/course/${course.id}`)}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<img src={course.thumbnail} alt={course.title} className="w-full h-36 object-cover" />
|
||||||
|
{completed && (
|
||||||
|
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||||
|
<div className="flex items-center gap-1.5 bg-emerald-500 text-white text-xs font-bold px-3 py-1.5 rounded-full">
|
||||||
|
<FiAward /> Completado
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!completed && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); router.push(`/course/${course.id}`); }}
|
||||||
|
className="absolute bottom-2 right-2 w-9 h-9 rounded-full bg-violet-600 hover:bg-violet-500 flex items-center justify-center text-white shadow-lg transition"
|
||||||
|
>
|
||||||
|
<FiPlay className="text-sm ml-0.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<span className={`text-[10px] font-semibold uppercase tracking-wider text-violet-500`}>{course.category}</span>
|
||||||
|
<p className={`font-semibold text-sm mt-1 leading-snug line-clamp-2 ${isDark ? "text-white" : "text-slate-900"}`}>
|
||||||
|
{course.title}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs mt-1 ${isDark ? "text-slate-400" : "text-slate-500"}`}>{course.instructor}</p>
|
||||||
|
<div className={`flex items-center gap-3 text-xs mt-2 ${isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||||
|
<span className="flex items-center gap-1"><FiClock /> {course.duration}</span>
|
||||||
|
<span className="flex items-center gap-1"><FiBookOpen /> {course.totalLessons} lecciones</span>
|
||||||
|
</div>
|
||||||
|
{progressBar}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stat card ─────────────────────────────────────────────────────────────────
|
||||||
|
function StatCard({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: string; color: string }) {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-4 p-4 rounded-2xl border ${isDark ? "bg-slate-900 border-slate-800" : "bg-white border-slate-200"}`}>
|
||||||
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-lg ${color}`}>{icon}</div>
|
||||||
|
<div>
|
||||||
|
<p className={`text-xl font-bold ${isDark ? "text-white" : "text-slate-900"}`}>{value}</p>
|
||||||
|
<p className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>{label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Página principal ──────────────────────────────────────────────────────────
|
||||||
|
export default function MyCourses() {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
const { isAuthenticated } = useRequireAuth();
|
||||||
|
const [tab, setTab] = useState<"all" | "in-progress" | "completed">("all");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [view, setView] = useState<"grid" | "list">("grid");
|
||||||
|
|
||||||
|
if (!isAuthenticated) return null;
|
||||||
|
|
||||||
|
const filtered = MOCK_COURSES.filter((c) => {
|
||||||
|
const matchTab =
|
||||||
|
tab === "all" ||
|
||||||
|
(tab === "in-progress" && c.progress < 100) ||
|
||||||
|
(tab === "completed" && c.progress === 100);
|
||||||
|
const matchSearch = c.title.toLowerCase().includes(search.toLowerCase());
|
||||||
|
return matchTab && matchSearch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalHours = Math.round(MOCK_COURSES.reduce((a, c) => a + parseFloat(c.duration), 0));
|
||||||
|
const completedCount = MOCK_COURSES.filter(c => c.progress === 100).length;
|
||||||
|
const avgProgress = Math.round(MOCK_COURSES.reduce((a, c) => a + c.progress, 0) / MOCK_COURSES.length);
|
||||||
|
|
||||||
|
const muted = isDark ? "text-slate-400" : "text-slate-500";
|
||||||
|
const tabBase = "px-4 py-2 rounded-xl text-sm font-medium transition-colors cursor-pointer";
|
||||||
|
const tabActive = isDark ? "bg-violet-600 text-white" : "bg-violet-600 text-white";
|
||||||
|
const tabInactive = isDark ? "text-slate-400 hover:text-white hover:bg-slate-800" : "text-slate-500 hover:text-slate-900 hover:bg-slate-100";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayoutBase>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className={`text-3xl font-extrabold tracking-tight ${isDark ? "text-white" : "text-slate-900"}`}>
|
||||||
|
Mis cursos
|
||||||
|
</h1>
|
||||||
|
<p className={`text-sm mt-1 ${muted}`}>Continúa donde lo dejaste.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
|
||||||
|
<StatCard icon={<FiBookOpen />} label="Cursos inscritos" value={String(MOCK_COURSES.length)} color="bg-violet-500/10 text-violet-500" />
|
||||||
|
<StatCard icon={<FiAward />} label="Completados" value={String(completedCount)} color="bg-emerald-500/10 text-emerald-500" />
|
||||||
|
<StatCard icon={<FiClock />} label="Horas totales" value={`${totalHours}h`} color="bg-blue-500/10 text-blue-500" />
|
||||||
|
<StatCard icon={<FiPlay />} label="Progreso promedio" value={`${avgProgress}%`} color="bg-orange-500/10 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className={`flex gap-1 p-1 rounded-2xl ${isDark ? "bg-slate-900" : "bg-slate-100"}`}>
|
||||||
|
{(["all", "in-progress", "completed"] as const).map((t) => (
|
||||||
|
<button key={t} onClick={() => setTab(t)} className={`${tabBase} ${tab === t ? tabActive : tabInactive}`}>
|
||||||
|
{t === "all" ? "Todos" : t === "in-progress" ? "En progreso" : "Completados"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search + view toggle */}
|
||||||
|
<div className="flex gap-2 ml-auto">
|
||||||
|
<div className={`flex items-center gap-2 px-3 py-2 rounded-xl border text-sm ${isDark ? "bg-slate-900 border-slate-800 text-slate-300" : "bg-white border-slate-200 text-slate-700"}`}>
|
||||||
|
<FiSearch className={muted} />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Buscar..."
|
||||||
|
className="bg-transparent outline-none w-36 placeholder:text-slate-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`flex gap-1 p-1 rounded-xl ${isDark ? "bg-slate-900" : "bg-slate-100"}`}>
|
||||||
|
<button onClick={() => setView("grid")} className={`p-2 rounded-lg transition ${view === "grid" ? (isDark ? "bg-slate-700 text-white" : "bg-white text-slate-900 shadow-sm") : muted}`}><FiGrid /></button>
|
||||||
|
<button onClick={() => setView("list")} className={`p-2 rounded-lg transition ${view === "list" ? (isDark ? "bg-slate-700 text-white" : "bg-white text-slate-900 shadow-sm") : muted}`}><FiList /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Courses */}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className={`flex flex-col items-center justify-center py-24 rounded-2xl border ${isDark ? "border-slate-800 bg-slate-900/40" : "border-slate-200 bg-white"}`}>
|
||||||
|
<FiBookOpen className={`text-4xl mb-4 ${isDark ? "text-slate-600" : "text-slate-300"}`} />
|
||||||
|
<p className={`text-base font-semibold ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||||
|
{search ? "No se encontraron cursos" : "Aún no tienes cursos"}
|
||||||
|
</p>
|
||||||
|
<p className={`text-sm mt-1 ${isDark ? "text-slate-600" : "text-slate-400"}`}>
|
||||||
|
{search ? "Prueba con otro término" : "Explora el catálogo y empieza a aprender"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : view === "grid" ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{filtered.map(c => <CourseCard key={c.id} course={c} view="grid" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{filtered.map(c => <CourseCard key={c.id} course={c} view="list" />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</LayoutBase>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import MyCourses from "./_content";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <MyCourses />;
|
||||||
|
}
|
||||||
@@ -1,26 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { FiMail, FiLock, FiArrowRight, FiCheck } from "react-icons/fi";
|
import { FiMail, FiLock, FiArrowRight, FiCheck } from "react-icons/fi";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Button, Input, Checkbox } from "@/components/ui";
|
import { Button, Input, Checkbox } from "@/components/ui";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { AuthDivider } from "./AuthDivider";
|
import { AuthDivider } from "./AuthDivider";
|
||||||
import { SocialButtons } from "./SocialButtons";
|
import { SocialButtons } from "./SocialButtons";
|
||||||
|
import { useLogin } from "@/hooks/auth/useLogin";
|
||||||
|
|
||||||
export function LoginForm({ onSwitchToRegister }: { onSwitchToRegister: () => void }) {
|
export function LoginForm({ onSwitchToRegister }: { onSwitchToRegister: () => void }) {
|
||||||
const router = useRouter();
|
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [remember, setRemember] = useState(true);
|
const [remember, setRemember] = useState(true);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const isValidEmail = email.includes("@") && email.includes(".");
|
const isValidEmail = email.includes("@") && email.includes(".");
|
||||||
|
|
||||||
|
const { mutate: login, isPending } = useLogin();
|
||||||
|
|
||||||
const handleLogin = (e: React.FormEvent) => {
|
const handleLogin = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!email || !password) return;
|
if (!email || !password) return;
|
||||||
setIsLoading(true);
|
login({ email, password }, {
|
||||||
setTimeout(() => { setIsLoading(false); router.push("/"); }, 800);
|
onError: () => toast.error("Credenciales inválidas. Intenta de nuevo."),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -33,6 +35,7 @@ export function LoginForm({ onSwitchToRegister }: { onSwitchToRegister: () => vo
|
|||||||
Ingresa tu correo y contraseña para continuar.
|
Ingresa tu correo y contraseña para continuar.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleLogin} className="space-y-6">
|
<form onSubmit={handleLogin} className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
@@ -76,7 +79,7 @@ export function LoginForm({ onSwitchToRegister }: { onSwitchToRegister: () => vo
|
|||||||
variant="violet"
|
variant="violet"
|
||||||
size="lg"
|
size="lg"
|
||||||
fullWidth
|
fullWidth
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
loadingText="Verificando..."
|
loadingText="Verificando..."
|
||||||
rightIcon={<FiArrowRight />}
|
rightIcon={<FiArrowRight />}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,47 +1,58 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { FiMail, FiLock, FiArrowRight, FiUser, FiMapPin, FiGlobe, FiCamera } from "react-icons/fi";
|
||||||
import { FiMail, FiLock, FiArrowRight, FiUser } from "react-icons/fi";
|
import { toast } from "sonner";
|
||||||
import { Button, Input } from "@/components/ui";
|
import { Button, Input, Textarea, PasswordStrength, DatePicker } from "@/components/ui";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { AuthDivider } from "./AuthDivider";
|
import { AuthDivider } from "./AuthDivider";
|
||||||
import { SocialButtons } from "./SocialButtons";
|
import { SocialButtons } from "./SocialButtons";
|
||||||
|
import { useRegister } from "@/hooks/auth/useRegister";
|
||||||
|
import { useSaveProfile } from "@/hooks/user/useSaveProfile";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
// ─── Paso 1: Credenciales ────────────────────────────────────────────────────
|
||||||
const router = useRouter();
|
function StepCredentials({
|
||||||
|
onNext,
|
||||||
|
onSwitchToLogin,
|
||||||
|
}: {
|
||||||
|
onNext: () => void;
|
||||||
|
onSwitchToLogin: () => void;
|
||||||
|
}) {
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const [name, setName] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [confirm, setConfirm] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleRegister = (e: React.FormEvent) => {
|
const passwordsMatch = confirm === "" || password === confirm;
|
||||||
e.preventDefault();
|
|
||||||
if (!name || !email || !password) return;
|
const { mutate: register, isPending } = useRegister(onNext);
|
||||||
setIsLoading(true);
|
|
||||||
setTimeout(() => { setIsLoading(false); router.push("/"); }, 800);
|
const muted = isDark ? "text-slate-400" : "text-slate-500";
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-10">
|
<div className="mb-8">
|
||||||
|
<div className={`text-xs font-semibold uppercase tracking-widest mb-2 text-violet-500`}>Paso 1 de 2</div>
|
||||||
<h1 className={`text-3xl font-extrabold tracking-tight ${isDark ? "text-white" : "text-slate-900"}`}>
|
<h1 className={`text-3xl font-extrabold tracking-tight ${isDark ? "text-white" : "text-slate-900"}`}>
|
||||||
Crear cuenta
|
Crear cuenta
|
||||||
</h1>
|
</h1>
|
||||||
<p className={`text-sm mt-1 ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
<p className={`text-sm mt-1 ${muted}`}>
|
||||||
Regístrate gratis y empieza a aprender hoy.
|
Ingresa tu correo y una contraseña segura.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleRegister} className="space-y-6">
|
|
||||||
<Input
|
<form onSubmit={(e) => {
|
||||||
label="Nombre completo"
|
e.preventDefault();
|
||||||
type="text"
|
if (!passwordsMatch) return;
|
||||||
required
|
register({ email, password }, {
|
||||||
value={name}
|
onError: (err) => {
|
||||||
onChange={(e) => setName(e.target.value)}
|
const msg = (err as { response?: { data?: string } }).response?.data ?? "";
|
||||||
placeholder="Tu nombre"
|
if (msg.includes("registrado")) toast.error("Este correo ya tiene una cuenta.");
|
||||||
leftIcon={<FiUser />}
|
else if (msg.toLowerCase().includes("password")) toast.error("La contraseña no cumple los requisitos.");
|
||||||
/>
|
else toast.error("Ocurrió un error. Intenta de nuevo.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}} className="space-y-5">
|
||||||
<Input
|
<Input
|
||||||
label="Correo electrónico"
|
label="Correo electrónico"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -51,30 +62,51 @@ export function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void
|
|||||||
placeholder="[email protected]"
|
placeholder="[email protected]"
|
||||||
leftIcon={<FiMail />}
|
leftIcon={<FiMail />}
|
||||||
/>
|
/>
|
||||||
<Input
|
<div>
|
||||||
label="Contraseña"
|
<Input
|
||||||
type="password"
|
label="Contraseña"
|
||||||
required
|
type="password"
|
||||||
value={password}
|
required
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
minLength={8}
|
||||||
placeholder="Mínimo 8 caracteres"
|
value={password}
|
||||||
leftIcon={<FiLock />}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
placeholder="Mínimo 8 caracteres"
|
||||||
|
leftIcon={<FiLock />}
|
||||||
|
/>
|
||||||
|
<PasswordStrength password={password} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="Confirmar contraseña"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
placeholder="Repite tu contraseña"
|
||||||
|
leftIcon={<FiLock />}
|
||||||
|
/>
|
||||||
|
{!passwordsMatch && (
|
||||||
|
<p className="text-[11px] text-red-400 mt-1">Las contraseñas no coinciden</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="violet"
|
variant="violet"
|
||||||
size="lg"
|
size="lg"
|
||||||
fullWidth
|
fullWidth
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
loadingText="Creando cuenta..."
|
loadingText="Creando cuenta..."
|
||||||
rightIcon={<FiArrowRight />}
|
rightIcon={<FiArrowRight />}
|
||||||
|
disabled={!passwordsMatch || password.length < 8}
|
||||||
>
|
>
|
||||||
Crear cuenta gratis
|
Continuar
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<AuthDivider />
|
<AuthDivider />
|
||||||
<SocialButtons />
|
<SocialButtons />
|
||||||
<p className={`text-center text-xs mt-7 ${isDark ? "text-slate-500" : "text-slate-400"}`}>
|
|
||||||
|
<p className={`text-center text-xs mt-7 ${muted}`}>
|
||||||
¿Ya tienes cuenta?{" "}
|
¿Ya tienes cuenta?{" "}
|
||||||
<button type="button" onClick={onSwitchToLogin} className="text-violet-500 hover:text-violet-400 font-semibold hover:underline transition">
|
<button type="button" onClick={onSwitchToLogin} className="text-violet-500 hover:text-violet-400 font-semibold hover:underline transition">
|
||||||
Inicia sesión
|
Inicia sesión
|
||||||
@@ -83,3 +115,211 @@ export function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Paso 2: Perfil ──────────────────────────────────────────────────────────
|
||||||
|
function StepProfile() {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
const router = useRouter();
|
||||||
|
const { mutate: saveProfile, isPending } = useSaveProfile({
|
||||||
|
onError: () => toast.error("No se pudo guardar el perfil. Intenta de nuevo."),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [bio, setBio] = useState("");
|
||||||
|
const [location, setLocation] = useState("");
|
||||||
|
const [websiteUrl, setWebsiteUrl] = useState("");
|
||||||
|
const [birthDate, setBirthDate] = useState("");
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState<string | undefined>();
|
||||||
|
const [bannerUrl, setBannerUrl] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const muted = isDark ? "text-slate-400" : "text-slate-500";
|
||||||
|
const divider = isDark ? "border-slate-800" : "border-slate-100";
|
||||||
|
|
||||||
|
const readFile = (file: File, cb: (url: string) => void) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => cb(e.target?.result as string);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
saveProfile({
|
||||||
|
displayName,
|
||||||
|
bio: bio || undefined,
|
||||||
|
avatarUrl: avatarUrl || undefined,
|
||||||
|
bannerUrl: bannerUrl || undefined,
|
||||||
|
location: location || undefined,
|
||||||
|
websiteUrl: websiteUrl || undefined,
|
||||||
|
birthDate: birthDate || undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-widest mb-2 text-violet-500">Paso 2 de 2</div>
|
||||||
|
<h1 className={`text-3xl font-extrabold tracking-tight ${isDark ? "text-white" : "text-slate-900"}`}>
|
||||||
|
Tu perfil
|
||||||
|
</h1>
|
||||||
|
<p className={`text-sm mt-1 ${muted}`}>
|
||||||
|
Personaliza cómo te verán los demás.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Banner + Avatar preview */}
|
||||||
|
<div className="relative mb-20">
|
||||||
|
{/* Banner */}
|
||||||
|
<div
|
||||||
|
className={`h-30 w-full rounded-2xl border-2 border-dashed transition-colors overflow-hidden ${
|
||||||
|
bannerUrl
|
||||||
|
? "border-transparent"
|
||||||
|
: isDark
|
||||||
|
? "border-slate-600 bg-slate-700"
|
||||||
|
: "border-slate-300 bg-slate-200"
|
||||||
|
}`}
|
||||||
|
style={bannerUrl ? { backgroundImage: `url(${bannerUrl})`, backgroundSize: "cover", backgroundPosition: "center" } : {}}
|
||||||
|
>
|
||||||
|
{!bannerUrl && (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center gap-2">
|
||||||
|
<FiCamera className={`text-2xl ${muted}`} />
|
||||||
|
<span className={`text-sm ${muted}`}>Click para subir banner</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label className="absolute inset-0 cursor-pointer">
|
||||||
|
<input type="file" accept="image/*" className="hidden"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) readFile(f, setBannerUrl); }} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{bannerUrl && (
|
||||||
|
<label className="absolute top-3 right-3 cursor-pointer z-10">
|
||||||
|
<div className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium backdrop-blur-sm transition ${isDark ? "bg-black/50 text-slate-300 hover:bg-black/70" : "bg-white/70 text-slate-600 hover:bg-white/90"}`}>
|
||||||
|
<FiCamera className="text-sm" /> Cambiar
|
||||||
|
</div>
|
||||||
|
<input type="file" accept="image/*" className="hidden"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) readFile(f, setBannerUrl); }} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="absolute -bottom-8 left-6">
|
||||||
|
<div className="relative group">
|
||||||
|
<div className={`w-22 h-22 rounded-2xl border-4 overflow-hidden ${isDark ? "border-slate-900 bg-slate-700" : "border-white bg-slate-200"}`}>
|
||||||
|
{avatarUrl
|
||||||
|
? <img src={avatarUrl} alt="Avatar" className="w-full h-full object-cover" />
|
||||||
|
: <div className="w-full h-full flex items-center justify-center"><FiUser className={`text-3xl ${muted}`} /></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<label className="absolute inset-0 flex items-center justify-center rounded-2xl cursor-pointer opacity-0 group-hover:opacity-100 transition bg-black/50">
|
||||||
|
<FiCamera className="text-white text-xl" />
|
||||||
|
<input type="file" accept="image/*" className="hidden"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) readFile(f, setAvatarUrl); }} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 -mt-4">
|
||||||
|
<Input
|
||||||
|
label="Nombre para mostrar *"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="¿Cómo quieres que te llamen?"
|
||||||
|
leftIcon={<FiUser />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={`block text-sm font-medium mb-1.5 ${isDark ? "text-slate-300" : "text-slate-700"}`}>
|
||||||
|
Biografía
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={bio}
|
||||||
|
onChange={(e) => setBio(e.target.value)}
|
||||||
|
placeholder="Cuéntanos sobre ti..."
|
||||||
|
rows={2}
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<p className={`text-right text-[11px] mt-0.5 ${muted}`}>{bio.length}/500</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`border-t ${divider} pt-4`}>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Ubicación"
|
||||||
|
type="text"
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
placeholder="Ciudad, País"
|
||||||
|
leftIcon={<FiMapPin />}
|
||||||
|
/>
|
||||||
|
<DatePicker
|
||||||
|
label="Fecha de nacimiento"
|
||||||
|
value={birthDate}
|
||||||
|
onChange={(v) => setBirthDate(v ?? "")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Input
|
||||||
|
label="Sitio web"
|
||||||
|
type="url"
|
||||||
|
value={websiteUrl}
|
||||||
|
onChange={(e) => setWebsiteUrl(e.target.value)}
|
||||||
|
placeholder="https://tu-sitio.com"
|
||||||
|
leftIcon={<FiGlobe />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="lg"
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Omitir
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="violet"
|
||||||
|
size="lg"
|
||||||
|
isLoading={isPending}
|
||||||
|
loadingText="Guardando..."
|
||||||
|
rightIcon={<FiArrowRight />}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!displayName.trim()}
|
||||||
|
>
|
||||||
|
Listo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Componente principal ────────────────────────────────────────────────────
|
||||||
|
export function RegisterForm({
|
||||||
|
onSwitchToLogin,
|
||||||
|
onStepChange,
|
||||||
|
}: {
|
||||||
|
onSwitchToLogin: () => void;
|
||||||
|
onStepChange?: (step: 1 | 2) => void;
|
||||||
|
}) {
|
||||||
|
const [step, setStep] = useState<1 | 2>(1);
|
||||||
|
|
||||||
|
const goToStep = (s: 1 | 2) => {
|
||||||
|
setStep(s);
|
||||||
|
onStepChange?.(s);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ animation: "fadeIn 0.4s ease-out" }}>
|
||||||
|
{step === 1
|
||||||
|
? <StepCredentials onNext={() => goToStep(2)} onSwitchToLogin={onSwitchToLogin} />
|
||||||
|
: <StepProfile />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,65 +1,86 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { GoogleLogin } from "@react-oauth/google";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useOAuth } from "@/hooks/auth/useOAuth";
|
||||||
const socialProviders = [
|
import { msalInstance, microsoftLoginRequest } from "@/lib/msalConfig";
|
||||||
{
|
import { toast } from "sonner";
|
||||||
label: "Google",
|
|
||||||
svg: (
|
|
||||||
<svg className="w-5 h-5 shrink-0" viewBox="0 0 24 24">
|
|
||||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
|
||||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
|
||||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
|
||||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "GitHub",
|
|
||||||
svg: (
|
|
||||||
<svg className="w-5 h-5 shrink-0" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Microsoft",
|
|
||||||
svg: (
|
|
||||||
<svg className="w-5 h-5 shrink-0" viewBox="0 0 24 24">
|
|
||||||
<path fill="#F25022" d="M1 1h10v10H1z" />
|
|
||||||
<path fill="#00A4EF" d="M13 1h10v10H13z" />
|
|
||||||
<path fill="#7FBA00" d="M1 13h10v10H1z" />
|
|
||||||
<path fill="#FFB900" d="M13 13h10v10H13z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Apple",
|
|
||||||
svg: (
|
|
||||||
<svg className="w-5 h-5 shrink-0" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SocialButtons() {
|
export function SocialButtons() {
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
|
const { mutate: oauthLogin, isPending } = useOAuth();
|
||||||
|
const googleTriggerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleMicrosoft = async () => {
|
||||||
|
try {
|
||||||
|
await msalInstance.initialize();
|
||||||
|
const result = await msalInstance.loginPopup(microsoftLoginRequest);
|
||||||
|
if (result.idToken) {
|
||||||
|
oauthLogin({ idToken: result.idToken, provider: "microsoft" }, {
|
||||||
|
onError: () => toast.error("No se pudo iniciar sesión con Microsoft."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// usuario cerró el popup
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5">
|
||||||
{socialProviders.map(({ label, svg }) => (
|
|
||||||
|
{/* Google — usa GoogleLogin invisible y nuestro botón lo dispara */}
|
||||||
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
key={label}
|
|
||||||
type="button"
|
type="button"
|
||||||
className={`w-full flex items-center justify-center gap-2.5 py-2.5 rounded-xl border text-sm font-medium transition-all duration-200 ${
|
disabled={isPending}
|
||||||
|
onClick={() => googleTriggerRef.current?.querySelector("div[role=button]")?.dispatchEvent(new MouseEvent("click", { bubbles: true }))}
|
||||||
|
className={`w-full flex items-center justify-center gap-2.5 py-2.5 rounded-xl border text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||||
isDark
|
isDark
|
||||||
? "border-slate-700 text-slate-300 hover:border-slate-600 hover:bg-slate-800"
|
? "border-slate-700 text-slate-300 hover:border-slate-600 hover:bg-slate-800"
|
||||||
: "border-slate-200 text-slate-600 hover:border-slate-300 hover:bg-slate-50"
|
: "border-slate-200 text-slate-600 hover:border-slate-300 hover:bg-slate-50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{svg}
|
<svg className="w-5 h-5 shrink-0" viewBox="0 0 24 24">
|
||||||
<span>Continuar con {label}</span>
|
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||||
|
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
||||||
|
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
||||||
|
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
||||||
|
</svg>
|
||||||
|
<span>Continuar con Google</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
{/* GoogleLogin invisible — actúa como trigger */}
|
||||||
|
<div ref={googleTriggerRef} className="absolute inset-0 opacity-0 pointer-events-none overflow-hidden">
|
||||||
|
<GoogleLogin
|
||||||
|
onSuccess={({ credential }) => {
|
||||||
|
if (credential) oauthLogin({ idToken: credential, provider: "google" }, {
|
||||||
|
onError: () => toast.error("No se pudo iniciar sesión con Google."),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onError={() => {}}
|
||||||
|
useOneTap={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Microsoft */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={handleMicrosoft}
|
||||||
|
className={`w-full flex items-center justify-center gap-2.5 py-2.5 rounded-xl border text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||||
|
isDark
|
||||||
|
? "border-slate-700 text-slate-300 hover:border-slate-600 hover:bg-slate-800"
|
||||||
|
: "border-slate-200 text-slate-600 hover:border-slate-300 hover:bg-slate-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 shrink-0" viewBox="0 0 24 24">
|
||||||
|
<path fill="#F25022" d="M1 1h10v10H1z" />
|
||||||
|
<path fill="#00A4EF" d="M13 1h10v10H13z" />
|
||||||
|
<path fill="#7FBA00" d="M1 13h10v10H1z" />
|
||||||
|
<path fill="#FFB900" d="M13 13h10v10H13z" />
|
||||||
|
</svg>
|
||||||
|
<span>Continuar con Microsoft</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,93 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useRouter, usePathname } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import { FiMenu, FiX } from "react-icons/fi";
|
import { FiMenu, FiX, FiUser, FiLogOut, FiSettings, FiBookOpen } from "react-icons/fi";
|
||||||
import { RouteNavigation, type INavigation } from "../../Routes";
|
import { RouteNavigation, type INavigation } from "../../Routes";
|
||||||
import { ShopCart } from "../ShopCart";
|
import { ShopCart } from "../ShopCart";
|
||||||
import { ThemeToggle } from "../ui/ThemeToggle";
|
import { ThemeToggle } from "../ui/ThemeToggle";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
import { BrandLogo } from "../common/BrandLogo";
|
import { BrandLogo } from "../common/BrandLogo";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
|
||||||
|
function UserMenu() {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const initials = user?.name
|
||||||
|
? user.name.split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase()
|
||||||
|
: user?.email?.[0]?.toUpperCase() ?? "U";
|
||||||
|
|
||||||
|
const menuItem = (icon: React.ReactNode, label: string, onClick: () => void) => (
|
||||||
|
<button
|
||||||
|
onClick={() => { onClick(); setOpen(false); }}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left transition-colors ${
|
||||||
|
isDark ? "text-slate-300 hover:bg-slate-800 hover:text-white" : "text-slate-700 hover:bg-slate-50 hover:text-slate-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{icon} {label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1.5 rounded-xl transition-colors ${
|
||||||
|
isDark ? "hover:bg-white/5" : "hover:bg-slate-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-violet-600 flex items-center justify-center text-white text-xs font-bold select-none">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<span className={`hidden sm:block text-sm font-medium max-w-[120px] truncate ${isDark ? "text-slate-200" : "text-slate-800"}`}>
|
||||||
|
{user?.name || user?.email || "Mi cuenta"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className={`absolute right-0 mt-2 w-52 rounded-2xl shadow-xl border overflow-hidden z-50 ${
|
||||||
|
isDark ? "bg-slate-900 border-slate-700" : "bg-white border-slate-200"
|
||||||
|
}`}>
|
||||||
|
<div className={`px-4 py-3 border-b ${isDark ? "border-slate-800" : "border-slate-100"}`}>
|
||||||
|
<p className={`text-xs font-semibold truncate ${isDark ? "text-slate-100" : "text-slate-800"}`}>
|
||||||
|
{user?.name || "Mi cuenta"}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs truncate mt-0.5 ${isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||||
|
{user?.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{menuItem(<FiBookOpen className="text-base" />, "Mis cursos", () => router.push("/my-courses"))}
|
||||||
|
{menuItem(<FiSettings className="text-base" />, "Configuración", () => router.push("/settings"))}
|
||||||
|
<div className={`border-t ${isDark ? "border-slate-800" : "border-slate-100"}`}>
|
||||||
|
{menuItem(<FiLogOut className="text-base text-red-400" />, "Cerrar sesión", () => {
|
||||||
|
logout();
|
||||||
|
localStorage.removeItem("refreshToken");
|
||||||
|
router.push("/");
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const Navbar = () => {
|
export const Navbar = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
const navigate = (path: string) => {
|
const navigate = (path: string) => {
|
||||||
@@ -54,21 +129,37 @@ export const Navbar = () => {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{isAuthenticated && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/my-courses")}
|
||||||
|
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-sm font-medium transition-colors duration-200 cursor-pointer ${
|
||||||
|
pathname === "/my-courses"
|
||||||
|
? isDark ? "text-white" : "text-violet-700"
|
||||||
|
: isDark ? "text-slate-400 hover:text-white" : "text-slate-500 hover:text-slate-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FiBookOpen />
|
||||||
|
<span>Mis cursos</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Derecha */}
|
{/* Derecha */}
|
||||||
<div className="flex items-center gap-2 shrink-0 ml-auto">
|
<div className="flex items-center gap-2 shrink-0 ml-auto">
|
||||||
{/* Login — oculto en mobile muy pequeño */}
|
{isAuthenticated ? (
|
||||||
<button
|
<UserMenu />
|
||||||
onClick={() => navigate("/login")}
|
) : (
|
||||||
className={`hidden sm:inline-flex text-sm font-medium px-4 py-1.5 rounded-full border transition-colors cursor-pointer ${
|
<button
|
||||||
isDark
|
onClick={() => navigate("/login")}
|
||||||
? "border-slate-700 text-slate-200 hover:border-violet-400 hover:text-violet-300"
|
className={`hidden sm:inline-flex text-sm font-medium px-4 py-1.5 rounded-full border transition-colors cursor-pointer ${
|
||||||
: "border-slate-300 text-slate-700 hover:border-violet-500 hover:text-violet-600"
|
isDark
|
||||||
}`}
|
? "border-slate-700 text-slate-200 hover:border-violet-400 hover:text-violet-300"
|
||||||
>
|
: "border-slate-300 text-slate-700 hover:border-violet-500 hover:text-violet-600"
|
||||||
Iniciar sesión
|
}`}
|
||||||
</button>
|
>
|
||||||
|
Iniciar sesión
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<ThemeToggle variant="switch" />
|
<ThemeToggle variant="switch" />
|
||||||
<ShopCart />
|
<ShopCart />
|
||||||
|
|
||||||
@@ -107,14 +198,29 @@ export const Navbar = () => {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className={`mt-2 pt-3 border-t ${border}`}>
|
{isAuthenticated && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/login")}
|
onClick={() => navigate("/my-courses")}
|
||||||
className="w-full py-3 bg-violet-600 hover:bg-violet-500 text-white text-sm font-semibold rounded-xl transition cursor-pointer"
|
className={`flex items-center gap-2.5 px-4 py-3 rounded-xl text-sm font-medium text-left transition cursor-pointer ${
|
||||||
|
pathname === "/my-courses"
|
||||||
|
? isDark ? "bg-white/5 text-white" : "bg-violet-50 text-violet-700"
|
||||||
|
: isDark ? "text-slate-400 hover:text-white hover:bg-white/5" : "text-slate-600 hover:text-slate-900 hover:bg-slate-50"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Iniciar sesión
|
<FiBookOpen />
|
||||||
|
Mis cursos
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
|
{!isAuthenticated && (
|
||||||
|
<div className={`mt-2 pt-3 border-t ${border}`}>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/login")}
|
||||||
|
className="w-full py-3 bg-violet-600 hover:bg-violet-500 text-white text-sm font-semibold rounded-xl transition cursor-pointer"
|
||||||
|
>
|
||||||
|
Iniciar sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { FiCalendar, FiChevronLeft, FiChevronRight, FiX } from "react-icons/fi";
|
||||||
|
|
||||||
|
interface DatePickerProps {
|
||||||
|
label?: string;
|
||||||
|
value?: string; // "YYYY-MM-DD"
|
||||||
|
onChange: (value: string | undefined) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
maxDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTHS = [
|
||||||
|
"Enero","Febrero","Marzo","Abril","Mayo","Junio",
|
||||||
|
"Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre",
|
||||||
|
];
|
||||||
|
const DAYS = ["Do","Lu","Ma","Mi","Ju","Vi","Sa"];
|
||||||
|
const DROPDOWN_H = 320; // px estimado del dropdown
|
||||||
|
|
||||||
|
function parseDate(value?: string): Date | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const d = new Date(value + "T12:00:00");
|
||||||
|
return isNaN(d.getTime()) ? undefined : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDisplay(value?: string): string {
|
||||||
|
const d = parseDate(value);
|
||||||
|
if (!d) return "";
|
||||||
|
return d.toLocaleDateString("es-MX", { day: "numeric", month: "long", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DatePicker({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Selecciona una fecha",
|
||||||
|
required,
|
||||||
|
maxDate = new Date(),
|
||||||
|
}: DatePickerProps) {
|
||||||
|
const selected = parseDate(value);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [view, setView] = useState<"days" | "months" | "years">("days");
|
||||||
|
const [cursor, setCursor] = useState(() => {
|
||||||
|
const d = selected ?? new Date();
|
||||||
|
return { month: d.getMonth(), year: d.getFullYear() };
|
||||||
|
});
|
||||||
|
const [pos, setPos] = useState({ top: 0, left: 0, width: 0, openUp: false });
|
||||||
|
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const calcPos = useCallback(() => {
|
||||||
|
if (!triggerRef.current) return;
|
||||||
|
const r = triggerRef.current.getBoundingClientRect();
|
||||||
|
const spaceBelow = window.innerHeight - r.bottom;
|
||||||
|
const openUp = spaceBelow < DROPDOWN_H + 8 && r.top > DROPDOWN_H + 8;
|
||||||
|
setPos({
|
||||||
|
top: openUp ? r.top - 8 : r.bottom + 8,
|
||||||
|
left: r.left,
|
||||||
|
width: r.width,
|
||||||
|
openUp,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openPicker = () => {
|
||||||
|
if (selected) setCursor({ month: selected.getMonth(), year: selected.getFullYear() });
|
||||||
|
setView("days");
|
||||||
|
calcPos();
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onScroll = () => calcPos();
|
||||||
|
const onResize = () => calcPos();
|
||||||
|
const onMouse = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
triggerRef.current?.contains(e.target as Node) ||
|
||||||
|
dropdownRef.current?.contains(e.target as Node)
|
||||||
|
) return;
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("scroll", onScroll, true);
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
document.addEventListener("mousedown", onMouse);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", onScroll, true);
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
document.removeEventListener("mousedown", onMouse);
|
||||||
|
};
|
||||||
|
}, [open, calcPos]);
|
||||||
|
|
||||||
|
const selectDay = (day: number) => {
|
||||||
|
const d = new Date(cursor.year, cursor.month, day);
|
||||||
|
const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
onChange(iso);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const daysInMonth = new Date(cursor.year, cursor.month + 1, 0).getDate();
|
||||||
|
const firstDow = new Date(cursor.year, cursor.month, 1).getDay();
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
const isDisabled = (day: number) => new Date(cursor.year, cursor.month, day) > maxDate;
|
||||||
|
const isSelected = (day: number) =>
|
||||||
|
selected?.getFullYear() === cursor.year &&
|
||||||
|
selected?.getMonth() === cursor.month &&
|
||||||
|
selected?.getDate() === day;
|
||||||
|
const isToday = (day: number) =>
|
||||||
|
today.getFullYear() === cursor.year &&
|
||||||
|
today.getMonth() === cursor.month &&
|
||||||
|
today.getDate() === day;
|
||||||
|
|
||||||
|
const yearRange = Array.from({ length: 101 }, (_, i) => today.getFullYear() - 100 + i).reverse();
|
||||||
|
|
||||||
|
const dropdown = open ? (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: pos.openUp ? "auto" : pos.top,
|
||||||
|
bottom: pos.openUp ? window.innerHeight - pos.top : "auto",
|
||||||
|
left: pos.left,
|
||||||
|
width: Math.max(pos.width, 288),
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
className="rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 dark:border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => view === "days" && setCursor(c => {
|
||||||
|
const d = new Date(c.year, c.month - 1, 1);
|
||||||
|
return { month: d.getMonth(), year: d.getFullYear() };
|
||||||
|
})}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
<FiChevronLeft />
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView(v => v === "months" ? "days" : "months")}
|
||||||
|
className="text-sm font-semibold text-slate-800 dark:text-slate-100 hover:text-violet-600 dark:hover:text-violet-400 transition"
|
||||||
|
>
|
||||||
|
{MONTHS[cursor.month]}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView(v => v === "years" ? "days" : "years")}
|
||||||
|
className="text-sm font-semibold text-slate-800 dark:text-slate-100 hover:text-violet-600 dark:hover:text-violet-400 transition"
|
||||||
|
>
|
||||||
|
{cursor.year}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => view === "days" && setCursor(c => {
|
||||||
|
const d = new Date(c.year, c.month + 1, 1);
|
||||||
|
return { month: d.getMonth(), year: d.getFullYear() };
|
||||||
|
})}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
<FiChevronRight />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Days */}
|
||||||
|
{view === "days" && (
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="grid grid-cols-7 mb-1">
|
||||||
|
{DAYS.map(d => (
|
||||||
|
<div key={d} className="text-center text-[10px] font-semibold text-slate-400 dark:text-slate-500 py-1">{d}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-y-0.5">
|
||||||
|
{Array.from({ length: firstDow }).map((_, i) => <div key={`e-${i}`} />)}
|
||||||
|
{Array.from({ length: daysInMonth }, (_, i) => i + 1).map(day => (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
type="button"
|
||||||
|
disabled={isDisabled(day)}
|
||||||
|
onClick={() => selectDay(day)}
|
||||||
|
className={`
|
||||||
|
aspect-square w-full flex items-center justify-center rounded-lg text-sm transition-all
|
||||||
|
${isSelected(day)
|
||||||
|
? "bg-violet-600 text-white font-semibold"
|
||||||
|
: isToday(day)
|
||||||
|
? "border border-violet-400 text-violet-600 dark:text-violet-400 font-semibold"
|
||||||
|
: "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"}
|
||||||
|
${isDisabled(day) ? "opacity-30 cursor-not-allowed" : ""}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Months */}
|
||||||
|
{view === "months" && (
|
||||||
|
<div className="grid grid-cols-3 gap-2 p-3">
|
||||||
|
{MONTHS.map((m, i) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setCursor(c => ({ ...c, month: i })); setView("days"); }}
|
||||||
|
className={`py-2 rounded-xl text-sm font-medium transition ${
|
||||||
|
cursor.month === i
|
||||||
|
? "bg-violet-600 text-white"
|
||||||
|
: "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.slice(0, 3)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Years */}
|
||||||
|
{view === "years" && (
|
||||||
|
<div className="h-52 overflow-y-auto p-2 grid grid-cols-3 gap-1 content-start">
|
||||||
|
{yearRange.map(y => (
|
||||||
|
<button
|
||||||
|
key={y}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setCursor(c => ({ ...c, year: y })); setView("days"); }}
|
||||||
|
className={`py-2 rounded-xl text-sm font-medium transition ${
|
||||||
|
cursor.year === y
|
||||||
|
? "bg-violet-600 text-white"
|
||||||
|
: "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{y}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-1.5">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300">
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-1 text-rose-500 font-bold">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<div className="absolute left-3.5 pointer-events-none text-slate-400 dark:text-slate-500">
|
||||||
|
<FiCalendar className="text-base" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
onClick={openPicker}
|
||||||
|
className={`
|
||||||
|
w-full pl-10 pr-10 py-2.5 text-sm rounded-xl text-left transition-all duration-200 outline-none
|
||||||
|
bg-white dark:bg-slate-900/90
|
||||||
|
border border-slate-300 dark:border-slate-800
|
||||||
|
hover:border-slate-400 dark:hover:border-slate-700
|
||||||
|
focus:border-cyan-500 focus:ring-4 focus:ring-cyan-500/15
|
||||||
|
dark:focus:border-cyan-500 dark:focus:ring-cyan-500/25
|
||||||
|
${value ? "text-slate-900 dark:text-slate-100" : "text-slate-400 dark:text-slate-500"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{value ? formatDisplay(value) : placeholder}
|
||||||
|
</button>
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(undefined)}
|
||||||
|
className="absolute right-3 p-1 rounded-md text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
<FiX className="text-sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{typeof window !== "undefined" && createPortal(dropdown, document.body)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { FiUpload, FiX } from "react-icons/fi";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
|
export interface ImageUploadProps {
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
onChange: (dataUrl: string | undefined) => void;
|
||||||
|
aspectRatio?: "square" | "banner";
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageUpload({ label, value, onChange, aspectRatio = "square", hint }: ImageUploadProps) {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
const isBanner = aspectRatio === "banner";
|
||||||
|
|
||||||
|
const handleFile = (file: File) => {
|
||||||
|
if (!file.type.startsWith("image/")) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => onChange(e.target?.result as string);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) handleFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const border = isDark ? "border-slate-700" : "border-slate-200";
|
||||||
|
const bg = isDark ? "bg-slate-800/50" : "bg-slate-50";
|
||||||
|
const active = dragging ? (isDark ? "border-violet-500 bg-violet-500/10" : "border-violet-400 bg-violet-50") : "";
|
||||||
|
const muted = isDark ? "text-slate-400" : "text-slate-500";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className={`block text-sm font-medium mb-1.5 ${isDark ? "text-slate-300" : "text-slate-700"}`}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`relative rounded-xl border-2 border-dashed transition-all duration-200 overflow-hidden cursor-pointer ${border} ${bg} ${active} ${isBanner ? "h-32" : "h-28 w-28"}`}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||||
|
onDragLeave={() => setDragging(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{value ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={value}
|
||||||
|
alt={label}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onChange(undefined); }}
|
||||||
|
className="absolute top-2 right-2 p-1 rounded-full bg-black/60 text-white hover:bg-black/80 transition"
|
||||||
|
>
|
||||||
|
<FiX className="text-xs" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-1.5 px-4 text-center">
|
||||||
|
<FiUpload className={`text-xl ${muted}`} />
|
||||||
|
<p className={`text-xs ${muted}`}>
|
||||||
|
{dragging ? "Suelta aquí" : "Click o arrastra"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hint && <p className={`text-[11px] mt-1 ${muted}`}>{hint}</p>}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
interface Props { password: string }
|
||||||
|
|
||||||
|
function getStrength(p: string): { score: number; label: string; color: string } {
|
||||||
|
if (p.length === 0) return { score: 0, label: "", color: "" };
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
if (p.length >= 8) score++;
|
||||||
|
if (p.length >= 12) score++;
|
||||||
|
if (/[A-Z]/.test(p)) score++;
|
||||||
|
if (/[0-9]/.test(p)) score++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(p)) score++;
|
||||||
|
|
||||||
|
if (score <= 1) return { score: 1, label: "Muy débil", color: "bg-red-500" };
|
||||||
|
if (score === 2) return { score: 2, label: "Débil", color: "bg-orange-500" };
|
||||||
|
if (score === 3) return { score: 3, label: "Regular", color: "bg-yellow-400" };
|
||||||
|
if (score === 4) return { score: 4, label: "Fuerte", color: "bg-emerald-500" };
|
||||||
|
return { score: 5, label: "Muy fuerte", color: "bg-emerald-400" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordStrength({ password }: Props) {
|
||||||
|
if (!password) return null;
|
||||||
|
|
||||||
|
const { score, label, color } = getStrength(password);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-1 flex-1 rounded-full transition-all duration-300 ${i <= score ? color : "bg-slate-700"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className={`text-[11px] font-medium transition-colors duration-300 ${
|
||||||
|
score <= 1 ? "text-red-400" :
|
||||||
|
score === 2 ? "text-orange-400" :
|
||||||
|
score === 3 ? "text-yellow-400" :
|
||||||
|
"text-emerald-400"
|
||||||
|
}`}>
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,3 +47,9 @@ export type { ModalProps } from './Modal';
|
|||||||
export { ThemeToggle } from './ThemeToggle';
|
export { ThemeToggle } from './ThemeToggle';
|
||||||
export type { ThemeToggleProps } from './ThemeToggle';
|
export type { ThemeToggleProps } from './ThemeToggle';
|
||||||
export * from './Dropdown';
|
export * from './Dropdown';
|
||||||
|
|
||||||
|
export { ImageUpload } from './ImageUpload';
|
||||||
|
export type { ImageUploadProps } from './ImageUpload';
|
||||||
|
|
||||||
|
export { PasswordStrength } from './PasswordStrength';
|
||||||
|
export { DatePicker } from './DatePicker';
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
|
||||||
|
/** Redirige si el usuario YA está autenticado (para login/registro) */
|
||||||
|
export function useGuestOnly(redirectTo = "/") {
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) router.replace(redirectTo);
|
||||||
|
}, [isAuthenticated, redirectTo, router]);
|
||||||
|
|
||||||
|
return { isAuthenticated };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redirige si el usuario NO está autenticado (para rutas protegidas) */
|
||||||
|
export function useRequireAuth(redirectTo = "/login") {
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) router.replace(redirectTo);
|
||||||
|
}, [isAuthenticated, redirectTo, router]);
|
||||||
|
|
||||||
|
return { isAuthenticated };
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { apiClient } from "@/lib/apiClient";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import type { LoginRequest, LoginResponse } from "@/types/auth";
|
||||||
|
|
||||||
|
async function loginFn(data: LoginRequest): Promise<LoginResponse> {
|
||||||
|
const res = await apiClient.post<LoginResponse>("/api/auth/login", data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogin() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { login } = useAuthStore();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: loginFn,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
login(
|
||||||
|
{ id: "", name: "", email: "", role: "student" },
|
||||||
|
data.token,
|
||||||
|
);
|
||||||
|
localStorage.setItem("refreshToken", data.refreshToken);
|
||||||
|
router.push("/");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { apiClient } from "@/lib/apiClient";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import type { LoginResponse } from "@/types/auth";
|
||||||
|
|
||||||
|
interface OAuthRequest {
|
||||||
|
idToken: string;
|
||||||
|
provider: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function oauthFn(data: OAuthRequest): Promise<LoginResponse> {
|
||||||
|
const res = await apiClient.post<LoginResponse>("/api/auth/oauth", data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOAuth() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { login } = useAuthStore();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: oauthFn,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
login(
|
||||||
|
{ id: "", name: "", email: "", role: "student" },
|
||||||
|
data.token,
|
||||||
|
);
|
||||||
|
localStorage.setItem("refreshToken", data.refreshToken);
|
||||||
|
router.push("/login?step=profile");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { apiClient } from "@/lib/apiClient";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import type { RegisterRequest, LoginResponse } from "@/types/auth";
|
||||||
|
|
||||||
|
async function registerFn(data: RegisterRequest): Promise<LoginResponse> {
|
||||||
|
const res = await apiClient.post<LoginResponse>("/api/auth/register", data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegister(onSuccess?: () => void) {
|
||||||
|
const { login } = useAuthStore();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: registerFn,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
login(
|
||||||
|
{ id: "", name: "", email: "", role: "student" },
|
||||||
|
data.token,
|
||||||
|
);
|
||||||
|
localStorage.setItem("refreshToken", data.refreshToken);
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { apiClient } from "@/lib/apiClient";
|
||||||
|
|
||||||
|
export interface UpdateProfileRequest {
|
||||||
|
displayName: string;
|
||||||
|
bio?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
bannerUrl?: string;
|
||||||
|
location?: string;
|
||||||
|
websiteUrl?: string;
|
||||||
|
birthDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfileFn(data: UpdateProfileRequest) {
|
||||||
|
const res = await apiClient.put("/api/profile", data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
onError?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveProfile(options?: Options) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: saveProfileFn,
|
||||||
|
onSuccess: () => router.push("/"),
|
||||||
|
onError: options?.onError,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [client] = useState(() => new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { staleTime: 60_000, retry: 1 },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
|
||||||
|
export const apiClient = axios.create({
|
||||||
|
baseURL: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:5000",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
const token = useAuthStore.getState().token;
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(error) => {
|
||||||
|
// Solo logout si el token ya existía y el servidor lo rechazó (sesión expirada)
|
||||||
|
// No hacer logout en errores de auth/login o auth/register
|
||||||
|
const url: string = error.config?.url ?? "";
|
||||||
|
const isAuthEndpoint = url.includes("/api/auth/");
|
||||||
|
if (error.response?.status === 401 && !isAuthEndpoint && useAuthStore.getState().token) {
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { PublicClientApplication, Configuration } from "@azure/msal-browser";
|
||||||
|
|
||||||
|
const msalConfig: Configuration = {
|
||||||
|
auth: {
|
||||||
|
clientId: process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID ?? "",
|
||||||
|
authority: "https://login.microsoftonline.com/common",
|
||||||
|
redirectUri: typeof window !== "undefined" ? window.location.origin : "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const msalInstance = new PublicClientApplication(msalConfig);
|
||||||
|
|
||||||
|
export const microsoftLoginRequest = {
|
||||||
|
scopes: ["openid", "profile", "email"],
|
||||||
|
};
|
||||||
@@ -1,5 +1,24 @@
|
|||||||
export type UserRole = "student" | "instructor" | "admin";
|
export type UserRole = "student" | "instructor" | "admin";
|
||||||
|
|
||||||
|
// --- API shapes ---
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string;
|
||||||
|
expiresAt: string;
|
||||||
|
refreshToken: string;
|
||||||
|
refreshExpiresAt: string;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2024.d.ts","./node_modules/typescript/lib/lib.es2025.d.ts","./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2024.collection.d.ts","./node_modules/typescript/lib/lib.es2024.object.d.ts","./node_modules/typescript/lib/lib.es2024.promise.d.ts","./node_modules/typescript/lib/lib.es2024.regexp.d.ts","./node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2024.string.d.ts","./node_modules/typescript/lib/lib.es2025.collection.d.ts","./node_modules/typescript/lib/lib.es2025.float16.d.ts","./node_modules/typescript/lib/lib.es2025.intl.d.ts","./node_modules/typescript/lib/lib.es2025.iterator.d.ts","./node_modules/typescript/lib/lib.es2025.promise.d.ts","./node_modules/typescript/lib/lib.es2025.regexp.d.ts","./node_modules/typescript/lib/lib.esnext.array.d.ts","./node_modules/typescript/lib/lib.esnext.collection.d.ts","./node_modules/typescript/lib/lib.esnext.date.d.ts","./node_modules/typescript/lib/lib.esnext.decorators.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.error.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","./node_modules/typescript/lib/lib.esnext.temporal.d.ts","./node_modules/typescript/lib/lib.esnext.typedarrays.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@tyLine truncated
|
{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2024.d.ts","./node_modules/typescript/lib/lib.es2025.d.ts","./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2024.collection.d.ts","./node_modules/typescript/lib/lib.es2024.object.d.ts","./node_modules/typescript/lib/lib.es2024.promise.d.ts","./node_modules/typescript/lib/lib.es2024.regexp.d.ts","./node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2024.string.d.ts","./node_modules/typescript/lib/lib.es2025.collection.d.ts","./node_modules/typescript/lib/lib.es2025.float16.d.ts","./node_modules/typescript/lib/lib.es2025.intl.d.ts","./node_modules/typescript/lib/lib.es2025.iterator.d.ts","./node_modules/typescript/lib/lib.es2025.promise.d.ts","./node_modules/typescript/lib/lib.es2025.regexp.d.ts","./node_modules/typescript/lib/lib.esnext.array.d.ts","./node_modules/typescript/lib/lib.esnext.collection.d.ts","./node_modules/typescript/lib/lib.esnext.date.d.ts","./node_modules/typescript/lib/lib.esnext.decorators.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.error.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","./node_modules/typescript/lib/lib.esnext.temporal.d.ts","./node_modules/typescript/lib/lib.esnext.typedarrays.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@tyLine truncated
|
||||||
Reference in new issue
Block a user