Add feedback dialog for cloud dashboard (#293)

This commit is contained in:
KM Koushik
2025-11-29 10:22:12 +11:00
committed by GitHub
parent 357d561a8e
commit e1b64d0d7b
9 changed files with 247 additions and 25 deletions
@@ -1,8 +1,7 @@
"use client";
import { AppSidebar } from "~/components/AppSideBar";
import { SidebarInset, SidebarTrigger } from "@usesend/ui/src/sidebar";
import { SidebarProvider } from "@usesend/ui/src/sidebar";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@usesend/ui/src/sidebar";
import { useIsMobile } from "@usesend/ui/src/hooks/use-mobile";
import { UpgradeModal } from "~/components/payments/UpgradeModal";
+16 -2
View File
@@ -4,6 +4,7 @@ import {
BookUser,
Code,
Cog,
MessageSquare,
Globe,
LayoutTemplate,
Mail,
@@ -35,7 +36,7 @@ import {
import Link from "next/link";
import { MiniThemeSwitcher, ThemeSwitcher } from "./theme/ThemeSwitcher";
import { useSession } from "next-auth/react";
import { isSelfHosted } from "~/utils/common";
import { isCloud, isSelfHosted } from "~/utils/common";
import { usePathname } from "next/navigation";
import { Badge } from "@usesend/ui/src/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@usesend/ui/src/avatar";
@@ -49,6 +50,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@usesend/ui/src/dropdown-menu";
import { FeedbackDialog } from "./FeedbackDialog";
// General items
const generalItems = [
@@ -117,7 +119,7 @@ const settingsItems = [
export function AppSidebar() {
const { data: session } = useSession();
const { state, open } = useSidebar();
const showFeedback = isCloud();
const pathname = usePathname();
@@ -233,6 +235,18 @@ export function AppSidebar() {
<SidebarFooter>
<SidebarGroupContent>
<SidebarMenu>
{showFeedback ? (
<SidebarMenuItem>
<FeedbackDialog
trigger={
<SidebarMenuButton tooltip="Feedback">
<MessageSquare />
<span>Feedback</span>
</SidebarMenuButton>
}
/>
</SidebarMenuItem>
) : null}
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Docs">
<Link href="https://docs.usesend.com" target="_blank">
+164
View File
@@ -0,0 +1,164 @@
"use client";
import { type KeyboardEvent, type ReactNode, useEffect, useState } from "react";
import { Button } from "@usesend/ui/src/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { Textarea } from "@usesend/ui/src/textarea";
import { toast } from "@usesend/ui/src/toaster";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { api } from "~/trpc/react";
const FeedbackSchema = z.object({
message: z.string().trim().min(1, "Feedback is required").max(2000),
});
export function FeedbackDialog({ trigger }: { trigger?: ReactNode }) {
const [open, setOpen] = useState(false);
const [isMac, setIsMac] = useState(false);
const form = useForm<z.infer<typeof FeedbackSchema>>({
resolver: zodResolver(FeedbackSchema),
defaultValues: {
message: "",
},
});
const feedbackMutation = api.feedback.send.useMutation({
onSuccess: () => {
toast.success("Thanks for sharing your feedback!");
form.reset();
setOpen(false);
},
onError: (error) => {
toast.error(error.message);
},
});
const messageValue = form.watch("message");
const trimmedMessage = messageValue?.trim() ?? "";
useEffect(() => {
const platform = navigator.userAgent || navigator.platform || "unknown";
setIsMac(/Mac|iPhone|iPod|iPad/i.test(platform));
}, []);
function handleOpenChange(nextOpen: boolean) {
setOpen(nextOpen);
if (!nextOpen) {
form.reset();
}
}
function onSubmit(values: z.infer<typeof FeedbackSchema>) {
feedbackMutation.mutate({ message: values.message.trim() });
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
const isSubmitShortcut =
(event.metaKey || event.ctrlKey) && event.key === "Enter";
if (feedbackMutation.isPending || !isSubmitShortcut) return;
event.preventDefault();
form.handleSubmit(onSubmit)();
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" size="sm">
Feedback
</Button>
)}
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Send feedback</DialogTitle>
<DialogDescription>
Share any thoughts or issues. Your message goes straight to our
founders.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
{...field}
minLength={1}
maxLength={2000}
onKeyDown={handleKeyDown}
placeholder="Tell us what's on your mind"
className="min-h-[160px]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={feedbackMutation.isPending}
>
Cancel
</Button>
<Button
type="submit"
disabled={!trimmedMessage || feedbackMutation.isPending}
>
{feedbackMutation.isPending ? "Sending..." : "Send feedback"}
{!feedbackMutation.isPending ? (
<>
<span
className="ml-2 inline-flex items-center gap-1 text-xs opacity-85"
aria-hidden
>
<kbd className="inline-flex items-center justify-center rounded border border-input bg-muted/20 px-1 py-0.5 h-5 min-w-5 font-sans leading-none h-5 uppercase">
{isMac ? "⌘" : "^"}
</kbd>
<kbd className="inline-flex items-center justify-center rounded border border-input bg-muted/20 px-1 py-0.5 pt-1 h-5 min-w-5 leading-none font-sans h-5 uppercase">
</kbd>
</span>
<span className="sr-only">
{isMac ? "Command" : "Control"} plus Enter
</span>
</>
) : null}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
+2
View File
@@ -13,6 +13,7 @@ import { dashboardRouter } from "./routers/dashboard";
import { suppressionRouter } from "./routers/suppression";
import { limitsRouter } from "./routers/limits";
import { waitlistRouter } from "./routers/waitlist";
import { feedbackRouter } from "./routers/feedback";
/**
* This is the primary router for your server.
@@ -34,6 +35,7 @@ export const appRouter = createTRPCRouter({
suppression: suppressionRouter,
limits: limitsRouter,
waitlist: waitlistRouter,
feedback: feedbackRouter,
});
// export type definition of API
+1 -11
View File
@@ -10,6 +10,7 @@ import { sendMail } from "~/server/mailer";
import { logger } from "~/server/logger/log";
import { UseSend } from "usesend-js";
import { isCloud } from "~/utils/common";
import { toPlainHtml } from "~/server/utils/email-content";
const waitlistUserSelection = {
id: true,
@@ -19,17 +20,6 @@ const waitlistUserSelection = {
createdAt: true,
} as const;
function toPlainHtml(text: string) {
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
return `<pre style="font-family: inherit; white-space: pre-wrap; margin: 0;">${escaped}</pre>`;
}
function formatDisplayNameFromEmail(email: string) {
const localPart = email.split("@")[0] ?? email;
const pieces = localPart.split(/[._-]+/).filter(Boolean);
@@ -0,0 +1,49 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
import { env } from "~/env";
import { isCloud } from "~/utils/common";
import { sendMail } from "~/server/mailer";
import { toPlainHtml } from "~/server/utils/email-content";
export const feedbackRouter = createTRPCRouter({
send: teamProcedure
.input(
z.object({
message: z.string().trim().min(1, "Feedback cannot be empty").max(2000),
}),
)
.mutation(async ({ ctx, input }) => {
if (!isCloud()) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Feedback is only available on the cloud version.",
});
}
if (!env.FOUNDER_EMAIL) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Feedback email is not configured.",
});
}
const senderEmail = ctx.session.user.email ?? "Unknown";
const senderName = ctx.session.user.name ?? "Unknown";
const text = `New feedback received\n\nFrom: ${senderName} (${senderEmail})\nUser ID: ${ctx.session.user.id}\nTeam: ${ctx.team.name} (ID: ${ctx.team.id})\n\nMessage:\n${
input.message
}`;
await sendMail(
env.FOUNDER_EMAIL,
`Product feedback from ${ctx.team.name}`,
text,
toPlainHtml(text),
ctx.session.user.email ?? undefined,
);
return { success: true };
}),
});
+1 -9
View File
@@ -9,6 +9,7 @@ import {
WAITLIST_EMAIL_TYPES,
waitlistSubmissionSchema,
} from "~/app/wait-list/schema";
import { escapeHtml } from "~/server/utils/email-content";
const RATE_LIMIT_WINDOW_SECONDS = 60 * 60 * 6; // 6 hours
const RATE_LIMIT_MAX_ATTEMPTS = 3;
@@ -18,15 +19,6 @@ const EMAIL_TYPE_LABEL: Record<(typeof WAITLIST_EMAIL_TYPES)[number], string> =
marketing: "Marketing",
};
function escapeHtml(input: string) {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export const waitlistRouter = createTRPCRouter({
submitRequest: authedProcedure
.input(waitlistSubmissionSchema)
@@ -0,0 +1,12 @@
export function escapeHtml(input: string) {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function toPlainHtml(text: string) {
return `<pre style="font-family: inherit; white-space: pre-wrap; margin: 0;">${escapeHtml(text)}</pre>`;
}