Skip to content

Commit e1b64d0

Browse files
authored
Add feedback dialog for cloud dashboard (#293)
1 parent 357d561 commit e1b64d0

File tree

9 files changed

+247
-25
lines changed

9 files changed

+247
-25
lines changed

apps/web/src/app/(dashboard)/dasboard-layout.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
"use client";
22

33
import { AppSidebar } from "~/components/AppSideBar";
4-
import { SidebarInset, SidebarTrigger } from "@usesend/ui/src/sidebar";
5-
import { SidebarProvider } from "@usesend/ui/src/sidebar";
4+
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@usesend/ui/src/sidebar";
65
import { useIsMobile } from "@usesend/ui/src/hooks/use-mobile";
76
import { UpgradeModal } from "~/components/payments/UpgradeModal";
87

apps/web/src/components/AppSideBar.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
BookUser,
55
Code,
66
Cog,
7+
MessageSquare,
78
Globe,
89
LayoutTemplate,
910
Mail,
@@ -35,7 +36,7 @@ import {
3536
import Link from "next/link";
3637
import { MiniThemeSwitcher, ThemeSwitcher } from "./theme/ThemeSwitcher";
3738
import { useSession } from "next-auth/react";
38-
import { isSelfHosted } from "~/utils/common";
39+
import { isCloud, isSelfHosted } from "~/utils/common";
3940
import { usePathname } from "next/navigation";
4041
import { Badge } from "@usesend/ui/src/badge";
4142
import { Avatar, AvatarFallback, AvatarImage } from "@usesend/ui/src/avatar";
@@ -49,6 +50,7 @@ import {
4950
DropdownMenuSeparator,
5051
DropdownMenuTrigger,
5152
} from "@usesend/ui/src/dropdown-menu";
53+
import { FeedbackDialog } from "./FeedbackDialog";
5254

5355
// General items
5456
const generalItems = [
@@ -117,7 +119,7 @@ const settingsItems = [
117119

118120
export function AppSidebar() {
119121
const { data: session } = useSession();
120-
const { state, open } = useSidebar();
122+
const showFeedback = isCloud();
121123

122124
const pathname = usePathname();
123125

@@ -233,6 +235,18 @@ export function AppSidebar() {
233235
<SidebarFooter>
234236
<SidebarGroupContent>
235237
<SidebarMenu>
238+
{showFeedback ? (
239+
<SidebarMenuItem>
240+
<FeedbackDialog
241+
trigger={
242+
<SidebarMenuButton tooltip="Feedback">
243+
<MessageSquare />
244+
<span>Feedback</span>
245+
</SidebarMenuButton>
246+
}
247+
/>
248+
</SidebarMenuItem>
249+
) : null}
236250
<SidebarMenuItem>
237251
<SidebarMenuButton asChild tooltip="Docs">
238252
<Link href="https://docs.usesend.com" target="_blank">
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"use client";
2+
3+
import { type KeyboardEvent, type ReactNode, useEffect, useState } from "react";
4+
import { Button } from "@usesend/ui/src/button";
5+
import {
6+
Dialog,
7+
DialogContent,
8+
DialogDescription,
9+
DialogFooter,
10+
DialogHeader,
11+
DialogTitle,
12+
DialogTrigger,
13+
} from "@usesend/ui/src/dialog";
14+
import {
15+
Form,
16+
FormControl,
17+
FormField,
18+
FormItem,
19+
FormLabel,
20+
FormMessage,
21+
} from "@usesend/ui/src/form";
22+
import { Textarea } from "@usesend/ui/src/textarea";
23+
import { toast } from "@usesend/ui/src/toaster";
24+
import { zodResolver } from "@hookform/resolvers/zod";
25+
import { useForm } from "react-hook-form";
26+
import { z } from "zod";
27+
28+
import { api } from "~/trpc/react";
29+
30+
const FeedbackSchema = z.object({
31+
message: z.string().trim().min(1, "Feedback is required").max(2000),
32+
});
33+
34+
export function FeedbackDialog({ trigger }: { trigger?: ReactNode }) {
35+
const [open, setOpen] = useState(false);
36+
const [isMac, setIsMac] = useState(false);
37+
38+
const form = useForm<z.infer<typeof FeedbackSchema>>({
39+
resolver: zodResolver(FeedbackSchema),
40+
defaultValues: {
41+
message: "",
42+
},
43+
});
44+
45+
const feedbackMutation = api.feedback.send.useMutation({
46+
onSuccess: () => {
47+
toast.success("Thanks for sharing your feedback!");
48+
form.reset();
49+
setOpen(false);
50+
},
51+
onError: (error) => {
52+
toast.error(error.message);
53+
},
54+
});
55+
56+
const messageValue = form.watch("message");
57+
const trimmedMessage = messageValue?.trim() ?? "";
58+
59+
useEffect(() => {
60+
const platform = navigator.userAgent || navigator.platform || "unknown";
61+
setIsMac(/Mac|iPhone|iPod|iPad/i.test(platform));
62+
}, []);
63+
64+
function handleOpenChange(nextOpen: boolean) {
65+
setOpen(nextOpen);
66+
if (!nextOpen) {
67+
form.reset();
68+
}
69+
}
70+
71+
function onSubmit(values: z.infer<typeof FeedbackSchema>) {
72+
feedbackMutation.mutate({ message: values.message.trim() });
73+
}
74+
75+
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
76+
const isSubmitShortcut =
77+
(event.metaKey || event.ctrlKey) && event.key === "Enter";
78+
79+
if (feedbackMutation.isPending || !isSubmitShortcut) return;
80+
81+
event.preventDefault();
82+
form.handleSubmit(onSubmit)();
83+
}
84+
85+
return (
86+
<Dialog open={open} onOpenChange={handleOpenChange}>
87+
<DialogTrigger asChild>
88+
{trigger ?? (
89+
<Button variant="outline" size="sm">
90+
Feedback
91+
</Button>
92+
)}
93+
</DialogTrigger>
94+
<DialogContent className="max-w-lg">
95+
<DialogHeader>
96+
<DialogTitle>Send feedback</DialogTitle>
97+
<DialogDescription>
98+
Share any thoughts or issues. Your message goes straight to our
99+
founders.
100+
</DialogDescription>
101+
</DialogHeader>
102+
103+
<Form {...form}>
104+
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
105+
<FormField
106+
control={form.control}
107+
name="message"
108+
render={({ field }) => (
109+
<FormItem>
110+
<FormControl>
111+
<Textarea
112+
{...field}
113+
minLength={1}
114+
maxLength={2000}
115+
onKeyDown={handleKeyDown}
116+
placeholder="Tell us what's on your mind"
117+
className="min-h-[160px]"
118+
/>
119+
</FormControl>
120+
<FormMessage />
121+
</FormItem>
122+
)}
123+
/>
124+
125+
<DialogFooter>
126+
<Button
127+
type="button"
128+
variant="outline"
129+
onClick={() => handleOpenChange(false)}
130+
disabled={feedbackMutation.isPending}
131+
>
132+
Cancel
133+
</Button>
134+
<Button
135+
type="submit"
136+
disabled={!trimmedMessage || feedbackMutation.isPending}
137+
>
138+
{feedbackMutation.isPending ? "Sending..." : "Send feedback"}
139+
{!feedbackMutation.isPending ? (
140+
<>
141+
<span
142+
className="ml-2 inline-flex items-center gap-1 text-xs opacity-85"
143+
aria-hidden
144+
>
145+
<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">
146+
{isMac ? "⌘" : "^"}
147+
</kbd>
148+
<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">
149+
150+
</kbd>
151+
</span>
152+
<span className="sr-only">
153+
{isMac ? "Command" : "Control"} plus Enter
154+
</span>
155+
</>
156+
) : null}
157+
</Button>
158+
</DialogFooter>
159+
</form>
160+
</Form>
161+
</DialogContent>
162+
</Dialog>
163+
);
164+
}

apps/web/src/server/api/root.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { dashboardRouter } from "./routers/dashboard";
1313
import { suppressionRouter } from "./routers/suppression";
1414
import { limitsRouter } from "./routers/limits";
1515
import { waitlistRouter } from "./routers/waitlist";
16+
import { feedbackRouter } from "./routers/feedback";
1617

1718
/**
1819
* This is the primary router for your server.
@@ -34,6 +35,7 @@ export const appRouter = createTRPCRouter({
3435
suppression: suppressionRouter,
3536
limits: limitsRouter,
3637
waitlist: waitlistRouter,
38+
feedback: feedbackRouter,
3739
});
3840

3941
// export type definition of API

apps/web/src/server/api/routers/admin.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { sendMail } from "~/server/mailer";
1010
import { logger } from "~/server/logger/log";
1111
import { UseSend } from "usesend-js";
1212
import { isCloud } from "~/utils/common";
13+
import { toPlainHtml } from "~/server/utils/email-content";
1314

1415
const waitlistUserSelection = {
1516
id: true,
@@ -19,17 +20,6 @@ const waitlistUserSelection = {
1920
createdAt: true,
2021
} as const;
2122

22-
function toPlainHtml(text: string) {
23-
const escaped = text
24-
.replace(/&/g, "&amp;")
25-
.replace(/</g, "&lt;")
26-
.replace(/>/g, "&gt;")
27-
.replace(/"/g, "&quot;")
28-
.replace(/'/g, "&#39;");
29-
30-
return `<pre style="font-family: inherit; white-space: pre-wrap; margin: 0;">${escaped}</pre>`;
31-
}
32-
3323
function formatDisplayNameFromEmail(email: string) {
3424
const localPart = email.split("@")[0] ?? email;
3525
const pieces = localPart.split(/[._-]+/).filter(Boolean);
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { z } from "zod";
2+
import { TRPCError } from "@trpc/server";
3+
4+
import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
5+
import { env } from "~/env";
6+
import { isCloud } from "~/utils/common";
7+
import { sendMail } from "~/server/mailer";
8+
import { toPlainHtml } from "~/server/utils/email-content";
9+
10+
export const feedbackRouter = createTRPCRouter({
11+
send: teamProcedure
12+
.input(
13+
z.object({
14+
message: z.string().trim().min(1, "Feedback cannot be empty").max(2000),
15+
}),
16+
)
17+
.mutation(async ({ ctx, input }) => {
18+
if (!isCloud()) {
19+
throw new TRPCError({
20+
code: "FORBIDDEN",
21+
message: "Feedback is only available on the cloud version.",
22+
});
23+
}
24+
25+
if (!env.FOUNDER_EMAIL) {
26+
throw new TRPCError({
27+
code: "INTERNAL_SERVER_ERROR",
28+
message: "Feedback email is not configured.",
29+
});
30+
}
31+
32+
const senderEmail = ctx.session.user.email ?? "Unknown";
33+
const senderName = ctx.session.user.name ?? "Unknown";
34+
35+
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${
36+
input.message
37+
}`;
38+
39+
await sendMail(
40+
env.FOUNDER_EMAIL,
41+
`Product feedback from ${ctx.team.name}`,
42+
text,
43+
toPlainHtml(text),
44+
ctx.session.user.email ?? undefined,
45+
);
46+
47+
return { success: true };
48+
}),
49+
});

apps/web/src/server/api/routers/waitlist.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
WAITLIST_EMAIL_TYPES,
1010
waitlistSubmissionSchema,
1111
} from "~/app/wait-list/schema";
12+
import { escapeHtml } from "~/server/utils/email-content";
1213

1314
const RATE_LIMIT_WINDOW_SECONDS = 60 * 60 * 6; // 6 hours
1415
const RATE_LIMIT_MAX_ATTEMPTS = 3;
@@ -18,15 +19,6 @@ const EMAIL_TYPE_LABEL: Record<(typeof WAITLIST_EMAIL_TYPES)[number], string> =
1819
marketing: "Marketing",
1920
};
2021

21-
function escapeHtml(input: string) {
22-
return input
23-
.replace(/&/g, "&amp;")
24-
.replace(/</g, "&lt;")
25-
.replace(/>/g, "&gt;")
26-
.replace(/"/g, "&quot;")
27-
.replace(/'/g, "&#39;");
28-
}
29-
3022
export const waitlistRouter = createTRPCRouter({
3123
submitRequest: authedProcedure
3224
.input(waitlistSubmissionSchema)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export function escapeHtml(input: string) {
2+
return input
3+
.replace(/&/g, "&amp;")
4+
.replace(/</g, "&lt;")
5+
.replace(/>/g, "&gt;")
6+
.replace(/"/g, "&quot;")
7+
.replace(/'/g, "&#39;");
8+
}
9+
10+
export function toPlainHtml(text: string) {
11+
return `<pre style="font-family: inherit; white-space: pre-wrap; margin: 0;">${escapeHtml(text)}</pre>`;
12+
}

packages/ui/src/textarea.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
1010
return (
1111
<textarea
1212
className={cn(
13-
"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",
13+
"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",
1414
className
1515
)}
1616
ref={ref}

0 commit comments

Comments
 (0)