From 9c88eb01df0b793103da7356fe148dde33732101 Mon Sep 17 00:00:00 2001 From: ktx-monil Date: Wed, 1 Oct 2025 21:29:16 +0530 Subject: [PATCH 1/5] 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. --- docs/assets/feedback.svg | 3 + docs/js/feedback.js | 243 +++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + overrides/css/output.css | 190 ++++++++++++++++++++++++++ overrides/partials/footer.html | 209 +++++++++++++++++++++++++++- 5 files changed, 645 insertions(+), 1 deletion(-) create mode 100644 docs/assets/feedback.svg create mode 100644 docs/js/feedback.js diff --git a/docs/assets/feedback.svg b/docs/assets/feedback.svg new file mode 100644 index 0000000..16b0d14 --- /dev/null +++ b/docs/assets/feedback.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/js/feedback.js b/docs/js/feedback.js new file mode 100644 index 0000000..731ada1 --- /dev/null +++ b/docs/js/feedback.js @@ -0,0 +1,243 @@ +// feedback.js +document.addEventListener("DOMContentLoaded", () => { + const feedbackButton = document.querySelector("#feedbackButton"); + const modal = document.querySelector("#feedbackModal"); + + if (!feedbackButton || !modal) { + return; + } + + const form = modal.querySelector("form"); + const successView = modal.querySelector(".success-view"); + const formView = modal.querySelector(".form-view"); + const errorView = modal.querySelector(".error-view"); + const tabs = modal.querySelectorAll(".feedback-tab"); + let lastActiveElement = null; + + // Ensure the form exists before touching it + if (!form) { + return; + } + + // ensure there's an input[name=type] for the form (hidden) so the submit code can read it + let typeInput = form.querySelector("input[name=type]"); + if (!typeInput) { + typeInput = document.createElement("input"); + typeInput.type = "hidden"; + typeInput.name = "type"; + typeInput.value = "Issue"; + form.appendChild(typeInput); + } + + function openModal() { + // store previous active element so we can restore focus when modal closes + lastActiveElement = document.activeElement; + modal.classList.remove("tw-hidden"); + calculatePosition(); + // focus the textarea for immediate typing if present + const ta = modal.querySelector("textarea"); + if (ta) ta.focus(); + } + + function closeModal() { + modal.classList.add("tw-hidden"); + form.reset(); + errorView.classList.add("tw-hidden"); + successView.classList.add("tw-hidden"); + // remove layout class when hidden to avoid display conflicts + successView.classList.remove("tw-flex"); + // clear any inline positioning set during calculatePosition + try { + modal.style.top = ""; + modal.style.bottom = ""; + } catch (e) { + /* ignore */ + } + formView.classList.remove("tw-hidden"); + // restore focus to previously active element + try { + if (lastActiveElement && typeof lastActiveElement.focus === "function") { + lastActiveElement.focus(); + } + } catch (e) { + /* ignore */ + } + } + + function calculatePosition() { + // class-based positioning like the Vue component: toggle top-full / bottom-full + try { + const btnRect = feedbackButton.getBoundingClientRect(); + const screenHeight = window.innerHeight; + const buttonCenter = btnRect.top + btnRect.height / 2; + const placeAbove = buttonCenter > screenHeight / 2; + + // rely on CSS classes and the parent .tw-relative for positioning + modal.classList.remove( + "tw-top-full", + "tw-bottom-full", + "tw-mt-4", + "tw-mb-4" + ); + if (placeAbove) { + modal.classList.add("tw-bottom-full", "tw-mb-4"); + // explicitly position above using inline style to avoid CSS specificity issues + modal.style.bottom = "100%"; + modal.style.top = ""; + } else { + modal.classList.add("tw-top-full", "tw-mt-4"); + // explicitly position below + modal.style.top = "100%"; + modal.style.bottom = ""; + } + // ensure right alignment like Vue: right-0 on the modal container + if (!modal.classList.contains("tw-right-0")) + modal.classList.add("tw-right-0"); + } catch (err) {} + } + + // wire tab clicks with keyboard navigation and ARIA handling + if (tabs && tabs.length) { + const setActiveTab = (index) => { + tabs.forEach((tb, i) => { + const selected = i === index; + tb.classList.toggle("tw-bg-white", selected); + tb.classList.toggle("tw-text-gray-900", selected); + tb.classList.toggle("tw-shadow-sm", selected); + tb.setAttribute("aria-selected", selected ? "true" : "false"); + if (selected) { + const type = tb.getAttribute("data-type") || tb.textContent.trim(); + typeInput.value = type; + const ta = modal.querySelector("textarea"); + if (ta) ta.placeholder = `Type your ${type.toLowerCase()} here...`; + } + }); + }; + + tabs.forEach((t, idx) => { + t.addEventListener("click", () => { + setActiveTab(idx); + t.focus(); + }); + + t.addEventListener("keydown", (ev) => { + const key = ev.key; + let newIndex = null; + if (key === "ArrowRight") newIndex = (idx + 1) % tabs.length; + else if (key === "ArrowLeft") + newIndex = (idx - 1 + tabs.length) % tabs.length; + else if (key === "Home") newIndex = 0; + else if (key === "End") newIndex = tabs.length - 1; + + if (newIndex !== null) { + ev.preventDefault(); + setActiveTab(newIndex); + tabs[newIndex].focus(); + } + }); + }); + + // init + setActiveTab(0); + } + + feedbackButton.addEventListener("click", () => { + try { + if (modal.classList.contains("tw-hidden")) { + openModal(); + } else { + closeModal(); + } + } catch (err) {} + }); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") closeModal(); + }); + + document.addEventListener("mousedown", (e) => { + if (!modal.contains(e.target) && !feedbackButton.contains(e.target)) { + closeModal(); + } + }); + + window.addEventListener("resize", calculatePosition); + + form.addEventListener("submit", (e) => { + e.preventDefault(); + + // First, let the browser run HTML5 validation UI (native popup) if any + // required fields are missing. reportValidity() will show the native + // validation message and return false if invalid. + if (typeof form.reportValidity === "function") { + const ok = form.reportValidity(); + if (!ok) { + // browser showed a native message; stop submission + return; + } + } + + // hide any previous custom error + try { + errorView.classList.add("tw-hidden"); + } catch (err) { + /* ignore */ + } + + // grab textarea and read trimmed value (we already know it's non-empty) + const ta = + form.querySelector("textarea") || modal.querySelector("textarea"); + const message = (ta && ta.value && ta.value.trim()) || ""; + + const data = { + // use the prepared hidden input value (always present) + type: (typeInput && typeInput.value) || "Issue", + message: message, + currentUrl: window.location.href, + userAgent: navigator.userAgent, + source: "feedback_form", + }; + + // Track feedback in Segment (if segment.js is loaded) + if (typeof window.trackFeedback === "function") { + try { + window.trackFeedback(data); + } catch (e) { + // Segment tracking error should not block submission + } + } + + // show immediate success view (keeps original UX), then submit in background + formView.classList.add("tw-hidden"); + // ensure success view displays as flex column when visible + successView.classList.add("tw-flex"); + successView.classList.remove("tw-hidden"); + + setTimeout(closeModal, 1500); + + fetch( + "https://script.google.com/macros/s/AKfycby5A7NSQCmG4KIBdM0HkRP-5zpRPy8aTrQHiQoe9uG_c_rv1VCiAnnZE8co7-kofgw-hg/exec", + { + method: "POST", + mode: "no-cors", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + } + ).catch(() => { + // network failure: hide success and show error + try { + successView.classList.add("tw-hidden"); + successView.classList.remove("tw-flex"); + formView.classList.remove("tw-hidden"); + if (errorView) { + errorView.textContent = + "Failed to submit feedback. Please try again."; + errorView.classList.remove("tw-hidden"); + } + if (ta && typeof ta.focus === "function") ta.focus(); + } catch (err) { + /* ignore */ + } + }); + }); +}); diff --git a/mkdocs.yml b/mkdocs.yml index 43e8410..190da68 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -28,6 +28,7 @@ extra_javascript: # - js/reo.js - js/landing-individual-card-ms-tracking.js - js/google-tag-manager.js + - js/feedback.js extra: # social: # - icon: fontawesome/brands/linkedin diff --git a/overrides/css/output.css b/overrides/css/output.css index b43d224..9265806 100644 --- a/overrides/css/output.css +++ b/overrides/css/output.css @@ -457,6 +457,7 @@ legend { padding: 0; } +ol, ul, menu { list-style: none; @@ -631,6 +632,10 @@ video { left: 50%; } +.tw-right-0 { + right: 0px; +} + .tw-right-2 { right: 0.5rem; } @@ -647,6 +652,10 @@ video { top: 100%; } +.tw-z-10 { + z-index: 10; +} + .tw-z-50 { z-index: 50; } @@ -688,6 +697,10 @@ video { margin-top: 0.5rem; } +.tw-mt-4 { + margin-top: 1rem; +} + .tw-mt-5 { margin-top: 1.25rem; } @@ -724,6 +737,10 @@ video { height: 3rem; } +.tw-h-16 { + height: 4rem; +} + .tw-h-4 { height: 1rem; } @@ -740,6 +757,14 @@ video { height: 1.75rem; } +.tw-h-8 { + height: 2rem; +} + +.tw-h-9 { + height: 2.25rem; +} + .tw-h-\[calc\(100vh-120px\)\] { height: calc(100vh - 120px); } @@ -752,6 +777,10 @@ video { width: 3rem; } +.tw-w-16 { + width: 4rem; +} + .tw-w-4 { width: 1rem; } @@ -764,10 +793,22 @@ video { width: 1.25rem; } +.tw-w-6 { + width: 1.5rem; +} + .tw-w-7 { width: 1.75rem; } +.tw-w-8 { + width: 2rem; +} + +.tw-w-9 { + width: 2.25rem; +} + .tw-w-\[430px\] { width: 430px; } @@ -814,6 +855,10 @@ video { cursor: pointer; } +.tw-resize-none { + resize: none; +} + .tw-grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -854,6 +899,10 @@ video { gap: 0.625rem; } +.tw-gap-3 { + gap: 0.75rem; +} + .tw-gap-4 { gap: 1rem; } @@ -922,6 +971,10 @@ video { border-radius: 0.375rem; } +.tw-rounded-xl { + border-radius: 0.75rem; +} + .tw-border { border-width: 1px; } @@ -949,11 +1002,21 @@ video { border-color: rgb(0 0 0 / var(--tw-border-opacity, 1)); } +.tw-border-gray-200 { + --tw-border-opacity: 1; + border-color: rgb(229 231 235 / var(--tw-border-opacity, 1)); +} + .tw-border-gray-300 { --tw-border-opacity: 1; border-color: rgb(209 213 219 / var(--tw-border-opacity, 1)); } +.tw-border-gray-400 { + --tw-border-opacity: 1; + border-color: rgb(156 163 175 / var(--tw-border-opacity, 1)); +} + .tw-border-gray-700 { --tw-border-opacity: 1; border-color: rgb(55 65 81 / var(--tw-border-opacity, 1)); @@ -969,6 +1032,11 @@ video { background-color: rgb(119 130 255 / var(--tw-bg-opacity, 1)); } +.tw-bg-gray-200 { + --tw-bg-opacity: 1; + background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)); +} + .tw-bg-transparent { background-color: transparent; } @@ -978,11 +1046,29 @@ video { background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); } +.tw-bg-gradient-to-r { + background-image: linear-gradient(to right, var(--tw-gradient-stops)); +} + +.tw-from-blue-500 { + --tw-gradient-from: #3b82f6 var(--tw-gradient-from-position); + --tw-gradient-to: rgb(59 130 246 / 0) var(--tw-gradient-to-position); + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); +} + +.tw-to-purple-600 { + --tw-gradient-to: #9333ea var(--tw-gradient-to-position); +} + .tw-object-contain { -o-object-fit: contain; object-fit: contain; } +.tw-p-0\.5 { + padding: 0.125rem; +} + .tw-p-2 { padding: 0.5rem; } @@ -1048,6 +1134,11 @@ video { padding-bottom: 0.5rem; } +.tw-py-3 { + padding-top: 0.75rem; + padding-bottom: 0.75rem; +} + .tw-pt-4 { padding-top: 1rem; } @@ -1064,6 +1155,14 @@ video { text-align: center; } +.tw-font-sans { + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; +} + +.tw-text-\[12px\] { + font-size: 12px; +} + .tw-text-\[14px\] { font-size: 14px; } @@ -1091,6 +1190,11 @@ video { line-height: 1.25rem; } +.tw-text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + .tw-text-xs { font-size: 0.75rem; line-height: 1rem; @@ -1112,6 +1216,10 @@ video { text-transform: capitalize; } +.tw-leading-relaxed { + line-height: 1.625; +} + .\!tw-text-white { --tw-text-opacity: 1 !important; color: rgb(255 255 255 / var(--tw-text-opacity, 1)) !important; @@ -1141,11 +1249,31 @@ video { color: rgb(107 114 128 / var(--tw-text-opacity, 1)); } +.tw-text-gray-600 { + --tw-text-opacity: 1; + color: rgb(75 85 99 / var(--tw-text-opacity, 1)); +} + +.tw-text-gray-900 { + --tw-text-opacity: 1; + color: rgb(17 24 39 / var(--tw-text-opacity, 1)); +} + .tw-text-red-400 { --tw-text-opacity: 1; color: rgb(248 113 113 / var(--tw-text-opacity, 1)); } +.tw-text-red-500 { + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity, 1)); +} + +.tw-text-teal-500 { + --tw-text-opacity: 1; + color: rgb(20 184 166 / var(--tw-text-opacity, 1)); +} + .tw-text-white { --tw-text-opacity: 1; color: rgb(255 255 255 / var(--tw-text-opacity, 1)); @@ -1172,6 +1300,16 @@ video { color: rgba(255,255,255,0.6); } +.tw-placeholder-gray-400::-moz-placeholder { + --tw-placeholder-opacity: 1; + color: rgb(156 163 175 / var(--tw-placeholder-opacity, 1)); +} + +.tw-placeholder-gray-400::placeholder { + --tw-placeholder-opacity: 1; + color: rgb(156 163 175 / var(--tw-placeholder-opacity, 1)); +} + .tw-caret-white { caret-color: #fff; } @@ -1186,6 +1324,18 @@ video { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); } +.tw-shadow-md { + --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.tw-shadow-sm { + --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + .tw-outline-none { outline: 2px solid transparent; outline-offset: 2px; @@ -1346,6 +1496,11 @@ main { background-clip: text; } +.hover\:tw-border-gray-200:hover { + --tw-border-opacity: 1; + border-color: rgb(229 231 235 / var(--tw-border-opacity, 1)); +} + .hover\:tw-bg-\[\#6672fa\]:hover { --tw-bg-opacity: 1; background-color: rgb(102 114 250 / var(--tw-bg-opacity, 1)); @@ -1370,6 +1525,16 @@ main { --tw-bg-opacity: 0.1; } +.hover\:tw-from-blue-600:hover { + --tw-gradient-from: #2563eb var(--tw-gradient-from-position); + --tw-gradient-to: rgb(37 99 235 / 0) var(--tw-gradient-to-position); + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); +} + +.hover\:tw-to-purple-700:hover { + --tw-gradient-to: #7e22ce var(--tw-gradient-to-position); +} + .hover\:tw-text-\[\#6b76e3\]:hover { --tw-text-opacity: 1; color: rgb(107 118 227 / var(--tw-text-opacity, 1)); @@ -1385,12 +1550,37 @@ main { color: rgb(0 0 0 / var(--tw-text-opacity, 1)); } +.hover\:tw-text-gray-900:hover { + --tw-text-opacity: 1; + color: rgb(17 24 39 / var(--tw-text-opacity, 1)); +} + .hover\:tw-shadow-xl:hover { --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); } +.focus\:tw-border-transparent:focus { + border-color: transparent; +} + +.focus\:tw-outline-none:focus { + outline: 2px solid transparent; + outline-offset: 2px; +} + +.focus\:tw-ring-2:focus { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.focus\:tw-ring-blue-500:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1)); +} + .focus-visible\:tw-outline-none:focus-visible { outline: 2px solid transparent; outline-offset: 2px; diff --git a/overrides/partials/footer.html b/overrides/partials/footer.html index 04982aa..662399a 100644 --- a/overrides/partials/footer.html +++ b/overrides/partials/footer.html @@ -392,6 +392,146 @@