Skip to content

Commit 136878f

Browse files
committed
Merge branch 'feedback' into 'master'
added a feedback form and Google Sheets connection See merge request CodeScoring/docs!644
2 parents 936d9e0 + 7b20a23 commit 136878f

3 files changed

Lines changed: 310 additions & 0 deletions

File tree

docs/js/feedback.js

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
(function () {
2+
const STRINGS = {
3+
ru: {
4+
done: "Спасибо!",
5+
error: "Не удалось отправить. Попробуйте позже.",
6+
missingEndpoint: "Не задан endpoint для фидбэка.",
7+
emptyComment: "Добавьте комментарий."
8+
},
9+
en: {
10+
done: "Thanks!",
11+
error: "Failed to send. Try later.",
12+
missingEndpoint: "Feedback endpoint is not configured.",
13+
emptyComment: "Please add a comment."
14+
}
15+
};
16+
17+
function setText(el, lang) {
18+
const value = el.getAttribute(lang === "en" ? "data-en" : "data-ru");
19+
if (value) el.textContent = value;
20+
}
21+
22+
function postFeedback(endpoint, payload) {
23+
return fetch(endpoint, {
24+
method: "POST",
25+
mode: "no-cors",
26+
keepalive: true,
27+
headers: { "Content-Type": "text/plain;charset=UTF-8" },
28+
body: JSON.stringify(payload)
29+
});
30+
}
31+
32+
function buildPayload(pageTitle, pageUrl, lang, rating, comment) {
33+
return {
34+
rating: rating,
35+
comment: comment,
36+
page_title: pageTitle,
37+
page_url: pageUrl,
38+
lang: lang,
39+
user_agent: navigator.userAgent,
40+
created_at: new Date().toISOString()
41+
};
42+
}
43+
44+
document.addEventListener("DOMContentLoaded", function () {
45+
const widget = document.querySelector(".cs-feedback");
46+
if (!widget) return;
47+
48+
const lang = widget.getAttribute("data-lang") || "ru";
49+
const strings = STRINGS[lang] || STRINGS.ru;
50+
const endpoint = widget.getAttribute("data-endpoint") || "";
51+
const pageTitle = widget.getAttribute("data-page-title") || "";
52+
const pageUrl = widget.getAttribute("data-page-url") || "";
53+
54+
widget.querySelectorAll("[data-ru]").forEach((el) => setText(el, lang));
55+
widget.querySelectorAll(".cs-feedback__btn").forEach((btn) => setText(btn, lang));
56+
widget.querySelectorAll(".cs-feedback__submit").forEach((btn) => setText(btn, lang));
57+
58+
const statusText = widget.querySelector(".cs-feedback__status-text");
59+
const details = widget.querySelector(".cs-feedback__details");
60+
const comment = widget.querySelector(".cs-feedback__comment");
61+
const submit = widget.querySelector(".cs-feedback__submit");
62+
const trap = widget.querySelector(".cs-feedback__trap");
63+
64+
function setStatus(text) {
65+
if (statusText) statusText.textContent = text || "";
66+
}
67+
68+
function markDone() {
69+
widget.classList.add("cs-feedback--done");
70+
}
71+
72+
function setLoading(state) {
73+
widget.classList.toggle("cs-feedback--loading", state);
74+
}
75+
76+
async function sendFeedback(rating, value) {
77+
try {
78+
setLoading(true);
79+
await postFeedback(endpoint, buildPayload(pageTitle, pageUrl, lang, rating, value));
80+
setStatus(strings.done);
81+
markDone();
82+
83+
if (rating === "no") {
84+
details.hidden = true;
85+
comment.value = "";
86+
}
87+
} catch (err) {
88+
setStatus(strings.error);
89+
} finally {
90+
setLoading(false);
91+
}
92+
}
93+
94+
widget.querySelectorAll(".cs-feedback__btn").forEach((btn) => {
95+
btn.addEventListener("click", async function () {
96+
const value = btn.getAttribute("data-value");
97+
setStatus("");
98+
99+
if (trap && trap.value) {
100+
setStatus(strings.done);
101+
markDone();
102+
return;
103+
}
104+
105+
if (!endpoint) {
106+
setStatus(strings.missingEndpoint);
107+
return;
108+
}
109+
110+
if (value === "no") {
111+
details.hidden = false;
112+
comment.focus();
113+
return;
114+
}
115+
116+
await sendFeedback("yes", "");
117+
});
118+
});
119+
120+
submit.addEventListener("click", async function () {
121+
if (trap && trap.value) {
122+
setStatus(strings.done);
123+
markDone();
124+
return;
125+
}
126+
127+
if (!endpoint) {
128+
setStatus(strings.missingEndpoint);
129+
return;
130+
}
131+
132+
const value = comment.value.trim();
133+
if (!value) {
134+
setStatus(strings.emptyComment);
135+
return;
136+
}
137+
138+
await sendFeedback("no", value);
139+
});
140+
});
141+
})();

docs/stylesheets/extra.css

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,132 @@ body:not(.is-home) .md-content__inner {
620620
color: var(--md-default-fg-color);
621621
}
622622

