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:
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user