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 (