Feedback form

This commit is contained in:
Luke Vella 2025-04-16 09:57:39 +01:00
parent 2cd5adddae
commit 7f877689b2
No known key found for this signature in database
GPG key ID: 469CAD687F0D784C
8 changed files with 209 additions and 9 deletions

View file

@ -196,8 +196,6 @@
"upgradePromptTitle": "Upgrade to Pro",
"upgradeOverlaySubtitle3": "Unlock these feature by upgrading to a Pro plan.",
"home": "Home",
"groupPoll": "Group Poll",
"groupPollDescription": "Share your availability with a group of people and find the best time to meet.",
"create": "Create",
"upcoming": "Upcoming",
"past": "Past",
@ -206,7 +204,6 @@
"upcomingEventsEmptyStateDescription": "When you schedule events, they will appear here.",
"pastEventsEmptyStateTitle": "No Past Events",
"pastEventsEmptyStateDescription": "When you schedule events, they will appear here.",
"activePollCount": "{activePollCount} Live",
"createPoll": "Create poll",
"addToCalendar": "Add to Calendar",
"microsoft365": "Microsoft 365",
@ -306,10 +303,6 @@
"moreParticipants": "{count} more…",
"noDates": "No dates",
"commandMenuNoResults": "No results",
"selectedPolls": "{count} {count, plural, one {poll} other {polls}} selected",
"unselectAll": "Unselect All",
"deletePolls": "Delete Polls",
"deletePollsDescription": "Are you sure you want to delete these {count} polls? This action cannot be undone.",
"commandMenu": "Command Menu",
"commandMenuDescription": "Select a command",
"eventsPageDesc": "View and manage your scheduled events",
@ -324,5 +317,7 @@
"paginationNext": "Next",
"upgradeToProDesc": "Unlock all Pro features",
"searchPollsPlaceholder": "Search polls by title...",
"poll": "Poll"
"poll": "Poll",
"sendFeedbackDesc": "Share your feedback with us.",
"sendFeedbackSuccess": "Thank you for your feedback!"
}

View file

@ -23,6 +23,7 @@ import * as React from "react";
import { LogoLink } from "@/app/components/logo-link";
import { Trans } from "@/components/trans";
import { getUser } from "@/data/get-user";
import { FeedbackToggle } from "@/features/feedback/components/feedback-toggle";
import { getTranslation } from "@/i18n/server";
import { UpgradeButton } from "../upgrade-button";
@ -42,6 +43,7 @@ export async function AppSidebar({
<LogoLink />
</div>
<div className="flex items-center gap-1">
<FeedbackToggle />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild>

View file

@ -0,0 +1,35 @@
"use server";
import { getUser } from "@/data/get-user";
import { type Feedback, feedbackSchema } from "@/features/feedback/schema";
import { getEmailClient } from "@/utils/emails";
import { rateLimit } from "../rate-limit";
export const submitFeedback = async (formData: Feedback) => {
const { success } = await rateLimit("submitFeedback", 3, "1h");
if (!success) {
return {
error: "Rate limit exceeded" as const,
};
}
const user = await getUser();
try {
const { content } = feedbackSchema.parse(formData);
getEmailClient().sendEmail({
to: "feedback@rallly.co",
replyTo: user.email,
subject: "Feedback",
text: `User: ${user.name} (${user.email})\n\n${content}`,
});
return {
success: true,
};
} catch {
return {
error: "Invalid Form Data" as const,
};
}
};

View file

@ -0,0 +1,123 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@rallly/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@rallly/ui/dialog";
import { Form, FormField, FormItem, FormMessage } from "@rallly/ui/form";
import { Icon } from "@rallly/ui/icon";
import { Textarea } from "@rallly/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@rallly/ui/tooltip";
import { CheckCircle2Icon, MegaphoneIcon } from "lucide-react";
import { useForm } from "react-hook-form";
import { Trans } from "@/components/trans";
import { submitFeedback } from "../actions";
import type { Feedback } from "../schema";
import { feedbackSchema } from "../schema";
export function FeedbackToggle() {
const form = useForm<Feedback>({
resolver: zodResolver(feedbackSchema),
});
return (
<Dialog>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant="ghost" size="icon">
<Icon>
<MegaphoneIcon />
</Icon>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
<Trans i18nKey="sendFeedback" defaults="Send Feedback" />
</TooltipContent>
</TooltipPortal>
</Tooltip>
<DialogContent>
{!form.formState.isSubmitSuccessful ? (
<>
<DialogHeader>
<DialogTitle>
<Trans i18nKey="sendFeedback" defaults="Send Feedback" />
</DialogTitle>
<DialogDescription>
<Trans
i18nKey="sendFeedbackDesc"
defaults="Share your feedback with us."
/>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(async (data) => {
const res = await submitFeedback(data);
if (res.error) {
form.setError("content", {
message: res.error,
});
}
})}
>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<Textarea
disabled={form.formState.isSubmitting}
className="w-full"
rows={5}
{...field}
placeholder="Enter your feedback"
/>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
loading={form.formState.isSubmitting}
className="mt-6"
variant="primary"
>
<Trans i18nKey="sendFeedback" defaults="Send Feedback" />
</Button>
</form>
</Form>
</>
) : (
<div className="flex h-60 items-center justify-center">
<div className="flex flex-col items-center text-center">
<CheckCircle2Icon className="h-12 w-12 text-green-500" />
<div className="mt-4 text-sm">
<Trans
i18nKey="sendFeedbackSuccess"
defaults="Thank you for your feedback!"
/>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,7 @@
import z from "zod";
export const feedbackSchema = z.object({
content: z.string().min(10).max(10000),
});
export type Feedback = z.infer<typeof feedbackSchema>;

View file

@ -0,0 +1 @@
export const isRateLimitEnabled = !!process.env.KV_REST_API_URL;

View file

@ -0,0 +1,37 @@
"server-only";
import { Ratelimit } from "@upstash/ratelimit";
import { kv } from "@vercel/kv";
import { headers } from "next/headers";
import { auth } from "@/next-auth";
import { isRateLimitEnabled } from "./constants";
type Unit = "ms" | "s" | "m" | "h" | "d";
type Duration = `${number} ${Unit}` | `${number}${Unit}`;
async function getIPAddress() {
return headers().get("x-forwarded-for");
}
export async function rateLimit(
name: string,
requests: number,
duration: Duration,
) {
if (!isRateLimitEnabled) {
return {
success: true,
};
}
const session = await auth();
const identifier = session?.user?.id || (await getIPAddress());
const ratelimit = new Ratelimit({
redis: kv,
limiter: Ratelimit.slidingWindow(requests, duration),
});
return ratelimit.limit(`${identifier}:${name}`);
}

View file

@ -37,7 +37,7 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const dialogContentVariants = cva(
cn(
//style
"bg-background sm:rounded-lg sm:border shadow-lg p-6 gap-4",
"bg-background sm:rounded-lg sm:border shadow-lg p-4 gap-4",
// position
"fixed z-50 grid w-full top-0 left-1/2 -translate-x-1/2",
// animation