From 3124f3dad7cd3fff4356d1f4008bd4fd1c2c1912 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Mon, 11 Mar 2024 20:28:25 -0400 Subject: [PATCH] feat: add docs feedback widget (#21131) Add a docs feedback widget to allow upvoting and downvoting pages. Votes (completely anonymized, barely more than a counter) go to a database in the main supabase-com project. If the user has accepted telemetry, the votes also go to Logflare with a bit more info. Post-vote, logged-in users can leave a comment, which goes to the `platform/feedback/send` endpoint. There is a warning in the feedback modal that the feedback is not anonymous. --- apps/docs/components/Feedback/Feedback.tsx | 202 ++++++++++++++++++ .../components/Feedback/FeedbackModal.tsx | 65 ++++++ apps/docs/components/Feedback/index.tsx | 1 + .../Navigation/NavigationMenu/TopNavBar.tsx | 2 +- apps/docs/layouts/MainSkeleton.tsx | 39 ++-- apps/docs/layouts/guides/index.tsx | 25 ++- apps/docs/lib/fetch/feedback.ts | 40 ++++ apps/docs/lib/telemetry.ts | 41 ++++ apps/docs/package.json | 2 +- apps/docs/styles/main.scss | 5 - apps/docs/tailwind.config.js | 1 + package-lock.json | 9 + .../20240208001120_add_feedback_table.sql | 19 ++ .../20240306233728_create_feedback_view.sql | 7 + 14 files changed, 427 insertions(+), 31 deletions(-) create mode 100644 apps/docs/components/Feedback/Feedback.tsx create mode 100644 apps/docs/components/Feedback/FeedbackModal.tsx create mode 100644 apps/docs/components/Feedback/index.tsx create mode 100644 apps/docs/lib/fetch/feedback.ts create mode 100644 apps/docs/lib/telemetry.ts create mode 100644 supabase/migrations/20240208001120_add_feedback_table.sql create mode 100644 supabase/migrations/20240306233728_create_feedback_view.sql diff --git a/apps/docs/components/Feedback/Feedback.tsx b/apps/docs/components/Feedback/Feedback.tsx new file mode 100644 index 00000000000..2d656947d23 --- /dev/null +++ b/apps/docs/components/Feedback/Feedback.tsx @@ -0,0 +1,202 @@ +import { useSupabaseClient } from '@supabase/auth-helpers-react' +import { Check, MessageSquareQuote, X } from 'lucide-react' +import { usePathname } from 'next/navigation' +import { + type CSSProperties, + type MouseEventHandler, + forwardRef, + useReducer, + useRef, + useState, +} from 'react' + +import { useIsLoggedIn } from 'common' +import { Button, cn } from 'ui' + +import { useSendFeedbackMutation } from '~/lib/fetch/feedback' +import { useSendTelemetryEvent } from '~/lib/telemetry' +import { FeedbackModal, type FeedbackFields } from './FeedbackModal' + +const FeedbackButton = forwardRef< + HTMLButtonElement, + { isYes: boolean; onClick: MouseEventHandler; visible: boolean } +>(({ isYes, onClick, visible }, ref) => { + const isLoggedIn = useIsLoggedIn() + if (!isLoggedIn) return null + + return ( + + ) +}) +FeedbackButton.displayName = 'FeedbackButton' + +type Response = 'yes' | 'no' + +enum StateType { + Unanswered = 'unanswered', + Followup = 'followup', +} + +type State = { type: StateType.Unanswered } | { type: StateType.Followup; response: Response } + +type Action = { event: 'VOTED'; response: Response } + +const initialState = { type: StateType.Unanswered } satisfies State + +function reducer(state: State, action: Action) { + switch (action.event) { + case 'VOTED': + if (state.type === StateType.Unanswered) + return { type: StateType.Followup, response: action.response } + default: + return state + } +} + +function Feedback() { + const [state, dispatch] = useReducer(reducer, initialState) + const [modalOpen, setModalOpen] = useState(false) + const feedbackButtonRef = useRef(null) + + const pathname = usePathname() + const sendTelemetryEvent = useSendTelemetryEvent() + const { mutate: sendFeedbackComment } = useSendFeedbackMutation() + const supabase = useSupabaseClient() + + const unanswered = state.type === 'unanswered' + const isYes = 'response' in state && state.response === 'yes' + const isNo = 'response' in state && state.response === 'no' + const showYes = unanswered || isYes + const showNo = unanswered || isNo + + async function sendFeedbackVote(response: Response) { + const { error } = await supabase.from('feedback').insert({ vote: response, page: pathname }) + if (error) console.error(error) + } + + function handleVote(response: Response) { + sendTelemetryEvent({ + category: 'docs', + action: 'feedback_voted', + label: response, + }) + sendFeedbackVote(response) + dispatch({ event: 'VOTED', response }) + // Focus so screen reader users are aware of the new element + setTimeout(() => { + feedbackButtonRef.current?.focus() + // Wait for element to show up first + }, 700) + } + + function refocusButton() { + setTimeout(() => { + feedbackButtonRef.current?.focus() + // Wait for modal to disappear and button to become focusable again + }, 100) + } + + async function handleSubmit({ page, comment }: FeedbackFields) { + sendFeedbackComment({ message: comment, pathname: page }) + setModalOpen(false) + refocusButton() + } + + return ( +
+

+ Is this helpful? +

+
+
+ + +
+
+ Thanks for your feedback! + setModalOpen(true)} + isYes={isYes} + visible={!unanswered} + /> +
+
+ { + setModalOpen(false) + refocusButton() + }} + onSubmit={handleSubmit} + /> +
+ ) +} + +export { Feedback } diff --git a/apps/docs/components/Feedback/FeedbackModal.tsx b/apps/docs/components/Feedback/FeedbackModal.tsx new file mode 100644 index 00000000000..6013eb972d1 --- /dev/null +++ b/apps/docs/components/Feedback/FeedbackModal.tsx @@ -0,0 +1,65 @@ +import { Button, Form, Input, Modal } from 'ui' + +export type FeedbackFields = { + page: string + comment: string +} + +type FeedbackModalProps = { + visible: boolean + page: string + onCancel: () => void + onSubmit: (values: FeedbackFields) => void +} + +function FeedbackModal({ visible, page, onCancel, onSubmit }: FeedbackModalProps) { + return ( + +
{ + const errors: Partial = {} + + if (!vals.comment) { + errors.comment = 'Required' + } + + return errors + }} + onReset={onCancel} + onSubmit={onSubmit} + > + {({ isSubmitting }: { isSubmitting: boolean }) => ( + <> + + + + + + +
+ + +
+
+ + )} + +
+ ) +} + +export { FeedbackModal } diff --git a/apps/docs/components/Feedback/index.tsx b/apps/docs/components/Feedback/index.tsx new file mode 100644 index 00000000000..4772c0be963 --- /dev/null +++ b/apps/docs/components/Feedback/index.tsx @@ -0,0 +1 @@ +export { Feedback } from './Feedback' diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx index 743f32ecc7e..ad1a9bdec4a 100644 --- a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx @@ -15,7 +15,7 @@ const TopNavBar: FC = () => { return (