Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e9c705f
Swizzle DocItem/Footer as wrapper for feedback widget
pwizla May 6, 2026
80f2c14
Add ThankYou component for post-submission state
pwizla May 6, 2026
d004dc8
Add feedback API stub with localhost bypass
pwizla May 6, 2026
5bbaf4e
Add FeedbackForm component with required and optional modes
pwizla May 6, 2026
f435f65
Add PageFeedback SCSS styles with dark mode support
pwizla May 6, 2026
a3a70cb
Add PageFeedback widget with voting state machine
pwizla May 6, 2026
38a622e
Wire PageFeedback widget into DocItem/Footer swizzle
pwizla May 6, 2026
873da03
Add @notionhq/client for feedback API
pwizla May 6, 2026
7c4abff
Add feedback payload validator
pwizla May 6, 2026
f3785f8
Add in-memory rate limiter for feedback API
pwizla May 6, 2026
e7a7e87
Add Notion client helper for feedback writes
pwizla May 6, 2026
9d0bb3d
Add Vercel Function for feedback submissions
pwizla May 6, 2026
80b4477
Clean up feedback API client for production use
pwizla May 6, 2026
df26ddb
Add CORS headers for feedback API endpoint
pwizla May 6, 2026
bac78a2
Rename ID to Feedback ID to match Notion database schema
pwizla May 6, 2026
b3ec06d
Raise minimum comment length to 20 characters for negative feedback
pwizla May 6, 2026
4092c51
Use Phosphor Icons for thank you state instead of emoji
pwizla May 6, 2026
ae12681
Hide disclosure text after feedback submission
pwizla May 6, 2026
06ae788
Add GitHub issue creation button on negative feedback (Level 3)
pwizla May 6, 2026
ab69738
Add documentation feedback widget section to Usage information page
pwizla May 6, 2026
e3d642d
Add floating selection feedback bubble
pwizla May 6, 2026
7321467
Add heading anchor feedback buttons for H2/H3
pwizla May 6, 2026
9a4e3c9
Add DOM helpers for selection-based feedback
pwizla May 6, 2026
20f5de2
Remove disclosure text from feedback widget (moved to Usage Informati…
pwizla May 6, 2026
0243d93
Wire Level 2 selection feedback into DocItem/Footer with styles
pwizla May 6, 2026
30a35bd
Skip Kapa auto-response for feedback: from-docs-widget label
pwizla May 6, 2026
c9eb634
Rename to 'Leave feedback', double widget margin, add issue template,…
pwizla May 6, 2026
a2d96fa
Include selected text in pre-filled GitHub issue body
pwizla May 6, 2026
1d2837e
Put thank you icon and text on the same line
pwizla May 6, 2026
c581933
Pivot feedback backend from Vercel Functions to n8n webhook
pwizla May 7, 2026
d494e36
Wire honeypot field value into feedback submission payload
pwizla May 7, 2026
7aeecfb
Change spam alert frequency from every minute to every hour
pwizla May 7, 2026
b125efd
Remove n8n workflow JSON files from repo
pwizla May 7, 2026
ae64cc1
Add kill switch to hide feedback widget on all pages
pwizla May 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/ISSUE_TEMPLATE/doc-feedback.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Documentation feedback
description: Feedback submitted from the docs.strapi.io feedback widget
labels: ["feedback: from-docs-widget"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to give us feedback!
The fields below are pre-filled from the widget. Edit them if needed.
- type: input
id: page
attributes:
label: Page
description: Which doc page is this about?
validations:
required: true
- type: textarea
id: selection
attributes:
label: Selected text or section
description: The specific part of the page you selected (if any)
- type: textarea
id: comment
attributes:
label: Your feedback
validations:
required: true
10 changes: 9 additions & 1 deletion .github/workflows/auto-respond-issues.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,15 @@ jobs:
console.log('Issue from maintainer, skipping auto-response');
return;
}


// Skip if issue has a label that should bypass auto-response
const skipLabels = ['feedback: from-docs-widget'];
const issueLabels = (issue.labels || []).map(l => typeof l === 'string' ? l : l.name);
if (issueLabels.some(label => skipLabels.includes(label))) {
console.log('Issue has skip label, skipping auto-response');
return;
}

// Prepare the enhanced query for Kapa AI
const cleanedBody = issueBody
.replace(/<!--[\s\S]*?-->/g, '') // Remove HTML comments
Expand Down
14 changes: 14 additions & 0 deletions docusaurus/docs/cms/usage-information.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@ Data collection can later be re-enabled by deleting the flag or setting it to fa
If you have any questions or concerns regarding data collection, please contact us at the following email address [privacy@strapi.io](mailto:privacy@strapi.io).
:::

## Documentation feedback widget {#documentation-feedback-widget}

The documentation website at [docs.strapi.io](https://docs.strapi.io) includes a feedback widget at the bottom of each page. When you submit feedback, the following data is collected:

- Your vote (positive or negative)
- Your comment (if provided)
- The page URL and title
- Your browser's user agent string (for debugging purposes)
- Your country (inferred from your IP address by Vercel; the IP address itself is not stored)

This data is stored in a private Notion database accessible only to the Strapi documentation team. No email address, name, or other personally identifiable information is collected. Feedback is anonymous and cannot be traced back to individual users.

For questions about feedback data collection, contact [privacy@strapi.io](mailto:privacy@strapi.io).

## Strapi AI data handling {#strapi-ai-data-handling}

[Strapi AI features](/cms/ai/for-content-managers) process requests through Strapi-managed infrastructure. Temporary metadata and content snippets exist only for the duration of each request. Strapi never stores unpublished content or credentials outside your instance.
Expand Down
82 changes: 82 additions & 0 deletions docusaurus/src/components/PageFeedback/FeedbackForm.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { useState } from 'react';

const MIN_COMMENT_LENGTH = 20;
const MAX_COMMENT_LENGTH = 2000;

export default function FeedbackForm({
onSubmit,
onCancel,
isSubmitting,
required,
}) {
const [comment, setComment] = useState('');

const trimmed = comment.trim();
const isTooLong = trimmed.length > MAX_COMMENT_LENGTH;
const canSubmit = required
? trimmed.length >= MIN_COMMENT_LENGTH && !isTooLong && !isSubmitting
: !isTooLong && !isSubmitting;

function handleSubmit(e) {
e.preventDefault();
if (!canSubmit) return;
const hp = e.target.elements._hp?.value || '';
onSubmit(trimmed || null, hp);
}

return (
<form onSubmit={handleSubmit} className="pageFeedback__form">
<label htmlFor="feedback-comment" className="pageFeedback__formLabel">
{required ? 'Please tell us more' : 'Want to add a comment? (optional)'}
</label>
<textarea
id="feedback-comment"
className="pageFeedback__textarea"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder={
required
? 'What was missing or confusing?'
: 'What did you find helpful?'
}
rows={3}
maxLength={MAX_COMMENT_LENGTH}
disabled={isSubmitting}
autoFocus
/>
{/* Honeypot -- hidden from real users, bots fill it in */}
<input
type="text"
name="_hp"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
style={{ position: 'absolute', left: '-9999px' }}
/>
<div className="pageFeedback__formActions">
<span className="pageFeedback__charCount">
{required && trimmed.length < MIN_COMMENT_LENGTH
? `At least ${MIN_COMMENT_LENGTH - trimmed.length} more character${MIN_COMMENT_LENGTH - trimmed.length === 1 ? '' : 's'}`
: `${trimmed.length} / ${MAX_COMMENT_LENGTH}`}
</span>
<div className="pageFeedback__formButtons">
<button
type="button"
className="pageFeedback__cancelButton"
onClick={onCancel}
disabled={isSubmitting}
>
Cancel
</button>
<button
type="submit"
className="pageFeedback__submitButton"
disabled={!canSubmit}
>
{isSubmitting ? 'Sending...' : 'Submit'}
</button>
</div>
</div>
</form>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React, { useEffect, useState, useCallback } from 'react';

/**
* Injects a small feedback button in the gutter next to each H2/H3 heading
* inside <article>. The button appears on heading hover and clicking it
* opens the feedback form pre-filled with the heading text and anchor.
*/
export default function HeadingAnchor({ onFeedback }) {
const [headings, setHeadings] = useState([]);

useEffect(() => {
const article = document.querySelector('article');
if (!article) return;

const found = Array.from(article.querySelectorAll('h2[id], h3[id]')).map((el) => ({
id: el.id,
text: el.textContent.trim(),
el,
}));
setHeadings(found);
}, []);

const handleClick = useCallback(
(heading) => {
onFeedback({
kind: 'element',
selection: {
text: heading.text,
sectionHeading: heading.text,
anchor: heading.id,
},
});
},
[onFeedback],
);

if (headings.length === 0) return null;

return (
<>
{headings.map((heading) => (
<HeadingButton key={heading.id} heading={heading} onClick={handleClick} />
))}
</>
);
}

function HeadingButton({ heading, onClick }) {
const [pos, setPos] = useState(null);
const [visible, setVisible] = useState(false);

useEffect(() => {
function updatePosition() {
const rect = heading.el.getBoundingClientRect();
setPos({
top: rect.top + window.scrollY + rect.height / 2 - 12,
});
}
updatePosition();

// Show button when hovering the heading element
const show = () => setVisible(true);
const hide = () => setVisible(false);
heading.el.addEventListener('mouseenter', show);
heading.el.addEventListener('mouseleave', hide);

window.addEventListener('resize', updatePosition);
return () => {
heading.el.removeEventListener('mouseenter', show);
heading.el.removeEventListener('mouseleave', hide);
window.removeEventListener('resize', updatePosition);
};
}, [heading.el]);

if (!pos) return null;

return (
<button
className="headingAnchor__button"
style={{
position: 'absolute',
top: `${pos.top}px`,
left: '-36px',
opacity: visible ? 1 : undefined,
}}
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
onClick={() => onClick(heading)}
aria-label={`Give feedback on section: ${heading.text}`}
title="Give feedback on this section"
>
<i className="ph ph-chat-text" aria-hidden="true" />
</button>
);
}
105 changes: 105 additions & 0 deletions docusaurus/src/components/PageFeedback/SelectionFeedback/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React, { useState, useEffect, useCallback } from 'react';
import { findClosestHeading, findClosestAnchor, isInsideExcludedElement } from './selectionHelpers';

/**
* Floating bubble that appears when the user selects text inside <article>.
* Clicking it opens a feedback form pre-filled with the selected text.
*/
export default function SelectionFeedback({ onFeedback }) {
const [bubble, setBubble] = useState(null);

const handleSelectionChange = useCallback(() => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
setBubble(null);
return;
}

const range = selection.getRangeAt(0);
const text = selection.toString().trim();
if (!text || text.length < 3) {
setBubble(null);
return;
}

// Only trigger inside the article content area
const article = document.querySelector('article');
if (!article || !article.contains(range.commonAncestorContainer)) {
setBubble(null);
return;
}

// Skip code blocks, inputs, search, Kapa modal, and our own widget
if (isInsideExcludedElement(range.commonAncestorContainer)) {
setBubble(null);
return;
}

const rect = range.getBoundingClientRect();
setBubble({
text: text.slice(0, 500),
sectionHeading: findClosestHeading(range.commonAncestorContainer),
anchor: findClosestAnchor(range.commonAncestorContainer),
x: rect.left + rect.width / 2,
y: rect.top + window.scrollY - 44,
});
}, []);

useEffect(() => {
document.addEventListener('mouseup', handleSelectionChange);
document.addEventListener('keyup', handleSelectionChange);
return () => {
document.removeEventListener('mouseup', handleSelectionChange);
document.removeEventListener('keyup', handleSelectionChange);
};
}, [handleSelectionChange]);

// Dismiss on click outside
useEffect(() => {
if (!bubble) return;
function handleClick(e) {
if (!e.target.closest('.selectionFeedback__bubble')) {
setBubble(null);
}
}
// Delay to avoid catching the mouseup that created the bubble
const timer = setTimeout(() => {
document.addEventListener('mousedown', handleClick);
}, 100);
return () => {
clearTimeout(timer);
document.removeEventListener('mousedown', handleClick);
};
}, [bubble]);

if (!bubble) return null;

return (
<button
className="selectionFeedback__bubble"
style={{
position: 'absolute',
left: `${bubble.x}px`,
top: `${bubble.y}px`,
transform: 'translateX(-50%)',
zIndex: 100,
}}
onClick={() => {
onFeedback({
kind: 'selection',
selection: {
text: bubble.text,
sectionHeading: bubble.sectionHeading,
anchor: bubble.anchor,
},
});
setBubble(null);
window.getSelection()?.removeAllRanges();
}}
aria-label="Leave feedback on selected text"
>
<i className="ph ph-chat-text" aria-hidden="true" />
<span>Leave feedback</span>
</button>
);
}
Loading
Loading