Tworzenie złożonych, wieloetapowych formularzy to powszechne zadanie w aplikacjach webowych. Takie formularze są często wykorzystywane do procesów rejestracji, rezerwacji, zamówień czy konfiguracji produktów. W tym module przyjrzymy się różnym strategiom implementacji wielokrokowych formularzy w środowisku React i Next.js.
Rozdzielenie długiego formularza na mniejsze kroki przynosi wiele korzyści:
Istnieje kilka głównych podejść do implementacji wieloetapowych formularzy:
Najprostszym sposobem jest przechowywanie obecnego kroku i wszystkich danych formularza w stanie komponentu:
1'use client';
2
3import { useState, FormEvent } from 'react';
4
5// Typy danych dla poszczególnych kroków
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// Połączone dane formularza
26interface FormData {
27 user: UserDetails;
28 address: AddressDetails;
29 payment: PaymentDetails;
30}
31
32export default function MultiStepForm() {
33 // Stan aktualnego kroku
34 const [step, setStep] = useState(1);
35
36 // Stan danych formularza
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 // Stan walidacji
57 const [errors, setErrors] = useState<Record<string, string>>({});
58
59 // Obsługa zmiany pól formularza
60 function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
61 const { name, value } = e.target;
62
63 // Określenie, do którego kroku należy pole
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 // Usuwanie błędu po edycji pola
91 if (errors[name]) {
92 setErrors({
93 ...errors,
94 [name]: ''
95 });
96 }
97 }
98
99 // Walidacja danych formularza dla każdego kroku
100 function validateStep(stepNumber: number): boolean {
101 const newErrors: Record<string, string> = {};
102
103 if (stepNumber === 1) {
104 // Walidacja danych użytkownika
105 if (!formData.user.firstName.trim()) {
106 newErrors.firstName = 'Imię jest wymagane';
107 }
108
109 if (!formData.user.lastName.trim()) {
110 newErrors.lastName = 'Nazwisko jest wymagane';
111 }
112
113 if (!formData.user.email.trim()) {
114 newErrors.email = 'Email jest wymagany';
115 } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+.[A-Z]{2,}$/i.test(formData.user.email)) {
116 newErrors.email = 'Niepoprawny format adresu email';
117 }
118 } else if (stepNumber === 2) {
119 // Walidacja adresu
120 if (!formData.address.street.trim()) {
121 newErrors.street = 'Ulica jest wymagana';
122 }
123
124 if (!formData.address.city.trim()) {
125 newErrors.city = 'Miasto jest wymagane';
126 }
127
128 if (!formData.address.zipCode.trim()) {
129 newErrors.zipCode = 'Kod pocztowy jest wymagany';
130 }
131
132 if (!formData.address.country.trim()) {
133 newErrors.country = 'Kraj jest wymagany';
134 }
135 } else if (stepNumber === 3) {
136 // Walidacja płatności
137 if (!formData.payment.cardNumber.trim()) {
138 newErrors.cardNumber = 'Numer karty jest wymagany';
139 } else if (!/^d{16}$/.test(formData.payment.cardNumber.replace(/s/g, ''))) {
140 newErrors.cardNumber = 'Niepoprawny numer karty';
141 }
142
143 if (!formData.payment.expiryDate.trim()) {
144 newErrors.expiryDate = 'Data ważności jest wymagana';
145 }
146
147 if (!formData.payment.cvv.trim()) {
148 newErrors.cvv = 'Kod CVV jest wymagany';
149 } else if (!/^d{3,4}$/.test(formData.payment.cvv)) {
150 newErrors.cvv = 'Niepoprawny kod CVV';
151 }
152 }
153
154 setErrors(newErrors);
155 return Object.keys(newErrors).length === 0;
156 }
157
158 // Przejście do następnego kroku
159 function handleNext() {
160 const isValid = validateStep(step);
161
162 if (isValid) {
163 setStep(step + 1);
164 }
165 }
166
167 // Powrót do poprzedniego kroku
168 function handlePrevious() {
169 setStep(step - 1);
170 }
171
172 // Przesłanie całego formularza
173 function handleSubmit(e: FormEvent) {
174 e.preventDefault();
175
176 const isValid = validateStep(step);
177
178 if (isValid) {
179 // Tutaj kod do wysłania danych do API
180 console.log('Przesyłanie danych formularza:', formData);
181
182 // Przykładowe wywołanie API (w rzeczywistej aplikacji)
183 // await fetch('/api/checkout', {
184 // method: 'POST',
185 // headers: { 'Content-Type': 'application/json' },
186 // body: JSON.stringify(formData)
187 // });
188
189 // Opcjonalnie: przekierowanie do strony potwierdzenia
190 // router.push('/checkout/success');
191 }
192 }
193
194 // Renderowanie odpowiedniego kroku
195 function renderStep() {
196 switch (step) {
197 case 1:
198 return (
199 <div className="space-y-4">
200 <h2 className="text-xl font-semibold">Dane osobowe</h2>
201
202 <div>
203 <label htmlFor="firstName" className="block text-sm font-medium text-gray-700">
204 Imię
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 Nazwisko
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 Dalej
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">Adres dostawy</h2>
275
276 <div>
277 <label htmlFor="street" className="block text-sm font-medium text-gray-700">
278 Ulica i numer
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 Miasto
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 Kod pocztowy
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 Kraj
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 Wstecz
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 Dalej
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">Dane płatności</h2>
375
376 <div>
377 <label htmlFor="cardNumber" className="block text-sm font-medium text-gray-700">
378 Numer karty
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 Data ważności
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 Wstecz
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 Złóż zamówienie
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' : ''}>Dane osobowe</span>
489 <span className={step >= 2 ? 'text-blue-500 font-medium' : ''}>Adres</span>
490 <span className={step >= 3 ? 'text-blue-500 font-medium' : ''}>Płatność</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}Główne cechy tego podejścia:
Alternatywnym podejściem jest stworzenie osobnych komponentów dla każdego kroku, co poprawia organizację kodu i możliwość ponownego użycia:
1'use client';
2
3import { useState } from 'react';
4import { useRouter } from 'next/navigation';
5
6// Typy dla danych formularza
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// Komponent dla kroku 1: Dane użytkownika
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 // Czyszczenie błędów
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 = 'Imię jest wymagane';
57 }
58
59 if (!formData.lastName.trim()) {
60 newErrors.lastName = 'Nazwisko jest wymagane';
61 }
62
63 if (!formData.email.trim()) {
64 newErrors.email = 'Email jest wymagany';
65 } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+.[A-Z]{2,}$/i.test(formData.email)) {
66 newErrors.email = 'Niepoprawny format adresu email';
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">Dane osobowe</h2>
84
85 <div>
86 <label htmlFor="firstName" className="block text-sm font-medium text-gray-700">
87 Imię
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 Nazwisko
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 Dalej
148 </button>
149 </div>
150 </form>
151 );
152}
153
154// Komponent dla kroku 2: Adres dostawy
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 = 'Ulica jest wymagana';
179 }
180
181 if (!formData.city.trim()) {
182 newErrors.city = 'Miasto jest wymagane';
183 }
184
185 if (!formData.zipCode.trim()) {
186 newErrors.zipCode = 'Kod pocztowy jest wymagany';
187 }
188
189 if (!formData.country.trim()) {
190 newErrors.country = 'Kraj jest wymagany';
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">Adres dostawy</h2>
208
209 <div>
210 <label htmlFor="street" className="block text-sm font-medium text-gray-700">
211 Ulica i numer
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 Miasto
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 Kod pocztowy
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 Kraj
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 Wstecz
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 Dalej
298 </button>
299 </div>
300 </form>
301 );
302}
303
304// Komponent dla kroku 3: Płatność
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 = 'Numer karty jest wymagany';
329 } else if (!/^d{16}$/.test(formData.cardNumber.replace(/s/g, ''))) {
330 newErrors.cardNumber = 'Niepoprawny numer karty';
331 }
332
333 if (!formData.expiryDate.trim()) {
334 newErrors.expiryDate = 'Data ważności jest wymagana';
335 }
336
337 if (!formData.cvv.trim()) {
338 newErrors.cvv = 'Kod CVV jest wymagany';
339 } else if (!/^d{3,4}$/.test(formData.cvv)) {
340 newErrors.cvv = 'Niepoprawny kod CVV';
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">Dane płatności</h2>
358
359 <div>
360 <label htmlFor="cardNumber" className="block text-sm font-medium text-gray-700">
361 Numer karty
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 Data ważności
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 Wstecz
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 Złóż zamówienie
431 </button>
432 </div>
433 </form>
434 );
435}
436
437// Komponent potwierdzenia
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 // Symulacja wysyłki do API
449 console.log('Przesyłanie zamówienia:', data);
450
451 // Przykładowe wywołanie API (w rzeczywistej aplikacji)
452 // await fetch('/api/checkout', {
453 // method: 'POST',
454 // headers: { 'Content-Type': 'application/json' },
455 // body: JSON.stringify(data)
456 // });
457
458 // Przekierowanie do strony sukcesu
459 // router.push('/checkout/success');
460
461 // W przykładzie po prostu alertujemy sukces
462 alert('Zamówienie zostało złożone pomyślnie!');
463 } catch (error) {
464 console.error('Błąd podczas składania zamówienia:', error);
465 alert('Wystąpił błąd podczas składania zamówienia. Spróbuj ponownie.');
466 }
467 }
468
469 return (
470 <div className="space-y-6">
471 <h2 className="text-xl font-semibold">Potwierdzenie zamówienia</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">Dane osobowe</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 Edytuj
486 </button>
487 </div>
488
489 <div>
490 <h3 className="font-medium text-gray-700 mb-2">Adres dostawy</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 Edytuj
502 </button>
503 </div>
504
505 <div>
506 <h3 className="font-medium text-gray-700 mb-2">Sposób płatności</h3>
507 <div className="bg-gray-50 p-3 rounded">
508 <p>Karta: **** **** **** {data.payment.cardNumber.slice(-4)}</p>
509 <p>Ważna do: {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 Edytuj
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 Potwierdź i złóż zamówienie
528 </button>
529 </div>
530 </div>
531 );
532}
533
534// Główny komponent formularza wielokrokowego
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' : ''}>Dane osobowe</span>
590 <span className={step >= 2 ? 'text-blue-500 font-medium' : ''}>Adres</span>
591 <span className={step >= 3 ? 'text-blue-500 font-medium' : ''}>Płatność</span>
592 <span className={step >= 4 ? 'text-blue-500 font-medium' : ''}>Potwierdzenie</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}Zalety tego podejścia:
Dla aplikacji Next.js możemy wykorzystać Server Actions do obsługi danych formularza:
1// app/checkout/page.tsx
2'use client';
3
4import { useState } from 'react';
5import { submitUserData, submitAddressData, submitPaymentData, completeOrder } from './actions';
6
7// Podobne typy i komponenty formularzy jak w poprzednim przykładzie
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: { /* dane użytkownika */ },
14 address: { /* adres */ },
15 payment: { /* płatność */ }
16 });
17
18 async function handleUserSubmit(userData: UserFormData) {
19 try {
20 // Zapisujemy dane użytkownika z użyciem 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 // Obsługa błędów
29 alert(result.error || 'Wystąpił błąd. Spróbuj ponownie.');
30 }
31 } catch (error) {
32 console.error('Error submitting user data:', error);
33 alert('Wystąpił nieoczekiwany błąd. Spróbuj ponownie.');
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 || 'Wystąpił błąd. Spróbuj ponownie.');
48 }
49 } catch (error) {
50 console.error('Error submitting address data:', error);
51 alert('Wystąpił nieoczekiwany błąd. Spróbuj ponownie.');
52 }
53 }
54
55 // Podobne funkcje dla pozostałych kroków
56 //
57 // Render komponentów formularza dla każdego kroku, podobnie jak wcześniej
58 //
59}A oto przykładowa implementacja Server Actions:
1// app/checkout/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6// Schematy walidacji dla formularzy
7const UserSchema = z.object({
8 firstName: z.string().min(1, { message: 'Imię jest wymagane' }),
9 lastName: z.string().min(1, { message: 'Nazwisko jest wymagane' }),
10 email: z.string().email({ message: 'Niepoprawny adres email' })
11});
12
13const AddressSchema = z.object({
14 street: z.string().min(1, { message: 'Ulica jest wymagana' }),
15 city: z.string().min(1, { message: 'Miasto jest wymagane' }),
16 zipCode: z.string().min(1, { message: 'Kod pocztowy jest wymagany' }),
17 country: z.string().min(1, { message: 'Kraj jest wymagany' })
18});
19
20// Typy odpowiedzi z serwera
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 // Walidacja danych
28 const validatedData = UserSchema.safeParse(formData);
29
30 if (!validatedData.success) {
31 return {
32 success: false,
33 error: validatedData.error.errors[0]?.message || 'Niepoprawne dane'
34 };
35 }
36
37 // W prawdziwej aplikacji: zapisanie danych w bazie
38 // const user = await db.user.create({ data: validatedData.data });
39
40 // Symulacja utworzenia użytkownika
41 console.log('Zapisano dane użytkownika:', validatedData.data);
42
43 return {
44 success: true,
45 data: { userId: 'user_123' }
46 };
47 } catch (error) {
48 console.error('Server error when submitting user data:', error);
49 return {
50 success: false,
51 error: 'Wystąpił błąd podczas przetwarzania danych'
52 };
53 }
54}
55
56export async function submitAddressData(
57 userId: string,
58 formData: unknown
59): Promise<ServerResponse<{ addressId: string }>> {
60 try {
61 // Walidacja danych
62 const validatedData = AddressSchema.safeParse(formData);
63
64 if (!validatedData.success) {
65 return {
66 success: false,
67 error: validatedData.error.errors[0]?.message || 'Niepoprawne dane adresu'
68 };
69 }
70
71 // W prawdziwej aplikacji: zapisanie adresu w bazie
72 // const address = await db.address.create({
73 // data: { ...validatedData.data, userId }
74 // });
75
76 // Symulacja zapisania adresu
77 console.log('Zapisano adres dla użytkownika', userId, ':', validatedData.data);
78
79 return {
80 success: true,
81 data: { addressId: 'addr_456' }
82 };
83 } catch (error) {
84 console.error('Server error when submitting address data:', error);
85 return {
86 success: false,
87 error: 'Wystąpił błąd podczas przetwarzania adresu'
88 };
89 }
90}
91
92// Podobne funkcje dla płatności i finalizacji zamówienia
93//Zalety tego podejścia:
W przypadku bardziej złożonych formularzy warto rozważyć użycie bibliotek do zarządzania stanem formularza:
1// Przykład z React Hook Form
2import { useForm, FormProvider, useFormContext } from 'react-hook-form';
3import { zodResolver } from '@hookform/resolvers/zod';
4import { z } from 'zod';
5
6// Schemat walidacji
7const CheckoutSchema = z.object({
8 user: z.object({
9 firstName: z.string().min(1, 'Imię jest wymagane'),
10 lastName: z.string().min(1, 'Nazwisko jest wymagane'),
11 email: z.string().email('Niepoprawny adres email')
12 }),
13 address: z.object({
14 street: z.string().min(1, 'Ulica jest wymagana'),
15 city: z.string().min(1, 'Miasto jest wymagane'),
16 zipCode: z.string().min(1, 'Kod pocztowy jest wymagany'),
17 country: z.string().min(1, 'Kraj jest wymagany')
18 }),
19 payment: z.object({
20 cardNumber: z.string().refine(val => /^d{16}$/.test(val.replace(/s/g, '')), {
21 message: 'Niepoprawny numer karty'
22 }),
23 expiryDate: z.string().min(1, 'Data ważności jest wymagana'),
24 cvv: z.string().refine(val => /^d{3,4}$/.test(val), {
25 message: 'Niepoprawny kod CVV'
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">Dane osobowe</h2>
38
39 <div>
40 <label htmlFor="firstName" className="block text-sm font-medium">Imię</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 {/* Pozostałe pola... */}
54 </div>
55 );
56}
57
58// Podobnie definiujemy pozostałe kroki formularza
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('Przesyłanie danych:', data);
75 // Logika przesyłania danych do 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 {/* Pasek postępu */}
92
93 {/* Renderowanie odpowiedniego kroku */}
94 {step === 1 && <UserFormStep />}
95 {step === 2 && <AddressFormStep />}
96 {step === 3 && <PaymentFormStep />}
97
98 {/* Przyciski nawigacyjne */}
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 Wstecz
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 ? 'Złóż zamówienie' : 'Dalej'}
118 </button>
119 </div>
120 </form>
121 </FormProvider>
122 );
123}Dla bardzo złożonych formularzy może być korzystne użycie React Context API:
1// context/CheckoutContext.tsx
2import { createContext, useContext, useReducer, ReactNode } from 'react';
3
4// Typy danych formularza
5//
6// Stan formularza
7interface CheckoutState {
8 step: number;
9 user: UserFormData;
10 address: AddressFormData;
11 payment: PaymentFormData;
12 errors: Record<string, string>;
13}
14
15// Typy akcji
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// Początkowy stan
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// Typ kontekstu
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// Kontekst
66const CheckoutContext = createContext<CheckoutContextType | undefined>(undefined);
67
68// Provider
69export function CheckoutProvider({ children }: { children: ReactNode }) {
70 const [state, dispatch] = useReducer(checkoutReducer, initialState);
71
72 // Funkcje pomocnicze
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 // Walidacja kroków
96 const validateStep = (step: number): boolean => {
97 // Logika walidacji...
98 return true; // Uproszczenie dla przykładu
99 };
100
101 // Przesyłanie zamówienia
102 const submitOrder = async () => {
103 if (validateStep(state.step)) {
104 try {
105 // Logika przesyłania danych
106 console.log('Przesyłanie danych:', state);
107 } catch (error) {
108 console.error('Error submitting order:', 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 do użycia kontekstu
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}Czasami chcemy, aby użytkownicy przechodzili różnymi ścieżkami formularza w zależności od ich wyborów:
1// Przykładowa implementacja warunkowych ścieżek
2function determineNextStep(currentStep: number, formData: FormData): number {
3 if (currentStep === 1) {
4 // Jeśli użytkownik wybrał wysyłkę do innego adresu, dodaj krok adresu dostawy
5 if (formData.shippingDifferent) {
6 return 2; // Krok adresu dostawy
7 } else {
8 return 3; // Pomiń krok adresu dostawy, przejdź do płatności
9 }
10 }
11
12 if (currentStep === 3) {
13 // Jeśli użytkownik wybrał płatność przelewem, pomiń krok danych karty
14 if (formData.paymentMethod === 'bankTransfer') {
15 return 5; // Przejdź do podsumowania
16 } else {
17 return 4; // Krok danych karty
18 }
19 }
20
21 // Domyślne przejście do następnego kroku
22 return currentStep + 1;
23}
24
25// Użycie w komponencie
26function handleNext() {
27 const isValid = validateCurrentStep();
28
29 if (isValid) {
30 const nextStep = determineNextStep(step, formData);
31 setStep(nextStep);
32 }
33}Dla długich formularzy warto umożliwić zapisanie postępu:
1'use client';
2
3import { useState, useEffect } from 'react';
4
5// Zapisywanie stanu formularza
6function saveFormState(data: FormData) {
7 localStorage.setItem('checkout_form', JSON.stringify(data));
8 localStorage.setItem('checkout_timestamp', Date.now().toString());
9}
10
11// Odtwarzanie stanu formularza
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 // Sprawdź, czy zapisane dane nie są za stare (np. 24 godziny)
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 // Dane są za stare, usuń je
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 // Domyślny stan formularza
42 });
43
44 const [step, setStep] = useState(1);
45 const [hasRestoredState, setHasRestoredState] = useState(false);
46
47 // Próba odtworzenia stanu formularza przy pierwszym renderowaniu
48 useEffect(() => {
49 if (!hasRestoredState) {
50 const savedState = getSavedFormState();
51
52 if (savedState) {
53 setFormData(savedState);
54 setHasRestoredState(true);
55
56 // Opcjonalnie, zapytaj użytkownika, czy chce kontynuować od zapisanego miejsca
57 const wishToContinue = window.confirm(
58 'Znaleziono niekompletny formularz z poprzedniej sesji. Czy chcesz kontynuować od zapisanego miejsca?'
59 );
60
61 if (wishToContinue) {
62 // Możemy ustalić, na którym kroku użytkownik zakończył
63 // np. na podstawie tego, które dane są już wypełnione
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 // Użytkownik nie chce kontynuować, wyczyść zapisane dane
73 localStorage.removeItem('checkout_form');
74 localStorage.removeItem('checkout_timestamp');
75 setFormData({
76 // Zresetuj do domyślnego stanu
77 });
78 }
79 }
80
81 setHasRestoredState(true);
82 }
83 }, [hasRestoredState]);
84
85 // Zapisuj stan formularza przy zmianach
86 useEffect(() => {
87 if (hasRestoredState) {
88 saveFormState(formData);
89 }
90 }, [formData, hasRestoredState]);
91
92 // Reszta komponentu...
93}W niektórych formularzach wieloetapowych użytkownicy mogą potrzebować dynamicznie dodawać lub usuwać elementy:
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 // Nie usuwaj ostatniego elementu
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">Elementy zamówienia</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="flex-grow">
41 <label htmlFor={`item-name-${item.id}`} className="block text-sm font-medium">
42 Nazwa produktu
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 Ilość
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="Usuń element"
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 + Dodaj więcej produktów
84 </button>
85 </div>
86 );
87}Pokaż postęp - wskazuj użytkownikowi, w którym miejscu procesu się znajduje (pasek postępu, numeracja kroków)
Zachowaj dane - użytkownicy nie powinni tracić wprowadzonych danych w przypadku odświeżenia strony lub przypadkowego zamknięcia
Umożliw nawigację między krokami - pozwól użytkownikom wrócić do poprzednich kroków i edytować dane
Waliduj każdy krok - sprawdzaj poprawność danych przed przejściem do następnego kroku
Dostarczaj jasne komunikaty o błędach - informuj użytkownika dokładnie, co poszło nie tak
Zapewnij możliwość zapisania i powrotu później - zwłaszcza dla długich formularzy
Dostosuj kroki do urządzenia - na mniejszych ekranach rozważ jeszcze mniejsze kroki
Zapewnij podsumowanie przed finalnym przesłaniem - daj użytkownikom przegląd wszystkich wprowadzonych danych
Obsługuj wstecz/dalej w przeglądarce - dostosuj aplikację do działania przycisku wstecz/dalej przeglądarki
Testuj różne scenariusze - upewnij się, że formularz działa poprawnie zarówno w przypadku standardowego wypełniania, jak i w scenariuszach takich jak:
W Metropolii Quantum roku 2150, gdzie każdy cykl procesora ma znaczenie, developerzy często szukają narzędzi, które są zarówno potężne, jak i lekkie. Drizzle ORM to nowoczesna alternatywa dla Prisma, która oferuje type safety przy jednoczesnym zachowaniu bliskości do czystego SQL.
Zarówno Drizzle jak i Prisma to type-safe ORM-y dla TypeScript, ale różnią się w filozofii i podejściu:
| Aspekt | Drizzle ORM | Prisma | |--------|-------------|--------| | Filozofia | SQL-like, bliżej metalu | Abstraction-first, przyjazne API | | Rozmiar | ~20KB | ~600KB+ | | Migracje | SQL lub TypeScript | Własny język DSL | | Queries | Przypominają SQL | Własne API | | Wydajność | Bardzo szybki | Dobry | | Krzywa uczenia | Łagodna dla znających SQL | Łagodna dla początkujących | | Type safety | Pełny | Pełny | | Edge Runtime | Natywne wsparcie | Wymaga Data Proxy |
Drizzle to "thin TypeScript layer" nad SQL, co oznacza, że:
1// Drizzle - przypomina SQL
2const result = await db
3 .select()
4 .from(users)
5 .where(eq(users.email, 'user@example.com'))
6 .limit(1);
7
8// Prisma - bardziej abstrakcyjne
9const result = await prisma.user.findUnique({
10 where: { email: 'user@example.com' }
11});Drizzle oferuje pełne wsparcie TypeScript, wykrywając błędy już na etapie pisania kodu:
1// db/schema.ts
2import { pgTable, serial, text, varchar, timestamp, integer, boolean } from 'drizzle-orm/pg-core';
3import { relations } from 'drizzle-orm';
4
5// Tabela użytkowników
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// Tabela postów
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// Tabela komentarzy
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// Relacje między tabelami
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// Typy TypeScript automatycznie wygenerowane
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;1// db/index.ts
2import { drizzle } from 'drizzle-orm/postgres-js';
3import postgres from 'postgres';
4import * as schema from './schema';
5
6// Klient PostgreSQL
7const connectionString = process.env.DATABASE_URL!;
8const client = postgres(connectionString);
9
10// Drizzle instance
11export const db = drizzle(client, { schema });
12
13// Dla 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 });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 - pobieranie danych
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 z wieloma warunkami
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 - tworzenie użytkownika
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 - aktualizacja użytkownika
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 - usuwanie użytkownika
69export async function deleteUser(id: number) {
70 await db
71 .delete(users)
72 .where(eq(users.id, id));
73}
74
75// JOIN - pobieranie użytkownika z postami
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// Agregacje
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// Transakcje
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 // Utwórz użytkownika
105 const [user] = await tx
106 .insert(users)
107 .values(userData)
108 .returning();
109
110 // Utwórz post dla tego użytkownika
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}1// Najprostszy sposób na JOIN-y
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// Częściowy select z relacjami
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// Filtrowanie w relacjach
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}1import { sql } from 'drizzle-orm';
2
3// Raw SQL dla zaawansowanych queries
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// Łączenie type-safe queries z raw SQL
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}Drizzle oferuje dwa sposoby na migracje:
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# Generuj migracje
2npx drizzle-kit generate:pg
3
4# Wykonaj migracje
5npx drizzle-kit push:pg
6
7# Lub używając własnego skryptu
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();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);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 // Query wykonywane w 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>Użytkownik nie znaleziony</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">Posty</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}Wybierz Drizzle gdy:
Wybierz Prisma gdy:
W Metropolii Quantum, gdzie aplikacje muszą być zarówno potężne jak i lekkie, Drizzle oferuje doskonałą alternatywę. Jego bliskość do SQL oznacza, że doświadczeni developerzy mogą łatwo optymalizować queries, podczas gdy pełne wsparcie TypeScript zapewnia bezpieczeństwo typów na każdym kroku.
1// Przykład real-world: Dashboard z analytics
2export async function getDashboardStats(userId: number) {
3 // Wszystko type-safe, przypomina SQL, szybkie
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}Wielokrokowe formularze to potężne narzędzie do zbierania złożonych danych od użytkowników w sposób przyjazny i zorganizowany. Istnieje wiele strategii ich implementacji, od prostych opartych o stan komponentu, przez modułowe podejście z oddzielnymi komponentami dla każdego kroku, po zaawansowane rozwiązania wykorzystujące kontekst React i biblioteki do zarządzania formularzami.
W React i Next.js mamy elastyczne możliwości implementacji takich formularzy, z dodatkowymi zaletami jakie oferują Server Actions w Next.js dla progresywnego ulepszania i bezpieczeństwa danych.
Kluczem do sukcesu jest wybór podejścia odpowiedniego do złożoności formularza i potrzeb aplikacji, jednocześnie zapewniając użytkownikom płynne i bezproblemowe doświadczenie.