@explorer02/cfm-survey-sdk 0.1.3 → 0.1.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/package.json +1 -1
- package/templates/AGENT.md +557 -122
package/package.json
CHANGED
package/templates/AGENT.md
CHANGED
|
@@ -744,16 +744,14 @@ export default function SurveyPage() {
|
|
|
744
744
|
)}
|
|
745
745
|
|
|
746
746
|
{/* Navigation Buttons */}
|
|
747
|
-
<
|
|
748
|
-
{/* Back Button — only show if enabled and not on the first page */}
|
|
747
|
+
<div className="mt-8 flex items-center justify-start gap-4">
|
|
749
748
|
{state.currentPageIndex > 0 && surveyConfig.layout.showBackButton && (
|
|
750
749
|
<button
|
|
751
750
|
type="button"
|
|
752
751
|
onClick={() => onAction({ type: 'PREVIOUS' })}
|
|
753
|
-
|
|
752
|
+
className="rounded-md border border-[#e20074] px-6 py-2 text-sm font-semibold text-[#e20074] transition-all hover:bg-[#fdf2f8]"
|
|
754
753
|
>
|
|
755
|
-
{
|
|
756
|
-
Back
|
|
754
|
+
{survey.language?.startsWith('de') ? 'Zurück' : 'Back'}
|
|
757
755
|
</button>
|
|
758
756
|
)}
|
|
759
757
|
|
|
@@ -763,17 +761,22 @@ export default function SurveyPage() {
|
|
|
763
761
|
type="button"
|
|
764
762
|
onClick={() => onAction({ type: 'NEXT' })}
|
|
765
763
|
disabled={submitSurveyResults.isLoading}
|
|
766
|
-
|
|
767
|
-
Must have disabled state styling for when submission is in progress. */
|
|
764
|
+
className="rounded-md bg-[#e20074] px-6 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
|
768
765
|
>
|
|
769
766
|
{submitSurveyResults.isLoading
|
|
770
|
-
? '
|
|
767
|
+
? survey.language?.startsWith('de')
|
|
768
|
+
? 'Wird gesendet...'
|
|
769
|
+
: 'Submitting...'
|
|
771
770
|
: state.currentPageIndex < survey.pages.length - 1
|
|
772
|
-
? '
|
|
773
|
-
|
|
771
|
+
? survey.language?.startsWith('de')
|
|
772
|
+
? 'Weiter'
|
|
773
|
+
: 'Next'
|
|
774
|
+
: survey.language?.startsWith('de')
|
|
775
|
+
? 'Absenden'
|
|
776
|
+
: 'Submit'}
|
|
774
777
|
</button>
|
|
775
778
|
)}
|
|
776
|
-
</
|
|
779
|
+
</div>
|
|
777
780
|
|
|
778
781
|
{/* Footer — pass links/data as needed */}
|
|
779
782
|
<Footer />
|
|
@@ -1022,34 +1025,73 @@ export default function Question({ question, selectedValue, validationError, onS
|
|
|
1022
1025
|
### Logic Skeleton: `RatingScale.tsx` — Rating Input
|
|
1023
1026
|
|
|
1024
1027
|
```tsx
|
|
1028
|
+
import type { SurveyOption, AnswerValue } from '@explorer02/cfm-survey-sdk';
|
|
1029
|
+
|
|
1025
1030
|
type RatingScaleProps = {
|
|
1026
1031
|
questionId: string;
|
|
1027
|
-
options:
|
|
1028
|
-
selectedValue?:
|
|
1029
|
-
onSelect: (value:
|
|
1032
|
+
options: SurveyOption[];
|
|
1033
|
+
selectedValue?: AnswerValue;
|
|
1034
|
+
onSelect: (value: AnswerValue) => void;
|
|
1030
1035
|
};
|
|
1031
1036
|
|
|
1032
1037
|
export default function RatingScale({ questionId, options, selectedValue, onSelect }: RatingScaleProps) {
|
|
1033
1038
|
return (
|
|
1034
|
-
<div>
|
|
1035
|
-
{/*
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
<
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
>
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
}
|
|
1039
|
+
<div role="radiogroup" className="w-full">
|
|
1040
|
+
{/* Badges row */}
|
|
1041
|
+
<div
|
|
1042
|
+
className="grid gap-0.5 sm:gap-1 mb-3"
|
|
1043
|
+
style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
|
|
1044
|
+
>
|
|
1045
|
+
{options.map((option, index) => (
|
|
1046
|
+
<div key={`${questionId}-badge-${option.value}`} className="flex justify-center">
|
|
1047
|
+
<div
|
|
1048
|
+
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"
|
|
1049
|
+
style={{ backgroundColor: option.color ?? '#e20074' }}
|
|
1050
|
+
>
|
|
1051
|
+
{option.label}
|
|
1052
|
+
</div>
|
|
1053
|
+
</div>
|
|
1054
|
+
))}
|
|
1055
|
+
</div>
|
|
1056
|
+
|
|
1057
|
+
{/* Track row */}
|
|
1058
|
+
<div
|
|
1059
|
+
className="grid bg-[#f2f2f2] rounded-md overflow-hidden h-11 sm:h-12 border border-gray-200/50"
|
|
1060
|
+
style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
|
|
1061
|
+
>
|
|
1062
|
+
{options.map((option, index) => {
|
|
1063
|
+
const isSelected = selectedValue === option.value;
|
|
1064
|
+
return (
|
|
1065
|
+
<label
|
|
1066
|
+
key={`${questionId}-track-${option.value}`}
|
|
1067
|
+
className="flex items-center justify-center cursor-pointer transition-colors relative h-full w-full"
|
|
1068
|
+
>
|
|
1069
|
+
<input
|
|
1070
|
+
type="radio"
|
|
1071
|
+
name={questionId}
|
|
1072
|
+
value={option.value === null ? '' : String(option.value)}
|
|
1073
|
+
checked={isSelected}
|
|
1074
|
+
onChange={() => onSelect(option.value)}
|
|
1075
|
+
className="sr-only"
|
|
1076
|
+
/>
|
|
1077
|
+
<div
|
|
1078
|
+
className={`flex items-center justify-center w-[90%] h-[80%] rounded-[6px] transition-all ${
|
|
1079
|
+
isSelected ? 'bg-[#fbe8f3]' : 'hover:bg-gray-200/40'
|
|
1080
|
+
}`}
|
|
1081
|
+
>
|
|
1082
|
+
{/* Custom Radio Button */}
|
|
1083
|
+
<div
|
|
1084
|
+
className={`flex h-4.5 w-4.5 sm:h-5.5 sm:w-5.5 items-center justify-center rounded-full transition-all ${
|
|
1085
|
+
isSelected
|
|
1086
|
+
? 'border-[4px] border-[#e20074] bg-white shadow-sm'
|
|
1087
|
+
: 'border-2 border-gray-300 bg-white'
|
|
1088
|
+
}`}
|
|
1089
|
+
/>
|
|
1090
|
+
</div>
|
|
1091
|
+
</label>
|
|
1092
|
+
);
|
|
1093
|
+
})}
|
|
1094
|
+
</div>
|
|
1053
1095
|
</div>
|
|
1054
1096
|
);
|
|
1055
1097
|
}
|
|
@@ -1061,57 +1103,96 @@ export default function RatingScale({ questionId, options, selectedValue, onSele
|
|
|
1061
1103
|
|
|
1062
1104
|
```tsx
|
|
1063
1105
|
import type { CsatQuestion } from '@explorer02/cfm-survey-sdk';
|
|
1064
|
-
import {
|
|
1106
|
+
import { CsatEmojiMapping, CsatStarIcons, getEmojiForIndex } from '@explorer02/cfm-survey-sdk';
|
|
1065
1107
|
|
|
1066
1108
|
type CsatScaleProps = {
|
|
1067
1109
|
question: CsatQuestion;
|
|
1068
|
-
selectedValue
|
|
1110
|
+
selectedValue?: string | number | null;
|
|
1069
1111
|
onSelect: (value: string | number | null) => void;
|
|
1070
1112
|
};
|
|
1071
1113
|
|
|
1072
1114
|
export default function CsatScale({ question, selectedValue, onSelect }: CsatScaleProps) {
|
|
1073
|
-
const {
|
|
1115
|
+
const { buttonType, options, labelPosition } = question;
|
|
1116
|
+
|
|
1117
|
+
if (buttonType === 'dropdown') {
|
|
1118
|
+
return (
|
|
1119
|
+
<div className="w-full max-w-sm">
|
|
1120
|
+
<select
|
|
1121
|
+
className="w-full rounded-[4px] border border-gray-300 bg-white p-3 text-[15px] font-medium text-gray-900 outline-none transition-colors hover:border-gray-400 focus:border-[#e20074] focus:ring-1 focus:ring-[#e20074]"
|
|
1122
|
+
value={selectedValue !== undefined ? String(selectedValue) : ''}
|
|
1123
|
+
onChange={(e) => {
|
|
1124
|
+
const val = e.target.value === 'null' ? null : e.target.value;
|
|
1125
|
+
const numeric = Number(val);
|
|
1126
|
+
onSelect(val !== null && !isNaN(numeric) ? numeric : val);
|
|
1127
|
+
}}
|
|
1128
|
+
>
|
|
1129
|
+
<option value="" disabled>Select an option</option>
|
|
1130
|
+
{options.map((opt, index) => (
|
|
1131
|
+
<option key={`${String(opt.value)}-${index}`} value={String(opt.value)}>
|
|
1132
|
+
{opt.label}
|
|
1133
|
+
</option>
|
|
1134
|
+
))}
|
|
1135
|
+
</select>
|
|
1136
|
+
</div>
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1074
1139
|
|
|
1075
1140
|
return (
|
|
1076
|
-
<div>
|
|
1077
|
-
{
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1141
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
1142
|
+
{options.map((option, index) => {
|
|
1143
|
+
const isSelected = selectedValue === option.value;
|
|
1144
|
+
const isNA = option.value === null;
|
|
1145
|
+
|
|
1146
|
+
// Emoji
|
|
1147
|
+
const scaleOptionsLength = options.filter(o => o.value !== null).length;
|
|
1148
|
+
const EmojiNode =
|
|
1149
|
+
buttonType === 'emoji' && !isNA
|
|
1150
|
+
? getEmojiForIndex(index, scaleOptionsLength)
|
|
1151
|
+
: null;
|
|
1152
|
+
|
|
1153
|
+
// Star
|
|
1154
|
+
const StarNode =
|
|
1155
|
+
buttonType === 'star' && !isNA
|
|
1156
|
+
? isSelected ? CsatStarIcons.filled : CsatStarIcons.empty
|
|
1157
|
+
: null;
|
|
1158
|
+
|
|
1159
|
+
// Numbered Button
|
|
1160
|
+
const isNumbered = buttonType === 'numbered' || buttonType === 'number';
|
|
1161
|
+
|
|
1162
|
+
return (
|
|
1163
|
+
<button
|
|
1164
|
+
key={`${String(option.value)}-${index}`}
|
|
1165
|
+
type="button"
|
|
1166
|
+
onClick={() => onSelect(option.value)}
|
|
1167
|
+
className={`flex ${labelPosition === 'top' ? 'flex-col-reverse' : 'flex-col'} items-center justify-center gap-2 rounded-lg border px-5 py-4 transition-all ${
|
|
1168
|
+
isSelected
|
|
1169
|
+
? 'border-[#e20074] bg-[#fdf2f8] shadow-sm'
|
|
1170
|
+
: 'border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50'
|
|
1171
|
+
} ${StarNode ? 'border-transparent bg-transparent hover:bg-transparent shadow-none px-2 py-2' : ''}`}
|
|
1172
|
+
>
|
|
1173
|
+
<div
|
|
1174
|
+
className={`flex h-10 items-center justify-center text-3xl transition-transform ${
|
|
1175
|
+
isSelected ? 'scale-110' : 'scale-100'
|
|
1176
|
+
}`}
|
|
1088
1177
|
>
|
|
1089
|
-
{
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
)}
|
|
1096
|
-
{/* Default fallback for other buttonTypes */}
|
|
1097
|
-
{buttonType !== 'emoji' && buttonType !== 'star' && (
|
|
1098
|
-
<span>{option.label}</span>
|
|
1099
|
-
)}
|
|
1100
|
-
</button>
|
|
1101
|
-
);
|
|
1102
|
-
})}
|
|
1103
|
-
</div>
|
|
1178
|
+
{(EmojiNode ?? StarNode ?? (
|
|
1179
|
+
<span className={`text-lg ${isNumbered ? 'font-mono text-xl' : 'font-bold'} ${isSelected ? 'text-[#e20074]' : 'text-gray-700'}`}>
|
|
1180
|
+
{option.value !== null ? option.value : 'N/A'}
|
|
1181
|
+
</span>
|
|
1182
|
+
)) as React.ReactNode}
|
|
1183
|
+
</div>
|
|
1104
1184
|
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1185
|
+
{labelPosition !== 'hidden' && !StarNode && (
|
|
1186
|
+
<span
|
|
1187
|
+
className={`text-[13px] font-medium leading-tight ${
|
|
1188
|
+
isSelected ? 'text-[#e20074]' : 'text-gray-600'
|
|
1189
|
+
}`}
|
|
1190
|
+
dangerouslySetInnerHTML={{ __html: option.label }}
|
|
1191
|
+
/>
|
|
1192
|
+
)}
|
|
1193
|
+
</button>
|
|
1194
|
+
);
|
|
1195
|
+
})}
|
|
1115
1196
|
</div>
|
|
1116
1197
|
);
|
|
1117
1198
|
}
|
|
@@ -1248,52 +1329,245 @@ export default function LikertMatrixScale({ question, selectedValue = {}, onSele
|
|
|
1248
1329
|
### Logic Skeleton: `SliderMatrixScale.tsx`
|
|
1249
1330
|
|
|
1250
1331
|
```tsx
|
|
1251
|
-
import
|
|
1332
|
+
import React from 'react';
|
|
1333
|
+
import type { SliderMatrixQuestion } from '@explorer02/cfm-survey-sdk';
|
|
1334
|
+
import { CustomSliderTrack } from './CustomSliderTrack';
|
|
1252
1335
|
|
|
1253
1336
|
type SliderMatrixScaleProps = {
|
|
1254
1337
|
question: SliderMatrixQuestion;
|
|
1255
|
-
selectedValue?:
|
|
1256
|
-
onSelect: (value:
|
|
1338
|
+
selectedValue?: Record<string, number | null | 'N/A'>;
|
|
1339
|
+
onSelect: (value: Record<string, number | null | 'N/A'>) => void;
|
|
1257
1340
|
};
|
|
1258
1341
|
|
|
1259
|
-
export
|
|
1260
|
-
const
|
|
1342
|
+
export function SliderMatrixScale({ question, selectedValue = {}, onSelect }: SliderMatrixScaleProps) {
|
|
1343
|
+
const { rows, labels, ticks, tickValues, enableInputBox, displayValues, enableNotApplicable, sliderType } = question;
|
|
1344
|
+
|
|
1345
|
+
const handleSliderChange = (rowId: string, val: number | 'N/A') => {
|
|
1261
1346
|
onSelect({ ...selectedValue, [rowId]: val });
|
|
1262
1347
|
};
|
|
1263
1348
|
|
|
1349
|
+
// Assume uniform min/max/step across rows for the header
|
|
1350
|
+
const baseRow = rows[0];
|
|
1351
|
+
const min = baseRow?.min ?? 0;
|
|
1352
|
+
const max = baseRow?.max ?? 10;
|
|
1353
|
+
const step = baseRow?.step ?? 1;
|
|
1354
|
+
|
|
1355
|
+
const marks: number[] = [];
|
|
1356
|
+
if (ticks && ticks > 1) {
|
|
1357
|
+
const markStep = (max - min) / (ticks - 1);
|
|
1358
|
+
for (let i = 0; i < ticks; i++) {
|
|
1359
|
+
marks.push(Number((min + i * markStep).toFixed(2))); // Round to 2 decimal digits
|
|
1360
|
+
}
|
|
1361
|
+
} else {
|
|
1362
|
+
for (let i = min; i <= max; i += step) {
|
|
1363
|
+
marks.push(Number(i.toFixed(2)));
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const htmlStep = (ticks && ticks > 1) ? ((max - min) / (ticks - 1)) : step;
|
|
1368
|
+
|
|
1369
|
+
const hasRightText = rows.some(r => !!r.rightText);
|
|
1370
|
+
|
|
1371
|
+
const renderHeaderRow = () => {
|
|
1372
|
+
if ((!labels || labels.length === 0) && (!tickValues || tickValues.length === 0) && hasRightText) {
|
|
1373
|
+
return null;
|
|
1374
|
+
}
|
|
1375
|
+
return (
|
|
1376
|
+
<div style={{
|
|
1377
|
+
display: 'flex', width: '100%', marginBottom: '4px', alignItems: 'flex-end',
|
|
1378
|
+
padding: '0 16px', boxSizing: 'border-box'
|
|
1379
|
+
}}>
|
|
1380
|
+
{/* Left spacer for statement text */}
|
|
1381
|
+
<div style={{ flex: '0 0 25%', paddingRight: '16px' }} />
|
|
1382
|
+
|
|
1383
|
+
{/* Slider track area header */}
|
|
1384
|
+
<div style={{ flex: '1', position: 'relative' }}>
|
|
1385
|
+
{/* Labels row */}
|
|
1386
|
+
{labels && labels.length > 0 && (
|
|
1387
|
+
<div style={{
|
|
1388
|
+
position: 'relative', display: 'flex', justifyContent: 'space-between',
|
|
1389
|
+
margin: '0 16px', marginBottom: '16px', color: '#111827', fontSize: '14px', fontWeight: 500
|
|
1390
|
+
}}>
|
|
1391
|
+
{labels.map((lbl, idx) => (
|
|
1392
|
+
<div key={idx} style={{ width: 0, display: 'flex', justifyContent: 'center' }}>
|
|
1393
|
+
<span style={{ whiteSpace: 'nowrap' }}>{lbl}</span>
|
|
1394
|
+
</div>
|
|
1395
|
+
))}
|
|
1396
|
+
</div>
|
|
1397
|
+
)}
|
|
1398
|
+
|
|
1399
|
+
{/* Numbers row */}
|
|
1400
|
+
<div style={{
|
|
1401
|
+
position: 'relative', margin: '0 16px', marginBottom: '8px',
|
|
1402
|
+
color: '#4b5563', fontSize: '14px', fontWeight: 400, height: '28px'
|
|
1403
|
+
}}>
|
|
1404
|
+
{tickValues && tickValues.length > 0 ? (
|
|
1405
|
+
tickValues.map((tv, idx) => {
|
|
1406
|
+
const percentage = max !== min ? ((tv.value - min) / (max - min)) * 100 : 0;
|
|
1407
|
+
const sliceWidth = tickValues.length > 1 ? 100 / (tickValues.length - 1) : 100;
|
|
1408
|
+
return (
|
|
1409
|
+
<div key={idx} style={{
|
|
1410
|
+
position: 'absolute', left: `${percentage}%`, transform: 'translateX(-50%)',
|
|
1411
|
+
width: `${sliceWidth}%`, display: 'flex', justifyContent: 'center'
|
|
1412
|
+
}}>
|
|
1413
|
+
{sliderType === 'graphics' ? (
|
|
1414
|
+
<div style={{ wordBreak: 'break-word', textAlign: 'center', lineHeight: '1.2', fontWeight: 500, color: '#4b5563', fontSize: '13px' }}>
|
|
1415
|
+
{tv.label}
|
|
1416
|
+
</div>
|
|
1417
|
+
) : (
|
|
1418
|
+
<div style={{
|
|
1419
|
+
backgroundColor: tv.color, color: 'white', padding: '4px 8px', borderRadius: '4px',
|
|
1420
|
+
fontWeight: 600, wordBreak: 'break-word', textAlign: 'center', lineHeight: '1.2',
|
|
1421
|
+
boxShadow: '0 1px 2px rgba(0,0,0,0.1)'
|
|
1422
|
+
}}>
|
|
1423
|
+
{tv.label}
|
|
1424
|
+
</div>
|
|
1425
|
+
)}
|
|
1426
|
+
</div>
|
|
1427
|
+
);
|
|
1428
|
+
})
|
|
1429
|
+
) : (
|
|
1430
|
+
marks.map((m, idx) => {
|
|
1431
|
+
const percentage = max !== min ? ((m - min) / (max - min)) * 100 : 0;
|
|
1432
|
+
const sliceWidth = marks.length > 1 ? 100 / (marks.length - 1) : 100;
|
|
1433
|
+
return (
|
|
1434
|
+
<div key={idx} style={{
|
|
1435
|
+
position: 'absolute', left: `${percentage}%`, transform: 'translateX(-50%)',
|
|
1436
|
+
width: `${sliceWidth}%`, display: 'flex', justifyContent: 'center'
|
|
1437
|
+
}}>
|
|
1438
|
+
<span style={{ wordBreak: 'break-word', textAlign: 'center', lineHeight: '1.2' }}>{m}</span>
|
|
1439
|
+
</div>
|
|
1440
|
+
);
|
|
1441
|
+
})
|
|
1442
|
+
)}
|
|
1443
|
+
</div>
|
|
1444
|
+
</div>
|
|
1445
|
+
|
|
1446
|
+
{/* Right Statement Header Spacer */}
|
|
1447
|
+
{hasRightText && <div style={{ flex: '0 0 25%', paddingLeft: '16px' }} />}
|
|
1448
|
+
|
|
1449
|
+
{/* Right spacers for input box and N/A checkbox if enabled */}
|
|
1450
|
+
{enableInputBox && <div style={{ width: '80px', marginLeft: '16px' }} />}
|
|
1451
|
+
{enableNotApplicable && (
|
|
1452
|
+
<div style={{ width: '40px', marginLeft: '16px', textAlign: 'center', fontSize: '13px', fontWeight: 600, color: '#4b5563', paddingBottom: '8px' }}>
|
|
1453
|
+
N/A
|
|
1454
|
+
</div>
|
|
1455
|
+
)}
|
|
1456
|
+
</div>
|
|
1457
|
+
);
|
|
1458
|
+
};
|
|
1459
|
+
|
|
1264
1460
|
return (
|
|
1265
|
-
<div>
|
|
1266
|
-
{
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1461
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
1462
|
+
{renderHeaderRow()}
|
|
1463
|
+
|
|
1464
|
+
{rows.map((row) => {
|
|
1465
|
+
const rowVal = selectedValue[row.id];
|
|
1466
|
+
const isNa = rowVal === 'N/A';
|
|
1467
|
+
const val = (typeof rowVal === 'number') ? rowVal : (isNa ? row.min : (row.defaultValue ?? row.min));
|
|
1468
|
+
|
|
1271
1469
|
return (
|
|
1272
|
-
<div key={row.id}
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
onChange={(e) => handleSliderChange(row.id, Number(e.target.value))}
|
|
1282
|
-
style={{ zIndex: 10, width: '100%', position: 'absolute', opacity: 0 }}
|
|
1283
|
-
/* STYLE: Make the native input invisible but keep it on top for interaction */
|
|
1284
|
-
/>
|
|
1285
|
-
{/* STYLE: Build a custom visible track and thumb behind the transparent native input */}
|
|
1286
|
-
<div style={{ width: '100%', height: '8px', backgroundColor: '#e5e7eb' }}>
|
|
1287
|
-
<div style={{ width: `${((currentVal - row.min) / (row.max - row.min)) * 100}%`, height: '100%', backgroundColor: 'var(--brand-color)' }} />
|
|
1288
|
-
</div>
|
|
1470
|
+
<div key={row.id} style={{
|
|
1471
|
+
display: 'flex', width: '100%', alignItems: 'center',
|
|
1472
|
+
backgroundColor: '#f3f4f6', borderRadius: '12px', padding: '16px', boxSizing: 'border-box'
|
|
1473
|
+
}}>
|
|
1474
|
+
{/* Statement Text */}
|
|
1475
|
+
<div style={{ flex: '0 0 25%', paddingRight: '16px', fontSize: '15px', color: '#111827', wordWrap: 'break-word', display: 'flex', alignItems: 'center' }}>
|
|
1476
|
+
{row.text !== 'Question' && row.text !== '' ? (
|
|
1477
|
+
<span dangerouslySetInnerHTML={{ __html: row.text }} />
|
|
1478
|
+
) : null}
|
|
1289
1479
|
</div>
|
|
1290
1480
|
|
|
1291
|
-
{
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1481
|
+
{/* Slider */}
|
|
1482
|
+
<div style={{ flex: '1' }}>
|
|
1483
|
+
<CustomSliderTrack
|
|
1484
|
+
min={row.min}
|
|
1485
|
+
max={row.max}
|
|
1486
|
+
htmlStep={htmlStep}
|
|
1487
|
+
value={val}
|
|
1488
|
+
disabled={isNa}
|
|
1489
|
+
displayValues={displayValues}
|
|
1490
|
+
hasSelectedValue={typeof rowVal === 'number'}
|
|
1491
|
+
sliderType={sliderType}
|
|
1492
|
+
ticks={ticks}
|
|
1493
|
+
onChange={(v) => handleSliderChange(row.id, v)}
|
|
1296
1494
|
/>
|
|
1495
|
+
</div>
|
|
1496
|
+
|
|
1497
|
+
{/* Right Statement Text (Bipolar) */}
|
|
1498
|
+
{row.rightText && (
|
|
1499
|
+
<div style={{ flex: '0 0 25%', paddingLeft: '16px', fontSize: '15px', color: '#111827', wordWrap: 'break-word', display: 'flex', alignItems: 'center', textAlign: 'right', justifyContent: 'flex-end' }}>
|
|
1500
|
+
<span dangerouslySetInnerHTML={{ __html: row.rightText }} />
|
|
1501
|
+
</div>
|
|
1502
|
+
)}
|
|
1503
|
+
|
|
1504
|
+
{/* Input Box */}
|
|
1505
|
+
{enableInputBox && (
|
|
1506
|
+
<div style={{
|
|
1507
|
+
marginLeft: '16px', width: '80px', display: 'flex', alignItems: 'stretch',
|
|
1508
|
+
border: '1px solid #d1d5db', borderRadius: '8px', overflow: 'hidden',
|
|
1509
|
+
backgroundColor: isNa ? '#e5e7eb' : '#fff', boxSizing: 'border-box'
|
|
1510
|
+
}}>
|
|
1511
|
+
<input
|
|
1512
|
+
type="text"
|
|
1513
|
+
value={isNa ? '' : Math.round(Number(val))}
|
|
1514
|
+
disabled={isNa}
|
|
1515
|
+
onChange={(e) => {
|
|
1516
|
+
if (e.target.value === '') return;
|
|
1517
|
+
let num = Number(e.target.value);
|
|
1518
|
+
if (!isNaN(num)) {
|
|
1519
|
+
if (num < row.min) num = row.min;
|
|
1520
|
+
if (num > row.max) num = row.max;
|
|
1521
|
+
handleSliderChange(row.id, num);
|
|
1522
|
+
}
|
|
1523
|
+
}}
|
|
1524
|
+
style={{
|
|
1525
|
+
width: '50px', padding: '8px', border: 'none',
|
|
1526
|
+
fontSize: '14px', outline: 'none', backgroundColor: 'transparent', textAlign: 'center'
|
|
1527
|
+
}}
|
|
1528
|
+
/>
|
|
1529
|
+
<div style={{ display: 'flex', flexDirection: 'column', borderLeft: '1px solid #d1d5db', backgroundColor: '#f9fafb', width: '30px' }}>
|
|
1530
|
+
<button
|
|
1531
|
+
disabled={isNa}
|
|
1532
|
+
onClick={() => {
|
|
1533
|
+
const num = Number((Number(val) + htmlStep).toFixed(2));
|
|
1534
|
+
if (num <= row.max) handleSliderChange(row.id, num);
|
|
1535
|
+
}}
|
|
1536
|
+
style={{ flex: 1, border: 'none', background: 'none', cursor: isNa ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#4b5563' }}
|
|
1537
|
+
>
|
|
1538
|
+
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 5L5 1L9 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
|
1539
|
+
</button>
|
|
1540
|
+
<div style={{ height: '1px', backgroundColor: '#d1d5db', width: '100%' }} />
|
|
1541
|
+
<button
|
|
1542
|
+
disabled={isNa}
|
|
1543
|
+
onClick={() => {
|
|
1544
|
+
const num = Number((Number(val) - htmlStep).toFixed(2));
|
|
1545
|
+
if (num >= row.min) handleSliderChange(row.id, num);
|
|
1546
|
+
}}
|
|
1547
|
+
style={{ flex: 1, border: 'none', background: 'none', cursor: isNa ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#4b5563' }}
|
|
1548
|
+
>
|
|
1549
|
+
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
|
1550
|
+
</button>
|
|
1551
|
+
</div>
|
|
1552
|
+
</div>
|
|
1553
|
+
)}
|
|
1554
|
+
|
|
1555
|
+
{/* N/A Checkbox */}
|
|
1556
|
+
{enableNotApplicable && (
|
|
1557
|
+
<div style={{ marginLeft: '16px', width: '40px', display: 'flex', justifyContent: 'center' }}>
|
|
1558
|
+
<input
|
|
1559
|
+
type="checkbox"
|
|
1560
|
+
checked={isNa}
|
|
1561
|
+
onChange={(e) => {
|
|
1562
|
+
if (e.target.checked) {
|
|
1563
|
+
handleSliderChange(row.id, 'N/A');
|
|
1564
|
+
} else {
|
|
1565
|
+
handleSliderChange(row.id, row.defaultValue ?? row.min);
|
|
1566
|
+
}
|
|
1567
|
+
}}
|
|
1568
|
+
style={{ width: '20px', height: '20px', cursor: 'pointer' }}
|
|
1569
|
+
/>
|
|
1570
|
+
</div>
|
|
1297
1571
|
)}
|
|
1298
1572
|
</div>
|
|
1299
1573
|
);
|
|
@@ -1305,6 +1579,156 @@ export default function SliderMatrixScale({ question, selectedValue = {}, onSele
|
|
|
1305
1579
|
|
|
1306
1580
|
---
|
|
1307
1581
|
|
|
1582
|
+
### Logic Skeleton: `CustomSliderTrack.tsx`
|
|
1583
|
+
|
|
1584
|
+
```tsx
|
|
1585
|
+
import React from 'react';
|
|
1586
|
+
import { getEmojiForIndex } from '@explorer02/cfm-survey-sdk';
|
|
1587
|
+
|
|
1588
|
+
type CustomSliderTrackProps = {
|
|
1589
|
+
min: number;
|
|
1590
|
+
max: number;
|
|
1591
|
+
htmlStep: number;
|
|
1592
|
+
value: number;
|
|
1593
|
+
disabled: boolean;
|
|
1594
|
+
displayValues?: boolean;
|
|
1595
|
+
hasSelectedValue: boolean;
|
|
1596
|
+
sliderType?: 'graphics';
|
|
1597
|
+
ticks?: number;
|
|
1598
|
+
reverseScaleOrder?: boolean;
|
|
1599
|
+
onChange: (v: number) => void;
|
|
1600
|
+
};
|
|
1601
|
+
|
|
1602
|
+
export function CustomSliderTrack({
|
|
1603
|
+
min,
|
|
1604
|
+
max,
|
|
1605
|
+
htmlStep,
|
|
1606
|
+
value,
|
|
1607
|
+
disabled,
|
|
1608
|
+
displayValues,
|
|
1609
|
+
hasSelectedValue,
|
|
1610
|
+
sliderType,
|
|
1611
|
+
ticks,
|
|
1612
|
+
reverseScaleOrder,
|
|
1613
|
+
onChange
|
|
1614
|
+
}: CustomSliderTrackProps) {
|
|
1615
|
+
const [isHovered, setIsHovered] = React.useState(false);
|
|
1616
|
+
const [isDragging, setIsDragging] = React.useState(false);
|
|
1617
|
+
|
|
1618
|
+
const percentage = ((value - min) / (max - min)) * 100;
|
|
1619
|
+
|
|
1620
|
+
// Use gray color if disabled, otherwise use pink/magenta theme color
|
|
1621
|
+
const themeColor = disabled ? '#9ca3af' : '#e20074';
|
|
1622
|
+
|
|
1623
|
+
return (
|
|
1624
|
+
<div
|
|
1625
|
+
style={{ position: 'relative', height: '32px', display: 'flex', alignItems: 'center', margin: '0 16px', opacity: disabled ? 0.5 : 1 }}
|
|
1626
|
+
onMouseEnter={() => !disabled && setIsHovered(true)}
|
|
1627
|
+
onMouseLeave={() => { setIsHovered(false); setIsDragging(false); }}
|
|
1628
|
+
>
|
|
1629
|
+
<div style={{
|
|
1630
|
+
position: 'absolute', top: '50%', left: 0, right: 0, transform: 'translateY(-50%)',
|
|
1631
|
+
height: '4px', borderRadius: '2px', backgroundColor: '#e5e7eb'
|
|
1632
|
+
}} />
|
|
1633
|
+
|
|
1634
|
+
<div style={{
|
|
1635
|
+
position: 'absolute', top: '50%', left: 0, width: `${percentage}%`, transform: 'translateY(-50%)',
|
|
1636
|
+
height: '4px', borderRadius: '2px', backgroundColor: themeColor,
|
|
1637
|
+
transition: isDragging ? 'none' : 'width 0.15s ease-in-out'
|
|
1638
|
+
}} />
|
|
1639
|
+
|
|
1640
|
+
{/* Ticks on track */}
|
|
1641
|
+
{ticks && ticks > 1 && Array.from({ length: ticks }).map((_, idx) => {
|
|
1642
|
+
const tickPct = (idx / (ticks - 1)) * 100;
|
|
1643
|
+
return (
|
|
1644
|
+
<div
|
|
1645
|
+
key={idx}
|
|
1646
|
+
style={{
|
|
1647
|
+
position: 'absolute',
|
|
1648
|
+
top: '50%',
|
|
1649
|
+
left: `${tickPct}%`,
|
|
1650
|
+
transform: 'translate(-50%, -50%)',
|
|
1651
|
+
width: '1px',
|
|
1652
|
+
height: '8px',
|
|
1653
|
+
backgroundColor: '#d1d5db',
|
|
1654
|
+
pointerEvents: 'none'
|
|
1655
|
+
}}
|
|
1656
|
+
/>
|
|
1657
|
+
);
|
|
1658
|
+
})}
|
|
1659
|
+
|
|
1660
|
+
<input
|
|
1661
|
+
type="range"
|
|
1662
|
+
min={min} max={max} step={htmlStep} value={value}
|
|
1663
|
+
disabled={disabled}
|
|
1664
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
1665
|
+
onMouseDown={() => !disabled && setIsDragging(true)}
|
|
1666
|
+
onMouseUp={() => setIsDragging(false)}
|
|
1667
|
+
onTouchStart={() => !disabled && setIsDragging(true)}
|
|
1668
|
+
onTouchEnd={() => setIsDragging(false)}
|
|
1669
|
+
style={{
|
|
1670
|
+
position: 'absolute', top: '50%', left: '-16px', right: '-16px', width: 'calc(100% + 32px)', height: '100%',
|
|
1671
|
+
transform: 'translateY(-50%)', opacity: 0, cursor: disabled ? 'not-allowed' : 'pointer', zIndex: 10, margin: 0,
|
|
1672
|
+
}}
|
|
1673
|
+
/>
|
|
1674
|
+
|
|
1675
|
+
{sliderType === 'graphics' ? (
|
|
1676
|
+
<div style={{
|
|
1677
|
+
position: 'absolute', top: '50%', left: `${percentage}%`,
|
|
1678
|
+
transform: 'translate(-50%, -50%)',
|
|
1679
|
+
transition: isDragging ? 'none' : 'left 0.15s ease-in-out',
|
|
1680
|
+
pointerEvents: 'none', zIndex: 11,
|
|
1681
|
+
opacity: disabled ? 0.5 : 1
|
|
1682
|
+
}}>
|
|
1683
|
+
{getEmojiForIndex(
|
|
1684
|
+
reverseScaleOrder
|
|
1685
|
+
? (ticks || 10) - 1 - Math.min((ticks || 10) - 1, Math.max(0, Math.round(((value - min) / (max - min)) * ((ticks || 10) - 1))))
|
|
1686
|
+
: Math.min((ticks || 10) - 1, Math.max(0, Math.round(((value - min) / (max - min)) * ((ticks || 10) - 1)))),
|
|
1687
|
+
ticks || 10
|
|
1688
|
+
) as React.ReactNode}
|
|
1689
|
+
</div>
|
|
1690
|
+
) : (
|
|
1691
|
+
<div style={{
|
|
1692
|
+
position: 'absolute', top: '50%', left: `${percentage}%`,
|
|
1693
|
+
transform: 'translate(-50%, -50%)',
|
|
1694
|
+
width: '18px',
|
|
1695
|
+
height: '18px',
|
|
1696
|
+
borderRadius: '50%',
|
|
1697
|
+
backgroundColor: '#ffffff',
|
|
1698
|
+
border: `2.5px solid ${themeColor}`,
|
|
1699
|
+
boxShadow: (isHovered || isDragging) && !disabled
|
|
1700
|
+
? '0 0 0 6px rgba(226, 0, 116, 0.2), 0 1px 3px rgba(0,0,0,0.2)'
|
|
1701
|
+
: '0 1px 3px rgba(0,0,0,0.2)',
|
|
1702
|
+
pointerEvents: 'none',
|
|
1703
|
+
transition: isDragging ? 'none' : 'left 0.15s ease-in-out, box-shadow 0.15s, border-color 0.2s',
|
|
1704
|
+
boxSizing: 'border-box'
|
|
1705
|
+
}} />
|
|
1706
|
+
)}
|
|
1707
|
+
|
|
1708
|
+
{(isHovered || isDragging) && !disabled && (
|
|
1709
|
+
<div style={{
|
|
1710
|
+
position: 'absolute', bottom: '100%', left: `${percentage}%`,
|
|
1711
|
+
transform: 'translate(-50%, -10px)',
|
|
1712
|
+
backgroundColor: '#1f2937', color: '#fff', fontSize: '13px', fontWeight: 600,
|
|
1713
|
+
padding: '4px 10px', borderRadius: '6px', pointerEvents: 'none',
|
|
1714
|
+
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
|
1715
|
+
zIndex: 20
|
|
1716
|
+
}}>
|
|
1717
|
+
{Math.round(value)}
|
|
1718
|
+
<div style={{
|
|
1719
|
+
position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
|
1720
|
+
borderWidth: '5px', borderStyle: 'solid',
|
|
1721
|
+
borderColor: '#1f2937 transparent transparent transparent'
|
|
1722
|
+
}} />
|
|
1723
|
+
</div>
|
|
1724
|
+
)}
|
|
1725
|
+
</div>
|
|
1726
|
+
);
|
|
1727
|
+
}
|
|
1728
|
+
```
|
|
1729
|
+
|
|
1730
|
+
---
|
|
1731
|
+
|
|
1308
1732
|
### Logic Skeleton: `FileUploadScale.tsx`
|
|
1309
1733
|
|
|
1310
1734
|
```tsx
|
|
@@ -1401,24 +1825,35 @@ type LanguageSelectorProps = {
|
|
|
1401
1825
|
onChange: (languageCode: string) => void;
|
|
1402
1826
|
};
|
|
1403
1827
|
|
|
1404
|
-
export default function LanguageSelector({
|
|
1405
|
-
|
|
1406
|
-
|
|
1828
|
+
export default function LanguageSelector({
|
|
1829
|
+
languages,
|
|
1830
|
+
selectedLanguage,
|
|
1831
|
+
onChange,
|
|
1832
|
+
}: LanguageSelectorProps) {
|
|
1833
|
+
if (!languages || languages.length <= 1) {
|
|
1834
|
+
return null;
|
|
1835
|
+
}
|
|
1407
1836
|
|
|
1408
1837
|
return (
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1838
|
+
<div className="flex justify-end mb-6 mt-4">
|
|
1839
|
+
<div className="relative">
|
|
1840
|
+
<select
|
|
1841
|
+
value={selectedLanguage}
|
|
1842
|
+
onChange={(e) => onChange(e.target.value)}
|
|
1843
|
+
className="appearance-none rounded-lg border border-[#2563eb] bg-white py-2 pl-4 pr-10 text-sm font-semibold text-[#2563eb] focus:outline-none focus:ring-2 focus:ring-[#2563eb] cursor-pointer"
|
|
1844
|
+
>
|
|
1845
|
+
{languages.map((lang) => (
|
|
1846
|
+
<option key={lang.code} value={lang.code} className="text-gray-900">
|
|
1847
|
+
{lang.name}
|
|
1848
|
+
</option>
|
|
1849
|
+
))}
|
|
1850
|
+
</select>
|
|
1851
|
+
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
|
1852
|
+
<svg className="h-4 w-4 text-[#2563eb]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1853
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
|
1854
|
+
</svg>
|
|
1855
|
+
</div>
|
|
1856
|
+
</div>
|
|
1422
1857
|
</div>
|
|
1423
1858
|
);
|
|
1424
1859
|
}
|