Skip to content

Commit 4c53e4b

Browse files
committed
feat(feedback): added feeback form
1 parent c582731 commit 4c53e4b

8 files changed

Lines changed: 275 additions & 8 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {
2+
Body,
3+
Container,
4+
Head,
5+
Heading,
6+
Hr,
7+
Html,
8+
Img,
9+
Preview,
10+
Section,
11+
Tailwind,
12+
Text,
13+
} from "@react-email/components";
14+
15+
import { env } from "@/config/env";
16+
17+
type FeedbackEmailProps = {
18+
userEmail: string;
19+
userName: string | null;
20+
feedback: string;
21+
};
22+
23+
const appBaseUrl = env.FRONTEND_URL;
24+
25+
export const FeedbackEmail = ({
26+
userEmail,
27+
userName,
28+
feedback,
29+
}: FeedbackEmailProps) => {
30+
const previewText = `New feedback from ${userName || userEmail}`;
31+
32+
return (
33+
<Html>
34+
<Head />
35+
<Preview>{previewText}</Preview>
36+
<Tailwind>
37+
<Body className="mx-auto my-auto bg-white px-2 font-sans">
38+
<Container className="mx-auto my-10 max-w-[465px] rounded border border-[#e0e0e0] border-solid p-5">
39+
<Section className="mt-8">
40+
<Img
41+
alt="Flagix Logo"
42+
className="mx-auto"
43+
height="48"
44+
src={`${appBaseUrl}/icon.png`}
45+
width="48"
46+
/>
47+
</Section>
48+
49+
<Heading className="my-6 text-center font-bold text-3xl text-[#10a390]">
50+
User Feedback
51+
</Heading>
52+
53+
<Text className="text-base text-gray-800 leading-relaxed">
54+
<b>From:</b> {userName ? `${userName} (${userEmail})` : userEmail}
55+
</Text>
56+
57+
<Section className="my-6 rounded-lg bg-gray-50 p-4">
58+
<Text className="text-base text-gray-800 leading-relaxed">
59+
{feedback}
60+
</Text>
61+
</Section>
62+
63+
<Hr className="mx-0 my-[26px] w-full border border-[#e0e0e0] border-solid" />
64+
65+
<Text className="text-[12px] text-gray-500 leading-6">
66+
This feedback was submitted through the Flagix application.
67+
</Text>
68+
</Container>
69+
</Body>
70+
</Tailwind>
71+
</Html>
72+
);
73+
};
74+

