@explorer02/cfm-survey-sdk 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/AGENT.md +8 -4
- package/templates/docs/00-integration/analytics-events-catalog.md +54 -0
- package/templates/docs/00-integration/client-integration-guide.md +1 -1
- package/templates/docs/00-integration/component-checklist.md +10 -0
- package/templates/docs/00-integration/display-logic-and-navigation.md +16 -1
- package/templates/docs/00-integration/file-upload-aws.md +116 -0
- package/templates/docs/00-integration/logic-fields-catalog.md +105 -0
- package/templates/docs/00-integration/partial-save-and-recovery.md +24 -0
- package/templates/docs/00-integration/question-type-sdk-matrix.md +11 -9
- package/templates/docs/00-integration/setup.md +3 -3
- package/templates/docs/00-integration/skip-logic-and-navigation.md +1 -1
- package/templates/docs/00-integration/survey-lifecycle-analytics.md +1 -1
- package/templates/docs/01-components/06-likert-matrix-scale.md +2 -1
- package/templates/docs/01-components/08-file-upload-scale.md +15 -52
- package/templates/docs/01-components/13-matrix-dropdown.md +28 -33
- package/templates/docs/01-components/17-heatmap-scale.md +4 -4
- package/templates/docs/01-components/18-rank-order-scale.md +24 -31
- package/templates/docs/02-reference/question-types/11-file-upload.md +36 -21
- package/templates/docs/02-reference/question-types/README.md +11 -1
- package/templates/docs/03-ui-specs/01-rating.md +10 -2
- package/templates/docs/03-ui-specs/02-radio.md +80 -25
- package/templates/docs/03-ui-specs/04-csat.md +35 -5
- package/templates/docs/03-ui-specs/07-matrix-cfm.md +20 -4
- package/templates/docs/03-ui-specs/08-matrix-csat-rating.md +5 -1
- package/templates/docs/03-ui-specs/09-slider-matrix.md +17 -0
- package/templates/docs/03-ui-specs/10-file-upload.md +41 -20
- package/templates/docs/03-ui-specs/12-survey-chrome.md +38 -0
- package/templates/docs/03-ui-specs/13-heatmap.md +9 -2
- package/templates/docs/03-ui-specs/14-rank-order.md +98 -29
- package/templates/docs/03-ui-specs/README.md +7 -5
- package/templates/docs/03-ui-specs/shared/custom-slider-track.md +28 -35
- package/templates/docs/MANIFEST.json +28 -3
- package/templates/docs/index.md +5 -4
- package/templates/docs/templates/CustomSliderTrack.tsx +144 -0
- package/templates/docs/templates/MatrixDropdown.tsx +216 -0
- package/templates/docs/templates/RankOrderScale.tsx +353 -0
- package/templates/docs/templates/implementation_plan.md +20 -0
- package/templates/docs/templates/survey-inventory.schema.json +28 -23
- package/templates/docs/templates/surveyUiIcons.tsx +52 -0
- package/templates/docs/templates/verify-agent-build.sh +32 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Portable RankOrderScale template — copy to src/components/RankOrderScale.tsx
|
|
5
|
+
* Import SDK from your package.json: @explorer02/cfm-survey-sdk or @repo/sdk
|
|
6
|
+
* Requires: @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
|
|
7
|
+
* Next.js: add 'use client' (already present); optional dynamic(..., { ssr: false }) wrapper in Question.tsx
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React, { useMemo } from 'react';
|
|
11
|
+
import {
|
|
12
|
+
DndContext,
|
|
13
|
+
closestCenter,
|
|
14
|
+
KeyboardSensor,
|
|
15
|
+
PointerSensor,
|
|
16
|
+
useSensor,
|
|
17
|
+
useSensors,
|
|
18
|
+
type DragEndEvent,
|
|
19
|
+
} from '@dnd-kit/core';
|
|
20
|
+
import {
|
|
21
|
+
SortableContext,
|
|
22
|
+
arrayMove,
|
|
23
|
+
sortableKeyboardCoordinates,
|
|
24
|
+
useSortable,
|
|
25
|
+
verticalListSortingStrategy,
|
|
26
|
+
} from '@dnd-kit/sortable';
|
|
27
|
+
import { CSS } from '@dnd-kit/utilities';
|
|
28
|
+
import type {
|
|
29
|
+
RankOrderAnswers,
|
|
30
|
+
RankOrderOption,
|
|
31
|
+
RankOrderOptionDisplay,
|
|
32
|
+
RankOrderQuestion,
|
|
33
|
+
} from '@explorer02/cfm-survey-sdk';
|
|
34
|
+
import {
|
|
35
|
+
assignRankWithoutDuplicates,
|
|
36
|
+
buildRankOrderFromOrderedOptionIds,
|
|
37
|
+
getOrderedOptionIdsFromRanks,
|
|
38
|
+
normalizeRankOrderAnswers,
|
|
39
|
+
} from '@explorer02/cfm-survey-sdk';
|
|
40
|
+
|
|
41
|
+
type RankOrderScaleProps = {
|
|
42
|
+
question: RankOrderQuestion;
|
|
43
|
+
selectedValue?: RankOrderAnswers;
|
|
44
|
+
onSelect: (value: RankOrderAnswers) => void;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type RankOrderOptionRowProps = {
|
|
48
|
+
option: RankOrderOption;
|
|
49
|
+
optionDisplay: RankOrderOptionDisplay;
|
|
50
|
+
rankSelect?: React.ReactNode;
|
|
51
|
+
trailingControls?: React.ReactNode;
|
|
52
|
+
dragHandle?: React.ReactNode;
|
|
53
|
+
rankBadge?: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function RankOrderOptionContent({
|
|
57
|
+
option,
|
|
58
|
+
optionDisplay,
|
|
59
|
+
}: {
|
|
60
|
+
option: RankOrderOption;
|
|
61
|
+
optionDisplay: RankOrderOptionDisplay;
|
|
62
|
+
}) {
|
|
63
|
+
const imageUrl = option.imageUrl || option.previewImageUrl;
|
|
64
|
+
|
|
65
|
+
if (optionDisplay === 'imageOnly') {
|
|
66
|
+
return (
|
|
67
|
+
<div className="flex flex-1 items-center">
|
|
68
|
+
{imageUrl ? (
|
|
69
|
+
<img
|
|
70
|
+
src={imageUrl}
|
|
71
|
+
alt={option.optionLabel || 'Option image'}
|
|
72
|
+
className="h-24 w-24 rounded-md object-cover"
|
|
73
|
+
/>
|
|
74
|
+
) : (
|
|
75
|
+
<div className="flex h-24 w-24 items-center justify-center rounded-md bg-gray-100 text-xs text-gray-400">
|
|
76
|
+
No image
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (optionDisplay === 'textOnly') {
|
|
84
|
+
return (
|
|
85
|
+
<span
|
|
86
|
+
className="flex-1 text-[15px] font-medium leading-tight text-gray-900"
|
|
87
|
+
dangerouslySetInnerHTML={{ __html: option.optionLabel }}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className="flex flex-1 items-center gap-4">
|
|
94
|
+
{imageUrl ? (
|
|
95
|
+
<img
|
|
96
|
+
src={imageUrl}
|
|
97
|
+
alt={option.optionLabel || 'Option image'}
|
|
98
|
+
className="h-14 w-14 shrink-0 rounded-md object-cover"
|
|
99
|
+
/>
|
|
100
|
+
) : (
|
|
101
|
+
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-gray-100 text-xs text-gray-400">
|
|
102
|
+
No image
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
<span
|
|
106
|
+
className="text-[15px] font-medium leading-tight text-gray-900"
|
|
107
|
+
dangerouslySetInnerHTML={{ __html: option.optionLabel }}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function RankOrderOptionRow({
|
|
114
|
+
option,
|
|
115
|
+
optionDisplay,
|
|
116
|
+
rankSelect,
|
|
117
|
+
trailingControls,
|
|
118
|
+
dragHandle,
|
|
119
|
+
rankBadge,
|
|
120
|
+
}: RankOrderOptionRowProps) {
|
|
121
|
+
return (
|
|
122
|
+
<div className="flex items-center gap-3 rounded-lg border border-[#e5e5e5] bg-white px-4 py-3 transition-colors hover:bg-gray-50/50">
|
|
123
|
+
{rankSelect}
|
|
124
|
+
{dragHandle}
|
|
125
|
+
{rankBadge !== undefined && (
|
|
126
|
+
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[#fdf2f8] text-sm font-semibold text-[#e20074]">
|
|
127
|
+
{rankBadge}
|
|
128
|
+
</span>
|
|
129
|
+
)}
|
|
130
|
+
<RankOrderOptionContent option={option} optionDisplay={optionDisplay} />
|
|
131
|
+
{trailingControls}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function DropdownRankLayout({
|
|
137
|
+
question,
|
|
138
|
+
selectedValue,
|
|
139
|
+
onSelect,
|
|
140
|
+
}: RankOrderScaleProps) {
|
|
141
|
+
const rankAnswers = normalizeRankOrderAnswers(selectedValue);
|
|
142
|
+
const rankChoices = question.options.map((_, index) => index + 1);
|
|
143
|
+
|
|
144
|
+
const handleRankChange = (optionId: string, rawValue: string) => {
|
|
145
|
+
const nextRank = rawValue === '' ? undefined : Number(rawValue);
|
|
146
|
+
onSelect(assignRankWithoutDuplicates(rankAnswers, optionId, nextRank));
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div className="space-y-3">
|
|
151
|
+
{question.options.map(option => {
|
|
152
|
+
const currentRank = rankAnswers[option.id];
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<RankOrderOptionRow
|
|
156
|
+
key={option.id}
|
|
157
|
+
option={option}
|
|
158
|
+
optionDisplay={question.optionDisplay}
|
|
159
|
+
rankSelect={
|
|
160
|
+
<select
|
|
161
|
+
aria-label={`Rank for ${option.optionLabel || option.id}`}
|
|
162
|
+
value={currentRank ?? ''}
|
|
163
|
+
onChange={event => handleRankChange(option.id, event.target.value)}
|
|
164
|
+
className="h-10 w-16 shrink-0 rounded border border-gray-300 bg-white px-2 text-sm outline-none focus:border-[#e20074] focus:ring-1 focus:ring-[#e20074]"
|
|
165
|
+
>
|
|
166
|
+
<option value="">-</option>
|
|
167
|
+
{rankChoices.map(rank => (
|
|
168
|
+
<option key={rank} value={rank}>
|
|
169
|
+
{rank}
|
|
170
|
+
</option>
|
|
171
|
+
))}
|
|
172
|
+
</select>
|
|
173
|
+
}
|
|
174
|
+
/>
|
|
175
|
+
);
|
|
176
|
+
})}
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
type SortableRankItemProps = {
|
|
182
|
+
option: RankOrderOption;
|
|
183
|
+
optionDisplay: RankOrderOptionDisplay;
|
|
184
|
+
rank: number;
|
|
185
|
+
onMoveUp: () => void;
|
|
186
|
+
onMoveDown: () => void;
|
|
187
|
+
isFirst: boolean;
|
|
188
|
+
isLast: boolean;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
function SortableRankItem({
|
|
192
|
+
option,
|
|
193
|
+
optionDisplay,
|
|
194
|
+
rank,
|
|
195
|
+
onMoveUp,
|
|
196
|
+
onMoveDown,
|
|
197
|
+
isFirst,
|
|
198
|
+
isLast,
|
|
199
|
+
}: SortableRankItemProps) {
|
|
200
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
201
|
+
id: option.id,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const style = {
|
|
205
|
+
transform: CSS.Transform.toString(transform),
|
|
206
|
+
transition,
|
|
207
|
+
opacity: isDragging ? 0.85 : 1,
|
|
208
|
+
zIndex: isDragging ? 10 : undefined,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<div ref={setNodeRef} style={style}>
|
|
213
|
+
<RankOrderOptionRow
|
|
214
|
+
option={option}
|
|
215
|
+
optionDisplay={optionDisplay}
|
|
216
|
+
rankBadge={rank}
|
|
217
|
+
dragHandle={
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
aria-label={`Drag to reorder ${option.optionLabel || option.id}`}
|
|
221
|
+
className="flex h-10 w-8 shrink-0 cursor-grab items-center justify-center rounded text-gray-500 hover:bg-gray-100 active:cursor-grabbing"
|
|
222
|
+
{...attributes}
|
|
223
|
+
{...listeners}
|
|
224
|
+
>
|
|
225
|
+
<svg viewBox="0 0 20 20" className="h-5 w-5" fill="currentColor" aria-hidden="true">
|
|
226
|
+
<circle cx="7" cy="6" r="1.5" />
|
|
227
|
+
<circle cx="13" cy="6" r="1.5" />
|
|
228
|
+
<circle cx="7" cy="10" r="1.5" />
|
|
229
|
+
<circle cx="13" cy="10" r="1.5" />
|
|
230
|
+
<circle cx="7" cy="14" r="1.5" />
|
|
231
|
+
<circle cx="13" cy="14" r="1.5" />
|
|
232
|
+
</svg>
|
|
233
|
+
</button>
|
|
234
|
+
}
|
|
235
|
+
trailingControls={
|
|
236
|
+
<div className="flex shrink-0 flex-col gap-1">
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
aria-label={`Move ${option.optionLabel || option.id} up`}
|
|
240
|
+
disabled={isFirst}
|
|
241
|
+
onClick={onMoveUp}
|
|
242
|
+
className="rounded border border-gray-300 px-2 py-1 text-xs text-gray-700 disabled:cursor-not-allowed disabled:opacity-40"
|
|
243
|
+
>
|
|
244
|
+
Up
|
|
245
|
+
</button>
|
|
246
|
+
<button
|
|
247
|
+
type="button"
|
|
248
|
+
aria-label={`Move ${option.optionLabel || option.id} down`}
|
|
249
|
+
disabled={isLast}
|
|
250
|
+
onClick={onMoveDown}
|
|
251
|
+
className="rounded border border-gray-300 px-2 py-1 text-xs text-gray-700 disabled:cursor-not-allowed disabled:opacity-40"
|
|
252
|
+
>
|
|
253
|
+
Down
|
|
254
|
+
</button>
|
|
255
|
+
</div>
|
|
256
|
+
}
|
|
257
|
+
/>
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function DragDropRankLayout({
|
|
263
|
+
question,
|
|
264
|
+
selectedValue,
|
|
265
|
+
onSelect,
|
|
266
|
+
}: RankOrderScaleProps) {
|
|
267
|
+
const rankAnswers = normalizeRankOrderAnswers(selectedValue);
|
|
268
|
+
const optionIds = question.options.map(option => option.id);
|
|
269
|
+
|
|
270
|
+
const orderedOptionIds = useMemo(() => {
|
|
271
|
+
const rankedIds = getOrderedOptionIdsFromRanks(rankAnswers, optionIds);
|
|
272
|
+
const unrankedIds = optionIds.filter(optionId => !rankedIds.includes(optionId));
|
|
273
|
+
return rankedIds.length > 0 ? [...rankedIds, ...unrankedIds] : optionIds;
|
|
274
|
+
}, [rankAnswers, optionIds]);
|
|
275
|
+
|
|
276
|
+
const sensors = useSensors(
|
|
277
|
+
useSensor(PointerSensor),
|
|
278
|
+
useSensor(KeyboardSensor, {
|
|
279
|
+
coordinateGetter: sortableKeyboardCoordinates,
|
|
280
|
+
})
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const updateOrder = (nextOrder: string[]) => {
|
|
284
|
+
onSelect(buildRankOrderFromOrderedOptionIds(nextOrder));
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const handleDragEnd = (event: DragEndEvent) => {
|
|
288
|
+
const { active, over } = event;
|
|
289
|
+
if (!over || active.id === over.id) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const oldIndex = orderedOptionIds.indexOf(String(active.id));
|
|
294
|
+
const newIndex = orderedOptionIds.indexOf(String(over.id));
|
|
295
|
+
if (oldIndex === -1 || newIndex === -1) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
updateOrder(arrayMove(orderedOptionIds, oldIndex, newIndex));
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const moveOption = (optionId: string, direction: 'up' | 'down') => {
|
|
303
|
+
const currentIndex = orderedOptionIds.indexOf(optionId);
|
|
304
|
+
if (currentIndex === -1) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
|
309
|
+
if (targetIndex < 0 || targetIndex >= orderedOptionIds.length) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
updateOrder(arrayMove(orderedOptionIds, currentIndex, targetIndex));
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
318
|
+
<SortableContext items={orderedOptionIds} strategy={verticalListSortingStrategy}>
|
|
319
|
+
<div className="space-y-3">
|
|
320
|
+
{orderedOptionIds.map((optionId, index) => {
|
|
321
|
+
const option = question.options.find(item => item.id === optionId);
|
|
322
|
+
if (!option) {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return (
|
|
327
|
+
<SortableRankItem
|
|
328
|
+
key={option.id}
|
|
329
|
+
option={option}
|
|
330
|
+
optionDisplay={question.optionDisplay}
|
|
331
|
+
rank={index + 1}
|
|
332
|
+
isFirst={index === 0}
|
|
333
|
+
isLast={index === orderedOptionIds.length - 1}
|
|
334
|
+
onMoveUp={() => moveOption(option.id, 'up')}
|
|
335
|
+
onMoveDown={() => moveOption(option.id, 'down')}
|
|
336
|
+
/>
|
|
337
|
+
);
|
|
338
|
+
})}
|
|
339
|
+
</div>
|
|
340
|
+
</SortableContext>
|
|
341
|
+
</DndContext>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function RankOrderScale({ question, selectedValue, onSelect }: RankOrderScaleProps) {
|
|
346
|
+
if (question.interactionMode === 'dragAndDrop') {
|
|
347
|
+
return (
|
|
348
|
+
<DragDropRankLayout question={question} selectedValue={selectedValue} onSelect={onSelect} />
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return <DropdownRankLayout question={question} selectedValue={selectedValue} onSelect={onSelect} />;
|
|
353
|
+
}
|
|
@@ -121,8 +121,28 @@ Fill from `00-integration/question-type-sdk-matrix.md`:
|
|
|
121
121
|
| FileUploadScale | | `08-file-upload-scale.md` |
|
|
122
122
|
| Header / Footer / ProgressBar / LanguageSelector | | `10`–`12` |
|
|
123
123
|
|
|
124
|
+
## 3e. Interaction dependencies (from survey inventory)
|
|
125
|
+
|
|
126
|
+
| Package | Install when |
|
|
127
|
+
|---------|--------------|
|
|
128
|
+
| `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities` | Survey contains `RANK_ORDER` |
|
|
129
|
+
| `react-icons` | Always (shared setup) + CSAT emoji/star matrix |
|
|
130
|
+
|
|
131
|
+
Copy portable templates from `docs/templates/` — see `component-checklist.md`.
|
|
132
|
+
|
|
124
133
|
## 4b. Per-Question Config & UI Coverage
|
|
125
134
|
|
|
135
|
+
Inventory required config fields per type:
|
|
136
|
+
|
|
137
|
+
| type | must inventory |
|
|
138
|
+
|------|----------------|
|
|
139
|
+
| MCQ | `selectionMode`, `minSelections`, `maxSelections`, `defaultOptionIds`, answer-logic on options |
|
|
140
|
+
| RANK_ORDER | `interactionMode`, `optionDisplay`, `requireRankAll`, `shuffleOptions` |
|
|
141
|
+
| CFM/CSAT/RATING | `gridLayout`, `displayStyle`, `selectionMode`, `hasNotApplicableOption`, `statementLayout` |
|
|
142
|
+
| SLIDER_MATRIX | `enableInputBox`, `enableNotApplicable`, `sliderStyle` |
|
|
143
|
+
| FILE_UPLOAD | `maxFileCount`, `fileSizeLimit`, `fileSizeLimitType`, upload API configured? |
|
|
144
|
+
| HEATMAP | `maxClicksAllowed`, image URL |
|
|
145
|
+
|
|
126
146
|
| questionId | type | configs detected | uiSpec read | component files | branches implemented |
|
|
127
147
|
|------------|------|------------------|-------------|-----------------|---------------------|
|
|
128
148
|
| | | | | | |
|
|
@@ -7,22 +7,27 @@
|
|
|
7
7
|
"required": ["questions", "uniqueTypes"],
|
|
8
8
|
"properties": {
|
|
9
9
|
"instanceId": { "type": "string", "description": "JWT used to fetch survey" },
|
|
10
|
+
"interactionDependencies": {
|
|
11
|
+
"type": "array",
|
|
12
|
+
"items": { "type": "string" },
|
|
13
|
+
"description": "npm packages e.g. @dnd-kit/core, react-icons"
|
|
14
|
+
},
|
|
10
15
|
"uniqueTypes": {
|
|
11
16
|
"type": "array",
|
|
12
17
|
"items": {
|
|
13
18
|
"type": "string",
|
|
14
19
|
"enum": [
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
20
|
+
"MCQ",
|
|
21
|
+
"TEXTFIELD",
|
|
22
|
+
"NPS_SCALE",
|
|
23
|
+
"CFM_MATRIX",
|
|
24
|
+
"CSAT_MATRIX",
|
|
25
|
+
"RATING_MATRIX",
|
|
26
|
+
"SLIDER_MATRIX",
|
|
27
|
+
"FILE_UPLOAD",
|
|
28
|
+
"TEXT_AND_MEDIA",
|
|
29
|
+
"HEATMAP",
|
|
30
|
+
"RANK_ORDER"
|
|
26
31
|
]
|
|
27
32
|
},
|
|
28
33
|
"uniqueItems": true
|
|
@@ -37,23 +42,23 @@
|
|
|
37
42
|
"type": {
|
|
38
43
|
"type": "string",
|
|
39
44
|
"enum": [
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
45
|
+
"MCQ",
|
|
46
|
+
"TEXTFIELD",
|
|
47
|
+
"NPS_SCALE",
|
|
48
|
+
"CFM_MATRIX",
|
|
49
|
+
"CSAT_MATRIX",
|
|
50
|
+
"RATING_MATRIX",
|
|
51
|
+
"SLIDER_MATRIX",
|
|
52
|
+
"FILE_UPLOAD",
|
|
53
|
+
"TEXT_AND_MEDIA",
|
|
54
|
+
"HEATMAP",
|
|
55
|
+
"RANK_ORDER"
|
|
51
56
|
]
|
|
52
57
|
},
|
|
53
58
|
"configFlags": {
|
|
54
59
|
"type": "array",
|
|
55
60
|
"items": { "type": "string" },
|
|
56
|
-
"description": "
|
|
61
|
+
"description": "e.g. interactionMode:dragAndDrop, gridLayout:carousel, selectionMode:multiple, defaultOptionIds"
|
|
57
62
|
},
|
|
58
63
|
"uiSpecRead": { "type": "string" },
|
|
59
64
|
"componentFiles": {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import {
|
|
5
|
+
FaFaceAngry,
|
|
6
|
+
FaFaceSadTear,
|
|
7
|
+
FaFaceFrown,
|
|
8
|
+
FaFaceFrownOpen,
|
|
9
|
+
FaFaceMeh,
|
|
10
|
+
FaFaceSmile,
|
|
11
|
+
FaFaceSmileBeam,
|
|
12
|
+
FaFaceGrin,
|
|
13
|
+
FaFaceGrinWide,
|
|
14
|
+
FaFaceGrinStars,
|
|
15
|
+
} from 'react-icons/fa6';
|
|
16
|
+
|
|
17
|
+
const EMOJI_COLORS = [
|
|
18
|
+
'#dc2626', '#e04832', '#ea580c', '#f59e0b', '#eab308',
|
|
19
|
+
'#a3a323', '#84cc16', '#65a30d', '#22c55e', '#16a34a', '#059669',
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
const FACE_ICONS = [
|
|
23
|
+
FaFaceAngry, FaFaceSadTear, FaFaceFrown, FaFaceFrownOpen, FaFaceMeh, FaFaceMeh,
|
|
24
|
+
FaFaceSmile, FaFaceSmileBeam, FaFaceGrin, FaFaceGrinWide, FaFaceGrinStars,
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
const CsatEmojiSet: Record<number, React.ReactNode> = {};
|
|
28
|
+
for (let i = 0; i < 11; i++) {
|
|
29
|
+
const Icon = FACE_ICONS[i];
|
|
30
|
+
CsatEmojiSet[i + 1] = React.createElement(Icon as React.ElementType, {
|
|
31
|
+
className: 'transition-transform',
|
|
32
|
+
style: { color: EMOJI_COLORS[i], fontSize: '28px' },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const EMOJI_SCALES: Record<number, number[]> = {
|
|
37
|
+
3: [3, 6, 8],
|
|
38
|
+
4: [3, 4, 7, 9],
|
|
39
|
+
5: [3, 4, 6, 7, 9],
|
|
40
|
+
6: [3, 4, 6, 7, 9, 11],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function getEmojiForIndex(index: number, total: number): React.ReactNode {
|
|
44
|
+
if (total <= 1) return CsatEmojiSet[6];
|
|
45
|
+
if (EMOJI_SCALES[total]) {
|
|
46
|
+
const key = EMOJI_SCALES[total][index];
|
|
47
|
+
if (key) return CsatEmojiSet[key];
|
|
48
|
+
}
|
|
49
|
+
const percent = index / (total - 1);
|
|
50
|
+
const key = Math.min(11, Math.max(1, Math.round(percent * 10) + 1));
|
|
51
|
+
return CsatEmojiSet[key];
|
|
52
|
+
}
|
|
@@ -46,6 +46,38 @@ else
|
|
|
46
46
|
exit 1
|
|
47
47
|
fi
|
|
48
48
|
echo "OK: All 11 question.type branches present"
|
|
49
|
+
|
|
50
|
+
if grep -q "RANK_ORDER" "$QUESTION_FILE" || grep -q "RankOrderScale" "$QUESTION_FILE"; then
|
|
51
|
+
RANK_FILE=""
|
|
52
|
+
for candidate in src/components/RankOrderScale.tsx components/RankOrderScale.tsx app/components/RankOrderScale.tsx; do
|
|
53
|
+
if [ -f "$candidate" ]; then RANK_FILE="$candidate"; break; fi
|
|
54
|
+
done
|
|
55
|
+
if [ -z "$RANK_FILE" ]; then
|
|
56
|
+
echo "FAIL: RankOrderScale.tsx required when RANK_ORDER is wired in Question.tsx"
|
|
57
|
+
exit 1
|
|
58
|
+
fi
|
|
59
|
+
echo "OK: RankOrderScale.tsx exists ($RANK_FILE)"
|
|
60
|
+
if ! grep -q "@dnd-kit/core" package.json 2>/dev/null; then
|
|
61
|
+
echo "FAIL: @dnd-kit/core missing from package.json (required for RANK_ORDER)"
|
|
62
|
+
exit 1
|
|
63
|
+
fi
|
|
64
|
+
echo "OK: @dnd-kit in package.json"
|
|
65
|
+
if ! grep -q "getVisibleRankOrderOptionsForAnswers" "$QUESTION_FILE"; then
|
|
66
|
+
echo "WARN: getVisibleRankOrderOptionsForAnswers not found in Question.tsx"
|
|
67
|
+
fi
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
if grep -q "FILE_UPLOAD" "$QUESTION_FILE" || grep -q "FileUploadScale" "$QUESTION_FILE"; then
|
|
71
|
+
UPLOAD_FILE=""
|
|
72
|
+
for candidate in src/components/FileUploadScale.tsx components/FileUploadScale.tsx app/components/FileUploadScale.tsx; do
|
|
73
|
+
if [ -f "$candidate" ]; then UPLOAD_FILE="$candidate"; break; fi
|
|
74
|
+
done
|
|
75
|
+
if [ -z "$UPLOAD_FILE" ]; then
|
|
76
|
+
echo "FAIL: FileUploadScale.tsx required when FILE_UPLOAD is wired"
|
|
77
|
+
exit 1
|
|
78
|
+
fi
|
|
79
|
+
echo "OK: FileUploadScale.tsx exists ($UPLOAD_FILE)"
|
|
80
|
+
fi
|
|
49
81
|
fi
|
|
50
82
|
|
|
51
83
|
echo "Running npm run build..."
|