@explorer02/cfm-survey-sdk 0.2.3 → 0.2.5

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 (100) hide show
  1. package/dist/cli/index.js +141 -33
  2. package/dist/cli/index.mjs +141 -33
  3. package/dist/index.js +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/package.json +3 -1
  6. package/postinstall.js +9 -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 +293 -0
  10. package/templates/docs/00-integration/agent-operating-contract.md +268 -0
  11. package/templates/docs/00-integration/apply-ui-config.md +390 -0
  12. package/templates/docs/00-integration/aws-deploy.md +117 -3
  13. package/templates/docs/00-integration/canonical-survey-fixtures.md +94 -0
  14. package/templates/docs/00-integration/client-integration-guide.md +9 -7
  15. package/templates/docs/00-integration/client-lib-folder.md +115 -0
  16. package/templates/docs/00-integration/component-checklist.md +90 -62
  17. package/templates/docs/00-integration/constraints.md +14 -5
  18. package/templates/docs/00-integration/custom-field-logic-and-navigation.md +56 -0
  19. package/templates/docs/00-integration/mockup-ui-extraction.md +113 -0
  20. package/templates/docs/00-integration/placeholders-and-custom-fields-wiring.md +192 -0
  21. package/templates/docs/00-integration/placeholders-and-tokens.md +2 -1
  22. package/templates/docs/00-integration/progress.md +4 -2
  23. package/templates/docs/00-integration/setup.md +12 -0
  24. package/templates/docs/00-integration/ui-customization-wizard.md +123 -0
  25. package/templates/docs/00-integration/useSurveySDK.md +35 -1
  26. package/templates/docs/00-integration/wizard-api.md +126 -0
  27. package/templates/docs/00-integration/wizard-config-handoff.md +292 -0
  28. package/templates/docs/00-integration/wizard-preview-build-guide.md +405 -0
  29. package/templates/docs/00-integration/wizard-question-type-styling.md +197 -0
  30. package/templates/docs/00-integration/wizard-troubleshooting.md +80 -0
  31. package/templates/docs/01-components/01-survey-page.md +18 -8
  32. package/templates/docs/01-components/02-question.md +2 -2
  33. package/templates/docs/01-components/03-rating-scale.md +12 -18
  34. package/templates/docs/01-components/05-csat-matrix-scale.md +18 -26
  35. package/templates/docs/01-components/06-likert-matrix-scale.md +19 -39
  36. package/templates/docs/01-components/07-slider-matrix-scale.md +48 -33
  37. package/templates/docs/01-components/09-custom-slider-track.md +15 -25
  38. package/templates/docs/01-components/10-header-footer.md +23 -4
  39. package/templates/docs/01-components/11-progress-bar.md +14 -6
  40. package/templates/docs/01-components/19-survey-sticky-chrome.md +79 -0
  41. package/templates/docs/01-components/README.md +2 -2
  42. package/templates/docs/02-reference/question-types/10-slider-matrix.md +38 -26
  43. package/templates/docs/02-reference/routing-table.md +1 -0
  44. package/templates/docs/02-reference/value-derivation.md +1 -1
  45. package/templates/docs/03-ui-specs/00-question-shell.md +10 -0
  46. package/templates/docs/03-ui-specs/01-rating.md +18 -0
  47. package/templates/docs/03-ui-specs/02-radio.md +16 -1
  48. package/templates/docs/03-ui-specs/03-text.md +12 -1
  49. package/templates/docs/03-ui-specs/04-csat.md +84 -50
  50. package/templates/docs/03-ui-specs/06-slider.md +3 -1
  51. package/templates/docs/03-ui-specs/07-matrix-cfm.md +71 -48
  52. package/templates/docs/03-ui-specs/08-matrix-csat-rating.md +48 -18
  53. package/templates/docs/03-ui-specs/09-slider-matrix.md +59 -32
  54. package/templates/docs/03-ui-specs/10-file-upload.md +11 -0
  55. package/templates/docs/03-ui-specs/11-text-and-media.md +4 -0
  56. package/templates/docs/03-ui-specs/12-survey-chrome.md +54 -5
  57. package/templates/docs/03-ui-specs/13-heatmap.md +12 -1
  58. package/templates/docs/03-ui-specs/14-rank-order.md +11 -0
  59. package/templates/docs/03-ui-specs/README.md +4 -2
  60. package/templates/docs/03-ui-specs/shared/matrix-dropdown.md +1 -1
  61. package/templates/docs/MANIFEST.json +180 -7
  62. package/templates/docs/index.md +114 -117
  63. package/templates/docs/templates/CsatMatrixScale.tsx +637 -0
  64. package/templates/docs/templates/FileUploadScale.tsx +262 -0
  65. package/templates/docs/templates/HeatmapScale.tsx +114 -0
  66. package/templates/docs/templates/LikertMatrixScale.tsx +414 -0
  67. package/templates/docs/templates/Question.tsx +3 -0
  68. package/templates/docs/templates/RatingScale.tsx +129 -0
  69. package/templates/docs/templates/SliderMatrixScale.tsx +248 -0
  70. package/templates/docs/templates/SurveyStickyChrome.tsx +24 -0
  71. package/templates/docs/templates/customFieldValues.ts +36 -0
  72. package/templates/docs/templates/deploy-to-aws.sh +74 -0
  73. package/templates/docs/templates/implementation_plan.md +64 -10
  74. package/templates/docs/templates/labelStyles.ts +33 -0
  75. package/templates/docs/templates/selectionStyles.ts +100 -0
  76. package/templates/docs/templates/surveyUiIcons.tsx +11 -0
  77. package/templates/docs/templates/surveyUiScaleUtils.ts +51 -0
  78. package/templates/docs/templates/verify-agent-build.sh +119 -0
  79. package/templates/preview-harness/preview-bridge.inline.js +201 -0
  80. package/templates/preview-harness/previewPages.js +108 -0
  81. package/templates/preview-harness/previewPages.ts +298 -0
  82. package/templates/preview-harness/vite-app/src/PreviewConfigContext.tsx +67 -0
  83. package/templates/preview-harness/vite-app/src/QuestionPreview.tsx +122 -0
  84. package/templates/preview-harness/vite-app/src/SurveyPagePreview.tsx +75 -0
  85. package/templates/preview-harness/vite-app/src/fixtures/questions.ts +311 -0
  86. package/templates/preview-harness/vite-app/src/globals.css +16 -0
  87. package/templates/preview-harness/vite-app/src/mount.tsx +9 -0
  88. package/templates/preview-harness/vite-app/src/preview-live-overrides.css +101 -0
  89. package/templates/preview-harness/vite-app/stubs/next-dynamic.tsx +25 -0
  90. package/templates/preview-harness/vite-app/stubs/next-image.tsx +29 -0
  91. package/templates/previewBridge.ts +153 -0
  92. package/templates/survey-theme.css +295 -0
  93. package/templates/survey-ui-config.schema.json +213 -0
  94. package/templates/wizard-dist/assets/PreviewMock-AoZdAEPZ.js +1 -0
  95. package/templates/wizard-dist/assets/TypePanel-39FnbtvG.js +1 -0
  96. package/templates/wizard-dist/assets/index-BhWM50Yu.css +1 -0
  97. package/templates/wizard-dist/assets/index-CWKdsJOp.js +34 -0
  98. package/templates/wizard-dist/assets/vendor-BwkXDkd3.js +17 -0
  99. package/templates/wizard-dist/index.html +20 -0
  100. package/templates/wizard.html +13 -1129
