@explorer02/cfm-survey-sdk 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/cli/index.js +18 -18
  2. package/dist/cli/index.mjs +14 -14
  3. package/dist/index.d.mts +465 -580
  4. package/dist/index.d.ts +465 -580
  5. package/dist/index.js +1 -1
  6. package/dist/index.mjs +1 -1
  7. package/package.json +6 -4
  8. package/postinstall.js +96 -69
  9. package/templates/AGENT.md +18 -8
  10. package/templates/docs/00-integration/analytics-events-catalog.md +54 -0
  11. package/templates/docs/00-integration/answer-logic-and-navigation.md +50 -0
  12. package/templates/docs/00-integration/aws-deploy.md +281 -0
  13. package/templates/docs/00-integration/client-integration-guide.md +351 -0
  14. package/templates/docs/00-integration/component-checklist.md +55 -22
  15. package/templates/docs/00-integration/constraints.md +49 -4
  16. package/templates/docs/00-integration/custom-field-logic-and-navigation.md +174 -0
  17. package/templates/docs/00-integration/display-logic-and-navigation.md +64 -0
  18. package/templates/docs/00-integration/end-page-logic.md +64 -0
  19. package/templates/docs/00-integration/file-upload-aws.md +116 -0
  20. package/templates/docs/00-integration/logic-fields-catalog.md +105 -0
  21. package/templates/docs/00-integration/partial-save-and-recovery.md +98 -0
  22. package/templates/docs/00-integration/placeholders-and-tokens.md +116 -0
  23. package/templates/docs/00-integration/progress.md +61 -0
  24. package/templates/docs/00-integration/question-display-variants.md +65 -0
  25. package/templates/docs/00-integration/question-numbering.md +51 -0
  26. package/templates/docs/00-integration/question-type-sdk-matrix.md +244 -0
  27. package/templates/docs/00-integration/setup.md +84 -0
  28. package/templates/docs/00-integration/skip-logic-and-navigation.md +80 -0
  29. package/templates/docs/00-integration/survey-lifecycle-analytics.md +94 -0
  30. package/templates/docs/00-integration/useSurveySDK.md +151 -23
  31. package/templates/docs/01-components/01-survey-page.md +76 -54
  32. package/templates/docs/01-components/02-question.md +61 -58
  33. package/templates/docs/01-components/03-rating-scale.md +11 -1
  34. package/templates/docs/01-components/04-star-rating-scale.md +45 -0
  35. package/templates/docs/01-components/05-csat-matrix-scale.md +20 -11
  36. package/templates/docs/01-components/06-likert-matrix-scale.md +14 -4
  37. package/templates/docs/01-components/07-slider-matrix-scale.md +11 -2
  38. package/templates/docs/01-components/08-file-upload-scale.md +23 -51
  39. package/templates/docs/01-components/09-custom-slider-track.md +2 -2
  40. package/templates/docs/01-components/11-progress-bar.md +10 -4
  41. package/templates/docs/01-components/13-matrix-dropdown.md +29 -32
  42. package/templates/docs/01-components/14-intro-page.md +34 -0
  43. package/templates/docs/01-components/15-end-page.md +31 -0
  44. package/templates/docs/01-components/16-paused-page.md +27 -0
  45. package/templates/docs/01-components/17-heatmap-scale.md +75 -0
  46. package/templates/docs/01-components/18-rank-order-scale.md +93 -0
  47. package/templates/docs/01-components/README.md +42 -36
  48. package/templates/docs/02-reference/config-field-index.md +141 -139
  49. package/templates/docs/02-reference/exports.md +149 -0
  50. package/templates/docs/02-reference/question-types/01-rating.md +31 -32
  51. package/templates/docs/02-reference/question-types/04-csat.md +51 -42
  52. package/templates/docs/02-reference/question-types/05-rating-scale.md +16 -11
  53. package/templates/docs/02-reference/question-types/06-slider.md +2 -2
  54. package/templates/docs/02-reference/question-types/07-matrix-cfm.md +44 -30
  55. package/templates/docs/02-reference/question-types/08-matrix-csat.md +4 -26
  56. package/templates/docs/02-reference/question-types/09-matrix-rating.md +29 -13
  57. package/templates/docs/02-reference/question-types/10-slider-matrix.md +15 -4
  58. package/templates/docs/02-reference/question-types/11-file-upload.md +43 -17
  59. package/templates/docs/02-reference/question-types/12-heatmap.md +59 -0
  60. package/templates/docs/02-reference/question-types/13-rank-order.md +101 -0
  61. package/templates/docs/02-reference/question-types/README.md +96 -47
  62. package/templates/docs/02-reference/routing-table.md +33 -18
  63. package/templates/docs/02-reference/value-derivation.md +63 -0
  64. package/templates/docs/03-ui-specs/00-question-shell.md +19 -88
  65. package/templates/docs/03-ui-specs/01-rating.md +18 -52
  66. package/templates/docs/03-ui-specs/02-radio.md +80 -37
  67. package/templates/docs/03-ui-specs/03-text.md +2 -2
  68. package/templates/docs/03-ui-specs/04-csat.md +68 -33
  69. package/templates/docs/03-ui-specs/05-rating-scale.md +21 -18
  70. package/templates/docs/03-ui-specs/06-slider.md +3 -1
  71. package/templates/docs/03-ui-specs/07-matrix-cfm.md +44 -25
  72. package/templates/docs/03-ui-specs/08-matrix-csat-rating.md +19 -43
  73. package/templates/docs/03-ui-specs/09-slider-matrix.md +18 -1
  74. package/templates/docs/03-ui-specs/10-file-upload.md +42 -21
  75. package/templates/docs/03-ui-specs/11-text-and-media.md +29 -29
  76. package/templates/docs/03-ui-specs/12-survey-chrome.md +58 -14
  77. package/templates/docs/03-ui-specs/13-heatmap.md +80 -0
  78. package/templates/docs/03-ui-specs/14-rank-order.md +128 -0
  79. package/templates/docs/03-ui-specs/README.md +16 -14
  80. package/templates/docs/03-ui-specs/shared/custom-slider-track.md +28 -35
  81. package/templates/docs/03-ui-specs/shared/matrix-dropdown.md +5 -0
  82. package/templates/docs/MANIFEST.json +476 -69
  83. package/templates/docs/index.md +82 -30
  84. package/templates/docs/templates/CustomSliderTrack.tsx +144 -0
  85. package/templates/docs/templates/MatrixDropdown.tsx +216 -0
  86. package/templates/docs/templates/Question.tsx +221 -154
  87. package/templates/docs/templates/RankOrderScale.tsx +353 -0
  88. package/templates/docs/templates/deploy-to-aws.sh +114 -0
  89. package/templates/docs/templates/heatmapCoords.ts +58 -0
  90. package/templates/docs/templates/implementation_plan.md +126 -67
  91. package/templates/docs/templates/survey-inventory.schema.json +77 -0
  92. package/templates/docs/templates/surveyUiIcons.tsx +52 -0
  93. package/templates/docs/templates/verify-agent-build.sh +85 -0
  94. package/templates/docs/01-components/04-csat-scale.md +0 -40
  95. package/templates/docs/02-question-types/01-rating.md +0 -44
