Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/shared/src/components/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
() =>
Expand Down Expand Up @@ -215,6 +216,7 @@ function MainLayoutComponent({
)}
{children}
</main>
<FeedbackWidget />
</div>
);
}
Expand Down
42 changes: 42 additions & 0 deletions packages/shared/src/components/feedback/FeedbackWidget.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button
variant={ButtonVariant.Primary}
size={ButtonSize.Medium}
icon={<FeedbackIcon />}
className={classNames('fixed bottom-4 right-4 z-3 shadow-2', className)}
onClick={() => openModal({ type: LazyModal.Feedback })}
aria-label="Send feedback"
>
Feedback
</Button>
);
}

export default FeedbackWidget;
1 change: 1 addition & 0 deletions packages/shared/src/components/feedback/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { FeedbackWidget, default } from './FeedbackWidget';
142 changes: 142 additions & 0 deletions packages/shared/src/components/modals/FeedbackModal.tsx
Original file line number Diff line number Diff line change
@@ -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>(
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 (
<Modal
{...props}
onRequestClose={onRequestClose}
isDrawerOnMobile
size={ModalSize.Small}
shouldCloseOnOverlayClick={!isPending}
>
<Modal.Header title="Send Feedback" />
<Modal.Body className="flex flex-col gap-4">
<Typography
type={TypographyType.Body}
color={TypographyColor.Secondary}
>
Help us improve by sharing your thoughts
</Typography>

{/* Category selector */}
<div className="flex flex-col gap-2">
<Typography
type={TypographyType.Footnote}
color={TypographyColor.Tertiary}
bold
>
Category
</Typography>
<div className="flex flex-wrap gap-2">
{categoryOptions.map((option) => (
<Button
key={option.value}
variant={
category === option.value
? ButtonVariant.Primary
: ButtonVariant.Float
}
size={ButtonSize.Small}
onClick={() => setCategory(option.value)}
disabled={isPending}
>
{option.label}
</Button>
))}
</div>
</div>

{/* Description textarea */}
<Textarea
inputId="feedback-description"
label="Your feedback"
placeholder="Tell us what's on your mind..."
value={description}
valueChanged={setDescription}
maxLength={FEEDBACK_MAX_LENGTH}
rows={6}
fieldType="tertiary"
disabled={isPending}
className={{
baseField: 'min-h-[150px]',
}}
/>

{/* Submit button */}
<Button
variant={ButtonVariant.Primary}
size={ButtonSize.Large}
onClick={handleSubmit}
loading={isPending}
disabled={!description.trim() || isPending}
className="w-full"
>
Submit Feedback
</Button>
</Modal.Body>
</Modal>
);
};

export default FeedbackModal;
5 changes: 5 additions & 0 deletions packages/shared/src/components/modals/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,10 @@ const CandidateSignInModal = dynamic(
),
);

const FeedbackModal = dynamic(
() => import(/* webpackChunkName: "feedbackModal" */ './FeedbackModal'),
);

export const modals = {
[LazyModal.SquadMember]: SquadMemberModal,
[LazyModal.UpvotedPopup]: UpvotedPopupModal,
Expand Down Expand Up @@ -480,6 +484,7 @@ export const modals = {
[LazyModal.SlackChannelConfirmation]: SlackChannelConfirmationModal,
[LazyModal.RecruiterSeats]: RecruiterSeatsModal,
[LazyModal.CandidateSignIn]: CandidateSignInModal,
[LazyModal.Feedback]: FeedbackModal,
};

type GetComponentProps<T> = T extends
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/components/modals/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export enum LazyModal {
SlackChannelConfirmation = 'slackChannelConfirmation',
RecruiterSeats = 'recruiterSeats',
CandidateSignIn = 'candidateSignIn',
Feedback = 'feedback',
}

export type ModalTabItem = {
Expand Down
33 changes: 33 additions & 0 deletions packages/shared/src/graphql/feedback.ts
Original file line number Diff line number Diff line change
@@ -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<EmptyResponse> =>
gqlClient.request(SUBMIT_FEEDBACK_MUTATION, { input });
27 changes: 21 additions & 6 deletions packages/webapp/components/footer/FooterPlusButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@ import {
} from '@dailydotdev/shared/src/components/buttons/Button';
import {
EditIcon,
FeedbackIcon,
LinkIcon,
PlusIcon,
PollIcon,
} from '@dailydotdev/shared/src/components/icons';
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 = <TagName extends AllowedTags>({
children,
Expand All @@ -43,18 +46,30 @@ export function FooterPlusButton(): ReactElement {
const { user } = useAuthContext();
const drawerRef = useRef<DrawerRef>();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const { openModal } = useLazyModal();
const props = user
? { onClick: () => setIsDrawerOpen(true) }
: { tag: 'a' as AllowedTags, href: '/onboarding' };

return (
<>
<Button
{...props}
icon={<PlusIcon />}
variant={ButtonVariant.Primary}
className="absolute bottom-24 right-4 z-1 ml-auto justify-self-center border border-border-subtlest-tertiary"
/>
<div className="absolute bottom-24 right-4 z-1 flex gap-2">
{user && (
<Button
icon={<FeedbackIcon />}
variant={ButtonVariant.Subtle}
className="border border-border-subtlest-tertiary"
onClick={() => openModal({ type: LazyModal.Feedback })}
aria-label="Send feedback"
/>
)}
<Button
{...props}
icon={<PlusIcon />}
variant={ButtonVariant.Primary}
className="border border-border-subtlest-tertiary"
/>
</div>
<RootPortal>
<Drawer
ref={drawerRef}
Expand Down