Skip to content

Commit 10d73ae

Browse files
committed
added a feedback form and Google Sheets connection
1 parent 0a86b98 commit 10d73ae

4 files changed

Lines changed: 342 additions & 0 deletions

File tree

docs/js/feedback.js

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
(function () {
2+
function setText(el, lang) {
3+
const value = el.getAttribute(lang === "en" ? "data-en" : "data-ru");
4+
if (value) el.textContent = value;
5+
}
6+
7+
function postFeedback(endpoint, payload) {
8+
return fetch(endpoint, {
9+
method: "POST",
10+
mode: "no-cors",
11+
keepalive: true,
12+
headers: { "Content-Type": "text/plain;charset=UTF-8" },
13+
body: JSON.stringify(payload)
14+
});
15+
}
16+
17+
document.addEventListener("DOMContentLoaded", function () {
18+
const widget = document.querySelector(".cs-feedback");
19+
if (!widget) return;
20+
21+
const lang = widget.getAttribute("data-lang") || "ru";
22+
const endpoint = widget.getAttribute("data-endpoint") || "";
23+
const pageTitle = widget.getAttribute("data-page-title") || "";
24+
const pageUrl = widget.getAttribute("data-page-url") || "";
25+
26+
widget.querySelectorAll("[data-ru]").forEach((el) => setText(el, lang));
27+
widget.querySelectorAll(".cs-feedback__btn").forEach((btn) => setText(btn, lang));
28+
widget.querySelectorAll(".cs-feedback__submit").forEach((btn) => setText(btn, lang));
29+
30+
const status = widget.querySelector(".cs-feedback__status");
31+
const statusText = widget.querySelector(".cs-feedback__status-text");
32+
const details = widget.querySelector(".cs-feedback__details");
33+
const comment = widget.querySelector(".cs-feedback__comment");
34+
const submit = widget.querySelector(".cs-feedback__submit");
35+
const trap = widget.querySelector(".cs-feedback__trap");
36+
const storageKey = `cs-feedback:${pageUrl || location.pathname}`;
37+
const cooldownMs = 2 * 60 * 1000;
38+
39+
function setStatus(text) {
40+
if (statusText) statusText.textContent = text || "";
41+
}
42+
43+
function doneMessage() {
44+
return lang === "en" ? "Thanks!" : "Спасибо!";
45+
}
46+
47+
function errorMessage() {
48+
return lang === "en" ? "Failed to send. Try later." : "Не удалось отправить. Попробуйте позже.";
49+
}
50+
51+
function requireEndpoint() {
52+
return lang === "en" ? "Feedback endpoint is not configured." : "Не задан endpoint для фидбэка.";
53+
}
54+
55+
function alreadySentMessage() {
56+
return lang === "en" ? "We already got your feedback. Спасибо!" : "Мы уже получили ваш отзыв. Спасибо!";
57+
}
58+
59+
function markDone() {
60+
widget.classList.add("cs-feedback--done");
61+
}
62+
63+
function setLoading(state) {
64+
widget.classList.toggle("cs-feedback--loading", state);
65+
}
66+
67+
function saveSent() {
68+
try {
69+
localStorage.setItem(storageKey, String(Date.now()));
70+
} catch (e) {
71+
// ignore storage errors
72+
}
73+
}
74+
75+
function isRateLimited() {
76+
try {
77+
const last = Number(localStorage.getItem(storageKey) || 0);
78+
return last && Date.now() - last < cooldownMs;
79+
} catch (e) {
80+
return false;
81+
}
82+
}
83+
84+
widget.querySelectorAll(".cs-feedback__btn").forEach((btn) => {
85+
btn.addEventListener("click", async function () {
86+
const value = btn.getAttribute("data-value");
87+
setStatus("");
88+
89+
if (trap && trap.value) {
90+
setStatus(doneMessage());
91+
markDone();
92+
return;
93+
}
94+
95+
if (isRateLimited()) {
96+
setStatus(alreadySentMessage());
97+
markDone();
98+
return;
99+
}
100+
101+
if (!endpoint) {
102+
setStatus(requireEndpoint());
103+
return;
104+
}
105+
106+
if (value === "no") {
107+
details.hidden = false;
108+
comment.focus();
109+
return;
110+
}
111+
112+
try {
113+
setLoading(true);
114+
await postFeedback(endpoint, {
115+
rating: "yes",
116+
comment: "",
117+
page_title: pageTitle,
118+
page_url: pageUrl,
119+
lang,
120+
user_agent: navigator.userAgent,
121+
created_at: new Date().toISOString()
122+
});
123+
saveSent();
124+
setStatus(doneMessage());
125+
markDone();
126+
} catch (err) {
127+
setStatus(errorMessage());
128+
} finally {
129+
setLoading(false);
130+
}
131+
});
132+
});
133+
134+
submit.addEventListener("click", async function () {
135+
if (trap && trap.value) {
136+
setStatus(doneMessage());
137+
markDone();
138+
return;
139+
}
140+
141+
if (isRateLimited()) {
142+
setStatus(alreadySentMessage());
143+
markDone();
144+
return;
145+
}
146+
147+
if (!endpoint) {
148+
setStatus(requireEndpoint());
149+
return;
150+
}
151+
152+
const value = comment.value.trim();
153+
if (!value) {
154+
setStatus(lang === "en" ? "Please add a comment." : "Добавьте комментарий.");
155+
return;
156+
}
157+
158+
try {
159+
setLoading(true);
160+
await postFeedback(endpoint, {
161+
rating: "no",
162+
comment: value,
163+
page_title: pageTitle,
164+
page_url: pageUrl,
165+
lang,
166+
user_agent: navigator.userAgent,
167+
created_at: new Date().toISOString()
168+
});
169+
saveSent();
170+
setStatus(doneMessage());
171+
markDone();
172+
details.hidden = true;
173+
comment.value = "";
174+
} catch (err) {
175+
setStatus(errorMessage());
176+
} finally {
177+
setLoading(false);
178+
}
179+
});
180+
});
181+
})();

