), inputs, textareas, the search overlay, or the Kapa modal.
+ */
+export function isInsideExcludedElement(node) {
+ let current = node;
+ while (current) {
+ if (current.nodeType === 1) {
+ const tag = current.tagName;
+ if (tag === 'PRE' || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'CODE') {
+ return true;
+ }
+ // Kapa modal or search overlay
+ if (
+ current.classList &&
+ (current.classList.contains('kapa-modal') ||
+ current.classList.contains('DocSearch-Modal') ||
+ current.id === 'feedback-comment')
+ ) {
+ return true;
+ }
+ // Our own feedback widget
+ if (current.getAttribute('aria-label') === 'Page feedback') {
+ return true;
+ }
+ }
+ current = current.parentElement;
+ }
+ return false;
+}
diff --git a/docusaurus/src/components/PageFeedback/ThankYou.jsx b/docusaurus/src/components/PageFeedback/ThankYou.jsx
new file mode 100644
index 0000000000..07b1c563a5
--- /dev/null
+++ b/docusaurus/src/components/PageFeedback/ThankYou.jsx
@@ -0,0 +1,53 @@
+import React from 'react';
+
+function buildGitHubIssueUrl({ pagePath, pageTitle, comment, selectionText }) {
+ const title = `[Doc feedback] ${pageTitle}`;
+ const body = [
+ `**Page:** [${pageTitle}](https://docs.strapi.io${pagePath})`,
+ selectionText ? `\n**Selected text:**\n> ${selectionText}` : null,
+ '',
+ '**Feedback:**',
+ comment,
+ '',
+ '',
+ ].filter(Boolean).join('\n');
+
+ const params = new URLSearchParams({
+ template: 'doc-feedback.yml',
+ title,
+ body,
+ labels: 'feedback: from-docs-widget',
+ });
+
+ return `https://github.com/strapi/documentation/issues/new?${params.toString()}`;
+}
+
+export default function ThankYou({ vote, pagePath, pageTitle, comment, selectionText }) {
+ return (
+
+
+
+
+ {vote === 'up'
+ ? 'Thanks for your feedback!'
+ : 'Thanks for letting us know. We\'ll look into it.'}
+
+
+ {vote === 'down' && comment && (
+
+
+ {' '}Create a GitHub issue
+
+ )}
+
+ );
+}
diff --git a/docusaurus/src/components/PageFeedback/api.js b/docusaurus/src/components/PageFeedback/api.js
new file mode 100644
index 0000000000..0285f20159
--- /dev/null
+++ b/docusaurus/src/components/PageFeedback/api.js
@@ -0,0 +1,28 @@
+/**
+ * Submit feedback to the n8n webhook backend.
+ *
+ * Returns the created feedback ID on success, or throws on failure.
+ * The endpoint URL is configured once here so swapping backends
+ * requires changing only this file.
+ */
+
+const FEEDBACK_ENDPOINT = 'https://n8n.tools.strapi.team/webhook/docs-feedback';
+
+export async function submitFeedback(payload) {
+ const response = await fetch(FEEDBACK_ENDPOINT, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Feedback-Source': 'docs-widget',
+ },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Feedback submission failed (${response.status}): ${text}`);
+ }
+
+ const data = await response.json();
+ return data.id;
+}
diff --git a/docusaurus/src/components/PageFeedback/config.js b/docusaurus/src/components/PageFeedback/config.js
new file mode 100644
index 0000000000..e82be64bbd
--- /dev/null
+++ b/docusaurus/src/components/PageFeedback/config.js
@@ -0,0 +1,5 @@
+/**
+ * Kill switch for the feedback widget.
+ * Set to false to hide the widget on all pages.
+ */
+export const FEEDBACK_ENABLED = true;
diff --git a/docusaurus/src/components/PageFeedback/index.jsx b/docusaurus/src/components/PageFeedback/index.jsx
new file mode 100644
index 0000000000..5435e98e39
--- /dev/null
+++ b/docusaurus/src/components/PageFeedback/index.jsx
@@ -0,0 +1,142 @@
+import React, { useState, useCallback, useImperativeHandle, forwardRef } from 'react';
+import FeedbackForm from './FeedbackForm';
+import ThankYou from './ThankYou';
+import { submitFeedback } from './api';
+import { FEEDBACK_ENABLED } from './config';
+import styles from './styles.module.scss';
+
+const PageFeedback = forwardRef(function PageFeedback({ pagePath, pageId, pageTitle }, ref) {
+ if (!FEEDBACK_ENABLED) return null;
+ const [stage, setStage] = useState('initial'); // initial | form | submitting | done | error
+ const [vote, setVote] = useState(null);
+ const [lastComment, setLastComment] = useState(null);
+ const [selectionData, setSelectionData] = useState(null); // L2: { kind, selection }
+
+ const handleVote = useCallback((value) => {
+ setVote(value);
+ setSelectionData(null);
+ setStage('form');
+ }, []);
+
+ const handleSelectionFeedback = useCallback((data) => {
+ setVote('down');
+ setSelectionData(data);
+ setStage('form');
+ document.querySelector('[aria-label="Page feedback"]')?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'center',
+ });
+ }, []);
+
+ // Expose handleSelectionFeedback to the footer wrapper via ref
+ useImperativeHandle(ref, () => ({
+ onSelectionFeedback: handleSelectionFeedback,
+ }), [handleSelectionFeedback]);
+
+ const doSubmit = useCallback(
+ async (comment, hp) => {
+ setLastComment(comment);
+ setStage('submitting');
+ try {
+ await submitFeedback({
+ kind: selectionData?.kind || 'page',
+ vote,
+ comment: comment || undefined,
+ pagePath,
+ pageId,
+ pageTitle,
+ selection: selectionData?.selection || undefined,
+ _hp: hp || undefined,
+ });
+ setStage('done');
+ } catch {
+ setStage('error');
+ }
+ },
+ [vote, pagePath, pageId, pageTitle, selectionData],
+ );
+
+ const handleCancel = useCallback(() => {
+ setSelectionData(null);
+ setStage('initial');
+ }, []);
+
+ return (
+
+ {stage === 'initial' && (
+
+
+ Was this page helpful?
+
+
+
+
+
+
+ )}
+
+ {stage === 'form' && (
+ <>
+ {selectionData && (
+
+
+
+ {selectionData.selection?.text?.slice(0, 100)}
+ {selectionData.selection?.text?.length > 100 ? '...' : ''}
+
+
+ )}
+
+ >
+ )}
+
+ {stage === 'submitting' && (
+
+ Sending feedback...
+
+ )}
+
+ {stage === 'done' && (
+
+ )}
+
+ {stage === 'error' && (
+
+ Something went wrong. Please try again.
+
+
+ )}
+
+
+ );
+});
+
+export default PageFeedback;
diff --git a/docusaurus/src/components/PageFeedback/styles.module.scss b/docusaurus/src/components/PageFeedback/styles.module.scss
new file mode 100644
index 0000000000..5b00cf6cf6
--- /dev/null
+++ b/docusaurus/src/components/PageFeedback/styles.module.scss
@@ -0,0 +1,303 @@
+@use '../../scss/_mixins.scss' as *;
+
+.pageFeedback {
+ margin-top: 4rem;
+ margin-bottom: var(--strapi-spacing-4, 1rem);
+ padding: var(--strapi-spacing-4, 1rem) var(--strapi-spacing-5, 1.5rem);
+ border: 1px solid var(--ifm-toc-border-color);
+ border-radius: 8px;
+ background: var(--ifm-background-surface-color, #fff);
+ text-align: center;
+
+ @include dark {
+ .pageFeedback {
+ background: var(--ifm-background-surface-color, #1e1e1e);
+ }
+ }
+}
+
+.pageFeedback__prompt {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--strapi-spacing-3, 0.75rem);
+ flex-wrap: wrap;
+}
+
+.pageFeedback__question {
+ font-size: 0.95rem;
+ font-weight: 500;
+ color: var(--ifm-font-color-base);
+}
+
+.pageFeedback__buttons {
+ display: flex;
+ gap: var(--strapi-spacing-2, 0.5rem);
+}
+
+.pageFeedback__voteButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border: 1px solid var(--ifm-toc-border-color);
+ border-radius: 8px;
+ background: transparent;
+ cursor: pointer;
+ font-size: 1.25rem;
+ color: var(--ifm-font-color-secondary);
+ @include transition;
+
+ &:hover {
+ border-color: var(--ifm-color-primary);
+ color: var(--ifm-color-primary);
+ background: var(--ifm-color-primary-lightest, rgba(99, 91, 255, 0.05));
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--ifm-color-primary);
+ outline-offset: 2px;
+ }
+}
+
+/* Form (shown after voting) */
+:global {
+ .pageFeedback__form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--strapi-spacing-3, 0.75rem);
+ text-align: left;
+ }
+
+ .pageFeedback__formLabel {
+ font-size: 0.95rem;
+ font-weight: 500;
+ color: var(--ifm-font-color-base);
+ }
+
+ .pageFeedback__textarea {
+ width: 100%;
+ min-height: 80px;
+ padding: var(--strapi-spacing-2, 0.5rem) var(--strapi-spacing-3, 0.75rem);
+ border: 1px solid var(--ifm-toc-border-color);
+ border-radius: 6px;
+ background: var(--ifm-background-color);
+ color: var(--ifm-font-color-base);
+ font-family: inherit;
+ font-size: 0.9rem;
+ resize: vertical;
+
+ &:focus {
+ border-color: var(--ifm-color-primary);
+ outline: none;
+ box-shadow: 0 0 0 2px var(--ifm-color-primary-lightest, rgba(99, 91, 255, 0.15));
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+ }
+
+ .pageFeedback__formActions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: var(--strapi-spacing-2, 0.5rem);
+ }
+
+ .pageFeedback__charCount {
+ font-size: 0.8rem;
+ color: var(--ifm-font-color-secondary);
+ }
+
+ .pageFeedback__formButtons {
+ display: flex;
+ gap: var(--strapi-spacing-2, 0.5rem);
+ }
+
+ .pageFeedback__cancelButton {
+ padding: var(--strapi-spacing-1, 0.25rem) var(--strapi-spacing-3, 0.75rem);
+ border: 1px solid var(--ifm-toc-border-color);
+ border-radius: 6px;
+ background: transparent;
+ color: var(--ifm-font-color-secondary);
+ cursor: pointer;
+ font-size: 0.85rem;
+
+ &:hover {
+ border-color: var(--ifm-font-color-secondary);
+ }
+ }
+
+ .pageFeedback__submitButton {
+ padding: var(--strapi-spacing-1, 0.25rem) var(--strapi-spacing-3, 0.75rem);
+ border: none;
+ border-radius: 6px;
+ background: var(--ifm-color-primary);
+ color: #fff;
+ cursor: pointer;
+ font-size: 0.85rem;
+ font-weight: 500;
+
+ &:hover:not(:disabled) {
+ background: var(--ifm-color-primary-dark);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+
+ .pageFeedback__thankYou {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--strapi-spacing-2, 0.5rem);
+ padding: var(--strapi-spacing-2, 0.5rem) 0;
+ }
+
+ .pageFeedback__thankYouRow {
+ display: flex;
+ align-items: center;
+ gap: var(--strapi-spacing-2, 0.5rem);
+
+ i {
+ font-size: 1.2rem;
+ }
+ }
+
+ .pageFeedback__thankYouText {
+ margin: 0;
+ font-size: 0.95rem;
+ color: var(--ifm-font-color-base);
+ }
+
+ .pageFeedback__issueLink {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--strapi-spacing-1, 0.25rem);
+ margin-top: var(--strapi-spacing-1, 0.25rem);
+ padding: var(--strapi-spacing-1, 0.25rem) var(--strapi-spacing-3, 0.75rem);
+ border: 1px solid var(--ifm-toc-border-color);
+ border-radius: 6px;
+ font-size: 0.85rem;
+ color: var(--ifm-font-color-secondary);
+ text-decoration: none;
+
+ &:hover {
+ border-color: var(--ifm-color-primary);
+ color: var(--ifm-color-primary);
+ text-decoration: none;
+ }
+ }
+}
+
+.pageFeedback__loading {
+ padding: var(--strapi-spacing-3, 0.75rem) 0;
+ font-size: 0.9rem;
+ color: var(--ifm-font-color-secondary);
+}
+
+.pageFeedback__error {
+ padding: var(--strapi-spacing-2, 0.5rem) 0;
+ color: var(--ifm-color-danger);
+ font-size: 0.9rem;
+
+ p {
+ margin: 0 0 var(--strapi-spacing-2, 0.5rem);
+ }
+}
+
+.pageFeedback__retryButton {
+ padding: var(--strapi-spacing-1, 0.25rem) var(--strapi-spacing-3, 0.75rem);
+ border: 1px solid var(--ifm-color-danger);
+ border-radius: 6px;
+ background: transparent;
+ color: var(--ifm-color-danger);
+ cursor: pointer;
+ font-size: 0.85rem;
+
+ &:hover {
+ background: var(--ifm-color-danger);
+ color: #fff;
+ }
+}
+
+/* Selection context shown above the form when triggered from L2 */
+.pageFeedback__selectionContext {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--strapi-spacing-2, 0.5rem);
+ padding: var(--strapi-spacing-2, 0.5rem) var(--strapi-spacing-3, 0.75rem);
+ margin-bottom: var(--strapi-spacing-2, 0.5rem);
+ border-left: 3px solid var(--ifm-color-primary);
+ background: var(--ifm-color-primary-lightest, rgba(99, 91, 255, 0.05));
+ border-radius: 0 6px 6px 0;
+ font-size: 0.85rem;
+ color: var(--ifm-font-color-secondary);
+ text-align: left;
+
+ i {
+ flex-shrink: 0;
+ margin-top: 2px;
+ color: var(--ifm-color-primary);
+ }
+}
+
+/* L2: Floating selection bubble */
+:global {
+ .selectionFeedback__bubble {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--strapi-spacing-1, 0.25rem);
+ padding: 6px 12px;
+ border: none;
+ border-radius: 20px;
+ background: var(--ifm-color-primary);
+ color: #fff;
+ font-size: 0.8rem;
+ font-weight: 500;
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ white-space: nowrap;
+ @include transition;
+
+ &:hover {
+ background: var(--ifm-color-primary-dark);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+ }
+
+ i {
+ font-size: 1rem;
+ }
+ }
+
+ /* L2: Heading anchor feedback button */
+ .headingAnchor__button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--ifm-font-color-secondary);
+ cursor: pointer;
+ font-size: 0.9rem;
+ opacity: 0;
+ @include transition;
+
+ &:hover {
+ opacity: 1;
+ border-color: var(--ifm-color-primary);
+ color: var(--ifm-color-primary);
+ background: var(--ifm-color-primary-lightest, rgba(99, 91, 255, 0.05));
+ }
+ }
+
+}
diff --git a/docusaurus/src/theme/DocItem/Footer/index.js b/docusaurus/src/theme/DocItem/Footer/index.js
new file mode 100644
index 0000000000..4e5beccbb9
--- /dev/null
+++ b/docusaurus/src/theme/DocItem/Footer/index.js
@@ -0,0 +1,29 @@
+import React, { useRef, useCallback } from 'react';
+import Footer from '@theme-original/DocItem/Footer';
+import PageFeedback from '@site/src/components/PageFeedback';
+import SelectionFeedback from '@site/src/components/PageFeedback/SelectionFeedback';
+import HeadingAnchor from '@site/src/components/PageFeedback/SelectionFeedback/HeadingAnchor';
+import { useDoc } from '@docusaurus/plugin-content-docs/client';
+
+export default function FooterWrapper(props) {
+ const { metadata } = useDoc();
+ const feedbackRef = useRef(null);
+
+ const handleSelectionFeedback = useCallback((data) => {
+ feedbackRef.current?.onSelectionFeedback(data);
+ }, []);
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/docusaurus/yarn.lock b/docusaurus/yarn.lock
index 9524f60f21..3475befe9c 100644
--- a/docusaurus/yarn.lock
+++ b/docusaurus/yarn.lock
@@ -2956,13 +2956,13 @@ autoprefixer@^10.4.14, autoprefixer@^10.4.19:
picocolors "^1.0.1"
postcss-value-parser "^4.2.0"
-axios@^1.9.0:
- version "1.9.0"
- resolved "https://registry.yarnpkg.com/axios/-/axios-1.9.0.tgz#25534e3b72b54540077d33046f77e3b8d7081901"
- integrity sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==
+axios@1.13.5:
+ version "1.13.5"
+ resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.5.tgz#5e464688fa127e11a660a2c49441c009f6567a43"
+ integrity sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==
dependencies:
- follow-redirects "^1.15.6"
- form-data "^4.0.0"
+ follow-redirects "^1.15.11"
+ form-data "^4.0.5"
proxy-from-env "^1.1.0"
babel-loader@^9.1.3:
@@ -4902,11 +4902,16 @@ flat@^5.0.2:
resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241"
integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==
-follow-redirects@^1.0.0, follow-redirects@^1.15.6:
+follow-redirects@^1.0.0:
version "1.15.9"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1"
integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==
+follow-redirects@^1.15.11:
+ version "1.16.0"
+ resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc"
+ integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==
+
foreground-child@^3.1.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.0.tgz#0ac8644c06e431439f8561db8ecf29a7b5519c77"
@@ -4947,14 +4952,15 @@ form-data-encoder@^2.1.2:
resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-2.1.4.tgz#261ea35d2a70d48d30ec7a9603130fa5515e9cd5"
integrity sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==
-form-data@^4.0.0:
- version "4.0.2"
- resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.2.tgz#35cabbdd30c3ce73deb2c42d3c8d3ed9ca51794c"
- integrity sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==
+form-data@^4.0.5:
+ version "4.0.5"
+ resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053"
+ integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.8"
es-set-tostringtag "^2.1.0"
+ hasown "^2.0.2"
mime-types "^2.1.12"
format@^0.2.0: