@explorer02/cfm-survey-sdk 0.2.2 → 0.2.4

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 (113) hide show
  1. package/dist/cli/index.js +140 -33
  2. package/dist/cli/index.mjs +140 -33
  3. package/dist/index.js +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/package.json +8 -1
  6. package/postinstall.js +8 -5
  7. package/templates/AGENT.md +62 -17
  8. package/templates/deploy-wizard-to-s3.sh +52 -0
  9. package/templates/docs/00-integration/agent-execution-flow.md +281 -0
  10. package/templates/docs/00-integration/agent-operating-contract.md +260 -0
  11. package/templates/docs/00-integration/analytics-events-catalog.md +54 -0
  12. package/templates/docs/00-integration/apply-ui-config.md +365 -0
  13. package/templates/docs/00-integration/canonical-survey-fixtures.md +94 -0
  14. package/templates/docs/00-integration/client-integration-guide.md +10 -8
  15. package/templates/docs/00-integration/client-lib-folder.md +115 -0
  16. package/templates/docs/00-integration/component-checklist.md +90 -54
  17. package/templates/docs/00-integration/constraints.md +13 -5
  18. package/templates/docs/00-integration/custom-field-logic-and-navigation.md +56 -0
  19. package/templates/docs/00-integration/display-logic-and-navigation.md +16 -1
  20. package/templates/docs/00-integration/file-upload-aws.md +116 -0
  21. package/templates/docs/00-integration/logic-fields-catalog.md +105 -0
  22. package/templates/docs/00-integration/partial-save-and-recovery.md +24 -0
  23. package/templates/docs/00-integration/progress.md +4 -2
  24. package/templates/docs/00-integration/question-type-sdk-matrix.md +11 -9
  25. package/templates/docs/00-integration/setup.md +15 -3
  26. package/templates/docs/00-integration/skip-logic-and-navigation.md +1 -1
  27. package/templates/docs/00-integration/survey-lifecycle-analytics.md +1 -1
  28. package/templates/docs/00-integration/ui-customization-wizard.md +144 -0
  29. package/templates/docs/00-integration/useSurveySDK.md +35 -1
  30. package/templates/docs/00-integration/wizard-api.md +134 -0
  31. package/templates/docs/00-integration/wizard-config-handoff.md +246 -0
  32. package/templates/docs/00-integration/wizard-preview-build-guide.md +400 -0
  33. package/templates/docs/00-integration/wizard-question-type-styling.md +195 -0
  34. package/templates/docs/01-components/01-survey-page.md +18 -8
  35. package/templates/docs/01-components/02-question.md +2 -2
  36. package/templates/docs/01-components/03-rating-scale.md +12 -18
  37. package/templates/docs/01-components/05-csat-matrix-scale.md +18 -26
  38. package/templates/docs/01-components/06-likert-matrix-scale.md +19 -38
  39. package/templates/docs/01-components/07-slider-matrix-scale.md +48 -33
  40. package/templates/docs/01-components/08-file-upload-scale.md +15 -52
  41. package/templates/docs/01-components/09-custom-slider-track.md +15 -25
  42. package/templates/docs/01-components/10-header-footer.md +23 -4
  43. package/templates/docs/01-components/11-progress-bar.md +14 -6
  44. package/templates/docs/01-components/13-matrix-dropdown.md +28 -33
  45. package/templates/docs/01-components/17-heatmap-scale.md +4 -4
  46. package/templates/docs/01-components/18-rank-order-scale.md +24 -31
  47. package/templates/docs/01-components/19-survey-sticky-chrome.md +79 -0
  48. package/templates/docs/01-components/README.md +2 -2
  49. package/templates/docs/02-reference/question-types/10-slider-matrix.md +38 -26
  50. package/templates/docs/02-reference/question-types/11-file-upload.md +36 -21
  51. package/templates/docs/02-reference/question-types/README.md +11 -1
  52. package/templates/docs/02-reference/routing-table.md +1 -0
  53. package/templates/docs/02-reference/value-derivation.md +1 -1
  54. package/templates/docs/03-ui-specs/00-question-shell.md +10 -0
  55. package/templates/docs/03-ui-specs/01-rating.md +28 -2
  56. package/templates/docs/03-ui-specs/02-radio.md +94 -24
  57. package/templates/docs/03-ui-specs/03-text.md +12 -1
  58. package/templates/docs/03-ui-specs/04-csat.md +90 -26
  59. package/templates/docs/03-ui-specs/06-slider.md +3 -1
  60. package/templates/docs/03-ui-specs/07-matrix-cfm.md +74 -35
  61. package/templates/docs/03-ui-specs/08-matrix-csat-rating.md +50 -16
  62. package/templates/docs/03-ui-specs/09-slider-matrix.md +70 -26
  63. package/templates/docs/03-ui-specs/10-file-upload.md +52 -20
  64. package/templates/docs/03-ui-specs/11-text-and-media.md +4 -0
  65. package/templates/docs/03-ui-specs/12-survey-chrome.md +92 -5
  66. package/templates/docs/03-ui-specs/13-heatmap.md +21 -3
  67. package/templates/docs/03-ui-specs/14-rank-order.md +108 -28
  68. package/templates/docs/03-ui-specs/README.md +11 -7
  69. package/templates/docs/03-ui-specs/shared/custom-slider-track.md +28 -35
  70. package/templates/docs/03-ui-specs/shared/matrix-dropdown.md +1 -1
  71. package/templates/docs/MANIFEST.json +187 -6
  72. package/templates/docs/index.md +116 -116
  73. package/templates/docs/templates/CsatMatrixScale.tsx +637 -0
  74. package/templates/docs/templates/CustomSliderTrack.tsx +144 -0
  75. package/templates/docs/templates/FileUploadScale.tsx +262 -0
  76. package/templates/docs/templates/HeatmapScale.tsx +114 -0
  77. package/templates/docs/templates/LikertMatrixScale.tsx +414 -0
  78. package/templates/docs/templates/MatrixDropdown.tsx +216 -0
  79. package/templates/docs/templates/Question.tsx +3 -0
  80. package/templates/docs/templates/RankOrderScale.tsx +353 -0
  81. package/templates/docs/templates/RatingScale.tsx +129 -0
  82. package/templates/docs/templates/SliderMatrixScale.tsx +248 -0
  83. package/templates/docs/templates/SurveyStickyChrome.tsx +24 -0
  84. package/templates/docs/templates/customFieldValues.ts +36 -0
  85. package/templates/docs/templates/implementation_plan.md +75 -8
  86. package/templates/docs/templates/labelStyles.ts +33 -0
  87. package/templates/docs/templates/selectionStyles.ts +100 -0
  88. package/templates/docs/templates/survey-inventory.schema.json +28 -23
  89. package/templates/docs/templates/surveyUiIcons.tsx +63 -0
  90. package/templates/docs/templates/surveyUiScaleUtils.ts +51 -0
  91. package/templates/docs/templates/verify-agent-build.sh +151 -0
  92. package/templates/preview-harness/preview-bridge.inline.js +201 -0
  93. package/templates/preview-harness/previewPages.js +108 -0
  94. package/templates/preview-harness/previewPages.ts +298 -0
  95. package/templates/preview-harness/vite-app/src/PreviewConfigContext.tsx +67 -0
  96. package/templates/preview-harness/vite-app/src/QuestionPreview.tsx +122 -0
  97. package/templates/preview-harness/vite-app/src/SurveyPagePreview.tsx +75 -0
  98. package/templates/preview-harness/vite-app/src/fixtures/questions.ts +311 -0
  99. package/templates/preview-harness/vite-app/src/globals.css +16 -0
  100. package/templates/preview-harness/vite-app/src/mount.tsx +9 -0
  101. package/templates/preview-harness/vite-app/src/preview-live-overrides.css +101 -0
  102. package/templates/preview-harness/vite-app/stubs/next-dynamic.tsx +25 -0
  103. package/templates/preview-harness/vite-app/stubs/next-image.tsx +29 -0
  104. package/templates/previewBridge.ts +153 -0
  105. package/templates/survey-theme.css +295 -0
  106. package/templates/survey-ui-config.schema.json +213 -0
  107. package/templates/wizard-dist/assets/PreviewMock-DgHfrVeb.js +1 -0
  108. package/templates/wizard-dist/assets/TypePanel-CFVC3Ptn.js +1 -0
  109. package/templates/wizard-dist/assets/index-BhWM50Yu.css +1 -0
  110. package/templates/wizard-dist/assets/index-DYK3X1e5.js +34 -0
  111. package/templates/wizard-dist/assets/vendor-BwkXDkd3.js +17 -0
  112. package/templates/wizard-dist/index.html +20 -0
  113. package/templates/wizard.html +13 -1129
