mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
feat(docs): add optional email to page feedback (#10697)
This commit is contained in:
@@ -39,3 +39,28 @@ $ GIT_USER=<Your GitHub username> yarn deploy
|
||||
```
|
||||
|
||||
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
|
||||
|
||||
### Feedback follow-up
|
||||
|
||||
The page feedback widget accepts an optional email address after a negative vote.
|
||||
It sends the address as `data.email` in the same Formbricks response as the comment,
|
||||
only when the visitor selects **Send**. An empty address is omitted; **Skip** saves
|
||||
only the vote. Email addresses are not included in Matomo events.
|
||||
|
||||
Before deploying this widget, add an optional **Open Text** question to the
|
||||
Formbricks survey configured in `customFields.formbricks`:
|
||||
|
||||
- Question ID: `email` (the API key, not just the displayed label).
|
||||
- Input type: `email`.
|
||||
- Required: off.
|
||||
- Label: `Want a follow-up? (optional)`.
|
||||
- Description: `We'll only use this to ask about your feedback or let you know when it's addressed.`
|
||||
|
||||
Keep the existing question IDs and reason choices unchanged. Apply the same field
|
||||
to the XCP-ng, Xen Orchestra, and Product docs surveys before deploying their
|
||||
respective widgets. Verify that a response with an email is visible in Formbricks
|
||||
and that anonymous feedback still works.
|
||||
|
||||
Follow-ups are manual. Use the address only to clarify the report or reply once
|
||||
the correction is live, and record the reply in a private triage note. Do not copy
|
||||
the address into public issues or pull requests.
|
||||
|
||||
@@ -1,202 +1,247 @@
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {useLocation} from '@docusaurus/router';
|
||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
import styles from './styles.module.css';
|
||||
import React, { useEffect, useId, 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;
|
||||
};
|
||||
apiHost: string
|
||||
environmentId: string
|
||||
surveyId: string
|
||||
}
|
||||
|
||||
type Vote = 'Yes' | 'No'
|
||||
type Step = 'vote' | 'why' | 'done'
|
||||
|
||||
// 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';
|
||||
const REASONS = ['Outdated', 'Unclear or confusing', 'Missing information', 'Inaccurate']
|
||||
|
||||
// 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]);
|
||||
};
|
||||
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],
|
||||
);
|
||||
const { siteConfig } = useDocusaurusContext()
|
||||
const formbricks = siteConfig.customFields?.formbricks as FormbricksConfig
|
||||
const { pathname } = useLocation()
|
||||
|
||||
if (!formbricks?.environmentId) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return <FeedbackForm key={pathname} formbricks={formbricks} pathname={pathname} />
|
||||
}
|
||||
|
||||
function FeedbackForm({ formbricks, pathname }: { formbricks: FormbricksConfig; pathname: string }) {
|
||||
const [step, setStep] = useState<Step>('vote')
|
||||
const [reasons, setReasons] = useState<string[]>([])
|
||||
const [details, setDetails] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const emailId = useId()
|
||||
const active = useRef(true)
|
||||
const responseId = useRef<string | null>(null)
|
||||
const votePending = useRef<Promise<boolean> | null>(null)
|
||||
const request = useRef<AbortController | null>(null)
|
||||
const trackedVote = useRef<Vote | null>(null)
|
||||
|
||||
// Each page gets its own form and response. Abort any request when leaving it.
|
||||
useEffect(() => {
|
||||
active.current = true
|
||||
return () => {
|
||||
active.current = false
|
||||
request.current?.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const send = async (value: Vote, finished: boolean, includeDetails = false) => {
|
||||
const base = `${formbricks.apiHost}/api/v1/client/${formbricks.environmentId}/responses`
|
||||
const id = responseId.current
|
||||
const controller = new AbortController()
|
||||
request.current = controller
|
||||
const timeout = setTimeout(() => controller.abort(), 15000)
|
||||
try {
|
||||
const res = await fetch(id ? `${base}/${id}` : base, {
|
||||
method: id ? 'PUT' : 'POST',
|
||||
signal: controller.signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...(!id ? { surveyId: formbricks.surveyId, meta: { url: window.location.href } } : {}),
|
||||
finished,
|
||||
data: {
|
||||
vote: value,
|
||||
page: pathname,
|
||||
...(includeDetails
|
||||
? {
|
||||
...(reasons.length > 0 ? { reason: reasons } : {}),
|
||||
...(details.trim() ? { details: details.trim() } : {}),
|
||||
...(email.trim() ? { email: email.trim() } : {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
})
|
||||
if (!res.ok) return false
|
||||
if (!id) {
|
||||
const json = await res.json()
|
||||
if (!json?.data?.id) return false
|
||||
responseId.current = json.data.id
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
const vote = async (value: Vote) => {
|
||||
setError(false)
|
||||
if (trackedVote.current !== value) {
|
||||
trackFeedbackEvent(value === 'Yes' ? 'up' : 'down', pathname)
|
||||
trackedVote.current = value
|
||||
}
|
||||
// Save the vote even if the visitor leaves without completing the follow-up.
|
||||
votePending.current = send(value, value === 'Yes')
|
||||
if (value === 'No') {
|
||||
setStep('why')
|
||||
return
|
||||
}
|
||||
setSending(true)
|
||||
const saved = await votePending.current
|
||||
if (!active.current) return
|
||||
setSending(false)
|
||||
if (saved) setStep('done')
|
||||
else setError(true)
|
||||
}
|
||||
|
||||
const finish = async (includeDetails: boolean) => {
|
||||
setSending(true)
|
||||
setError(false)
|
||||
// Wait for the vote so that follow-up details update the same response.
|
||||
await votePending.current
|
||||
if (!active.current) return
|
||||
const saved = await send('No', true, includeDetails)
|
||||
if (!active.current) return
|
||||
setSending(false)
|
||||
if (saved) {
|
||||
if (includeDetails) {
|
||||
reasons.forEach(reason => trackFeedbackEvent(`reason:${reason}`, pathname))
|
||||
}
|
||||
setEmail('')
|
||||
setStep('done')
|
||||
} else {
|
||||
setError(true)
|
||||
}
|
||||
}
|
||||
|
||||
const submitWhy = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
void finish(true)
|
||||
}
|
||||
|
||||
const toggleReason = (reason: string) =>
|
||||
setReasons(prev => (prev.includes(reason) ? prev.filter(r => r !== reason) : [...prev, reason]))
|
||||
|
||||
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')}>
|
||||
<button type='button' className={styles.voteButton} disabled={sending} onClick={() => vote('Yes')}>
|
||||
<ThumbIcon /> Yes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.voteButton}
|
||||
onClick={() => vote('No')}>
|
||||
<button type='button' className={styles.voteButton} disabled={sending} 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>
|
||||
<form className={styles.why} onSubmit={submitWhy}>
|
||||
<span className={styles.question}>Sorry about that. What was the problem?</span>
|
||||
<div className={styles.reasons}>
|
||||
{REASONS.map((reason) => (
|
||||
{REASONS.map(reason => (
|
||||
<button
|
||||
key={reason}
|
||||
type="button"
|
||||
type='button'
|
||||
className={styles.reason}
|
||||
aria-pressed={reasons.includes(reason)}
|
||||
onClick={() => toggleReason(reason)}>
|
||||
disabled={sending}
|
||||
onClick={() => toggleReason(reason)}
|
||||
>
|
||||
{reason}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
className={styles.details}
|
||||
aria-label="Additional details"
|
||||
disabled={sending}
|
||||
aria-label='Additional details'
|
||||
rows={3}
|
||||
placeholder="Tell us what's wrong or missing... (optional)"
|
||||
value={details}
|
||||
onChange={(e) => setDetails(e.target.value)}
|
||||
onChange={e => setDetails(e.target.value)}
|
||||
/>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.submit}
|
||||
<div className={styles.followUp}>
|
||||
<label htmlFor={emailId}>Want a follow-up? (optional)</label>
|
||||
<input
|
||||
id={emailId}
|
||||
className={styles.email}
|
||||
type='email'
|
||||
name='email'
|
||||
autoComplete='email'
|
||||
aria-describedby={`${emailId}-help`}
|
||||
placeholder='Email address'
|
||||
maxLength={254}
|
||||
value={email}
|
||||
disabled={sending}
|
||||
onClick={submitWhy}>
|
||||
Send
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
/>
|
||||
<small id={`${emailId}-help`}>
|
||||
We'll only use this to ask about your feedback or let you know when it's addressed.
|
||||
</small>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<button type='submit' className={styles.submit} disabled={sending}>
|
||||
{sending ? 'Sending...' : 'Send'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.skip}
|
||||
onClick={() => setStep('done')}>
|
||||
<button type='button' className={styles.skip} disabled={sending} onClick={() => void finish(false)}>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'done' && (
|
||||
<span className={styles.thanks}>Thanks for your feedback!</span>
|
||||
{error && (
|
||||
<p className={styles.error} role='alert'>
|
||||
We couldn't send your feedback. Please try again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{step === 'done' && <span className={styles.thanks}>Thanks for your feedback!</span>}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ThumbIcon({down = false}: {down?: boolean}) {
|
||||
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" />
|
||||
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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,8 @@
|
||||
color: var(--ifm-color-primary-contrast-foreground, #fff);
|
||||
}
|
||||
|
||||
.details {
|
||||
.details,
|
||||
.email {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
@@ -80,7 +81,6 @@
|
||||
background: var(--ifm-background-color);
|
||||
color: var(--ifm-font-color-base);
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -116,3 +116,33 @@
|
||||
.thanks {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.details {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.followUp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.email {
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.followUp small {
|
||||
color: var(--ifm-font-color-secondary);
|
||||
}
|
||||
|
||||
.voteButton:disabled,
|
||||
.reason:disabled,
|
||||
.skip:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0.6rem 0 0;
|
||||
color: var(--ifm-color-danger);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user