📢 Add a feedback form (#590)

This commit is contained in:
Luke Vella 2023-03-21 16:31:12 +00:00 committed by GitHub
parent ef1fd25a7c
commit e8fd472ef6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 177 additions and 3 deletions

View file

@ -33,6 +33,10 @@ declare global {
* Crisp website ID
*/
NEXT_PUBLIC_CRISP_WEBSITE_ID?: string;
/**
* When defined users will be able to send feedback to this email address
*/
NEXT_PUBLIC_FEEDBACK_EMAIL?: string;
/**
* Users of your instance will see this as their support email
*/

View file

@ -18,6 +18,12 @@
"comment": "Comment",
"commentPlaceholder": "Leave a comment on this poll (visible to everyone)",
"comments": "Comments",
"feedbackSent": "Thank you! Your feedback has been sent.",
"feedbackFormLabel": "How can we improve <appname />?",
"feedbackFormPlaceholder": "Share your thoughts…",
"feedbackFormFooter": "Need help? Visit the <a>support page</a>.",
"feedbackFormTitle": "Feedback Form",
"close": "Close",
"continue": "Continue",
"copied": "Copied",
"copyLink": "Copy link",
@ -123,6 +129,8 @@
"response": "Response",
"save": "Save",
"saveInstruction": "Select your availability and click <b>{{action}}</b>",
"send": "Send",
"sendFeedback": "Send Feedback",
"share": "Share",
"shareDescription": "Give this link to your <b>participants</b> to allow them to vote on your poll.",
"shareLink": "Share via link",

View file

@ -0,0 +1,108 @@
import Link from "next/link";
import { Trans, useTranslation } from "next-i18next";
import { useForm } from "react-hook-form";
import { Button } from "@/components/button";
import CheckCircle from "@/components/icons/check-circle.svg";
import Speakerphone from "@/components/icons/speakerphone.svg";
import { Logo } from "@/components/logo";
import { useModalState } from "@/components/modal/use-modal";
import Tooltip from "@/components/tooltip";
import { trpc } from "@/utils/trpc";
const FeedbackForm = (props: { onClose: () => void }) => {
const { t } = useTranslation("app");
const sendFeedback = trpc.feedback.send.useMutation();
const { handleSubmit, register, formState } = useForm<{ content: string }>();
return (
<form
onSubmit={handleSubmit(async (data) => {
await sendFeedback.mutateAsync(data);
})}
className="shadow-huge animate-popIn fixed bottom-8 right-8 z-20 w-[460px] max-w-full origin-bottom-right space-y-2 overflow-hidden rounded-md border bg-white p-3"
>
{formState.isSubmitted ? (
<div className="absolute inset-0 flex h-full w-full items-center justify-center bg-white">
<div className="space-y-3 text-center">
<CheckCircle className="inline-block h-14 text-green-500" />
<div>{t("feedbackSent")}</div>
<div>
<button onClick={props.onClose} className="text-link">
{t("close")}
</button>
</div>
</div>
</div>
) : null}
<div className="font-semibold text-slate-800">
{t("feedbackFormTitle")}
</div>
<fieldset>
<label className="mb-2" htmlFor="feedback">
<Trans
t={t}
i18nKey="feedbackFormLabel"
components={{ appname: <Logo /> }}
/>
</label>
<textarea
id="feedback"
autoFocus={true}
placeholder={t("feedbackFormPlaceholder")}
rows={4}
className="w-full border bg-gray-50 p-2 text-slate-800"
{...register("content", { required: true })}
/>
</fieldset>
<div className="flex items-center justify-between gap-4">
<div className="flex gap-2">
<Button onClick={props.onClose}>{t("cancel")}</Button>
<Button
loading={formState.isSubmitting}
htmlType="submit"
type="primary"
>
{t("send")}
</Button>
</div>
<div className="text-sm">
<Trans
t={t}
i18nKey="feedbackFormFooter"
components={{
a: (
<Link href="https://support.rallly.co" className="text-link" />
),
}}
/>
</div>
</div>
</form>
);
};
const FeedbackButton = () => {
const { t } = useTranslation("app");
const [isVisible, show, close] = useModalState();
if (isVisible) {
return <FeedbackForm onClose={close} />;
}
return (
<Tooltip
className="fixed bottom-8 right-8 z-20 hidden sm:block"
content={t("sendFeedback")}
placement="left"
>
<button
onClick={show}
className="shadow-huge inline-flex h-14 w-14 items-center justify-center rounded-full bg-slate-800"
>
<Speakerphone className="h-7 text-white" />
</button>
</Tooltip>
);
};
export default FeedbackButton;

View file

@ -1,4 +1,5 @@
import { domMax, LazyMotion } from "framer-motion";
import dynamic from "next/dynamic";
import React from "react";
import { DayjsProvider } from "@/utils/dayjs";
@ -8,6 +9,8 @@ import ModalProvider from "../modal/modal-provider";
import { UserProvider } from "../user-provider";
import { MobileNavigation } from "./standard-layout/mobile-navigation";
const Feedback = dynamic(() => import("../feedback"), { ssr: false });
const StandardLayout: React.FunctionComponent<{
children?: React.ReactNode;
}> = ({ children, ...rest }) => {
@ -17,6 +20,7 @@ const StandardLayout: React.FunctionComponent<{
<DayjsProvider>
<ModalProvider>
<div className="bg-pattern relative min-h-full" {...rest}>
{process.env.NEXT_PUBLIC_FEEDBACK_EMAIL ? <Feedback /> : null}
<MobileNavigation />
<div className="mx-auto max-w-4xl grow">{children}</div>
</div>

View file

@ -1,5 +1,4 @@
import { useTranslation } from "next-i18next";
import { posthog } from "posthog-js";
import React from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { useMount } from "react-use";

View file

@ -42,6 +42,7 @@ if (typeof window !== "undefined" && process.env.NEXT_PUBLIC_POSTHOG_API_KEY) {
capture_pageview: false,
capture_pageleave: false,
autocapture: false,
opt_in_site_apps: true,
loaded: (posthog) => {
if (!process.env.NEXT_PUBLIC_POSTHOG_API_KEY) {
posthog.opt_out_capturing();

View file

@ -1,5 +1,6 @@
import { mergeRouters, router } from "../trpc";
import { auth } from "./auth";
import { feedback } from "./feedback";
import { polls } from "./polls";
import { user } from "./user";
import { whoami } from "./whoami";
@ -10,6 +11,7 @@ export const appRouter = mergeRouters(
auth,
polls,
user,
feedback,
}),
);

View file

@ -0,0 +1,37 @@
import { prisma } from "@rallly/database";
import { sendRawEmail } from "@rallly/emails";
import { z } from "zod";
import { publicProcedure, router } from "@/server/trpc";
export const feedback = router({
send: publicProcedure
.input(z.object({ content: z.string() }))
.mutation(async ({ input, ctx }) => {
let replyTo: string | undefined;
let name = "Guest";
if (!ctx.user.isGuest) {
const user = await prisma.user.findUnique({
where: { id: ctx.user.id },
select: { email: true, name: true },
});
if (user) {
replyTo = user.email;
name = user.name;
}
}
await sendRawEmail({
to: process.env.NEXT_PUBLIC_FEEDBACK_EMAIL,
from: {
name: "Rallly Feedback Form",
address: process.env.SUPPORT_EMAIL,
},
subject: "Feedback",
replyTo,
text: `${name} says:\n\n${input.content}`,
});
}),
});

View file

@ -2,6 +2,7 @@ import * as aws from "@aws-sdk/client-ses";
import { defaultProvider } from "@aws-sdk/credential-provider-node";
import { render } from "@react-email/render";
import { createTransport, Transporter } from "nodemailer";
import type Mail from "nodemailer/lib/mailer";
import React from "react";
import * as templates from "./templates";
@ -81,11 +82,10 @@ export const sendEmail = async <T extends TemplateName>(
return;
}
const transport = getTransport();
const Template = templates[templateName] as TemplateComponent<T>;
try {
await transport.sendMail({
await sendRawEmail({
from: {
name: "Rallly",
address: process.env.SUPPORT_EMAIL,
@ -101,3 +101,13 @@ export const sendEmail = async <T extends TemplateName>(
options.onError?.();
}
};
export const sendRawEmail = async (options: Mail.Options) => {
const transport = getTransport();
try {
await transport.sendMail(options);
return;
} catch (e) {
console.error("Error sending email");
}
};

View file

@ -25,6 +25,7 @@
"NEXT_PUBLIC_POSTHOG_API_KEY",
"NEXT_PUBLIC_SENTRY_DSN",
"NEXT_PUBLIC_VERCEL_URL",
"NEXT_PUBLIC_FEEDBACK_EMAIL",
"NODE_ENV",
"PORT",
"SECRET_PASSWORD",