@@ -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,262 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Portable FileUploadScale — copy to src/components/FileUploadScale.tsx
5
+ * Wire UPLOAD_API_BASE env (see docs/00-integration/file-upload-aws.md).
6
+ * Final answer MUST be UploadedFileAnswer[] with id + mediaUrl — not raw File[].
7
+ */
8
+
9
+ import React, { useState, useRef } from 'react';
10
+ import type { FileUploadQuestion } from '@explorer02/cfm-survey-sdk';
11
+
12
+ type UploadedFileAnswer = {
13
+ id: string;
14
+ mediaUrl: string;
15
+ name?: string;
16
+ mimeType?: string;
17
+ size?: number;
18
+ };
19
+
20
+ type FileUploadScaleProps = {
21
+ question: FileUploadQuestion;
22
+ selectedValue?: UploadedFileAnswer[];
23
+ onSelect: (value: UploadedFileAnswer[] | undefined) => void;
24
+ };
25
+
26
+ const UPLOAD_API_BASE =
27
+ typeof process !== 'undefined' && process.env.NEXT_PUBLIC_UPLOAD_API_BASE
28
+ ? process.env.NEXT_PUBLIC_UPLOAD_API_BASE
29
+ : typeof process !== 'undefined' && process.env.VITE_UPLOAD_API_BASE
30
+ ? process.env.VITE_UPLOAD_API_BASE
31
+ : '';
32
+
33
+ async function uploadFileToS3(
34
+ file: File,
35
+ questionId: string
36
+ ): Promise<UploadedFileAnswer> {
37
+ if (!UPLOAD_API_BASE) {
38
+ throw new Error('UPLOAD_API_BASE env not configured — see file-upload-aws.md');
39
+ }
40
+ const res = await fetch(`${UPLOAD_API_BASE}/get-upload-url`, {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify({
44
+ fileName: file.name,
45
+ contentType: file.type || 'application/octet-stream',
46
+ questionId,
47
+ }),
48
+ });
49
+ if (!res.ok) throw new Error('Failed to get upload URL');
50
+ const { uploadUrl, fileId, mediaUrl } = await res.json();
51
+ const putRes = await fetch(uploadUrl, {
52
+ method: 'PUT',
53
+ body: file,
54
+ headers: { 'Content-Type': file.type || 'application/octet-stream' },
55
+ });
56
+ if (!putRes.ok) throw new Error('Failed to upload file');
57
+ return {
58
+ id: fileId,
59
+ mediaUrl,
60
+ name: file.name,
61
+ mimeType: file.type,
62
+ size: file.size,
63
+ };
64
+ }
65
+
66
+ function FileUploadDropzone({
67
+ uploadMessage,
68
+ isDragActive,
69
+ maxFileCount,
70
+ fileSizeLimit,
71
+ fileSizeLimitType,
72
+ formattedFormats,
73
+ disabled,
74
+ onTriggerInput,
75
+ onDrag,
76
+ onDrop,
77
+ }: {
78
+ uploadMessage: string;
79
+ isDragActive: boolean;
80
+ maxFileCount: number;
81
+ fileSizeLimit: number;
82
+ fileSizeLimitType: string;
83
+ formattedFormats: string;
84
+ disabled: boolean;
85
+ onTriggerInput: () => void;
86
+ onDrag: (e: React.DragEvent) => void;
87
+ onDrop: (e: React.DragEvent) => void;
88
+ }) {
89
+ return (
90
+ <div
91
+ onDragEnter={onDrag}
92
+ onDragOver={onDrag}
93
+ onDragLeave={onDrag}
94
+ onDrop={onDrop}
95
+ onClick={disabled ? undefined : onTriggerInput}
96
+ style={{
97
+ display: 'flex',
98
+ flexDirection: 'column',
99
+ alignItems: 'center',
100
+ justifyContent: 'center',
101
+ border: `2px dashed ${isDragActive ? '#e20074' : '#d1d5db'}`,
102
+ borderRadius: '12px',
103
+ backgroundColor: isDragActive ? '#fdf2f8' : '#f9fafb',
104
+ padding: '40px 24px',
105
+ cursor: disabled ? 'not-allowed' : 'pointer',
106
+ opacity: disabled ? 0.6 : 1,
107
+ textAlign: 'center',
108
+ boxSizing: 'border-box',
109
+ }}
110
+ >
111
+ <div style={{ fontSize: '16px', fontWeight: 600, color: '#374151', marginBottom: '8px' }}
112
+ dangerouslySetInnerHTML={{ __html: uploadMessage }}
113
+ />
114
+ <div style={{ fontSize: '13px', color: '#6b7280', lineHeight: 1.5 }}>
115
+ File limit: {maxFileCount}; File size limit: {fileSizeLimit}MB/
116
+ {fileSizeLimitType === 'PER_FILE' ? 'file' : 'in total'};
117
+ {formattedFormats && ` Supports: ${formattedFormats};`}
118
+ </div>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ export function FileUploadScale({ question, selectedValue, onSelect }: FileUploadScaleProps) {
124
+ const {
125
+ uploadMessage = 'Drag attachments here or browse',
126
+ supportedFileFormats = [],
127
+ maxFileCount = 1,
128
+ fileSizeLimit = 10,
129
+ fileSizeLimitType = 'PER_FILE',
130
+ } = question;
131
+
132
+ const [isDragActive, setIsDragActive] = useState(false);
133
+ const [localError, setLocalError] = useState<string | null>(null);
134
+ const [uploading, setUploading] = useState(false);
135
+ const fileInputRef = useRef<HTMLInputElement>(null);
136
+
137
+ const currentFiles: UploadedFileAnswer[] = Array.isArray(selectedValue) ? selectedValue : [];
138
+
139
+ const validateAndUpload = async (files: FileList) => {
140
+ setLocalError(null);
141
+ const newFiles = Array.from(files);
142
+
143
+ if (currentFiles.length + newFiles.length > maxFileCount) {
144
+ setLocalError(`You can only upload up to ${maxFileCount} file(s) in total.`);
145
+ return;
146
+ }
147
+
148
+ for (const file of newFiles) {
149
+ const ext = file.name.split('.').pop()?.toUpperCase() || '';
150
+ if (supportedFileFormats.length > 0 && !supportedFileFormats.includes(ext)) {
151
+ setLocalError(`File type .${ext.toLowerCase()} is not supported.`);
152
+ return;
153
+ }
154
+ if (fileSizeLimitType === 'PER_FILE' && file.size > fileSizeLimit * 1024 * 1024) {
155
+ setLocalError(`File "${file.name}" exceeds the size limit of ${fileSizeLimit}MB per file.`);
156
+ return;
157
+ }
158
+ }
159
+
160
+ if (fileSizeLimitType === 'IN_TOTAL') {
161
+ const total = [...currentFiles.map(f => f.size ?? 0), ...newFiles.map(f => f.size)].reduce((a, b) => a + b, 0);
162
+ if (total > fileSizeLimit * 1024 * 1024) {
163
+ setLocalError(`Total size exceeds the limit of ${fileSizeLimit}MB.`);
164
+ return;
165
+ }
166
+ }
167
+
168
+ setUploading(true);
169
+ try {
170
+ const uploaded: UploadedFileAnswer[] = [];
171
+ for (const file of newFiles) {
172
+ uploaded.push(await uploadFileToS3(file, question.id));
173
+ }
174
+ onSelect([...currentFiles, ...uploaded]);
175
+ } catch (err) {
176
+ setLocalError(err instanceof Error ? err.message : 'Upload failed');
177
+ } finally {
178
+ setUploading(false);
179
+ }
180
+ };
181
+
182
+ const handleDrag = (e: React.DragEvent) => {
183
+ e.preventDefault();
184
+ e.stopPropagation();
185
+ if (uploading) return;
186
+ setIsDragActive(e.type === 'dragenter' || e.type === 'dragover');
187
+ };
188
+
189
+ const handleDrop = (e: React.DragEvent) => {
190
+ e.preventDefault();
191
+ e.stopPropagation();
192
+ setIsDragActive(false);
193
+ if (!uploading && e.dataTransfer.files?.length) {
194
+ void validateAndUpload(e.dataTransfer.files);
195
+ }
196
+ };
197
+
198
+ const removeFile = (index: number) => {
199
+ const next = currentFiles.filter((_, i) => i !== index);
200
+ onSelect(next.length > 0 ? next : undefined);
201
+ setLocalError(null);
202
+ };
203
+
204
+ const formattedFormats = supportedFileFormats.join(', ');
205
+
206
+ return (
207
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
208
+ <input
209
+ type="file"
210
+ ref={fileInputRef}
211
+ onChange={e => e.target.files && void validateAndUpload(e.target.files)}
212
+ multiple={maxFileCount > 1}
213
+ accept={supportedFileFormats.map(f => `.${f.toLowerCase()}`).join(',')}
214
+ style={{ display: 'none' }}
215
+ />
216
+
217
+ <FileUploadDropzone
218
+ uploadMessage={uploadMessage}
219
+ isDragActive={isDragActive}
220
+ maxFileCount={maxFileCount}
221
+ fileSizeLimit={fileSizeLimit}
222
+ fileSizeLimitType={fileSizeLimitType}
223
+ formattedFormats={formattedFormats}
224
+ disabled={uploading}
225
+ onTriggerInput={() => fileInputRef.current?.click()}
226
+ onDrag={handleDrag}
227
+ onDrop={handleDrop}
228
+ />
229
+
230
+ {uploading && (
231
+ <div style={{ fontSize: '14px', color: '#6b7280' }}>Uploading…</div>
232
+ )}
233
+
234
+ {localError && (
235
+ <div style={{
236
+ backgroundColor: '#fee2e2', border: '1px solid #fecaca', borderRadius: '8px',
237
+ padding: '12px 16px', color: '#b91c1c', fontSize: '14px', fontWeight: 500,
238
+ }}>
239
+ {localError}
240
+ </div>
241
+ )}
242
+
243
+ {currentFiles.length > 0 && (
244
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
245
+ {currentFiles.map((file, idx) => (
246
+ <div key={file.id} style={{
247
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
248
+ padding: '12px 16px', backgroundColor: '#fff', border: '1px solid #e5e7eb', borderRadius: '8px',
249
+ }}>
250
+ <span style={{ fontSize: '14px', fontWeight: 500, color: '#374151' }}>{file.name ?? file.mediaUrl}</span>
251
+ <button type="button" onClick={() => removeFile(idx)} style={{
252
+ border: 'none', background: '#f3f4f6', borderRadius: '50%', width: 24, height: 24, cursor: 'pointer',
253
+ }}>
254
+ ×
255
+ </button>
256
+ </div>
257
+ ))}
258
+ </div>
259
+ )}
260
+ </div>
261
+ );
262
+ }
@@ -0,0 +1,114 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Portable HeatmapScale — copy to src/components/HeatmapScale.tsx
5
+ * Also copy templates/heatmapCoords.ts to src/lib/heatmapCoords.ts
6
+ */
7
+
8
+ import React, { useState } from 'react';
9
+ import type { HeatmapAnswer, HeatmapQuestion } from '@explorer02/cfm-survey-sdk';
10
+ import {
11
+ createNewSpot,
12
+ findSpotNearClick,
13
+ getNormalizedClickFromImage,
14
+ } from './heatmapCoords';
15
+
16
+ type HeatmapScaleProps = {
17
+ question: HeatmapQuestion;
18
+ selectedValue?: HeatmapAnswer;
19
+ onSelect: (answer: HeatmapAnswer) => void;
20
+ };
21
+
22
+ function HeatmapSpot({ spot }: { spot: { id: string; x: number; y: number } }) {
23
+ return (
24
+ <div
25
+ className="absolute rounded-full transition-transform duration-150 ease-out"
26
+ style={{
27
+ left: `${spot.x * 100}%`,
28
+ top: `${spot.y * 100}%`,
29
+ width: 16,
30
+ height: 16,
31
+ transform: 'translate(-50%, -50%)',
32
+ backgroundColor: '#F97316',
33
+ border: '2px solid white',
34
+ boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
35
+ }}
36
+ />
37
+ );
38
+ }
39
+
40
+ export function HeatmapScale({ question, selectedValue = [], onSelect }: HeatmapScaleProps) {
41
+ const [isImageReady, setIsImageReady] = useState(false);
42
+ const spots = selectedValue ?? [];
43
+ const spotLabel = question.maxClicksAllowed === 1 ? 'spot' : 'spots';
44
+
45
+ function handleImageClick(event: React.MouseEvent<HTMLImageElement>) {
46
+ if (!isImageReady) {
47
+ return;
48
+ }
49
+
50
+ const imageElement = event.currentTarget;
51
+ const rect = imageElement.getBoundingClientRect();
52
+ const { x, y } = getNormalizedClickFromImage(event, imageElement);
53
+ const nearSpot = findSpotNearClick(x, y, spots, rect);
54
+
55
+ if (nearSpot) {
56
+ const updated = spots.filter(spot => spot.id !== nearSpot.id);
57
+ onSelect(updated);
58
+ return;
59
+ }
60
+
61
+ if (spots.length >= question.maxClicksAllowed) {
62
+ return;
63
+ }
64
+
65
+ onSelect([...spots, createNewSpot(x, y)]);
66
+ }
67
+
68
+ function handleImageLoad() {
69
+ setIsImageReady(true);
70
+ }
71
+
72
+ function handleImageError() {
73
+ setIsImageReady(false);
74
+ }
75
+
76
+ return (
77
+ <div className="mt-4 flex w-full flex-col items-center">
78
+ <div className="relative inline-block max-w-full">
79
+ {!isImageReady && (
80
+ <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-gray-100">
81
+ <span className="text-sm text-gray-500">Loading image...</span>
82
+ </div>
83
+ )}
84
+ <img
85
+ src={question.imageUrl}
86
+ alt=""
87
+ draggable={false}
88
+ onLoad={handleImageLoad}
89
+ onError={handleImageError}
90
+ onClick={handleImageClick}
91
+ className="block max-w-full h-auto select-none rounded-md"
92
+ style={{ cursor: isImageReady ? 'crosshair' : 'default' }}
93
+ />
94
+ {isImageReady && (
95
+ <div className="pointer-events-none absolute inset-0">
96
+ {spots.map(spot => (
97
+ <HeatmapSpot key={spot.id} spot={spot} />
98
+ ))}
99
+ </div>
100
+ )}
101
+ </div>
102
+
103
+ <p className="mt-3 flex items-start gap-2 text-sm text-gray-500">
104
+ <span aria-hidden="true" className="mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-gray-400 text-[10px] font-semibold text-gray-500">
105
+ i
106
+ </span>
107
+ <span>
108
+ You can select up to {question.maxClicksAllowed} {spotLabel}. To change your selection,
109
+ simply click on the same spot again to remove it.
110
+ </span>
111
+ </p>
112
+ </div>
113
+ );
114
+ }