mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-06 04:31:50 +02:00
✨ Allow participant to enter email to receive edit link (#534)
This commit is contained in:
parent
aab999598e
commit
0ac3c95755
20 changed files with 230 additions and 81 deletions
|
@ -16,6 +16,7 @@ const config: PlaywrightTestConfig = {
|
||||||
use: {
|
use: {
|
||||||
viewport: { width: 1280, height: 720 },
|
viewport: { width: 1280, height: 720 },
|
||||||
baseURL,
|
baseURL,
|
||||||
|
permissions: ["clipboard-read"],
|
||||||
trace: "retain-on-failure",
|
trace: "retain-on-failure",
|
||||||
},
|
},
|
||||||
webServer: {
|
webServer: {
|
||||||
|
|
|
@ -69,7 +69,7 @@ export const NewParticipantModal = (props: NewParticipantModalProps) => {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const { register, formState, handleSubmit } =
|
const { register, formState, handleSubmit } =
|
||||||
useForm<NewParticipantFormData>();
|
useForm<NewParticipantFormData>();
|
||||||
const { requiredString } = useFormValidation();
|
const { requiredString, validEmail } = useFormValidation();
|
||||||
const { poll } = usePoll();
|
const { poll } = usePoll();
|
||||||
const addParticipant = useAddParticipantMutation();
|
const addParticipant = useAddParticipantMutation();
|
||||||
return (
|
return (
|
||||||
|
@ -83,6 +83,7 @@ export const NewParticipantModal = (props: NewParticipantModalProps) => {
|
||||||
const newParticipant = await addParticipant.mutateAsync({
|
const newParticipant = await addParticipant.mutateAsync({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
votes: props.votes,
|
votes: props.votes,
|
||||||
|
email: data.email,
|
||||||
pollId: poll.id,
|
pollId: poll.id,
|
||||||
});
|
});
|
||||||
props.onSubmit?.(newParticipant);
|
props.onSubmit?.(newParticipant);
|
||||||
|
@ -107,7 +108,7 @@ export const NewParticipantModal = (props: NewParticipantModalProps) => {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{/* <fieldset>
|
<fieldset>
|
||||||
<label htmlFor="email" className="text-slate-500">
|
<label htmlFor="email" className="text-slate-500">
|
||||||
{t("email")} ({t("optional")})
|
{t("email")} ({t("optional")})
|
||||||
</label>
|
</label>
|
||||||
|
@ -128,7 +129,7 @@ export const NewParticipantModal = (props: NewParticipantModalProps) => {
|
||||||
{formState.errors.email.message}
|
{formState.errors.email.message}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</fieldset> */}
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<label className="text-slate-500">{t("response")}</label>
|
<label className="text-slate-500">{t("response")}</label>
|
||||||
<VoteSummary
|
<VoteSummary
|
||||||
|
|
|
@ -58,7 +58,7 @@ export const PollContextProvider: React.FunctionComponent<{
|
||||||
}> = ({ poll, urlId, admin, children }) => {
|
}> = ({ poll, urlId, admin, children }) => {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const { participants } = useParticipants();
|
const { participants } = useParticipants();
|
||||||
const { user } = useUser();
|
const { user, ownsObject } = useUser();
|
||||||
const [targetTimeZone, setTargetTimeZone] =
|
const [targetTimeZone, setTargetTimeZone] =
|
||||||
React.useState(getBrowserTimeZone);
|
React.useState(getBrowserTimeZone);
|
||||||
|
|
||||||
|
@ -108,9 +108,7 @@ export const PollContextProvider: React.FunctionComponent<{
|
||||||
};
|
};
|
||||||
|
|
||||||
const userAlreadyVoted =
|
const userAlreadyVoted =
|
||||||
user && participants
|
user && participants ? participants.some(ownsObject) : false;
|
||||||
? participants.some((participant) => participant.userId === user.id)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
const optionIds = parsedOptions.options.map(({ optionId }) => optionId);
|
const optionIds = parsedOptions.options.map(({ optionId }) => optionId);
|
||||||
|
|
||||||
|
@ -160,6 +158,7 @@ export const PollContextProvider: React.FunctionComponent<{
|
||||||
}, [
|
}, [
|
||||||
admin,
|
admin,
|
||||||
getScore,
|
getScore,
|
||||||
|
ownsObject,
|
||||||
participants,
|
participants,
|
||||||
poll,
|
poll,
|
||||||
targetTimeZone,
|
targetTimeZone,
|
||||||
|
|
|
@ -69,7 +69,10 @@ export const ParticipantRowView: React.FunctionComponent<{
|
||||||
<Dropdown
|
<Dropdown
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
trigger={
|
trigger={
|
||||||
<button className="text-slate-500 hover:text-slate-800">
|
<button
|
||||||
|
data-testid="participant-menu"
|
||||||
|
className="text-slate-500 hover:text-slate-800"
|
||||||
|
>
|
||||||
<DotsVertical className="h-3" />
|
<DotsVertical className="h-3" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,7 +56,10 @@ const MobilePoll: React.FunctionComponent = () => {
|
||||||
const { reset, handleSubmit, formState } = form;
|
const { reset, handleSubmit, formState } = form;
|
||||||
const [selectedParticipantId, setSelectedParticipantId] = React.useState<
|
const [selectedParticipantId, setSelectedParticipantId] = React.useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>();
|
>(() => {
|
||||||
|
const participant = participants.find((p) => session.ownsObject(p));
|
||||||
|
return participant?.id;
|
||||||
|
});
|
||||||
|
|
||||||
const selectedParticipant = selectedParticipantId
|
const selectedParticipant = selectedParticipantId
|
||||||
? getParticipantById(selectedParticipantId)
|
? getParticipantById(selectedParticipantId)
|
||||||
|
|
|
@ -15,9 +15,9 @@ export const normalizeVotes = (
|
||||||
|
|
||||||
export const useAddParticipantMutation = () => {
|
export const useAddParticipantMutation = () => {
|
||||||
return trpc.polls.participants.add.useMutation({
|
return trpc.polls.participants.add.useMutation({
|
||||||
onSuccess: (participant) => {
|
onSuccess: (_, { name }) => {
|
||||||
posthog.capture("add participant", {
|
posthog.capture("add participant", {
|
||||||
name: participant.name,
|
name,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -47,7 +47,10 @@ export const IfGuest = (props: { children?: React.ReactNode }) => {
|
||||||
return <>{props.children}</>;
|
return <>{props.children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UserProvider = (props: { children?: React.ReactNode }) => {
|
export const UserProvider = (props: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
forceUserId?: string;
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
|
|
||||||
const queryClient = trpc.useContext();
|
const queryClient = trpc.useContext();
|
||||||
|
@ -105,7 +108,10 @@ export const UserProvider = (props: { children?: React.ReactNode }) => {
|
||||||
return queryClient.whoami.invalidate();
|
return queryClient.whoami.invalidate();
|
||||||
},
|
},
|
||||||
ownsObject: ({ userId }) => {
|
ownsObject: ({ userId }) => {
|
||||||
if (userId && user.id === userId) {
|
if (
|
||||||
|
(userId && user.id === userId) ||
|
||||||
|
(props.forceUserId && props.forceUserId === userId)
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|
|
@ -6,8 +6,8 @@ import { useTranslation } from "next-i18next";
|
||||||
import { ParticipantsProvider } from "@/components/participants-provider";
|
import { ParticipantsProvider } from "@/components/participants-provider";
|
||||||
import { Poll } from "@/components/poll";
|
import { Poll } from "@/components/poll";
|
||||||
import { PollContextProvider } from "@/components/poll-context";
|
import { PollContextProvider } from "@/components/poll-context";
|
||||||
import { useUser } from "@/components/user-provider";
|
import { UserProvider, useUser } from "@/components/user-provider";
|
||||||
import { withSessionSsr } from "@/utils/auth";
|
import { decryptToken, withSessionSsr } from "@/utils/auth";
|
||||||
import { trpc } from "@/utils/trpc";
|
import { trpc } from "@/utils/trpc";
|
||||||
import { withPageTranslations } from "@/utils/with-page-translations";
|
import { withPageTranslations } from "@/utils/with-page-translations";
|
||||||
|
|
||||||
|
@ -15,7 +15,10 @@ import StandardLayout from "../../components/layouts/standard-layout";
|
||||||
import ModalProvider from "../../components/modal/modal-provider";
|
import ModalProvider from "../../components/modal/modal-provider";
|
||||||
import { NextPageWithLayout } from "../../types";
|
import { NextPageWithLayout } from "../../types";
|
||||||
|
|
||||||
const Page: NextPageWithLayout<{ urlId: string }> = ({ urlId }) => {
|
const Page: NextPageWithLayout<{
|
||||||
|
urlId: string;
|
||||||
|
forceUserId: string | null;
|
||||||
|
}> = ({ urlId, forceUserId }) => {
|
||||||
const pollQuery = trpc.polls.getByParticipantUrlId.useQuery({ urlId });
|
const pollQuery = trpc.polls.getByParticipantUrlId.useQuery({ urlId });
|
||||||
|
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
@ -29,23 +32,25 @@ const Page: NextPageWithLayout<{ urlId: string }> = ({ urlId }) => {
|
||||||
<title>{poll.title}</title>
|
<title>{poll.title}</title>
|
||||||
<meta name="robots" content="noindex,nofollow" />
|
<meta name="robots" content="noindex,nofollow" />
|
||||||
</Head>
|
</Head>
|
||||||
<ParticipantsProvider pollId={poll.id}>
|
<UserProvider forceUserId={forceUserId ?? undefined}>
|
||||||
<PollContextProvider poll={poll} urlId={urlId} admin={false}>
|
<ParticipantsProvider pollId={poll.id}>
|
||||||
<ModalProvider>
|
<PollContextProvider poll={poll} urlId={urlId} admin={false}>
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<ModalProvider>
|
||||||
{user.id === poll.user.id ? (
|
<div className="space-y-3 p-3 sm:space-y-4 sm:p-4">
|
||||||
<Link
|
{user.id === poll.user.id ? (
|
||||||
className="btn-default"
|
<Link
|
||||||
href={`/admin/${poll.adminUrlId}`}
|
className="btn-default"
|
||||||
>
|
href={`/admin/${poll.adminUrlId}`}
|
||||||
← {t("goToAdmin")}
|
>
|
||||||
</Link>
|
← {t("goToAdmin")}
|
||||||
) : null}
|
</Link>
|
||||||
<Poll />
|
) : null}
|
||||||
</div>
|
<Poll />
|
||||||
</ModalProvider>
|
</div>
|
||||||
</PollContextProvider>
|
</ModalProvider>
|
||||||
</ParticipantsProvider>
|
</PollContextProvider>
|
||||||
|
</ParticipantsProvider>
|
||||||
|
</UserProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -61,9 +66,17 @@ export const getServerSideProps: GetServerSideProps = withSessionSsr(
|
||||||
[
|
[
|
||||||
withPageTranslations(["common", "app", "errors"]),
|
withPageTranslations(["common", "app", "errors"]),
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
|
let userId: string | null = null;
|
||||||
|
if (ctx.query.token) {
|
||||||
|
const res = await decryptToken<{ userId: string }>(
|
||||||
|
ctx.query.token as string,
|
||||||
|
);
|
||||||
|
userId = res?.userId;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
urlId: ctx.query.urlId as string,
|
urlId: ctx.query.urlId as string,
|
||||||
|
forceUserId: userId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
import { prisma } from "@rallly/database";
|
import { prisma } from "@rallly/database";
|
||||||
|
import { sendEmail } from "@rallly/emails";
|
||||||
|
import { absoluteUrl } from "@rallly/utils";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { sendNotification } from "../../../utils/api-utils";
|
import { sendNotification } from "../../../utils/api-utils";
|
||||||
|
import { createToken } from "../../../utils/auth";
|
||||||
import { publicProcedure, router } from "../../trpc";
|
import { publicProcedure, router } from "../../trpc";
|
||||||
|
|
||||||
export const participants = router({
|
export const participants = router({
|
||||||
|
@ -46,7 +49,8 @@ export const participants = router({
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
pollId: z.string(),
|
pollId: z.string(),
|
||||||
name: z.string().nonempty("Participant name is required"),
|
name: z.string().min(1, "Participant name is required"),
|
||||||
|
email: z.string().optional(),
|
||||||
votes: z
|
votes: z
|
||||||
.object({
|
.object({
|
||||||
optionId: z.string(),
|
optionId: z.string(),
|
||||||
|
@ -55,9 +59,9 @@ export const participants = router({
|
||||||
.array(),
|
.array(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input: { pollId, votes, name } }) => {
|
.mutation(async ({ ctx, input: { pollId, votes, name, email } }) => {
|
||||||
const user = ctx.session.user;
|
const user = ctx.session.user;
|
||||||
const participant = await prisma.participant.create({
|
const res = await prisma.participant.create({
|
||||||
data: {
|
data: {
|
||||||
pollId: pollId,
|
pollId: pollId,
|
||||||
name: name,
|
name: name,
|
||||||
|
@ -72,14 +76,43 @@ export const participants = router({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
select: {
|
||||||
votes: true,
|
id: true,
|
||||||
|
poll: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
participantUrlId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { poll, ...participant } = res;
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
const token = await createToken(
|
||||||
|
{ userId: user.id },
|
||||||
|
{
|
||||||
|
ttl: 0, // basically forever
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendEmail("NewParticipantConfirmationEmail", {
|
||||||
|
to: email,
|
||||||
|
subject: `Your response for ${poll.title} has been received`,
|
||||||
|
props: {
|
||||||
|
name,
|
||||||
|
title: poll.title,
|
||||||
|
editSubmissionUrl: absoluteUrl(
|
||||||
|
`/p/${poll.participantUrlId}?token=${token}`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await sendNotification(pollId, {
|
await sendNotification(pollId, {
|
||||||
type: "newParticipant",
|
type: "newParticipant",
|
||||||
participantName: participant.name,
|
participantName: name,
|
||||||
});
|
});
|
||||||
|
|
||||||
return participant;
|
return participant;
|
||||||
|
|
|
@ -143,10 +143,13 @@ export const decryptToken = async <P extends Record<string, unknown>>(
|
||||||
|
|
||||||
export const createToken = async <T extends Record<string, unknown>>(
|
export const createToken = async <T extends Record<string, unknown>>(
|
||||||
payload: T,
|
payload: T,
|
||||||
|
options?: {
|
||||||
|
ttl?: number;
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
return await sealData(payload, {
|
return await sealData(payload, {
|
||||||
password: sessionOptions.password,
|
password: sessionOptions.password,
|
||||||
ttl: 60 * 15, // 15 minutes
|
ttl: options?.ttl ?? 60 * 15, // 15 minutes
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { load } from "cheerio";
|
import { load } from "cheerio";
|
||||||
import smtpTester from "smtp-tester";
|
import smtpTester, { SmtpTester } from "smtp-tester";
|
||||||
|
|
||||||
test.describe.serial(() => {
|
test.describe.serial(() => {
|
||||||
let mailServer: smtpTester.SmtpTester;
|
|
||||||
|
|
||||||
let pollUrl: string;
|
let pollUrl: string;
|
||||||
|
|
||||||
|
let mailServer: SmtpTester;
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
mailServer = smtpTester.init(4025);
|
mailServer = smtpTester.init(4025);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,18 +1,33 @@
|
||||||
import { expect, Page, Request, test } from "@playwright/test";
|
import { expect, Page, Request, test } from "@playwright/test";
|
||||||
|
import { load } from "cheerio";
|
||||||
|
import smtpTester, { SmtpTester } from "smtp-tester";
|
||||||
|
|
||||||
test.describe.parallel(() => {
|
test.describe.parallel(() => {
|
||||||
let page: Page;
|
let adminPage: Page;
|
||||||
|
|
||||||
let touchRequest: Promise<Request>;
|
let touchRequest: Promise<Request>;
|
||||||
|
let participantUrl: string;
|
||||||
|
let editSubmissionUrl: string;
|
||||||
|
|
||||||
|
let mailServer: SmtpTester;
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
mailServer = smtpTester.init(4025);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
mailServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
test.beforeAll(async ({ browser }) => {
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
page = await context.newPage();
|
adminPage = await context.newPage();
|
||||||
touchRequest = page.waitForRequest(
|
touchRequest = adminPage.waitForRequest(
|
||||||
(request) =>
|
(request) =>
|
||||||
request.method() === "POST" &&
|
request.method() === "POST" &&
|
||||||
request.url().includes("/api/trpc/polls.touch"),
|
request.url().includes("/api/trpc/polls.touch"),
|
||||||
);
|
);
|
||||||
await page.goto("/demo");
|
await adminPage.goto("/demo");
|
||||||
await page.waitForSelector('text="Lunch Meeting"');
|
await adminPage.waitForSelector('text="Lunch Meeting"');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should call touch endpoint", async () => {
|
test("should call touch endpoint", async () => {
|
||||||
|
@ -21,30 +36,62 @@ test.describe.parallel(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should be able to comment", async () => {
|
test("should be able to comment", async () => {
|
||||||
await page.getByPlaceholder("Your name…").fill("Test user");
|
await adminPage.getByPlaceholder("Your name…").fill("Test user");
|
||||||
await page
|
await adminPage
|
||||||
.getByPlaceholder("Leave a comment on this poll")
|
.getByPlaceholder("Leave a comment on this poll")
|
||||||
.fill("This is a comment!");
|
.fill("This is a comment!");
|
||||||
await page.click("text='Comment'");
|
await adminPage.click("text='Comment'");
|
||||||
|
|
||||||
const comment = page.locator("data-testid=comment");
|
const comment = adminPage.locator("data-testid=comment");
|
||||||
await expect(comment.locator("text='This is a comment!'")).toBeVisible();
|
await expect(comment.locator("text='This is a comment!'")).toBeVisible();
|
||||||
await expect(comment.locator("text=You")).toBeVisible();
|
await expect(comment.locator("text=You")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should be able to vote", async () => {
|
test("copy participant link", async () => {
|
||||||
// There is a hidden checkbox (nth=0) that exists so that the behaviour of the form is consistent even
|
await adminPage.click("text='Share'");
|
||||||
// when we only have a single option/checkbox.
|
await adminPage.click("text='Copy link'");
|
||||||
await page.locator("data-testid=vote-selector >> nth=0").click();
|
participantUrl = await adminPage.evaluate("navigator.clipboard.readText()");
|
||||||
await page.locator("data-testid=vote-selector >> nth=2").click();
|
|
||||||
await page.click("button >> text='Continue'");
|
|
||||||
|
|
||||||
await page.type('[placeholder="Jessie Smith"]', "Test user");
|
expect(participantUrl).toMatch(/\/p\/[a-zA-Z0-9]+/);
|
||||||
await page.click("text='Submit'");
|
});
|
||||||
|
|
||||||
await expect(page.locator("text='Test user'")).toBeVisible();
|
test("should be able to vote with an email", async ({
|
||||||
await expect(
|
page: participantPage,
|
||||||
page.locator("data-testid=participant-row >> nth=0").locator("text=You"),
|
}) => {
|
||||||
).toBeVisible();
|
await participantPage.goto(participantUrl);
|
||||||
|
await participantPage.locator("data-testid=vote-selector >> nth=0").click();
|
||||||
|
await participantPage.locator("data-testid=vote-selector >> nth=2").click();
|
||||||
|
await participantPage.click("button >> text='Continue'");
|
||||||
|
|
||||||
|
await participantPage.type('[placeholder="Jessie Smith"]', "Anne");
|
||||||
|
await participantPage.type(
|
||||||
|
'[placeholder="jessie.smith@email.com"]',
|
||||||
|
"test@email.com",
|
||||||
|
);
|
||||||
|
await participantPage.click("text='Submit'");
|
||||||
|
|
||||||
|
await expect(participantPage.locator("text='Anne'")).toBeVisible();
|
||||||
|
|
||||||
|
const { email } = await mailServer.captureOne("test@email.com", {
|
||||||
|
wait: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(email.headers.subject).toBe(
|
||||||
|
"Your response for Lunch Meeting has been received",
|
||||||
|
);
|
||||||
|
|
||||||
|
const $ = load(email.html);
|
||||||
|
const href = $("#editSubmissionUrl").attr("href");
|
||||||
|
|
||||||
|
if (!href) {
|
||||||
|
throw new Error("Could not get edit submission link from email");
|
||||||
|
}
|
||||||
|
|
||||||
|
editSubmissionUrl = href;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should be able to edit submission", async ({ page: newPage }) => {
|
||||||
|
await newPage.goto(editSubmissionUrl);
|
||||||
|
await expect(newPage.getByTestId("participant-menu")).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,7 +7,8 @@
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
"db:deploy": "turbo db:deploy",
|
"db:deploy": "turbo db:deploy",
|
||||||
"db:generate": "turbo db:generate",
|
"db:generate": "turbo db:generate",
|
||||||
"test": "turbo test",
|
"db:migrate": "yarn workspace @rallly/database prisma migrate dev",
|
||||||
|
"test": "yarn workspace @rallly/web test",
|
||||||
"lint": "turbo lint",
|
"lint": "turbo lint",
|
||||||
"lint:tsc": "turbo lint:tsc",
|
"lint:tsc": "turbo lint:tsc",
|
||||||
"changelog:update": "gitmoji-changelog",
|
"changelog:update": "gitmoji-changelog",
|
||||||
|
|
|
@ -5,7 +5,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:push": "prisma db push --skip-generate",
|
"db:push": "prisma db push --skip-generate",
|
||||||
"db:deploy": "prisma migrate deploy"
|
"db:deploy": "prisma migrate deploy",
|
||||||
|
"db:migrate": "prisma migrate dev"
|
||||||
},
|
},
|
||||||
"main": "./index.ts",
|
"main": "./index.ts",
|
||||||
"types": "./index.ts",
|
"types": "./index.ts",
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "participants" ADD COLUMN "email" TEXT;
|
|
@ -62,6 +62,7 @@ model Poll {
|
||||||
model Participant {
|
model Participant {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
|
email String?
|
||||||
userId String? @map("user_id")
|
userId String? @map("user_id")
|
||||||
poll Poll @relation(fields: [pollId], references: [id])
|
poll Poll @relation(fields: [pollId], references: [id])
|
||||||
pollId String @map("poll_id")
|
pollId String @map("poll_id")
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
export * from "./templates/guest-verify-email";
|
export * from "./templates/guest-verify-email";
|
||||||
export * from "./templates/new-comment";
|
export * from "./templates/new-comment";
|
||||||
export * from "./templates/new-participant";
|
export * from "./templates/new-participant";
|
||||||
|
export * from "./templates/new-participant-confirmation";
|
||||||
export * from "./templates/new-poll";
|
export * from "./templates/new-poll";
|
||||||
export * from "./templates/new-poll-verification";
|
export * from "./templates/new-poll-verification";
|
||||||
export * from "./templates/verification-code";
|
export * from "./templates/verification-code";
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { EmailLayout } from "./components/email-layout";
|
||||||
|
import { Button, Section, Text } from "./components/styled-components";
|
||||||
|
|
||||||
|
interface NewParticipantConfirmationEmailProps {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
editSubmissionUrl: string;
|
||||||
|
}
|
||||||
|
export const NewParticipantConfirmationEmail = ({
|
||||||
|
title = "Untitled Poll",
|
||||||
|
name = "Guest",
|
||||||
|
editSubmissionUrl = "https://rallly.co",
|
||||||
|
}: NewParticipantConfirmationEmailProps) => {
|
||||||
|
return (
|
||||||
|
<EmailLayout preview="To edit your response use the link below">
|
||||||
|
<Text>Hi {name},</Text>
|
||||||
|
<Text>
|
||||||
|
Thank you for submitting your availability for <strong>{title}</strong>.
|
||||||
|
</Text>
|
||||||
|
<Text>To review your response, use the link below:</Text>
|
||||||
|
<Section>
|
||||||
|
<Button id="editSubmissionUrl" href={editSubmissionUrl}>
|
||||||
|
Review response →
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
<Text>
|
||||||
|
<em className="text-slate-500">
|
||||||
|
Keep this link safe and do not share it with others.
|
||||||
|
</em>
|
||||||
|
</Text>
|
||||||
|
</EmailLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NewParticipantConfirmationEmail;
|
|
@ -17,9 +17,7 @@ export const NewParticipantEmail = ({
|
||||||
unsubscribeUrl = "https://rallly.co",
|
unsubscribeUrl = "https://rallly.co",
|
||||||
}: NewParticipantEmailProps) => {
|
}: NewParticipantEmailProps) => {
|
||||||
return (
|
return (
|
||||||
<EmailLayout
|
<EmailLayout preview={`${participantName} has responded`}>
|
||||||
preview={`${participantName} has shared their availability for ${title}`}
|
|
||||||
>
|
|
||||||
<Text>Hi {name},</Text>
|
<Text>Hi {name},</Text>
|
||||||
<Text>
|
<Text>
|
||||||
<strong>{participantName}</strong> has shared their availability for{" "}
|
<strong>{participantName}</strong> has shared their availability for{" "}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Heading } from "@react-email/heading";
|
import { Heading } from "@react-email/heading";
|
||||||
|
|
||||||
import { EmailLayout } from "./components/email-layout";
|
import { EmailLayout } from "./components/email-layout";
|
||||||
import { Text } from "./components/styled-components";
|
import { Section, Text } from "./components/styled-components";
|
||||||
|
|
||||||
interface VerificationCodeEmailProps {
|
interface VerificationCodeEmailProps {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -13,18 +13,20 @@ export const VerificationCodeEmail = ({
|
||||||
code = "123456",
|
code = "123456",
|
||||||
}: VerificationCodeEmailProps) => {
|
}: VerificationCodeEmailProps) => {
|
||||||
return (
|
return (
|
||||||
<EmailLayout preview="Here is your 6-digit code">
|
<EmailLayout preview={`Your 6-digit code is ${code}`}>
|
||||||
<Text>Hi {name},</Text>
|
<Text>Hi {name},</Text>
|
||||||
<Text>Your 6-digit code is:</Text>
|
<Text>Please use the code below to verify your email address.</Text>
|
||||||
<Heading className="font-sans tracking-widest" id="code">
|
<Section className="rounded bg-gray-50 text-center">
|
||||||
{code}
|
<Text>Your 6-digit code is:</Text>
|
||||||
</Heading>
|
<Heading className="font-sans tracking-widest" id="code">
|
||||||
<Text>
|
{code}
|
||||||
<span className="text-slate-500">
|
</Heading>
|
||||||
This code is valid for 10 minutes
|
<Text>
|
||||||
</span>
|
<span className="text-slate-500">
|
||||||
</Text>
|
This code is valid for 15 minutes
|
||||||
<Text>Use this code to complete the verification process.</Text>
|
</span>
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
</EmailLayout>
|
</EmailLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue