diff --git a/bin/check-gt-navigation.ts b/bin/check-gt-navigation.ts index 02ae330a5..250ad0c18 100644 --- a/bin/check-gt-navigation.ts +++ b/bin/check-gt-navigation.ts @@ -119,6 +119,7 @@ function assertUiStrings(): void { for (const section of [ "account", "actions", + "feedback", "navigation", "search", "status", diff --git a/i18n/en/ui.json b/i18n/en/ui.json index dee4a6990..3d42a3835 100644 --- a/i18n/en/ui.json +++ b/i18n/en/ui.json @@ -16,6 +16,22 @@ "view": "View", "viewMarkdown": "View as Markdown" }, + "feedback": { + "additionalFeedback": "Additional feedback (optional)", + "close": "Close feedback form", + "confusing": "Hard to follow or confusing", + "error": "Feedback could not be sent. Please try again.", + "inaccurate": "Inaccurate, out of date, or doesn't work", + "missingInformation": "Missing information", + "negativeLabel": "This page could be improved", + "negativeQuestion": "Why was it not helpful?", + "other": "Something else", + "positiveLabel": "This page was helpful", + "question": "Was this page helpful?", + "submit": "Submit", + "submitting": "Sending…", + "thankYou": "Thanks for your feedback!" + }, "navigation": { "breadcrumb": "Breadcrumb", "chooseLanguage": "Choose documentation language", diff --git a/src/components/Feedback.astro b/src/components/Feedback.astro new file mode 100644 index 000000000..1f8dc4e4d --- /dev/null +++ b/src/components/Feedback.astro @@ -0,0 +1,288 @@ +--- +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { uiStrings } from "@/lib/ui-strings.server"; + +interface Props { + currentLocale?: string; +} + +const { currentLocale } = Astro.props; +const messages = uiStrings(currentLocale).feedback; +--- + + + + {messages.question} + + + + + + + + + + + + + + + + + + + + + + {messages.negativeQuestion} + + + + + + + + + {messages.negativeQuestion} + {messages.missingInformation} + {messages.confusing} + {messages.inaccurate} + {messages.other} + + + {messages.additionalFeedback} + + + {messages.submit} + + + + + + + + + + + diff --git a/src/components/feedback.client.ts b/src/components/feedback.client.ts new file mode 100644 index 000000000..124211210 --- /dev/null +++ b/src/components/feedback.client.ts @@ -0,0 +1,127 @@ +import { mount } from "@cloudflare/nimbus-docs/client"; + +const FEEDBACK_ENDPOINT = "https://sql-clickhouse.clickhouse.com"; +const INSERT_QUERY = "INSERT INTO docs_feedback.feedback FORMAT JSONEachRow"; + +type Sentiment = "Positive" | "Negative"; + +function googleAnalyticsId(): string { + const cookie = document.cookie + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith("_ga=")) + ?.slice(4); + const parts = cookie?.split(".").slice(-2); + return parts?.length === 2 ? parts.join("-") : "anonymous"; +} + +function feedbackRow(sentiment: Sentiment, reason = "", comment = "") { + return { + page_url: window.location.href, + date: new Date().toISOString().replace("T", " ").slice(0, 19), + sentiment, + reason, + google_id: googleAnalyticsId(), + comment, + }; +} + +async function sendFeedback(sentiment: Sentiment, reason = "", comment = "") { + const url = new URL(FEEDBACK_ENDPOINT); + url.searchParams.set("query", INSERT_QUERY); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "text/plain", + "x-clickhouse-user": "docs_feedback", + "x-clickhouse-key": "", + }, + body: `${JSON.stringify(feedbackRow(sentiment, reason, comment))}\n`, + }); + + if (!response.ok) { + throw new Error(`Feedback submission failed with status ${response.status}`); + } +} + +mount("[data-ch-feedback]", (root) => { + const positive = root.querySelector("[data-ch-feedback-positive]"); + const negative = root.querySelector("[data-ch-feedback-negative]"); + const form = root.querySelector("[data-ch-feedback-form]"); + const panel = root.querySelector("[data-popover-content]"); + const close = root.querySelector("[data-ch-feedback-close]"); + const question = root.querySelector("[data-ch-feedback-question]"); + const status = root.querySelector("[data-ch-feedback-status]"); + const submit = form?.querySelector("[type=submit]"); + if (!positive || !negative || !form || !panel || !close || !question || !status || !submit) { + return () => {}; + } + + const controller = new AbortController(); + const { signal } = controller; + let submitted = false; + let submitting = false; + + const select = (button: HTMLButtonElement) => { + for (const candidate of [positive, negative]) { + const selected = candidate === button; + candidate.toggleAttribute("data-selected", selected); + candidate.setAttribute("aria-pressed", String(selected)); + } + }; + + const syncDisabledState = () => { + const disabled = submitted || submitting; + positive.disabled = disabled; + negative.disabled = disabled; + submit.disabled = disabled; + }; + + const finish = () => { + submitted = true; + submitting = false; + syncDisabledState(); + question.textContent = root.dataset.thankYouLabel ?? "Thanks for your feedback!"; + status.textContent = ""; + if (panel.matches(":popover-open")) panel.hidePopover(); + }; + + const reportError = () => { + submitting = false; + syncDisabledState(); + status.textContent = root.dataset.errorLabel ?? "Feedback could not be sent. Please try again."; + }; + + const submitFeedback = (sentiment: Sentiment, reason = "", comment = "") => { + if (submitted || submitting) return; + submitting = true; + syncDisabledState(); + status.textContent = root.dataset.submittingLabel ?? "Sending…"; + void sendFeedback(sentiment, reason, comment).then(finish, reportError); + }; + + positive.addEventListener("click", () => { + if (submitted || submitting) return; + select(positive); + submitFeedback("Positive"); + }, { signal }); + + negative.addEventListener("click", () => { + if (!submitted && !submitting) select(negative); + }, { signal }); + + close.addEventListener("click", () => panel.hidePopover(), { signal }); + + form.addEventListener("submit", (event) => { + event.preventDefault(); + if (submitted || submitting) return; + const data = new FormData(form); + submitFeedback( + "Negative", + String(data.get("reason") ?? ""), + String(data.get("comment") ?? ""), + ); + }, { signal }); + + return () => controller.abort(); +}); diff --git a/src/layouts/DocsLayout.astro b/src/layouts/DocsLayout.astro index 7bfb27fb0..eab6718a4 100644 --- a/src/layouts/DocsLayout.astro +++ b/src/layouts/DocsLayout.astro @@ -18,6 +18,7 @@ import { Pagination } from "@/components/ui/pagination"; import { PageActions } from "@/components/ui/page-actions"; import { Badge } from "@/components/ui/badge"; import ViewSwitcher from "@/components/compat/ViewSwitcher.astro"; +import Feedback from "@/components/Feedback.astro"; import type { DocsPageProps } from "@cloudflare/nimbus-docs/types"; import { getVersionStatus, getVersionAlternates } from "@cloudflare/nimbus-docs"; import { withBase } from "@/lib/base"; @@ -299,16 +300,17 @@ const messages = uiStrings(currentLocale); )} - {!isWide && headings !== false && (hasToc || headings.length > 0) && ( + {!isWide && ( )} diff --git a/src/lib/ui-strings.server.ts b/src/lib/ui-strings.server.ts index a041d1b4f..f3ee16aeb 100644 --- a/src/lib/ui-strings.server.ts +++ b/src/lib/ui-strings.server.ts @@ -26,6 +26,22 @@ export type UiStrings = { view: string; viewMarkdown: string; }; + feedback: { + additionalFeedback: string; + close: string; + confusing: string; + error: string; + inaccurate: string; + missingInformation: string; + negativeLabel: string; + negativeQuestion: string; + other: string; + positiveLabel: string; + question: string; + submit: string; + submitting: string; + thankYou: string; + }; navigation: { breadcrumb: string; chooseLanguage: string;
+ {messages.question} +