@explorer02/cfm-survey-sdk 0.1.1 → 0.1.3

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