@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/templates/AGENT.md +574 -129
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@explorer02/cfm-survey-sdk",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -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 to Phase 3.
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
- <nav>
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
- /* STYLE: Design a secondary/outline button matching the mockup. */
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
- {/* STYLE: Use client's preferred label (e.g., "Back", "Previous", "Zurück") */}
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
- /* STYLE: Design a primary action button matching the mockup.
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
- ? 'Submitting...'
767
+ ? survey.language?.startsWith('de')
768
+ ? 'Wird gesendet...'
769
+ : 'Submitting...'
761
770
  : state.currentPageIndex < survey.pages.length - 1
762
- ? 'Next'
763
- : 'Submit'}
771
+ ? survey.language?.startsWith('de')
772
+ ? 'Weiter'
773
+ : 'Next'
774
+ : survey.language?.startsWith('de')
775
+ ? 'Absenden'
776
+ : 'Submit'}
764
777
  </button>
765
778
  )}
766
- </nav>
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: { label: string; value: string | number; color?: string }[];
1018
- selectedValue?: string | number;
1019
- onSelect: (value: string | number) => void;
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
- {/* STYLE: Design the rating scale layout to match the mockup.
1026
- Common patterns: horizontal badge row, numbered circles, star rating, slider.
1027
- Each option.color (if present) should be used as the badge/indicator background color. */}
1028
- {options.map(option => {
1029
- const isSelected = selectedValue === option.value;
1030
- return (
1031
- <button
1032
- key={option.value}
1033
- type="button"
1034
- onClick={() => onSelect(option.value)}
1035
- /* STYLE: Design selected vs. unselected states.
1036
- If option.color is available, use it: style={{ backgroundColor: option.color }}
1037
- Must be clearly distinguishable which option is selected. */
1038
- >
1039
- {option.label}
1040
- </button>
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 { getEmojiForIndex, CsatStarIcons } from '@explorer02/cfm-survey-sdk';
1106
+ import { CsatEmojiMapping, CsatStarIcons, getEmojiForIndex } from '@explorer02/cfm-survey-sdk';
1055
1107
 
1056
1108
  type CsatScaleProps = {
1057
1109
  question: CsatQuestion;
1058
- selectedValue: string | number | null;
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 { options, buttonType, hasNotApplicable, reverseScaleOrder } = question;
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
- {/* STYLE: Design a horizontal layout for CSAT options. */}
1068
- <div>
1069
- {options.map((option, index) => {
1070
- const isSelected = selectedValue === option.value;
1071
-
1072
- return (
1073
- <button
1074
- key={option.value}
1075
- type="button"
1076
- onClick={() => onSelect(option.value)}
1077
- /* STYLE: Style selected vs unselected states */
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
- {buttonType === 'emoji' && (
1080
- /* ⚠️ SDK provides getEmojiForIndex helper! */
1081
- <span dangerouslySetInnerHTML={{ __html: getEmojiForIndex(index, options.length, reverseScaleOrder) }} />
1082
- )}
1083
- {buttonType === 'star' && (
1084
- <span dangerouslySetInnerHTML={{ __html: CsatStarIcons[isSelected ? 'filled' : 'empty'] }} />
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
- {hasNotApplicable && (
1096
- <label>
1097
- <input
1098
- type="checkbox"
1099
- checked={selectedValue === null}
1100
- onChange={() => onSelect(selectedValue === null ? undefined : null)}
1101
- />
1102
- <span>Not Applicable</span>
1103
- </label>
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 type { SliderMatrixQuestion, MatrixAnswerMap } from '@explorer02/cfm-survey-sdk';
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?: MatrixAnswerMap;
1246
- onSelect: (value: MatrixAnswerMap) => void;
1338
+ selectedValue?: Record<string, number | null | 'N/A'>;
1339
+ onSelect: (value: Record<string, number | null | 'N/A'>) => void;
1247
1340
  };
1248
1341
 
1249
- export default function SliderMatrixScale({ question, selectedValue = {}, onSelect }: SliderMatrixScaleProps) {
1250
- const handleSliderChange = (rowId: string, val: number) => {
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
- {question.rows.map(row => {
1257
- const currentVal = typeof selectedValue[row.id] === 'number'
1258
- ? selectedValue[row.id] as number
1259
- : row.defaultValue ?? row.min;
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
- <div dangerouslySetInnerHTML={{ __html: row.text }} />
1264
-
1265
- {/* Custom Slider logic: Calculate percentage fill */}
1266
- <div style={{ position: 'relative' }}>
1267
- <input
1268
- type="range"
1269
- min={row.min} max={row.max} step={row.step}
1270
- value={currentVal}
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
- {row.enableInputBox && (
1282
- <input
1283
- type="number"
1284
- value={currentVal}
1285
- onChange={(e) => handleSliderChange(row.id, Number(e.target.value))}
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({ languages, selectedLanguage, onChange }: LanguageSelectorProps) {
1395
- // Don't render if there's only one language
1396
- if (!languages || languages.length <= 1) return null;
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
- /* STYLE: Design a language selector matching the mockup.
1400
- Common patterns: dropdown, pill buttons, flag icons.
1401
- Position according to the mockup (e.g., top-right corner, header area). */
1402
- <div>
1403
- <select
1404
- value={selectedLanguage}
1405
- onChange={e => onChange(e.target.value)}
1406
- /* STYLE: Style the select element or replace with a custom dropdown component. */
1407
- >
1408
- {languages.map(lang => (
1409
- <option key={lang.code} value={lang.code}>{lang.name}</option>
1410
- ))}
1411
- </select>
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
  }