Skip to content

Commit

Permalink
Adds feedback button (#11)
Browse files Browse the repository at this point in the history
* Add feedback button

* Fix env for feedback
  • Loading branch information
aprusty-cn authored Sep 3, 2024
1 parent fa20c76 commit 85f6d87
Show file tree
Hide file tree
Showing 7 changed files with 867 additions and 4 deletions.
2 changes: 2 additions & 0 deletions app/(main)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import FileUploader from "../../components/FileUploader";
import PromptForm from "../../components/PromptForm";
import ModelSelector from "../../components/ModelSelector";
import CodeEditor from "../../components/CodeEditor";
import FeedbackButton from "../../components/FeedbackButton";
import { generateCode, modifyCode, getApiSpec } from "../../utils/apiClient";
import UpdatePromptForm from "../../components/UpdatePromptForm";
import PublishButton from "../../components/PublishButton";
Expand Down Expand Up @@ -430,6 +431,7 @@ export default function Home() {
{/* {(status === "creating" || status === "updating") && (
<FunFactRenderer funFact={funFact} />
)} */}
<FeedbackButton />
</div>
);
}
106 changes: 106 additions & 0 deletions app/api/feedback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { NextResponse } from 'next/server';
import { google } from 'googleapis';
import { drive_v3 } from 'googleapis/build/src/apis/drive/v3';
import { Readable } from 'stream';
import { v4 as uuidv4 } from 'uuid';

const privateKeyBase64 = process.env.GOOGLE_PRIVATE_KEY_BASE64;
const privateKey = privateKeyBase64
? Buffer.from(privateKeyBase64, 'base64').toString('ascii')
: undefined;

// Configure a JWT auth client using environment variables
const jwtClient = new google.auth.JWT({
email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
key: privateKey,
scopes: [
'https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/drive.file',
],
});

const spreadsheetId = process.env.GOOGLE_SHEET_ID;
const range = 'Sheet1!A:F';
const driveFolderId = process.env.GOOGLE_DRIVE_FOLDER_ID;

export async function POST(req: Request) {
try {
const formData = await req.formData();
const issueType = formData.get('issueType') as string;
const description = formData.get('description') as string;
const severity = formData.get('severity') as string;
const email = formData.get('email') as string;

await jwtClient.authorize();
const sheets = google.sheets({ version: 'v4', auth: jwtClient });
const drive = google.drive({ version: 'v3', auth: jwtClient });

// Create a new folder for this feedback
const feedbackFolderId = uuidv4().slice(0, 8);
const feedbackFolderMetadata: drive_v3.Schema$File = {
name: `Feedback_${feedbackFolderId}`,
mimeType: 'application/vnd.google-apps.folder',
};

if (driveFolderId) {
feedbackFolderMetadata.parents = [driveFolderId];
}

const feedbackFolder = await drive.files.create({
requestBody: feedbackFolderMetadata,
fields: 'id, webViewLink',
});

const attachmentLinks: string[] = [];

// Upload all attachments
const entries = Array.from(formData.entries());
for (let i = 0; i < entries.length; i++) {
const [key, value] = entries[i];
if (key.startsWith('attachment') && value instanceof File) {
const file = value as File;
const fileMetadata: drive_v3.Schema$File = {
name: file.name,
parents: [feedbackFolder.data.id!],
};

const buffer = Buffer.from(await file.arrayBuffer());
const stream = Readable.from(buffer);

const uploadedFile = await drive.files.create({
requestBody: fileMetadata,
media: {
mimeType: file.type,
body: stream,
},
fields: 'id, webViewLink',
});

attachmentLinks.push(uploadedFile.data.webViewLink || '');
}
}

const values = [
[
new Date().toISOString(),
issueType,
description,
severity,
email,
feedbackFolder.data.webViewLink,
]
];

const response = await sheets.spreadsheets.values.append({
spreadsheetId,
range,
valueInputOption: 'USER_ENTERED',
requestBody: { values },
});

return NextResponse.json({ success: true, response: response.data }, { status: 201 });
} catch (error) {
console.error('Error submitting feedback:', error);
return NextResponse.json({ success: false, error: 'Failed to submit feedback' }, { status: 500 });
}
}
262 changes: 262 additions & 0 deletions components/FeedbackButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import React, { useState } from 'react';
import { Modal, TextField, Select, MenuItem, RadioGroup, FormControlLabel, Radio, IconButton, CircularProgress, Fade } from '@mui/material';
import { Close as CloseIcon, Info as InfoIcon, BugReport, Lightbulb, MoreHoriz, Email, AttachFile, Feedback as FeedbackIcon } from '@mui/icons-material';
import { toast } from 'sonner';
import * as Tooltip from "@radix-ui/react-tooltip";

const FeedbackButton: React.FC = () => {
const [open, setOpen] = useState(false);
const [feedback, setFeedback] = useState({
issueType: 'bug',
description: '',
severity: 'medium',
email: '',
});
const [files, setFiles] = useState<File[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);

const handleOpen = () => setOpen(true);
const handleClose = () => {
setOpen(false);
setFiles([]);
};

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const formData = new FormData();
formData.append('issueType', feedback.issueType);
formData.append('description', feedback.description);
formData.append('severity', feedback.severity);
formData.append('email', feedback.email);
files.forEach((file, index) => {
formData.append(`attachment${index}`, file);
});

const response = await fetch('/api/feedback', {
method: 'POST',
body: formData,
});

if (response.ok) {
toast.success('Feedback submitted successfully!');
handleClose();
setFeedback({ issueType: 'bug', description: '', severity: 'medium', email: '' });
} else {
throw new Error('Failed to submit feedback');
}
} catch (error) {
console.error('Error submitting feedback:', error);
toast.error('Failed to submit feedback. Please try again.');
} finally {
setIsSubmitting(false);
}
};

