@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.
- package/dist/index.d.mts +592 -13
- package/dist/index.d.ts +592 -13
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +4 -3
- package/templates/AGENT.md +650 -29
package/templates/AGENT.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
//
|
|
311
|
-
|
|
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
|
-
|
|
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; //
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
|
331
|
-
color?: string; // Optional color (
|
|
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?:
|
|
792
|
+
selectedValue?: AnswerValue;
|
|
596
793
|
validationError?: string;
|
|
597
|
-
onSelect: (value:
|
|
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
|
-
|
|
609
|
-
<
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|