@explorer02/cfm-survey-sdk 0.2.3 → 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 (94) 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 +60 -19
  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/apply-ui-config.md +365 -0
  12. package/templates/docs/00-integration/canonical-survey-fixtures.md +94 -0
  13. package/templates/docs/00-integration/client-integration-guide.md +9 -7
  14. package/templates/docs/00-integration/client-lib-folder.md +115 -0
  15. package/templates/docs/00-integration/component-checklist.md +88 -62
  16. package/templates/docs/00-integration/constraints.md +13 -5
  17. package/templates/docs/00-integration/custom-field-logic-and-navigation.md +56 -0
  18. package/templates/docs/00-integration/progress.md +4 -2
  19. package/templates/docs/00-integration/setup.md +12 -0
  20. package/templates/docs/00-integration/ui-customization-wizard.md +144 -0
  21. package/templates/docs/00-integration/useSurveySDK.md +35 -1
  22. package/templates/docs/00-integration/wizard-api.md +134 -0
  23. package/templates/docs/00-integration/wizard-config-handoff.md +246 -0
  24. package/templates/docs/00-integration/wizard-preview-build-guide.md +400 -0
  25. package/templates/docs/00-integration/wizard-question-type-styling.md +195 -0
  26. package/templates/docs/01-components/01-survey-page.md +18 -8
  27. package/templates/docs/01-components/02-question.md +2 -2
  28. package/templates/docs/01-components/03-rating-scale.md +12 -18
  29. package/templates/docs/01-components/05-csat-matrix-scale.md +18 -26
  30. package/templates/docs/01-components/06-likert-matrix-scale.md +19 -39
  31. package/templates/docs/01-components/07-slider-matrix-scale.md +48 -33
  32. package/templates/docs/01-components/09-custom-slider-track.md +15 -25
  33. package/templates/docs/01-components/10-header-footer.md +23 -4
  34. package/templates/docs/01-components/11-progress-bar.md +14 -6
  35. package/templates/docs/01-components/19-survey-sticky-chrome.md +79 -0
  36. package/templates/docs/01-components/README.md +2 -2
  37. package/templates/docs/02-reference/question-types/10-slider-matrix.md +38 -26
  38. package/templates/docs/02-reference/routing-table.md +1 -0
  39. package/templates/docs/02-reference/value-derivation.md +1 -1
  40. package/templates/docs/03-ui-specs/00-question-shell.md +10 -0
  41. package/templates/docs/03-ui-specs/01-rating.md +18 -0
  42. package/templates/docs/03-ui-specs/02-radio.md +16 -1
  43. package/templates/docs/03-ui-specs/03-text.md +12 -1
  44. package/templates/docs/03-ui-specs/04-csat.md +84 -50
  45. package/templates/docs/03-ui-specs/06-slider.md +3 -1
  46. package/templates/docs/03-ui-specs/07-matrix-cfm.md +71 -48
  47. package/templates/docs/03-ui-specs/08-matrix-csat-rating.md +48 -18
  48. package/templates/docs/03-ui-specs/09-slider-matrix.md +59 -32
  49. package/templates/docs/03-ui-specs/10-file-upload.md +11 -0
  50. package/templates/docs/03-ui-specs/11-text-and-media.md +4 -0
  51. package/templates/docs/03-ui-specs/12-survey-chrome.md +54 -5
  52. package/templates/docs/03-ui-specs/13-heatmap.md +12 -1
  53. package/templates/docs/03-ui-specs/14-rank-order.md +11 -0
  54. package/templates/docs/03-ui-specs/README.md +4 -2
  55. package/templates/docs/03-ui-specs/shared/matrix-dropdown.md +1 -1
  56. package/templates/docs/MANIFEST.json +162 -6
  57. package/templates/docs/index.md +116 -117
  58. package/templates/docs/templates/CsatMatrixScale.tsx +637 -0
  59. package/templates/docs/templates/FileUploadScale.tsx +262 -0
  60. package/templates/docs/templates/HeatmapScale.tsx +114 -0
  61. package/templates/docs/templates/LikertMatrixScale.tsx +414 -0
  62. package/templates/docs/templates/Question.tsx +3 -0
  63. package/templates/docs/templates/RatingScale.tsx +129 -0
  64. package/templates/docs/templates/SliderMatrixScale.tsx +248 -0
  65. package/templates/docs/templates/SurveyStickyChrome.tsx +24 -0
  66. package/templates/docs/templates/customFieldValues.ts +36 -0
  67. package/templates/docs/templates/implementation_plan.md +56 -9
  68. package/templates/docs/templates/labelStyles.ts +33 -0
  69. package/templates/docs/templates/selectionStyles.ts +100 -0
  70. package/templates/docs/templates/surveyUiIcons.tsx +11 -0
  71. package/templates/docs/templates/surveyUiScaleUtils.ts +51 -0
  72. package/templates/docs/templates/verify-agent-build.sh +119 -0
  73. package/templates/preview-harness/preview-bridge.inline.js +201 -0
  74. package/templates/preview-harness/previewPages.js +108 -0
  75. package/templates/preview-harness/previewPages.ts +298 -0
  76. package/templates/preview-harness/vite-app/src/PreviewConfigContext.tsx +67 -0
  77. package/templates/preview-harness/vite-app/src/QuestionPreview.tsx +122 -0
  78. package/templates/preview-harness/vite-app/src/SurveyPagePreview.tsx +75 -0
  79. package/templates/preview-harness/vite-app/src/fixtures/questions.ts +311 -0
  80. package/templates/preview-harness/vite-app/src/globals.css +16 -0
  81. package/templates/preview-harness/vite-app/src/mount.tsx +9 -0
  82. package/templates/preview-harness/vite-app/src/preview-live-overrides.css +101 -0
  83. package/templates/preview-harness/vite-app/stubs/next-dynamic.tsx +25 -0
  84. package/templates/preview-harness/vite-app/stubs/next-image.tsx +29 -0
  85. package/templates/previewBridge.ts +153 -0
  86. package/templates/survey-theme.css +295 -0
  87. package/templates/survey-ui-config.schema.json +213 -0
  88. package/templates/wizard-dist/assets/PreviewMock-DgHfrVeb.js +1 -0
  89. package/templates/wizard-dist/assets/TypePanel-CFVC3Ptn.js +1 -0
  90. package/templates/wizard-dist/assets/index-BhWM50Yu.css +1 -0
  91. package/templates/wizard-dist/assets/index-DYK3X1e5.js +34 -0
  92. package/templates/wizard-dist/assets/vendor-BwkXDkd3.js +17 -0
  93. package/templates/wizard-dist/index.html +20 -0
  94. package/templates/wizard.html +13 -1129
@@ -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
+ }