mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-18 02:21:49 +02:00
✨ Feedback form (#1670)
This commit is contained in:
parent
2cd5adddae
commit
1ad5f7019a
8 changed files with 216 additions and 9 deletions
|
@ -196,8 +196,6 @@
|
||||||
"upgradePromptTitle": "Upgrade to Pro",
|
"upgradePromptTitle": "Upgrade to Pro",
|
||||||
"upgradeOverlaySubtitle3": "Unlock these feature by upgrading to a Pro plan.",
|
"upgradeOverlaySubtitle3": "Unlock these feature by upgrading to a Pro plan.",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"groupPoll": "Group Poll",
|
|
||||||
"groupPollDescription": "Share your availability with a group of people and find the best time to meet.",
|
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"upcoming": "Upcoming",
|
"upcoming": "Upcoming",
|
||||||
"past": "Past",
|
"past": "Past",
|
||||||
|
@ -206,7 +204,6 @@
|
||||||
"upcomingEventsEmptyStateDescription": "When you schedule events, they will appear here.",
|
"upcomingEventsEmptyStateDescription": "When you schedule events, they will appear here.",
|
||||||
"pastEventsEmptyStateTitle": "No Past Events",
|
"pastEventsEmptyStateTitle": "No Past Events",
|
||||||
"pastEventsEmptyStateDescription": "When you schedule events, they will appear here.",
|
"pastEventsEmptyStateDescription": "When you schedule events, they will appear here.",
|
||||||
"activePollCount": "{activePollCount} Live",
|
|
||||||
"createPoll": "Create poll",
|
"createPoll": "Create poll",
|
||||||
"addToCalendar": "Add to Calendar",
|
"addToCalendar": "Add to Calendar",
|
||||||
"microsoft365": "Microsoft 365",
|
"microsoft365": "Microsoft 365",
|
||||||
|
@ -306,10 +303,6 @@
|
||||||
"moreParticipants": "{count} more…",
|
"moreParticipants": "{count} more…",
|
||||||
"noDates": "No dates",
|
"noDates": "No dates",
|
||||||
"commandMenuNoResults": "No results",
|
"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",
|
"commandMenu": "Command Menu",
|
||||||
"commandMenuDescription": "Select a command",
|
"commandMenuDescription": "Select a command",
|
||||||
"eventsPageDesc": "View and manage your scheduled events",
|
"eventsPageDesc": "View and manage your scheduled events",
|
||||||
|
@ -324,5 +317,7 @@
|
||||||
"paginationNext": "Next",
|
"paginationNext": "Next",
|
||||||
"upgradeToProDesc": "Unlock all Pro features",
|
"upgradeToProDesc": "Unlock all Pro features",
|
||||||
"searchPollsPlaceholder": "Search polls by title...",
|
"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 { LogoLink } from "@/app/components/logo-link";
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
import { getUser } from "@/data/get-user";
|
import { getUser } from "@/data/get-user";
|
||||||
|
import { FeedbackToggle } from "@/features/feedback/components/feedback-toggle";
|
||||||
import { getTranslation } from "@/i18n/server";
|
import { getTranslation } from "@/i18n/server";
|
||||||
|
|
||||||
import { UpgradeButton } from "../upgrade-button";
|
import { UpgradeButton } from "../upgrade-button";
|
||||||
|
@ -42,6 +43,7 @@ export async function AppSidebar({
|
||||||
<LogoLink />
|
<LogoLink />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<FeedbackToggle />
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" 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;
|
44
apps/web/src/features/rate-limit/index.ts
Normal file
44
apps/web/src/features/rate-limit/index.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
"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());
|
||||||
|
try {
|
||||||
|
const ratelimit = new Ratelimit({
|
||||||
|
redis: kv,
|
||||||
|
limiter: Ratelimit.slidingWindow(requests, duration),
|
||||||
|
});
|
||||||
|
|
||||||
|
return ratelimit.limit(`${identifier}:${name}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -37,7 +37,7 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
const dialogContentVariants = cva(
|
const dialogContentVariants = cva(
|
||||||
cn(
|
cn(
|
||||||
//style
|
//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
|
// position
|
||||||
"fixed z-50 grid w-full top-0 left-1/2 -translate-x-1/2",
|
"fixed z-50 grid w-full top-0 left-1/2 -translate-x-1/2",
|
||||||
// animation
|
// animation
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue