@qwickapps/react-framework 1.5.12 → 1.6.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.
Files changed (65) hide show
  1. package/README.md +23 -0
  2. package/dist/components/blocks/ImageGallery.d.ts +30 -0
  3. package/dist/components/blocks/ImageGallery.d.ts.map +1 -0
  4. package/dist/components/blocks/OptionSelector.d.ts +45 -0
  5. package/dist/components/blocks/OptionSelector.d.ts.map +1 -0
  6. package/dist/components/blocks/index.d.ts +4 -0
  7. package/dist/components/blocks/index.d.ts.map +1 -1
  8. package/dist/components/forms/Captcha.d.ts +33 -28
  9. package/dist/components/forms/Captcha.d.ts.map +1 -1
  10. package/dist/components/forms/FormCheckbox.d.ts +15 -12
  11. package/dist/components/forms/FormCheckbox.d.ts.map +1 -1
  12. package/dist/components/forms/FormField.d.ts +20 -23
  13. package/dist/components/forms/FormField.d.ts.map +1 -1
  14. package/dist/components/forms/FormSelect.d.ts +16 -15
  15. package/dist/components/forms/FormSelect.d.ts.map +1 -1
  16. package/dist/hooks/useBaseProps.d.ts +27 -1172
  17. package/dist/hooks/useBaseProps.d.ts.map +1 -1
  18. package/dist/index.esm.js +1674 -554
  19. package/dist/index.js +1676 -552
  20. package/dist/palettes/manifest.json +19 -19
  21. package/dist/schemas/CaptchaSchema.d.ts +16 -0
  22. package/dist/schemas/CaptchaSchema.d.ts.map +1 -0
  23. package/dist/schemas/FormCheckboxSchema.d.ts +16 -0
  24. package/dist/schemas/FormCheckboxSchema.d.ts.map +1 -0
  25. package/dist/schemas/FormFieldSchema.d.ts +23 -0
  26. package/dist/schemas/FormFieldSchema.d.ts.map +1 -0
  27. package/dist/schemas/FormSelectSchema.d.ts +20 -0
  28. package/dist/schemas/FormSelectSchema.d.ts.map +1 -0
  29. package/dist/schemas/ImageGallerySchema.d.ts +27 -0
  30. package/dist/schemas/ImageGallerySchema.d.ts.map +1 -0
  31. package/dist/schemas/OptionSelectorSchema.d.ts +34 -0
  32. package/dist/schemas/OptionSelectorSchema.d.ts.map +1 -0
  33. package/dist/schemas/index.d.ts +6 -0
  34. package/dist/schemas/index.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/src/components/blocks/Article.tsx +1 -1
  37. package/src/components/blocks/ImageGallery.tsx +464 -0
  38. package/src/components/blocks/OptionSelector.tsx +459 -0
  39. package/src/components/blocks/index.ts +4 -0
  40. package/src/components/forms/Captcha.tsx +57 -63
  41. package/src/components/forms/FormCheckbox.tsx +35 -43
  42. package/src/components/forms/FormField.tsx +50 -66
  43. package/src/components/forms/FormSelect.tsx +41 -49
  44. package/src/hooks/useBaseProps.ts +34 -1
  45. package/src/schemas/CaptchaSchema.ts +65 -0
  46. package/src/schemas/FormCheckboxSchema.ts +65 -0
  47. package/src/schemas/FormFieldSchema.ts +140 -0
  48. package/src/schemas/FormSelectSchema.ts +108 -0
  49. package/src/schemas/ImageGallerySchema.ts +148 -0
  50. package/src/schemas/OptionSelectorSchema.ts +216 -0
  51. package/src/schemas/index.ts +6 -0
  52. package/src/stories/ImageGallery.stories.tsx +497 -0
  53. package/src/stories/OptionSelector.stories.tsx +506 -0
  54. /package/dist/palettes/{palette-autumn.1.5.12.css → palette-autumn.1.6.0.css} +0 -0
  55. /package/dist/palettes/{palette-autumn.1.5.12.min.css → palette-autumn.1.6.0.min.css} +0 -0
  56. /package/dist/palettes/{palette-cosmic.1.5.12.css → palette-cosmic.1.6.0.css} +0 -0
  57. /package/dist/palettes/{palette-cosmic.1.5.12.min.css → palette-cosmic.1.6.0.min.css} +0 -0
  58. /package/dist/palettes/{palette-default.1.5.12.css → palette-default.1.6.0.css} +0 -0
  59. /package/dist/palettes/{palette-default.1.5.12.min.css → palette-default.1.6.0.min.css} +0 -0
  60. /package/dist/palettes/{palette-ocean.1.5.12.css → palette-ocean.1.6.0.css} +0 -0
  61. /package/dist/palettes/{palette-ocean.1.5.12.min.css → palette-ocean.1.6.0.min.css} +0 -0
  62. /package/dist/palettes/{palette-spring.1.5.12.css → palette-spring.1.6.0.css} +0 -0
  63. /package/dist/palettes/{palette-spring.1.5.12.min.css → palette-spring.1.6.0.min.css} +0 -0
  64. /package/dist/palettes/{palette-winter.1.5.12.css → palette-winter.1.6.0.css} +0 -0
  65. /package/dist/palettes/{palette-winter.1.5.12.min.css → palette-winter.1.6.0.min.css} +0 -0
