mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-09-10 22:14:48 -05:00
feat(docs): page feedback widget wired to Formbricks
Swizzles DocItem/Footer to show a 'Was this page helpful?' widget under every doc article, ported from the Vates VMS docs. Votes post to the self-hosted Formbricks instance via its public client API; a thumbs-down asks why (outdated, unclear, missing info, inaccurate) plus optional free text. The vote is recorded on click so it survives the visitor leaving before the follow-up. Page path rides along as a hidden field for per-page filtering.
This commit is contained in:
202
docs/src/components/PageFeedback/index.tsx
Normal file
202
docs/src/components/PageFeedback/index.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {useLocation} from '@docusaurus/router';
|
||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type FormbricksConfig = {
|
||||
apiHost: string;
|
||||
environmentId: string;
|
||||
surveyId: string;
|
||||
};
|
||||
|
||||
// Labels must match the choice labels of the "XO Docs page feedback" survey in Formbricks.
|
||||
const REASONS = [
|
||||
'Outdated',
|
||||
'Unclear or confusing',
|
||||
'Missing information',
|
||||
'Inaccurate',
|
||||
];
|
||||
|
||||
type Step = 'vote' | 'why' | 'done';
|
||||
|
||||
// Mirror feedback into Matomo (when tracking is enabled) so quality
|
||||
// signals can be crossed with traffic data, e.g. thumbs-down per view.
|
||||
const trackFeedbackEvent = (action: string, page: string) => {
|
||||
window._paq?.push(['trackEvent', 'doc_feedback', action, page]);
|
||||
};
|
||||
|
||||
export default function PageFeedback(): React.ReactElement | null {
|
||||
const {siteConfig} = useDocusaurusContext();
|
||||
const formbricks = siteConfig.customFields?.formbricks as FormbricksConfig;
|
||||
const {pathname} = useLocation();
|
||||
|
||||
const [step, setStep] = useState<Step>('vote');
|
||||
const [reasons, setReasons] = useState<string[]>([]);
|
||||
const [details, setDetails] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const responseId = useRef<string | null>(null);
|
||||
const votePending = useRef<Promise<void> | null>(null);
|
||||
|
||||
// A client-side navigation lands on a new page: reset the widget.
|
||||
useEffect(() => {
|
||||
setStep('vote');
|
||||
setReasons([]);
|
||||
setDetails('');
|
||||
responseId.current = null;
|
||||
votePending.current = null;
|
||||
}, [pathname]);
|
||||
|
||||
const responseData = (vote: 'Yes' | 'No') => ({
|
||||
vote,
|
||||
page: pathname,
|
||||
...(reasons.length > 0 ? {reason: reasons} : {}),
|
||||
...(details.trim() ? {details: details.trim()} : {}),
|
||||
});
|
||||
|
||||
const send = async (
|
||||
method: 'POST' | 'PUT',
|
||||
vote: 'Yes' | 'No',
|
||||
finished: boolean,
|
||||
) => {
|
||||
const base = `${formbricks.apiHost}/api/v1/client/${formbricks.environmentId}/responses`;
|
||||
const url = method === 'PUT' ? `${base}/${responseId.current}` : base;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
...(method === 'POST'
|
||||
? {surveyId: formbricks.surveyId, meta: {url: window.location.href}}
|
||||
: {}),
|
||||
finished,
|
||||
data: responseData(vote),
|
||||
}),
|
||||
});
|
||||
if (method === 'POST' && res.ok) {
|
||||
const json = await res.json();
|
||||
responseId.current = json?.data?.id ?? null;
|
||||
}
|
||||
} catch {
|
||||
// Feedback must never break the docs. Votes lost to network
|
||||
// errors or blockers are acceptable.
|
||||
}
|
||||
};
|
||||
|
||||
const vote = (value: 'Yes' | 'No') => {
|
||||
setStep(value === 'Yes' ? 'done' : 'why');
|
||||
trackFeedbackEvent(value === 'Yes' ? 'up' : 'down', pathname);
|
||||
// Record the vote right away so it is kept even if the visitor
|
||||
// leaves without answering the follow-up.
|
||||
votePending.current = send('POST', value, value === 'Yes');
|
||||
};
|
||||
|
||||
const submitWhy = async () => {
|
||||
setSending(true);
|
||||
reasons.forEach((reason) => trackFeedbackEvent(`reason:${reason}`, pathname));
|
||||
// Wait for the initial vote POST so we update its response instead
|
||||
// of creating a duplicate when the visitor answers quickly.
|
||||
await votePending.current;
|
||||
await send(responseId.current ? 'PUT' : 'POST', 'No', true);
|
||||
setSending(false);
|
||||
setStep('done');
|
||||
};
|
||||
|
||||
const toggleReason = (reason: string) =>
|
||||
setReasons((prev) =>
|
||||
prev.includes(reason)
|
||||
? prev.filter((r) => r !== reason)
|
||||
: [...prev, reason],
|
||||
);
|
||||
|
||||
if (!formbricks?.environmentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.feedback}>
|
||||
{step === 'vote' && (
|
||||
<div className={styles.voteRow}>
|
||||
<span className={styles.question}>Was this page helpful?</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.voteButton}
|
||||
onClick={() => vote('Yes')}>
|
||||
<ThumbIcon /> Yes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.voteButton}
|
||||
onClick={() => vote('No')}>
|
||||
<ThumbIcon down /> No
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'why' && (
|
||||
<div className={styles.why}>
|
||||
<span className={styles.question}>
|
||||
Sorry about that. What was the problem?
|
||||
</span>
|
||||
<div className={styles.reasons}>
|
||||
{REASONS.map((reason) => (
|
||||
<button
|
||||
key={reason}
|
||||
type="button"
|
||||
className={styles.reason}
|
||||
aria-pressed={reasons.includes(reason)}
|
||||
onClick={() => toggleReason(reason)}>
|
||||
{reason}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
className={styles.details}
|
||||
aria-label="Additional details"
|
||||
rows={3}
|
||||
placeholder="Tell us what's wrong or missing... (optional)"
|
||||
value={details}
|
||||
onChange={(e) => setDetails(e.target.value)}
|
||||
/>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.submit}
|
||||
disabled={sending}
|
||||
onClick={submitWhy}>
|
||||
Send
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.skip}
|
||||
onClick={() => setStep('done')}>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'done' && (
|
||||
<span className={styles.thanks}>Thanks for your feedback!</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThumbIcon({down = false}: {down?: boolean}) {
|
||||
return (
|
||||
<svg
|
||||
className={down ? styles.thumbDown : undefined}
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<path d="M7 10v12" />
|
||||
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
118
docs/src/components/PageFeedback/styles.module.css
Normal file
118
docs/src/components/PageFeedback/styles.module.css
Normal file
@@ -0,0 +1,118 @@
|
||||
.feedback {
|
||||
margin: 2rem 0 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: var(--ifm-global-radius);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.voteRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.question {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.voteButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.9rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: var(--ifm-global-radius);
|
||||
background: none;
|
||||
color: var(--ifm-font-color-base);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.voteButton:hover {
|
||||
border-color: var(--ifm-color-primary);
|
||||
color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.thumbDown {
|
||||
transform: scaleY(-1);
|
||||
}
|
||||
|
||||
.why {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.reasons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reason {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 1rem;
|
||||
background: none;
|
||||
color: var(--ifm-font-color-base);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reason:hover {
|
||||
border-color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.reason[aria-pressed='true'] {
|
||||
border-color: var(--ifm-color-primary);
|
||||
background: var(--ifm-color-primary);
|
||||
color: var(--ifm-color-primary-contrast-foreground, #fff);
|
||||
}
|
||||
|
||||
.details {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: var(--ifm-global-radius);
|
||||
background: var(--ifm-background-color);
|
||||
color: var(--ifm-font-color-base);
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.submit {
|
||||
padding: 0.3rem 1.1rem;
|
||||
border: 1px solid var(--ifm-color-primary);
|
||||
border-radius: var(--ifm-global-radius);
|
||||
background: var(--ifm-color-primary);
|
||||
color: var(--ifm-color-primary-contrast-foreground, #fff);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.skip {
|
||||
padding: 0.3rem 1.1rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: var(--ifm-global-radius);
|
||||
background: none;
|
||||
color: var(--ifm-font-color-secondary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thanks {
|
||||
font-weight: 600;
|
||||
}
|
||||
16
docs/src/theme/DocItem/Footer/index.tsx
Normal file
16
docs/src/theme/DocItem/Footer/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import Footer from '@theme-original/DocItem/Footer';
|
||||
import type FooterType from '@theme/DocItem/Footer';
|
||||
import type {WrapperProps} from '@docusaurus/types';
|
||||
import PageFeedback from '@site/src/components/PageFeedback';
|
||||
|
||||
type Props = WrapperProps<typeof FooterType>;
|
||||
|
||||
export default function FooterWrapper(props: Props): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<PageFeedback />
|
||||
<Footer {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user