Cours Next.js · Module 6: Formulaires et Server Actions
Formulaires multi-étapes et parcours
Dans cette leçon7
Créer des formulaires complexes et multi-étapes est une tâche courante dans les applications web. De tels formulaires sont fréquemment utilisés pour les processus d'inscription, les réservations, les commandes ou la configuration de produits. Dans ce module, nous examinerons différentes stratégies d'implémentation des formulaires multi-étapes dans les environnements React et Next.js.
Pourquoi découper les formulaires en étapes ?
Découper un long formulaire en étapes plus petites apporte de nombreux avantages :
- Meilleure expérience utilisateur - les formulaires plus courts sont moins accablants et réduisent le risque d'abandon
- Plus de clarté - les utilisateurs peuvent se concentrer plus facilement sur un aspect du processus à la fois
- Collecte progressive des données - nous pouvons valider chaque étape séparément et adapter les étapes suivantes selon les réponses précédentes
- Organisation logique - regrouper les champs liés améliore la structure et la compréhension
- Sauvegarde partielle - les utilisateurs peuvent sauvegarder leur progression et revenir plus tard
Approches de base des formulaires multi-étapes
Il existe plusieurs approches principales pour implémenter des formulaires multi-étapes :
1. Approche basée sur l'état du composant
La manière la plus simple consiste à stocker l'étape actuelle et toutes les données du formulaire dans l'état du composant :
1'use client';
2
3import { useState, FormEvent } from 'react';
4
5// Types de données pour chaque étape
6interface UserDetails {
7 firstName: string;
8 lastName: string;
9 email: string;
10}
11
12interface AddressDetails {
13 street: string;
14 city: string;
15 zipCode: string;
16 country: string;
17}
18
19interface PaymentDetails {
20 cardNumber: string;
21 expiryDate: string;
22 cvv: string;
23}
24
25// Données combinées du formulaire
26interface FormData {
27 user: UserDetails;
28 address: AddressDetails;
29 payment: PaymentDetails;
30}
31
32export default function MultiStepForm() {
33 // État de l'étape actuelle
34 const [step, setStep] = useState(1);
35
36 // État des données du formulaire
37 const [formData, setFormData] = useState<FormData>({
38 user: {
39 firstName: '',
40 lastName: '',
41 email: ''
42 },
43 address: {
44 street: '',
45 city: '',
46 zipCode: '',
47 country: ''
48 },
49 payment: {
50 cardNumber: '',
51 expiryDate: '',
52 cvv: ''
53 }
54 });
55
56 // État de validation
57 const [errors, setErrors] = useState<Record<string, string>>({});
58
59 // Gérer les changements des champs du formulaire
60 function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
61 const { name, value } = e.target;
62
63 // Déterminer à quelle étape appartient le champ
64 if (['firstName', 'lastName', 'email'].includes(name)) {
65 setFormData({
66 ...formData,
67 user: {
68 ...formData.user,
69 [name]: value
70 }
71 });
72 } else if (['street', 'city', 'zipCode', 'country'].includes(name)) {
73 setFormData({
74 ...formData,
75 address: {
76 ...formData.address,
77 [name]: value
78 }
79 });
80 } else if (['cardNumber', 'expiryDate', 'cvv'].includes(name)) {
81 setFormData({
82 ...formData,
83 payment: {
84 ...formData.payment,
85 [name]: value
86 }
87 });
88 }
89
90 // Supprimer l'erreur après l'édition du champ
91 if (errors[name]) {
92 setErrors({
93 ...errors,
94 [name]: ''
95 });
96 }
97 }
98
99 // Valider les données du formulaire pour chaque étape
100 function validateStep(stepNumber: number): boolean {
101 const newErrors: Record<string, string> = {};
102
103 if (stepNumber === 1) {
104 // Valider les données de l'utilisateur
105 if (!formData.user.firstName.trim()) {
106 newErrors.firstName = 'Le prénom est requis';
107 }
108
109 if (!formData.user.lastName.trim()) {
110 newErrors.lastName = 'Le nom de famille est requis';
111 }
112
113 if (!formData.user.email.trim()) {
114 newErrors.email = "L'email est requis";
115 } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(formData.user.email)) {
116 newErrors.email = "Format d'adresse email invalide";
117 }
118 } else if (stepNumber === 2) {
119 // Validation de l'adresse
120 if (!formData.address.street.trim()) {
121 newErrors.street = 'La rue est requise';
122 }
123
124 if (!formData.address.city.trim()) {
125 newErrors.city = 'La ville est requise';
126 }
127
128 if (!formData.address.zipCode.trim()) {
129 newErrors.zipCode = 'Le code postal est requis';
130 }
131
132 if (!formData.address.country.trim()) {
133 newErrors.country = 'Le pays est requis';
134 }
135 } else if (stepNumber === 3) {
136 // Validation du paiement
137 if (!formData.payment.cardNumber.trim()) {
138 newErrors.cardNumber = 'Le numéro de carte est requis';
139 } else if (!/^\d{16}$/.test(formData.payment.cardNumber.replace(/\s/g, ''))) {
140 newErrors.cardNumber = 'Numéro de carte invalide';
141 }
142
143 if (!formData.payment.expiryDate.trim()) {
144 newErrors.expiryDate = "La date d'expiration est requise";
145 }
146
147 if (!formData.payment.cvv.trim()) {
148 newErrors.cvv = 'Le code CVV est requis';
149 } else if (!/^\d{3,4}$/.test(formData.payment.cvv)) {
150 newErrors.cvv = 'Code CVV invalide';
151 }
152 }
153
154 setErrors(newErrors);
155 return Object.keys(newErrors).length === 0;
156 }
157
158 // Passer à l'étape suivante
159 function handleNext() {
160 const isValid = validateStep(step);
161
162 if (isValid) {
163 setStep(step + 1);
164 }
165 }
166
167 // Revenir à l'étape précédente
168 function handlePrevious() {
169 setStep(step - 1);
170 }
171
172 // Soumettre l'ensemble du formulaire
173 function handleSubmit(e: FormEvent) {
174 e.preventDefault();
175
176 const isValid = validateStep(step);
177
178 if (isValid) {
179 // Code pour envoyer les données à l'API
180 console.log('Envoi des données du formulaire :', formData);
181
182 // Exemple d'appel d'API (dans une vraie application)
183 // await fetch('/api/checkout', {
184 // method: 'POST',
185 // headers: { 'Content-Type': 'application/json' },
186 // body: JSON.stringify(formData)
187 // });
188
189 // Facultatif : rediriger vers la page de confirmation
190 // router.push('/checkout/success');
191 }
192 }
193
194 // Rendre l'étape appropriée
195 function renderStep() {
196 switch (step) {
197 case 1:
198 return (
199 <div className="space-y-4">
200 <h2 className="text-xl font-semibold">Données personnelles</h2>
201
202 <div>
203 <label htmlFor="firstName" className="block text-sm font-medium text-gray-700">
204 Prénom
205 </label>
206 <input
207 type="text"
208 id="firstName"
209 name="firstName"
210 value={formData.user.firstName}
211 onChange={handleChange}
212 className={`mt-1 block w-full rounded-md border ${
213 errors.firstName ? 'border-red-500' : 'border-gray-300'
214 } px-3 py-2`}
215 />
216 {errors.firstName && (
217 <p className="mt-1 text-sm text-red-600">{errors.firstName}</p>
218 )}
219 </div>
220
221 <div>
222 <label htmlFor="lastName" className="block text-sm font-medium text-gray-700">
223 Nom de famille
224 </label>
225 <input
226 type="text"
227 id="lastName"
228 name="lastName"
229 value={formData.user.lastName}
230 onChange={handleChange}
231 className={`mt-1 block w-full rounded-md border ${
232 errors.lastName ? 'border-red-500' : 'border-gray-300'
233 } px-3 py-2`}
234 />
235 {errors.lastName && (
236 <p className="mt-1 text-sm text-red-600">{errors.lastName}</p>
237 )}
238 </div>
239
240 <div>
241 <label htmlFor="email" className="block text-sm font-medium text-gray-700">
242 Email
243 </label>
244 <input
245 type="email"
246 id="email"
247 name="email"
248 value={formData.user.email}
249 onChange={handleChange}
250 className={`mt-1 block w-full rounded-md border ${
251 errors.email ? 'border-red-500' : 'border-gray-300'
252 } px-3 py-2`}
253 />
254 {errors.email && (
255 <p className="mt-1 text-sm text-red-600">{errors.email}</p>
256 )}
257 </div>
258
259 <div className="flex justify-end">
260 <button
261 type="button"
262 onClick={handleNext}
263 className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
264 >
265 Suivant
266 </button>
267 </div>
268 </div>
269 );
270
271 case 2:
272 return (
273 <div className="space-y-4">
274 <h2 className="text-xl font-semibold">Adresse de livraison</h2>
275
276 <div>
277 <label htmlFor="street" className="block text-sm font-medium text-gray-700">
278 Rue et numéro
279 </label>
280 <input
281 type="text"
282 id="street"
283 name="street"
284 value={formData.address.street}
285 onChange={handleChange}
286 className={`mt-1 block w-full rounded-md border ${
287 errors.street ? 'border-red-500' : 'border-gray-300'
288 } px-3 py-2`}
289 />
290 {errors.street && (
291 <p className="mt-1 text-sm text-red-600">{errors.street}</p>
292 )}
293 </div>
294
295 <div>
296 <label htmlFor="city" className="block text-sm font-medium text-gray-700">
297 Ville
298 </label>
299 <input
300 type="text"
301 id="city"
302 name="city"
303 value={formData.address.city}
304 onChange={handleChange}
305 className={`mt-1 block w-full rounded-md border ${
306 errors.city ? 'border-red-500' : 'border-gray-300'
307 } px-3 py-2`}
308 />
309 {errors.city && (
310 <p className="mt-1 text-sm text-red-600">{errors.city}</p>
311 )}
312 </div>
313
314 <div>
315 <label htmlFor="zipCode" className="block text-sm font-medium text-gray-700">
316 Code postal
317 </label>
318 <input
319 type="text"
320 id="zipCode"
321 name="zipCode"
322 value={formData.address.zipCode}
323 onChange={handleChange}
324 className={`mt-1 block w-full rounded-md border ${
325 errors.zipCode ? 'border-red-500' : 'border-gray-300'
326 } px-3 py-2`}
327 />
328 {errors.zipCode && (
329 <p className="mt-1 text-sm text-red-600">{errors.zipCode}</p>
330 )}
331 </div>
332
333 <div>
334 <label htmlFor="country" className="block text-sm font-medium text-gray-700">
335 Pays
336 </label>
337 <input
338 type="text"
339 id="country"
340 name="country"
341 value={formData.address.country}
342 onChange={handleChange}
343 className={`mt-1 block w-full rounded-md border ${
344 errors.country ? 'border-red-500' : 'border-gray-300'
345 } px-3 py-2`}
346 />
347 {errors.country && (
348 <p className="mt-1 text-sm text-red-600">{errors.country}</p>
349 )}
350 </div>
351
352 <div className="flex justify-between">
353 <button
354 type="button"
355 onClick={handlePrevious}
356 className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300"
357 >
358 Retour
359 </button>
360 <button
361 type="button"
362 onClick={handleNext}
363 className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
364 >
365 Next
366 </button>
367 </div>
368 </div>
369 );
370
371 case 3:
372 return (
373 <div className="space-y-4">
374 <h2 className="text-xl font-semibold">Détails du paiement</h2>
375
376 <div>
377 <label htmlFor="cardNumber" className="block text-sm font-medium text-gray-700">
378 Numéro de carte
379 </label>
380 <input
381 type="text"
382 id="cardNumber"
383 name="cardNumber"
384 value={formData.payment.cardNumber}
385 onChange={handleChange}
386 className={`mt-1 block w-full rounded-md border ${
387 errors.cardNumber ? 'border-red-500' : 'border-gray-300'
388 } px-3 py-2`}
389 />
390 {errors.cardNumber && (
391 <p className="mt-1 text-sm text-red-600">{errors.cardNumber}</p>
392 )}
393 </div>
394
395 <div className="grid grid-cols-2 gap-4">
396 <div>
397 <label htmlFor="expiryDate" className="block text-sm font-medium text-gray-700">
398 Date d'expiration
399 </label>
400 <input
401 type="text"
402 id="expiryDate"
403 name="expiryDate"
404 value={formData.payment.expiryDate}
405 onChange={handleChange}
406 className={`mt-1 block w-full rounded-md border ${
407 errors.expiryDate ? 'border-red-500' : 'border-gray-300'
408 } px-3 py-2`}
409 />
410 {errors.expiryDate && (
411 <p className="mt-1 text-sm text-red-600">{errors.expiryDate}</p>
412 )}
413 </div>
414
415 <div>
416 <label htmlFor="cvv" className="block text-sm font-medium text-gray-700">
417 CVV
418 </label>
419 <input
420 type="text"
421 id="cvv"
422 name="cvv"
423 value={formData.payment.cvv}
424 onChange={handleChange}
425 className={`mt-1 block w-full rounded-md border ${
426 errors.cvv ? 'border-red-500' : 'border-gray-300'
427 } px-3 py-2`}
428 />
429 {errors.cvv && (
430 <p className="mt-1 text-sm text-red-600">{errors.cvv}</p>
431 )}
432 </div>
433 </div>
434
435 <div className="flex justify-between">
436 <button
437 type="button"
438 onClick={handlePrevious}
439 className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300"
440 >
441 Retour
442 </button>
443 <button
444 type="submit"
445 onClick={handleSubmit}
446 className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600"
447 >
448 Place order
449 </button>
450 </div>
451 </div>
452 );
453
454 default:
455 return null;
456 }
457 }
458
459 return (
460 <div className="max-w-2xl mx-auto px-4 py-8">
461 <div className="mb-8">
462 <div className="flex items-center justify-between mb-4">
463 <div className="flex items-center">
464 <div className={`w-8 h-8 rounded-full flex items-center justify-center ${
465 step >= 1 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'
466 }`}>
467 1
468 </div>
469 <div className={`h-1 w-12 ${step >= 2 ? 'bg-blue-500' : 'bg-gray-200'}`}></div>
470 </div>
471 <div className="flex items-center">
472 <div className={`w-8 h-8 rounded-full flex items-center justify-center ${
473 step >= 2 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'
474 }`}>
475 2
476 </div>
477 <div className={`h-1 w-12 ${step >= 3 ? 'bg-blue-500' : 'bg-gray-200'}`}></div>
478 </div>
479 <div className="flex items-center">
480 <div className={`w-8 h-8 rounded-full flex items-center justify-center ${
481 step >= 3 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'
482 }`}>
483 3
484 </div>
485 </div>
486 </div>
487 <div className="flex justify-between text-sm">
488 <span className={step >= 1 ? 'text-blue-500 font-medium' : ''}>Données personnelles</span>
489 <span className={step >= 2 ? 'text-blue-500 font-medium' : ''}>Adresse</span>
490 <span className={step >= 3 ? 'text-blue-500 font-medium' : ''}>Paiement</span>
491 </div>
492 </div>
493
494 <form className="bg-white shadow-md rounded-lg p-6">
495 {renderStep()}
496 </form>
497 </div>
498 );
499}Principales caractéristiques de cette approche :
- Un seul composant gère tout le processus
- L'état contient toutes les données du formulaire et l'étape actuelle
- Le passage d'une étape à l'autre se fait en modifiant l'état
- La validation est effectuée avant de passer à l'étape suivante
2. Approche par composant par étape
Une approche alternative consiste à créer des composants séparés pour chaque étape, ce qui améliore l'organisation et la réutilisabilité du code :
1'use client';
2
3import { useState } from 'react';
4import { useRouter } from 'next/navigation';
5
6// Types pour les données du formulaire
7interface UserFormData {
8 firstName: string;
9 lastName: string;
10 email: string;
11}
12
13interface AddressFormData {
14 street: string;
15 city: string;
16 zipCode: string;
17 country: string;
18}
19
20interface PaymentFormData {
21 cardNumber: string;
22 expiryDate: string;
23 cvv: string;
24}
25
26export type FormData = {
27 user: UserFormData;
28 address: AddressFormData;
29 payment: PaymentFormData;
30}
31
32// Composant pour l'étape 1 : Données de l'utilisateur
33interface UserFormProps {
34 data: UserFormData;
35 onSubmit: (data: UserFormData) => void;
36}
37
38function UserForm({ data, onSubmit }: UserFormProps) {
39 const [formData, setFormData] = useState(data);
40 const [errors, setErrors] = useState<Record<string, string>>({});
41
42 function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
43 const { name, value } = e.target;
44 setFormData({ ...formData, [name]: value });
45
46 // Effacement des erreurs
47 if (errors[name]) {
48 setErrors({ ...errors, [name]: '' });
49 }
50 }
51
52 function validate(): boolean {
53 const newErrors: Record<string, string> = {};
54
55 if (!formData.firstName.trim()) {
56 newErrors.firstName = 'Le prénom est requis';
57 }
58
59 if (!formData.lastName.trim()) {
60 newErrors.lastName = 'Le nom de famille est requis';
61 }
62
63 if (!formData.email.trim()) {
64 newErrors.email = "L'email est requis";
65 } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(formData.email)) {
66 newErrors.email = "Format d'adresse email invalide";
67 }
68
69 setErrors(newErrors);
70 return Object.keys(newErrors).length === 0;
71 }
72
73 function handleSubmit(e: React.FormEvent) {
74 e.preventDefault();
75
76 if (validate()) {
77 onSubmit(formData);
78 }
79 }
80
81 return (
82 <form onSubmit={handleSubmit} className="space-y-4">
83 <h2 className="text-xl font-semibold">Données personnelles</h2>
84
85 <div>
86 <label htmlFor="firstName" className="block text-sm font-medium text-gray-700">
87 Prénom
88 </label>
89 <input
90 type="text"
91 id="firstName"
92 name="firstName"
93 value={formData.firstName}
94 onChange={handleChange}
95 className={`mt-1 block w-full rounded-md border ${
96 errors.firstName ? 'border-red-500' : 'border-gray-300'
97 } px-3 py-2`}
98 />
99 {errors.firstName && (
100 <p className="mt-1 text-sm text-red-600">{errors.firstName}</p>
101 )}
102 </div>
103
104 <div>
105 <label htmlFor="lastName" className="block text-sm font-medium text-gray-700">
106 Nom de famille
107 </label>
108 <input
109 type="text"
110 id="lastName"
111 name="lastName"
112 value={formData.lastName}
113 onChange={handleChange}
114 className={`mt-1 block w-full rounded-md border ${
115 errors.lastName ? 'border-red-500' : 'border-gray-300'
116 } px-3 py-2`}
117 />
118 {errors.lastName && (
119 <p className="mt-1 text-sm text-red-600">{errors.lastName}</p>
120 )}
121 </div>
122
123 <div>
124 <label htmlFor="email" className="block text-sm font-medium text-gray-700">
125 Email
126 </label>
127 <input
128 type="email"
129 id="email"
130 name="email"
131 value={formData.email}
132 onChange={handleChange}
133 className={`mt-1 block w-full rounded-md border ${
134 errors.email ? 'border-red-500' : 'border-gray-300'
135 } px-3 py-2`}
136 />
137 {errors.email && (
138 <p className="mt-1 text-sm text-red-600">{errors.email}</p>
139 )}
140 </div>
141
142 <div className="flex justify-end">
143 <button
144 type="submit"
145 className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
146 >
147 Suivant
148 </button>
149 </div>
150 </form>
151 );
152}
153
154// Composant pour l'étape 2 : Adresse de livraison
155interface AddressFormProps {
156 data: AddressFormData;
157 onSubmit: (data: AddressFormData) => void;
158 onBack: () => void;
159}
160
161function AddressForm({ data, onSubmit, onBack }: AddressFormProps) {
162 const [formData, setFormData] = useState(data);
163 const [errors, setErrors] = useState<Record<string, string>>({});
164
165 function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
166 const { name, value } = e.target;
167 setFormData({ ...formData, [name]: value });
168
169 if (errors[name]) {
170 setErrors({ ...errors, [name]: '' });
171 }
172 }
173
174 function validate(): boolean {
175 const newErrors: Record<string, string> = {};
176
177 if (!formData.street.trim()) {
178 newErrors.street = 'La rue est requise';
179 }
180
181 if (!formData.city.trim()) {
182 newErrors.city = 'La ville est requise';
183 }
184
185 if (!formData.zipCode.trim()) {
186 newErrors.zipCode = 'Le code postal est requis';
187 }
188
189 if (!formData.country.trim()) {
190 newErrors.country = 'Le pays est requis';
191 }
192
193 setErrors(newErrors);
194 return Object.keys(newErrors).length === 0;
195 }
196
197 function handleSubmit(e: React.FormEvent) {
198 e.preventDefault();
199
200 if (validate()) {
201 onSubmit(formData);
202 }
203 }
204
205 return (
206 <form onSubmit={handleSubmit} className="space-y-4">
207 <h2 className="text-xl font-semibold">Adresse de livraison</h2>
208
209 <div>
210 <label htmlFor="street" className="block text-sm font-medium text-gray-700">
211 Rue et numéro
212 </label>
213 <input
214 type="text"
215 id="street"
216 name="street"
217 value={formData.street}
218 onChange={handleChange}
219 className={`mt-1 block w-full rounded-md border ${
220 errors.street ? 'border-red-500' : 'border-gray-300'
221 } px-3 py-2`}
222 />
223 {errors.street && (
224 <p className="mt-1 text-sm text-red-600">{errors.street}</p>
225 )}
226 </div>
227
228 <div>
229 <label htmlFor="city" className="block text-sm font-medium text-gray-700">
230 Ville
231 </label>
232 <input
233 type="text"
234 id="city"
235 name="city"
236 value={formData.city}
237 onChange={handleChange}
238 className={`mt-1 block w-full rounded-md border ${
239 errors.city ? 'border-red-500' : 'border-gray-300'
240 } px-3 py-2`}
241 />
242 {errors.city && (
243 <p className="mt-1 text-sm text-red-600">{errors.city}</p>
244 )}
245 </div>
246
247 <div>
248 <label htmlFor="zipCode" className="block text-sm font-medium text-gray-700">
249 Code postal
250 </label>
251 <input
252 type="text"
253 id="zipCode"
254 name="zipCode"
255 value={formData.zipCode}
256 onChange={handleChange}
257 className={`mt-1 block w-full rounded-md border ${
258 errors.zipCode ? 'border-red-500' : 'border-gray-300'
259 } px-3 py-2`}
260 />
261 {errors.zipCode && (
262 <p className="mt-1 text-sm text-red-600">{errors.zipCode}</p>
263 )}
264 </div>
265
266 <div>
267 <label htmlFor="country" className="block text-sm font-medium text-gray-700">
268 Pays
269 </label>
270 <input
271 type="text"
272 id="country"
273 name="country"
274 value={formData.country}
275 onChange={handleChange}
276 className={`mt-1 block w-full rounded-md border ${
277 errors.country ? 'border-red-500' : 'border-gray-300'
278 } px-3 py-2`}
279 />
280 {errors.country && (
281 <p className="mt-1 text-sm text-red-600">{errors.country}</p>
282 )}
283 </div>
284
285 <div className="flex justify-between">
286 <button
287 type="button"
288 onClick={onBack}
289 className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300"
290 >
291 Retour
292 </button>
293 <button
294 type="submit"
295 className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
296 >
297 Suivant
298 </button>
299 </div>
300 </form>
301 );
302}
303
304// Composant pour l'étape 3 : Paiement
305interface PaymentFormProps {
306 data: PaymentFormData;
307 onSubmit: (data: PaymentFormData) => void;
308 onBack: () => void;
309}
310
311function PaymentForm({ data, onSubmit, onBack }: PaymentFormProps) {
312 const [formData, setFormData] = useState(data);
313 const [errors, setErrors] = useState<Record<string, string>>({});
314
315 function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
316 const { name, value } = e.target;
317 setFormData({ ...formData, [name]: value });
318
319 if (errors[name]) {
320 setErrors({ ...errors, [name]: '' });
321 }
322 }
323
324 function validate(): boolean {
325 const newErrors: Record<string, string> = {};
326
327 if (!formData.cardNumber.trim()) {
328 newErrors.cardNumber = 'Le numéro de carte est requis';
329 } else if (!/^\d{16}$/.test(formData.cardNumber.replace(/\s/g, ''))) {
330 newErrors.cardNumber = 'Numéro de carte invalide';
331 }
332
333 if (!formData.expiryDate.trim()) {
334 newErrors.expiryDate = "La date d'expiration est requise";
335 }
336
337 if (!formData.cvv.trim()) {
338 newErrors.cvv = 'Le code CVV est requis';
339 } else if (!/^\d{3,4}$/.test(formData.cvv)) {
340 newErrors.cvv = 'Code CVV invalide';
341 }
342
343 setErrors(newErrors);
344 return Object.keys(newErrors).length === 0;
345 }
346
347 function handleSubmit(e: React.FormEvent) {
348 e.preventDefault();
349
350 if (validate()) {
351 onSubmit(formData);
352 }
353 }
354
355 return (
356 <form onSubmit={handleSubmit} className="space-y-4">
357 <h2 className="text-xl font-semibold">Détails du paiement</h2>
358
359 <div>
360 <label htmlFor="cardNumber" className="block text-sm font-medium text-gray-700">
361 Numéro de carte
362 </label>
363 <input
364 type="text"
365 id="cardNumber"
366 name="cardNumber"
367 value={formData.cardNumber}
368 onChange={handleChange}
369 className={`mt-1 block w-full rounded-md border ${
370 errors.cardNumber ? 'border-red-500' : 'border-gray-300'
371 } px-3 py-2`}
372 />
373 {errors.cardNumber && (
374 <p className="mt-1 text-sm text-red-600">{errors.cardNumber}</p>
375 )}
376 </div>
377
378 <div className="grid grid-cols-2 gap-4">
379 <div>
380 <label htmlFor="expiryDate" className="block text-sm font-medium text-gray-700">
381 Date d'expiration
382 </label>
383 <input
384 type="text"
385 id="expiryDate"
386 name="expiryDate"
387 value={formData.expiryDate}
388 onChange={handleChange}
389 className={`mt-1 block w-full rounded-md border ${
390 errors.expiryDate ? 'border-red-500' : 'border-gray-300'
391 } px-3 py-2`}
392 />
393 {errors.expiryDate && (
394 <p className="mt-1 text-sm text-red-600">{errors.expiryDate}</p>
395 )}
396 </div>
397
398 <div>
399 <label htmlFor="cvv" className="block text-sm font-medium text-gray-700">
400 CVV
401 </label>
402 <input
403 type="text"
404 id="cvv"
405 name="cvv"
406 value={formData.cvv}
407 onChange={handleChange}
408 className={`mt-1 block w-full rounded-md border ${
409 errors.cvv ? 'border-red-500' : 'border-gray-300'
410 } px-3 py-2`}
411 />
412 {errors.cvv && (
413 <p className="mt-1 text-sm text-red-600">{errors.cvv}</p>
414 )}
415 </div>
416 </div>
417
418 <div className="flex justify-between">
419 <button
420 type="button"
421 onClick={onBack}
422 className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300"
423 >
424 Retour
425 </button>
426 <button
427 type="submit"
428 className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600"
429 >
430 Passer la commande
431 </button>
432 </div>
433 </form>
434 );
435}
436
437// Composant de confirmation
438interface ConfirmationProps {
439 data: FormData;
440 onEdit: (step: number) => void;
441}
442
443function Confirmation({ data, onEdit }: ConfirmationProps) {
444 const router = useRouter();
445
446 async function handleConfirm() {
447 try {
448 // Simuler l'envoi à l'API
449 console.log('Envoi de la commande :', data);
450
451 // Exemple d'appel d'API (dans une vraie application)
452 // await fetch('/api/checkout', {
453 // method: 'POST',
454 // headers: { 'Content-Type': 'application/json' },
455 // body: JSON.stringify(data)
456 // });
457
458 // Rediriger vers la page de succès
459 // router.push('/checkout/success');
460
461 // Dans cet exemple, nous affichons simplement une alerte de succès
462 alert('La commande a été passée avec succès !');
463 } catch (error) {
464 console.error('Erreur lors de la commande :', error);
465 alert("Une erreur s'est produite lors de la commande. Réessaie.");
466 }
467 }
468
469 return (
470 <div className="space-y-6">
471 <h2 className="text-xl font-semibold">Confirmation de la commande</h2>
472
473 <div className="border border-gray-200 rounded-lg p-4 space-y-4">
474 <div>
475 <h3 className="font-medium text-gray-700 mb-2">Données personnelles</h3>
476 <div className="bg-gray-50 p-3 rounded">
477 <p>{data.user.firstName} {data.user.lastName}</p>
478 <p>{data.user.email}</p>
479 </div>
480 <button
481 type="button"
482 onClick={() => onEdit(1)}
483 className="text-sm text-blue-500 mt-1"
484 >
485 Modifier
486 </button>
487 </div>
488
489 <div>
490 <h3 className="font-medium text-gray-700 mb-2">Adresse de livraison</h3>
491 <div className="bg-gray-50 p-3 rounded">
492 <p>{data.address.street}</p>
493 <p>{data.address.zipCode} {data.address.city}</p>
494 <p>{data.address.country}</p>
495 </div>
496 <button
497 type="button"
498 onClick={() => onEdit(2)}
499 className="text-sm text-blue-500 mt-1"
500 >
501 Modifier
502 </button>
503 </div>
504
505 <div>
506 <h3 className="font-medium text-gray-700 mb-2">Mode de paiement</h3>
507 <div className="bg-gray-50 p-3 rounded">
508 <p>Carte : **** **** **** {data.payment.cardNumber.slice(-4)}</p>
509 <p>Valable jusqu'au : {data.payment.expiryDate}</p>
510 </div>
511 <button
512 type="button"
513 onClick={() => onEdit(3)}
514 className="text-sm text-blue-500 mt-1"
515 >
516 Modifier
517 </button>
518 </div>
519 </div>
520
521 <div className="flex justify-center">
522 <button
523 type="button"
524 onClick={handleConfirm}
525 className="px-6 py-2 bg-green-500 text-white rounded-md hover:bg-green-600"
526 >
527 Confirmer et passer la commande
528 </button>
529 </div>
530 </div>
531 );
532}
533
534// Composant principal du formulaire multi-étapes
535export default function MultiStepCheckout() {
536 const [step, setStep] = useState(1);
537 const [formData, setFormData] = useState<FormData>({
538 user: {
539 firstName: '',
540 lastName: '',
541 email: ''
542 },
543 address: {
544 street: '',
545 city: '',
546 zipCode: '',
547 country: ''
548 },
549 payment: {
550 cardNumber: '',
551 expiryDate: '',
552 cvv: ''
553 }
554 });
555
556 function handleUserSubmit(userData: UserFormData) {
557 setFormData({ ...formData, user: userData });
558 setStep(2);
559 }
560
561 function handleAddressSubmit(addressData: AddressFormData) {
562 setFormData({ ...formData, address: addressData });
563 setStep(3);
564 }
565
566 function handlePaymentSubmit(paymentData: PaymentFormData) {
567 setFormData({ ...formData, payment: paymentData });
568 setStep(4);
569 }
570
571 function renderProgressBar() {
572 return (
573 <div className="mb-8">
574 <div className="flex items-center justify-between mb-4">
575 {[1, 2, 3, 4].map((num) => (
576 <div key={num} className="flex items-center">
577 <div className={`w-8 h-8 rounded-full flex items-center justify-center ${
578 step >= num ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'
579 }`}>
580 {num}
581 </div>
582 {num < 4 && (
583 <div className={`h-1 w-12 ${step > num ? 'bg-blue-500' : 'bg-gray-200'}`}></div>
584 )}
585 </div>
586 ))}
587 </div>
588 <div className="flex justify-between text-sm">
589 <span className={step >= 1 ? 'text-blue-500 font-medium' : ''}>Données personnelles</span>
590 <span className={step >= 2 ? 'text-blue-500 font-medium' : ''}>Adresse</span>
591 <span className={step >= 3 ? 'text-blue-500 font-medium' : ''}>Paiement</span>
592 <span className={step >= 4 ? 'text-blue-500 font-medium' : ''}>Confirmation</span>
593 </div>
594 </div>
595 );
596 }
597
598 function renderStep() {
599 switch (step) {
600 case 1:
601 return <UserForm data={formData.user} onSubmit={handleUserSubmit} />;
602 case 2:
603 return (
604 <AddressForm
605 data={formData.address}
606 onSubmit={handleAddressSubmit}
607 onBack={() => setStep(1)}
608 />
609 );
610 case 3:
611 return (
612 <PaymentForm
613 data={formData.payment}
614 onSubmit={handlePaymentSubmit}
615 onBack={() => setStep(2)}
616 />
617 );
618 case 4:
619 return <Confirmation data={formData} onEdit={setStep} />;
620 default:
621 return null;
622 }
623 }
624
625 return (
626 <div className="max-w-2xl mx-auto px-4 py-8">
627 {renderProgressBar()}
628
629 <div className="bg-white shadow-md rounded-lg p-6">
630 {renderStep()}
631 </div>
632 </div>
633 );
634}Avantages de cette approche :
- Meilleure organisation du code
- Chaque étape est un composant séparé et indépendant
- Validation plus facile pour chaque étape
- Les composants peuvent être réutilisés ailleurs
- Tests plus faciles de chaque étape
3. Formulaires multi-étapes avec les Server Actions en Next.js
Pour les applications Next.js, nous pouvons utiliser les Server Actions pour gérer les données du formulaire :
1// app/checkout/page.tsx
2'use client';
3
4import { useState } from 'react';
5import { submitUserData, submitAddressData, submitPaymentData, completeOrder } from './actions';
6
7// Types et composants de formulaire similaires à l'exemple précédent
8//
9export default function CheckoutPage() {
10 const [step, setStep] = useState(1);
11 const [userId, setUserId] = useState<string | null>(null);
12 const [formData, setFormData] = useState({
13 user: { /* données utilisateur */ },
14 address: { /* adresse */ },
15 payment: { /* paiement */ }
16 });
17
18 async function handleUserSubmit(userData: UserFormData) {
19 try {
20 // Sauvegarder les données de l'utilisateur via une Server Action
21 const result = await submitUserData(userData);
22
23 if (result.success) {
24 setUserId(result.userId);
25 setFormData({ ...formData, user: userData });
26 setStep(2);
27 } else {
28 // Gestion des erreurs
29 alert(result.error || "Une erreur s'est produite. Réessaie.");
30 }
31 } catch (error) {
32 console.error("Erreur lors de l'envoi des données utilisateur :", error);
33 alert("Une erreur inattendue s'est produite. Réessaie.");
34 }
35 }
36
37 async function handleAddressSubmit(addressData: AddressFormData) {
38 if (!userId) return;
39
40 try {
41 const result = await submitAddressData(userId, addressData);
42
43 if (result.success) {
44 setFormData({ ...formData, address: addressData });
45 setStep(3);
46 } else {
47 alert(result.error || "Une erreur s'est produite. Réessaie.");
48 }
49 } catch (error) {
50 console.error("Erreur lors de l'envoi des données d'adresse :", error);
51 alert("Une erreur inattendue s'est produite. Réessaie.");
52 }
53 }
54
55 // Fonctions similaires pour les étapes restantes
56 //
57 // Rendre les composants de formulaire pour chaque étape, comme précédemment
58 //
59}Voici un exemple d'implémentation des Server Actions :
1// app/checkout/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6// Schémas de validation pour les formulaires
7const UserSchema = z.object({
8 firstName: z.string().min(1, { message: 'Le prénom est requis' }),
9 lastName: z.string().min(1, { message: 'Le nom de famille est requis' }),
10 email: z.string().email({ message: 'Adresse email invalide' })
11});
12
13const AddressSchema = z.object({
14 street: z.string().min(1, { message: 'La rue est requise' }),
15 city: z.string().min(1, { message: 'La ville est requise' }),
16 zipCode: z.string().min(1, { message: 'Le code postal est requis' }),
17 country: z.string().min(1, { message: 'Le pays est requis' })
18});
19
20// Types de réponse du serveur
21type ServerResponse<T> =
22 | { success: true; data: T }
23 | { success: false; error: string };
24
25export async function submitUserData(formData: unknown): Promise<ServerResponse<{ userId: string }>> {
26 try {
27 // Valider les données
28 const validatedData = UserSchema.safeParse(formData);
29
30 if (!validatedData.success) {
31 return {
32 success: false,
33 error: validatedData.error.errors[0]?.message || 'Données invalides'
34 };
35 }
36
37 // Dans une vraie application : sauvegarder les données en base de données
38 // const user = await db.user.create({ data: validatedData.data });
39
40 // Simulation de la création de l'utilisateur
41 console.log('Données utilisateur sauvegardées :', validatedData.data);
42
43 return {
44 success: true,
45 data: { userId: 'user_123' }
46 };
47 } catch (error) {
48 console.error("Erreur serveur lors de l'envoi des données utilisateur :", error);
49 return {
50 success: false,
51 error: "Une erreur s'est produite lors du traitement des données"
52 };
53 }
54}
55
56export async function submitAddressData(
57 userId: string,
58 formData: unknown
59): Promise<ServerResponse<{ addressId: string }>> {
60 try {
61 // Valider les données
62 const validatedData = AddressSchema.safeParse(formData);
63
64 if (!validatedData.success) {
65 return {
66 success: false,
67 error: validatedData.error.errors[0]?.message || "Données d'adresse invalides"
68 };
69 }
70
71 // Dans une vraie application : sauvegarder l'adresse en base de données
72 // const address = await db.address.create({
73 // data: { ...validatedData.data, userId }
74 // });
75
76 // Simulation de la sauvegarde de l'adresse
77 console.log("Adresse sauvegardée pour l'utilisateur", userId, ':', validatedData.data);
78
79 return {
80 success: true,
81 data: { addressId: 'addr_456' }
82 };
83 } catch (error) {
84 console.error("Erreur serveur lors de l'envoi des données d'adresse :", error);
85 return {
86 success: false,
87 error: "Une erreur s'est produite lors du traitement de l'adresse"
88 };
89 }
90}
91
92// Fonctions similaires pour le paiement et la finalisation de la commande
93//Avantages de cette approche :
- Validation et traitement des données sur le serveur
- Possibilité de sauvegarder les données en base de données de façon incrémentale
- Meilleure protection des données sensibles (par ex. les détails de paiement)
- Prise en charge de l'amélioration progressive
Gestion de l'état dans les formulaires multi-étapes
Pour les formulaires plus complexes, il est utile d'envisager des bibliothèques de gestion de l'état des formulaires :
Gestion de l'état avec Formik ou React Hook Form
1// Exemple avec React Hook Form
2import { useForm, FormProvider, useFormContext } from 'react-hook-form';
3import { zodResolver } from '@hookform/resolvers/zod';
4import { z } from 'zod';
5
6// Validation schema
7const CheckoutSchema = z.object({
8 user: z.object({
9 firstName: z.string().min(1, 'Le prénom est requis'),
10 lastName: z.string().min(1, 'Le nom de famille est requis'),
11 email: z.string().email('Adresse email invalide')
12 }),
13 address: z.object({
14 street: z.string().min(1, 'La rue est requise'),
15 city: z.string().min(1, 'La ville est requise'),
16 zipCode: z.string().min(1, 'Le code postal est requis'),
17 country: z.string().min(1, 'Le pays est requis')
18 }),
19 payment: z.object({
20 cardNumber: z.string().refine(val => /^\d{16}$/.test(val.replace(/\s/g, '')), {
21 message: 'Numéro de carte invalide'
22 }),
23 expiryDate: z.string().min(1, "La date d'expiration est requise"),
24 cvv: z.string().refine(val => /^\d{3,4}$/.test(val), {
25 message: 'Code CVV invalide'
26 })
27 })
28});
29
30type CheckoutData = z.infer<typeof CheckoutSchema>;
31
32function UserFormStep() {
33 const { register, formState: { errors } } = useFormContext<CheckoutData>();
34
35 return (
36 <div className="space-y-4">
37 <h2 className="text-xl font-semibold">Données personnelles</h2>
38
39 <div>
40 <label htmlFor="firstName" className="block text-sm font-medium">First name</label>
41 <input
42 id="firstName"
43 {...register('user.firstName')}
44 className={`mt-1 block w-full rounded-md border ${
45 errors.user?.firstName ? 'border-red-500' : 'border-gray-300'
46 } px-3 py-2`}
47 />
48 {errors.user?.firstName && (
49 <p className="mt-1 text-sm text-red-600">{errors.user.firstName.message}</p>
50 )}
51 </div>
52
53 {/* Champs restants... */}
54 </div>
55 );
56}
57
58// Définir de la même manière les étapes restantes du formulaire
59//
60export default function CheckoutForm() {
61 const [step, setStep] = useState(1);
62
63 const methods = useForm<CheckoutData>({
64 resolver: zodResolver(CheckoutSchema),
65 mode: 'onBlur',
66 defaultValues: {
67 user: { firstName: '', lastName: '', email: '' },
68 address: { street: '', city: '', zipCode: '', country: '' },
69 payment: { cardNumber: '', expiryDate: '', cvv: '' }
70 }
71 });
72
73 function onSubmit(data: CheckoutData) {
74 console.log('Envoi des données :', data);
75 // Logique d'envoi des données à l'API
76 }
77
78 const nextStep = () => {
79 if (step < 3) {
80 setStep(step + 1);
81 } else {
82 methods.handleSubmit(onSubmit)();
83 }
84 };
85
86 const prevStep = () => setStep(step - 1);
87
88 return (
89 <FormProvider {...methods}>
90 <form onSubmit={methods.handleSubmit(onSubmit)}>
91 {/* Barre de progression */}
92
93 {/* Rendre l'étape appropriée */}
94 {step === 1 && <UserFormStep />}
95 {step === 2 && <AddressFormStep />}
96 {step === 3 && <PaymentFormStep />}
97
98 {/* Boutons de navigation */}
99 <div className="mt-6 flex justify-between">
100 {step > 1 && (
101 <button
102 type="button"
103 onClick={prevStep}
104 className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md"
105 >
106 Retour
107 </button>
108 )}
109
110 <button
111 type="button"
112 onClick={nextStep}
113 className={`px-4 py-2 ${
114 step === 3 ? 'bg-green-500 hover:bg-green-600' : 'bg-blue-500 hover:bg-blue-600'
115 } text-white rounded-md`}
116 >
117 {step === 3 ? 'Passer la commande' : 'Suivant'}
118 </button>
119 </div>
120 </form>
121 </FormProvider>
122 );
123}Gestion de l'état avec React Context
Pour les formulaires très complexes, il peut être avantageux d'utiliser l'API React Context :
1// context/CheckoutContext.tsx
2import { createContext, useContext, useReducer, ReactNode } from 'react';
3
4// Types de données du formulaire
5//
6// État du formulaire
7interface CheckoutState {
8 step: number;
9 user: UserFormData;
10 address: AddressFormData;
11 payment: PaymentFormData;
12 errors: Record<string, string>;
13}
14
15// Types d'actions
16type CheckoutAction =
17 | { type: 'SET_STEP'; payload: number }
18 | { type: 'UPDATE_USER'; payload: Partial<UserFormData> }
19 | { type: 'UPDATE_ADDRESS'; payload: Partial<AddressFormData> }
20 | { type: 'UPDATE_PAYMENT'; payload: Partial<PaymentFormData> }
21 | { type: 'SET_ERRORS'; payload: Record<string, string> }
22 | { type: 'CLEAR_ERRORS' };
23
24// État initial
25const initialState: CheckoutState = {
26 step: 1,
27 user: { firstName: '', lastName: '', email: '' },
28 address: { street: '', city: '', zipCode: '', country: '' },
29 payment: { cardNumber: '', expiryDate: '', cvv: '' },
30 errors: {}
31};
32
33// Reducer
34function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState {
35 switch (action.type) {
36 case 'SET_STEP':
37 return { ...state, step: action.payload };
38 case 'UPDATE_USER':
39 return { ...state, user: { ...state.user, ...action.payload } };
40 case 'UPDATE_ADDRESS':
41 return { ...state, address: { ...state.address, ...action.payload } };
42 case 'UPDATE_PAYMENT':
43 return { ...state, payment: { ...state.payment, ...action.payload } };
44 case 'SET_ERRORS':
45 return { ...state, errors: action.payload };
46 case 'CLEAR_ERRORS':
47 return { ...state, errors: {} };
48 default:
49 return state;
50 }
51}
52
53// Type du contexte
54interface CheckoutContextType {
55 state: CheckoutState;
56 nextStep: () => void;
57 prevStep: () => void;
58 updateUser: (data: Partial<UserFormData>) => void;
59 updateAddress: (data: Partial<AddressFormData>) => void;
60 updatePayment: (data: Partial<PaymentFormData>) => void;
61 validateStep: (step: number) => boolean;
62 submitOrder: () => Promise<void>;
63}
64
65// Contexte
66const CheckoutContext = createContext<CheckoutContextType | undefined>(undefined);
67
68// Provider
69export function CheckoutProvider({ children }: { children: ReactNode }) {
70 const [state, dispatch] = useReducer(checkoutReducer, initialState);
71
72 // Fonctions utilitaires
73 const nextStep = () => {
74 if (validateStep(state.step)) {
75 dispatch({ type: 'SET_STEP', payload: state.step + 1 });
76 }
77 };
78
79 const prevStep = () => {
80 dispatch({ type: 'SET_STEP', payload: state.step - 1 });
81 };
82
83 const updateUser = (data: Partial<UserFormData>) => {
84 dispatch({ type: 'UPDATE_USER', payload: data });
85 };
86
87 const updateAddress = (data: Partial<AddressFormData>) => {
88 dispatch({ type: 'UPDATE_ADDRESS', payload: data });
89 };
90
91 const updatePayment = (data: Partial<PaymentFormData>) => {
92 dispatch({ type: 'UPDATE_PAYMENT', payload: data });
93 };
94
95 // Validation de l'étape
96 const validateStep = (step: number): boolean => {
97 // Logique de validation...
98 return true; // Simplifié pour l'exemple
99 };
100
101 // Envoi de la commande
102 const submitOrder = async () => {
103 if (validateStep(state.step)) {
104 try {
105 // Logique d'envoi des données
106 console.log('Envoi des données :', state);
107 } catch (error) {
108 console.error("Erreur lors de l'envoi de la commande :", error);
109 }
110 }
111 };
112
113 return (
114 <CheckoutContext.Provider
115 value={{
116 state,
117 nextStep,
118 prevStep,
119 updateUser,
120 updateAddress,
121 updatePayment,
122 validateStep,
123 submitOrder
124 }}
125 >
126 {children}
127 </CheckoutContext.Provider>
128 );
129}
130
131// Hook pour utiliser le contexte
132export function useCheckout() {
133 const context = useContext(CheckoutContext);
134 if (context === undefined) {
135 throw new Error('useCheckout must be used within a CheckoutProvider');
136 }
137 return context;
138}Techniques avancées pour les formulaires multi-étapes
1. Parcours de formulaire conditionnels
Parfois, nous voulons que les utilisateurs suivent différents parcours de formulaire selon leurs choix :
1// Exemple d'implémentation de parcours conditionnels
2function determineNextStep(currentStep: number, formData: FormData): number {
3 if (currentStep === 1) {
4 // Si l'utilisateur a choisi la livraison à une autre adresse, ajouter l'étape de l'adresse de livraison
5 if (formData.shippingDifferent) {
6 return 2; // Étape de l'adresse de livraison
7 } else {
8 return 3; // Sauter l'étape de l'adresse de livraison, aller au paiement
9 }
10 }
11
12 if (currentStep === 3) {
13 // Si l'utilisateur a choisi le paiement par virement, sauter l'étape des détails de la carte
14 if (formData.paymentMethod === 'bankTransfer') {
15 return 5; // Aller au récapitulatif
16 } else {
17 return 4; // Étape des détails de la carte
18 }
19 }
20
21 // Transition par défaut vers l'étape suivante
22 return currentStep + 1;
23}
24
25// Utilisation dans un composant
26function handleNext() {
27 const isValid = validateCurrentStep();
28
29 if (isValid) {
30 const nextStep = determineNextStep(step, formData);
31 setStep(nextStep);
32 }
33}2. Sauvegarder et restaurer l'état du formulaire
Pour les longs formulaires, il est utile de permettre la sauvegarde de la progression :
1'use client';
2
3import { useState, useEffect } from 'react';
4
5// Sauvegarder l'état du formulaire
6function saveFormState(data: FormData) {
7 localStorage.setItem('checkout_form', JSON.stringify(data));
8 localStorage.setItem('checkout_timestamp', Date.now().toString());
9}
10
11// Restaurer l'état du formulaire
12function getSavedFormState(): FormData | null {
13 const savedData = localStorage.getItem('checkout_form');
14 const timestamp = localStorage.getItem('checkout_timestamp');
15
16 if (!savedData || !timestamp) {
17 return null;
18 }
19
20 // Vérifier que les données sauvegardées ne sont pas trop anciennes (par ex. 24 heures)
21 const savedTime = parseInt(timestamp, 10);
22 const now = Date.now();
23 const oneDayMs = 24 * 60 * 60 * 1000;
24
25 if (now - savedTime > oneDayMs) {
26 // Les données sont trop anciennes, les supprimer
27 localStorage.removeItem('checkout_form');
28 localStorage.removeItem('checkout_timestamp');
29 return null;
30 }
31
32 try {
33 return JSON.parse(savedData) as FormData;
34 } catch (e) {
35 return null;
36 }
37}
38
39export default function CheckoutForm() {
40 const [formData, setFormData] = useState<FormData>({
41 // État par défaut du formulaire
42 });
43
44 const [step, setStep] = useState(1);
45 const [hasRestoredState, setHasRestoredState] = useState(false);
46
47 // Tenter de restaurer l'état du formulaire au premier rendu
48 useEffect(() => {
49 if (!hasRestoredState) {
50 const savedState = getSavedFormState();
51
52 if (savedState) {
53 setFormData(savedState);
54 setHasRestoredState(true);
55
56 // Facultatif : demander à l'utilisateur s'il veut continuer là où il s'était arrêté
57 const wishToContinue = window.confirm(
58 "Un formulaire incomplet d'une session précédente a été trouvé. Veux-tu continuer là où tu t'étais arrêté ?"
59 );
60
61 if (wishToContinue) {
62 // Nous pouvons déterminer à quelle étape l'utilisateur s'est arrêté
63 // par ex. selon les données déjà renseignées
64 if (savedState.payment.cardNumber) {
65 setStep(3);
66 } else if (savedState.address.street) {
67 setStep(2);
68 } else {
69 setStep(1);
70 }
71 } else {
72 // L'utilisateur ne veut pas continuer, effacer les données sauvegardées
73 localStorage.removeItem('checkout_form');
74 localStorage.removeItem('checkout_timestamp');
75 setFormData({
76 // Réinitialiser à l'état par défaut
77 });
78 }
79 }
80
81 setHasRestoredState(true);
82 }
83 }, [hasRestoredState]);
84
85 // Sauvegarder l'état du formulaire on changes
86 useEffect(() => {
87 if (hasRestoredState) {
88 saveFormState(formData);
89 }
90 }, [formData, hasRestoredState]);
91
92 // Reste du composant...
93}3. Ajouter et supprimer dynamiquement des champs de formulaire
Dans certains formulaires multi-étapes, les utilisateurs peuvent avoir besoin d'ajouter ou de supprimer dynamiquement des éléments :
1'use client';
2
3import { useState } from 'react';
4
5interface Item {
6 id: string;
7 name: string;
8 quantity: number;
9}
10
11function OrderItemsStep() {
12 const [items, setItems] = useState<Item[]>([
13 { id: '1', name: '', quantity: 1 }
14 ]);
15
16 const addItem = () => {
17 const newId = (parseInt(items[items.length - 1].id) + 1).toString();
18 setItems([...items, { id: newId, name: '', quantity: 1 }]);
19 };
20
21 const removeItem = (id: string) => {
22 // Ne pas supprimer le dernier élément
23 if (items.length <= 1) return;
24
25 setItems(items.filter(item => item.id !== id));
26 };
27
28 const updateItem = (id: string, field: keyof Item, value: string | number) => {
29 setItems(items.map(item =>
30 item.id === id ? { ...item, [field]: value } : item
31 ));
32 };
33
34 return (
35 <div className="space-y-4">
36 <h2 className="text-xl font-semibold">Articles de la commande</h2>
37
38 {items.map(item => (
39 <div key={item.id} className="flex items-center space-x-4 p-4 border rounded-md">
40 <div className="grow">
41 <label htmlFor={`item-name-${item.id}`} className="block text-sm font-medium">
42 Nom du produit
43 </label>
44 <input
45 id={`item-name-${item.id}`}
46 type="text"
47 value={item.name}
48 onChange={(e) => updateItem(item.id, 'name', e.target.value)}
49 className="mt-1 block w-full rounded-md border-gray-300 px-3 py-2"
50 />
51 </div>
52
53 <div className="w-24">
54 <label htmlFor={`item-qty-${item.id}`} className="block text-sm font-medium">
55 Quantité
56 </label>
57 <input
58 id={`item-qty-${item.id}`}
59 type="number"
60 min="1"
61 value={item.quantity}
62 onChange={(e) => updateItem(item.id, 'quantity', parseInt(e.target.value))}
63 className="mt-1 block w-full rounded-md border-gray-300 px-3 py-2"
64 />
65 </div>
66
67 <button
68 type="button"
69 onClick={() => removeItem(item.id)}
70 className="mt-6 px-2 py-2 bg-red-100 text-red-600 rounded-md hover:bg-red-200"
71 aria-label="Supprimer l'article"
72 >
73 ×
74 </button>
75 </div>
76 ))}
77
78 <button
79 type="button"
80 onClick={addItem}
81 className="w-full mt-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200"
82 >
83 + Ajouter d'autres produits
84 </button>
85 </div>
86 );
87}Bonnes pratiques pour les formulaires multi-étapes
Montre la progression - indique à l'utilisateur où il en est dans le processus (barre de progression, numérotation des étapes)
Préserve les données - les utilisateurs ne doivent pas perdre les données saisies quand la page est rafraîchie ou fermée par accident
Permets la navigation entre les étapes - permets aux utilisateurs de revenir aux étapes précédentes et de modifier les données
Valide chaque étape - vérifie l'exactitude des données avant de passer à l'étape suivante
Fournis des messages d'erreur clairs - indique à l'utilisateur exactement ce qui a mal tourné
Permets de sauvegarder et de revenir plus tard - surtout pour les longs formulaires
Adapte les étapes à l'appareil - sur les petits écrans, envisage des étapes encore plus courtes
Fournis un récapitulatif avant la soumission finale - donne aux utilisateurs un aperçu de toutes les données saisies
Gère les boutons précédent/suivant du navigateur - adapte l'application pour fonctionner avec les boutons précédent/suivant du navigateur
Teste divers scénarios - assure-toi que le formulaire fonctionne correctement à la fois pour un remplissage standard et dans des scénarios comme :
- Le retour aux étapes précédentes
- Le rafraîchissement de la page
- La tentative de soumission de données incomplètes
- La perte de connexion internet
Drizzle ORM - une alternative légère à Prisma
Dans la Métropole Quantique de l'an 2150, où chaque cycle de processeur compte, les développeurs recherchent souvent des outils à la fois puissants et légers. Drizzle ORM est une alternative moderne à Prisma qui offre la sûreté des types tout en restant proche du SQL pur.
Drizzle vs Prisma - comparaison
Drizzle et Prisma sont tous deux des ORM à typage sûr pour TypeScript, mais ils diffèrent par leur philosophie et leur approche :
| Aspect | Drizzle ORM | Prisma |
|---|---|---|
| Philosophie | Proche du SQL, au plus près de la machine | L'abstraction d'abord, API conviviale |
| Taille | ~20 Ko | ~600 Ko+ |
| Migrations | SQL ou TypeScript | Langage DSL personnalisé |
| Requêtes | Proches du SQL | API personnalisée |
| Performances | Très rapides | Bonnes |
| Courbe d'apprentissage | Douce pour les utilisateurs de SQL | Douce pour les débutants |
| Sûreté des types | Complète | Complète |
| Edge Runtime | Support natif | Nécessite Data Proxy |
Pourquoi Drizzle est plus léger et plus proche du SQL
Drizzle est une « fine couche TypeScript » au-dessus du SQL, ce qui signifie :
- Abstraction minimale - les requêtes ressemblent à du SQL, donc faciles à optimiser
- Petite taille de bundle - le code de Drizzle est compact
- Aucun surcoût à l'exécution - tous les types sont supprimés à la compilation
- Contrôle total - tu peux utiliser du SQL brut quand tu en as besoin
1// Drizzle - ressemble à du SQL
2const result = await db
3 .select()
4 .from(users)
5 .where(eq(users.email, 'user@example.com'))
6 .limit(1);
7
8// Prisma - plus abstrait
9const result = await prisma.user.findUnique({
10 where: { email: 'user@example.com' }
11});Requêtes à typage sûr avec Drizzle
Drizzle offre un support complet de TypeScript, détectant les erreurs pendant que tu écris le code :
Définition du schéma
1// db/schema.ts
2import { pgTable, serial, text, varchar, timestamp, integer, boolean } from 'drizzle-orm/pg-core';
3import { relations } from 'drizzle-orm';
4
5// Table des utilisateurs
6export const users = pgTable('users', {
7 id: serial('id').primaryKey(),
8 name: varchar('name', { length: 255 }).notNull(),
9 email: varchar('email', { length: 255 }).notNull().unique(),
10 password: text('password').notNull(),
11 createdAt: timestamp('created_at').defaultNow().notNull(),
12 updatedAt: timestamp('updated_at').defaultNow().notNull()
13});
14
15// Table des articles
16export const posts = pgTable('posts', {
17 id: serial('id').primaryKey(),
18 title: varchar('title', { length: 255 }).notNull(),
19 content: text('content').notNull(),
20 published: boolean('published').default(false).notNull(),
21 authorId: integer('author_id')
22 .notNull()
23 .references(() => users.id, { onDelete: 'cascade' }),
24 createdAt: timestamp('created_at').defaultNow().notNull(),
25 updatedAt: timestamp('updated_at').defaultNow().notNull()
26});
27
28// Table des commentaires
29export const comments = pgTable('comments', {
30 id: serial('id').primaryKey(),
31 content: text('content').notNull(),
32 postId: integer('post_id')
33 .notNull()
34 .references(() => posts.id, { onDelete: 'cascade' }),
35 authorId: integer('author_id')
36 .notNull()
37 .references(() => users.id, { onDelete: 'cascade' }),
38 createdAt: timestamp('created_at').defaultNow().notNull()
39});
40
41// Relations entre les tables
42export const usersRelations = relations(users, ({ many }) => ({
43 posts: many(posts),
44 comments: many(comments)
45}));
46
47export const postsRelations = relations(posts, ({ one, many }) => ({
48 author: one(users, {
49 fields: [posts.authorId],
50 references: [users.id]
51 }),
52 comments: many(comments)
53}));
54
55export const commentsRelations = relations(comments, ({ one }) => ({
56 post: one(posts, {
57 fields: [comments.postId],
58 references: [posts.id]
59 }),
60 author: one(users, {
61 fields: [comments.authorId],
62 references: [users.id]
63 })
64}));
65
66// Types TypeScript générés automatiquement
67export type User = typeof users.$inferSelect;
68export type NewUser = typeof users.$inferInsert;
69
70export type Post = typeof posts.$inferSelect;
71export type NewPost = typeof posts.$inferInsert;
72
73export type Comment = typeof comments.$inferSelect;
74export type NewComment = typeof comments.$inferInsert;Configuration de la connexion
1// db/index.ts
2import { drizzle } from 'drizzle-orm/postgres-js';
3import postgres from 'postgres';
4import * as schema from './schema';
5
6// Client PostgreSQL
7const connectionString = process.env.DATABASE_URL!;
8const client = postgres(connectionString);
9
10// Instance Drizzle
11export const db = drizzle(client, { schema });
12
13// Pour l'Edge Runtime (Vercel, Cloudflare Workers)
14// import { drizzle } from 'drizzle-orm/vercel-postgres';
15// import { sql } from '@vercel/postgres';
16// export const db = drizzle(sql, { schema });Requêtes à typage sûr
1// app/actions/users.ts
2'use server';
3
4import { db } from '@/db';
5import { users, posts } from '@/db/schema';
6import { eq, and, or, like, desc, count } from 'drizzle-orm';
7
8// SELECT - récupération des données
9export async function getUser(id: number) {
10 const [user] = await db
11 .select()
12 .from(users)
13 .where(eq(users.id, id))
14 .limit(1);
15
16 return user;
17}
18
19// SELECT avec plusieurs conditions
20export async function searchUsers(query: string) {
21 return await db
22 .select({
23 id: users.id,
24 name: users.name,
25 email: users.email
26 })
27 .from(users)
28 .where(
29 or(
30 like(users.name, `%${query}%`),
31 like(users.email, `%${query}%`)
32 )
33 )
34 .limit(10);
35}
36
37// INSERT - création d'un utilisateur
38export async function createUser(data: {
39 name: string;
40 email: string;
41 password: string;
42}) {
43 const [newUser] = await db
44 .insert(users)
45 .values(data)
46 .returning(); // PostgreSQL specific
47
48 return newUser;
49}
50
51// UPDATE - mise à jour d'un utilisateur
52export async function updateUser(
53 id: number,
54 data: Partial<{ name: string; email: string }>
55) {
56 const [updated] = await db
57 .update(users)
58 .set({
59 ...data,
60 updatedAt: new Date()
61 })
62 .where(eq(users.id, id))
63 .returning();
64
65 return updated;
66}
67
68// DELETE - suppression d'un utilisateur
69export async function deleteUser(id: number) {
70 await db
71 .delete(users)
72 .where(eq(users.id, id));
73}
74
75// JOIN - récupération d'un utilisateur avec ses articles
76export async function getUserWithPosts(userId: number) {
77 return await db.query.users.findFirst({
78 where: eq(users.id, userId),
79 with: {
80 posts: {
81 orderBy: [desc(posts.createdAt)],
82 limit: 10
83 }
84 }
85 });
86}
87
88// Agrégations
89export async function getPostsCount(userId: number) {
90 const [result] = await db
91 .select({ count: count() })
92 .from(posts)
93 .where(eq(posts.authorId, userId));
94
95 return result.count;
96}
97
98// Transactions
99export async function createUserWithPost(
100 userData: { name: string; email: string; password: string },
101 postData: { title: string; content: string }
102) {
103 return await db.transaction(async (tx) => {
104 // Créer l'utilisateur
105 const [user] = await tx
106 .insert(users)
107 .values(userData)
108 .returning();
109
110 // Créer un article pour cet utilisateur
111 const [post] = await tx
112 .insert(posts)
113 .values({
114 ...postData,
115 authorId: user.id,
116 published: false
117 })
118 .returning();
119
120 return { user, post };
121 });
122}Requêtes avancées
Requêtes relationnelles (Relational Queries API)
1// La façon la plus simple de faire des JOIN
2export async function getPostsWithAuthors() {
3 return await db.query.posts.findMany({
4 with: {
5 author: true,
6 comments: {
7 with: {
8 author: true
9 }
10 }
11 },
12 orderBy: [desc(posts.createdAt)],
13 limit: 20
14 });
15}
16
17// Sélection partielle avec relations
18export async function getPostsPartial() {
19 return await db.query.posts.findMany({
20 columns: {
21 id: true,
22 title: true,
23 createdAt: true
24 },
25 with: {
26 author: {
27 columns: {
28 id: true,
29 name: true
30 }
31 }
32 }
33 });
34}
35
36// Filtrage au sein des relations
37export async function getPublishedPostsWithComments() {
38 return await db.query.posts.findMany({
39 where: eq(posts.published, true),
40 with: {
41 comments: {
42 where: (comments, { gte }) =>
43 gte(comments.createdAt, new Date('2024-01-01')),
44 limit: 5,
45 orderBy: [desc(comments.createdAt)]
46 }
47 }
48 });
49}SQL brut quand tu en as besoin
1import { sql } from 'drizzle-orm';
2
3// SQL brut pour les requêtes avancées
4export async function complexAnalytics() {
5 const result = await db.execute(sql`
6 SELECT
7 u.id,
8 u.name,
9 COUNT(DISTINCT p.id) as posts_count,
10 COUNT(DISTINCT c.id) as comments_count,
11 MAX(p.created_at) as last_post_date
12 FROM users u
13 LEFT JOIN posts p ON p.author_id = u.id
14 LEFT JOIN comments c ON c.author_id = u.id
15 WHERE u.created_at > NOW() - INTERVAL '30 days'
16 GROUP BY u.id, u.name
17 ORDER BY posts_count DESC
18 LIMIT 10
19 `);
20
21 return result.rows;
22}
23
24// Combiner des requêtes à typage sûr avec du SQL brut
25export async function hybridQuery(minPosts: number) {
26 return await db
27 .select({
28 userId: users.id,
29 userName: users.name,
30 postsCount: sql<number>`COUNT(${posts.id})`
31 })
32 .from(users)
33 .leftJoin(posts, eq(posts.authorId, users.id))
34 .groupBy(users.id)
35 .having(sql`COUNT(${posts.id}) > ${minPosts}`);
36}Migrations dans Drizzle
Drizzle propose deux façons de gérer les migrations :
1. Génération SQL automatique
1// drizzle.config.ts
2import type { Config } from 'drizzle-kit';
3
4export default {
5 schema: './db/schema.ts',
6 out: './drizzle',
7 driver: 'pg',
8 dbCredentials: {
9 connectionString: process.env.DATABASE_URL!
10 }
11} satisfies Config;1# Générer les migrations
2npx drizzle-kit generate:pg
3
4# Exécuter les migrations
5npx drizzle-kit push:pg
6
7# Ou à l'aide d'un script personnalisé
8# db/migrate.ts
9import { drizzle } from 'drizzle-orm/postgres-js';
10import { migrate } from 'drizzle-orm/postgres-js/migrator';
11import postgres from 'postgres';
12
13const sql = postgres(process.env.DATABASE_URL!, { max: 1 });
14const db = drizzle(sql);
15
16await migrate(db, { migrationsFolder: './drizzle' });
17await sql.end();2. Migrations SQL manuelles
1-- drizzle/0001_initial.sql
2CREATE TABLE IF NOT EXISTS "users" (
3 "id" serial PRIMARY KEY NOT NULL,
4 "name" varchar(255) NOT NULL,
5 "email" varchar(255) NOT NULL,
6 "password" text NOT NULL,
7 "created_at" timestamp DEFAULT now() NOT NULL,
8 "updated_at" timestamp DEFAULT now() NOT NULL,
9 CONSTRAINT "users_email_unique" UNIQUE("email")
10);
11
12CREATE TABLE IF NOT EXISTS "posts" (
13 "id" serial PRIMARY KEY NOT NULL,
14 "title" varchar(255) NOT NULL,
15 "content" text NOT NULL,
16 "published" boolean DEFAULT false NOT NULL,
17 "author_id" integer NOT NULL,
18 "created_at" timestamp DEFAULT now() NOT NULL,
19 "updated_at" timestamp DEFAULT now() NOT NULL,
20 CONSTRAINT "posts_author_id_users_id_fk"
21 FOREIGN KEY ("author_id") REFERENCES "users"("id")
22 ON DELETE cascade
23);Intégration avec l'App Router de Next.js
1// app/users/[id]/page.tsx
2import { db } from '@/db';
3import { users, posts } from '@/db/schema';
4import { eq, desc } from 'drizzle-orm';
5
6export default async function UserPage({
7 params
8}: {
9 params: Promise<{ id: string }>
10}) {
11 const userId = parseInt((await params).id);
12
13 // Requête exécutée dans un Server Component
14 const user = await db.query.users.findFirst({
15 where: eq(users.id, userId),
16 with: {
17 posts: {
18 orderBy: [desc(posts.createdAt)],
19 limit: 5
20 }
21 }
22 });
23
24 if (!user) {
25 return <div>Utilisateur introuvable</div>;
26 }
27
28 return (
29 <div className="p-8">
30 <h1 className="text-3xl font-bold mb-4">{user.name}</h1>
31 <p className="text-gray-600 mb-8">{user.email}</p>
32
33 <h2 className="text-2xl font-semibold mb-4">Articles</h2>
34 <div className="space-y-4">
35 {user.posts.map(post => (
36 <article key={post.id} className="border p-4 rounded-lg">
37 <h3 className="text-xl font-bold">{post.title}</h3>
38 <p className="text-gray-600 mt-2">{post.content}</p>
39 <time className="text-sm text-gray-400 mt-2 block">
40 {post.createdAt.toLocaleDateString()}
41 </time>
42 </article>
43 ))}
44 </div>
45 </div>
46 );
47}
48
49// app/actions/posts.ts
50'use server';
51
52import { db } from '@/db';
53import { posts } from '@/db/schema';
54import { revalidatePath } from 'next/cache';
55
56export async function createPost(formData: FormData) {
57 const title = formData.get('title') as string;
58 const content = formData.get('content') as string;
59 const authorId = parseInt(formData.get('authorId') as string);
60
61 const [newPost] = await db
62 .insert(posts)
63 .values({
64 title,
65 content,
66 authorId,
67 published: false
68 })
69 .returning();
70
71 revalidatePath(`/users/${authorId}`);
72
73 return { success: true, post: newPost };
74}Drizzle vs Prisma - lequel choisir et quand ?
Choisis Drizzle lorsque :
- Tu connais bien le SQL et veux un contrôle total
- Tu tiens à une petite taille de bundle
- Tu construis des applications edge
- Tu as besoin de performances maximales
- Tu préfères le minimalisme et la simplicité
Choisis Prisma lorsque :
- Tu es un développeur débutant ou intermédiaire
- Tu as besoin d'une excellente documentation et d'un bon écosystème
- Tu veux de l'abstraction et ne veux pas te soucier du SQL
- Tu utilises Prisma Studio pour gérer les données
- Ton équipe est plus grande et a besoin de conventions cohérentes
Application pratique
Dans la Métropole Quantique, où les applications doivent être à la fois puissantes et légères, Drizzle offre une excellente alternative. Sa proximité avec le SQL permet aux développeurs expérimentés d'optimiser facilement les requêtes, tandis que le support complet de TypeScript assure la sûreté des types à chaque étape.
1// Exemple réel : tableau de bord avec des analyses
2export async function getDashboardStats(userId: number) {
3 // Tout est à typage sûr, proche du SQL, rapide
4 const stats = await db
5 .select({
6 totalPosts: count(posts.id),
7 publishedPosts: sql<number>`
8 COUNT(CASE WHEN ${posts.published} = true THEN 1 END)
9 `,
10 draftPosts: sql<number>`
11 COUNT(CASE WHEN ${posts.published} = false THEN 1 END)
12 `,
13 totalComments: count(comments.id)
14 })
15 .from(users)
16 .leftJoin(posts, eq(posts.authorId, users.id))
17 .leftJoin(comments, eq(comments.postId, posts.id))
18 .where(eq(users.id, userId))
19 .groupBy(users.id);
20
21 return stats[0];
22}Résumé
Les formulaires multi-étapes sont un outil puissant pour collecter des données complexes auprès des utilisateurs de façon conviviale et organisée. Il existe de nombreuses stratégies pour les implémenter, des approches simples basées sur l'état du composant, aux approches modulaires avec des composants séparés pour chaque étape, jusqu'aux solutions avancées qui exploitent le contexte React et les bibliothèques de gestion de formulaires.
En React et Next.js, nous disposons d'options flexibles pour implémenter de tels formulaires, avec les avantages supplémentaires offerts par les Server Actions de Next.js pour l'amélioration progressive et la sécurité des données.
La clé du succès est de choisir une approche adaptée à la complexité du formulaire et aux besoins de l'application, tout en offrant aux utilisateurs une expérience fluide et sans friction.
Code de cette leçon : App.tsx
1import React, { useState } from 'react';
2
3type Step1Data = { firstName: string; lastName: string; email: string };
4type Step2Data = { company: string; role: string; experience: string };
5type Step3Data = { plan: string; newsletter: boolean };
6type AllData = Step1Data & Step2Data & Step3Data;
7
8const TOTAL_STEPS = 3;
9
10function StepIndicator({ current, total }: { current: number; total: number }) {
11 return (
12 <div style={{ display: 'flex', alignItems: 'center', marginBottom: 24 }}>
13 {Array.from({ length: total }, (_, i) => (
14 <React.Fragment key={i}>
15 <div style={{
16 width: 32, height: 32, borderRadius: '50%', display: 'flex',
17 alignItems: 'center', justifyContent: 'center', fontWeight: 'bold',
18 fontSize: 13, fontFamily: 'monospace',
19 background: i + 1 < current ? '#4caf50' : i + 1 === current ? '#1a2744' : '#0d1117',
20 color: i + 1 < current ? '#fff' : i + 1 === current ? '#64ffda' : '#555',
21 border: i + 1 === current ? '2px solid #64ffda' : '2px solid transparent',
22 }}>
23 {i + 1 < current ? '✓' : i + 1}
24 </div>
25 {i < total - 1 && (
26 <div style={{ flex: 1, height: 2, background: i + 1 < current ? '#4caf50' : '#333', margin: '0 6px' }} />
27 )}
28 </React.Fragment>
29 ))}
30 </div>
31 );
32}
33
34const inputStyle: React.CSSProperties = {
35 width: '100%', padding: '10px', background: '#0d1117',
36 border: '1px solid #333', borderRadius: 6, color: '#e0e0e0',
37 fontFamily: 'monospace', fontSize: 13, boxSizing: 'border-box', marginBottom: 12
38};
39
40function Step1({ data, onChange }: { data: Step1Data; onChange: (d: Partial<Step1Data>) => void }) {
41 return (
42 <div>
43 <h3 style={{ color: '#64ffda', margin: '0 0 12px' }}>Étape 1 : Informations personnelles</h3>
44 <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Prénom *</label>
45 <input value={data.firstName} onChange={e => onChange({ firstName: e.target.value })} placeholder="Jan" style={inputStyle} />
46 <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Nom *</label>
47 <input value={data.lastName} onChange={e => onChange({ lastName: e.target.value })} placeholder="Kowalski" style={inputStyle} />
48 <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Email *</label>
49 <input type="email" value={data.email} onChange={e => onChange({ email: e.target.value })} placeholder="jan@example.com" style={inputStyle} />
50 </div>
51 );
52}
53
54function Step2({ data, onChange }: { data: Step2Data; onChange: (d: Partial<Step2Data>) => void }) {
55 return (
56 <div>
57 <h3 style={{ color: '#64ffda', margin: '0 0 12px' }}>Étape 2 : Informations professionnelles</h3>
58 <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Entreprise</label>
59 <input value={data.company} onChange={e => onChange({ company: e.target.value })} placeholder="Nom de l'entreprise" style={inputStyle} />
60 <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Poste *</label>
61 <select value={data.role} onChange={e => onChange({ role: e.target.value })} style={inputStyle}>
62 <option value="">-- Choisir --</option>
63 <option value="frontend">Frontend Developer</option>
64 <option value="backend">Backend Developer</option>
65 <option value="fullstack">Fullstack Developer</option>
66 <option value="designer">UI/UX Designer</option>
67 </select>
68 <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Expérience (années) *</label>
69 <input type="number" value={data.experience} onChange={e => onChange({ experience: e.target.value })} placeholder="3" min="0" max="50" style={inputStyle} />
70 </div>
71 );
72}
73
74function Step3({ data, onChange }: { data: Step3Data; onChange: (d: Partial<Step3Data>) => void }) {
75 const plans = [
76 { value: 'free', label: 'Free', desc: 'Accès basique' },
77 { value: 'pro', label: 'Pro', desc: 'Accès complet + support' },
78 { value: 'enterprise', label: 'Enterprise', desc: 'Pour les équipes + SLA' },
79 ];
80 return (
81 <div>
82 <h3 style={{ color: '#64ffda', margin: '0 0 12px' }}>Étape 3 : Choisis un forfait</h3>
83 {plans.map(p => (
84 <label key={p.value} style={{
85 display: 'flex', alignItems: 'center', gap: 10, padding: 12, borderRadius: 6,
86 background: data.plan === p.value ? '#1a2744' : '#0d1117',
87 border: data.plan === p.value ? '1px solid #64ffda' : '1px solid #333',
88 cursor: 'pointer', marginBottom: 8
89 }}>
90 <input type="radio" name="plan" value={p.value} checked={data.plan === p.value}
91 onChange={() => onChange({ plan: p.value })} />
92 <div>
93 <strong style={{ color: data.plan === p.value ? '#64ffda' : '#e0e0e0' }}>{p.label}</strong>
94 <div style={{ color: '#888', fontSize: 11 }}>{p.desc}</div>
95 </div>
96 </label>
97 ))}
98 <label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 12, cursor: 'pointer' }}>
99 <input type="checkbox" checked={data.newsletter} onChange={e => onChange({ newsletter: e.target.checked })} />
100 Je souhaite recevoir la newsletter
101 </label>
102 </div>
103 );
104}
105
106export default function MultiStepFormDemo() {
107 const [step, setStep] = useState(1);
108 const [data, setData] = useState<AllData>({
109 firstName: '', lastName: '', email: '',
110 company: '', role: '', experience: '',
111 plan: 'free', newsletter: false,
112 });
113 const [errors, setErrors] = useState<string[]>([]);
114 const [complete, setComplete] = useState(false);
115
116 const update = (partial: Partial<AllData>) => { setData(prev => ({ ...prev, ...partial })); setErrors([]); };
117
118 const validateStep = () => {
119 const errs: string[] = [];
120 if (step === 1) {
121 if (!data.firstName) errs.push('Le prénom est requis');
122 if (!data.lastName) errs.push('Le nom est requis');
123 if (!data.email) errs.push('L\'email est requis');
124 } else if (step === 2) {
125 if (!data.role) errs.push('Choisis un poste');
126 if (!data.experience) errs.push('Indique ton expérience');
127 }
128 setErrors(errs);
129 return errs.length === 0;
130 };
131
132 const next = () => { if (validateStep()) setStep(s => Math.min(s + 1, TOTAL_STEPS)); };
133 const prev = () => { setErrors([]); setStep(s => Math.max(s - 1, 1)); };
134 const submit = (e: React.FormEvent) => { e.preventDefault(); if (validateStep()) setComplete(true); };
135
136 if (complete) {
137 return (
138 <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
139 <div style={{ maxWidth: 400, margin: '0 auto', textAlign: 'center' }}>
140 <div style={{ fontSize: 40, marginBottom: 12 }}>✅</div>
141 <h2 style={{ color: '#64ffda' }}>Inscription terminée !</h2>
142 <div style={{ background: '#0d1117', borderRadius: 8, padding: 12, marginTop: 12, textAlign: 'left', fontSize: 12 }}>
143 <p><strong>Prénom :</strong> {data.firstName} {data.lastName}</p>
144 <p><strong>Email :</strong> {data.email}</p>
145 <p><strong>Entreprise :</strong> {data.company || 'Aucune'}</p>
146 <p><strong>Poste :</strong> {data.role}</p>
147 <p><strong>Expérience :</strong> {data.experience} ans</p>
148 <p><strong>Plan :</strong> {data.plan}</p>
149 <p><strong>Newsletter :</strong> {data.newsletter ? 'Oui' : 'Non'}</p>
150 </div>
151 <button onClick={() => { setComplete(false); setStep(1); setData({ firstName: '', lastName: '', email: '', company: '', role: '', experience: '', plan: 'free', newsletter: false }); }}
152 style={{ marginTop: 12, padding: '8px 16px', borderRadius: 6, border: '1px solid #64ffda', background: 'transparent', color: '#64ffda', cursor: 'pointer', fontFamily: 'monospace' }}>
153 Nouvelle inscription
154 </button>
155 </div>
156 </div>
157 );
158 }
159
160 return (
161 <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
162 <h1 style={{ color: '#64ffda' }}>Formulaire multi-étapes</h1>
163 <p style={{ color: '#888', fontSize: 13, marginBottom: 16 }}>
164 Un processus d'inscription en 3 étapes avec validation à chaque étape.
165 </p>
166
167 <div style={{ maxWidth: 400 }}>
168 <StepIndicator current={step} total={TOTAL_STEPS} />
169
170 {errors.length > 0 && (
171 <div role="alert" style={{ padding: 10, background: '#3e1010', borderRadius: 6, marginBottom: 12, border: '1px solid #f44336' }}>
172 {errors.map((err, i) => <p key={i} style={{ color: '#f44336', fontSize: 12, margin: '2px 0' }}>{err}</p>)}
173 </div>
174 )}
175
176 <form onSubmit={submit}>
177 {step === 1 && <Step1 data={data} onChange={update} />}
178 {step === 2 && <Step2 data={data} onChange={update} />}
179 {step === 3 && <Step3 data={data} onChange={update} />}
180
181 <div style={{ display: 'flex', gap: 10, marginTop: 16 }}>
182 {step > 1 && (
183 <button type="button" onClick={prev} style={{
184 flex: 1, padding: '10px', borderRadius: 6, border: '1px solid #444',
185 background: 'transparent', color: '#aaa', cursor: 'pointer', fontFamily: 'monospace'
186 }}>Retour</button>
187 )}
188 {step < TOTAL_STEPS ? (
189 <button type="button" onClick={next} style={{
190 flex: 1, padding: '10px', borderRadius: 6, border: 'none',
191 background: '#64ffda', color: '#0f0f23', fontWeight: 'bold', cursor: 'pointer', fontFamily: 'monospace'
192 }}>Suivant</button>
193 ) : (
194 <button type="submit" style={{
195 flex: 1, padding: '10px', borderRadius: 6, border: 'none',
196 background: '#4caf50', color: '#fff', fontWeight: 'bold', cursor: 'pointer', fontFamily: 'monospace'
197 }}>S'inscrire</button>
198 )}
199 </div>
200 </form>
201 </div>
202 </div>
203 );
204}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. L'optimisation des formulaires pour la performance inclut :
2. Les mises à jour optimistes dans les formulaires signifient :
Voici 2 des 6 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri horizontal
Ordonne la syntaxe de useForm avec l'intégration de zodResolver
- Tri vertical
Ordonne les éléments dans le bon ordre : Sélectionner le fichier → Validation client → Téléverser
- Tri horizontal
Ordonne la syntaxe du composant ErrorMessage pour afficher les erreurs
- Tri vertical
Ordonne dans l'ordre les étapes du pipeline de validation de formulaire
- Tri vertical
Ordonne dans l'ordre les étapes d'un formulaire de commande multi-étapes
- Clique dans l'ordre
Ordonne la syntaxe d'un élément form avec action
- Clique dans l'ordre
Ordonne la chaîne de validation Zod pour un mot de passe
- Tri horizontal
Ordonne le motif pour accéder au message d'erreur du champ e-mail dans formState
- Tri vertical
Ordonne les éléments dans le bon ordre : Validation côté client → Soumission du formulaire
- Tri horizontal
Ordonne la syntaxe d'un formulaire avec Server Action dans Next.js
- Tri horizontal
Ordonne la syntaxe pour créer un objet FormData à partir d'un élément form
- Éditeur de code
Crée un framework de sécurité complet : 1. Système d'authentification - OAuth multi-providers avec implémentation JWT personnalisée 2. Moteur d'autorisation - RBAC/ABAC avec permissions fines 3. Gestion des sessions - gestion sécurisée des sessions avec déconnexion automatique 4. Protection des données - chiffrement de bout en bout avec gestion des clés 5. En-têtes de sécurité - configuration CSP, HSTS, protection XSS 6. Validation des entrées - assainissement avancé avec validateurs personnalisés 7. Limitation de débit - rate limiting distribué avec Redis 8. Système d'audit - journalisation complète avec détection d'anomalies 9. Tests de pénétration - analyse de sécurité automatisée 10. Outils de conformité - automatisation de la conformité RGPD, CCPA Utilise des patterns d'entreprise : NextAuth.js, jose, bcryptjs, helmet, rate-limiter.