@explorer02/cfm-survey-sdk 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +94 -83
- package/dist/cli/index.mjs +94 -83
- package/package.json +1 -1
- package/templates/docs/00-integration/client-lib-folder.md +1 -0
- package/templates/docs/00-integration/wizard-chrome-contract.md +2 -1
- package/templates/docs/00-integration/wizard-preview-build-guide.md +17 -0
- package/templates/docs/01-components/03-rating-scale.md +8 -3
- package/templates/docs/02-reference/value-derivation.md +2 -0
- package/templates/docs/03-ui-specs/01-rating.md +15 -1
- package/templates/docs/templates/CsatMatrixScale.tsx +18 -24
- package/templates/docs/templates/CustomSliderTrack.tsx +5 -3
- package/templates/docs/templates/FileUploadScale.tsx +143 -18
- package/templates/docs/templates/Header.tsx +1 -0
- package/templates/docs/templates/HeatmapScale.tsx +11 -2
- package/templates/docs/templates/LanguageSelector.tsx +42 -19
- package/templates/docs/templates/LikertMatrixScale.tsx +43 -33
- package/templates/docs/templates/MatrixDropdown.tsx +12 -11
- package/templates/docs/templates/Question.tsx +109 -44
- package/templates/docs/templates/RankOrderScale.tsx +22 -5
- package/templates/docs/templates/RatingScale.tsx +77 -39
- package/templates/docs/templates/SliderMatrixScale.tsx +44 -16
- package/templates/docs/templates/selectionStyles.ts +90 -2
- package/templates/docs/templates/surveyUiIcons.tsx +2 -2
- package/templates/docs/templates/surveyUiScaleUtils.ts +52 -5
- package/templates/docs/templates/uiConfig.ts +56 -0
- package/templates/preview-harness/preview-bridge.inline.js +44 -0
- package/templates/preview-harness/previewPages.js +76 -51
- package/templates/preview-harness/previewPages.ts +7 -34
- package/templates/preview-harness/vite-app/src/PreviewConfigContext.tsx +7 -0
- package/templates/preview-harness/vite-app/src/QuestionPreview.tsx +59 -30
- package/templates/preview-harness/vite-app/src/SurveyPagePreview.tsx +36 -8
- package/templates/preview-harness/vite-app/src/fixtures/questions.ts +182 -82
- package/templates/previewBridge.ts +33 -0
- package/templates/survey-theme.css +13 -4
- package/templates/wizard-dist/assets/{PreviewMock-Bax7oRAL.js → PreviewMock-DbbLpHdF.js} +1 -1
- package/templates/wizard-dist/assets/TypePanel-DQbV2iCf.js +1 -0
- package/templates/wizard-dist/assets/index-CY7WMJ93.js +34 -0
- package/templates/wizard-dist/index.html +1 -1
- package/templates/wizard-dist/assets/TypePanel-D2t4FPSd.js +0 -1
- package/templates/wizard-dist/assets/index-c5lka74l.js +0 -34
package/package.json
CHANGED
|
@@ -81,6 +81,7 @@ Exports used by portable scale templates:
|
|
|
81
81
|
| `tickColorFromIndex(index, total)` | SLIDER_MATRIX header tick badge colors |
|
|
82
82
|
| `matrixColumnStoredValue(col)` | Matrix answer storage — column `id` or `null` for N/A |
|
|
83
83
|
| `npsValueFromOption` / `starValueFromOption` | NPS badge numeric values |
|
|
84
|
+
| `isScaleAnswerSelected` | NPS/star `checked` state — loose number compare |
|
|
84
85
|
| `nonNaScaleColumns` | Filter N/A column from scale columns |
|
|
85
86
|
|
|
86
87
|
Prefer SDK `matrixColumnStoredValue` when importing from package; template re-exports the same contract for co-located components.
|
|
@@ -22,7 +22,8 @@ Pipeline: [`wizard-preview-build-guide.md`](wizard-preview-build-guide.md) · [`
|
|
|
22
22
|
| Scale selection colors not updating | Import `selectionStyles.ts`; use `var(--cfm-matrix-selected)` / `var(--cfm-input-focus-ring)` |
|
|
23
23
|
| Company name not showing after logo | Copy `Header.tsx` template — brand row is `[logo][data-cfm-company]` in one flex container |
|
|
24
24
|
| Company shows when empty | Bridge clears `[data-cfm-company]` when `companyName` is `""` — do not hardcode "Company Name" as visible default |
|
|
25
|
-
| NPS
|
|
25
|
+
| NPS clicks do nothing / no visible selection before wizard | Copy latest `RatingScale.tsx` + `selectionStyles.ts` + `surveyUiScaleUtils.ts`; use `isScaleAnswerSelected()`; radio track row must use `label` + `onChange` → `onSelect(number)` — see `03-ui-specs/01-rating.md` |
|
|
26
|
+
| NPS colors / rings not updating | Copy `RatingScale.tsx` + `selectionStyles.ts` — hex fallbacks in `selectionStyles.ts` for pre-theme builds |
|
|
26
27
|
| Text placeholder color not updating | `data-cfm-textfield` + `--cfm-input-placeholder` in CSS / `::placeholder` rule |
|
|
27
28
|
| Matrix 2nd row toggle | Harness adds row via `previewMultiStatement` — `LikertMatrixScale` must render all `statementRows` from props |
|
|
28
29
|
| Blank page after deploy | **Never** reference `UI_CONFIG` without import — use `src/lib/uiConfig.ts` |
|
|
@@ -50,6 +50,23 @@ If components use **hardcoded** colors (`bg-[#e20074]`) with no `var(--cfm-*)` h
|
|
|
50
50
|
|
|
51
51
|
**Default for production wizard:** agent components. Use templates-only for SDK smoke tests, not client delivery.
|
|
52
52
|
|
|
53
|
+
### Wizard static preview vs production survey
|
|
54
|
+
|
|
55
|
+
During `npx cfm-sdk customize`, the right panel loads **SDK static HTML** from `preview-harness/previewPages.js` — not your React tree. Layout matches the original survey-page mock; **customization** is driven by `--cfm-*` CSS vars + `preview-bridge.inline.js`.
|
|
56
|
+
|
|
57
|
+
| Wizard control | Static preview (iframe) | Production survey (after Phase 6c) |
|
|
58
|
+
|----------------|-------------------------|-------------------------------------|
|
|
59
|
+
| NPS hints + number labels | Hint text + badge colors via bridge | Copy `RatingScale.tsx` + `Question.tsx` hint row |
|
|
60
|
+
| Matrix multi-row toggle | Shows/hides `[data-cfm-matrix-row-extra]` | `LikertMatrixScale` maps all `statementRows` |
|
|
61
|
+
| Matrix row width + label items | `--cfm-matrix-row-width`, column label vars | `LikertMatrixScale` + `labelStyles.ts` |
|
|
62
|
+
| CSAT/Rating star/numbered/radio | Theme focus ring + cell selected on mock cells | `CsatMatrixScale.tsx` + `selectionStyles.ts` |
|
|
63
|
+
| Slider track + tick labels | `--cfm-slider-*` on static slider mock | `SliderMatrixScale` + `CustomSliderTrack` |
|
|
64
|
+
| File upload dropzone | `--cfm-upload-*` on `.cfm-dropzone` | `FileUploadScale.tsx` |
|
|
65
|
+
| Rank / heatmap | `--cfm-rank-*` / `--cfm-heatmap-pin-*` | `RankOrderScale.tsx` / `HeatmapScale.tsx` |
|
|
66
|
+
| Logo size slider | `--cfm-header-logo-width/height` + `max-height` in header | `Header.tsx` via `uiConfig.ts` |
|
|
67
|
+
|
|
68
|
+
**Rule:** If a wizard control updates the static preview but not localhost dev, the agent has not copied the matching template from `docs/templates/` — see [`wizard-chrome-contract.md`](wizard-chrome-contract.md).
|
|
69
|
+
|
|
53
70
|
---
|
|
54
71
|
|
|
55
72
|
## Required project artifacts (Phase 5)
|
|
@@ -21,9 +21,14 @@ Render a horizontal sequence of numbered badges with traffic-light colors (NPS 0
|
|
|
21
21
|
## UI/UX Rules (CRITICAL)
|
|
22
22
|
|
|
23
23
|
1. **Shape**: Square badges with rounded corners — not full circles
|
|
24
|
-
2. **
|
|
25
|
-
3. **
|
|
26
|
-
4. **
|
|
24
|
+
2. **Interactive row**: Only the **radio track row** accepts clicks (`label` + hidden `input type="radio"`)
|
|
25
|
+
3. **Selection compare**: Use `isScaleAnswerSelected(selectedValue, optionValue)` — store/compare **numbers**, not option ids
|
|
26
|
+
4. **Unselected State**: Gray ring (`#d1d5db`) + white fill on track row
|
|
27
|
+
5. **Selected State**: Focus ring `#e20074` (or `--cfm-input-focus-ring`) + annulus `#fbe8f3` (or `--cfm-matrix-selected`) + filled dot
|
|
28
|
+
6. **Tooltips**: `title={option.label}` on every clickable cell
|
|
29
|
+
7. **`buttonStyle: 'standard'`**: Radio track only — omit numbered badge row
|
|
30
|
+
|
|
31
|
+
Reference implementation: [`apps/client/src/components/RatingScale.tsx`](../../../../apps/client/src/components/RatingScale.tsx)
|
|
27
32
|
|
|
28
33
|
## SDK Integration Checklist (agent)
|
|
29
34
|
|
|
@@ -39,6 +39,8 @@ function npsValueFromOption(option: NpsScalePoint, index: number): number {
|
|
|
39
39
|
|
|
40
40
|
NPS questions use `question.type === 'NPS_SCALE'`. Store a **number** in `SurveyAnswers`. Badge colors: traffic-light table keyed by numeric value (0–10).
|
|
41
41
|
|
|
42
|
+
Compare selection with `isScaleAnswerSelected(selectedValue, npsValueFromOption(option, index))` — do not compare to `option.id`.
|
|
43
|
+
|
|
42
44
|
## MCQ
|
|
43
45
|
|
|
44
46
|
- Selected answer: **`option.id`** (string), not a separate `optionValue` field
|
|
@@ -31,11 +31,22 @@ When `reverseScaleOrder === true`, map visual index `i` to stored index `options
|
|
|
31
31
|
## Answer Mutation
|
|
32
32
|
|
|
33
33
|
```typescript
|
|
34
|
+
import { isScaleAnswerSelected, npsValueFromOption } from '@/lib/surveyUiScaleUtils';
|
|
35
|
+
|
|
34
36
|
const value = npsValueFromOption(option, index);
|
|
35
|
-
onSelect(value) // number
|
|
37
|
+
onSelect(value); // number — never option.id
|
|
36
38
|
// → onAction CHANGE { questionId, answerValue: number }
|
|
39
|
+
|
|
40
|
+
// Compare stored answer to option value (handles string/number drift):
|
|
41
|
+
const isSelected = isScaleAnswerSelected(selectedValue, value);
|
|
37
42
|
```
|
|
38
43
|
|
|
44
|
+
**Selection row (required):** Only the **radio track row** is interactive — use `<label>` + hidden `<input type="radio">` + `onChange={() => onSelect(option.value)}`. Mirror [`apps/client/src/components/RatingScale.tsx`](../../../../apps/client/src/components/RatingScale.tsx) (reference monorepo).
|
|
45
|
+
|
|
46
|
+
**Pre-customization defaults:** Copy `selectionStyles.ts` with `#e20074` / `#fbe8f3` hex fallbacks so selected rings are visible before `survey-theme.css` loads. Import `survey-theme.css` in `globals.css` for full wizard tokens.
|
|
47
|
+
|
|
48
|
+
**`buttonStyle`:** `standard` = radio track only (no numbered badge row). `numbered` = badge row + track. `emoji` = emoji cells.
|
|
49
|
+
|
|
39
50
|
See [`02-reference/value-derivation.md`](../02-reference/value-derivation.md).
|
|
40
51
|
|
|
41
52
|
## Agent Checklist
|
|
@@ -59,6 +70,9 @@ See [`02-reference/value-derivation.md`](../02-reference/value-derivation.md).
|
|
|
59
70
|
### Wizard checklist
|
|
60
71
|
|
|
61
72
|
- [ ] `selectionStyles.ts` imported in `RatingScale.tsx`
|
|
73
|
+
- [ ] `isScaleAnswerSelected()` used for `checked` state — not raw `===` on `selectedValue`
|
|
74
|
+
- [ ] Radio track: `label` + `sr-only` radio + `onChange` → `onSelect(number)` (see client `RatingScale.tsx`)
|
|
62
75
|
- [ ] Selected radio: outer border + dot = focus ring; annulus = cell selected
|
|
63
76
|
- [ ] Selected numbered badge: border = focus ring, fill = cell selected, text white
|
|
64
77
|
- [ ] Unselected badges keep traffic-light / per-number `--cfm-number-*` colors
|
|
78
|
+
- [ ] `buttonStyle: 'standard'` hides badge row; track row remains clickable
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
emojiSizeStyle,
|
|
13
13
|
cellSelectedVar,
|
|
14
14
|
focusRingVar,
|
|
15
|
+
mcqSelectedAccentVar,
|
|
16
|
+
mcqSelectedBgVar,
|
|
15
17
|
} from '@/lib/surveyUi/selectionStyles';
|
|
16
18
|
import { csatColumnLabelStyle } from '@/lib/surveyUi/labelStyles';
|
|
17
19
|
|
|
@@ -106,10 +108,10 @@ function CsatMatrixVerticalList({
|
|
|
106
108
|
onClick={() => onCellSelect(row.id, submitValue)}
|
|
107
109
|
style={{
|
|
108
110
|
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'flex-start',
|
|
109
|
-
borderRadius: '8px', border: `1px solid ${isSelected ?
|
|
111
|
+
borderRadius: '8px', border: `1px solid ${isSelected ? mcqSelectedAccentVar : '#d1d5db'}`,
|
|
110
112
|
padding: '14px 18px', cursor: 'pointer', transition: 'all 0.15s',
|
|
111
|
-
backgroundColor: isSelected ?
|
|
112
|
-
color: isSelected ?
|
|
113
|
+
backgroundColor: isSelected ? mcqSelectedBgVar : '#fff',
|
|
114
|
+
color: isSelected ? mcqSelectedAccentVar : '#374151',
|
|
113
115
|
fontWeight: isSelected ? 600 : 400, fontSize: '15px', textAlign: 'left',
|
|
114
116
|
}}
|
|
115
117
|
>
|
|
@@ -128,9 +130,9 @@ function CsatMatrixVerticalList({
|
|
|
128
130
|
key={col.id}
|
|
129
131
|
style={{
|
|
130
132
|
display: 'flex', alignItems: 'center', gap: '16px',
|
|
131
|
-
borderRadius: '8px', border: `1px solid ${isSelected ?
|
|
133
|
+
borderRadius: '8px', border: `1px solid ${isSelected ? mcqSelectedAccentVar : '#e5e5e5'}`,
|
|
132
134
|
padding: '16px 20px', cursor: 'pointer', transition: 'all 0.15s',
|
|
133
|
-
backgroundColor: isSelected ?
|
|
135
|
+
backgroundColor: isSelected ? mcqSelectedBgVar : '#fff',
|
|
134
136
|
}}
|
|
135
137
|
>
|
|
136
138
|
<input type="radio" name={`${question.id}-${row.id}`}
|
|
@@ -138,13 +140,8 @@ function CsatMatrixVerticalList({
|
|
|
138
140
|
onChange={() => onCellSelect(row.id, submitValue)}
|
|
139
141
|
style={{ position: 'absolute', width: 0, height: 0, opacity: 0 }}
|
|
140
142
|
/>
|
|
141
|
-
<div style={
|
|
142
|
-
|
|
143
|
-
alignItems: 'center', justifyContent: 'center', borderRadius: '50%',
|
|
144
|
-
border: isSelected ? '2px solid #e20074' : '1.5px solid #9ca3af',
|
|
145
|
-
backgroundColor: '#fff',
|
|
146
|
-
}}>
|
|
147
|
-
{isSelected && <div style={{ width: '10px', height: '10px', borderRadius: '50%', backgroundColor: '#e20074' }} />}
|
|
143
|
+
<div style={matrixRadioRingStyle(isSelected, '20px')}>
|
|
144
|
+
<div style={matrixRadioDotStyle(isSelected, '10px')} />
|
|
148
145
|
</div>
|
|
149
146
|
<span style={{ fontSize: '15px', fontWeight: 500, color: '#111827' }}
|
|
150
147
|
dangerouslySetInnerHTML={{ __html: col.label }} />
|
|
@@ -158,7 +155,7 @@ function CsatMatrixVerticalList({
|
|
|
158
155
|
<label style={{ display: 'flex', alignItems: 'center', gap: '12px', cursor: 'pointer', userSelect: 'none' }}>
|
|
159
156
|
<input type="checkbox" checked={selectedValue[row.id] === null}
|
|
160
157
|
onChange={() => onCellSelect(row.id, selectedValue[row.id] === null ? undefined : null)}
|
|
161
|
-
style={{ width: '16px', height: '16px', accentColor:
|
|
158
|
+
style={{ width: '16px', height: '16px', accentColor: focusRingVar, cursor: 'pointer' }} />
|
|
162
159
|
<span style={{ fontSize: '14px', fontWeight: 500, color: '#4b5563' }}>Not Applicable</span>
|
|
163
160
|
</label>
|
|
164
161
|
)}
|
|
@@ -387,7 +384,7 @@ function CsatMatrixGrid({
|
|
|
387
384
|
<label style={{ cursor: 'pointer' }}>
|
|
388
385
|
<input type="checkbox" checked={selectedValue[row.id] === null}
|
|
389
386
|
onChange={() => onCellSelect(row.id, selectedValue[row.id] === null ? undefined : null)}
|
|
390
|
-
style={{ width: '16px', height: '16px', accentColor:
|
|
387
|
+
style={{ width: '16px', height: '16px', accentColor: focusRingVar, cursor: 'pointer' }} />
|
|
391
388
|
</label>
|
|
392
389
|
</div>
|
|
393
390
|
)}
|
|
@@ -490,13 +487,8 @@ function CsatMatrixCarousel({
|
|
|
490
487
|
const EmojiNode = isEmoji ? getEmojiForIndex(displayIdx, scaleColumns.length) : null;
|
|
491
488
|
const StarNode = isStar ? (isSelected ? (CsatStarIcons.filled as any) : (CsatStarIcons.empty as any)) : null;
|
|
492
489
|
const RadioNode = (
|
|
493
|
-
<div style={
|
|
494
|
-
|
|
495
|
-
alignItems: 'center', justifyContent: 'center', borderRadius: '50%',
|
|
496
|
-
margin: '0 auto', border: isSelected ? '2px solid #e20074' : '2px solid #d1d5db',
|
|
497
|
-
backgroundColor: '#fff',
|
|
498
|
-
}}>
|
|
499
|
-
{isSelected && <div style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: '#e20074' }} />}
|
|
490
|
+
<div style={matrixRadioRingStyle(isSelected, '18px')}>
|
|
491
|
+
<div style={matrixRadioDotStyle(isSelected, '8px')} />
|
|
500
492
|
</div>
|
|
501
493
|
);
|
|
502
494
|
|
|
@@ -518,7 +510,9 @@ function CsatMatrixCarousel({
|
|
|
518
510
|
padding: '4px', transform: isHovered ? 'scale(1.1)' : 'scale(1)', opacity: isSelected ? 1 : isHovered ? 1 : 0.5,
|
|
519
511
|
} : isNumbered ? {
|
|
520
512
|
width: '40px', height: '40px', borderRadius: '8px', fontSize: '14px', fontWeight: 500,
|
|
521
|
-
|
|
513
|
+
...(isSelected
|
|
514
|
+
? scaleCellSelectedStyle(true)
|
|
515
|
+
: { border: '1px solid #d1d5db', backgroundColor: '#fff', color: '#111827' }),
|
|
522
516
|
} : { padding: '8px' }),
|
|
523
517
|
}}
|
|
524
518
|
>
|
|
@@ -537,7 +531,7 @@ function CsatMatrixCarousel({
|
|
|
537
531
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
|
538
532
|
<input type="checkbox" checked={selectedValue[row.id] === null}
|
|
539
533
|
onChange={() => onCellSelect(row.id, selectedValue[row.id] === null ? undefined : null)}
|
|
540
|
-
style={{ width: '16px', height: '16px', accentColor:
|
|
534
|
+
style={{ width: '16px', height: '16px', accentColor: focusRingVar, cursor: 'pointer' }} />
|
|
541
535
|
<span style={{ fontSize: '14px', fontWeight: 500, color: '#4b5563' }}>N/A</span>
|
|
542
536
|
</label>
|
|
543
537
|
</div>
|
|
@@ -559,7 +553,7 @@ function CsatMatrixCarousel({
|
|
|
559
553
|
<button key={i} type="button" onClick={() => setActiveCarouselIndex(i)}
|
|
560
554
|
style={{
|
|
561
555
|
width: i === activeCarouselIndex ? '20px' : '8px', height: '8px', borderRadius: '4px',
|
|
562
|
-
backgroundColor: i === activeCarouselIndex ?
|
|
556
|
+
backgroundColor: i === activeCarouselIndex ? mcqSelectedAccentVar : '#d1d5db', border: 'none', padding: 0,
|
|
563
557
|
cursor: 'pointer', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
|
|
564
558
|
}} />
|
|
565
559
|
))}
|
|
@@ -50,7 +50,9 @@ export function CustomSliderTrack({
|
|
|
50
50
|
? tooltipLabel.replace(/<[^>]*>/g, '')
|
|
51
51
|
: String(Math.round(value));
|
|
52
52
|
|
|
53
|
-
const themeColor = disabled
|
|
53
|
+
const themeColor = disabled
|
|
54
|
+
? '#9ca3af'
|
|
55
|
+
: 'var(--cfm-slider-thumb, var(--cfm-input-focus-ring, var(--cfm-primary)))';
|
|
54
56
|
|
|
55
57
|
return (
|
|
56
58
|
<div
|
|
@@ -127,7 +129,7 @@ export function CustomSliderTrack({
|
|
|
127
129
|
{(isHovered || isDragging) && !disabled && (
|
|
128
130
|
<div style={{
|
|
129
131
|
position: 'absolute', bottom: '100%', left: `${percentage}%`, transform: 'translate(-50%, -10px)',
|
|
130
|
-
backgroundColor: '#1f2937', color: '#fff', fontSize: '13px', fontWeight: 600,
|
|
132
|
+
backgroundColor: 'var(--cfm-tooltip-bg, #1f2937)', color: '#fff', fontSize: '13px', fontWeight: 600,
|
|
131
133
|
padding: '4px 10px', borderRadius: '6px', pointerEvents: 'none',
|
|
132
134
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', zIndex: 20,
|
|
133
135
|
}}>
|
|
@@ -135,7 +137,7 @@ export function CustomSliderTrack({
|
|
|
135
137
|
<div style={{
|
|
136
138
|
position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
|
137
139
|
borderWidth: '5px', borderStyle: 'solid',
|
|
138
|
-
borderColor: '#1f2937 transparent transparent transparent',
|
|
140
|
+
borderColor: 'var(--cfm-tooltip-bg, #1f2937) transparent transparent transparent',
|
|
139
141
|
}} />
|
|
140
142
|
</div>
|
|
141
143
|
)}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import React, { useState, useRef } from 'react';
|
|
10
10
|
import type { FileUploadQuestion } from '@explorer02/cfm-survey-sdk';
|
|
11
|
+
import { focusRingVar, mcqSelectedBgVar } from '@/lib/surveyUi/selectionStyles';
|
|
11
12
|
|
|
12
13
|
type UploadedFileAnswer = {
|
|
13
14
|
id: string;
|
|
@@ -86,8 +87,20 @@ function FileUploadDropzone({
|
|
|
86
87
|
onDrag: (e: React.DragEvent) => void;
|
|
87
88
|
onDrop: (e: React.DragEvent) => void;
|
|
88
89
|
}) {
|
|
90
|
+
const baseBorder = 'var(--cfm-upload-border-color, #d1d5db)';
|
|
91
|
+
const activeBorder = focusRingVar;
|
|
92
|
+
const baseBg = 'var(--cfm-upload-fill, #f9fafb)';
|
|
93
|
+
const activeBg = mcqSelectedBgVar;
|
|
94
|
+
const borderStyle =
|
|
95
|
+
typeof document !== 'undefined'
|
|
96
|
+
? getComputedStyle(document.documentElement).getPropertyValue('--cfm-upload-border-style').trim() ||
|
|
97
|
+
'dashed'
|
|
98
|
+
: 'dashed';
|
|
99
|
+
|
|
89
100
|
return (
|
|
90
101
|
<div
|
|
102
|
+
data-cfm-dropzone
|
|
103
|
+
className="cfm-dropzone"
|
|
91
104
|
onDragEnter={onDrag}
|
|
92
105
|
onDragOver={onDrag}
|
|
93
106
|
onDragLeave={onDrag}
|
|
@@ -98,17 +111,45 @@ function FileUploadDropzone({
|
|
|
98
111
|
flexDirection: 'column',
|
|
99
112
|
alignItems: 'center',
|
|
100
113
|
justifyContent: 'center',
|
|
101
|
-
border: `2px ${isDragActive ?
|
|
114
|
+
border: `2px ${borderStyle} ${isDragActive ? activeBorder : baseBorder}`,
|
|
102
115
|
borderRadius: 'var(--cfm-border-radius, 12px)',
|
|
103
|
-
backgroundColor: isDragActive ?
|
|
116
|
+
backgroundColor: isDragActive ? activeBg : baseBg,
|
|
104
117
|
padding: 'var(--cfm-upload-padding, 40px 24px)',
|
|
105
118
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
106
119
|
opacity: disabled ? 0.6 : 1,
|
|
107
120
|
textAlign: 'center',
|
|
108
121
|
boxSizing: 'border-box',
|
|
122
|
+
transition: 'all 0.2s ease-in-out',
|
|
123
|
+
}}
|
|
124
|
+
onMouseEnter={e => {
|
|
125
|
+
if (disabled) return;
|
|
126
|
+
e.currentTarget.style.borderColor = activeBorder;
|
|
127
|
+
e.currentTarget.style.backgroundColor = activeBg;
|
|
128
|
+
}}
|
|
129
|
+
onMouseLeave={e => {
|
|
130
|
+
if (disabled || isDragActive) return;
|
|
131
|
+
e.currentTarget.style.borderColor = baseBorder;
|
|
132
|
+
e.currentTarget.style.backgroundColor = baseBg;
|
|
109
133
|
}}
|
|
110
134
|
>
|
|
111
|
-
<div style={{
|
|
135
|
+
<div style={{ marginBottom: '16px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
136
|
+
<svg
|
|
137
|
+
width="40"
|
|
138
|
+
height="40"
|
|
139
|
+
viewBox="0 0 24 24"
|
|
140
|
+
fill="none"
|
|
141
|
+
stroke="var(--cfm-upload-icon-color, #9ca3af)"
|
|
142
|
+
strokeWidth="2"
|
|
143
|
+
strokeLinecap="round"
|
|
144
|
+
strokeLinejoin="round"
|
|
145
|
+
>
|
|
146
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
147
|
+
<polyline points="17 8 12 3 7 8" />
|
|
148
|
+
<line x1="12" y1="3" x2="12" y2="15" />
|
|
149
|
+
</svg>
|
|
150
|
+
</div>
|
|
151
|
+
<div
|
|
152
|
+
style={{ fontSize: '16px', fontWeight: 600, color: '#374151', marginBottom: '8px' }}
|
|
112
153
|
dangerouslySetInnerHTML={{ __html: uploadMessage }}
|
|
113
154
|
/>
|
|
114
155
|
<div style={{ fontSize: '13px', color: '#6b7280', lineHeight: 1.5 }}>
|
|
@@ -120,6 +161,104 @@ function FileUploadDropzone({
|
|
|
120
161
|
);
|
|
121
162
|
}
|
|
122
163
|
|
|
164
|
+
function formatFileSize(bytes: number): string {
|
|
165
|
+
if (bytes === 0) return '0 Bytes';
|
|
166
|
+
const k = 1024;
|
|
167
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
168
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
169
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function UploadedFileList({
|
|
173
|
+
files,
|
|
174
|
+
onRemoveFile,
|
|
175
|
+
}: {
|
|
176
|
+
files: UploadedFileAnswer[];
|
|
177
|
+
onRemoveFile: (index: number) => void;
|
|
178
|
+
}) {
|
|
179
|
+
return (
|
|
180
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
181
|
+
{files.map((file, idx) => (
|
|
182
|
+
<div
|
|
183
|
+
key={file.id}
|
|
184
|
+
style={{
|
|
185
|
+
display: 'flex',
|
|
186
|
+
alignItems: 'center',
|
|
187
|
+
justifyContent: 'space-between',
|
|
188
|
+
padding: '12px 16px',
|
|
189
|
+
backgroundColor: 'var(--cfm-card-bg, #fff)',
|
|
190
|
+
border: '1px solid var(--cfm-card-border-color, #e5e7eb)',
|
|
191
|
+
borderRadius: 'var(--cfm-border-radius, 8px)',
|
|
192
|
+
boxSizing: 'border-box',
|
|
193
|
+
}}
|
|
194
|
+
>
|
|
195
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', overflow: 'hidden' }}>
|
|
196
|
+
<svg
|
|
197
|
+
width="20"
|
|
198
|
+
height="20"
|
|
199
|
+
viewBox="0 0 24 24"
|
|
200
|
+
fill="none"
|
|
201
|
+
stroke="#6b7280"
|
|
202
|
+
strokeWidth="2"
|
|
203
|
+
strokeLinecap="round"
|
|
204
|
+
strokeLinejoin="round"
|
|
205
|
+
>
|
|
206
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
207
|
+
<polyline points="14 2 14 8 20 8" />
|
|
208
|
+
</svg>
|
|
209
|
+
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
210
|
+
<span
|
|
211
|
+
style={{
|
|
212
|
+
fontSize: '14px',
|
|
213
|
+
fontWeight: 500,
|
|
214
|
+
color: '#374151',
|
|
215
|
+
textOverflow: 'ellipsis',
|
|
216
|
+
overflow: 'hidden',
|
|
217
|
+
whiteSpace: 'nowrap',
|
|
218
|
+
}}
|
|
219
|
+
>
|
|
220
|
+
{file.name ?? file.mediaUrl}
|
|
221
|
+
</span>
|
|
222
|
+
{file.size != null && (
|
|
223
|
+
<span style={{ fontSize: '12px', color: '#9ca3af' }}>{formatFileSize(file.size)}</span>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
onClick={() => onRemoveFile(idx)}
|
|
230
|
+
style={{
|
|
231
|
+
display: 'flex',
|
|
232
|
+
alignItems: 'center',
|
|
233
|
+
justifyContent: 'center',
|
|
234
|
+
width: '24px',
|
|
235
|
+
height: '24px',
|
|
236
|
+
borderRadius: '50%',
|
|
237
|
+
backgroundColor: '#f3f4f6',
|
|
238
|
+
border: 'none',
|
|
239
|
+
color: '#6b7280',
|
|
240
|
+
cursor: 'pointer',
|
|
241
|
+
fontSize: '14px',
|
|
242
|
+
fontWeight: 'bold',
|
|
243
|
+
transition: 'background-color 0.2s',
|
|
244
|
+
}}
|
|
245
|
+
onMouseEnter={e => {
|
|
246
|
+
e.currentTarget.style.backgroundColor = '#fee2e2';
|
|
247
|
+
e.currentTarget.style.color = '#ef4444';
|
|
248
|
+
}}
|
|
249
|
+
onMouseLeave={e => {
|
|
250
|
+
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
|
251
|
+
e.currentTarget.style.color = '#6b7280';
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
×
|
|
255
|
+
</button>
|
|
256
|
+
</div>
|
|
257
|
+
))}
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
123
262
|
export function FileUploadScale({ question, selectedValue, onSelect }: FileUploadScaleProps) {
|
|
124
263
|
const {
|
|
125
264
|
uploadMessage = 'Drag attachments here or browse',
|
|
@@ -241,21 +380,7 @@ export function FileUploadScale({ question, selectedValue, onSelect }: FileUploa
|
|
|
241
380
|
)}
|
|
242
381
|
|
|
243
382
|
{currentFiles.length > 0 && (
|
|
244
|
-
<
|
|
245
|
-
{currentFiles.map((file, idx) => (
|
|
246
|
-
<div key={file.id} style={{
|
|
247
|
-
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
248
|
-
padding: '12px 16px', backgroundColor: '#fff', border: '1px solid #e5e7eb', borderRadius: '8px',
|
|
249
|
-
}}>
|
|
250
|
-
<span style={{ fontSize: '14px', fontWeight: 500, color: '#374151' }}>{file.name ?? file.mediaUrl}</span>
|
|
251
|
-
<button type="button" onClick={() => removeFile(idx)} style={{
|
|
252
|
-
border: 'none', background: '#f3f4f6', borderRadius: '50%', width: 24, height: 24, cursor: 'pointer',
|
|
253
|
-
}}>
|
|
254
|
-
×
|
|
255
|
-
</button>
|
|
256
|
-
</div>
|
|
257
|
-
))}
|
|
258
|
-
</div>
|
|
383
|
+
<UploadedFileList files={currentFiles} onRemoveFile={removeFile} />
|
|
259
384
|
)}
|
|
260
385
|
</div>
|
|
261
386
|
);
|
|
@@ -56,6 +56,7 @@ export default function Header({ embedded = false }: HeaderProps) {
|
|
|
56
56
|
style={{
|
|
57
57
|
width: 'var(--cfm-header-logo-width, 120px)',
|
|
58
58
|
height: 'var(--cfm-header-logo-height, 120px)',
|
|
59
|
+
maxHeight: 'calc(var(--cfm-header-height, 120px) - 8px)',
|
|
59
60
|
padding: 'var(--cfm-header-logo-padding, 10px)',
|
|
60
61
|
background: 'transparent',
|
|
61
62
|
}}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Portable HeatmapScale — copy to src/components/HeatmapScale.tsx
|
|
5
|
-
* Also copy templates/heatmapCoords.ts to src/lib/heatmapCoords.ts
|
|
5
|
+
* Also copy templates/heatmapCoords.ts to src/lib/surveyUi/heatmapCoords.ts
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import React, { useState } from 'react';
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
createNewSpot,
|
|
12
12
|
findSpotNearClick,
|
|
13
13
|
getNormalizedClickFromImage,
|
|
14
|
-
} from '
|
|
14
|
+
} from '@/lib/surveyUi/heatmapCoords';
|
|
15
15
|
|
|
16
16
|
type HeatmapScaleProps = {
|
|
17
17
|
question: HeatmapQuestion;
|
|
@@ -91,6 +91,15 @@ export function HeatmapScale({ question, selectedValue = [], onSelect }: Heatmap
|
|
|
91
91
|
className="block max-w-full h-auto select-none rounded-md"
|
|
92
92
|
style={{ cursor: isImageReady ? 'crosshair' : 'default' }}
|
|
93
93
|
/>
|
|
94
|
+
{isImageReady && (
|
|
95
|
+
<div
|
|
96
|
+
className="pointer-events-none absolute inset-0 rounded-md"
|
|
97
|
+
style={{
|
|
98
|
+
backgroundColor: '#000000',
|
|
99
|
+
opacity: 'var(--cfm-heatmap-overlay-opacity, 0)',
|
|
100
|
+
}}
|
|
101
|
+
/>
|
|
102
|
+
)}
|
|
94
103
|
{isImageReady && (
|
|
95
104
|
<div className="pointer-events-none absolute inset-0">
|
|
96
105
|
{spots.map(spot => (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import type { SurveyLanguage } from '@explorer02/cfm-survey-sdk';
|
|
4
|
+
import { focusRingVar } from '@/lib/surveyUi/selectionStyles';
|
|
4
5
|
|
|
5
6
|
type LanguageSelectorProps = {
|
|
6
7
|
languages: SurveyLanguage[];
|
|
@@ -14,27 +15,49 @@ export default function LanguageSelector({
|
|
|
14
15
|
selectedLanguage,
|
|
15
16
|
onChange,
|
|
16
17
|
}: LanguageSelectorProps) {
|
|
17
|
-
if (languages.length <= 1) return null;
|
|
18
|
+
if (!languages || languages.length <= 1) return null;
|
|
18
19
|
|
|
19
20
|
return (
|
|
20
|
-
<div data-cfm-language className="mb-6 flex justify-end">
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
21
|
+
<div data-cfm-language className="mb-6 mt-4 flex justify-end">
|
|
22
|
+
<div className="relative">
|
|
23
|
+
<select
|
|
24
|
+
value={selectedLanguage}
|
|
25
|
+
onChange={(e) => onChange(e.target.value)}
|
|
26
|
+
className="cursor-pointer appearance-none py-2 pl-4 pr-10 text-sm font-semibold outline-none focus:ring-2"
|
|
27
|
+
style={{
|
|
28
|
+
background: 'var(--cfm-language-bg, #fff)',
|
|
29
|
+
color: 'var(--cfm-language-text, #2563eb)',
|
|
30
|
+
borderColor: 'var(--cfm-language-border, #2563eb)',
|
|
31
|
+
borderWidth: 1,
|
|
32
|
+
borderStyle: 'solid',
|
|
33
|
+
borderRadius: 'var(--cfm-border-radius, 8px)',
|
|
34
|
+
boxShadow: `0 0 0 0px ${focusRingVar}`,
|
|
35
|
+
}}
|
|
36
|
+
onFocus={e => {
|
|
37
|
+
e.currentTarget.style.boxShadow = `0 0 0 2px ${focusRingVar}`;
|
|
38
|
+
}}
|
|
39
|
+
onBlur={e => {
|
|
40
|
+
e.currentTarget.style.boxShadow = 'none';
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
{languages.map((lang) => (
|
|
44
|
+
<option key={lang.code} value={lang.code} className="text-gray-900">
|
|
45
|
+
{lang.name}
|
|
46
|
+
</option>
|
|
47
|
+
))}
|
|
48
|
+
</select>
|
|
49
|
+
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
|
50
|
+
<svg
|
|
51
|
+
className="h-4 w-4"
|
|
52
|
+
style={{ color: 'var(--cfm-language-text, #2563eb)' }}
|
|
53
|
+
fill="none"
|
|
54
|
+
stroke="currentColor"
|
|
55
|
+
viewBox="0 0 24 24"
|
|
56
|
+
>
|
|
57
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
|
58
|
+
</svg>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
38
61
|
</div>
|
|
39
62
|
);
|
|
40
63
|
}
|