feat(front+back): pwa added, register parkour update with it, and jeux added in coming soon
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 20s

This commit is contained in:
EvanChal
2026-01-25 19:28:21 +01:00
parent 0020c13bfd
commit 5bbe05000e
27 changed files with 14084 additions and 222 deletions

View File

@@ -0,0 +1,152 @@
<template>
<div
v-if="showInstallPrompt"
class="fixed bottom-4 right-4 z-50 max-w-sm bg-white rounded-lg shadow-lg border border-gray-200 p-4 animate-slide-up"
>
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<div class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
<svg
class="w-6 h-6 text-indigo-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
</div>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-gray-900">
Installer LeDiscord
</h3>
<p class="mt-1 text-sm text-gray-500">
Installez l'application pour un accès rapide et une meilleure expérience.
</p>
<div class="mt-3 flex space-x-2">
<button
@click="installApp"
class="flex-1 bg-indigo-600 text-white text-sm font-medium py-2 px-4 rounded-md hover:bg-indigo-700 transition-colors"
>
Installer
</button>
<button
@click="dismissPrompt"
class="flex-1 bg-gray-100 text-gray-700 text-sm font-medium py-2 px-4 rounded-md hover:bg-gray-200 transition-colors"
>
Plus tard
</button>
</div>
</div>
<button
@click="dismissPrompt"
class="flex-shrink-0 text-gray-400 hover:text-gray-500"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const showInstallPrompt = ref(false)
const deferredPrompt = ref(null)
onMounted(() => {
// Vérifier si l'app est déjà installée
if (window.matchMedia('(display-mode: standalone)').matches) {
return
}
// Vérifier si le prompt a été rejeté récemment
const dismissed = localStorage.getItem('pwa-install-dismissed')
if (dismissed) {
const dismissedTime = parseInt(dismissed, 10)
const daysSinceDismissed = (Date.now() - dismissedTime) / (1000 * 60 * 60 * 24)
// Réafficher après 7 jours
if (daysSinceDismissed < 7) {
return
}
}
// Écouter l'événement beforeinstallprompt
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
})
onBeforeUnmount(() => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
})
function handleBeforeInstallPrompt(e) {
// Empêcher le prompt par défaut
e.preventDefault()
// Stocker l'événement pour l'utiliser plus tard
deferredPrompt.value = e
// Afficher notre prompt personnalisé
showInstallPrompt.value = true
}
async function installApp() {
if (!deferredPrompt.value) {
return
}
try {
// Afficher le prompt d'installation
deferredPrompt.value.prompt()
// Attendre la réponse de l'utilisateur
const { outcome } = await deferredPrompt.value.userChoice
if (outcome === 'accepted') {
console.log('✅ PWA installée avec succès')
} else {
console.log('❌ Installation PWA annulée')
}
// Réinitialiser
deferredPrompt.value = null
showInstallPrompt.value = false
} catch (error) {
console.error('Erreur lors de l\'installation:', error)
}
}
function dismissPrompt() {
showInstallPrompt.value = false
// Enregistrer le rejet avec timestamp
localStorage.setItem('pwa-install-dismissed', Date.now().toString())
}
</script>
<style scoped>
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
</style>

View File

@@ -1,20 +1,20 @@
<template>
<div class="min-h-screen bg-gradient-discord flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="text-center mb-4">
<h1 class="text-4xl font-bold bg-gradient-primary bg-clip-text text-transparent mb-0">LeDiscord</h1>
<p class="text-secondary-600">Notre espace privé</p>
<div class="min-h-screen bg-gradient-discord flex items-center justify-center p-3 sm:p-4 md:p-6">
<div class="w-full max-w-md lg:max-w-lg flex flex-col">
<div class="text-center mb-3 sm:mb-4">
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-primary bg-clip-text text-transparent mb-0">LeDiscord</h1>
<p class="text-xs sm:text-sm md:text-base text-secondary-600 mt-1">Notre espace privé</p>
</div>
<div class="bg-white rounded-2xl shadow-xl p-8">
<div class="bg-white rounded-xl sm:rounded-2xl shadow-xl p-4 sm:p-6 md:p-8 flex-1 flex flex-col">
<slot />
</div>
<div class="text-center mt-4">
<div class="text-center mt-3 sm:mt-4">
<img
src="/logo_lediscord.png"
alt="LeDiscord Logo"
class="mx-auto h-48 w-auto mb-0 drop-shadow-lg"
class="mx-auto h-24 sm:h-32 md:h-40 w-auto drop-shadow-lg"
>
</div>
</div>

View File

@@ -21,11 +21,21 @@
v-for="item in navigation"
:key="item.name"
:to="item.to"
class="inline-flex items-center px-1 pt-1 text-sm font-medium text-secondary-600 hover:text-primary-600 border-b-2 border-transparent hover:border-primary-600 transition-colors"
active-class="!text-primary-600 !border-primary-600"
class="inline-flex items-center px-1 pt-1 text-sm font-medium border-b-2 border-transparent transition-colors relative"
:class="item.comingSoon
? 'text-gray-400 cursor-not-allowed opacity-60 !border-transparent'
: 'text-secondary-600 hover:text-primary-600 hover:border-primary-600'"
:active-class="item.comingSoon ? '' : '!text-primary-600 !border-primary-600'"
@click.prevent="item.comingSoon ? null : null"
>
<component :is="item.icon" class="w-4 h-4 mr-2" />
{{ item.name }}
<span
v-if="item.comingSoon"
class="ml-1.5 px-1 py-0.5 text-[9px] font-medium text-white bg-purple-600 rounded"
>
Soon
</span>
</router-link>
</div>
</div>
@@ -39,8 +49,10 @@
<Bell class="w-5 h-5" />
<span
v-if="unreadNotifications > 0"
class="absolute top-0 right-0 block h-2 w-2 rounded-full bg-red-500"
/>
class="absolute -top-1 -right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-bold text-white bg-red-500 rounded-full"
>
{{ unreadNotifications > 99 ? '99+' : unreadNotifications }}
</span>
</button>
<!-- User Menu -->
@@ -112,6 +124,25 @@
Administration
</router-link>
<hr class="my-1">
<!-- Installer l'app -->
<button
@click="handleInstallApp"
:disabled="isPWAInstalled || !canInstall"
class="block w-full text-left px-4 py-2 text-sm transition-colors"
:class="isPWAInstalled || !canInstall
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-700 hover:bg-gray-100'"
>
<div class="flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
{{ isPWAInstalled ? 'Application installée' : 'Installer l\'app' }}
</div>
</button>
<hr class="my-1">
<button
@click="logout"
@@ -145,11 +176,21 @@
v-for="item in navigation"
:key="item.name"
:to="item.to"
class="flex items-center px-4 py-2 text-base font-medium text-gray-600 hover:text-primary-600 hover:bg-gray-50"
class="flex items-center px-4 py-2 text-base font-medium hover:bg-gray-50 relative"
:class="item.comingSoon
? 'text-gray-400 cursor-not-allowed opacity-60'
: 'text-gray-600 hover:text-primary-600'"
active-class="!text-primary-600 bg-primary-50"
@click.prevent="item.comingSoon ? null : null"
>
<component :is="item.icon" class="w-5 h-5 mr-3" />
{{ item.name }}
<span>{{ item.name }}</span>
<span
v-if="item.comingSoon"
class="ml-auto px-1 py-0.5 text-[9px] font-medium text-white bg-purple-600 rounded"
>
Soon
</span>
</router-link>
</div>
</div>
@@ -226,7 +267,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
import { format } from 'date-fns'
@@ -242,7 +283,8 @@ import {
User,
ChevronDown,
X,
Menu
Menu,
Dice6
} from 'lucide-vue-next'
import UserAvatar from '@/components/UserAvatar.vue'
import TicketFloatingButton from '@/components/TicketFloatingButton.vue'
@@ -255,12 +297,16 @@ const navigation = [
{ name: 'Événements', to: '/events', icon: Calendar },
{ name: 'Albums', to: '/albums', icon: Image },
{ name: 'Vlogs', to: '/vlogs', icon: Film },
{ name: 'Publications', to: '/posts', icon: MessageSquare }
{ name: 'Publications', to: '/posts', icon: MessageSquare },
{ name: 'Jeux', to: '#', icon: Dice6, comingSoon: true }
]
const showUserMenu = ref(false)
const showNotifications = ref(false)
const isMobileMenuOpen = ref(false)
const deferredPrompt = ref(null)
const isPWAInstalled = ref(false)
const canInstall = ref(false)
const user = computed(() => authStore.user)
const notifications = computed(() => authStore.notifications)
@@ -276,7 +322,8 @@ async function logout() {
}
async function fetchNotifications() {
await authStore.fetchNotifications()
const result = await authStore.fetchNotifications()
// Les notifications sont maintenant mises à jour automatiquement via le polling
}
async function markAllRead() {
@@ -295,9 +342,82 @@ async function handleNotificationClick(notification) {
showNotifications.value = false
}
// PWA Installation logic
function checkPWAInstalled() {
// Vérifier si l'app est déjà installée (mode standalone)
isPWAInstalled.value = window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true ||
document.referrer.includes('android-app://')
}
function handleBeforeInstallPrompt(e) {
// Empêcher le prompt par défaut
e.preventDefault()
// Stocker l'événement pour l'utiliser plus tard
deferredPrompt.value = e
canInstall.value = true
}
async function handleInstallApp() {
if (!deferredPrompt.value || isPWAInstalled.value) {
return
}
try {
showUserMenu.value = false
// Afficher le prompt d'installation
deferredPrompt.value.prompt()
// Attendre la réponse de l'utilisateur
const { outcome } = await deferredPrompt.value.userChoice
if (outcome === 'accepted') {
console.log('✅ PWA installée avec succès')
isPWAInstalled.value = true
} else {
console.log('❌ Installation PWA annulée')
}
// Réinitialiser
deferredPrompt.value = null
canInstall.value = false
} catch (error) {
console.error('Erreur lors de l\'installation:', error)
}
}
onMounted(async () => {
await authStore.fetchCurrentUser()
await fetchNotifications()
await authStore.fetchUnreadCount()
if (authStore.isAuthenticated) {
await fetchNotifications()
await authStore.fetchUnreadCount()
// Démarrer le polling des notifications
const notificationService = (await import('@/services/notificationService')).default
notificationService.startPolling()
notificationService.setupServiceWorkerListener()
// Demander la permission pour les notifications push
await notificationService.requestNotificationPermission()
}
// Vérifier si PWA est installée
checkPWAInstalled()
// Écouter l'événement beforeinstallprompt
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
// Écouter les changements de display mode
window.matchMedia('(display-mode: standalone)').addEventListener('change', checkPWAInstalled)
})
onBeforeUnmount(async () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
// Arrêter le polling des notifications
const notificationService = (await import('@/services/notificationService')).default
notificationService.stopPolling()
})
</script>

View File

@@ -0,0 +1,159 @@
import { useAuthStore } from '@/stores/auth'
class NotificationService {
constructor() {
this.pollingInterval = null
this.pollInterval = 30000 // 30 secondes
this.isPolling = false
}
startPolling() {
if (this.isPolling) return
const authStore = useAuthStore()
if (!authStore.isAuthenticated) return
this.isPolling = true
// Récupérer immédiatement
this.fetchNotifications()
// Puis toutes les 30 secondes
this.pollingInterval = setInterval(() => {
this.fetchNotifications()
}, this.pollInterval)
}
stopPolling() {
if (this.pollingInterval) {
clearInterval(this.pollingInterval)
this.pollingInterval = null
}
this.isPolling = false
}
async fetchNotifications() {
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
this.stopPolling()
return
}
try {
const result = await authStore.fetchNotifications()
// Si de nouvelles notifications non lues ont été détectées
if (result && result.hasNewNotifications && result.newCount > result.previousCount) {
// Trouver les nouvelles notifications non lues (les plus récentes en premier)
const newUnreadNotifications = authStore.notifications
.filter(n => !n.is_read)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, result.newCount - result.previousCount)
if (newUnreadNotifications.length > 0) {
// Afficher une notification push pour la plus récente
const latestNotification = newUnreadNotifications[0]
await this.showPushNotification(latestNotification.title, {
body: latestNotification.message,
link: latestNotification.link || '/',
data: { notificationId: latestNotification.id }
})
}
}
} catch (error) {
console.error('Error polling notifications:', error)
}
}
showNotificationBadge() {
// Mettre à jour le badge du titre de la page
if ('Notification' in window && Notification.permission === 'granted') {
// La notification push sera gérée par le service worker
return
}
}
// Gestion des notifications push PWA
async requestNotificationPermission() {
if (!('Notification' in window)) {
console.log('Ce navigateur ne supporte pas les notifications')
return false
}
if (Notification.permission === 'granted') {
return true
}
if (Notification.permission !== 'denied') {
const permission = await Notification.requestPermission()
return permission === 'granted'
}
return false
}
async showPushNotification(title, options = {}) {
if (!('Notification' in window)) return
const hasPermission = await this.requestNotificationPermission()
if (!hasPermission) return
// Si on est dans un service worker, utiliser la notification API du SW
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.ready
await registration.showNotification(title, {
icon: '/icon-192x192.png',
badge: '/icon-96x96.png',
tag: 'lediscord-notification',
requireInteraction: false,
vibrate: [200, 100, 200],
...options
})
return
} catch (error) {
console.error('Error showing notification via service worker:', error)
}
}
// Fallback: notification native du navigateur
const notification = new Notification(title, {
icon: '/icon-192x192.png',
badge: '/icon-96x96.png',
tag: 'lediscord-notification',
requireInteraction: false,
...options
})
notification.onclick = () => {
window.focus()
notification.close()
if (options.link) {
window.location.href = options.link
}
}
// Fermer automatiquement après 5 secondes
setTimeout(() => {
notification.close()
}, 5000)
return notification
}
// Écouter les messages du service worker pour les notifications push
setupServiceWorkerListener() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'NOTIFICATION') {
const { title, options } = event.data
this.showPushNotification(title, options)
}
})
}
}
}
export default new NotificationService()

