@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.
- 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 +667 -36
package/templates/AGENT.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
//
|
|
311
|
-
|
|
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
|
-
|
|
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; //
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
|
331
|
-
color?: string; // Optional color (
|
|
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?:
|
|
802
|
+
selectedValue?: AnswerValue;
|
|
596
803
|
validationError?: string;
|
|
597
|
-
onSelect: (value:
|
|
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
|
-
|
|
609
|
-
<
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|