@explorer02/cfm-survey-sdk 0.1.4 → 0.1.5
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/package.json +1 -1
- package/postinstall.js +30 -18
- package/templates/AGENT.md +10 -2341
- package/templates/docs/01-sdk-core/01-fetch-survey.md +68 -0
- package/templates/docs/01-sdk-core/02-survey-mapper.md +85 -0
- package/templates/docs/01-sdk-core/03-question-mappers.md +114 -0
- package/templates/docs/01-sdk-core/04-pagination.md +72 -0
- package/templates/docs/01-sdk-core/05-validation.md +66 -0
- package/templates/docs/01-sdk-core/06-submit-survey.md +90 -0
- package/templates/docs/01-sdk-core/07-language-handling.md +111 -0
- package/templates/docs/01-sdk-core/08-icons-and-emojis.md +88 -0
- package/templates/docs/01-sdk-core/README.md +53 -0
- package/templates/docs/02-question-types/01-rating.md +52 -0
- package/templates/docs/02-question-types/02-radio.md +26 -0
- package/templates/docs/02-question-types/03-text.md +26 -0
- package/templates/docs/02-question-types/04-csat.md +54 -0
- package/templates/docs/02-question-types/05-rating-scale.md +26 -0
- package/templates/docs/02-question-types/06-slider.md +30 -0
- package/templates/docs/02-question-types/07-matrix-cfm.md +43 -0
- package/templates/docs/02-question-types/08-matrix-csat.md +29 -0
- package/templates/docs/02-question-types/09-matrix-rating.md +28 -0
- package/templates/docs/02-question-types/10-slider-matrix.md +40 -0
- package/templates/docs/02-question-types/11-file-upload.md +34 -0
- package/templates/docs/02-question-types/12-text-and-media.md +35 -0
- package/templates/docs/02-question-types/README.md +74 -0
- package/templates/docs/03-client-components/01-survey-page.md +113 -0
- package/templates/docs/03-client-components/02-question.md +57 -0
- package/templates/docs/03-client-components/03-rating-scale.md +38 -0
- package/templates/docs/03-client-components/04-csat-scale.md +40 -0
- package/templates/docs/03-client-components/05-csat-matrix-scale.md +43 -0
- package/templates/docs/03-client-components/06-likert-matrix-scale.md +38 -0
- package/templates/docs/03-client-components/07-slider-matrix-scale.md +44 -0
- package/templates/docs/03-client-components/08-file-upload-scale.md +33 -0
- package/templates/docs/03-client-components/09-custom-slider-track.md +27 -0
- package/templates/docs/03-client-components/10-header-footer.md +46 -0
- package/templates/docs/03-client-components/11-progress-bar.md +31 -0
- package/templates/docs/03-client-components/12-language-selector.md +41 -0
- package/templates/docs/03-client-components/13-matrix-dropdown.md +42 -0
- package/templates/docs/03-client-components/README.md +63 -0
- package/templates/docs/04-critical-rules/01-import-rules.md +51 -0
- package/templates/docs/04-critical-rules/02-action-dispatching.md +56 -0
- package/templates/docs/04-critical-rules/03-scroll-navigation.md +37 -0
- package/templates/docs/04-critical-rules/04-logo-branding.md +42 -0
- package/templates/docs/04-critical-rules/05-troubleshooting.md +46 -0
- package/templates/docs/04-critical-rules/README.md +29 -0
- package/templates/docs/index.md +129 -0
package/templates/AGENT.md
CHANGED
|
@@ -1,2348 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
You are an AI coding agent tasked with building a survey UI using `@explorer02/cfm-survey-sdk`.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# STOP! READ THIS FIRST!
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
This document is just a pointer.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
The complete, authoritative documentation for how to use this SDK is located inside the `node_modules` folder of the project.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**Your absolute first step** is to read the master index file located here:
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
2. [Execution Workflow](#2-execution-workflow)
|
|
13
|
-
3. [SDK Knowledge Base](#3-sdk-knowledge-base)
|
|
14
|
-
4. [Component Architecture Blueprint (Logic Skeletons)](#4-component-architecture-blueprint-logic-skeletons)
|
|
15
|
-
5. [Critical Constraints & Rules](#5-critical-constraints--rules)
|
|
16
|
-
6. [UI Fidelity & Styling Rules](#6-ui-fidelity--styling-rules)
|
|
17
|
-
7. [Configuration & Build Requirements](#7-configuration--build-requirements)
|
|
18
|
-
8. [Deployment Phase](#8-deployment-phase)
|
|
19
|
-
9. [Reference: Styled Code Templates (Reference Only)](#9-reference-styled-code-templates-reference-only)
|
|
20
|
-
10. [Appendix A: Other Frameworks (Vite, Remix, CRA)](#appendix-a-other-frameworks-vite-remix-cra)
|
|
21
|
-
11. [Troubleshooting Guide](#troubleshooting-guide)
|
|
11
|
+
👉 `node_modules/@explorer02/cfm-survey-sdk/docs/index.md` 👈
|
|
22
12
|
|
|
23
|
-
|
|
13
|
+
Do **NOT** guess how the SDK works.
|
|
14
|
+
Do **NOT** try to import from internal paths.
|
|
15
|
+
Do **NOT** start writing React components before reading the master index.
|
|
24
16
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
### Who You Are
|
|
28
|
-
You are an expert Senior Frontend Engineer acting as an autonomous build agent. The person you are working with is a **non-developer client**. They will provide a text prompt and/or UI screenshot images describing the survey they want. They do not know React, TypeScript, or terminal commands.
|
|
29
|
-
|
|
30
|
-
### How You Communicate
|
|
31
|
-
- Use **simple, non-technical language** when talking to the client.
|
|
32
|
-
- Never show raw error logs. Instead, summarise issues in plain English and fix them yourself.
|
|
33
|
-
- When you need input (e.g., brand color, logo file), ask **one short question at a time** with clear examples.
|
|
34
|
-
- Always confirm before deploying: *"Your survey is built and tested. Ready to publish it live?"*
|
|
35
|
-
|
|
36
|
-
### What You Must Never Do
|
|
37
|
-
- ❌ Never build custom state management, pagination, or form validation logic. The SDK handles ALL of this.
|
|
38
|
-
- ❌ Never use `useState` or `useReducer` to track answers, page index, or errors — these come from `useSurveySDK()`.
|
|
39
|
-
- ❌ Never install unnecessary packages. The SDK and its peer dependencies are all you need for survey logic.
|
|
40
|
-
- ❌ Never skip the `transpilePackages` config in `next.config.js` — the build will fail without it.
|
|
41
|
-
- ❌ Never remove the `id={question.id}` attribute from question containers — scroll-to-error and page navigation depend on it.
|
|
42
|
-
- ❌ Never copy-paste styling from the reference templates in Section 9. **Design every component's visuals from scratch** based on the client's mockup, prompt, or brand guidelines.
|
|
43
|
-
- ❌ Never scan, read, or reference any existing files in the client's project (e.g. existing components, styles, configs, or code) to derive decisions. Your ONLY reference is this `AGENT.md` file and the generated `./survey-config.json`. Follow the steps in this file strictly and sequentially.
|
|
44
|
-
|
|
45
|
-
---
|
|
46
|
-
|
|
47
|
-
## 2. Execution Workflow
|
|
48
|
-
|
|
49
|
-
Execute these phases **in strict sequential order**. Do not skip or reorder.
|
|
50
|
-
|
|
51
|
-
> 🚨 **DO NOT ask any questions in the IDE chat.** You must run `npx cfm-sdk init` yourself as a shell command (Step 3 below). That terminal wizard collects ALL required information directly from the client. Once it exits, **continue immediately to Phase 3** — do not ask the client to re-invoke you.
|
|
52
|
-
|
|
53
|
-
### Phase 1: Project Scaffolding
|
|
54
|
-
|
|
55
|
-
Run the following commands in the client's project directory **without pausing to ask anything in chat**:
|
|
56
|
-
|
|
57
|
-
```bash
|
|
58
|
-
# 1. Create a new Next.js project with TypeScript, Tailwind CSS, ESLint, App Router
|
|
59
|
-
npx -y create-next-app@latest ./ --ts --tailwind --eslint --app --src-dir --import-alias "@/*" --use-npm
|
|
60
|
-
|
|
61
|
-
# 2. Install the SDK and its required peer dependency
|
|
62
|
-
npm install @explorer02/cfm-survey-sdk @tanstack/react-query
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
### Phase 2: Configuration Handshake — Run the Local Setup Wizard
|
|
66
|
-
|
|
67
|
-
> 🚨 **CRITICAL — READ CAREFULLY:**
|
|
68
|
-
> 1. Open a **new terminal panel** in the IDE (Ctrl+` or click the + icon in the Terminal panel).
|
|
69
|
-
> 2. Run this command in that new terminal:
|
|
70
|
-
> ```bash
|
|
71
|
-
> npx cfm-sdk init
|
|
72
|
-
> ```
|
|
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
|
-
> 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
|
-
|
|
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!
|
|
83
|
-
|
|
84
|
-
### Phase 3: Configuration Setup
|
|
85
|
-
|
|
86
|
-
#### 3a. Read `./survey-config.json` — Full Field Reference
|
|
87
|
-
|
|
88
|
-
Immediately open and read `./survey-config.json`. This is the single source of truth generated by `npx cfm-sdk init`. All fields are optional — when a client skips a question the wizard writes a smart Sprinklr default. Use the table below to map every key to a concrete coding decision:
|
|
89
|
-
|
|
90
|
-
> ⚠️ **PRIORITY RULE**: Client terminal inputs always override anything observed in mockup screenshots. If `layout.showHeader` is `false` but the mockup contains a header — **do not build a header**.
|
|
91
|
-
|
|
92
|
-
| Key | Type | What to do |
|
|
93
|
-
|---|---|---|
|
|
94
|
-
| `hasMockup` | boolean | `true` → Analyse attached images for layout, colors, and typography. `false` → Design a professional clean UI from scratch. |
|
|
95
|
-
| `surveyTitle` | string | Render as the main `<h1>` heading inside the survey page. |
|
|
96
|
-
| `companyName` | string | Display in the header (next to logo) and footer. Default: `"Sprinklr"`. |
|
|
97
|
-
| `browserTabTitle` | string | Set as `metadata.title` in `layout.tsx`. |
|
|
98
|
-
| `thankYouMessage` | string | Render in the post-submission `SubmitSummary` component. |
|
|
99
|
-
| `logo.fileName` | string \| null | If provided, render the logo in the header. **Always apply `style={{ maxHeight: '40px', width: 'auto', objectFit: 'contain', display: 'block' }}`** — never hardcode pixel dimensions from the source file. This ensures industry-standard header sizing regardless of the logo's original resolution. |
|
|
100
|
-
| `logo.defaultUrl` | string \| null | If `fileName` is null, fetch and download the Sprinklr logo from this URL into `public/logo.png` and use it. Apply the same `max-height: 40px; object-fit: contain` CSS rule. |
|
|
101
|
-
| `layout.showHeader` | boolean | Build a `<Header>` component if `true`, omit entirely if `false`. |
|
|
102
|
-
| `layout.showFooter` | boolean | Build a `<Footer>` component if `true`, omit entirely if `false`. |
|
|
103
|
-
| `layout.showProgressBar` | boolean | Render `<ProgressBar>` if `true`. |
|
|
104
|
-
| `layout.showLanguageSwitcher` | boolean | Render `<LanguageSelector>` if `true`. |
|
|
105
|
-
| `layout.showNextButton` | boolean | Show a Next button navigating to the next page if `true`. |
|
|
106
|
-
| `layout.showBackButton` | boolean | Show a Back button navigating to the previous page if `true`. |
|
|
107
|
-
| `layout.width` | `narrow`/`standard`/`wide` | Map to Tailwind: `max-w-xl` / `max-w-3xl` / `max-w-5xl` on the survey container. |
|
|
108
|
-
| `layout.borderStyle` | `sharp`/`rounded`/`pill` | Map to Tailwind: `rounded-none` / `rounded-lg` / `rounded-full` on cards and inputs. |
|
|
109
|
-
| `layout.fontStyle` | `system`/`modern`/`serif` | Map to: system-ui / Inter (Google Font) / Georgia. Add the Google Font `<link>` in `layout.tsx` if `modern`. |
|
|
110
|
-
| `layout.showQuestionNumbers` | boolean | Prefix each question with "Q1.", "Q2." if `true`. |
|
|
111
|
-
| `layout.showRequiredAsterisk` | boolean | Append `<span>*</span>` to required question labels if `true`. |
|
|
112
|
-
| `layout.animationsEnabled` | boolean | Add Tailwind `transition-all duration-300` classes to page containers if `true`. |
|
|
113
|
-
| `buttons.submit` | string | Label on the final submit button. |
|
|
114
|
-
| `buttons.next` | string | Label on the next-page button. |
|
|
115
|
-
| `buttons.back` | string | Label on the back button. |
|
|
116
|
-
| `colorSource` | `mockup`/`logo`/`custom`/`auto` | `mockup` → Extract exact hex codes from attached images. `logo` → Derive palette from the logo file. `custom` → Use `colorScheme` field directly. `auto` → Generate a professional palette based on `companyName` and logo. |
|
|
117
|
-
| `colorScheme.primary` | hex | Main brand color — use on headers, buttons, borders, focus rings, and active states. |
|
|
118
|
-
| `colorScheme.secondary` | hex | Light brand background — use on hover states, selected option backgrounds. |
|
|
119
|
-
| `colorScheme.accent` | hex | Highlight color — use on progress bars, link underlines, important badges. |
|
|
120
|
-
| `colorScheme.background` | hex | Page background. |
|
|
121
|
-
| `colorScheme.text` | hex | Default body text color. |
|
|
122
|
-
| `colorScheme.buttonText` | hex | Text color on primary buttons (usually `#FFFFFF`). |
|
|
123
|
-
| `placeholders.firstName` | string | Pass as `SURVEY_PLACEHOLDERS.FIRST_NAME`. Default: `"Unnat"`. |
|
|
124
|
-
| `placeholders.lastName` | string | Pass as `SURVEY_PLACEHOLDERS.LAST_NAME`. Default: `"Agrawal"`. |
|
|
125
|
-
| `placeholders.email` | string | Pass as `SURVEY_PLACEHOLDERS.EMAIL_ADDRESS`. Default: `"unnat.agrawal@sprinklr.com"`. |
|
|
126
|
-
| `debug` | boolean | Pass as `options.debug` in `useSurveySDK()`. |
|
|
127
|
-
| `deployToVercel` | boolean | `true` → Run `npx cfm-sdk deploy` automatically after build verification. `false` → Ask the client in chat before deploying. |
|
|
128
|
-
|
|
129
|
-
**Apply all color values as CSS custom properties in `globals.css`:**
|
|
130
|
-
```css
|
|
131
|
-
@import "tailwindcss";
|
|
132
|
-
|
|
133
|
-
:root {
|
|
134
|
-
--color-primary: /* colorScheme.primary */;
|
|
135
|
-
--color-secondary: /* colorScheme.secondary */;
|
|
136
|
-
--color-accent: /* colorScheme.accent */;
|
|
137
|
-
--color-bg: /* colorScheme.background */;
|
|
138
|
-
--color-text: /* colorScheme.text */;
|
|
139
|
-
--color-btn-text: /* colorScheme.buttonText */;
|
|
140
|
-
}
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
#### 3b. `next.config.js` (or `next.config.ts`)
|
|
144
|
-
```javascript
|
|
145
|
-
/** @type {import('next').NextConfig} */
|
|
146
|
-
const nextConfig = {
|
|
147
|
-
reactStrictMode: true,
|
|
148
|
-
transpilePackages: ["@explorer02/cfm-survey-sdk"],
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
module.exports = nextConfig;
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
> ⚠️ **CRITICAL**: The `transpilePackages` entry is **mandatory** during development in this monorepo workspace to enable Hot Module Replacement (HMR). Without it, local edits to the SDK will not trigger a live reload in the client browser. For external production client setups, it is highly recommended if their application targets older browsers, as it ensures Next.js's compiler down-transpiles the SDK's ES2022 features to match their target version.
|
|
155
|
-
|
|
156
|
-
#### 3b. `postcss.config.mjs`
|
|
157
|
-
Ensure the PostCSS config uses the Tailwind CSS plugin. If Tailwind v4 is installed (check `package.json`), use:
|
|
158
|
-
```javascript
|
|
159
|
-
const config = {
|
|
160
|
-
plugins: {
|
|
161
|
-
"@tailwindcss/postcss": {},
|
|
162
|
-
},
|
|
163
|
-
};
|
|
164
|
-
export default config;
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
### Phase 4: Build the Survey UI
|
|
168
|
-
|
|
169
|
-
Create all components following the [Component Architecture Blueprint](#4-component-architecture-blueprint-logic-skeletons) below. **Design all visuals from scratch** based on the client's mockup/prompt:
|
|
170
|
-
|
|
171
|
-
- If the client provided **screenshots** → Follow the [Design Analysis Checklist](#6a-design-analysis-checklist-when-client-provides-screenshots)
|
|
172
|
-
- If the client provided **brand guidelines/colors** → Follow [Section 6b](#6b-when-the-client-provides-a-color-scheme-or-brand-guidelines)
|
|
173
|
-
- If the client provided **text only (no mockup)** → Follow the [No-Mockup Default Design](#6c-no-mockup-default-design)
|
|
174
|
-
|
|
175
|
-
### Phase 5: Build Verification
|
|
176
|
-
|
|
177
|
-
```bash
|
|
178
|
-
npm run build
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
If the build fails:
|
|
182
|
-
1. Read the error output carefully.
|
|
183
|
-
2. Fix all TypeScript and compilation errors automatically.
|
|
184
|
-
3. Re-run `npm run build` until it succeeds with zero errors.
|
|
185
|
-
4. Do NOT proceed to Phase 6 until the build is clean.
|
|
186
|
-
|
|
187
|
-
### Phase 6: UI Verification (Runtime Check)
|
|
188
|
-
|
|
189
|
-
After a successful build, verify the survey works at runtime:
|
|
190
|
-
|
|
191
|
-
```bash
|
|
192
|
-
npm run dev
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
Open the app in a browser and verify the following checklist:
|
|
196
|
-
|
|
197
|
-
- [ ] **Survey loads** — The survey data fetches and renders without errors.
|
|
198
|
-
- [ ] **All question types render** — Rating scales, radio buttons, and text areas all display correctly.
|
|
199
|
-
- [ ] **Navigation works** — "Next" advances pages; "Back" returns to the previous page.
|
|
200
|
-
- [ ] **Validation fires** — Clicking "Next" on a page with unanswered required questions shows error messages and scrolls to the first error.
|
|
201
|
-
- [ ] **Submission works** — On the final page, clicking "Submit" triggers submission and shows the thank-you state.
|
|
202
|
-
- [ ] **Progress bar updates** — The progress indicator reflects answered questions.
|
|
203
|
-
- [ ] **Language switcher** *(if applicable)* — Changing language re-renders question text.
|
|
204
|
-
- [ ] **Responsive design** — The layout looks correct on both mobile and desktop viewports.
|
|
205
|
-
|
|
206
|
-
If any check fails, fix the issue and re-verify before proceeding.
|
|
207
|
-
|
|
208
|
-
### Phase 7: Deployment
|
|
209
|
-
|
|
210
|
-
Check the value of `deployToVercel` in `./survey-config.json`:
|
|
211
|
-
|
|
212
|
-
* **If `deployToVercel` is `true`** (or if the client requests it in the chat):
|
|
213
|
-
Run the automated deployment CLI command:
|
|
214
|
-
```bash
|
|
215
|
-
npx cfm-sdk deploy
|
|
216
|
-
```
|
|
217
|
-
This command will check the Node version, verify/install the Vercel CLI, handle authentication, and deploy to production.
|
|
218
|
-
|
|
219
|
-
* **If `deployToVercel` is `false`**:
|
|
220
|
-
Do not run the deploy command automatically. Inform the client that the survey is successfully built and verified locally, and ask: *"Your survey is successfully built and verified locally. Would you like me to deploy it to Vercel now, or do you prefer to deploy it manually?"*
|
|
221
|
-
|
|
222
|
-
Report the final deployment URL to the client.
|
|
223
|
-
|
|
224
|
-
---
|
|
225
|
-
|
|
226
|
-
## 3. SDK Knowledge Base
|
|
227
|
-
|
|
228
|
-
### 3a. Package Exports
|
|
229
|
-
|
|
230
|
-
The SDK package `@explorer02/cfm-survey-sdk` exports exactly these items:
|
|
231
|
-
|
|
232
|
-
```typescript
|
|
233
|
-
// Main hook — the ONLY hook you need
|
|
234
|
-
export { useSurveySDK } from './surveySDK/useSurveySDK';
|
|
235
|
-
|
|
236
|
-
// Type exports — use these for type-safe component props
|
|
237
|
-
export { type SurveyQuestion } from './fetchSurvey/useFetchSurvey';
|
|
238
|
-
export { type SurveyLanguage } from './fetchSurvey/useFetchSurvey';
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
### 3b. `useSurveySDK()` Hook — Full API Reference
|
|
242
|
-
|
|
243
|
-
```typescript
|
|
244
|
-
import { useSurveySDK } from '@explorer02/cfm-survey-sdk';
|
|
245
|
-
|
|
246
|
-
const {
|
|
247
|
-
// ── 1. Survey Query Results ──────────────────────────
|
|
248
|
-
surveyQueryResults: {
|
|
249
|
-
data, // Survey | null — The fetched survey configuration object
|
|
250
|
-
isLoading, // boolean — True while the survey is being fetched
|
|
251
|
-
error, // Error | null — Fetch error (network, API, etc.)
|
|
252
|
-
},
|
|
253
|
-
|
|
254
|
-
// ── 2. Submit Survey Results ─────────────────────────
|
|
255
|
-
submitSurveyResults: {
|
|
256
|
-
data, // SubmitResult | null — { success: boolean, responseId: string }
|
|
257
|
-
isLoading, // boolean — True while the response is being submitted
|
|
258
|
-
error, // Error | null — Submission error
|
|
259
|
-
},
|
|
260
|
-
|
|
261
|
-
// ── 3. Client-Interactive State ──────────────────────
|
|
262
|
-
state: {
|
|
263
|
-
currentPageIndex, // number — Zero-based index of the active page
|
|
264
|
-
currentQuestions, // SurveyQuestion[] — Questions on the current page
|
|
265
|
-
values, // Record<string, string|number> — All user answers keyed by question ID
|
|
266
|
-
validationErrors, // Record<string, string> — Validation error messages keyed by question ID
|
|
267
|
-
progressPercentage, // number — 0–100 completion percentage
|
|
268
|
-
},
|
|
269
|
-
|
|
270
|
-
// ── 4. Action Dispatcher ─────────────────────────────
|
|
271
|
-
onAction, // (action: SurveyAction) => void — The single dispatcher for ALL user interactions
|
|
272
|
-
} = useSurveySDK({
|
|
273
|
-
options: {
|
|
274
|
-
language: string | undefined, // Optional language override code (e.g., 'de', 'en')
|
|
275
|
-
placeholders: Record<string, string>, // Optional placeholder replacement map
|
|
276
|
-
debug: boolean, // Optional debug logging flag
|
|
277
|
-
}
|
|
278
|
-
});
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
### 3c. Action Types
|
|
282
|
-
|
|
283
|
-
The `onAction` dispatcher accepts these action types:
|
|
284
|
-
|
|
285
|
-
| Action | Payload | Effect |
|
|
286
|
-
|--------|---------|--------|
|
|
287
|
-
| `{ type: 'NEXT' }` | None | Validates current page → advances to next page. If on the last page, auto-triggers `SUBMIT`. |
|
|
288
|
-
| `{ type: 'PREVIOUS' }` | None | Navigates back to the previous page. |
|
|
289
|
-
| `{ type: 'SUBMIT' }` | None | Validates all pages and submits the survey response to the server. |
|
|
290
|
-
| `{ type: 'CHANGE', payload: { questionId, value } }` | `{ questionId: string, value: string \| number }` | Updates the answer for a specific question. |
|
|
291
|
-
|
|
292
|
-
### 3d. Survey Data Model (Type Reference)
|
|
293
|
-
|
|
294
|
-
```typescript
|
|
295
|
-
// ── Survey Structure ───────────────────────────────────
|
|
296
|
-
type Survey = {
|
|
297
|
-
id: string; // Unique survey identifier
|
|
298
|
-
language: string; // Active language code (e.g., 'en', 'de')
|
|
299
|
-
languages: SurveyLanguage[]; // All available languages
|
|
300
|
-
pages: SurveyPageData[]; // Ordered list of survey pages
|
|
301
|
-
};
|
|
302
|
-
|
|
303
|
-
type SurveyPageData = {
|
|
304
|
-
id: string; // Unique page identifier
|
|
305
|
-
title?: string; // Optional page title
|
|
306
|
-
questions: SurveyQuestion[]; // Questions on this page (discriminated union — see below)
|
|
307
|
-
};
|
|
308
|
-
|
|
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 = {
|
|
326
|
-
id: string; // ⚠️ CRITICAL: Must be used as HTML element ID for scroll navigation
|
|
327
|
-
text: string; // Question text (may contain HTML — render with dangerouslySetInnerHTML)
|
|
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
|
-
};
|
|
336
|
-
|
|
337
|
-
// ── type: 'rating' (NPS scale / numeric rating) ────────
|
|
338
|
-
type RatingQuestion = QuestionBase & {
|
|
339
|
-
type: 'rating';
|
|
340
|
-
options: SurveyOption[]; // Numeric badge options (0–10 for NPS)
|
|
341
|
-
minLabel?: string; // Label for the minimum end (e.g., "Not Likely")
|
|
342
|
-
midLabel?: string; // Label for the middle point
|
|
343
|
-
maxLabel?: string; // Label for the maximum end (e.g., "Extremely Likely")
|
|
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
|
-
};
|
|
348
|
-
|
|
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
|
-
};
|
|
355
|
-
|
|
356
|
-
// ── type: 'text' (short or long text field) ────────────
|
|
357
|
-
type TextQuestion = QuestionBase & {
|
|
358
|
-
type: 'text';
|
|
359
|
-
maxCharacterCount?: number; // Character limit 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;
|
|
435
|
-
};
|
|
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 ────────────────────────────────────
|
|
456
|
-
type SurveyOption = {
|
|
457
|
-
id?: string; // Internal ID
|
|
458
|
-
label: string; // Display text for the option
|
|
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)
|
|
473
|
-
};
|
|
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
|
-
|
|
513
|
-
// ── Language Structure ─────────────────────────────────
|
|
514
|
-
type SurveyLanguage = {
|
|
515
|
-
code: string; // Language code (e.g., 'en', 'de')
|
|
516
|
-
name: string; // Human-readable name (e.g., 'English', 'Deutsch')
|
|
517
|
-
};
|
|
518
|
-
```
|
|
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
|
-
|
|
538
|
-
### 3e. How the SDK Works Internally (Context for the Agent)
|
|
539
|
-
|
|
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.
|
|
541
|
-
2. **Pagination**: The SDK tracks `currentPageIndex` internally. Calling `onAction({ type: 'NEXT' })` validates the current page, and if valid, increments the page index. On the last page, it auto-submits.
|
|
542
|
-
3. **Validation**: When `NEXT` is dispatched, the SDK validates all required questions on the current page. If any are unanswered, it populates `state.validationErrors` and scrolls to the first error using `document.getElementById(questionId)`.
|
|
543
|
-
4. **Scroll Navigation**: The SDK calls `document.getElementById(question.id)` and `.scrollIntoView()` for both validation error scrolling and page transition scrolling. This is why the client MUST set `id={question.id}` on question wrapper elements.
|
|
544
|
-
5. **Progress Tracking**: `state.progressPercentage` is computed automatically based on answered questions across all pages.
|
|
545
|
-
6. **Submission**: The SDK constructs the API payload and sends it to the Sprinklr CFM endpoint. The result is available in `submitSurveyResults.data`.
|
|
546
|
-
7. **Language Switching**: Passing a different `language` option to `useSurveySDK()` triggers a re-mapping of the survey to the target locale. The raw API data is fetched once and translated client-side.
|
|
547
|
-
8. **Placeholders**: Dynamic tokens like `{{FIRST_NAME}}` in question text are replaced at fetch time using the `placeholders` map.
|
|
548
|
-
|
|
549
|
-
---
|
|
550
|
-
|
|
551
|
-
## 4. Component Architecture Blueprint (Logic Skeletons)
|
|
552
|
-
|
|
553
|
-
> ⚠️ **IMPORTANT**: The code in this section shows **only the SDK data wiring and structural logic**. All styling has been intentionally stripped. You MUST design all visuals (colors, spacing, typography, layout, animations, hover states) from scratch based on the client's mockup or prompt. See [Section 6](#6-ui-fidelity--styling-rules) for styling guidance.
|
|
554
|
-
|
|
555
|
-
### Recommended File Structure
|
|
556
|
-
|
|
557
|
-
Create a modular, multi-file component structure. **Never write all code in a single file** — always refactor into well-separated components for maintainability. The structure below is a strong default; adapt it as needed to match the client's UI complexity:
|
|
558
|
-
|
|
559
|
-
```
|
|
560
|
-
src/
|
|
561
|
-
├── app/
|
|
562
|
-
│ ├── globals.css # Tailwind import + brand CSS custom properties
|
|
563
|
-
│ ├── layout.tsx # Root HTML layout with metadata
|
|
564
|
-
│ └── page.tsx # Entry page with <Suspense> wrapper
|
|
565
|
-
└── components/
|
|
566
|
-
├── SurveyPage.tsx # Main survey orchestrator (uses useSurveySDK) — REQUIRED
|
|
567
|
-
├── Header.tsx # Brand header with logo — adapt to mockup
|
|
568
|
-
├── Footer.tsx # Footer with optional links — adapt to mockup
|
|
569
|
-
├── ProgressBar.tsx # Visual progress indicator — adapt to mockup
|
|
570
|
-
├── Question.tsx # Question dispatcher (rating/radio/text) — REQUIRED
|
|
571
|
-
├── RatingScale.tsx # Rating scale input component — REQUIRED for rating questions
|
|
572
|
-
├── LanguageSelector.tsx # Language dropdown — include if survey is multi-language
|
|
573
|
-
└── [Additional].tsx # Add more components as the mockup demands (e.g., Sidebar, Stepper, ThankYou, etc.)
|
|
574
|
-
```
|
|
575
|
-
|
|
576
|
-
**Guidelines for component breakdown**:
|
|
577
|
-
- `SurveyPage.tsx` and `Question.tsx` are always required — they contain critical SDK integration logic.
|
|
578
|
-
- `Header.tsx`, `Footer.tsx`, `ProgressBar.tsx` should exist as separate files unless the mockup clearly doesn't need them.
|
|
579
|
-
- If the mockup shows a complex layout (sidebar navigation, stepper, multi-column), create additional components as needed.
|
|
580
|
-
- If a component's JSX exceeds ~80 lines, extract sub-components.
|
|
581
|
-
|
|
582
|
-
---
|
|
583
|
-
|
|
584
|
-
### Logic Skeleton: `page.tsx` — Entry Point
|
|
585
|
-
|
|
586
|
-
```tsx
|
|
587
|
-
import { Suspense } from "react";
|
|
588
|
-
import SurveyPage from "@/components/SurveyPage";
|
|
589
|
-
|
|
590
|
-
export default function Page() {
|
|
591
|
-
return (
|
|
592
|
-
<Suspense
|
|
593
|
-
fallback={
|
|
594
|
-
/* STYLE: Design a loading state that matches the client's brand.
|
|
595
|
-
This can be a spinner, skeleton screen, or branded loading message.
|
|
596
|
-
Must fill the viewport while survey data loads. */
|
|
597
|
-
<div>{/* Loading UI */}</div>
|
|
598
|
-
}
|
|
599
|
-
>
|
|
600
|
-
<SurveyPage />
|
|
601
|
-
</Suspense>
|
|
602
|
-
);
|
|
603
|
-
}
|
|
604
|
-
```
|
|
605
|
-
|
|
606
|
-
---
|
|
607
|
-
|
|
608
|
-
### Logic Skeleton: `SurveyPage.tsx` — Main Orchestrator
|
|
609
|
-
|
|
610
|
-
This is the **only component** that calls `useSurveySDK()`. The logic below is **mandatory** — implement all 5 render states exactly as shown. Style each state to match the client's mockup.
|
|
611
|
-
|
|
612
|
-
```tsx
|
|
613
|
-
'use client';
|
|
614
|
-
|
|
615
|
-
import { useState } from 'react';
|
|
616
|
-
import { useSurveySDK } from '@explorer02/cfm-survey-sdk';
|
|
617
|
-
// Import your custom components — adjust names/paths to match your component structure
|
|
618
|
-
import Header from '@/components/Header';
|
|
619
|
-
import Footer from '@/components/Footer';
|
|
620
|
-
import Question from '@/components/Question';
|
|
621
|
-
import ProgressBar from '@/components/ProgressBar';
|
|
622
|
-
import LanguageSelector from '@/components/LanguageSelector';
|
|
623
|
-
|
|
624
|
-
// ⚠️ CRITICAL: Define placeholder values OUTSIDE the component body.
|
|
625
|
-
// Defining inside creates a new object every render → infinite re-fetch loop.
|
|
626
|
-
const SURVEY_PLACEHOLDERS = {
|
|
627
|
-
FIRST_NAME: 'Customer', // Replace with client-provided test values
|
|
628
|
-
LAST_NAME: '',
|
|
629
|
-
EMAIL_ADDRESS: '',
|
|
630
|
-
};
|
|
631
|
-
|
|
632
|
-
export default function SurveyPage() {
|
|
633
|
-
const [selectedLanguage, setSelectedLanguage] = useState<string | undefined>("");
|
|
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
|
-
|
|
644
|
-
const { surveyQueryResults, submitSurveyResults, state, onAction } = useSurveySDK({
|
|
645
|
-
options: INPUT_OPTIONS
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
const survey = surveyQueryResults.data;
|
|
649
|
-
|
|
650
|
-
// ══════════════════════════════════════════════════════
|
|
651
|
-
// STATE 1: LOADING
|
|
652
|
-
// Show while survey data is being fetched from the API.
|
|
653
|
-
// ══════════════════════════════════════════════════════
|
|
654
|
-
if (surveyQueryResults.isLoading) {
|
|
655
|
-
return (
|
|
656
|
-
/* STYLE: Design a full-page loading state.
|
|
657
|
-
Can include: skeleton screens, spinner, brand animation, or simple text.
|
|
658
|
-
Should match the overall page layout (header + centered content + footer). */
|
|
659
|
-
<div>{/* Loading UI */}</div>
|
|
660
|
-
);
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
// ══════════════════════════════════════════════════════
|
|
664
|
-
// STATE 2: ERROR
|
|
665
|
-
// Show when the API fetch fails (network error, bad response, etc.)
|
|
666
|
-
// ══════════════════════════════════════════════════════
|
|
667
|
-
if (surveyQueryResults.error) {
|
|
668
|
-
return (
|
|
669
|
-
/* STYLE: Design a user-friendly error state.
|
|
670
|
-
Show a clear message (not raw error text).
|
|
671
|
-
Example: "Something went wrong. Please try again later." */
|
|
672
|
-
<div>{/* Error UI — display surveyQueryResults.error.message for debugging */}</div>
|
|
673
|
-
);
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
// ══════════════════════════════════════════════════════
|
|
677
|
-
// STATE 3: NO DATA
|
|
678
|
-
// Show when the API returns successfully but with no survey data.
|
|
679
|
-
// ══════════════════════════════════════════════════════
|
|
680
|
-
if (!survey) {
|
|
681
|
-
return (
|
|
682
|
-
/* STYLE: Design an empty state.
|
|
683
|
-
Example: "This survey is no longer available." */
|
|
684
|
-
<div>{/* Empty state UI */}</div>
|
|
685
|
-
);
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// ══════════════════════════════════════════════════════
|
|
689
|
-
// STATE 4: SUBMITTED
|
|
690
|
-
// Show after the survey has been successfully submitted.
|
|
691
|
-
// ══════════════════════════════════════════════════════
|
|
692
|
-
if (submitSurveyResults.data) {
|
|
693
|
-
return (
|
|
694
|
-
/* STYLE: Design a thank-you / confirmation screen.
|
|
695
|
-
Use the client's thank-you message from Phase 1 requirements.
|
|
696
|
-
Can include: checkmark animation, brand graphics, redirect timer. */
|
|
697
|
-
<div>{/* Thank-you UI */}</div>
|
|
698
|
-
);
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
// ══════════════════════════════════════════════════════
|
|
702
|
-
// STATE 5: ACTIVE SURVEY
|
|
703
|
-
// The main survey view with questions, navigation, and progress.
|
|
704
|
-
// This is where all the visual design work happens.
|
|
705
|
-
// ══════════════════════════════════════════════════════
|
|
706
|
-
return (
|
|
707
|
-
<div>
|
|
708
|
-
{/* STYLE: Design the overall page layout to match the client's mockup. */}
|
|
709
|
-
|
|
710
|
-
{/* Header — pass brand data as needed */}
|
|
711
|
-
<Header />
|
|
712
|
-
|
|
713
|
-
{/* Progress Bar — receives percentage from SDK */}
|
|
714
|
-
<ProgressBar progressPercentage={state.progressPercentage} />
|
|
715
|
-
|
|
716
|
-
{/* Language Selector — only render if survey has multiple languages */}
|
|
717
|
-
{survey.languages.length > 1 && (
|
|
718
|
-
<LanguageSelector
|
|
719
|
-
languages={survey.languages}
|
|
720
|
-
selectedLanguage={survey.language}
|
|
721
|
-
onChange={setSelectedLanguage}
|
|
722
|
-
/>
|
|
723
|
-
)}
|
|
724
|
-
|
|
725
|
-
{/* Questions — iterate over currentQuestions and render each one */}
|
|
726
|
-
<main>
|
|
727
|
-
{state.currentQuestions.map(question => (
|
|
728
|
-
<Question
|
|
729
|
-
key={question.id}
|
|
730
|
-
question={question}
|
|
731
|
-
selectedValue={state.values[question.id]}
|
|
732
|
-
validationError={state.validationErrors[question.id]}
|
|
733
|
-
onSelect={value =>
|
|
734
|
-
onAction({ type: 'CHANGE', payload: { questionId: question.id, value } })
|
|
735
|
-
}
|
|
736
|
-
/>
|
|
737
|
-
))}
|
|
738
|
-
</main>
|
|
739
|
-
|
|
740
|
-
{/* Submission Error — display if submission fails */}
|
|
741
|
-
{submitSurveyResults.error && (
|
|
742
|
-
/* STYLE: Design an inline error message for submission failures. */
|
|
743
|
-
<div>{submitSurveyResults.error.message}</div>
|
|
744
|
-
)}
|
|
745
|
-
|
|
746
|
-
{/* Navigation Buttons */}
|
|
747
|
-
<div className="mt-8 flex items-center justify-start gap-4">
|
|
748
|
-
{state.currentPageIndex > 0 && surveyConfig.layout.showBackButton && (
|
|
749
|
-
<button
|
|
750
|
-
type="button"
|
|
751
|
-
onClick={() => onAction({ type: 'PREVIOUS' })}
|
|
752
|
-
className="rounded-md border border-[#e20074] px-6 py-2 text-sm font-semibold text-[#e20074] transition-all hover:bg-[#fdf2f8]"
|
|
753
|
-
>
|
|
754
|
-
{survey.language?.startsWith('de') ? 'Zurück' : 'Back'}
|
|
755
|
-
</button>
|
|
756
|
-
)}
|
|
757
|
-
|
|
758
|
-
{/* Next / Submit Button */}
|
|
759
|
-
{(state.currentPageIndex < survey.pages.length - 1 ? surveyConfig.layout.showNextButton : true) && (
|
|
760
|
-
<button
|
|
761
|
-
type="button"
|
|
762
|
-
onClick={() => onAction({ type: 'NEXT' })}
|
|
763
|
-
disabled={submitSurveyResults.isLoading}
|
|
764
|
-
className="rounded-md bg-[#e20074] px-6 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
|
765
|
-
>
|
|
766
|
-
{submitSurveyResults.isLoading
|
|
767
|
-
? survey.language?.startsWith('de')
|
|
768
|
-
? 'Wird gesendet...'
|
|
769
|
-
: 'Submitting...'
|
|
770
|
-
: state.currentPageIndex < survey.pages.length - 1
|
|
771
|
-
? survey.language?.startsWith('de')
|
|
772
|
-
? 'Weiter'
|
|
773
|
-
: 'Next'
|
|
774
|
-
: survey.language?.startsWith('de')
|
|
775
|
-
? 'Absenden'
|
|
776
|
-
: 'Submit'}
|
|
777
|
-
</button>
|
|
778
|
-
)}
|
|
779
|
-
</div>
|
|
780
|
-
|
|
781
|
-
{/* Footer — pass links/data as needed */}
|
|
782
|
-
<Footer />
|
|
783
|
-
</div>
|
|
784
|
-
);
|
|
785
|
-
}
|
|
786
|
-
```
|
|
787
|
-
|
|
788
|
-
---
|
|
789
|
-
|
|
790
|
-
### Logic Skeleton: `Question.tsx` — Question Renderer
|
|
791
|
-
|
|
792
|
-
**⚠️ CRITICAL**: The outermost element MUST have `id={question.id}`. This enables the SDK's scroll-to-error and page-transition scroll features.
|
|
793
|
-
|
|
794
|
-
```tsx
|
|
795
|
-
import type { SurveyQuestion, AnswerValue, MatrixAnswerMap } from '@explorer02/cfm-survey-sdk';
|
|
796
|
-
import RatingScale from './RatingScale';
|
|
797
|
-
import CsatScale from './CsatScale';
|
|
798
|
-
import LikertMatrixScale from './LikertMatrixScale';
|
|
799
|
-
import CsatMatrixScale from './CsatMatrixScale';
|
|
800
|
-
import SliderMatrixScale from './SliderMatrixScale';
|
|
801
|
-
import FileUploadScale from './FileUploadScale';
|
|
802
|
-
|
|
803
|
-
type QuestionProps = {
|
|
804
|
-
question: SurveyQuestion;
|
|
805
|
-
selectedValue?: AnswerValue;
|
|
806
|
-
validationError?: string;
|
|
807
|
-
onSelect: (value: AnswerValue) => void;
|
|
808
|
-
};
|
|
809
|
-
|
|
810
|
-
export default function Question({ question, selectedValue, validationError, onSelect }: QuestionProps) {
|
|
811
|
-
return (
|
|
812
|
-
// ⚠️ MANDATORY: id={question.id} — the SDK uses document.getElementById() for scroll navigation.
|
|
813
|
-
// Do NOT modify, prefix, or omit this ID.
|
|
814
|
-
<section id={question.id}>
|
|
815
|
-
|
|
816
|
-
{/* Question Title — skip for text_and_media (may be display-only card)
|
|
817
|
-
⚠️ MANDATORY: Use dangerouslySetInnerHTML — question.text may contain HTML markup. */}
|
|
818
|
-
{question.type !== 'text_and_media' && (
|
|
819
|
-
<h2>
|
|
820
|
-
<span dangerouslySetInnerHTML={{ __html: question.text }} />
|
|
821
|
-
{question.required && (
|
|
822
|
-
/* STYLE: Design a required indicator (e.g., red asterisk, "(required)" label) */
|
|
823
|
-
<span>*</span>
|
|
824
|
-
)}
|
|
825
|
-
</h2>
|
|
826
|
-
)}
|
|
827
|
-
|
|
828
|
-
{/* ── RATING TYPE ─────────────────────────────────── */}
|
|
829
|
-
{question.type === 'rating' && (
|
|
830
|
-
<div>
|
|
831
|
-
{/* Scale Endpoint Labels — display min/mid/max labels if available */}
|
|
832
|
-
{(question.minLabel || question.midLabel || question.maxLabel) && (
|
|
833
|
-
<div>
|
|
834
|
-
{/* STYLE: Position these labels at the start, middle, and end of the scale.
|
|
835
|
-
Use dangerouslySetInnerHTML for labels (may contain HTML).
|
|
836
|
-
For midLabel positioning, use question.midLabelIndex:
|
|
837
|
-
position = (midLabelIndex / (options.length - 1)) * 100 → percentage */}
|
|
838
|
-
{question.minLabel && (
|
|
839
|
-
<span dangerouslySetInnerHTML={{ __html: question.minLabel }} />
|
|
840
|
-
)}
|
|
841
|
-
{question.midLabel && (
|
|
842
|
-
<span
|
|
843
|
-
dangerouslySetInnerHTML={{ __html: question.midLabel }}
|
|
844
|
-
/* STYLE: Position at calculated percentage based on midLabelIndex */
|
|
845
|
-
/>
|
|
846
|
-
)}
|
|
847
|
-
{question.maxLabel && (
|
|
848
|
-
<span dangerouslySetInnerHTML={{ __html: question.maxLabel }} />
|
|
849
|
-
)}
|
|
850
|
-
</div>
|
|
851
|
-
)}
|
|
852
|
-
|
|
853
|
-
{/* Rating Scale Component — handles the individual rating options */}
|
|
854
|
-
<RatingScale
|
|
855
|
-
questionId={question.id}
|
|
856
|
-
options={question.options}
|
|
857
|
-
selectedValue={typeof selectedValue === 'number' || typeof selectedValue === 'string' ? selectedValue : undefined}
|
|
858
|
-
onSelect={onSelect}
|
|
859
|
-
/>
|
|
860
|
-
</div>
|
|
861
|
-
)}
|
|
862
|
-
|
|
863
|
-
{/* ── RADIO TYPE ──────────────────────────────────── */}
|
|
864
|
-
{question.type === 'radio' && (
|
|
865
|
-
<div>
|
|
866
|
-
{/* STYLE: Design radio option cards/buttons matching the mockup.
|
|
867
|
-
Each option should have: default, hover, and selected states. */}
|
|
868
|
-
{question.options.map(option => {
|
|
869
|
-
const isSelected = selectedValue === option.value;
|
|
870
|
-
return (
|
|
871
|
-
<label key={option.value}>
|
|
872
|
-
{/* Hidden native radio — use className="sr-only" for accessibility */}
|
|
873
|
-
<input
|
|
874
|
-
type="radio"
|
|
875
|
-
name={question.id}
|
|
876
|
-
value={option.value}
|
|
877
|
-
checked={isSelected}
|
|
878
|
-
onChange={() => onSelect(option.value)}
|
|
879
|
-
className="sr-only"
|
|
880
|
-
/>
|
|
881
|
-
|
|
882
|
-
{/* STYLE: Design a custom radio indicator (circle, checkmark, etc.)
|
|
883
|
-
Must visually differentiate selected vs. unselected state. */}
|
|
884
|
-
<div>{/* Custom radio indicator */}</div>
|
|
885
|
-
|
|
886
|
-
{/* Option Label — use dangerouslySetInnerHTML (may contain HTML) */}
|
|
887
|
-
<span dangerouslySetInnerHTML={{ __html: option.label }} />
|
|
888
|
-
</label>
|
|
889
|
-
);
|
|
890
|
-
})}
|
|
891
|
-
</div>
|
|
892
|
-
)}
|
|
893
|
-
|
|
894
|
-
{/* ── CSAT TYPE ────────────────────────────────────── */}
|
|
895
|
-
{question.type === 'csat' && (
|
|
896
|
-
<div>
|
|
897
|
-
{/* Scale Endpoint Labels */}
|
|
898
|
-
{(question.minLabel || question.maxLabel) && (
|
|
899
|
-
<div>
|
|
900
|
-
{question.minLabel && <span dangerouslySetInnerHTML={{ __html: question.minLabel }} />}
|
|
901
|
-
{question.maxLabel && <span dangerouslySetInnerHTML={{ __html: question.maxLabel }} />}
|
|
902
|
-
</div>
|
|
903
|
-
)}
|
|
904
|
-
|
|
905
|
-
{/* CsatScale Component is imported from @repo/sdk! */}
|
|
906
|
-
<CsatScale
|
|
907
|
-
question={question}
|
|
908
|
-
selectedValue={selectedValue as string | number | null}
|
|
909
|
-
onSelect={onSelect}
|
|
910
|
-
/>
|
|
911
|
-
</div>
|
|
912
|
-
)}
|
|
913
|
-
|
|
914
|
-
{/* ── TEXT TYPE ────────────────────────────────────── */}
|
|
915
|
-
{question.type === 'text' && (
|
|
916
|
-
<div>
|
|
917
|
-
<textarea
|
|
918
|
-
rows={4}
|
|
919
|
-
value={typeof selectedValue === 'string' ? selectedValue : ''}
|
|
920
|
-
onChange={e => onSelect(e.target.value)}
|
|
921
|
-
placeholder={question.placeholder || 'Type your response here...'}
|
|
922
|
-
/* STYLE: Design the textarea to match the mockup.
|
|
923
|
-
Include focus state styling (border color, ring, etc.) */
|
|
924
|
-
/>
|
|
925
|
-
|
|
926
|
-
{/* Character Counter — show remaining characters if maxCharacterCount is set */}
|
|
927
|
-
{question.maxCharacterCount && (
|
|
928
|
-
<div>
|
|
929
|
-
{/* STYLE: Design a character counter (e.g., "250 characters remaining") */}
|
|
930
|
-
{Math.max(
|
|
931
|
-
0,
|
|
932
|
-
question.maxCharacterCount - (typeof selectedValue === 'string' ? selectedValue.length : 0)
|
|
933
|
-
)}{' '}
|
|
934
|
-
characters remaining
|
|
935
|
-
</div>
|
|
936
|
-
)}
|
|
937
|
-
</div>
|
|
938
|
-
)}
|
|
939
|
-
|
|
940
|
-
{/* ── MATRIX TYPE (CFM_MATRIX / CSAT_MATRIX) ──────── */}
|
|
941
|
-
{question.type === 'matrix' && (
|
|
942
|
-
<CsatMatrixScale
|
|
943
|
-
question={question}
|
|
944
|
-
selectedValue={selectedValue as MatrixAnswerMap}
|
|
945
|
-
onSelect={onSelect}
|
|
946
|
-
/>
|
|
947
|
-
)}
|
|
948
|
-
|
|
949
|
-
{/* ── RATING MATRIX TYPE ───────────────────────────── */}
|
|
950
|
-
{question.type === 'rating_matrix' && (
|
|
951
|
-
<LikertMatrixScale
|
|
952
|
-
question={question}
|
|
953
|
-
selectedValue={selectedValue as MatrixAnswerMap}
|
|
954
|
-
onSelect={onSelect}
|
|
955
|
-
/>
|
|
956
|
-
)}
|
|
957
|
-
|
|
958
|
-
{/* ── SLIDER MATRIX TYPE ───────────────────────────── */}
|
|
959
|
-
{question.type === 'slider_matrix' && (
|
|
960
|
-
<SliderMatrixScale
|
|
961
|
-
question={question}
|
|
962
|
-
selectedValue={selectedValue as MatrixAnswerMap}
|
|
963
|
-
onSelect={onSelect}
|
|
964
|
-
/>
|
|
965
|
-
)}
|
|
966
|
-
|
|
967
|
-
{/* ── FILE UPLOAD TYPE ─────────────────────────────── */}
|
|
968
|
-
{question.type === 'file_upload' && (
|
|
969
|
-
<FileUploadScale
|
|
970
|
-
question={question}
|
|
971
|
-
selectedValue={selectedValue}
|
|
972
|
-
onSelect={onSelect}
|
|
973
|
-
/>
|
|
974
|
-
)}
|
|
975
|
-
|
|
976
|
-
{/* ── TEXT AND MEDIA TYPE ──────────────────────────── */}
|
|
977
|
-
{question.type === 'text_and_media' && (
|
|
978
|
-
<div>
|
|
979
|
-
{/* Question text (informational content) */}
|
|
980
|
-
{question.text && (
|
|
981
|
-
<div dangerouslySetInnerHTML={{ __html: question.text }} />
|
|
982
|
-
)}
|
|
983
|
-
|
|
984
|
-
{/* Media — render image or video based on mediaMimeType */}
|
|
985
|
-
{question.mediaUrl && (
|
|
986
|
-
question.mediaMimeType?.startsWith('video') ? (
|
|
987
|
-
<video
|
|
988
|
-
src={question.mediaUrl}
|
|
989
|
-
controls
|
|
990
|
-
/* STYLE: Constrain max width, add rounded corners, etc. */
|
|
991
|
-
/>
|
|
992
|
-
) : (
|
|
993
|
-
<img
|
|
994
|
-
src={question.mediaUrl}
|
|
995
|
-
alt={question.mediaTitle ?? ''}
|
|
996
|
-
/* STYLE: Responsive image sizing */
|
|
997
|
-
/>
|
|
998
|
-
)
|
|
999
|
-
)}
|
|
1000
|
-
|
|
1001
|
-
{/* Optional caption */}
|
|
1002
|
-
{question.mediaTitle && <p>{question.mediaTitle}</p>}
|
|
1003
|
-
{/* NOTE: No onSelect call — this is display-only. The SDK skips this in validation. */}
|
|
1004
|
-
</div>
|
|
1005
|
-
)}
|
|
1006
|
-
|
|
1007
|
-
{/* Validation Error — display when the SDK flags a required question as unanswered */}
|
|
1008
|
-
{validationError && (
|
|
1009
|
-
/* STYLE: Design a visible error message.
|
|
1010
|
-
Must be clearly noticeable (e.g., red text, bordered box, icon).
|
|
1011
|
-
Do NOT use subtle styling — users must see this immediately. */
|
|
1012
|
-
<div>
|
|
1013
|
-
<p>{validationError}</p>
|
|
1014
|
-
</div>
|
|
1015
|
-
)}
|
|
1016
|
-
</section>
|
|
1017
|
-
);
|
|
1018
|
-
}
|
|
1019
|
-
```
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
---
|
|
1024
|
-
|
|
1025
|
-
### Logic Skeleton: `RatingScale.tsx` — Rating Input
|
|
1026
|
-
|
|
1027
|
-
```tsx
|
|
1028
|
-
import type { SurveyOption, AnswerValue } from '@explorer02/cfm-survey-sdk';
|
|
1029
|
-
|
|
1030
|
-
type RatingScaleProps = {
|
|
1031
|
-
questionId: string;
|
|
1032
|
-
options: SurveyOption[];
|
|
1033
|
-
selectedValue?: AnswerValue;
|
|
1034
|
-
onSelect: (value: AnswerValue) => void;
|
|
1035
|
-
};
|
|
1036
|
-
|
|
1037
|
-
export default function RatingScale({ questionId, options, selectedValue, onSelect }: RatingScaleProps) {
|
|
1038
|
-
return (
|
|
1039
|
-
<div role="radiogroup" className="w-full">
|
|
1040
|
-
{/* Badges row */}
|
|
1041
|
-
<div
|
|
1042
|
-
className="grid gap-0.5 sm:gap-1 mb-3"
|
|
1043
|
-
style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
|
|
1044
|
-
>
|
|
1045
|
-
{options.map((option, index) => (
|
|
1046
|
-
<div key={`${questionId}-badge-${option.value}`} className="flex justify-center">
|
|
1047
|
-
<div
|
|
1048
|
-
className="flex h-5 w-5 sm:h-6 sm:w-6 items-center justify-center rounded-[3px] text-[10px] sm:text-xs font-bold text-white shadow-sm"
|
|
1049
|
-
style={{ backgroundColor: option.color ?? '#e20074' }}
|
|
1050
|
-
>
|
|
1051
|
-
{option.label}
|
|
1052
|
-
</div>
|
|
1053
|
-
</div>
|
|
1054
|
-
))}
|
|
1055
|
-
</div>
|
|
1056
|
-
|
|
1057
|
-
{/* Track row */}
|
|
1058
|
-
<div
|
|
1059
|
-
className="grid bg-[#f2f2f2] rounded-md overflow-hidden h-11 sm:h-12 border border-gray-200/50"
|
|
1060
|
-
style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
|
|
1061
|
-
>
|
|
1062
|
-
{options.map((option, index) => {
|
|
1063
|
-
const isSelected = selectedValue === option.value;
|
|
1064
|
-
return (
|
|
1065
|
-
<label
|
|
1066
|
-
key={`${questionId}-track-${option.value}`}
|
|
1067
|
-
className="flex items-center justify-center cursor-pointer transition-colors relative h-full w-full"
|
|
1068
|
-
>
|
|
1069
|
-
<input
|
|
1070
|
-
type="radio"
|
|
1071
|
-
name={questionId}
|
|
1072
|
-
value={option.value === null ? '' : String(option.value)}
|
|
1073
|
-
checked={isSelected}
|
|
1074
|
-
onChange={() => onSelect(option.value)}
|
|
1075
|
-
className="sr-only"
|
|
1076
|
-
/>
|
|
1077
|
-
<div
|
|
1078
|
-
className={`flex items-center justify-center w-[90%] h-[80%] rounded-[6px] transition-all ${
|
|
1079
|
-
isSelected ? 'bg-[#fbe8f3]' : 'hover:bg-gray-200/40'
|
|
1080
|
-
}`}
|
|
1081
|
-
>
|
|
1082
|
-
{/* Custom Radio Button */}
|
|
1083
|
-
<div
|
|
1084
|
-
className={`flex h-4.5 w-4.5 sm:h-5.5 sm:w-5.5 items-center justify-center rounded-full transition-all ${
|
|
1085
|
-
isSelected
|
|
1086
|
-
? 'border-[4px] border-[#e20074] bg-white shadow-sm'
|
|
1087
|
-
: 'border-2 border-gray-300 bg-white'
|
|
1088
|
-
}`}
|
|
1089
|
-
/>
|
|
1090
|
-
</div>
|
|
1091
|
-
</label>
|
|
1092
|
-
);
|
|
1093
|
-
})}
|
|
1094
|
-
</div>
|
|
1095
|
-
</div>
|
|
1096
|
-
);
|
|
1097
|
-
}
|
|
1098
|
-
```
|
|
1099
|
-
|
|
1100
|
-
---
|
|
1101
|
-
|
|
1102
|
-
### Logic Skeleton: `CsatScale.tsx` — CSAT Satisfaction Input
|
|
1103
|
-
|
|
1104
|
-
```tsx
|
|
1105
|
-
import type { CsatQuestion } from '@explorer02/cfm-survey-sdk';
|
|
1106
|
-
import { CsatEmojiMapping, CsatStarIcons, getEmojiForIndex } from '@explorer02/cfm-survey-sdk';
|
|
1107
|
-
|
|
1108
|
-
type CsatScaleProps = {
|
|
1109
|
-
question: CsatQuestion;
|
|
1110
|
-
selectedValue?: string | number | null;
|
|
1111
|
-
onSelect: (value: string | number | null) => void;
|
|
1112
|
-
};
|
|
1113
|
-
|
|
1114
|
-
export default function CsatScale({ question, selectedValue, onSelect }: CsatScaleProps) {
|
|
1115
|
-
const { buttonType, options, labelPosition } = question;
|
|
1116
|
-
|
|
1117
|
-
if (buttonType === 'dropdown') {
|
|
1118
|
-
return (
|
|
1119
|
-
<div className="w-full max-w-sm">
|
|
1120
|
-
<select
|
|
1121
|
-
className="w-full rounded-[4px] border border-gray-300 bg-white p-3 text-[15px] font-medium text-gray-900 outline-none transition-colors hover:border-gray-400 focus:border-[#e20074] focus:ring-1 focus:ring-[#e20074]"
|
|
1122
|
-
value={selectedValue !== undefined ? String(selectedValue) : ''}
|
|
1123
|
-
onChange={(e) => {
|
|
1124
|
-
const val = e.target.value === 'null' ? null : e.target.value;
|
|
1125
|
-
const numeric = Number(val);
|
|
1126
|
-
onSelect(val !== null && !isNaN(numeric) ? numeric : val);
|
|
1127
|
-
}}
|
|
1128
|
-
>
|
|
1129
|
-
<option value="" disabled>Select an option</option>
|
|
1130
|
-
{options.map((opt, index) => (
|
|
1131
|
-
<option key={`${String(opt.value)}-${index}`} value={String(opt.value)}>
|
|
1132
|
-
{opt.label}
|
|
1133
|
-
</option>
|
|
1134
|
-
))}
|
|
1135
|
-
</select>
|
|
1136
|
-
</div>
|
|
1137
|
-
);
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
return (
|
|
1141
|
-
<div className="flex flex-wrap items-center gap-3">
|
|
1142
|
-
{options.map((option, index) => {
|
|
1143
|
-
const isSelected = selectedValue === option.value;
|
|
1144
|
-
const isNA = option.value === null;
|
|
1145
|
-
|
|
1146
|
-
// Emoji
|
|
1147
|
-
const scaleOptionsLength = options.filter(o => o.value !== null).length;
|
|
1148
|
-
const EmojiNode =
|
|
1149
|
-
buttonType === 'emoji' && !isNA
|
|
1150
|
-
? getEmojiForIndex(index, scaleOptionsLength)
|
|
1151
|
-
: null;
|
|
1152
|
-
|
|
1153
|
-
// Star
|
|
1154
|
-
const StarNode =
|
|
1155
|
-
buttonType === 'star' && !isNA
|
|
1156
|
-
? isSelected ? CsatStarIcons.filled : CsatStarIcons.empty
|
|
1157
|
-
: null;
|
|
1158
|
-
|
|
1159
|
-
// Numbered Button
|
|
1160
|
-
const isNumbered = buttonType === 'numbered' || buttonType === 'number';
|
|
1161
|
-
|
|
1162
|
-
return (
|
|
1163
|
-
<button
|
|
1164
|
-
key={`${String(option.value)}-${index}`}
|
|
1165
|
-
type="button"
|
|
1166
|
-
onClick={() => onSelect(option.value)}
|
|
1167
|
-
className={`flex ${labelPosition === 'top' ? 'flex-col-reverse' : 'flex-col'} items-center justify-center gap-2 rounded-lg border px-5 py-4 transition-all ${
|
|
1168
|
-
isSelected
|
|
1169
|
-
? 'border-[#e20074] bg-[#fdf2f8] shadow-sm'
|
|
1170
|
-
: 'border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50'
|
|
1171
|
-
} ${StarNode ? 'border-transparent bg-transparent hover:bg-transparent shadow-none px-2 py-2' : ''}`}
|
|
1172
|
-
>
|
|
1173
|
-
<div
|
|
1174
|
-
className={`flex h-10 items-center justify-center text-3xl transition-transform ${
|
|
1175
|
-
isSelected ? 'scale-110' : 'scale-100'
|
|
1176
|
-
}`}
|
|
1177
|
-
>
|
|
1178
|
-
{(EmojiNode ?? StarNode ?? (
|
|
1179
|
-
<span className={`text-lg ${isNumbered ? 'font-mono text-xl' : 'font-bold'} ${isSelected ? 'text-[#e20074]' : 'text-gray-700'}`}>
|
|
1180
|
-
{option.value !== null ? option.value : 'N/A'}
|
|
1181
|
-
</span>
|
|
1182
|
-
)) as React.ReactNode}
|
|
1183
|
-
</div>
|
|
1184
|
-
|
|
1185
|
-
{labelPosition !== 'hidden' && !StarNode && (
|
|
1186
|
-
<span
|
|
1187
|
-
className={`text-[13px] font-medium leading-tight ${
|
|
1188
|
-
isSelected ? 'text-[#e20074]' : 'text-gray-600'
|
|
1189
|
-
}`}
|
|
1190
|
-
dangerouslySetInnerHTML={{ __html: option.label }}
|
|
1191
|
-
/>
|
|
1192
|
-
)}
|
|
1193
|
-
</button>
|
|
1194
|
-
);
|
|
1195
|
-
})}
|
|
1196
|
-
</div>
|
|
1197
|
-
);
|
|
1198
|
-
}
|
|
1199
|
-
```
|
|
1200
|
-
|
|
1201
|
-
---
|
|
1202
|
-
|
|
1203
|
-
### Logic Skeleton: `CsatMatrixScale.tsx` — Responsive Grid
|
|
1204
|
-
|
|
1205
|
-
```tsx
|
|
1206
|
-
import type { MatrixQuestion, MatrixAnswerMap } from '@explorer02/cfm-survey-sdk';
|
|
1207
|
-
|
|
1208
|
-
type CsatMatrixScaleProps = {
|
|
1209
|
-
question: MatrixQuestion;
|
|
1210
|
-
selectedValue?: MatrixAnswerMap;
|
|
1211
|
-
onSelect: (value: MatrixAnswerMap) => void;
|
|
1212
|
-
};
|
|
1213
|
-
|
|
1214
|
-
export default function CsatMatrixScale({ question, selectedValue = {}, onSelect }: CsatMatrixScaleProps) {
|
|
1215
|
-
const { rows, columns, hasNotApplicable } = question;
|
|
1216
|
-
|
|
1217
|
-
const handleCellSelect = (rowId: string, colValue: any) => {
|
|
1218
|
-
onSelect({ ...selectedValue, [rowId]: colValue });
|
|
1219
|
-
};
|
|
1220
|
-
|
|
1221
|
-
return (
|
|
1222
|
-
<div>
|
|
1223
|
-
{/* STYLE: Create a responsive layout.
|
|
1224
|
-
On desktop, show a standard table/grid.
|
|
1225
|
-
On mobile, stack the rows vertically and use a dropdown or list. */}
|
|
1226
|
-
|
|
1227
|
-
{rows.map(row => (
|
|
1228
|
-
<div key={row.id}>
|
|
1229
|
-
<h3 dangerouslySetInnerHTML={{ __html: row.text }} />
|
|
1230
|
-
|
|
1231
|
-
{/* Mobile view example: Dropdown */}
|
|
1232
|
-
<div className="md:hidden">
|
|
1233
|
-
<select
|
|
1234
|
-
value={selectedValue[row.id] ?? ''}
|
|
1235
|
-
onChange={(e) => handleCellSelect(row.id, e.target.value)}
|
|
1236
|
-
>
|
|
1237
|
-
<option value="" disabled>Select...</option>
|
|
1238
|
-
{columns.map(col => (
|
|
1239
|
-
<option key={col.id} value={col.value}>{col.label}</option>
|
|
1240
|
-
))}
|
|
1241
|
-
</select>
|
|
1242
|
-
</div>
|
|
1243
|
-
|
|
1244
|
-
{/* Desktop view example: Row of buttons */}
|
|
1245
|
-
<div className="hidden md:flex">
|
|
1246
|
-
{columns.map(col => (
|
|
1247
|
-
<button
|
|
1248
|
-
key={col.id}
|
|
1249
|
-
type="button"
|
|
1250
|
-
onClick={() => handleCellSelect(row.id, col.value)}
|
|
1251
|
-
>
|
|
1252
|
-
{col.label}
|
|
1253
|
-
</button>
|
|
1254
|
-
))}
|
|
1255
|
-
</div>
|
|
1256
|
-
|
|
1257
|
-
{hasNotApplicable && (
|
|
1258
|
-
<label>
|
|
1259
|
-
<input
|
|
1260
|
-
type="checkbox"
|
|
1261
|
-
checked={selectedValue[row.id] === null}
|
|
1262
|
-
onChange={() => handleCellSelect(row.id, selectedValue[row.id] === null ? undefined : null)}
|
|
1263
|
-
/>
|
|
1264
|
-
N/A
|
|
1265
|
-
</label>
|
|
1266
|
-
)}
|
|
1267
|
-
</div>
|
|
1268
|
-
))}
|
|
1269
|
-
</div>
|
|
1270
|
-
);
|
|
1271
|
-
}
|
|
1272
|
-
```
|
|
1273
|
-
|
|
1274
|
-
---
|
|
1275
|
-
|
|
1276
|
-
### Logic Skeleton: `LikertMatrixScale.tsx`
|
|
1277
|
-
|
|
1278
|
-
```tsx
|
|
1279
|
-
import type { MatrixQuestion, MatrixAnswerMap } from '@explorer02/cfm-survey-sdk';
|
|
1280
|
-
|
|
1281
|
-
type LikertMatrixScaleProps = {
|
|
1282
|
-
question: MatrixQuestion;
|
|
1283
|
-
selectedValue?: MatrixAnswerMap;
|
|
1284
|
-
onSelect: (value: MatrixAnswerMap) => void;
|
|
1285
|
-
};
|
|
1286
|
-
|
|
1287
|
-
export default function LikertMatrixScale({ question, selectedValue = {}, onSelect }: LikertMatrixScaleProps) {
|
|
1288
|
-
const { rows, columns } = question;
|
|
1289
|
-
|
|
1290
|
-
const handleCellSelect = (rowId: string, colValue: any) => {
|
|
1291
|
-
onSelect({ ...selectedValue, [rowId]: colValue });
|
|
1292
|
-
};
|
|
1293
|
-
|
|
1294
|
-
return (
|
|
1295
|
-
<table>
|
|
1296
|
-
<thead>
|
|
1297
|
-
<tr>
|
|
1298
|
-
<th />
|
|
1299
|
-
{columns.map(col => (
|
|
1300
|
-
<th key={col.id} style={{ color: col.color }}>{col.label}</th>
|
|
1301
|
-
))}
|
|
1302
|
-
</tr>
|
|
1303
|
-
</thead>
|
|
1304
|
-
<tbody>
|
|
1305
|
-
{rows.map(row => (
|
|
1306
|
-
<tr key={row.id}>
|
|
1307
|
-
<td><span dangerouslySetInnerHTML={{ __html: row.text }} /></td>
|
|
1308
|
-
{columns.map(col => (
|
|
1309
|
-
<td key={col.id}>
|
|
1310
|
-
<button
|
|
1311
|
-
type="button"
|
|
1312
|
-
onClick={() => handleCellSelect(row.id, col.value)}
|
|
1313
|
-
style={{ backgroundColor: selectedValue[row.id] === col.value ? col.color : undefined }}
|
|
1314
|
-
>
|
|
1315
|
-
{col.label}
|
|
1316
|
-
</button>
|
|
1317
|
-
</td>
|
|
1318
|
-
))}
|
|
1319
|
-
</tr>
|
|
1320
|
-
))}
|
|
1321
|
-
</tbody>
|
|
1322
|
-
</table>
|
|
1323
|
-
);
|
|
1324
|
-
}
|
|
1325
|
-
```
|
|
1326
|
-
|
|
1327
|
-
---
|
|
1328
|
-
|
|
1329
|
-
### Logic Skeleton: `SliderMatrixScale.tsx`
|
|
1330
|
-
|
|
1331
|
-
```tsx
|
|
1332
|
-
import React from 'react';
|
|
1333
|
-
import type { SliderMatrixQuestion } from '@explorer02/cfm-survey-sdk';
|
|
1334
|
-
import { CustomSliderTrack } from './CustomSliderTrack';
|
|
1335
|
-
|
|
1336
|
-
type SliderMatrixScaleProps = {
|
|
1337
|
-
question: SliderMatrixQuestion;
|
|
1338
|
-
selectedValue?: Record<string, number | null | 'N/A'>;
|
|
1339
|
-
onSelect: (value: Record<string, number | null | 'N/A'>) => void;
|
|
1340
|
-
};
|
|
1341
|
-
|
|
1342
|
-
export function SliderMatrixScale({ question, selectedValue = {}, onSelect }: SliderMatrixScaleProps) {
|
|
1343
|
-
const { rows, labels, ticks, tickValues, enableInputBox, displayValues, enableNotApplicable, sliderType } = question;
|
|
1344
|
-
|
|
1345
|
-
const handleSliderChange = (rowId: string, val: number | 'N/A') => {
|
|
1346
|
-
onSelect({ ...selectedValue, [rowId]: val });
|
|
1347
|
-
};
|
|
1348
|
-
|
|
1349
|
-
// Assume uniform min/max/step across rows for the header
|
|
1350
|
-
const baseRow = rows[0];
|
|
1351
|
-
const min = baseRow?.min ?? 0;
|
|
1352
|
-
const max = baseRow?.max ?? 10;
|
|
1353
|
-
const step = baseRow?.step ?? 1;
|
|
1354
|
-
|
|
1355
|
-
const marks: number[] = [];
|
|
1356
|
-
if (ticks && ticks > 1) {
|
|
1357
|
-
const markStep = (max - min) / (ticks - 1);
|
|
1358
|
-
for (let i = 0; i < ticks; i++) {
|
|
1359
|
-
marks.push(Number((min + i * markStep).toFixed(2))); // Round to 2 decimal digits
|
|
1360
|
-
}
|
|
1361
|
-
} else {
|
|
1362
|
-
for (let i = min; i <= max; i += step) {
|
|
1363
|
-
marks.push(Number(i.toFixed(2)));
|
|
1364
|
-
}
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
const htmlStep = (ticks && ticks > 1) ? ((max - min) / (ticks - 1)) : step;
|
|
1368
|
-
|
|
1369
|
-
const hasRightText = rows.some(r => !!r.rightText);
|
|
1370
|
-
|
|
1371
|
-
const renderHeaderRow = () => {
|
|
1372
|
-
if ((!labels || labels.length === 0) && (!tickValues || tickValues.length === 0) && hasRightText) {
|
|
1373
|
-
return null;
|
|
1374
|
-
}
|
|
1375
|
-
return (
|
|
1376
|
-
<div style={{
|
|
1377
|
-
display: 'flex', width: '100%', marginBottom: '4px', alignItems: 'flex-end',
|
|
1378
|
-
padding: '0 16px', boxSizing: 'border-box'
|
|
1379
|
-
}}>
|
|
1380
|
-
{/* Left spacer for statement text */}
|
|
1381
|
-
<div style={{ flex: '0 0 25%', paddingRight: '16px' }} />
|
|
1382
|
-
|
|
1383
|
-
{/* Slider track area header */}
|
|
1384
|
-
<div style={{ flex: '1', position: 'relative' }}>
|
|
1385
|
-
{/* Labels row */}
|
|
1386
|
-
{labels && labels.length > 0 && (
|
|
1387
|
-
<div style={{
|
|
1388
|
-
position: 'relative', display: 'flex', justifyContent: 'space-between',
|
|
1389
|
-
margin: '0 16px', marginBottom: '16px', color: '#111827', fontSize: '14px', fontWeight: 500
|
|
1390
|
-
}}>
|
|
1391
|
-
{labels.map((lbl, idx) => (
|
|
1392
|
-
<div key={idx} style={{ width: 0, display: 'flex', justifyContent: 'center' }}>
|
|
1393
|
-
<span style={{ whiteSpace: 'nowrap' }}>{lbl}</span>
|
|
1394
|
-
</div>
|
|
1395
|
-
))}
|
|
1396
|
-
</div>
|
|
1397
|
-
)}
|
|
1398
|
-
|
|
1399
|
-
{/* Numbers row */}
|
|
1400
|
-
<div style={{
|
|
1401
|
-
position: 'relative', margin: '0 16px', marginBottom: '8px',
|
|
1402
|
-
color: '#4b5563', fontSize: '14px', fontWeight: 400, height: '28px'
|
|
1403
|
-
}}>
|
|
1404
|
-
{tickValues && tickValues.length > 0 ? (
|
|
1405
|
-
tickValues.map((tv, idx) => {
|
|
1406
|
-
const percentage = max !== min ? ((tv.value - min) / (max - min)) * 100 : 0;
|
|
1407
|
-
const sliceWidth = tickValues.length > 1 ? 100 / (tickValues.length - 1) : 100;
|
|
1408
|
-
return (
|
|
1409
|
-
<div key={idx} style={{
|
|
1410
|
-
position: 'absolute', left: `${percentage}%`, transform: 'translateX(-50%)',
|
|
1411
|
-
width: `${sliceWidth}%`, display: 'flex', justifyContent: 'center'
|
|
1412
|
-
}}>
|
|
1413
|
-
{sliderType === 'graphics' ? (
|
|
1414
|
-
<div style={{ wordBreak: 'break-word', textAlign: 'center', lineHeight: '1.2', fontWeight: 500, color: '#4b5563', fontSize: '13px' }}>
|
|
1415
|
-
{tv.label}
|
|
1416
|
-
</div>
|
|
1417
|
-
) : (
|
|
1418
|
-
<div style={{
|
|
1419
|
-
backgroundColor: tv.color, color: 'white', padding: '4px 8px', borderRadius: '4px',
|
|
1420
|
-
fontWeight: 600, wordBreak: 'break-word', textAlign: 'center', lineHeight: '1.2',
|
|
1421
|
-
boxShadow: '0 1px 2px rgba(0,0,0,0.1)'
|
|
1422
|
-
}}>
|
|
1423
|
-
{tv.label}
|
|
1424
|
-
</div>
|
|
1425
|
-
)}
|
|
1426
|
-
</div>
|
|
1427
|
-
);
|
|
1428
|
-
})
|
|
1429
|
-
) : (
|
|
1430
|
-
marks.map((m, idx) => {
|
|
1431
|
-
const percentage = max !== min ? ((m - min) / (max - min)) * 100 : 0;
|
|
1432
|
-
const sliceWidth = marks.length > 1 ? 100 / (marks.length - 1) : 100;
|
|
1433
|
-
return (
|
|
1434
|
-
<div key={idx} style={{
|
|
1435
|
-
position: 'absolute', left: `${percentage}%`, transform: 'translateX(-50%)',
|
|
1436
|
-
width: `${sliceWidth}%`, display: 'flex', justifyContent: 'center'
|
|
1437
|
-
}}>
|
|
1438
|
-
<span style={{ wordBreak: 'break-word', textAlign: 'center', lineHeight: '1.2' }}>{m}</span>
|
|
1439
|
-
</div>
|
|
1440
|
-
);
|
|
1441
|
-
})
|
|
1442
|
-
)}
|
|
1443
|
-
</div>
|
|
1444
|
-
</div>
|
|
1445
|
-
|
|
1446
|
-
{/* Right Statement Header Spacer */}
|
|
1447
|
-
{hasRightText && <div style={{ flex: '0 0 25%', paddingLeft: '16px' }} />}
|
|
1448
|
-
|
|
1449
|
-
{/* Right spacers for input box and N/A checkbox if enabled */}
|
|
1450
|
-
{enableInputBox && <div style={{ width: '80px', marginLeft: '16px' }} />}
|
|
1451
|
-
{enableNotApplicable && (
|
|
1452
|
-
<div style={{ width: '40px', marginLeft: '16px', textAlign: 'center', fontSize: '13px', fontWeight: 600, color: '#4b5563', paddingBottom: '8px' }}>
|
|
1453
|
-
N/A
|
|
1454
|
-
</div>
|
|
1455
|
-
)}
|
|
1456
|
-
</div>
|
|
1457
|
-
);
|
|
1458
|
-
};
|
|
1459
|
-
|
|
1460
|
-
return (
|
|
1461
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
1462
|
-
{renderHeaderRow()}
|
|
1463
|
-
|
|
1464
|
-
{rows.map((row) => {
|
|
1465
|
-
const rowVal = selectedValue[row.id];
|
|
1466
|
-
const isNa = rowVal === 'N/A';
|
|
1467
|
-
const val = (typeof rowVal === 'number') ? rowVal : (isNa ? row.min : (row.defaultValue ?? row.min));
|
|
1468
|
-
|
|
1469
|
-
return (
|
|
1470
|
-
<div key={row.id} style={{
|
|
1471
|
-
display: 'flex', width: '100%', alignItems: 'center',
|
|
1472
|
-
backgroundColor: '#f3f4f6', borderRadius: '12px', padding: '16px', boxSizing: 'border-box'
|
|
1473
|
-
}}>
|
|
1474
|
-
{/* Statement Text */}
|
|
1475
|
-
<div style={{ flex: '0 0 25%', paddingRight: '16px', fontSize: '15px', color: '#111827', wordWrap: 'break-word', display: 'flex', alignItems: 'center' }}>
|
|
1476
|
-
{row.text !== 'Question' && row.text !== '' ? (
|
|
1477
|
-
<span dangerouslySetInnerHTML={{ __html: row.text }} />
|
|
1478
|
-
) : null}
|
|
1479
|
-
</div>
|
|
1480
|
-
|
|
1481
|
-
{/* Slider */}
|
|
1482
|
-
<div style={{ flex: '1' }}>
|
|
1483
|
-
<CustomSliderTrack
|
|
1484
|
-
min={row.min}
|
|
1485
|
-
max={row.max}
|
|
1486
|
-
htmlStep={htmlStep}
|
|
1487
|
-
value={val}
|
|
1488
|
-
disabled={isNa}
|
|
1489
|
-
displayValues={displayValues}
|
|
1490
|
-
hasSelectedValue={typeof rowVal === 'number'}
|
|
1491
|
-
sliderType={sliderType}
|
|
1492
|
-
ticks={ticks}
|
|
1493
|
-
onChange={(v) => handleSliderChange(row.id, v)}
|
|
1494
|
-
/>
|
|
1495
|
-
</div>
|
|
1496
|
-
|
|
1497
|
-
{/* Right Statement Text (Bipolar) */}
|
|
1498
|
-
{row.rightText && (
|
|
1499
|
-
<div style={{ flex: '0 0 25%', paddingLeft: '16px', fontSize: '15px', color: '#111827', wordWrap: 'break-word', display: 'flex', alignItems: 'center', textAlign: 'right', justifyContent: 'flex-end' }}>
|
|
1500
|
-
<span dangerouslySetInnerHTML={{ __html: row.rightText }} />
|
|
1501
|
-
</div>
|
|
1502
|
-
)}
|
|
1503
|
-
|
|
1504
|
-
{/* Input Box */}
|
|
1505
|
-
{enableInputBox && (
|
|
1506
|
-
<div style={{
|
|
1507
|
-
marginLeft: '16px', width: '80px', display: 'flex', alignItems: 'stretch',
|
|
1508
|
-
border: '1px solid #d1d5db', borderRadius: '8px', overflow: 'hidden',
|
|
1509
|
-
backgroundColor: isNa ? '#e5e7eb' : '#fff', boxSizing: 'border-box'
|
|
1510
|
-
}}>
|
|
1511
|
-
<input
|
|
1512
|
-
type="text"
|
|
1513
|
-
value={isNa ? '' : Math.round(Number(val))}
|
|
1514
|
-
disabled={isNa}
|
|
1515
|
-
onChange={(e) => {
|
|
1516
|
-
if (e.target.value === '') return;
|
|
1517
|
-
let num = Number(e.target.value);
|
|
1518
|
-
if (!isNaN(num)) {
|
|
1519
|
-
if (num < row.min) num = row.min;
|
|
1520
|
-
if (num > row.max) num = row.max;
|
|
1521
|
-
handleSliderChange(row.id, num);
|
|
1522
|
-
}
|
|
1523
|
-
}}
|
|
1524
|
-
style={{
|
|
1525
|
-
width: '50px', padding: '8px', border: 'none',
|
|
1526
|
-
fontSize: '14px', outline: 'none', backgroundColor: 'transparent', textAlign: 'center'
|
|
1527
|
-
}}
|
|
1528
|
-
/>
|
|
1529
|
-
<div style={{ display: 'flex', flexDirection: 'column', borderLeft: '1px solid #d1d5db', backgroundColor: '#f9fafb', width: '30px' }}>
|
|
1530
|
-
<button
|
|
1531
|
-
disabled={isNa}
|
|
1532
|
-
onClick={() => {
|
|
1533
|
-
const num = Number((Number(val) + htmlStep).toFixed(2));
|
|
1534
|
-
if (num <= row.max) handleSliderChange(row.id, num);
|
|
1535
|
-
}}
|
|
1536
|
-
style={{ flex: 1, border: 'none', background: 'none', cursor: isNa ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#4b5563' }}
|
|
1537
|
-
>
|
|
1538
|
-
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 5L5 1L9 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
|
1539
|
-
</button>
|
|
1540
|
-
<div style={{ height: '1px', backgroundColor: '#d1d5db', width: '100%' }} />
|
|
1541
|
-
<button
|
|
1542
|
-
disabled={isNa}
|
|
1543
|
-
onClick={() => {
|
|
1544
|
-
const num = Number((Number(val) - htmlStep).toFixed(2));
|
|
1545
|
-
if (num >= row.min) handleSliderChange(row.id, num);
|
|
1546
|
-
}}
|
|
1547
|
-
style={{ flex: 1, border: 'none', background: 'none', cursor: isNa ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#4b5563' }}
|
|
1548
|
-
>
|
|
1549
|
-
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
|
1550
|
-
</button>
|
|
1551
|
-
</div>
|
|
1552
|
-
</div>
|
|
1553
|
-
)}
|
|
1554
|
-
|
|
1555
|
-
{/* N/A Checkbox */}
|
|
1556
|
-
{enableNotApplicable && (
|
|
1557
|
-
<div style={{ marginLeft: '16px', width: '40px', display: 'flex', justifyContent: 'center' }}>
|
|
1558
|
-
<input
|
|
1559
|
-
type="checkbox"
|
|
1560
|
-
checked={isNa}
|
|
1561
|
-
onChange={(e) => {
|
|
1562
|
-
if (e.target.checked) {
|
|
1563
|
-
handleSliderChange(row.id, 'N/A');
|
|
1564
|
-
} else {
|
|
1565
|
-
handleSliderChange(row.id, row.defaultValue ?? row.min);
|
|
1566
|
-
}
|
|
1567
|
-
}}
|
|
1568
|
-
style={{ width: '20px', height: '20px', cursor: 'pointer' }}
|
|
1569
|
-
/>
|
|
1570
|
-
</div>
|
|
1571
|
-
)}
|
|
1572
|
-
</div>
|
|
1573
|
-
);
|
|
1574
|
-
})}
|
|
1575
|
-
</div>
|
|
1576
|
-
);
|
|
1577
|
-
}
|
|
1578
|
-
```
|
|
1579
|
-
|
|
1580
|
-
---
|
|
1581
|
-
|
|
1582
|
-
### Logic Skeleton: `CustomSliderTrack.tsx`
|
|
1583
|
-
|
|
1584
|
-
```tsx
|
|
1585
|
-
import React from 'react';
|
|
1586
|
-
import { getEmojiForIndex } from '@explorer02/cfm-survey-sdk';
|
|
1587
|
-
|
|
1588
|
-
type CustomSliderTrackProps = {
|
|
1589
|
-
min: number;
|
|
1590
|
-
max: number;
|
|
1591
|
-
htmlStep: number;
|
|
1592
|
-
value: number;
|
|
1593
|
-
disabled: boolean;
|
|
1594
|
-
displayValues?: boolean;
|
|
1595
|
-
hasSelectedValue: boolean;
|
|
1596
|
-
sliderType?: 'graphics';
|
|
1597
|
-
ticks?: number;
|
|
1598
|
-
reverseScaleOrder?: boolean;
|
|
1599
|
-
onChange: (v: number) => void;
|
|
1600
|
-
};
|
|
1601
|
-
|
|
1602
|
-
export function CustomSliderTrack({
|
|
1603
|
-
min,
|
|
1604
|
-
max,
|
|
1605
|
-
htmlStep,
|
|
1606
|
-
value,
|
|
1607
|
-
disabled,
|
|
1608
|
-
displayValues,
|
|
1609
|
-
hasSelectedValue,
|
|
1610
|
-
sliderType,
|
|
1611
|
-
ticks,
|
|
1612
|
-
reverseScaleOrder,
|
|
1613
|
-
onChange
|
|
1614
|
-
}: CustomSliderTrackProps) {
|
|
1615
|
-
const [isHovered, setIsHovered] = React.useState(false);
|
|
1616
|
-
const [isDragging, setIsDragging] = React.useState(false);
|
|
1617
|
-
|
|
1618
|
-
const percentage = ((value - min) / (max - min)) * 100;
|
|
1619
|
-
|
|
1620
|
-
// Use gray color if disabled, otherwise use pink/magenta theme color
|
|
1621
|
-
const themeColor = disabled ? '#9ca3af' : '#e20074';
|
|
1622
|
-
|
|
1623
|
-
return (
|
|
1624
|
-
<div
|
|
1625
|
-
style={{ position: 'relative', height: '32px', display: 'flex', alignItems: 'center', margin: '0 16px', opacity: disabled ? 0.5 : 1 }}
|
|
1626
|
-
onMouseEnter={() => !disabled && setIsHovered(true)}
|
|
1627
|
-
onMouseLeave={() => { setIsHovered(false); setIsDragging(false); }}
|
|
1628
|
-
>
|
|
1629
|
-
<div style={{
|
|
1630
|
-
position: 'absolute', top: '50%', left: 0, right: 0, transform: 'translateY(-50%)',
|
|
1631
|
-
height: '4px', borderRadius: '2px', backgroundColor: '#e5e7eb'
|
|
1632
|
-
}} />
|
|
1633
|
-
|
|
1634
|
-
<div style={{
|
|
1635
|
-
position: 'absolute', top: '50%', left: 0, width: `${percentage}%`, transform: 'translateY(-50%)',
|
|
1636
|
-
height: '4px', borderRadius: '2px', backgroundColor: themeColor,
|
|
1637
|
-
transition: isDragging ? 'none' : 'width 0.15s ease-in-out'
|
|
1638
|
-
}} />
|
|
1639
|
-
|
|
1640
|
-
{/* Ticks on track */}
|
|
1641
|
-
{ticks && ticks > 1 && Array.from({ length: ticks }).map((_, idx) => {
|
|
1642
|
-
const tickPct = (idx / (ticks - 1)) * 100;
|
|
1643
|
-
return (
|
|
1644
|
-
<div
|
|
1645
|
-
key={idx}
|
|
1646
|
-
style={{
|
|
1647
|
-
position: 'absolute',
|
|
1648
|
-
top: '50%',
|
|
1649
|
-
left: `${tickPct}%`,
|
|
1650
|
-
transform: 'translate(-50%, -50%)',
|
|
1651
|
-
width: '1px',
|
|
1652
|
-
height: '8px',
|
|
1653
|
-
backgroundColor: '#d1d5db',
|
|
1654
|
-
pointerEvents: 'none'
|
|
1655
|
-
}}
|
|
1656
|
-
/>
|
|
1657
|
-
);
|
|
1658
|
-
})}
|
|
1659
|
-
|
|
1660
|
-
<input
|
|
1661
|
-
type="range"
|
|
1662
|
-
min={min} max={max} step={htmlStep} value={value}
|
|
1663
|
-
disabled={disabled}
|
|
1664
|
-
onChange={(e) => onChange(Number(e.target.value))}
|
|
1665
|
-
onMouseDown={() => !disabled && setIsDragging(true)}
|
|
1666
|
-
onMouseUp={() => setIsDragging(false)}
|
|
1667
|
-
onTouchStart={() => !disabled && setIsDragging(true)}
|
|
1668
|
-
onTouchEnd={() => setIsDragging(false)}
|
|
1669
|
-
style={{
|
|
1670
|
-
position: 'absolute', top: '50%', left: '-16px', right: '-16px', width: 'calc(100% + 32px)', height: '100%',
|
|
1671
|
-
transform: 'translateY(-50%)', opacity: 0, cursor: disabled ? 'not-allowed' : 'pointer', zIndex: 10, margin: 0,
|
|
1672
|
-
}}
|
|
1673
|
-
/>
|
|
1674
|
-
|
|
1675
|
-
{sliderType === 'graphics' ? (
|
|
1676
|
-
<div style={{
|
|
1677
|
-
position: 'absolute', top: '50%', left: `${percentage}%`,
|
|
1678
|
-
transform: 'translate(-50%, -50%)',
|
|
1679
|
-
transition: isDragging ? 'none' : 'left 0.15s ease-in-out',
|
|
1680
|
-
pointerEvents: 'none', zIndex: 11,
|
|
1681
|
-
opacity: disabled ? 0.5 : 1
|
|
1682
|
-
}}>
|
|
1683
|
-
{getEmojiForIndex(
|
|
1684
|
-
reverseScaleOrder
|
|
1685
|
-
? (ticks || 10) - 1 - Math.min((ticks || 10) - 1, Math.max(0, Math.round(((value - min) / (max - min)) * ((ticks || 10) - 1))))
|
|
1686
|
-
: Math.min((ticks || 10) - 1, Math.max(0, Math.round(((value - min) / (max - min)) * ((ticks || 10) - 1)))),
|
|
1687
|
-
ticks || 10
|
|
1688
|
-
) as React.ReactNode}
|
|
1689
|
-
</div>
|
|
1690
|
-
) : (
|
|
1691
|
-
<div style={{
|
|
1692
|
-
position: 'absolute', top: '50%', left: `${percentage}%`,
|
|
1693
|
-
transform: 'translate(-50%, -50%)',
|
|
1694
|
-
width: '18px',
|
|
1695
|
-
height: '18px',
|
|
1696
|
-
borderRadius: '50%',
|
|
1697
|
-
backgroundColor: '#ffffff',
|
|
1698
|
-
border: `2.5px solid ${themeColor}`,
|
|
1699
|
-
boxShadow: (isHovered || isDragging) && !disabled
|
|
1700
|
-
? '0 0 0 6px rgba(226, 0, 116, 0.2), 0 1px 3px rgba(0,0,0,0.2)'
|
|
1701
|
-
: '0 1px 3px rgba(0,0,0,0.2)',
|
|
1702
|
-
pointerEvents: 'none',
|
|
1703
|
-
transition: isDragging ? 'none' : 'left 0.15s ease-in-out, box-shadow 0.15s, border-color 0.2s',
|
|
1704
|
-
boxSizing: 'border-box'
|
|
1705
|
-
}} />
|
|
1706
|
-
)}
|
|
1707
|
-
|
|
1708
|
-
{(isHovered || isDragging) && !disabled && (
|
|
1709
|
-
<div style={{
|
|
1710
|
-
position: 'absolute', bottom: '100%', left: `${percentage}%`,
|
|
1711
|
-
transform: 'translate(-50%, -10px)',
|
|
1712
|
-
backgroundColor: '#1f2937', color: '#fff', fontSize: '13px', fontWeight: 600,
|
|
1713
|
-
padding: '4px 10px', borderRadius: '6px', pointerEvents: 'none',
|
|
1714
|
-
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
|
1715
|
-
zIndex: 20
|
|
1716
|
-
}}>
|
|
1717
|
-
{Math.round(value)}
|
|
1718
|
-
<div style={{
|
|
1719
|
-
position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
|
1720
|
-
borderWidth: '5px', borderStyle: 'solid',
|
|
1721
|
-
borderColor: '#1f2937 transparent transparent transparent'
|
|
1722
|
-
}} />
|
|
1723
|
-
</div>
|
|
1724
|
-
)}
|
|
1725
|
-
</div>
|
|
1726
|
-
);
|
|
1727
|
-
}
|
|
1728
|
-
```
|
|
1729
|
-
|
|
1730
|
-
---
|
|
1731
|
-
|
|
1732
|
-
### Logic Skeleton: `FileUploadScale.tsx`
|
|
1733
|
-
|
|
1734
|
-
```tsx
|
|
1735
|
-
import { useState, useRef } from 'react';
|
|
1736
|
-
import type { FileUploadQuestion } from '@explorer02/cfm-survey-sdk';
|
|
1737
|
-
|
|
1738
|
-
export default function FileUploadScale({ question, selectedValue, onSelect }: { question: FileUploadQuestion, selectedValue: any, onSelect: any }) {
|
|
1739
|
-
const { maxFileCount = 1, fileSizeLimit = 10, fileSizeLimitType, supportedFileFormats = [], uploadMessage } = question;
|
|
1740
|
-
const [isDragActive, setIsDragActive] = useState(false);
|
|
1741
|
-
const [error, setError] = useState('');
|
|
1742
|
-
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
1743
|
-
|
|
1744
|
-
const currentFiles: File[] = Array.isArray(selectedValue) ? selectedValue : [];
|
|
1745
|
-
|
|
1746
|
-
const handleFiles = (files: FileList) => {
|
|
1747
|
-
setError('');
|
|
1748
|
-
const newFiles = Array.from(files);
|
|
1749
|
-
|
|
1750
|
-
// Validate count
|
|
1751
|
-
if (currentFiles.length + newFiles.length > maxFileCount) {
|
|
1752
|
-
setError(`Maximum ${maxFileCount} files allowed.`);
|
|
1753
|
-
return;
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
// Validate extensions & size
|
|
1757
|
-
for (const file of newFiles) {
|
|
1758
|
-
const ext = file.name.split('.').pop()?.toUpperCase() || '';
|
|
1759
|
-
if (supportedFileFormats.length > 0 && !supportedFileFormats.includes(ext)) {
|
|
1760
|
-
setError(`File type .${ext} not supported.`);
|
|
1761
|
-
return;
|
|
1762
|
-
}
|
|
1763
|
-
if (fileSizeLimitType === 'PER_FILE' && file.size > fileSizeLimit * 1024 * 1024) {
|
|
1764
|
-
setError(`File exceeds limit of ${fileSizeLimit}MB.`);
|
|
1765
|
-
return;
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
|
|
1769
|
-
onSelect([...currentFiles, ...newFiles]);
|
|
1770
|
-
};
|
|
1771
|
-
|
|
1772
|
-
const handleDrop = (e: React.DragEvent) => {
|
|
1773
|
-
e.preventDefault(); setIsDragActive(false);
|
|
1774
|
-
if (e.dataTransfer.files) handleFiles(e.dataTransfer.files);
|
|
1775
|
-
};
|
|
1776
|
-
|
|
1777
|
-
const handleDragOver = (e: React.DragEvent) => {
|
|
1778
|
-
e.preventDefault(); setIsDragActive(true);
|
|
1779
|
-
};
|
|
1780
|
-
|
|
1781
|
-
return (
|
|
1782
|
-
<div>
|
|
1783
|
-
<input
|
|
1784
|
-
type="file"
|
|
1785
|
-
hidden ref={fileInputRef}
|
|
1786
|
-
onChange={(e) => e.target.files && handleFiles(e.target.files)}
|
|
1787
|
-
multiple={maxFileCount > 1}
|
|
1788
|
-
/>
|
|
1789
|
-
|
|
1790
|
-
<div
|
|
1791
|
-
onDrop={handleDrop}
|
|
1792
|
-
onDragOver={handleDragOver}
|
|
1793
|
-
onDragLeave={() => setIsDragActive(false)}
|
|
1794
|
-
onClick={() => fileInputRef.current?.click()}
|
|
1795
|
-
/* STYLE: Apply dashed border, drag active state styles */
|
|
1796
|
-
>
|
|
1797
|
-
<span dangerouslySetInnerHTML={{ __html: uploadMessage || 'Drag and drop files here' }} />
|
|
1798
|
-
</div>
|
|
1799
|
-
|
|
1800
|
-
{error && <div /* STYLE: Error message styling */>{error}</div>}
|
|
1801
|
-
|
|
1802
|
-
<div>
|
|
1803
|
-
{currentFiles.map((f, i) => (
|
|
1804
|
-
<div key={i}>
|
|
1805
|
-
{f.name} ({(f.size / 1024 / 1024).toFixed(2)} MB)
|
|
1806
|
-
<button type="button" onClick={() => onSelect(currentFiles.filter((_, idx) => idx !== i))}>Remove</button>
|
|
1807
|
-
</div>
|
|
1808
|
-
))}
|
|
1809
|
-
</div>
|
|
1810
|
-
</div>
|
|
1811
|
-
);
|
|
1812
|
-
}
|
|
1813
|
-
```
|
|
1814
|
-
|
|
1815
|
-
---
|
|
1816
|
-
|
|
1817
|
-
### Logic Skeleton: `LanguageSelector.tsx`
|
|
1818
|
-
|
|
1819
|
-
```tsx
|
|
1820
|
-
import type { SurveyLanguage } from '@explorer02/cfm-survey-sdk';
|
|
1821
|
-
|
|
1822
|
-
type LanguageSelectorProps = {
|
|
1823
|
-
languages: SurveyLanguage[];
|
|
1824
|
-
selectedLanguage: string;
|
|
1825
|
-
onChange: (languageCode: string) => void;
|
|
1826
|
-
};
|
|
1827
|
-
|
|
1828
|
-
export default function LanguageSelector({
|
|
1829
|
-
languages,
|
|
1830
|
-
selectedLanguage,
|
|
1831
|
-
onChange,
|
|
1832
|
-
}: LanguageSelectorProps) {
|
|
1833
|
-
if (!languages || languages.length <= 1) {
|
|
1834
|
-
return null;
|
|
1835
|
-
}
|
|
1836
|
-
|
|
1837
|
-
return (
|
|
1838
|
-
<div className="flex justify-end mb-6 mt-4">
|
|
1839
|
-
<div className="relative">
|
|
1840
|
-
<select
|
|
1841
|
-
value={selectedLanguage}
|
|
1842
|
-
onChange={(e) => onChange(e.target.value)}
|
|
1843
|
-
className="appearance-none rounded-lg border border-[#2563eb] bg-white py-2 pl-4 pr-10 text-sm font-semibold text-[#2563eb] focus:outline-none focus:ring-2 focus:ring-[#2563eb] cursor-pointer"
|
|
1844
|
-
>
|
|
1845
|
-
{languages.map((lang) => (
|
|
1846
|
-
<option key={lang.code} value={lang.code} className="text-gray-900">
|
|
1847
|
-
{lang.name}
|
|
1848
|
-
</option>
|
|
1849
|
-
))}
|
|
1850
|
-
</select>
|
|
1851
|
-
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
|
1852
|
-
<svg className="h-4 w-4 text-[#2563eb]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1853
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
|
1854
|
-
</svg>
|
|
1855
|
-
</div>
|
|
1856
|
-
</div>
|
|
1857
|
-
</div>
|
|
1858
|
-
);
|
|
1859
|
-
}
|
|
1860
|
-
```
|
|
1861
|
-
|
|
1862
|
-
---
|
|
1863
|
-
|
|
1864
|
-
### Logic Skeleton: `ProgressBar.tsx`
|
|
1865
|
-
|
|
1866
|
-
```tsx
|
|
1867
|
-
type ProgressBarProps = {
|
|
1868
|
-
progressPercentage: number;
|
|
1869
|
-
};
|
|
1870
|
-
|
|
1871
|
-
export default function ProgressBar({ progressPercentage }: ProgressBarProps) {
|
|
1872
|
-
return (
|
|
1873
|
-
/* STYLE: Design a progress indicator matching the mockup.
|
|
1874
|
-
Common patterns: horizontal bar, circular/radial progress, step indicator, percentage text.
|
|
1875
|
-
Use progressPercentage (0–100) to control the fill width/amount.
|
|
1876
|
-
Add a smooth transition animation for width changes. */
|
|
1877
|
-
<div>
|
|
1878
|
-
<div style={{ width: `${progressPercentage}%` }}>
|
|
1879
|
-
{/* Progress fill */}
|
|
1880
|
-
</div>
|
|
1881
|
-
</div>
|
|
1882
|
-
);
|
|
1883
|
-
}
|
|
1884
|
-
```
|
|
1885
|
-
|
|
1886
|
-
---
|
|
1887
|
-
|
|
1888
|
-
### Logic Skeleton: `Header.tsx` and `Footer.tsx`
|
|
1889
|
-
|
|
1890
|
-
These are **fully custom layout components** — there is no SDK logic here. Build them entirely from the client's mockup/brand:
|
|
1891
|
-
|
|
1892
|
-
- **Header**: May include logo (use Next.js `<Image>` with `priority`), survey title, brand graphics.
|
|
1893
|
-
- **Footer**: May include legal links (Privacy Policy, Imprint), copyright text, brand logo.
|
|
1894
|
-
|
|
1895
|
-
If the mockup doesn't show a header or footer, you may omit these components.
|
|
1896
|
-
|
|
1897
|
-
---
|
|
1898
|
-
|
|
1899
|
-
## 5. Critical Constraints & Rules
|
|
1900
|
-
|
|
1901
|
-
These are **non-negotiable** requirements that must be followed in every build:
|
|
1902
|
-
|
|
1903
|
-
### 5a. HTML Element IDs for Scroll Navigation
|
|
1904
|
-
|
|
1905
|
-
The SDK internally uses `document.getElementById(question.id)` to find question elements in the DOM for:
|
|
1906
|
-
- **Scroll-to-error**: When validation fails, the SDK scrolls to the first unanswered required question.
|
|
1907
|
-
- **Page transition scroll**: When navigating to a new page, the SDK scrolls to the first question.
|
|
1908
|
-
|
|
1909
|
-
**Rule**: Every question wrapper element MUST have `id={question.id}`:
|
|
1910
|
-
```tsx
|
|
1911
|
-
// ✅ CORRECT — SDK can find this element
|
|
1912
|
-
<section id={question.id}>...</section>
|
|
1913
|
-
|
|
1914
|
-
// ❌ WRONG — SDK cannot find the element, scroll will fail silently
|
|
1915
|
-
<section>...</section>
|
|
1916
|
-
<section id={`question-${question.id}`}>...</section> // Modified ID, won't match
|
|
1917
|
-
```
|
|
1918
|
-
|
|
1919
|
-
### 5b. Import Rules
|
|
1920
|
-
|
|
1921
|
-
| Import | Source | Used In |
|
|
1922
|
-
|--------|--------|---------|
|
|
1923
|
-
| `useSurveySDK` | `@explorer02/cfm-survey-sdk` | `SurveyPage.tsx` only |
|
|
1924
|
-
| `type SurveyQuestion` | `@explorer02/cfm-survey-sdk` | `Question.tsx` component props |
|
|
1925
|
-
| `type SurveyLanguage` | `@explorer02/cfm-survey-sdk` | `LanguageSelector.tsx` component props |
|
|
1926
|
-
|
|
1927
|
-
**Never import internal SDK modules**. Only use the public exports listed above.
|
|
1928
|
-
|
|
1929
|
-
### 5c. `'use client'` Directive
|
|
1930
|
-
|
|
1931
|
-
The `SurveyPage.tsx` component MUST have `'use client';` as the very first line. This is required because:
|
|
1932
|
-
- `useSurveySDK()` is a React hook (client-only).
|
|
1933
|
-
- `useState` for language selection is client-only.
|
|
1934
|
-
|
|
1935
|
-
Child components (`Question`, `Header`, `Footer`, etc.) do NOT need `'use client'` because they receive data as props from the parent client component.
|
|
1936
|
-
|
|
1937
|
-
### 5d. `<Suspense>` Boundary
|
|
1938
|
-
|
|
1939
|
-
The main `page.tsx` must wrap `<SurveyPage />` in a `<Suspense>` boundary. This prevents hydration mismatches and provides a loading fallback while the client component initialises.
|
|
1940
|
-
|
|
1941
|
-
### 5e. Placeholder Constants
|
|
1942
|
-
|
|
1943
|
-
Define placeholder values as a **constant object outside the component** to avoid unnecessary re-renders:
|
|
1944
|
-
|
|
1945
|
-
```tsx
|
|
1946
|
-
// ✅ CORRECT — stable reference, defined outside component
|
|
1947
|
-
const SURVEY_PLACEHOLDERS = { FIRST_NAME: 'John', LAST_NAME: 'Doe' };
|
|
1948
|
-
|
|
1949
|
-
export default function SurveyPage() {
|
|
1950
|
-
const { ... } = useSurveySDK({ options: { placeholders: SURVEY_PLACEHOLDERS } });
|
|
1951
|
-
}
|
|
1952
|
-
|
|
1953
|
-
// ❌ WRONG — new object created every render, triggers infinite re-fetch
|
|
1954
|
-
export default function SurveyPage() {
|
|
1955
|
-
const { ... } = useSurveySDK({ options: { placeholders: { FIRST_NAME: 'John' } } });
|
|
1956
|
-
}
|
|
1957
|
-
```
|
|
1958
|
-
|
|
1959
|
-
### 5f. Action Dispatching Pattern
|
|
1960
|
-
|
|
1961
|
-
Always use `onAction()` for ALL user interactions. Never call internal SDK functions directly:
|
|
1962
|
-
|
|
1963
|
-
```tsx
|
|
1964
|
-
// ✅ CORRECT
|
|
1965
|
-
onAction({ type: 'CHANGE', payload: { questionId: question.id, value: newValue } })
|
|
1966
|
-
onAction({ type: 'NEXT' })
|
|
1967
|
-
onAction({ type: 'PREVIOUS' })
|
|
1968
|
-
|
|
1969
|
-
// ❌ WRONG — bypasses SDK validation, logging, and analytics
|
|
1970
|
-
setValues(prev => ({ ...prev, [question.id]: newValue }))
|
|
1971
|
-
setCurrentPageIndex(prev => prev + 1)
|
|
1972
|
-
```
|
|
1973
|
-
|
|
1974
|
-
---
|
|
1975
|
-
|
|
1976
|
-
## 6. UI Fidelity & Styling Rules
|
|
1977
|
-
|
|
1978
|
-
### Styling Framework
|
|
1979
|
-
|
|
1980
|
-
**Default**: Use **Tailwind CSS** utility classes for all styling. The `create-next-app --tailwind` setup in Phase 2 configures this automatically.
|
|
1981
|
-
|
|
1982
|
-
**Alternative**: If the client explicitly requests a different styling approach (vanilla CSS, CSS Modules, styled-components, etc.), you may use that instead. The SDK has **zero CSS dependencies** — it works with any styling solution.
|
|
1983
|
-
|
|
1984
|
-
---
|
|
1985
|
-
|
|
1986
|
-
### 6a. Design Analysis Checklist (When Client Provides Screenshots)
|
|
1987
|
-
|
|
1988
|
-
When the client shares UI mockup images, **systematically extract every design detail** before writing any code. Go through this checklist:
|
|
1989
|
-
|
|
1990
|
-
#### Colors
|
|
1991
|
-
- [ ] **Primary brand color** — The dominant accent color (buttons, progress bar, selected states)
|
|
1992
|
-
- [ ] **Secondary color** — If used for secondary buttons, links, or accents
|
|
1993
|
-
- [ ] **Background color** — Page background (white, off-white, dark, gradient?)
|
|
1994
|
-
- [ ] **Card/surface color** — Background for question cards or content areas
|
|
1995
|
-
- [ ] **Text colors** — Primary text, secondary/muted text, heading text
|
|
1996
|
-
- [ ] **Border colors** — Default borders, active/selected borders
|
|
1997
|
-
- [ ] **Error color** — Validation error messages and indicators
|
|
1998
|
-
- [ ] **Selected state colors** — Background tint and border for selected options
|
|
1999
|
-
|
|
2000
|
-
#### Typography
|
|
2001
|
-
- [ ] **Font family** — Identify the typeface (use Google Fonts if not a system font)
|
|
2002
|
-
- [ ] **Heading sizes** — H1, H2, question title sizes
|
|
2003
|
-
- [ ] **Body text size** — Default paragraph and option label sizes
|
|
2004
|
-
- [ ] **Font weights** — Regular, medium, semibold, bold usage
|
|
2005
|
-
- [ ] **Line heights** — Tight, normal, or relaxed spacing
|
|
2006
|
-
|
|
2007
|
-
#### Layout & Spacing
|
|
2008
|
-
- [ ] **Page max-width** — Content area width (narrow, medium, wide, full-bleed)
|
|
2009
|
-
- [ ] **Header height and structure** — Fixed? Contains logo? Background color?
|
|
2010
|
-
- [ ] **Content padding** — Horizontal and vertical spacing around the main content
|
|
2011
|
-
- [ ] **Question spacing** — Gap between consecutive questions
|
|
2012
|
-
- [ ] **Option spacing** — Gap between radio options or rating badges
|
|
2013
|
-
- [ ] **Footer position** — Sticky? Fixed at bottom? Below content?
|
|
2014
|
-
|
|
2015
|
-
#### Component Styles
|
|
2016
|
-
- [ ] **Card design** — Border radius, shadow, padding, background
|
|
2017
|
-
- [ ] **Button design** — Border radius, padding, font size, hover/active/disabled states
|
|
2018
|
-
- [ ] **Radio/option cards** — Shape, border style, selected highlight
|
|
2019
|
-
- [ ] **Rating scale style** — Badges, circles, numbered grid, stars?
|
|
2020
|
-
- [ ] **Progress bar style** — Bar thickness, border radius, animation
|
|
2021
|
-
- [ ] **Input field style** — Border, border radius, focus ring
|
|
2022
|
-
|
|
2023
|
-
#### Interactive States
|
|
2024
|
-
- [ ] **Hover effects** — Color changes, shadows, scale transforms
|
|
2025
|
-
- [ ] **Selected state** — How selected options differ from unselected
|
|
2026
|
-
- [ ] **Disabled state** — How the submit button looks while submitting
|
|
2027
|
-
- [ ] **Error state** — How validation errors are displayed (inline, toast, bordered box)
|
|
2028
|
-
- [ ] **Transitions** — Smooth transitions on state changes (duration, easing)
|
|
2029
|
-
|
|
2030
|
-
After completing this checklist, define the extracted values as **CSS custom properties** in `globals.css`:
|
|
2031
|
-
|
|
2032
|
-
```css
|
|
2033
|
-
@import "tailwindcss";
|
|
2034
|
-
|
|
2035
|
-
:root {
|
|
2036
|
-
--brand-color: /* extracted primary color */;
|
|
2037
|
-
--brand-bg-light: /* light tint of primary for selected backgrounds */;
|
|
2038
|
-
--bg-page: /* page background */;
|
|
2039
|
-
--bg-card: /* card/surface background */;
|
|
2040
|
-
--text-primary: /* primary text color */;
|
|
2041
|
-
--text-secondary: /* secondary/muted text */;
|
|
2042
|
-
--border-default: /* default border color */;
|
|
2043
|
-
--border-active: /* selected/active border color */;
|
|
2044
|
-
--radius-card: /* card border radius */;
|
|
2045
|
-
--radius-button: /* button border radius */;
|
|
2046
|
-
}
|
|
2047
|
-
```
|
|
2048
|
-
|
|
2049
|
-
---
|
|
2050
|
-
|
|
2051
|
-
### 6b. When the Client Provides a Color Scheme or Brand Guidelines
|
|
2052
|
-
|
|
2053
|
-
Apply the primary color to:
|
|
2054
|
-
- Progress bar fill
|
|
2055
|
-
- Selected radio button indicators
|
|
2056
|
-
- Selected rating scale highlights
|
|
2057
|
-
- Primary action button (Next/Submit) background
|
|
2058
|
-
- Focus ring on text inputs
|
|
2059
|
-
- Active border on radio option cards
|
|
2060
|
-
|
|
2061
|
-
Apply a light tint of the primary color to:
|
|
2062
|
-
- Selected radio card backgrounds
|
|
2063
|
-
- Selected rating scale cell backgrounds
|
|
2064
|
-
|
|
2065
|
-
---
|
|
2066
|
-
|
|
2067
|
-
### 6c. No-Mockup Default Design
|
|
2068
|
-
|
|
2069
|
-
When the client provides **only a text prompt with no screenshots, mockup, or brand guidelines**, use this clean, modern default design system:
|
|
2070
|
-
|
|
2071
|
-
| Property | Default Value |
|
|
2072
|
-
|----------|---------------|
|
|
2073
|
-
| **Background** | White (`#ffffff`) |
|
|
2074
|
-
| **Text color** | Dark gray (`#1a1a1a`) |
|
|
2075
|
-
| **Secondary text** | Medium gray (`#6b7280`) |
|
|
2076
|
-
| **Brand/accent color** | Ask the client; if none given, use a professional blue (`#2563eb`) |
|
|
2077
|
-
| **Font family** | System font stack: `system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif` |
|
|
2078
|
-
| **Content max-width** | `max-w-3xl` (48rem / 768px) |
|
|
2079
|
-
| **Card style** | White background, 1px `#e5e7eb` border, `0.75rem` border radius, `1.5rem` padding |
|
|
2080
|
-
| **Button style** | Brand color background, white text, `0.5rem` border radius, medium font weight |
|
|
2081
|
-
| **Radio options** | Card-style with border, subtle hover bg, brand-color border + light tint bg when selected |
|
|
2082
|
-
| **Rating scale** | Horizontal row of numbered badges, brand-color highlight on selected |
|
|
2083
|
-
| **Progress bar** | 4px height, gray-100 track, brand-color fill, rounded-full, smooth width transition |
|
|
2084
|
-
| **Spacing** | `2rem` between questions, `1rem` between options |
|
|
2085
|
-
| **Transitions** | `transition-all duration-200` on interactive elements |
|
|
2086
|
-
|
|
2087
|
-
This creates a neutral, professional design that works for any brand. The agent should **still ask the client for their brand color** — it's the single most impactful customisation.
|
|
2088
|
-
|
|
2089
|
-
---
|
|
2090
|
-
|
|
2091
|
-
### 6d. General Styling Standards (Apply to All Builds)
|
|
2092
|
-
|
|
2093
|
-
- Ensure full **responsive design** (mobile-first with `sm:`, `md:`, `lg:` breakpoints).
|
|
2094
|
-
- Add **smooth transitions** on interactive elements.
|
|
2095
|
-
- Use **`sr-only`** class to hide native radio inputs while keeping them accessible.
|
|
2096
|
-
- Rating badges should display the option's `color` property using inline `style={{ backgroundColor: option.color }}` when available.
|
|
2097
|
-
- Text areas should show a **character count** using `question.maxCharacterCount`.
|
|
2098
|
-
- Validation errors should be **visually prominent** (not subtle gray text).
|
|
2099
|
-
|
|
2100
|
-
### 6e. Logo Handling
|
|
2101
|
-
|
|
2102
|
-
When a logo is provided (either `logo.fileName` or `logo.defaultUrl`):
|
|
2103
|
-
1. Render it in the header as an industry-standard practice.
|
|
2104
|
-
2. Apply styling to ensure it is displayed in a standard size. Regardless of its original pixel size or resolution, it MUST NOT exceed standard dimensions. Specifically, set its maximum height to 40px, preserve aspect ratio, and handle overflow. Do NOT hardcode large width/height dimensions that might break the layout. For example, use `style={{ maxHeight: '40px', width: 'auto', objectFit: 'contain', display: 'block' }}` or equivalent Tailwind classes.
|
|
2105
|
-
3. If using Next.js `<Image>`, do NOT let the width/height attributes override standard constraints. Always constrain the containing element or the image itself to `max-height: 40px` and `width: auto`.
|
|
2106
|
-
|
|
2107
|
-
---
|
|
2108
|
-
|
|
2109
|
-
## 7. Configuration & Build Requirements
|
|
2110
|
-
|
|
2111
|
-
### 7a. Required Dependencies
|
|
2112
|
-
|
|
2113
|
-
```json
|
|
2114
|
-
{
|
|
2115
|
-
"dependencies": {
|
|
2116
|
-
"@explorer02/cfm-survey-sdk": "latest",
|
|
2117
|
-
"@tanstack/react-query": "^5.0.0",
|
|
2118
|
-
"next": "^15.0.0",
|
|
2119
|
-
"react": "^19.0.0",
|
|
2120
|
-
"react-dom": "^19.0.0"
|
|
2121
|
-
},
|
|
2122
|
-
"devDependencies": {
|
|
2123
|
-
"@tailwindcss/postcss": "^4.0.0",
|
|
2124
|
-
"@types/node": "^22.0.0",
|
|
2125
|
-
"@types/react": "^19.0.0",
|
|
2126
|
-
"@types/react-dom": "^19.0.0",
|
|
2127
|
-
"eslint": "^9.0.0",
|
|
2128
|
-
"eslint-config-next": "^15.0.0",
|
|
2129
|
-
"postcss": "^8.0.0",
|
|
2130
|
-
"tailwindcss": "^4.0.0",
|
|
2131
|
-
"typescript": "^5.0.0"
|
|
2132
|
-
}
|
|
2133
|
-
}
|
|
2134
|
-
```
|
|
2135
|
-
|
|
2136
|
-
### 7b. `next.config.js` — MANDATORY Configuration
|
|
2137
|
-
|
|
2138
|
-
```javascript
|
|
2139
|
-
/** @type {import('next').NextConfig} */
|
|
2140
|
-
const nextConfig = {
|
|
2141
|
-
reactStrictMode: true,
|
|
2142
|
-
transpilePackages: ["@explorer02/cfm-survey-sdk"],
|
|
2143
|
-
};
|
|
2144
|
-
|
|
2145
|
-
module.exports = nextConfig;
|
|
2146
|
-
```
|
|
2147
|
-
|
|
2148
|
-
> **Why `transpilePackages` is required**: In this monorepo workspace, it allows Next.js to watch the local SDK files directly and hot-reload changes in real-time. For clients, since the SDK compiles to modern ES2022 JavaScript, transpiling allows Next.js's SWC compiler to tree-shake the code and down-transpile it to fit any older browser targets specified by the client application.
|
|
2149
|
-
|
|
2150
|
-
### 7c. `layout.tsx` — Root Layout
|
|
2151
|
-
|
|
2152
|
-
```tsx
|
|
2153
|
-
import type { Metadata } from "next";
|
|
2154
|
-
import "./globals.css";
|
|
2155
|
-
|
|
2156
|
-
export const metadata: Metadata = {
|
|
2157
|
-
title: "Customer Survey", // ← Replace with client's survey title
|
|
2158
|
-
description: "Customer feedback survey",
|
|
2159
|
-
};
|
|
2160
|
-
|
|
2161
|
-
export default function RootLayout({
|
|
2162
|
-
children,
|
|
2163
|
-
}: Readonly<{ children: React.ReactNode }>) {
|
|
2164
|
-
return (
|
|
2165
|
-
<html lang="en">
|
|
2166
|
-
<body>{children}</body>
|
|
2167
|
-
</html>
|
|
2168
|
-
);
|
|
2169
|
-
}
|
|
2170
|
-
```
|
|
2171
|
-
|
|
2172
|
-
---
|
|
2173
|
-
|
|
2174
|
-
## 8. Deployment Phase
|
|
2175
|
-
|
|
2176
|
-
### Pre-Deployment Checklist
|
|
2177
|
-
|
|
2178
|
-
Before deploying, verify:
|
|
2179
|
-
- [ ] `npm run build` completes with zero errors
|
|
2180
|
-
- [ ] `next.config.js` has `transpilePackages: ["@explorer02/cfm-survey-sdk"]`
|
|
2181
|
-
- [ ] Logo files (if any) are in the `public/` directory
|
|
2182
|
-
- [ ] All components use `id={question.id}` on question wrappers
|
|
2183
|
-
- [ ] UI Verification Checklist (Phase 6) has been completed
|
|
2184
|
-
|
|
2185
|
-
### Deployment Command
|
|
2186
|
-
|
|
2187
|
-
Based on the `deployToVercel` flag inside `./survey-config.json` (or user approval in chat):
|
|
2188
|
-
|
|
2189
|
-
```bash
|
|
2190
|
-
npx cfm-sdk deploy
|
|
2191
|
-
```
|
|
2192
|
-
|
|
2193
|
-
This command handles the full Vercel deployment pipeline:
|
|
2194
|
-
1. **Node.js version check** — Ensures v18+ is installed.
|
|
2195
|
-
2. **Vercel CLI check** — Installs Vercel CLI globally if missing (with user consent), or falls back to `npx`.
|
|
2196
|
-
3. **Vercel authentication** — Checks for `VERCEL_TOKEN` env variable or prompts interactive login.
|
|
2197
|
-
4. **Production deployment** — Runs `vercel --prod --yes` and outputs the live URL.
|
|
2198
|
-
|
|
2199
|
-
If the deployment command is not available (e.g., SDK not installed globally), you can deploy manually:
|
|
2200
|
-
|
|
2201
|
-
```bash
|
|
2202
|
-
npx vercel --prod --yes
|
|
2203
|
-
```
|
|
2204
|
-
|
|
2205
|
-
---
|
|
2206
|
-
|
|
2207
|
-
## 9. Reference: Styled Code Templates (Reference Only)
|
|
2208
|
-
|
|
2209
|
-
> ⚠️ **WARNING — DO NOT COPY-PASTE STYLING FROM THIS SECTION**
|
|
2210
|
-
>
|
|
2211
|
-
> The styled code below is a **reference implementation** from one specific client (Telekom). It is included **only** to show how the SDK data wiring connects to a real, working UI. The Tailwind classes, colors (`#e20074`), layout choices, and component designs in this section are **NOT universal** — they are specific to one brand.
|
|
2212
|
-
>
|
|
2213
|
-
> **What you MUST do instead**:
|
|
2214
|
-
> 1. Use the **Logic Skeletons in Section 4** for the SDK integration structure.
|
|
2215
|
-
> 2. Design **all visuals from scratch** based on the client's mockup or prompt.
|
|
2216
|
-
> 3. Refer to this section ONLY to understand how SDK data flows into JSX — never for styling decisions.
|
|
2217
|
-
|
|
2218
|
-
### `src/app/globals.css`
|
|
2219
|
-
```css
|
|
2220
|
-
@import "tailwindcss";
|
|
2221
|
-
|
|
2222
|
-
:root {
|
|
2223
|
-
--brand-color: #e20074;
|
|
2224
|
-
--brand-bg-light: #fdf2f8;
|
|
2225
|
-
}
|
|
2226
|
-
|
|
2227
|
-
body {
|
|
2228
|
-
margin: 0;
|
|
2229
|
-
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
2230
|
-
color: #262626;
|
|
2231
|
-
background-color: #ffffff;
|
|
2232
|
-
}
|
|
2233
|
-
```
|
|
2234
|
-
|
|
2235
|
-
### `src/app/layout.tsx`
|
|
2236
|
-
```tsx
|
|
2237
|
-
import type { Metadata } from "next";
|
|
2238
|
-
import "./globals.css";
|
|
2239
|
-
|
|
2240
|
-
export const metadata: Metadata = {
|
|
2241
|
-
title: "Customer Survey",
|
|
2242
|
-
description: "Customer feedback survey",
|
|
2243
|
-
};
|
|
2244
|
-
|
|
2245
|
-
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
2246
|
-
return (
|
|
2247
|
-
<html lang="en">
|
|
2248
|
-
<body>{children}</body>
|
|
2249
|
-
</html>
|
|
2250
|
-
);
|
|
2251
|
-
}
|
|
2252
|
-
```
|
|
2253
|
-
|
|
2254
|
-
### `src/app/page.tsx`
|
|
2255
|
-
```tsx
|
|
2256
|
-
import { Suspense } from "react";
|
|
2257
|
-
import SurveyPage from "@/components/SurveyPage";
|
|
2258
|
-
|
|
2259
|
-
export default function Page() {
|
|
2260
|
-
return (
|
|
2261
|
-
<Suspense fallback={<div className="flex min-h-screen items-center justify-center"><p>Loading...</p></div>}>
|
|
2262
|
-
<SurveyPage />
|
|
2263
|
-
</Suspense>
|
|
2264
|
-
);
|
|
2265
|
-
}
|
|
2266
|
-
```
|
|
2267
|
-
|
|
2268
|
-
### `next.config.js`
|
|
2269
|
-
```javascript
|
|
2270
|
-
/** @type {import('next').NextConfig} */
|
|
2271
|
-
const nextConfig = {
|
|
2272
|
-
reactStrictMode: true,
|
|
2273
|
-
transpilePackages: ["@explorer02/cfm-survey-sdk"],
|
|
2274
|
-
};
|
|
2275
|
-
module.exports = nextConfig;
|
|
2276
|
-
```
|
|
2277
|
-
|
|
2278
|
-
---
|
|
2279
|
-
|
|
2280
|
-
## Appendix A: Other Frameworks (Vite, Remix, CRA)
|
|
2281
|
-
|
|
2282
|
-
The SDK's `useSurveySDK()` hook is a **standard React hook** — it works in any React-based framework, not just Next.js. Here are the key differences when using other frameworks:
|
|
2283
|
-
|
|
2284
|
-
### Vite + React
|
|
2285
|
-
|
|
2286
|
-
```bash
|
|
2287
|
-
# Initialise
|
|
2288
|
-
npx -y create-vite@latest ./ --template react-ts
|
|
2289
|
-
|
|
2290
|
-
# Install dependencies
|
|
2291
|
-
npm install @explorer02/cfm-survey-sdk @tanstack/react-query
|
|
2292
|
-
```
|
|
2293
|
-
|
|
2294
|
-
**Key differences from Next.js**:
|
|
2295
|
-
- ❌ **No `transpilePackages` needed** — Vite handles TypeScript transpilation natively.
|
|
2296
|
-
- ❌ **No `'use client'` directive needed** — Vite doesn't use Server Components.
|
|
2297
|
-
- ❌ **No `<Suspense>` boundary needed** — No Server Component hydration concerns.
|
|
2298
|
-
- ✅ **Still needs `useSurveySDK()`**, `id={question.id}`, `SURVEY_PLACEHOLDERS` outside component, and `onAction()` dispatcher — all SDK constraints still apply.
|
|
2299
|
-
|
|
2300
|
-
### Remix
|
|
2301
|
-
|
|
2302
|
-
```bash
|
|
2303
|
-
npx -y create-remix@latest ./
|
|
2304
|
-
npm install @explorer02/cfm-survey-sdk @tanstack/react-query
|
|
2305
|
-
```
|
|
2306
|
-
|
|
2307
|
-
**Key differences from Next.js**:
|
|
2308
|
-
- ❌ **No `transpilePackages` needed** — Remix uses Vite/esbuild under the hood.
|
|
2309
|
-
- ✅ **`'use client'` IS needed** in Remix v2 if using Server Components (depends on version).
|
|
2310
|
-
- ✅ All SDK constraints still apply.
|
|
2311
|
-
|
|
2312
|
-
### Create React App (CRA)
|
|
2313
|
-
|
|
2314
|
-
```bash
|
|
2315
|
-
npx -y create-react-app ./ --template typescript
|
|
2316
|
-
npm install @explorer02/cfm-survey-sdk @tanstack/react-query
|
|
2317
|
-
```
|
|
2318
|
-
|
|
2319
|
-
**Key differences from Next.js**:
|
|
2320
|
-
- ❌ **No `transpilePackages` needed** — CRA's webpack config handles this.
|
|
2321
|
-
- ❌ **No `'use client'` directive** — CRA doesn't have Server Components.
|
|
2322
|
-
- ❌ **No `<Suspense>` boundary needed** — No SSR concerns.
|
|
2323
|
-
- ✅ All SDK constraints still apply.
|
|
2324
|
-
|
|
2325
|
-
### Universal SDK Constraints (Apply to ALL Frameworks)
|
|
2326
|
-
|
|
2327
|
-
Regardless of framework, these rules always apply:
|
|
2328
|
-
1. `id={question.id}` on question wrappers — for scroll navigation.
|
|
2329
|
-
2. `dangerouslySetInnerHTML={{ __html: question.text }}` — for HTML in question text.
|
|
2330
|
-
3. `SURVEY_PLACEHOLDERS` defined outside the component — to prevent infinite re-renders.
|
|
2331
|
-
4. `onAction()` for all user interactions — never bypass the dispatcher.
|
|
2332
|
-
5. Import only from `@explorer02/cfm-survey-sdk` — never from internal paths.
|
|
2333
|
-
|
|
2334
|
-
---
|
|
2335
|
-
|
|
2336
|
-
## Troubleshooting Guide
|
|
2337
|
-
|
|
2338
|
-
| Symptom | Cause | Fix |
|
|
2339
|
-
|---------|-------|-----|
|
|
2340
|
-
| `SyntaxError: Unexpected token 'export'` | Missing `transpilePackages` in `next.config.js` | Add `transpilePackages: ["@explorer02/cfm-survey-sdk"]` |
|
|
2341
|
-
| Scroll-to-error doesn't work | Missing `id={question.id}` on question wrapper | Add `id={question.id}` to the outermost element of each question |
|
|
2342
|
-
| Survey shows "No survey data" | SDK cannot reach the API endpoint | Check network connectivity; the SDK fetches from `https://custom-p0.feedbook.me/api/feedback/survey/cfm` |
|
|
2343
|
-
| Infinite re-renders | Placeholders object defined inside component | Move `SURVEY_PLACEHOLDERS` outside the component body |
|
|
2344
|
-
| `type SurveyQuestion` import error | Importing from wrong path | Import from `@explorer02/cfm-survey-sdk`, not internal paths |
|
|
2345
|
-
| Tailwind classes not applying | Missing `@import "tailwindcss"` in CSS | Add `@import "tailwindcss";` to `globals.css` |
|
|
2346
|
-
| Build error: "Cannot find module" | Missing dependencies | Run `npm install @explorer02/cfm-survey-sdk @tanstack/react-query` |
|
|
2347
|
-
| Hydration mismatch errors | Missing `<Suspense>` boundary in `page.tsx` | Wrap `<SurveyPage />` in `<Suspense>` in the entry page |
|
|
2348
|
-
| Survey looks identical to another client's | Agent copied styling from Section 9 reference | Delete all copied styles; redesign from scratch using Section 4 skeletons + client mockup |
|
|
17
|
+
Open `node_modules/@explorer02/cfm-survey-sdk/docs/index.md` now and follow the instructions inside it strictly.
|