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.
This commit is contained in:
Charis authored and GitHub committed 2024-03-11 20:28:25 -04:00
1 parent ce0ebafab0
commit 3124f3dad7
14 files changed
+427 -31

No files matched your search

+202
View File
@@ -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 (
<button
ref={ref}
className={cn(
'mt-0',
'flex items-center gap-2',
'text-xs text-foreground-lighter',
'hover:text-foreground',
!visible && 'opacity-0 invisible',
'[transition-property:opacity,color]',
'[transition-delay:700ms,0ms]'
)}
onClick={onClick}
>
{isYes ? <>What went well?</> : <>How can we improve?</>}
<MessageSquareQuote size={14} strokeWidth={1.5} />
</button>
)
})
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<HTMLButtonElement>(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 (
<section className="@container px-5 mb-6" aria-labelledby="feedback-title">
<h3 id="feedback-title" className="block font-mono text-xs uppercase text-foreground mb-4">
Is this helpful?
</h3>
<div className="relative flex flex-col gap-y-4 mb-2 @[12rem]:flex-row @[12rem]:items-center">
<div
style={{ '--container-flex-gap': '0.5rem' } as CSSProperties}
className={`relative flex gap-[var(--container-flex-gap)] items-center`}
>
<Button
type="outline"
rounded
className={cn(
'px-1',
!isYes && 'hover:text-brand-600 hover:border-brand-500',
isYes && 'bg-brand text-brand-200 !border-brand disabled:opacity-100',
!showYes && 'opacity-0 invisible',
'[transition-property:opacity,transform,color] [transition-duration:150ms,300ms,300ms]',
'motion-reduce:[transition-duration:150ms,1ms,300ms]'
)}
onClick={() => handleVote('yes')}
disabled={state.type === StateType.Followup}
>
<Check size={14} strokeWidth={3} />
<span className="sr-only">Yes</span>
</Button>
<Button
type="outline"
rounded
className={cn(
'px-1',
!isNo && 'hover:text-warning-600 hover:border-warning-500',
isNo &&
`bg-warning text-warning-200 !border-warning -translate-x-[calc(100%+var(--container-flex-gap,0.5rem))] disabled:opacity-100`,
!showNo && 'opacity-0 invisible',
'[transition-property:opacity,transform,color] [transition-duration:150ms,300ms,300ms]',
'motion-reduce:[transition-duration:150ms,1ms,300ms]'
)}
onClick={() => handleVote('no')}
disabled={state.type === StateType.Followup}
>
<X size={14} strokeWidth={3} />
<span className="sr-only">No</span>
</Button>
</div>
<div
className={cn(
'flex flex-col gap-1',
'text-xs',
'opacity-0 invisible',
'text-left',
'-translate-x-[0.25rem] @[12rem]:-translate-x-[1.25rem]',
'[transition-property:opacity,transform] [transition-duration:150ms,300ms]',
'motion-reduce:[transition-duration:150ms,1ms]',
'delay-300',
state.type === StateType.Followup &&
'opacity-100 visible -translate-x-0 @[12rem]:-translate-x-[1rem]'
)}
>
<span className="text-foreground-light">Thanks for your feedback!</span>
<FeedbackButton
ref={feedbackButtonRef}
onClick={() => setModalOpen(true)}
isYes={isYes}
visible={!unanswered}
/>
</div>
</div>
<FeedbackModal
visible={modalOpen}
page={pathname}
onCancel={() => {
setModalOpen(false)
refocusButton()
}}
onSubmit={handleSubmit}
/>
</section>
)
}
export { Feedback }
@@ -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 (
<Modal hideFooter header="Leave a comment" visible={visible} onEscapeKeyDown={onCancel}>
<Form
initialValues={{ page, comment: '' }}
validateOnBlur
validate={(vals) => {
const errors: Partial<FeedbackFields> = {}
if (!vals.comment) {
errors.comment = 'Required'
}
return errors
}}
onReset={onCancel}
onSubmit={onSubmit}
>
{({ isSubmitting }: { isSubmitting: boolean }) => (
<>
<Modal.Content className="pt-4 pb-2">
<Input type="hidden" id="page" name="page" value={page} />
<Input.TextArea
label="Comment"
id="comment"
name="comment"
size="medium"
className="mb-2"
textAreaClassName="resize-none"
afterLabel=" (not anonymous)"
/>
</Modal.Content>
<Modal.Separator />
<Modal.Content className="pt-2 pb-4">
<div className="flex items-center justify-end gap-2">
<Button htmlType="reset" type="default" onClick={onCancel} disabled={isSubmitting}>
Cancel
</Button>
<Button htmlType="submit" loading={isSubmitting} disabled={isSubmitting}>
Submit feedback
</Button>
</div>
</Modal.Content>
</>
)}
</Form>
</Modal>
)
}
export { FeedbackModal }
+1
View File
@@ -0,0 +1 @@
export { Feedback } from './Feedback'
@@ -15,7 +15,7 @@ const TopNavBar: FC = () => {
return (
<nav
aria-label="top bar"
className="h-[60px] border-b backdrop-blur backdrop-filter bg bg-opacity-75"
className="h-[var(--desktop-header-height,60px)] border-b backdrop-blur backdrop-filter bg bg-opacity-75"
>
<div className="px-5 max-w-7xl mx-auto flex gap-3 justify-between items-center h-full">
<div className="lg:hidden">
+26 -13
View File
@@ -1,11 +1,13 @@
import { useTheme } from 'next-themes'
import Image from 'next/legacy/image'
import Link from 'next/link'
import { type CSSProperties, type PropsWithChildren, memo, useEffect } from 'react'
import { cn } from 'ui'
import Footer from '~/components/Navigation/Footer'
import NavigationMenu, { type MenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu'
import TopNavBar from '~/components/Navigation/NavigationMenu/TopNavBar'
import { PropsWithChildren, memo, useEffect } from 'react'
import Footer from '~/components/Navigation/Footer'
import { menuState, useMenuMobileOpen } from '~/hooks/useMenuState'
const levelsData = {
@@ -149,13 +151,13 @@ const MobileHeader = memo(function MobileHeader({ menuId }: { menuId: MenuId })
return (
<div
className={[
className={cn(
'transition-all ease-out z-10',
'top-0',
mobileMenuOpen && 'absolute',
'flex items-center h-[40px]',
mobileMenuOpen ? 'gap-0' : 'gap-3',
].join(' ')}
'flex items-center h-[var(--mobile-header-height,40px)]',
mobileMenuOpen ? 'gap-0' : 'gap-3'
)}
>
<button
className={['mr-2', mobileMenuOpen && 'mt-0.5'].join(' ')}
@@ -257,23 +259,27 @@ const HeaderLogo = memo(function HeaderLogo() {
)
})
const Container = memo(function Container(props: PropsWithChildren) {
const Container = memo(function Container({
children,
style,
}: PropsWithChildren<{ style?: CSSProperties }>) {
const mobileMenuOpen = useMenuMobileOpen()
return (
<div
// #docs-content-container is used by layout to scroll to top
id="docs-content-container"
className={[
className={cn(
// 'overflow-x-auto',
'w-full transition-all ease-out',
// 'absolute lg:relative',
mobileMenuOpen ? 'ml-[75%] sm:ml-[50%] md:ml-[33%] overflow-hidden' : 'overflow-auto',
// desktop override any margin styles
'lg:ml-0',
].join(' ')}
'lg:ml-0'
)}
style={style}
>
<div className="flex flex-col relative">{props.children}</div>
<div className="flex flex-col relative">{children}</div>
</div>
)
})
@@ -348,7 +354,14 @@ function MainSkeleton({ children, menuId }: PropsWithChildren<{ menuId: MenuId }
return (
<div className="flex flex-row h-full">
<NavContainer menuId={menuId} />
<Container>
<Container
style={
{
'--desktop-header-height': '60px',
'--mobile-header-height': '40px',
} as CSSProperties
}
>
<div className={['lg:sticky top-0 z-10 overflow-hidden'].join(' ')}>
<TopNavBar />
</div>
+14 -11
View File
@@ -4,16 +4,19 @@ import Head from 'next/head'
import { usePathname } from 'next/navigation'
import { useRouter } from 'next/router'
import { FC, useEffect, useRef, useState } from 'react'
import { IconExternalLink } from 'ui'
import { IconExternalLink, cn } from 'ui'
import { ExpandableVideo } from 'ui-patterns/ExpandableVideo'
import components from '~/components'
import { highlightSelectedTocItem } from '~/components/CustomHTMLElements/CustomHTMLElements.utils'
import { FooterHelpCalloutType } from '~/components/FooterHelpCallout'
import GuidesTableOfContents from '~/components/GuidesTableOfContents'
import { type MenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu'
import useHash from '~/hooks/useHash'
import { LayoutMainContent } from '../DefaultLayout'
import { MainSkeleton } from '../MainSkeleton'
import { Feedback } from '~/components/Feedback'
import { LayoutMainContent } from '~/layouts/DefaultLayout'
import { MainSkeleton } from '~/layouts/MainSkeleton'
interface Props {
meta: {
@@ -69,7 +72,7 @@ const Layout: FC<Props> = (props) => {
return { text, link, level }
})
setTocList(newHeadings)
}, [pathname]) // Needed to recalculate the ToC when the page changes
}, [pathname]) // needed to recalculate the toc when path changes
const hasTableOfContents = tocList.length > 0
const tocVideoPreview = `http://img.youtube.com/vi/${props.meta?.tocVideo}/0.jpg`
@@ -162,13 +165,12 @@ const Layout: FC<Props> = (props) => {
</div>
{!props.hideToc && hasTableOfContents && !props.meta?.hide_table_of_contents && (
<div
className={[
'col-span-3',
'border-overlay bg-background table-of-contents-height',
'thin-scrollbar overflow-y-auto sticky hidden md:block md:col-span-3 px-2',
'transition-all ease-out',
'duration-100',
].join(' ')}
className={cn(
'col-span-3 self-start',
'border-overlay bg-background',
'thin-scrollbar overflow-y-auto hidden md:block md:col-span-3 px-2',
'sticky top-[calc(var(--mobile-header-height,40px)+2rem)] lg:top-[calc(var(--desktop-header-height,60px)+2rem)]'
)}
>
<div className="border-l">
{props.meta?.tocVideo && !!tocVideoPreview && (
@@ -176,6 +178,7 @@ const Layout: FC<Props> = (props) => {
<ExpandableVideo imgUrl={tocVideoPreview} videoId={props.meta.tocVideo} />
</div>
)}
<Feedback key={pathname} />
<span className="block font-mono text-xs uppercase text-foreground px-5 mb-6">
On this page
</span>
+40
View File
@@ -0,0 +1,40 @@
import { type UseMutationOptions, useMutation } from '@tanstack/react-query'
import { type ResponseError } from '~/types/fetch'
import { post } from './fetchWrappers'
type SendFeedbackVariables = {
message: string
pathname?: string
}
export async function sendFeedback({ message, pathname }: SendFeedbackVariables) {
const { data, error } = await post('/platform/feedback/send', {
body: {
message,
category: 'Feedback',
tags: ['docs-feedback'],
pathname,
},
})
if (error) throw Error(`Couldn't send feedback`, { cause: error })
return data
}
type SendFeedbackData = Awaited<ReturnType<typeof sendFeedback>>
export const useSendFeedbackMutation = (
options: Omit<
UseMutationOptions<SendFeedbackData, ResponseError, SendFeedbackVariables>,
'mutationFn'
> = {}
) => {
return useMutation<SendFeedbackData, ResponseError, SendFeedbackVariables>({
...options,
mutationFn: (vars) => sendFeedback(vars),
onError: (error, vars, ctx) => {
console.error(error)
options.onError?.(error, vars, ctx)
},
})
}
+41
View File
@@ -0,0 +1,41 @@
import { usePathname } from 'next/navigation'
import { useConsent } from 'ui-patterns/ConsentToast'
import { unauthedAllowedPost } from './fetch/fetchWrappers'
type TelemetryEvent = {
action: string
category: string
label: string
}
const noop = () => {}
/**
* Sends a telemetry event to Logflare for tracking by the product team.
*
* Checks for user consent to telemetry before sending.
*/
const useSendTelemetryEvent = () => {
const { hasAcceptedConsent } = useConsent()
const pathname = usePathname()
if (!hasAcceptedConsent) return noop
return (event: TelemetryEvent) =>
unauthedAllowedPost('/platform/telemetry/event', {
// @ts-ignore - endpoint will accept this just fine
body: {
...event,
page_title: document?.title,
page_location: pathname,
},
})
.then(({ error }) => {
if (error) console.error(error)
})
.catch((error) => console.error(error))
}
export { useSendTelemetryEvent }
+1 -1
View File
@@ -31,6 +31,7 @@
"@radix-ui/react-accordion": "^1.1.2",
"@supabase/auth-helpers-react": "^0.4.2",
"@supabase/supabase-js": "^2.39.3",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^5.13.4",
"common": "*",
"common-tags": "^1.8.2",
@@ -70,7 +71,6 @@
"remark-gfm": "^3.0.1",
"shared-data": "*",
"ui": "*",
"icons": "*",
"ui-patterns": "*",
"unist-builder": "^3.0.1",
"unist-util-filter": "^4.0.1",
-5
View File
@@ -69,11 +69,6 @@ article h1 {
}
}
.table-of-contents-height {
max-height: calc(100vh - 3.75rem - 2rem);
top: calc(4rem + 60px); // padding height + navbar height
}
.width-full {
width: 100% !important;
}
+1
View File
@@ -20,5 +20,6 @@ module.exports = config({
},
})
},
require('@tailwindcss/container-queries'),
],
})
+9
View File
@@ -473,6 +473,7 @@
"@radix-ui/react-accordion": "^1.1.2",
"@supabase/auth-helpers-react": "^0.4.2",
"@supabase/supabase-js": "^2.39.3",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^5.13.4",
"common": "*",
"common-tags": "^1.8.2",
@@ -14615,6 +14616,14 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@tailwindcss/container-queries": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz",
"integrity": "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==",
"peerDependencies": {
"tailwindcss": ">=3.2.0"
}
},
"node_modules/@tailwindcss/forms": {
"version": "0.5.6",
"license": "MIT",
@@ -0,0 +1,19 @@
create type feedback_vote as enum (
'yes',
'no'
);
create table feedback (
id bigint primary key generated always as identity,
date_created date not null default current_date,
vote feedback_vote not null,
page text not null
);
alter table feedback enable row level security;
create policy "Anyone can insert feedback"
on feedback
as permissive for insert
to public
with check (true);
@@ -0,0 +1,7 @@
create schema if not exists metrics;
create view metrics.feedback_response_aggregate
as select
count(*) filter (where vote = 'yes') as yes,
count(*) filter (where vote = 'no') as no
from feedback;