@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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/templates/AGENT.md +8 -4
  3. package/templates/docs/00-integration/analytics-events-catalog.md +54 -0
  4. package/templates/docs/00-integration/client-integration-guide.md +1 -1
  5. package/templates/docs/00-integration/component-checklist.md +10 -0
  6. package/templates/docs/00-integration/display-logic-and-navigation.md +16 -1
  7. package/templates/docs/00-integration/file-upload-aws.md +116 -0
  8. package/templates/docs/00-integration/logic-fields-catalog.md +105 -0
  9. package/templates/docs/00-integration/partial-save-and-recovery.md +24 -0
  10. package/templates/docs/00-integration/question-type-sdk-matrix.md +11 -9
  11. package/templates/docs/00-integration/setup.md +3 -3
  12. package/templates/docs/00-integration/skip-logic-and-navigation.md +1 -1
  13. package/templates/docs/00-integration/survey-lifecycle-analytics.md +1 -1
  14. package/templates/docs/01-components/06-likert-matrix-scale.md +2 -1
  15. package/templates/docs/01-components/08-file-upload-scale.md +15 -52
  16. package/templates/docs/01-components/13-matrix-dropdown.md +28 -33
  17. package/templates/docs/01-components/17-heatmap-scale.md +4 -4
  18. package/templates/docs/01-components/18-rank-order-scale.md +24 -31
  19. package/templates/docs/02-reference/question-types/11-file-upload.md +36 -21
  20. package/templates/docs/02-reference/question-types/README.md +11 -1
  21. package/templates/docs/03-ui-specs/01-rating.md +10 -2
  22. package/templates/docs/03-ui-specs/02-radio.md +80 -25
  23. package/templates/docs/03-ui-specs/04-csat.md +35 -5
  24. package/templates/docs/03-ui-specs/07-matrix-cfm.md +20 -4
  25. package/templates/docs/03-ui-specs/08-matrix-csat-rating.md +5 -1
  26. package/templates/docs/03-ui-specs/09-slider-matrix.md +17 -0
  27. package/templates/docs/03-ui-specs/10-file-upload.md +41 -20
  28. package/templates/docs/03-ui-specs/12-survey-chrome.md +38 -0
  29. package/templates/docs/03-ui-specs/13-heatmap.md +9 -2
  30. package/templates/docs/03-ui-specs/14-rank-order.md +98 -29
  31. package/templates/docs/03-ui-specs/README.md +7 -5
  32. package/templates/docs/03-ui-specs/shared/custom-slider-track.md +28 -35
  33. package/templates/docs/MANIFEST.json +28 -3
  34. package/templates/docs/index.md +5 -4
  35. package/templates/docs/templates/CustomSliderTrack.tsx +144 -0
  36. package/templates/docs/templates/MatrixDropdown.tsx +216 -0
  37. package/templates/docs/templates/RankOrderScale.tsx +353 -0
  38. package/templates/docs/templates/implementation_plan.md +20 -0
  39. package/templates/docs/templates/survey-inventory.schema.json +28 -23
  40. package/templates/docs/templates/surveyUiIcons.tsx +52 -0
  41. 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
- "nps",
16
- "mcq",
17
- "text_input",
18
- "csat",
19
- "star_rating",
20
- "slider",
21
- "cfm_matrix",
22
- "rating_matrix",
23
- "slider_matrix",
24
- "file_upload",
25
- "text_and_media"
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
- "nps",
41
- "mcq",
42
- "text_input",
43
- "csat",
44
- "star_rating",
45
- "slider",
46
- "cfm_matrix",
47
- "rating_matrix",
48
- "slider_matrix",
49
- "file_upload",
50
- "text_and_media"
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": "Non-default config, e.g. gridLayout:carousel"
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..."