docs/stylesheets/extra.css

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,131 @@
356356
color: var(--md-default-fg-color);
357357
}
358358

359+
.cs-feedback {
360+
display: flex !important;
361+
flex-direction: column;
362+
align-items: center;
363+
gap: 0.75rem;
364+
}
365+
366+
.cs-feedback__prompt {
367+
font-weight: 600;
368+
}
369+
370+
.cs-feedback__actions {
371+
display: flex;
372+
gap: 0.6rem;
373+
}
374+
375+
.cs-feedback__prompt,
376+
.cs-feedback__actions,
377+
.cs-feedback__details {
378+
transition: opacity 0.2s ease, transform 0.2s ease;
379+
}
380+
381+
.cs-feedback__details {
382+
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.2s ease;
383+
}
384+
385+
.cs-feedback__btn,
386+
.cs-feedback__submit {
387+
border: 1px solid #ff3d6c;
388+
background: transparent;
389+
color: #ff3d6c;
390+
border-radius: 999px;
391+
padding: 0.35rem 0.9rem;
392+
font-size: 0.85rem;
393+
cursor: pointer;
394+
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
395+
}
396+
397+
.cs-feedback__btn:hover,
398+
.cs-feedback__submit:hover {
399+
background: #ff3d6c;
400+
color: #ffffff;
401+
}
402+
403+
.cs-feedback__details {
404+
width: min(620px, 100%);
405+
display: flex;
406+
flex-direction: column;
407+
gap: 0.5rem;
408+
}
409+
410+
.cs-feedback__label {
411+
display: flex;
412+
flex-direction: column;
413+
gap: 0.35rem;
414+
align-items: stretch;
415+
}
416+
417+
.cs-feedback__comment {
418+
width: 100%;
419+
border-radius: 8px;
420+
border: 1px solid rgba(255, 61, 108, 0.5);
421+
background: rgba(0, 0, 0, 0.15);
422+
color: var(--md-default-fg-color);
423+
padding: 0.5rem 0.6rem;
424+
resize: vertical;
425+
}
426+
427+
.cs-feedback__status {
428+
font-size: 0.85rem;
429+
color: var(--md-default-fg-color--light);
430+
min-height: 1.2em;
431+
display: inline-flex;
432+
align-items: center;
433+
gap: 0.5rem;
434+
}
435+
436+
.cs-feedback__trap {
437+
position: absolute;
438+
left: -9999px;
439+
width: 1px;
440+
height: 1px;
441+
opacity: 0;
442+
pointer-events: none;
443+
}
444+
445+
.cs-feedback--done .cs-feedback__prompt,
446+
.cs-feedback--done .cs-feedback__actions,
447+
.cs-feedback--done .cs-feedback__details {
448+
opacity: 0;
449+
transform: translateY(-6px);
450+
pointer-events: none;
451+
}
452+
453+
.cs-feedback--done .cs-feedback__details {
454+
max-height: 0;
455+
overflow: hidden;
456+
}
457+
458+
.cs-feedback--done .cs-feedback__status {
459+
font-weight: 600;
460+
color: #ff3d6c;
461+
}
462+
463+
.cs-feedback__loader {
464+
width: 14px;
465+
height: 14px;
466+
border-radius: 50%;
467+
border: 2px solid rgba(255, 61, 108, 0.35);
468+
border-top-color: #ff3d6c;
469+
animation: csFeedbackSpin 0.8s linear infinite;
470+
opacity: 0;
471+
transform: scale(0.9);
472+
transition: opacity 0.2s ease, transform 0.2s ease;
473+
}
474+
475+
.cs-feedback--loading .cs-feedback__loader {
476+
opacity: 1;
477+
transform: scale(1);
478+
}
479+
480+
@keyframes csFeedbackSpin {
481+
to { transform: rotate(360deg); }
482+
}
483+
359484
.module-tag {
360485
display: inline-flex;
361486
align-items: center;

mkdocs.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,8 +734,10 @@ extra_css:
734734
extra_javascript:
735735
- js/search.js
736736
- js/home.js
737+
- js/feedback.js
737738

738739
extra:
740+
feedback_endpoint: "https://script.google.com/macros/s/AKfycbwK-c3VzYAAMBxg85FKaoA99EL32Lh-3-Bkcr1tWJUyRiVycR4K0r3qy4z0XnT6fpqicg/exec"
739741
social:
740742
- icon: fontawesome/brands/youtube
741743
link: https://www.youtube.com/@codescoring

overrides/main.html

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,40 @@
44

55
{{ super() }} {# Keeps the existing page content #}
66

7+
{% set file_name = page.file.name | default('') | lower %}
8+
{% set page_url = page.url | default('') %}
9+
{% set is_index = file_name.startswith('index.') %}
10+
{% set is_guide = page_url in ['user-guide/', 'user-guide.en/', 'admin-guide/', 'admin-guide.en/'] %}
11+
{% if not page.is_homepage and not is_index and not is_guide %}
12+
<div
13+
class="md-feedback cs-feedback"
14+
data-lang="{% if '.en/' in page.url or page.url.endswith('.en/') %}en{% else %}ru{% endif %}"
15+
data-endpoint="{{ config.extra.feedback_endpoint | default('') }}"
16+
data-page-title="{{ page.title | e }}"
17+
data-page-url="{{ page.url | e }}"
18+
>
19+
<div class="cs-feedback__prompt">
20+
<span class="cs-feedback__prompt-text" data-ru="Страница была полезна?" data-en="Was this page useful?">Страница была полезна?</span>
21+
</div>
22+
<div class="cs-feedback__actions">
23+
<button type="button" class="cs-feedback__btn" data-value="yes" data-ru="Да" data-en="Yes">Да</button>
24+
<button type="button" class="cs-feedback__btn" data-value="no" data-ru="Нет" data-en="No">Нет</button>
25+
</div>
26+
<div class="cs-feedback__details" hidden>
27+
<label class="cs-feedback__label">
28+
<span data-ru="Что можно улучшить?" data-en="What should we improve?">Что можно улучшить?</span>
29+
<textarea class="cs-feedback__comment" rows="3" maxlength="1000"></textarea>
30+
</label>
31+
<button type="button" class="cs-feedback__submit" data-ru="Отправить" data-en="Send">Отправить</button>
32+
</div>
33+
<input type="text" class="cs-feedback__trap" name="company" tabindex="-1" autocomplete="off" aria-hidden="true">
34+
<div class="cs-feedback__status" role="status" aria-live="polite">
35+
<span class="cs-feedback__loader" aria-hidden="true"></span>
36+
<span class="cs-feedback__status-text"></span>
37+
</div>
38+
</div>
39+
{% endif %}
40+
741
<script>
842
document.addEventListener("DOMContentLoaded", function() {
943
let printButton = document.getElementById("print-pdf-btn");

0 commit comments

Comments
 (0)