@@ -0,0 +1,459 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * OptionSelector - Universal option selection component with visual modes
5
+ *
6
+ * Features:
7
+ * - Multiple display modes (text, color swatches, images)
8
+ * - Multiple variants (buttons, dropdown, grid)
9
+ * - Visual disabled state for unavailable options
10
+ * - Tooltips for unavailable items
11
+ * - Selected state highlighting
12
+ * - Responsive layouts (horizontal, vertical, wrap)
13
+ * - Theme-compliant styling with CSS custom properties
14
+ * - Full serialization support
15
+ *
16
+ * @example
17
+ * // Text mode (sizes, quantities, etc.)
18
+ * <OptionSelector
19
+ * options={[
20
+ * { id: 's', label: 'S', available: true },
21
+ * { id: 'm', label: 'M', available: true },
22
+ * ]}
23
+ * displayMode="text"
24
+ * />
25
+ *
26
+ * @example
27
+ * // Color mode (color selection)
28
+ * <OptionSelector
29
+ * options={[
30
+ * { id: 'red', label: 'Red', hexValue: '#FF0000', available: true },
31
+ * { id: 'blue', label: 'Blue', hexValue: '#0000FF', available: true },
32
+ * ]}
33
+ * displayMode="color"
34
+ * />
35
+ *
36
+ * @example
37
+ * // Image mode (pattern selection)
38
+ * <OptionSelector
39
+ * options={[
40
+ * { id: 'pattern1', label: 'Stripes', imageUrl: '/patterns/stripes.jpg', available: true },
41
+ * ]}
42
+ * displayMode="image"
43
+ * />
44
+ *
45
+ * Copyright (c) 2025 QwickApps.com. All rights reserved.
46
+ */
47
+
48
+ import React, { useCallback } from 'react';
49
+ import { Box, Button, Select, MenuItem, FormControl, InputLabel, Tooltip } from '@mui/material';
50
+ import CheckIcon from '@mui/icons-material/Check';
51
+ import { createSerializableView, SerializableComponent } from '../shared/createSerializableView';
52
+ import { ViewProps } from '../shared/viewProps';
53
+
54
+ export interface SelectOption {
55
+ /** Unique identifier */
56
+ id: string;
57
+
58
+ /** Display label */
59
+ label: string;
60
+
61
+ /** Whether this option is available for selection */
62
+ available: boolean;
63
+
64
+ /** Optional price adjustment */
65
+ price?: number;
66
+
67
+ /** Hex color value (for color display mode) */
68
+ hexValue?: string;
69
+
70
+ /** Image URL (for image or color display mode) */
71
+ imageUrl?: string;
72
+ }
73
+
74
+ export interface OptionSelectorProps extends ViewProps {
75
+ /** Array of available options */
76
+ options: SelectOption[];
77
+
78
+ /** Currently selected option ID */
79
+ selectedOption?: string;
80
+
81
+ /** Callback when option is selected */
82
+ onOptionSelect?: (optionId: string) => void;
83
+
84
+ /** Display mode */
85
+ displayMode?: 'text' | 'color' | 'image';
86
+
87
+ /** Display variant */
88
+ variant?: 'buttons' | 'dropdown' | 'grid';
89
+
90
+ /** Layout direction (for buttons variant) */
91
+ layout?: 'horizontal' | 'vertical' | 'wrap';
92
+
93
+ /** Visual size (for color/image modes) */
94
+ visualSize?: 'small' | 'medium' | 'large';
95
+
96
+ /** Show label below visual (for color/image modes) */
97
+ showLabel?: boolean;
98
+
99
+ /** Disable all selections */
100
+ disabled?: boolean;
101
+
102
+ /** Label for the selector */
103
+ label?: string;
104
+
105
+ /** Data source for dynamic loading */
106
+ dataSource?: string;
107
+
108
+ /** Data binding configuration */
109
+ bindingOptions?: Record<string, unknown>;
110
+ }
111
+
112
+ // View component
113
+ function OptionSelectorView({
114
+ options = [],
115
+ selectedOption,
116
+ onOptionSelect,
117
+ displayMode = 'text',
118
+ variant = 'grid',
119
+ layout = 'wrap',
120
+ visualSize = 'medium',
121
+ showLabel = false,
122
+ disabled = false,
123
+ label = 'Select Option',
124
+ dataSource,
125
+ bindingOptions,
126
+ ...restProps
127
+ }: OptionSelectorProps) {
128
+ const handleOptionClick = useCallback((optionId: string, available: boolean) => {
129
+ if (!disabled && available && onOptionSelect) {
130
+ onOptionSelect(optionId);
131
+ }
132
+ }, [disabled, onOptionSelect]);
133
+
134
+ const handleDropdownChange = useCallback((event: any) => {
135
+ if (onOptionSelect) {
136
+ onOptionSelect(event.target.value);
137
+ }
138
+ }, [onOptionSelect]);
139
+
140
+ // Get visual size in pixels
141
+ const getSizePixels = () => {
142
+ if (displayMode === 'text') return 48;
143
+ switch (visualSize) {
144
+ case 'small': return 32;
145
+ case 'large': return 56;
146
+ case 'medium':
147
+ default: return 44;
148
+ }
149
+ };
150
+
151
+ const sizeInPx = getSizePixels();
152
+
153
+ // Render nothing if no options
154
+ if (!options || options.length === 0) {
155
+ return null;
156
+ }
157
+
158
+ // Dropdown variant
159
+ if (variant === 'dropdown') {
160
+ return (
161
+ <FormControl
162
+ fullWidth
163
+ disabled={disabled}
164
+ {...restProps}
165
+ sx={{
166
+ '& .MuiOutlinedInput-root': {
167
+ '& fieldset': {
168
+ borderColor: 'var(--theme-border-main)',
169
+ },
170
+ '&:hover fieldset': {
171
+ borderColor: 'var(--theme-border-emphasis)',
172
+ },
173
+ '&.Mui-focused fieldset': {
174
+ borderColor: 'var(--theme-primary)',
175
+ },
176
+ },
177
+ '& .MuiInputLabel-root': {
178
+ color: 'var(--theme-text-secondary)',
179
+ '&.Mui-focused': {
180
+ color: 'var(--theme-primary)',
181
+ },
182
+ },
183
+ }}
184
+ >
185
+ <InputLabel>{label}</InputLabel>
186
+ <Select
187
+ value={selectedOption || ''}
188
+ onChange={handleDropdownChange}
189
+ label={label}
190
+ sx={{
191
+ backgroundColor: 'var(--theme-surface)',
192
+ color: 'var(--theme-text-primary)',
193
+ }}
194
+ >
195
+ {options.map((option) => (
196
+ <MenuItem
197
+ key={option.id}
198
+ value={option.id}
199
+ disabled={!option.available}
200
+ sx={{
201
+ color: option.available ? 'var(--theme-text-primary)' : 'var(--theme-text-disabled)',
202
+ }}
203
+ >
204
+ {displayMode === 'color' && option.hexValue && (
205
+ <Box
206
+ sx={{
207
+ width: 20,
208
+ height: 20,
209
+ mr: 1,
210
+ borderRadius: '50%',
211
+ backgroundColor: option.hexValue,
212
+ border: '1px solid var(--theme-border-main)',
213
+ backgroundImage: option.imageUrl ? `url(${option.imageUrl})` : undefined,
214
+ backgroundSize: 'cover',
215
+ }}
216
+ />
217
+ )}
218
+ {option.label}
219
+ {!option.available && ' (Out of stock)'}
220
+ {option.price && option.price !== 0 && ` (+$${(option.price / 100).toFixed(2)})`}
221
+ </MenuItem>
222
+ ))}
223
+ </Select>
224
+ </FormControl>
225
+ );
226
+ }
227
+
228
+ // Buttons/Grid variant with visual modes
229
+ const getLayoutStyles = () => {
230
+ if (variant === 'grid') {
231
+ const minWidth = displayMode === 'text' ? 60 : sizeInPx + 16;
232
+ return {
233
+ display: 'grid',
234
+ gridTemplateColumns: `repeat(auto-fill, minmax(${minWidth}px, 1fr))`,
235
+ gap: displayMode === 'text' ? 1 : 2,
236
+ };
237
+ }
238
+
239
+ return {
240
+ display: 'flex',
241
+ flexDirection: layout === 'vertical' ? 'column' : 'row',
242
+ flexWrap: layout === 'wrap' ? 'wrap' : 'nowrap',
243
+ gap: 1,
244
+ };
245
+ };
246
+
247
+ return (
248
+ <Box {...restProps}>
249
+ {label && (
250
+ <Box
251
+ component="label"
252
+ sx={{
253
+ display: 'block',
254
+ mb: 1,
255
+ fontSize: '0.875rem',
256
+ fontWeight: 500,
257
+ color: 'var(--theme-text-primary)',
258
+ }}
259
+ >
260
+ {label}
261
+ </Box>
262
+ )}
263
+
264
+ <Box sx={getLayoutStyles()}>
265
+ {options.map((option) => {
266
+ const isSelected = selectedOption === option.id;
267
+ const isAvailable = option.available;
268
+
269
+ // Text mode - render as buttons
270
+ if (displayMode === 'text') {
271
+ const button = (
272
+ <Button
273
+ key={option.id}
274
+ onClick={() => handleOptionClick(option.id, isAvailable)}
275
+ disabled={disabled || !isAvailable}
276
+ variant={isSelected ? 'contained' : 'outlined'}
277
+ sx={{
278
+ minWidth: variant === 'grid' ? '60px' : '80px',
279
+ height: `${sizeInPx}px`,
280
+ borderRadius: 'var(--theme-border-radius-small)',
281
+ textTransform: 'uppercase',
282
+ fontWeight: 600,
283
+ fontSize: '0.875rem',
284
+ backgroundColor: isSelected ? 'var(--theme-primary)' : 'var(--theme-surface)',
285
+ color: isSelected ? 'var(--theme-text-on-primary)' : 'var(--theme-text-primary)',
286
+ borderColor: isSelected ? 'var(--theme-primary)' : 'var(--theme-border-main)',
287
+ borderWidth: '2px',
288
+ borderStyle: 'solid',
289
+ '&.Mui-disabled': {
290
+ backgroundColor: 'var(--theme-surface-variant)',
291
+ color: 'var(--theme-text-disabled)',
292
+ borderColor: 'var(--theme-border-light)',
293
+ opacity: 0.5,
294
+ textDecoration: 'line-through',
295
+ },
296
+ '&:hover:not(.Mui-disabled)': {
297
+ backgroundColor: !isSelected ? 'var(--theme-surface-variant)' : undefined,
298
+ borderColor: !isSelected ? 'var(--theme-border-emphasis)' : undefined,
299
+ boxShadow: 'var(--theme-elevation-1)',
300
+ },
301
+ transition: 'all 0.2s ease-in-out',
302
+ }}
303
+ >
304
+ {option.label}
305
+ </Button>
306
+ );
307
+
308
+ return !isAvailable ? (
309
+ <Tooltip
310
+ key={option.id}
311
+ title="Not available"
312
+ arrow
313
+ sx={{
314
+ '& .MuiTooltip-tooltip': {
315
+ backgroundColor: 'var(--theme-surface)',
316
+ color: 'var(--theme-text-primary)',
317
+ border: '1px solid var(--theme-border-main)',
318
+ boxShadow: 'var(--theme-elevation-2)',
319
+ },
320
+ '& .MuiTooltip-arrow': {
321
+ color: 'var(--theme-surface)',
322
+ },
323
+ }}
324
+ >
325
+ <span>{button}</span>
326
+ </Tooltip>
327
+ ) : button;
328
+ }
329
+
330
+ // Color/Image mode - render as visual swatches
331
+ const swatchContent = (
332
+ <Box
333
+ key={option.id}
334
+ onClick={() => handleOptionClick(option.id, isAvailable)}
335
+ sx={{
336
+ display: 'flex',
337
+ flexDirection: 'column',
338
+ alignItems: 'center',
339
+ gap: 0.5,
340
+ cursor: disabled || !isAvailable ? 'not-allowed' : 'pointer',
341
+ opacity: disabled || !isAvailable ? 0.5 : 1,
342
+ }}
343
+ >
344
+ <Box
345
+ sx={{
346
+ position: 'relative',
347
+ width: sizeInPx,
348
+ height: sizeInPx,
349
+ borderRadius: displayMode === 'color' ? '50%' : 'var(--theme-border-radius-small)',
350
+ backgroundColor: option.hexValue || 'var(--theme-surface-variant)',
351
+ backgroundImage: option.imageUrl ? `url(${option.imageUrl})` : undefined,
352
+ backgroundSize: 'cover',
353
+ backgroundPosition: 'center',
354
+ border: '2px solid',
355
+ borderColor: isSelected ? 'var(--theme-primary)' : 'var(--theme-border-main)',
356
+ boxShadow: isSelected ? 'var(--theme-elevation-2)' : 'none',
357
+ transition: 'all 0.2s ease-in-out',
358
+ ...(isAvailable && !disabled && !isSelected && {
359
+ '&:hover': {
360
+ borderColor: 'var(--theme-border-emphasis)',
361
+ boxShadow: 'var(--theme-elevation-1)',
362
+ transform: 'scale(1.05)',
363
+ },
364
+ }),
365
+ ...(!isAvailable && {
366
+ '&::after': {
367
+ content: '""',
368
+ position: 'absolute',
369
+ top: '50%',
370
+ left: '10%',
371
+ right: '10%',
372
+ height: '2px',
373
+ backgroundColor: 'var(--theme-border-emphasis)',
374
+ transform: 'translateY(-50%) rotate(-45deg)',
375
+ },
376
+ }),
377
+ }}
378
+ >
379
+ {isSelected && (
380
+ <Box
381
+ sx={{
382
+ position: 'absolute',
383
+ top: '50%',
384
+ left: '50%',
385
+ transform: 'translate(-50%, -50%)',
386
+ display: 'flex',
387
+ alignItems: 'center',
388
+ justifyContent: 'center',
389
+ width: '100%',
390
+ height: '100%',
391
+ borderRadius: 'inherit',
392
+ backgroundColor: 'rgba(0, 0, 0, 0.3)',
393
+ }}
394
+ >
395
+ <CheckIcon
396
+ sx={{
397
+ color: 'white',
398
+ fontSize: sizeInPx * 0.5,
399
+ filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.5))',
400
+ }}
401
+ />
402
+ </Box>
403
+ )}
404
+ </Box>
405
+
406
+ {showLabel && (
407
+ <Box
408
+ sx={{
409
+ fontSize: '0.75rem',
410
+ color: 'var(--theme-text-secondary)',
411
+ textAlign: 'center',
412
+ maxWidth: sizeInPx + 16,
413
+ overflow: 'hidden',
414
+ textOverflow: 'ellipsis',
415
+ whiteSpace: 'nowrap',
416
+ }}
417
+ >
418
+ {option.label}
419
+ </Box>
420
+ )}
421
+ </Box>
422
+ );
423
+
424
+ return (
425
+ <Tooltip
426
+ key={option.id}
427
+ title={!isAvailable ? 'Not available' : option.label}
428
+ arrow
429
+ sx={{
430
+ '& .MuiTooltip-tooltip': {
431
+ backgroundColor: 'var(--theme-surface)',
432
+ color: 'var(--theme-text-primary)',
433
+ border: '1px solid var(--theme-border-main)',
434
+ boxShadow: 'var(--theme-elevation-2)',
435
+ },
436
+ '& .MuiTooltip-arrow': {
437
+ color: 'var(--theme-surface)',
438
+ },
439
+ }}
440
+ >
441
+ {swatchContent}
442
+ </Tooltip>
443
+ );
444
+ })}
445
+ </Box>
446
+ </Box>
447
+ );
448
+ }
449
+
450
+ // Create the serializable component
451
+ export const OptionSelector: SerializableComponent<OptionSelectorProps> =
452
+ createSerializableView<OptionSelectorProps>({
453
+ tagName: 'OptionSelector',
454
+ version: '1.0.0',
455
+ role: 'view',
456
+ View: OptionSelectorView,
457
+ });
458
+
459
+ export default OptionSelector;
@@ -22,6 +22,8 @@ export { default as Section } from './Section';
22
22
  export { default as Image } from './Image';
