Add feedback dialog for cloud dashboard (#293)
This commit is contained in:
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AppSidebar } from "~/components/AppSideBar";
|
import { AppSidebar } from "~/components/AppSideBar";
|
||||||
import { SidebarInset, SidebarTrigger } from "@usesend/ui/src/sidebar";
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@usesend/ui/src/sidebar";
|
||||||
import { SidebarProvider } from "@usesend/ui/src/sidebar";
|
|
||||||
import { useIsMobile } from "@usesend/ui/src/hooks/use-mobile";
|
import { useIsMobile } from "@usesend/ui/src/hooks/use-mobile";
|
||||||
import { UpgradeModal } from "~/components/payments/UpgradeModal";
|
import { UpgradeModal } from "~/components/payments/UpgradeModal";
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
BookUser,
|
BookUser,
|
||||||
Code,
|
Code,
|
||||||
Cog,
|
Cog,
|
||||||
|
MessageSquare,
|
||||||
Globe,
|
Globe,
|
||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -35,7 +36,7 @@ import {
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { MiniThemeSwitcher, ThemeSwitcher } from "./theme/ThemeSwitcher";
|
import { MiniThemeSwitcher, ThemeSwitcher } from "./theme/ThemeSwitcher";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { isSelfHosted } from "~/utils/common";
|
import { isCloud, isSelfHosted } from "~/utils/common";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { Badge } from "@usesend/ui/src/badge";
|
import { Badge } from "@usesend/ui/src/badge";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@usesend/ui/src/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@usesend/ui/src/avatar";
|
||||||
@@ -49,6 +50,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@usesend/ui/src/dropdown-menu";
|
} from "@usesend/ui/src/dropdown-menu";
|
||||||
|
import { FeedbackDialog } from "./FeedbackDialog";
|
||||||
|
|
||||||
// General items
|
// General items
|
||||||
const generalItems = [
|
const generalItems = [
|
||||||
@@ -117,7 +119,7 @@ const settingsItems = [
|
|||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { state, open } = useSidebar();
|
const showFeedback = isCloud();
|
||||||
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
@@ -233,6 +235,18 @@ export function AppSidebar() {
|
|||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
|
{showFeedback ? (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<FeedbackDialog
|
||||||
|
trigger={
|
||||||
|
<SidebarMenuButton tooltip="Feedback">
|
||||||
|
<MessageSquare />
|
||||||
|
<span>Feedback</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
) : null}
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton asChild tooltip="Docs">
|
<SidebarMenuButton asChild tooltip="Docs">
|
||||||
<Link href="https://docs.usesend.com" target="_blank">
|
<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 { suppressionRouter } from "./routers/suppression";
|
||||||
import { limitsRouter } from "./routers/limits";
|
import { limitsRouter } from "./routers/limits";
|
||||||
import { waitlistRouter } from "./routers/waitlist";
|
import { waitlistRouter } from "./routers/waitlist";
|
||||||
|
import { feedbackRouter } from "./routers/feedback";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
@@ -34,6 +35,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
suppression: suppressionRouter,
|
suppression: suppressionRouter,
|
||||||
limits: limitsRouter,
|
limits: limitsRouter,
|
||||||
waitlist: waitlistRouter,
|
waitlist: waitlistRouter,
|
||||||
|
feedback: feedbackRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { sendMail } from "~/server/mailer";
|
|||||||
import { logger } from "~/server/logger/log";
|
import { logger } from "~/server/logger/log";
|
||||||
import { UseSend } from "usesend-js";
|
import { UseSend } from "usesend-js";
|
||||||
import { isCloud } from "~/utils/common";
|
import { isCloud } from "~/utils/common";
|
||||||
|
import { toPlainHtml } from "~/server/utils/email-content";
|
||||||
|
|
||||||
const waitlistUserSelection = {
|
const waitlistUserSelection = {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -19,17 +20,6 @@ const waitlistUserSelection = {
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
} as const;
|
} 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) {
|
function formatDisplayNameFromEmail(email: string) {
|
||||||
const localPart = email.split("@")[0] ?? email;
|
const localPart = email.split("@")[0] ?? email;
|
||||||
const pieces = localPart.split(/[._-]+/).filter(Boolean);
|
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,
|
WAITLIST_EMAIL_TYPES,
|
||||||
waitlistSubmissionSchema,
|
waitlistSubmissionSchema,
|
||||||
} from "~/app/wait-list/schema";
|
} 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_WINDOW_SECONDS = 60 * 60 * 6; // 6 hours
|
||||||
const RATE_LIMIT_MAX_ATTEMPTS = 3;
|
const RATE_LIMIT_MAX_ATTEMPTS = 3;
|
||||||
@@ -18,15 +19,6 @@ const EMAIL_TYPE_LABEL: Record<(typeof WAITLIST_EMAIL_TYPES)[number], string> =
|
|||||||
marketing: "Marketing",
|
marketing: "Marketing",
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeHtml(input: string) {
|
|
||||||
return input
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
export const waitlistRouter = createTRPCRouter({
|
export const waitlistRouter = createTRPCRouter({
|
||||||
submitRequest: authedProcedure
|
submitRequest: authedProcedure
|
||||||
.input(waitlistSubmissionSchema)
|
.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 (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
Reference in New Issue
Block a user