apps/api/src/lib/validations/project.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,7 @@ export const inviteSchema = z.object({
2929
export const updateRoleSchema = z.object({
3030
role: z.enum(["OWNER", "ADMIN", "MEMBER", "VIEWER"]),
3131
});
32+
33+
export const feedbackSchema = z.object({
34+
feedback: z.string().min(1, "Feedback is required").max(5000, "Feedback must be less than 5000 characters"),
35+
});

apps/api/src/routes/project/route.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import { db } from "@flagix/db";
33
import type { Prisma } from "@flagix/db/client";
44
import { formatDistanceToNow } from "date-fns";
55
import { type Response, Router } from "express";
6-
import { env, env as environmentVariable } from "@/config/env";
6+
import { env as environmentVariable } from "@/config/env";
77
import { PROJECT_ADMIN_ROLES } from "@/constants/project";
88
import { calculateAbStats } from "@/lib/analytics/stats";
99
import {
1010
createEnvironmentSchema,
1111
createProjectSchema,
12+
feedbackSchema,
1213
inviteSchema,
1314
updateProjectSchema,
1415
updateRoleSchema,
@@ -22,7 +23,7 @@ import {
2223
getTinybirdCount,
2324
pivotAnalyticsData,
2425
} from "@/utils/analytics";
25-
import { sendProjectInviteEmail } from "@/utils/project";
26+
import { sendFeedbackEmail, sendProjectInviteEmail } from "@/utils/project";
2627

2728
const router = Router();
2829

@@ -135,6 +136,38 @@ router.get("/", async (req: RequestWithSession, res: Response) => {
135136
}
136137
});
137138

139+
router.post(
140+
"/feedback",
141+
async (req: RequestWithSession, res: Response) => {
142+
const session = req.session;
143+
144+
if (!session || !session.user) {
145+
return res.status(401).json({ error: "unauthenticated" });
146+
}
147+
148+
const userEmail = session.user.email;
149+
const userName = session.user.name;
150+
151+
try {
152+
const body = feedbackSchema.parse(req.body);
153+
154+
await sendFeedbackEmail({
155+
userEmail,
156+
userName,
157+
feedback: body.feedback,
158+
});
159+
160+
res.status(200).json({ success: true, message: "Feedback submitted successfully" });
161+
} catch (error) {
162+
console.error("Failed to submit feedback:", error);
163+
if (error instanceof Error && error.name === "ZodError") {
164+
return res.status(400).json({ error: "Invalid feedback data" });
165+
}
166+
res.status(500).json({ error: "Failed to submit feedback" });
167+
}
168+
}
169+
);
170+
138171
router.use("/:projectId", resolveRoleByProjectParams);
139172

140173
router.get(
@@ -1358,7 +1391,9 @@ router.get(
13581391
try {
13591392
const tbUrl = `https://api.europe-west2.gcp.tinybird.co/v0/pipes/usage_analytics.json?projectId=${projectId}&days=${days}`;
13601393
const response = await fetch(tbUrl, {
1361-
headers: { Authorization: `Bearer ${env.TINYBIRD_TOKEN}` },
1394+
headers: {
1395+
Authorization: `Bearer ${environmentVariable.TINYBIRD_TOKEN}`,
1396+
},
13621397
});
13631398

13641399
if (!response.ok) {
@@ -1459,12 +1494,12 @@ router.get(
14591494
const [summaryRes, trendRes] = await Promise.all([
14601495
fetch(summaryUrl, {
14611496
headers: {
1462-
Authorization: `Bearer ${env.TINYBIRD_TOKEN}`,
1497+
Authorization: `Bearer ${environmentVariable.TINYBIRD_TOKEN}`,
14631498
},
14641499
}),
14651500
fetch(trendUrl, {
14661501
headers: {
1467-
Authorization: `Bearer ${env.TINYBIRD_TOKEN}`,
1502+
Authorization: `Bearer ${environmentVariable.TINYBIRD_TOKEN}`,
14681503
},
14691504
}),
14701505
]);

apps/api/src/utils/project.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Resend } from "resend";
22
import { env } from "@/config/env";
3+
import { FeedbackEmail } from "@/lib/emails/FeedbackEmail";
34
import { ProjectInviteEmail } from "@/lib/emails/ProjectInviteEmail";
45

56
const resend = new Resend(env.RESEND_API_KEY);
@@ -29,3 +30,26 @@ export async function sendProjectInviteEmail({
2930
}),
3031
});
3132
}
33+
34+
type sendFeedbackEmailPropsType = {
35+
userEmail: string;
36+
userName: string | null;
37+
feedback: string;
38+
};
39+
40+
export async function sendFeedbackEmail({
41+
userEmail,
42+
userName,
43+
feedback,
44+
}: sendFeedbackEmailPropsType) {
45+
await resend.emails.send({
46+
from: "onboarding@resend.dev",
47+
to: "ajiboladavid0963@gmail.com",
48+
subject: `New Feedback from ${userName || userEmail}`,
49+
react: FeedbackEmail({
50+
userEmail,
51+
userName,
52+
feedback,
53+
}),
54+
});
55+
}

apps/web/app/(main)/project-invite/page-client.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export default function PageClient() {
4949

5050
const renderMissingTokenState = () => (
5151
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-50 p-4 text-center">
52-
<XCircle className="h-10 w-10 text-red-500" />
52+
<XCircle className="h-6 w-6" />
5353
<h1 className="mt-4 font-bold text-2xl text-gray-800">
5454
Invalid Invitation Link
5555
</h1>
@@ -85,7 +85,7 @@ export default function PageClient() {
8585

8686
return (
8787
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-50 p-4 text-center">
88-
<XCircle className="h-10 w-10 text-red-500" />
88+
<XCircle className="h-6 w-6" />
8989
<h1 className="mt-4 font-bold text-2xl text-gray-800">
9090
Invitation Failed
9191
</h1>
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"use client";
2+
3+
import { Button } from "@flagix/ui/components/button";
4+
import {
5+
Dialog,
6+
DialogContent,
7+
DialogDescription,
8+
DialogFooter,
9+
DialogHeader,
10+
DialogTitle,
11+
} from "@flagix/ui/components/dialog";
12+
import { toast } from "@flagix/ui/components/sonner";
13+
import { useMutation } from "@tanstack/react-query";
14+
import { Loader2 } from "lucide-react";
15+
import { type ChangeEvent, useState } from "react";
16+
import { api } from "@/lib/api";
17+
18+
type FeedbackModalProps = {
19+
isOpen: boolean;
20+
onClose: () => void;
21+
};
22+
23+
export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) {
24+
const [feedback, setFeedback] = useState("");
25+
26+
const feedbackMutation = useMutation({
27+
mutationFn: (feedback: string) =>
28+
api.post("/api/projects/feedback", { feedback }).then((res) => res.data),
29+
onSuccess: () => {
30+
toast.success("Thank you for your feedback! We read every submission.");
31+
setFeedback("");
32+
onClose();
33+
},
34+
onError: () => {
35+
const message = "Failed to submit feedback. Please try again.";
36+
toast.error(message);
37+
},
38+
});
39+
40+
const handleFeedbackChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
41+
setFeedback(e.target.value);
42+
};
43+
44+
const handleSubmit = () => {
45+
if (feedback.trim() && !feedbackMutation.isPending) {
46+
feedbackMutation.mutate(feedback.trim());
47+
}
48+
};
49+
50+
const handleOpenChange = (open: boolean) => {
51+
if (!open) {
52+
setFeedback("");
53+
onClose();
54+
}
55+
};
56+
57+
const isFormValid = feedback.trim().length > 0;
58+
59+
return (
60+
<Dialog onOpenChange={handleOpenChange} open={isOpen}>
61+
<DialogContent className="max-w-md">
62+
<DialogHeader>
63+
<DialogTitle>Submit Feedback</DialogTitle>
64+
<DialogDescription>
65+
We read every piece of feedback and use it to improve Flagix. Thank
66+
you for taking the time to share your thoughts!
67+
</DialogDescription>
68+
</DialogHeader>
69+
70+
<div className="space-y-4 py-4">
71+
<div className="grid gap-2">
72+
<label
73+
className="font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
74+
htmlFor="feedback"
75+
>
76+
Your Feedback
77+
</label>
78+
<textarea
79+
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
80+
id="feedback"
81+
onChange={handleFeedbackChange}
82+
placeholder="Share your thoughts, suggestions, or report any issues..."
83+
rows={6}
84+
value={feedback}
85+
/>
86+
</div>
87+
</div>
88+
89+
<DialogFooter className="mt-4">
90+
<Button
91+
className="border border-gray-300 p-2 text-gray-700 text-sm hover:bg-gray-100"
92+
disabled={feedbackMutation.isPending}
93+
onClick={onClose}
94+
variant="ghost"
95+
>
96+
Cancel
97+
</Button>
98+
<Button
99+
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm text-white hover:bg-emerald-700 disabled:cursor-not-allowed"
100+
disabled={!isFormValid || feedbackMutation.isPending}
101+
onClick={handleSubmit}
102+
>
103+
{feedbackMutation.isPending ? (
104+
<>
105+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
106+
Submitting...
107+
</>
108+
) : (
109+
"Submit Feedback"
110+
)}
111+
</Button>
112+
</DialogFooter>
113+
</DialogContent>
114+
</Dialog>
115+
);
116+
}

apps/web/components/nav/nav-client.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import Image from "next/image";
1414
import Link from "next/link";
1515
import { usePathname } from "next/navigation";
1616
import { useState } from "react";
17+
import { FeedbackModal } from "@/components/feedback/feedback-modal";
1718
import { NotificationsPopover } from "@/components/notifications/notifications-popover";
1819
import { useAuth } from "@/hooks/use-auth";
1920
import { useProject } from "@/providers/project";
@@ -26,6 +27,7 @@ export const Nav = () => {
2627
const { user, logout } = useAuth();
2728
const pathname = usePathname();
2829
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
30+
const [isFeedbackModalOpen, setIsFeedbackModalOpen] = useState(false);
2931
const projectRoutes = projectId ? getProjectRoutes(projectId) : [];
3032
const isActive = (href: string) => pathname.startsWith(href);
3133

@@ -83,6 +85,13 @@ export const Nav = () => {
8385
<p className="text-gray-500 text-xs">{user?.email}</p>
8486
</div>
8587
<DropdownMenuSeparator />
88+
<DropdownMenuItem
89+
className="cursor-pointer"
90+
onClick={() => setIsFeedbackModalOpen(true)}
91+
>
92+
Submit Feedback
93+
</DropdownMenuItem>
94+
<DropdownMenuSeparator />
8695
<DropdownMenuItem className="cursor-pointer" onClick={logout}>
8796
Log out
8897
</DropdownMenuItem>
@@ -120,6 +129,11 @@ export const Nav = () => {
120129
))}
121130
</nav>
122131
)}
132+
133+
<FeedbackModal
134+
isOpen={isFeedbackModalOpen}
135+
onClose={() => setIsFeedbackModalOpen(false)}
136+
/>
123137
</header>
124138
);
125139
};

apps/web/components/notifications/notifications-popover.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { Bell } from "lucide-react";
1313
export const NotificationsPopover = () => (
1414
<DropdownMenu>
1515
<DropdownMenuTrigger asChild>
16-
<Button aria-label="Notifications" size="icon" variant="ghost">
16+
<Button aria-label="Notifications" size="icon">
1717
<Bell className="h-5 w-5" />
1818
</Button>
1919
</DropdownMenuTrigger>

0 commit comments

Comments
 (0)