View File

@@ -68,8 +68,15 @@ export const useAuthStore = defineStore('auth', () => {
async function logout() {
token.value = null
user.value = null
notifications.value = []
unreadCount.value = 0
localStorage.removeItem('token')
delete axios.defaults.headers.common['Authorization']
// Arrêter le polling des notifications
const notificationService = (await import('@/services/notificationService')).default
notificationService.stopPolling()
router.push('/login')
toast.info('Déconnexion réussie')
}
@@ -130,10 +137,36 @@ export const useAuthStore = defineStore('auth', () => {
try {
const response = await axios.get('/api/notifications?limit=50')
notifications.value = response.data
unreadCount.value = notifications.value.filter(n => !n.is_read).length
const newNotifications = response.data
// Détecter les nouvelles notifications non lues
const previousIds = new Set(notifications.value.map(n => n.id))
const previousUnreadIds = new Set(
notifications.value.filter(n => !n.is_read).map(n => n.id)
)
// Nouvelles notifications = celles qui n'existaient pas avant
const hasNewNotifications = newNotifications.some(n => !previousIds.has(n.id))
// Nouvelles notifications non lues = nouvelles ET non lues
const newUnreadNotifications = newNotifications.filter(
n => !previousIds.has(n.id) && !n.is_read
)
notifications.value = newNotifications
const newUnreadCount = notifications.value.filter(n => !n.is_read).length
const previousUnreadCount = unreadCount.value
unreadCount.value = newUnreadCount
// Retourner si de nouvelles notifications non lues ont été détectées
return {
hasNewNotifications: newUnreadNotifications.length > 0,
newCount: newUnreadCount,
previousCount: previousUnreadCount
}
} catch (error) {
console.error('Error fetching notifications:', error)
return { hasNewNotifications: false, newCount: unreadCount.value, previousCount: unreadCount.value }
}
}

View File

@@ -1,144 +1,333 @@
<template>
<div class="space-y-6">
<div class="w-full max-w-2xl mx-auto space-y-4 sm:space-y-6 px-4 sm:px-6 pb-4 sm:pb-6">
<!-- Header -->
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900">Créer un compte</h2>
<p class="mt-2 text-sm text-gray-500">Rejoignez notre communauté en quelques étapes</p>
<div class="text-center pt-2 sm:pt-0">
<h2 class="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900">Créer un compte</h2>
<p class="mt-2 text-xs sm:text-sm text-gray-500">Remplissez ça et après promis je vous embête plus</p>
</div>
<!-- Progress Bar -->
<div class="pt-4">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-700">Étape {{ currentStep }} sur {{ totalSteps }}</span>
<span class="text-sm font-medium text-gray-500">{{ Math.round((currentStep / totalSteps) * 100) }}%</span>
<div class="pt-2 sm:pt-4">
<div class="flex items-center justify-between mb-2">
<span class="text-xs sm:text-sm font-medium text-gray-700">Étape {{ currentStep }} sur {{ totalSteps }}</span>
<span class="text-xs sm:text-sm font-medium text-gray-500">{{ Math.round((currentStep / totalSteps) * 100) }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div class="w-full bg-gray-200 rounded-full h-2 sm:h-2.5">
<div
class="bg-primary-600 h-1.5 rounded-full transition-all duration-500 ease-in-out"
class="bg-primary-600 h-2 sm:h-2.5 rounded-full transition-all duration-500 ease-in-out"
:style="{ width: `${(currentStep / totalSteps) * 100}%` }"
></div>
</div>
</div>
<!-- Step Content -->
<div class="min-h-[350px] flex flex-col justify-center">
<div class="flex flex-col justify-center py-4 sm:py-6">
<StepTransition :step="currentStep">
<!-- Step 1: Welcome -->
<div v-if="currentStep === 1" class="text-center">
<h3 class="text-2xl font-bold text-gray-900 mb-4">Bienvenue sur LeDiscord !</h3>
<p class="text-gray-600 max-w-sm mx-auto">
Nous sommes ravis de vous accueillir. Préparez-vous à rejoindre une communauté passionnante.
<div v-if="currentStep === 1" class="text-center px-2 sm:px-0">
<br />
<br />
<br />
<br />
<h3 class="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 mb-3 sm:mb-4">Yo les ptits potes ! 🎉</h3>
<p class="text-sm sm:text-base text-gray-600 max-w-md mx-auto leading-relaxed mb-6 sm:mb-8">
Bienvenue sur LeDiscord ! Ici on partage nos vlogs, nos photos de soirées et on organise nos prochaines beuveries. 🍻
</p>
<!-- Minimalist features preview -->
<div class="grid grid-cols-3 gap-2 sm:gap-4 max-w-md mx-auto mt-6 sm:mt-8">
<div class="text-center">
<div class="text-2xl sm:text-3xl mb-1">📹</div>
<div class="text-xs sm:text-sm font-medium text-gray-700">Vlogs</div>
</div>
<div class="text-center">
<div class="text-2xl sm:text-3xl mb-1">🍺</div>
<div class="text-xs sm:text-sm font-medium text-gray-700">Événements</div>
</div>
<div class="text-center">
<div class="text-2xl sm:text-3xl mb-1">📸</div>
<div class="text-xs sm:text-sm font-medium text-gray-700">Albums</div>
</div>
</div>
</div>
<!-- Step 2: Registration Form -->
<div v-if="currentStep === 2">
<form @submit.prevent="nextStep" class="space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
<div v-if="currentStep === 2" class="w-full">
<form @submit.prevent="nextStep" class="space-y-4 sm:space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
<div>
<label for="email" class="label">Email</label>
<input id="email" v-model="form.email" type="email" required class="input" @blur="touchedFields.email = true">
<label for="email" class="label text-sm sm:text-base">Email</label>
<input
id="email"
v-model="form.email"
type="email"
required
class="input text-sm sm:text-base"
placeholder="exemple@email.com"
@blur="touchedFields.email = true"
>
</div>
<div>
<label for="username" class="label">Nom d'utilisateur</label>
<input id="username" v-model="form.username" type="text" required minlength="3" class="input" @blur="touchedFields.username = true">
<label for="username" class="label text-sm sm:text-base">Nom d'utilisateur</label>
<input
id="username"
v-model="form.username"
type="text"
required
minlength="3"
class="input text-sm sm:text-base"
placeholder="nom_utilisateur"
@blur="touchedFields.username = true"
>
</div>
</div>
<div>
<label for="full_name" class="label">Nom complet</label>
<input id="full_name" v-model="form.full_name" type="text" required class="input" @blur="touchedFields.full_name = true">
<label for="full_name" class="label text-sm sm:text-base">Nom complet</label>
<input
id="full_name"
v-model="form.full_name"
type="text"
required
class="input text-sm sm:text-base"
placeholder="Prénom Nom"
@blur="touchedFields.full_name = true"
>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
<div>
<label for="password" class="label">Mot de passe</label>
<input id="password" v-model="form.password" type="password" required minlength="6" class="input" @blur="touchedFields.password = true">
<label for="password" class="label text-sm sm:text-base">Mot de passe</label>
<input
id="password"
v-model="form.password"
type="password"
required
minlength="6"
class="input text-sm sm:text-base"
placeholder="••••••••"
@blur="touchedFields.password = true"
>
</div>
<div>
<label for="password_confirm" class="label">Confirmer</label>
<input id="password_confirm" v-model="form.password_confirm" type="password" required class="input" @blur="touchedFields.password_confirm = true">
<label for="password_confirm" class="label text-sm sm:text-base">Confirmer</label>
<input
id="password_confirm"
v-model="form.password_confirm"
type="password"
required
class="input text-sm sm:text-base"
placeholder="••••••••"
@blur="touchedFields.password_confirm = true"
>
</div>
</div>
<PasswordStrength :password="form.password" />
<div v-if="touchedFields.password_confirm && form.password_confirm && form.password !== form.password_confirm" class="flex items-center text-sm text-red-600">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" /></svg>
Les mots de passe ne correspondent pas
</div>
<PasswordStrength :password="form.password" />
<div v-if="touchedFields.password_confirm && form.password_confirm && form.password !== form.password_confirm" class="flex items-center text-xs sm:text-sm text-red-600 mt-2">
<svg class="w-4 h-4 mr-1 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<span>Les mots de passe ne correspondent pas</span>
</div>
</form>
</div>
<!-- Step 3: Warning -->
<div v-if="currentStep === 3" class="text-center">
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3 text-left">
<p class="text-sm text-yellow-700">
LeDiscord est actuellement en version bêta. Votre retour est précieux pour nous aider à améliorer la plateforme.
</p>
</div>
</div>
</div>
<div v-if="currentStep === 3" class="w-full px-2 sm:px-0 text-center">
<h3 class="text-xl sm:text-2xl font-bold text-gray-900 mb-3 sm:mb-4">
Version Bêta en cours ! 🚧
</h3>
<div class="bg-gradient-to-br from-yellow-50 to-orange-50 border-2 border-yellow-200 rounded-xl p-4 sm:p-6 max-w-lg mx-auto mb-4 sm:mb-6">
<p class="text-sm sm:text-base text-gray-700 leading-relaxed mb-4">
Je suis encore en train de peaufiner tout ça, donc si tu vois un bug ou que quelque chose te fait chier, <strong>dis-moi !</strong> 💬
</p>
<div class="flex items-center justify-center space-x-2 text-xs sm:text-sm text-gray-600">
<span>🔧</span>
<span>J'améliore au fur et à mesure</span>
</div>
</div>
<div class="mt-4 sm:mt-6">
<p class="text-xs sm:text-sm text-gray-700 mb-3 font-medium">Pour me signaler un problème, utilise le bouton ticket :</p>
<div class="inline-flex items-center justify-center bg-primary-600 text-white rounded-full p-3 sm:p-4 shadow-lg">
<svg class="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
</svg>
</div>
<p class="text-xs sm:text-sm text-gray-500 mt-3">
Il se trouve en bas à droite de l'écran une fois connecté
</p>
</div>
</div>
<!-- Step 4: Features Tour -->
<div v-if="currentStep === 4" class="text-center">
<h3 class="text-2xl font-bold text-gray-900 mb-4">Découvrez les fonctionnalités</h3>
<div class="space-y-4">
<div v-for="feature in features" :key="feature.title" class="border rounded-lg p-4 text-left flex items-center space-x-4">
<div class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
<span v-html="feature.icon"></span>
<!-- Step 4: Interactive Tour -->
<div v-if="currentStep === 4" class="w-full px-2 sm:px-0">
<h3 class="text-xl sm:text-2xl font-bold text-gray-900 mb-4 sm:mb-6 text-center">Petite visite guidée 🗺️</h3>
<div class="max-w-lg mx-auto">
<!-- Tour Content -->
<div class="bg-white border-2 border-gray-200 rounded-xl p-4 sm:p-6 mb-4 sm:mb-6 min-h-[280px] flex flex-col justify-center">
<div v-if="tourStep === 0" class="text-center">
<div class="text-4xl sm:text-5xl mb-4">🏠</div>
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Accueil</h4>
<p class="text-sm sm:text-base text-gray-600 mb-3">C'est ici que tu verras toutes les dernières activités de la communauté</p>
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
<p class="mb-1"> <strong>Astuce :</strong> Tu peux liker, commenter et partager tout ce qui t'intéresse</p>
<p>🔔 Les notifications te tiendront au courant des nouvelles interactions</p>
</div>
</div>
<div>
<h4 class="font-semibold">{{ feature.title }}</h4>
<p class="text-sm text-gray-600">{{ feature.description }}</p>
<div v-if="tourStep === 1" class="text-center">
<div class="text-4xl sm:text-5xl mb-4">📅</div>
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Événements</h4>
<p class="text-sm sm:text-base text-gray-600 mb-3">Organise et participe aux prochaines soirées et beuveries</p>
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
<p class="mb-1">📝 <strong>Crée un événement :</strong> Date, lieu, description, le tout en quelques clics</p>
<p>✅ <strong>Participe :</strong> Indique ta présence et vois qui vient</p>
</div>
</div>
<div v-if="tourStep === 2" class="text-center">
<div class="text-4xl sm:text-5xl mb-4">📸</div>
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Albums</h4>
<p class="text-sm sm:text-base text-gray-600 mb-3">Partage tes meilleures photos de soirées et de moments entre potes</p>
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
<p class="mb-1">📁 <strong>Crée un album :</strong> Regroupe tes photos par événement ou thème</p>
<p>💾 <strong>Upload multiple :</strong> Ajoute plusieurs photos d'un coup</p>
</div>
</div>
<div v-if="tourStep === 3" class="text-center">
<div class="text-4xl sm:text-5xl mb-4">📹</div>
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Vlogs</h4>
<p class="text-sm sm:text-base text-gray-600 mb-3">Regarde et partage tes vlogs avec la communauté</p>
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
<p class="mb-1">🎬 <strong>Upload un vlog :</strong> Vidéos jusqu'à 500MB, avec thumbnail personnalisé</p>
<p>👀 <strong>Statistiques :</strong> Vues, replays, likes, tout est tracké</p>
</div>
</div>
<div v-if="tourStep === 4" class="text-center">
<div class="text-4xl sm:text-5xl mb-4">💬</div>
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Publications</h4>
<p class="text-sm sm:text-base text-gray-600 mb-3">Discute, partage et échange avec tout le monde</p>
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
<p class="mb-1">@<strong>Mentions :</strong> Tag tes potes avec @nom_utilisateur</p>
<p>📎 <strong>Médias :</strong> Ajoute des images directement dans tes posts</p>
</div>
</div>
</div>
<!-- Tour Navigation -->
<div class="flex items-center justify-between">
<button
@click="tourStep = Math.max(0, tourStep - 1)"
:disabled="tourStep === 0"
class="px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
← Précédent
</button>
<div class="flex space-x-2">
<div
v-for="i in 5"
:key="i"
class="w-2 h-2 rounded-full transition-all duration-300"
:class="tourStep === i - 1 ? 'bg-primary-600 w-6' : 'bg-gray-300'"
></div>
</div>
<button
@click="tourStep = Math.min(4, tourStep + 1)"
:disabled="tourStep === 4"
class="px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Suivant →
</button>
</div>
</div>
</div>
<!-- Step 5: Install App -->
<div v-if="currentStep === 5" class="w-full px-2 sm:px-0 text-center">
<h3 class="text-xl sm:text-2xl font-bold text-gray-900 mb-3 sm:mb-4">
Ah et tiens... 📱
</h3>
<p class="text-sm sm:text-base text-gray-600 max-w-md mx-auto leading-relaxed mb-6 sm:mb-8">
Prends l'app, ce sera plus sympa sur mobile ! Accès rapide, notifications, et tout ça directement depuis ton téléphone.
</p>
<div class="bg-gradient-to-br from-primary-50 to-purple-50 border-2 border-primary-200 rounded-xl p-4 sm:p-6 max-w-md mx-auto mb-6">
<div class="flex items-center justify-center space-x-3 mb-4">
<div class="text-3xl">📱</div>
<div class="text-3xl"></div>
<div class="text-3xl"></div>
</div>
<p class="text-xs sm:text-sm text-gray-700 mb-4">
Une fois connecté, tu pourras installer l'app depuis le menu de ton profil
</p>
<button
v-if="deferredPrompt"
@click="handleInstallApp"
class="w-full sm:w-auto bg-primary-600 hover:bg-primary-700 text-white font-medium py-3 px-6 rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105 text-sm sm:text-base"
>
<div class="flex items-center justify-center space-x-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span>Installer l'app maintenant</span>
</div>
</button>
<p v-else class="text-xs sm:text-sm text-gray-500">
Voilà, c'est quand même mieux comme ça !
</p>
</div>
</div>
</StepTransition>
</div>
<!-- Navigation Buttons -->
<div class="flex items-center pt-6" :class="currentStep > 1 ? 'justify-between' : 'justify-end'">
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 sm:gap-4 pt-4 sm:pt-6" :class="currentStep > 1 ? 'sm:justify-between' : 'sm:justify-end'">
<button
v-if="currentStep > 1"
@click="previousStep"
class="btn-secondary"
class="btn-secondary w-full sm:w-auto order-2 sm:order-1 text-sm sm:text-base py-2.5 sm:py-2"
:disabled="loading"
>Précédent</button>
>
Précédent
</button>
<button
v-if="currentStep < totalSteps"
@click="nextStep"
:disabled="!canProceed || loading"
class="btn-primary bg-gradient-to-r from-primary-500 to-purple-600"
:class="{ 'opacity-50 cursor-not-allowed': !canProceed }"
>Suivant</button>
class="btn-primary bg-gradient-to-r from-primary-500 to-purple-600 w-full sm:w-auto order-1 sm:order-2 text-sm sm:text-base py-2.5 sm:py-2"
:class="{ 'opacity-50 cursor-not-allowed': !canProceed || loading }"
>
Suivant
</button>
<button
v-if="currentStep === totalSteps"
@click="handleRegister"
:disabled="loading"
class="btn-primary bg-gradient-to-r from-primary-500 to-purple-600"
:disabled="loading || !canProceed"
class="btn-primary bg-gradient-to-r from-primary-500 to-purple-600 w-full sm:w-auto order-1 sm:order-2 text-sm sm:text-base py-2.5 sm:py-2"
:class="{ 'opacity-50 cursor-not-allowed': loading || !canProceed }"
>
<span v-if="loading">Création en cours...</span>
<span v-else>Créer mon compte</span>
</button>
</div>
<div v-if="error" class="mt-4 text-center text-red-600">
<div v-if="error" class="mt-4 text-center text-xs sm:text-sm text-red-600 px-2">
{{ error }}
</div>
<!-- Login Link -->
<div class="mt-8 text-center">
<p class="text-sm text-gray-600">
<div class="mt-6 sm:mt-8 text-center pb-4 sm:pb-0">
<p class="text-xs sm:text-sm text-gray-600">
Déjà un compte ?
<router-link to="/login" class="font-medium text-primary-600 hover:text-primary-500">
<router-link to="/login" class="font-medium text-primary-600 hover:text-primary-500 transition-colors">
Se connecter
</router-link>
</p>
@@ -147,7 +336,7 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import StepTransition from '@/components/StepTransition.vue'
@@ -159,7 +348,9 @@ const router = useRouter()
// Step management
const currentStep = ref(1)
const totalSteps = 4
const totalSteps = 5
const tourStep = ref(0)
const deferredPrompt = ref(null)
// Form data
const form = ref({
@@ -214,6 +405,10 @@ const canProceed = computed(() => {
function nextStep() {
if (currentStep.value < totalSteps && canProceed.value) {
currentStep.value++
// Reset tour step when entering step 4
if (currentStep.value === 4) {
tourStep.value = 0
}
}
}
@@ -242,4 +437,35 @@ async function handleRegister() {
}
loading.value = false
}
// PWA Installation
function handleBeforeInstallPrompt(e) {
e.preventDefault()
deferredPrompt.value = e
}
async function handleInstallApp() {
if (!deferredPrompt.value) return
try {
deferredPrompt.value.prompt()
const { outcome } = await deferredPrompt.value.userChoice
if (outcome === 'accepted') {
console.log('✅ PWA installée avec succès')
}
deferredPrompt.value = null
} catch (error) {
console.error('Erreur lors de l\'installation:', error)
}
}
onMounted(() => {
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
})
onBeforeUnmount(() => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
})
</script>