@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/templates/AGENT.md +557 -122
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@explorer02/cfm-survey-sdk",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -744,16 +744,14 @@ export default function SurveyPage() {
744
744
  )}
745
745
 
746
746
  {/* Navigation Buttons */}
747
- <nav>
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
- /* 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]"
754
753
  >
755
- {/* STYLE: Use client's preferred label (e.g., "Back", "Previous", "Zurück") */}
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
- /* STYLE: Design a primary action button matching the mockup.
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
- ? 'Submitting...'
767
+ ? survey.language?.startsWith('de')
768
+ ? 'Wird gesendet...'
769
+ : 'Submitting...'
771
770
  : state.currentPageIndex < survey.pages.length - 1
772
- ? 'Next'
773
- : 'Submit'}
771
+ ? survey.language?.startsWith('de')
772
+ ? 'Weiter'
773
+ : 'Next'
774
+ : survey.language?.startsWith('de')
775
+ ? 'Absenden'
776
+ : 'Submit'}
774
777
  </button>
775
778
  )}
776
- </nav>
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: { label: string; value: string | number; color?: string }[];
1028
- selectedValue?: string | number;
1029
- onSelect: (value: string | number) => void;
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
- {/* STYLE: Design the rating scale layout to match the mockup.
1036
- Common patterns: horizontal badge row, numbered circles, star rating, slider.
1037
- Each option.color (if present) should be used as the badge/indicator background color. */}
1038
- {options.map(option => {
1039
- const isSelected = selectedValue === option.value;
1040
- return (
1041
- <button
1042
- key={option.value}
1043
- type="button"
1044
- onClick={() => onSelect(option.value)}
1045
- /* STYLE: Design selected vs. unselected states.
1046
- If option.color is available, use it: style={{ backgroundColor: option.color }}
1047
- Must be clearly distinguishable which option is selected. */
1048
- >
1049
- {option.label}
1050
- </button>
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 { getEmojiForIndex, CsatStarIcons } from '@explorer02/cfm-survey-sdk';
1106
+ import { CsatEmojiMapping, CsatStarIcons, getEmojiForIndex } from '@explorer02/cfm-survey-sdk';
1065
1107
 
1066
1108
  type CsatScaleProps = {
1067
1109
  question: CsatQuestion;
1068
- selectedValue: string | number | null;
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 { 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
+ }
1074
1139
 
1075
1140
  return (
1076
- <div>
1077
- {/* STYLE: Design a horizontal layout for CSAT options. */}
1078
- <div>
1079
- {options.map((option, index) => {
1080
- const isSelected = selectedValue === option.value;
1081
-
1082
- return (
1083
- <button
1084
- key={option.value}
1085
- type="button"
1086
- onClick={() => onSelect(option.value)}
1087
- /* 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
+ }`}
1088
1177
  >
1089
- {buttonType === 'emoji' && (
1090
- /* ⚠️ SDK provides getEmojiForIndex helper! */
1091
- <span dangerouslySetInnerHTML={{ __html: getEmojiForIndex(index, options.length, reverseScaleOrder) }} />
1092
- )}
1093
- {buttonType === 'star' && (
1094
- <span dangerouslySetInnerHTML={{ __html: CsatStarIcons[isSelected ? 'filled' : 'empty'] }} />
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
- {hasNotApplicable && (
1106
- <label>
1107
- <input
1108
- type="checkbox"
1109
- checked={selectedValue === null}
1110
- onChange={() => onSelect(selectedValue === null ? undefined : null)}
1111
- />
1112
- <span>Not Applicable</span>
1113
- </label>
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 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';
1252
1335
 
1253
1336
  type SliderMatrixScaleProps = {
1254
1337
  question: SliderMatrixQuestion;
1255
- selectedValue?: MatrixAnswerMap;
1256
- onSelect: (value: MatrixAnswerMap) => void;
1338
+ selectedValue?: Record<string, number | null | 'N/A'>;
1339
+ onSelect: (value: Record<string, number | null | 'N/A'>) => void;
1257
1340
  };
1258
1341
 
1259
- export default function SliderMatrixScale({ question, selectedValue = {}, onSelect }: SliderMatrixScaleProps) {
1260
- 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') => {
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
- {question.rows.map(row => {
1267
- const currentVal = typeof selectedValue[row.id] === 'number'
1268
- ? selectedValue[row.id] as number
1269
- : row.defaultValue ?? row.min;
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
- <div dangerouslySetInnerHTML={{ __html: row.text }} />
1274
-
1275
- {/* Custom Slider logic: Calculate percentage fill */}
1276
- <div style={{ position: 'relative' }}>
1277
- <input
1278
- type="range"
1279
- min={row.min} max={row.max} step={row.step}
1280
- value={currentVal}
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
- {row.enableInputBox && (
1292
- <input
1293
- type="number"
1294
- value={currentVal}
1295
- 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)}
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({ languages, selectedLanguage, onChange }: LanguageSelectorProps) {
1405
- // Don't render if there's only one language
1406
- 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
+ }
1407
1836
 
1408
1837
  return (
1409
- /* STYLE: Design a language selector matching the mockup.
1410
- Common patterns: dropdown, pill buttons, flag icons.
1411
- Position according to the mockup (e.g., top-right corner, header area). */
1412
- <div>
1413
- <select
1414
- value={selectedLanguage}
1415
- onChange={e => onChange(e.target.value)}
1416
- /* STYLE: Style the select element or replace with a custom dropdown component. */
1417
- >
1418
- {languages.map(lang => (
1419
- <option key={lang.code} value={lang.code}>{lang.name}</option>
1420
- ))}
1421
- </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>
1422
1857
  </div>
1423
1858
  );
1424
1859
  }