@e-llm-studio/feedback-flow 0.0.0-stage → 0.1.32
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/README.md +63 -2
- package/dist/FeedbackErrorBoundary.d.ts +17 -0
- package/dist/FeedbackErrorBoundary.js +26 -0
- package/dist/FeedbackExperience.d.ts +27 -0
- package/dist/FeedbackExperience.js +54 -0
- package/dist/FeedbackModal.d.ts +27 -0
- package/dist/FeedbackModal.js +659 -0
- package/dist/FeedbackPrompt.d.ts +20 -0
- package/dist/FeedbackPrompt.js +43 -0
- package/dist/FeedbackScreenshotThumbnail.d.ts +9 -0
- package/dist/FeedbackScreenshotThumbnail.js +33 -0
- package/dist/StarRating.d.ts +17 -0
- package/dist/StarRating.js +16 -0
- package/dist/featureAvailability.d.ts +21 -0
- package/dist/featureAvailability.js +104 -0
- package/dist/feedback.css +1093 -0
- package/dist/feedbackApi.d.ts +70 -0
- package/dist/feedbackApi.js +164 -0
- package/dist/feedbackTheme.d.ts +4 -0
- package/dist/feedbackTheme.js +22 -0
- package/dist/feedbackUpload.d.ts +50 -0
- package/dist/feedbackUpload.js +190 -0
- package/dist/httpFeedbackApi.d.ts +15 -0
- package/dist/httpFeedbackApi.js +390 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +31 -0
- package/dist/tracking.d.ts +3 -0
- package/dist/tracking.js +22 -0
- package/dist/types.d.ts +276 -0
- package/dist/types.js +152 -0
- package/dist/useFeedbackFlow.d.ts +32 -0
- package/dist/useFeedbackFlow.js +137 -0
- package/dist/useFeedbackPersistence.d.ts +12 -0
- package/dist/useFeedbackPersistence.js +130 -0
- package/package.json +34 -3
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FeedbackModal = FeedbackModal;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
const react_1 = require("react");
|
|
6
|
+
const react_dom_1 = require("react-dom");
|
|
7
|
+
const lucide_react_1 = require("lucide-react");
|
|
8
|
+
const types_1 = require("./types");
|
|
9
|
+
const feedbackTheme_1 = require("./feedbackTheme");
|
|
10
|
+
const FeedbackScreenshotThumbnail_1 = require("./FeedbackScreenshotThumbnail");
|
|
11
|
+
const severities = ['critical', 'high', 'medium', 'low'];
|
|
12
|
+
const wordCount = (value) => value.trim().split(/\s+/).filter(Boolean).length;
|
|
13
|
+
function FeedbackModal(props) {
|
|
14
|
+
if (!props.flow)
|
|
15
|
+
return null;
|
|
16
|
+
return (0, jsx_runtime_1.jsx)(FeedbackModalContent, { ...props });
|
|
17
|
+
}
|
|
18
|
+
function FeedbackModalContent({ flow, copy: copyOverrides, theme, onSubmit, onProgress, onDismiss, saving = false, resolveScreenshotUrl, anchorRef, overlayHostRef, anchorKey, slots, className, style, styles, classNames, backdropClassName, backdropStyle, dialogClassName, dialogStyle, }) {
|
|
19
|
+
const copy = (0, types_1.createFeedbackCopy)(copyOverrides);
|
|
20
|
+
const mergedTheme = (0, types_1.createFeedbackTheme)(theme);
|
|
21
|
+
const [customTime, setCustomTime] = (0, react_1.useState)(false);
|
|
22
|
+
const [introDismissed, setIntroDismissed] = (0, react_1.useState)(false);
|
|
23
|
+
const [showUseful, setShowUseful] = (0, react_1.useState)(false);
|
|
24
|
+
const [showLowRatingTime, setShowLowRatingTime] = (0, react_1.useState)(false);
|
|
25
|
+
const [revealedGapCount, setRevealedGapCount] = (0, react_1.useState)(0);
|
|
26
|
+
const [activeGapIndex, setActiveGapIndex] = (0, react_1.useState)(0);
|
|
27
|
+
const [reviewPageIndex, setReviewPageIndex] = (0, react_1.useState)(0);
|
|
28
|
+
const [submitError, setSubmitError] = (0, react_1.useState)(false);
|
|
29
|
+
const [isSubmitting, setIsSubmitting] = (0, react_1.useState)(false);
|
|
30
|
+
const [screenshotPreview, setScreenshotPreview] = (0, react_1.useState)(null);
|
|
31
|
+
const [capturingScreenshot, setCapturingScreenshot] = (0, react_1.useState)(false);
|
|
32
|
+
const [captureError, setCaptureError] = (0, react_1.useState)('');
|
|
33
|
+
const submittingRef = (0, react_1.useRef)(false);
|
|
34
|
+
const [position, setPosition] = (0, react_1.useState)({ left: 12, top: 12, width: 480, maxHeight: 600 });
|
|
35
|
+
const dialogRef = (0, react_1.useRef)(null);
|
|
36
|
+
const screenshotPreviewRef = (0, react_1.useRef)(null);
|
|
37
|
+
const savingOverlayRef = (0, react_1.useRef)(null);
|
|
38
|
+
const dismissRef = (0, react_1.useRef)(() => undefined);
|
|
39
|
+
const wasOpenRef = (0, react_1.useRef)(false);
|
|
40
|
+
const isOpen = flow.step !== 'closed';
|
|
41
|
+
const dismiss = () => {
|
|
42
|
+
if (flow.step !== 'success' && flow.step !== 'closed' && flow.submission) {
|
|
43
|
+
void onDismiss?.(flow.submission, flow.step, flow.completed)
|
|
44
|
+
?.catch((error) => console.error('Feedback draft could not be saved on dismiss', error));
|
|
45
|
+
}
|
|
46
|
+
flow.close();
|
|
47
|
+
};
|
|
48
|
+
dismissRef.current = dismiss;
|
|
49
|
+
const isGapFirstFlow = flow.rating !== null && (flow.completed ? flow.ratingBase < 4 : flow.rating < 4);
|
|
50
|
+
const isReview = flow.completed && isOpen && flow.step !== 'success';
|
|
51
|
+
const isLowRatingGap = flow.step === 'gap' && flow.ratingBase <= 3 && !isReview;
|
|
52
|
+
const showLowRatingIntro = isLowRatingGap && !introDismissed;
|
|
53
|
+
const showPositiveRatingIntro = flow.step === 'time' && flow.ratingBase >= 4 && !isReview && !showUseful && !introDismissed;
|
|
54
|
+
const reviewPages = flow.ratingBase >= 4
|
|
55
|
+
? [...(flow.ratingBase === 4 ? ['rating'] : []), 'time', 'gap']
|
|
56
|
+
: [...(flow.ratingBase === 3 ? ['rating'] : []), 'gap', 'useful'];
|
|
57
|
+
const reviewPage = reviewPages[reviewPageIndex];
|
|
58
|
+
const canContinueReview = reviewPage === 'gap'
|
|
59
|
+
? flow.gaps.every((gap) => Boolean(gap.description.trim()) && Boolean(gap.severity))
|
|
60
|
+
: reviewPage === 'useful' || reviewPage === 'time'
|
|
61
|
+
? Boolean(flow.mostUseful.trim()) &&
|
|
62
|
+
(reviewPage !== 'useful' || flow.timeSavedMinutes !== undefined)
|
|
63
|
+
: true;
|
|
64
|
+
(0, react_1.useEffect)(() => {
|
|
65
|
+
setCustomTime(false);
|
|
66
|
+
setIntroDismissed(false);
|
|
67
|
+
setShowUseful(false);
|
|
68
|
+
setShowLowRatingTime(false);
|
|
69
|
+
setRevealedGapCount(0);
|
|
70
|
+
setActiveGapIndex(0);
|
|
71
|
+
setReviewPageIndex(0);
|
|
72
|
+
setSubmitError(false);
|
|
73
|
+
}, [flow.requestId, flow.featureId, flow.rating]);
|
|
74
|
+
(0, react_1.useEffect)(() => {
|
|
75
|
+
if (isOpen && !wasOpenRef.current) {
|
|
76
|
+
setShowUseful((flow.rating ?? 0) >= 4 && flow.timeSavedMinutes !== undefined);
|
|
77
|
+
setShowLowRatingTime((flow.rating ?? 0) < 4 && flow.timeSavedMinutes !== undefined);
|
|
78
|
+
const firstShortGap = flow.gaps.findIndex((gap) => !gap.description.trim() || (wordCount(gap.description) < copy.usefulMinimumWords && !gap.detailRequested));
|
|
79
|
+
setRevealedGapCount(firstShortGap < 0 ? flow.gaps.length : firstShortGap);
|
|
80
|
+
setReviewPageIndex(0);
|
|
81
|
+
}
|
|
82
|
+
wasOpenRef.current = isOpen;
|
|
83
|
+
}, [isOpen]);
|
|
84
|
+
(0, react_1.useEffect)(() => {
|
|
85
|
+
if (!isReview || !reviewPage)
|
|
86
|
+
return;
|
|
87
|
+
const step = reviewPage === 'time' && isGapFirstFlow ? 'useful' : reviewPage;
|
|
88
|
+
if (flow.step !== step)
|
|
89
|
+
flow.setStep(step);
|
|
90
|
+
setShowUseful(reviewPage === 'time' && !isGapFirstFlow);
|
|
91
|
+
setShowLowRatingTime(reviewPage === 'useful' && isGapFirstFlow);
|
|
92
|
+
if (reviewPage === 'gap')
|
|
93
|
+
setRevealedGapCount(flow.gaps.length);
|
|
94
|
+
if (reviewPage === 'time')
|
|
95
|
+
setCustomTime(flow.timeSavedMinutes !== undefined && !copy.timeOptions.some((option) => option.value === flow.timeSavedMinutes));
|
|
96
|
+
}, [isReview, reviewPageIndex, reviewPages.join('|')]);
|
|
97
|
+
(0, react_1.useEffect)(() => {
|
|
98
|
+
if (isReview)
|
|
99
|
+
setReviewPageIndex(0);
|
|
100
|
+
}, [flow.ratingBase]);
|
|
101
|
+
(0, react_1.useLayoutEffect)(() => {
|
|
102
|
+
if (!isOpen)
|
|
103
|
+
return;
|
|
104
|
+
const updatePosition = () => {
|
|
105
|
+
const rect = anchorRef?.current?.getBoundingClientRect();
|
|
106
|
+
if (!rect)
|
|
107
|
+
return;
|
|
108
|
+
const panel = overlayHostRef?.current?.getBoundingClientRect() ?? anchorRef?.current
|
|
109
|
+
?.closest('[data-feedback-chat-panel]')?.getBoundingClientRect();
|
|
110
|
+
const boundsTop = Math.max(0, panel?.top ?? 0);
|
|
111
|
+
const boundsBottom = Math.min(window.innerHeight, panel?.bottom ?? window.innerHeight);
|
|
112
|
+
const preferredWidth = flow.step === 'gap'
|
|
113
|
+
? isLowRatingGap && flow.gaps.length === 1 && revealedGapCount === 0 ? 520 : 600
|
|
114
|
+
: flow.step === 'success' ? 620 : flow.step === 'time' ? 440 : 540;
|
|
115
|
+
const width = Math.min(preferredWidth, window.innerWidth - 24);
|
|
116
|
+
const above = Math.max(0, rect.top - boundsTop - 24);
|
|
117
|
+
const below = Math.max(0, boundsBottom - rect.bottom - 24);
|
|
118
|
+
const placeAbove = above >= below;
|
|
119
|
+
const placementMaxHeight = Math.max(80, placeAbove ? above : below);
|
|
120
|
+
const dialogHeight = Math.min(dialogRef.current?.scrollHeight ?? 340, placementMaxHeight);
|
|
121
|
+
const top = placeAbove
|
|
122
|
+
? Math.max(boundsTop + 12, rect.top - dialogHeight - 12)
|
|
123
|
+
: Math.min(boundsBottom - dialogHeight - 12, rect.bottom + 12);
|
|
124
|
+
// Keep the dialog visually attached to the rating card that opened it.
|
|
125
|
+
// Clamp to the viewport, not the chat panel, so narrow panels can still show it fully.
|
|
126
|
+
const left = Math.max(12, Math.min(window.innerWidth - width - 12, rect.right - width));
|
|
127
|
+
setPosition((previous) => previous.left === left && previous.top === top && previous.width === width && previous.maxHeight === placementMaxHeight
|
|
128
|
+
? previous
|
|
129
|
+
: { left, top, width, maxHeight: placementMaxHeight });
|
|
130
|
+
};
|
|
131
|
+
updatePosition();
|
|
132
|
+
window.addEventListener('resize', updatePosition);
|
|
133
|
+
window.addEventListener('scroll', updatePosition, true);
|
|
134
|
+
const panel = overlayHostRef?.current ?? anchorRef?.current?.closest('[data-feedback-chat-panel]');
|
|
135
|
+
const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(updatePosition) : null;
|
|
136
|
+
if (panel)
|
|
137
|
+
resizeObserver?.observe(panel);
|
|
138
|
+
if (dialogRef.current)
|
|
139
|
+
resizeObserver?.observe(dialogRef.current);
|
|
140
|
+
if (anchorRef?.current)
|
|
141
|
+
resizeObserver?.observe(anchorRef?.current);
|
|
142
|
+
return () => {
|
|
143
|
+
window.removeEventListener('resize', updatePosition);
|
|
144
|
+
window.removeEventListener('scroll', updatePosition, true);
|
|
145
|
+
resizeObserver?.disconnect();
|
|
146
|
+
};
|
|
147
|
+
}, [anchorRef, overlayHostRef, anchorKey, isOpen, flow.step, flow.gaps.length, revealedGapCount, showLowRatingTime, showUseful, isLowRatingGap, showPositiveRatingIntro]);
|
|
148
|
+
(0, react_1.useEffect)(() => {
|
|
149
|
+
if (!isOpen)
|
|
150
|
+
return;
|
|
151
|
+
const onKeyDown = (event) => {
|
|
152
|
+
if (event.key !== 'Escape' || isSubmitting || saving || capturingScreenshot)
|
|
153
|
+
return;
|
|
154
|
+
if (screenshotPreview) {
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
setScreenshotPreview(null);
|
|
157
|
+
}
|
|
158
|
+
else
|
|
159
|
+
dismissRef.current();
|
|
160
|
+
};
|
|
161
|
+
window.addEventListener('keydown', onKeyDown);
|
|
162
|
+
dialogRef.current?.focus();
|
|
163
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
164
|
+
}, [isOpen, flow.setStep, isSubmitting, saving, screenshotPreview, capturingScreenshot]);
|
|
165
|
+
(0, react_1.useEffect)(() => {
|
|
166
|
+
if (screenshotPreview)
|
|
167
|
+
screenshotPreviewRef.current?.focus();
|
|
168
|
+
}, [screenshotPreview]);
|
|
169
|
+
(0, react_1.useEffect)(() => {
|
|
170
|
+
if (isSubmitting || saving)
|
|
171
|
+
savingOverlayRef.current?.focus();
|
|
172
|
+
}, [isSubmitting, saving]);
|
|
173
|
+
if (!isOpen)
|
|
174
|
+
return null;
|
|
175
|
+
const busy = isSubmitting || saving;
|
|
176
|
+
const ratingBase = flow.ratingBase;
|
|
177
|
+
const decimalRatings = Array.from({ length: 6 }, (_, index) => Number((ratingBase + index * 0.2).toFixed(1)));
|
|
178
|
+
const updateGap = (index, patch) => {
|
|
179
|
+
flow.setGaps(flow.gaps.map((gap, gapIndex) => (gapIndex === index ? { ...gap, ...patch } : gap)));
|
|
180
|
+
};
|
|
181
|
+
const removeGap = (index) => {
|
|
182
|
+
if (flow.gaps.length < 2)
|
|
183
|
+
return;
|
|
184
|
+
flow.setGaps(flow.gaps.filter((_, gapIndex) => gapIndex !== index));
|
|
185
|
+
setRevealedGapCount((count) => Math.min(flow.gaps.length - 1, count - (index < count ? 1 : 0)));
|
|
186
|
+
setActiveGapIndex((current) => current === index
|
|
187
|
+
? Math.max(0, Math.min(index, flow.gaps.length - 2))
|
|
188
|
+
: current > index ? current - 1 : current);
|
|
189
|
+
};
|
|
190
|
+
const canAdvanceGap = revealedGapCount >= flow.gaps.length && flow.gaps.every((gap) => Boolean(gap.severity) && Boolean(gap.description.trim()) &&
|
|
191
|
+
(wordCount(gap.description) >= copy.usefulMinimumWords || gap.detailRequested));
|
|
192
|
+
const persistAndAdvance = (nextStep, advance, submission = flow.submission) => {
|
|
193
|
+
if (!submission || submittingRef.current)
|
|
194
|
+
return;
|
|
195
|
+
setSubmitError(false);
|
|
196
|
+
advance();
|
|
197
|
+
// Progress saves are serialized by useFeedbackPersistence. Navigation must not
|
|
198
|
+
// wait for the network; the final submit waits for every queued save.
|
|
199
|
+
void Promise.resolve()
|
|
200
|
+
.then(() => onProgress?.(submission, nextStep))
|
|
201
|
+
.catch((error) => {
|
|
202
|
+
console.error('Feedback progress could not be saved', error);
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
const submitFeedback = async () => {
|
|
206
|
+
if (!flow.submission || submittingRef.current)
|
|
207
|
+
return;
|
|
208
|
+
if (!flow.mostUseful.trim() ||
|
|
209
|
+
(wordCount(flow.mostUseful) < copy.usefulMinimumWords && !flow.usefulDetailShown) ||
|
|
210
|
+
flow.gaps.some((gap) => !gap.description.trim() ||
|
|
211
|
+
(wordCount(gap.description) < copy.usefulMinimumWords && !gap.detailRequested)) ||
|
|
212
|
+
flow.timeSavedMinutes === undefined)
|
|
213
|
+
return;
|
|
214
|
+
submittingRef.current = true;
|
|
215
|
+
setIsSubmitting(true);
|
|
216
|
+
try {
|
|
217
|
+
setSubmitError(false);
|
|
218
|
+
await onSubmit?.(flow.submission);
|
|
219
|
+
flow.setCompleted(true);
|
|
220
|
+
flow.setStep('success');
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
console.error('Feedback submission failed', error);
|
|
224
|
+
setSubmitError(true);
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
submittingRef.current = false;
|
|
228
|
+
setIsSubmitting(false);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
const nextReviewPage = () => {
|
|
232
|
+
if (!canContinueReview)
|
|
233
|
+
return;
|
|
234
|
+
if (reviewPage === 'gap') {
|
|
235
|
+
const gaps = flow.gaps.map((gap) => wordCount(gap.description) < copy.usefulMinimumWords
|
|
236
|
+
? { ...gap, detailRequested: true } : gap);
|
|
237
|
+
if (gaps.some((gap, index) => gap.detailRequested && !flow.gaps[index].detailRequested)) {
|
|
238
|
+
flow.setGaps(gaps);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
else if ((reviewPage === 'useful' || reviewPage === 'time') &&
|
|
243
|
+
wordCount(flow.mostUseful) < copy.usefulMinimumWords && !flow.usefulDetailShown) {
|
|
244
|
+
flow.setUsefulDetailShown(true);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (reviewPageIndex < reviewPages.length - 1) {
|
|
248
|
+
const nextPage = reviewPages[reviewPageIndex + 1];
|
|
249
|
+
void persistAndAdvance(nextPage, () => setReviewPageIndex(reviewPageIndex + 1));
|
|
250
|
+
}
|
|
251
|
+
else
|
|
252
|
+
void submitFeedback();
|
|
253
|
+
};
|
|
254
|
+
const previousReviewPage = () => {
|
|
255
|
+
if (reviewPageIndex > 0)
|
|
256
|
+
setReviewPageIndex(reviewPageIndex - 1);
|
|
257
|
+
};
|
|
258
|
+
const continueUseful = () => {
|
|
259
|
+
if (!flow.mostUseful.trim())
|
|
260
|
+
return;
|
|
261
|
+
if (wordCount(flow.mostUseful) < copy.usefulMinimumWords && !flow.usefulDetailShown) {
|
|
262
|
+
flow.setUsefulDetailShown(true);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (isReview) {
|
|
266
|
+
nextReviewPage();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (flow.timeSavedMinutes !== undefined)
|
|
270
|
+
void submitFeedback();
|
|
271
|
+
};
|
|
272
|
+
const continueTime = () => {
|
|
273
|
+
if (showUseful && !flow.mostUseful.trim())
|
|
274
|
+
return;
|
|
275
|
+
if (showUseful && wordCount(flow.mostUseful) < copy.usefulMinimumWords && !flow.usefulDetailShown) {
|
|
276
|
+
flow.setUsefulDetailShown(true);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (isReview) {
|
|
280
|
+
nextReviewPage();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (flow.timeSavedMinutes === undefined)
|
|
284
|
+
return;
|
|
285
|
+
if (showUseful) {
|
|
286
|
+
void persistAndAdvance('gap', () => flow.setStep('gap'));
|
|
287
|
+
}
|
|
288
|
+
else
|
|
289
|
+
void persistAndAdvance('time', () => setShowUseful(true));
|
|
290
|
+
};
|
|
291
|
+
const selectDecimalRating = (rating) => {
|
|
292
|
+
flow.setRating(rating);
|
|
293
|
+
const nextStep = rating < 4 ? 'gap' : 'time';
|
|
294
|
+
void persistAndAdvance(nextStep, () => flow.setStep(nextStep), {
|
|
295
|
+
requestId: flow.requestId,
|
|
296
|
+
featureId: flow.featureId,
|
|
297
|
+
rating,
|
|
298
|
+
gaps: [],
|
|
299
|
+
metadata: flow.submission?.metadata,
|
|
300
|
+
});
|
|
301
|
+
};
|
|
302
|
+
const selectTimeSaved = (minutes) => {
|
|
303
|
+
flow.setTimeSavedMinutes(minutes);
|
|
304
|
+
if (!flow.submission)
|
|
305
|
+
return;
|
|
306
|
+
void persistAndAdvance(isGapFirstFlow ? 'useful' : 'time', () => {
|
|
307
|
+
if (isGapFirstFlow)
|
|
308
|
+
setShowLowRatingTime(true);
|
|
309
|
+
else
|
|
310
|
+
setShowUseful(true);
|
|
311
|
+
}, { ...flow.submission, timeSavedMinutes: minutes });
|
|
312
|
+
};
|
|
313
|
+
const returnToRating = () => {
|
|
314
|
+
flow.setTimeSavedMinutes(undefined);
|
|
315
|
+
flow.setMostUseful('');
|
|
316
|
+
flow.setUsefulMoreDetail('');
|
|
317
|
+
flow.setUsefulDetailShown(false);
|
|
318
|
+
flow.setGaps([{ description: '', screenshots: [] }]);
|
|
319
|
+
setShowUseful(false);
|
|
320
|
+
setShowLowRatingTime(false);
|
|
321
|
+
setCustomTime(false);
|
|
322
|
+
setRevealedGapCount(0);
|
|
323
|
+
setActiveGapIndex(0);
|
|
324
|
+
flow.setStep('rating');
|
|
325
|
+
};
|
|
326
|
+
const back = () => {
|
|
327
|
+
if (flow.step === 'time') {
|
|
328
|
+
returnToRating();
|
|
329
|
+
}
|
|
330
|
+
else if (flow.step === 'gap') {
|
|
331
|
+
if (flow.rating !== null && flow.rating < 4)
|
|
332
|
+
returnToRating();
|
|
333
|
+
else {
|
|
334
|
+
setShowUseful(true);
|
|
335
|
+
flow.setStep('time');
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
else if (flow.step === 'useful' && isGapFirstFlow) {
|
|
339
|
+
if (showLowRatingTime)
|
|
340
|
+
setShowLowRatingTime(false);
|
|
341
|
+
else
|
|
342
|
+
flow.setStep('gap');
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
const captureTabScreenshot = async (gapIndex) => {
|
|
346
|
+
if (!navigator.mediaDevices?.getDisplayMedia) {
|
|
347
|
+
setCaptureError('Tab capture is not supported in this browser. Please attach a screenshot instead.');
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
let stream;
|
|
351
|
+
let video;
|
|
352
|
+
try {
|
|
353
|
+
setCaptureError('');
|
|
354
|
+
// Call from the click itself: browsers require a user gesture for display capture.
|
|
355
|
+
const capturePromise = navigator.mediaDevices.getDisplayMedia({
|
|
356
|
+
video: { displaySurface: 'browser' },
|
|
357
|
+
audio: false,
|
|
358
|
+
preferCurrentTab: true,
|
|
359
|
+
});
|
|
360
|
+
(0, react_dom_1.flushSync)(() => setCapturingScreenshot(true));
|
|
361
|
+
document.body.classList.add('cw-feedback-capturing');
|
|
362
|
+
stream = await capturePromise;
|
|
363
|
+
const track = stream.getVideoTracks()[0];
|
|
364
|
+
const displaySurface = track?.getSettings().displaySurface;
|
|
365
|
+
if (!track || (displaySurface && displaySurface !== 'browser')) {
|
|
366
|
+
throw new Error('Please select a browser tab to capture.');
|
|
367
|
+
}
|
|
368
|
+
video = document.createElement('video');
|
|
369
|
+
video.srcObject = stream;
|
|
370
|
+
video.muted = true;
|
|
371
|
+
await video.play();
|
|
372
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
|
|
373
|
+
const canvas = document.createElement('canvas');
|
|
374
|
+
canvas.width = video.videoWidth || track.getSettings().width || 0;
|
|
375
|
+
canvas.height = video.videoHeight || track.getSettings().height || 0;
|
|
376
|
+
if (!canvas.width || !canvas.height)
|
|
377
|
+
throw new Error('The selected tab did not provide a video frame.');
|
|
378
|
+
const context = canvas.getContext('2d');
|
|
379
|
+
if (!context)
|
|
380
|
+
throw new Error('Could not capture the selected tab.');
|
|
381
|
+
context.drawImage(video, 0, 0);
|
|
382
|
+
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
|
383
|
+
if (!blob)
|
|
384
|
+
throw new Error('Could not create the screenshot.');
|
|
385
|
+
const file = new File([blob], `feedback-screenshot-${Date.now()}.png`, { type: 'image/png' });
|
|
386
|
+
flow.setGaps((gaps) => gaps.map((gap, index) => index === gapIndex ? { ...gap, screenshots: [...gap.screenshots, file] } : gap));
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
if (error instanceof Error && error.name !== 'NotAllowedError' && error.name !== 'AbortError') {
|
|
390
|
+
setCaptureError(error.message);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
395
|
+
if (video)
|
|
396
|
+
video.srcObject = null;
|
|
397
|
+
document.body.classList.remove('cw-feedback-capturing');
|
|
398
|
+
setCapturingScreenshot(false);
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
const backdrop = ((0, jsx_runtime_1.jsx)("div", { onMouseDown: (event) => !busy && event.target === event.currentTarget && dismiss(), className: ['cw-feedback-backdrop', backdropClassName, classNames?.backdrop].filter(Boolean).join(' '), style: {
|
|
402
|
+
background: mergedTheme.overlay ?? '#11182755',
|
|
403
|
+
visibility: capturingScreenshot ? 'hidden' : undefined,
|
|
404
|
+
...backdropStyle,
|
|
405
|
+
...styles?.backdrop,
|
|
406
|
+
} }));
|
|
407
|
+
const dialog = ((0, jsx_runtime_1.jsxs)("div", { ref: dialogRef, style: {
|
|
408
|
+
...position,
|
|
409
|
+
...(0, feedbackTheme_1.feedbackThemeStyle)(theme),
|
|
410
|
+
visibility: capturingScreenshot ? 'hidden' : undefined,
|
|
411
|
+
...style,
|
|
412
|
+
...dialogStyle,
|
|
413
|
+
...styles?.dialog,
|
|
414
|
+
}, className: [
|
|
415
|
+
'cw-feedback-dialog',
|
|
416
|
+
flow.step === 'gap' ? 'cw-feedback-dialog--wide' : '',
|
|
417
|
+
isLowRatingGap && flow.gaps.length === 1 && revealedGapCount === 0 ? 'cw-feedback-dialog--low-intro' : '',
|
|
418
|
+
flow.step === 'gap' && canAdvanceGap ? 'cw-feedback-dialog--has-action' : '',
|
|
419
|
+
flow.step === 'success' ? 'cw-feedback-dialog--success' : '',
|
|
420
|
+
className,
|
|
421
|
+
dialogClassName,
|
|
422
|
+
classNames?.dialog,
|
|
423
|
+
]
|
|
424
|
+
.filter(Boolean)
|
|
425
|
+
.join(' '), role: "dialog", "aria-modal": "true", "aria-busy": busy, tabIndex: -1, onKeyDown: (event) => {
|
|
426
|
+
if (busy) {
|
|
427
|
+
event.preventDefault();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (event.target instanceof HTMLButtonElement)
|
|
431
|
+
return;
|
|
432
|
+
if (isReview && event.key === 'Enter' && !(event.target instanceof HTMLTextAreaElement) && !(event.target instanceof HTMLInputElement && event.target.type === 'number')) {
|
|
433
|
+
event.preventDefault();
|
|
434
|
+
nextReviewPage();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (event.key === 'Enter' && flow.step === 'rating' && flow.rating !== null) {
|
|
438
|
+
event.preventDefault();
|
|
439
|
+
selectDecimalRating(flow.rating);
|
|
440
|
+
}
|
|
441
|
+
if (event.key === 'Enter' &&
|
|
442
|
+
flow.step === 'time' &&
|
|
443
|
+
flow.timeSavedMinutes !== undefined &&
|
|
444
|
+
!showUseful &&
|
|
445
|
+
event.target instanceof HTMLElement &&
|
|
446
|
+
event.target.tagName !== 'TEXTAREA' &&
|
|
447
|
+
!(event.target instanceof HTMLInputElement && event.target.type === 'number')) {
|
|
448
|
+
event.preventDefault();
|
|
449
|
+
continueTime();
|
|
450
|
+
}
|
|
451
|
+
if (event.key === 'Enter' &&
|
|
452
|
+
flow.step === 'useful' &&
|
|
453
|
+
showLowRatingTime &&
|
|
454
|
+
flow.timeSavedMinutes !== undefined &&
|
|
455
|
+
event.target instanceof HTMLElement &&
|
|
456
|
+
event.target.tagName !== 'TEXTAREA') {
|
|
457
|
+
event.preventDefault();
|
|
458
|
+
continueUseful();
|
|
459
|
+
}
|
|
460
|
+
}, children: [(0, jsx_runtime_1.jsxs)("div", { className: ['cw-feedback-dialog__content', classNames?.content].filter(Boolean).join(' '), style: styles?.content, children: [!isReview &&
|
|
461
|
+
((flow.step === 'time' && flow.ratingBase === 4) ||
|
|
462
|
+
(flow.step === 'gap' && flow.rating !== null && flow.rating > 2) ||
|
|
463
|
+
(flow.step === 'useful' && isGapFirstFlow) ||
|
|
464
|
+
flow.step === 'success') && ((0, jsx_runtime_1.jsxs)("div", { className: ['cw-feedback-dialog__topbar', classNames?.topbar].filter(Boolean).join(' '), style: styles?.topbar, children: [flow.step === 'time' || flow.step === 'gap' || flow.step === 'useful' ? ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: back, "aria-label": copy.backLabel, className: classNames?.backButton, style: styles?.backButton, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ArrowLeft, { size: 19 }) })) : ((0, jsx_runtime_1.jsx)("span", {})), flow.step === 'success' && ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: flow.close, "aria-label": copy.closeLabel, className: classNames?.closeButton, style: styles?.closeButton, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 19 }) }))] })), flow.step === 'rating' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h2", { className: classNames?.title, style: styles?.title, children: copy.ratingTitle }), (0, jsx_runtime_1.jsxs)("p", { className: ['cw-feedback-subtitle', classNames?.subtitle].filter(Boolean).join(' '), style: styles?.subtitle, children: ["\u2728 ", copy.ratingSubtitle] }), (0, jsx_runtime_1.jsxs)("div", { className: ['cw-feedback-range', classNames?.rangeContainer].filter(Boolean).join(' '), style: styles?.rangeContainer, children: [flow.rating !== null && ((0, jsx_runtime_1.jsx)("div", { className: ['cw-feedback-range-tooltip', classNames?.rangeTooltip].filter(Boolean).join(' '), style: {
|
|
465
|
+
left: `clamp(150px, ${(flow.rating - ratingBase) * 100}%, calc(100% - 150px))`,
|
|
466
|
+
...styles?.rangeTooltip,
|
|
467
|
+
}, children: copy.ratingValueLabel(flow.rating) })), (0, jsx_runtime_1.jsx)("input", { "aria-label": copy.ratingControlLabel, type: "range", min: ratingBase, max: ratingBase + 1, step: "0.1", value: flow.rating ?? ratingBase, className: classNames?.rangeInput, style: styles?.rangeInput, onChange: (e) => (isReview ? flow.setReviewRating : flow.setRating)(Number(e.target.value)), onPointerUp: (event) => selectDecimalRating(Number(event.currentTarget.value)), onKeyUp: (event) => {
|
|
468
|
+
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
|
|
469
|
+
selectDecimalRating(Number(event.currentTarget.value));
|
|
470
|
+
}
|
|
471
|
+
}, onKeyDown: (event) => {
|
|
472
|
+
if (event.key === 'Enter') {
|
|
473
|
+
event.preventDefault();
|
|
474
|
+
event.stopPropagation();
|
|
475
|
+
selectDecimalRating(Number(event.currentTarget.value));
|
|
476
|
+
}
|
|
477
|
+
} })] }), (0, jsx_runtime_1.jsx)("div", { className: ['cw-feedback-rating-options', classNames?.ratingOptionsContainer].filter(Boolean).join(' '), style: styles?.ratingOptionsContainer, children: decimalRatings.map((value) => {
|
|
478
|
+
const isSelected = flow.rating === value;
|
|
479
|
+
return ((0, jsx_runtime_1.jsx)("button", { type: "button", className: [
|
|
480
|
+
isSelected ? 'is-selected' : '',
|
|
481
|
+
classNames?.ratingOptionButton,
|
|
482
|
+
isSelected ? classNames?.ratingOptionButtonSelected : '',
|
|
483
|
+
]
|
|
484
|
+
.filter(Boolean)
|
|
485
|
+
.join(' '), style: {
|
|
486
|
+
...styles?.ratingOptionButton,
|
|
487
|
+
...(isSelected ? styles?.ratingOptionButtonSelected : {}),
|
|
488
|
+
}, onClick: () => selectDecimalRating(value), children: value.toFixed(1) }, value));
|
|
489
|
+
}) }), (0, jsx_runtime_1.jsx)("p", { className: ['cw-feedback-hint', classNames?.hint].filter(Boolean).join(' '), style: styles?.hint, children: copy.ratingHint }), slots?.rating] })), flow.step === 'time' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [showPositiveRatingIntro && ((0, jsx_runtime_1.jsxs)("div", { className: "cw-feedback-low-rating-intro", children: [(0, jsx_runtime_1.jsx)("div", { className: "cw-feedback-low-rating-intro__emoji", "aria-hidden": "true", children: "\uD83D\uDE0A\uD83E\uDD73" }), (0, jsx_runtime_1.jsx)("strong", { children: copy.positiveRatingIntroTitle }), (0, jsx_runtime_1.jsx)("p", { children: copy.positiveRatingIntroDescription })] })), (0, jsx_runtime_1.jsx)("h2", { className: classNames?.title, style: styles?.title, children: copy.timeSavedTitle }), (0, jsx_runtime_1.jsxs)("div", { className: ['cw-feedback-pills', 'cw-feedback-time-options', classNames?.timePillsContainer].filter(Boolean).join(' '), style: styles?.timePillsContainer, children: [copy.timeOptions.map(({ value, label }) => {
|
|
490
|
+
const isSelected = flow.timeSavedMinutes === value && !customTime;
|
|
491
|
+
return ((0, jsx_runtime_1.jsx)("button", { className: [
|
|
492
|
+
isSelected ? 'is-selected' : '',
|
|
493
|
+
classNames?.timePillButton,
|
|
494
|
+
isSelected ? classNames?.timePillButtonSelected : '',
|
|
495
|
+
]
|
|
496
|
+
.filter(Boolean)
|
|
497
|
+
.join(' '), style: {
|
|
498
|
+
...styles?.timePillButton,
|
|
499
|
+
...(isSelected ? styles?.timePillButtonSelected : {}),
|
|
500
|
+
}, type: "button", onClick: () => {
|
|
501
|
+
setIntroDismissed(true);
|
|
502
|
+
setCustomTime(false);
|
|
503
|
+
selectTimeSaved(value);
|
|
504
|
+
}, children: label }, value));
|
|
505
|
+
}), (0, jsx_runtime_1.jsx)("button", { className: [
|
|
506
|
+
customTime ? 'is-selected' : '',
|
|
507
|
+
classNames?.timePillButton,
|
|
508
|
+
customTime ? classNames?.timePillButtonSelected : '',
|
|
509
|
+
]
|
|
510
|
+
.filter(Boolean)
|
|
511
|
+
.join(' '), style: {
|
|
512
|
+
...styles?.timePillButton,
|
|
513
|
+
...(customTime ? styles?.timePillButtonSelected : {}),
|
|
514
|
+
}, type: "button", onClick: () => { setIntroDismissed(true); setCustomTime(true); }, children: copy.otherLabel }), customTime && ((0, jsx_runtime_1.jsxs)("label", { className: ['cw-feedback-number', 'cw-feedback-time-custom', classNames?.customTimeWrapper].filter(Boolean).join(' '), style: styles?.customTimeWrapper, children: [(0, jsx_runtime_1.jsx)("input", { autoFocus: true, type: "number", min: "0", value: flow.timeSavedMinutes ?? '', className: classNames?.customTimeInput, style: styles?.customTimeInput, onChange: (e) => flow.setTimeSavedMinutes(Number(e.target.value)), onKeyDown: (event) => {
|
|
515
|
+
if (event.key !== 'Enter' || flow.timeSavedMinutes === undefined)
|
|
516
|
+
return;
|
|
517
|
+
event.preventDefault();
|
|
518
|
+
event.stopPropagation();
|
|
519
|
+
selectTimeSaved(flow.timeSavedMinutes);
|
|
520
|
+
} }), ' ', copy.minuteUnit] }))] }), !showUseful && flow.timeSavedMinutes === undefined && ((0, jsx_runtime_1.jsx)("p", { className: "cw-feedback-time-hint", children: copy.enterHint })), showUseful && ((0, jsx_runtime_1.jsxs)("label", { className: ['cw-feedback-field', classNames?.usefulField].filter(Boolean).join(' '), style: styles?.usefulField, children: [(0, jsx_runtime_1.jsx)("span", { children: copy.usefulTitle }), flow.usefulDetailShown && wordCount(flow.mostUseful) < copy.usefulMinimumWords && ((0, jsx_runtime_1.jsxs)("span", { className: "cw-feedback-inline-detail-prompt", children: ["*", copy.usefulMoreDetailPrompt] })), (0, jsx_runtime_1.jsx)("textarea", { autoFocus: true, value: flow.mostUseful, className: classNames?.usefulTextarea, style: styles?.usefulTextarea, onChange: (e) => flow.setMostUseful(e.target.value), placeholder: copy.usefulPlaceholder, onKeyDown: (e) => {
|
|
521
|
+
if (e.key === 'Enter' && !e.shiftKey && flow.mostUseful.trim()) {
|
|
522
|
+
e.preventDefault();
|
|
523
|
+
if (isReview)
|
|
524
|
+
nextReviewPage();
|
|
525
|
+
else
|
|
526
|
+
continueTime();
|
|
527
|
+
}
|
|
528
|
+
} })] })), !isReview && flow.timeSavedMinutes !== undefined &&
|
|
529
|
+
(!showUseful || !flow.usefulDetailShown || Boolean(flow.mostUseful.trim())) && ((0, jsx_runtime_1.jsx)("div", { className: ['cw-feedback-footer', classNames?.footer].filter(Boolean).join(' '), style: styles?.footer, children: (0, jsx_runtime_1.jsx)("button", { className: ['cw-feedback-hint-action', classNames?.enterHintButton].filter(Boolean).join(' '), style: styles?.enterHintButton, type: "button", onClick: continueTime, children: copy.enterHint }) })), slots?.time] })), flow.step === 'useful' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h2", { className: ['cw-feedback-time-title', classNames?.title].filter(Boolean).join(' '), style: styles?.title, children: copy.timeSavedTitle }), (0, jsx_runtime_1.jsxs)("div", { className: ['cw-feedback-pills', classNames?.timePillsContainer].filter(Boolean).join(' '), style: styles?.timePillsContainer, children: [copy.timeOptions.map(({ value, label }) => {
|
|
530
|
+
const isSelected = flow.timeSavedMinutes === value && !customTime;
|
|
531
|
+
return ((0, jsx_runtime_1.jsx)("button", { className: [
|
|
532
|
+
isSelected ? 'is-selected' : '',
|
|
533
|
+
classNames?.timePillButton,
|
|
534
|
+
isSelected ? classNames?.timePillButtonSelected : '',
|
|
535
|
+
]
|
|
536
|
+
.filter(Boolean)
|
|
537
|
+
.join(' '), style: {
|
|
538
|
+
...styles?.timePillButton,
|
|
539
|
+
...(isSelected ? styles?.timePillButtonSelected : {}),
|
|
540
|
+
}, type: "button", onClick: () => {
|
|
541
|
+
setCustomTime(false);
|
|
542
|
+
selectTimeSaved(value);
|
|
543
|
+
}, children: label }, value));
|
|
544
|
+
}), (0, jsx_runtime_1.jsx)("button", { className: [
|
|
545
|
+
customTime ? 'is-selected' : '',
|
|
546
|
+
classNames?.timePillButton,
|
|
547
|
+
customTime ? classNames?.timePillButtonSelected : '',
|
|
548
|
+
]
|
|
549
|
+
.filter(Boolean)
|
|
550
|
+
.join(' '), style: {
|
|
551
|
+
...styles?.timePillButton,
|
|
552
|
+
...(customTime ? styles?.timePillButtonSelected : {}),
|
|
553
|
+
}, type: "button", onClick: () => setCustomTime(true), children: copy.otherLabel })] }), customTime && ((0, jsx_runtime_1.jsxs)("label", { className: ['cw-feedback-number', classNames?.customTimeWrapper].filter(Boolean).join(' '), style: styles?.customTimeWrapper, children: [(0, jsx_runtime_1.jsx)("input", { autoFocus: true, type: "number", min: "0", value: flow.timeSavedMinutes ?? '', className: classNames?.customTimeInput, style: styles?.customTimeInput, onChange: (event) => flow.setTimeSavedMinutes(Number(event.target.value)), onKeyDown: (event) => {
|
|
554
|
+
if (event.key !== 'Enter' || flow.timeSavedMinutes === undefined)
|
|
555
|
+
return;
|
|
556
|
+
event.preventDefault();
|
|
557
|
+
event.stopPropagation();
|
|
558
|
+
selectTimeSaved(flow.timeSavedMinutes);
|
|
559
|
+
} }), ' ', copy.minuteUnit] })), showLowRatingTime && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h2", { className: ['cw-feedback-useful-title', classNames?.title, classNames?.usefulTitle].filter(Boolean).join(' '), style: { ...styles?.title, ...styles?.usefulTitle }, children: copy.lowRatingUsefulTitle }), flow.usefulDetailShown && wordCount(flow.mostUseful) < copy.usefulMinimumWords && ((0, jsx_runtime_1.jsxs)("p", { className: ['cw-feedback-useful-prompt', classNames?.usefulPrompt].filter(Boolean).join(' '), style: styles?.usefulPrompt, children: ["*", copy.usefulMoreDetailPrompt] })), (0, jsx_runtime_1.jsx)("label", { className: ['cw-feedback-field', 'cw-feedback-useful-field', classNames?.usefulField].filter(Boolean).join(' '), style: styles?.usefulField, children: (0, jsx_runtime_1.jsx)("textarea", { autoFocus: true, value: flow.mostUseful, className: classNames?.usefulTextarea, style: styles?.usefulTextarea, onChange: (event) => flow.setMostUseful(event.target.value), placeholder: copy.usefulPlaceholder, onKeyDown: (event) => {
|
|
560
|
+
if (event.key !== 'Enter' || event.shiftKey)
|
|
561
|
+
return;
|
|
562
|
+
event.preventDefault();
|
|
563
|
+
continueUseful();
|
|
564
|
+
} }) })] }))] })), flow.step === 'gap' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [showLowRatingIntro && ((0, jsx_runtime_1.jsxs)("div", { className: "cw-feedback-low-rating-intro", children: [(0, jsx_runtime_1.jsx)("div", { className: "cw-feedback-low-rating-intro__emoji", "aria-hidden": "true", children: "\uD83D\uDE14\uD83E\uDD7A" }), (0, jsx_runtime_1.jsx)("strong", { children: copy.lowRatingIntroTitle }), (0, jsx_runtime_1.jsx)("p", { children: copy.lowRatingIntroDescription })] })), (0, jsx_runtime_1.jsx)("h2", { className: classNames?.title, style: styles?.title, children: flow.rating === 5 ? copy.fiveStarGapTitle : copy.gapTitle }), copy.gapSubtitle && (!isLowRatingGap || (flow.gaps.length === 1 && flow.gaps[0].detailRequested)) && ((0, jsx_runtime_1.jsx)("p", { className: [
|
|
565
|
+
flow.gaps.length === 1 && flow.gaps[0].detailRequested && wordCount(flow.gaps[0].description) < copy.usefulMinimumWords
|
|
566
|
+
? 'cw-feedback-inline-detail-prompt' : 'cw-feedback-subtitle',
|
|
567
|
+
classNames?.subtitle,
|
|
568
|
+
].filter(Boolean).join(' '), style: styles?.subtitle, children: flow.gaps.length === 1 && flow.gaps[0].detailRequested && wordCount(flow.gaps[0].description) < copy.usefulMinimumWords
|
|
569
|
+
? `*${copy.gapMoreDetailPrompt}`
|
|
570
|
+
: `✨ ${copy.gapSubtitle}` })), (0, jsx_runtime_1.jsx)("div", { className: ['cw-feedback-gap-list', classNames?.gapList].filter(Boolean).join(' '), style: styles?.gapList, children: flow.gaps.map((gap, index) => ((0, jsx_runtime_1.jsxs)("section", { className: ['cw-feedback-gap', flow.gaps.length >= 2 && index !== activeGapIndex ? 'cw-feedback-gap--collapsed' : '', classNames?.gapItem].filter(Boolean).join(' '), style: styles?.gapItem, children: [flow.gaps.length >= 2 && ((0, jsx_runtime_1.jsxs)("div", { className: "cw-feedback-gap__header", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "cw-feedback-gap__toggle", onClick: () => setActiveGapIndex(index), "aria-expanded": index === activeGapIndex, children: ["Gap ", index + 1, gap.severity && (0, jsx_runtime_1.jsx)("span", { className: `cw-feedback-gap__severity ${gap.severity}`, children: copy.severityLabels[gap.severity] }), index !== activeGapIndex && ((0, jsx_runtime_1.jsx)("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("path", { d: "m6 9 6 6 6-6" }) }))] }), index === activeGapIndex && (0, jsx_runtime_1.jsx)("button", { type: "button", className: "cw-feedback-gap__remove", onClick: () => removeGap(index), "aria-label": `Delete gap ${index + 1}`, title: `Delete gap ${index + 1}`, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 16, "aria-hidden": "true" }) })] })), (flow.gaps.length < 2 || index === activeGapIndex) && (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [flow.gaps.length >= 2 && gap.detailRequested && wordCount(gap.description) < copy.usefulMinimumWords && ((0, jsx_runtime_1.jsxs)("p", { className: "cw-feedback-inline-detail-prompt cw-feedback-gap__detail-prompt", children: ["*", copy.gapMoreDetailPrompt] })), (0, jsx_runtime_1.jsx)("textarea", { autoFocus: index === 0 && !isLowRatingGap, value: gap.description, className: classNames?.gapTextarea, style: styles?.gapTextarea, onFocus: () => { if (isLowRatingGap)
|
|
571
|
+
setIntroDismissed(true); }, onChange: (e) => updateGap(index, { description: e.target.value }), onKeyDown: (event) => {
|
|
572
|
+
if (event.key === 'Enter' && !event.shiftKey && gap.description.trim()) {
|
|
573
|
+
event.preventDefault();
|
|
574
|
+
if (wordCount(gap.description) < copy.usefulMinimumWords && !gap.detailRequested) {
|
|
575
|
+
updateGap(index, { detailRequested: true });
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
setRevealedGapCount(Math.max(revealedGapCount, index + 1));
|
|
579
|
+
}
|
|
580
|
+
}, placeholder: copy.gapPlaceholder }), index < revealedGapCount && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("strong", { className: classNames?.severityTitle, style: styles?.severityTitle, children: copy.severityQuestion }), (0, jsx_runtime_1.jsx)("div", { className: ['cw-feedback-severity', classNames?.severityContainer].filter(Boolean).join(' '), style: styles?.severityContainer, children: severities.map((severity) => {
|
|
581
|
+
const isSelected = gap.severity === severity;
|
|
582
|
+
return ((0, jsx_runtime_1.jsx)("button", { type: "button", className: [severity, isSelected ? 'is-selected' : '', classNames?.severityButton]
|
|
583
|
+
.filter(Boolean)
|
|
584
|
+
.join(' '), style: {
|
|
585
|
+
color: mergedTheme.severityColors?.[severity],
|
|
586
|
+
'--cw-feedback-severity-color': mergedTheme.severityColors?.[severity],
|
|
587
|
+
...(isSelected
|
|
588
|
+
? {
|
|
589
|
+
...(mergedTheme.severityBackgrounds?.[severity]
|
|
590
|
+
? { backgroundColor: mergedTheme.severityBackgrounds[severity] }
|
|
591
|
+
: {}),
|
|
592
|
+
}
|
|
593
|
+
: {}),
|
|
594
|
+
...styles?.severityButton,
|
|
595
|
+
}, onClick: () => {
|
|
596
|
+
const gaps = flow.gaps.map((item, gapIndex) => gapIndex === index ? { ...item, severity } : item);
|
|
597
|
+
flow.setGaps(gaps);
|
|
598
|
+
if (flow.submission) {
|
|
599
|
+
void persistAndAdvance('gap', () => undefined, {
|
|
600
|
+
...flow.submission,
|
|
601
|
+
gaps: gaps.filter((item) => item.description.trim() || item.screenshots.length),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
}, children: copy.severityLabels[severity] }, severity));
|
|
605
|
+
}) }), (0, jsx_runtime_1.jsxs)("div", { className: "cw-feedback-capture-actions", children: [(0, jsx_runtime_1.jsxs)("label", { className: ['cw-feedback-upload', classNames?.uploadButton].filter(Boolean).join(' '), style: styles?.uploadButton, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Paperclip, { size: 18 }), " ", copy.attachScreenshot, (0, jsx_runtime_1.jsx)("input", { type: "file", accept: "image/*", multiple: true, className: classNames?.uploadInput, style: styles?.uploadInput, onChange: (e) => {
|
|
606
|
+
const files = Array.from(e.target.files ?? []);
|
|
607
|
+
if (files.length)
|
|
608
|
+
updateGap(index, { screenshots: [...gap.screenshots, ...files] });
|
|
609
|
+
e.target.value = '';
|
|
610
|
+
} })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "cw-feedback-upload", onClick: () => void captureTabScreenshot(index), children: [(0, jsx_runtime_1.jsx)("span", { "aria-hidden": "true", children: "\u25A3" }), " Take a screenshot"] })] }), captureError && (0, jsx_runtime_1.jsx)("p", { className: "cw-feedback-capture-error", role: "alert", children: captureError }), gap.screenshots.length > 0 && ((0, jsx_runtime_1.jsx)("div", { className: ['cw-feedback-files', classNames?.filesList].filter(Boolean).join(' '), style: styles?.filesList, children: gap.screenshots.map((file, fileIndex) => ((0, jsx_runtime_1.jsx)("div", { className: classNames?.fileTag, style: styles?.fileTag, children: (0, jsx_runtime_1.jsx)(FeedbackScreenshotThumbnail_1.FeedbackScreenshotThumbnail, { screenshot: file, resolveUrl: resolveScreenshotUrl, onOpen: (url, name) => setScreenshotPreview({ url, name }), onRemove: () => updateGap(index, { screenshots: gap.screenshots.filter((_, screenshotIndex) => screenshotIndex !== fileIndex) }) }) }, `${file.name}-${file.lastModified}-${fileIndex}`))) }))] }))] })] }, index))) }), revealedGapCount < flow.gaps.length &&
|
|
611
|
+
(!isLowRatingGap || Boolean(flow.gaps[revealedGapCount]?.description.trim())) &&
|
|
612
|
+
(!flow.gaps[revealedGapCount]?.detailRequested ||
|
|
613
|
+
Boolean(flow.gaps[revealedGapCount]?.description.trim())) && ((0, jsx_runtime_1.jsx)("button", { className: ['cw-feedback-hint', 'cw-feedback-hint-action', classNames?.hint, classNames?.enterHintButton]
|
|
614
|
+
.filter(Boolean)
|
|
615
|
+
.join(' '), style: {
|
|
616
|
+
...styles?.hint,
|
|
617
|
+
...styles?.enterHintButton,
|
|
618
|
+
}, type: "button", onClick: () => {
|
|
619
|
+
const index = revealedGapCount;
|
|
620
|
+
const gap = flow.gaps[index];
|
|
621
|
+
if (!gap?.description.trim())
|
|
622
|
+
return;
|
|
623
|
+
if (wordCount(gap.description) < copy.usefulMinimumWords && !gap.detailRequested) {
|
|
624
|
+
updateGap(index, { detailRequested: true });
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
setRevealedGapCount(index + 1);
|
|
628
|
+
}, children: copy.enterHint })), canAdvanceGap && ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: (0, jsx_runtime_1.jsxs)("button", { className: ['cw-feedback-add', classNames?.addGapButton].filter(Boolean).join(' '), style: styles?.addGapButton, type: "button", onClick: () => {
|
|
629
|
+
setActiveGapIndex(flow.gaps.length);
|
|
630
|
+
flow.setGaps([...flow.gaps, { description: '', screenshots: [] }]);
|
|
631
|
+
}, children: ["\uFF0B ", copy.addAnotherGap] }) })), slots?.gap] })), flow.step === 'success' && ((0, jsx_runtime_1.jsxs)("div", { className: ['cw-feedback-success', classNames?.successContainer].filter(Boolean).join(' '), style: styles?.successContainer, children: [(0, jsx_runtime_1.jsx)("span", { className: ['cw-feedback-success__check', classNames?.successCheck].filter(Boolean).join(' '), style: styles?.successCheck, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Check, { size: 38 }) }), (0, jsx_runtime_1.jsx)("h2", { className: classNames?.successTitle, style: styles?.successTitle, children: copy.successTitle }), (0, jsx_runtime_1.jsx)("p", { className: classNames?.successDescription, style: styles?.successDescription, children: copy.successDescription }), (0, jsx_runtime_1.jsxs)("div", { className: ['cw-feedback-success__coming-soon', classNames?.successComingSoon].filter(Boolean).join(' '), style: styles?.successComingSoon, children: [(0, jsx_runtime_1.jsxs)("svg", { className: "cw-feedback-success__leaderboard-icon", viewBox: "0 0 64 64", fill: "none", "aria-hidden": "true", children: [(0, jsx_runtime_1.jsx)("path", { d: "M7 48h50M11 48V33h12v15m8 0V23h12v25m-16 0V38h-4v10m20 0V36h10v12", stroke: "currentColor", strokeWidth: "3", strokeLinecap: "round", strokeLinejoin: "round" }), (0, jsx_runtime_1.jsx)("path", { d: "m37 7 1.8 4.2 4.5.4-3.4 3 1 4.4-3.9-2.3-3.9 2.3 1-4.4-3.4-3 4.5-.4L37 7Z", fill: "currentColor" })] }), (0, jsx_runtime_1.jsx)("span", { className: "cw-feedback-success__leaderboard-title", children: "Leaderboard" }), (0, jsx_runtime_1.jsx)("span", { className: "cw-feedback-success__leaderboard-status", children: copy.successComingSoon })] }), slots?.success] })), submitError && flow.step !== 'success' && ((0, jsx_runtime_1.jsx)("p", { role: "alert", className: ['cw-feedback-submit-error', classNames?.submitError].filter(Boolean).join(' '), style: styles?.submitError, children: copy.submitErrorLabel }))] }), !isReview && flow.step === 'gap' && canAdvanceGap && ((0, jsx_runtime_1.jsx)("div", { className: ['cw-feedback-dialog__action', classNames?.actionBar].filter(Boolean).join(' '), style: styles?.actionBar, children: (0, jsx_runtime_1.jsx)("button", { className: ['cw-feedback-primary', classNames?.primaryButton].filter(Boolean).join(' '), style: styles?.primaryButton, type: "button", onClick: isGapFirstFlow
|
|
632
|
+
? () => { void persistAndAdvance('useful', () => flow.setStep('useful')); }
|
|
633
|
+
: submitFeedback, children: isGapFirstFlow ? copy.nextLabel : copy.doneLabel }) })), !isReview && flow.step === 'useful' && ((0, jsx_runtime_1.jsxs)("div", { className: ['cw-feedback-dialog__action', classNames?.actionBar].filter(Boolean).join(' '), style: styles?.actionBar, children: [showLowRatingTime &&
|
|
634
|
+
(!flow.usefulDetailShown || Boolean(flow.mostUseful.trim())) && ((0, jsx_runtime_1.jsx)("button", { className: ['cw-feedback-hint', 'cw-feedback-hint-action', classNames?.hint, classNames?.enterHintButton]
|
|
635
|
+
.filter(Boolean)
|
|
636
|
+
.join(' '), style: {
|
|
637
|
+
...styles?.hint,
|
|
638
|
+
...styles?.enterHintButton,
|
|
639
|
+
}, type: "button", onClick: continueUseful, children: copy.enterHint })), showLowRatingTime && flow.timeSavedMinutes !== undefined &&
|
|
640
|
+
Boolean(flow.mostUseful.trim()) &&
|
|
641
|
+
(wordCount(flow.mostUseful) >= copy.usefulMinimumWords || flow.usefulDetailShown) && ((0, jsx_runtime_1.jsx)("button", { className: ['cw-feedback-primary', classNames?.primaryButton].filter(Boolean).join(' '), style: styles?.primaryButton, type: "button", onClick: submitFeedback, children: copy.doneLabel }))] })), isReview && ((0, jsx_runtime_1.jsxs)("div", { className: ['cw-feedback-review-nav', classNames?.reviewNav].filter(Boolean).join(' '), style: styles?.reviewNav, children: [(0, jsx_runtime_1.jsx)("button", { className: ['cw-feedback-review-nav__button', classNames?.reviewNavButton].filter(Boolean).join(' '), style: styles?.reviewNavButton, type: "button", onClick: previousReviewPage, disabled: reviewPageIndex === 0, "aria-label": copy.backLabel, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ArrowLeft, { size: 18 }) }), (0, jsx_runtime_1.jsxs)("button", { className: [
|
|
642
|
+
'cw-feedback-review-nav__button',
|
|
643
|
+
'cw-feedback-review-nav__button--next',
|
|
644
|
+
classNames?.reviewNavButton,
|
|
645
|
+
classNames?.reviewNavNextButton,
|
|
646
|
+
]
|
|
647
|
+
.filter(Boolean)
|
|
648
|
+
.join(' '), style: {
|
|
649
|
+
...styles?.reviewNavButton,
|
|
650
|
+
...styles?.reviewNavNextButton,
|
|
651
|
+
}, type: "button", onClick: nextReviewPage, disabled: !canContinueReview, "aria-label": reviewPageIndex === reviewPages.length - 1 ? copy.saveChangesLabel : copy.nextLabel, children: [reviewPageIndex === reviewPages.length - 1 && (0, jsx_runtime_1.jsx)("span", { children: copy.saveChangesLabel }), (0, jsx_runtime_1.jsx)(lucide_react_1.ArrowRight, { size: 18 })] })] })), busy && ((0, jsx_runtime_1.jsx)("div", { ref: savingOverlayRef, className: "cw-feedback-saving-overlay", role: "status", "aria-live": "polite", tabIndex: -1, onKeyDown: (event) => { event.preventDefault(); event.stopPropagation(); }, children: (0, jsx_runtime_1.jsxs)("div", { className: "cw-feedback-saving-overlay__card", children: [(0, jsx_runtime_1.jsx)("span", { className: "cw-feedback-saving__spinner", "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("span", { children: copy.savingLabel })] }) }))] }));
|
|
652
|
+
const previewDialog = screenshotPreview && ((0, jsx_runtime_1.jsx)("div", { className: "cw-feedback-image-preview-backdrop", onMouseDown: (event) => {
|
|
653
|
+
if (event.target === event.currentTarget)
|
|
654
|
+
setScreenshotPreview(null);
|
|
655
|
+
}, children: (0, jsx_runtime_1.jsxs)("div", { ref: screenshotPreviewRef, className: "cw-feedback-image-preview-dialog", role: "dialog", "aria-modal": "true", "aria-label": `Screenshot preview: ${screenshotPreview.name}`, tabIndex: -1, children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "cw-feedback-image-preview-close", onClick: () => setScreenshotPreview(null), "aria-label": "Close screenshot preview", children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 22 }) }), (0, jsx_runtime_1.jsx)("img", { src: screenshotPreview.url, alt: screenshotPreview.name })] }) }));
|
|
656
|
+
return typeof document !== 'undefined'
|
|
657
|
+
? (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, react_dom_1.createPortal)(backdrop, document.body), (0, react_dom_1.createPortal)(dialog, document.body), previewDialog && (0, react_dom_1.createPortal)(previewDialog, document.body)] })
|
|
658
|
+
: (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [backdrop, dialog, previewDialog] });
|
|
659
|
+
}
|