Add feedback dialog for cloud dashboard (#293)
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
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 };
|
||||
}),
|
||||
});
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export const waitlistRouter = createTRPCRouter({
|
||||
submitRequest: authedProcedure
|
||||
.input(waitlistSubmissionSchema)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export function escapeHtml(input: string) {
|
||||
return input
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function toPlainHtml(text: string) {
|
||||
return `<pre style="font-family: inherit; white-space: pre-wrap; margin: 0;">${escapeHtml(text)}</pre>`;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/10 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
Reference in New Issue
Block a user