@explorer02/cfm-survey-sdk 0.2.2 → 0.2.4
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 +140 -33
- package/dist/cli/index.mjs +140 -33
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +8 -1
- package/postinstall.js +8 -5
- package/templates/AGENT.md +62 -17
- package/templates/deploy-wizard-to-s3.sh +52 -0
- package/templates/docs/00-integration/agent-execution-flow.md +281 -0
- package/templates/docs/00-integration/agent-operating-contract.md +260 -0
- package/templates/docs/00-integration/analytics-events-catalog.md +54 -0
- package/templates/docs/00-integration/apply-ui-config.md +365 -0
- package/templates/docs/00-integration/canonical-survey-fixtures.md +94 -0
- package/templates/docs/00-integration/client-integration-guide.md +10 -8
- package/templates/docs/00-integration/client-lib-folder.md +115 -0
- package/templates/docs/00-integration/component-checklist.md +90 -54
- package/templates/docs/00-integration/constraints.md +13 -5
- package/templates/docs/00-integration/custom-field-logic-and-navigation.md +56 -0
- package/templates/docs/00-integration/display-logic-and-navigation.md +16 -1
- package/templates/docs/00-integration/file-upload-aws.md +116 -0
- package/templates/docs/00-integration/logic-fields-catalog.md +105 -0
- package/templates/docs/00-integration/partial-save-and-recovery.md +24 -0
- package/templates/docs/00-integration/progress.md +4 -2
- package/templates/docs/00-integration/question-type-sdk-matrix.md +11 -9
- package/templates/docs/00-integration/setup.md +15 -3
- package/templates/docs/00-integration/skip-logic-and-navigation.md +1 -1
- package/templates/docs/00-integration/survey-lifecycle-analytics.md +1 -1
- package/templates/docs/00-integration/ui-customization-wizard.md +144 -0
- package/templates/docs/00-integration/useSurveySDK.md +35 -1
- package/templates/docs/00-integration/wizard-api.md +134 -0
- package/templates/docs/00-integration/wizard-config-handoff.md +246 -0
- package/templates/docs/00-integration/wizard-preview-build-guide.md +400 -0
- package/templates/docs/00-integration/wizard-question-type-styling.md +195 -0
- package/templates/docs/01-components/01-survey-page.md +18 -8
- package/templates/docs/01-components/02-question.md +2 -2
- package/templates/docs/01-components/03-rating-scale.md +12 -18
- package/templates/docs/01-components/05-csat-matrix-scale.md +18 -26
- package/templates/docs/01-components/06-likert-matrix-scale.md +19 -38
- package/templates/docs/01-components/07-slider-matrix-scale.md +48 -33
- package/templates/docs/01-components/08-file-upload-scale.md +15 -52
- package/templates/docs/01-components/09-custom-slider-track.md +15 -25
- package/templates/docs/01-components/10-header-footer.md +23 -4
- package/templates/docs/01-components/11-progress-bar.md +14 -6
- package/templates/docs/01-components/13-matrix-dropdown.md +28 -33
- package/templates/docs/01-components/17-heatmap-scale.md +4 -4
- package/templates/docs/01-components/18-rank-order-scale.md +24 -31
- package/templates/docs/01-components/19-survey-sticky-chrome.md +79 -0
- package/templates/docs/01-components/README.md +2 -2
- package/templates/docs/02-reference/question-types/10-slider-matrix.md +38 -26
- package/templates/docs/02-reference/question-types/11-file-upload.md +36 -21
- package/templates/docs/02-reference/question-types/README.md +11 -1
- package/templates/docs/02-reference/routing-table.md +1 -0
- package/templates/docs/02-reference/value-derivation.md +1 -1
- package/templates/docs/03-ui-specs/00-question-shell.md +10 -0
- package/templates/docs/03-ui-specs/01-rating.md +28 -2
- package/templates/docs/03-ui-specs/02-radio.md +94 -24
- package/templates/docs/03-ui-specs/03-text.md +12 -1
- package/templates/docs/03-ui-specs/04-csat.md +90 -26
- package/templates/docs/03-ui-specs/06-slider.md +3 -1
- package/templates/docs/03-ui-specs/07-matrix-cfm.md +74 -35
- package/templates/docs/03-ui-specs/08-matrix-csat-rating.md +50 -16
- package/templates/docs/03-ui-specs/09-slider-matrix.md +70 -26
- package/templates/docs/03-ui-specs/10-file-upload.md +52 -20
- package/templates/docs/03-ui-specs/11-text-and-media.md +4 -0
- package/templates/docs/03-ui-specs/12-survey-chrome.md +92 -5
- package/templates/docs/03-ui-specs/13-heatmap.md +21 -3
- package/templates/docs/03-ui-specs/14-rank-order.md +108 -28
- package/templates/docs/03-ui-specs/README.md +11 -7
- package/templates/docs/03-ui-specs/shared/custom-slider-track.md +28 -35
- package/templates/docs/03-ui-specs/shared/matrix-dropdown.md +1 -1
- package/templates/docs/MANIFEST.json +187 -6
- package/templates/docs/index.md +116 -116
- package/templates/docs/templates/CsatMatrixScale.tsx +637 -0
- package/templates/docs/templates/CustomSliderTrack.tsx +144 -0
- package/templates/docs/templates/FileUploadScale.tsx +262 -0
- package/templates/docs/templates/HeatmapScale.tsx +114 -0
- package/templates/docs/templates/LikertMatrixScale.tsx +414 -0
- package/templates/docs/templates/MatrixDropdown.tsx +216 -0
- package/templates/docs/templates/Question.tsx +3 -0
- package/templates/docs/templates/RankOrderScale.tsx +353 -0
- package/templates/docs/templates/RatingScale.tsx +129 -0
- package/templates/docs/templates/SliderMatrixScale.tsx +248 -0
- package/templates/docs/templates/SurveyStickyChrome.tsx +24 -0
- package/templates/docs/templates/customFieldValues.ts +36 -0
- package/templates/docs/templates/implementation_plan.md +75 -8
- package/templates/docs/templates/labelStyles.ts +33 -0
- package/templates/docs/templates/selectionStyles.ts +100 -0
- package/templates/docs/templates/survey-inventory.schema.json +28 -23
- package/templates/docs/templates/surveyUiIcons.tsx +63 -0
- package/templates/docs/templates/surveyUiScaleUtils.ts +51 -0
- package/templates/docs/templates/verify-agent-build.sh +151 -0
- package/templates/preview-harness/preview-bridge.inline.js +201 -0
- package/templates/preview-harness/previewPages.js +108 -0
- package/templates/preview-harness/previewPages.ts +298 -0
- package/templates/preview-harness/vite-app/src/PreviewConfigContext.tsx +67 -0
- package/templates/preview-harness/vite-app/src/QuestionPreview.tsx +122 -0
- package/templates/preview-harness/vite-app/src/SurveyPagePreview.tsx +75 -0
- package/templates/preview-harness/vite-app/src/fixtures/questions.ts +311 -0
- package/templates/preview-harness/vite-app/src/globals.css +16 -0
- package/templates/preview-harness/vite-app/src/mount.tsx +9 -0
- package/templates/preview-harness/vite-app/src/preview-live-overrides.css +101 -0
- package/templates/preview-harness/vite-app/stubs/next-dynamic.tsx +25 -0
- package/templates/preview-harness/vite-app/stubs/next-image.tsx +29 -0
- package/templates/previewBridge.ts +153 -0
- package/templates/survey-theme.css +295 -0
- package/templates/survey-ui-config.schema.json +213 -0
- package/templates/wizard-dist/assets/PreviewMock-DgHfrVeb.js +1 -0
- package/templates/wizard-dist/assets/TypePanel-CFVC3Ptn.js +1 -0
- package/templates/wizard-dist/assets/index-BhWM50Yu.css +1 -0
- package/templates/wizard-dist/assets/index-DYK3X1e5.js +34 -0
- package/templates/wizard-dist/assets/vendor-BwkXDkd3.js +17 -0
- package/templates/wizard-dist/index.html +20 -0
- package/templates/wizard.html +13 -1129
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Portable CustomSliderTrack template — copy to src/components/CustomSliderTrack.tsx
|
|
5
|
+
* Also copy surveyUiIcons.tsx to the same folder (or src/lib/surveyUiIcons.tsx)
|
|
6
|
+
* Pass tickLabels from scaleColumns[].label for matrix scale label rendering (Day 19 fix)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React from 'react';
|
|
10
|
+
import { getEmojiForIndex } from './surveyUiIcons';
|
|
11
|
+
|
|
12
|
+
type CustomSliderTrackProps = {
|
|
13
|
+
min: number;
|
|
14
|
+
max: number;
|
|
15
|
+
htmlStep: number;
|
|
16
|
+
value: number;
|
|
17
|
+
disabled: boolean;
|
|
18
|
+
displayValues?: boolean;
|
|
19
|
+
hasSelectedValue: boolean;
|
|
20
|
+
sliderType?: 'graphics';
|
|
21
|
+
ticks?: number;
|
|
22
|
+
/** Pass scaleColumns.map(c => c.label) for visible column labels under slider */
|
|
23
|
+
tickLabels?: string[];
|
|
24
|
+
reverseScaleOrder?: boolean;
|
|
25
|
+
onChange: (v: number) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function CustomSliderTrack({
|
|
29
|
+
min,
|
|
30
|
+
max,
|
|
31
|
+
htmlStep,
|
|
32
|
+
value,
|
|
33
|
+
disabled,
|
|
34
|
+
sliderType,
|
|
35
|
+
ticks,
|
|
36
|
+
tickLabels,
|
|
37
|
+
reverseScaleOrder,
|
|
38
|
+
onChange,
|
|
39
|
+
}: CustomSliderTrackProps) {
|
|
40
|
+
const [isHovered, setIsHovered] = React.useState(false);
|
|
41
|
+
const [isDragging, setIsDragging] = React.useState(false);
|
|
42
|
+
|
|
43
|
+
const percentage = ((value - min) / (max - min)) * 100;
|
|
44
|
+
const tickIndex = Math.min(
|
|
45
|
+
Math.max(0, Math.round(value)),
|
|
46
|
+
tickLabels && tickLabels.length > 0 ? tickLabels.length - 1 : Math.round(value)
|
|
47
|
+
);
|
|
48
|
+
const tooltipLabel = tickLabels?.[tickIndex];
|
|
49
|
+
const tooltipText = tooltipLabel
|
|
50
|
+
? tooltipLabel.replace(/<[^>]*>/g, '')
|
|
51
|
+
: String(Math.round(value));
|
|
52
|
+
|
|
53
|
+
const themeColor = disabled ? '#9ca3af' : '#e20074';
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
style={{ position: 'relative', height: '32px', display: 'flex', alignItems: 'center', margin: '0 16px', opacity: disabled ? 0.5 : 1 }}
|
|
58
|
+
onMouseEnter={() => !disabled && setIsHovered(true)}
|
|
59
|
+
onMouseLeave={() => { setIsHovered(false); setIsDragging(false); }}
|
|
60
|
+
>
|
|
61
|
+
<div style={{
|
|
62
|
+
position: 'absolute', top: '50%', left: 0, right: 0, transform: 'translateY(-50%)',
|
|
63
|
+
height: '4px', borderRadius: '2px', backgroundColor: '#e5e7eb',
|
|
64
|
+
}} />
|
|
65
|
+
|
|
66
|
+
<div style={{
|
|
67
|
+
position: 'absolute', top: '50%', left: 0, width: `${percentage}%`, transform: 'translateY(-50%)',
|
|
68
|
+
height: '4px', borderRadius: '2px', backgroundColor: themeColor,
|
|
69
|
+
transition: isDragging ? 'none' : 'width 0.15s ease-in-out',
|
|
70
|
+
}} />
|
|
71
|
+
|
|
72
|
+
{ticks && ticks > 1 && Array.from({ length: ticks }).map((_, idx) => {
|
|
73
|
+
const tickPct = (idx / (ticks - 1)) * 100;
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
key={idx}
|
|
77
|
+
style={{
|
|
78
|
+
position: 'absolute', top: '50%', left: `${tickPct}%`, transform: 'translate(-50%, -50%)',
|
|
79
|
+
width: '1px', height: '8px', backgroundColor: '#d1d5db', pointerEvents: 'none',
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
83
|
+
})}
|
|
84
|
+
|
|
85
|
+
<input
|
|
86
|
+
type="range"
|
|
87
|
+
min={min} max={max} step={htmlStep} value={value}
|
|
88
|
+
disabled={disabled}
|
|
89
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
90
|
+
onMouseDown={() => !disabled && setIsDragging(true)}
|
|
91
|
+
onMouseUp={() => setIsDragging(false)}
|
|
92
|
+
onTouchStart={() => !disabled && setIsDragging(true)}
|
|
93
|
+
onTouchEnd={() => setIsDragging(false)}
|
|
94
|
+
style={{
|
|
95
|
+
position: 'absolute', top: '50%', left: '-16px', right: '-16px', width: 'calc(100% + 32px)', height: '100%',
|
|
96
|
+
transform: 'translateY(-50%)', opacity: 0, cursor: disabled ? 'not-allowed' : 'pointer', zIndex: 10, margin: 0,
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
|
|
100
|
+
{sliderType === 'graphics' ? (
|
|
101
|
+
<div style={{
|
|
102
|
+
position: 'absolute', top: '50%', left: `${percentage}%`, transform: 'translate(-50%, -50%)',
|
|
103
|
+
transition: isDragging ? 'none' : 'left 0.15s ease-in-out', pointerEvents: 'none', zIndex: 11,
|
|
104
|
+
opacity: disabled ? 0.5 : 1,
|
|
105
|
+
}}>
|
|
106
|
+
{getEmojiForIndex(
|
|
107
|
+
reverseScaleOrder
|
|
108
|
+
? (ticks || 10) - 1 - Math.min((ticks || 10) - 1, Math.max(0, Math.round(((value - min) / (max - min)) * ((ticks || 10) - 1))))
|
|
109
|
+
: Math.min((ticks || 10) - 1, Math.max(0, Math.round(((value - min) / (max - min)) * ((ticks || 10) - 1)))),
|
|
110
|
+
ticks || 10
|
|
111
|
+
) as React.ReactNode}
|
|
112
|
+
</div>
|
|
113
|
+
) : (
|
|
114
|
+
<div style={{
|
|
115
|
+
position: 'absolute', top: '50%', left: `${percentage}%`, transform: 'translate(-50%, -50%)',
|
|
116
|
+
width: '18px', height: '18px', borderRadius: '50%', backgroundColor: '#ffffff',
|
|
117
|
+
border: `2.5px solid ${themeColor}`,
|
|
118
|
+
boxShadow: (isHovered || isDragging) && !disabled
|
|
119
|
+
? '0 0 0 6px rgba(226, 0, 116, 0.2), 0 1px 3px rgba(0,0,0,0.2)'
|
|
120
|
+
: '0 1px 3px rgba(0,0,0,0.2)',
|
|
121
|
+
pointerEvents: 'none',
|
|
122
|
+
transition: isDragging ? 'none' : 'left 0.15s ease-in-out, box-shadow 0.15s, border-color 0.2s',
|
|
123
|
+
boxSizing: 'border-box',
|
|
124
|
+
}} />
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
{(isHovered || isDragging) && !disabled && (
|
|
128
|
+
<div style={{
|
|
129
|
+
position: 'absolute', bottom: '100%', left: `${percentage}%`, transform: 'translate(-50%, -10px)',
|
|
130
|
+
backgroundColor: '#1f2937', color: '#fff', fontSize: '13px', fontWeight: 600,
|
|
131
|
+
padding: '4px 10px', borderRadius: '6px', pointerEvents: 'none',
|
|
132
|
+
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', zIndex: 20,
|
|
133
|
+
}}>
|
|
134
|
+
{tooltipText}
|
|
135
|
+
<div style={{
|
|
136
|
+
position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
|
137
|
+
borderWidth: '5px', borderStyle: 'solid',
|
|
138
|
+
borderColor: '#1f2937 transparent transparent transparent',
|
|
139
|
+
}} />
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Portable FileUploadScale — copy to src/components/FileUploadScale.tsx
|
|
5
|
+
* Wire UPLOAD_API_BASE env (see docs/00-integration/file-upload-aws.md).
|
|
6
|
+
* Final answer MUST be UploadedFileAnswer[] with id + mediaUrl — not raw File[].
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React, { useState, useRef } from 'react';
|
|
10
|
+
import type { FileUploadQuestion } from '@explorer02/cfm-survey-sdk';
|
|
11
|
+
|
|
12
|
+
type UploadedFileAnswer = {
|
|
13
|
+
id: string;
|
|
14
|
+
mediaUrl: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
mimeType?: string;
|
|
17
|
+
size?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type FileUploadScaleProps = {
|
|
21
|
+
question: FileUploadQuestion;
|
|
22
|
+
selectedValue?: UploadedFileAnswer[];
|
|
23
|
+
onSelect: (value: UploadedFileAnswer[] | undefined) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const UPLOAD_API_BASE =
|
|
27
|
+
typeof process !== 'undefined' && process.env.NEXT_PUBLIC_UPLOAD_API_BASE
|
|
28
|
+
? process.env.NEXT_PUBLIC_UPLOAD_API_BASE
|
|
29
|
+
: typeof process !== 'undefined' && process.env.VITE_UPLOAD_API_BASE
|
|
30
|
+
? process.env.VITE_UPLOAD_API_BASE
|
|
31
|
+
: '';
|
|
32
|
+
|
|
33
|
+
async function uploadFileToS3(
|
|
34
|
+
file: File,
|
|
35
|
+
questionId: string
|
|
36
|
+
): Promise<UploadedFileAnswer> {
|
|
37
|
+
if (!UPLOAD_API_BASE) {
|
|
38
|
+
throw new Error('UPLOAD_API_BASE env not configured — see file-upload-aws.md');
|
|
39
|
+
}
|
|
40
|
+
const res = await fetch(`${UPLOAD_API_BASE}/get-upload-url`, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
fileName: file.name,
|
|
45
|
+
contentType: file.type || 'application/octet-stream',
|
|
46
|
+
questionId,
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) throw new Error('Failed to get upload URL');
|
|
50
|
+
const { uploadUrl, fileId, mediaUrl } = await res.json();
|
|
51
|
+
const putRes = await fetch(uploadUrl, {
|
|
52
|
+
method: 'PUT',
|
|
53
|
+
body: file,
|
|
54
|
+
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
|
55
|
+
});
|
|
56
|
+
if (!putRes.ok) throw new Error('Failed to upload file');
|
|
57
|
+
return {
|
|
58
|
+
id: fileId,
|
|
59
|
+
mediaUrl,
|
|
60
|
+
name: file.name,
|
|
61
|
+
mimeType: file.type,
|
|
62
|
+
size: file.size,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function FileUploadDropzone({
|
|
67
|
+
uploadMessage,
|
|
68
|
+
isDragActive,
|
|
69
|
+
maxFileCount,
|
|
70
|
+
fileSizeLimit,
|
|
71
|
+
fileSizeLimitType,
|
|
72
|
+
formattedFormats,
|
|
73
|
+
disabled,
|
|
74
|
+
onTriggerInput,
|
|
75
|
+
onDrag,
|
|
76
|
+
onDrop,
|
|
77
|
+
}: {
|
|
78
|
+
uploadMessage: string;
|
|
79
|
+
isDragActive: boolean;
|
|
80
|
+
maxFileCount: number;
|
|
81
|
+
fileSizeLimit: number;
|
|
82
|
+
fileSizeLimitType: string;
|
|
83
|
+
formattedFormats: string;
|
|
84
|
+
disabled: boolean;
|
|
85
|
+
onTriggerInput: () => void;
|
|
86
|
+
onDrag: (e: React.DragEvent) => void;
|
|
87
|
+
onDrop: (e: React.DragEvent) => void;
|
|
88
|
+
}) {
|
|
89
|
+
return (
|
|
90
|
+
<div
|
|
91
|
+
onDragEnter={onDrag}
|
|
92
|
+
onDragOver={onDrag}
|
|
93
|
+
onDragLeave={onDrag}
|
|
94
|
+
onDrop={onDrop}
|
|
95
|
+
onClick={disabled ? undefined : onTriggerInput}
|
|
96
|
+
style={{
|
|
97
|
+
display: 'flex',
|
|
98
|
+
flexDirection: 'column',
|
|
99
|
+
alignItems: 'center',
|
|
100
|
+
justifyContent: 'center',
|
|
101
|
+
border: `2px dashed ${isDragActive ? '#e20074' : '#d1d5db'}`,
|
|
102
|
+
borderRadius: '12px',
|
|
103
|
+
backgroundColor: isDragActive ? '#fdf2f8' : '#f9fafb',
|
|
104
|
+
padding: '40px 24px',
|
|
105
|
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
106
|
+
opacity: disabled ? 0.6 : 1,
|
|
107
|
+
textAlign: 'center',
|
|
108
|
+
boxSizing: 'border-box',
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
<div style={{ fontSize: '16px', fontWeight: 600, color: '#374151', marginBottom: '8px' }}
|
|
112
|
+
dangerouslySetInnerHTML={{ __html: uploadMessage }}
|
|
113
|
+
/>
|
|
114
|
+
<div style={{ fontSize: '13px', color: '#6b7280', lineHeight: 1.5 }}>
|
|
115
|
+
File limit: {maxFileCount}; File size limit: {fileSizeLimit}MB/
|
|
116
|
+
{fileSizeLimitType === 'PER_FILE' ? 'file' : 'in total'};
|
|
117
|
+
{formattedFormats && ` Supports: ${formattedFormats};`}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function FileUploadScale({ question, selectedValue, onSelect }: FileUploadScaleProps) {
|
|
124
|
+
const {
|
|
125
|
+
uploadMessage = 'Drag attachments here or browse',
|
|
126
|
+
supportedFileFormats = [],
|
|
127
|
+
maxFileCount = 1,
|
|
128
|
+
fileSizeLimit = 10,
|
|
129
|
+
fileSizeLimitType = 'PER_FILE',
|
|
130
|
+
} = question;
|
|
131
|
+
|
|
132
|
+
const [isDragActive, setIsDragActive] = useState(false);
|
|
133
|
+
const [localError, setLocalError] = useState<string | null>(null);
|
|
134
|
+
const [uploading, setUploading] = useState(false);
|
|
135
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
136
|
+
|
|
137
|
+
const currentFiles: UploadedFileAnswer[] = Array.isArray(selectedValue) ? selectedValue : [];
|
|
138
|
+
|
|
139
|
+
const validateAndUpload = async (files: FileList) => {
|
|
140
|
+
setLocalError(null);
|
|
141
|
+
const newFiles = Array.from(files);
|
|
142
|
+
|
|
143
|
+
if (currentFiles.length + newFiles.length > maxFileCount) {
|
|
144
|
+
setLocalError(`You can only upload up to ${maxFileCount} file(s) in total.`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const file of newFiles) {
|
|
149
|
+
const ext = file.name.split('.').pop()?.toUpperCase() || '';
|
|
150
|
+
if (supportedFileFormats.length > 0 && !supportedFileFormats.includes(ext)) {
|
|
151
|
+
setLocalError(`File type .${ext.toLowerCase()} is not supported.`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (fileSizeLimitType === 'PER_FILE' && file.size > fileSizeLimit * 1024 * 1024) {
|
|
155
|
+
setLocalError(`File "${file.name}" exceeds the size limit of ${fileSizeLimit}MB per file.`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (fileSizeLimitType === 'IN_TOTAL') {
|
|
161
|
+
const total = [...currentFiles.map(f => f.size ?? 0), ...newFiles.map(f => f.size)].reduce((a, b) => a + b, 0);
|
|
162
|
+
if (total > fileSizeLimit * 1024 * 1024) {
|
|
163
|
+
setLocalError(`Total size exceeds the limit of ${fileSizeLimit}MB.`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
setUploading(true);
|
|
169
|
+
try {
|
|
170
|
+
const uploaded: UploadedFileAnswer[] = [];
|
|
171
|
+
for (const file of newFiles) {
|
|
172
|
+
uploaded.push(await uploadFileToS3(file, question.id));
|
|
173
|
+
}
|
|
174
|
+
onSelect([...currentFiles, ...uploaded]);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
setLocalError(err instanceof Error ? err.message : 'Upload failed');
|
|
177
|
+
} finally {
|
|
178
|
+
setUploading(false);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const handleDrag = (e: React.DragEvent) => {
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
e.stopPropagation();
|
|
185
|
+
if (uploading) return;
|
|
186
|
+
setIsDragActive(e.type === 'dragenter' || e.type === 'dragover');
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const handleDrop = (e: React.DragEvent) => {
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
e.stopPropagation();
|
|
192
|
+
setIsDragActive(false);
|
|
193
|
+
if (!uploading && e.dataTransfer.files?.length) {
|
|
194
|
+
void validateAndUpload(e.dataTransfer.files);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const removeFile = (index: number) => {
|
|
199
|
+
const next = currentFiles.filter((_, i) => i !== index);
|
|
200
|
+
onSelect(next.length > 0 ? next : undefined);
|
|
201
|
+
setLocalError(null);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const formattedFormats = supportedFileFormats.join(', ');
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
|
|
208
|
+
<input
|
|
209
|
+
type="file"
|
|
210
|
+
ref={fileInputRef}
|
|
211
|
+
onChange={e => e.target.files && void validateAndUpload(e.target.files)}
|
|
212
|
+
multiple={maxFileCount > 1}
|
|
213
|
+
accept={supportedFileFormats.map(f => `.${f.toLowerCase()}`).join(',')}
|
|
214
|
+
style={{ display: 'none' }}
|
|
215
|
+
/>
|
|
216
|
+
|
|
217
|
+
<FileUploadDropzone
|
|
218
|
+
uploadMessage={uploadMessage}
|
|
219
|
+
isDragActive={isDragActive}
|
|
220
|
+
maxFileCount={maxFileCount}
|
|
221
|
+
fileSizeLimit={fileSizeLimit}
|
|
222
|
+
fileSizeLimitType={fileSizeLimitType}
|
|
223
|
+
formattedFormats={formattedFormats}
|
|
224
|
+
disabled={uploading}
|
|
225
|
+
onTriggerInput={() => fileInputRef.current?.click()}
|
|
226
|
+
onDrag={handleDrag}
|
|
227
|
+
onDrop={handleDrop}
|
|
228
|
+
/>
|
|
229
|
+
|
|
230
|
+
{uploading && (
|
|
231
|
+
<div style={{ fontSize: '14px', color: '#6b7280' }}>Uploading…</div>
|
|
232
|
+
)}
|
|
233
|
+
|
|
234
|
+
{localError && (
|
|
235
|
+
<div style={{
|
|
236
|
+
backgroundColor: '#fee2e2', border: '1px solid #fecaca', borderRadius: '8px',
|
|
237
|
+
padding: '12px 16px', color: '#b91c1c', fontSize: '14px', fontWeight: 500,
|
|
238
|
+
}}>
|
|
239
|
+
{localError}
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{currentFiles.length > 0 && (
|
|
244
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
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>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Portable HeatmapScale — copy to src/components/HeatmapScale.tsx
|
|
5
|
+
* Also copy templates/heatmapCoords.ts to src/lib/heatmapCoords.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useState } from 'react';
|
|
9
|
+
import type { HeatmapAnswer, HeatmapQuestion } from '@explorer02/cfm-survey-sdk';
|
|
10
|
+
import {
|
|
11
|
+
createNewSpot,
|
|
12
|
+
findSpotNearClick,
|
|
13
|
+
getNormalizedClickFromImage,
|
|
14
|
+
} from './heatmapCoords';
|
|
15
|
+
|
|
16
|
+
type HeatmapScaleProps = {
|
|
17
|
+
question: HeatmapQuestion;
|
|
18
|
+
selectedValue?: HeatmapAnswer;
|
|
19
|
+
onSelect: (answer: HeatmapAnswer) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function HeatmapSpot({ spot }: { spot: { id: string; x: number; y: number } }) {
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
className="absolute rounded-full transition-transform duration-150 ease-out"
|
|
26
|
+
style={{
|
|
27
|
+
left: `${spot.x * 100}%`,
|
|
28
|
+
top: `${spot.y * 100}%`,
|
|
29
|
+
width: 16,
|
|
30
|
+
height: 16,
|
|
31
|
+
transform: 'translate(-50%, -50%)',
|
|
32
|
+
backgroundColor: '#F97316',
|
|
33
|
+
border: '2px solid white',
|
|
34
|
+
boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
|
|
35
|
+
}}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function HeatmapScale({ question, selectedValue = [], onSelect }: HeatmapScaleProps) {
|
|
41
|
+
const [isImageReady, setIsImageReady] = useState(false);
|
|
42
|
+
const spots = selectedValue ?? [];
|
|
43
|
+
const spotLabel = question.maxClicksAllowed === 1 ? 'spot' : 'spots';
|
|
44
|
+
|
|
45
|
+
function handleImageClick(event: React.MouseEvent<HTMLImageElement>) {
|
|
46
|
+
if (!isImageReady) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const imageElement = event.currentTarget;
|
|
51
|
+
const rect = imageElement.getBoundingClientRect();
|
|
52
|
+
const { x, y } = getNormalizedClickFromImage(event, imageElement);
|
|
53
|
+
const nearSpot = findSpotNearClick(x, y, spots, rect);
|
|
54
|
+
|
|
55
|
+
if (nearSpot) {
|
|
56
|
+
const updated = spots.filter(spot => spot.id !== nearSpot.id);
|
|
57
|
+
onSelect(updated);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (spots.length >= question.maxClicksAllowed) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
onSelect([...spots, createNewSpot(x, y)]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function handleImageLoad() {
|
|
69
|
+
setIsImageReady(true);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function handleImageError() {
|
|
73
|
+
setIsImageReady(false);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="mt-4 flex w-full flex-col items-center">
|
|
78
|
+
<div className="relative inline-block max-w-full">
|
|
79
|
+
{!isImageReady && (
|
|
80
|
+
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-gray-100">
|
|
81
|
+
<span className="text-sm text-gray-500">Loading image...</span>
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
<img
|
|
85
|
+
src={question.imageUrl}
|
|
86
|
+
alt=""
|
|
87
|
+
draggable={false}
|
|
88
|
+
onLoad={handleImageLoad}
|
|
89
|
+
onError={handleImageError}
|
|
90
|
+
onClick={handleImageClick}
|
|
91
|
+
className="block max-w-full h-auto select-none rounded-md"
|
|
92
|
+
style={{ cursor: isImageReady ? 'crosshair' : 'default' }}
|
|
93
|
+
/>
|
|
94
|
+
{isImageReady && (
|
|
95
|
+
<div className="pointer-events-none absolute inset-0">
|
|
96
|
+
{spots.map(spot => (
|
|
97
|
+
<HeatmapSpot key={spot.id} spot={spot} />
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<p className="mt-3 flex items-start gap-2 text-sm text-gray-500">
|
|
104
|
+
<span aria-hidden="true" className="mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-gray-400 text-[10px] font-semibold text-gray-500">
|
|
105
|
+
i
|
|
106
|
+
</span>
|
|
107
|
+
<span>
|
|
108
|
+
You can select up to {question.maxClicksAllowed} {spotLabel}. To change your selection,
|
|
109
|
+
simply click on the same spot again to remove it.
|
|
110
|
+
</span>
|
|
111
|
+
</p>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|