23
23
  export { default as Text } from './Text';
24
24
  export { default as ProductCard } from './ProductCard';
25
+ export { default as ImageGallery } from './ImageGallery';
26
+ export { default as OptionSelector } from './OptionSelector';
25
27
  export { default as FeatureCard } from './FeatureCard';
26
28
  export { default as CardListGrid } from './CardListGrid';
27
29
 
@@ -37,5 +39,7 @@ export type { SectionProps } from './Section';
37
39
  export type { ImageProps } from './Image';
38
40
  export type { TextProps } from './Text';
39
41
  export type { ProductCardProps, Product, ProductCardAction } from './ProductCard';
42
+ export type { ImageGalleryProps, GalleryImage } from './ImageGallery';
43
+ export type { OptionSelectorProps, SelectOption } from './OptionSelector';
40
44
  export type { FeatureCardProps, FeatureItem, FeatureCardAction } from './FeatureCard';
41
45
  export type { CardListGridProps } from './CardListGrid';
@@ -11,7 +11,7 @@
11
11
  * - Provider-agnostic API
12
12
  * - Automatic script loading
13
13
  * - TypeScript support
14
- * - Themed styling with base props support
14
+ * - Themed styling with schema-driven architecture
15
15
  * - Grid behavior support
16
16
  * - Error handling
