@explorer02/cfm-survey-sdk 0.2.1 → 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/dist/cli/index.js +18 -18
- package/dist/cli/index.mjs +14 -14
- package/dist/index.d.mts +465 -580
- package/dist/index.d.ts +465 -580
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +6 -4
- package/postinstall.js +96 -69
- package/templates/AGENT.md +18 -8
- package/templates/docs/00-integration/analytics-events-catalog.md +54 -0
- package/templates/docs/00-integration/answer-logic-and-navigation.md +50 -0
- package/templates/docs/00-integration/aws-deploy.md +281 -0
- package/templates/docs/00-integration/client-integration-guide.md +351 -0
- package/templates/docs/00-integration/component-checklist.md +55 -22
- package/templates/docs/00-integration/constraints.md +49 -4
- package/templates/docs/00-integration/custom-field-logic-and-navigation.md +174 -0
- package/templates/docs/00-integration/display-logic-and-navigation.md +64 -0
- package/templates/docs/00-integration/end-page-logic.md +64 -0
- 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 +98 -0
- package/templates/docs/00-integration/placeholders-and-tokens.md +116 -0
- package/templates/docs/00-integration/progress.md +61 -0
- package/templates/docs/00-integration/question-display-variants.md +65 -0
- package/templates/docs/00-integration/question-numbering.md +51 -0
- package/templates/docs/00-integration/question-type-sdk-matrix.md +244 -0
- package/templates/docs/00-integration/setup.md +84 -0
- package/templates/docs/00-integration/skip-logic-and-navigation.md +80 -0
- package/templates/docs/00-integration/survey-lifecycle-analytics.md +94 -0
- package/templates/docs/00-integration/useSurveySDK.md +151 -23
- package/templates/docs/01-components/01-survey-page.md +76 -54
- package/templates/docs/01-components/02-question.md +61 -58
- package/templates/docs/01-components/03-rating-scale.md +11 -1
- package/templates/docs/01-components/04-star-rating-scale.md +45 -0
- package/templates/docs/01-components/05-csat-matrix-scale.md +20 -11
- package/templates/docs/01-components/06-likert-matrix-scale.md +14 -4
- package/templates/docs/01-components/07-slider-matrix-scale.md +11 -2
- package/templates/docs/01-components/08-file-upload-scale.md +23 -51
- package/templates/docs/01-components/09-custom-slider-track.md +2 -2
- package/templates/docs/01-components/11-progress-bar.md +10 -4
- package/templates/docs/01-components/13-matrix-dropdown.md +29 -32
- package/templates/docs/01-components/14-intro-page.md +34 -0
- package/templates/docs/01-components/15-end-page.md +31 -0
- package/templates/docs/01-components/16-paused-page.md +27 -0
- package/templates/docs/01-components/17-heatmap-scale.md +75 -0
- package/templates/docs/01-components/18-rank-order-scale.md +93 -0
- package/templates/docs/01-components/README.md +42 -36
- package/templates/docs/02-reference/config-field-index.md +141 -139
- package/templates/docs/02-reference/exports.md +149 -0
- package/templates/docs/02-reference/question-types/01-rating.md +31 -32
- package/templates/docs/02-reference/question-types/04-csat.md +51 -42
- package/templates/docs/02-reference/question-types/05-rating-scale.md +16 -11
- package/templates/docs/02-reference/question-types/06-slider.md +2 -2
- package/templates/docs/02-reference/question-types/07-matrix-cfm.md +44 -30
- package/templates/docs/02-reference/question-types/08-matrix-csat.md +4 -26
- package/templates/docs/02-reference/question-types/09-matrix-rating.md +29 -13
- package/templates/docs/02-reference/question-types/10-slider-matrix.md +15 -4
- package/templates/docs/02-reference/question-types/11-file-upload.md +43 -17
- package/templates/docs/02-reference/question-types/12-heatmap.md +59 -0
- package/templates/docs/02-reference/question-types/13-rank-order.md +101 -0
- package/templates/docs/02-reference/question-types/README.md +96 -47
- package/templates/docs/02-reference/routing-table.md +33 -18
- package/templates/docs/02-reference/value-derivation.md +63 -0
- package/templates/docs/03-ui-specs/00-question-shell.md +19 -88
- package/templates/docs/03-ui-specs/01-rating.md +18 -52
- package/templates/docs/03-ui-specs/02-radio.md +80 -37
- package/templates/docs/03-ui-specs/03-text.md +2 -2
- package/templates/docs/03-ui-specs/04-csat.md +68 -33
- package/templates/docs/03-ui-specs/05-rating-scale.md +21 -18
- package/templates/docs/03-ui-specs/06-slider.md +3 -1
- package/templates/docs/03-ui-specs/07-matrix-cfm.md +44 -25
- package/templates/docs/03-ui-specs/08-matrix-csat-rating.md +19 -43
- package/templates/docs/03-ui-specs/09-slider-matrix.md +18 -1
- package/templates/docs/03-ui-specs/10-file-upload.md +42 -21
- package/templates/docs/03-ui-specs/11-text-and-media.md +29 -29
- package/templates/docs/03-ui-specs/12-survey-chrome.md +58 -14
- package/templates/docs/03-ui-specs/13-heatmap.md +80 -0
- package/templates/docs/03-ui-specs/14-rank-order.md +128 -0
- package/templates/docs/03-ui-specs/README.md +16 -14
- package/templates/docs/03-ui-specs/shared/custom-slider-track.md +28 -35
- package/templates/docs/03-ui-specs/shared/matrix-dropdown.md +5 -0
- package/templates/docs/MANIFEST.json +476 -69
- package/templates/docs/index.md +82 -30
- package/templates/docs/templates/CustomSliderTrack.tsx +144 -0
- package/templates/docs/templates/MatrixDropdown.tsx +216 -0
- package/templates/docs/templates/Question.tsx +221 -154
- package/templates/docs/templates/RankOrderScale.tsx +353 -0
- package/templates/docs/templates/deploy-to-aws.sh +114 -0
- package/templates/docs/templates/heatmapCoords.ts +58 -0
- package/templates/docs/templates/implementation_plan.md +126 -67
- package/templates/docs/templates/survey-inventory.schema.json +77 -0
- package/templates/docs/templates/surveyUiIcons.tsx +52 -0
- package/templates/docs/templates/verify-agent-build.sh +85 -0
- package/templates/docs/01-components/04-csat-scale.md +0 -40
- package/templates/docs/02-question-types/01-rating.md +0 -44
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Deploy static survey UI to AWS via EC2 middle API (S3 + CloudFront).
|
|
3
|
+
# Prerequisites: CFM_INSTANCE_ID, CFM_DEPLOY_API_BASE, OUT_DIR set in environment.
|
|
4
|
+
# Run from project root after a successful static build.
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
: "${CFM_INSTANCE_ID:?Set CFM_INSTANCE_ID to the same JWT as SurveyPage SURVEY_INSTANCE_ID}"
|
|
8
|
+
: "${CFM_DEPLOY_API_BASE:?Set CFM_DEPLOY_API_BASE (e.g. http://43.204.26.213:3000)}"
|
|
9
|
+
: "${OUT_DIR:?Set OUT_DIR to absolute path of out/ (Next.js) or dist/ (Vite)}"
|
|
10
|
+
|
|
11
|
+
if [ ! -f "$OUT_DIR/index.html" ]; then
|
|
12
|
+
echo "FAIL: $OUT_DIR/index.html not found — run build first"
|
|
13
|
+
exit 1
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
FILE_COUNT=$(find "$OUT_DIR" -type f | wc -l | tr -d ' ')
|
|
17
|
+
if [ "$FILE_COUNT" -gt 500 ]; then
|
|
18
|
+
echo "FAIL: $FILE_COUNT files exceeds API limit of 500"
|
|
19
|
+
exit 1
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
echo "=== AWS Deploy ==="
|
|
23
|
+
echo "API: $CFM_DEPLOY_API_BASE"
|
|
24
|
+
echo "OUT: $OUT_DIR ($FILE_COUNT files)"
|
|
25
|
+
echo
|
|
26
|
+
|
|
27
|
+
HEALTH=$(curl -s "$CFM_DEPLOY_API_BASE/health")
|
|
28
|
+
echo "Health: $HEALTH"
|
|
29
|
+
echo
|
|
30
|
+
|
|
31
|
+
python3 <<'PY' > /tmp/deploy-manifest.json
|
|
32
|
+
import json, os, mimetypes
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
out = Path(os.environ["OUT_DIR"])
|
|
35
|
+
files = []
|
|
36
|
+
for path in sorted(out.rglob("*")):
|
|
37
|
+
if not path.is_file():
|
|
38
|
+
continue
|
|
39
|
+
rel = path.relative_to(out).as_posix()
|
|
40
|
+
mime, _ = mimetypes.guess_type(rel)
|
|
41
|
+
files.append({
|
|
42
|
+
"path": rel,
|
|
43
|
+
"contentType": mime or "application/octet-stream",
|
|
44
|
+
"sizeBytes": path.stat().st_size,
|
|
45
|
+
})
|
|
46
|
+
print(json.dumps({"files": files}))
|
|
47
|
+
print(f"files: {len(files)}", file=__import__('sys').stderr)
|
|
48
|
+
PY
|
|
49
|
+
|
|
50
|
+
echo "Channel 1: deploy/start"
|
|
51
|
+
curl -s -X POST "$CFM_DEPLOY_API_BASE/deploy/start" \
|
|
52
|
+
-H "Authorization: Bearer $CFM_INSTANCE_ID" \
|
|
53
|
+
-H "Content-Type: application/json" \
|
|
54
|
+
-d @/tmp/deploy-manifest.json \
|
|
55
|
+
| tee /tmp/deploy-start.json
|
|
56
|
+
echo
|
|
57
|
+
|
|
58
|
+
if ! python3 -c "import json; json.load(open('/tmp/deploy-start.json'))['deployId']" 2>/dev/null; then
|
|
59
|
+
echo "FAIL: deploy/start did not return deployId"
|
|
60
|
+
exit 1
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
echo "Channel 2: S3 uploads"
|
|
64
|
+
python3 <<'PY'
|
|
65
|
+
import json, os, subprocess
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
|
|
68
|
+
out = Path(os.environ["OUT_DIR"])
|
|
69
|
+
start = json.load(open("/tmp/deploy-start.json"))
|
|
70
|
+
manifest = {f["path"]: f["contentType"] for f in json.load(open("/tmp/deploy-manifest.json"))["files"]}
|
|
71
|
+
failed = []
|
|
72
|
+
|
|
73
|
+
for item in start["uploads"]:
|
|
74
|
+
path = item["path"]
|
|
75
|
+
url = item["uploadUrl"]
|
|
76
|
+
mime = manifest.get(path, "application/octet-stream")
|
|
77
|
+
proc = subprocess.run(
|
|
78
|
+
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
|
79
|
+
"-X", "PUT", url, "-H", f"Content-Type: {mime}",
|
|
80
|
+
"--data-binary", f"@{out / path}"],
|
|
81
|
+
capture_output=True, text=True,
|
|
82
|
+
)
|
|
83
|
+
code = proc.stdout.strip()
|
|
84
|
+
mark = "OK" if code == "200" else "FAIL"
|
|
85
|
+
print(f"{mark} {code} {path}")
|
|
86
|
+
if code != "200":
|
|
87
|
+
failed.append(path)
|
|
88
|
+
|
|
89
|
+
if failed:
|
|
90
|
+
print(f"\n{len(failed)} failed — rerun deploy (presigned URLs expire)")
|
|
91
|
+
raise SystemExit(1)
|
|
92
|
+
print("\nAll uploads succeeded")
|
|
93
|
+
PY
|
|
94
|
+
|
|
95
|
+
echo
|
|
96
|
+
echo "Channel 3: deploy/complete"
|
|
97
|
+
DEPLOY_ID=$(python3 -c "import json; print(json.load(open('/tmp/deploy-start.json'))['deployId'])")
|
|
98
|
+
curl -s -X POST "$CFM_DEPLOY_API_BASE/deploy/complete" \
|
|
99
|
+
-H "Authorization: Bearer $CFM_INSTANCE_ID" \
|
|
100
|
+
-H "Content-Type: application/json" \
|
|
101
|
+
-d "{\"deployId\":\"$DEPLOY_ID\"}" \
|
|
102
|
+
| tee /tmp/deploy-complete.json
|
|
103
|
+
echo
|
|
104
|
+
|
|
105
|
+
LIVE_URL=$(python3 -c "import json; print(json.load(open('/tmp/deploy-complete.json'))['url'])")
|
|
106
|
+
echo "Live URL: $LIVE_URL"
|
|
107
|
+
HTTP=$(curl -s -o /dev/null -w "%{http_code}" "$LIVE_URL")
|
|
108
|
+
echo "Verify HTTP: $HTTP"
|
|
109
|
+
if [ "$HTTP" != "200" ]; then
|
|
110
|
+
echo "WARN: index.html returned $HTTP — check CloudFront path or invalidation"
|
|
111
|
+
exit 1
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
echo "=== Deploy complete ==="
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable heatmap coordinate helpers for agent projects.
|
|
3
|
+
*
|
|
4
|
+
* Copy to e.g. src/lib/heatmapCoords.ts — do not import from monorepo-only paths.
|
|
5
|
+
* See 01-components/17-heatmap-scale.md and question-type-sdk-matrix.md#heatmap.
|
|
6
|
+
*/
|
|
7
|
+
import type { MouseEvent } from 'react';
|
|
8
|
+
import type { HeatmapClickPoint } from '@explorer02/cfm-survey-sdk';
|
|
9
|
+
|
|
10
|
+
export const SPOT_HIT_RADIUS_PX = 14;
|
|
11
|
+
|
|
12
|
+
export function roundCoordinate(value: number): number {
|
|
13
|
+
return Math.round(value * 100000) / 100000;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getNormalizedClickFromImage(
|
|
17
|
+
event: MouseEvent,
|
|
18
|
+
imageElement: HTMLImageElement
|
|
19
|
+
): { x: number; y: number } {
|
|
20
|
+
const rect = imageElement.getBoundingClientRect();
|
|
21
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
22
|
+
return { x: 0, y: 0 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const x = (event.clientX - rect.left) / rect.width;
|
|
26
|
+
const y = (event.clientY - rect.top) / rect.height;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
x: roundCoordinate(Math.min(1, Math.max(0, x))),
|
|
30
|
+
y: roundCoordinate(Math.min(1, Math.max(0, y))),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function findSpotNearClick(
|
|
35
|
+
clickX: number,
|
|
36
|
+
clickY: number,
|
|
37
|
+
spots: HeatmapClickPoint[],
|
|
38
|
+
imageRect: DOMRect
|
|
39
|
+
): HeatmapClickPoint | undefined {
|
|
40
|
+
const clickPixelX = clickX * imageRect.width;
|
|
41
|
+
const clickPixelY = clickY * imageRect.height;
|
|
42
|
+
|
|
43
|
+
for (const spot of spots) {
|
|
44
|
+
const spotPixelX = spot.x * imageRect.width;
|
|
45
|
+
const spotPixelY = spot.y * imageRect.height;
|
|
46
|
+
const distance = Math.hypot(spotPixelX - clickPixelX, spotPixelY - clickPixelY);
|
|
47
|
+
|
|
48
|
+
if (distance <= SPOT_HIT_RADIUS_PX) {
|
|
49
|
+
return spot;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createNewSpot(x: number, y: number): HeatmapClickPoint {
|
|
57
|
+
return { id: crypto.randomUUID(), x, y };
|
|
58
|
+
}
|