@explorer02/cfm-survey-sdk 0.2.2 → 0.2.3

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 (41) hide show
  1. package/package.json +1 -1
  2. package/templates/AGENT.md +8 -4
  3. package/templates/docs/00-integration/analytics-events-catalog.md +54 -0
  4. package/templates/docs/00-integration/client-integration-guide.md +1 -1
  5. package/templates/docs/00-integration/component-checklist.md +10 -0
  6. package/templates/docs/00-integration/display-logic-and-navigation.md +16 -1
  7. package/templates/docs/00-integration/file-upload-aws.md +116 -0
  8. package/templates/docs/00-integration/logic-fields-catalog.md +105 -0
  9. package/templates/docs/00-integration/partial-save-and-recovery.md +24 -0
  10. package/templates/docs/00-integration/question-type-sdk-matrix.md +11 -9
  11. package/templates/docs/00-integration/setup.md +3 -3
  12. package/templates/docs/00-integration/skip-logic-and-navigation.md +1 -1
  13. package/templates/docs/00-integration/survey-lifecycle-analytics.md +1 -1
  14. package/templates/docs/01-components/06-likert-matrix-scale.md +2 -1
  15. package/templates/docs/01-components/08-file-upload-scale.md +15 -52
  16. package/templates/docs/01-components/13-matrix-dropdown.md +28 -33
  17. package/templates/docs/01-components/17-heatmap-scale.md +4 -4
  18. package/templates/docs/01-components/18-rank-order-scale.md +24 -31
  19. package/templates/docs/02-reference/question-types/11-file-upload.md +36 -21
  20. package/templates/docs/02-reference/question-types/README.md +11 -1
  21. package/templates/docs/03-ui-specs/01-rating.md +10 -2
  22. package/templates/docs/03-ui-specs/02-radio.md +80 -25
  23. package/templates/docs/03-ui-specs/04-csat.md +35 -5
  24. package/templates/docs/03-ui-specs/07-matrix-cfm.md +20 -4
  25. package/templates/docs/03-ui-specs/08-matrix-csat-rating.md +5 -1
  26. package/templates/docs/03-ui-specs/09-slider-matrix.md +17 -0
  27. package/templates/docs/03-ui-specs/10-file-upload.md +41 -20
  28. package/templates/docs/03-ui-specs/12-survey-chrome.md +38 -0
  29. package/templates/docs/03-ui-specs/13-heatmap.md +9 -2
  30. package/templates/docs/03-ui-specs/14-rank-order.md +98 -29
  31. package/templates/docs/03-ui-specs/README.md +7 -5
  32. package/templates/docs/03-ui-specs/shared/custom-slider-track.md +28 -35
  33. package/templates/docs/MANIFEST.json +28 -3
  34. package/templates/docs/index.md +5 -4
  35. package/templates/docs/templates/CustomSliderTrack.tsx +144 -0
  36. package/templates/docs/templates/MatrixDropdown.tsx +216 -0
  37. package/templates/docs/templates/RankOrderScale.tsx +353 -0
  38. package/templates/docs/templates/implementation_plan.md +20 -0
  39. package/templates/docs/templates/survey-inventory.schema.json +28 -23
  40. package/templates/docs/templates/surveyUiIcons.tsx +52 -0
  41. package/templates/docs/templates/verify-agent-build.sh +32 -0