17
17
  *
@@ -20,57 +20,57 @@
20
20
 
21
21
  import React, { useEffect, useRef, useState } from 'react';
22
22
  import { Box, Alert } from '@mui/material';
23
- import { useBaseProps, WithBaseProps, QWICKAPP_COMPONENT } from '../../hooks/useBaseProps';
23
+ import type { SchemaProps } from '@qwickapps/schema';
24
+ import CaptchaModel from '../../schemas/CaptchaSchema';
25
+ import { ViewProps } from '../shared/viewProps';
26
+ import { createSerializableView, SerializableComponent } from '../shared/createSerializableView';
24
27
 
25
- export type CaptchaProvider = 'recaptcha-v2' | 'recaptcha-v3' | 'hcaptcha' | 'turnstile';
28
+ // Declare global interfaces for CAPTCHA providers
29
+ declare global {
30
+ interface Window {
31
+ grecaptcha?: {
32
+ render?: (container: HTMLElement | null, params: Record<string, unknown>) => string | number;
33
+ execute?: (siteKey: string, options: { action: string }) => Promise<string>;
34
+ reset?: (widgetId: string | number) => void;
35
+ };
36
+ hcaptcha?: {
37
+ render?: (container: HTMLElement | null, params: Record<string, unknown>) => string | number;
38
+ remove?: (widgetId: string | number) => void;
39
+ };
40
+ turnstile?: {
41
+ render?: (container: HTMLElement | null, params: Record<string, unknown>) => string | number;
42
+ remove?: (widgetId: string | number) => void;
43
+ };
44
+ }
45
+ }
26
46
 
