Files
at-mintel/packages/next-feedback/src/components/FeedbackOverlay.tsx

622 lines
22 KiB
TypeScript

"use client";
import React, { useState, useEffect, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { MessageSquare, X, Check, Plus, List, Send, User } from "lucide-react";
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import html2canvas from "html2canvas";
function cn(...inputs: any[]) {
return twMerge(clsx(inputs));
}
interface FeedbackComment {
id: string;
userName: string;
text: string;
createdAt: string;
}
interface Feedback {
id: string;
x: number;
y: number;
selector: string;
text: string;
type: "design" | "content";
elementRect: DOMRect | null;
userName: string;
comments: FeedbackComment[];
}
export function FeedbackOverlay() {
const [isActive, setIsActive] = useState(false);
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(
null,
);
const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(
null,
);
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
const [currentComment, setCurrentComment] = useState("");
const [currentType, setCurrentType] = useState<"design" | "content">(
"design",
);
const [showList, setShowList] = useState(false);
const [currentUser, setCurrentUser] = useState<{
identity: string;
isDevFallback?: boolean;
} | null>(null);
const [newCommentTexts, setNewCommentTexts] = useState<{
[feedbackId: string]: string;
}>({});
const [isCapturing, setIsCapturing] = useState(false);
// 1. Fetch Identity and Existing Feedback
useEffect(() => {
const checkAuth = async () => {
try {
const urlParams = new URLSearchParams(window.location.search);
const bypass = urlParams.get("gatekeeper_bypass");
const apiUrl = bypass
? `/api/whoami?gatekeeper_bypass=${bypass}`
: "/api/whoami";
const res = await fetch(apiUrl);
if (res.ok) {
const data = await res.json();
setCurrentUser(data);
} else {
setCurrentUser({ identity: "Guest" });
}
} catch (_e) {
setCurrentUser({ identity: "Guest" });
}
};
const fetchFeedback = async () => {
try {
const res = await fetch("/api/feedback");
if (res.ok) {
const data = await res.json();
const mapped = data.map((fb: any) => ({
id: fb.id,
x: fb.x,
y: fb.y,
selector: fb.selector,
text: fb.text,
type: fb.type,
userName: fb.user_name,
comments: (fb.comments || []).map((c: any) => ({
id: c.id,
userName: c.user_name,
text: c.text,
createdAt: c.date_created,
})),
}));
setFeedbacks(mapped);
}
} catch (e) {
console.error("Failed to fetch feedbacks", e);
}
};
checkAuth();
fetchFeedback();
}, []);
const getSelector = (el: HTMLElement): string => {
if (el.id) return `#${el.id}`;
const path = [];
let curr: HTMLElement | null = el;
while (curr && curr.parentElement) {
const index = Array.from(curr.parentElement.children).indexOf(curr) + 1;
path.unshift(`${curr.tagName.toLowerCase()}:nth-child(${index})`);
curr = curr.parentElement;
}
return path.join(" > ");
};
useEffect(() => {
if (!isActive) {
setHoveredElement(null);
return;
}
const handleMouseMove = (e: MouseEvent) => {
if (selectedElement) return;
const target = e.target as HTMLElement;
if (target.closest(".feedback-ui-ignore")) {
setHoveredElement(null);
return;
}
setHoveredElement(target);
};
const handleClick = (e: MouseEvent) => {
if (selectedElement) return;
const target = e.target as HTMLElement;
if (target.closest(".feedback-ui-ignore")) return;
e.preventDefault();
e.stopPropagation();
setSelectedElement(target);
setHoveredElement(null);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("click", handleClick, true);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("click", handleClick, true);
};
}, [isActive, selectedElement]);
const captureScreenshot = async (): Promise<string | null> => {
try {
setIsCapturing(true);
const canvas = await html2canvas(document.body, {
useCORS: true,
scale: 1,
ignoreElements: (el) => el.classList.contains("feedback-ui-ignore"),
});
return canvas.toDataURL("image/png");
} catch (e) {
console.error("Screenshot failed", e);
return null;
} finally {
setIsCapturing(false);
}
};
const saveFeedback = async () => {
if (!selectedElement || !currentComment) return;
const rect = selectedElement.getBoundingClientRect();
const screenshot = await captureScreenshot();
const feedbackData = {
url: window.location.href,
x: rect.left + rect.width / 2 + window.scrollX,
y: rect.top + rect.height / 2 + window.scrollY,
selector: getSelector(selectedElement),
text: currentComment,
type: currentType,
userName: currentUser?.identity || "Unknown",
userIdentity: currentUser?.identity === "Admin" ? "admin" : "user",
screenshot_base64: screenshot,
};
try {
const res = await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(feedbackData),
});
if (res.ok) {
const savedFb = await res.json();
const newFeedback: Feedback = {
id: savedFb.id,
x: savedFb.x,
y: savedFb.y,
selector: savedFb.selector,
text: savedFb.text,
type: savedFb.type,
elementRect: rect,
userName: savedFb.user_name,
comments: [],
};
setFeedbacks([...feedbacks, newFeedback]);
setSelectedElement(null);
setCurrentComment("");
}
} catch (e) {
console.error("Failed to save feedback", e);
}
};
const addReply = async (feedbackId: string) => {
const text = newCommentTexts[feedbackId];
if (!text) return;
if (!currentUser?.identity || currentUser.identity === "Guest") {
alert("Nur angemeldete Benutzer können antworten.");
return;
}
try {
const res = await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "reply",
feedbackId,
userName: currentUser?.identity || "Unknown",
text,
}),
});
if (res.ok) {
const savedReply = await res.json();
setFeedbacks(
feedbacks.map((f) => {
if (f.id === feedbackId) {
return {
...f,
comments: [
...f.comments,
{
id: savedReply.id,
userName: savedReply.user_name,
text: savedReply.text,
createdAt: savedReply.date_created,
},
],
};
}
return f;
}),
);
setNewCommentTexts({ ...newCommentTexts, [feedbackId]: "" });
}
} catch (e) {
console.error("Failed to save reply", e);
}
};
const hoveredRect = useMemo(
() => hoveredElement?.getBoundingClientRect(),
[hoveredElement],
);
const selectedRect = useMemo(
() => selectedElement?.getBoundingClientRect(),
[selectedElement],
);
return (
<div className="feedback-ui-ignore">
{/* 1. Global Toolbar */}
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[9999]">
<div className="bg-black/80 backdrop-blur-xl border border-white/10 p-2 rounded-2xl shadow-2xl flex items-center gap-2">
<div
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-xl transition-all",
currentUser?.isDevFallback
? "bg-orange-500/20 text-orange-400"
: "bg-white/5 text-white/40",
)}
>
<User size={14} />
<span className="text-[10px] font-bold uppercase tracking-wider">
{currentUser?.identity || "Loading..."}
{currentUser?.isDevFallback && " (Local Dev Bypass)"}
</span>
</div>
<div className="w-px h-6 bg-white/10 mx-1" />
<button
onClick={() => {
if (!currentUser?.identity || currentUser.identity === "Guest") {
alert("Bitte logge dich ein, um Feedback zu geben.");
return;
}
setIsActive(!isActive);
}}
disabled={
!currentUser?.identity || currentUser.identity === "Guest"
}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-xl transition-all font-medium disabled:opacity-30 disabled:cursor-not-allowed",
isActive
? "bg-blue-500 text-white shadow-lg shadow-blue-500/20"
: "text-white/70 hover:text-white hover:bg-white/10",
)}
>
{isActive ? <X size={18} /> : <MessageSquare size={18} />}
{isActive ? "Modus beenden" : "Feedback geben"}
</button>
<div className="w-px h-6 bg-white/10 mx-1" />
<button
onClick={() => setShowList(!showList)}
className="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-xl relative"
>
<List size={20} />
{feedbacks.length > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-blue-500 text-[10px] flex items-center justify-center rounded-full text-white font-bold border-2 border-[#1a1a1a]">
{feedbacks.length}
</span>
)}
</button>
</div>
</div>
{/* 2. Feedback Markers & Highlights */}
<AnimatePresence>
{isActive && (
<>
{/* Fixed Overlay for real-time highlights */}
<div className="fixed inset-0 pointer-events-none z-[9998]">
{hoveredRect && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute border-2 border-blue-400 bg-blue-400/10 rounded-sm transition-all duration-200"
style={{
top: hoveredRect.top,
left: hoveredRect.left,
width: hoveredRect.width,
height: hoveredRect.height,
}}
/>
)}
{selectedRect && (
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="absolute border-2 border-yellow-400 bg-yellow-400/20 rounded-sm"
style={{
top: selectedRect.top,
left: selectedRect.left,
width: selectedRect.width,
height: selectedRect.height,
}}
/>
)}
</div>
{/* Absolute Overlay for persistent pins */}
<div className="absolute inset-0 pointer-events-none z-[9997]">
{feedbacks.map((fb) => (
<div
key={fb.id}
className="absolute"
style={{ top: fb.y, left: fb.x }}
>
<button
onClick={() => {
setShowList(true);
}}
className={cn(
"w-6 h-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white cursor-pointer pointer-events-auto transition-transform hover:scale-110",
fb.type === "design" ? "bg-purple-500" : "bg-orange-500",
)}
>
<Plus size={14} className="rotate-45" />
</button>
</div>
))}
</div>
</>
)}
</AnimatePresence>
{/* 3. Feedback Modal */}
<AnimatePresence>
{selectedElement && (
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-black/40 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
className="bg-[#1c1c1e] border border-white/10 rounded-3xl p-6 w-[400px] shadow-2xl"
>
<div className="flex items-center justify-between mb-6">
<h3 className="text-white font-bold text-lg">Feedback geben</h3>
<button
onClick={() => setSelectedElement(null)}
className="text-white/40 hover:text-white"
>
<X size={20} />
</button>
</div>
<div className="flex gap-2 mb-6">
{(["design", "content"] as const).map((type) => (
<button
key={type}
onClick={() => setCurrentType(type)}
className={cn(
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all capitalize",
currentType === type
? "bg-white text-black shadow-lg"
: "bg-white/5 text-white/40 hover:bg-white/10",
)}
>
{type === "design" ? "🎨 Design" : "✍️ Content"}
</button>
))}
</div>
<textarea
autoFocus
value={currentComment}
onChange={(e) => setCurrentComment(e.target.value)}
placeholder="Was möchtest du anmerken?"
className="w-full h-32 bg-white/5 border border-white/5 rounded-2xl p-4 text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors resize-none mb-6"
/>
<button
disabled={!currentComment || isCapturing}
onClick={saveFeedback}
className="w-full bg-blue-500 hover:bg-blue-400 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-4 rounded-2xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-blue-500/20"
>
{isCapturing ? (
"Erfasse Screenshot..."
) : (
<>
<Check size={20} />
Feedback speichern
</>
)}
</button>
</motion.div>
</div>
)}
</AnimatePresence>
{/* 4. Feedback List Sidebar */}
<AnimatePresence>
{showList && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowList(false)}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[10001]"
/>
<motion.div
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className="fixed top-0 right-0 h-full w-[400px] bg-[#1c1c1e] border-l border-white/10 z-[10002] shadow-2xl flex flex-col"
>
<div className="p-8 border-b border-white/10 flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-white mb-1">
Feedback
</h2>
<p className="text-white/40 text-sm">
{feedbacks.length} Anmerkungen live
</p>
</div>
<button
onClick={() => setShowList(false)}
className="p-2 text-white/40 hover:text-white bg-white/5 rounded-xl transition-colors"
>
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{feedbacks.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center px-8 opacity-40">
<MessageSquare size={48} className="mb-4" />
<p>
Noch kein Feedback vorhanden. Aktiviere den Modus um
Stellen auf der Seite zu markieren.
</p>
</div>
) : (
feedbacks.map((fb) => (
<div
key={fb.id}
className="bg-white/5 border border-white/5 rounded-3xl overflow-hidden hover:border-white/20 transition-all flex flex-col"
>
<div className="p-5 border-b border-white/5 bg-white/[0.02]">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center text-blue-400">
<User size={14} />
</div>
<div>
<p className="text-white text-[11px] font-bold uppercase tracking-wider">
{fb.userName}
</p>
<p className="text-white/20 text-[9px] uppercase tracking-widest">
Original Poster
</p>
</div>
</div>
<span
className={cn(
"px-3 py-1 rounded-full text-[9px] font-bold uppercase tracking-wider",
fb.type === "design"
? "bg-purple-500/20 text-purple-400"
: "bg-orange-500/20 text-orange-400",
)}
>
{fb.type}
</span>
</div>
<p className="text-white/80 whitespace-pre-wrap text-sm leading-relaxed">
{fb.text}
</p>
<div className="mt-3 flex items-center gap-2">
<div className="w-1 h-1 bg-white/10 rounded-full" />
<span className="text-white/20 text-[9px] truncate tracking-wider italic">
{fb.selector}
</span>
</div>
</div>
{fb.comments.length > 0 && (
<div className="bg-black/20 p-5 space-y-4">
{fb.comments.map((comment) => (
<div key={comment.id} className="flex gap-3">
<div className="w-6 h-6 rounded-full bg-white/10 flex items-center justify-center text-white/40 shrink-0">
<User size={10} />
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="text-[10px] font-bold text-white/60 uppercase">
{comment.userName}
</p>
<p className="text-[10px] text-white/20">
{new Date(
comment.createdAt,
).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
<p className="text-white/80 text-xs leading-snug">
{comment.text}
</p>
</div>
</div>
))}
</div>
)}
<div className="p-4 bg-white/[0.01] mt-auto border-t border-white/5">
<div className="relative">
<input
type="text"
value={newCommentTexts[fb.id] || ""}
onChange={(e) =>
setNewCommentTexts({
...newCommentTexts,
[fb.id]: e.target.value,
})
}
placeholder="Antworten..."
className="w-full bg-black/40 border border-white/5 rounded-2xl py-3 pl-4 pr-12 text-xs text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors"
onKeyDown={(e) => {
if (e.key === "Enter") addReply(fb.id);
}}
/>
<button
onClick={() => addReply(fb.id)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-blue-500 hover:text-blue-400 transition-colors disabled:opacity-30"
disabled={!newCommentTexts[fb.id]}
>
<Send size={14} />
</button>
</div>
</div>
</div>
))
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}