@@ -0,0 +1,144 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Portable CustomSliderTrack template — copy to src/components/CustomSliderTrack.tsx
5
+ * Also copy surveyUiIcons.tsx to the same folder (or src/lib/surveyUiIcons.tsx)
6
+ * Pass tickLabels from scaleColumns[].label for matrix scale label rendering (Day 19 fix)
7
+ */
8
+
9
+ import React from 'react';
10
+ import { getEmojiForIndex } from './surveyUiIcons';
11
+
12
+ type CustomSliderTrackProps = {
13
+ min: number;
14
+ max: number;
15
+ htmlStep: number;
16
+ value: number;
17
+ disabled: boolean;
18
+ displayValues?: boolean;
19
+ hasSelectedValue: boolean;
20
+ sliderType?: 'graphics';
21
+ ticks?: number;
22
+ /** Pass scaleColumns.map(c => c.label) for visible column labels under slider */
23
+ tickLabels?: string[];
24
+ reverseScaleOrder?: boolean;
25
+ onChange: (v: number) => void;
26
+ };
27
+
28
+ export function CustomSliderTrack({
29
+ min,
30
+ max,
31
+ htmlStep,
32
+ value,
33
+ disabled,
34
+ sliderType,
35
+ ticks,
36
+ tickLabels,
37
+ reverseScaleOrder,
38
+ onChange,
39
+ }: CustomSliderTrackProps) {
40
+ const [isHovered, setIsHovered] = React.useState(false);
41
+ const [isDragging, setIsDragging] = React.useState(false);
42
+
43
+ const percentage = ((value - min) / (max - min)) * 100;
44
+ const tickIndex = Math.min(
45
+ Math.max(0, Math.round(value)),
46
+ tickLabels && tickLabels.length > 0 ? tickLabels.length - 1 : Math.round(value)
47
+ );
48
+ const tooltipLabel = tickLabels?.[tickIndex];
49
+ const tooltipText = tooltipLabel
50
+ ? tooltipLabel.replace(/<[^>]*>/g, '')
51
+ : String(Math.round(value));
52
+
53
+ const themeColor = disabled ? '#9ca3af' : '#e20074';
54
+
55
+ return (
56
+ <div
57
+ style={{ position: 'relative', height: '32px', display: 'flex', alignItems: 'center', margin: '0 16px', opacity: disabled ? 0.5 : 1 }}
58
+ onMouseEnter={() => !disabled && setIsHovered(true)}
59
+ onMouseLeave={() => { setIsHovered(false); setIsDragging(false); }}
60
+ >
61
+ <div style={{
62
+ position: 'absolute', top: '50%', left: 0, right: 0, transform: 'translateY(-50%)',
63
+ height: '4px', borderRadius: '2px', backgroundColor: '#e5e7eb',
64
+ }} />
65
+
66
+ <div style={{
67
+ position: 'absolute', top: '50%', left: 0, width: `${percentage}%`, transform: 'translateY(-50%)',
68
+ height: '4px', borderRadius: '2px', backgroundColor: themeColor,
69
+ transition: isDragging ? 'none' : 'width 0.15s ease-in-out',
70
+ }} />
71
+
72
+ {ticks && ticks > 1 && Array.from({ length: ticks }).map((_, idx) => {
73
+ const tickPct = (idx / (ticks - 1)) * 100;
74
+ return (
75
+ <div
76
+ key={idx}
77
+ style={{
78
+ position: 'absolute', top: '50%', left: `${tickPct}%`, transform: 'translate(-50%, -50%)',
79
+ width: '1px', height: '8px', backgroundColor: '#d1d5db', pointerEvents: 'none',
80
+ }}
81
+ />
82
+ );
83
+ })}
84
+
85
+ <input
86
+ type="range"
87
+ min={min} max={max} step={htmlStep} value={value}
88
+ disabled={disabled}
89
+ onChange={(e) => onChange(Number(e.target.value))}
90
+ onMouseDown={() => !disabled && setIsDragging(true)}
91
+ onMouseUp={() => setIsDragging(false)}
92
+ onTouchStart={() => !disabled && setIsDragging(true)}
93
+ onTouchEnd={() => setIsDragging(false)}
94
+ style={{
95
+ position: 'absolute', top: '50%', left: '-16px', right: '-16px', width: 'calc(100% + 32px)', height: '100%',
96
+ transform: 'translateY(-50%)', opacity: 0, cursor: disabled ? 'not-allowed' : 'pointer', zIndex: 10, margin: 0,
97
+ }}
98
+ />
99
+
100
+ {sliderType === 'graphics' ? (
101
+ <div style={{
102
+ position: 'absolute', top: '50%', left: `${percentage}%`, transform: 'translate(-50%, -50%)',
103
+ transition: isDragging ? 'none' : 'left 0.15s ease-in-out', pointerEvents: 'none', zIndex: 11,
104
+ opacity: disabled ? 0.5 : 1,
105
+ }}>
106
+ {getEmojiForIndex(
107
+ reverseScaleOrder
108
+ ? (ticks || 10) - 1 - Math.min((ticks || 10) - 1, Math.max(0, Math.round(((value - min) / (max - min)) * ((ticks || 10) - 1))))
109
+ : Math.min((ticks || 10) - 1, Math.max(0, Math.round(((value - min) / (max - min)) * ((ticks || 10) - 1)))),
110
+ ticks || 10
111
+ ) as React.ReactNode}
112
+ </div>
113
+ ) : (
114
+ <div style={{
115
+ position: 'absolute', top: '50%', left: `${percentage}%`, transform: 'translate(-50%, -50%)',
116
+ width: '18px', height: '18px', borderRadius: '50%', backgroundColor: '#ffffff',
117
+ border: `2.5px solid ${themeColor}`,
118
+ boxShadow: (isHovered || isDragging) && !disabled
119
+ ? '0 0 0 6px rgba(226, 0, 116, 0.2), 0 1px 3px rgba(0,0,0,0.2)'
120
+ : '0 1px 3px rgba(0,0,0,0.2)',
121
+ pointerEvents: 'none',
122
+ transition: isDragging ? 'none' : 'left 0.15s ease-in-out, box-shadow 0.15s, border-color 0.2s',
123
+ boxSizing: 'border-box',
124
+ }} />
125
+ )}
126
+
127
+ {(isHovered || isDragging) && !disabled && (
128
+ <div style={{
129
+ position: 'absolute', bottom: '100%', left: `${percentage}%`, transform: 'translate(-50%, -10px)',
130
+ backgroundColor: '#1f2937', color: '#fff', fontSize: '13px', fontWeight: 600,
131
+ padding: '4px 10px', borderRadius: '6px', pointerEvents: 'none',
132
+ boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', zIndex: 20,
133
+ }}>
134
+ {tooltipText}
135
+ <div style={{
136
+ position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
137
+ borderWidth: '5px', borderStyle: 'solid',
138
+ borderColor: '#1f2937 transparent transparent transparent',
139
+ }} />
140
+ </div>
141
+ )}
142
+ </div>
143
+ );
144
+ }
@@ -0,0 +1,216 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Portable MatrixDropdown template — copy to src/components/MatrixDropdown.tsx
5
+ * Import SDK from your package.json: @explorer02/cfm-survey-sdk or @repo/sdk
6
+ * UI spec: docs/03-ui-specs/shared/matrix-dropdown.md
7
+ */
8
+
9
+ import React, { useState, useRef, useEffect } from 'react';
10
+ import type { ScaleColumn } from '@explorer02/cfm-survey-sdk';
11
+ import { matrixColumnStoredValue } from '@explorer02/cfm-survey-sdk';
12
+
13
+ type MatrixDropdownProps = {
14
+ options: ScaleColumn[];
15
+ value: string | number | null | Array<string | number | null> | undefined;
16
+ onChange: (val: string | number | null | Array<string | number | null>) => void;
17
+ multiple?: boolean;
18
+ placeholder?: string;
19
+ };
20
+
21
+ export default function MatrixDropdown({
22
+ options,
23
+ value,
24
+ onChange,
25
+ multiple = false,
26
+ placeholder = 'Select Answer',
27
+ }: MatrixDropdownProps) {
28
+ const [isOpen, setIsOpen] = useState(false);
29
+ const containerRef = useRef<HTMLDivElement>(null);
30
+
31
+ useEffect(() => {
32
+ const handleClickOutside = (event: MouseEvent) => {
33
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
34
+ setIsOpen(false);
35
+ }
36
+ };
37
+ document.addEventListener('mousedown', handleClickOutside);
38
+ return () => document.removeEventListener('mousedown', handleClickOutside);
39
+ }, []);
40
+
41
+ const multiValues: Array<string | number | null> = Array.isArray(value) ? value : [];
42
+ const selectedOptionSingle = !multiple
43
+ ? options.find(o => matrixColumnStoredValue(o) === value)
44
+ : null;
45
+
46
+ const toggleMultiOption = (val: string | number | null) => {
47
+ if (val === null) {
48
+ if (multiValues.includes(null)) onChange([]);
49
+ else onChange([null]);
50
+ return;
51
+ }
52
+
53
+ let nextVals = multiValues.filter(v => v !== null);
54
+ if (nextVals.includes(val)) {
55
+ nextVals = nextVals.filter(v => v !== val);
56
+ } else {
57
+ nextVals = [...nextVals, val];
58
+ }
59
+ onChange(nextVals);
60
+ };
61
+
62
+ const removeMultiOption = (e: React.MouseEvent, val: string | number | null) => {
63
+ e.stopPropagation();
64
+ onChange(multiValues.filter(v => v !== val));
65
+ };
66
+
67
+ const renderTrigger = () => {
68
+ if (multiple) {
69
+ return (
70
+ <div
71
+ onClick={() => setIsOpen(!isOpen)}
72
+ style={{
73
+ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px',
74
+ width: '100%', padding: '10px 14px', backgroundColor: '#fff',
75
+ border: `1px solid ${isOpen ? '#e20074' : '#d1d5db'}`, borderRadius: '8px',
76
+ cursor: 'pointer', transition: 'all 0.2s', minHeight: '44px',
77
+ boxShadow: isOpen ? '0 0 0 3px rgba(226, 0, 116, 0.1)' : '0 1px 2px rgba(0,0,0,0.05)',
78
+ }}
79
+ >
80
+ {multiValues.length === 0 ? (
81
+ <span style={{ fontSize: '15px', color: '#6b7280', flex: 1 }}>{placeholder}</span>
82
+ ) : (
83
+ multiValues.map(v => {
84
+ const opt = options.find(o => matrixColumnStoredValue(o) === v);
85
+ return (
86
+ <div
87
+ key={String(v)}
88
+ style={{
89
+ display: 'flex', alignItems: 'center', gap: '6px',
90
+ backgroundColor: '#f3f4f6', padding: '4px 8px', borderRadius: '4px',
91
+ fontSize: '14px', color: '#374151', fontWeight: 500,
92
+ }}
93
+ >
94
+ <span dangerouslySetInnerHTML={{ __html: opt?.label || String(v) }} />
95
+ <div
96
+ onClick={e => removeMultiOption(e, v)}
97
+ style={{
98
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
99
+ width: '16px', height: '16px', borderRadius: '50%', backgroundColor: '#e5e7eb',
100
+ color: '#6b7280', cursor: 'pointer', fontSize: '12px', fontWeight: 'bold',
101
+ }}
102
+ >
103
+ &times;
104
+ </div>
105
+ </div>
106
+ );
107
+ })
108
+ )}
109
+ <div style={{ marginLeft: 'auto' }}>
110
+ <svg
111
+ style={{ width: '18px', height: '18px', color: '#6b7280', transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
112
+ fill="none" stroke="currentColor" viewBox="0 0 24 24"
113
+ >
114
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
115
+ </svg>
116
+ </div>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ return (
122
+ <div
123
+ onClick={() => setIsOpen(!isOpen)}
124
+ style={{
125
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
126
+ width: '100%', padding: '14px 16px', backgroundColor: '#fff',
127
+ border: `1px solid ${isOpen ? '#e20074' : '#d1d5db'}`, borderRadius: '8px',
128
+ cursor: 'pointer', transition: 'all 0.2s',
129
+ boxShadow: isOpen ? '0 0 0 3px rgba(226, 0, 116, 0.1)' : '0 1px 2px rgba(0,0,0,0.05)',
130
+ }}
131
+ >
132
+ <span
133
+ style={{ fontSize: '15px', color: selectedOptionSingle ? '#111827' : '#6b7280', fontWeight: selectedOptionSingle ? 500 : 400 }}
134
+ dangerouslySetInnerHTML={{ __html: selectedOptionSingle ? selectedOptionSingle.label : placeholder }}
135
+ />
136
+ <svg
137
+ style={{ width: '18px', height: '18px', color: '#6b7280', transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
138
+ fill="none" stroke="currentColor" viewBox="0 0 24 24"
139
+ >
140
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
141
+ </svg>
142
+ </div>
143
+ );
144
+ };
145
+
146
+ return (
147
+ <div ref={containerRef} style={{ position: 'relative', width: '100%', userSelect: 'none' }}>
148
+ {renderTrigger()}
149
+
150
+ {isOpen && (
151
+ <div style={{
152
+ position: 'absolute', top: 'calc(100% + 8px)', left: 0, right: 0,
153
+ backgroundColor: '#fff', border: '1px solid #e5e5e5', borderRadius: '8px',
154
+ boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)',
155
+ zIndex: 50, overflow: 'hidden', maxHeight: '280px', overflowY: 'auto',
156
+ }}>
157
+ {options.map(col => {
158
+ const submitValue = matrixColumnStoredValue(col);
159
+ const isSelected = multiple ? multiValues.includes(submitValue) : value === submitValue;
160
+ const isNaSelected = multiple && multiValues.includes(null);
161
+ const isDisabled = multiple && isNaSelected && submitValue !== null;
162
+
163
+ return (
164
+ <div
165
+ key={col.id}
166
+ style={{
167
+ padding: '12px 16px', cursor: isDisabled ? 'not-allowed' : 'pointer', transition: 'background-color 0.15s',
168
+ backgroundColor: isSelected ? '#fdf2f8' : '#fff',
169
+ opacity: isDisabled ? 0.5 : 1,
170
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px',
171
+ }}
172
+ onMouseEnter={e => { if (!isDisabled) e.currentTarget.style.backgroundColor = '#fdf2f8'; }}
173
+ onMouseLeave={e => { if (!isDisabled) e.currentTarget.style.backgroundColor = isSelected ? '#fdf2f8' : '#fff'; }}
174
+ onClick={e => {
175
+ e.preventDefault();
176
+ if (isDisabled) return;
177
+ if (multiple) {
178
+ toggleMultiOption(submitValue);
179
+ } else {
180
+ onChange(submitValue);
181
+ setIsOpen(false);
182
+ }
183
+ }}
184
+ >
185
+ <div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
186
+ {multiple && (
187
+ <div style={{
188
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
189
+ width: '18px', height: '18px', borderRadius: '4px',
190
+ border: isSelected ? '2px solid #e20074' : '2px solid #d1d5db',
191
+ backgroundColor: isSelected ? '#e20074' : '#fff',
192
+ flexShrink: 0, transition: 'all 0.15s',
193
+ }}>
194
+ {isSelected && (
195
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
196
+ <polyline points="20 6 9 17 4 12" />
197
+ </svg>
198
+ )}
199
+ </div>
200
+ )}
201
+ <span style={{ fontSize: '15px', fontWeight: isSelected ? 600 : 400, color: isSelected ? '#e20074' : '#374151', userSelect: 'none' }}
202
+ dangerouslySetInnerHTML={{ __html: col.label }} />
203
+ </div>
204
+ {!multiple && isSelected && (
205
+ <svg style={{ width: '18px', height: '18px', color: '#e20074', flexShrink: 0 }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
206
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
207
+ </svg>
208
+ )}
209
+ </div>
210
+ );
211
+ })}
212
+ </div>
213
+ )}
214
+ </div>
215
+ );
216
+ }