@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,637 @@
1
+ import React, { useState } from 'react';
2
+ import type { CsatQuestion, RatingMatrixQuestion, MatrixRowAnswers, ScaleColumn } from '@explorer02/cfm-survey-sdk';
3
+ import { getEmojiForIndex, CsatStarIcons } from './surveyUiIcons';
4
+ import { columnSubmitValue, nonNaScaleColumns } from './surveyUiScaleUtils';
5
+ import { CustomSliderTrack } from './CustomSliderTrack';
6
+ import MatrixDropdown from './MatrixDropdown';
7
+
8
+ type CsatMatrixQuestion = CsatQuestion | RatingMatrixQuestion;
9
+
10
+ function GraphicsColumnLabelsRow({ scaleColumns }: { scaleColumns: ScaleColumn[] }) {
11
+ if (scaleColumns.length === 0) return null;
12
+
13
+ return (
14
+ <div style={{ flex: 1, padding: '0 24px' }}>
15
+ <div style={{ position: 'relative', margin: '0 16px', height: '16px' }}>
16
+ {scaleColumns.map((col, i) => {
17
+ const percent = scaleColumns.length === 1 ? 0 : (i / (scaleColumns.length - 1)) * 100;
18
+ return (
19
+ <div
20
+ key={col.id}
21
+ style={{
22
+ position: 'absolute',
23
+ left: `${percent}%`,
24
+ transform: 'translateX(-50%)',
25
+ bottom: 0,
26
+ textAlign: 'center',
27
+ fontSize: '12px',
28
+ fontWeight: 500,
29
+ color: '#4b5563',
30
+ lineHeight: 1.2,
31
+ wordBreak: 'break-word',
32
+ width: '64px',
33
+ }}
34
+ >
35
+ <span dangerouslySetInnerHTML={{ __html: col.label }} />
36
+ </div>
37
+ );
38
+ })}
39
+ </div>
40
+ </div>
41
+ );
42
+ }
43
+
44
+ type CsatMatrixScaleProps = {
45
+ question: CsatQuestion | RatingMatrixQuestion;
46
+ selectedValue?: MatrixRowAnswers;
47
+ onSelect: (value: MatrixRowAnswers) => void;
48
+ };
49
+
50
+ // ── SUB-COMPONENT: Vertical Layout (Dropdown, Selectbox, Radio list) ────────
51
+ type VerticalListProps = {
52
+ question: CsatMatrixQuestion;
53
+ scaleColumns: ScaleColumn[];
54
+ selectedValue: MatrixRowAnswers;
55
+ onCellSelect: (rowId: string, colValue: any) => void;
56
+ isDropdown: boolean;
57
+ isSelectBox: boolean;
58
+ };
59
+
60
+ function CsatMatrixVerticalList({
61
+ question,
62
+ scaleColumns,
63
+ selectedValue,
64
+ onCellSelect,
65
+ isDropdown,
66
+ isSelectBox
67
+ }: VerticalListProps) {
68
+ const { statementRows, hasNotApplicableOption: hasNotApplicable } = question;
69
+
70
+ return (
71
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
72
+ {statementRows.map(row => (
73
+ <div key={row.id} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
74
+ <h3 style={{ fontSize: '15px', fontWeight: 500, color: '#111827', lineHeight: 1.6, margin: 0 }}>
75
+ <span dangerouslySetInnerHTML={{ __html: row.statementText }} />
76
+ </h3>
77
+
78
+ {isDropdown ? (
79
+ <MatrixDropdown
80
+ value={selectedValue[row.id]}
81
+ options={scaleColumns}
82
+ onChange={(val) => onCellSelect(row.id, val)}
83
+ placeholder="Select..."
84
+ />
85
+ ) : isSelectBox ? (
86
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
87
+ {scaleColumns.map(col => {
88
+ const submitValue = columnSubmitValue(col);
89
+ const isSelected = selectedValue[row.id] === submitValue;
90
+ return (
91
+ <button
92
+ key={col.id}
93
+ type="button"
94
+ onClick={() => onCellSelect(row.id, submitValue)}
95
+ style={{
96
+ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'flex-start',
97
+ borderRadius: '8px', border: `1px solid ${isSelected ? '#e20074' : '#d1d5db'}`,
98
+ padding: '14px 18px', cursor: 'pointer', transition: 'all 0.15s',
99
+ backgroundColor: isSelected ? '#fdf2f8' : '#fff',
100
+ color: isSelected ? '#e20074' : '#374151',
101
+ fontWeight: isSelected ? 600 : 400, fontSize: '15px', textAlign: 'left',
102
+ }}
103
+ >
104
+ <span dangerouslySetInnerHTML={{ __html: col.label }} />
105
+ </button>
106
+ );
107
+ })}
108
+ </div>
109
+ ) : (
110
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
111
+ {scaleColumns.map(col => {
112
+ const submitValue = columnSubmitValue(col);
113
+ const isSelected = selectedValue[row.id] === submitValue;
114
+ return (
115
+ <label
116
+ key={col.id}
117
+ style={{
118
+ display: 'flex', alignItems: 'center', gap: '16px',
119
+ borderRadius: '8px', border: `1px solid ${isSelected ? '#e20074' : '#e5e5e5'}`,
120
+ padding: '16px 20px', cursor: 'pointer', transition: 'all 0.15s',
121
+ backgroundColor: isSelected ? '#fdf2f8' : '#fff',
122
+ }}
123
+ >
124
+ <input type="radio" name={`${question.id}-${row.id}`}
125
+ value={submitValue === null ? '' : submitValue} checked={isSelected}
126
+ onChange={() => onCellSelect(row.id, submitValue)}
127
+ style={{ position: 'absolute', width: 0, height: 0, opacity: 0 }}
128
+ />
129
+ <div style={{
130
+ display: 'flex', width: '20px', height: '20px', flexShrink: 0,
131
+ alignItems: 'center', justifyContent: 'center', borderRadius: '50%',
132
+ border: isSelected ? '2px solid #e20074' : '1.5px solid #9ca3af',
133
+ backgroundColor: '#fff',
134
+ }}>
135
+ {isSelected && <div style={{ width: '10px', height: '10px', borderRadius: '50%', backgroundColor: '#e20074' }} />}
136
+ </div>
137
+ <span style={{ fontSize: '15px', fontWeight: 500, color: '#111827' }}
138
+ dangerouslySetInnerHTML={{ __html: col.label }} />
139
+ </label>
140
+ );
141
+ })}
142
+ </div>
143
+ )}
144
+
145
+ {hasNotApplicable && (
146
+ <label style={{ display: 'flex', alignItems: 'center', gap: '12px', cursor: 'pointer', userSelect: 'none' }}>
147
+ <input type="checkbox" checked={selectedValue[row.id] === null}
148
+ onChange={() => onCellSelect(row.id, selectedValue[row.id] === null ? undefined : null)}
149
+ style={{ width: '16px', height: '16px', accentColor: '#e20074', cursor: 'pointer' }} />
150
+ <span style={{ fontSize: '14px', fontWeight: 500, color: '#4b5563' }}>Not Applicable</span>
151
+ </label>
152
+ )}
153
+ </div>
154
+ ))}
155
+ </div>
156
+ );
157
+ }
158
+
159
+ // ── SUB-COMPONENT: Grid Layout (Visual choices: emojis, stars, numbers, slider) ─
160
+ type GridProps = {
161
+ question: CsatMatrixQuestion;
162
+ scaleColumns: ScaleColumn[];
163
+ selectedValue: MatrixRowAnswers;
164
+ onCellSelect: (rowId: string, colValue: any) => void;
165
+ isEmoji: boolean;
166
+ isStar: boolean;
167
+ isNumbered: boolean;
168
+ isGraphics: boolean;
169
+ };
170
+
171
+ function CsatMatrixGrid({
172
+ question,
173
+ scaleColumns,
174
+ selectedValue,
175
+ onCellSelect,
176
+ isEmoji,
177
+ isStar,
178
+ isNumbered,
179
+ isGraphics
180
+ }: GridProps) {
181
+ const { statementRows, scaleAnchorLabels: labels, hasNotApplicableOption: hasNotApplicable, reverseScaleOrder } = question;
182
+ const [hoveredCell, setHoveredCell] = useState<string | null>(null);
183
+
184
+ const showLabels = labels && labels.length > 0;
185
+ const m = scaleColumns.length;
186
+ const n = labels?.length || 0;
187
+
188
+ return (
189
+ <div style={{ width: '100%' }}>
190
+ {/* Labels row */}
191
+ {showLabels && m > 0 && (
192
+ <div style={{ display: 'flex', alignItems: 'flex-end', marginBottom: '12px' }}>
193
+ <div style={{ width: '180px', flexShrink: 0 }} />
194
+ <div style={{ flex: 1, display: 'flex' }}>
195
+ <div style={{ flex: 1, position: 'relative', height: '24px', ...(isGraphics ? { margin: '0 40px' } : {}) }}>
196
+ {labels!.map((label, i) => {
197
+ const startPercent = isGraphics ? 0 : 0.5 / m;
198
+ const rangePercent = isGraphics ? 1 : (m - 1) / m;
199
+ const percent = n === 1 ? 50 : (startPercent + (i / (n - 1)) * rangePercent) * 100;
200
+
201
+ return (
202
+ <div key={i} style={{
203
+ position: 'absolute',
204
+ left: `${percent}%`,
205
+ transform: 'translateX(-50%)',
206
+ textAlign: 'center', fontSize: '13px', fontWeight: 600,
207
+ color: '#4b5563', lineHeight: 1.3, whiteSpace: 'nowrap'
208
+ }}>
209
+ {label}
210
+ </div>
211
+ );
212
+ })}
213
+ </div>
214
+ {hasNotApplicable && <div style={{ width: '56px', flexShrink: 0 }} />}
215
+ </div>
216
+ </div>
217
+ )}
218
+
219
+ {/* Column tick labels for graphics sliders (shown with or without anchor labels) */}
220
+ {isGraphics && m > 0 && (
221
+ <div style={{ display: 'flex', alignItems: 'flex-end', marginBottom: showLabels ? '8px' : '16px', minHeight: '24px' }}>
222
+ <div style={{ width: '180px', flexShrink: 0 }} />
223
+ <div style={{ flex: 1, display: 'flex' }}>
224
+ <GraphicsColumnLabelsRow scaleColumns={scaleColumns} />
225
+ {hasNotApplicable && (
226
+ <div style={{ width: '56px', flexShrink: 0 }} />
227
+ )}
228
+ </div>
229
+ </div>
230
+ )}
231
+
232
+ {/* Column Headers for non-graphics */}
233
+ {!isGraphics && !showLabels && m > 0 && (
234
+ <div style={{ display: 'flex', alignItems: 'flex-end', marginBottom: '16px', minHeight: '24px' }}>
235
+ <div style={{ width: '180px', flexShrink: 0 }} />
236
+ <div style={{ flex: 1, display: 'flex' }}>
237
+ <div style={{ flex: 1, display: 'grid', gridTemplateColumns: `repeat(${scaleColumns.length}, 1fr)` }}>
238
+ {scaleColumns.map(col => (
239
+ <div key={col.id} style={{ display: 'flex', justifyContent: 'center', padding: '0 4px' }}>
240
+ <span
241
+ style={{ fontSize: '13px', fontWeight: 500, color: '#4b5563', textAlign: 'center', lineHeight: 1.2 }}
242
+ dangerouslySetInnerHTML={{ __html: col.label }}
243
+ />
244
+ </div>
245
+ ))}
246
+ </div>
247
+ {hasNotApplicable && (
248
+ <div style={{ width: '56px', flexShrink: 0, textAlign: 'center', fontSize: '11px', fontWeight: 600, color: '#6b7280', textTransform: 'uppercase', display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}>
249
+ N/A
250
+ </div>
251
+ )}
252
+ </div>
253
+ </div>
254
+ )}
255
+
256
+ {/* Grid Rows */}
257
+ <div>
258
+ {statementRows.map((row, rowIdx) => (
259
+ <div key={row.id} style={{
260
+ display: 'flex', alignItems: 'center', borderRadius: '8px',
261
+ padding: '12px 0', backgroundColor: rowIdx % 2 === 0 ? 'rgba(249, 250, 251, 0.8)' : '#fff',
262
+ }}>
263
+ <div style={{ width: '180px', flexShrink: 0, padding: '0 16px' }}>
264
+ <span style={{ fontSize: '14px', fontWeight: 500, color: '#111827', lineHeight: 1.4 }}
265
+ dangerouslySetInnerHTML={{ __html: row.statementText }} />
266
+ </div>
267
+
268
+ {isGraphics ? (
269
+ <div style={{ flex: 1, padding: '0 24px', position: 'relative', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
270
+ <CustomSliderTrack
271
+ min={0}
272
+ max={scaleColumns.length - 1}
273
+ htmlStep={1}
274
+ value={
275
+ selectedValue[row.id] !== undefined && selectedValue[row.id] !== null
276
+ ? scaleColumns.findIndex(c => columnSubmitValue(c) === selectedValue[row.id])
277
+ : (reverseScaleOrder ? scaleColumns.length - 1 : 0)
278
+ }
279
+ disabled={selectedValue[row.id] === null}
280
+ hasSelectedValue={selectedValue[row.id] !== undefined && selectedValue[row.id] !== null}
281
+ sliderType="graphics"
282
+ ticks={scaleColumns.length}
283
+ tickLabels={scaleColumns.map(col => col.label)}
284
+ reverseScaleOrder={reverseScaleOrder}
285
+ onChange={v => onCellSelect(row.id, columnSubmitValue(scaleColumns[v]))}
286
+ />
287
+ </div>
288
+ ) : (
289
+ <div style={{ flex: 1, display: 'grid', alignItems: 'center', gridTemplateColumns: `repeat(${scaleColumns.length}, 1fr)` }}>
290
+ {scaleColumns.map((col, colIdx) => {
291
+ const submitValue = columnSubmitValue(col);
292
+ const isSelected = selectedValue[row.id] === submitValue;
293
+ const cellKey = `${row.id}-${col.id}`;
294
+ const isHovered = hoveredCell === cellKey;
295
+ const displayIdx = reverseScaleOrder ? scaleColumns.length - 1 - colIdx : colIdx;
296
+
297
+ const EmojiNode = isEmoji ? getEmojiForIndex(displayIdx, scaleColumns.length) : null;
298
+ const StarNode = isStar ? (isSelected ? (CsatStarIcons.filled as any) : (CsatStarIcons.empty as any)) : null;
299
+
300
+ const RadioNode = (
301
+ <div style={{
302
+ display: 'flex', width: '18px', height: '18px',
303
+ alignItems: 'center', justifyContent: 'center', borderRadius: '50%',
304
+ margin: '0 auto', border: isSelected ? '2px solid #e20074' : '2px solid #d1d5db',
305
+ backgroundColor: '#fff',
306
+ }}>
307
+ {isSelected && <div style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: '#e20074' }} />}
308
+ </div>
309
+ );
310
+
311
+ return (
312
+ <div key={col.id} style={{ display: 'flex', justifyContent: 'center' }}>
313
+ <div
314
+ style={{ position: 'relative' }}
315
+ onMouseEnter={() => setHoveredCell(cellKey)}
316
+ onMouseLeave={() => setHoveredCell(null)}
317
+ >
318
+ <button
319
+ type="button"
320
+ onClick={() => onCellSelect(row.id, submitValue)}
321
+ style={{
322
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
323
+ transition: 'all 0.15s', border: 'none', backgroundColor: 'transparent',
324
+ cursor: 'pointer', borderRadius: '6px',
325
+ ...(isEmoji ? {
326
+ padding: '6px',
327
+ transform: isSelected ? 'scale(1.1)' : isHovered ? 'scale(1.25)' : 'scale(1)',
328
+ ...(isSelected ? {
329
+ backgroundColor: 'rgba(252, 231, 243, 0.6)',
330
+ boxShadow: '0 0 0 2px rgba(226, 0, 116, 0.3)',
331
+ borderRadius: '50%',
332
+ } : {}),
333
+ } : isStar ? {
334
+ padding: '4px',
335
+ transform: isHovered ? 'scale(1.1)' : 'scale(1)',
336
+ opacity: isSelected ? 1 : isHovered ? 1 : 0.5,
337
+ } : isNumbered ? {
338
+ width: '40px', height: '40px', borderRadius: '8px',
339
+ fontSize: '14px', fontWeight: 500,
340
+ border: `1px solid ${isSelected ? '#e20074' : '#d1d5db'}`,
341
+ backgroundColor: isSelected ? '#e20074' : '#fff',
342
+ color: isSelected ? '#fff' : '#111827',
343
+ transition: 'all 0.15s',
344
+ } : { padding: '8px' }),
345
+ }}
346
+ >
347
+ {(EmojiNode ?? StarNode ?? (isNumbered ? (displayIdx + 1) : RadioNode)) as React.ReactNode}
348
+ </button>
349
+
350
+ {isHovered && (
351
+ <div style={{
352
+ position: 'absolute', bottom: '100%', left: '50%',
353
+ transform: 'translateX(-50%)', marginBottom: '8px',
354
+ whiteSpace: 'nowrap', borderRadius: '6px',
355
+ backgroundColor: '#1f2937', padding: '5px 10px',
356
+ fontSize: '12px', fontWeight: 500, color: '#fff',
357
+ boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
358
+ zIndex: 50, pointerEvents: 'none',
359
+ }}>
360
+ {col.label}
361
+ <div style={{
362
+ position: 'absolute', top: '100%', left: '50%',
363
+ transform: 'translateX(-50%)',
364
+ borderWidth: '5px', borderStyle: 'solid',
365
+ borderColor: '#1f2937 transparent transparent transparent',
366
+ }} />
367
+ </div>
368
+ )}
369
+ </div>
370
+ </div>
371
+ );
372
+ })}
373
+ </div>
374
+ )}
375
+
376
+ {hasNotApplicable && (
377
+ <div style={{ width: '56px', flexShrink: 0, display: 'flex', justifyContent: 'center' }}>
378
+ <label style={{ cursor: 'pointer' }}>
379
+ <input type="checkbox" checked={selectedValue[row.id] === null}
380
+ onChange={() => onCellSelect(row.id, selectedValue[row.id] === null ? undefined : null)}
381
+ style={{ width: '16px', height: '16px', accentColor: '#e20074', cursor: 'pointer' }} />
382
+ </label>
383
+ </div>
384
+ )}
385
+ </div>
386
+ ))}
387
+ </div>
388
+ </div>
389
+ );
390
+ }
391
+
392
+ // ── SUB-COMPONENT: Carousel Layout ─────────────────────────────────────────
393
+ type CarouselProps = {
394
+ question: CsatMatrixQuestion;
395
+ scaleColumns: ScaleColumn[];
396
+ selectedValue: MatrixRowAnswers;
397
+ onCellSelect: (rowId: string, colValue: any) => void;
398
+ isEmoji: boolean;
399
+ isStar: boolean;
400
+ isNumbered: boolean;
401
+ isGraphics: boolean;
402
+ };
403
+
404
+ function CsatMatrixCarousel({
405
+ question,
406
+ scaleColumns,
407
+ selectedValue,
408
+ onCellSelect,
409
+ isEmoji,
410
+ isStar,
411
+ isNumbered,
412
+ isGraphics
413
+ }: CarouselProps) {
414
+ const { statementRows, scaleAnchorLabels: labels, hasNotApplicableOption: hasNotApplicable, reverseScaleOrder } = question;
415
+ const [activeCarouselIndex, setActiveCarouselIndex] = useState(0);
416
+ const [hoveredCell, setHoveredCell] = useState<string | null>(null);
417
+
418
+ const row = statementRows[activeCarouselIndex];
419
+ if (!row) return null;
420
+
421
+ const showLabels = labels && labels.length > 0;
422
+
423
+ return (
424
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', backgroundColor: '#fff', borderRadius: '12px', border: '1px solid #e5e5e5', padding: '32px', boxShadow: '0 4px 6px -1px rgba(0,0,0,0.05)', width: '100%', boxSizing: 'border-box' }}>
425
+ <h3 style={{ fontSize: '18px', fontWeight: 500, color: '#111827', marginBottom: '32px', textAlign: 'center' }} dangerouslySetInnerHTML={{ __html: row.statementText }} />
426
+
427
+ {showLabels && scaleColumns.length > 0 && (
428
+ <div style={{ width: '100%', maxWidth: '800px', display: 'flex', position: 'relative', height: '24px', marginBottom: '16px' }}>
429
+ {labels!.map((label, i) => {
430
+ const m = scaleColumns.length;
431
+ const n = labels!.length;
432
+ const startPercent = isGraphics ? 0 : 0.5 / m;
433
+ const rangePercent = isGraphics ? 1 : (m - 1) / m;
434
+ const percent = n === 1 ? 50 : (startPercent + (i / (n - 1)) * rangePercent) * 100;
435
+
436
+ return (
437
+ <div key={i} style={{
438
+ position: 'absolute', left: `${percent}%`, transform: 'translateX(-50%)',
439
+ textAlign: 'center', fontSize: '13px', fontWeight: 600,
440
+ color: '#4b5563', lineHeight: 1.3, whiteSpace: 'nowrap'
441
+ }}>
442
+ {label}
443
+ </div>
444
+ );
445
+ })}
446
+ </div>
447
+ )}
448
+
449
+ {isGraphics && scaleColumns.length > 0 && (
450
+ <div style={{ width: '100%', maxWidth: '800px', marginBottom: showLabels ? '8px' : '16px' }}>
451
+ <GraphicsColumnLabelsRow scaleColumns={scaleColumns} />
452
+ </div>
453
+ )}
454
+
455
+ {isGraphics ? (
456
+ <div style={{ width: '100%', maxWidth: '800px', marginBottom: '40px', padding: '0 24px' }}>
457
+ <CustomSliderTrack
458
+ min={0} max={scaleColumns.length - 1} htmlStep={1}
459
+ value={
460
+ selectedValue[row.id] !== undefined && selectedValue[row.id] !== null
461
+ ? scaleColumns.findIndex(c => columnSubmitValue(c) === selectedValue[row.id])
462
+ : (reverseScaleOrder ? scaleColumns.length - 1 : 0)
463
+ }
464
+ disabled={selectedValue[row.id] === null}
465
+ hasSelectedValue={selectedValue[row.id] !== undefined && selectedValue[row.id] !== null}
466
+ sliderType="graphics" ticks={scaleColumns.length}
467
+ tickLabels={scaleColumns.map(col => col.label)}
468
+ reverseScaleOrder={reverseScaleOrder}
469
+ onChange={v => onCellSelect(row.id, columnSubmitValue(scaleColumns[v]))}
470
+ />
471
+ </div>
472
+ ) : (
473
+ <div style={{ display: 'grid', gridTemplateColumns: `repeat(auto-fit, minmax(120px, 1fr))`, gap: '20px 32px', width: '100%', maxWidth: '800px', marginBottom: '40px', justifyContent: 'center' }}>
474
+ {scaleColumns.map((col, colIdx) => {
475
+ const submitValue = columnSubmitValue(col);
476
+ const isSelected = selectedValue[row.id] === submitValue;
477
+ const cellKey = `${row.id}-${col.id}`;
478
+ const isHovered = hoveredCell === cellKey;
479
+ const displayIdx = reverseScaleOrder ? scaleColumns.length - 1 - colIdx : colIdx;
480
+
481
+ const EmojiNode = isEmoji ? getEmojiForIndex(displayIdx, scaleColumns.length) : null;
482
+ const StarNode = isStar ? (isSelected ? (CsatStarIcons.filled as any) : (CsatStarIcons.empty as any)) : null;
483
+ const RadioNode = (
484
+ <div style={{
485
+ display: 'flex', width: '18px', height: '18px',
486
+ alignItems: 'center', justifyContent: 'center', borderRadius: '50%',
487
+ margin: '0 auto', border: isSelected ? '2px solid #e20074' : '2px solid #d1d5db',
488
+ backgroundColor: '#fff',
489
+ }}>
490
+ {isSelected && <div style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: '#e20074' }} />}
491
+ </div>
492
+ );
493
+
494
+ return (
495
+ <div key={col.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px', cursor: 'pointer' }}
496
+ onClick={() => onCellSelect(row.id, submitValue)}
497
+ onMouseEnter={() => setHoveredCell(cellKey)}
498
+ onMouseLeave={() => setHoveredCell(null)}>
499
+ <button
500
+ type="button"
501
+ style={{
502
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
503
+ transition: 'all 0.15s', border: 'none', backgroundColor: 'transparent',
504
+ cursor: 'pointer', borderRadius: '6px', pointerEvents: 'none',
505
+ ...(isEmoji ? {
506
+ padding: '6px', transform: isSelected ? 'scale(1.1)' : isHovered ? 'scale(1.25)' : 'scale(1)',
507
+ ...(isSelected ? { backgroundColor: 'rgba(252, 231, 243, 0.6)', boxShadow: '0 0 0 2px rgba(226, 0, 116, 0.3)', borderRadius: '50%' } : {}),
508
+ } : isStar ? {
509
+ padding: '4px', transform: isHovered ? 'scale(1.1)' : 'scale(1)', opacity: isSelected ? 1 : isHovered ? 1 : 0.5,
510
+ } : isNumbered ? {
511
+ width: '40px', height: '40px', borderRadius: '8px', fontSize: '14px', fontWeight: 500,
512
+ border: `1px solid ${isSelected ? '#e20074' : '#d1d5db'}`, backgroundColor: isSelected ? '#e20074' : '#fff', color: isSelected ? '#fff' : '#111827',
513
+ } : { padding: '8px' }),
514
+ }}
515
+ >
516
+ {(EmojiNode ?? StarNode ?? (isNumbered ? (displayIdx + 1) : RadioNode)) as React.ReactNode}
517
+ </button>
518
+ <span style={{ fontSize: '13px', fontWeight: 500, color: '#4b5563', textAlign: 'center', lineHeight: 1.2 }}
519
+ dangerouslySetInnerHTML={{ __html: col.label }} />
520
+ </div>
521
+ );
522
+ })}
523
+ </div>
524
+ )}
525
+
526
+ {hasNotApplicable && (
527
+ <div style={{ marginBottom: '32px' }}>
528
+ <label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
529
+ <input type="checkbox" checked={selectedValue[row.id] === null}
530
+ onChange={() => onCellSelect(row.id, selectedValue[row.id] === null ? undefined : null)}
531
+ style={{ width: '16px', height: '16px', accentColor: '#e20074', cursor: 'pointer' }} />
532
+ <span style={{ fontSize: '14px', fontWeight: 500, color: '#4b5563' }}>N/A</span>
533
+ </label>
534
+ </div>
535
+ )}
536
+
537
+ <div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
538
+ <button type="button" onClick={() => setActiveCarouselIndex(Math.max(0, activeCarouselIndex - 1))}
539
+ disabled={activeCarouselIndex === 0}
540
+ style={{
541
+ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '32px', height: '32px',
542
+ borderRadius: '50%', backgroundColor: 'transparent', border: 'none',
543
+ cursor: activeCarouselIndex === 0 ? 'not-allowed' : 'pointer', opacity: activeCarouselIndex === 0 ? 0.3 : 1, transition: 'opacity 0.2s'
544
+ }}>
545
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#6b7280" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
546
+ </button>
547
+
548
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
549
+ {statementRows.map((_, i) => (
550
+ <button key={i} type="button" onClick={() => setActiveCarouselIndex(i)}
551
+ style={{
552
+ width: i === activeCarouselIndex ? '20px' : '8px', height: '8px', borderRadius: '4px',
553
+ backgroundColor: i === activeCarouselIndex ? '#e20074' : '#d1d5db', border: 'none', padding: 0,
554
+ cursor: 'pointer', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
555
+ }} />
556
+ ))}
557
+ </div>
558
+
559
+ <button type="button" onClick={() => setActiveCarouselIndex(Math.min(statementRows.length - 1, activeCarouselIndex + 1))}
560
+ disabled={activeCarouselIndex === statementRows.length - 1}
561
+ style={{
562
+ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '32px', height: '32px',
563
+ borderRadius: '50%', backgroundColor: 'transparent', border: 'none',
564
+ cursor: activeCarouselIndex === statementRows.length - 1 ? 'not-allowed' : 'pointer', opacity: activeCarouselIndex === statementRows.length - 1 ? 0.3 : 1, transition: 'opacity 0.2s'
565
+ }}>
566
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#6b7280" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6" /></svg>
567
+ </button>
568
+ </div>
569
+ </div>
570
+ );
571
+ }
572
+
573
+ // ── ORCHESTRATOR COMPONENT: CsatMatrixScale ──────────────────────────────────
574
+ export function CsatMatrixScale({ question, selectedValue = {}, onSelect }: CsatMatrixScaleProps) {
575
+ const { displayStyle, gridLayout } = question;
576
+
577
+ const handleCellSelect = (rowId: string, colValue: string | number | null | undefined) => {
578
+ if (colValue === undefined) {
579
+ const next = { ...selectedValue };
580
+ delete next[rowId];
581
+ onSelect(next);
582
+ } else {
583
+ onSelect({ ...selectedValue, [rowId]: colValue });
584
+ }
585
+ };
586
+
587
+ const isDropdown = displayStyle === 'dropdown';
588
+ const isSelectBox = displayStyle === 'selectbox';
589
+ const isEmoji = displayStyle === 'emoji';
590
+ const isStar = displayStyle === 'star';
591
+ const isNumbered = displayStyle === 'numbered';
592
+ const isGraphics = displayStyle === 'graphics';
593
+ const isStandardRadio = displayStyle === 'standard';
594
+
595
+ const scaleColumns = nonNaScaleColumns(question.scaleColumns);
596
+
597
+ if (gridLayout === 'carousel') {
598
+ return (
599
+ <CsatMatrixCarousel
600
+ question={question}
601
+ scaleColumns={scaleColumns}
602
+ selectedValue={selectedValue}
603
+ onCellSelect={handleCellSelect}
604
+ isEmoji={isEmoji}
605
+ isStar={isStar}
606
+ isNumbered={isNumbered}
607
+ isGraphics={isGraphics}
608
+ />
609
+ );
610
+ }
611
+
612
+ if (isDropdown || isSelectBox || isStandardRadio) {
613
+ return (
614
+ <CsatMatrixVerticalList
615
+ question={question}
616
+ scaleColumns={scaleColumns}
617
+ selectedValue={selectedValue}
618
+ onCellSelect={handleCellSelect}
619
+ isDropdown={isDropdown}
620
+ isSelectBox={isSelectBox}
621
+ />
622
+ );
623
+ }
624
+
625
+ return (
626
+ <CsatMatrixGrid
627
+ question={question}
628
+ scaleColumns={scaleColumns}
629
+ selectedValue={selectedValue}
630
+ onCellSelect={handleCellSelect}
631
+ isEmoji={isEmoji}
632
+ isStar={isStar}
633
+ isNumbered={isNumbered}
634
+ isGraphics={isGraphics}
635
+ />
636
+ );
637
+ }