27
- interface CaptchaBaseProps {
28
- /** CAPTCHA provider */
29
- provider: CaptchaProvider;
30
- /** Site key (public key) */
31
- siteKey: string;
47
+ /**
48
+ * Props interface for Captcha component
49
+ * Combines schema props with callback handlers
50
+ */
51
+ export interface CaptchaProps extends ViewProps, SchemaProps<typeof CaptchaModel> {
32
52
  /** Callback when CAPTCHA is successfully completed */
33
53
  onVerify: (token: string) => void;
34
54
  /** Callback when CAPTCHA expires or fails */
35
55
  onExpire?: () => void;
36
56
  /** Callback when CAPTCHA encounters an error */
37
57
  onError?: (error: Error) => void;
38
- /** Theme for the widget (light or dark) */
39
- theme?: 'light' | 'dark';
40
- /** Size of the widget */
41
- size?: 'normal' | 'compact' | 'invisible';
42
- /** reCAPTCHA v3 action name */
43
- action?: string;
44
58
  }
45
59
 
46
- export interface CaptchaProps extends WithBaseProps<CaptchaBaseProps> {}
47
-
48
- // Declare global interfaces for CAPTCHA providers
49
- declare global {
50
- interface Window {
51
- grecaptcha?: unknown;
52
- hcaptcha?: unknown;
53
- turnstile?: unknown;
54
- onRecaptchaLoad?: () => void;
55
- onHcaptchaLoad?: () => void;
56
- onTurnstileLoad?: () => void;
57
- }
58
- }
59
-
60
- export const Captcha = React.forwardRef<HTMLDivElement, CaptchaProps>((props, ref) => {
61
- const { gridProps, styleProps, htmlProps, restProps } = useBaseProps(props);
62
-
63
- const {
64
- provider,
65
- siteKey,
66
- onVerify,
67
- onExpire,
68
- onError,
69
- theme = 'light',
70
- size = 'normal',
71
- action = 'submit',
72
- } = restProps as CaptchaBaseProps;
73
-
60
+ /**
61
+ * CaptchaView - Pure view component that renders the CAPTCHA widget
62
+ */
63
+ function CaptchaView({
64
+ provider,
65
+ siteKey,
66
+ onVerify,
67
+ onExpire,
68
+ onError,
69
+ theme = 'light',
70
+ size = 'normal',
71
+ action = 'submit',
72
+ ...restProps
73
+ }: CaptchaProps) {
74
74
  const containerRef = useRef<HTMLDivElement>(null);
75
75
  const widgetIdRef = useRef<string | number | null>(null);
76
76
  const [isLoaded, setIsLoaded] = useState(false);
@@ -155,7 +155,7 @@ export const Captcha = React.forwardRef<HTMLDivElement, CaptchaProps>((props, re
155
155
  try {
156
156
  switch (provider) {
157
157
  case 'recaptcha-v2':
158
- if (window.grecaptcha && window.grecaptcha.render) {
158
+ if (window.grecaptcha?.render) {
159
159
  widgetIdRef.current = window.grecaptcha.render(containerRef.current, {
160
160
  sitekey: siteKey,
161
161
  callback: onVerify,
@@ -173,7 +173,7 @@ export const Captcha = React.forwardRef<HTMLDivElement, CaptchaProps>((props, re
173
173
 
174
174
  case 'recaptcha-v3':
175
175
  // reCAPTCHA v3 is invisible and executes programmatically
176
- if (window.grecaptcha && window.grecaptcha.execute) {
176
+ if (window.grecaptcha?.execute) {
177
177
  window.grecaptcha.execute(siteKey, { action }).then((token: string) => {
178
178
  onVerify(token);
179
179
  }).catch((err: Error) => {
@@ -184,7 +184,7 @@ export const Captcha = React.forwardRef<HTMLDivElement, CaptchaProps>((props, re
184
184
  break;
185
185
 
186
186
  case 'hcaptcha':
187
- if (window.hcaptcha && window.hcaptcha.render) {
187
+ if (window.hcaptcha?.render) {
188
188
  widgetIdRef.current = window.hcaptcha.render(containerRef.current, {
189
189
  sitekey: siteKey,
190
190
  callback: onVerify,
@@ -201,7 +201,7 @@ export const Captcha = React.forwardRef<HTMLDivElement, CaptchaProps>((props, re
201
201
  break;
202
202
 
203
203
  case 'turnstile':
204
- if (window.turnstile && window.turnstile.render) {
204
+ if (window.turnstile?.render) {
205
205
  widgetIdRef.current = window.turnstile.render(containerRef.current, {
206
206
  sitekey: siteKey,
207
207
  callback: onVerify,
@@ -257,21 +257,10 @@ export const Captcha = React.forwardRef<HTMLDivElement, CaptchaProps>((props, re
257
257
 
258
258
  return (
259
259
  <Box
260
- ref={ref}
261
- {...htmlProps}
260
+ {...restProps}
262
261
  sx={{
263
262
  my: 2,
264
- ...styleProps.sx,
265
263
  }}
266
- // Store grid props as data attributes for ColumnLayout to pick up
267
- {...(gridProps && {
268
- 'data-grid-span': gridProps.span,
269
- 'data-grid-xs': gridProps.xs,
270
- 'data-grid-sm': gridProps.sm,
271
- 'data-grid-md': gridProps.md,
272
- 'data-grid-lg': gridProps.lg,
273
- 'data-grid-xl': gridProps.xl,
274
- })}
275
264
  >
276
265
  {error && (
277
266
  <Alert severity="error" sx={{ mb: 2 }}>
@@ -281,11 +270,16 @@ export const Captcha = React.forwardRef<HTMLDivElement, CaptchaProps>((props, re
281
270
  <div ref={containerRef} />
282
271
  </Box>
283
272
  );
284
- });
285
-
286
- Captcha.displayName = 'Captcha';
273
+ }
287
274
 
288
- // Mark as QwickApp component
289
- Object.assign(Captcha, { [QWICKAPP_COMPONENT]: true });
275
+ /**
276
+ * Create Captcha component using the factory pattern
277
+ */
278
+ export const Captcha: SerializableComponent<CaptchaProps> = createSerializableView<CaptchaProps>({
279
+ tagName: 'Captcha',
280
+ version: '1.0.0',
281
+ role: 'input',
282
+ View: CaptchaView,
283
+ });
290
284
 
291
285
  export default Captcha;