Skip to content

Commit e8eeb1d

Browse files
Monil-KTXktx-krupa
andauthored
Add feedback widget and modal for user feedback submission (#193)
* Add feedback widget and modal for user feedback submission - Introduced a feedback button in the footer that opens a modal for users to submit feedback. - Created a feedback modal with tabs for different feedback types (Issue, Idea, Other). - Implemented JavaScript functionality to handle modal opening, closing, and form submission. - Added SVG icon for the feedback button. - Styled the feedback modal and button using Tailwind CSS classes. - Included error handling for feedback submission failures. - Ensured responsive design for the feedback modal across different screen sizes. * feat: implement newsletter subscription with reCAPTCHA validation * feat: add segment related code for the send data to swisspipe --------- Co-authored-by: krupa <[email protected]>
1 parent a90d4d1 commit e8eeb1d

File tree

7 files changed

+1153
-71
lines changed

7 files changed

+1153
-71
lines changed

docs/assets/feedback.svg

Lines changed: 3 additions & 0 deletions
Loading

docs/js/feedback.js

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// feedback.js
2+
document.addEventListener("DOMContentLoaded", () => {
3+
const feedbackButton = document.querySelector("#feedbackButton");
4+
const modal = document.querySelector("#feedbackModal");
5+
6+
if (!feedbackButton || !modal) {
7+
return;
8+
}
9+
10+
const form = modal.querySelector("form");
11+
const successView = modal.querySelector(".success-view");
12+
const formView = modal.querySelector(".form-view");
13+
const errorView = modal.querySelector(".error-view");
14+
const tabs = modal.querySelectorAll(".feedback-tab");
15+
let lastActiveElement = null;
16+
17+
// Ensure the form exists before touching it
18+
if (!form) {
19+
return;
20+
}
21+
22+
// ensure there's an input[name=type] for the form (hidden) so the submit code can read it
23+
let typeInput = form.querySelector("input[name=type]");
24+
if (!typeInput) {
25+
typeInput = document.createElement("input");
26+
typeInput.type = "hidden";
27+
typeInput.name = "type";
28+
typeInput.value = "Issue";
29+
form.appendChild(typeInput);
30+
}
31+
32+
function openModal() {
33+
// store previous active element so we can restore focus when modal closes
34+
lastActiveElement = document.activeElement;
35+
modal.classList.remove("tw-hidden");
36+
calculatePosition();
37+
// focus the textarea for immediate typing if present
38+
const ta = modal.querySelector("textarea");
39+
if (ta) ta.focus();
40+
}
41+
42+
function closeModal() {
43+
modal.classList.add("tw-hidden");
44+
form.reset();
45+
errorView.classList.add("tw-hidden");
46+
successView.classList.add("tw-hidden");
47+
// remove layout class when hidden to avoid display conflicts
48+
successView.classList.remove("tw-flex");
49+
// clear any inline positioning set during calculatePosition
50+
try {
51+
modal.style.top = "";
52+
modal.style.bottom = "";
53+
} catch (e) {
54+
/* ignore */
55+
}
56+
formView.classList.remove("tw-hidden");
57+
// restore focus to previously active element
58+
try {
59+
if (lastActiveElement && typeof lastActiveElement.focus === "function") {
60+
lastActiveElement.focus();
61+
}
62+
} catch (e) {
63+
/* ignore */
64+
}
65+
}
66+
67+
function calculatePosition() {
68+
// class-based positioning like the Vue component: toggle top-full / bottom-full
69+
try {
70+
const btnRect = feedbackButton.getBoundingClientRect();
71+
const screenHeight = window.innerHeight;
72+
const buttonCenter = btnRect.top + btnRect.height / 2;
73+
const placeAbove = buttonCenter > screenHeight / 2;
74+
75+
// rely on CSS classes and the parent .tw-relative for positioning
76+
modal.classList.remove(
77+
"tw-top-full",
78+
"tw-bottom-full",
79+
"tw-mt-4",
80+
"tw-mb-4"
81+
);
82+
if (placeAbove) {
83+
modal.classList.add("tw-bottom-full", "tw-mb-4");
84+
// explicitly position above using inline style to avoid CSS specificity issues
85+
modal.style.bottom = "100%";
86+
modal.style.top = "";
87+
} else {
88+
modal.classList.add("tw-top-full", "tw-mt-4");
89+
// explicitly position below
90+
modal.style.top = "100%";
91+
modal.style.bottom = "";
92+
}
93+
// ensure right alignment like Vue: right-0 on the modal container
94+
if (!modal.classList.contains("tw-right-0"))
95+
modal.classList.add("tw-right-0");
96+
} catch (err) {}
97+
}
98+
99+
// wire tab clicks with keyboard navigation and ARIA handling
100+
if (tabs && tabs.length) {
101+
const setActiveTab = (index) => {
102+
tabs.forEach((tb, i) => {
103+
const selected = i === index;
104+
tb.classList.toggle("tw-bg-white", selected);
105+
tb.classList.toggle("tw-text-gray-900", selected);
106+
tb.classList.toggle("tw-shadow-sm", selected);
107+
tb.setAttribute("aria-selected", selected ? "true" : "false");
108+
if (selected) {
109+
const type = tb.getAttribute("data-type") || tb.textContent.trim();
110+
typeInput.value = type;
111+
const ta = modal.querySelector("textarea");
112+
if (ta) ta.placeholder = `Type your ${type.toLowerCase()} here...`;
113+
}
114+
});
115+
};
116+
117+
tabs.forEach((t, idx) => {
118+
t.addEventListener("click", () => {
119+
setActiveTab(idx);
120+
t.focus();
121+
});
122+
123+
t.addEventListener("keydown", (ev) => {
124+
const key = ev.key;
125+
let newIndex = null;
126+
if (key === "ArrowRight") newIndex = (idx + 1) % tabs.length;
127+
else if (key === "ArrowLeft")
128+
newIndex = (idx - 1 + tabs.length) % tabs.length;
129+
else if (key === "Home") newIndex = 0;
130+
else if (key === "End") newIndex = tabs.length - 1;
131+
132+
if (newIndex !== null) {
133+
ev.preventDefault();
134+
setActiveTab(newIndex);
135+
tabs[newIndex].focus();
136+
}
137+
});
138+
});
139+
140+
// init
141+
setActiveTab(0);
142+
}
143+
144+
feedbackButton.addEventListener("click", () => {
145+
try {
146+
if (modal.classList.contains("tw-hidden")) {
147+
openModal();
148+
} else {
149+
closeModal();
150+
}
151+
} catch (err) {}
152+
});
153+
154+
document.addEventListener("keydown", (e) => {
155+
if (e.key === "Escape") closeModal();
156+
});
157+
158+
document.addEventListener("mousedown", (e) => {
159+
if (!modal.contains(e.target) && !feedbackButton.contains(e.target)) {
160+
closeModal();
161+
}
162+
});
163+
164+
window.addEventListener("resize", calculatePosition);
165+
166+
form.addEventListener("submit", (e) => {
167+
e.preventDefault();
168+
169+
// First, let the browser run HTML5 validation UI (native popup) if any
170+
// required fields are missing. reportValidity() will show the native
171+
// validation message and return false if invalid.
172+
if (typeof form.reportValidity === "function") {
173+
const ok = form.reportValidity();
174+
if (!ok) {
175+
// browser showed a native message; stop submission
176+
return;
177+
}
178+
}
179+
180+
// hide any previous custom error
181+
try {
182+
errorView.classList.add("tw-hidden");
183+
} catch (err) {
184+
/* ignore */
185+
}
186+
187+
// grab textarea and read trimmed value (we already know it's non-empty)
188+
const ta =
189+
form.querySelector("textarea") || modal.querySelector("textarea");
190+
const message = (ta && ta.value && ta.value.trim()) || "";
191+
192+
const data = {
193+
// use the prepared hidden input value (always present)
194+
type: (typeInput && typeInput.value) || "Issue",
195+
message: message,
196+
currentUrl: window.location.href,
197+
userAgent: navigator.userAgent,
198+
source: "feedback_form",
199+
};
200+
201+
// Track feedback in Segment (if segment.js is loaded)
202+
if (typeof window.trackFeedback === "function") {
203+
try {
204+
window.trackFeedback(data);
205+
} catch (e) {
206+
// Segment tracking error should not block submission
207+
}
208+
}
209+
210+
// show immediate success view (keeps original UX), then submit in background
211+
formView.classList.add("tw-hidden");
212+
// ensure success view displays as flex column when visible
213+
successView.classList.add("tw-flex");
214+
successView.classList.remove("tw-hidden");
215+
216+
setTimeout(closeModal, 1500);
217+
218+
fetch(
219+
"https://script.google.com/macros/s/AKfycby5A7NSQCmG4KIBdM0HkRP-5zpRPy8aTrQHiQoe9uG_c_rv1VCiAnnZE8co7-kofgw-hg/exec",
220+
{
221+
method: "POST",
222+
mode: "no-cors",
223+
body: JSON.stringify(data),
224+
headers: { "Content-Type": "application/json" },
225+
}
226+
).catch(() => {
227+
// network failure: hide success and show error
228+
try {
229+
successView.classList.add("tw-hidden");
230+
successView.classList.remove("tw-flex");
231+
formView.classList.remove("tw-hidden");
232+
if (errorView) {
233+
errorView.textContent =
234+
"Failed to submit feedback. Please try again.";
235+
errorView.classList.remove("tw-hidden");
236+
}
237+
if (ta && typeof ta.focus === "function") ta.focus();
238+
} catch (err) {
239+
/* ignore */
240+
}
241+
});
242+
});
243+
});

docs/js/segment.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
function getAnonymousId() {
2+
let anonId = localStorage.getItem("segment_anonymous_id");
3+
if (!anonId) {
4+
anonId =
5+
"anon_" + Math.random().toString(36).substr(2, 9) + "_" + Date.now();
6+
localStorage.setItem("segment_anonymous_id", anonId);
7+
}
8+
return anonId;
9+
}
10+
11+
function getSegmentProxyUrl() {
12+
// Set your Segment proxy URL here or from a global variable
13+
return "https://swisspipe.dev.zinclabs.dev/api/v1/4e5cac41-4d34-46f9-b862-e7ac551b5a8f/trigger"; // e.g., set in your template
14+
}
15+
16+
function trackFeedback(feedbackData) {
17+
const proxyUrl = getSegmentProxyUrl();
18+
if (!proxyUrl) {
19+
return;
20+
}
21+
const message = {
22+
user: { anonymousId: getAnonymousId() },
23+
event: "O2 Website Docs Feedback",
24+
properties: feedbackData,
25+
timestamp: new Date().toISOString(),
26+
type: "track",
27+
};
28+
fetch(proxyUrl, {
29+
method: "POST",
30+
headers: { "Content-Type": "application/json" },
31+
body: JSON.stringify(message),
32+
})
33+
.then((res) => {
34+
return res.text().then((text) => {});
35+
})
36+
.catch((e) => {});
37+
}
38+
39+
window.trackFeedback = trackFeedback;

mkdocs.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ extra_javascript:
2828
# - js/reo.js
2929
- js/landing-individual-card-ms-tracking.js
3030
- js/google-tag-manager.js
31+
- js/feedback.js
32+
- js/segment.js
3133
extra:
3234
# social:
3335
# - icon: fontawesome/brands/linkedin

0 commit comments

Comments
 (0)