diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 35045ae57b..7c130bee0e 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -33,6 +33,7 @@ import { AuthTriggers } from '../lib/auth'; import PlusMobileEntryBanner from './banners/PlusMobileEntryBanner'; import usePlusEntry from '../hooks/usePlusEntry'; import { SearchProvider } from '../contexts/search/SearchContext'; +import { FeedbackWidget } from './feedback'; const GoBackHeaderMobile = dynamic( () => @@ -215,6 +216,7 @@ function MainLayoutComponent({ )} {children} + ); } diff --git a/packages/shared/src/components/feedback/FeedbackWidget.tsx b/packages/shared/src/components/feedback/FeedbackWidget.tsx new file mode 100644 index 0000000000..e1750095a3 --- /dev/null +++ b/packages/shared/src/components/feedback/FeedbackWidget.tsx @@ -0,0 +1,42 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { Button, ButtonVariant, ButtonSize } from '../buttons/Button'; +import { FeedbackIcon } from '../icons'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useViewSize, ViewSize } from '../../hooks/useViewSize'; +import { useLazyModal } from '../../hooks/useLazyModal'; +import { LazyModal } from '../modals/common/types'; + +interface FeedbackWidgetProps { + className?: string; +} + +export function FeedbackWidget({ + className, +}: FeedbackWidgetProps): ReactElement | null { + const { user } = useAuthContext(); + const isMobile = useViewSize(ViewSize.MobileL); + const { openModal } = useLazyModal(); + + // Only show for authenticated users on desktop + // Mobile feedback is handled by FooterPlusButton + if (!user || isMobile) { + return null; + } + + return ( + + ); +} + +export default FeedbackWidget; diff --git a/packages/shared/src/components/feedback/index.ts b/packages/shared/src/components/feedback/index.ts new file mode 100644 index 0000000000..69d6e88c53 --- /dev/null +++ b/packages/shared/src/components/feedback/index.ts @@ -0,0 +1 @@ +export { FeedbackWidget, default } from './FeedbackWidget'; diff --git a/packages/shared/src/components/modals/FeedbackModal.tsx b/packages/shared/src/components/modals/FeedbackModal.tsx new file mode 100644 index 0000000000..af1c7d3048 --- /dev/null +++ b/packages/shared/src/components/modals/FeedbackModal.tsx @@ -0,0 +1,142 @@ +import type { ReactElement } from 'react'; +import React, { useState, useCallback } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import type { ModalProps } from './common/Modal'; +import { Modal } from './common/Modal'; +import { ModalSize } from './common/types'; +import { Button, ButtonVariant, ButtonSize } from '../buttons/Button'; +import Textarea from '../fields/Textarea'; +import { useToastNotification } from '../../hooks/useToastNotification'; +import { FeedbackCategory, submitFeedback } from '../../graphql/feedback'; +import type { FeedbackInput } from '../../graphql/feedback'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../typography/Typography'; + +const FEEDBACK_MAX_LENGTH = 2000; + +const categoryOptions: { value: FeedbackCategory; label: string }[] = [ + { value: FeedbackCategory.BugReport, label: 'Bug Report' }, + { value: FeedbackCategory.FeatureRequest, label: 'Feature Request' }, + { value: FeedbackCategory.UxIssue, label: 'UX Issue' }, + { value: FeedbackCategory.PerformanceComplaint, label: 'Performance' }, + { value: FeedbackCategory.ContentQuality, label: 'Content Quality' }, +]; + +const FeedbackModal = ({ + onRequestClose, + ...props +}: ModalProps): ReactElement => { + const { displayToast } = useToastNotification(); + + const [category, setCategory] = useState( + FeedbackCategory.BugReport, + ); + const [description, setDescription] = useState(''); + + const { mutate: submitMutation, isPending } = useMutation({ + mutationFn: (input: FeedbackInput) => submitFeedback(input), + onSuccess: () => { + displayToast('Thank you for your feedback!'); + onRequestClose?.(null); + }, + onError: () => { + displayToast('Failed to submit feedback. Please try again.'); + }, + }); + + const handleSubmit = useCallback(() => { + if (!description.trim()) { + displayToast('Please enter your feedback'); + return; + } + + submitMutation({ + category, + description: description.trim(), + pageUrl: typeof window !== 'undefined' ? window.location.href : undefined, + userAgent: + typeof navigator !== 'undefined' ? navigator.userAgent : undefined, + }); + }, [category, description, submitMutation, displayToast]); + + return ( + + + + + Help us improve by sharing your thoughts + + + {/* Category selector */} +
+ + Category + +
+ {categoryOptions.map((option) => ( + + ))} +
+
+ + {/* Description textarea */} +