623+
.cs-feedback {
624+
display: flex !important;
625+
flex-direction: column;
626+
align-items: center;
627+
gap: 0.75rem;
628+
}
629+
630+
.cs-feedback__prompt {
631+
font-weight: 600;
632+
}
633+
634+
.cs-feedback__actions {
635+
display: flex;
636+
gap: 0.6rem;
637+
}
638+
639+
.cs-feedback__prompt,
640+
.cs-feedback__actions,
641+
.cs-feedback__details {
642+
transition: opacity 0.2s ease, transform 0.2s ease;
643+
}
644+
645+
.cs-feedback__details {
646+
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.2s ease;
647+
}
648+
649+
.cs-feedback__btn,
650+
.cs-feedback__submit {
651+
border: 1px solid #ff3d6c;
652+
background: transparent;
653+
color: #ff3d6c;
654+
border-radius: 999px;
655+
padding: 0.35rem 0.9rem;
656+
font-size: 0.85rem;
657+
cursor: pointer;
658+
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
659+
}
660+
661+
.cs-feedback__btn:hover,
662+
.cs-feedback__submit:hover {
663+
background: #ff3d6c;
664+
color: #ffffff;
665+
}
666+
667+
.cs-feedback__details {
668+
width: min(620px, 100%);
669+
display: flex;
670+
flex-direction: column;
671+
gap: 0.5rem;
672+
}
673+
674+
.cs-feedback__label {
675+
display: flex;
676+
flex-direction: column;
677+
gap: 0.35rem;
678+
align-items: stretch;
679+
}
680+
681+
.cs-feedback__comment {
682+
width: 100%;
683+
border-radius: 8px;
684+
border: 1px solid rgba(255, 61, 108, 0.5);
685+
background: rgba(0, 0, 0, 0.15);
686+
color: var(--md-default-fg-color);
687+
padding: 0.5rem 0.6rem;
688+
resize: vertical;
689+
}
690+
691+
.cs-feedback__status {
692+
font-size: 0.85rem;
693+
color: var(--md-default-fg-color--light);
694+
min-height: 1.2em;
695+
display: inline-flex;
696+
align-items: center;
697+
gap: 0.5rem;
698+
}
699+
700+
.cs-feedback__trap {
701+
position: absolute;
702+
left: -9999px;
703+
width: 1px;
704+
height: 1px;
705+
opacity: 0;
706+
pointer-events: none;
707+
}
708+
709+
.cs-feedback--done .cs-feedback__prompt,
710+
.cs-feedback--done .cs-feedback__actions,
711+
.cs-feedback--done .cs-feedback__details {
712+
opacity: 0;
713+
transform: translateY(-6px);
714+
pointer-events: none;
715+
}
716+
717+
.cs-feedback--done .cs-feedback__details {
718+
max-height: 0;
719+
overflow: hidden;
720+
}
721+
722+
.cs-feedback--done .cs-feedback__status {
723+
font-weight: 600;
724+
color: #ff3d6c;
725+
}
726+
727+
.cs-feedback__loader {
728+
display: inline-block;
729+
width: 14px;
730+
height: 14px;
731+
border-radius: 50%;
732+
border: 2px solid rgba(255, 61, 108, 0.35);
733+
border-top-color: #ff3d6c;
734+
animation: csFeedbackSpin 0.8s linear infinite;
735+
animation-play-state: paused;
736+
opacity: 0;
737+
transition: opacity 0.2s ease;
738+
}
739+
740+
.cs-feedback--loading .cs-feedback__loader {
741+
opacity: 1;
742+
animation-play-state: running;
743+
}
744+
745+
@keyframes csFeedbackSpin {
746+
to { transform: rotate(360deg); }
747+
}
748+
623749
.module-tag {
624750
display: inline-flex;
625751
align-items: center;

overrides/main.html

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,49 @@
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 excluded_feedback_urls = [
11+
'user-guide/',
12+
'user-guide.en/',
13+
'admin-guide/',
14+
'admin-guide.en/',
15+
'on-premise/how-to/general/',
16+
'on-premise/how-to/general.en/'
17+
] %}
18+
{% set is_guide = page_url in excluded_feedback_urls %}
19+
{% set is_changelog = page_url.startswith('changelog/') %}
20+
{% if not page.is_homepage and not is_index and not is_guide and not is_changelog %}
21+
<div
22+
class="md-feedback cs-feedback"
23+
data-lang="{% if '.en/' in page.url or page.url.endswith('.en/') %}en{% else %}ru{% endif %}"
24+
data-endpoint="{{ config.extra.feedback_endpoint | default('') }}"
25+
data-page-title="{{ page.title | e }}"
26+
data-page-url="{{ page.url | e }}"
27+
>
28+
<div class="cs-feedback__prompt">
29+
<span class="cs-feedback__prompt-text" data-ru="Страница была полезна?" data-en="Was this page useful?">Страница была полезна?</span>
30+
</div>
31+
<div class="cs-feedback__actions">
32+
<button type="button" class="cs-feedback__btn" data-value="yes" data-ru="Да" data-en="Yes">Да</button>
33+
<button type="button" class="cs-feedback__btn" data-value="no" data-ru="Нет" data-en="No">Нет</button>
34+
</div>
35+
<div class="cs-feedback__details" hidden>
36+
<label class="cs-feedback__label">
37+
<span data-ru="Что можно улучшить?" data-en="What should we improve?">Что можно улучшить?</span>
38+
<textarea class="cs-feedback__comment" rows="3" maxlength="1000"></textarea>
39+
</label>
40+
<button type="button" class="cs-feedback__submit" data-ru="Отправить" data-en="Send">Отправить</button>
41+
</div>
42+
<input type="text" class="cs-feedback__trap" name="company" tabindex="-1" autocomplete="off" aria-hidden="true">
43+
<div class="cs-feedback__status" role="status" aria-live="polite">
44+
<span class="cs-feedback__loader" aria-hidden="true"></span>
45+
<span class="cs-feedback__status-text"></span>
46+
</div>
47+
</div>
48+
{% endif %}
49+
750
<script>
851
document.addEventListener("DOMContentLoaded", function() {
952
let printButton = document.getElementById("print-pdf-btn");

0 commit comments

Comments
 (0)