@@ -0,0 +1,414 @@
1
+ import React, { useState } from 'react';
2
+ import type { CfmMatrixQuestion, MatrixRowAnswers } from '@explorer02/cfm-survey-sdk';
3
+ import { columnSubmitValue } from './surveyUiScaleUtils';
4
+ import MatrixDropdown from './MatrixDropdown';
5
+
6
+ type LikertMatrixScaleProps = {
7
+ question: CfmMatrixQuestion;
8
+ selectedValue?: MatrixRowAnswers;
9
+ onSelect: (value: MatrixRowAnswers) => void;
10
+ };
11
+
12
+ // ── SUB-COMPONENT: Dropdown Layout ──────────────────────────────────────────
13
+ type DropdownLayoutProps = {
14
+ question: CfmMatrixQuestion;
15
+ selectedValue: MatrixRowAnswers;
16
+ isMultiple: boolean;
17
+ onCellSelect: (rowId: string, colValue: string | number | null | undefined) => void;
18
+ onSelectAll: (value: MatrixRowAnswers) => void;
19
+ };
20
+
21
+ function LikertMatrixDropdownLayout({
22
+ question,
23
+ selectedValue,
24
+ isMultiple,
25
+ onCellSelect,
26
+ onSelectAll
27
+ }: DropdownLayoutProps) {
28
+ const { statementRows, scaleColumns } = question;
29
+
30
+ return (
31
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
32
+ {statementRows.map(row => (
33
+ <div key={row.id} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
34
+ <div style={{ fontSize: '15px', fontWeight: 500, color: '#374151' }} dangerouslySetInnerHTML={{ __html: row.statementText }} />
35
+ <MatrixDropdown
36
+ options={scaleColumns}
37
+ value={isMultiple ? (Array.isArray(selectedValue[row.id]) ? selectedValue[row.id] as any[] : []) : selectedValue[row.id]}
38
+ multiple={isMultiple}
39
+ onChange={(vals) => {
40
+ if (isMultiple) {
41
+ const multiVals = Array.isArray(vals) ? vals : [];
42
+ if (multiVals.length === 0) {
43
+ const next = { ...selectedValue };
44
+ delete next[row.id];
45
+ onSelectAll(next);
46
+ } else {
47
+ onSelectAll({ ...selectedValue, [row.id]: multiVals });
48
+ }
49
+ } else if (!Array.isArray(vals)) {
50
+ onCellSelect(row.id, vals);
51
+ }
52
+ }}
53
+ />
54
+ </div>
55
+ ))}
56
+ </div>
57
+ );
58
+ }
59
+
60
+ // ── SUB-COMPONENT: Carousel Layout ──────────────────────────────────────────
61
+ type CarouselLayoutProps = {
62
+ question: CfmMatrixQuestion;
63
+ selectedValue: MatrixRowAnswers;
64
+ isMultiple: boolean;
65
+ onCellSelect: (rowId: string, colValue: string | number | null | undefined) => void;
66
+ isSelected: (rowId: string, colValue: string | number | null) => boolean;
67
+ };
68
+
69
+ function LikertMatrixCarouselLayout({
70
+ question,
71
+ selectedValue,
72
+ isMultiple,
73
+ onCellSelect,
74
+ isSelected
75
+ }: CarouselLayoutProps) {
76
+ const { scaleColumns, statementRows } = question;
77
+ const [activeCarouselIndex, setActiveCarouselIndex] = useState(0);
78
+
79
+ const row = statementRows[activeCarouselIndex];
80
+ if (!row) return null;
81
+
82
+ return (
83
+ <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' }}>
84
+ <h3 style={{ fontSize: '18px', fontWeight: 500, color: '#111827', marginBottom: '32px', textAlign: 'center' }} dangerouslySetInnerHTML={{ __html: row.statementText }} />
85
+
86
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '20px 32px', width: '100%', maxWidth: '800px', marginBottom: '40px', justifyContent: 'center' }}>
87
+ {scaleColumns.map(col => {
88
+ const submitValue = columnSubmitValue(col);
89
+ const selected = isSelected(row.id, submitValue);
90
+ const isNaSelected = isSelected(row.id, null);
91
+ const isDisabled = isNaSelected && submitValue !== null;
92
+
93
+ return (
94
+ <div
95
+ key={col.id}
96
+ onClick={() => {
97
+ if (isDisabled) return;
98
+ if (!isMultiple && selected) {
99
+ onCellSelect(row.id, undefined);
100
+ } else {
101
+ onCellSelect(row.id, submitValue);
102
+ }
103
+ }}
104
+ style={{
105
+ display: 'flex', alignItems: 'center', gap: '12px',
106
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
107
+ opacity: isDisabled ? 0.5 : 1,
108
+ transition: 'opacity 0.15s'
109
+ }}>
110
+ {isMultiple ? (
111
+ <div style={{
112
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
113
+ width: '18px', height: '18px', borderRadius: '4px',
114
+ border: selected ? '2px solid #e20074' : '2px solid #d1d5db',
115
+ backgroundColor: selected ? '#e20074' : '#fff',
116
+ flexShrink: 0, transition: 'all 0.15s'
117
+ }}>
118
+ {selected && (
119
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
120
+ <polyline points="20 6 9 17 4 12" />
121
+ </svg>
122
+ )}
123
+ </div>
124
+ ) : (
125
+ <div style={{
126
+ display: 'flex', width: '18px', height: '18px', flexShrink: 0,
127
+ alignItems: 'center', justifyContent: 'center', borderRadius: '50%',
128
+ border: selected ? '2px solid #e20074' : '1.5px solid #9ca3af',
129
+ backgroundColor: '#fff',
130
+ }}>
131
+ {selected && <div style={{ width: '10px', height: '10px', borderRadius: '50%', backgroundColor: '#e20074' }} />}
132
+ </div>
133
+ )}
134
+ <span style={{ fontSize: '15px', fontWeight: 400, color: '#374151', userSelect: 'none' }} dangerouslySetInnerHTML={{ __html: col.label }} />
135
+ </div>
136
+ );
137
+ })}
138
+ </div>
139
+
140
+ <div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
141
+ <button
142
+ type="button"
143
+ disabled={activeCarouselIndex === 0}
144
+ onClick={() => setActiveCarouselIndex(i => i - 1)}
145
+ style={{ padding: '8px', color: activeCarouselIndex === 0 ? '#d1d5db' : '#6b7280', cursor: activeCarouselIndex === 0 ? 'not-allowed' : 'pointer', background: 'none', border: 'none' }}
146
+ >
147
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
148
+ </button>
149
+
150
+ <div style={{ display: 'flex', gap: '8px' }}>
151
+ {statementRows.map((_, i) => (
152
+ <div key={i} style={{
153
+ width: '10px', height: '10px', borderRadius: '50%',
154
+ backgroundColor: i === activeCarouselIndex ? '#2563eb' : '#e5e7eb',
155
+ cursor: 'pointer', transition: 'background-color 0.2s'
156
+ }} onClick={() => setActiveCarouselIndex(i)} />
157
+ ))}
158
+ </div>
159
+
160
+ <button
161
+ type="button"
162
+ disabled={activeCarouselIndex === statementRows.length - 1}
163
+ onClick={() => setActiveCarouselIndex(i => i + 1)}
164
+ style={{ padding: '8px', color: activeCarouselIndex === statementRows.length - 1 ? '#d1d5db' : '#6b7280', cursor: activeCarouselIndex === statementRows.length - 1 ? 'not-allowed' : 'pointer', background: 'none', border: 'none' }}
165
+ >
166
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>
167
+ </button>
168
+ </div>
169
+ </div>
170
+ );
171
+ }
172
+
173
+ // ── SUB-COMPONENT: Standard Grid Layout ──────────────────────────────────────
174
+ type GridLayoutProps = {
175
+ question: CfmMatrixQuestion;
176
+ isMultiple: boolean;
177
+ onCellSelect: (rowId: string, colValue: string | number | null | undefined) => void;
178
+ isSelected: (rowId: string, colValue: string | number | null) => boolean;
179
+ };
180
+
181
+ function LikertMatrixGridLayout({
182
+ question,
183
+ isMultiple,
184
+ onCellSelect,
185
+ isSelected
186
+ }: GridLayoutProps) {
187
+ const {
188
+ scaleColumns, statementRows, transposeTable = false, repeatColumnHeaders = false,
189
+ showColumnHeaders = true
190
+ } = question;
191
+
192
+ const gridRows = transposeTable ? scaleColumns : statementRows;
193
+ const gridCols = transposeTable ? statementRows : scaleColumns;
194
+ const isBipolar = question.statementLayout === 'bipolar';
195
+ const labels = question.scaleAnchorLabels;
196
+
197
+ const renderHeader = () => {
198
+ const numCols = gridCols.length;
199
+ const hasLabels = isBipolar && labels && labels.length > 0;
200
+
201
+ if (!hasLabels && !showColumnHeaders) {
202
+ return null;
203
+ }
204
+
205
+ return (
206
+ <div style={{ display: 'flex', flexDirection: 'column', marginBottom: '16px' }}>
207
+ {hasLabels && (
208
+ <div style={{ display: 'flex', alignItems: 'flex-end', minHeight: '24px', marginBottom: '8px' }}>
209
+ <div style={{ flex: '0 0 25%', paddingRight: '16px', boxSizing: 'border-box' }} />
210
+ <div style={{ flex: 1, position: 'relative', height: '20px' }}>
211
+ {labels.map((lbl, idx) => {
212
+ let leftPos = '50%';
213
+ if (idx === 0) leftPos = `${100 / (2 * numCols)}%`;
214
+ else if (idx === labels.length - 1 && labels.length > 1) leftPos = `${100 - 100 / (2 * numCols)}%`;
215
+ else if (labels.length === 3 && idx === 1) leftPos = '50%';
216
+
217
+ return (
218
+ <div key={idx} style={{
219
+ position: 'absolute', left: leftPos, transform: 'translateX(-50%)',
220
+ bottom: 0, display: 'flex', justifyContent: 'center', whiteSpace: 'nowrap'
221
+ }}>
222
+ <span style={{ fontSize: '13px', fontWeight: 600, color: '#111827' }}>{lbl}</span>
223
+ </div>
224
+ );
225
+ })}
226
+ </div>
227
+ <div style={{ flex: '0 0 25%', paddingLeft: '16px', boxSizing: 'border-box' }} />
228
+ </div>
229
+ )}
230
+ {showColumnHeaders && (
231
+ <div style={{ display: 'flex', alignItems: 'flex-end', minHeight: '24px' }}>
232
+ <div style={{ flex: isBipolar ? '0 0 25%' : '0 0 180px', paddingRight: '16px', boxSizing: 'border-box' }} />
233
+ <div style={{ flex: 1, display: 'flex' }}>
234
+ <div style={{ flex: 1, display: 'grid', gridTemplateColumns: `repeat(${gridCols.length}, 1fr)` }}>
235
+ {gridCols.map((col) => (
236
+ <div key={col.id} style={{ display: 'flex', justifyContent: 'center', padding: '0 4px' }}>
237
+ <span style={{ fontSize: '13px', fontWeight: 500, color: '#4b5563', textAlign: 'center', lineHeight: 1.2 }}
238
+ dangerouslySetInnerHTML={{ __html: transposeTable ? ('statementText' in col ? col.statementText : col.label) : ('label' in col ? col.label : (col as { statementText: string }).statementText) }} />
239
+ </div>
240
+ ))}
241
+ </div>
242
+ </div>
243
+ {isBipolar && <div style={{ flex: '0 0 25%', paddingLeft: '16px', boxSizing: 'border-box' }} />}
244
+ </div>
245
+ )}
246
+ </div>
247
+ );
248
+ };
249
+
250
+ return (
251
+ <div style={{ width: '100%' }}>
252
+ {!repeatColumnHeaders && renderHeader()}
253
+
254
+ <div style={{ display: 'flex', flexDirection: 'column', gap: repeatColumnHeaders ? '24px' : '0' }}>
255
+ {gridRows.map((rowItem, rowIdx) => {
256
+ return (
257
+ <div key={rowItem.id} style={{ display: 'flex', flexDirection: 'column' }}>
258
+ {repeatColumnHeaders && renderHeader()}
259
+ <div style={{
260
+ display: 'flex', alignItems: 'center', borderRadius: '8px',
261
+ padding: '12px 0', backgroundColor: !repeatColumnHeaders && rowIdx % 2 === 0 ? 'rgba(249, 250, 251, 0.8)' : '#fff',
262
+ boxSizing: 'border-box'
263
+ }}>
264
+ <div style={{ flex: isBipolar ? '0 0 25%' : '0 0 180px', padding: '0 16px', boxSizing: 'border-box' }}>
265
+ <span style={{ fontSize: '14px', fontWeight: 500, color: '#111827', lineHeight: 1.4, wordWrap: 'break-word', display: 'block' }}
266
+ dangerouslySetInnerHTML={{ __html: transposeTable ? ('label' in rowItem ? rowItem.label : '') : ('statementText' in rowItem ? rowItem.statementText : '') }} />
267
+ </div>
268
+
269
+ <div style={{ flex: 1, display: 'flex', boxSizing: 'border-box' }}>
270
+ <div style={{ flex: 1, display: 'grid', alignItems: 'center', gridTemplateColumns: `repeat(${gridCols.length}, 1fr)` }}>
271
+ {gridCols.map((colItem) => {
272
+ const actualRowId = transposeTable ? colItem.id : rowItem.id;
273
+ const actualColValue = transposeTable
274
+ ? columnSubmitValue(rowItem as typeof scaleColumns[number])
275
+ : columnSubmitValue(colItem as typeof scaleColumns[number]);
276
+ const selected = isSelected(actualRowId, actualColValue);
277
+ const isNaSelected = isSelected(actualRowId, null);
278
+ const isDisabled = isNaSelected && actualColValue !== null;
279
+
280
+ return (
281
+ <div key={colItem.id} style={{ display: 'flex', justifyContent: 'center' }}>
282
+ <label style={{
283
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
284
+ opacity: isDisabled ? 0.4 : 1,
285
+ padding: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center'
286
+ }}>
287
+ <input
288
+ type={isMultiple ? "checkbox" : "radio"}
289
+ checked={selected}
290
+ disabled={isDisabled}
291
+ onClick={(e) => {
292
+ if (!isMultiple && selected) {
293
+ onCellSelect(actualRowId, undefined);
294
+ }
295
+ }}
296
+ onChange={(e) => {
297
+ if (isMultiple || !selected) {
298
+ onCellSelect(actualRowId, actualColValue);
299
+ }
300
+ }}
301
+ style={{
302
+ width: '18px', height: '18px', accentColor: '#e20074',
303
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
304
+ ...(isMultiple ? { borderRadius: '4px' } : {})
305
+ }}
306
+ />
307
+ </label>
308
+ </div>
309
+ );
310
+ })}
311
+ </div>
312
+ </div>
313
+
314
+ {isBipolar && (
315
+ <div style={{ flex: '0 0 25%', padding: '0 16px', textAlign: 'right', boxSizing: 'border-box' }}>
316
+ <span style={{ fontSize: '14px', fontWeight: 500, color: '#111827', lineHeight: 1.4, wordWrap: 'break-word', display: 'block' }}
317
+ dangerouslySetInnerHTML={{ __html: transposeTable ? '' : ('oppositeStatementText' in rowItem ? rowItem.oppositeStatementText ?? '' : '') }} />
318
+ </div>
319
+ )}
320
+ </div>
321
+ </div>
322
+ );
323
+ })}
324
+ </div>
325
+ </div>
326
+ );
327
+ }
328
+
329
+ // ── ORCHESTRATOR COMPONENT: LikertMatrixScale ────────────────────────────────
330
+ export function LikertMatrixScale({ question, selectedValue = {}, onSelect }: LikertMatrixScaleProps) {
331
+ const { gridLayout = 'standard', selectionMode = 'single' } = question;
332
+ const isMultiple = selectionMode === 'multiple';
333
+
334
+ const handleCellSelect = (rowId: string, colValue: string | number | null | undefined) => {
335
+ if (colValue === undefined) {
336
+ const next = { ...selectedValue };
337
+ delete next[rowId];
338
+ onSelect(next);
339
+ return;
340
+ }
341
+
342
+ if (isMultiple) {
343
+ const currentVals = Array.isArray(selectedValue[rowId]) ? selectedValue[rowId] as any[] : [];
344
+ if (colValue === null) {
345
+ if (currentVals.includes(null)) {
346
+ const next = { ...selectedValue };
347
+ delete next[rowId];
348
+ onSelect(next);
349
+ } else {
350
+ onSelect({ ...selectedValue, [rowId]: [null] });
351
+ }
352
+ return;
353
+ }
354
+
355
+ let nextVals = currentVals.filter(v => v !== null);
356
+ if (nextVals.includes(colValue)) {
357
+ nextVals = nextVals.filter(v => v !== colValue);
358
+ } else {
359
+ nextVals = [...nextVals, colValue];
360
+ }
361
+
362
+ if (nextVals.length === 0) {
363
+ const next = { ...selectedValue };
364
+ delete next[rowId];
365
+ onSelect(next);
366
+ } else {
367
+ onSelect({ ...selectedValue, [rowId]: nextVals });
368
+ }
369
+ } else {
370
+ onSelect({ ...selectedValue, [rowId]: colValue });
371
+ }
372
+ };
373
+
374
+ const isSelected = (rowId: string, colValue: string | number | null) => {
375
+ const val = selectedValue[rowId];
376
+ if (isMultiple) {
377
+ return Array.isArray(val) && val.includes(colValue);
378
+ }
379
+ return val === colValue;
380
+ };
381
+
382
+ if (gridLayout === 'dropdown') {
383
+ return (
384
+ <LikertMatrixDropdownLayout
385
+ question={question}
386
+ selectedValue={selectedValue}
387
+ isMultiple={isMultiple}
388
+ onCellSelect={handleCellSelect}
389
+ onSelectAll={onSelect}
390
+ />
391
+ );
392
+ }
393
+
394
+ if (gridLayout === 'carousel') {
395
+ return (
396
+ <LikertMatrixCarouselLayout
397
+ question={question}
398
+ selectedValue={selectedValue}
399
+ isMultiple={isMultiple}
400
+ onCellSelect={handleCellSelect}
401
+ isSelected={isSelected}
402
+ />
403
+ );
404
+ }
405
+
406
+ return (
407
+ <LikertMatrixGridLayout
408
+ question={question}
409
+ isMultiple={isMultiple}
410
+ onCellSelect={handleCellSelect}
411
+ isSelected={isSelected}
412
+ />
413
+ );
414
+ }
@@ -103,6 +103,9 @@ export default function Question({
103
103
  return (
104
104
  <section id={question.id} className="space-y-4 rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
105
105
  <h2 className="text-lg font-medium leading-relaxed text-gray-900">
106
+ {question.questionNumber != null && (
107
+ <span className="mr-1 font-semibold">{question.questionNumber}.</span>
108
+ )}
106
109
  <span dangerouslySetInnerHTML={{ __html: displayQuestionText }} />
107
110
  {question.required && <span className="text-red-500 ml-1">*</span>}
108
111
  </h2>
@@ -0,0 +1,129 @@
1
+ import type { AnswerValue, NpsScalePoint } from '@explorer02/cfm-survey-sdk';
2
+ import { getAccentColor, npsValueFromOption, starValueFromOption } from './surveyUiScaleUtils';
3
+ import { getEmojiForIndex } from './surveyUiIcons';
4
+
5
+ type RatingScaleProps = {
6
+ questionId: string;
7
+ options: NpsScalePoint[];
8
+ selectedValue?: AnswerValue;
9
+ onSelect: (value: AnswerValue) => void;
10
+ variant?: 'nps' | 'star';
11
+ buttonStyle?: 'standard' | 'numbered' | 'emoji';
12
+ };
13
+
14
+ export default function RatingScale({
15
+ questionId,
16
+ options,
17
+ selectedValue,
18
+ onSelect,
19
+ variant = 'nps',
20
+ buttonStyle = 'numbered',
21
+ }: RatingScaleProps) {
22
+ const resolvedOptions = options.map((option, index) => {
23
+ const value =
24
+ variant === 'star'
25
+ ? starValueFromOption(option, index)
26
+ : npsValueFromOption(option as NpsScalePoint, index);
27
+ const color =
28
+ variant === 'nps' && typeof value === 'number' ? getAccentColor(value) : '#e20074';
29
+ return {
30
+ id: option.id,
31
+ label: option.optionLabel,
32
+ value,
33
+ color,
34
+ };
35
+ });
36
+
37
+ const columnCount = resolvedOptions.length;
38
+ const gridStyle = {
39
+ display: 'grid' as const,
40
+ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
41
+ gap: '2px',
42
+ };
43
+
44
+ if (buttonStyle === 'emoji') {
45
+ return (
46
+ <div role="radiogroup" className="w-full overflow-x-auto">
47
+ <div className="min-w-0" style={gridStyle}>
48
+ {resolvedOptions.map((option, index) => {
49
+ const isSelected = selectedValue === option.value;
50
+ return (
51
+ <label
52
+ key={`${questionId}-${option.value}`}
53
+ title={option.label}
54
+ className="flex cursor-pointer justify-center"
55
+ >
56
+ <input
57
+ type="radio"
58
+ name={questionId}
59
+ checked={isSelected}
60
+ onChange={() => onSelect(option.value)}
61
+ className="sr-only"
62
+ />
63
+ <div
64
+ className={`flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-lg border-2 text-2xl transition-all ${
65
+ isSelected ? 'border-[#e20074] bg-[#fdf2f8] shadow-md' : 'border-transparent'
66
+ }`}
67
+ >
68
+ {getEmojiForIndex(index, columnCount)}
69
+ </div>
70
+ </label>
71
+ );
72
+ })}
73
+ </div>
74
+ </div>
75
+ );
76
+ }
77
+
78
+ return (
79
+ <div role="radiogroup" className="w-full overflow-x-auto">
80
+ <div className="mb-3 min-w-0" style={gridStyle}>
81
+ {resolvedOptions.map(option => (
82
+ <div key={`${questionId}-badge-${option.value}`} className="flex justify-center">
83
+ <div
84
+ className="flex h-5 w-5 sm:h-6 sm:w-6 items-center justify-center rounded-[3px] text-[10px] sm:text-xs font-bold text-white shadow-sm"
85
+ style={{ backgroundColor: option.color }}
86
+ title={option.label}
87
+ >
88
+ {option.label}
89
+ </div>
90
+ </div>
91
+ ))}
92
+ </div>
93
+
94
+ <div className="relative w-full rounded-md bg-[#f2f2f2] px-1 py-2">
95
+ <div className="min-w-0" style={gridStyle}>
96
+ {resolvedOptions.map(option => {
97
+ const isSelected = selectedValue === option.value;
98
+ return (
99
+ <label
100
+ key={`${questionId}-${option.value}`}
101
+ title={option.label}
102
+ className="flex cursor-pointer justify-center"
103
+ >
104
+ <input
105
+ type="radio"
106
+ name={questionId}
107
+ checked={isSelected}
108
+ onChange={() => onSelect(option.value)}
109
+ className="sr-only"
110
+ />
111
+ <div
112
+ className={`flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-full border-2 transition-all ${
113
+ isSelected ? 'border-[#e20074] bg-[#fbe8f3]' : 'border-transparent bg-white'
114
+ }`}
115
+ >
116
+ <div
117
+ className={`h-3 w-3 sm:h-4 sm:w-4 rounded-full ${
118
+ isSelected ? 'bg-[#e20074]' : 'bg-gray-300'
119
+ }`}
120
+ />
121
+ </div>
122
+ </label>
123
+ );
124
+ })}
125
+ </div>
126
+ </div>
127
+ </div>
128
+ );
129
+ }