mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-03 11:11:48 +02:00
📢 Add a feedback form (#590)
This commit is contained in:
parent
ef1fd25a7c
commit
e8fd472ef6
10 changed files with 177 additions and 3 deletions
4
apps/web/declarations/environment.d.ts
vendored
4
apps/web/declarations/environment.d.ts
vendored
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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",
|
||||
|
|
108
apps/web/src/components/feedback.tsx
Normal file
108
apps/web/src/components/feedback.tsx
Normal 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;
|
|
@ -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>
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
37
apps/web/src/server/routers/feedback.ts
Normal file
37
apps/web/src/server/routers/feedback.ts
Normal 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}`,
|
||||
});
|
||||
}),
|
||||
});
|
|
@ -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");
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue