feat(auth): scaffold auth pages and guards
- Add Pinia auth store, types, and auth service (mocked) - Add login/register/verify/forgot password views and dashboard - Add reusable auth UI components (card, inputs, buttons) - Add router guards and protected routing wiring - Disable vue devtools plugin by default to avoid Node localStorage crash - Update dependencies for cookie handling - Comment out Umami script in index.html Made-with: Cursor
This commit is contained in:
+180
@@ -0,0 +1,180 @@
|
|||||||
|
Parfait Killian. Voici **tous les prompts structurés bloc par bloc** pour développer ta **plateforme de formation ultra sécurisée avec abonnement Stripe, cours vidéo/textes, exercices exécutables, progression, etc.** dans Cursor.
|
||||||
|
|
||||||
|
Chaque bloc est indépendant et peut être copié-collé dans Cursor directement pour guider la création pas à pas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🧱 Bloc 1 – Authentification sécurisée (frontend uniquement)**
|
||||||
|
|
||||||
|
```
|
||||||
|
Tu crées une plateforme de formation avec cours vidéo/textes, exercices interactifs, paiement par abonnement via Stripe, progression personnalisée et haut niveau de sécurité.
|
||||||
|
|
||||||
|
Commence par créer l’authentification côté frontend (Vue 3 + TypeScript).
|
||||||
|
|
||||||
|
Fonctionnalités :
|
||||||
|
- Inscription (email, mot de passe, plan choisi — non fonctionnel pour l’instant)
|
||||||
|
- Connexion avec token (JWT ou cookie, à simuler)
|
||||||
|
- Déconnexion
|
||||||
|
- Mot de passe oublié
|
||||||
|
- Vérification d’email (page après inscription)
|
||||||
|
|
||||||
|
Implémente :
|
||||||
|
- un store Pinia `useAuthStore`
|
||||||
|
- une couche `authService.ts` avec appels mockés
|
||||||
|
- des vues `LoginView`, `RegisterView`, `ForgotPasswordView`
|
||||||
|
- composants UI réutilisables pour formulaire
|
||||||
|
- navigation protégée (route guards)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🧱 Bloc 2 – Intégration Stripe (paiement + plans)**
|
||||||
|
|
||||||
|
```
|
||||||
|
Ajoute le système de paiement mensuel avec Stripe.
|
||||||
|
|
||||||
|
Fonctionnalités :
|
||||||
|
- Plans d’abonnement multiples (mensuel, annuel, premium, etc.)
|
||||||
|
- Paiement sécurisé via Stripe Checkout ou Billing Portal
|
||||||
|
- Stockage du statut de souscription côté backend
|
||||||
|
- Accès conditionnel aux cours selon le plan
|
||||||
|
|
||||||
|
Frontend :
|
||||||
|
- Crée une page "Plans" avec boutons "S’abonner"
|
||||||
|
- Intègre Stripe.js et redirige vers Checkout
|
||||||
|
- Gère le retour de paiement via `success_url` / `cancel_url`
|
||||||
|
|
||||||
|
Backend (simulé ou prêt pour implémentation future) :
|
||||||
|
- Création d’un client Stripe à l’inscription
|
||||||
|
- Webhooks Stripe pour gérer statut de paiement
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🧱 Bloc 3 – Architecture des cours (textes + vidéos + code)**
|
||||||
|
|
||||||
|
```
|
||||||
|
Crée la structure d’un cours dans le système.
|
||||||
|
|
||||||
|
Fonctionnalités :
|
||||||
|
- Un cours contient plusieurs chapitres
|
||||||
|
- Un chapitre contient des leçons (texte, vidéo, exercices)
|
||||||
|
- Une leçon contient du contenu markdown + vidéo + zone de code
|
||||||
|
|
||||||
|
Modèle (à représenter en TypeScript dans frontend pour l’instant) :
|
||||||
|
- Course: id, title, description, cover, plan_required
|
||||||
|
- Chapter: id, course_id, title, position
|
||||||
|
- Lesson: id, chapter_id, title, content (markdown), video_url, exercise_type
|
||||||
|
|
||||||
|
Crée les vues :
|
||||||
|
- `CourseOverview.vue` pour explorer un cours
|
||||||
|
- `LessonViewer.vue` avec affichage markdown + vidéo + éditeur de code
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🧱 Bloc 4 – Progression utilisateur dans les cours**
|
||||||
|
|
||||||
|
```
|
||||||
|
Crée un système pour suivre la progression individuelle de l’utilisateur.
|
||||||
|
|
||||||
|
Fonctionnalités :
|
||||||
|
- Chaque utilisateur peut suivre plusieurs cours
|
||||||
|
- Chaque leçon peut être marquée comme complétée
|
||||||
|
- Affichage de la progression dans le cours (ex: 4/10 leçons terminées)
|
||||||
|
- Synchronisé avec le backend (mocké pour l’instant)
|
||||||
|
|
||||||
|
Implémente :
|
||||||
|
- Un store Pinia `useProgressStore`
|
||||||
|
- Une structure : { user_id, course_id, completed_lessons: [lesson_ids] }
|
||||||
|
- Affiche des badges de progression dans l’UI
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🧱 Bloc 5 – Éditeur de code avec exécution sandboxée**
|
||||||
|
|
||||||
|
```
|
||||||
|
Ajoute une zone d’exécution de code dans certaines leçons (exercices interactifs).
|
||||||
|
|
||||||
|
Fonctionnalités :
|
||||||
|
- Zone d’édition avec CodeMirror ou Monaco Editor
|
||||||
|
- Bouton "Exécuter"
|
||||||
|
- Envoi du code à une API d’exécution (Docker, VM, ou service type Piston ou Playground)
|
||||||
|
- Récupération de l’output stdout/stderr
|
||||||
|
|
||||||
|
Commence avec un backend mocké (`codeRunnerService.ts`) qui renvoie une sortie simulée, prêt à être branché sur un vrai moteur plus tard.
|
||||||
|
|
||||||
|
UI :
|
||||||
|
- Composant `CodeExercise.vue` avec éditeur + output + reset
|
||||||
|
- Validation des exercices dans la progression si output match attendu
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🧱 Bloc 6 – Sécurité avancée & protection des routes**
|
||||||
|
|
||||||
|
```
|
||||||
|
Implémente une sécurité frontend renforcée.
|
||||||
|
|
||||||
|
Fonctionnalités :
|
||||||
|
- Redirection automatique si token expiré
|
||||||
|
- Accès conditionnel selon abonnement (plan actif ou non)
|
||||||
|
- Navigation dynamique : masquer les cours inaccessibles
|
||||||
|
|
||||||
|
Ajoute :
|
||||||
|
- Middleware de navigation Vue Router pour bloquer l’accès aux routes protégées
|
||||||
|
- Contrôle d’accès par rôle/plan dans `authStore`
|
||||||
|
- Méthodes helpers comme `canAccess(course)` ou `hasPlan(planName)`
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🧱 Bloc 7 – Tableau de bord utilisateur (dashboard)**
|
||||||
|
|
||||||
|
```
|
||||||
|
Crée un dashboard utilisateur avec :
|
||||||
|
- Cours suivis et progression
|
||||||
|
- Plan actuel et facturation
|
||||||
|
- Historique des paiements (via Stripe si connecté)
|
||||||
|
- Accès aux paramètres de compte
|
||||||
|
|
||||||
|
Crée la vue `DashboardView.vue` avec :
|
||||||
|
- Composants `UserCourses.vue`, `BillingInfo.vue`, `AccountSettings.vue`
|
||||||
|
- Requêtes via les stores et services simulés
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🧱 Bloc 8 – Administration des cours (interface admin)**
|
||||||
|
|
||||||
|
```
|
||||||
|
Crée une interface pour que l’administrateur puisse :
|
||||||
|
- Créer, modifier, supprimer des cours
|
||||||
|
- Ajouter des chapitres, leçons, vidéos, exercices
|
||||||
|
|
||||||
|
Crée la vue `AdminCoursesView.vue` avec :
|
||||||
|
- Liste des cours avec boutons d’action
|
||||||
|
- Formulaires dynamiques pour ajouter chapitre/leçon
|
||||||
|
- Éditeur markdown intégré + upload vidéo
|
||||||
|
|
||||||
|
Utilise Pinia ou un store temporaire pour gérer les états du formulaire.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🧱 Bloc 9 – Gestion des erreurs et UX propre**
|
||||||
|
|
||||||
|
```
|
||||||
|
Ajoute une UX propre et robuste :
|
||||||
|
- Toasts pour succès/erreurs
|
||||||
|
- Indicateurs de chargement dans tous les appels API
|
||||||
|
- Pages 404 / 403 / erreur serveur
|
||||||
|
- Fallbacks pour vidéos, markdown, et éditeur de code
|
||||||
|
|
||||||
|
Ajoute un composable `useToast.ts` pour centraliser l'affichage des messages et erreurs.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Souhaites-tu que je t’aide à écrire le backend ou les services API de ces blocs en parallèle ? (Rails, Node.js, autre ?)
|
||||||
+2
-2
@@ -236,8 +236,8 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script defer src="https://umami.killiandalcin.fr/script.js"
|
<!-- <script defer src="https://umami.killiandalcin.fr/script.js"
|
||||||
data-website-id="83631152-9b6b-4724-aad1-828459ff36dc"></script>
|
data-website-id="83631152-9b6b-4724-aad1-828459ff36dc"></script> -->
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Generated
+34
@@ -8,7 +8,9 @@
|
|||||||
"name": "portfolio",
|
"name": "portfolio",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@vueuse/head": "^2.0.0",
|
"@vueuse/head": "^2.0.0",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
"pinia": "^3.0.1",
|
"pinia": "^3.0.1",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-i18n": "^9.14.4",
|
"vue-i18n": "^9.14.4",
|
||||||
@@ -105,6 +107,7 @@
|
|||||||
"integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==",
|
"integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.2.0",
|
"@ampproject/remapping": "^2.2.0",
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
@@ -2103,6 +2106,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/js-cookie": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -2116,6 +2125,7 @@
|
|||||||
"integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==",
|
"integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -2166,6 +2176,7 @@
|
|||||||
"integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==",
|
"integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.34.1",
|
"@typescript-eslint/scope-manager": "8.34.1",
|
||||||
"@typescript-eslint/types": "8.34.1",
|
"@typescript-eslint/types": "8.34.1",
|
||||||
@@ -2832,6 +2843,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -3000,6 +3012,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001718",
|
"caniuse-lite": "^1.0.30001718",
|
||||||
"electron-to-chromium": "^1.5.160",
|
"electron-to-chromium": "^1.5.160",
|
||||||
@@ -3382,6 +3395,7 @@
|
|||||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3443,6 +3457,7 @@
|
|||||||
"integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==",
|
"integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"eslint-config-prettier": "bin/cli.js"
|
"eslint-config-prettier": "bin/cli.js"
|
||||||
},
|
},
|
||||||
@@ -3490,6 +3505,7 @@
|
|||||||
"integrity": "sha512-A5dRYc3eQ5i2rJFBW8J6F69ur/H7YfYg+5SCg6v829FU0BhM4fUTrRVR2d4MdZgzw0ioJEk6otYHEAnoGFqO4A==",
|
"integrity": "sha512-A5dRYc3eQ5i2rJFBW8J6F69ur/H7YfYg+5SCg6v829FU0BhM4fUTrRVR2d4MdZgzw0ioJEk6otYHEAnoGFqO4A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.4.0",
|
"@eslint-community/eslint-utils": "^4.4.0",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
@@ -4167,6 +4183,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/js-cookie": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -5071,6 +5096,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -5117,6 +5143,7 @@
|
|||||||
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
|
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -5234,6 +5261,7 @@
|
|||||||
"integrity": "sha512-qHcdEzLCiktQIfwBq420pn2dP+30uzqYxv9ETm91wdt2R9AFcWfjNAmje4NWlnCIQ5RMTzVf0ZyisOKqHR6RwA==",
|
"integrity": "sha512-qHcdEzLCiktQIfwBq420pn2dP+30uzqYxv9ETm91wdt2R9AFcWfjNAmje4NWlnCIQ5RMTzVf0ZyisOKqHR6RwA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -5539,6 +5567,7 @@
|
|||||||
"integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==",
|
"integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/source-map": "^0.3.3",
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
"acorn": "^8.14.0",
|
"acorn": "^8.14.0",
|
||||||
@@ -5590,6 +5619,7 @@
|
|||||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -5652,6 +5682,7 @@
|
|||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -5767,6 +5798,7 @@
|
|||||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
@@ -5945,6 +5977,7 @@
|
|||||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -5964,6 +5997,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.17.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.17.tgz",
|
||||||
"integrity": "sha512-LbHV3xPN9BeljML+Xctq4lbz2lVHCR6DtbpTf5XIO6gugpXUN49j2QQPcMj086r9+AkJ0FfUT8xjulKKBkkr9g==",
|
"integrity": "sha512-LbHV3xPN9BeljML+Xctq4lbz2lVHCR6DtbpTf5XIO6gugpXUN49j2QQPcMj086r9+AkJ0FfUT8xjulKKBkkr9g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.17",
|
"@vue/compiler-dom": "3.5.17",
|
||||||
"@vue/compiler-sfc": "3.5.17",
|
"@vue/compiler-sfc": "3.5.17",
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
"format": "prettier --write src/"
|
"format": "prettier --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@vueuse/head": "^2.0.0",
|
"@vueuse/head": "^2.0.0",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
"pinia": "^3.0.1",
|
"pinia": "^3.0.1",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-i18n": "^9.14.4",
|
"vue-i18n": "^9.14.4",
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-card-header">
|
||||||
|
<h1 class="auth-title">{{ title }}</h1>
|
||||||
|
<p v-if="subtitle" class="auth-subtitle">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-card-body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="$slots.footer" class="auth-card-footer">
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-card {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-title {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-subtitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-body {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-footer {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.auth-card {
|
||||||
|
background: var(--bg-primary-dark, #1a1a1a);
|
||||||
|
border-color: var(--border-color-dark, #333);
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.auth-card {
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<template>
|
||||||
|
<button :type="type" :disabled="disabled || loading" :class="['form-button', variant, size, { loading }]"
|
||||||
|
@click="$emit('click')">
|
||||||
|
<div v-if="loading" class="loading-spinner">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||||
|
stroke-dasharray="31.416" stroke-dashoffset="31.416">
|
||||||
|
<animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416"
|
||||||
|
repeatCount="indefinite" />
|
||||||
|
<animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416"
|
||||||
|
repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span v-else class="button-content">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
type?: 'button' | 'submit' | 'reset'
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
disabled?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
type: 'button',
|
||||||
|
variant: 'primary',
|
||||||
|
size: 'md',
|
||||||
|
disabled: false,
|
||||||
|
loading: false
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
click: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-button.loading {
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sizes */
|
||||||
|
.form-button.sm {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-button.md {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-button.lg {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variants */
|
||||||
|
.form-button.primary {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-button.primary:hover:not(:disabled) {
|
||||||
|
background: var(--primary-color-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--primary-color-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-button.secondary {
|
||||||
|
background: var(--secondary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-button.secondary:hover:not(:disabled) {
|
||||||
|
background: var(--secondary-color-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-button.outline {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--primary-color);
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-button.outline:hover:not(:disabled) {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-button.ghost:hover:not(:disabled) {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-button.danger {
|
||||||
|
background: var(--error-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-button.danger:hover:not(:disabled) {
|
||||||
|
background: var(--error-color-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner */
|
||||||
|
.loading-spinner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner svg {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button content */
|
||||||
|
.button-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus styles */
|
||||||
|
.form-button:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--primary-color-rgb), 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state */
|
||||||
|
.form-button:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<template>
|
||||||
|
<div class="form-group">
|
||||||
|
<label v-if="label" :for="id" class="form-label">{{ label }}</label>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input
|
||||||
|
:id="id"
|
||||||
|
:type="type"
|
||||||
|
:value="modelValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:required="required"
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="['form-input', { 'error': hasError }]"
|
||||||
|
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||||
|
@blur="$emit('blur')"
|
||||||
|
@focus="$emit('focus')"
|
||||||
|
/>
|
||||||
|
<div v-if="icon" class="input-icon">
|
||||||
|
<component :is="icon" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
|
<div v-if="hint" class="hint-text">{{ hint }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: string
|
||||||
|
label?: string
|
||||||
|
type?: 'text' | 'email' | 'password' | 'tel' | 'number'
|
||||||
|
placeholder?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
error?: string
|
||||||
|
hint?: string
|
||||||
|
icon?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
disabled: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
'blur': []
|
||||||
|
'focus': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const id = computed(() => `input-${Math.random().toString(36).substr(2, 9)}`)
|
||||||
|
const hasError = computed(() => !!props.error)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--primary-color-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input.error {
|
||||||
|
border-color: var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-icon {
|
||||||
|
position: absolute;
|
||||||
|
right: 1rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-text {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.form-input {
|
||||||
|
background: var(--bg-secondary-dark, #2a2a2a);
|
||||||
|
border-color: var(--border-color-dark, #404040);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
// Public routes that don't require authentication
|
||||||
|
const publicRoutes = [
|
||||||
|
'/',
|
||||||
|
'/login',
|
||||||
|
'/register',
|
||||||
|
'/forgot-password',
|
||||||
|
'/verify-email',
|
||||||
|
'/projects',
|
||||||
|
'/about',
|
||||||
|
'/contact',
|
||||||
|
'/fiverr',
|
||||||
|
'/formation'
|
||||||
|
]
|
||||||
|
|
||||||
|
// Routes that require authentication
|
||||||
|
const protectedRoutes = [
|
||||||
|
'/dashboard',
|
||||||
|
'/profile',
|
||||||
|
'/courses',
|
||||||
|
'/progress'
|
||||||
|
]
|
||||||
|
|
||||||
|
// Routes that require email verification
|
||||||
|
const verifiedEmailRoutes = [
|
||||||
|
'/dashboard',
|
||||||
|
'/courses'
|
||||||
|
]
|
||||||
|
|
||||||
|
export const requireAuth = async (
|
||||||
|
to: RouteLocationNormalized,
|
||||||
|
from: RouteLocationNormalized,
|
||||||
|
next: NavigationGuardNext
|
||||||
|
) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
// Initialize auth if not already done
|
||||||
|
if (!authStore.user && authStore.token) {
|
||||||
|
await authStore.initializeAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if route requires authentication
|
||||||
|
const isProtectedRoute = protectedRoutes.some(route =>
|
||||||
|
to.path.startsWith(route)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isProtectedRoute && !authStore.isAuthenticated) {
|
||||||
|
// Redirect to login with return URL
|
||||||
|
next({
|
||||||
|
path: '/login',
|
||||||
|
query: { redirect: to.fullPath }
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if route requires email verification
|
||||||
|
const requiresEmailVerification = verifiedEmailRoutes.some(route =>
|
||||||
|
to.path.startsWith(route)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (requiresEmailVerification && authStore.user && !authStore.user.isEmailVerified) {
|
||||||
|
// Redirect to email verification
|
||||||
|
next({
|
||||||
|
path: '/verify-email',
|
||||||
|
query: { email: authStore.user.email }
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user is authenticated and trying to access auth pages, redirect to dashboard
|
||||||
|
if (authStore.isAuthenticated && ['/login', '/register'].includes(to.path)) {
|
||||||
|
next('/dashboard')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requireGuest = (
|
||||||
|
to: RouteLocationNormalized,
|
||||||
|
from: RouteLocationNormalized,
|
||||||
|
next: NavigationGuardNext
|
||||||
|
) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
if (authStore.isAuthenticated) {
|
||||||
|
next('/dashboard')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requireVerifiedEmail = (
|
||||||
|
to: RouteLocationNormalized,
|
||||||
|
from: RouteLocationNormalized,
|
||||||
|
next: NavigationGuardNext
|
||||||
|
) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
if (!authStore.isAuthenticated) {
|
||||||
|
next('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authStore.user && !authStore.user.isEmailVerified) {
|
||||||
|
next('/verify-email')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
+58
-4
@@ -1,6 +1,7 @@
|
|||||||
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router'
|
||||||
import { nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
import HomePage from '../views/HomePage.vue'
|
import HomePage from '../views/HomePage.vue'
|
||||||
|
import { requireAuth, requireGuest, requireVerifiedEmail } from './guards'
|
||||||
|
|
||||||
// Google Analytics gtag types
|
// Google Analytics gtag types
|
||||||
declare global {
|
declare global {
|
||||||
@@ -48,6 +49,58 @@ const router = createRouter({
|
|||||||
name: 'formation',
|
name: 'formation',
|
||||||
component: () => import('../views/FormationPage.vue')
|
component: () => import('../views/FormationPage.vue')
|
||||||
},
|
},
|
||||||
|
// Auth routes
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('../views/LoginView.vue'),
|
||||||
|
beforeEnter: requireGuest,
|
||||||
|
meta: {
|
||||||
|
title: 'Connexion - Plateforme de Formation',
|
||||||
|
description: 'Connectez-vous à votre compte pour accéder à vos formations en développement web.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/register',
|
||||||
|
name: 'register',
|
||||||
|
component: () => import('../views/RegisterView.vue'),
|
||||||
|
beforeEnter: requireGuest,
|
||||||
|
meta: {
|
||||||
|
title: 'Inscription - Plateforme de Formation',
|
||||||
|
description: 'Créez votre compte pour accéder à nos formations en développement web.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/forgot-password',
|
||||||
|
name: 'forgot-password',
|
||||||
|
component: () => import('../views/ForgotPasswordView.vue'),
|
||||||
|
beforeEnter: requireGuest,
|
||||||
|
meta: {
|
||||||
|
title: 'Mot de passe oublié - Plateforme de Formation',
|
||||||
|
description: 'Réinitialisez votre mot de passe pour accéder à votre compte.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/verify-email',
|
||||||
|
name: 'verify-email',
|
||||||
|
component: () => import('../views/VerifyEmailView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Vérification Email - Plateforme de Formation',
|
||||||
|
description: 'Vérifiez votre adresse email pour activer votre compte et accéder aux formations.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Protected routes
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
name: 'dashboard',
|
||||||
|
component: () => import('../views/DashboardView.vue'),
|
||||||
|
beforeEnter: requireVerifiedEmail,
|
||||||
|
meta: {
|
||||||
|
title: 'Tableau de bord - Plateforme de Formation',
|
||||||
|
description: 'Accédez à vos formations et suivez votre progression.',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
},
|
||||||
// TODO: page 404
|
// TODO: page 404
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
@@ -81,8 +134,11 @@ const router = createRouter({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// SEO Meta tags handler
|
// Global navigation guard
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
|
// Handle authentication
|
||||||
|
await requireAuth(to, from, next)
|
||||||
|
|
||||||
// Update document title
|
// Update document title
|
||||||
if (to.meta?.title) {
|
if (to.meta?.title) {
|
||||||
document.title = to.meta.title as string
|
document.title = to.meta.title as string
|
||||||
@@ -98,8 +154,6 @@ router.beforeEach((to, from, next) => {
|
|||||||
}
|
}
|
||||||
metaDescription.setAttribute('content', to.meta.description as string)
|
metaDescription.setAttribute('content', to.meta.description as string)
|
||||||
}
|
}
|
||||||
|
|
||||||
next()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Google Analytics page view tracking function
|
// Google Analytics page view tracking function
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import type {
|
||||||
|
LoginCredentials,
|
||||||
|
RegisterCredentials,
|
||||||
|
AuthResponse,
|
||||||
|
User,
|
||||||
|
ForgotPasswordRequest,
|
||||||
|
ResetPasswordRequest,
|
||||||
|
VerifyEmailRequest
|
||||||
|
} from '@/types/auth'
|
||||||
|
|
||||||
|
// Mock data for development
|
||||||
|
const mockUsers: User[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
plan: 'pro',
|
||||||
|
isEmailVerified: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockTokens: Record<string, string> = {
|
||||||
|
'test@example.com': 'mock-jwt-token-12345'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate API delay
|
||||||
|
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
class AuthService {
|
||||||
|
private baseURL = '/api/auth'
|
||||||
|
|
||||||
|
// Simulate API call
|
||||||
|
private async mockApiCall<T>(data: T, success = true, delayMs = 1000): Promise<T> {
|
||||||
|
await delay(delayMs)
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
throw new Error('API Error')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(credentials: LoginCredentials): Promise<AuthResponse> {
|
||||||
|
// Simulate validation
|
||||||
|
const user = mockUsers.find(u => u.email === credentials.email)
|
||||||
|
|
||||||
|
if (!user || credentials.password !== 'password') {
|
||||||
|
throw new Error('Email ou mot de passe incorrect')
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = mockTokens[credentials.email] || 'mock-jwt-token'
|
||||||
|
|
||||||
|
return this.mockApiCall({
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
refreshToken: 'mock-refresh-token'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(credentials: RegisterCredentials): Promise<AuthResponse> {
|
||||||
|
// Simulate validation
|
||||||
|
if (credentials.password !== credentials.confirmPassword) {
|
||||||
|
throw new Error('Les mots de passe ne correspondent pas')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentials.password.length < 6) {
|
||||||
|
throw new Error('Le mot de passe doit contenir au moins 6 caractères')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
if (mockUsers.find(u => u.email === credentials.email)) {
|
||||||
|
throw new Error('Un compte avec cet email existe déjà')
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUser: User = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
email: credentials.email,
|
||||||
|
firstName: credentials.firstName,
|
||||||
|
lastName: credentials.lastName,
|
||||||
|
plan: credentials.plan,
|
||||||
|
isEmailVerified: false,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = `mock-jwt-token-${Date.now()}`
|
||||||
|
mockTokens[credentials.email] = token
|
||||||
|
mockUsers.push(newUser)
|
||||||
|
|
||||||
|
return this.mockApiCall({
|
||||||
|
user: newUser,
|
||||||
|
token,
|
||||||
|
refreshToken: 'mock-refresh-token'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
return this.mockApiCall(undefined, true, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
async forgotPassword(request: ForgotPasswordRequest): Promise<void> {
|
||||||
|
// Simulate email validation
|
||||||
|
if (!request.email || !request.email.includes('@')) {
|
||||||
|
throw new Error('Email invalide')
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mockApiCall(undefined, true, 800)
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(request: ResetPasswordRequest): Promise<void> {
|
||||||
|
// Simulate validation
|
||||||
|
if (request.password !== request.confirmPassword) {
|
||||||
|
throw new Error('Les mots de passe ne correspondent pas')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.password.length < 6) {
|
||||||
|
throw new Error('Le mot de passe doit contenir au moins 6 caractères')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.token) {
|
||||||
|
throw new Error('Token invalide')
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mockApiCall(undefined, true, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyEmail(request: VerifyEmailRequest): Promise<void> {
|
||||||
|
if (!request.token) {
|
||||||
|
throw new Error('Token invalide')
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mockApiCall(undefined, true, 800)
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(): Promise<AuthResponse> {
|
||||||
|
// Simulate token refresh
|
||||||
|
const user = mockUsers[0] // Use first user for demo
|
||||||
|
const token = `mock-jwt-token-refreshed-${Date.now()}`
|
||||||
|
|
||||||
|
return this.mockApiCall({
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
refreshToken: 'mock-refresh-token-new'
|
||||||
|
}, true, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentUser(): Promise<User | null> {
|
||||||
|
// Simulate getting current user from token
|
||||||
|
return this.mockApiCall(mockUsers[0] || null, true, 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService = new AuthService()
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { authService } from '@/services/authService'
|
||||||
|
import type {
|
||||||
|
User,
|
||||||
|
LoginCredentials,
|
||||||
|
RegisterCredentials,
|
||||||
|
ForgotPasswordRequest,
|
||||||
|
ResetPasswordRequest,
|
||||||
|
VerifyEmailRequest,
|
||||||
|
AuthState
|
||||||
|
} from '@/types/auth'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
// State
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const token = ref<string | null>(localStorage.getItem('auth_token'))
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const isAuthenticated = computed(() => !!user.value && !!token.value)
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const setToken = (newToken: string | null) => {
|
||||||
|
token.value = newToken
|
||||||
|
if (newToken) {
|
||||||
|
localStorage.setItem('auth_token', newToken)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setUser = (newUser: User | null) => {
|
||||||
|
user.value = newUser
|
||||||
|
}
|
||||||
|
|
||||||
|
const setError = (newError: string | null) => {
|
||||||
|
error.value = newError
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearError = () => {
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (credentials: LoginCredentials) => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
clearError()
|
||||||
|
|
||||||
|
const response = await authService.login(credentials)
|
||||||
|
|
||||||
|
setUser(response.user)
|
||||||
|
setToken(response.token)
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Erreur de connexion'
|
||||||
|
setError(errorMessage)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const register = async (credentials: RegisterCredentials) => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
clearError()
|
||||||
|
|
||||||
|
const response = await authService.register(credentials)
|
||||||
|
|
||||||
|
setUser(response.user)
|
||||||
|
setToken(response.token)
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Erreur d\'inscription'
|
||||||
|
setError(errorMessage)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
await authService.logout()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Logout error:', err)
|
||||||
|
} finally {
|
||||||
|
setUser(null)
|
||||||
|
setToken(null)
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const forgotPassword = async (request: ForgotPasswordRequest) => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
clearError()
|
||||||
|
|
||||||
|
await authService.forgotPassword(request)
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Erreur lors de l\'envoi de l\'email'
|
||||||
|
setError(errorMessage)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetPassword = async (request: ResetPasswordRequest) => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
clearError()
|
||||||
|
|
||||||
|
await authService.resetPassword(request)
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Erreur lors de la réinitialisation du mot de passe'
|
||||||
|
setError(errorMessage)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyEmail = async (request: VerifyEmailRequest) => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
clearError()
|
||||||
|
|
||||||
|
await authService.verifyEmail(request)
|
||||||
|
|
||||||
|
// Update user verification status
|
||||||
|
if (user.value) {
|
||||||
|
user.value.isEmailVerified = true
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Erreur lors de la vérification de l\'email'
|
||||||
|
setError(errorMessage)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshToken = async () => {
|
||||||
|
try {
|
||||||
|
const response = await authService.refreshToken()
|
||||||
|
setUser(response.user)
|
||||||
|
setToken(response.token)
|
||||||
|
return response
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Token refresh error:', err)
|
||||||
|
// If refresh fails, logout user
|
||||||
|
await logout()
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentUser = async () => {
|
||||||
|
try {
|
||||||
|
if (!token.value) return null
|
||||||
|
|
||||||
|
const currentUser = await authService.getCurrentUser()
|
||||||
|
if (currentUser) {
|
||||||
|
setUser(currentUser)
|
||||||
|
}
|
||||||
|
return currentUser
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Get current user error:', err)
|
||||||
|
// If getting user fails, logout
|
||||||
|
await logout()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initializeAuth = async () => {
|
||||||
|
if (token.value) {
|
||||||
|
await getCurrentUser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
isAuthenticated,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
forgotPassword,
|
||||||
|
resetPassword,
|
||||||
|
verifyEmail,
|
||||||
|
refreshToken,
|
||||||
|
getCurrentUser,
|
||||||
|
initializeAuth,
|
||||||
|
setError,
|
||||||
|
clearError
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
plan?: string
|
||||||
|
isEmailVerified: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginCredentials {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterCredentials {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
confirmPassword: string
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
plan?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
user: User
|
||||||
|
token: string
|
||||||
|
refreshToken?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForgotPasswordRequest {
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResetPasswordRequest {
|
||||||
|
token: string
|
||||||
|
password: string
|
||||||
|
confirmPassword: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyEmailRequest {
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
user: User | null
|
||||||
|
token: string | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-page page-enter">
|
||||||
|
<div class="container">
|
||||||
|
<header class="dashboard-header">
|
||||||
|
<h1 class="dashboard-title">Tableau de bord</h1>
|
||||||
|
<div class="user-info">
|
||||||
|
<span>Bienvenue, {{ authStore.user?.firstName || authStore.user?.email }}</span>
|
||||||
|
<FormButton variant="outline" size="sm" @click="handleLogout">
|
||||||
|
Déconnexion
|
||||||
|
</FormButton>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="dashboard-content">
|
||||||
|
<div class="welcome-card">
|
||||||
|
<h2>Bienvenue sur votre plateforme de formation !</h2>
|
||||||
|
<p>
|
||||||
|
Vous êtes maintenant connecté avec le plan
|
||||||
|
<strong>{{ getPlanName(authStore.user?.plan) }}</strong>
|
||||||
|
</p>
|
||||||
|
<p v-if="authStore.user?.isEmailVerified" class="verified-badge">
|
||||||
|
✅ Email vérifié
|
||||||
|
</p>
|
||||||
|
<p v-else class="unverified-badge">
|
||||||
|
⚠️ Email non vérifié
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Cours disponibles</h3>
|
||||||
|
<div class="stat-value">12</div>
|
||||||
|
<p>Cours en développement web</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Progression</h3>
|
||||||
|
<div class="stat-value">0%</div>
|
||||||
|
<p>Commencez votre premier cours</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Certificats</h3>
|
||||||
|
<div class="stat-value">0</div>
|
||||||
|
<p>Certificats obtenus</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="quick-actions">
|
||||||
|
<h3>Actions rapides</h3>
|
||||||
|
<div class="actions-grid">
|
||||||
|
<button class="action-button">
|
||||||
|
<span>📚</span>
|
||||||
|
Parcourir les cours
|
||||||
|
</button>
|
||||||
|
<button class="action-button">
|
||||||
|
<span>🎯</span>
|
||||||
|
Voir mes objectifs
|
||||||
|
</button>
|
||||||
|
<button class="action-button">
|
||||||
|
<span>👥</span>
|
||||||
|
Communauté
|
||||||
|
</button>
|
||||||
|
<button class="action-button">
|
||||||
|
<span>⚙️</span>
|
||||||
|
Paramètres
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import FormButton from '@/components/ui/FormButton.vue'
|
||||||
|
import { useSeo } from '@/composables/useSeo'
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
useSeo({
|
||||||
|
title: 'Tableau de bord - Plateforme de Formation',
|
||||||
|
description: 'Accédez à vos formations et suivez votre progression en développement web.',
|
||||||
|
keywords: 'tableau de bord, formation, progression, cours développement web'
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
// Get plan name
|
||||||
|
const getPlanName = (planId?: string) => {
|
||||||
|
const plans: Record<string, string> = {
|
||||||
|
starter: 'Starter',
|
||||||
|
pro: 'Pro',
|
||||||
|
expert: 'Expert'
|
||||||
|
}
|
||||||
|
return plans[planId || ''] || 'Inconnu'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle logout
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await authStore.logout()
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-card {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-card h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-card p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-badge {
|
||||||
|
color: #10b981;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unverified-badge {
|
||||||
|
color: #f59e0b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button span {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
|
||||||
|
.stat-card,
|
||||||
|
.action-button {
|
||||||
|
background: var(--bg-secondary-dark, #2a2a2a);
|
||||||
|
border-color: var(--border-color-dark, #404040);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.container {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<div class="forgot-password-page page-enter">
|
||||||
|
<div class="auth-container">
|
||||||
|
<AuthCard
|
||||||
|
title="Mot de passe oublié"
|
||||||
|
subtitle="Entrez votre email pour recevoir un lien de réinitialisation"
|
||||||
|
>
|
||||||
|
<form @submit.prevent="handleForgotPassword" class="auth-form">
|
||||||
|
<FormInput
|
||||||
|
v-model="form.email"
|
||||||
|
type="email"
|
||||||
|
label="Email"
|
||||||
|
placeholder="votre@email.com"
|
||||||
|
required
|
||||||
|
:error="errors.email"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<FormButton
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
:loading="authStore.isLoading"
|
||||||
|
class="submit-button"
|
||||||
|
>
|
||||||
|
Envoyer le lien
|
||||||
|
</FormButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="auth-links">
|
||||||
|
<router-link to="/login" class="auth-link">
|
||||||
|
Retour à la connexion
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</AuthCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import AuthCard from '@/components/ui/AuthCard.vue'
|
||||||
|
import FormInput from '@/components/ui/FormInput.vue'
|
||||||
|
import FormButton from '@/components/ui/FormButton.vue'
|
||||||
|
import { useSeo } from '@/composables/useSeo'
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
useSeo({
|
||||||
|
title: 'Mot de passe oublié - Plateforme de Formation',
|
||||||
|
description: 'Réinitialisez votre mot de passe pour accéder à votre compte.',
|
||||||
|
keywords: 'mot de passe oublié, réinitialisation, authentification'
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
const form = reactive({
|
||||||
|
email: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Form errors
|
||||||
|
const errors = reactive({
|
||||||
|
email: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
const validateForm = () => {
|
||||||
|
errors.email = ''
|
||||||
|
|
||||||
|
if (!form.email) {
|
||||||
|
errors.email = 'L\'email est requis'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.email.includes('@')) {
|
||||||
|
errors.email = 'L\'email n\'est pas valide'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle forgot password
|
||||||
|
const handleForgotPassword = async () => {
|
||||||
|
if (!validateForm()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authStore.forgotPassword({
|
||||||
|
email: form.email
|
||||||
|
})
|
||||||
|
|
||||||
|
// Show success message or redirect
|
||||||
|
alert('Un email de réinitialisation a été envoyé à votre adresse email.')
|
||||||
|
router.push('/login')
|
||||||
|
} catch (error) {
|
||||||
|
// Error is handled by the store
|
||||||
|
console.error('Forgot password error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.forgot-password-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link:hover {
|
||||||
|
color: var(--primary-color-dark);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.forgot-password-page {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -56,9 +56,10 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<button :class="['cta-button', plan.popular ? 'primary' : 'secondary']">
|
<router-link :to="{ name: 'register', query: { plan: plan.id } }"
|
||||||
|
:class="['cta-button', plan.popular ? 'primary' : 'secondary']">
|
||||||
{{ $t('pricing.startTrial') }}
|
{{ $t('pricing.startTrial') }}
|
||||||
</button>
|
</router-link>
|
||||||
<p class="trial-info">{{ $t('pricing.trialInfo') }}</p>
|
<p class="trial-info">{{ $t('pricing.trialInfo') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-page page-enter">
|
||||||
|
<div class="auth-container">
|
||||||
|
<AuthCard title="Connexion" subtitle="Connectez-vous à votre compte pour accéder à vos formations">
|
||||||
|
<form @submit.prevent="handleLogin" class="auth-form">
|
||||||
|
<FormInput v-model="form.email" type="email" label="Email" placeholder="votre@email.com" required
|
||||||
|
:error="errors.email" />
|
||||||
|
|
||||||
|
<FormInput v-model="form.password" type="password" label="Mot de passe"
|
||||||
|
placeholder="Votre mot de passe" required :error="errors.password" />
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<FormButton type="submit" variant="primary" size="lg" :loading="authStore.isLoading"
|
||||||
|
class="submit-button">
|
||||||
|
Se connecter
|
||||||
|
</FormButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="auth-links">
|
||||||
|
<router-link to="/forgot-password" class="auth-link">
|
||||||
|
Mot de passe oublié ?
|
||||||
|
</router-link>
|
||||||
|
<div class="auth-divider">
|
||||||
|
<span>ou</span>
|
||||||
|
</div>
|
||||||
|
<router-link to="/register" class="auth-link">
|
||||||
|
Créer un compte
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</AuthCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import AuthCard from '@/components/ui/AuthCard.vue'
|
||||||
|
import FormInput from '@/components/ui/FormInput.vue'
|
||||||
|
import FormButton from '@/components/ui/FormButton.vue'
|
||||||
|
import { useSeo } from '@/composables/useSeo'
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
useSeo({
|
||||||
|
title: 'Connexion - Plateforme de Formation',
|
||||||
|
description: 'Connectez-vous à votre compte pour accéder à vos formations en développement web.',
|
||||||
|
keywords: 'connexion, authentification, formation développement web'
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
const form = reactive({
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Form errors
|
||||||
|
const errors = reactive({
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
const validateForm = () => {
|
||||||
|
errors.email = ''
|
||||||
|
errors.password = ''
|
||||||
|
|
||||||
|
if (!form.email) {
|
||||||
|
errors.email = 'L\'email est requis'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.email.includes('@')) {
|
||||||
|
errors.email = 'L\'email n\'est pas valide'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.password) {
|
||||||
|
errors.password = 'Le mot de passe est requis'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.password.length < 6) {
|
||||||
|
errors.password = 'Le mot de passe doit contenir au moins 6 caractères'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle login
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!validateForm()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authStore.login({
|
||||||
|
email: form.email,
|
||||||
|
password: form.password
|
||||||
|
})
|
||||||
|
|
||||||
|
// Redirect to intended page or dashboard
|
||||||
|
const redirectPath = route.query.redirect as string || '/dashboard'
|
||||||
|
router.push(redirectPath)
|
||||||
|
} catch (error) {
|
||||||
|
// Error is handled by the store
|
||||||
|
console.error('Login error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link:hover {
|
||||||
|
color: var(--primary-color-dark);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-divider {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-divider::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-divider span {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 0 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.auth-divider span {
|
||||||
|
background: var(--bg-primary-dark, #1a1a1a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-page {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
<template>
|
||||||
|
<div class="register-page page-enter">
|
||||||
|
<div class="auth-container">
|
||||||
|
<AuthCard title="Créer un compte" subtitle="Rejoignez notre plateforme de formation en développement web">
|
||||||
|
<form @submit.prevent="handleRegister" class="auth-form">
|
||||||
|
<div class="name-fields">
|
||||||
|
<FormInput v-model="form.firstName" type="text" label="Prénom" placeholder="Votre prénom"
|
||||||
|
:error="errors.firstName" />
|
||||||
|
|
||||||
|
<FormInput v-model="form.lastName" type="text" label="Nom" placeholder="Votre nom"
|
||||||
|
:error="errors.lastName" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormInput v-model="form.email" type="email" label="Email" placeholder="votre@email.com" required
|
||||||
|
:error="errors.email" />
|
||||||
|
|
||||||
|
<FormInput v-model="form.password" type="password" label="Mot de passe" placeholder="Votre mot de passe"
|
||||||
|
required :error="errors.password" hint="Au moins 6 caractères" />
|
||||||
|
|
||||||
|
<FormInput v-model="form.confirmPassword" type="password" label="Confirmer le mot de passe"
|
||||||
|
placeholder="Confirmez votre mot de passe" required :error="errors.confirmPassword" />
|
||||||
|
|
||||||
|
<div class="plan-selection">
|
||||||
|
<label class="plan-label">Plan d'abonnement</label>
|
||||||
|
<div class="plan-options">
|
||||||
|
<label v-for="plan in plans" :key="plan.id" :class="['plan-option', { selected: form.plan === plan.id }]">
|
||||||
|
<input type="radio" :value="plan.id" v-model="form.plan" class="plan-radio" />
|
||||||
|
<div class="plan-info">
|
||||||
|
<div class="plan-name">{{ plan.name }}</div>
|
||||||
|
<div class="plan-price">{{ plan.price }}€/mois</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="errors.plan" class="error-message">{{ errors.plan }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<FormButton type="submit" variant="primary" size="lg" :loading="authStore.isLoading" class="submit-button">
|
||||||
|
Créer mon compte
|
||||||
|
</FormButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="auth-links">
|
||||||
|
<p class="terms-text">
|
||||||
|
En créant un compte, vous acceptez nos
|
||||||
|
<a href="/terms" class="auth-link">conditions d'utilisation</a> et notre
|
||||||
|
<a href="/privacy" class="auth-link">politique de confidentialité</a>
|
||||||
|
</p>
|
||||||
|
<div class="auth-divider">
|
||||||
|
<span>ou</span>
|
||||||
|
</div>
|
||||||
|
<router-link to="/login" class="auth-link">
|
||||||
|
Déjà un compte ? Se connecter
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</AuthCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import AuthCard from '@/components/ui/AuthCard.vue'
|
||||||
|
import FormInput from '@/components/ui/FormInput.vue'
|
||||||
|
import FormButton from '@/components/ui/FormButton.vue'
|
||||||
|
import { useSeo } from '@/composables/useSeo'
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
useSeo({
|
||||||
|
title: 'Inscription - Plateforme de Formation',
|
||||||
|
description: 'Créez votre compte pour accéder à nos formations en développement web.',
|
||||||
|
keywords: 'inscription, créer compte, formation développement web'
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
// Available plans
|
||||||
|
const plans = [
|
||||||
|
{ id: 'starter', name: 'Starter', price: 29 },
|
||||||
|
{ id: 'pro', name: 'Pro', price: 59 },
|
||||||
|
{ id: 'expert', name: 'Expert', price: 99 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
const form = reactive({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
plan: 'pro'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set plan from URL query if provided
|
||||||
|
onMounted(() => {
|
||||||
|
if (route.query.plan && typeof route.query.plan === 'string') {
|
||||||
|
const planId = route.query.plan
|
||||||
|
if (plans.some(plan => plan.id === planId)) {
|
||||||
|
form.plan = planId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Form errors
|
||||||
|
const errors = reactive({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
plan: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
const validateForm = () => {
|
||||||
|
errors.firstName = ''
|
||||||
|
errors.lastName = ''
|
||||||
|
errors.email = ''
|
||||||
|
errors.password = ''
|
||||||
|
errors.confirmPassword = ''
|
||||||
|
errors.plan = ''
|
||||||
|
|
||||||
|
if (!form.firstName.trim()) {
|
||||||
|
errors.firstName = 'Le prénom est requis'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.lastName.trim()) {
|
||||||
|
errors.lastName = 'Le nom est requis'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.email) {
|
||||||
|
errors.email = 'L\'email est requis'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.email.includes('@')) {
|
||||||
|
errors.email = 'L\'email n\'est pas valide'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.password) {
|
||||||
|
errors.password = 'Le mot de passe est requis'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.password.length < 6) {
|
||||||
|
errors.password = 'Le mot de passe doit contenir au moins 6 caractères'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.confirmPassword) {
|
||||||
|
errors.confirmPassword = 'La confirmation du mot de passe est requise'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.password !== form.confirmPassword) {
|
||||||
|
errors.confirmPassword = 'Les mots de passe ne correspondent pas'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.plan) {
|
||||||
|
errors.plan = 'Veuillez sélectionner un plan'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle register
|
||||||
|
const handleRegister = async () => {
|
||||||
|
if (!validateForm()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authStore.register({
|
||||||
|
email: form.email,
|
||||||
|
password: form.password,
|
||||||
|
confirmPassword: form.confirmPassword,
|
||||||
|
firstName: form.firstName,
|
||||||
|
lastName: form.lastName,
|
||||||
|
plan: form.plan
|
||||||
|
})
|
||||||
|
|
||||||
|
// Redirect to email verification page
|
||||||
|
router.push('/verify-email')
|
||||||
|
} catch (error) {
|
||||||
|
// Error is handled by the store
|
||||||
|
console.error('Register error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.register-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-selection {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-option:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-option.selected {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: rgba(var(--primary-color-rgb), 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-radio {
|
||||||
|
margin-right: 1rem;
|
||||||
|
accent-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-price {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link:hover {
|
||||||
|
color: var(--primary-color-dark);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-divider {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-divider::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-divider span {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 0 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.auth-divider span {
|
||||||
|
background: var(--bg-primary-dark, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-option {
|
||||||
|
background: var(--bg-secondary-dark, #2a2a2a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.register-page {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<template>
|
||||||
|
<div class="verify-email-page page-enter">
|
||||||
|
<div class="auth-container">
|
||||||
|
<AuthCard title="Vérification de l'email" subtitle="Vérifiez votre adresse email pour activer votre compte">
|
||||||
|
<div class="verify-content">
|
||||||
|
<div class="email-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M4 4H20C21.1 4 22 4.9 22 6V18C22 19.1 21.1 20 20 20H4C2.9 20 2 19.1 2 18V6C2 4.9 2.9 4 4 4Z"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<polyline points="22,6 12,13 2,6" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="verify-message">
|
||||||
|
<h3>Vérifiez votre boîte mail</h3>
|
||||||
|
<p>
|
||||||
|
Nous avons envoyé un email de vérification à
|
||||||
|
<strong>{{ userEmail }}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Cliquez sur le lien dans l'email pour activer votre compte et commencer
|
||||||
|
votre formation en développement web.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="verify-actions">
|
||||||
|
<FormButton variant="outline" size="lg" :loading="authStore.isLoading" @click="resendEmail">
|
||||||
|
Renvoyer l'email
|
||||||
|
</FormButton>
|
||||||
|
|
||||||
|
<FormButton variant="primary" size="lg" @click="checkVerification">
|
||||||
|
J'ai vérifié mon email
|
||||||
|
</FormButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="verify-tips">
|
||||||
|
<h4>Conseils :</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Vérifiez votre dossier spam si vous ne trouvez pas l'email</li>
|
||||||
|
<li>Assurez-vous d'avoir saisi la bonne adresse email</li>
|
||||||
|
<li>L'email peut prendre quelques minutes à arriver</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="auth-links">
|
||||||
|
<router-link to="/login" class="auth-link">
|
||||||
|
Retour à la connexion
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</AuthCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import AuthCard from '@/components/ui/AuthCard.vue'
|
||||||
|
import FormButton from '@/components/ui/FormButton.vue'
|
||||||
|
import { useSeo } from '@/composables/useSeo'
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
useSeo({
|
||||||
|
title: 'Vérification Email - Plateforme de Formation',
|
||||||
|
description: 'Vérifiez votre adresse email pour activer votre compte et accéder aux formations.',
|
||||||
|
keywords: 'vérification email, activation compte, formation développement web'
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const userEmail = ref('')
|
||||||
|
|
||||||
|
// Get user email from store or route
|
||||||
|
onMounted(() => {
|
||||||
|
if (authStore.user?.email) {
|
||||||
|
userEmail.value = authStore.user.email
|
||||||
|
} else if (route.query.email) {
|
||||||
|
userEmail.value = route.query.email as string
|
||||||
|
} else {
|
||||||
|
// Redirect to login if no email found
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Resend verification email
|
||||||
|
const resendEmail = async () => {
|
||||||
|
try {
|
||||||
|
await authStore.verifyEmail({ token: 'mock-token' })
|
||||||
|
alert('Email de vérification renvoyé avec succès !')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Resend email error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email is verified
|
||||||
|
const checkVerification = async () => {
|
||||||
|
try {
|
||||||
|
// In a real app, this would check the verification status
|
||||||
|
// For now, we'll simulate success
|
||||||
|
if (authStore.user) {
|
||||||
|
authStore.user.isEmailVerified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('Email vérifié avec succès !')
|
||||||
|
router.push('/dashboard')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Verification check error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.verify-email-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-content {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-icon {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-icon svg {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-message {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-message h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-message p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-message strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-tips {
|
||||||
|
text-align: left;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-tips h4 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-tips ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-tips li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link:hover {
|
||||||
|
color: var(--primary-color-dark);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.verify-tips {
|
||||||
|
background: var(--bg-secondary-dark, #2a2a2a);
|
||||||
|
border-color: var(--border-color-dark, #404040);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.verify-email-page {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-actions {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-tips {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+16
-6
@@ -2,14 +2,23 @@ import { fileURLToPath, URL } from 'node:url'
|
|||||||
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig(async ({ command }) => {
|
||||||
plugins: [
|
// `vite-plugin-vue-devtools` (via @vue/devtools-kit) can access `localStorage`
|
||||||
vue(),
|
// at import time, which crashes when Vite loads this config in Node.
|
||||||
vueDevTools(),
|
// Load it lazily and only for the dev server.
|
||||||
],
|
const plugins = [vue()]
|
||||||
|
|
||||||
|
// Opt-in only: enable by running `VITE_VUE_DEVTOOLS=true npm run dev`
|
||||||
|
// This avoids Node-side crashes when loading the Vite config.
|
||||||
|
if (command === 'serve' && process.env.VITE_VUE_DEVTOOLS === 'true') {
|
||||||
|
const { default: vueDevTools } = await import('vite-plugin-vue-devtools')
|
||||||
|
plugins.push(vueDevTools())
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins,
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
@@ -51,4 +60,5 @@ export default defineConfig({
|
|||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['vue', 'vue-router']
|
include: ['vue', 'vue-router']
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user