@explorer02/cfm-survey-sdk 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +140 -33
- package/dist/cli/index.mjs +140 -33
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +8 -1
- package/postinstall.js +8 -5
- package/templates/AGENT.md +62 -17
- package/templates/deploy-wizard-to-s3.sh +52 -0
- package/templates/docs/00-integration/agent-execution-flow.md +281 -0
- package/templates/docs/00-integration/agent-operating-contract.md +260 -0
- package/templates/docs/00-integration/analytics-events-catalog.md +54 -0
- package/templates/docs/00-integration/apply-ui-config.md +365 -0
- package/templates/docs/00-integration/canonical-survey-fixtures.md +94 -0
- package/templates/docs/00-integration/client-integration-guide.md +10 -8
- package/templates/docs/00-integration/client-lib-folder.md +115 -0
- package/templates/docs/00-integration/component-checklist.md +90 -54
- package/templates/docs/00-integration/constraints.md +13 -5
- package/templates/docs/00-integration/custom-field-logic-and-navigation.md +56 -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/progress.md +4 -2
- package/templates/docs/00-integration/question-type-sdk-matrix.md +11 -9
- package/templates/docs/00-integration/setup.md +15 -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/00-integration/ui-customization-wizard.md +144 -0
- package/templates/docs/00-integration/useSurveySDK.md +35 -1
- package/templates/docs/00-integration/wizard-api.md +134 -0
- package/templates/docs/00-integration/wizard-config-handoff.md +246 -0
- package/templates/docs/00-integration/wizard-preview-build-guide.md +400 -0
- package/templates/docs/00-integration/wizard-question-type-styling.md +195 -0
- package/templates/docs/01-components/01-survey-page.md +18 -8
- package/templates/docs/01-components/02-question.md +2 -2
- package/templates/docs/01-components/03-rating-scale.md +12 -18
- package/templates/docs/01-components/05-csat-matrix-scale.md +18 -26
- package/templates/docs/01-components/06-likert-matrix-scale.md +19 -38
- package/templates/docs/01-components/07-slider-matrix-scale.md +48 -33
- package/templates/docs/01-components/08-file-upload-scale.md +15 -52
- package/templates/docs/01-components/09-custom-slider-track.md +15 -25
- package/templates/docs/01-components/10-header-footer.md +23 -4
- package/templates/docs/01-components/11-progress-bar.md +14 -6
- 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/01-components/19-survey-sticky-chrome.md +79 -0
- package/templates/docs/01-components/README.md +2 -2
- package/templates/docs/02-reference/question-types/10-slider-matrix.md +38 -26
- 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/02-reference/routing-table.md +1 -0
- package/templates/docs/02-reference/value-derivation.md +1 -1
- package/templates/docs/03-ui-specs/00-question-shell.md +10 -0
- package/templates/docs/03-ui-specs/01-rating.md +28 -2
- package/templates/docs/03-ui-specs/02-radio.md +94 -24
- package/templates/docs/03-ui-specs/03-text.md +12 -1
- package/templates/docs/03-ui-specs/04-csat.md +90 -26
- package/templates/docs/03-ui-specs/06-slider.md +3 -1
- package/templates/docs/03-ui-specs/07-matrix-cfm.md +74 -35
- package/templates/docs/03-ui-specs/08-matrix-csat-rating.md +50 -16
- package/templates/docs/03-ui-specs/09-slider-matrix.md +70 -26
- package/templates/docs/03-ui-specs/10-file-upload.md +52 -20
- package/templates/docs/03-ui-specs/11-text-and-media.md +4 -0
- package/templates/docs/03-ui-specs/12-survey-chrome.md +92 -5
- package/templates/docs/03-ui-specs/13-heatmap.md +21 -3
- package/templates/docs/03-ui-specs/14-rank-order.md +108 -28
- package/templates/docs/03-ui-specs/README.md +11 -7
- package/templates/docs/03-ui-specs/shared/custom-slider-track.md +28 -35
- package/templates/docs/03-ui-specs/shared/matrix-dropdown.md +1 -1
- package/templates/docs/MANIFEST.json +187 -6
- package/templates/docs/index.md +116 -116
- package/templates/docs/templates/CsatMatrixScale.tsx +637 -0
- package/templates/docs/templates/CustomSliderTrack.tsx +144 -0
- package/templates/docs/templates/FileUploadScale.tsx +262 -0
- package/templates/docs/templates/HeatmapScale.tsx +114 -0
- package/templates/docs/templates/LikertMatrixScale.tsx +414 -0
- package/templates/docs/templates/MatrixDropdown.tsx +216 -0
- package/templates/docs/templates/Question.tsx +3 -0
- package/templates/docs/templates/RankOrderScale.tsx +353 -0
- package/templates/docs/templates/RatingScale.tsx +129 -0
- package/templates/docs/templates/SliderMatrixScale.tsx +248 -0
- package/templates/docs/templates/SurveyStickyChrome.tsx +24 -0
- package/templates/docs/templates/customFieldValues.ts +36 -0
- package/templates/docs/templates/implementation_plan.md +75 -8
- package/templates/docs/templates/labelStyles.ts +33 -0
- package/templates/docs/templates/selectionStyles.ts +100 -0
- package/templates/docs/templates/survey-inventory.schema.json +28 -23
- package/templates/docs/templates/surveyUiIcons.tsx +63 -0
- package/templates/docs/templates/surveyUiScaleUtils.ts +51 -0
- package/templates/docs/templates/verify-agent-build.sh +151 -0
- package/templates/preview-harness/preview-bridge.inline.js +201 -0
- package/templates/preview-harness/previewPages.js +108 -0
- package/templates/preview-harness/previewPages.ts +298 -0
- package/templates/preview-harness/vite-app/src/PreviewConfigContext.tsx +67 -0
- package/templates/preview-harness/vite-app/src/QuestionPreview.tsx +122 -0
- package/templates/preview-harness/vite-app/src/SurveyPagePreview.tsx +75 -0
- package/templates/preview-harness/vite-app/src/fixtures/questions.ts +311 -0
- package/templates/preview-harness/vite-app/src/globals.css +16 -0
- package/templates/preview-harness/vite-app/src/mount.tsx +9 -0
- package/templates/preview-harness/vite-app/src/preview-live-overrides.css +101 -0
- package/templates/preview-harness/vite-app/stubs/next-dynamic.tsx +25 -0
- package/templates/preview-harness/vite-app/stubs/next-image.tsx +29 -0
- package/templates/previewBridge.ts +153 -0
- package/templates/survey-theme.css +295 -0
- package/templates/survey-ui-config.schema.json +213 -0
- package/templates/wizard-dist/assets/PreviewMock-DgHfrVeb.js +1 -0
- package/templates/wizard-dist/assets/TypePanel-CFVC3Ptn.js +1 -0
- package/templates/wizard-dist/assets/index-BhWM50Yu.css +1 -0
- package/templates/wizard-dist/assets/index-DYK3X1e5.js +34 -0
- package/templates/wizard-dist/assets/vendor-BwkXDkd3.js +17 -0
- package/templates/wizard-dist/index.html +20 -0
- package/templates/wizard.html +13 -1129
|
@@ -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,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
|
+
}
|