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 ( + } + className={classNames('fixed bottom-4 right-4 z-3 shadow-2', className)} + onClick={() => openModal({ type: LazyModal.Feedback })} + aria-label="Send feedback" + > + Feedback + + ); +} + +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) => ( + setCategory(option.value)} + disabled={isPending} + > + {option.label} + + ))} + + + + {/* Description textarea */} + + + {/* Submit button */} + + Submit Feedback + + + + ); +}; + +export default FeedbackModal; diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index 7620df5e20..570bd67299 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -413,6 +413,10 @@ const CandidateSignInModal = dynamic( ), ); +const FeedbackModal = dynamic( + () => import(/* webpackChunkName: "feedbackModal" */ './FeedbackModal'), +); + export const modals = { [LazyModal.SquadMember]: SquadMemberModal, [LazyModal.UpvotedPopup]: UpvotedPopupModal, @@ -480,6 +484,7 @@ export const modals = { [LazyModal.SlackChannelConfirmation]: SlackChannelConfirmationModal, [LazyModal.RecruiterSeats]: RecruiterSeatsModal, [LazyModal.CandidateSignIn]: CandidateSignInModal, + [LazyModal.Feedback]: FeedbackModal, }; type GetComponentProps = T extends diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index 155ee78030..47f91bebf8 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -91,6 +91,7 @@ export enum LazyModal { SlackChannelConfirmation = 'slackChannelConfirmation', RecruiterSeats = 'recruiterSeats', CandidateSignIn = 'candidateSignIn', + Feedback = 'feedback', } export type ModalTabItem = { diff --git a/packages/shared/src/graphql/feedback.ts b/packages/shared/src/graphql/feedback.ts new file mode 100644 index 0000000000..6d17dea302 --- /dev/null +++ b/packages/shared/src/graphql/feedback.ts @@ -0,0 +1,33 @@ +import { gql } from 'graphql-request'; +import { gqlClient } from './common'; +import type { EmptyResponse } from './emptyResponse'; + +/** + * @generated from enum dailydotdev.bragi.pipelines.FeedbackCategory + */ +export enum FeedbackCategory { + Unspecified = 0, + BugReport = 1, + FeatureRequest = 2, + UxIssue = 3, + PerformanceComplaint = 4, + ContentQuality = 5, +} + +export interface FeedbackInput { + category: FeedbackCategory; + description: string; + pageUrl?: string; + userAgent?: string; +} + +export const SUBMIT_FEEDBACK_MUTATION = gql` + mutation SubmitFeedback($input: FeedbackInput!) { + submitFeedback(input: $input) { + _ + } + } +`; + +export const submitFeedback = (input: FeedbackInput): Promise => + gqlClient.request(SUBMIT_FEEDBACK_MUTATION, { input }); diff --git a/packages/webapp/components/footer/FooterPlusButton.tsx b/packages/webapp/components/footer/FooterPlusButton.tsx index 6eb9d81658..0a0b607cbe 100644 --- a/packages/webapp/components/footer/FooterPlusButton.tsx +++ b/packages/webapp/components/footer/FooterPlusButton.tsx @@ -13,6 +13,7 @@ import { } from '@dailydotdev/shared/src/components/buttons/Button'; import { EditIcon, + FeedbackIcon, LinkIcon, PlusIcon, PollIcon, @@ -20,6 +21,8 @@ import { import { link } from '@dailydotdev/shared/src/lib/links'; import { RootPortal } from '@dailydotdev/shared/src/components/tooltips/Portal'; import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { useLazyModal } from '@dailydotdev/shared/src/hooks/useLazyModal'; +import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/types'; const ActionButton = ({ children, @@ -43,18 +46,30 @@ export function FooterPlusButton(): ReactElement { const { user } = useAuthContext(); const drawerRef = useRef(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const { openModal } = useLazyModal(); const props = user ? { onClick: () => setIsDrawerOpen(true) } : { tag: 'a' as AllowedTags, href: '/onboarding' }; return ( <> - } - variant={ButtonVariant.Primary} - className="absolute bottom-24 right-4 z-1 ml-auto justify-self-center border border-border-subtlest-tertiary" - /> + + {user && ( + } + variant={ButtonVariant.Subtle} + className="border border-border-subtlest-tertiary" + onClick={() => openModal({ type: LazyModal.Feedback })} + aria-label="Send feedback" + /> + )} + } + variant={ButtonVariant.Primary} + className="border border-border-subtlest-tertiary" + /> +