Skip to content
Merged
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
22 changes: 22 additions & 0 deletions client/src/infra/rest/apis/feedback/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { get, post } from '../../index';
import type { ApiResponse } from '../../typings';
import type { FeedbackItem, SubmitFeedbackPayload } from './typing';

export const submitFeedback = async (payload: SubmitFeedbackPayload) => {
return post<SubmitFeedbackPayload, ApiResponse<{ feedback: FeedbackItem }>>(
'/api/feedback/submit',
true,
payload
);
};

export const getUserFeedback = async (params?: {
limit?: number;
skip?: number;
}) => {
const { limit = 10, skip = 0 } = params || {};
return get<
undefined,
ApiResponse<{ feedback: FeedbackItem[]; total: number; hasMore: boolean }>
>(`/api/feedback/user?limit=${limit}&skip=${skip}`, true);
};
27 changes: 27 additions & 0 deletions client/src/infra/rest/apis/feedback/typing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export enum FeedbackCategory {
ARTICLES = 'articles',
CHATS = 'chats',
CODE = 'code',
}

export interface SubmitFeedbackPayload {
title: string;
details: string;
category: FeedbackCategory;
reproduce_steps?: string;
attachment?: string;
}

export interface FeedbackItem {
_id: string;
user_id: string;
title: string;
details: string;
category: FeedbackCategory;
reproduce_steps?: string;
attachment_url?: string;
attachment_public_id?: string;
status: 'pending' | 'reviewed' | 'resolved';
createdAt: string;
updatedAt: string;
}
6 changes: 3 additions & 3 deletions client/src/modules/home/v1/components/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ const HomeContent = ({
sx={{
minWidth: { lg: 400 },
maxWidth: 400,
maxHeight: 'calc(100vh - 130px)',
overflowY: 'auto',
maxHeight: 'calc(100vh - 130px)',
overflowY: 'auto',
borderLeft: theme => `1px solid ${theme.palette.divider}`,
pl: 4,
pt: 1,
Expand All @@ -123,7 +123,7 @@ const HomeContent = ({
key={i}
onClick={() => {
setSelectedCategory(
selectedCategory === category ? null : category,
selectedCategory === category ? null : category
);
}}
>
Expand Down
6 changes: 3 additions & 3 deletions client/src/modules/home/v1/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const useHomeV1 = () => {
setProjects(response.data);
}
},
[setProjects]
[setProjects, isHomePage]
);

const fetchTrendingProjects = useCallback(async () => {
Expand All @@ -45,7 +45,7 @@ const useHomeV1 = () => {
if (response.data) {
setTrendingProjects(response.data);
}
}, [setTrendingProjects]);
}, [setTrendingProjects, isHomePage]);

const fetchProjectsByCategory = useCallback(
async ({
Expand Down Expand Up @@ -76,7 +76,7 @@ const useHomeV1 = () => {
setProjects(response.data);
}
},
[setProjects]
[setProjects, isHomePage]
);

const searchTerm = useMemo(() => {
Expand Down
117 changes: 117 additions & 0 deletions client/src/shared/components/organisms/feedback-modal/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { useRef, useState } from 'react';
import { submitFeedback } from '../../../../../infra/rest/apis/feedback';
import { useNotifications } from '../../../../hooks/use-notification';
import fileToBase64 from '../../../../hooks/useFileToBase64';
import { FeedbackCategory } from '../../../../../infra/rest/apis/feedback/typing';

const useFeedbackModal = ({ onClose }: { onClose: () => void }) => {
const { addNotification } = useNotifications();

const [title, setTitle] = useState('');
const [details, setDetails] = useState('');
const [category, setCategory] = useState<FeedbackCategory | ''>('');
const [reproduceSteps, setReproduceSteps] = useState('');
const [attachment, setAttachment] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const fileInputRef = useRef<HTMLInputElement>(null);

const validate = () => {
const newErrors: { [key: string]: string } = {};

if (title.length < 5 || title.length > 200) {
newErrors.title = 'Title must be between 5 and 200 characters';
}

if (details.length < 10 || details.length > 2000) {
newErrors.details = 'Details must be between 10 and 2000 characters';
}

if (!category) {
newErrors.category = 'Please select a category';
}

setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};

const handleSubmit = async () => {
if (!validate()) return;

setIsSubmitting(true);
try {
let attachmentBase64 = undefined;
if (attachment) {
attachmentBase64 = await fileToBase64(attachment);
}

await submitFeedback({
title,
details,
category: category as FeedbackCategory,
reproduce_steps: reproduceSteps,
attachment: attachmentBase64,
});

addNotification({
message: 'Feedback submitted successfully!',
type: 'success',
});
handleClose();
} catch (error) {
console.error('Feedback submission error:', error);
addNotification({
message: 'Failed to submit feedback. Please try again later.',
type: 'error',
});
} finally {
setIsSubmitting(false);
}
};

const handleClose = () => {
setTitle('');
setDetails('');
setCategory('');
setReproduceSteps('');
setAttachment(null);
setErrors({});
onClose();
};

const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
if (file.size > 5 * 1024 * 1024) {
addNotification({
message: 'File size must be less than 5MB',
type: 'error',
});
return;
}
setAttachment(file);
}
};

return {
title,
setTitle,
details,
setDetails,
category,
setCategory,
reproduceSteps,
setReproduceSteps,
attachment,
setAttachment,
errors,
setErrors,
fileInputRef,
isSubmitting,
handleSubmit,
handleClose,
handleFileChange,
};
};

export default useFeedbackModal;
180 changes: 180 additions & 0 deletions client/src/shared/components/organisms/feedback-modal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import {
Box,
Button,
FormControl,
InputLabel,
MenuItem,
Select,
TextField,
FormHelperText,
Typography,
} from '@mui/material';

import A2ZModal from '../../atoms/modal';
import A2ZTypography from '../../atoms/typography';
import { FeedbackCategory } from '../../../../infra/rest/apis/feedback/typing';
import useFeedbackModal from './hooks';

const FeedbackModal = ({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) => {
const {
title,
setTitle,
details,
setDetails,
category,
setCategory,
reproduceSteps,
setReproduceSteps,
attachment,
setAttachment,
errors,
setErrors,
fileInputRef,
isSubmitting,
handleFileChange,
handleSubmit,
handleClose,
} = useFeedbackModal({ onClose });

return (
<A2ZModal open={open} onClose={handleClose}>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '90%', sm: 600 },
maxWidth: '100%',
maxHeight: '80vh',
bgcolor: 'background.paper',
p: 4,
display: 'flex',
flexDirection: 'column',
gap: 2,
overflowY: 'auto',
borderRadius: 2,
}}
>
<A2ZTypography variant="h5" component="h2" text="Share Your Feedback" />

<TextField
label="Short and descriptive title"
fullWidth
value={title}
onChange={e => {
setTitle(e.target.value);
setErrors(prev => ({ ...prev, title: '' }));
}}
error={!!errors.title}
helperText={errors.title || `${title.length}/200`}
FormHelperTextProps={{ sx: { textAlign: 'right' } }}
inputProps={{ maxLength: 200 }}
/>

<FormControl fullWidth error={!!errors.category}>
<InputLabel>Category</InputLabel>
<Select
value={category}
label="Category"
onChange={e => {
setCategory(e.target.value);
setErrors(prev => ({ ...prev, category: '' }));
}}
>
<MenuItem value={FeedbackCategory.ARTICLES}>Articles</MenuItem>
<MenuItem value={FeedbackCategory.CHATS}>Chats</MenuItem>
<MenuItem value={FeedbackCategory.CODE}>Code</MenuItem>
</Select>
{errors.category && (
<FormHelperText>{errors.category}</FormHelperText>
)}
</FormControl>

<TextField
label="Details box"
multiline
minRows={3}
maxRows={8}
fullWidth
value={details}
onChange={e => {
setDetails(e.target.value);
setErrors(prev => ({ ...prev, details: '' }));
}}
error={!!errors.details}
helperText={errors.details || `${details.length}/2000`}
FormHelperTextProps={{ sx: { textAlign: 'right' } }}
inputProps={{ maxLength: 2000 }}
/>

<TextField
label="Reproduce steps (Optional)"
multiline
minRows={2}
maxRows={8}
fullWidth
value={reproduceSteps}
onChange={e => setReproduceSteps(e.target.value)}
placeholder="1. Go to page X&#10;2. Click button Y..."
/>

<Box>
<input
type="file"
accept="image/*"
style={{ display: 'none' }}
ref={fileInputRef}
onChange={handleFileChange}
/>
<Button
variant="outlined"
onClick={() => fileInputRef.current?.click()}
sx={{ mr: 2 }}
>
{attachment ? 'Change Attachment' : 'Add Attachment'}
</Button>
{attachment && (
<Typography variant="caption" sx={{ display: 'block', mt: 1 }}>
Attached: {attachment.name}
<Button
size="small"
color="error"
onClick={() => {
setAttachment(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}}
sx={{ ml: 1 }}
>
Remove
</Button>
</Typography>
)}
</Box>

<Box
sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 2 }}
>
<Button variant="text" onClick={handleClose}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit Feedback'}
</Button>
</Box>
</Box>
</A2ZModal>
);
};

export default FeedbackModal;
Loading
Loading