@explorer02/cfm-survey-sdk 0.1.1 → 0.1.2

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.
@@ -298,39 +298,213 @@ type Survey = {
298
298
  type SurveyPageData = {
299
299
  id: string; // Unique page identifier
300
300
  title?: string; // Optional page title
301
- questions: SurveyQuestion[]; // Questions on this page
301
+ questions: SurveyQuestion[]; // Questions on this page (discriminated union — see below)
302
302
  };
303
303
 
304
- // ── Question Structure ─────────────────────────────────
305
- type SurveyQuestion = {
304
+ // ── Question Structure (Discriminated Union) ───────────
305
+ // Use question.type as the discriminant to narrow to the correct variant.
306
+ type SurveyQuestion =
307
+ | RatingQuestion
308
+ | RadioQuestion
309
+ | TextQuestion
310
+ | MatrixQuestion
311
+ | RatingMatrixQuestion
312
+ | SliderMatrixQuestion
313
+ | TextAndMediaQuestion
314
+ | CsatQuestion
315
+ | SliderQuestion
316
+ | RatingScaleQuestion
317
+ | FileUploadQuestion;
318
+
319
+ // ── Base fields (present on ALL question variants) ─────
320
+ type QuestionBase = {
306
321
  id: string; // ⚠️ CRITICAL: Must be used as HTML element ID for scroll navigation
307
322
  text: string; // Question text (may contain HTML — render with dangerouslySetInnerHTML)
308
- type: 'rating' | 'radio' | 'text'; // Determines which input component to render
309
-
310
- // Options (for 'rating' and 'radio' types)
311
- options: SurveyOption[];
323
+ required?: boolean; // Whether the question must be answered
324
+ requiredErrorMessage?: string; // Custom validation error message
325
+ questionNumber?: string; // E.g. "Q1", "1." to display before the title
326
+ containerMediaUrl?: string; // Media to display alongside the question block
327
+ containerMediaMimeType?: string;
328
+ containerMediaAlignment?: 'left' | 'right' | 'top' | 'bottom';
329
+ containerMediaSize?: 'small' | 'medium' | 'large' | 'full';
330
+ };
312
331
 
313
- // Rating scale labels (for 'rating' type only)
332
+ // ── type: 'rating' (NPS scale / numeric rating) ────────
333
+ type RatingQuestion = QuestionBase & {
334
+ type: 'rating';
335
+ options: SurveyOption[]; // Numeric badge options (0–10 for NPS)
314
336
  minLabel?: string; // Label for the minimum end (e.g., "Not Likely")
315
337
  midLabel?: string; // Label for the middle point
316
338
  maxLabel?: string; // Label for the maximum end (e.g., "Extremely Likely")
317
- midLabelIndex?: number; // Position index for the mid label
339
+ midLabelIndex?: number; // CSS position index for the mid label
340
+ reverseScaleOrder?: boolean; // If true, render high to low
341
+ buttonVariant?: 'radio' | 'numbered' | 'emoji'; // Render style for rating options
342
+ };
318
343
 
319
- // Validation
320
- required?: boolean; // Whether the question must be answered
321
- requiredErrorMessage?: string; // Custom validation error message
344
+ // ── type: 'radio' (MCQ single-select or multi-select) ──
345
+ type RadioQuestion = QuestionBase & {
346
+ type: 'radio';
347
+ options: SurveyOption[]; // Selectable answer choices
348
+ isMultiSelect?: boolean; // If true, render checkboxes and map answer as an array
349
+ };
322
350
 
323
- // Text input specific
351
+ // ── type: 'text' (short or long text field) ────────────
352
+ type TextQuestion = QuestionBase & {
353
+ type: 'text';
324
354
  maxCharacterCount?: number; // Character limit for text inputs
325
- placeholder?: string; // Placeholder text for text inputs
355
+ placeholder?: string; // Placeholder hint for the input field
356
+ };
357
+
358
+ // ── type: 'csat' (CSAT 1-5 satisfaction) ───────────────
359
+ type CsatQuestion = QuestionBase & {
360
+ type: 'csat';
361
+ options: SurveyOption[];
362
+ minLabel?: string;
363
+ maxLabel?: string;
364
+ buttonType?: SurveyButtonType; // 'emoji' | 'star' | 'radio' | etc.
365
+ hasNotApplicable?: boolean; // If true, render N/A option
366
+ reverseScaleOrder?: boolean;
367
+ labelPosition?: 'top' | 'bottom' | 'hidden';
368
+ };
369
+
370
+ // ── type: 'slider' (Standalone Slider) ─────────────────
371
+ type SliderQuestion = QuestionBase & {
372
+ type: 'slider';
373
+ min: number;
374
+ max: number;
375
+ step: number;
376
+ defaultValue?: number;
377
+ minLabel?: string;
378
+ maxLabel?: string;
379
+ enableInputBox?: boolean; // If true, show numeric input alongside slider
380
+ tickValues?: TickValue[]; // Ticks/markers to display on the slider track
381
+ displayValues?: number[]; // Which tick values should have labels rendered
382
+ };
383
+
384
+ // ── type: 'rating_scale' (Stars / Emojis) ──────────────
385
+ type RatingScaleQuestion = QuestionBase & {
386
+ type: 'rating_scale';
387
+ options: SurveyOption[];
388
+ minLabel?: string;
389
+ midLabel?: string;
390
+ maxLabel?: string;
391
+ midLabelIndex?: number;
392
+ scaleStyle?: 'star' | 'emoji'; // Icon style to render
393
+ reverseScaleOrder?: boolean;
326
394
  };
327
395
 
396
+ // ── type: 'file_upload' (File attachments) ─────────────
397
+ // Submit target is quesIdVsAttachmentDetails mapping instead of questionToAnswers
398
+ type FileUploadQuestion = QuestionBase & {
399
+ type: 'file_upload';
400
+ uploadMessage?: string; // Instructions above dropzone
401
+ supportedFileFormats?: string[]; // E.g. ['PNG', 'PDF', 'DOCX']
402
+ maxFileCount?: number; // Max files allowed
403
+ fileSizeLimit?: number; // Size limit in MB
404
+ fileSizeLimitType?: 'PER_FILE' | 'IN_TOTAL';
405
+ };
406
+
407
+ // ── type: 'matrix' (CFM_MATRIX / CSAT_MATRIX) ─────────
408
+ // Grid of rows × columns; respondent selects one column per row.
409
+ // CHANGE payload value: Record<rowId, columnValue>
410
+ type MatrixQuestion = QuestionBase & {
411
+ type: 'matrix';
412
+ subType: 'CFM_MATRIX' | 'CSAT_MATRIX';
413
+ rows: MatrixRow[]; // Row sub-questions (one per grid row)
414
+ columns: MatrixColumn[]; // Column header options (shared across all rows)
415
+ buttonType?: SurveyButtonType; // For CSAT_MATRIX icon styling
416
+ hasNotApplicable?: boolean;
417
+ reverseScaleOrder?: boolean;
418
+ labels?: string[]; // General hints
419
+ };
420
+
421
+ // ── type: 'rating_matrix' (RATING_MATRIX) ─────────────
422
+ // Grid of rows × rating-scale columns with optional NPS-style colors.
423
+ // CHANGE payload value: Record<rowId, columnValue>
424
+ type RatingMatrixQuestion = QuestionBase & {
425
+ type: 'rating_matrix';
426
+ rows: MatrixRow[];
427
+ columns: MatrixColumn[]; // Columns may carry color hints (like NPS)
428
+ minLabel?: string;
429
+ maxLabel?: string;
430
+ };
431
+
432
+ // ── type: 'slider_matrix' (SLIDER_MATRIX) ─────────────
433
+ // Grid of rows, each with its own slider; respondent drags a handle per row.
434
+ // CHANGE payload value: Record<rowId, numericValue>
435
+ type SliderMatrixQuestion = QuestionBase & {
436
+ type: 'slider_matrix';
437
+ rows: SliderMatrixRow[]; // Each row carries its own min/max/step
438
+ };
439
+
440
+ // ── type: 'text_and_media' (TEXT_AND_MEDIA) ───────────
441
+ // Display-only card. No answer recorded. Excluded from validation and progress %.
442
+ // Do NOT dispatch a CHANGE action for this type.
443
+ type TextAndMediaQuestion = QuestionBase & {
444
+ type: 'text_and_media';
445
+ mediaUrl?: string; // Image or video URL to display
446
+ mediaMimeType?: string; // e.g. 'image/png', 'video/mp4'
447
+ mediaTitle?: string; // Optional media caption
448
+ };
449
+
450
+ // ── Primitive types ────────────────────────────────────
328
451
  type SurveyOption = {
452
+ id?: string; // Internal ID
329
453
  label: string; // Display text for the option
330
- value: string | number; // Actual value sent on submission
331
- color?: string; // Optional color (used for rating scale badges)
454
+ value: string | number | null; // Actual value sent on submission
455
+ color?: string; // Optional color (NPS rating badges)
456
+ };
457
+
458
+ type MatrixRow = {
459
+ id: string; // Row sub-question ID (key in answer map)
460
+ text: string; // Display label for the row
461
+ };
462
+
463
+ type MatrixColumn = {
464
+ id: string; // Column identifier
465
+ label: string; // Display text for the column header
466
+ value: string | number | null; // Value recorded when this column is selected
467
+ color?: string; // Optional color hint (rating_matrix)
468
+ };
469
+
470
+ type SliderMatrixRow = MatrixRow & {
471
+ min: number; // Slider minimum (default: 0)
472
+ max: number; // Slider maximum (default: 10)
473
+ step: number; // Slider step increment (default: 1)
474
+ defaultValue?: number; // Optional initial slider position
475
+ enableInputBox?: boolean; // If true, render numeric input next to slider
476
+ displayValues?: number[]; // Tick marks
332
477
  };
333
478
 
479
+ type TickValue = {
480
+ value: number;
481
+ label?: string;
482
+ };
483
+
484
+ type UploadedFile = {
485
+ id: string;
486
+ name: string;
487
+ mediaUrl: string;
488
+ mimeType?: string;
489
+ size?: number;
490
+ };
491
+
492
+ type SurveyButtonType =
493
+ | 'radio'
494
+ | 'checkbox'
495
+ | 'numbered'
496
+ | 'emoji'
497
+ | 'star'
498
+ | 'graphics'
499
+ | 'dropdown'
500
+ | 'selectbox'
501
+ | 'matrix';
502
+
503
+ // ── Answer value types ─────────────────────────────────
504
+ // Used in state.values and onAction CHANGE payload.
505
+ type MatrixAnswerMap = Record<string, string | number>; // rowId → selectedValue
506
+ type AnswerValue = string | number | (string | number)[] | MatrixAnswerMap | UploadedFile[];
507
+
334
508
  // ── Language Structure ─────────────────────────────────
335
509
  type SurveyLanguage = {
336
510
  code: string; // Language code (e.g., 'en', 'de')
@@ -338,6 +512,24 @@ type SurveyLanguage = {
338
512
  };
339
513
  ```
340
514
 
515
+
516
+ #### CHANGE action value by question type
517
+
518
+ | `question.type` | `value` in `onAction({ type: 'CHANGE', payload: { questionId, value } })` | Notes |
519
+ |---|---|---|
520
+ | `'rating'` | `number` | NPS 0–10 badge selection |
521
+ | `'radio'` | `string \| number \| (string \| number)[]` | option's `.value`, or array of values if `isMultiSelect` |
522
+ | `'text'` | `string` | typed text |
523
+ | `'csat'` | `number \| string \| null` | selected satisfaction value or `null` if N/A |
524
+ | `'slider'` | `number` | slider handle position |
525
+ | `'rating_scale'` | `number` | selected star/icon value |
526
+ | `'file_upload'` | `UploadedFile[]` | array of successfully uploaded file descriptors |
527
+ | `'matrix'` | `Record<rowId, columnValue>` | full map of all row answers |
528
+ | `'rating_matrix'` | `Record<rowId, columnValue>` | full map of all row answers |
529
+ | `'slider_matrix'` | `Record<rowId, numericValue>` | full map of all slider positions |
530
+ | `'text_and_media'` | ❌ **Do not dispatch CHANGE** | display-only, no answer |
531
+
532
+
341
533
  ### 3e. How the SDK Works Internally (Context for the Agent)
342
534
 
343
535
  1. **Data Fetching**: The SDK uses `@tanstack/react-query` with an internal singleton `QueryClient`. The client does NOT need to wrap their app in a `QueryClientProvider` — the SDK manages this automatically.
@@ -587,14 +779,19 @@ export default function SurveyPage() {
587
779
  **⚠️ CRITICAL**: The outermost element MUST have `id={question.id}`. This enables the SDK's scroll-to-error and page-transition scroll features.
588
780
 
589
781
  ```tsx
590
- import type { SurveyQuestion } from '@explorer02/cfm-survey-sdk';
782
+ import type { SurveyQuestion, AnswerValue, MatrixAnswerMap } from '@explorer02/cfm-survey-sdk';
591
783
  import RatingScale from './RatingScale';
784
+ import CsatScale from './CsatScale';
785
+ import LikertMatrixScale from './LikertMatrixScale';
786
+ import CsatMatrixScale from './CsatMatrixScale';
787
+ import SliderMatrixScale from './SliderMatrixScale';
788
+ import FileUploadScale from './FileUploadScale';
592
789
 
593
790
  type QuestionProps = {
594
791
  question: SurveyQuestion;
595
- selectedValue?: string | number;
792
+ selectedValue?: AnswerValue;
596
793
  validationError?: string;
597
- onSelect: (value: string | number) => void;
794
+ onSelect: (value: AnswerValue) => void;
598
795
  };
599
796
 
600
797
  export default function Question({ question, selectedValue, validationError, onSelect }: QuestionProps) {
@@ -603,15 +800,17 @@ export default function Question({ question, selectedValue, validationError, onS
603
800
  // Do NOT modify, prefix, or omit this ID.
604
801
  <section id={question.id}>
605
802
 
606
- {/* Question Title
803
+ {/* Question Title — skip for text_and_media (may be display-only card)
607
804
  ⚠️ MANDATORY: Use dangerouslySetInnerHTML — question.text may contain HTML markup. */}
608
- <h2>
609
- <span dangerouslySetInnerHTML={{ __html: question.text }} />
610
- {question.required && (
611
- /* STYLE: Design a required indicator (e.g., red asterisk, "(required)" label) */
612
- <span>*</span>
613
- )}
614
- </h2>
805
+ {question.type !== 'text_and_media' && (
806
+ <h2>
807
+ <span dangerouslySetInnerHTML={{ __html: question.text }} />
808
+ {question.required && (
809
+ /* STYLE: Design a required indicator (e.g., red asterisk, "(required)" label) */
810
+ <span>*</span>
811
+ )}
812
+ </h2>
813
+ )}
615
814
 
616
815
  {/* ── RATING TYPE ─────────────────────────────────── */}
617
816
  {question.type === 'rating' && (
@@ -642,7 +841,7 @@ export default function Question({ question, selectedValue, validationError, onS
642
841
  <RatingScale
643
842
  questionId={question.id}
644
843
  options={question.options}
645
- selectedValue={selectedValue}
844
+ selectedValue={typeof selectedValue === 'number' || typeof selectedValue === 'string' ? selectedValue : undefined}
646
845
  onSelect={onSelect}
647
846
  />
648
847
  </div>
@@ -679,12 +878,32 @@ export default function Question({ question, selectedValue, validationError, onS
679
878
  </div>
680
879
  )}
681
880
 
881
+ {/* ── CSAT TYPE ────────────────────────────────────── */}
882
+ {question.type === 'csat' && (
883
+ <div>
884
+ {/* Scale Endpoint Labels */}
885
+ {(question.minLabel || question.maxLabel) && (
886
+ <div>
887
+ {question.minLabel && <span dangerouslySetInnerHTML={{ __html: question.minLabel }} />}
888
+ {question.maxLabel && <span dangerouslySetInnerHTML={{ __html: question.maxLabel }} />}
889
+ </div>
890
+ )}
891
+
892
+ {/* CsatScale Component is imported from @repo/sdk! */}
893
+ <CsatScale
894
+ question={question}
895
+ selectedValue={selectedValue as string | number | null}
896
+ onSelect={onSelect}
897
+ />
898
+ </div>
899
+ )}
900
+
682
901
  {/* ── TEXT TYPE ────────────────────────────────────── */}
683
902
  {question.type === 'text' && (
684
903
  <div>
685
904
  <textarea
686
905
  rows={4}
687
- value={selectedValue ?? ''}
906
+ value={typeof selectedValue === 'string' ? selectedValue : ''}
688
907
  onChange={e => onSelect(e.target.value)}
689
908
  placeholder={question.placeholder || 'Type your response here...'}
690
909
  /* STYLE: Design the textarea to match the mockup.
@@ -705,6 +924,73 @@ export default function Question({ question, selectedValue, validationError, onS
705
924
  </div>
706
925
  )}
707
926
 
927
+ {/* ── MATRIX TYPE (CFM_MATRIX / CSAT_MATRIX) ──────── */}
928
+ {question.type === 'matrix' && (
929
+ <CsatMatrixScale
930
+ question={question}
931
+ selectedValue={selectedValue as MatrixAnswerMap}
932
+ onSelect={onSelect}
933
+ />
934
+ )}
935
+
936
+ {/* ── RATING MATRIX TYPE ───────────────────────────── */}
937
+ {question.type === 'rating_matrix' && (
938
+ <LikertMatrixScale
939
+ question={question}
940
+ selectedValue={selectedValue as MatrixAnswerMap}
941
+ onSelect={onSelect}
942
+ />
943
+ )}
944
+
945
+ {/* ── SLIDER MATRIX TYPE ───────────────────────────── */}
946
+ {question.type === 'slider_matrix' && (
947
+ <SliderMatrixScale
948
+ question={question}
949
+ selectedValue={selectedValue as MatrixAnswerMap}
950
+ onSelect={onSelect}
951
+ />
952
+ )}
953
+
954
+ {/* ── FILE UPLOAD TYPE ─────────────────────────────── */}
955
+ {question.type === 'file_upload' && (
956
+ <FileUploadScale
957
+ question={question}
958
+ selectedValue={selectedValue}
959
+ onSelect={onSelect}
960
+ />
961
+ )}
962
+
963
+ {/* ── TEXT AND MEDIA TYPE ──────────────────────────── */}
964
+ {question.type === 'text_and_media' && (
965
+ <div>
966
+ {/* Question text (informational content) */}
967
+ {question.text && (
968
+ <div dangerouslySetInnerHTML={{ __html: question.text }} />
969
+ )}
970
+
971
+ {/* Media — render image or video based on mediaMimeType */}
972
+ {question.mediaUrl && (
973
+ question.mediaMimeType?.startsWith('video') ? (
974
+ <video
975
+ src={question.mediaUrl}
976
+ controls
977
+ /* STYLE: Constrain max width, add rounded corners, etc. */
978
+ />
979
+ ) : (
980
+ <img
981
+ src={question.mediaUrl}
982
+ alt={question.mediaTitle ?? ''}
983
+ /* STYLE: Responsive image sizing */
984
+ />
985
+ )
986
+ )}
987
+
988
+ {/* Optional caption */}
989
+ {question.mediaTitle && <p>{question.mediaTitle}</p>}
990
+ {/* NOTE: No onSelect call — this is display-only. The SDK skips this in validation. */}
991
+ </div>
992
+ )}
993
+
708
994
  {/* Validation Error — display when the SDK flags a required question as unanswered */}
709
995
  {validationError && (
710
996
  /* STYLE: Design a visible error message.
@@ -719,6 +1005,8 @@ export default function Question({ question, selectedValue, validationError, onS
719
1005
  }
720
1006
  ```
721
1007
 
1008
+
1009
+
722
1010
  ---
723
1011
 
724
1012
  ### Logic Skeleton: `RatingScale.tsx` — Rating Input
@@ -759,6 +1047,339 @@ export default function RatingScale({ questionId, options, selectedValue, onSele
759
1047
 
760
1048
  ---
761
1049
 
1050
+ ### Logic Skeleton: `CsatScale.tsx` — CSAT Satisfaction Input
1051
+
1052
+ ```tsx
1053
+ import type { CsatQuestion } from '@explorer02/cfm-survey-sdk';
1054
+ import { getEmojiForIndex, CsatStarIcons } from '@explorer02/cfm-survey-sdk';
1055
+
1056
+ type CsatScaleProps = {
1057
+ question: CsatQuestion;
1058
+ selectedValue: string | number | null;
1059
+ onSelect: (value: string | number | null) => void;
1060
+ };
1061
+
1062
+ export default function CsatScale({ question, selectedValue, onSelect }: CsatScaleProps) {
1063
+ const { options, buttonType, hasNotApplicable, reverseScaleOrder } = question;
1064
+
1065
+ 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 */
1078
+ >
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>
1094
+
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
+ )}
1105
+ </div>
1106
+ );
1107
+ }
1108
+ ```
1109
+
1110
+ ---
1111
+
1112
+ ### Logic Skeleton: `CsatMatrixScale.tsx` — Responsive Grid
1113
+
1114
+ ```tsx
1115
+ import type { MatrixQuestion, MatrixAnswerMap } from '@explorer02/cfm-survey-sdk';
1116
+
1117
+ type CsatMatrixScaleProps = {
1118
+ question: MatrixQuestion;
1119
+ selectedValue?: MatrixAnswerMap;
1120
+ onSelect: (value: MatrixAnswerMap) => void;
1121
+ };
1122
+
1123
+ export default function CsatMatrixScale({ question, selectedValue = {}, onSelect }: CsatMatrixScaleProps) {
1124
+ const { rows, columns, hasNotApplicable } = question;
1125
+
1126
+ const handleCellSelect = (rowId: string, colValue: any) => {
1127
+ onSelect({ ...selectedValue, [rowId]: colValue });
1128
+ };
1129
+
1130
+ return (
1131
+ <div>
1132
+ {/* STYLE: Create a responsive layout.
1133
+ On desktop, show a standard table/grid.
1134
+ On mobile, stack the rows vertically and use a dropdown or list. */}
1135
+
1136
+ {rows.map(row => (
1137
+ <div key={row.id}>
1138
+ <h3 dangerouslySetInnerHTML={{ __html: row.text }} />
1139
+
1140
+ {/* Mobile view example: Dropdown */}
1141
+ <div className="md:hidden">
1142
+ <select
1143
+ value={selectedValue[row.id] ?? ''}
1144
+ onChange={(e) => handleCellSelect(row.id, e.target.value)}
1145
+ >
1146
+ <option value="" disabled>Select...</option>
1147
+ {columns.map(col => (
1148
+ <option key={col.id} value={col.value}>{col.label}</option>
1149
+ ))}
1150
+ </select>
1151
+ </div>
1152
+
1153
+ {/* Desktop view example: Row of buttons */}
1154
+ <div className="hidden md:flex">
1155
+ {columns.map(col => (
1156
+ <button
1157
+ key={col.id}
1158
+ type="button"
1159
+ onClick={() => handleCellSelect(row.id, col.value)}
1160
+ >
1161
+ {col.label}
1162
+ </button>
1163
+ ))}
1164
+ </div>
1165
+
1166
+ {hasNotApplicable && (
1167
+ <label>
1168
+ <input
1169
+ type="checkbox"
1170
+ checked={selectedValue[row.id] === null}
1171
+ onChange={() => handleCellSelect(row.id, selectedValue[row.id] === null ? undefined : null)}
1172
+ />
1173
+ N/A
1174
+ </label>
1175
+ )}
1176
+ </div>
1177
+ ))}
1178
+ </div>
1179
+ );
1180
+ }
1181
+ ```
1182
+
1183
+ ---
1184
+
1185
+ ### Logic Skeleton: `LikertMatrixScale.tsx`
1186
+
1187
+ ```tsx
1188
+ import type { MatrixQuestion, MatrixAnswerMap } from '@explorer02/cfm-survey-sdk';
1189
+
1190
+ type LikertMatrixScaleProps = {
1191
+ question: MatrixQuestion;
1192
+ selectedValue?: MatrixAnswerMap;
1193
+ onSelect: (value: MatrixAnswerMap) => void;
1194
+ };
1195
+
1196
+ export default function LikertMatrixScale({ question, selectedValue = {}, onSelect }: LikertMatrixScaleProps) {
1197
+ const { rows, columns } = question;
1198
+
1199
+ const handleCellSelect = (rowId: string, colValue: any) => {
1200
+ onSelect({ ...selectedValue, [rowId]: colValue });
1201
+ };
1202
+
1203
+ return (
1204
+ <table>
1205
+ <thead>
1206
+ <tr>
1207
+ <th />
1208
+ {columns.map(col => (
1209
+ <th key={col.id} style={{ color: col.color }}>{col.label}</th>
1210
+ ))}
1211
+ </tr>
1212
+ </thead>
1213
+ <tbody>
1214
+ {rows.map(row => (
1215
+ <tr key={row.id}>
1216
+ <td><span dangerouslySetInnerHTML={{ __html: row.text }} /></td>
1217
+ {columns.map(col => (
1218
+ <td key={col.id}>
1219
+ <button
1220
+ type="button"
1221
+ onClick={() => handleCellSelect(row.id, col.value)}
1222
+ style={{ backgroundColor: selectedValue[row.id] === col.value ? col.color : undefined }}
1223
+ >
1224
+ {col.label}
1225
+ </button>
1226
+ </td>
1227
+ ))}
1228
+ </tr>
1229
+ ))}
1230
+ </tbody>
1231
+ </table>
1232
+ );
1233
+ }
1234
+ ```
1235
+
1236
+ ---
1237
+
1238
+ ### Logic Skeleton: `SliderMatrixScale.tsx`
1239
+
1240
+ ```tsx
1241
+ import type { SliderMatrixQuestion, MatrixAnswerMap } from '@explorer02/cfm-survey-sdk';
1242
+
1243
+ type SliderMatrixScaleProps = {
1244
+ question: SliderMatrixQuestion;
1245
+ selectedValue?: MatrixAnswerMap;
1246
+ onSelect: (value: MatrixAnswerMap) => void;
1247
+ };
1248
+
1249
+ export default function SliderMatrixScale({ question, selectedValue = {}, onSelect }: SliderMatrixScaleProps) {
1250
+ const handleSliderChange = (rowId: string, val: number) => {
1251
+ onSelect({ ...selectedValue, [rowId]: val });
1252
+ };
1253
+
1254
+ 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
+
1261
+ 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>
1279
+ </div>
1280
+
1281
+ {row.enableInputBox && (
1282
+ <input
1283
+ type="number"
1284
+ value={currentVal}
1285
+ onChange={(e) => handleSliderChange(row.id, Number(e.target.value))}
1286
+ />
1287
+ )}
1288
+ </div>
1289
+ );
1290
+ })}
1291
+ </div>
1292
+ );
1293
+ }
1294
+ ```
1295
+
1296
+ ---
1297
+
1298
+ ### Logic Skeleton: `FileUploadScale.tsx`
1299
+
1300
+ ```tsx
1301
+ import { useState, useRef } from 'react';
1302
+ import type { FileUploadQuestion } from '@explorer02/cfm-survey-sdk';
1303
+
1304
+ export default function FileUploadScale({ question, selectedValue, onSelect }: { question: FileUploadQuestion, selectedValue: any, onSelect: any }) {
1305
+ const { maxFileCount = 1, fileSizeLimit = 10, fileSizeLimitType, supportedFileFormats = [], uploadMessage } = question;
1306
+ const [isDragActive, setIsDragActive] = useState(false);
1307
+ const [error, setError] = useState('');
1308
+ const fileInputRef = useRef<HTMLInputElement>(null);
1309
+
1310
+ const currentFiles: File[] = Array.isArray(selectedValue) ? selectedValue : [];
1311
+
1312
+ const handleFiles = (files: FileList) => {
1313
+ setError('');
1314
+ const newFiles = Array.from(files);
1315
+
1316
+ // Validate count
1317
+ if (currentFiles.length + newFiles.length > maxFileCount) {
1318
+ setError(`Maximum ${maxFileCount} files allowed.`);
1319
+ return;
1320
+ }
1321
+
1322
+ // Validate extensions & size
1323
+ for (const file of newFiles) {
1324
+ const ext = file.name.split('.').pop()?.toUpperCase() || '';
1325
+ if (supportedFileFormats.length > 0 && !supportedFileFormats.includes(ext)) {
1326
+ setError(`File type .${ext} not supported.`);
1327
+ return;
1328
+ }
1329
+ if (fileSizeLimitType === 'PER_FILE' && file.size > fileSizeLimit * 1024 * 1024) {
1330
+ setError(`File exceeds limit of ${fileSizeLimit}MB.`);
1331
+ return;
1332
+ }
1333
+ }
1334
+
1335
+ onSelect([...currentFiles, ...newFiles]);
1336
+ };
1337
+
1338
+ const handleDrop = (e: React.DragEvent) => {
1339
+ e.preventDefault(); setIsDragActive(false);
1340
+ if (e.dataTransfer.files) handleFiles(e.dataTransfer.files);
1341
+ };
1342
+
1343
+ const handleDragOver = (e: React.DragEvent) => {
1344
+ e.preventDefault(); setIsDragActive(true);
1345
+ };
1346
+
1347
+ return (
1348
+ <div>
1349
+ <input
1350
+ type="file"
1351
+ hidden ref={fileInputRef}
1352
+ onChange={(e) => e.target.files && handleFiles(e.target.files)}
1353
+ multiple={maxFileCount > 1}
1354
+ />
1355
+
1356
+ <div
1357
+ onDrop={handleDrop}
1358
+ onDragOver={handleDragOver}
1359
+ onDragLeave={() => setIsDragActive(false)}
1360
+ onClick={() => fileInputRef.current?.click()}
1361
+ /* STYLE: Apply dashed border, drag active state styles */
1362
+ >
1363
+ <span dangerouslySetInnerHTML={{ __html: uploadMessage || 'Drag and drop files here' }} />
1364
+ </div>
1365
+
1366
+ {error && <div /* STYLE: Error message styling */>{error}</div>}
1367
+
1368
+ <div>
1369
+ {currentFiles.map((f, i) => (
1370
+ <div key={i}>
1371
+ {f.name} ({(f.size / 1024 / 1024).toFixed(2)} MB)
1372
+ <button type="button" onClick={() => onSelect(currentFiles.filter((_, idx) => idx !== i))}>Remove</button>
1373
+ </div>
1374
+ ))}
1375
+ </div>
1376
+ </div>
1377
+ );
1378
+ }
1379
+ ```
1380
+
1381
+ ---
1382
+
762
1383
  ### Logic Skeleton: `LanguageSelector.tsx`
763
1384
 
764
1385
  ```tsx