fi-edback 0.2.0

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/client.js ADDED
@@ -0,0 +1,1062 @@
1
+ "use client";
2
+
3
+ // src/components/FeedbackRoot.tsx
4
+ import { useState as useState5, useEffect as useEffect4 } from "react";
5
+
6
+ // src/components/FeedbackLauncher.tsx
7
+ import { useState as useState4, useEffect as useEffect3 } from "react";
8
+
9
+ // src/lib/i18n.ts
10
+ var translations = {
11
+ en: {
12
+ feedbackButton: "Feedback",
13
+ cancelButton: "Cancel",
14
+ instructionText: "Click the Feedback button to leave a comment",
15
+ messagePlaceholder: "What would you like to tell us?",
16
+ messageLabel: "Message",
17
+ nameLabel: "Name (optional)",
18
+ emailLabel: "Email (optional)",
19
+ submitButton: "Send feedback",
20
+ submitting: "Sending...",
21
+ successMessage: "Thank you!",
22
+ successDescription: "Your feedback has been submitted.",
23
+ errorTitle: "Error",
24
+ clickToPlace: "Click anywhere to place a feedback pin",
25
+ feedbackSubmitted: "Feedback submitted",
26
+ deleteFeedback: "Delete feedback",
27
+ by: "by",
28
+ anonymous: "Anonymous",
29
+ reactions: "Reactions"
30
+ },
31
+ de: {
32
+ feedbackButton: "Feedback",
33
+ cancelButton: "Abbrechen",
34
+ instructionText: "Klicken Sie auf die Feedback-Schaltfl\xE4che, um einen Kommentar zu hinterlassen",
35
+ messagePlaceholder: "Was m\xF6chten Sie uns mitteilen?",
36
+ messageLabel: "Nachricht",
37
+ nameLabel: "Name (optional)",
38
+ emailLabel: "E-Mail (optional)",
39
+ submitButton: "Feedback senden",
40
+ submitting: "Wird gesendet...",
41
+ successMessage: "Vielen Dank!",
42
+ successDescription: "Ihr Feedback wurde \xFCbermittelt.",
43
+ errorTitle: "Fehler",
44
+ clickToPlace: "Klicken Sie \xFCberall, um einen Feedback-Pin zu platzieren",
45
+ feedbackSubmitted: "Feedback \xFCbermittelt",
46
+ deleteFeedback: "Feedback l\xF6schen",
47
+ by: "von",
48
+ anonymous: "Anonym",
49
+ reactions: "Reaktionen"
50
+ },
51
+ ga: {
52
+ feedbackButton: "Aiseolas",
53
+ cancelButton: "Cealaigh",
54
+ instructionText: "Clice\xE1il an cnaipe Aiseolas chun tr\xE1cht a fh\xE1g\xE1il",
55
+ messagePlaceholder: "Cad ba mhaith leat a r\xE1 linn?",
56
+ messageLabel: "Teachtaireacht",
57
+ nameLabel: "Ainm (roghnach)",
58
+ emailLabel: "R\xEDomhphost (roghnach)",
59
+ submitButton: "Seol aiseolas",
60
+ submitting: "\xC1 sheoladh...",
61
+ successMessage: "Go raibh maith agat!",
62
+ successDescription: "Seoladh d'aiseolas.",
63
+ errorTitle: "Earr\xE1id",
64
+ clickToPlace: "Clice\xE1il \xE1it ar bith chun bior\xE1in aiseolais a chur",
65
+ feedbackSubmitted: "Aiseolas seolta",
66
+ deleteFeedback: "Scrios aiseolas",
67
+ by: "le",
68
+ anonymous: "Gan ainm",
69
+ reactions: "Imoibrithe"
70
+ }
71
+ };
72
+ function getTranslations(lang) {
73
+ return translations[lang];
74
+ }
75
+
76
+ // src/components/FeedbackOverlay.tsx
77
+ import { jsx } from "react/jsx-runtime";
78
+ function FeedbackOverlay({
79
+ language,
80
+ onPinPlaced
81
+ }) {
82
+ const t = getTranslations(language);
83
+ function handleClick(e) {
84
+ const x = e.clientX + window.scrollX;
85
+ const y = e.clientY + window.scrollY;
86
+ onPinPlaced(x, y);
87
+ }
88
+ return /* @__PURE__ */ jsx(
89
+ "div",
90
+ {
91
+ onClick: handleClick,
92
+ role: "button",
93
+ "aria-label": t.clickToPlace,
94
+ tabIndex: 0,
95
+ onKeyDown: (e) => {
96
+ if (e.key === "Escape") e.currentTarget.blur();
97
+ },
98
+ style: {
99
+ position: "fixed",
100
+ inset: 0,
101
+ zIndex: 9997,
102
+ cursor: "crosshair",
103
+ backgroundColor: "rgba(0, 0, 0, 0.04)"
104
+ }
105
+ }
106
+ );
107
+ }
108
+
109
+ // src/components/FeedbackPinLayer.tsx
110
+ import { useState, useRef, useEffect } from "react";
111
+ import { Fragment, jsx as jsx2 } from "react/jsx-runtime";
112
+ function FeedbackPinLayer({
113
+ pins,
114
+ onPinClick,
115
+ onPinMoved,
116
+ title = "Feedback submitted"
117
+ }) {
118
+ const [draggingId, setDraggingId] = useState(null);
119
+ const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
120
+ const [tempPosition, setTempPosition] = useState(null);
121
+ const dragStartPos = useRef({ x: 0, y: 0 });
122
+ const hasMoved = useRef(false);
123
+ const longPressTimer = useRef(null);
124
+ useEffect(() => {
125
+ if (!draggingId) return;
126
+ const handleMove = (clientX, clientY) => {
127
+ hasMoved.current = true;
128
+ const x = clientX + window.scrollX - dragOffset.x;
129
+ const y = clientY + window.scrollY - dragOffset.y;
130
+ setTempPosition({ x, y });
131
+ };
132
+ const handleMouseMove = (e) => {
133
+ handleMove(e.clientX, e.clientY);
134
+ };
135
+ const handleTouchMove = (e) => {
136
+ e.preventDefault();
137
+ const touch = e.touches[0];
138
+ handleMove(touch.clientX, touch.clientY);
139
+ };
140
+ const handleEnd = () => {
141
+ if (draggingId && tempPosition && hasMoved.current) {
142
+ if (onPinMoved) {
143
+ onPinMoved(draggingId, tempPosition.x, tempPosition.y);
144
+ }
145
+ }
146
+ setDraggingId(null);
147
+ setTempPosition(null);
148
+ hasMoved.current = false;
149
+ };
150
+ document.addEventListener("mousemove", handleMouseMove);
151
+ document.addEventListener("mouseup", handleEnd);
152
+ document.addEventListener("touchmove", handleTouchMove, { passive: false });
153
+ document.addEventListener("touchend", handleEnd);
154
+ return () => {
155
+ document.removeEventListener("mousemove", handleMouseMove);
156
+ document.removeEventListener("mouseup", handleEnd);
157
+ document.removeEventListener("touchmove", handleTouchMove);
158
+ document.removeEventListener("touchend", handleEnd);
159
+ };
160
+ }, [draggingId, dragOffset, tempPosition, onPinMoved]);
161
+ if (pins.length === 0) return null;
162
+ return /* @__PURE__ */ jsx2(Fragment, { children: pins.map((pin) => {
163
+ const isDragging = draggingId === pin.id;
164
+ const position = isDragging && tempPosition ? tempPosition : { x: pin.x, y: pin.y };
165
+ const handleStart = (clientX, clientY) => {
166
+ hasMoved.current = false;
167
+ const pinCenterX = pin.x;
168
+ const pinCenterY = pin.y;
169
+ const offsetX = clientX + window.scrollX - pinCenterX;
170
+ const offsetY = clientY + window.scrollY - pinCenterY;
171
+ setDragOffset({ x: offsetX, y: offsetY });
172
+ setDraggingId(pin.id);
173
+ dragStartPos.current = { x: clientX, y: clientY };
174
+ };
175
+ return /* @__PURE__ */ jsx2(
176
+ "div",
177
+ {
178
+ title,
179
+ onMouseDown: (e) => {
180
+ e.stopPropagation();
181
+ handleStart(e.clientX, e.clientY);
182
+ },
183
+ onTouchStart: (e) => {
184
+ e.stopPropagation();
185
+ const touch = e.touches[0];
186
+ handleStart(touch.clientX, touch.clientY);
187
+ },
188
+ onClick: (e) => {
189
+ if (!hasMoved.current && onPinClick) {
190
+ e.stopPropagation();
191
+ onPinClick(pin.id);
192
+ }
193
+ },
194
+ style: {
195
+ position: "absolute",
196
+ left: position.x - 10,
197
+ top: position.y - 22,
198
+ zIndex: isDragging ? 9998 : 9996,
199
+ width: "20px",
200
+ height: "20px",
201
+ borderRadius: "50% 50% 50% 0",
202
+ transform: isDragging ? "rotate(-45deg) scale(1.15)" : "rotate(-45deg)",
203
+ backgroundColor: "#18181b",
204
+ border: "2px solid #fff",
205
+ boxShadow: isDragging ? "0 8px 24px rgba(0,0,0,0.4)" : "0 2px 6px rgba(0,0,0,0.3)",
206
+ pointerEvents: "auto",
207
+ cursor: isDragging ? "grabbing" : "grab",
208
+ transition: isDragging ? "none" : "transform 0.15s ease, box-shadow 0.15s ease",
209
+ userSelect: "none"
210
+ },
211
+ onMouseEnter: (e) => {
212
+ if (!isDragging) {
213
+ e.currentTarget.style.transform = "rotate(-45deg) scale(1.1)";
214
+ }
215
+ },
216
+ onMouseLeave: (e) => {
217
+ if (!isDragging) {
218
+ e.currentTarget.style.transform = "rotate(-45deg) scale(1)";
219
+ }
220
+ }
221
+ },
222
+ pin.id
223
+ );
224
+ }) });
225
+ }
226
+
227
+ // src/components/FeedbackForm.tsx
228
+ import { useState as useState2, useEffect as useEffect2, useRef as useRef2 } from "react";
229
+
230
+ // src/lib/config.ts
231
+ var API_PATH = "/api/fi-edback";
232
+ var SESSION_COOKIE_NAME = "fi_session";
233
+
234
+ // src/lib/session.ts
235
+ function generateId() {
236
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
237
+ return crypto.randomUUID();
238
+ }
239
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
240
+ }
241
+ function getOrCreateSessionId() {
242
+ if (typeof document === "undefined") return "";
243
+ const cookies = Object.fromEntries(
244
+ document.cookie.split("; ").filter(Boolean).map((c) => {
245
+ const idx = c.indexOf("=");
246
+ return [c.slice(0, idx), c.slice(idx + 1)];
247
+ })
248
+ );
249
+ const existing = cookies[SESSION_COOKIE_NAME];
250
+ if (existing) return existing;
251
+ const id = generateId();
252
+ const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3).toUTCString();
253
+ document.cookie = `${SESSION_COOKIE_NAME}=${id}; expires=${expires}; path=/; SameSite=Lax`;
254
+ return id;
255
+ }
256
+
257
+ // src/components/FeedbackForm.tsx
258
+ import { jsx as jsx3, jsxs } from "react/jsx-runtime";
259
+ function FeedbackForm({
260
+ x,
261
+ y,
262
+ projectSlug,
263
+ apiPath,
264
+ language,
265
+ onSubmitted,
266
+ onCancelled
267
+ }) {
268
+ const [message, setMessage] = useState2("");
269
+ const [name, setName] = useState2("");
270
+ const [email, setEmail] = useState2("");
271
+ const [website, setWebsite] = useState2("");
272
+ const [status, setStatus] = useState2("idle");
273
+ const [errorText, setErrorText] = useState2("");
274
+ const messageRef = useRef2(null);
275
+ const t = getTranslations(language);
276
+ useEffect2(() => {
277
+ messageRef.current?.focus();
278
+ }, []);
279
+ async function handleSubmit(e) {
280
+ e.preventDefault();
281
+ if (status === "submitting") return;
282
+ setStatus("submitting");
283
+ setErrorText("");
284
+ const sessionId = getOrCreateSessionId();
285
+ const payload = {
286
+ projectSlug,
287
+ pageUrl: window.location.href,
288
+ x,
289
+ y,
290
+ message,
291
+ name: name || void 0,
292
+ email: email || void 0,
293
+ sessionId,
294
+ website
295
+ // honeypot
296
+ };
297
+ try {
298
+ const res = await fetch(apiPath, {
299
+ method: "POST",
300
+ headers: { "Content-Type": "application/json" },
301
+ body: JSON.stringify(payload)
302
+ });
303
+ if (res.status === 429) {
304
+ setStatus("error");
305
+ setErrorText(
306
+ "Too many submissions \u2014 please wait a moment before trying again."
307
+ );
308
+ return;
309
+ }
310
+ if (!res.ok) {
311
+ const data2 = await res.json().catch(() => ({}));
312
+ setStatus("error");
313
+ setErrorText(
314
+ data2.error ?? "Something went wrong. Please try again."
315
+ );
316
+ return;
317
+ }
318
+ const data = await res.json();
319
+ setStatus("success");
320
+ setTimeout(() => {
321
+ onSubmitted(data.feedback);
322
+ }, 900);
323
+ } catch {
324
+ setStatus("error");
325
+ setErrorText(
326
+ "Network error \u2014 please check your connection and try again."
327
+ );
328
+ }
329
+ }
330
+ const viewportX = x - window.scrollX;
331
+ const viewportY = y - window.scrollY;
332
+ const isMobile = window.innerWidth < 640;
333
+ const formWidth = isMobile ? Math.min(window.innerWidth - 24, 300) : 300;
334
+ const formHeight = 320;
335
+ const left = isMobile ? 12 : Math.min(viewportX + 14, window.innerWidth - formWidth - 12);
336
+ const top = isMobile ? Math.min(
337
+ window.innerHeight - formHeight - 12,
338
+ window.innerHeight / 2 - formHeight / 2
339
+ ) : Math.min(viewportY + 14, window.innerHeight - formHeight - 12);
340
+ const inputStyle = {
341
+ width: "100%",
342
+ border: "1px solid #e4e4e7",
343
+ borderRadius: "6px",
344
+ padding: "8px 10px",
345
+ fontSize: "13px",
346
+ boxSizing: "border-box",
347
+ outline: "none",
348
+ fontFamily: "system-ui, sans-serif",
349
+ color: "#18181b",
350
+ backgroundColor: "#fff"
351
+ };
352
+ return /* @__PURE__ */ jsx3(
353
+ "div",
354
+ {
355
+ role: "dialog",
356
+ "aria-label": "Leave feedback",
357
+ style: {
358
+ position: "fixed",
359
+ zIndex: 9999,
360
+ top,
361
+ left,
362
+ width: `${formWidth}px`,
363
+ backgroundColor: "#fff",
364
+ borderRadius: "12px",
365
+ boxShadow: "0 8px 32px rgba(0,0,0,0.16)",
366
+ padding: "16px",
367
+ fontFamily: "system-ui, sans-serif",
368
+ fontSize: "14px"
369
+ },
370
+ children: status === "success" ? /* @__PURE__ */ jsxs(
371
+ "div",
372
+ {
373
+ style: { textAlign: "center", padding: "20px 0", color: "#16a34a" },
374
+ children: [
375
+ /* @__PURE__ */ jsx3("div", { style: { fontSize: "28px", marginBottom: "8px" }, children: "\u2713" }),
376
+ /* @__PURE__ */ jsx3("div", { style: { fontWeight: "600", color: "#18181b" }, children: t.successMessage }),
377
+ /* @__PURE__ */ jsx3("div", { style: { color: "#71717a", fontSize: "13px", marginTop: "4px" }, children: t.successDescription })
378
+ ]
379
+ }
380
+ ) : /* @__PURE__ */ jsxs("form", { onSubmit: handleSubmit, children: [
381
+ /* @__PURE__ */ jsx3(
382
+ "div",
383
+ {
384
+ style: {
385
+ marginBottom: "12px",
386
+ fontWeight: "600",
387
+ color: "#18181b",
388
+ fontSize: "14px"
389
+ },
390
+ children: t.feedbackButton
391
+ }
392
+ ),
393
+ /* @__PURE__ */ jsxs("div", { style: { display: "none" }, "aria-hidden": "true", children: [
394
+ /* @__PURE__ */ jsx3("label", { htmlFor: "fi-website", children: "Website" }),
395
+ /* @__PURE__ */ jsx3(
396
+ "input",
397
+ {
398
+ id: "fi-website",
399
+ type: "text",
400
+ value: website,
401
+ onChange: (e) => setWebsite(e.target.value),
402
+ tabIndex: -1,
403
+ autoComplete: "off"
404
+ }
405
+ )
406
+ ] }),
407
+ /* @__PURE__ */ jsx3("div", { style: { marginBottom: "10px" }, children: /* @__PURE__ */ jsx3(
408
+ "textarea",
409
+ {
410
+ ref: messageRef,
411
+ value: message,
412
+ onChange: (e) => setMessage(e.target.value),
413
+ placeholder: t.messagePlaceholder,
414
+ required: true,
415
+ rows: 3,
416
+ style: { ...inputStyle, resize: "vertical" }
417
+ }
418
+ ) }),
419
+ /* @__PURE__ */ jsx3("div", { style: { marginBottom: "10px" }, children: /* @__PURE__ */ jsx3(
420
+ "input",
421
+ {
422
+ type: "text",
423
+ value: name,
424
+ onChange: (e) => setName(e.target.value),
425
+ placeholder: t.nameLabel,
426
+ style: inputStyle
427
+ }
428
+ ) }),
429
+ /* @__PURE__ */ jsx3("div", { style: { marginBottom: "14px" }, children: /* @__PURE__ */ jsx3(
430
+ "input",
431
+ {
432
+ type: "email",
433
+ value: email,
434
+ onChange: (e) => setEmail(e.target.value),
435
+ placeholder: t.emailLabel,
436
+ style: inputStyle
437
+ }
438
+ ) }),
439
+ errorText && /* @__PURE__ */ jsx3(
440
+ "div",
441
+ {
442
+ style: {
443
+ marginBottom: "10px",
444
+ color: "#ef4444",
445
+ fontSize: "12px",
446
+ lineHeight: "1.4"
447
+ },
448
+ children: errorText
449
+ }
450
+ ),
451
+ /* @__PURE__ */ jsxs(
452
+ "div",
453
+ {
454
+ style: { display: "flex", gap: "8px", justifyContent: "flex-end" },
455
+ children: [
456
+ /* @__PURE__ */ jsx3(
457
+ "button",
458
+ {
459
+ type: "button",
460
+ onClick: onCancelled,
461
+ style: {
462
+ padding: "7px 14px",
463
+ borderRadius: "6px",
464
+ border: "1px solid #e4e4e7",
465
+ backgroundColor: "transparent",
466
+ fontSize: "13px",
467
+ cursor: "pointer",
468
+ color: "#71717a",
469
+ fontFamily: "system-ui, sans-serif"
470
+ },
471
+ children: t.cancelButton
472
+ }
473
+ ),
474
+ /* @__PURE__ */ jsx3(
475
+ "button",
476
+ {
477
+ type: "submit",
478
+ disabled: status === "submitting" || !message.trim(),
479
+ style: {
480
+ padding: "7px 14px",
481
+ borderRadius: "6px",
482
+ border: "none",
483
+ backgroundColor: "#18181b",
484
+ color: "#fff",
485
+ fontSize: "13px",
486
+ fontWeight: "500",
487
+ cursor: status === "submitting" || !message.trim() ? "not-allowed" : "pointer",
488
+ opacity: status === "submitting" || !message.trim() ? 0.5 : 1,
489
+ fontFamily: "system-ui, sans-serif"
490
+ },
491
+ children: status === "submitting" ? t.submitting : t.submitButton
492
+ }
493
+ )
494
+ ]
495
+ }
496
+ )
497
+ ] })
498
+ }
499
+ );
500
+ }
501
+
502
+ // src/components/FeedbackPopup.tsx
503
+ import { useState as useState3 } from "react";
504
+ import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
505
+ var REACTIONS = ["\u{1F44D}", "\u2705", "\u2764\uFE0F", "\u{1F525}", "\u{1F440}"];
506
+ function FeedbackPopup({
507
+ feedback,
508
+ apiPath,
509
+ language,
510
+ onDeleted,
511
+ onReactionToggled,
512
+ onClose
513
+ }) {
514
+ const [isDeleting, setIsDeleting] = useState3(false);
515
+ const [reactingTo, setReactingTo] = useState3(null);
516
+ const t = getTranslations(language);
517
+ async function handleReaction(reaction) {
518
+ if (reactingTo) return;
519
+ setReactingTo(reaction);
520
+ const sessionId = getOrCreateSessionId();
521
+ try {
522
+ const res = await fetch(apiPath, {
523
+ method: "PATCH",
524
+ headers: { "Content-Type": "application/json" },
525
+ body: JSON.stringify({
526
+ feedbackId: feedback.id,
527
+ reaction,
528
+ sessionId
529
+ })
530
+ });
531
+ if (res.ok) {
532
+ const data = await res.json();
533
+ onReactionToggled(feedback.id, reaction, data.added);
534
+ }
535
+ } catch (error) {
536
+ console.error("[fi-edback] Failed to toggle reaction:", error);
537
+ } finally {
538
+ setReactingTo(null);
539
+ }
540
+ }
541
+ async function handleDelete() {
542
+ if (isDeleting) return;
543
+ if (!confirm(t.deleteFeedback + "?")) return;
544
+ setIsDeleting(true);
545
+ try {
546
+ const res = await fetch(`${apiPath}?id=${feedback.id}`, {
547
+ method: "DELETE"
548
+ });
549
+ if (res.ok) {
550
+ onDeleted(feedback.id);
551
+ } else {
552
+ alert(t.errorTitle);
553
+ setIsDeleting(false);
554
+ }
555
+ } catch {
556
+ alert(t.errorTitle);
557
+ setIsDeleting(false);
558
+ }
559
+ }
560
+ const viewportX = feedback.x - window.scrollX;
561
+ const viewportY = feedback.y - window.scrollY;
562
+ const isMobile = window.innerWidth < 640;
563
+ const popupWidth = isMobile ? Math.min(window.innerWidth - 24, 300) : 300;
564
+ const popupMaxHeight = 400;
565
+ const left = isMobile ? 12 : Math.min(viewportX + 14, window.innerWidth - popupWidth - 12);
566
+ const top = isMobile ? Math.min(
567
+ window.innerHeight - popupMaxHeight - 12,
568
+ window.innerHeight / 2 - popupMaxHeight / 2
569
+ ) : Math.min(viewportY + 14, window.innerHeight - popupMaxHeight - 12);
570
+ return /* @__PURE__ */ jsxs2(Fragment2, { children: [
571
+ /* @__PURE__ */ jsx4(
572
+ "div",
573
+ {
574
+ onClick: onClose,
575
+ style: {
576
+ position: "fixed",
577
+ inset: 0,
578
+ zIndex: 9998,
579
+ backgroundColor: "transparent"
580
+ }
581
+ }
582
+ ),
583
+ /* @__PURE__ */ jsxs2(
584
+ "div",
585
+ {
586
+ role: "dialog",
587
+ "aria-label": "Feedback details",
588
+ style: {
589
+ position: "fixed",
590
+ zIndex: 9999,
591
+ top,
592
+ left,
593
+ width: `${popupWidth}px`,
594
+ maxHeight: `${popupMaxHeight}px`,
595
+ backgroundColor: "#fff",
596
+ borderRadius: "12px",
597
+ boxShadow: "0 8px 32px rgba(0,0,0,0.16)",
598
+ padding: "16px",
599
+ fontFamily: "system-ui, sans-serif",
600
+ fontSize: "14px",
601
+ overflow: "auto"
602
+ },
603
+ children: [
604
+ /* @__PURE__ */ jsx4(
605
+ "button",
606
+ {
607
+ onClick: onClose,
608
+ "aria-label": "Close",
609
+ style: {
610
+ position: "absolute",
611
+ top: "12px",
612
+ right: "12px",
613
+ width: "24px",
614
+ height: "24px",
615
+ border: "none",
616
+ backgroundColor: "transparent",
617
+ cursor: "pointer",
618
+ fontSize: "18px",
619
+ lineHeight: "1",
620
+ color: "#71717a",
621
+ padding: 0
622
+ },
623
+ children: "\xD7"
624
+ }
625
+ ),
626
+ /* @__PURE__ */ jsxs2(
627
+ "div",
628
+ {
629
+ style: {
630
+ fontSize: "12px",
631
+ color: "#71717a",
632
+ marginBottom: "12px",
633
+ paddingRight: "24px"
634
+ },
635
+ children: [
636
+ /* @__PURE__ */ jsxs2("div", { children: [
637
+ t.by,
638
+ " ",
639
+ /* @__PURE__ */ jsx4("strong", { style: { color: "#18181b" }, children: feedback.name || t.anonymous })
640
+ ] }),
641
+ /* @__PURE__ */ jsx4("div", { style: { marginTop: "4px" }, children: new Date(feedback.createdAt).toLocaleString(
642
+ language === "de" ? "de-DE" : "en-US",
643
+ {
644
+ dateStyle: "medium",
645
+ timeStyle: "short"
646
+ }
647
+ ) })
648
+ ]
649
+ }
650
+ ),
651
+ /* @__PURE__ */ jsx4(
652
+ "div",
653
+ {
654
+ style: {
655
+ marginBottom: "16px",
656
+ lineHeight: "1.5",
657
+ color: "#18181b",
658
+ whiteSpace: "pre-wrap",
659
+ wordBreak: "break-word"
660
+ },
661
+ children: feedback.message
662
+ }
663
+ ),
664
+ /* @__PURE__ */ jsxs2("div", { style: { marginBottom: "16px" }, children: [
665
+ /* @__PURE__ */ jsx4(
666
+ "div",
667
+ {
668
+ style: {
669
+ fontSize: "12px",
670
+ color: "#71717a",
671
+ marginBottom: "8px",
672
+ fontWeight: "500"
673
+ },
674
+ children: t.reactions
675
+ }
676
+ ),
677
+ /* @__PURE__ */ jsx4("div", { style: { display: "flex", flexWrap: "wrap", gap: "8px" }, children: REACTIONS.map((reaction) => {
678
+ const summary = feedback.reactions?.find(
679
+ (r) => r.reaction === reaction
680
+ );
681
+ const count = summary?.count || 0;
682
+ const hasReacted = summary?.hasReacted || false;
683
+ return /* @__PURE__ */ jsxs2(
684
+ "button",
685
+ {
686
+ onClick: () => handleReaction(reaction),
687
+ disabled: reactingTo !== null,
688
+ style: {
689
+ display: "flex",
690
+ alignItems: "center",
691
+ gap: "4px",
692
+ padding: "6px 10px",
693
+ backgroundColor: hasReacted ? "#e0e7ff" : "#f4f4f5",
694
+ border: hasReacted ? "2px solid #6366f1" : "1px solid #e4e4e7",
695
+ borderRadius: "16px",
696
+ fontSize: "14px",
697
+ cursor: reactingTo ? "not-allowed" : "pointer",
698
+ opacity: reactingTo ? 0.6 : 1,
699
+ fontFamily: "system-ui, sans-serif",
700
+ transition: "all 0.15s ease"
701
+ },
702
+ onMouseEnter: (e) => {
703
+ if (!reactingTo) {
704
+ e.currentTarget.style.transform = "scale(1.05)";
705
+ }
706
+ },
707
+ onMouseLeave: (e) => {
708
+ e.currentTarget.style.transform = "scale(1)";
709
+ },
710
+ children: [
711
+ /* @__PURE__ */ jsx4("span", { children: reaction }),
712
+ count > 0 && /* @__PURE__ */ jsx4(
713
+ "span",
714
+ {
715
+ style: {
716
+ fontSize: "12px",
717
+ color: hasReacted ? "#6366f1" : "#71717a",
718
+ fontWeight: "600"
719
+ },
720
+ children: count
721
+ }
722
+ )
723
+ ]
724
+ },
725
+ reaction
726
+ );
727
+ }) })
728
+ ] }),
729
+ /* @__PURE__ */ jsx4(
730
+ "button",
731
+ {
732
+ onClick: handleDelete,
733
+ disabled: isDeleting,
734
+ style: {
735
+ width: "100%",
736
+ backgroundColor: "#ef4444",
737
+ color: "#fff",
738
+ border: "none",
739
+ borderRadius: "6px",
740
+ padding: "8px 12px",
741
+ fontSize: "13px",
742
+ fontWeight: "500",
743
+ cursor: isDeleting ? "not-allowed" : "pointer",
744
+ opacity: isDeleting ? 0.6 : 1,
745
+ fontFamily: "system-ui, sans-serif"
746
+ },
747
+ children: isDeleting ? "..." : t.deleteFeedback
748
+ }
749
+ )
750
+ ]
751
+ }
752
+ )
753
+ ] });
754
+ }
755
+
756
+ // src/components/FeedbackLauncher.tsx
757
+ import { Fragment as Fragment3, jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
758
+ function FeedbackLauncher({ projectSlug }) {
759
+ const [language, setLanguage] = useState4("en");
760
+ const [isActive, setIsActive] = useState4(false);
761
+ const [pins, setPins] = useState4([]);
762
+ const [fullFeedback, setFullFeedback] = useState4([]);
763
+ const [pendingPin, setPendingPin] = useState4(
764
+ null
765
+ );
766
+ const [selectedFeedbackId, setSelectedFeedbackId] = useState4(
767
+ null
768
+ );
769
+ const [isLoading, setIsLoading] = useState4(true);
770
+ const t = getTranslations(language);
771
+ useEffect3(() => {
772
+ async function fetchFeedback() {
773
+ try {
774
+ const sessionId = getOrCreateSessionId();
775
+ const params = new URLSearchParams({
776
+ projectSlug,
777
+ pageUrl: window.location.href,
778
+ sessionId
779
+ });
780
+ const res = await fetch(`${API_PATH}?${params}`);
781
+ if (res.ok) {
782
+ const data = await res.json();
783
+ setFullFeedback(data.feedback);
784
+ setPins(data.feedback.map((f) => ({ id: f.id, x: f.x, y: f.y })));
785
+ }
786
+ } catch (error) {
787
+ console.error("[fi-edback] Failed to fetch feedback:", error);
788
+ } finally {
789
+ setIsLoading(false);
790
+ }
791
+ }
792
+ fetchFeedback();
793
+ }, [projectSlug]);
794
+ function handleActivate() {
795
+ setIsActive(true);
796
+ setSelectedFeedbackId(null);
797
+ }
798
+ function handleDeactivate() {
799
+ setIsActive(false);
800
+ setPendingPin(null);
801
+ }
802
+ function handlePinPlaced(x, y) {
803
+ setIsActive(false);
804
+ setPendingPin({ x, y });
805
+ }
806
+ function handleFormSubmitted(feedback) {
807
+ setPins((prev) => [
808
+ ...prev,
809
+ { id: feedback.id, x: feedback.x, y: feedback.y }
810
+ ]);
811
+ setFullFeedback((prev) => [...prev, feedback]);
812
+ setPendingPin(null);
813
+ }
814
+ function handleFormCancelled() {
815
+ setPendingPin(null);
816
+ }
817
+ function handlePinClick(id) {
818
+ setSelectedFeedbackId(id);
819
+ }
820
+ function handlePopupClose() {
821
+ setSelectedFeedbackId(null);
822
+ }
823
+ function handleFeedbackDeleted(id) {
824
+ setPins((prev) => prev.filter((p) => p.id !== id));
825
+ setFullFeedback((prev) => prev.filter((f) => f.id !== id));
826
+ setSelectedFeedbackId(null);
827
+ }
828
+ function handleReactionToggled(feedbackId, reaction, added) {
829
+ setFullFeedback(
830
+ (prev) => prev.map((f) => {
831
+ if (f.id !== feedbackId) return f;
832
+ const reactions = f.reactions || [];
833
+ const existingIndex = reactions.findIndex(
834
+ (r) => r.reaction === reaction
835
+ );
836
+ if (existingIndex >= 0) {
837
+ const updated = [...reactions];
838
+ if (added) {
839
+ updated[existingIndex] = {
840
+ ...updated[existingIndex],
841
+ count: updated[existingIndex].count + 1,
842
+ hasReacted: true
843
+ };
844
+ } else {
845
+ const newCount = updated[existingIndex].count - 1;
846
+ if (newCount === 0) {
847
+ updated.splice(existingIndex, 1);
848
+ } else {
849
+ updated[existingIndex] = {
850
+ ...updated[existingIndex],
851
+ count: newCount,
852
+ hasReacted: false
853
+ };
854
+ }
855
+ }
856
+ return { ...f, reactions: updated };
857
+ } else {
858
+ return {
859
+ ...f,
860
+ reactions: [...reactions, { reaction, count: 1, hasReacted: true }]
861
+ };
862
+ }
863
+ })
864
+ );
865
+ }
866
+ async function handlePinMoved(id, x, y) {
867
+ setPins((prev) => prev.map((p) => p.id === id ? { ...p, x, y } : p));
868
+ setFullFeedback(
869
+ (prev) => prev.map((f) => f.id === id ? { ...f, x, y } : f)
870
+ );
871
+ try {
872
+ const res = await fetch(API_PATH, {
873
+ method: "PATCH",
874
+ headers: { "Content-Type": "application/json" },
875
+ body: JSON.stringify({ feedbackId: id, x, y })
876
+ });
877
+ if (!res.ok) {
878
+ console.error("[fi-edback] Failed to update pin position");
879
+ }
880
+ } catch (error) {
881
+ console.error("[fi-edback] Failed to update pin position:", error);
882
+ }
883
+ }
884
+ function toggleLanguage() {
885
+ setLanguage((prev) => {
886
+ if (prev === "en") return "de";
887
+ if (prev === "de") return "ga";
888
+ return "en";
889
+ });
890
+ }
891
+ const selectedFeedback = fullFeedback.find(
892
+ (f) => f.id === selectedFeedbackId
893
+ );
894
+ const isMobile = typeof window !== "undefined" && window.innerWidth < 640;
895
+ return /* @__PURE__ */ jsxs3(Fragment3, { children: [
896
+ !isActive && !pendingPin && /* @__PURE__ */ jsx5(
897
+ "div",
898
+ {
899
+ style: {
900
+ position: "fixed",
901
+ bottom: isMobile ? "90px" : "24px",
902
+ right: isMobile ? "12px" : "140px",
903
+ zIndex: 9998,
904
+ display: "flex",
905
+ gap: "4px",
906
+ backgroundColor: "#fff",
907
+ border: "1px solid #e4e4e7",
908
+ borderRadius: "6px",
909
+ padding: "4px",
910
+ boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
911
+ fontFamily: "system-ui, sans-serif"
912
+ },
913
+ children: ["en", "de", "ga"].map((lang) => /* @__PURE__ */ jsx5(
914
+ "button",
915
+ {
916
+ onClick: () => setLanguage(lang),
917
+ "aria-label": `Switch to ${lang.toUpperCase()}`,
918
+ style: {
919
+ padding: isMobile ? "6px 10px" : "4px 8px",
920
+ fontSize: isMobile ? "13px" : "12px",
921
+ fontWeight: language === lang ? "700" : "500",
922
+ color: language === lang ? "#18181b" : "#71717a",
923
+ backgroundColor: language === lang ? "#f4f4f5" : "transparent",
924
+ border: "none",
925
+ borderRadius: "4px",
926
+ cursor: "pointer",
927
+ transition: "all 0.15s ease"
928
+ },
929
+ children: lang.toUpperCase()
930
+ },
931
+ lang
932
+ ))
933
+ }
934
+ ),
935
+ !isActive && !pendingPin && /* @__PURE__ */ jsxs3(
936
+ "div",
937
+ {
938
+ style: {
939
+ position: "fixed",
940
+ bottom: isMobile ? "12px" : "24px",
941
+ right: isMobile ? "12px" : "24px",
942
+ zIndex: 9998,
943
+ display: "flex",
944
+ flexDirection: "column",
945
+ alignItems: "flex-end",
946
+ gap: "8px"
947
+ },
948
+ children: [
949
+ /* @__PURE__ */ jsx5(
950
+ "div",
951
+ {
952
+ style: {
953
+ fontSize: isMobile ? "11px" : "12px",
954
+ color: "#71717a",
955
+ fontFamily: "system-ui, sans-serif",
956
+ textAlign: "right",
957
+ maxWidth: isMobile ? "160px" : "200px",
958
+ lineHeight: "1.4"
959
+ },
960
+ children: t.instructionText
961
+ }
962
+ ),
963
+ /* @__PURE__ */ jsx5(
964
+ "button",
965
+ {
966
+ onClick: handleActivate,
967
+ "aria-label": "Open feedback tool",
968
+ style: {
969
+ backgroundColor: "#18181b",
970
+ color: "#fff",
971
+ border: "none",
972
+ borderRadius: "9999px",
973
+ padding: isMobile ? "12px 20px" : "10px 18px",
974
+ fontSize: isMobile ? "15px" : "14px",
975
+ fontWeight: "500",
976
+ cursor: "pointer",
977
+ boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
978
+ fontFamily: "system-ui, sans-serif",
979
+ letterSpacing: "0.01em"
980
+ },
981
+ children: t.feedbackButton
982
+ }
983
+ )
984
+ ]
985
+ }
986
+ ),
987
+ isActive && /* @__PURE__ */ jsx5(
988
+ "button",
989
+ {
990
+ onClick: handleDeactivate,
991
+ "aria-label": "Cancel feedback",
992
+ style: {
993
+ position: "fixed",
994
+ bottom: isMobile ? "12px" : "24px",
995
+ right: isMobile ? "12px" : "24px",
996
+ zIndex: 9999,
997
+ backgroundColor: "#ef4444",
998
+ color: "#fff",
999
+ border: "none",
1000
+ borderRadius: "9999px",
1001
+ padding: isMobile ? "12px 20px" : "10px 18px",
1002
+ fontSize: isMobile ? "15px" : "14px",
1003
+ fontWeight: "500",
1004
+ cursor: "pointer",
1005
+ boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
1006
+ fontFamily: "system-ui, sans-serif"
1007
+ },
1008
+ children: t.cancelButton
1009
+ }
1010
+ ),
1011
+ isActive && /* @__PURE__ */ jsx5(FeedbackOverlay, { language, onPinPlaced: handlePinPlaced }),
1012
+ /* @__PURE__ */ jsx5(
1013
+ FeedbackPinLayer,
1014
+ {
1015
+ pins,
1016
+ onPinClick: handlePinClick,
1017
+ onPinMoved: handlePinMoved,
1018
+ title: t.feedbackSubmitted
1019
+ }
1020
+ ),
1021
+ pendingPin && /* @__PURE__ */ jsx5(
1022
+ FeedbackForm,
1023
+ {
1024
+ x: pendingPin.x,
1025
+ y: pendingPin.y,
1026
+ projectSlug,
1027
+ apiPath: API_PATH,
1028
+ language,
1029
+ onSubmitted: handleFormSubmitted,
1030
+ onCancelled: handleFormCancelled
1031
+ }
1032
+ ),
1033
+ selectedFeedback && /* @__PURE__ */ jsx5(
1034
+ FeedbackPopup,
1035
+ {
1036
+ feedback: selectedFeedback,
1037
+ apiPath: API_PATH,
1038
+ language,
1039
+ onDeleted: handleFeedbackDeleted,
1040
+ onReactionToggled: handleReactionToggled,
1041
+ onClose: handlePopupClose
1042
+ }
1043
+ )
1044
+ ] });
1045
+ }
1046
+
1047
+ // src/components/FeedbackRoot.tsx
1048
+ import { jsx as jsx6 } from "react/jsx-runtime";
1049
+ function FeedbackRoot() {
1050
+ const [mounted, setMounted] = useState5(false);
1051
+ useEffect4(() => {
1052
+ setMounted(true);
1053
+ }, []);
1054
+ if (!mounted) return null;
1055
+ if (process.env.NEXT_PUBLIC_ENABLE_FEEDBACK !== "true") return null;
1056
+ const projectSlug = process.env.NEXT_PUBLIC_FEEDBACK_PROJECT_SLUG;
1057
+ if (!projectSlug) return null;
1058
+ return /* @__PURE__ */ jsx6(FeedbackLauncher, { projectSlug });
1059
+ }
1060
+ export {
1061
+ FeedbackRoot
1062
+ };