@explorer02/cfm-survey-sdk 0.1.2 → 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 +574 -129
package/package.json
CHANGED
package/templates/AGENT.md
CHANGED
|
@@ -72,9 +72,14 @@ npm install @explorer02/cfm-survey-sdk @tanstack/react-query
|
|
|
72
72
|
> ```
|
|
73
73
|
> 3. Tell the client in chat: *"I am launching a premium interactive setup page in your browser. Please customize your survey preferences and logo there, then click Complete Setup."*
|
|
74
74
|
> 4. The wizard will open in the browser. The client will answer the questions and upload their logo there. **You do not need to do anything while the wizard is running — just wait.**
|
|
75
|
-
> 5. When `npx cfm-sdk init` exits (the terminal returns to a prompt), **immediately** read `./survey-config.json` and proceed
|
|
76
|
-
> 6. **Do NOT ask the client to invoke you again.** Continue automatically.
|
|
75
|
+
> 5. When `npx cfm-sdk init` exits (the terminal returns to a prompt), **immediately** read `./survey-config.json` and proceed.
|
|
77
76
|
|
|
77
|
+
### Phase 2b: Request Instance ID (STRICT REQUIREMENT)
|
|
78
|
+
|
|
79
|
+
Before proceeding to Phase 3, you **MUST STRICTLY ASK THE CLIENT** for the Survey Instance ID input:
|
|
80
|
+
*"Please provide the Instance ID (JWT Token) for your survey so I can configure the fetch logic."*
|
|
81
|
+
|
|
82
|
+
**DO NOT proceed with code generation** or build the SurveyPage until the client provides this Instance ID in the chat!
|
|
78
83
|
|
|
79
84
|
### Phase 3: Configuration Setup
|
|
80
85
|
|
|
@@ -627,12 +632,17 @@ const SURVEY_PLACEHOLDERS = {
|
|
|
627
632
|
export default function SurveyPage() {
|
|
628
633
|
const [selectedLanguage, setSelectedLanguage] = useState<string | undefined>("");
|
|
629
634
|
|
|
635
|
+
// ⚠️ MANDATORY: Pass the exact Instance ID that the client provided to you in the chat!
|
|
636
|
+
// The SDK will only fetch the survey data once this valid instance ID is passed.
|
|
637
|
+
const INPUT_OPTIONS = {
|
|
638
|
+
instanceId: '<<REPLACE_WITH_CLIENT_PROVIDED_INSTANCE_ID>>',
|
|
639
|
+
language: selectedLanguage,
|
|
640
|
+
debug: false,
|
|
641
|
+
placeholders: SURVEY_PLACEHOLDERS,
|
|
642
|
+
};
|
|
643
|
+
|
|
630
644
|
const { surveyQueryResults, submitSurveyResults, state, onAction } = useSurveySDK({
|
|
631
|
-
options:
|
|
632
|
-
language: selectedLanguage,
|
|
633
|
-
debug: false,
|
|
634
|
-
placeholders: SURVEY_PLACEHOLDERS,
|
|
635
|
-
},
|
|
645
|
+
options: INPUT_OPTIONS
|
|
636
646
|
});
|
|
637
647
|
|
|
638
648
|
const survey = surveyQueryResults.data;
|
|
@@ -734,16 +744,14 @@ export default function SurveyPage() {
|
|
|
734
744
|
)}
|
|
735
745
|
|
|
736
746
|
{/* Navigation Buttons */}
|
|
737
|
-
<
|
|
738
|
-
{/* Back Button — only show if enabled and not on the first page */}
|
|
747
|
+
<div className="mt-8 flex items-center justify-start gap-4">
|
|
739
748
|
{state.currentPageIndex > 0 && surveyConfig.layout.showBackButton && (
|
|
740
749
|
<button
|
|
741
750
|
type="button"
|
|
742
751
|
onClick={() => onAction({ type: 'PREVIOUS' })}
|
|
743
|
-
|
|
752
|
+
className="rounded-md border border-[#e20074] px-6 py-2 text-sm font-semibold text-[#e20074] transition-all hover:bg-[#fdf2f8]"
|
|
744
753
|
>
|
|
745
|
-
{
|
|
746
|
-
Back
|
|
754
|
+
{survey.language?.startsWith('de') ? 'Zurück' : 'Back'}
|
|
747
755
|
</button>
|
|
748
756
|
)}
|
|
749
757
|
|
|
@@ -753,17 +761,22 @@ export default function SurveyPage() {
|
|
|
753
761
|
type="button"
|
|
754
762
|
onClick={() => onAction({ type: 'NEXT' })}
|
|
755
763
|
disabled={submitSurveyResults.isLoading}
|
|
756
|
-
|
|
757
|
-
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"
|
|
758
765
|
>
|
|
759
766
|
{submitSurveyResults.isLoading
|
|
760
|
-
? '
|
|
767
|
+
? survey.language?.startsWith('de')
|
|
768
|
+
? 'Wird gesendet...'
|
|
769
|
+
: 'Submitting...'
|
|
761
770
|
: state.currentPageIndex < survey.pages.length - 1
|
|
762
|
-
? '
|
|
763
|
-
|
|
771
|
+
? survey.language?.startsWith('de')
|
|
772
|
+
? 'Weiter'
|
|
773
|
+
: 'Next'
|
|
774
|
+
: survey.language?.startsWith('de')
|
|
775
|
+
? 'Absenden'
|
|
776
|
+
: 'Submit'}
|
|
764
777
|
</button>
|
|
765
778
|
)}
|
|
766
|
-
</
|
|
779
|
+
</div>
|
|
767
780
|
|
|
768
781
|
{/* Footer — pass links/data as needed */}
|
|
769
782
|
<Footer />
|
|
@@ -1012,34 +1025,73 @@ export default function Question({ question, selectedValue, validationError, onS
|
|
|
1012
1025
|
### Logic Skeleton: `RatingScale.tsx` — Rating Input
|
|
1013
1026
|
|
|
1014
1027
|
```tsx
|
|
1028
|
+
import type { SurveyOption, AnswerValue } from '@explorer02/cfm-survey-sdk';
|
|
1029
|
+
|
|
1015
1030
|
type RatingScaleProps = {
|
|
1016
1031
|
questionId: string;
|
|
1017
|
-
options:
|
|
1018
|
-
selectedValue?:
|
|
1019
|
-
onSelect: (value:
|
|
1032
|
+
options: SurveyOption[];
|
|
1033
|
+
selectedValue?: AnswerValue;
|
|
1034
|
+
onSelect: (value: AnswerValue) => void;
|
|
1020
1035
|
};
|
|
1021
1036
|
|
|
1022
1037
|
export default function RatingScale({ questionId, options, selectedValue, onSelect }: RatingScaleProps) {
|
|
1023
1038
|
return (
|
|
1024
|
-
<div>
|
|
1025
|
-
{/*
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
<
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
>
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
}
|
|
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>
|
|
1043
1095
|
</div>
|
|
1044
1096
|
);
|
|
1045
1097
|
}
|
|
@@ -1051,57 +1103,96 @@ export default function RatingScale({ questionId, options, selectedValue, onSele
|
|
|
1051
1103
|
|
|
1052
1104
|
```tsx
|
|
1053
1105
|
import type { CsatQuestion } from '@explorer02/cfm-survey-sdk';
|
|
1054
|
-
import {
|
|
1106
|
+
import { CsatEmojiMapping, CsatStarIcons, getEmojiForIndex } from '@explorer02/cfm-survey-sdk';
|
|
1055
1107
|
|
|
1056
1108
|
type CsatScaleProps = {
|
|
1057
1109
|
question: CsatQuestion;
|
|
1058
|
-
selectedValue
|
|
1110
|
+
selectedValue?: string | number | null;
|
|
1059
1111
|
onSelect: (value: string | number | null) => void;
|
|
1060
1112
|
};
|
|
1061
1113
|
|
|
1062
1114
|
export default function CsatScale({ question, selectedValue, onSelect }: CsatScaleProps) {
|
|
1063
|
-
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
|
+
}
|
|
1064
1139
|
|
|
1065
1140
|
return (
|
|
1066
|
-
<div>
|
|
1067
|
-
{
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
+
}`}
|
|
1078
1177
|
>
|
|
1079
|
-
{
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
)}
|
|
1086
|
-
{/* Default fallback for other buttonTypes */}
|
|
1087
|
-
{buttonType !== 'emoji' && buttonType !== 'star' && (
|
|
1088
|
-
<span>{option.label}</span>
|
|
1089
|
-
)}
|
|
1090
|
-
</button>
|
|
1091
|
-
);
|
|
1092
|
-
})}
|
|
1093
|
-
</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>
|
|
1094
1184
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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
|
+
})}
|
|
1105
1196
|
</div>
|
|
1106
1197
|
);
|
|
1107
1198
|
}
|
|
@@ -1238,52 +1329,245 @@ export default function LikertMatrixScale({ question, selectedValue = {}, onSele
|
|
|
1238
1329
|
### Logic Skeleton: `SliderMatrixScale.tsx`
|
|
1239
1330
|
|
|
1240
1331
|
```tsx
|
|
1241
|
-
import
|
|
1332
|
+
import React from 'react';
|
|
1333
|
+
import type { SliderMatrixQuestion } from '@explorer02/cfm-survey-sdk';
|
|
1334
|
+
import { CustomSliderTrack } from './CustomSliderTrack';
|
|
1242
1335
|
|
|
1243
1336
|
type SliderMatrixScaleProps = {
|
|
1244
1337
|
question: SliderMatrixQuestion;
|
|
1245
|
-
selectedValue?:
|
|
1246
|
-
onSelect: (value:
|
|
1338
|
+
selectedValue?: Record<string, number | null | 'N/A'>;
|
|
1339
|
+
onSelect: (value: Record<string, number | null | 'N/A'>) => void;
|
|
1247
1340
|
};
|
|
1248
1341
|
|
|
1249
|
-
export
|
|
1250
|
-
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') => {
|
|
1251
1346
|
onSelect({ ...selectedValue, [rowId]: val });
|
|
1252
1347
|
};
|
|
1253
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
|
+
|
|
1254
1460
|
return (
|
|
1255
|
-
<div>
|
|
1256
|
-
{
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
+
|
|
1261
1469
|
return (
|
|
1262
|
-
<div key={row.id}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
onChange={(e) => handleSliderChange(row.id, Number(e.target.value))}
|
|
1272
|
-
style={{ zIndex: 10, width: '100%', position: 'absolute', opacity: 0 }}
|
|
1273
|
-
/* STYLE: Make the native input invisible but keep it on top for interaction */
|
|
1274
|
-
/>
|
|
1275
|
-
{/* STYLE: Build a custom visible track and thumb behind the transparent native input */}
|
|
1276
|
-
<div style={{ width: '100%', height: '8px', backgroundColor: '#e5e7eb' }}>
|
|
1277
|
-
<div style={{ width: `${((currentVal - row.min) / (row.max - row.min)) * 100}%`, height: '100%', backgroundColor: 'var(--brand-color)' }} />
|
|
1278
|
-
</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}
|
|
1279
1479
|
</div>
|
|
1280
1480
|
|
|
1281
|
-
{
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
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)}
|
|
1286
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>
|
|
1287
1571
|
)}
|
|
1288
1572
|
</div>
|
|
1289
1573
|
);
|
|
@@ -1295,6 +1579,156 @@ export default function SliderMatrixScale({ question, selectedValue = {}, onSele
|
|
|
1295
1579
|
|
|
1296
1580
|
---
|
|
1297
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
|
+
|
|
1298
1732
|
### Logic Skeleton: `FileUploadScale.tsx`
|
|
1299
1733
|
|
|
1300
1734
|
```tsx
|
|
@@ -1391,24 +1825,35 @@ type LanguageSelectorProps = {
|
|
|
1391
1825
|
onChange: (languageCode: string) => void;
|
|
1392
1826
|
};
|
|
1393
1827
|
|
|
1394
|
-
export default function LanguageSelector({
|
|
1395
|
-
|
|
1396
|
-
|
|
1828
|
+
export default function LanguageSelector({
|
|
1829
|
+
languages,
|
|
1830
|
+
selectedLanguage,
|
|
1831
|
+
onChange,
|
|
1832
|
+
}: LanguageSelectorProps) {
|
|
1833
|
+
if (!languages || languages.length <= 1) {
|
|
1834
|
+
return null;
|
|
1835
|
+
}
|
|
1397
1836
|
|
|
1398
1837
|
return (
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
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>
|
|
1412
1857
|
</div>
|
|
1413
1858
|
);
|
|
1414
1859
|
}
|