feat(all): conexión de servicio de login.

This commit is contained in:
Emmanuel Rojas committed 2026-09-07 20:11:59 -05:00
1 parent 33d3afb768
commit 9fa5cc38e8
27 files changed
+2173 -139

No files matched your search

+359 -2
View File
@@ -8,13 +8,17 @@
"name": "courses-rynext",
"version": "0.0.0",
"dependencies": {
"@azure/msal-browser": "^5.21.0",
"@react-oauth/google": "^0.13.5",
"@tailwindcss/postcss": "^4.3.3",
"@tanstack/react-query": "^5.101.4",
"axios": "^1.20.0",
"next": "^16.3.3",
"postcss": "^8.5.26",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-icons": "^5.7.0",
"sonner": "^2.0.8",
"tailwindcss": "^4.3.3",
"zustand": "^5.0.15"
},
@@ -43,6 +47,27 @@
"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": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1168,6 +1193,16 @@
"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": {
"version": "0.5.23",
"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"
}
},
"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": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -1796,6 +1843,24 @@
"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": {
"version": "4.0.4",
"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_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": {
"version": "1.0.30001809",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
@@ -1892,6 +1970,18 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1925,7 +2015,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -1946,6 +2035,15 @@
"dev": true,
"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": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1955,6 +2053,20 @@
"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": {
"version": "1.5.412",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz",
@@ -1975,6 +2087,51 @@
"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": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -2274,6 +2431,51 @@
"dev": true,
"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": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -2284,6 +2486,43 @@
"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": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2310,12 +2549,63 @@
"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": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"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": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -2333,6 +2623,19 @@
"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": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2754,6 +3057,36 @@
"@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": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -2774,7 +3107,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -3021,6 +3353,15 @@
"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": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3165,6 +3506,22 @@
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+4
View File
@@ -10,13 +10,17 @@
"lint": "next lint"
},
"dependencies": {
"@azure/msal-browser": "^5.21.0",
"@react-oauth/google": "^0.13.5",
"@tailwindcss/postcss": "^4.3.3",
"@tanstack/react-query": "^5.101.4",
"axios": "^1.20.0",
"next": "^16.3.3",
"postcss": "^8.5.26",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-icons": "^5.7.0",
"sonner": "^2.0.8",
"tailwindcss": "^4.3.3",
"zustand": "^5.0.15"
},
+295
View File
@@ -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>
);
}
+7
View File
@@ -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} />;
}
+23 -11
View File
@@ -15,6 +15,8 @@ import {
FiChevronDown,
} from "react-icons/fi";
import { ThemeToggle } from "@/components/ui";
import { useAuthStore } from "@/stores/authStore";
import { useRequireAuth } from "@/hooks/auth/useAuthGuard";
const navigation = [
{ name: "Dashboard", href: "/instructor", icon: FiHome },
@@ -35,6 +37,8 @@ export default function InstructorLayout({
children: React.ReactNode;
}) {
const pathname = usePathname();
const { isAuthenticated } = useRequireAuth("/login");
const { user, logout } = useAuthStore();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
const [dropdownPos, setDropdownPos] = useState({ top: 0, right: 0 });
@@ -75,6 +79,18 @@ export default function InstructorLayout({
}
}, [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 = () => (
<>
<div>
@@ -200,16 +216,12 @@ export default function InstructorLayout({
>
<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">
Instructor Name
{user?.name || "Mi cuenta"}
</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 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">
<img
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 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">
{initials}
</div>
<FiChevronDown
className={`hidden md:block text-sm text-slate-400 transition-transform duration-200 ${
@@ -228,8 +240,8 @@ export default function InstructorLayout({
>
{/* User info header */}
<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-xs text-slate-500 truncate">instructor@rynext.com</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">{user?.email}</p>
</div>
{/* Menu items */}
@@ -247,7 +259,7 @@ export default function InstructorLayout({
<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"
onClick={() => setProfileMenuOpen(false)}
onClick={() => { setProfileMenuOpen(false); handleLogout(); }}
>
<FiLogOut className="text-base" />
<span className="font-medium">Cerrar Sesión</span>
+15 -7
View File
@@ -3,6 +3,9 @@ import { ThemeProvider } from "@/context/ThemeContext";
import { CartProvider } from "@/context/CartContext";
import { CartDrawer } from "@/components/buyer/CartDrawer";
import { CartAnimator } from "@/components/buyer/CartAnimator";
import { QueryProvider } from "@/lib/QueryProvider";
import { GoogleOAuthProvider } from "@react-oauth/google";
import { Toaster } from "sonner";
import "./globals.css";
export const metadata: Metadata = {
@@ -43,13 +46,18 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="es" suppressHydrationWarning>
<body className="bg-slate-950">
<ThemeProvider>
<CartProvider>
{children}
<CartDrawer />
<CartAnimator />
</CartProvider>
</ThemeProvider>
<GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ?? ""}>
<QueryProvider>
<ThemeProvider>
<CartProvider>
{children}
<CartDrawer />
<CartAnimator />
</CartProvider>
</ThemeProvider>
<Toaster richColors position="top-right" />
</QueryProvider>
</GoogleOAuthProvider>
</body>
</html>
);
+45 -7
View File
@@ -1,24 +1,37 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useSearchParams } from "next/navigation";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
import { useTheme } from "@/context/ThemeContext";
import { BrandLogo } from "@/components/common/BrandLogo";
import { LoginForm } from "@/components/auth/LoginForm";
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 fullWidth = flipped && registerStep === 2;
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">
<BrandLogo size="sm" asLink />
<ThemeToggle variant="switch" />
</div>
<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" }}>
{flipped
? <RegisterForm onSwitchToLogin={() => onFlip(false)} />
? <RegisterForm onSwitchToLogin={() => onFlip(false)} onStepChange={onRegisterStep} />
: <LoginForm onSwitchToRegister={() => onFlip(true)} />
}
</div>
@@ -68,11 +81,36 @@ function HeroPanel({ flipped }: { flipped: boolean }) {
}
export default function LoginContent() {
const { isAuthenticated } = useGuestOnly();
const searchParams = useSearchParams();
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 (
<div className="flex min-h-screen">
<HeroPanel flipped={flipped} />
<AuthPanel flipped={flipped} onFlip={setFlipped} />
{showHero && <HeroPanel flipped={flipped} />}
<AuthPanel
flipped={flipped}
onFlip={handleFlip}
registerStep={registerStep}
onRegisterStep={setRegisterStep}
/>
</div>
);
}
+285
View File
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import MyCourses from "./_content";
export default function Page() {
return <MyCourses />;
}
+9 -6
View File
@@ -1,26 +1,28 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { FiMail, FiLock, FiArrowRight, FiCheck } from "react-icons/fi";
import { toast } from "sonner";
import { Button, Input, Checkbox } from "@/components/ui";
import { useTheme } from "@/context/ThemeContext";
import { AuthDivider } from "./AuthDivider";
import { SocialButtons } from "./SocialButtons";
import { useLogin } from "@/hooks/auth/useLogin";
export function LoginForm({ onSwitchToRegister }: { onSwitchToRegister: () => void }) {
const router = useRouter();
const { isDark } = useTheme();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const isValidEmail = email.includes("@") && email.includes(".");
const { mutate: login, isPending } = useLogin();
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) return;
setIsLoading(true);
setTimeout(() => { setIsLoading(false); router.push("/"); }, 800);
login({ email, password }, {
onError: () => toast.error("Credenciales inválidas. Intenta de nuevo."),
});
};
return (
@@ -33,6 +35,7 @@ export function LoginForm({ onSwitchToRegister }: { onSwitchToRegister: () => vo
Ingresa tu correo y contraseña para continuar.
</p>
</div>
<form onSubmit={handleLogin} className="space-y-6">
<div>
<Input
@@ -76,7 +79,7 @@ export function LoginForm({ onSwitchToRegister }: { onSwitchToRegister: () => vo
variant="violet"
size="lg"
fullWidth
isLoading={isLoading}
isLoading={isPending}
loadingText="Verificando..."
rightIcon={<FiArrowRight />}
>
+280 -40
View File
@@ -1,47 +1,58 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { FiMail, FiLock, FiArrowRight, FiUser } from "react-icons/fi";
import { Button, Input } from "@/components/ui";
import { FiMail, FiLock, FiArrowRight, FiUser, FiMapPin, FiGlobe, FiCamera } from "react-icons/fi";
import { toast } from "sonner";
import { Button, Input, Textarea, PasswordStrength, DatePicker } from "@/components/ui";
import { useTheme } from "@/context/ThemeContext";
import { AuthDivider } from "./AuthDivider";
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 }) {
const router = useRouter();
// ─── Paso 1: Credenciales ────────────────────────────────────────────────────
function StepCredentials({
onNext,
onSwitchToLogin,
}: {
onNext: () => void;
onSwitchToLogin: () => void;
}) {
const { isDark } = useTheme();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const handleRegister = (e: React.FormEvent) => {
e.preventDefault();
if (!name || !email || !password) return;
setIsLoading(true);
setTimeout(() => { setIsLoading(false); router.push("/"); }, 800);
};
const passwordsMatch = confirm === "" || password === confirm;
const { mutate: register, isPending } = useRegister(onNext);
const muted = isDark ? "text-slate-400" : "text-slate-500";
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"}`}>
Crear cuenta
</h1>
<p className={`text-sm mt-1 ${isDark ? "text-slate-400" : "text-slate-500"}`}>
Regístrate gratis y empieza a aprender hoy.
<p className={`text-sm mt-1 ${muted}`}>
Ingresa tu correo y una contraseña segura.
</p>
</div>
<form onSubmit={handleRegister} className="space-y-6">
<Input
label="Nombre completo"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Tu nombre"
leftIcon={<FiUser />}
/>
<form onSubmit={(e) => {
e.preventDefault();
if (!passwordsMatch) return;
register({ email, password }, {
onError: (err) => {
const msg = (err as { response?: { data?: string } }).response?.data ?? "";
if (msg.includes("registrado")) toast.error("Este correo ya tiene una cuenta.");
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
label="Correo electrónico"
type="email"
@@ -51,30 +62,51 @@ export function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void
placeholder="[email protected]"
leftIcon={<FiMail />}
/>
<Input
label="Contraseña"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mínimo 8 caracteres"
leftIcon={<FiLock />}
/>
<div>
<Input
label="Contraseña"
type="password"
required
minLength={8}
value={password}
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
type="submit"
variant="violet"
size="lg"
fullWidth
isLoading={isLoading}
isLoading={isPending}
loadingText="Creando cuenta..."
rightIcon={<FiArrowRight />}
disabled={!passwordsMatch || password.length < 8}
>
Crear cuenta gratis
Continuar
</Button>
</form>
<AuthDivider />
<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?{" "}
<button type="button" onClick={onSwitchToLogin} className="text-violet-500 hover:text-violet-400 font-semibold hover:underline transition">
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>
);
}
+68 -47
View File
@@ -1,65 +1,86 @@
"use client";
import { useRef } from "react";
import { GoogleLogin } from "@react-oauth/google";
import { useTheme } from "@/context/ThemeContext";
const socialProviders = [
{
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>
),
},
];
import { useOAuth } from "@/hooks/auth/useOAuth";
import { msalInstance, microsoftLoginRequest } from "@/lib/msalConfig";
import { toast } from "sonner";
export function SocialButtons() {
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 (
<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
key={label}
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
? "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}
<span>Continuar con {label}</span>
<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>
<span>Continuar con Google</span>
</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>
);
}
+124 -18
View File
@@ -1,18 +1,93 @@
"use client";
import { useState } from "react";
import { useState, useRef, useEffect } from "react";
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 { ShopCart } from "../ShopCart";
import { ThemeToggle } from "../ui/ThemeToggle";
import { useTheme } from "../../context/ThemeContext";
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 = () => {
const router = useRouter();
const pathname = usePathname();
const { isDark } = useTheme();
const { isAuthenticated } = useAuthStore();
const [mobileOpen, setMobileOpen] = useState(false);
const navigate = (path: string) => {
@@ -54,21 +129,37 @@ export const Navbar = () => {
</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>
{/* Derecha */}
<div className="flex items-center gap-2 shrink-0 ml-auto">
{/* Login — oculto en mobile muy pequeño */}
<button
onClick={() => navigate("/login")}
className={`hidden sm:inline-flex text-sm font-medium px-4 py-1.5 rounded-full border transition-colors cursor-pointer ${
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>
{isAuthenticated ? (
<UserMenu />
) : (
<button
onClick={() => navigate("/login")}
className={`hidden sm:inline-flex text-sm font-medium px-4 py-1.5 rounded-full border transition-colors cursor-pointer ${
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>
)}
<ThemeToggle variant="switch" />
<ShopCart />
@@ -107,14 +198,29 @@ export const Navbar = () => {
</button>
);
})}
<div className={`mt-2 pt-3 border-t ${border}`}>
{isAuthenticated && (
<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"
onClick={() => navigate("/my-courses")}
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>
</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>
)}
+289
View File
@@ -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>
);
}
+89
View File
@@ -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>
);
}
+47
View File
@@ -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>
);
}
+6
View File
@@ -47,3 +47,9 @@ export type { ModalProps } from './Modal';
export { ThemeToggle } from './ThemeToggle';
export type { ThemeToggleProps } from './ThemeToggle';
export * from './Dropdown';
export { ImageUpload } from './ImageUpload';
export type { ImageUploadProps } from './ImageUpload';
export { PasswordStrength } from './PasswordStrength';
export { DatePicker } from './DatePicker';
+28
View File
@@ -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 };
}
+28
View File
@@ -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("/");
},
});
}
+33
View File
@@ -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");
},
});
}
+26
View File
@@ -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?.();
},
});
}
+33
View File
@@ -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,
});
}
+13
View File
@@ -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>;
}
+27
View File
@@ -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);
},
);
+15
View File
@@ -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"],
};
+19
View File
@@ -1,5 +1,24 @@
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 {
id: string;
name: string;
+1 -1
View File
@@ -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