From 125e9b345de31fa57cbe033467937f3e4c743f64 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Wed, 10 Dec 2025 05:54:06 +0100 Subject: [PATCH] Enhance user registration and profile management: Update registration API to include bio, character class, and avatar fields. Implement validation for character class and improve error messages. Refactor registration page to support multi-step form with avatar upload and additional profile customization options, enhancing user experience during account creation. --- app/api/profile/route.ts | 2 +- app/api/register/avatar/route.ts | 63 ++++ app/api/register/complete/route.ts | 161 +++++++++ app/api/register/route.ts | 27 +- app/register/page.tsx | 534 +++++++++++++++++++++++------ 5 files changed, 688 insertions(+), 99 deletions(-) create mode 100644 app/api/register/avatar/route.ts create mode 100644 app/api/register/complete/route.ts diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts index e740f5e..3810021 100644 --- a/app/api/profile/route.ts +++ b/app/api/profile/route.ts @@ -88,7 +88,7 @@ export async function PUT(request: Request) { } // Validation bio - if (bio !== undefined) { + if (bio !== undefined && bio !== null) { if (typeof bio !== "string") { return NextResponse.json( { error: "La bio doit être une chaîne de caractères" }, diff --git a/app/api/register/avatar/route.ts b/app/api/register/avatar/route.ts new file mode 100644 index 0000000..f5a7298 --- /dev/null +++ b/app/api/register/avatar/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import { writeFile, mkdir } from "fs/promises"; +import { join } from "path"; +import { existsSync } from "fs"; + +export async function POST(request: Request) { + try { + const formData = await request.formData(); + const file = formData.get("file") as File; + + if (!file) { + return NextResponse.json( + { error: "Aucun fichier fourni" }, + { status: 400 } + ); + } + + // Vérifier le type de fichier + if (!file.type.startsWith("image/")) { + return NextResponse.json( + { error: "Le fichier doit être une image" }, + { status: 400 } + ); + } + + // Limiter la taille (par exemple 5MB) + const maxSize = 5 * 1024 * 1024; // 5MB + if (file.size > maxSize) { + return NextResponse.json( + { error: "L'image est trop grande (max 5MB)" }, + { status: 400 } + ); + } + + // Créer le dossier uploads s'il n'existe pas + const uploadsDir = join(process.cwd(), "public", "uploads"); + if (!existsSync(uploadsDir)) { + await mkdir(uploadsDir, { recursive: true }); + } + + // Générer un nom de fichier unique avec timestamp + const timestamp = Date.now(); + const randomId = Math.random().toString(36).substring(2, 9); + const filename = `avatar-register-${timestamp}-${randomId}-${file.name}`; + const filepath = join(uploadsDir, filename); + + // Convertir le fichier en buffer et l'écrire + const bytes = await file.arrayBuffer(); + const buffer = Buffer.from(bytes); + await writeFile(filepath, buffer); + + // Retourner l'URL de l'image + const imageUrl = `/uploads/${filename}`; + return NextResponse.json({ url: imageUrl }); + } catch (error) { + console.error("Error uploading avatar:", error); + return NextResponse.json( + { error: "Erreur lors de l'upload de l'avatar" }, + { status: 500 } + ); + } +} + diff --git a/app/api/register/complete/route.ts b/app/api/register/complete/route.ts new file mode 100644 index 0000000..55cdd2b --- /dev/null +++ b/app/api/register/complete/route.ts @@ -0,0 +1,161 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { CharacterClass } from "@/prisma/generated/prisma/enums"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { userId, username, avatar, bio, characterClass } = body; + + if (!userId) { + return NextResponse.json( + { error: "ID utilisateur requis" }, + { status: 400 } + ); + } + + // Vérifier que l'utilisateur existe et a été créé récemment (dans les 10 dernières minutes) + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + return NextResponse.json( + { error: "Utilisateur non trouvé" }, + { status: 404 } + ); + } + + // Vérifier que le compte a été créé récemment (dans les 10 dernières minutes) + const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); + if (user.createdAt < tenMinutesAgo) { + return NextResponse.json( + { error: "Temps écoulé pour finaliser l'inscription" }, + { status: 400 } + ); + } + + // Validation username + if (username !== undefined) { + if (typeof username !== "string" || username.trim().length === 0) { + return NextResponse.json( + { error: "Le nom d'utilisateur ne peut pas être vide" }, + { status: 400 } + ); + } + + if (username.length < 3 || username.length > 20) { + return NextResponse.json( + { error: "Le nom d'utilisateur doit contenir entre 3 et 20 caractères" }, + { status: 400 } + ); + } + + // Vérifier si le username est déjà pris par un autre utilisateur + const existingUser = await prisma.user.findFirst({ + where: { + username: username.trim(), + NOT: { id: userId }, + }, + }); + + if (existingUser) { + return NextResponse.json( + { error: "Ce nom d'utilisateur est déjà pris" }, + { status: 400 } + ); + } + } + + // Validation bio + if (bio !== undefined && bio !== null) { + if (typeof bio !== "string") { + return NextResponse.json( + { error: "La bio doit être une chaîne de caractères" }, + { status: 400 } + ); + } + if (bio.length > 500) { + return NextResponse.json( + { error: "La bio ne peut pas dépasser 500 caractères" }, + { status: 400 } + ); + } + } + + // Validation characterClass + const validClasses = [ + "WARRIOR", + "MAGE", + "ROGUE", + "RANGER", + "PALADIN", + "ENGINEER", + "MERCHANT", + "SCHOLAR", + "BERSERKER", + "NECROMANCER", + ]; + if (characterClass !== undefined && characterClass !== null) { + if (!validClasses.includes(characterClass)) { + return NextResponse.json( + { error: "Classe de personnage invalide" }, + { status: 400 } + ); + } + } + + // Mettre à jour l'utilisateur + const updateData: { + username?: string; + avatar?: string | null; + bio?: string | null; + characterClass?: CharacterClass | null; + } = {}; + + if (username !== undefined) { + updateData.username = username.trim(); + } + if (avatar !== undefined) { + updateData.avatar = avatar || null; + } + if (bio !== undefined) { + if (bio === null || bio === "") { + updateData.bio = null; + } else if (typeof bio === "string") { + updateData.bio = bio.trim() || null; + } else { + updateData.bio = null; + } + } + if (characterClass !== undefined) { + updateData.characterClass = (characterClass as CharacterClass) || null; + } + + // Si aucun champ à mettre à jour, retourner succès quand même + if (Object.keys(updateData).length === 0) { + return NextResponse.json({ + message: "Profil finalisé avec succès", + userId: user.id, + }); + } + + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: updateData, + }); + + return NextResponse.json({ + message: "Profil finalisé avec succès", + userId: updatedUser.id, + }); + } catch (error) { + console.error("Error completing registration:", error); + const errorMessage = error instanceof Error ? error.message : "Erreur inconnue"; + return NextResponse.json( + { error: `Erreur lors de la finalisation de l'inscription: ${errorMessage}` }, + { status: 500 } + ); + } +} + diff --git a/app/api/register/route.ts b/app/api/register/route.ts index 1048b77..7f67079 100644 --- a/app/api/register/route.ts +++ b/app/api/register/route.ts @@ -5,11 +5,11 @@ import bcrypt from "bcryptjs"; export async function POST(request: Request) { try { const body = await request.json(); - const { email, username, password } = body; + const { email, username, password, bio, characterClass, avatar } = body; if (!email || !username || !password) { return NextResponse.json( - { error: "Tous les champs sont requis" }, + { error: "Email, nom d'utilisateur et mot de passe sont requis" }, { status: 400 } ); } @@ -21,6 +21,26 @@ export async function POST(request: Request) { ); } + // Valider characterClass si fourni + const validCharacterClasses = [ + "WARRIOR", + "MAGE", + "ROGUE", + "RANGER", + "PALADIN", + "ENGINEER", + "MERCHANT", + "SCHOLAR", + "BERSERKER", + "NECROMANCER", + ]; + if (characterClass && !validCharacterClasses.includes(characterClass)) { + return NextResponse.json( + { error: "Classe de personnage invalide" }, + { status: 400 } + ); + } + // Vérifier si l'email existe déjà const existingUser = await prisma.user.findFirst({ where: { @@ -44,6 +64,9 @@ export async function POST(request: Request) { email, username, password: hashedPassword, + bio: bio || null, + characterClass: characterClass || null, + avatar: avatar || null, }, }); diff --git a/app/register/page.tsx b/app/register/page.tsx index 8b80b61..6b7a902 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useRef } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import Navigation from "@/components/Navigation"; @@ -12,18 +12,64 @@ export default function RegisterPage() { username: "", password: "", confirmPassword: "", + bio: "", + characterClass: null as string | null, + avatar: null as string | null, }); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const [uploadingAvatar, setUploadingAvatar] = useState(false); + const [step, setStep] = useState(1); + const [userId, setUserId] = useState(null); + const fileInputRef = useRef(null); - const handleChange = (e: React.ChangeEvent) => { + const handleChange = ( + e: React.ChangeEvent + ) => { setFormData({ ...formData, [e.target.name]: e.target.value, }); }; - const handleSubmit = async (e: React.FormEvent) => { + const handleAvatarUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setUploadingAvatar(true); + setError(""); + + try { + const formDataUpload = new FormData(); + formDataUpload.append("file", file); + + const response = await fetch("/api/register/avatar", { + method: "POST", + body: formDataUpload, + }); + + if (response.ok) { + const data = await response.json(); + setFormData({ + ...formData, + avatar: data.url, + }); + } else { + const errorData = await response.json(); + setError(errorData.error || "Erreur lors de l'upload de l'avatar"); + } + } catch (err) { + console.error("Error uploading avatar:", err); + setError("Erreur lors de l'upload de l'avatar"); + } finally { + setUploadingAvatar(false); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }; + + const handleStep1Submit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); @@ -49,6 +95,9 @@ export default function RegisterPage() { email: formData.email, username: formData.username, password: formData.password, + bio: null, + characterClass: null, + avatar: null, }), }); @@ -59,7 +108,8 @@ export default function RegisterPage() { return; } - router.push("/login?registered=true"); + setUserId(data.userId); + setStep(2); } catch (err) { setError("Une erreur est survenue"); } finally { @@ -67,6 +117,47 @@ export default function RegisterPage() { } }; + const handleStep2Submit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (!userId) { + setError("Erreur: ID utilisateur manquant"); + return; + } + + setLoading(true); + + try { + const response = await fetch("/api/register/complete", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + userId, + username: formData.username, + bio: formData.bio && formData.bio.trim() ? formData.bio.trim() : null, + characterClass: formData.characterClass || null, + avatar: formData.avatar || null, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + setError(errorData.error || "Une erreur est survenue"); + return; + } + + router.push("/login?registered=true"); + } catch (err) { + console.error("Registration completion error:", err); + setError(err instanceof Error ? err.message : "Une erreur est survenue"); + } finally { + setLoading(false); + } + }; + return (
@@ -89,101 +180,352 @@ export default function RegisterPage() { INSCRIPTION -

- Créez votre compte pour commencer +

+ {step === 1 + ? "Créez votre compte pour commencer" + : "Personnalisez votre profil"}

-
- {error && ( -
- {error} -
- )} - -
- - -
- -
- - -
- -
- - -
- -
- - -
- - -
+ 1 + +
= 2 ? "bg-pixel-gold" : "bg-gray-700" + }`} + /> +
= 2 + ? "bg-pixel-gold text-black" + : "bg-gray-700 text-gray-400" + }`} + > + 2 +
+
+ + {step === 1 ? ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} + + {/* Avatar Selection */} +
+ +
+ {/* Preview */} +
+
+ {formData.avatar ? ( + Avatar + ) : formData.username ? ( + + {formData.username.charAt(0).toUpperCase()} + + ) : ( + + ? + + )} +
+ {uploadingAvatar && ( +
+
+ Upload... +
+
+ )} +
+ + {/* Default Avatars */} +
+ +
+ {[ + "/avatar-1.jpg", + "/avatar-2.jpg", + "/avatar-3.jpg", + "/avatar-4.jpg", + "/avatar-5.jpg", + "/avatar-6.jpg", + ].map((defaultAvatar) => ( + + ))} +
+
+ + {/* Custom Upload */} +
+ + +
+
+
+ +
+ + +

3-20 caractères

+
+ +
+ +