mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-28 17:56:37 +02:00
✨ Feedback form
This commit is contained in:
parent
2cd5adddae
commit
7f877689b2
8 changed files with 209 additions and 9 deletions
|
@ -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!"
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
35
apps/web/src/features/feedback/actions.ts
Normal file
35
apps/web/src/features/feedback/actions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
};
|
123
apps/web/src/features/feedback/components/feedback-toggle.tsx
Normal file
123
apps/web/src/features/feedback/components/feedback-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
7
apps/web/src/features/feedback/schema.ts
Normal file
7
apps/web/src/features/feedback/schema.ts
Normal 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>;
|
1
apps/web/src/features/rate-limit/constants.ts
Normal file
1
apps/web/src/features/rate-limit/constants.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const isRateLimitEnabled = !!process.env.KV_REST_API_URL;
|
37
apps/web/src/features/rate-limit/index.ts
Normal file
37
apps/web/src/features/rate-limit/index.ts
Normal 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}`);
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue