@moontra/moonui-pro 2.6.1 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +304 -20
- package/dist/index.mjs +15467 -2135
- package/package.json +1 -1
- package/src/components/credit-card-input/index.tsx +406 -0
- package/src/components/form-wizard/form-wizard-context.tsx +248 -0
- package/src/components/form-wizard/form-wizard-navigation.tsx +118 -0
- package/src/components/form-wizard/form-wizard-progress.tsx +193 -0
- package/src/components/form-wizard/form-wizard-step.tsx +100 -0
- package/src/components/form-wizard/index.tsx +105 -0
- package/src/components/form-wizard/types.ts +76 -0
- package/src/components/index.ts +16 -1
- package/src/components/multi-step-form/index.tsx +223 -0
- package/src/components/phone-number-input/index.tsx +335 -0
- package/src/components/quiz-form/index.tsx +479 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moontra/moonui-pro",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "Premium React components for MoonUI - Advanced UI library with 50+ pro components including performance, interactive, and gesture components",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.mjs",
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useEffect } from "react"
|
|
4
|
+
import { cn } from "../../lib/utils"
|
|
5
|
+
import { Input } from "../ui/input"
|
|
6
|
+
import { Label } from "../ui/label"
|
|
7
|
+
import { CreditCard, Lock } from "lucide-react"
|
|
8
|
+
import { motion, AnimatePresence } from "framer-motion"
|
|
9
|
+
|
|
10
|
+
export interface CreditCardInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
|
|
11
|
+
value?: {
|
|
12
|
+
number: string
|
|
13
|
+
expiry: string
|
|
14
|
+
cvc: string
|
|
15
|
+
name?: string
|
|
16
|
+
}
|
|
17
|
+
onChange?: (value: {
|
|
18
|
+
number: string
|
|
19
|
+
expiry: string
|
|
20
|
+
cvc: string
|
|
21
|
+
name?: string
|
|
22
|
+
}) => void
|
|
23
|
+
showCardPreview?: boolean
|
|
24
|
+
showCardType?: boolean
|
|
25
|
+
showSecurityBadge?: boolean
|
|
26
|
+
autoFormat?: boolean
|
|
27
|
+
validateOnChange?: boolean
|
|
28
|
+
labels?: {
|
|
29
|
+
number?: string
|
|
30
|
+
expiry?: string
|
|
31
|
+
cvc?: string
|
|
32
|
+
name?: string
|
|
33
|
+
}
|
|
34
|
+
placeholders?: {
|
|
35
|
+
number?: string
|
|
36
|
+
expiry?: string
|
|
37
|
+
cvc?: string
|
|
38
|
+
name?: string
|
|
39
|
+
}
|
|
40
|
+
errors?: {
|
|
41
|
+
number?: string
|
|
42
|
+
expiry?: string
|
|
43
|
+
cvc?: string
|
|
44
|
+
name?: string
|
|
45
|
+
}
|
|
46
|
+
className?: string
|
|
47
|
+
inputClassName?: string
|
|
48
|
+
labelClassName?: string
|
|
49
|
+
errorClassName?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Kart tipi tespiti için regex patternleri
|
|
53
|
+
const cardPatterns = {
|
|
54
|
+
visa: /^4/,
|
|
55
|
+
mastercard: /^5[1-5]/,
|
|
56
|
+
amex: /^3[47]/,
|
|
57
|
+
discover: /^6(?:011|5)/,
|
|
58
|
+
diners: /^3(?:0[0-5]|[68])/,
|
|
59
|
+
jcb: /^35/,
|
|
60
|
+
unionpay: /^62/
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Kart numarası formatlama fonksiyonu
|
|
64
|
+
function formatCardNumber(value: string, cardType?: string): string {
|
|
65
|
+
const v = value.replace(/\s+/g, '').replace(/[^0-9]/gi, '')
|
|
66
|
+
const matches = cardType === 'amex'
|
|
67
|
+
? v.match(/\d{1,4}/g)
|
|
68
|
+
: v.match(/\d{1,4}/g)
|
|
69
|
+
return matches ? matches.join(' ') : v
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Expiry formatlama fonksiyonu
|
|
73
|
+
function formatExpiry(value: string): string {
|
|
74
|
+
const v = value.replace(/\s+/g, '').replace(/[^0-9]/gi, '')
|
|
75
|
+
if (v.length >= 2) {
|
|
76
|
+
return v.slice(0, 2) + (v.length > 2 ? '/' + v.slice(2, 4) : '')
|
|
77
|
+
}
|
|
78
|
+
return v
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Kart tipi tespiti
|
|
82
|
+
function detectCardType(number: string): string | null {
|
|
83
|
+
const cleanNumber = number.replace(/\s+/g, '')
|
|
84
|
+
for (const [type, pattern] of Object.entries(cardPatterns)) {
|
|
85
|
+
if (pattern.test(cleanNumber)) {
|
|
86
|
+
return type
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Luhn algoritması ile kart numarası doğrulama
|
|
93
|
+
function validateCardNumber(number: string): boolean {
|
|
94
|
+
const cleanNumber = number.replace(/\s+/g, '')
|
|
95
|
+
if (!/^\d+$/.test(cleanNumber)) return false
|
|
96
|
+
|
|
97
|
+
let sum = 0
|
|
98
|
+
let isEven = false
|
|
99
|
+
|
|
100
|
+
for (let i = cleanNumber.length - 1; i >= 0; i--) {
|
|
101
|
+
let digit = parseInt(cleanNumber.charAt(i), 10)
|
|
102
|
+
|
|
103
|
+
if (isEven) {
|
|
104
|
+
digit *= 2
|
|
105
|
+
if (digit > 9) {
|
|
106
|
+
digit -= 9
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
sum += digit
|
|
111
|
+
isEven = !isEven
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return sum % 10 === 0
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Expiry doğrulama
|
|
118
|
+
function validateExpiry(expiry: string): boolean {
|
|
119
|
+
const parts = expiry.split('/')
|
|
120
|
+
if (parts.length !== 2) return false
|
|
121
|
+
|
|
122
|
+
const month = parseInt(parts[0], 10)
|
|
123
|
+
const year = parseInt('20' + parts[1], 10)
|
|
124
|
+
|
|
125
|
+
if (month < 1 || month > 12) return false
|
|
126
|
+
|
|
127
|
+
const now = new Date()
|
|
128
|
+
const expiryDate = new Date(year, month - 1)
|
|
129
|
+
|
|
130
|
+
return expiryDate > now
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const MoonUICreditCardInputPro = React.forwardRef<HTMLDivElement, CreditCardInputProps>(({
|
|
134
|
+
value = { number: '', expiry: '', cvc: '', name: '' },
|
|
135
|
+
onChange,
|
|
136
|
+
showCardPreview = true,
|
|
137
|
+
showCardType = true,
|
|
138
|
+
showSecurityBadge = true,
|
|
139
|
+
autoFormat = true,
|
|
140
|
+
validateOnChange = false,
|
|
141
|
+
labels = {},
|
|
142
|
+
placeholders = {},
|
|
143
|
+
errors = {},
|
|
144
|
+
className,
|
|
145
|
+
inputClassName,
|
|
146
|
+
labelClassName,
|
|
147
|
+
errorClassName,
|
|
148
|
+
disabled,
|
|
149
|
+
required,
|
|
150
|
+
...props
|
|
151
|
+
}, ref) => {
|
|
152
|
+
const [cardType, setCardType] = useState<string | null>(null)
|
|
153
|
+
const [focused, setFocused] = useState<string | null>(null)
|
|
154
|
+
const [localErrors, setLocalErrors] = useState<typeof errors>({})
|
|
155
|
+
|
|
156
|
+
const numberRef = useRef<HTMLInputElement>(null)
|
|
157
|
+
const expiryRef = useRef<HTMLInputElement>(null)
|
|
158
|
+
const cvcRef = useRef<HTMLInputElement>(null)
|
|
159
|
+
const nameRef = useRef<HTMLInputElement>(null)
|
|
160
|
+
|
|
161
|
+
// Kart tipini tespit et
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
const detectedType = detectCardType(value.number)
|
|
164
|
+
setCardType(detectedType)
|
|
165
|
+
}, [value.number])
|
|
166
|
+
|
|
167
|
+
// Doğrulama
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (!validateOnChange) return
|
|
170
|
+
|
|
171
|
+
const newErrors: typeof errors = {}
|
|
172
|
+
|
|
173
|
+
if (value.number && !validateCardNumber(value.number)) {
|
|
174
|
+
newErrors.number = 'Invalid card number'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (value.expiry && !validateExpiry(value.expiry)) {
|
|
178
|
+
newErrors.expiry = 'Invalid expiry date'
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (value.cvc && (value.cvc.length < 3 || value.cvc.length > 4)) {
|
|
182
|
+
newErrors.cvc = 'Invalid CVC'
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
setLocalErrors(newErrors)
|
|
186
|
+
}, [value, validateOnChange])
|
|
187
|
+
|
|
188
|
+
const handleChange = (field: keyof typeof value, newValue: string) => {
|
|
189
|
+
let formattedValue = newValue
|
|
190
|
+
|
|
191
|
+
if (autoFormat) {
|
|
192
|
+
if (field === 'number') {
|
|
193
|
+
formattedValue = formatCardNumber(newValue, cardType || undefined)
|
|
194
|
+
// Maksimum uzunluk kontrolü
|
|
195
|
+
const maxLength = cardType === 'amex' ? 18 : 19 // Boşluklarla birlikte
|
|
196
|
+
if (formattedValue.length > maxLength) return
|
|
197
|
+
} else if (field === 'expiry') {
|
|
198
|
+
formattedValue = formatExpiry(newValue)
|
|
199
|
+
if (formattedValue.length > 5) return
|
|
200
|
+
} else if (field === 'cvc') {
|
|
201
|
+
formattedValue = newValue.replace(/\D/g, '')
|
|
202
|
+
const maxLength = cardType === 'amex' ? 4 : 3
|
|
203
|
+
if (formattedValue.length > maxLength) return
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
onChange?.({
|
|
208
|
+
...value,
|
|
209
|
+
[field]: formattedValue
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// Otomatik geçiş
|
|
213
|
+
if (autoFormat) {
|
|
214
|
+
if (field === 'number' && formattedValue.length === (cardType === 'amex' ? 18 : 19)) {
|
|
215
|
+
expiryRef.current?.focus()
|
|
216
|
+
} else if (field === 'expiry' && formattedValue.length === 5) {
|
|
217
|
+
cvcRef.current?.focus()
|
|
218
|
+
} else if (field === 'cvc' && formattedValue.length === (cardType === 'amex' ? 4 : 3)) {
|
|
219
|
+
nameRef.current?.focus()
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const displayErrors = { ...localErrors, ...errors }
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<div ref={ref} className={cn("space-y-4", className)} {...props}>
|
|
228
|
+
{showCardPreview && (
|
|
229
|
+
<motion.div
|
|
230
|
+
className="relative w-full max-w-md mx-auto h-48 rounded-xl bg-gradient-to-r from-gray-700 to-gray-900 p-6 text-white shadow-xl"
|
|
231
|
+
initial={{ opacity: 0, y: -20 }}
|
|
232
|
+
animate={{ opacity: 1, y: 0 }}
|
|
233
|
+
transition={{ duration: 0.3 }}
|
|
234
|
+
>
|
|
235
|
+
<div className="flex justify-between items-start mb-8">
|
|
236
|
+
<div className="text-lg font-semibold">
|
|
237
|
+
{cardType ? cardType.toUpperCase() : 'CARD'}
|
|
238
|
+
</div>
|
|
239
|
+
<CreditCard className="w-8 h-8" />
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<div className="space-y-4">
|
|
243
|
+
<div className="text-xl font-mono tracking-wider">
|
|
244
|
+
{value.number || '•••• •••• •••• ••••'}
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<div className="flex justify-between">
|
|
248
|
+
<div>
|
|
249
|
+
<div className="text-xs opacity-70">NAME</div>
|
|
250
|
+
<div className="text-sm uppercase">
|
|
251
|
+
{value.name || 'YOUR NAME'}
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<div>
|
|
256
|
+
<div className="text-xs opacity-70">EXPIRES</div>
|
|
257
|
+
<div className="text-sm">
|
|
258
|
+
{value.expiry || 'MM/YY'}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
</motion.div>
|
|
264
|
+
)}
|
|
265
|
+
|
|
266
|
+
<div className="space-y-4">
|
|
267
|
+
{/* Kart Numarası */}
|
|
268
|
+
<div className="space-y-1">
|
|
269
|
+
<Label htmlFor="card-number" className={labelClassName}>
|
|
270
|
+
{labels.number || 'Card Number'}
|
|
271
|
+
{required && <span className="text-destructive ml-1">*</span>}
|
|
272
|
+
</Label>
|
|
273
|
+
<div className="relative">
|
|
274
|
+
<Input
|
|
275
|
+
ref={numberRef}
|
|
276
|
+
id="card-number"
|
|
277
|
+
type="text"
|
|
278
|
+
inputMode="numeric"
|
|
279
|
+
value={value.number}
|
|
280
|
+
onChange={(e) => handleChange('number', e.target.value)}
|
|
281
|
+
onFocus={() => setFocused('number')}
|
|
282
|
+
onBlur={() => setFocused(null)}
|
|
283
|
+
placeholder={placeholders.number || '1234 5678 9012 3456'}
|
|
284
|
+
className={cn(
|
|
285
|
+
"pr-20",
|
|
286
|
+
displayErrors.number && "border-destructive",
|
|
287
|
+
inputClassName
|
|
288
|
+
)}
|
|
289
|
+
disabled={disabled}
|
|
290
|
+
/>
|
|
291
|
+
{showCardType && cardType && (
|
|
292
|
+
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-medium text-muted-foreground">
|
|
293
|
+
{cardType.toUpperCase()}
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
{displayErrors.number && (
|
|
298
|
+
<p className={cn("text-sm text-destructive", errorClassName)}>
|
|
299
|
+
{displayErrors.number}
|
|
300
|
+
</p>
|
|
301
|
+
)}
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
{/* Expiry ve CVC */}
|
|
305
|
+
<div className="grid grid-cols-2 gap-4">
|
|
306
|
+
<div className="space-y-1">
|
|
307
|
+
<Label htmlFor="card-expiry" className={labelClassName}>
|
|
308
|
+
{labels.expiry || 'Expiry Date'}
|
|
309
|
+
{required && <span className="text-destructive ml-1">*</span>}
|
|
310
|
+
</Label>
|
|
311
|
+
<Input
|
|
312
|
+
ref={expiryRef}
|
|
313
|
+
id="card-expiry"
|
|
314
|
+
type="text"
|
|
315
|
+
inputMode="numeric"
|
|
316
|
+
value={value.expiry}
|
|
317
|
+
onChange={(e) => handleChange('expiry', e.target.value)}
|
|
318
|
+
onFocus={() => setFocused('expiry')}
|
|
319
|
+
onBlur={() => setFocused(null)}
|
|
320
|
+
placeholder={placeholders.expiry || 'MM/YY'}
|
|
321
|
+
className={cn(
|
|
322
|
+
displayErrors.expiry && "border-destructive",
|
|
323
|
+
inputClassName
|
|
324
|
+
)}
|
|
325
|
+
disabled={disabled}
|
|
326
|
+
/>
|
|
327
|
+
{displayErrors.expiry && (
|
|
328
|
+
<p className={cn("text-sm text-destructive", errorClassName)}>
|
|
329
|
+
{displayErrors.expiry}
|
|
330
|
+
</p>
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
|
|
334
|
+
<div className="space-y-1">
|
|
335
|
+
<Label htmlFor="card-cvc" className={labelClassName}>
|
|
336
|
+
{labels.cvc || 'CVC'}
|
|
337
|
+
{required && <span className="text-destructive ml-1">*</span>}
|
|
338
|
+
</Label>
|
|
339
|
+
<div className="relative">
|
|
340
|
+
<Input
|
|
341
|
+
ref={cvcRef}
|
|
342
|
+
id="card-cvc"
|
|
343
|
+
type="text"
|
|
344
|
+
inputMode="numeric"
|
|
345
|
+
value={value.cvc}
|
|
346
|
+
onChange={(e) => handleChange('cvc', e.target.value)}
|
|
347
|
+
onFocus={() => setFocused('cvc')}
|
|
348
|
+
onBlur={() => setFocused(null)}
|
|
349
|
+
placeholder={placeholders.cvc || cardType === 'amex' ? '1234' : '123'}
|
|
350
|
+
className={cn(
|
|
351
|
+
displayErrors.cvc && "border-destructive",
|
|
352
|
+
inputClassName
|
|
353
|
+
)}
|
|
354
|
+
disabled={disabled}
|
|
355
|
+
/>
|
|
356
|
+
{showSecurityBadge && (
|
|
357
|
+
<Lock className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
358
|
+
)}
|
|
359
|
+
</div>
|
|
360
|
+
{displayErrors.cvc && (
|
|
361
|
+
<p className={cn("text-sm text-destructive", errorClassName)}>
|
|
362
|
+
{displayErrors.cvc}
|
|
363
|
+
</p>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
|
|
368
|
+
{/* Kart Sahibi Adı */}
|
|
369
|
+
<div className="space-y-1">
|
|
370
|
+
<Label htmlFor="card-name" className={labelClassName}>
|
|
371
|
+
{labels.name || 'Cardholder Name'}
|
|
372
|
+
</Label>
|
|
373
|
+
<Input
|
|
374
|
+
ref={nameRef}
|
|
375
|
+
id="card-name"
|
|
376
|
+
type="text"
|
|
377
|
+
value={value.name}
|
|
378
|
+
onChange={(e) => handleChange('name', e.target.value)}
|
|
379
|
+
onFocus={() => setFocused('name')}
|
|
380
|
+
onBlur={() => setFocused(null)}
|
|
381
|
+
placeholder={placeholders.name || 'John Doe'}
|
|
382
|
+
className={cn(
|
|
383
|
+
displayErrors.name && "border-destructive",
|
|
384
|
+
inputClassName
|
|
385
|
+
)}
|
|
386
|
+
disabled={disabled}
|
|
387
|
+
/>
|
|
388
|
+
{displayErrors.name && (
|
|
389
|
+
<p className={cn("text-sm text-destructive", errorClassName)}>
|
|
390
|
+
{displayErrors.name}
|
|
391
|
+
</p>
|
|
392
|
+
)}
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
{showSecurityBadge && (
|
|
397
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
398
|
+
<Lock className="w-4 h-4" />
|
|
399
|
+
<span>Your payment information is secure and encrypted</span>
|
|
400
|
+
</div>
|
|
401
|
+
)}
|
|
402
|
+
</div>
|
|
403
|
+
)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
MoonUICreditCardInputPro.displayName = "MoonUICreditCardInputPro"
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, useState, useCallback, useEffect } from "react"
|
|
4
|
+
import { WizardContextValue, WizardStep, FormWizardProps } from "./types"
|
|
5
|
+
|
|
6
|
+
const FormWizardContext = createContext<WizardContextValue | null>(null)
|
|
7
|
+
|
|
8
|
+
export const useFormWizard = () => {
|
|
9
|
+
const context = useContext(FormWizardContext)
|
|
10
|
+
if (!context) {
|
|
11
|
+
throw new Error("useFormWizard must be used within a FormWizardProvider")
|
|
12
|
+
}
|
|
13
|
+
return context
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface FormWizardProviderProps {
|
|
17
|
+
children: React.ReactNode
|
|
18
|
+
steps: WizardStep[]
|
|
19
|
+
initialStep?: number
|
|
20
|
+
onStepChange?: FormWizardProps['onStepChange']
|
|
21
|
+
onComplete?: FormWizardProps['onComplete']
|
|
22
|
+
validateOnStepChange?: boolean
|
|
23
|
+
allowBackNavigation?: boolean
|
|
24
|
+
allowStepSkip?: boolean
|
|
25
|
+
autoSave?: boolean
|
|
26
|
+
autoSaveDelay?: number
|
|
27
|
+
onAutoSave?: FormWizardProps['onAutoSave']
|
|
28
|
+
persistData?: boolean
|
|
29
|
+
storageKey?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const FormWizardProvider: React.FC<FormWizardProviderProps> = ({
|
|
33
|
+
children,
|
|
34
|
+
steps,
|
|
35
|
+
initialStep = 0,
|
|
36
|
+
onStepChange,
|
|
37
|
+
onComplete,
|
|
38
|
+
validateOnStepChange = true,
|
|
39
|
+
allowBackNavigation = true,
|
|
40
|
+
allowStepSkip = false,
|
|
41
|
+
autoSave = false,
|
|
42
|
+
autoSaveDelay = 2000,
|
|
43
|
+
onAutoSave,
|
|
44
|
+
persistData = false,
|
|
45
|
+
storageKey = "form-wizard-data"
|
|
46
|
+
}) => {
|
|
47
|
+
const [currentStep, setCurrentStep] = useState(initialStep)
|
|
48
|
+
const [stepData, setStepData] = useState<Record<string, any>>({})
|
|
49
|
+
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set())
|
|
50
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
51
|
+
const [error, setError] = useState<string | null>(null)
|
|
52
|
+
|
|
53
|
+
// Load persisted data on mount
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (persistData && typeof window !== 'undefined') {
|
|
56
|
+
const savedData = localStorage.getItem(storageKey)
|
|
57
|
+
if (savedData) {
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(savedData)
|
|
60
|
+
setStepData(parsed.stepData || {})
|
|
61
|
+
setCurrentStep(parsed.currentStep || 0)
|
|
62
|
+
setCompletedSteps(new Set(parsed.completedSteps || []))
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.error("Failed to load persisted wizard data:", e)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}, [persistData, storageKey])
|
|
69
|
+
|
|
70
|
+
// Auto-save functionality
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!autoSave || !onAutoSave) return
|
|
73
|
+
|
|
74
|
+
const timeoutId = setTimeout(() => {
|
|
75
|
+
const currentStepData = stepData[steps[currentStep].id]
|
|
76
|
+
if (currentStepData) {
|
|
77
|
+
onAutoSave(steps[currentStep].id, currentStepData)
|
|
78
|
+
}
|
|
79
|
+
}, autoSaveDelay)
|
|
80
|
+
|
|
81
|
+
return () => clearTimeout(timeoutId)
|
|
82
|
+
}, [stepData, currentStep, autoSave, autoSaveDelay, onAutoSave, steps])
|
|
83
|
+
|
|
84
|
+
// Persist data when it changes
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (persistData && typeof window !== 'undefined') {
|
|
87
|
+
const dataToSave = {
|
|
88
|
+
stepData,
|
|
89
|
+
currentStep,
|
|
90
|
+
completedSteps: Array.from(completedSteps)
|
|
91
|
+
}
|
|
92
|
+
localStorage.setItem(storageKey, JSON.stringify(dataToSave))
|
|
93
|
+
}
|
|
94
|
+
}, [stepData, currentStep, completedSteps, persistData, storageKey])
|
|
95
|
+
|
|
96
|
+
const validateCurrentStep = useCallback(async (): Promise<boolean> => {
|
|
97
|
+
const step = steps[currentStep]
|
|
98
|
+
if (!step.validation) return true
|
|
99
|
+
|
|
100
|
+
setIsLoading(true)
|
|
101
|
+
setError(null)
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const isValid = await step.validation()
|
|
105
|
+
if (!isValid) {
|
|
106
|
+
setError("Please complete all required fields before proceeding")
|
|
107
|
+
}
|
|
108
|
+
return isValid
|
|
109
|
+
} catch (err) {
|
|
110
|
+
setError(err instanceof Error ? err.message : "Validation failed")
|
|
111
|
+
return false
|
|
112
|
+
} finally {
|
|
113
|
+
setIsLoading(false)
|
|
114
|
+
}
|
|
115
|
+
}, [currentStep, steps])
|
|
116
|
+
|
|
117
|
+
const updateStepData = useCallback((stepId: string, data: any) => {
|
|
118
|
+
setStepData(prev => ({
|
|
119
|
+
...prev,
|
|
120
|
+
[stepId]: { ...prev[stepId], ...data }
|
|
121
|
+
}))
|
|
122
|
+
setError(null)
|
|
123
|
+
}, [])
|
|
124
|
+
|
|
125
|
+
const goToStep = useCallback(async (stepIndex: number) => {
|
|
126
|
+
if (stepIndex < 0 || stepIndex >= steps.length) return
|
|
127
|
+
if (stepIndex === currentStep) return
|
|
128
|
+
|
|
129
|
+
const targetStep = steps[stepIndex]
|
|
130
|
+
const isDisabled = typeof targetStep.isDisabled === 'function'
|
|
131
|
+
? targetStep.isDisabled(currentStep, steps)
|
|
132
|
+
: targetStep.isDisabled
|
|
133
|
+
|
|
134
|
+
if (isDisabled) return
|
|
135
|
+
|
|
136
|
+
// Validate current step if moving forward
|
|
137
|
+
if (validateOnStepChange && stepIndex > currentStep) {
|
|
138
|
+
const isValid = await validateCurrentStep()
|
|
139
|
+
if (!isValid) return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Call exit handler for current step
|
|
143
|
+
const currentStepObj = steps[currentStep]
|
|
144
|
+
if (currentStepObj.onExit) {
|
|
145
|
+
await currentStepObj.onExit()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Update completed steps
|
|
149
|
+
if (stepIndex > currentStep) {
|
|
150
|
+
setCompletedSteps(prev => new Set([...prev, currentStep]))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Call enter handler for new step
|
|
154
|
+
if (targetStep.onEnter) {
|
|
155
|
+
await targetStep.onEnter()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const previousStep = currentStep
|
|
159
|
+
setCurrentStep(stepIndex)
|
|
160
|
+
onStepChange?.(stepIndex, previousStep)
|
|
161
|
+
}, [currentStep, steps, validateOnStepChange, validateCurrentStep, onStepChange])
|
|
162
|
+
|
|
163
|
+
const goToNext = useCallback(() => {
|
|
164
|
+
goToStep(currentStep + 1)
|
|
165
|
+
}, [currentStep, goToStep])
|
|
166
|
+
|
|
167
|
+
const goToPrevious = useCallback(() => {
|
|
168
|
+
if (!allowBackNavigation) return
|
|
169
|
+
goToStep(currentStep - 1)
|
|
170
|
+
}, [currentStep, goToStep, allowBackNavigation])
|
|
171
|
+
|
|
172
|
+
const completeWizard = useCallback(async () => {
|
|
173
|
+
const isValid = await validateCurrentStep()
|
|
174
|
+
if (!isValid) return
|
|
175
|
+
|
|
176
|
+
setCompletedSteps(prev => new Set([...prev, currentStep]))
|
|
177
|
+
|
|
178
|
+
if (onComplete) {
|
|
179
|
+
setIsLoading(true)
|
|
180
|
+
try {
|
|
181
|
+
await onComplete(stepData)
|
|
182
|
+
|
|
183
|
+
// Clear persisted data after successful completion
|
|
184
|
+
if (persistData && typeof window !== 'undefined') {
|
|
185
|
+
localStorage.removeItem(storageKey)
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
setError(err instanceof Error ? err.message : "Failed to complete wizard")
|
|
189
|
+
} finally {
|
|
190
|
+
setIsLoading(false)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}, [currentStep, stepData, validateCurrentStep, onComplete, persistData, storageKey])
|
|
194
|
+
|
|
195
|
+
const resetWizard = useCallback(() => {
|
|
196
|
+
setCurrentStep(0)
|
|
197
|
+
setStepData({})
|
|
198
|
+
setCompletedSteps(new Set())
|
|
199
|
+
setError(null)
|
|
200
|
+
|
|
201
|
+
if (persistData && typeof window !== 'undefined') {
|
|
202
|
+
localStorage.removeItem(storageKey)
|
|
203
|
+
}
|
|
204
|
+
}, [persistData, storageKey])
|
|
205
|
+
|
|
206
|
+
const isStepCompleted = useCallback((stepIndex: number) => {
|
|
207
|
+
return completedSteps.has(stepIndex)
|
|
208
|
+
}, [completedSteps])
|
|
209
|
+
|
|
210
|
+
const isStepAccessible = useCallback((stepIndex: number) => {
|
|
211
|
+
if (stepIndex === 0) return true
|
|
212
|
+
if (allowStepSkip) return true
|
|
213
|
+
|
|
214
|
+
// Check if all previous steps are completed
|
|
215
|
+
for (let i = 0; i < stepIndex; i++) {
|
|
216
|
+
if (!completedSteps.has(i) && !steps[i].isOptional) {
|
|
217
|
+
return false
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return true
|
|
222
|
+
}, [completedSteps, allowStepSkip, steps])
|
|
223
|
+
|
|
224
|
+
const value: WizardContextValue = {
|
|
225
|
+
steps,
|
|
226
|
+
currentStep,
|
|
227
|
+
stepData,
|
|
228
|
+
isLoading,
|
|
229
|
+
error,
|
|
230
|
+
goToNext,
|
|
231
|
+
goToPrevious,
|
|
232
|
+
goToStep,
|
|
233
|
+
updateStepData: (data: any) => updateStepData(steps[currentStep].id, data),
|
|
234
|
+
validateCurrentStep,
|
|
235
|
+
completeWizard,
|
|
236
|
+
resetWizard,
|
|
237
|
+
isStepCompleted,
|
|
238
|
+
isStepAccessible,
|
|
239
|
+
canGoNext: currentStep < steps.length - 1,
|
|
240
|
+
canGoPrevious: allowBackNavigation && currentStep > 0
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<FormWizardContext.Provider value={value}>
|
|
245
|
+
{children}
|
|
246
|
+
</FormWizardContext.Provider>
|
|
247
|
+
)
|
|
248
|
+
}
|