1
0
mirror of https://github.com/alexandrev/xslt-lab.git synced 2026-09-13 08:43:16 +00:00

UX: replace feedback links with inline n8n webhook form

- Simple form with type selector (idea/bug/other) + textarea
- POSTs to VITE_FEEDBACK_WEBHOOK_URL (runtime env, set in Helm/entrypoint)
- States: idle → sending → success/error
- Removes email/GitHub links — no context switch for user
- Adds VITE_FEEDBACK_WEBHOOK_URL to entrypoint.sh env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-04-17 16:39:02 +02:00
parent 9394853338
commit 79e7d50ffc
3 changed files with 169 additions and 107 deletions
+3 -1
View File
@@ -13,6 +13,7 @@ ADSENSE_SLOT_ESC=$(escape_js_string "${VITE_ADSENSE_SLOT}")
FIREBASE_CONFIG_ESC=$(escape_js_string "${VITE_FIREBASE_CONFIG}")
GA_ID_ESC=$(escape_js_string "${VITE_GA_ID}")
CLARITY_ID_ESC=$(escape_js_string "${VITE_CLARITY_ID}")
FEEDBACK_WEBHOOK_ESC=$(escape_js_string "${VITE_FEEDBACK_WEBHOOK_URL}")
# Write runtime environment variables for the frontend
cat <<EOF >/usr/share/nginx/html/env.js
@@ -23,7 +24,8 @@ window.env = {
VITE_ADSENSE_SLOT: "${ADSENSE_SLOT_ESC}",
VITE_FIREBASE_CONFIG: "${FIREBASE_CONFIG_ESC}",
VITE_GA_ID: "${GA_ID_ESC}",
VITE_CLARITY_ID: "${CLARITY_ID_ESC}"
VITE_CLARITY_ID: "${CLARITY_ID_ESC}",
VITE_FEEDBACK_WEBHOOK_URL: "${FEEDBACK_WEBHOOK_ESC}"
};
EOF
+106 -106
View File
@@ -7,13 +7,14 @@ import {
useState,
} from "react";
const FEEDBACK_MAIL = "xsltplayground@alexandre-vazquez.cloud";
const STORAGE_KEY = "feedbackPos";
const MIN_MARGIN = 10;
const DEFAULT_MARGIN = 24;
const FALLBACK_WIDTH = 220;
const ISSUE_URL =
"https://github.com/alexandrev/xslt-lab/issues/new?template=idea.yml";
function getWebhookUrl() {
return (window.env && window.env.VITE_FEEDBACK_WEBHOOK_URL) || "";
}
export default function FeedbackWidget() {
const [collapsed, setCollapsed] = useState(() => {
@@ -36,21 +37,12 @@ export default function FeedbackWidget() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (
typeof parsed?.x === "number" &&
typeof parsed?.y === "number"
) {
if (typeof parsed?.x === "number" && typeof parsed?.y === "number") {
return parsed;
}
if (
typeof parsed?.right === "number" &&
typeof parsed?.top === "number"
) {
if (typeof parsed?.right === "number" && typeof parsed?.top === "number") {
return {
x: Math.max(
MIN_MARGIN,
window.innerWidth - parsed.right - FALLBACK_WIDTH,
),
x: Math.max(MIN_MARGIN, window.innerWidth - parsed.right - FALLBACK_WIDTH),
y: Math.max(MIN_MARGIN, parsed.top),
};
}
@@ -61,18 +53,21 @@ export default function FeedbackWidget() {
const [dragging, setDragging] = useState(false);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [opensUp, setOpensUp] = useState(false);
const resolvedPosition = useMemo(
() => {
if (position) return position;
const fallbackHeight =
viewportHeight || (typeof window !== "undefined" ? window.innerHeight : 0);
return {
x: DEFAULT_MARGIN,
y: Math.max(DEFAULT_MARGIN, fallbackHeight - 220),
};
},
[position, viewportHeight],
);
// Form state
const [message, setMessage] = useState("");
const [type, setType] = useState("idea");
const [status, setStatus] = useState("idle"); // idle | sending | success | error
const resolvedPosition = useMemo(() => {
if (position) return position;
const fallbackHeight =
viewportHeight || (typeof window !== "undefined" ? window.innerHeight : 0);
return {
x: DEFAULT_MARGIN,
y: Math.max(DEFAULT_MARGIN, fallbackHeight - 260),
};
}, [position, viewportHeight]);
useEffect(() => {
if (typeof window === "undefined") return;
@@ -90,48 +85,26 @@ export default function FeedbackWidget() {
} catch {}
}, [collapsed]);
const clampWithinViewport = useCallback(
(pos) => {
if (
!pos ||
typeof window === "undefined" ||
!widgetRef.current
) {
return pos;
}
const rect = widgetRef.current.getBoundingClientRect();
const maxX = Math.max(
MIN_MARGIN,
window.innerWidth - rect.width - MIN_MARGIN,
);
const maxY = Math.max(
MIN_MARGIN,
window.innerHeight - rect.height - MIN_MARGIN,
);
return {
x: Math.min(Math.max(MIN_MARGIN, pos.x), maxX),
y: Math.min(Math.max(MIN_MARGIN, pos.y), maxY),
};
},
[],
);
const clampWithinViewport = useCallback((pos) => {
if (!pos || typeof window === "undefined" || !widgetRef.current) return pos;
const rect = widgetRef.current.getBoundingClientRect();
const maxX = Math.max(MIN_MARGIN, window.innerWidth - rect.width - MIN_MARGIN);
const maxY = Math.max(MIN_MARGIN, window.innerHeight - rect.height - MIN_MARGIN);
return {
x: Math.min(Math.max(MIN_MARGIN, pos.x), maxX),
y: Math.min(Math.max(MIN_MARGIN, pos.y), maxY),
};
}, []);
useLayoutEffect(() => {
if (!position) return;
const next = clampWithinViewport(position);
if (!next) return;
if (next.x !== position.x || next.y !== position.y) {
setPosition(next);
}
if (next.x !== position.x || next.y !== position.y) setPosition(next);
}, [position, clampWithinViewport, viewportHeight, viewportWidth, collapsed]);
useLayoutEffect(() => {
if (
position ||
typeof window === "undefined" ||
typeof document === "undefined"
)
return;
if (position || typeof window === "undefined" || typeof document === "undefined") return;
const widget = widgetRef.current;
if (!widget) return;
const footer = document.querySelector(".footer");
@@ -139,14 +112,12 @@ export default function FeedbackWidget() {
const viewport = window.innerHeight;
const widgetRect = widget.getBoundingClientRect();
let y = Math.max(margin, viewport - widgetRect.height - margin);
if (footer) {
const footerRect = footer.getBoundingClientRect();
if (footerRect.top < viewport) {
y = Math.max(margin, footerRect.top - widgetRect.height - margin);
}
}
setPosition({ x: margin, y });
}, [position]);
@@ -157,28 +128,11 @@ export default function FeedbackWidget() {
dragMovedRef.current = true;
const widget = widgetRef.current;
const widgetRect = widget?.getBoundingClientRect();
const maxX = Math.max(
MIN_MARGIN,
window.innerWidth -
(widgetRect?.width ?? 0) -
MIN_MARGIN,
);
const maxY = Math.max(
MIN_MARGIN,
window.innerHeight -
(widgetRect?.height ?? 0) -
MIN_MARGIN,
);
const maxX = Math.max(MIN_MARGIN, window.innerWidth - (widgetRect?.width ?? 0) - MIN_MARGIN);
const maxY = Math.max(MIN_MARGIN, window.innerHeight - (widgetRect?.height ?? 0) - MIN_MARGIN);
setPosition({
x: Math.min(
Math.max(MIN_MARGIN, event.clientX - offset.x),
maxX,
),
y: Math.min(
Math.max(MIN_MARGIN, event.clientY - offset.y),
maxY,
),
x: Math.min(Math.max(MIN_MARGIN, event.clientX - offset.x), maxX),
y: Math.min(Math.max(MIN_MARGIN, event.clientY - offset.y), maxY),
});
};
const stop = () => setDragging(false);
@@ -209,22 +163,38 @@ export default function FeedbackWidget() {
event.preventDefault();
dragMovedRef.current = false;
const rect = event.currentTarget.getBoundingClientRect();
setOffset({
x: event.clientX - rect.left,
y: event.clientY - rect.top,
});
setOffset({ x: event.clientX - rect.left, y: event.clientY - rect.top });
setDragging(true);
};
const handleHeaderClick = () => {
if (!dragMovedRef.current) {
setCollapsed((prev) => !prev);
}
if (!dragMovedRef.current) setCollapsed((prev) => !prev);
};
const mailLink = `mailto:${FEEDBACK_MAIL}?subject=${encodeURIComponent(
"xsltplayground feedback",
)}`;
const handleSubmit = async (e) => {
e.preventDefault();
if (!message.trim()) return;
const webhookUrl = getWebhookUrl();
if (!webhookUrl) return;
setStatus("sending");
try {
await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type,
message: message.trim(),
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
}),
});
setStatus("success");
setMessage("");
} catch {
setStatus("error");
}
};
return (
<div
@@ -243,18 +213,48 @@ export default function FeedbackWidget() {
</div>
{!collapsed && (
<div className="feedback-body">
<p>Have an idea or found a glitch? I would love to hear from you.</p>
<a className="feedback-link" href={mailLink}>
Send feedback
</a>
<a
className="feedback-link"
href={ISSUE_URL}
target="_blank"
rel="noopener noreferrer"
>
🐞 Report issue on GitHub
</a>
{status === "success" ? (
<div className="feedback-success">
<p>Thanks! Your feedback has been sent 🙏</p>
<button
type="button"
className="feedback-submit"
onClick={() => setStatus("idle")}
>
Send another
</button>
</div>
) : (
<form onSubmit={handleSubmit}>
<select
className="feedback-type"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="idea">💡 Idea</option>
<option value="bug">🐞 Bug</option>
<option value="other">💬 Other</option>
</select>
<textarea
className="feedback-textarea"
placeholder="What's on your mind?"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={3}
maxLength={1000}
/>
{status === "error" && (
<p className="feedback-error">Failed to send, try again.</p>
)}
<button
type="submit"
className="feedback-submit"
disabled={status === "sending" || !message.trim() || !getWebhookUrl()}
>
{status === "sending" ? "Sending…" : "Send feedback"}
</button>
</form>
)}
</div>
)}
</div>
+60
View File
@@ -1108,6 +1108,66 @@ a:focus-visible {
background: #ffe189;
}
.feedback-type {
width: 100%;
padding: 0.35rem 0.5rem;
border-radius: 6px;
border: 1px solid #c4d4ff;
background: #fff;
font-size: 0.85rem;
color: #21426c;
}
.feedback-textarea {
width: 100%;
padding: 0.4rem 0.5rem;
border-radius: 6px;
border: 1px solid #c4d4ff;
font-size: 0.85rem;
resize: vertical;
font-family: inherit;
color: #21426c;
box-sizing: border-box;
}
.feedback-textarea:focus,
.feedback-type:focus {
outline: none;
border-color: #7a9fff;
}
.feedback-submit {
width: 100%;
padding: 0.4rem;
border-radius: 6px;
border: none;
background: #21426c;
color: #fff;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
}
.feedback-submit:hover:not(:disabled) {
background: #2d5a96;
}
.feedback-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.feedback-error {
margin: 0;
font-size: 0.8rem;
color: #c0392b;
}
.feedback-success p {
text-align: center;
font-size: 0.85rem;
}
:root[data-theme="dark"] {
color-scheme: dark;
}