@@ -0,0 +1,353 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Portable RankOrderScale template — copy to src/components/RankOrderScale.tsx
5
+ * Import SDK from your package.json: @explorer02/cfm-survey-sdk or @repo/sdk
6
+ * Requires: @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
7
+ * Next.js: add 'use client' (already present); optional dynamic(..., { ssr: false }) wrapper in Question.tsx
8
+ */
9
+
10
+ import React, { useMemo } from 'react';
11
+ import {
12
+ DndContext,
13
+ closestCenter,
14
+ KeyboardSensor,
15
+ PointerSensor,
16
+ useSensor,
17
+ useSensors,
18
+ type DragEndEvent,
19
+ } from '@dnd-kit/core';
20
+ import {
21
+ SortableContext,
22
+ arrayMove,
23
+ sortableKeyboardCoordinates,
24
+ useSortable,
25
+ verticalListSortingStrategy,
26
+ } from '@dnd-kit/sortable';
27
+ import { CSS } from '@dnd-kit/utilities';
28
+ import type {
29
+ RankOrderAnswers,
30
+ RankOrderOption,
31
+ RankOrderOptionDisplay,
32
+ RankOrderQuestion,
33
+ } from '@explorer02/cfm-survey-sdk';
34
+ import {
35
+ assignRankWithoutDuplicates,
36
+ buildRankOrderFromOrderedOptionIds,
37
+ getOrderedOptionIdsFromRanks,
38
+ normalizeRankOrderAnswers,
39
+ } from '@explorer02/cfm-survey-sdk';
40
+
41
+ type RankOrderScaleProps = {
42
+ question: RankOrderQuestion;
43
+ selectedValue?: RankOrderAnswers;
44
+ onSelect: (value: RankOrderAnswers) => void;
45
+ };
46
+
47
+ type RankOrderOptionRowProps = {
48
+ option: RankOrderOption;
49
+ optionDisplay: RankOrderOptionDisplay;
50
+ rankSelect?: React.ReactNode;
51
+ trailingControls?: React.ReactNode;
52
+ dragHandle?: React.ReactNode;
53
+ rankBadge?: number;
54
+ };
55
+
56
+ function RankOrderOptionContent({
57
+ option,
58
+ optionDisplay,
59
+ }: {
60
+ option: RankOrderOption;
61
+ optionDisplay: RankOrderOptionDisplay;
62
+ }) {
63
+ const imageUrl = option.imageUrl || option.previewImageUrl;
64
+
65
+ if (optionDisplay === 'imageOnly') {
66
+ return (
67
+ <div className="flex flex-1 items-center">
68
+ {imageUrl ? (
69
+ <img
70
+ src={imageUrl}
71
+ alt={option.optionLabel || 'Option image'}
72
+ className="h-24 w-24 rounded-md object-cover"
73
+ />
74
+ ) : (
75
+ <div className="flex h-24 w-24 items-center justify-center rounded-md bg-gray-100 text-xs text-gray-400">
76
+ No image
77
+ </div>
78
+ )}
79
+ </div>
80
+ );
81
+ }
82
+
83
+ if (optionDisplay === 'textOnly') {
84
+ return (
85
+ <span
86
+ className="flex-1 text-[15px] font-medium leading-tight text-gray-900"
87
+ dangerouslySetInnerHTML={{ __html: option.optionLabel }}
88
+ />
89
+ );
90
+ }
91
+
92
+ return (
93
+ <div className="flex flex-1 items-center gap-4">
94
+ {imageUrl ? (
95
+ <img
96
+ src={imageUrl}
97
+ alt={option.optionLabel || 'Option image'}
98
+ className="h-14 w-14 shrink-0 rounded-md object-cover"
99
+ />
100
+ ) : (
101
+ <div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-gray-100 text-xs text-gray-400">
102
+ No image
103
+ </div>
104
+ )}
105
+ <span
106
+ className="text-[15px] font-medium leading-tight text-gray-900"
107
+ dangerouslySetInnerHTML={{ __html: option.optionLabel }}
108
+ />
109
+ </div>
110
+ );
111
+ }
112
+
113
+ function RankOrderOptionRow({
114
+ option,
115
+ optionDisplay,
116
+ rankSelect,
117
+ trailingControls,
118
+ dragHandle,
119
+ rankBadge,
120
+ }: RankOrderOptionRowProps) {
121
+ return (
122
+ <div className="flex items-center gap-3 rounded-lg border border-[#e5e5e5] bg-white px-4 py-3 transition-colors hover:bg-gray-50/50">
123
+ {rankSelect}
124
+ {dragHandle}
125
+ {rankBadge !== undefined && (
126
+ <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[#fdf2f8] text-sm font-semibold text-[#e20074]">
127
+ {rankBadge}
128
+ </span>
129
+ )}
130
+ <RankOrderOptionContent option={option} optionDisplay={optionDisplay} />
131
+ {trailingControls}
132
+ </div>
133
+ );
134
+ }
135
+
136
+ function DropdownRankLayout({
137
+ question,
138
+ selectedValue,
139
+ onSelect,
140
+ }: RankOrderScaleProps) {
141
+ const rankAnswers = normalizeRankOrderAnswers(selectedValue);
142
+ const rankChoices = question.options.map((_, index) => index + 1);
143
+
144
+ const handleRankChange = (optionId: string, rawValue: string) => {
145
+ const nextRank = rawValue === '' ? undefined : Number(rawValue);
146
+ onSelect(assignRankWithoutDuplicates(rankAnswers, optionId, nextRank));
147
+ };
148
+
149
+ return (
150
+ <div className="space-y-3">
151
+ {question.options.map(option => {
152
+ const currentRank = rankAnswers[option.id];
153
+
154
+ return (
155
+ <RankOrderOptionRow
156
+ key={option.id}
157
+ option={option}
158
+ optionDisplay={question.optionDisplay}
159
+ rankSelect={
160
+ <select
161
+ aria-label={`Rank for ${option.optionLabel || option.id}`}
162
+ value={currentRank ?? ''}
163
+ onChange={event => handleRankChange(option.id, event.target.value)}
164
+ className="h-10 w-16 shrink-0 rounded border border-gray-300 bg-white px-2 text-sm outline-none focus:border-[#e20074] focus:ring-1 focus:ring-[#e20074]"
165
+ >
166
+ <option value="">-</option>
167
+ {rankChoices.map(rank => (
168
+ <option key={rank} value={rank}>
169
+ {rank}
170
+ </option>
171
+ ))}
172
+ </select>
173
+ }
174
+ />
175
+ );
176
+ })}
177
+ </div>
178
+ );
179
+ }
180
+
181
+ type SortableRankItemProps = {
182
+ option: RankOrderOption;
183
+ optionDisplay: RankOrderOptionDisplay;
184
+ rank: number;
185
+ onMoveUp: () => void;
186
+ onMoveDown: () => void;
187
+ isFirst: boolean;
188
+ isLast: boolean;
189
+ };
190
+
191
+ function SortableRankItem({
192
+ option,
193
+ optionDisplay,
194
+ rank,
195
+ onMoveUp,
196
+ onMoveDown,
197
+ isFirst,
198
+ isLast,
199
+ }: SortableRankItemProps) {
200
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
201
+ id: option.id,
202
+ });
203
+
204
+ const style = {
205
+ transform: CSS.Transform.toString(transform),
206
+ transition,
207
+ opacity: isDragging ? 0.85 : 1,
208
+ zIndex: isDragging ? 10 : undefined,
209
+ };
210
+
211
+ return (
212
+ <div ref={setNodeRef} style={style}>
213
+ <RankOrderOptionRow
214
+ option={option}
215
+ optionDisplay={optionDisplay}
216
+ rankBadge={rank}
217
+ dragHandle={
218
+ <button
219
+ type="button"
220
+ aria-label={`Drag to reorder ${option.optionLabel || option.id}`}
221
+ className="flex h-10 w-8 shrink-0 cursor-grab items-center justify-center rounded text-gray-500 hover:bg-gray-100 active:cursor-grabbing"
222
+ {...attributes}
223
+ {...listeners}
224
+ >
225
+ <svg viewBox="0 0 20 20" className="h-5 w-5" fill="currentColor" aria-hidden="true">
226
+ <circle cx="7" cy="6" r="1.5" />
227
+ <circle cx="13" cy="6" r="1.5" />
228
+ <circle cx="7" cy="10" r="1.5" />
229
+ <circle cx="13" cy="10" r="1.5" />
230
+ <circle cx="7" cy="14" r="1.5" />
231
+ <circle cx="13" cy="14" r="1.5" />
232
+ </svg>
233
+ </button>
234
+ }
235
+ trailingControls={
236
+ <div className="flex shrink-0 flex-col gap-1">
237
+ <button
238
+ type="button"
239
+ aria-label={`Move ${option.optionLabel || option.id} up`}
240
+ disabled={isFirst}
241
+ onClick={onMoveUp}
242
+ className="rounded border border-gray-300 px-2 py-1 text-xs text-gray-700 disabled:cursor-not-allowed disabled:opacity-40"
243
+ >
244
+ Up
245
+ </button>
246
+ <button
247
+ type="button"
248
+ aria-label={`Move ${option.optionLabel || option.id} down`}
249
+ disabled={isLast}
250
+ onClick={onMoveDown}
251
+ className="rounded border border-gray-300 px-2 py-1 text-xs text-gray-700 disabled:cursor-not-allowed disabled:opacity-40"
252
+ >
253
+ Down
254
+ </button>
255
+ </div>
256
+ }
257
+ />
258
+ </div>
259
+ );
260
+ }
261
+
262
+ function DragDropRankLayout({
263
+ question,
264
+ selectedValue,
265
+ onSelect,
266
+ }: RankOrderScaleProps) {
267
+ const rankAnswers = normalizeRankOrderAnswers(selectedValue);
268
+ const optionIds = question.options.map(option => option.id);
269
+
270
+ const orderedOptionIds = useMemo(() => {
271
+ const rankedIds = getOrderedOptionIdsFromRanks(rankAnswers, optionIds);
272
+ const unrankedIds = optionIds.filter(optionId => !rankedIds.includes(optionId));
273
+ return rankedIds.length > 0 ? [...rankedIds, ...unrankedIds] : optionIds;
274
+ }, [rankAnswers, optionIds]);
275
+
276
+ const sensors = useSensors(
277
+ useSensor(PointerSensor),
278
+ useSensor(KeyboardSensor, {
279
+ coordinateGetter: sortableKeyboardCoordinates,
280
+ })
281
+ );
282
+
283
+ const updateOrder = (nextOrder: string[]) => {
284
+ onSelect(buildRankOrderFromOrderedOptionIds(nextOrder));
285
+ };
286
+
287
+ const handleDragEnd = (event: DragEndEvent) => {
288
+ const { active, over } = event;
289
+ if (!over || active.id === over.id) {
290
+ return;
291
+ }
292
+
293
+ const oldIndex = orderedOptionIds.indexOf(String(active.id));
294
+ const newIndex = orderedOptionIds.indexOf(String(over.id));
295
+ if (oldIndex === -1 || newIndex === -1) {
296
+ return;
297
+ }
298
+
299
+ updateOrder(arrayMove(orderedOptionIds, oldIndex, newIndex));
300
+ };
301
+
302
+ const moveOption = (optionId: string, direction: 'up' | 'down') => {
303
+ const currentIndex = orderedOptionIds.indexOf(optionId);
304
+ if (currentIndex === -1) {
305
+ return;
306
+ }
307
+
308
+ const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
309
+ if (targetIndex < 0 || targetIndex >= orderedOptionIds.length) {
310
+ return;
311
+ }
312
+
313
+ updateOrder(arrayMove(orderedOptionIds, currentIndex, targetIndex));
314
+ };
315
+
316
+ return (
317
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
318
+ <SortableContext items={orderedOptionIds} strategy={verticalListSortingStrategy}>
319
+ <div className="space-y-3">
320
+ {orderedOptionIds.map((optionId, index) => {
321
+ const option = question.options.find(item => item.id === optionId);
322
+ if (!option) {
323
+ return null;
324
+ }
325
+
326
+ return (
327
+ <SortableRankItem
328
+ key={option.id}
329
+ option={option}
330
+ optionDisplay={question.optionDisplay}
331
+ rank={index + 1}
332
+ isFirst={index === 0}
333
+ isLast={index === orderedOptionIds.length - 1}
334
+ onMoveUp={() => moveOption(option.id, 'up')}
335
+ onMoveDown={() => moveOption(option.id, 'down')}
336
+ />
337
+ );
338
+ })}
339
+ </div>
340
+ </SortableContext>
341
+ </DndContext>
342
+ );
343
+ }
344
+
345
+ export function RankOrderScale({ question, selectedValue, onSelect }: RankOrderScaleProps) {
346
+ if (question.interactionMode === 'dragAndDrop') {
347
+ return (
348
+ <DragDropRankLayout question={question} selectedValue={selectedValue} onSelect={onSelect} />
349
+ );
350
+ }
351
+
352
+ return <DropdownRankLayout question={question} selectedValue={selectedValue} onSelect={onSelect} />;
353
+ }
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env bash
2
+ # Deploy static survey UI to AWS via EC2 middle API (S3 + CloudFront).
3
+ # Prerequisites: CFM_INSTANCE_ID, CFM_DEPLOY_API_BASE, OUT_DIR set in environment.
4
+ # Run from project root after a successful static build.
5
+ set -euo pipefail
6
+
7
+ : "${CFM_INSTANCE_ID:?Set CFM_INSTANCE_ID to the same JWT as SurveyPage SURVEY_INSTANCE_ID}"
8
+ : "${CFM_DEPLOY_API_BASE:?Set CFM_DEPLOY_API_BASE (e.g. http://43.204.26.213:3000)}"
9
+ : "${OUT_DIR:?Set OUT_DIR to absolute path of out/ (Next.js) or dist/ (Vite)}"
10
+
11
+ if [ ! -f "$OUT_DIR/index.html" ]; then
12
+ echo "FAIL: $OUT_DIR/index.html not found — run build first"
13
+ exit 1
14
+ fi
15
+
16
+ FILE_COUNT=$(find "$OUT_DIR" -type f | wc -l | tr -d ' ')
17
+ if [ "$FILE_COUNT" -gt 500 ]; then
18
+ echo "FAIL: $FILE_COUNT files exceeds API limit of 500"
19
+ exit 1
20
+ fi
21
+
22
+ echo "=== AWS Deploy ==="
23
+ echo "API: $CFM_DEPLOY_API_BASE"
24
+ echo "OUT: $OUT_DIR ($FILE_COUNT files)"
25
+ echo
26
+
27
+ HEALTH=$(curl -s "$CFM_DEPLOY_API_BASE/health")
28
+ echo "Health: $HEALTH"
29
+ echo
30
+
31
+ python3 <<'PY' > /tmp/deploy-manifest.json
32
+ import json, os, mimetypes
33
+ from pathlib import Path
34
+ out = Path(os.environ["OUT_DIR"])
35
+ files = []
36
+ for path in sorted(out.rglob("*")):
37
+ if not path.is_file():
38
+ continue
39
+ rel = path.relative_to(out).as_posix()
40
+ mime, _ = mimetypes.guess_type(rel)
41
+ files.append({
42
+ "path": rel,
43
+ "contentType": mime or "application/octet-stream",
44
+ "sizeBytes": path.stat().st_size,
45
+ })
46
+ print(json.dumps({"files": files}))
47
+ print(f"files: {len(files)}", file=__import__('sys').stderr)
48
+ PY
49
+
50
+ echo "Channel 1: deploy/start"
51
+ curl -s -X POST "$CFM_DEPLOY_API_BASE/deploy/start" \
52
+ -H "Authorization: Bearer $CFM_INSTANCE_ID" \
53
+ -H "Content-Type: application/json" \
54
+ -d @/tmp/deploy-manifest.json \
55
+ | tee /tmp/deploy-start.json
56
+ echo
57
+
58
+ if ! python3 -c "import json; json.load(open('/tmp/deploy-start.json'))['deployId']" 2>/dev/null; then
59
+ echo "FAIL: deploy/start did not return deployId"
60
+ exit 1
61
+ fi
62
+
63
+ echo "Channel 2: S3 uploads"
64
+ python3 <<'PY'
65
+ import json, os, subprocess
66
+ from pathlib import Path
67
+
68
+ out = Path(os.environ["OUT_DIR"])
69
+ start = json.load(open("/tmp/deploy-start.json"))
70
+ manifest = {f["path"]: f["contentType"] for f in json.load(open("/tmp/deploy-manifest.json"))["files"]}
71
+ failed = []
72
+
73
+ for item in start["uploads"]:
74
+ path = item["path"]
75
+ url = item["uploadUrl"]
76
+ mime = manifest.get(path, "application/octet-stream")
77
+ proc = subprocess.run(
78
+ ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
79
+ "-X", "PUT", url, "-H", f"Content-Type: {mime}",
80
+ "--data-binary", f"@{out / path}"],
81
+ capture_output=True, text=True,
82
+ )
83
+ code = proc.stdout.strip()
84
+ mark = "OK" if code == "200" else "FAIL"
85
+ print(f"{mark} {code} {path}")
86
+ if code != "200":
87
+ failed.append(path)
88
+
89
+ if failed:
90
+ print(f"\n{len(failed)} failed — rerun deploy (presigned URLs expire)")
91
+ raise SystemExit(1)
92
+ print("\nAll uploads succeeded")
93
+ PY
94
+
95
+ echo
96
+ echo "Channel 3: deploy/complete"
97
+ DEPLOY_ID=$(python3 -c "import json; print(json.load(open('/tmp/deploy-start.json'))['deployId'])")
98
+ curl -s -X POST "$CFM_DEPLOY_API_BASE/deploy/complete" \
99
+ -H "Authorization: Bearer $CFM_INSTANCE_ID" \
100
+ -H "Content-Type: application/json" \
101
+ -d "{\"deployId\":\"$DEPLOY_ID\"}" \
102
+ | tee /tmp/deploy-complete.json
103
+ echo
104
+
105
+ LIVE_URL=$(python3 -c "import json; print(json.load(open('/tmp/deploy-complete.json'))['url'])")
106
+ echo "Live URL: $LIVE_URL"
107
+ HTTP=$(curl -s -o /dev/null -w "%{http_code}" "$LIVE_URL")
108
+ echo "Verify HTTP: $HTTP"
109
+ if [ "$HTTP" != "200" ]; then
110
+ echo "WARN: index.html returned $HTTP — check CloudFront path or invalidation"
111
+ exit 1
112
+ fi
113
+
114
+ echo "=== Deploy complete ==="
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Portable heatmap coordinate helpers for agent projects.
3
+ *
4
+ * Copy to e.g. src/lib/heatmapCoords.ts — do not import from monorepo-only paths.
5
+ * See 01-components/17-heatmap-scale.md and question-type-sdk-matrix.md#heatmap.
6
+ */
7
+ import type { MouseEvent } from 'react';
8
+ import type { HeatmapClickPoint } from '@explorer02/cfm-survey-sdk';
9
+
10
+ export const SPOT_HIT_RADIUS_PX = 14;
11
+
12
+ export function roundCoordinate(value: number): number {
13
+ return Math.round(value * 100000) / 100000;
14
+ }
15
+
16
+ export function getNormalizedClickFromImage(
17
+ event: MouseEvent,
18
+ imageElement: HTMLImageElement
19
+ ): { x: number; y: number } {
20
+ const rect = imageElement.getBoundingClientRect();
21
+ if (rect.width === 0 || rect.height === 0) {
22
+ return { x: 0, y: 0 };
23
+ }
24
+
25
+ const x = (event.clientX - rect.left) / rect.width;
26
+ const y = (event.clientY - rect.top) / rect.height;
27
+
28
+ return {
29
+ x: roundCoordinate(Math.min(1, Math.max(0, x))),
30
+ y: roundCoordinate(Math.min(1, Math.max(0, y))),
31
+ };
32
+ }
33
+
34
+ export function findSpotNearClick(
35
+ clickX: number,
36
+ clickY: number,
37
+ spots: HeatmapClickPoint[],
38
+ imageRect: DOMRect
39
+ ): HeatmapClickPoint | undefined {
40
+ const clickPixelX = clickX * imageRect.width;
41
+ const clickPixelY = clickY * imageRect.height;
42
+
43
+ for (const spot of spots) {
44
+ const spotPixelX = spot.x * imageRect.width;
45
+ const spotPixelY = spot.y * imageRect.height;
46
+ const distance = Math.hypot(spotPixelX - clickPixelX, spotPixelY - clickPixelY);
47
+
48
+ if (distance <= SPOT_HIT_RADIUS_PX) {
49
+ return spot;
50
+ }
51
+ }
52
+
53
+ return undefined;
54
+ }
55
+
56
+ export function createNewSpot(x: number, y: number): HeatmapClickPoint {
57
+ return { id: crypto.randomUUID(), x, y };
58
+ }