return (
<>
<style jsx global>{`
#radix-tooltip {
z-index: 1500 !important;
}
`}</style>
<Tooltip.Provider>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<button
onClick={handleOpen}
className="fixed bottom-5 right-5 inline-flex h-12 items-center justify-center gap-2 rounded-full bg-blue-500 px-4 py-2 text-white shadow-md transition-all hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<FeedbackIcon className="h-5 w-5 text-white" />
<span className="font-medium">Feedback</span>
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="select-none rounded bg-white px-4 py-2.5 text-sm leading-none shadow-md shadow-black/20"
sideOffset={5}
style={{ zIndex: 1600 }}
>
Submit your feedback or report issues
<Tooltip.Arrow className="fill-white" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>

<Modal
open={open}
onClose={handleClose}
closeAfterTransition
sx={{
'& .MuiBackdrop-root': {
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
}}
>
<Fade in={open}>
<div className="bg-white p-8 rounded-lg max-w-md mx-auto mt-20 relative">
<IconButton
onClick={handleClose}
sx={{ position: 'absolute', top: 8, right: 8 }}
>
<CloseIcon />
</IconButton>
<h2 className="text-2xl mb-6 font-semibold">Submit Feedback</h2>
<form onSubmit={handleSubmit}>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Select
value={feedback.issueType}
onChange={(e) => setFeedback({ ...feedback, issueType: e.target.value as string })}
fullWidth
className="mb-4"
style={{ zIndex: 1600 }}
renderValue={(selected) => (
<div style={{ display: 'flex', alignItems: 'center' }}>
{selected === 'bug' && <BugReport sx={{ mr: 1 }} />}
{selected === 'feature' && <Lightbulb sx={{ mr: 1 }} />}
{selected === 'other' && <MoreHoriz sx={{ mr: 1 }} />}
{selected.charAt(0).toUpperCase() + selected.slice(1)}
</div>
)}
>
<MenuItem value="bug"><BugReport sx={{ mr: 1 }} /> Bug</MenuItem>
<MenuItem value="feature"><Lightbulb sx={{ mr: 1 }} /> Feature Request</MenuItem>
<MenuItem value="other"><MoreHoriz sx={{ mr: 1 }} /> Other</MenuItem>
</Select>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="select-none rounded bg-white px-4 py-2.5 text-sm leading-none shadow-md shadow-black/20"
sideOffset={5}
style={{ zIndex: 1600 }}
>
Select the type of feedback you&apos;re submitting
<Tooltip.Arrow className="fill-white" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>

<TextField
multiline
rows={4}
value={feedback.description}
onChange={(e) => setFeedback({ ...feedback, description: e.target.value })}
fullWidth
className="mb-4"
placeholder="Describe your issue or suggestion"
InputProps={{
endAdornment: (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<InfoIcon color="action" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="select-none rounded bg-white px-4 py-2.5 text-sm leading-none shadow-md shadow-black/20"
sideOffset={5}
style={{ zIndex: 1600 }}
>
Provide as much detail as possible
<Tooltip.Arrow className="fill-white" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
),
}}
/>

<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<RadioGroup
row
value={feedback.severity}
onChange={(e) => setFeedback({ ...feedback, severity: e.target.value })}
className="mb-4"
>
<FormControlLabel value="low" control={<Radio />} label="Low" />
<FormControlLabel value="medium" control={<Radio />} label="Medium" />
<FormControlLabel value="high" control={<Radio />} label="High" />
</RadioGroup>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="select-none rounded bg-white px-4 py-2.5 text-sm leading-none shadow-md shadow-black/20"
sideOffset={5}
style={{ zIndex: 1600 }}
>
How urgent is this feedback?
<Tooltip.Arrow className="fill-white" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>

<TextField
type="email"
value={feedback.email}
onChange={(e) => setFeedback({ ...feedback, email: e.target.value })}
fullWidth
className="mb-4"
placeholder="Your email"
required
InputProps={{
startAdornment: <Email sx={{ mr: 1, color: 'action.active' }} />,
endAdornment: (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<InfoIcon color="action" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="select-none rounded bg-white px-4 py-2.5 text-sm leading-none shadow-md shadow-black/20"
sideOffset={5}
style={{ zIndex: 1600 }}
>
We&apos;ll use this to follow up on your feedback
<Tooltip.Arrow className="fill-white" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
),
}}
/>

<div className="mb-4">
<p className="mb-2 text-sm font-medium flex items-center">
<AttachFile sx={{ mr: 1, fontSize: '1rem' }} />
Attachments (optional)
</p>
<input
type="file"
multiple
onChange={(e) => setFiles(Array.from(e.target.files || []))}
className="w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4
file:rounded-full file:border-0
file:text-sm file:font-semibold
file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100"
/>
<p className="mt-1 text-xs text-gray-500">
You can add screenshots or relevant files to help explain your feedback.
</p>
</div>

<button
type="submit"
disabled={isSubmitting}
className="w-full bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? <CircularProgress size={24} /> : 'Submit Feedback'}
</button>
</form>
</div>
</Fade>
</Modal>
</Tooltip.Provider>
</>
);
};

export default FeedbackButton;
Loading

0 comments on commit 85f6d87

Please sign in to comment.